diff --git a/packages/daemon/src/runner.test.ts b/packages/daemon/src/runner.test.ts index 2c352d1..9c7febb 100644 --- a/packages/daemon/src/runner.test.ts +++ b/packages/daemon/src/runner.test.ts @@ -423,6 +423,41 @@ describe("runDelivery — workflow failure falls back to the agent", () => { ); expect(fs.existsSync(path.join(workdir, "captured-task.txt"))).toBe(false); }, 20000); + + test("a pure workflow's status (new|resolved|nothing-new) rides along in the final report", async () => { + const cap = reportCapture(); + const url = await cap.start(); + try { + await runDelivery( + delivery({ loop: { ...delivery().loop, workflow: `return { message: "healthy", status: "resolved" };` } }), + url, + [], + ); + } finally { + cap.close(); + } + const rep = cap.reports.find((r) => r.runId === "run-1"); + expect(rep).toBeTruthy(); + expect(rep.outcome).toBe("direct"); + expect(rep.status).toBe("resolved"); + }, 20000); + + test("a pure workflow that omits status reports undefined (unaffected — matches pre-status behavior)", async () => { + const cap = reportCapture(); + const url = await cap.start(); + try { + await runDelivery( + delivery({ loop: { ...delivery().loop, workflow: `return { message: "healthy" };` } }), + url, + [], + ); + } finally { + cap.close(); + } + const rep = cap.reports.find((r) => r.runId === "run-1"); + expect(rep).toBeTruthy(); + expect(rep.status).toBeUndefined(); + }, 20000); }); describe("runDelivery — a timed-out run keeps its session pointer", () => { diff --git a/packages/daemon/src/runner.ts b/packages/daemon/src/runner.ts index f131555..a586b26 100644 --- a/packages/daemon/src/runner.ts +++ b/packages/daemon/src/runner.ts @@ -64,6 +64,10 @@ interface ReportBody { durationMs: number; outcome?: "direct" | "silent" | "exec" | "evolve"; message?: string; + /** Content status — same vocabulary as an agent's `loopany report --status`; + * set only by a pure zero-LLM workflow (an agent sets it via that CLI verb + * directly, mid-run, not through this final report). */ + status?: "new" | "resolved" | "nothing-new"; /** Workflow cursor (free-form) to persist as loop.state for next run's `prev`. */ cursor?: unknown; sessionId?: string; @@ -375,7 +379,7 @@ async function runDeliveryImpl(d: Delivery, serverUrl: string, roots: string[], return reportRun({ runId: d.runId, ok: true, durationMs: Date.now() - start, outcome: wf.result!.message ? "direct" : "silent", - message: wf.result!.message, cursor, + message: wf.result!.message, status: wf.result!.status, cursor, taskFileContent: readTaskFile(workdir, d.loop.taskFile, roots), }); } diff --git a/packages/daemon/src/workflow.test.ts b/packages/daemon/src/workflow.test.ts index 556ca03..0483d59 100644 --- a/packages/daemon/src/workflow.test.ts +++ b/packages/daemon/src/workflow.test.ts @@ -55,6 +55,24 @@ describe("existing sandbox contract stays green", () => { expect(r.result?.message).toBeUndefined(); expect(r.result?.agentCalls).toEqual([]); }); + + test("workflow can set status (new|resolved|nothing-new) alongside message/state", async () => { + const r = await runWorkflow(`return { message: "ok", state: { n: 1 }, status: "resolved" };`, null, cwd); + expect(r.ok).toBe(true); + expect(r.result?.status).toBe("resolved"); + }); + + test("omitting status leaves it undefined (backward compatible with existing workflows)", async () => { + const r = await runWorkflow(`return { message: "ok" };`, null, cwd); + expect(r.ok).toBe(true); + expect(r.result?.status).toBeUndefined(); + }); + + test("an invalid status value fails the workflow with a clear error", async () => { + const r = await runWorkflow(`return { message: "ok", status: "bogus" };`, null, cwd); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/status.*new\|resolved\|nothing-new/); + }); }); describe("subprocess env allowlist", () => { diff --git a/packages/daemon/src/workflow.ts b/packages/daemon/src/workflow.ts index 62b968c..838a7c3 100644 --- a/packages/daemon/src/workflow.ts +++ b/packages/daemon/src/workflow.ts @@ -7,8 +7,11 @@ * Ported from c0's scheduler/workflow.ts. * * Script contract: - * - return "text" or { message?, state? } → `message` is the direct message to - * the user (no claude); `state` is the persisted cursor (passed back as `prev`). + * - return "text" or { message?, state?, status? } → `message` is the direct + * message to the user (no claude); `state` is the persisted cursor (passed + * back as `prev`); `status` (new|resolved|nothing-new) is the SAME + * content-status vocabulary an agent sets via `loopany report --status` — + * optional, drives the dashboard's color-coded run block + the notify gate. * - agent(message?, data?) → request escalation to claude (handled by the runner * after the script finishes; the task + message + data become claude's context). * - await tools.call("server.tool", args) → call one of this machine's OWN @@ -54,6 +57,11 @@ export interface AgentCall { export interface WorkflowResult { message?: string; state?: unknown; + /** Same content-status vocabulary an agent sets via `loopany report --status` + * (new | resolved | nothing-new) — lets a pure zero-LLM workflow drive the + * same color-coded run status (dashboard timeline block color + the + * `shouldNotify` gate), not just the neutral `direct`/`silent` outcome. */ + status?: "new" | "resolved" | "nothing-new"; agentCalls: AgentCall[]; } @@ -153,6 +161,9 @@ export async function runWorkflow(body: string, prevState: unknown, cwd: string, if (parsed.message !== undefined && typeof parsed.message !== "string") { return { ok: false, error: "workflow `message` must be a string", ...logs }; } + if (parsed.status !== undefined && parsed.status !== "new" && parsed.status !== "resolved" && parsed.status !== "nothing-new") { + return { ok: false, error: `workflow \`status\` must be new|resolved|nothing-new (got ${JSON.stringify(parsed.status)})`, ...logs }; + } return { ok: true, result: parsed, ...logs }; } finally { await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); diff --git a/packages/server/src/gateway/index.ts b/packages/server/src/gateway/index.ts index be3a07b..9aa623a 100644 --- a/packages/server/src/gateway/index.ts +++ b/packages/server/src/gateway/index.ts @@ -149,6 +149,8 @@ const WATCH_CACHE_TTL_MS = 15_000; * back to the role default). Mirrors the runs.outcome enum minus "error", which * only the server assigns. */ const RUN_OUTCOMES = new Set(["direct", "silent", "exec", "evolve"]); +// Same content-status vocabulary an agent sets via `loopany report --status`. +const RUN_STATUSES = new Set(["new", "resolved", "nothing-new"]); /** `loopany log`: how many recent runs to return, and the per-run transcript cap. * The on-machine agent wants recent history before editing/evolving — not an @@ -1306,6 +1308,12 @@ export class MachineGateway { outcome?: "direct" | "silent" | "exec" | "evolve"; /** Workflow's direct message (set on the run). */ message?: string; + /** Content status — same vocabulary as an agent's `loopany report --status` + * (new | resolved | nothing-new). An agent sets this via that CLI verb + * mid-run instead; this lets a pure zero-LLM workflow set it too, since + * only it calls this final report directly. Untrusted wire input — + * anything outside the enum is dropped (never persisted). */ + status?: unknown; /** Workflow cursor (free-form) → persisted as loop.state for next run's `prev`. */ cursor?: unknown; /** Claude-reported cost/usage for this run (usd + token counts). */ @@ -1378,6 +1386,9 @@ export class MachineGateway { const rawMessage = body.message !== undefined ? body.message : body.finalText; const message = typeof rawMessage === "string" ? clipText(rawMessage, MESSAGE_CAP) : undefined; const claimedOutcome = RUN_OUTCOMES.has(body.outcome as string) ? body.outcome : undefined; + const claimedStatus = RUN_STATUSES.has(body.status as string) + ? (body.status as "new" | "resolved" | "nothing-new") + : undefined; // Only a SUCCESSFUL reconcile carries the workflow cursor forward — same as // the normal path, a failed run must never advance loop.state (the next run's // `prev` would bind data whose output the user never saw). Bounded by @@ -1411,6 +1422,7 @@ export class MachineGateway { ...(artifacts ? { artifacts } : {}), ...(transcript ? { transcript } : {}), ...(runState ? { state: runState } : {}), + ...(claimedStatus ? { status: claimedStatus } : {}), ...(message !== undefined ? { message } : {}), // Success clears the generic reclaim reason; a genuine late failure REPLACES // it with the real error (honest record), keeping the run an error. @@ -1493,6 +1505,9 @@ export class MachineGateway { // Whitelist the claimed outcome (untrusted wire input) — anything outside the // known enum falls back to the role default rather than landing in the column. const claimedOutcome = RUN_OUTCOMES.has(body.outcome as string) ? body.outcome : undefined; + const claimedStatus = RUN_STATUSES.has(body.status as string) + ? (body.status as "new" | "resolved" | "nothing-new") + : undefined; const finalized = await store.updateRun(lease.runId, { phase: ok ? "done" : "error", outcome: ok ? claimedOutcome ?? (lease.role === "evolve" ? "evolve" : "exec") : "error", @@ -1503,6 +1518,7 @@ export class MachineGateway { ...(transcript ? { transcript } : {}), ...coerceCost({ ...(typeof body.cost === "object" && body.cost ? body.cost : {}), attempts: body.attempts }), ...(runState ? { state: runState } : {}), + ...(claimedStatus ? { status: claimedStatus } : {}), ...(message !== undefined ? { message } : {}), ...(ok ? {} : { error: typeof body.error === "string" ? clipText(body.error, MESSAGE_CAP) : "run failed on machine" }), progress: null, // live signal done — the full transcript supersedes it