From 5eefc490b63f6e43a6091ac10f1621199754712d Mon Sep 17 00:00:00 2001 From: Nikolas Demiridis Date: Mon, 14 Sep 2026 11:05:19 +0300 Subject: [PATCH 1/5] milestone bridge: distinguish a settled-then-cancelled run from an ordinary cancellation A completed protocol turn (real work done, a real answer produced, but nothing ever called run/finish to render a verdict on it) lands in the same "cancelled" run state as a run that never got that far, and the milestone digest told the leader the same thing either way: "was cancelled. Any partial output it produced is readable ..., though there may be none." For a run that genuinely finished its turn, that reads as if the run failed and might have produced nothing, when in fact the result is complete and just sitting unread. The run state itself does not change -- cancelled is the correct terminal state for both cases, and stays that way. Only the digest text now distinguishes them, using the same turnSettled bookkeeping the bridge already keeps (MilestoneTracker's one-shot settle-episode tracking, previously private to the settled-turn digest). Exposed as MilestoneTracker.hasSettledTurn(runId) and threaded into formatDigest as a required third argument, since formatDigest itself has no state of its own to answer the question from. Read the whole path this touches before changing it: formatDigest and MilestoneTracker have exactly two consumers in the whole extension -- milestones.ts's own attachMilestoneBridge (the one production call site, updated to pass its own tracker) and milestones.test.ts (every existing call site updated to pass a tracker; most pass a fresh one since they don't depend on settle state). Nothing else in the extension references either export. Reproduce-first: two new tests pin both branches of the cancelled case. Confirmed red against the pre-fix formatDigest (temporarily reverted, tests otherwise unchanged) -- the settled-turn case failed with the old wording -- then green after restoring the fix. --- packages/extension/src/milestones.test.ts | 44 +++++++++++++++--- packages/extension/src/milestones.ts | 55 ++++++++++++++++++++--- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/packages/extension/src/milestones.test.ts b/packages/extension/src/milestones.test.ts index 6602d09a..0711e7dc 100644 --- a/packages/extension/src/milestones.test.ts +++ b/packages/extension/src/milestones.test.ts @@ -179,6 +179,7 @@ test("paneDowngraded digest names the requested/actual backends and the reason", event: { type: "paneDowngraded", payload: { runId: "run-1", requestedBackend: "tmux", requestedPlacement: "splitDown", actualBackend: "hidden", attempted: ["tmux"], reason: "tmux exploded" } }, }), ROWS, + tracker(), ); expect(digest).toBeDefined(); expect(digest).toContain("tmux"); @@ -205,7 +206,7 @@ test("failed digest names the reason but never claims a failure count on its own const rows: RunLookup = { "run-1": { ...ROWS["run-1"], latestActivity: "process exited 1" } as MonitorRow, }; - const digest = formatDigest(run("run-1", "failed"), rows); + const digest = formatDigest(run("run-1", "failed"), rows, tracker()); expect(digest).toBeDefined(); expect(digest).toContain("FAILED"); expect(digest).toContain("process exited 1"); @@ -233,6 +234,7 @@ test("escalation digest for a genuine repeat failure carries the runtime's own q }, }), ROWS, + tracker(), ); expect(digest).toBeDefined(); expect(digest).toContain("repeated_failure"); @@ -246,7 +248,7 @@ test("succeeded digest tells the leader how to read the report", () => { // settled-turn digest below has always carried this instruction and had a // test pinning it; the terminal digests carried neither, which is how the // omission survived. - const digest = formatDigest(run("run-1", "succeeded"), ROWS); + const digest = formatDigest(run("run-1", "succeeded"), ROWS, tracker()); expect(digest).toBeDefined(); expect(digest).toContain("succeeded"); expect(digest).toContain('crew_run { op: "result"'); @@ -256,16 +258,45 @@ test("non-succeeded terminal digests offer partial output without promising it", // `run/result` accepts every terminal state and returns whatever visible // text the journal accumulated, which for a run that died is usually // partial and is sometimes null. The wording must offer it without - // claiming it exists. + // claiming it exists. `cancelled` here is the run's ONLY settle episode + // never happening at all -- see the dedicated `cancelled` tests below for + // the branch where it did. for (const state of ["failed", "cancelled", "lost"]) { const rows: RunLookup = { "run-1": { ...ROWS["run-1"], latestActivity: "process exited 1" } as MonitorRow }; - const digest = formatDigest(run("run-1", state), rows); + const digest = formatDigest(run("run-1", state), rows, tracker()); expect(digest, `${state} must produce a digest`).toBeDefined(); expect(digest, `${state} must point at run/result`).toContain('crew_run { op: "result"'); expect(digest, `${state} must not promise output exists`).toContain("there may be none"); } }); +test("cancelled after a settled turn says the result is complete, not 'may be none'", () => { + // ADR-0027's `unrendered_verdict()`: this run did real work and settled a + // turn, but nothing ever called run/finish to render a verdict -- the + // SAME `cancelled` state a never-started run gets, but a very different + // fact for the leader. The state itself does not change (the + // maintainer's own ruling); only the digest text does. + const t = tracker(); + expect(t.isMilestone(flags("run-1", true))).toBe(true); // the turn settles first + const digest = formatDigest(run("run-1", "cancelled"), ROWS, t); + expect(digest).toBeDefined(); + expect(digest).toContain("finished its turn and ended without a verdict"); + expect(digest).toContain("the result is complete"); + expect(digest).toContain('crew_run { op: "result"'); + expect(digest).not.toContain("there may be none"); + expect(digest).not.toContain("was cancelled"); +}); + +test("cancelled with no settled turn keeps the existing partial-output wording", () => { + const t = tracker(); + // No settle episode recorded for this run at all. + const digest = formatDigest(run("run-1", "cancelled"), ROWS, t); + expect(digest).toBeDefined(); + expect(digest).toContain("was cancelled"); + expect(digest).toContain("there may be none"); + expect(digest).not.toContain("finished its turn and ended without a verdict"); +}); + test("a settled turn (runFlagsEvent turnSettled:true) is a milestone, once per settle episode", () => { const t = tracker(); // First settle: milestone. @@ -287,7 +318,7 @@ test("a runFlagsEvent with turnSettled false is never a milestone on its own", ( }); test("settled-turn digest points the leader at crew_run result and finish", () => { - const digest = formatDigest(flags("run-1", true), ROWS); + const digest = formatDigest(flags("run-1", true), ROWS, tracker()); expect(digest).toBeDefined(); expect(digest).toContain("settled a turn"); expect(digest).toContain('crew_run { op: "result"'); @@ -301,6 +332,7 @@ test("question digest contains the question text and triage instruction", () => event: { type: "workerQuestion", payload: { runId: "run-1", taskId: "task-1", workerId: "w1", question: "should I delete the index?" } }, }), ROWS, + tracker(), ); expect(digest).toBeDefined(); expect(digest).toContain("should I delete the index?"); @@ -314,6 +346,7 @@ test("escalation digest with no question renders exactly today's sentence", () = event: { type: "escalationRaised", payload: { runId: "run-1", taskId: "task-1", workerId: "w1", reason: "write_violation", question: null } }, }), ROWS, + tracker(), ); expect(digest).toBe("Escalation raised on run run-1 (claude adapter) for task task-1: write_violation."); }); @@ -334,6 +367,7 @@ test("escalation digest with a question appends it after the reason", () => { }, }), ROWS, + tracker(), ); expect(digest).toBeDefined(); expect(digest).toContain("vendorFirstRunGate"); diff --git a/packages/extension/src/milestones.ts b/packages/extension/src/milestones.ts index f9ecf759..b08e3c42 100644 --- a/packages/extension/src/milestones.ts +++ b/packages/extension/src/milestones.ts @@ -245,15 +245,47 @@ export class MilestoneTracker { return false; } } + + /** + * Whether `runId`'s current turn settled (ADR-0027's `waitingUser` + + * `turnSettled`) before whatever terminal event is being formatted now -- + * the same bookkeeping {@link isMilestone}'s `runFlagsEvent` arm + * maintains, exposed read-only so {@link formatDigest} can tell a + * cancellation that arrived after a real, complete turn apart from one + * that never got that far (a rejected/aborted start, a worker that died + * before ever settling). Reflects the run's CURRENT settle episode only: + * `run/finish` (or a follow-up resuming the run) clears it, same as + * `isMilestone`'s own one-shot bookkeeping. + * + * A second, separate limit, worth naming rather than leaving implicit: + * this answers "did THIS bridge instance observe the run's turn settle", + * not "did the run's turn settle" -- the two usually coincide (bookkeeping + * runs on every envelope regardless of replay, so an ordinary reconnect + * still sees it), but a connection established strictly between a run's + * settle and its terminal event, with the settle already behind the + * replay cursor, leaves this `false` for a run that did complete. Not a + * regression this method introduces -- that run got the same + * (unsettled-looking) digest before this method existed too -- but a + * caller reading this as "the turn never settled" rather than "this + * bridge never saw it settle" will draw the wrong conclusion. + */ + hasSettledTurn(runId: string): boolean { + return this.#sawSettled.has(runId); + } } /** * Builds the compact prose digest for a milestone envelope. `lookup` names - * the run's adapter / task from the monitor's rows. Returns undefined when - * the envelope is not a milestone (callers should only call this after - * `isMilestone`). + * the run's adapter / task from the monitor's rows. `tracker` is the same + * {@link MilestoneTracker} instance `isMilestone` was already called + * against for this envelope -- its {@link MilestoneTracker.hasSettledTurn} + * is what lets the `cancelled` case below tell a run that finished a real + * turn (ADR-0027's `unrendered_verdict()`: real work done, nothing rendered + * a verdict on it) apart from one cancelled before ever getting that far. + * Returns undefined when the envelope is not a milestone (callers should + * only call this after `isMilestone`). */ -export function formatDigest(e: EventEnvelope, lookup: RunLookup): string | undefined { +export function formatDigest(e: EventEnvelope, lookup: RunLookup, tracker: MilestoneTracker): string | undefined { const event: RuntimeEvent = e.event; const runId = lookupKey(e); const row = runId !== undefined ? lookup[runId] : undefined; @@ -270,6 +302,19 @@ export function formatDigest(e: EventEnvelope, lookup: RunLookup): string | unde return `${capitalize(who)} succeeded. ${READ_THE_REPORT}`; } if (state === "cancelled") { + // `cancelled` is ADR-0027's `RunState::unrendered_verdict()`: it + // covers both "cancelled before doing anything" and "did real + // work, settled a turn, but nothing ever called run/finish to + // render a verdict on it" -- the SAME state, two very different + // facts to hand the leader. `tracker.hasSettledTurn` (the same + // bookkeeping `isMilestone`'s own `runFlagsEvent` arm keeps) is + // what tells them apart; the state itself never changes (the + // maintainer's own 2026-09-09 ruling, shared with the + // leader-disconnect case -- ADR-0027 reserves `succeeded` for an + // explicit `run/finish`). + if (runId !== undefined && tracker.hasSettledTurn(runId)) { + return `${capitalize(who)} finished its turn and ended without a verdict; the result is complete. ${READ_THE_REPORT}`; + } return `${capitalize(who)} was cancelled. ${READ_ANY_PARTIAL_OUTPUT}`; } if (state === "lost") { @@ -392,7 +437,7 @@ export function attachMilestoneBridge(pi: ExtensionAPI, monitor: MonitorControll } try { const rows = monitor.getState().rows; - const digest = formatDigest(e, rows); + const digest = formatDigest(e, rows, tracker); if (digest === undefined) { return; } From ca0b7d6713c93152de8cb278b5aaee8968711e7d Mon Sep 17 00:00:00 2001 From: Nikolas Demiridis Date: Wed, 16 Sep 2026 13:39:51 +0300 Subject: [PATCH 2/5] leader-facing docs: fix the false TUI-only premise, sharpen the cancelled comment Two independently visible errors in the leader-facing worker model, shipped together since fixing one without the other leaves a leader reading a mixed, still-wrong picture: - crew_run's own tool description and crew-orchestration/SKILL.md both stated "a TUI vendor's process outlives its turn" / "a TUI vendor process never exits between turns" as the reason a settled turn is readable before a run goes terminal. That is only true for a TUI worker; a protocol worker's own process exits when its turn ends. Both now state the model that actually covers both adapter shapes: a TUI worker persists between turns, a protocol worker exits when its turn ends, and in both cases a settled turn is when the answer is readable. - crew_run's own description also told the leader, unconditionally, "If the run fails, retry... If stuck, cancel" -- with no observed-state qualifier and no mention that reading the result and finishing the run is a run's own normal end. Reworded to name op: 'result' + op: 'finish' as the normal path, and to gate retry/cancel on what op: 'get' actually reports, not on how the run merely looks. The four already-correct sentences the same read-first pass checked (crew-orchestration/SKILL.md:29-30, crew-approvals/SKILL.md:35, docs/user-guide.md:187) are untouched -- each already gates its own advice on an observed state rather than asserting one unconditionally. Also sharpens the previous commit's own `cancelled` branch comment: it now names, explicitly, the two populations that land in `cancelled` for different reasons but get the identical next action from this digest -- a leader that never called run/finish on a real, completed turn, and the daemon's own recovery sweep ending a waiting run at restart, whose turn may have genuinely settled before the crash -- plus the one bound on relying on the bridge's own settle bookkeeping across a reconnect, consolidated into that one comment rather than split across two. Doc/description text only, no behavior change -- the test is the marker guard (clean) and a read of every changed sentence. --- .../skills/crew-orchestration/SKILL.md | 2 +- packages/extension/src/milestones.ts | 55 ++++++++++--------- packages/extension/src/tools/runs.ts | 2 +- 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/packages/extension/skills/crew-orchestration/SKILL.md b/packages/extension/skills/crew-orchestration/SKILL.md index e6367184..5d45d9a1 100644 --- a/packages/extension/skills/crew-orchestration/SKILL.md +++ b/packages/extension/skills/crew-orchestration/SKILL.md @@ -21,7 +21,7 @@ To run any task via Claude, Codex, Copilot, or OMP-RPC: ## Run lifecycle: a run settles, it doesn't just exit -A TUI vendor process never exits between turns — it sits there waiting for the next instruction. So a run's answer becomes readable, and the leader's turn to act begins, the moment the vendor finishes a turn, not only when the whole run reaches a terminal state (`succeeded`/`failed`/`cancelled`/`lost`): +A TUI worker persists between turns; a protocol worker exits when its turn ends. In both cases a settled turn is when the answer is readable — so a run's answer becomes readable, and the leader's turn to act begins, the moment the vendor finishes a turn, not only when the whole run reaches a terminal state (`succeeded`/`failed`/`cancelled`/`lost`): 1. A finished turn parks the run at `waitingUser` and journals the turn-end. The `/crew` monitor now emits a milestone for this the same way it does for terminal states, worker questions, and timeouts — you do not have to poll for it. 2. `crew_run { op: "result", runId }` is readable as soon as that happens (a settled `waitingUser`, not only a terminal run) — no need to cancel the run first to read its answer. diff --git a/packages/extension/src/milestones.ts b/packages/extension/src/milestones.ts index b08e3c42..4e57f3bc 100644 --- a/packages/extension/src/milestones.ts +++ b/packages/extension/src/milestones.ts @@ -252,22 +252,12 @@ export class MilestoneTracker { * the same bookkeeping {@link isMilestone}'s `runFlagsEvent` arm * maintains, exposed read-only so {@link formatDigest} can tell a * cancellation that arrived after a real, complete turn apart from one - * that never got that far (a rejected/aborted start, a worker that died - * before ever settling). Reflects the run's CURRENT settle episode only: - * `run/finish` (or a follow-up resuming the run) clears it, same as - * `isMilestone`'s own one-shot bookkeeping. - * - * A second, separate limit, worth naming rather than leaving implicit: - * this answers "did THIS bridge instance observe the run's turn settle", - * not "did the run's turn settle" -- the two usually coincide (bookkeeping - * runs on every envelope regardless of replay, so an ordinary reconnect - * still sees it), but a connection established strictly between a run's - * settle and its terminal event, with the settle already behind the - * replay cursor, leaves this `false` for a run that did complete. Not a - * regression this method introduces -- that run got the same - * (unsettled-looking) digest before this method existed too -- but a - * caller reading this as "the turn never settled" rather than "this - * bridge never saw it settle" will draw the wrong conclusion. + * that never got that far. Reflects the run's CURRENT settle episode + * only: `run/finish` (or a follow-up resuming the run) clears it, same + * as `isMilestone`'s own one-shot bookkeeping. See the `cancelled` case + * in {@link formatDigest} for the two populations this distinguishes and + * the one bound on relying on it (a connection opened between a settle + * and its terminal event, with the settle behind the replay cursor). */ hasSettledTurn(runId: string): boolean { return this.#sawSettled.has(runId); @@ -302,16 +292,29 @@ export function formatDigest(e: EventEnvelope, lookup: RunLookup, tracker: Miles return `${capitalize(who)} succeeded. ${READ_THE_REPORT}`; } if (state === "cancelled") { - // `cancelled` is ADR-0027's `RunState::unrendered_verdict()`: it - // covers both "cancelled before doing anything" and "did real - // work, settled a turn, but nothing ever called run/finish to - // render a verdict on it" -- the SAME state, two very different - // facts to hand the leader. `tracker.hasSettledTurn` (the same - // bookkeeping `isMilestone`'s own `runFlagsEvent` arm keeps) is - // what tells them apart; the state itself never changes (the - // maintainer's own 2026-09-09 ruling, shared with the - // leader-disconnect case -- ADR-0027 reserves `succeeded` for an - // explicit `run/finish`). + // `cancelled` is ADR-0027's `RunState::unrendered_verdict()`, and + // this branch deliberately covers TWO populations that land in it + // for different reasons, with the SAME next action for both: (1) a + // leader that never called run/finish to render a verdict on a + // real, completed turn, and (2) the daemon's own recovery sweep + // ending a `waitingUser`/`waitingPeer`/`paused` run at restart -- + // if that run's turn had genuinely settled before the restart, the + // sentence below is still true and the leader's next move is + // identical (read the result). The state itself never changes for + // either population (the maintainer's own 2026-09-09 ruling, + // shared with the leader-disconnect case -- ADR-0027 reserves + // `succeeded` for an explicit `run/finish`); only this digest text + // does. `tracker.hasSettledTurn` (the same bookkeeping + // `isMilestone`'s own `runFlagsEvent` arm keeps) is what tells the + // two apart from a run that never settled at all. + // + // Bound worth stating plainly: `hasSettledTurn` only knows what + // THIS bridge instance observed. A connection established between + // a run's settle and its terminal event, with the settle already + // behind the replay cursor, leaves it `false` for a run that did + // complete -- not a regression (that run got the same + // unsettled-looking wording before this branch existed), but the + // fallback below, not a claim that the turn never settled. if (runId !== undefined && tracker.hasSettledTurn(runId)) { return `${capitalize(who)} finished its turn and ended without a verdict; the result is complete. ${READ_THE_REPORT}`; } diff --git a/packages/extension/src/tools/runs.ts b/packages/extension/src/tools/runs.ts index 55140597..e56e1f3b 100644 --- a/packages/extension/src/tools/runs.ts +++ b/packages/extension/src/tools/runs.ts @@ -36,7 +36,7 @@ export function registerRunTool(pi: ExtensionAPI, ctx: OrchestrationToolContext) name: CREW_RUN_TOOL_NAME, label: "Crew Run", description: - "Use to execute, monitor, or manage task execution by external workers. Use op: 'submit' to start execution (requires taskId from crew_task, workerId from crew_worker, and prompt -- the instruction text the worker executes), op: 'get' to check progress/status of a run, op: 'result' to read a run's output text and token usage (requires runId; readable once the run is terminal OR has settled a turn without exiting -- a TUI vendor's process outlives its turn, so 'waitingUser' with a journaled turn-end already qualifies, per ADR-0027 -- chain work by passing resultText into the next submit's prompt), op: 'list' to list runs for a task, op: 'retry' to re-execute a terminal run (creates a new runId and starts a fresh worker process; pass prompt again), op: 'cancel' to stop a running run immediately, op: 'timeoutAck' to decide a WorkerTimeout fact (requires runId and decision: 'extend' | 'nudge' | 'abort'), or op: 'finish' to end a run as the leader's own settle decision (requires runId; optional outcome: 'succeeded' | 'failed', default 'succeeded' -- states the outcome rather than inferring it, and works on any non-terminal run, not only one that has settled a turn). After submitting, monitor with op: 'get'. If the run fails, retry with op: 'retry' (new runId). If stuck, cancel with op: 'cancel'.", + "Use to execute, monitor, or manage task execution by external workers. Use op: 'submit' to start execution (requires taskId from crew_task, workerId from crew_worker, and prompt -- the instruction text the worker executes), op: 'get' to check progress/status of a run, op: 'result' to read a run's output text and token usage (requires runId; readable once the run is terminal OR has settled a turn without exiting -- a TUI worker persists between turns and a protocol worker exits when its turn ends, but in both cases a settled turn is when the answer is readable, so 'waitingUser' with a journaled turn-end already qualifies, per ADR-0027 -- chain work by passing resultText into the next submit's prompt), op: 'list' to list runs for a task, op: 'retry' to re-execute a terminal run (creates a new runId and starts a fresh worker process; pass prompt again), op: 'cancel' to stop a running run immediately, op: 'timeoutAck' to decide a WorkerTimeout fact (requires runId and decision: 'extend' | 'nudge' | 'abort'), or op: 'finish' to end a run as the leader's own settle decision (requires runId; optional outcome: 'succeeded' | 'failed', default 'succeeded' -- states the outcome rather than inferring it, and works on any non-terminal run, not only one that has settled a turn). After submitting, monitor with op: 'get'. A run's NORMAL end is a rendered verdict, not a retry or a cancel: once op: 'get' (or a milestone) shows the run terminal or settled, read the answer with op: 'result' and close it out with op: 'finish' -- do this before reaching for either of the two below. Only retry with op: 'retry' if op: 'get' actually reports the run failed. Only cancel with op: 'cancel' if op: 'get' shows it genuinely stuck (no activity), never merely because it looks quiet while settled and awaiting your own read.", parameters: params, approval: (args) => (typeof args === "object" && args !== null && "op" in args && (args.op === "submit" || args.op === "retry" || args.op === "cancel" || args.op === "timeoutAck" || args.op === "finish") ? "exec" : "read"), async execute(_toolCallId, input, _signal, _onUpdate, extCtx) { From fc1a43a5df5163c8108f9cce4440bd0557d25af2 Mon Sep 17 00:00:00 2001 From: Nikolas Demiridis Date: Wed, 16 Sep 2026 14:15:26 +0300 Subject: [PATCH 3/5] leader-facing docs: split the terminal/parked outcomes instead of joining them The previous commit's own fix re-committed the premise error it was written to remove, four lines below the fix: it told the leader "once the run is terminal or settled, read via result and close via finish", joining two outcomes that need opposite handling on the protocol path. Measured end to end: a protocol worker's clean exit with a settled turn lands as RunState::unrendered_verdict() (crates/protocol/src/run.rs), which is literally "cancelled" -- a terminal state. run_finish rejects any terminal run outright (invalid_params, "already finished"). So a leader following the joined instruction calls finish on a run that is already terminal and gets refused. The waitingUser park is real, but a protocol worker's process typically exits behind it within milliseconds (the same drain the run/submit-awaits-start deadlock measured), so terminal is what's actually observed almost every time, and it is specifically the terminal -> finish edge that was wrong. Split it instead: settled but still non-terminal (waitingUser) -> result then finish, same as before. Already terminal (including cancelled after a settled turn) -> result is the whole of it; finish will refuse, because there is no verdict left to render. That is exactly what this branch's own milestone digest already says ("finished its turn and ended without a verdict; the result is complete") -- the tool description and the digest now agree instead of pointing opposite ways. In scope for the same reason: crew-orchestration/SKILL.md's run-lifecycle section named both worker shapes in its own header but its numbered list still described only the TUI trajectory underneath -- the settled-turn park as if it always persists, "no need to cancel-and-resubmit" for a process that, on the protocol path, is already gone by the time you'd send anything. Split the same way: item 1 (the park is transient for a protocol worker), item 2 (result is readable either way), item 3's crew_send bullet (steering requires a live process; a protocol worker's own exit means a fresh submit, not a follow-up) and finish bullet (works on any non-terminal run is exactly why it refuses once already terminal). Doc/description text only, no behavior change -- the test is the marker guard (clean) and a read of every changed sentence. --- .../extension/skills/crew-orchestration/SKILL.md | 12 ++++++------ packages/extension/src/tools/runs.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/extension/skills/crew-orchestration/SKILL.md b/packages/extension/skills/crew-orchestration/SKILL.md index 5d45d9a1..4749b423 100644 --- a/packages/extension/skills/crew-orchestration/SKILL.md +++ b/packages/extension/skills/crew-orchestration/SKILL.md @@ -23,12 +23,12 @@ To run any task via Claude, Codex, Copilot, or OMP-RPC: A TUI worker persists between turns; a protocol worker exits when its turn ends. In both cases a settled turn is when the answer is readable — so a run's answer becomes readable, and the leader's turn to act begins, the moment the vendor finishes a turn, not only when the whole run reaches a terminal state (`succeeded`/`failed`/`cancelled`/`lost`): -1. A finished turn parks the run at `waitingUser` and journals the turn-end. The `/crew` monitor now emits a milestone for this the same way it does for terminal states, worker questions, and timeouts — you do not have to poll for it. -2. `crew_run { op: "result", runId }` is readable as soon as that happens (a settled `waitingUser`, not only a terminal run) — no need to cancel the run first to read its answer. -3. From there you have three real choices, none of which requires a terminal state first: - - `crew_send { runId, text, kind }` to follow up — a follow-up on an existing run is never refused, so there is no need to cancel-and-resubmit just to keep steering the same conversation. - - `crew_run { op: "finish", runId, outcome: "succeeded" | "failed" }` — the leader's own decision that this conversation is done. States the outcome explicitly (default `"succeeded"`); never inferred from the vendor's own turn markers, because a finished turn only means "the vendor stopped talking," not "the task went well." Works on any non-terminal run, not only one that has settled a turn. - - `crew_stop { runId, outcome: "done" | "abort" }` to end it by killing the worker instead (see below) — reach for `finish` when the conversation is genuinely complete, `stop` when you need the process gone regardless. +1. A finished turn parks the run at `waitingUser` and journals the turn-end — a TUI worker, which stays alive between turns, sits there. A protocol worker's own process exits when its turn ends, so this park is transient: the exit typically lands within milliseconds, and the run you actually observe commonly already reads terminal (`cancelled`, ADR-0027's `unrendered_verdict()` — real work done, no `run/finish` call ever rendered a verdict on it). Either way, the `/crew` monitor emits a milestone for the settle the same way it does for terminal states, worker questions, and timeouts — you do not have to poll for it. +2. `crew_run { op: "result", runId }` is readable as soon as the turn settles, whether the run is still parked at `waitingUser` (TUI, or a protocol run you caught in time) or has already gone terminal (`cancelled`, the common protocol case) — no need to cancel the run first to read its answer either way. +3. From there: + - `crew_send { runId, text, kind }` to follow up **while the worker's own process is still alive** — a follow-up on a live run is never refused, so there is no need to cancel-and-resubmit just to keep steering that conversation. Once a protocol worker's process has exited (the run already reads terminal), there is no live adapter left to steer: the message still journals, but nothing is driving it — continuing the work means a fresh `crew_run { op: "submit" }`, not a follow-up. + - `crew_run { op: "finish", runId, outcome: "succeeded" | "failed" }` — the leader's own decision that this conversation is done. States the outcome explicitly (default `"succeeded"`); never inferred from the vendor's own turn markers, because a finished turn only means "the vendor stopped talking," not "the task went well." Works on any non-terminal run, not only one that has settled a turn — which is exactly why it refuses once the run is already terminal (the protocol case above, `invalid_params`): there is no verdict left to render, and `op: "result"` is the whole of what you get. + - `crew_stop { runId, outcome: "done" | "abort" }` to end it by killing the worker instead (see below) — reach for `finish` when the conversation is genuinely complete and still open to a verdict, `stop` when you need a live process gone regardless. `crew_finish { runIds }` is a different, narrower tool: it **cancels** the named plan runs in a loop (the same effect as `crew_stop { outcome: "abort" }`, batched) — it does not settle anything and never states an outcome. Use `crew_run { op: "finish" }` when you want to record how one run's conversation actually went; use `crew_finish` only to bulk-cancel a plan's remaining live runs once you're done with the plan as a whole. Neither ever releases a workspace lease behind your back. diff --git a/packages/extension/src/tools/runs.ts b/packages/extension/src/tools/runs.ts index e56e1f3b..1ceb60e1 100644 --- a/packages/extension/src/tools/runs.ts +++ b/packages/extension/src/tools/runs.ts @@ -36,7 +36,7 @@ export function registerRunTool(pi: ExtensionAPI, ctx: OrchestrationToolContext) name: CREW_RUN_TOOL_NAME, label: "Crew Run", description: - "Use to execute, monitor, or manage task execution by external workers. Use op: 'submit' to start execution (requires taskId from crew_task, workerId from crew_worker, and prompt -- the instruction text the worker executes), op: 'get' to check progress/status of a run, op: 'result' to read a run's output text and token usage (requires runId; readable once the run is terminal OR has settled a turn without exiting -- a TUI worker persists between turns and a protocol worker exits when its turn ends, but in both cases a settled turn is when the answer is readable, so 'waitingUser' with a journaled turn-end already qualifies, per ADR-0027 -- chain work by passing resultText into the next submit's prompt), op: 'list' to list runs for a task, op: 'retry' to re-execute a terminal run (creates a new runId and starts a fresh worker process; pass prompt again), op: 'cancel' to stop a running run immediately, op: 'timeoutAck' to decide a WorkerTimeout fact (requires runId and decision: 'extend' | 'nudge' | 'abort'), or op: 'finish' to end a run as the leader's own settle decision (requires runId; optional outcome: 'succeeded' | 'failed', default 'succeeded' -- states the outcome rather than inferring it, and works on any non-terminal run, not only one that has settled a turn). After submitting, monitor with op: 'get'. A run's NORMAL end is a rendered verdict, not a retry or a cancel: once op: 'get' (or a milestone) shows the run terminal or settled, read the answer with op: 'result' and close it out with op: 'finish' -- do this before reaching for either of the two below. Only retry with op: 'retry' if op: 'get' actually reports the run failed. Only cancel with op: 'cancel' if op: 'get' shows it genuinely stuck (no activity), never merely because it looks quiet while settled and awaiting your own read.", + "Use to execute, monitor, or manage task execution by external workers. Use op: 'submit' to start execution (requires taskId from crew_task, workerId from crew_worker, and prompt -- the instruction text the worker executes), op: 'get' to check progress/status of a run, op: 'result' to read a run's output text and token usage (requires runId; readable once the run is terminal OR has settled a turn without exiting -- a TUI worker persists between turns and a protocol worker exits when its turn ends, but in both cases a settled turn is when the answer is readable, so 'waitingUser' with a journaled turn-end already qualifies, per ADR-0027 -- chain work by passing resultText into the next submit's prompt), op: 'list' to list runs for a task, op: 'retry' to re-execute a terminal run (creates a new runId and starts a fresh worker process; pass prompt again), op: 'cancel' to stop a running run immediately, op: 'timeoutAck' to decide a WorkerTimeout fact (requires runId and decision: 'extend' | 'nudge' | 'abort'), or op: 'finish' to end a run as the leader's own settle decision (requires runId; optional outcome: 'succeeded' | 'failed', default 'succeeded' -- states the outcome rather than inferring it, and works on any non-terminal run, not only one that has settled a turn). After submitting, monitor with op: 'get'. A run's NORMAL end is a rendered verdict, not a retry or a cancel -- but which of 'result'/'finish' applies depends on whether the run is still parked or already terminal, and the two must not be joined: if op: 'get' (or a milestone) shows the run settled but still non-terminal ('waitingUser'), read the answer with op: 'result' and close it out with op: 'finish' -- a protocol worker's own process typically exits behind that park within milliseconds, so by the time you act the run may already read terminal instead of parked, which is expected, not a race you lost. If op: 'get' already shows the run terminal (including 'cancelled' after a settled turn -- the same run, no verdict rendered, but nothing left to render one on), op: 'result' is the whole of it: read it, and do not call op: 'finish' -- a terminal run refuses it ('invalid_params', already finished). Only retry with op: 'retry' if op: 'get' actually reports the run failed. Only cancel with op: 'cancel' if op: 'get' shows it genuinely stuck (no activity), never merely because it looks quiet while settled and awaiting your own read.", parameters: params, approval: (args) => (typeof args === "object" && args !== null && "op" in args && (args.op === "submit" || args.op === "retry" || args.op === "cancel" || args.op === "timeoutAck" || args.op === "finish") ? "exec" : "read"), async execute(_toolCallId, input, _signal, _onUpdate, extCtx) { From df93e8892bfcf263ac601fe842bb6ba3eef8196f Mon Sep 17 00:00:00 2001 From: Nikolas Demiridis Date: Wed, 16 Sep 2026 14:35:06 +0300 Subject: [PATCH 4/5] leader-facing docs: drop the unmeasured "within milliseconds" claim The structural reason a protocol worker's waitingUser park reads transient is that it runs one turn and exits -- true by construction, and stronger than any timing figure since it cannot rot. "Within milliseconds" was a reconstructed number, not a measured one: nothing in the proof-run artifacts times the turn-settle-to-process-exit interval specifically (the nearest figures measure different intervals entirely). Replaced with the repo's own established phrasing for this shape, already used to describe the run/submit-awaits-start root cause. Doc/description text only, no behavior change. --- packages/extension/skills/crew-orchestration/SKILL.md | 2 +- packages/extension/src/tools/runs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/extension/skills/crew-orchestration/SKILL.md b/packages/extension/skills/crew-orchestration/SKILL.md index 4749b423..637080d9 100644 --- a/packages/extension/skills/crew-orchestration/SKILL.md +++ b/packages/extension/skills/crew-orchestration/SKILL.md @@ -23,7 +23,7 @@ To run any task via Claude, Codex, Copilot, or OMP-RPC: A TUI worker persists between turns; a protocol worker exits when its turn ends. In both cases a settled turn is when the answer is readable — so a run's answer becomes readable, and the leader's turn to act begins, the moment the vendor finishes a turn, not only when the whole run reaches a terminal state (`succeeded`/`failed`/`cancelled`/`lost`): -1. A finished turn parks the run at `waitingUser` and journals the turn-end — a TUI worker, which stays alive between turns, sits there. A protocol worker's own process exits when its turn ends, so this park is transient: the exit typically lands within milliseconds, and the run you actually observe commonly already reads terminal (`cancelled`, ADR-0027's `unrendered_verdict()` — real work done, no `run/finish` call ever rendered a verdict on it). Either way, the `/crew` monitor emits a milestone for the settle the same way it does for terminal states, worker questions, and timeouts — you do not have to poll for it. +1. A finished turn parks the run at `waitingUser` and journals the turn-end — a TUI worker, which stays alive between turns, sits there. A protocol worker runs one turn and exits, so this park is transient by construction: the run you actually observe commonly already reads terminal (`cancelled`, ADR-0027's `unrendered_verdict()` — real work done, no `run/finish` call ever rendered a verdict on it). Either way, the `/crew` monitor emits a milestone for the settle the same way it does for terminal states, worker questions, and timeouts — you do not have to poll for it. 2. `crew_run { op: "result", runId }` is readable as soon as the turn settles, whether the run is still parked at `waitingUser` (TUI, or a protocol run you caught in time) or has already gone terminal (`cancelled`, the common protocol case) — no need to cancel the run first to read its answer either way. 3. From there: - `crew_send { runId, text, kind }` to follow up **while the worker's own process is still alive** — a follow-up on a live run is never refused, so there is no need to cancel-and-resubmit just to keep steering that conversation. Once a protocol worker's process has exited (the run already reads terminal), there is no live adapter left to steer: the message still journals, but nothing is driving it — continuing the work means a fresh `crew_run { op: "submit" }`, not a follow-up. diff --git a/packages/extension/src/tools/runs.ts b/packages/extension/src/tools/runs.ts index 1ceb60e1..8a9366b3 100644 --- a/packages/extension/src/tools/runs.ts +++ b/packages/extension/src/tools/runs.ts @@ -36,7 +36,7 @@ export function registerRunTool(pi: ExtensionAPI, ctx: OrchestrationToolContext) name: CREW_RUN_TOOL_NAME, label: "Crew Run", description: - "Use to execute, monitor, or manage task execution by external workers. Use op: 'submit' to start execution (requires taskId from crew_task, workerId from crew_worker, and prompt -- the instruction text the worker executes), op: 'get' to check progress/status of a run, op: 'result' to read a run's output text and token usage (requires runId; readable once the run is terminal OR has settled a turn without exiting -- a TUI worker persists between turns and a protocol worker exits when its turn ends, but in both cases a settled turn is when the answer is readable, so 'waitingUser' with a journaled turn-end already qualifies, per ADR-0027 -- chain work by passing resultText into the next submit's prompt), op: 'list' to list runs for a task, op: 'retry' to re-execute a terminal run (creates a new runId and starts a fresh worker process; pass prompt again), op: 'cancel' to stop a running run immediately, op: 'timeoutAck' to decide a WorkerTimeout fact (requires runId and decision: 'extend' | 'nudge' | 'abort'), or op: 'finish' to end a run as the leader's own settle decision (requires runId; optional outcome: 'succeeded' | 'failed', default 'succeeded' -- states the outcome rather than inferring it, and works on any non-terminal run, not only one that has settled a turn). After submitting, monitor with op: 'get'. A run's NORMAL end is a rendered verdict, not a retry or a cancel -- but which of 'result'/'finish' applies depends on whether the run is still parked or already terminal, and the two must not be joined: if op: 'get' (or a milestone) shows the run settled but still non-terminal ('waitingUser'), read the answer with op: 'result' and close it out with op: 'finish' -- a protocol worker's own process typically exits behind that park within milliseconds, so by the time you act the run may already read terminal instead of parked, which is expected, not a race you lost. If op: 'get' already shows the run terminal (including 'cancelled' after a settled turn -- the same run, no verdict rendered, but nothing left to render one on), op: 'result' is the whole of it: read it, and do not call op: 'finish' -- a terminal run refuses it ('invalid_params', already finished). Only retry with op: 'retry' if op: 'get' actually reports the run failed. Only cancel with op: 'cancel' if op: 'get' shows it genuinely stuck (no activity), never merely because it looks quiet while settled and awaiting your own read.", + "Use to execute, monitor, or manage task execution by external workers. Use op: 'submit' to start execution (requires taskId from crew_task, workerId from crew_worker, and prompt -- the instruction text the worker executes), op: 'get' to check progress/status of a run, op: 'result' to read a run's output text and token usage (requires runId; readable once the run is terminal OR has settled a turn without exiting -- a TUI worker persists between turns and a protocol worker exits when its turn ends, but in both cases a settled turn is when the answer is readable, so 'waitingUser' with a journaled turn-end already qualifies, per ADR-0027 -- chain work by passing resultText into the next submit's prompt), op: 'list' to list runs for a task, op: 'retry' to re-execute a terminal run (creates a new runId and starts a fresh worker process; pass prompt again), op: 'cancel' to stop a running run immediately, op: 'timeoutAck' to decide a WorkerTimeout fact (requires runId and decision: 'extend' | 'nudge' | 'abort'), or op: 'finish' to end a run as the leader's own settle decision (requires runId; optional outcome: 'succeeded' | 'failed', default 'succeeded' -- states the outcome rather than inferring it, and works on any non-terminal run, not only one that has settled a turn). After submitting, monitor with op: 'get'. A run's NORMAL end is a rendered verdict, not a retry or a cancel -- but which of 'result'/'finish' applies depends on whether the run is still parked or already terminal, and the two must not be joined: if op: 'get' (or a milestone) shows the run settled but still non-terminal ('waitingUser'), read the answer with op: 'result' and close it out with op: 'finish' -- a protocol worker runs one turn and exits, so that park is transient by construction: by the time you act the run commonly already reads terminal instead of parked, which is expected, not a race you lost. If op: 'get' already shows the run terminal (including 'cancelled' after a settled turn -- the same run, no verdict rendered, but nothing left to render one on), op: 'result' is the whole of it: read it, and do not call op: 'finish' -- a terminal run refuses it ('invalid_params', already finished). Only retry with op: 'retry' if op: 'get' actually reports the run failed. Only cancel with op: 'cancel' if op: 'get' shows it genuinely stuck (no activity), never merely because it looks quiet while settled and awaiting your own read.", parameters: params, approval: (args) => (typeof args === "object" && args !== null && "op" in args && (args.op === "submit" || args.op === "retry" || args.op === "cancel" || args.op === "timeoutAck" || args.op === "finish") ? "exec" : "read"), async execute(_toolCallId, input, _signal, _onUpdate, extCtx) { From 2a003489d594ae727b4881d70755814eb76bf9eb Mon Sep 17 00:00:00 2001 From: "crew-bot[bot]" <322634903+crew-bot[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:37:03 +0000 Subject: [PATCH 5/5] chore(extension): refresh bundle from linux-x64 CI Build: https://github.com/nikolasd/crew/actions/runs/35091236999 Source: af7dd9d4f4a04b927a139feb4fffd72a3869a4d0 --- packages/extension/dist/index.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/extension/dist/index.js b/packages/extension/dist/index.js index 3198465e..649c2c00 100644 --- a/packages/extension/dist/index.js +++ b/packages/extension/dist/index.js @@ -13419,7 +13419,7 @@ function registerRunTool(pi, ctx) { pi.registerTool({ name: CREW_RUN_TOOL_NAME, label: "Crew Run", - description: "Use to execute, monitor, or manage task execution by external workers. Use op: 'submit' to start execution (requires taskId from crew_task, workerId from crew_worker, and prompt -- the instruction text the worker executes), op: 'get' to check progress/status of a run, op: 'result' to read a run's output text and token usage (requires runId; readable once the run is terminal OR has settled a turn without exiting -- a TUI vendor's process outlives its turn, so 'waitingUser' with a journaled turn-end already qualifies, per ADR-0027 -- chain work by passing resultText into the next submit's prompt), op: 'list' to list runs for a task, op: 'retry' to re-execute a terminal run (creates a new runId and starts a fresh worker process; pass prompt again), op: 'cancel' to stop a running run immediately, op: 'timeoutAck' to decide a WorkerTimeout fact (requires runId and decision: 'extend' | 'nudge' | 'abort'), or op: 'finish' to end a run as the leader's own settle decision (requires runId; optional outcome: 'succeeded' | 'failed', default 'succeeded' -- states the outcome rather than inferring it, and works on any non-terminal run, not only one that has settled a turn). After submitting, monitor with op: 'get'. If the run fails, retry with op: 'retry' (new runId). If stuck, cancel with op: 'cancel'.", + description: "Use to execute, monitor, or manage task execution by external workers. Use op: 'submit' to start execution (requires taskId from crew_task, workerId from crew_worker, and prompt -- the instruction text the worker executes), op: 'get' to check progress/status of a run, op: 'result' to read a run's output text and token usage (requires runId; readable once the run is terminal OR has settled a turn without exiting -- a TUI worker persists between turns and a protocol worker exits when its turn ends, but in both cases a settled turn is when the answer is readable, so 'waitingUser' with a journaled turn-end already qualifies, per ADR-0027 -- chain work by passing resultText into the next submit's prompt), op: 'list' to list runs for a task, op: 'retry' to re-execute a terminal run (creates a new runId and starts a fresh worker process; pass prompt again), op: 'cancel' to stop a running run immediately, op: 'timeoutAck' to decide a WorkerTimeout fact (requires runId and decision: 'extend' | 'nudge' | 'abort'), or op: 'finish' to end a run as the leader's own settle decision (requires runId; optional outcome: 'succeeded' | 'failed', default 'succeeded' -- states the outcome rather than inferring it, and works on any non-terminal run, not only one that has settled a turn). After submitting, monitor with op: 'get'. A run's NORMAL end is a rendered verdict, not a retry or a cancel -- but which of 'result'/'finish' applies depends on whether the run is still parked or already terminal, and the two must not be joined: if op: 'get' (or a milestone) shows the run settled but still non-terminal ('waitingUser'), read the answer with op: 'result' and close it out with op: 'finish' -- a protocol worker runs one turn and exits, so that park is transient by construction: by the time you act the run commonly already reads terminal instead of parked, which is expected, not a race you lost. If op: 'get' already shows the run terminal (including 'cancelled' after a settled turn -- the same run, no verdict rendered, but nothing left to render one on), op: 'result' is the whole of it: read it, and do not call op: 'finish' -- a terminal run refuses it ('invalid_params', already finished). Only retry with op: 'retry' if op: 'get' actually reports the run failed. Only cancel with op: 'cancel' if op: 'get' shows it genuinely stuck (no activity), never merely because it looks quiet while settled and awaiting your own read.", parameters: params, approval: (args) => typeof args === "object" && args !== null && ("op" in args) && (args.op === "submit" || args.op === "retry" || args.op === "cancel" || args.op === "timeoutAck" || args.op === "finish") ? "exec" : "read", async execute(_toolCallId, input, _signal, _onUpdate, extCtx) { @@ -13742,8 +13742,11 @@ class MilestoneTracker { return false; } } + hasSettledTurn(runId) { + return this.#sawSettled.has(runId); + } } -function formatDigest(e, lookup) { +function formatDigest(e, lookup, tracker) { const event = e.event; const runId = lookupKey(e); const row = runId !== undefined ? lookup[runId] : undefined; @@ -13759,6 +13762,9 @@ function formatDigest(e, lookup) { return `${capitalize(who)} succeeded. ${READ_THE_REPORT}`; } if (state === "cancelled") { + if (runId !== undefined && tracker.hasSettledTurn(runId)) { + return `${capitalize(who)} finished its turn and ended without a verdict; the result is complete. ${READ_THE_REPORT}`; + } return `${capitalize(who)} was cancelled. ${READ_ANY_PARTIAL_OUTPUT}`; } if (state === "lost") { @@ -13812,7 +13818,7 @@ function attachMilestoneBridge(pi, monitor) { } try { const rows = monitor.getState().rows; - const digest = formatDigest(e, rows); + const digest = formatDigest(e, rows, tracker); if (digest === undefined) { return; }