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( diff --git a/apps/server/src/engine/domain/decide.test.ts b/apps/server/src/engine/domain/decide.test.ts index 297a1ac43..ad847ce7e 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()]); @@ -2940,7 +2945,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, @@ -3098,11 +3103,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 d62ba02e8..945a0b2eb 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. */ @@ -1360,6 +1412,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); @@ -1903,13 +1957,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); @@ -1921,11 +1977,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. 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; }), ), 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;