diff --git a/.task b/.task new file mode 100644 index 0000000..b5a3834 --- /dev/null +++ b/.task @@ -0,0 +1,10 @@ +{ + "taskId": "1750", + "phase": "execution", + "fenceToken": 9, + "sessionId": "2c16cdd1-6e26-4b9a-9aab-0f83c2d423ab", + "journalPath": "/tmp/taskcore-worktrees/journal-T1750/tasks/T1750/", + "codeWorktree": "/tmp/taskcore-worktrees/code-T1750", + "claimedAt": 1773508861049, + "reviewNotes": [] +} diff --git a/core/test/validator.test.ts b/core/test/validator.test.ts index 8a92eaf..0306eeb 100644 --- a/core/test/validator.test.ts +++ b/core/test/validator.test.ts @@ -458,3 +458,127 @@ test("TaskReparented: reparent of terminal task succeeds", () => { const error = validateEvent(state, event); assert.equal(error, null); }); + +function reviewActiveState(): SystemState { + let state = createInitialState(); + const events: Event[] = [ + { + type: "TaskCreated", + taskId: "RV1", + ts: 1, + title: "Review validation", + description: "Review fence coverage", + parentId: null, + rootId: "RV1", + initialPhase: "execution", + initialCondition: "ready", + attemptBudgets: { analysis: { max: 2 }, decomposition: { max: 2 }, execution: { max: 2 }, review: { max: 2 } }, + costBudget: 5, + dependencies: [], + reviewConfig: { reviewers: ["overseer"], policy: "all" }, + skipAnalysis: true, + metadata: {}, + source: { type: "middle", id: "test" }, + }, + { + type: "LeaseGranted", + taskId: "RV1", + ts: 2, + fenceToken: 1, + agentId: "coder", + phase: "execution", + leaseTimeout: 60_000, + sessionId: "exec-1", + sessionType: "fresh", + contextBudget: 512, + }, + { + type: "AgentStarted", + taskId: "RV1", + ts: 3, + fenceToken: 1, + agentContext: { + sessionId: "exec-1", + agentId: "coder", + memoryRef: null, + contextTokens: 128, + modelId: "test", + }, + }, + { + type: "PhaseTransition", + taskId: "RV1", + ts: 4, + from: { phase: "execution", condition: "active" }, + to: { phase: "review", condition: "ready" }, + reasonCode: "work_complete", + reason: "work_complete", + fenceToken: 1, + agentContext: { + sessionId: "exec-1", + agentId: "coder", + memoryRef: null, + contextTokens: 128, + modelId: "test", + }, + }, + { + type: "LeaseGranted", + taskId: "RV1", + ts: 5, + fenceToken: 2, + agentId: "overseer", + phase: "review", + leaseTimeout: 60_000, + sessionId: "review-1", + sessionType: "fresh", + contextBudget: 512, + }, + { + type: "AgentStarted", + taskId: "RV1", + ts: 6, + fenceToken: 2, + agentContext: { + sessionId: "review-1", + agentId: "overseer", + memoryRef: null, + contextTokens: 128, + modelId: "test", + }, + }, + ]; + + for (const event of events) { + const reduced = reduce(state, event); + assert.equal(reduced.ok, true, `Event ${event.type} failed: ${!reduced.ok ? reduced.error.message : ""}`); + state = reduced.ok ? reduced.value.state : state; + } + + return state; +} + +test("ReviewVerdictSubmitted rejects stale review fence token", () => { + const state = reviewActiveState(); + const event: Event = { + type: "ReviewVerdictSubmitted", + taskId: "RV1", + ts: 7, + fenceToken: 1, + reviewer: "overseer", + round: 1, + verdict: "approve", + reasoning: "Looks good", + agentContext: { + sessionId: "review-1", + agentId: "overseer", + memoryRef: null, + contextTokens: 128, + modelId: "test", + }, + }; + + const error = validateEvent(state, event); + assert.ok(error); + assert.equal(error.code, "stale_fence_token"); +}); diff --git a/docs/daemon-post-endpoints.md b/docs/daemon-post-endpoints.md new file mode 100644 index 0000000..88161d3 --- /dev/null +++ b/docs/daemon-post-endpoints.md @@ -0,0 +1,50 @@ +# Taskcore Daemon POST Endpoints Reference + +Source: `middle/http.ts` + +## Endpoint Summary + +| Route | Purpose | Key Body Fields | +|-------|---------|-----------------| +| `POST /tasks` | Create task | title*, description*, assignee, reviewer, priority, parentId, dependsOn, costBudget, skipAnalysis | +| `POST /tasks/:id/events` | Raw event | type*, (any core event fields) | +| `POST /tasks/:id/status` | Status transition | status* (review/done/blocked/pending/execute/decompose/cancel), evidence, blocker, stateRef | +| `POST /tasks/:id/reparent` | Reparent task | newParentId* | +| `POST /tasks/:id/revive` | Revive failed/blocked | phase, resetAttempts, reason | +| `POST /tasks/:id/budget` | Increase budget | attemptBudgetIncrease, costBudgetIncrease, reason | +| `POST /tasks/:id/decompose/start` | Begin decomposition | (none) | +| `POST /tasks/:id/decompose/add-child` | Add decomp child | title*, description*, costAllocation*, skipAnalysis, assignee, reviewer, dependsOnSiblings | +| `POST /tasks/:id/decompose/commit` | Finalize decomp | approach | +| `POST /tasks/:id/decompose` | One-shot decomp | children* (array), approach | + +Also: `PATCH /tasks/:id/metadata` — update metadata fields (priority, assignee, etc.) + +\* = required + +## Status Transition Map + +``` +status="execute": analysis.active → execution.ready +status="review": execution.active → review.ready +status="done": review.active → terminal:done +status="pending": review.active → execution.ready (changes requested) +status="blocked": any non-terminal → terminal:blocked +status="cancel": any non-terminal → terminal:canceled +status="decompose": analysis.active → decomposition.ready +``` + +## Decomposition Flow (Incremental) + +``` +POST /tasks/:id/decompose/start → creates in-memory session, returns budget +POST /tasks/:id/decompose/add-child → adds child spec (repeat) +POST /tasks/:id/decompose/commit → creates all children, parent → review.waiting +``` + +## Key Behaviors + +- **Fencing**: All writes use the task's `currentFenceToken` to prevent stale mutations +- **Registry validation**: Assignee/reviewer validated against `agents.json` +- **Cost enforcement**: Decomposition child costs must sum ≤ parent remaining budget +- **Completion checks**: Tasks with `metadata.repo` require a valid stateRef; parent tasks with `completionRule='and'` require all children done +- **Notifications**: `done` and `blocked` transitions notify Telegram targets in `metadata.informed` diff --git a/docs/ops/taskcore/t1750-execution-plan.md b/docs/ops/taskcore/t1750-execution-plan.md new file mode 100644 index 0000000..bba335c --- /dev/null +++ b/docs/ops/taskcore/t1750-execution-plan.md @@ -0,0 +1,122 @@ +# T1750: Taskcore Control Plane Fixes — Execution Plan + +**Epic owner:** ceo +**Review owner:** kelvin +**Status:** Open coordination epic +**Updated:** 2026-03-14 heartbeat session + +--- + +## What changed in this update + +This plan was corrected to match the **live** task graph rather than the earlier aspirational decomposition draft: + +- removed canceled/done children from the active-work list (`T2272` canceled, `T2273` done) +- restored the **full** metadata-inheritance scope for the active child (`T2271`), based on the earlier `T2267` root-cause analysis +- documented that **T2268 owns the merged T2268 + T2272 scope** (scheduler/dispatcher + claimability.js/CLI + reviewer deadlock detection) +- backfilled `parentId`, `reviewer`, and `repo` metadata onto the outstanding children so prompts and worktree provisioning line up with the parent epic + +--- + +## Live child status + +| Task | Status | Purpose | Notes | +|---|---|---|---| +| T2267 | blocked | Historical metadata-inheritance attempt | Earlier execution was misprovisioned against the wrong repo. Metadata now corrected to point at `/home/ubuntu/taskcore`; use as historical evidence, not as the primary forward work item. | +| T2268 | analysis.waiting | Queue claimability / review deadlocks | Active child. Includes merged T2272 claimability + CLI scope. | +| T2269 | analysis.waiting | Decomposition state reconciliation | Active child. | +| T2270 | analysis.waiting | Attention alerting integration | Active child; depends on T2274. | +| T2271 | analysis.waiting | Metadata inheritance | Active child. Scope is full parent metadata propagation, not only priority/assignee/reviewer. | +| T2272 | canceled | Duplicate of T2268 | Do not schedule. | +| T2273 | done | Completed decomposition slice | Do not schedule. | +| T2274 | analysis.waiting | Attention formatter crash fix | Active child. | +| T2275 | analysis.waiting | Obsolete / blocked task cleanup | Active child. | + +--- + +## Active execution waves + +### Wave 1 — parallel now + +1. **T2271 — Metadata inheritance** + - Required propagation set: `repo`, `base_branch`, `informed`, `consulted`, `parentId`, reviewer / assignee / priority defaults, and any other operational metadata that decomposition currently drops. + - Why first: fixes future child provisioning / context loading and removes one of the root causes already observed in T2267. + +2. **T2274 — Attention formatter crash** + - Harden the formatter / endpoint path so null or malformed task data cannot blow up the attention surface. + - Why first: prerequisite safety work for T2270. + +3. **T2275 — Obsolete / blocked task cleanup tooling** + - Build cleanup tooling with dry-run safety and blocker-aware filters. + - Why first: operational hygiene without dependency on the other bug fixes. + +### Wave 2 — after Wave 1 context is stable + +4. **T2268 — Queue claimability false positives + review deadlocks** + - Dispatch-side: exclude `review.waiting` / non-claimable states from scheduler availability. + - Claim-side: fix CLI/claimability false positives, active-lease visibility bugs, and reviewer-role deadlock detection. + - This is the merged successor for T2272's canceled scope. + +5. **T2269 — Decomposition state reconciliation** + - Make decomposition retries / partial materialization idempotent. + - Ensure superseded children are handled safely when a later decomposition version exists. + +### Wave 3 — depends on T2274 + +6. **T2270 — Wire attention output to automated alerting** + - Only start once the formatter path is hardened. + +--- + +## Metadata backfill applied during this session + +The following outstanding children were patched in live task state: + +- `T2267` +- `T2268` +- `T2269` +- `T2270` +- `T2271` +- `T2274` +- `T2275` + +Backfilled fields: + +- `parentId=1750` +- `reviewer=kelvin` +- `repo=/home/ubuntu/taskcore` + +Why this matters: + +- child prompts can now load the parent journal context +- review-ready children will not silently deadlock on a missing reviewer +- future code worktrees will target the real taskcore repo instead of the wrong workspace or no repo at all + +Verification snapshot after the backfill: + +| Task | `parentId` | `reviewer` | `repo` | +|---|---|---|---| +| T2267 | `1750` | `kelvin` | `/home/ubuntu/taskcore` | +| T2268 | `1750` | `kelvin` | `/home/ubuntu/taskcore` | +| T2269 | `1750` | `kelvin` | `/home/ubuntu/taskcore` | +| T2270 | `1750` | `kelvin` | `/home/ubuntu/taskcore` | +| T2271 | `1750` | `kelvin` | `/home/ubuntu/taskcore` | +| T2274 | `1750` | `kelvin` | `/home/ubuntu/taskcore` | +| T2275 | `1750` | `kelvin` | `/home/ubuntu/taskcore` | + +--- + +## Open coordination risks + +- **T2267 vs T2271 overlap:** T2267 remains blocked historical evidence while T2271 is the intended forward execution slot. Future submissions must not pretend the blocked historical attempt is the live implementation path. +- **Immutable task descriptions:** live task descriptions still reflect earlier wording; until taskcore gains a description-edit path, the parent journal and this plan are the authoritative coordination layer for corrected scope. +- **Parent should stay open:** no T1750 submission should claim epic completion until the remaining active children are actually closed out. + +--- + +## Immediate next actions + +1. Route coders to **T2271**, **T2274**, and **T2275** first. +2. Treat **T2268** as the merged owner of the canceled **T2272** claimability/CLI work. +3. Keep **T2270** behind **T2274**. +4. Do not list **T2272** or **T2273** as active child work in future evidence. diff --git a/docs/t1750-decomposition-strategy.md b/docs/t1750-decomposition-strategy.md new file mode 100644 index 0000000..4802039 --- /dev/null +++ b/docs/t1750-decomposition-strategy.md @@ -0,0 +1,52 @@ +# T1750 — Live Decomposition / Coordination State + +Updated during the 2026-03-14 heartbeat work session. + +## Epic status + +T1750 remains **open**. Do not close the epic until every required child is either `done` or explicitly superseded/canceled with the parent state reconciled. + +## Current child inventory + +| Task | Live status | Role in the epic | Coordination notes | +|---|---|---|---| +| T2267 | `blocked` | Historical metadata-inheritance attempt | Blocked earlier because the task was provisioned against the wrong repo/worktree. During this session the child metadata was backfilled to `repo=/home/ubuntu/taskcore`, `parentId=1750`, `reviewer=kelvin` so a future revive/retry will point at the correct repo. Treat T2271 as the forward execution slot for this bug. | +| T2268 | `analysis.waiting` | Active queue claimability / review-deadlock fix | **Merged scope from canceled T2272 lives here.** This task now owns both dispatch-side guards (`scheduler.ts`, `dispatcher.ts`) and claim-side classifier / CLI listing work (`claimability.js`, queue listing, reviewer deadlock detection). | +| T2269 | `analysis.waiting` | Active decomposition-state reconciliation fix | Covers idempotent / partial `DecompositionCreated` handling and cleanup of superseded children. | +| T2270 | `analysis.waiting` | Active attention-alert wiring fix | Depends on T2274 hardening the formatter path first. | +| T2271 | `analysis.waiting` | Active metadata-inheritance fix | **Full scope, not the earlier narrowed wording.** This child must propagate parent metadata needed for real execution: `repo`, `base_branch`, `informed`, `consulted`, `parentId`, and other operational/custom fields, while letting child-specified values override defaults. | +| T2272 | `canceled` | Duplicate | Canceled intentionally after its claimability / CLI scope was merged into T2268. Do not treat it as an active child. | +| T2273 | `done` | Completed decomposition-state slice | Finished. Keep it out of the active-child plan. | +| T2274 | `analysis.waiting` | Active attention formatter crash fix | Independent Wave 1 work. | +| T2275 | `analysis.waiting` | Active obsolete / blocked task cleanup tooling | Independent Wave 1 work. | + +## Execution waves + +### Wave 1 — ready in parallel +- **T2271** — metadata inheritance (full parent-metadata propagation scope) +- **T2274** — attention formatter crash hardening +- **T2275** — obsolete / blocked task cleanup tooling + +### Wave 2 — after Wave 1 context is in place +- **T2268** — merged queue claimability + CLI + review deadlock fix +- **T2269** — decomposition state reconciliation / idempotent child materialization + +### Wave 3 — depends on T2274 +- **T2270** — wire attention output into automated alerting + +## Coordination repairs applied in this session + +To reconcile live task state with the plan, the following metadata was backfilled onto the outstanding children (`T2267`, `T2268`, `T2269`, `T2270`, `T2271`, `T2274`, `T2275`): + +- `parentId=1750` — so child prompts can load parent journal context +- `reviewer=kelvin` — so review-ready children do not deadlock with a null reviewer +- `repo=/home/ubuntu/taskcore` — so future code worktrees target the actual taskcore repo instead of the wrong workspace or no repo at all + +This does **not** change the immutable task descriptions already stored in state, so the parent journal + this document are the live source of truth for the merged / corrected scope until the children are executed and closed. + +## Practical guidance + +- If a coder picks up **T2271**, they should implement the **full** metadata propagation bug described in the parent journal, not just priority/assignee/reviewer defaults. +- If a coder picks up **T2268**, they should treat the canceled **T2272** claimability / CLI scope as in-scope. +- Do **not** list T2272 or T2273 as active work items in future T1750 submissions. +- Keep the epic open; this session only repaired coordination / metadata drift and did not complete the children. diff --git a/middle/http.ts b/middle/http.ts index e65a87c..06afb9a 100644 --- a/middle/http.ts +++ b/middle/http.ts @@ -911,6 +911,52 @@ function notifyInformed(task: Task, event: string, detail?: string): void { } } +function isZeroedStateRef(ref: StateRef): boolean { + return ref.commit === "0000000" && ref.parentCommit === "0000000"; +} + +function verifyCompletion( + core: Core, + task: Task, + stateRef?: StateRef, +): RouteResult | null { + // (a) Task has metadata.repo but stateRef is missing or zeroed + if (task.metadata["repo"]) { + const ref = stateRef ?? task.stateRef; + if (!ref || isZeroedStateRef(ref)) { + return { + status: 422, + body: { + error: "missing_state_ref", + message: + "Task has metadata.repo but no valid stateRef was provided. " + + "Submit a stateRef with a real commit before marking done.", + }, + }; + } + } + + // (b) Task has children with completionRule='and' but not all children are terminal=done + if (task.completionRule === "and" && task.children.length > 0) { + const children = core.getChildren(task.id); + const allDone = children.every((c) => c.terminal === "done"); + if (!allDone) { + const notDone = children.filter((c) => c.terminal !== "done").map((c) => c.id); + return { + status: 422, + body: { + error: "children_not_done", + message: + `Task has completionRule='and' but ${notDone.length} child(ren) are not done: ` + + notDone.join(", "), + }, + }; + } + } + + return null; +} + function applyDoneTransition( core: Core, task: Task, @@ -920,6 +966,9 @@ function applyDoneTransition( evidence?: string, stateRef?: StateRef, ): RouteResult { + const completionErr = verifyCompletion(core, task, stateRef); + if (completionErr) return completionErr; + if (task.phase !== "review" || task.condition !== "active") { return { status: 409, diff --git a/middle/test/http.test.ts b/middle/test/http.test.ts index 6dbbbb0..94e3d27 100644 --- a/middle/test/http.test.ts +++ b/middle/test/http.test.ts @@ -423,4 +423,513 @@ describe("HTTP API", () => { const finalTask = (taskRes.body as { task: { terminal: string | null } }).task; assert.equal(finalTask.terminal, "done"); }); + + // --------------------------------------------------------------------------- + // verifyCompletion tests + // --------------------------------------------------------------------------- + + /** Bring a task into review.active so we can test the done transition. */ + async function bringToReviewActive(taskId: string): Promise { + const fenceToken = 1; + const sessionId = "test-session"; + const agentCtx = { + sessionId, + agentId: "coder", + memoryRef: null, + contextTokens: null, + modelId: "test", + }; + + await request("POST", `/tasks/${taskId}/events`, { + type: "LeaseGranted", + taskId, + ts: Date.now(), + fenceToken, + agentId: "coder", + phase: "execution", + leaseTimeout: 600000, + sessionId, + sessionType: "fresh", + contextBudget: 100, + }); + + await request("POST", `/tasks/${taskId}/events`, { + type: "AgentStarted", + taskId, + ts: Date.now(), + fenceToken, + agentContext: agentCtx, + }); + + // Push to review.ready + await request("POST", `/tasks/${taskId}/status`, { + status: "review", + evidence: "Work is done", + }); + + // Simulate reviewer taking the lease + const reviewFence = 2; + const reviewCtx = { + sessionId: "review-session", + agentId: "hermes", + memoryRef: null, + contextTokens: null, + modelId: "test", + }; + + await request("POST", `/tasks/${taskId}/events`, { + type: "LeaseGranted", + taskId, + ts: Date.now(), + fenceToken: reviewFence, + agentId: "hermes", + phase: "review", + leaseTimeout: 600000, + sessionId: "review-session", + sessionType: "fresh", + contextBudget: 100, + }); + + await request("POST", `/tasks/${taskId}/events`, { + type: "AgentStarted", + taskId, + ts: Date.now(), + fenceToken: reviewFence, + agentContext: reviewCtx, + }); + } + + test("verifyCompletion: rejects done from review.ready before reviewer lease starts", async () => { + const createRes = await request("POST", "/tasks", { + title: "Review ready task", + description: "Should not complete before review starts", + assignee: "coder", + reviewer: "overseer", + skipAnalysis: true, + }); + const taskId = (createRes.body as Record)["taskId"] as string; + + const fenceToken = 1; + const sessionId = "test-session"; + const agentCtx = { + sessionId, + agentId: "coder", + memoryRef: null, + contextTokens: null, + modelId: "test", + }; + + await request("POST", `/tasks/${taskId}/events`, { + type: "LeaseGranted", + taskId, + ts: Date.now(), + fenceToken, + agentId: "coder", + phase: "execution", + leaseTimeout: 600000, + sessionId, + sessionType: "fresh", + contextBudget: 100, + }); + + await request("POST", `/tasks/${taskId}/events`, { + type: "AgentStarted", + taskId, + ts: Date.now(), + fenceToken, + agentContext: agentCtx, + }); + + await request("POST", `/tasks/${taskId}/status`, { + status: "review", + evidence: "Ready for review", + }); + + const doneRes = await request("POST", `/tasks/${taskId}/status`, { + status: "done", + evidence: "Trying to skip reviewer lease", + }); + assert.equal(doneRes.status, 409); + const body = doneRes.body as Record; + assert.equal(body["error"], "invalid_state"); + + const taskRes = await request("GET", `/tasks/${taskId}`); + const task = (taskRes.body as { task: { phase: string; condition: string; terminal: string | null } }).task; + assert.equal(task.phase, "review"); + assert.equal(task.condition, "ready"); + assert.equal(task.terminal, null); + }); + + test("verifyCompletion: rejects done when metadata.repo is set but stateRef is missing", async () => { + // Create task, then set metadata.repo via PATCH + const createRes = await request("POST", "/tasks", { + title: "Repo task", + description: "Task with repo in metadata", + assignee: "coder", + reviewer: "overseer", + skipAnalysis: true, + }); + const taskId = (createRes.body as Record)["taskId"] as string; + await request("PATCH", `/tasks/${taskId}/metadata`, { repo: "my-org/my-repo" }); + + await bringToReviewActive(taskId); + + // Attempt done without stateRef — should get 422 + const doneRes = await request("POST", `/tasks/${taskId}/status`, { + status: "done", + evidence: "Looks good", + // no stateRef + }); + assert.equal(doneRes.status, 422); + const body = doneRes.body as Record; + assert.equal(body["error"], "missing_state_ref"); + }); + + test("verifyCompletion: rejects done when metadata.repo is set and stateRef is zeroed", async () => { + const createRes = await request("POST", "/tasks", { + title: "Repo task zeroed", + description: "Task with repo and zeroed stateRef", + assignee: "coder", + reviewer: "overseer", + skipAnalysis: true, + }); + const taskId = (createRes.body as Record)["taskId"] as string; + await request("PATCH", `/tasks/${taskId}/metadata`, { repo: "my-org/my-repo" }); + + await bringToReviewActive(taskId); + + // Attempt done with zeroed stateRef — should get 422 + const doneRes = await request("POST", `/tasks/${taskId}/status`, { + status: "done", + evidence: "Looks good", + stateRef: { branch: "main", commit: "0000000", parentCommit: "0000000" }, + }); + assert.equal(doneRes.status, 422); + const body = doneRes.body as Record; + assert.equal(body["error"], "missing_state_ref"); + }); + + test("verifyCompletion: rejects done when children with completionRule='and' are not all done", async () => { + // Create a dummy first task (skipAnalysis) to consume an ID, then the real parent + const dummyRes = await request("POST", "/tasks", { + title: "Parent task", + description: "Will be decomposed", + assignee: "coder", + reviewer: "overseer", + skipAnalysis: true, + }); + const dummyId = (dummyRes.body as Record)["taskId"] as string; + + // Bring dummy through lifecycle so it doesn't interfere + await request("POST", `/tasks/${dummyId}/events`, { + type: "LeaseGranted", + taskId: dummyId, + ts: Date.now(), + fenceToken: 1, + agentId: "coder", + phase: "execution", + leaseTimeout: 600000, + sessionId: "test-session", + sessionType: "fresh", + contextBudget: 100, + }); + + await request("POST", `/tasks/${dummyId}/events`, { + type: "AgentStarted", + taskId: dummyId, + ts: Date.now(), + fenceToken: 1, + agentContext: { + sessionId: "test-session", + agentId: "coder", + memoryRef: null, + contextTokens: null, + modelId: "test", + }, + }); + + // Submit review to move to review.ready + await request("POST", `/tasks/${dummyId}/status`, { + status: "review", + evidence: "Work done", + }); + + // Reviewer lease + start + await request("POST", `/tasks/${dummyId}/events`, { + type: "LeaseGranted", + taskId: dummyId, + ts: Date.now(), + fenceToken: 2, + agentId: "hermes", + phase: "review", + leaseTimeout: 600000, + sessionId: "review-session", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", `/tasks/${dummyId}/events`, { + type: "AgentStarted", + taskId: dummyId, + ts: Date.now(), + fenceToken: 2, + agentContext: { + sessionId: "review-session", + agentId: "hermes", + memoryRef: null, + contextTokens: null, + modelId: "test", + }, + }); + + // Send changes_requested to get back to execution + await request("POST", `/tasks/${dummyId}/status`, { + status: "pending", + evidence: "Need to decompose instead", + }); + + // Create the real decomposable parent (without skipAnalysis) + const parentRes = await request("POST", "/tasks", { + title: "Decomposable parent", + description: "This one will be decomposed", + assignee: "coder", + reviewer: "overseer", + }); + const parentId = (parentRes.body as Record)["taskId"] as string; + + // Parent is in analysis.ready. Lease and start it. + await request("POST", `/tasks/${parentId}/events`, { + type: "LeaseGranted", + taskId: parentId, + ts: Date.now(), + fenceToken: 1, + agentId: "coder", + phase: "analysis", + leaseTimeout: 600000, + sessionId: "test-session-2", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", `/tasks/${parentId}/events`, { + type: "AgentStarted", + taskId: parentId, + ts: Date.now(), + fenceToken: 1, + agentContext: { + sessionId: "test-session-2", + agentId: "coder", + memoryRef: null, + contextTokens: null, + modelId: "test", + }, + }); + + // Decompose parent into two children + const decompRes = await request("POST", `/tasks/${parentId}/decompose`, { + approach: "Split into two subtasks", + children: [ + { title: "Child A", description: "First child", costAllocation: 30 }, + { title: "Child B", description: "Second child", costAllocation: 30 }, + ], + }); + assert.equal(decompRes.status, 200); + const decompBody = decompRes.body as { children: Array<{ id: string }> }; + const childAId = decompBody.children[0]!.id; + const childBId = decompBody.children[1]!.id; + + // Complete child A only (leave child B incomplete) + // Child A: analysis.ready → lease → start → exec → review → done + await request("POST", `/tasks/${childAId}/events`, { + type: "LeaseGranted", + taskId: childAId, + ts: Date.now(), + fenceToken: 1, + agentId: "coder", + phase: "analysis", + leaseTimeout: 600000, + sessionId: "child-session", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", `/tasks/${childAId}/events`, { + type: "AgentStarted", + taskId: childAId, + ts: Date.now(), + fenceToken: 1, + agentContext: { + sessionId: "child-session", + agentId: "coder", + memoryRef: null, + contextTokens: null, + modelId: "test", + }, + }); + // Skip to execution + await request("POST", `/tasks/${childAId}/status`, { status: "execute" }); + await request("POST", `/tasks/${childAId}/events`, { + type: "LeaseGranted", + taskId: childAId, + ts: Date.now(), + fenceToken: 2, + agentId: "coder", + phase: "execution", + leaseTimeout: 600000, + sessionId: "child-session-2", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", `/tasks/${childAId}/events`, { + type: "AgentStarted", + taskId: childAId, + ts: Date.now(), + fenceToken: 2, + agentContext: { + sessionId: "child-session-2", + agentId: "coder", + memoryRef: null, + contextTokens: null, + modelId: "test", + }, + }); + await request("POST", `/tasks/${childAId}/status`, { + status: "review", + evidence: "Child A done", + }); + await request("POST", `/tasks/${childAId}/events`, { + type: "LeaseGranted", + taskId: childAId, + ts: Date.now(), + fenceToken: 3, + agentId: "hermes", + phase: "review", + leaseTimeout: 600000, + sessionId: "child-review", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", `/tasks/${childAId}/events`, { + type: "AgentStarted", + taskId: childAId, + ts: Date.now(), + fenceToken: 3, + agentContext: { + sessionId: "child-review", + agentId: "hermes", + memoryRef: null, + contextTokens: null, + modelId: "test", + }, + }); + const childADone = await request("POST", `/tasks/${childAId}/status`, { + status: "done", + evidence: "Approved", + }); + assert.equal(childADone.status, 200); + + // Parent should now be in review.waiting (children not all done). + let parentCheck = await request("GET", `/tasks/${parentId}`); + let parentTask = (parentCheck.body as { task: { phase: string; condition: string } }).task; + assert.equal(parentTask.phase, "review"); + assert.equal(parentTask.condition, "waiting"); + + // Force the parent into review.active by granting lease + await request("POST", `/tasks/${parentId}/events`, { + type: "LeaseGranted", + taskId: parentId, + ts: Date.now(), + fenceToken: 3, + agentId: "hermes", + phase: "review", + leaseTimeout: 600000, + sessionId: "parent-review", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", `/tasks/${parentId}/events`, { + type: "AgentStarted", + taskId: parentId, + ts: Date.now(), + fenceToken: 3, + agentContext: { + sessionId: "parent-review", + agentId: "hermes", + memoryRef: null, + contextTokens: null, + modelId: "test", + }, + }); + + // Attempt to mark parent done — should be rejected because child B is not done + const parentDoneRes = await request("POST", `/tasks/${parentId}/status`, { + status: "done", + evidence: "Trying to complete parent", + }); + assert.equal(parentDoneRes.status, 422); + const parentDoneBody = parentDoneRes.body as Record; + assert.equal(parentDoneBody["error"], "children_not_done"); + assert.ok((parentDoneBody["message"] as string).includes(childBId)); + }); + + test("verifyCompletion: missing proof is recoverable with a later valid retry", async () => { + const createRes = await request("POST", "/tasks", { + title: "Recoverable repo task", + description: "Task can retry done after supplying proof", + assignee: "coder", + reviewer: "overseer", + skipAnalysis: true, + }); + const taskId = (createRes.body as Record)["taskId"] as string; + await request("PATCH", `/tasks/${taskId}/metadata`, { repo: "my-org/my-repo" }); + + await bringToReviewActive(taskId); + + const firstDoneRes = await request("POST", `/tasks/${taskId}/status`, { + status: "done", + evidence: "First attempt without proof", + }); + assert.equal(firstDoneRes.status, 422); + const firstBody = firstDoneRes.body as Record; + assert.equal(firstBody["error"], "missing_state_ref"); + + let taskRes = await request("GET", `/tasks/${taskId}`); + let task = (taskRes.body as { task: { phase: string; condition: string; terminal: string | null } }).task; + assert.equal(task.phase, "review"); + assert.equal(task.condition, "active"); + assert.equal(task.terminal, null); + + const retryDoneRes = await request("POST", `/tasks/${taskId}/status`, { + status: "done", + evidence: "Second attempt with proof", + stateRef: { branch: `task/T${taskId}`, commit: "abc1234", parentCommit: "def5678" }, + }); + assert.equal(retryDoneRes.status, 200); + + taskRes = await request("GET", `/tasks/${taskId}`); + task = (taskRes.body as { task: { phase: string; condition: string; terminal: string | null; stateRef?: { commit: string } } }).task; + assert.equal(task.terminal, "done"); + assert.equal(task.stateRef?.commit, "abc1234"); + }); + + test("verifyCompletion: happy path — metadata.repo with valid stateRef succeeds", async () => { + const createRes = await request("POST", "/tasks", { + title: "Repo task happy", + description: "Task with repo and real stateRef", + assignee: "coder", + reviewer: "overseer", + skipAnalysis: true, + }); + const taskId = (createRes.body as Record)["taskId"] as string; + await request("PATCH", `/tasks/${taskId}/metadata`, { repo: "my-org/my-repo" }); + + await bringToReviewActive(taskId); + + // Attempt done with a real stateRef — should succeed + const doneRes = await request("POST", `/tasks/${taskId}/status`, { + status: "done", + evidence: "Looks good", + stateRef: { branch: `task/T${taskId}`, commit: "abc1234", parentCommit: "def5678" }, + }); + assert.equal(doneRes.status, 200); + const body = doneRes.body as Record; + assert.equal(body["ok"], true); + }); });