Skip to content

Commit 43e25fe

Browse files
committed
Age out stale running sessions and recover leftover run state
1 parent b3b1233 commit 43e25fe

11 files changed

Lines changed: 706 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- Stale `running` sessions age to `interrupted` after two missed 5-minute
19+
heartbeats, leftover newer parseable `run.json.*.tmp` files recover by mtime
20+
over a stale `run.json`, and resume persists `interrupted` before reopening
21+
as `running`. Signals stay `failed`; missing or unreadable state stays
22+
`crashed`.
23+
1624
## [0.3.20] - 2026-09-10
1725

1826
### Added

docs/TUI.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,7 @@ in `mouse-reporting-disabled.test.ts` for both `runListModal` and
540540
click-to-expand or drag-to-scroll, so leaving mouse reporting off lets the
541541
terminal's own text selection and copy work by default, with no Alt+M dance
542542
required. The resume picker lists the 10 most recently persisted sessions
543-
for this checkout — completed, failed, and crashed included. Recency is
543+
for this checkout — completed, failed, crashed, and interrupted included. Recency is
544544
the last write to `run.json`, not start time. Type to filter by name
545545
(printable keys claim the `>` row, same as the model picker); `--force`
546546
is not a list filter.

src/exec/runner.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import {
7575
syncRunStateHandle,
7676
type RunStateHandle,
7777
} from "../session/active-run.js";
78+
import { startRunHeartbeat } from "../session/run-liveness.js";
7879
import {
7980
setActiveDisposeHost,
8081
clearActiveDisposeHost,
@@ -389,13 +390,18 @@ export async function runExec(config: Config): Promise<ExecResult> {
389390
turnsUsed: 0,
390391
model: `${config.providerName}:${config.model}`,
391392
};
393+
let stopHeartbeat: (() => void) | undefined;
392394

393395
const persist = async (
394396
status: "running" | "done" | "failed" | "cancelled",
395397
extra?: { error?: string },
396398
): Promise<void> => {
397399
if (finalized && status === "running") return;
398-
if (status !== "running") finalized = true;
400+
if (status !== "running") {
401+
finalized = true;
402+
stopHeartbeat?.();
403+
stopHeartbeat = undefined;
404+
}
399405
const model = `${config.providerName}:${config.model}`;
400406
const nextTurnsUsed = runSink?.getTurnCount() ?? turnsUsed;
401407
syncRunStateHandle(activeRunHandle, {
@@ -437,6 +443,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
437443
};
438444

439445
await persist("running");
446+
stopHeartbeat = startRunHeartbeat({
447+
shouldTick: () => !finalized,
448+
tick: () => persist("running"),
449+
});
440450

441451
try {
442452
// Pricing seed is optional for exec; continue without rates rather than fail the run.

src/session/list-sessions.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,3 +275,19 @@ test("listSessions reports updatedAt from run.json mtime", async () => {
275275
expect(row?.updatedAt).toBeGreaterThanOrEqual(stamp - 2000);
276276
expect(row?.updatedAt).toBeLessThanOrEqual(stamp + 2000);
277277
});
278+
279+
test("listSessions ages a stale running session to interrupted", async () => {
280+
const sessionId = generateSessionId();
281+
const runPath = await writeRun(sessionId, {
282+
status: "running",
283+
task: "stale live",
284+
startedAt: 1,
285+
});
286+
const oldSec = Math.floor(Date.now() / 1000) - 20 * 60;
287+
await utimes(runPath, oldSec, oldSec);
288+
289+
const listed = await listSessions(cwd, home);
290+
const row = listed.find((s) => s.sessionId === sessionId);
291+
expect(row?.status).toBe("interrupted");
292+
expect(row?.status).not.toBe("running");
293+
});

src/session/run-liveness.test.ts

Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
import { mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
5+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6+
7+
import {
8+
clearActiveRun,
9+
setActiveRun,
10+
type RunStateHandle,
11+
} from "./active-run.js";
12+
import { generateSessionId, initSessionDir, sessionDir } from "./index.js";
13+
import {
14+
ageStaleRunningState,
15+
RUN_STALE_THRESHOLD_MS,
16+
startRunHeartbeat,
17+
} from "./run-liveness.js";
18+
import { loadState, saveState, type RunState } from "./state.js";
19+
20+
function baseState(over: Partial<RunState> = {}): RunState {
21+
return {
22+
status: "running",
23+
turnsUsed: 2,
24+
task: "liveness",
25+
startedAt: 1_000,
26+
model: "test:model",
27+
...over,
28+
};
29+
}
30+
31+
describe("ageStaleRunningState", () => {
32+
test("ages parseable stale running to interrupted", () => {
33+
const aged = ageStaleRunningState(baseState(), 1_000, {
34+
nowMs: 1_000 + RUN_STALE_THRESHOLD_MS + 1,
35+
sessionId: "other",
36+
});
37+
expect(aged.status).toBe("interrupted");
38+
expect(aged.finishedAt).toBe(1_000);
39+
});
40+
41+
test("does not age a fresh running record", () => {
42+
const aged = ageStaleRunningState(baseState(), 1_000, {
43+
nowMs: 1_000 + RUN_STALE_THRESHOLD_MS,
44+
});
45+
expect(aged.status).toBe("running");
46+
});
47+
48+
test("does not age the active run owned by this process", () => {
49+
clearActiveRun();
50+
const sessionId = "live-session";
51+
const handle: RunStateHandle = {
52+
sessionId,
53+
cwd: "/tmp",
54+
task: "live",
55+
startedAt: 1,
56+
};
57+
setActiveRun(handle);
58+
try {
59+
const aged = ageStaleRunningState(baseState(), 1_000, {
60+
nowMs: 1_000 + RUN_STALE_THRESHOLD_MS + 1,
61+
sessionId,
62+
});
63+
expect(aged.status).toBe("running");
64+
} finally {
65+
clearActiveRun();
66+
}
67+
});
68+
});
69+
70+
describe("startRunHeartbeat", () => {
71+
test("ticks while active and stops after teardown", async () => {
72+
let ticks = 0;
73+
let active = true;
74+
const stop = startRunHeartbeat({
75+
intervalMs: 20,
76+
shouldTick: () => active,
77+
tick: () => {
78+
ticks += 1;
79+
},
80+
});
81+
await new Promise((resolve) => setTimeout(resolve, 55));
82+
expect(ticks).toBeGreaterThan(0);
83+
const beforeStop = ticks;
84+
active = false;
85+
stop();
86+
await new Promise((resolve) => setTimeout(resolve, 45));
87+
expect(ticks).toBe(beforeStop);
88+
});
89+
});
90+
91+
describe("loadState tmp recovery and stale aging", () => {
92+
let cwd = "";
93+
let home = "";
94+
95+
beforeEach(async () => {
96+
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
97+
cwd = await mkdtemp(join(tmpdir(), `corbits-liveness-cwd-${stamp}-`));
98+
home = await mkdtemp(join(tmpdir(), `corbits-liveness-home-${stamp}-`));
99+
});
100+
101+
afterEach(async () => {
102+
await rm(cwd, { recursive: true, force: true });
103+
await rm(home, { recursive: true, force: true });
104+
});
105+
106+
test("prefers a newer parseable tmp over stale run.json by mtime", async () => {
107+
const sessionId = generateSessionId();
108+
await initSessionDir(cwd, sessionId, home);
109+
const dir = sessionDir(cwd, sessionId, home);
110+
const runPath = join(dir, "run.json");
111+
await saveState(cwd, sessionId, baseState({ task: "old-on-disk" }), home);
112+
const newer = baseState({ task: "newer-tmp", turnsUsed: 9 });
113+
const tmpPath = join(dir, `run.json.${process.pid}.recovery.tmp`);
114+
await writeFile(tmpPath, JSON.stringify(newer, null, 2));
115+
const older = Math.floor(Date.now() / 1000) - 20 * 60;
116+
const newerSec = Math.floor(Date.now() / 1000);
117+
await utimes(runPath, older, older);
118+
await utimes(tmpPath, newerSec, newerSec);
119+
120+
const loaded = await loadState(cwd, sessionId, home, {
121+
persistAgeOut: false,
122+
staleThresholdMs: 10 * 60_000,
123+
});
124+
expect(loaded).toMatchObject({
125+
kind: "ok",
126+
state: { task: "newer-tmp", turnsUsed: 9 },
127+
});
128+
});
129+
130+
test("does not prefer a newer parseable tmp over a fresh run.json", async () => {
131+
const sessionId = generateSessionId();
132+
await initSessionDir(cwd, sessionId, home);
133+
const dir = sessionDir(cwd, sessionId, home);
134+
await saveState(cwd, sessionId, baseState({ task: "canonical" }), home);
135+
const tmpPath = join(dir, `run.json.${process.pid}.inflight.tmp`);
136+
await writeFile(
137+
tmpPath,
138+
JSON.stringify(baseState({ task: "in-flight", turnsUsed: 9 }), null, 2),
139+
);
140+
const runPath = join(dir, "run.json");
141+
const older = Math.floor(Date.now() / 1000) - 30;
142+
const newerSec = Math.floor(Date.now() / 1000);
143+
await utimes(runPath, older, older);
144+
await utimes(tmpPath, newerSec, newerSec);
145+
146+
const loaded = await loadState(cwd, sessionId, home, {
147+
persistAgeOut: false,
148+
});
149+
expect(loaded).toMatchObject({
150+
kind: "ok",
151+
state: { task: "canonical" },
152+
});
153+
});
154+
155+
test("does not resurrect a newer running tmp over a fresh terminal run.json", async () => {
156+
const sessionId = generateSessionId();
157+
await initSessionDir(cwd, sessionId, home);
158+
const dir = sessionDir(cwd, sessionId, home);
159+
await saveState(
160+
cwd,
161+
sessionId,
162+
baseState({ status: "done", finishedAt: 999, task: "landed" }),
163+
home,
164+
);
165+
const tmpPath = join(dir, `run.json.${process.pid}.straggler.tmp`);
166+
await writeFile(
167+
tmpPath,
168+
JSON.stringify(baseState({ task: "straggler-running" }), null, 2),
169+
);
170+
const runPath = join(dir, "run.json");
171+
const landed = Math.floor(Date.now() / 1000);
172+
await utimes(runPath, landed, landed);
173+
await utimes(tmpPath, landed + 2, landed + 2);
174+
175+
const loaded = await loadState(cwd, sessionId, home, {
176+
persistAgeOut: false,
177+
});
178+
expect(loaded).toMatchObject({
179+
kind: "ok",
180+
state: { status: "done", task: "landed" },
181+
});
182+
});
183+
184+
test("recovers a parseable tmp when run.json is missing", async () => {
185+
const sessionId = generateSessionId();
186+
await initSessionDir(cwd, sessionId, home);
187+
const dir = sessionDir(cwd, sessionId, home);
188+
const tmpPath = join(dir, `run.json.${process.pid}.orphan.tmp`);
189+
await writeFile(
190+
tmpPath,
191+
JSON.stringify(baseState({ task: "orphan-tmp", turnsUsed: 4 }), null, 2),
192+
);
193+
194+
const loaded = await loadState(cwd, sessionId, home, {
195+
persistAgeOut: false,
196+
});
197+
expect(loaded).toMatchObject({
198+
kind: "ok",
199+
state: { task: "orphan-tmp", turnsUsed: 4 },
200+
});
201+
});
202+
203+
test("does not resurrect an older or equal-mtime tmp", async () => {
204+
const sessionId = generateSessionId();
205+
await initSessionDir(cwd, sessionId, home);
206+
const dir = sessionDir(cwd, sessionId, home);
207+
await saveState(cwd, sessionId, baseState({ task: "canonical" }), home);
208+
const tmpPath = join(dir, `run.json.${process.pid}.older.tmp`);
209+
await writeFile(
210+
tmpPath,
211+
JSON.stringify(baseState({ task: "stale-tmp" }), null, 2),
212+
);
213+
const runPath = join(dir, "run.json");
214+
const stamp = Math.floor(Date.now() / 1000);
215+
await utimes(runPath, stamp, stamp);
216+
await utimes(tmpPath, stamp - 30, stamp - 30);
217+
218+
const loaded = await loadState(cwd, sessionId, home, {
219+
persistAgeOut: false,
220+
});
221+
expect(loaded).toMatchObject({
222+
kind: "ok",
223+
state: { task: "canonical" },
224+
});
225+
});
226+
227+
test("does not prefer a malformed newer tmp", async () => {
228+
const sessionId = generateSessionId();
229+
await initSessionDir(cwd, sessionId, home);
230+
const dir = sessionDir(cwd, sessionId, home);
231+
await saveState(cwd, sessionId, baseState({ task: "canonical" }), home);
232+
const tmpPath = join(dir, `run.json.${process.pid}.bad.tmp`);
233+
await writeFile(tmpPath, "{ not-json");
234+
const runPath = join(dir, "run.json");
235+
const older = Math.floor(Date.now() / 1000) - 60;
236+
const newerSec = Math.floor(Date.now() / 1000);
237+
await utimes(runPath, older, older);
238+
await utimes(tmpPath, newerSec, newerSec);
239+
240+
const loaded = await loadState(cwd, sessionId, home, {
241+
persistAgeOut: false,
242+
});
243+
expect(loaded).toMatchObject({
244+
kind: "ok",
245+
state: { task: "canonical" },
246+
});
247+
});
248+
249+
test("sweeps aged temps but keeps a fresh in-flight tmp", async () => {
250+
const sessionId = generateSessionId();
251+
await initSessionDir(cwd, sessionId, home);
252+
const dir = sessionDir(cwd, sessionId, home);
253+
await saveState(cwd, sessionId, baseState(), home);
254+
const agedTmp = join(dir, `run.json.${process.pid}.aged.tmp`);
255+
const freshTmp = join(dir, `run.json.${process.pid}.fresh.tmp`);
256+
await writeFile(agedTmp, "{");
257+
await writeFile(freshTmp, "{");
258+
const agedSec = Math.floor(Date.now() / 1000) - 20 * 60;
259+
const freshSec = Math.floor(Date.now() / 1000);
260+
await utimes(agedTmp, agedSec, agedSec);
261+
await utimes(freshTmp, freshSec, freshSec);
262+
263+
await loadState(cwd, sessionId, home, {
264+
nowMs: Date.now(),
265+
tmpSweepAgeMs: 10 * 60_000,
266+
persistAgeOut: false,
267+
});
268+
269+
const { existsSync } = await import("node:fs");
270+
expect(existsSync(agedTmp)).toBe(false);
271+
expect(existsSync(freshTmp)).toBe(true);
272+
});
273+
274+
test("ages stale running to interrupted and persists it", async () => {
275+
const sessionId = generateSessionId();
276+
await initSessionDir(cwd, sessionId, home);
277+
await saveState(cwd, sessionId, baseState(), home);
278+
const runPath = join(sessionDir(cwd, sessionId, home), "run.json");
279+
const oldSec = Math.floor(Date.now() / 1000) - 20 * 60;
280+
await utimes(runPath, oldSec, oldSec);
281+
282+
const loaded = await loadState(cwd, sessionId, home, {
283+
nowMs: Date.now(),
284+
staleThresholdMs: 10 * 60_000,
285+
});
286+
expect(loaded).toMatchObject({
287+
kind: "ok",
288+
state: { status: "interrupted", turnsUsed: 2 },
289+
});
290+
const again = await loadState(cwd, sessionId, home, {
291+
persistAgeOut: false,
292+
});
293+
expect(again).toMatchObject({
294+
kind: "ok",
295+
state: { status: "interrupted" },
296+
});
297+
});
298+
299+
test("missing and unreadable stay non-interrupted", async () => {
300+
const missingId = generateSessionId();
301+
expect(await loadState(cwd, missingId, home)).toEqual({ kind: "missing" });
302+
303+
const badId = generateSessionId();
304+
await initSessionDir(cwd, badId, home);
305+
await writeFile(
306+
join(sessionDir(cwd, badId, home), "run.json"),
307+
"{ turnsUsed",
308+
);
309+
expect(await loadState(cwd, badId, home)).toEqual({ kind: "unreadable" });
310+
});
311+
});

0 commit comments

Comments
 (0)