From 80f08fd14b184e68f038efeebed2dc99b2122835 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:41:25 +0200 Subject: [PATCH 1/4] test(engine): background shells started together are each their own work Milo's stress run 3 (B +4:06) read as if the engine tracked one of three background jobs started in one turn. It tracked all three: the work items w10/w11 (items i/15, i/17) opened running and closed stopped at the person's Stop. The recorder cut its ws frames at 2,000 characters and its reader skipped the cut ones, so those frames never reached the breakdown. This pins the Claude shapes of three jobs started together: three task_started for three Bash calls, each its own work with its own end. --- .../src/engine/bridge/translate.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/apps/server/src/engine/bridge/translate.test.ts b/apps/server/src/engine/bridge/translate.test.ts index e61053a68..4ffed34e2 100644 --- a/apps/server/src/engine/bridge/translate.test.ts +++ b/apps/server/src/engine/bridge/translate.test.ts @@ -1545,6 +1545,98 @@ describe("real adapters, driven by a mock or an authored wire", () => { ]); }); + // Milo, 2026-10-09: three background commands in one response — one that ends, one that runs on, + // one that fails. Each is work of its own, and each ends by its own report. + it("claudeAgent [mock]: background shells started together are each their own work, with their own end", async () => { + const jobs = [ + ["toolu_ok", "b-ok", "npm run build", "Build the api"], + ["toolu_long", "b-long", "npm run dev", "Serve the api"], + ["toolu_fail", "b-fail", "npm run lint", "Lint the api"], + ] as const; + const frame = { session_id: CLAUDE_SESSION, parent_tool_use_id: null }; + const calls = jobs.map(([id, , command, description]) => ({ + type: "tool_use", + id, + name: "Bash", + input: { command, description, run_in_background: true }, + })); + const lines = await record( + "claudeAgent", + recordClaude([ + ...claudeTurnSoFar, + ...calls.map((call, index) => ({ + ...frame, + type: "assistant", + uuid: `calls-${index}`, + message: { + model: "claude-opus-5", + id: "msg_jobs", + type: "message", + role: "assistant", + content: [call], + stop_reason: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + })), + ...jobs.flatMap(([id, task, , description]) => [ + { + ...frame, + type: "system", + subtype: "task_started", + task_id: task, + tool_use_id: id, + description, + task_type: "local_bash", + uuid: `started-${task}`, + }, + { + ...frame, + type: "user", + uuid: `result-${task}`, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: id, + content: `Command running in background with ID: ${task}.`, + is_error: false, + }, + ], + }, + }, + ]), + claudeResult, + ...( + [ + ["toolu_fail", "b-fail", "failed"], + ["toolu_ok", "b-ok", "completed"], + ] as const + ).map(([id, task, status]) => ({ + ...frame, + type: "system", + subtype: "task_notification", + task_id: task, + tool_use_id: id, + status, + output_file: `/tmp/${task}.output`, + summary: status, + uuid: `ended-${task}`, + })), + ]), + ); + assert.deepStrictEqual( + lines.filter((line) => line.startsWith("s1.w")), + [ + "s1.w1 shell running, from h1", + "s1.w2 shell running, from h1", + "s1.w3 shell running, from h1", + "s1.w3 shell failed, from h1", + "s1.w1 shell completed, from h1", + ], + ); + }); + it("codex [mock]: an app-server exit mid-turn ends nothing, so the bridge ends the turn cut by the crash", async () => { assert.deepStrictEqual( await record( From e02a554a2b9ef980d7312e784bd9a6fde5ca29e6 Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:41:26 +0200 Subject: [PATCH 2/4] fix(engine): tell the agent a person's Stop stopped its background work A Stop after the turn closed the session and recorded each helper and job stopped, but nothing told the agent: a Stop's close had no words in the lost-work note, and the note was suppressed after a person's Stop. Milo later called the Stop "the restart". Work a person's Stop ended (its session's close, the driver's own stopped report as the session goes, or a Stop on the running turn) is recorded stopped and noted "stopped by the person". That note is held for the next message in a wake of its own: it never fires or is released to a run of its own, since the person stopped the work on purpose. The restart and exit notes keep their wake and behaviour. --- apps/server/src/engine/domain/decide.test.ts | 69 ++++++++++++++-- apps/server/src/engine/domain/decide.ts | 86 ++++++++++++++++---- 2 files changed, 135 insertions(+), 20 deletions(-) diff --git a/apps/server/src/engine/domain/decide.test.ts b/apps/server/src/engine/domain/decide.test.ts index 1e9ed1888..391836bb3 100644 --- a/apps/server/src/engine/domain/decide.test.ts +++ b/apps/server/src/engine/domain/decide.test.ts @@ -1788,6 +1788,11 @@ describe("decide: a signal delivered again changes nothing", () => { }); }); +/** Lost-work notes that would wake the Mate on their own: one held for a message never does. */ +const dueLostWork = ( + wakes: ReadonlyArray<{ readonly kind: string; readonly dueAt: number | null }>, +) => wakes.filter((wake) => wake.kind === "lost-work" && wake.dueAt !== Number.MAX_SAFE_INTEGER); + describe("decide: helpers and jobs are items under their run", () => { const work = ( status: Extract["status"], @@ -1850,7 +1855,7 @@ describe("decide: helpers and jobs are items under their run", () => { expect(workClosed(log)).toEqual([`${r(1)}:stopped`]); expect(state.items).toEqual({}); expect(state.session).toBeNull(); - expect(Object.values(state.wakes).map((wake) => wake.kind)).not.toContain("lost-work"); + expect(dueLostWork(Object.values(state.wakes))).toEqual([]); }); it("a second Stop while the helpers' session closes is already asked", () => { const scene = play([...proofRunning, work("running"), turnEnded, stop(), stop()]); @@ -2791,7 +2796,7 @@ describe("background work its session lost", () => { plural ? `Your background work ${titles} were stopped ${how} before they reported.` : `Your background work ${titles} was stopped ${how} before it reported.`; - const upserted = (key: string, title: string, status: "running" | "lost", n = 1) => + const upserted = (key: string, title: string, status: "running" | "lost" | "stopped", n = 1) => ({ kind: "work-upserted", work: key, @@ -2949,11 +2954,65 @@ describe("background work its session lost", () => { expect(log.some((event) => event._tag === "ItemClosed" && event.body.kind === "work")).toBe( true, ); - expect(log.some((event) => event._tag === "WakeArmed" && event.kind === "lost-work")).toBe( - false, - ); + expect( + dueLostWork(log.flatMap((event) => (event._tag === "WakeArmed" ? [event] : []))), + ).toEqual([]); }); + // Milo, 2026-10-09: the person stopped two helpers and a job living on after the turn; nothing + // told Milo, who later called the Stop "the restart". + const HELPER = "Watch the api build"; + const JOB = "Tail the worker log"; + const stoppedNote = lostNote(`“${HELPER}” and “${JOB}”`, "by the person", true); + const twoLive = [...running, work("w1", HELPER), work("w2", JOB)]; + const stoppedSignals = signal(upserted("w1", HELPER, "stopped"), upserted("w2", JOB, "stopped")); + it.each([ + { + name: "a Stop after the turn, by its session's close", + steps: [...twoLive, ended(1), stop(), sessionClosed()], + reopens: true, + }, + { + name: "a Stop after the turn, by the driver's word that each stopped", + steps: [...twoLive, ended(1), stop(), stoppedSignals, sessionClosed()], + reopens: true, + }, + { + name: "a Stop on the running turn whose close took the work", + steps: [ + ...twoLive, + stop(), + stoppedSignals, + ended(1, { kind: "interrupted" }, "stop-confirmed"), + ], + reopens: false, + }, + ])( + "work a person's Stop ended reaches the agent with the next message, stopped by the person: $name", + ({ steps, reopens }) => { + const { log, state } = playAll(steps); + expect( + log.flatMap((e) => + e._tag === "ItemClosed" && e.body.kind === "work" ? [e.body.status] : [], + ), + ).toEqual(["stopped", "stopped"]); + // Nothing runs for it on its own: the person stopped the work, and the agent learns it when + // someone writes again. + expect(playAll([...steps, fired("lost-work", "note")]).state.runs[r(2)]).toBeUndefined(); + expect(state.runs[r(2)]).toBeUndefined(); + const next = [ + ...steps, + send("carry on"), + prepared(2), + ...(reopens ? [opened(2, { session: "s2" })] : []), + ]; + expect(sendText(play(next))).toEqual([`${stoppedNote}\n\ncarry on`]); + expect( + Object.values(play([...next, sent(2)]).state.wakes).map((wake) => wake.kind), + ).not.toContain("lost-work"); + }, + ); + it("a session change that took the work tells the Mate with the message that changed it", () => { const steps = [ ...backgrounded, diff --git a/apps/server/src/engine/domain/decide.ts b/apps/server/src/engine/domain/decide.ts index 94a6d68c8..9de26eccb 100644 --- a/apps/server/src/engine/domain/decide.ts +++ b/apps/server/src/engine/domain/decide.ts @@ -480,18 +480,40 @@ interface LostWork { readonly how: string | undefined; } +/** How the note says a person's Stop ended the work: never a restart (Milo called it one). */ +const BY_THE_PERSON = "by the person"; + /** How the note says the work's session went, by why it closed; a close not listed tells nothing. */ const LOST_HOW: Partial> = { restart: "by a restart", exited: "when its session ended", model: "by a session change", settings: "by a session change", + stop: BY_THE_PERSON, }; /** The wake that tells the Mate its background work was lost: one per conversation. */ const lostWorkWakeId = (b: StepBuilder) => deriveWakeId(b.state.conversationId, "lost-work", "note"); +/** + * The note that tells the Mate a person's Stop ended its background work: held for the next + * message, never released to a run of its own — the person stopped the work on purpose. + */ +const stoppedWorkWakeId = (b: StepBuilder) => + deriveWakeId(b.state.conversationId, "lost-work", "stopped"); + +/** The lost-work notes waiting for the next message: the person's Stop's first. */ +const workNotes = (b: StepBuilder) => + [b.state.wakes[stoppedWorkWakeId(b)], b.state.wakes[lostWorkWakeId(b)]].filter( + (wake): wake is NonNullable => wake !== undefined && wake.text !== null, + ); + +/** A person's Stop is in force: the close it asked, or the latest run it stopped. */ +const personStopped = (state: ConversationState): boolean => + state.closing?.reason === "stop" || + (state.latestRunId !== null && state.runs[state.latestRunId]?.stopAsked != null); + /** A lost-work note held for the next send: it never fires on its own. */ const HELD_FOR_SEND = Number.MAX_SAFE_INTEGER; @@ -522,8 +544,14 @@ const runPending = (state: ConversationState): boolean => { * or the person chose (idle, signed out, a Stop's close). */ const noteLostWork = (b: StepBuilder): void => { - const lost = b.lost.splice(0).filter((work) => work.how !== undefined); - if (lost.length === 0 || b.state.archived) return; + const all = b.lost.splice(0).filter((work) => work.how !== undefined); + if (all.length === 0 || b.state.archived) return; + noteStoppedWork( + b, + all.filter((work) => work.how === BY_THE_PERSON), + ); + const lost = all.filter((work) => work.how !== BY_THE_PERSON); + if (lost.length === 0) return; const latest = b.state.latestRunId === null ? undefined : b.state.runs[b.state.latestRunId]; if (latest?.stopAsked != null) return; // A crew's run, or any in a crewmate's chat, is its crew's to carry on: no turn of the engine's. @@ -552,15 +580,38 @@ const noteLostWork = (b: StepBuilder): void => { }); }; +/** Work a person's Stop ended, told with the next message the agent gets. */ +const noteStoppedWork = (b: StepBuilder, stopped: ReadonlyArray): void => { + if (stopped.length === 0) return; + const text = lostWorkText( + stopped.map((work) => work.title), + BY_THE_PERSON, + ); + const armed = b.state.wakes[stoppedWorkWakeId(b)]; + const latest = b.state.latestRunId === null ? undefined : b.state.runs[b.state.latestRunId]; + b.emit({ + _tag: "WakeArmed", + wakeId: stoppedWorkWakeId(b), + kind: "lost-work", + dueAt: HELD_FOR_SEND, + cron: null, + principal: latest?.principal ?? ENGINE, + joins: null, + text: armed?.text == null ? text : `${armed.text} ${text}`, + }); +}; + /** - * The send's words with a lost-work note waiting for it, which go with them. The note stays held + * The send's words with the lost-work notes waiting for it, which go with them. A note stays held * until the run starts (`spendLostWorkNote`): a send cut or refused goes again with it. */ const withLostWorkNote = (b: StepBuilder, run: RunRecord): string => { - const wake = b.state.wakes[lostWorkWakeId(b)]; - if (wake === undefined || wake.text === null || run.maintenance) return run.text; - if (wake.dueAt !== HELD_FOR_SEND) rearmLostWork(b, wake, HELD_FOR_SEND); - return `${wake.text}\n\n${run.text}`; + const notes = workNotes(b); + if (notes.length === 0 || run.maintenance) return run.text; + for (const wake of notes) { + if (wake.dueAt !== HELD_FOR_SEND) rearmLostWork(b, wake, HELD_FOR_SEND); + } + return `${notes.map((wake) => wake.text).join(" ")}\n\n${run.text}`; }; const rearmLostWork = ( @@ -582,9 +633,10 @@ const rearmLostWork = ( /** A run that carried the note started: the agent has it. A self turn carried nothing. */ const spendLostWorkNote = (b: StepBuilder, run: RunRecord): void => { - const wake = b.state.wakes[lostWorkWakeId(b)]; - if (wake === undefined || run.maintenance || !needsPrepare(run)) return; - b.emit({ _tag: "WakeCancelled", wakeId: wake.id, reason: "went with the run's message" }); + if (run.maintenance || !needsPrepare(run)) return; + for (const wake of workNotes(b)) { + b.emit({ _tag: "WakeCancelled", wakeId: wake.id, reason: "went with the run's message" }); + } }; /** A note held for a message that will not go any more fires on its own. */ @@ -1359,6 +1411,8 @@ const wakeFired = ( return; } case "lost-work": { + // The note of a person's Stop only ever goes with a message. + if (id === stoppedWorkWakeId(b)) return; if (b.state.archived || crewCarriesOn(b.state, wake)) return; // A message waiting to go carries the note: held for it, never a run of its own. if (runPending(b.state)) return rearmLostWork(b, wake, HELD_FOR_SEND); @@ -1883,13 +1937,15 @@ const signalOne = (b: StepBuilder, sessionId: SessionId, signal: ProviderSignal) return; } case "work-upserted": { - // Work a person's Stop cut with its session was stopped, not lost. - const stopped = signal.status === "lost" && b.state.closing?.reason === "stop"; + // Work a person's Stop ended (its session's close cut it, or the driver stopped it as the + // session went) was stopped by the person, not lost. + const byPerson = + (signal.status === "lost" || signal.status === "stopped") && personStopped(b.state); const body: ItemBody = { kind: "work", work: signal.work, workKind: signal.workKind, - status: stopped ? "stopped" : signal.status, + status: byPerson ? "stopped" : signal.status, title: signal.title ?? null, }; const ends = WORK_ENDED.has(signal.status); @@ -1901,11 +1957,11 @@ const signalOne = (b: StepBuilder, sessionId: SessionId, signal: ProviderSignal) b.emit({ _tag: "ItemClosed", runId: open.runId, itemId: open.id, body }); // The bridge's word that the work's session is closing: asked, for the reason asked; // else it died. - if (body.status === "lost") { + if (byPerson || body.status === "lost") { b.lost.push({ title: body.title, runId: open.runId, - how: LOST_HOW[b.state.closing?.reason ?? "exited"], + how: byPerson ? BY_THE_PERSON : LOST_HOW[b.state.closing?.reason ?? "exited"], }); } // A run held for a setting the live work stood against goes now. From 4cad905e78c04bc0391b8debb2a65e2303d6c30b Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:41:27 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(web):=20Stopping=E2=80=A6=20belongs=20t?= =?UTF-8?q?o=20the=20turn=20its=20Stop=20was=20pressed=20under?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milo's stress run 3 (B +6:06.6): "Stopping…" showed under a later turn's wait on its helpers with no Stop pressed. The background Stop's flag cleared only when the after-turn liveness turned null. The Stop at +4:12.4 was pressed as wake 66 took over: the liveness was already null, never changed, and the flag lived on to the next wait's Stop button. The press now records the turn it was pressed under, and "Stopping…" shows only under that turn. --- apps/web/src/components/ChatView.tsx | 17 +++++++--- .../chat/backgroundStop.logic.test.ts | 31 +++++++++++++++++++ .../components/chat/backgroundStop.logic.ts | 13 ++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/chat/backgroundStop.logic.test.ts create mode 100644 apps/web/src/components/chat/backgroundStop.logic.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 28e64f4b5..e580ebd0b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -16,6 +16,7 @@ import { useQuestionAttachments } from "./chat/useQuestionAttachments"; import { vaultNote } from "@t3tools/client-runtime/data"; import { SurfaceLoading } from "./SurfaceLoading"; import { crewCardOf, timelineEntryTurnId } from "./chat/conversation.logic"; +import { type BackgroundStopPress, backgroundStopShows } from "./chat/backgroundStop.logic"; import { useStandupsDone } from "../zerops/activity/useStandupReading"; import { mateLimitAtom } from "@t3tools/client-runtime/data"; import { useThreadModelSelection } from "../zerops/useThreadModelSelection"; @@ -5197,22 +5198,28 @@ export default function ChatView(props: ChatViewProps) { [shellTaskKey, shellLiveness, isWorking, engineConversation], ); const liveJobs = useLiveJobs(liveJobsNow); - const [isStoppingBackgroundWork, setIsStoppingBackgroundWork] = useState(false); + const [backgroundStopPress, setBackgroundStopPress] = useState(null); + // "Stopping..." belongs to the turn the Stop was pressed under: a press as a wake took over + // never saw the liveness clear, and read "Stopping..." under a later turn's wait. + const isStoppingBackgroundWork = backgroundStopShows( + backgroundStopPress, + activeLatestTurn?.turnId ?? null, + ); useEffect(() => { // "Stopping..." holds until the liveness clears; the interrupt command // returning only means the request was accepted. if (activeBackgroundLiveness === null) { - setIsStoppingBackgroundWork(false); + setBackgroundStopPress(null); } }, [activeBackgroundLiveness]); useEffect(() => { // Per-thread state: switching threads while A's stop is pending must not // disable B's Stop button (review finding). - setIsStoppingBackgroundWork(false); + setBackgroundStopPress(null); }, [activeThreadId]); const handleStopBackgroundWork = useCallback(async () => { if (!activeThread) return; - setIsStoppingBackgroundWork(true); + setBackgroundStopPress({ turnId: activeThread.latestTurn?.turnId ?? null }); const result = await interruptThreadTurn({ environmentId, input: buildThreadTurnInterruptInput(activeThread), @@ -5221,7 +5228,7 @@ export default function ChatView(props: ChatViewProps) { // Every failure clears the pending state — an interrupted command // never reached the server, so liveness would hold "Stopping..." // forever. Only real failures toast. - setIsStoppingBackgroundWork(false); + setBackgroundStopPress(null); if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setThreadError( diff --git a/apps/web/src/components/chat/backgroundStop.logic.test.ts b/apps/web/src/components/chat/backgroundStop.logic.test.ts new file mode 100644 index 000000000..58c21ffcf --- /dev/null +++ b/apps/web/src/components/chat/backgroundStop.logic.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { backgroundStopShows } from "./backgroundStop.logic"; + +// Milo, 2026-10-09: a Stop pressed on one turn's wait for its helpers read "Stopping…" again under +// a later turn's wait, with no Stop pressed. +describe("the background work's Stop", () => { + it.each([ + { name: "nothing pressed reads Stop", press: null, turnId: "r/68", stopping: false }, + { + name: "a Stop reads Stopping… while the turn it was pressed under still waits", + press: { turnId: "r/65" }, + turnId: "r/65", + stopping: true, + }, + { + name: "a later turn's wait reads Stop, not the earlier Stop's Stopping…", + press: { turnId: "r/65" }, + turnId: "r/68", + stopping: false, + }, + { + name: "a Stop pressed before any turn reads Stopping… until one starts", + press: { turnId: null }, + turnId: null, + stopping: true, + }, + ])("$name", ({ press, turnId, stopping }) => { + expect(backgroundStopShows(press, turnId)).toBe(stopping); + }); +}); diff --git a/apps/web/src/components/chat/backgroundStop.logic.ts b/apps/web/src/components/chat/backgroundStop.logic.ts new file mode 100644 index 000000000..2b59a1b7c --- /dev/null +++ b/apps/web/src/components/chat/backgroundStop.logic.ts @@ -0,0 +1,13 @@ +/** A press of the background work's Stop, and the turn it was pressed under. */ +export interface BackgroundStopPress { + readonly turnId: string | null; +} + +/** + * Whether the background work's Stop reads "Stopping…": only under the turn it was pressed + * under. A later turn's wait on its helpers is its own, with its own Stop. + */ +export const backgroundStopShows = ( + press: BackgroundStopPress | null, + turnId: string | null, +): boolean => press !== null && press.turnId === turnId; From f7d971a9bd8f8b16d6b9aefb56887170f951336b Mon Sep 17 00:00:00 2001 From: Ales Rechtorik Date: Fri, 9 Oct 2026 21:44:59 +0200 Subject: [PATCH 4/4] test(engine): the running engine's Stop after the turn tells the next message The certified pump scene pinned the next send as the bare text; with the person's-Stop note it carries the note first. --- apps/server/src/engine/engine.pump.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/engine/engine.pump.test.ts b/apps/server/src/engine/engine.pump.test.ts index 4c9094555..8ee882f30 100644 --- a/apps/server/src/engine/engine.pump.test.ts +++ b/apps/server/src/engine/engine.pump.test.ts @@ -989,8 +989,15 @@ describe("the running engine", () => { ); yield* w.advance(0); assert.isUndefined(yield* w.run(r(2))); + // The next message tells the agent who stopped its work: never read as a restart. yield* send(w, "again"); - assert.strictEqual(w.provider.calls.at(-1), sendLine(w, "again")); + assert.strictEqual( + w.provider.calls.at(-1), + sendLine( + w, + "Your background work “Watch the build” and “Watch the build” were stopped by the person before they reported.\n\nagain", + ), + ); yield* w.shutdown; }), ),