diff --git a/.task b/.task new file mode 100644 index 0000000..52a4b46 --- /dev/null +++ b/.task @@ -0,0 +1,10 @@ +{ + "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": [] +} 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/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); + }); });