diff --git a/packages/agentstack/src/index.test.ts b/packages/agentstack/src/index.test.ts index 1e7cd45..e3f1632 100644 --- a/packages/agentstack/src/index.test.ts +++ b/packages/agentstack/src/index.test.ts @@ -76,6 +76,25 @@ describe("AgentStack coordinator", () => { expect(() => stack.updateTaskStatus(task.id, "running")).toThrow(/cancelled/); }); + it("rejects status jumps outside the AgentStack lifecycle", () => { + const stack = new AgentStack(); + stack.registerAgent(agent); + + const pending = stack.createTask({ ownerDid: owner, sourceApp: "ugig.net", title: "Pending task" }); + expect(() => stack.updateTaskStatus(pending.id, "running")).toThrow(/pending -> running/); + + const queued = stack.createTask({ + ownerDid: owner, + sourceApp: "ugig.net", + title: "Queued task", + assigneeDid: agent.did + }); + expect(() => stack.updateTaskStatus(queued.id, "complete")).toThrow(/queued -> complete/); + + const blocked = stack.updateTaskStatus(queued.id, "running"); + expect(stack.updateTaskStatus(blocked.id, "blocked").status).toBe("blocked"); + }); + it("refuses to assign a task that is already in a terminal status", () => { const stack = new AgentStack(); stack.registerAgent(agent); @@ -89,6 +108,7 @@ describe("AgentStack coordinator", () => { escrowId: "esc_1" }); stack.assignTask(task.id, agent.did); + stack.updateTaskStatus(task.id, "running"); stack.updateTaskStatus(task.id, "complete", { reputationEventId: "rep_1" }); expect(() => stack.assignTask(task.id, other.did)).toThrow(/already complete and cannot be assigned/); diff --git a/packages/agentstack/src/index.ts b/packages/agentstack/src/index.ts index 92f84ef..51caf91 100644 --- a/packages/agentstack/src/index.ts +++ b/packages/agentstack/src/index.ts @@ -40,7 +40,13 @@ export function isDidTask(value: unknown): value is DidTask { ); } -const TERMINAL: ReadonlySet = new Set(["complete", "failed", "cancelled"]); +const TERMINAL: ReadonlySet = new Set(["complete", "failed", "cancelled"]); +const ALLOWED_TRANSITIONS: Partial>> = { + pending: ["pending", "queued", "cancelled"], + queued: ["queued", "running", "cancelled"], + running: ["running", "blocked", "complete", "failed", "cancelled"] +}; +// Blocked re-entry is still a PRD open question, so keep its current permissive behavior for now. /** * In-memory AgentStack coordinator: registers agents, tracks portable tasks through their @@ -136,10 +142,14 @@ export class AgentStack { patch: Partial> = {} ): DidTask { const task = this.requireTask(taskId); - if (TERMINAL.has(task.status)) { - throw new Error(`Task ${taskId} is already ${task.status} and cannot transition to ${status}`); - } - const updated: DidTask = { ...task, ...patch, status, updatedAt: this.now() }; + if (TERMINAL.has(task.status)) { + throw new Error(`Task ${taskId} is already ${task.status} and cannot transition to ${status}`); + } + const allowedStatuses = ALLOWED_TRANSITIONS[task.status]; + if (allowedStatuses && !allowedStatuses.includes(status)) { + throw new Error(`Invalid task status transition: ${task.status} -> ${status}`); + } + const updated: DidTask = { ...task, ...patch, status, updatedAt: this.now() }; this.tasks.set(taskId, updated); this.emit({ type: "task.updated", task: updated }); return updated;