Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions apps/server/src/engine/bridge/translate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
69 changes: 64 additions & 5 deletions apps/server/src/engine/domain/decide.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderSignal, { kind: "work-upserted" }>["status"],
Expand Down Expand Up @@ -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()]);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 71 additions & 15 deletions apps/server/src/engine/domain/decide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<SessionCloseReason, string>> = {
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<typeof wake> => 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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<LostWork>): 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 = (
Expand All @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/engine/engine.pump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
),
Expand Down
Loading
Loading