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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename

### Fixed

- Crash and signal finalizers preserve `turnsUsed` from the in-memory active-run
handle instead of writing `0`, so a signaled or crashed session keeps the
turn count already persisted by mid-run snapshots.
- Occupancy takes one dry-episode shot when the parent settles idle even if the
live fleet 1→0 edge was never observed.
- Dry-fleet transcript and `/status` report the outcome tally only
Expand Down
34 changes: 24 additions & 10 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ import {
sessionContextDir,
sessionDir,
} from "../session/index.js";
import { setActiveRun } from "../session/active-run.js";
import {
setActiveRun,
syncRunStateHandle,
type RunStateHandle,
} from "../session/active-run.js";
import {
setActiveDisposeHost,
clearActiveDisposeHost,
Expand Down Expand Up @@ -377,19 +381,35 @@ export async function runExec(config: Config): Promise<ExecResult> {
let providerFailureObserved = false;
let providerError: InferenceErrorLike | undefined;
let result: ExecResult | undefined;
const activeRunHandle: RunStateHandle = {
sessionId,
cwd: config.cwd,
task,
startedAt,
turnsUsed: 0,
model: `${config.providerName}:${config.model}`,
};

const persist = async (
status: "running" | "done" | "failed" | "cancelled",
extra?: { error?: string },
): Promise<void> => {
if (finalized && status === "running") return;
if (status !== "running") finalized = true;
const model = `${config.providerName}:${config.model}`;
const nextTurnsUsed = runSink?.getTurnCount() ?? turnsUsed;
syncRunStateHandle(activeRunHandle, {
turnsUsed: nextTurnsUsed,
task,
startedAt,
model,
});
const snapshot = {
status,
turnsUsed: runSink?.getTurnCount() ?? turnsUsed,
turnsUsed: nextTurnsUsed,
task,
startedAt,
model: `${config.providerName}:${config.model}`,
model,
mcpServers: connectedMcp,
...(status !== "running" ? { finishedAt: Date.now() } : {}),
...(extra?.error !== undefined ? { error: extra.error } : {}),
Expand All @@ -398,13 +418,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
status === "running"
? saveState(config.cwd, sessionId, snapshot).then(() => {
if (!finalized) {
setActiveRun({
sessionId,
cwd: config.cwd,
task,
startedAt,
model: `${config.providerName}:${config.model}`,
});
setActiveRun(activeRunHandle);
}
})
: finalizeRunState(config.cwd, sessionId, snapshot);
Expand Down
8 changes: 4 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ export async function handleFatal(
// (e.g. a throw inside a fire-and-forget `void` call), so run.json was never
// closed out. getActiveRun surfaces the in-flight session set by the in-flight
// runner (TUI or exec), with
// enough (task, startedAt, model) carried on the handle itself that no read
// of run.json is needed — a readFile here would be exactly the kind of
// enough (task, startedAt, model, turnsUsed) carried on the handle itself that
// no read of run.json is needed — a readFile here would be exactly the kind of
// unbounded crash-path I/O primeCrashReporting (src/crash/report.ts) exists
// to avoid for git: a stalled disk or network mount would block process.exit
// forever. The write itself goes through saveCrashState, which bypasses the
Expand All @@ -222,7 +222,7 @@ async function finalizeActiveRunOnCrash(error: unknown): Promise<void> {
try {
await saveCrashState(run.cwd, run.sessionId, {
status: "crashed",
turnsUsed: 0,
turnsUsed: run.turnsUsed,
task: run.task,
startedAt: run.startedAt,
finishedAt: Date.now(),
Expand Down Expand Up @@ -267,7 +267,7 @@ async function finalizeActiveRunOnSignal(
try {
await saveCrashState(run.cwd, run.sessionId, {
status: "failed",
turnsUsed: 0,
turnsUsed: run.turnsUsed,
task: run.task,
startedAt: run.startedAt,
finishedAt: Date.now(),
Expand Down
38 changes: 38 additions & 0 deletions src/session/active-run.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";

import {
clearActiveRun,
getActiveRun,
setActiveRun,
syncRunStateHandle,
type RunStateHandle,
} from "./active-run.js";

describe("syncRunStateHandle", () => {
test("updates turnsUsed and identity fields on the live handle", () => {
clearActiveRun();
const handle: RunStateHandle = {
sessionId: "sess",
cwd: "/tmp",
task: "old",
startedAt: 1,
turnsUsed: 0,
model: "provider:old",
};
setActiveRun(handle);

syncRunStateHandle(handle, {
turnsUsed: 7,
task: "new",
startedAt: 42,
model: "provider:new",
});

expect(getActiveRun()).toBe(handle);
expect(handle.turnsUsed).toBe(7);
expect(handle.task).toBe("new");
expect(handle.startedAt).toBe(42);
expect(handle.model).toBe("provider:new");
clearActiveRun();
});
});
25 changes: 23 additions & 2 deletions src/session/active-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
// consulted from the crash path and from signal handlers: a run that never
// crashes and is never signaled never has this read.
//
// Carries enough of the live run state (task, startedAt, model) that the
// crash handler can build a full RunState record itself. It must not read
// Carries enough of the live run state (task, startedAt, model, turnsUsed) that
// the crash handler can build a full RunState record itself. It must not read
// run.json back off disk to fill these in — an unbounded readFile on the
// crash path has the exact failure mode primeCrashReporting (src/crash/
// report.ts) exists to avoid for git: a stalled disk or network mount would
Expand All @@ -20,9 +20,30 @@ export interface RunStateHandle {
cwd: string;
task: string;
startedAt: number;
turnsUsed: number;
model?: string;
}

// Keep the crash/signal handle in step with every persisted snapshot so a
// terminal write never falls back to turnsUsed: 0 when the live run has
// already advanced past that.
export function syncRunStateHandle(
handle: RunStateHandle,
snapshot: {
turnsUsed: number;
task: string;
startedAt: number;
model?: string;
},
): void {
handle.turnsUsed = snapshot.turnsUsed;
handle.task = snapshot.task;
handle.startedAt = snapshot.startedAt;
if (snapshot.model !== undefined) {
handle.model = snapshot.model;
}
}

let activeRun: RunStateHandle | null = null;

export function setActiveRun(handle: RunStateHandle): void {
Expand Down
2 changes: 1 addition & 1 deletion src/session/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ test("a straggler snapshot started before a terminal write does not overwrite it

test("a persisted terminal status agrees with the active-run handle without a second call site", async () => {
const sessionId = "sess-terminal";
setActiveRun({ sessionId, cwd, task: "task", startedAt: 1 });
setActiveRun({ sessionId, cwd, task: "task", startedAt: 1, turnsUsed: 0 });

await finalizeRunState(
cwd,
Expand Down
16 changes: 14 additions & 2 deletions src/tui/run-snapshot-kind.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,13 @@ describe("a snapshot write dispatched by kind", () => {
});

test("a rotation still records the outgoing session but leaves the run crash-coverable", async () => {
setActiveRun({ sessionId: "old", cwd, task: "task", startedAt: 1 });
setActiveRun({
sessionId: "old",
cwd,
task: "task",
startedAt: 1,
turnsUsed: 0,
});

await write(
"old",
Expand All @@ -87,7 +93,13 @@ describe("a snapshot write dispatched by kind", () => {
});

test("the run-ending write records the session and disarms the handle", async () => {
setActiveRun({ sessionId: "last", cwd, task: "task", startedAt: 1 });
setActiveRun({
sessionId: "last",
cwd,
task: "task",
startedAt: 1,
turnsUsed: 0,
});

await write(
"last",
Expand Down
2 changes: 1 addition & 1 deletion src/tui/runner/exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function stubQuit(args: {
getToolCallCount: () => 0,
},
crashGuard: { markFinalized: () => undefined, isFinalized: () => false },
activeRunHandle: { task: "", startedAt: 0, model: "" },
activeRunHandle: { task: "", startedAt: 0, turnsUsed: 0, model: "" },
hookManager: { dispatchPostRun: async () => undefined },
liveSessionMode: "orchestrator",
} as unknown as RunnerServices;
Expand Down
34 changes: 26 additions & 8 deletions src/tui/runner/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
truncateSessionLabel,
} from "../../session/session-label.js";
import { clearActiveDisposeHost } from "../../session/active-host.js";
import { syncRunStateHandle } from "../../session/active-run.js";
import { getValidCodexToken } from "../../auth/codex/session.js";
import { getValidXaiToken } from "../../auth/xai/session.js";
import { suppressProviderFailurePresentation } from "../provider/failure-attempt.js";
Expand Down Expand Up @@ -185,12 +186,16 @@ function createRunPersistence(state: RunnerState, services: RunnerServices) {
const model = `${state.liveSource.id}:${state.liveSource.model}`;
// Kept in step with every persisted snapshot so the crash handler's copy
// (activeRunHandle, read by index.ts) never lags what's actually on disk.
services.activeRunHandle.task = task;
services.activeRunHandle.startedAt = state.startedAt;
services.activeRunHandle.model = model;
const turnsUsed = services.runSink.getTurnCount();
syncRunStateHandle(services.activeRunHandle, {
turnsUsed,
task,
startedAt: state.startedAt,
model,
});
const persisted: RunState = {
status,
turnsUsed: services.runSink.getTurnCount(),
turnsUsed,
task,
startedAt: state.startedAt,
model,
Expand Down Expand Up @@ -586,11 +591,25 @@ export async function createRunLifecycle(
"session-rotation",
);
state.sessionId = generateSessionId();
// Repointed, not cleared: the process lives on, so the crash handler
// must keep finding this handle and close out the *new* session.
services.activeRunHandle.sessionId = state.sessionId;
state.startedAt = Date.now();
state.runTaskTitle = state.config.task;
const rotatedBundle = services.buildSessionSources();
// Repointed, not cleared: the process lives on, so the crash handler
// must keep finding this handle and close out the *new* session. The
// fields it copies reseed with the repoint — a crash inside
// initSessionDir/buildAgent below would otherwise stamp the outgoing
// session's turnsUsed (and task, startedAt, model) onto a session
// that has run zero turns.
services.activeRunHandle.sessionId = state.sessionId;
syncRunStateHandle(services.activeRunHandle, {
turnsUsed: 0,
task:
state.runTaskTitle.trim().length > 0
? state.runTaskTitle.trim()
: "(conversation)",
startedAt: state.startedAt,
model: `${rotatedBundle.selected.id}:${rotatedBundle.selected.model}`,
});
services.emitter.emit(
"session.title",
state.runTaskTitle.trim().length > 0
Expand All @@ -599,7 +618,6 @@ export async function createRunLifecycle(
);
state.workdir = sessionContextDir(state.config.cwd, state.sessionId);
await initSessionDir(state.config.cwd, state.sessionId);
const rotatedBundle = services.buildSessionSources();
state.liveSources = rotatedBundle.sources;
state.liveDefaultSource = rotatedBundle.defaultSource;
state.liveSource = rotatedBundle.selected;
Expand Down
12 changes: 12 additions & 0 deletions src/tui/session-start.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";

import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
import { setActiveRun, clearActiveRun } from "../session/active-run.js";
import type { RunState } from "../session/state.js";

describe("createTUICrashGuard", () => {
Expand All @@ -20,6 +21,15 @@ describe("createTUICrashGuard", () => {
},
}),
async () => {
clearActiveRun();
setActiveRun({
sessionId: "live-session",
cwd: "/live-cwd",
task: "live task",
startedAt: 99,
turnsUsed: 4,
model: "live-provider:live-model",
});
const { createTUICrashGuard } = await import("./session-start.js");
const guard = createTUICrashGuard(() => ({
cwd: "/boot-cwd",
Expand Down Expand Up @@ -64,6 +74,8 @@ describe("createTUICrashGuard", () => {
expect(captured[0]?.state.startedAt).toBe(99);
expect(captured[0]?.state.error).toBe("boom");
expect(captured[0]?.state.model).toBe("live-provider:live-model");
expect(captured[0]?.state.turnsUsed).toBe(4);
clearActiveRun();
},
);
});
Expand Down
5 changes: 4 additions & 1 deletion src/tui/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { Telemetry } from "../telemetry/index.js";
import { clearActiveDisposeHost } from "../session/active-host.js";
import {
clearActiveRun,
getActiveRun,
setActiveRun,
type RunStateHandle,
} from "../session/active-run.js";
Expand Down Expand Up @@ -111,6 +112,7 @@ export function createTUICrashGuard(
// escaped throw during the flushPartialOnCrash await just above would
// still reach that listener with the handle live, so it's cleared here
// too to close that earlier window.
const turnsUsed = getActiveRun()?.turnsUsed ?? 0;
clearActiveRun();
clearActiveDisposeHost();
await flushPartialOnCrash().catch((flushErr: unknown) => {
Expand All @@ -129,7 +131,7 @@ export function createTUICrashGuard(
const message = err instanceof Error ? err.message : String(err);
await finalizeRunState(live.cwd, live.sessionId, {
status: "failed",
turnsUsed: 0,
turnsUsed,
task:
live.runTaskTitle.trim().length > 0
? live.runTaskTitle.trim()
Expand Down Expand Up @@ -284,6 +286,7 @@ export async function prepareTUISession(
task:
runTaskTitle.trim().length > 0 ? runTaskTitle.trim() : "(conversation)",
startedAt,
turnsUsed: resumeSeed.turnsUsed,
model: `${config.providerName}:${config.model}`,
};
setActiveRun(activeRunHandle);
Expand Down
Loading
Loading