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
35 changes: 35 additions & 0 deletions packages/daemon/src/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
6 changes: 5 additions & 1 deletion packages/daemon/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
});
}
Expand Down
18 changes: 18 additions & 0 deletions packages/daemon/src/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
15 changes: 13 additions & 2 deletions packages/daemon/src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[];
}

Expand Down Expand Up @@ -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(() => {});
Expand Down
16 changes: 16 additions & 0 deletions packages/server/src/gateway/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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). */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down