Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .task
Original file line number Diff line number Diff line change
@@ -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": []
}
124 changes: 124 additions & 0 deletions core/test/validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
50 changes: 50 additions & 0 deletions docs/daemon-post-endpoints.md
Original file line number Diff line number Diff line change
@@ -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`
49 changes: 49 additions & 0 deletions middle/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading