UtkrushtDesign Document

Task Builder — State of the Branch & Completion Plan

Status: Live status + execution plan for feat/task-builder-service. Companion to, and successor of, task-builder-move-plan.md — that doc argued why the move and planned it; this one audits what actually landed, what is still outstanding (including drift that accumulated in the source repo since the fork), and the ordered plan to finish.

Scope: The task_builder/ service directory, its DAO/schema seam into shared/, the Flask v2 API that replaces the dropped FastAPI server, and the deploy path. Not the frontend chat UI — that stays as specified in the move plan §08.

Verified against: branch feat/task-builder-service @ b8af5338, source ngm9/utkrusht-task@origin/main @ 5d0c4c4, fork point refactor/symmetric-flows, and the live Supabase migration ledger. Read 21 Jul 2026.


0. Status at a glance

Area State
Pipeline moved into task_builder/ ✅ Done — byte-identical to the fork point
DAO rewire (generation path) ✅ Done — 8 raw .table() calls left, all off-path
competency_input_files migration ✅ Applied (the move plan's §11 "pending hand-apply" is closed)
Test suite green ❌ 17 orphaned files + 4 real regressions
Upstream drift ported ❌ 6 files outstanding (prompt corpus + human-in-the-loop scenario)
task_prompts schema in the ledger ❌ Table exists live, no migration file anywhere
API (v2 endpoints) ❌ Not started — ~1,850 LOC still in the source repo
Deploy (Dockerfile / CI / CLAUDE.md) ❌ Not started
Import hygiene ❌ 25 function-local imports, 4 sys.path bootstraps
Branch currency ❌ 106 commits behind origin/main

1. What is here — verified, not assumed

1.1 The move itself is clean

The copy in task_builder/ was diffed directory-by-directory against the fork point (ngm9/utkrusht-task@origin/refactor/symmetric-flows) across apps/, flows/, infra/, task_validation/, task_input_parser/, task_quality/. Zero differences at the copy commit (2c495ca8). Nothing was dropped or mangled in transit.

Every subsequent difference between our tree and the fork point is a deliberate change on this branch — confirmed file by file.

1.2 The DAO rewire landed

Only 8 raw Supabase query-builder calls remain in task_builder/, all in the two modules the move plan explicitly exempted as off the generation path:

File Calls Status
apps/cli/gist.py 5 Deploy/inspection CLI — exempt for now
infra/e2b/supabase_helpers.py 3 E2B deploy path (python -m infra.e2b) — exempt for now

New DAO/model pairs present in shared/: generated_scenario, template, task_template_match, competency_input_files, plus TaskDAO.create, TaskCompetenciesDAO.insert_link, CompetencyDAO.find_by_name_and_proficiency.

1.3 A move-plan item that is already closed

Move plan §11 records the competency_input_files migration as "handed off for a dev-targeted apply". It is applied20260709112759 is in the live ledger. That item can be struck.

1.4 A move-plan item that is already fixed

Upstream commit 54068a3 fixes a NameError: '_llm_client_for' is not defined that crashed every 04_tasks attempt. This branch fixed the same line independently in 9c4522a8 ("green it"). Not outstanding — noted here because a naive "port all upstream commits" pass would conflict on it.


2. What is not here — pipeline drift from the source repo

Since the fork, ngm9/utkrusht-task@main gained 4 commits touching the moved directories, totalling 6 files plus one requirements line. This is the entire pipeline-side backlog — small and portable.

Commit Feature Files
e9aaac5 Durable prompt corpus prompts/corpus.py (new, 190 LOC), prompts/tests/test_corpus.py (new, 99 LOC), prompts/retriever.py, prompts/__main__.py, generate/creator.py (+2 lines)
6eed175 Human-in-the-loop scenario pick generate/cli.py — new --scenario-file option
63fbd5f Deploy-path bits folded into generate/cli.py above
54068a3 openai_client fix already fixed here — skip

Plus requirements.txt: +logtail-python # Better Stack log shipping.

2.1 Why the prompt corpus matters

Generated prompts are written to data/generated/agent_prompts/ — the container's disposable writable layer — and the retriever only ever reads the curated task_generation_prompts/ tree. So a generated prompt is wiped on redeploy and could never become a reference even if it survived. A novel stack falls to the Level-6 generic skeleton on every run, forever.

corpus.py fixes both halves: save_candidate() on generation, approve_for_task() when the task ships (gated on status='ready', i.e. it passed the E2B gate + evals), approved_root() materialises approved rows into a dir the retriever globs. The curated/generated split is kept as a model-collapse guard.

2.2 ⚠️ The corpus has a schema-ledger problem — fix before porting

corpus.py reads and writes a task_prompts table.

So the DDL was applied outside the ledger, in violation of the CLAUDE.md hard rule. A fresh environment cannot be built from supabase/migrations/, and supabase db push has nothing to reconcile against.

Fix: write supabase/migrations/20260717135903_task_prompts.sql matching the live table exactly (introspect it first), including RLS + the standard 4 policies + grants. Because the version is already recorded as applied, this is a ledger-repair file, not a new schema change — it must not alter the live table.

This is the first concrete instance of exactly the failure mode the move plan predicted: "schema defined here, written there, enforced nowhere." Porting corpus.py without its migration would carry that failure into the monorepo.


3. What is not here — the API layer

Nothing of the API moved. The full surface still lives in the source repo at task_builder/:

Module LOC Role
server.py 607 FastAPI app — 13 endpoints, internal-token middleware, CORS
runner.py 302 Drives the 5-stage pipeline, emits StageEvent
jobs.py 257 In-process thread pool + generation_jobs row lifecycle
conversation.py 188 Chat turn logic (slot filling)
conversation_repo.py 116 conversations persistence
suggestions.py 99 LLM instruction chips for the review step
log_tail.py 85 Per-stage log tail into generation_jobs.stage_logs
validation.py, slots.py, prompts.py, logging_setup.py 188 Slot models, brief validation, logging

Both tables it needs — conversations (20260530091150) and generation_jobs (20260530091152) — already exist in Utkrushta/supabase/migrations/ and are applied. No new schema is required for the API. But there are no DAOs for them: ConversationDAO and GenerationJobDAO must be written, since the source hits both tables with raw .table() in five places.


4. The API design — one decision to make first

The move plan says "rebuild as Flask v2 endpoints." That is right for the control plane, but it collides with the job model in a way worth being explicit about before any code is written.

4.1 The collision

Source (FastAPI) Flask here
Process model single uvicorn process gunicorn, NUM_OF_FLASK_WORKERS sync workers
Job execution in-process daemon threads + BoundedSemaphore(3)
Run duration minutes (5-stage LLM pipeline, subprocess per stage) ms–seconds per request
Progress stream SSE, polls the job row every 500ms until terminal

Two things break on contact:

  1. Threads + semaphore. With N sync workers each gets its own semaphore, so the concurrency cap becomes 3 × N and is unenforceable. Worse, a redeploy or a worker recycle kills in-flight generations at any worker.
  2. SSE pins a sync worker for the entire run — minutes. A handful of concurrent generations starves the whole recruiter API.

4.2 The resolution

Split the control plane from the execution plane.

This also fixes the fragility the move plan accepted under duress: a crash currently loses an in-flight run. With a claimed-row queue, a crashed run is visibly stuck in running and can be reclaimed or failed by a sweeper.

4.3 Proposed endpoints

One module, flask_service/routes/v2/task_builder.py, blueprint v2_task_builder_bp, plus task_builder_schemas.py. Resource-oriented URLs, matching the v2 convention.

Source endpoint Flask v2 Notes
GET /api/greeting GET /v2/task-builder/greeting Static string; no row created
POST /api/session POST /v2/task-builder/sessions Creates the conversations row
GET /api/history GET /v2/task-builder/sessions Scoped to the caller; ?limit=
GET /api/conversations/{id} GET /v2/task-builder/sessions/<id> Full transcript + its jobs
POST /api/chat POST /v2/task-builder/sessions/<id>/messages One conversation turn
POST /api/prepare POST /v2/task-builder/sessions/<id>/prepare Phase 1 — build the scenario pool
GET /api/scenarios?session_id= GET /v2/task-builder/sessions/<id>/scenarios Pool for the brief's combo
GET /api/suggest-instructions GET /v2/task-builder/instruction-suggestions ?names=&proficiency=; soft-fails 503
POST /api/generate POST /v2/task-builder/sessions/<id>/generate Phase 2 — enqueue; returns job_id
GET /api/runs/{id}/state GET /v2/task-builder/runs/<job_id> Snapshot + derived stage progression
GET /api/runs/{id}/events Dropped — poll the above
GET /api/health, GET / Dropped/hello already exists

4.4 Three things to delete rather than port

The backend already solves each of these better than the standalone service could.

1. X-Internal-Token and its entire middleware. Flask requires a testmaker JWT by default. Nothing needs adding to auth_middleware.py — testmaker-only is the default branch, so the new routes are protected the moment they are registered.

2. X-Testmaker-Id. Read g.auth['testmaker_id'] instead. The header existed because the standalone service could not verify identity itself; here the middleware already has. This also closes a real hole: the source's scope guard reads

if x_testmaker_id and conv.get("started_by") and conv["started_by"] != x_testmaker_id:
    raise HTTPException(status_code=404, ...)

— so omitting the header disables the check entirely, and any caller reaching the API directly reads every testmaker's conversations. Deriving the id from the verified JWT makes the guard unconditional.

3. The ?env=dev|prod query parameter. The backend selects env from the ENV variable per deployment. A request-controlled env param on a shared API would let a dev-scoped caller write production rows.

4.5 House-style checklist for the new module


5. Gaps and defects

5.1 Test suite — 17 orphans and 4 real regressions

Current: 436 collected, 12 collection errors, 34 failures. Each failing file was re-run against the pristine fork point to attribute it.

Category Count Verdict
Orphaned — import task_builder.server / trace_ui / scripts/reconcile_tasks, all deliberately left behind 17 files Delete. The move plan said 12; the true count is 17
test_runtime_resolver.py 4 tests Real regression from the DAO rewire
test_general_reference (4), test_eval_personas (1), test_prompt_generator_e2b_convention (1) 6 tests Pre-existing upstream — fail identically at the fork point

The 17 orphans:

test_instructions_flow.py        test_task_builder_runner.py      test_trace_ui.py
test_llm_provider.py             test_task_builder_server.py      test_trace_ui_artifacts.py
test_resume.py                   test_task_builder_slots.py       test_trace_ui_classifier.py
test_server_auth.py              test_task_builder_validation.py  test_trace_ui_s3.py
test_task_builder_conversation.py  test_reconcile_tasks.py        test_trace_ui_stage_output.py
test_task_builder_integration.py   test_task_builder_log_tail.py

Note that test_server_auth.py, test_task_builder_server.py, test_instructions_flow.py and test_resume.py are coverage for the API we are about to rebuild. Delete them now, but their assertions are the starting spec for the v2 route tests.

The regression is narrow: the P3 conformance pass added an explicit on_conflict to TaskTemplateMatchDAO.upsert_match, but the test's fake Supabase _upsert() does not accept the kwarg. Result:

TypeError: _make_supabase.<locals>.table.<locals>._upsert() got an unexpected keyword argument 'on_conflict'

The DAO swallows it into logger.error, the fake records zero upserts, and four assertions fail. Fix the fake's signature.

5.2 P1 — task_builder/ is not yet a real service directory

5.3 P2 — import hygiene

25 function-local from daos… / from models… imports across 8 modules, each with a comment like # shared/ on PYTHONPATH via run_stage. Plus 4 distinct sys.path bootstrap mechanisms (flows/non_tech/*, input_files/generator.py, the E2B build_*.py template scripts). conftest.py additionally wraps dotenv in try/except ImportError.

All violate the zero-tolerance import rule. They exist only because shared/ is not reliably importable at process start — which the P1 Dockerfile PYTHONPATH=/utkrusht/shared:/utkrusht/task_builder plus one conftest.py line fixes. Do P1 first and this becomes pure deletion.

5.4 P4 — orphaned-row bug in task_validation/base.py

Inserts the task row, then raises on any link failure with no rollback — leaving a tasks row with no task_competencies. Affects the non_tech and pr_review flows. Also: preflight is case-sensitive against a case-insensitive generator lookup, and FK checks do not filter is_enabled.

5.5 P5 — polish

CHECK-column enums (proficiency / source / status) modelled as bare str; several logger.warning on paths that should be error; missing extra={} structured context; no tests for the preflight check, CompetencyInputFilesDAO, or the scenarios DB-error path.

5.6 Documentation standard

Only docs/task-builder-move-plan.html is committed — the markdown source does not exist, on disk or in git history. CLAUDE.md requires the markdown to be the editable source and the HTML to be a regenerable artifact, with an html-docs link in both. As it stands the generated artifact is the only copy and cannot be regenerated or reviewed as a diff.

5.7 Branch currency

feat/task-builder-service is 106 commits behind origin/main. flask_service/routes/v2/ has moved in that window, so rebase before adding the API module.


6. The plan

Six milestones, ordered so each unlocks the next. Milestones 1–3 are cleanup on what exists; 4 is the deploy shell that makes 5 possible; 5 is the API; 6 is the pipeline drift, which can land any time after 1.

M1 — Rebase and green the suite

  1. Rebase onto origin/main (106 commits).
  2. Delete the 17 orphaned test files. Keep a copy of test_server_auth.py / test_task_builder_server.py / test_instructions_flow.py / test_resume.py assertions as the spec for M5's route tests.
  3. Fix the fake Supabase _upsert() signature in test_runtime_resolver.py to accept on_conflict.

Done when: pytest tests collects clean and the only failures are the 6 known upstream ones — which should be marked xfail with a link to the upstream issue, so the suite is genuinely green.

M2 — Close the schema ledger

  1. Introspect the live task_prompts table.
  2. Write supabase/migrations/20260717135903_task_prompts.sql matching it exactly — RLS, the standard 4 policies, grants. Ledger repair only; must not alter the live table.
  3. Audit the rest of the applied ledger for other files-missing-locally once rebased on main.

Done when: supabase migration list shows no applied version without a local file.

M3 — Import hygiene (blocked on M4's PYTHONPATH; do together)

  1. Hoist all 25 function-local DAO/model imports to module top-level.
  2. Delete the 4 sys.path bootstraps.
  3. Drop the try/except ImportError in conftest.pypython-dotenv is a declared dependency.

Done when: grep -rE "^[[:space:]]+from (daos|models)" task_builder and grep -rn "sys.path" task_builder both return nothing outside the E2B template build scripts.

M4 — Make it a real service directory

  1. task_builder/task_builder.DockerfilePYTHONPATH=/utkrusht/shared:/utkrusht/task_builder, own venv, following the notifications/ container pattern.
  2. Split requirements.txt: pin every version; drop fastapi/uvicorn/sse-starlette/dspy/pytest from the runtime file; move dev deps to requirements-dev.txt.
  3. .github/workflows/task-builder-dev.yaml + task-builder.yaml, path-triggered on task_builder/** and shared/**, mirroring the notifications workflows.
  4. Add the task_builder row to the CLAUDE.md service table, the PYTHONPATH note, and the CI/CD section.

Done when: the image builds, python -m flows.tech --help runs inside it, and CI is green on a task_builder/** touch.

M5 — The API

  1. ConversationDAO + GenerationJobDAO in shared/daos/, models in shared/models/, rows in DAO_REGISTRY.
  2. flask_service/routes/v2/task_builder.py + task_builder_schemas.py — the 10 endpoints from §4.3, JWT-scoped, @openapi_doc on each, blueprint registered in server.py.
  3. Port conversation.py, slots.py, validation.py, suggestions.py into flask_service/services/ (or a task_builder_api/ package) — they are pure request-scoped logic with no pipeline dependency.
  4. Convert jobs.py from thread-spawning to row-enqueueing; add the claiming queue worker to the task_builder container entrypoint.
  5. Route tests, seeded from the deleted M1 assertions.

Done when: a full chat → prepare → scenario pick → generate → poll-to-done cycle runs against dev through Flask, with the pipeline executing in the task_builder container.

M6 — Port the upstream drift

Land after M1 (needs a green baseline) and after M2 (needs the task_prompts migration):

  1. prompts/corpus.py + prompts/tests/test_corpus.py.
  2. prompts/retriever.py — curated ∪ approved union, curated wins collisions and sorts first.
  3. prompts/__main__.pysave_candidate on write, slug_for consolidation.
  4. generate/creator.py — the approve_for_task hook (2 lines).
  5. generate/cli.py--scenario-file for the human-in-the-loop pick. M5 depends on this: it is the CLI seam POST .../generate passes the selected scenario through.
  6. requirements.txtlogtail-python.

Skip 54068a3 — already fixed here (§1.4).

Done when: a generation on a novel stack writes a candidate row, and a shipped task promotes it to approved and it is retrievable on the next run.

Deferred, deliberately


Appendix — how this was verified

Claim Method
Copy is clean git archive 2c495ca8 task_builder vs git archive origin/refactor/symmetric-flows, recursive diff -rq across 6 top-level dirs
Upstream drift is 4 commits / 7 files git log --oneline refactor/symmetric-flows..origin/main -- <moved dirs> + --stat
_llm_client_for already fixed Three-way diff: fork point vs. ours vs. upstream main on creator.py
8 raw .table() calls grep -rnE "\.(table\|from_\|rpc)\(" task_builder excluding tests
competency_input_files applied Supabase MCP list_migrations20260709112759 present
task_prompts has no migration Version 20260717135903 in the live ledger; absent from supabase/migrations/ on this branch and on origin/main; utkrusht-task has no .sql files at all
Failure attribution Ran the 5 non-orphan failing files against the pristine fork-point export with the same venv — 6 of them fail identically there, test_runtime_resolver passes
Flask worker model flask_service/flask.Dockerfile CMD — gunicorn --workers ${NUM_OF_FLASK_WORKERS} --worker-class sync
Scope-guard hole server.py:203if x_testmaker_id and ..., falsy header short-circuits the guard
106 commits behind git rev-list --count HEAD..origin/main after fetch
Utkrusht — Meaningful Skill Assessments for India