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 applied — 20260709112759 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.
- The table exists in the live database —
20260717135903_task_promptsis in the applied ledger. - There is no migration file for it in
Utkrushta/supabase/migrations/. - It is not on
origin/maineither. ngm9/utkrusht-taskhas no migrations directory at all — that was the whole premise of the move.
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.pywithout 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:
- Threads + semaphore. With N sync workers each gets its own semaphore, so the concurrency cap becomes
3 × Nand is unenforceable. Worse, a redeploy or a worker recycle kills in-flight generations at any worker. - 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.
- Flask v2 owns the control plane — greeting, session, chat turn, prepare, scenarios, generate, history, run status. Every one of these is a short DB read/write or a single LLM turn. Fine on a sync worker.
POST .../generateenqueues ageneration_jobsrow and returns immediately. No thread, no blocking.- The
task_buildercontainer becomes a queue worker on that table — claims queued rows, runsflows/_base/runner.py, writes stage/status/log-tail back. This is precisely the fix the move plan already names in §10 ("a queue worker on that table is the fix and is cheaper once co-located"); the move promotes it from nice-to-have to required. - Drop SSE; poll
GET /v2/task-builder/runs/<job_id>. The source already has this endpoint (/api/runs/{id}/state) and its own docstring calls it "designed for polling clients (Vercel + edge-friendly)". Same payload, no pinned worker, and it removes the?access_token=query-param hack SSE needed (becauseEventSourcecannot set headers).
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
- Blueprint + module-level DAO instantiation, mirroring
routes/v2/source_codes.py. task_builder_schemas.pywithextra="allow"pydantic models (documentation-only, perdocs/openapi-31-alignment.md) and@openapi_doc(...)on every route.ConversationDAO+GenerationJobDAOinshared/daos/with Pydantic models inshared/models/— no raw.table()in the route module.- Register both in
DAO_REGISTRYinflask_service/tests/unit/test_model_imports.py. - Pick read methods by zero-row semantics — routes with an explicit
if not row: 404need the non-.single()variant, or the 404 branch is dead code and missing rows surface as 500s. - Structured logging:
extra={"operation": ..., "testmaker_id": ..., "conversation_id": ...}; everyexceptlogs at ERROR. - Register the blueprint in
flask_service/server.py.
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
- No
task_builder/task_builder.Dockerfile. - No path-triggered CI workflow (
.github/workflows/has fastapi, flask, notifications — nothing for task_builder). - Zero mentions of
task_builderinCLAUDE.md— absent from the service table, the PYTHONPATH notes, and the CI/CD section. requirements.txthas no version pins at all (23 bare package names) and still ships:fastapi,uvicorn[standard],sse-starlette— the dropped server,dspy— which CLAUDE.md explicitly excludes from the runtime image (offline optimizer only),pytest— a dev dependency.
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
- Rebase onto
origin/main(106 commits). - 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.pyassertions as the spec for M5's route tests. - Fix the fake Supabase
_upsert()signature intest_runtime_resolver.pyto accepton_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
- Introspect the live
task_promptstable. - Write
supabase/migrations/20260717135903_task_prompts.sqlmatching it exactly — RLS, the standard 4 policies, grants. Ledger repair only; must not alter the live table. - 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)
- Hoist all 25 function-local DAO/model imports to module top-level.
- Delete the 4
sys.pathbootstraps. - Drop the
try/except ImportErrorinconftest.py—python-dotenvis 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
task_builder/task_builder.Dockerfile—PYTHONPATH=/utkrusht/shared:/utkrusht/task_builder, own venv, following thenotifications/container pattern.- Split
requirements.txt: pin every version; dropfastapi/uvicorn/sse-starlette/dspy/pytestfrom the runtime file; move dev deps torequirements-dev.txt. .github/workflows/task-builder-dev.yaml+task-builder.yaml, path-triggered ontask_builder/**andshared/**, mirroring the notifications workflows.- Add the
task_builderrow 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
ConversationDAO+GenerationJobDAOinshared/daos/, models inshared/models/, rows inDAO_REGISTRY.flask_service/routes/v2/task_builder.py+task_builder_schemas.py— the 10 endpoints from §4.3, JWT-scoped,@openapi_docon each, blueprint registered inserver.py.- Port
conversation.py,slots.py,validation.py,suggestions.pyintoflask_service/services/(or atask_builder_api/package) — they are pure request-scoped logic with no pipeline dependency. - Convert
jobs.pyfrom thread-spawning to row-enqueueing; add the claiming queue worker to thetask_buildercontainer entrypoint. - 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):
prompts/corpus.py+prompts/tests/test_corpus.py.prompts/retriever.py— curated ∪ approved union, curated wins collisions and sorts first.prompts/__main__.py—save_candidateon write,slug_forconsolidation.generate/creator.py— theapprove_for_taskhook (2 lines).generate/cli.py—--scenario-filefor the human-in-the-loop pick. M5 depends on this: it is the CLI seamPOST .../generatepasses the selected scenario through.requirements.txt—logtail-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
- P4 / P5 (§5.4, §5.5) — real but not blocking; P4 needs a decision on the insert contract (rollback vs. best-effort) before it can be fixed.
- The upstream scenario-generator 1-based/0-based
scenario_indexbug atscenarios/generator.py:730— intermittently drops a passing scenario and aborts generation. Pre-existing, unrelated to the move, but it killed one of the two green-it runs, so it will bite during M5 verification. apps/cli/gist.pyandinfra/e2b/supabase_helpers.pyraw.table()calls — off the generation path, rewire when the E2B deploy path is next touched.- The frontend track — unchanged from move plan §08.
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_migrations — 20260709112759 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:203 — if x_testmaker_id and ..., falsy header short-circuits the guard |
| 106 commits behind | git rev-list --count HEAD..origin/main after fetch |