From 4c8f5d27fc9ae545e038d0a3be3923c2ac470d2a Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 11 Mar 2026 12:29:39 +0000 Subject: [PATCH 1/6] T1756: Add verifyCompletion() gate to applyDoneTransition - verifyCompletion() rejects done if task has metadata.repo but stateRef is missing or zeroed (returns 422 missing_state_ref) - Rejects done if task has completionRule='and' but not all children are terminal=done (returns 422 children_not_done) - Added isZeroedStateRef() helper - Tests: stateRef missing, stateRef zeroed, children not all done, and happy path with valid stateRef Co-Authored-By: Claude Opus 4.6 --- .task | 10 + middle/http.ts | 49 +++++ middle/test/http.test.ts | 412 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 471 insertions(+) create mode 100644 .task diff --git a/.task b/.task new file mode 100644 index 0000000..0c808c2 --- /dev/null +++ b/.task @@ -0,0 +1,10 @@ +{ + "taskId": "1756", + "phase": "execution", + "fenceToken": 2, + "sessionId": "aba9bb5a-4dba-4716-8059-198833279981", + "journalPath": "/tmp/taskcore-worktrees/journal-T1756/tasks/T1756/", + "codeWorktree": "/tmp/taskcore-worktrees/code-T1756", + "claimedAt": 1773231666266, + "reviewNotes": [] +} diff --git a/middle/http.ts b/middle/http.ts index e65a87c..18481c1 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, @@ -930,6 +976,9 @@ function applyDoneTransition( }; } + const completionErr = verifyCompletion(core, task, stateRef); + if (completionErr) return completionErr; + const round = task.reviewState?.round ?? 1; // 1. ReviewVerdictSubmitted diff --git a/middle/test/http.test.ts b/middle/test/http.test.ts index 6dbbbb0..8e8b85d 100644 --- a/middle/test/http.test.ts +++ b/middle/test/http.test.ts @@ -423,4 +423,416 @@ 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 when metadata.repo is set but stateRef is missing", async () => { + // Create task, then set metadata.repo via PATCH + await request("POST", "/tasks", { + title: "Repo task", + description: "Task with repo in metadata", + assignee: "coder", + reviewer: "hermes", + skipAnalysis: true, + }); + await request("PATCH", "/tasks/1/metadata", { repo: "my-org/my-repo" }); + + await bringToReviewActive("1"); + + // Attempt done without stateRef — should get 422 + const doneRes = await request("POST", "/tasks/1/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 () => { + await request("POST", "/tasks", { + title: "Repo task zeroed", + description: "Task with repo and zeroed stateRef", + assignee: "coder", + reviewer: "hermes", + skipAnalysis: true, + }); + await request("PATCH", "/tasks/1/metadata", { repo: "my-org/my-repo" }); + + await bringToReviewActive("1"); + + // Attempt done with zeroed stateRef — should get 422 + const doneRes = await request("POST", "/tasks/1/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 parent task + await request("POST", "/tasks", { + title: "Parent task", + description: "Will be decomposed", + assignee: "coder", + reviewer: "hermes", + skipAnalysis: true, + }); + + // Bring to execution.active so we can transition to analysis for decompose + // Actually, skipAnalysis puts us at execution.ready. We need to go through + // the decompose flow. Let's use analysis flow instead. + // Re-create without skipAnalysis and with the decompose endpoint. + + // Lease for execution phase + await request("POST", "/tasks/1/events", { + type: "LeaseGranted", + taskId: "1", + ts: Date.now(), + fenceToken: 1, + agentId: "coder", + phase: "execution", + leaseTimeout: 600000, + sessionId: "test-session", + sessionType: "fresh", + contextBudget: 100, + }); + + await request("POST", "/tasks/1/events", { + type: "AgentStarted", + taskId: "1", + 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/1/status", { + status: "review", + evidence: "Work done", + }); + + // Reviewer lease + start + await request("POST", "/tasks/1/events", { + type: "LeaseGranted", + taskId: "1", + ts: Date.now(), + fenceToken: 2, + agentId: "hermes", + phase: "review", + leaseTimeout: 600000, + sessionId: "review-session", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", "/tasks/1/events", { + type: "AgentStarted", + taskId: "1", + 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/1/status", { + status: "pending", + evidence: "Need to decompose instead", + }); + + // Now at execution.ready — but we need analysis.active to decompose. + // Simpler approach: create a fresh task without skipAnalysis. + // Let's create a second task for decomposition. + await request("POST", "/tasks", { + title: "Decomposable parent", + description: "This one will be decomposed", + assignee: "coder", + reviewer: "hermes", + }); + + // Task 2 is in analysis.ready. Lease and start it. + await request("POST", "/tasks/2/events", { + type: "LeaseGranted", + taskId: "2", + ts: Date.now(), + fenceToken: 1, + agentId: "coder", + phase: "analysis", + leaseTimeout: 600000, + sessionId: "test-session-2", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", "/tasks/2/events", { + type: "AgentStarted", + taskId: "2", + ts: Date.now(), + fenceToken: 1, + agentContext: { + sessionId: "test-session-2", + agentId: "coder", + memoryRef: null, + contextTokens: null, + modelId: "test", + }, + }); + + // Decompose task 2 into two children + const decompRes = await request("POST", "/tasks/2/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 task 2 should now be in review.waiting (children not all done). + // We need it in review.active to attempt done. Lease it. + // After child A completes, parent may still be review.waiting since child B is not done. + let parentCheck = await request("GET", "/tasks/2"); + 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/2/events", { + type: "LeaseGranted", + taskId: "2", + ts: Date.now(), + fenceToken: 3, + agentId: "hermes", + phase: "review", + leaseTimeout: 600000, + sessionId: "parent-review", + sessionType: "fresh", + contextBudget: 100, + }); + await request("POST", "/tasks/2/events", { + type: "AgentStarted", + taskId: "2", + 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/2/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: happy path — metadata.repo with valid stateRef succeeds", async () => { + await request("POST", "/tasks", { + title: "Repo task happy", + description: "Task with repo and real stateRef", + assignee: "coder", + reviewer: "hermes", + skipAnalysis: true, + }); + await request("PATCH", "/tasks/1/metadata", { repo: "my-org/my-repo" }); + + await bringToReviewActive("1"); + + // Attempt done with a real stateRef — should succeed + const doneRes = await request("POST", "/tasks/1/status", { + status: "done", + evidence: "Looks good", + stateRef: { branch: "task/T1", commit: "abc1234", parentCommit: "def5678" }, + }); + assert.equal(doneRes.status, 200); + const body = doneRes.body as Record; + assert.equal(body["ok"], true); + }); }); From c3a7317619ad82fea8cf08251e731efe8fbd900d Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 11 Mar 2026 13:33:32 +0000 Subject: [PATCH 2/6] T1756: Fix tests to use dynamic task IDs (not hardcoded '1') Makes verifyCompletion tests independent of insertion order. Co-Authored-By: Claude Sonnet 4.6 --- .task | 6 +-- middle/test/http.test.ts | 98 +++++++++++++++++++--------------------- 2 files changed, 50 insertions(+), 54 deletions(-) diff --git a/.task b/.task index 0c808c2..77b500d 100644 --- a/.task +++ b/.task @@ -1,10 +1,10 @@ { "taskId": "1756", "phase": "execution", - "fenceToken": 2, - "sessionId": "aba9bb5a-4dba-4716-8059-198833279981", + "fenceToken": 4, + "sessionId": "f381868c-6f5a-4f35-a188-0f18c9c08a57", "journalPath": "/tmp/taskcore-worktrees/journal-T1756/tasks/T1756/", "codeWorktree": "/tmp/taskcore-worktrees/code-T1756", - "claimedAt": 1773231666266, + "claimedAt": 1773235751917, "reviewNotes": [] } diff --git a/middle/test/http.test.ts b/middle/test/http.test.ts index 8e8b85d..a014da4 100644 --- a/middle/test/http.test.ts +++ b/middle/test/http.test.ts @@ -501,19 +501,20 @@ describe("HTTP API", () => { test("verifyCompletion: rejects done when metadata.repo is set but stateRef is missing", async () => { // Create task, then set metadata.repo via PATCH - await request("POST", "/tasks", { + const createRes = await request("POST", "/tasks", { title: "Repo task", description: "Task with repo in metadata", assignee: "coder", reviewer: "hermes", skipAnalysis: true, }); - await request("PATCH", "/tasks/1/metadata", { repo: "my-org/my-repo" }); + const taskId = (createRes.body as Record)["taskId"] as string; + await request("PATCH", `/tasks/${taskId}/metadata`, { repo: "my-org/my-repo" }); - await bringToReviewActive("1"); + await bringToReviewActive(taskId); // Attempt done without stateRef — should get 422 - const doneRes = await request("POST", "/tasks/1/status", { + const doneRes = await request("POST", `/tasks/${taskId}/status`, { status: "done", evidence: "Looks good", // no stateRef @@ -524,19 +525,20 @@ describe("HTTP API", () => { }); test("verifyCompletion: rejects done when metadata.repo is set and stateRef is zeroed", async () => { - await request("POST", "/tasks", { + const createRes = await request("POST", "/tasks", { title: "Repo task zeroed", description: "Task with repo and zeroed stateRef", assignee: "coder", reviewer: "hermes", skipAnalysis: true, }); - await request("PATCH", "/tasks/1/metadata", { repo: "my-org/my-repo" }); + const taskId = (createRes.body as Record)["taskId"] as string; + await request("PATCH", `/tasks/${taskId}/metadata`, { repo: "my-org/my-repo" }); - await bringToReviewActive("1"); + await bringToReviewActive(taskId); // Attempt done with zeroed stateRef — should get 422 - const doneRes = await request("POST", "/tasks/1/status", { + const doneRes = await request("POST", `/tasks/${taskId}/status`, { status: "done", evidence: "Looks good", stateRef: { branch: "main", commit: "0000000", parentCommit: "0000000" }, @@ -547,24 +549,20 @@ describe("HTTP API", () => { }); test("verifyCompletion: rejects done when children with completionRule='and' are not all done", async () => { - // Create parent task - await request("POST", "/tasks", { + // 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: "hermes", skipAnalysis: true, }); + const dummyId = (dummyRes.body as Record)["taskId"] as string; - // Bring to execution.active so we can transition to analysis for decompose - // Actually, skipAnalysis puts us at execution.ready. We need to go through - // the decompose flow. Let's use analysis flow instead. - // Re-create without skipAnalysis and with the decompose endpoint. - - // Lease for execution phase - await request("POST", "/tasks/1/events", { + // Bring dummy through lifecycle so it doesn't interfere + await request("POST", `/tasks/${dummyId}/events`, { type: "LeaseGranted", - taskId: "1", + taskId: dummyId, ts: Date.now(), fenceToken: 1, agentId: "coder", @@ -575,9 +573,9 @@ describe("HTTP API", () => { contextBudget: 100, }); - await request("POST", "/tasks/1/events", { + await request("POST", `/tasks/${dummyId}/events`, { type: "AgentStarted", - taskId: "1", + taskId: dummyId, ts: Date.now(), fenceToken: 1, agentContext: { @@ -590,15 +588,15 @@ describe("HTTP API", () => { }); // Submit review to move to review.ready - await request("POST", "/tasks/1/status", { + await request("POST", `/tasks/${dummyId}/status`, { status: "review", evidence: "Work done", }); // Reviewer lease + start - await request("POST", "/tasks/1/events", { + await request("POST", `/tasks/${dummyId}/events`, { type: "LeaseGranted", - taskId: "1", + taskId: dummyId, ts: Date.now(), fenceToken: 2, agentId: "hermes", @@ -608,9 +606,9 @@ describe("HTTP API", () => { sessionType: "fresh", contextBudget: 100, }); - await request("POST", "/tasks/1/events", { + await request("POST", `/tasks/${dummyId}/events`, { type: "AgentStarted", - taskId: "1", + taskId: dummyId, ts: Date.now(), fenceToken: 2, agentContext: { @@ -623,25 +621,24 @@ describe("HTTP API", () => { }); // Send changes_requested to get back to execution - await request("POST", "/tasks/1/status", { + await request("POST", `/tasks/${dummyId}/status`, { status: "pending", evidence: "Need to decompose instead", }); - // Now at execution.ready — but we need analysis.active to decompose. - // Simpler approach: create a fresh task without skipAnalysis. - // Let's create a second task for decomposition. - await request("POST", "/tasks", { + // 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: "hermes", }); + const parentId = (parentRes.body as Record)["taskId"] as string; - // Task 2 is in analysis.ready. Lease and start it. - await request("POST", "/tasks/2/events", { + // Parent is in analysis.ready. Lease and start it. + await request("POST", `/tasks/${parentId}/events`, { type: "LeaseGranted", - taskId: "2", + taskId: parentId, ts: Date.now(), fenceToken: 1, agentId: "coder", @@ -651,9 +648,9 @@ describe("HTTP API", () => { sessionType: "fresh", contextBudget: 100, }); - await request("POST", "/tasks/2/events", { + await request("POST", `/tasks/${parentId}/events`, { type: "AgentStarted", - taskId: "2", + taskId: parentId, ts: Date.now(), fenceToken: 1, agentContext: { @@ -665,8 +662,8 @@ describe("HTTP API", () => { }, }); - // Decompose task 2 into two children - const decompRes = await request("POST", "/tasks/2/decompose", { + // 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 }, @@ -767,18 +764,16 @@ describe("HTTP API", () => { }); assert.equal(childADone.status, 200); - // Parent task 2 should now be in review.waiting (children not all done). - // We need it in review.active to attempt done. Lease it. - // After child A completes, parent may still be review.waiting since child B is not done. - let parentCheck = await request("GET", "/tasks/2"); + // 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/2/events", { + await request("POST", `/tasks/${parentId}/events`, { type: "LeaseGranted", - taskId: "2", + taskId: parentId, ts: Date.now(), fenceToken: 3, agentId: "hermes", @@ -788,9 +783,9 @@ describe("HTTP API", () => { sessionType: "fresh", contextBudget: 100, }); - await request("POST", "/tasks/2/events", { + await request("POST", `/tasks/${parentId}/events`, { type: "AgentStarted", - taskId: "2", + taskId: parentId, ts: Date.now(), fenceToken: 3, agentContext: { @@ -803,7 +798,7 @@ describe("HTTP API", () => { }); // Attempt to mark parent done — should be rejected because child B is not done - const parentDoneRes = await request("POST", "/tasks/2/status", { + const parentDoneRes = await request("POST", `/tasks/${parentId}/status`, { status: "done", evidence: "Trying to complete parent", }); @@ -814,22 +809,23 @@ describe("HTTP API", () => { }); test("verifyCompletion: happy path — metadata.repo with valid stateRef succeeds", async () => { - await request("POST", "/tasks", { + const createRes = await request("POST", "/tasks", { title: "Repo task happy", description: "Task with repo and real stateRef", assignee: "coder", reviewer: "hermes", skipAnalysis: true, }); - await request("PATCH", "/tasks/1/metadata", { repo: "my-org/my-repo" }); + const taskId = (createRes.body as Record)["taskId"] as string; + await request("PATCH", `/tasks/${taskId}/metadata`, { repo: "my-org/my-repo" }); - await bringToReviewActive("1"); + await bringToReviewActive(taskId); // Attempt done with a real stateRef — should succeed - const doneRes = await request("POST", "/tasks/1/status", { + const doneRes = await request("POST", `/tasks/${taskId}/status`, { status: "done", evidence: "Looks good", - stateRef: { branch: "task/T1", commit: "abc1234", parentCommit: "def5678" }, + stateRef: { branch: `task/T${taskId}`, commit: "abc1234", parentCommit: "def5678" }, }); assert.equal(doneRes.status, 200); const body = doneRes.body as Record; From d00e8aada9d1e968c3a0de36c9c1437bb65b876d Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 11 Mar 2026 14:24:52 +0000 Subject: [PATCH 3/6] T1756: gate done transition completion checks --- .task | 6 +++--- middle/http.ts | 6 +++--- middle/test/http.test.ts | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.task b/.task index 77b500d..7711b4e 100644 --- a/.task +++ b/.task @@ -1,10 +1,10 @@ { "taskId": "1756", "phase": "execution", - "fenceToken": 4, - "sessionId": "f381868c-6f5a-4f35-a188-0f18c9c08a57", + "fenceToken": 8, + "sessionId": "c7f04438-86fa-4c39-9487-55143f5a5209", "journalPath": "/tmp/taskcore-worktrees/journal-T1756/tasks/T1756/", "codeWorktree": "/tmp/taskcore-worktrees/code-T1756", - "claimedAt": 1773235751917, + "claimedAt": 1773238941929, "reviewNotes": [] } diff --git a/middle/http.ts b/middle/http.ts index 18481c1..06afb9a 100644 --- a/middle/http.ts +++ b/middle/http.ts @@ -966,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, @@ -976,9 +979,6 @@ function applyDoneTransition( }; } - const completionErr = verifyCompletion(core, task, stateRef); - if (completionErr) return completionErr; - const round = task.reviewState?.round ?? 1; // 1. ReviewVerdictSubmitted diff --git a/middle/test/http.test.ts b/middle/test/http.test.ts index a014da4..466eca5 100644 --- a/middle/test/http.test.ts +++ b/middle/test/http.test.ts @@ -505,7 +505,7 @@ describe("HTTP API", () => { title: "Repo task", description: "Task with repo in metadata", assignee: "coder", - reviewer: "hermes", + reviewer: "overseer", skipAnalysis: true, }); const taskId = (createRes.body as Record)["taskId"] as string; @@ -529,7 +529,7 @@ describe("HTTP API", () => { title: "Repo task zeroed", description: "Task with repo and zeroed stateRef", assignee: "coder", - reviewer: "hermes", + reviewer: "overseer", skipAnalysis: true, }); const taskId = (createRes.body as Record)["taskId"] as string; @@ -554,7 +554,7 @@ describe("HTTP API", () => { title: "Parent task", description: "Will be decomposed", assignee: "coder", - reviewer: "hermes", + reviewer: "overseer", skipAnalysis: true, }); const dummyId = (dummyRes.body as Record)["taskId"] as string; @@ -631,7 +631,7 @@ describe("HTTP API", () => { title: "Decomposable parent", description: "This one will be decomposed", assignee: "coder", - reviewer: "hermes", + reviewer: "overseer", }); const parentId = (parentRes.body as Record)["taskId"] as string; @@ -813,7 +813,7 @@ describe("HTTP API", () => { title: "Repo task happy", description: "Task with repo and real stateRef", assignee: "coder", - reviewer: "hermes", + reviewer: "overseer", skipAnalysis: true, }); const taskId = (createRes.body as Record)["taskId"] as string; From b3af9bb6448b97d67c7678a9b8a6fee194b7c145 Mon Sep 17 00:00:00 2001 From: krandder Date: Wed, 11 Mar 2026 15:58:48 +0000 Subject: [PATCH 4/6] T1782: add completion regression coverage --- .task | 14 ++-- core/test/validator.test.ts | 124 ++++++++++++++++++++++++++++++++++++ middle/test/http.test.ts | 101 +++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 7 deletions(-) diff --git a/.task b/.task index 7711b4e..5fa64ee 100644 --- a/.task +++ b/.task @@ -1,10 +1,10 @@ { - "taskId": "1756", - "phase": "execution", - "fenceToken": 8, - "sessionId": "c7f04438-86fa-4c39-9487-55143f5a5209", - "journalPath": "/tmp/taskcore-worktrees/journal-T1756/tasks/T1756/", - "codeWorktree": "/tmp/taskcore-worktrees/code-T1756", - "claimedAt": 1773238941929, + "taskId": "1782", + "phase": "analysis", + "fenceToken": 2, + "sessionId": "c7f259f0-9fb6-4bcc-ac1b-bbf22d452f15", + "journalPath": "/tmp/taskcore-worktrees/journal-T1782/tasks/T1782/", + "codeWorktree": "/tmp/taskcore-worktrees/code-T1782", + "claimedAt": 1773244535237, "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/middle/test/http.test.ts b/middle/test/http.test.ts index 466eca5..94e3d27 100644 --- a/middle/test/http.test.ts +++ b/middle/test/http.test.ts @@ -499,6 +499,67 @@ describe("HTTP API", () => { }); } + 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", { @@ -808,6 +869,46 @@ describe("HTTP API", () => { 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", From 628abba4f56459724d3532233f14969c46bc3937 Mon Sep 17 00:00:00 2001 From: krandder Date: Fri, 13 Mar 2026 04:43:00 +0000 Subject: [PATCH 5/6] T2119: Document taskcore daemon POST endpoints Investigated all 10 POST endpoints in middle/http.ts and created a reference doc covering routes, body schemas, status transitions, decomposition flow, and key behaviors (fencing, cost enforcement, completion checks, notifications). --- docs/daemon-post-endpoints.md | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 docs/daemon-post-endpoints.md 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` From 4d6fd9f4b4da86bb28e6ccdf6571cfa900786a99 Mon Sep 17 00:00:00 2001 From: krandder Date: Fri, 13 Mar 2026 04:44:46 +0000 Subject: [PATCH 6/6] T2119: update task metadata --- .task | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.task b/.task index 5fa64ee..52a4b46 100644 --- a/.task +++ b/.task @@ -1,10 +1,10 @@ { - "taskId": "1782", - "phase": "analysis", - "fenceToken": 2, - "sessionId": "c7f259f0-9fb6-4bcc-ac1b-bbf22d452f15", - "journalPath": "/tmp/taskcore-worktrees/journal-T1782/tasks/T1782/", - "codeWorktree": "/tmp/taskcore-worktrees/code-T1782", - "claimedAt": 1773244535237, + "taskId": "2119", + "phase": "execution", + "fenceToken": 5, + "sessionId": "cad8f968-aabc-4690-9f80-9126dd3891a9", + "journalPath": "/tmp/taskcore-worktrees/journal-T2119/tasks/T2119/", + "codeWorktree": "/tmp/taskcore-worktrees/code-T2119", + "claimedAt": 1773376891458, "reviewNotes": [] }