Skip to content

Commit 42a44cd

Browse files
committed
Distinguish missing and unreadable session state on load
loadState now returns a tagged result so callers can tell a missing run.json from a present but unreadable one without restatting the file. Unreadable resume-by-id prints one recovery line; parse diagnostics stay in the structured log.
1 parent ed630ab commit 42a44cd

15 files changed

Lines changed: 341 additions & 103 deletions

File tree

CHANGELOG.md

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

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- Failed sessions with an `error` string in `run.json` are valid resume
19+
candidates, not corrupt files. A truly unreadable session id prints one
20+
recovery line; parse diagnostics go to the structured log, not the
21+
terminal.
22+
1623
## [0.3.10] - 2026-08-30
1724

1825
### Fixed

docs/PRODUCT.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ Opens a picker of saved conversations for the working directory. Plain
8080
`corbits` always starts a fresh conversation; `corbits resume <session-id>`
8181
is the direct, explicit resume path.
8282

83+
A session that ended in `failed` (including one that recorded an `error`
84+
string in `run.json`) is a failed session, not a corrupt one — it still
85+
appears in the picker. Passing a corrupt session id prints one short
86+
recovery line instead of dumping the file path and parse details.
87+
8388
## Safety Model
8489

8590
- **Tiered permission gate** — Read-only tools (`read_file`, `search_files`, `grep`, `list_dir`) run freely. Every consequential tool (`write_file`, `edit_file`, `run_shell`, …) is gated. The operator can Allow Once or Allow Always (scoped to a file, a directory, or a command shape); "Allow Always" choices persist per working directory so repeat actions don't interrupt flow.
@@ -128,7 +133,9 @@ The exact turn thresholds are model-family-dependent (tighter for models with ob
128133

129134
**What the user sees:** `Ctrl+C` mid-run, network error, or crash. The last state is persisted.
130135

131-
**Recovery:** `corbits resume` reloads `RunState` and continues.
136+
**Recovery:** `corbits resume` reloads `RunState` and continues. Failed
137+
sessions remain failed (still listed); a corrupt id gets a short recovery
138+
line instead of a path dump.
132139

133140
## Configuration
134141

src/config.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
buildProviderCatalog,
1111
catalogEntryAsProviderSettings,
1212
CliHelpError,
13+
CliUserError,
1314
CLI_HELP_TEXT,
1415
KEYLESS_API_KEY,
1516
loadConfig,
@@ -503,6 +504,58 @@ describe("loadConfig", () => {
503504
}
504505
});
505506

507+
test("resume <id> --force among failed siblings stays silent and reopens the target", async () => {
508+
const cwd = await emptyCwd();
509+
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
510+
try {
511+
const globalPath = await writeGlobalSettings(cwd);
512+
const targetId = generateSessionId();
513+
for (let i = 0; i < 6; i++) {
514+
const id = i === 0 ? targetId : generateSessionId();
515+
await initSessionDir(cwd, id, home);
516+
await saveState(
517+
cwd,
518+
id,
519+
{
520+
status: "failed",
521+
turnsUsed: 2,
522+
task: i === 0 ? "target failed session" : `sibling failed ${i}`,
523+
startedAt: Date.now() - 1_000 - i,
524+
finishedAt: Date.now() - i,
525+
error: "Cycle commit failed\nhook dump: pre-commit rejected",
526+
},
527+
home,
528+
);
529+
}
530+
531+
const chunks: string[] = [];
532+
const orig = process.stderr.write.bind(process.stderr);
533+
process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => {
534+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
535+
return orig(chunk, ...(rest as []));
536+
}) as typeof process.stderr.write;
537+
let config: Awaited<ReturnType<typeof loadConfig>>;
538+
try {
539+
config = await loadConfig(["resume", targetId, "--force", "--cwd", cwd], {
540+
globalSettingsPath: globalPath,
541+
home,
542+
});
543+
} finally {
544+
process.stderr.write = orig;
545+
}
546+
assertConfigured(config);
547+
expect(config.sessionId).toBe(targetId);
548+
expect(config.task).toBe("target failed session");
549+
const text = chunks.join("");
550+
expect(text).not.toContain("ignoring unreadable");
551+
expect(text).not.toContain(home);
552+
expect(text).not.toContain("invalid shape");
553+
} finally {
554+
await rm(cwd, { recursive: true, force: true });
555+
await rm(home, { recursive: true, force: true });
556+
}
557+
});
558+
506559
test("--resume opens the picker", async () => {
507560
const cwd = await emptyCwd();
508561
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
@@ -621,14 +674,19 @@ describe("loadConfig", () => {
621674
process.stderr.write = orig;
622675
}
623676

624-
expect(thrown).toBeInstanceOf(Error);
677+
expect(thrown).toBeInstanceOf(CliUserError);
625678
const message = thrown instanceof Error ? thrown.message : String(thrown);
626-
expect(message).toMatch(new RegExp(`session ${sessionId} is unreadable`, "i"));
679+
expect(message).toBe(
680+
`Session ${sessionId} is unreadable. Use \`corbits resume\` to choose another.`,
681+
);
627682
expect(message).not.toMatch(/No session/);
628683
expect(message).not.toContain("ignoring unreadable");
629684
expect(message).not.toContain("invalid shape");
630685
expect(message).not.toContain(home);
631686
expect(message.split("\n")).toHaveLength(1);
687+
if (thrown instanceof CliUserError) {
688+
expect(thrown.exitCode).toBe(1);
689+
}
632690
const text = chunks.join("");
633691
expect(text).not.toContain("ignoring unreadable");
634692
expect(text).not.toContain(home);

src/config/index.ts

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
1-
import { join, resolve } from "node:path";
2-
import { stat } from "node:fs/promises";
1+
import { resolve } from "node:path";
32

43
import type { InferenceSource } from "@intx/types/runtime";
5-
import {
6-
generateSessionId,
7-
isSessionId,
8-
migrateLegacySessionIfNeeded,
9-
sessionDir,
10-
} from "../session/index.js";
4+
import { generateSessionId, isSessionId, migrateLegacySessionIfNeeded } from "../session/index.js";
115
import { loadState } from "../session/state.js";
126
import { COMMAND_NAME } from "../branding.js";
137

@@ -532,6 +526,19 @@ export class CliHelpError extends Error {
532526
}
533527
}
534528

529+
/**
530+
* Thrown for a recoverable operator mistake. Entry points must print
531+
* `message` to stderr and exit 1 — not dump a stack.
532+
*/
533+
export class CliUserError extends Error {
534+
readonly exitCode = 1 as const;
535+
536+
constructor(message: string) {
537+
super(message);
538+
this.name = "CliUserError";
539+
}
540+
}
541+
535542
export interface LoadConfigOptions {
536543
// Override the global settings file location (for tests / non-standard homes).
537544
globalSettingsPath?: string;
@@ -845,24 +852,18 @@ export async function loadConfig(
845852
} else if (resumeMode === "id") {
846853
const id = resumeSessionId!;
847854
await migrateLegacySessionIfNeeded(cwd, id, options.home);
848-
const state = await loadState(cwd, id, options.home);
849-
if (state === null) {
850-
let runJsonPresent = false;
851-
try {
852-
await stat(join(sessionDir(cwd, id, options.home), "run.json"));
853-
runJsonPresent = true;
854-
} catch {
855-
// Missing run.json: treat as no session for this project.
856-
}
857-
if (runJsonPresent) {
858-
throw new Error(
859-
`Session ${id} is unreadable. Use \`${COMMAND_NAME} resume\` to choose another.`,
860-
);
861-
}
855+
const loaded = await loadState(cwd, id, options.home);
856+
if (loaded.kind === "unreadable") {
857+
throw new CliUserError(
858+
`Session ${id} is unreadable. Use \`${COMMAND_NAME} resume\` to choose another.`,
859+
);
860+
}
861+
if (loaded.kind === "missing") {
862862
throw new Error(
863863
`No session ${id} for this project. Sessions are stored under ~/.corbits/projects/<project-key>/ (this checkout's git toplevel). Use \`${COMMAND_NAME} resume\` to choose one.`,
864864
);
865865
}
866+
const state = loaded.state;
866867
sessionId = id;
867868
skipInitialTask = true;
868869
if (task.length === 0) resumeTask = state.task;

src/index.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { primeCrashReporting, writeCrashReport, type CrashKind } from "./crash/r
44
import { getActiveRun, markCrashed } from "./session/active-run.js";
55
import { getActiveDisposeHost } from "./session/active-host.js";
66
import { saveCrashState } from "./session/state.js";
7-
import { loadConfig, CliHelpError } from "./config/index.js";
7+
import { loadConfig, CliHelpError, CliUserError } from "./config/index.js";
88
import { ensureTelemetrySettings, globalSettingsPath } from "./config/settings.js";
99
import { installFileLogSink } from "./logging/sink.js";
1010
import { flushPerfToOtel } from "./perf/index.js";
@@ -286,6 +286,24 @@ export function installSignalHandlers(): void {
286286
}
287287
}
288288

289+
export function cliCaughtExit(err: unknown): {
290+
stream: "stdout" | "stderr";
291+
text: string;
292+
code: number;
293+
} {
294+
if (err instanceof CliHelpError) {
295+
return { stream: "stdout", text: `${err.message}\n`, code: err.exitCode };
296+
}
297+
if (err instanceof CliUserError) {
298+
return { stream: "stderr", text: `${err.message}\n`, code: err.exitCode };
299+
}
300+
return {
301+
stream: "stderr",
302+
text: `${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`,
303+
code: 1,
304+
};
305+
}
306+
289307
if (import.meta.main) {
290308
installCrashHandlers();
291309
installSignalHandlers();
@@ -294,14 +312,10 @@ if (import.meta.main) {
294312
try {
295313
code = await main(process.argv.slice(2));
296314
} catch (err: unknown) {
297-
// Help is an intentional early exit, not a crash — stdout + 0.
298-
if (err instanceof CliHelpError) {
299-
process.stdout.write(`${err.message}\n`);
300-
code = err.exitCode;
301-
} else {
302-
process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
303-
code = 1;
304-
}
315+
const exit = cliCaughtExit(err);
316+
const dest = exit.stream === "stdout" ? process.stdout : process.stderr;
317+
dest.write(exit.text);
318+
code = exit.code;
305319
}
306320
process.exit(code);
307321
}

src/session/index.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -240,28 +240,23 @@ export async function listSessions(
240240
const summaries: SessionSummary[] = [];
241241
for (const entry of entries) {
242242
await migrateLegacySessionIfNeeded(cwd, entry, home);
243-
const state = await loadState(cwd, entry, home);
244-
if (state !== null) {
243+
const loaded = await loadState(cwd, entry, home);
244+
if (loaded.kind === "ok") {
245245
summaries.push({
246246
sessionId: entry,
247-
task: state.task,
248-
startedAt: state.startedAt,
249-
status: state.status,
247+
task: loaded.state.task,
248+
startedAt: loaded.state.startedAt,
249+
status: loaded.state.status,
250250
});
251251
continue;
252252
}
253-
// Present but unreadable run.json is not a picker candidate — diagnostics
254-
// already went to the structured log from loadState.
255-
try {
256-
await stat(join(sessionDir(cwd, entry, home), "run.json"));
253+
if (loaded.kind === "unreadable") {
257254
continue;
258-
} catch {
259-
// Missing run.json: fall through to the context-only crashed fallback.
260255
}
261-
// A session directory with context/ but no readable run.json never
262-
// reached its first saveState call (see src/tui/runner.ts's early
263-
// "running" write) and therefore isn't actually running: report it as
264-
// crashed rather than fabricating liveness.
256+
// Missing run.json: a session directory with context/ never reached its
257+
// first saveState call (see src/tui/runner.ts's early "running" write)
258+
// and therefore isn't actually running: report it as crashed rather
259+
// than fabricating liveness.
265260
try {
266261
const dirStat = await stat(sessionDir(cwd, entry, home));
267262
await stat(sessionContextDir(cwd, entry, home));
@@ -298,7 +293,7 @@ export async function renameSession(
298293
}
299294
await migrateLegacySessionIfNeeded(cwd, sessionId, home);
300295
const existing = await loadState(cwd, sessionId, home);
301-
if (existing === null) {
296+
if (existing.kind !== "ok") {
302297
let startedAt = Date.now();
303298
try {
304299
const dirStat = await stat(sessionDir(cwd, sessionId, home));
@@ -319,7 +314,7 @@ export async function renameSession(
319314
);
320315
return;
321316
}
322-
await saveState(cwd, sessionId, { ...existing, task: trimmed }, home);
317+
await saveState(cwd, sessionId, { ...existing.state, task: trimmed }, home);
323318
}
324319

325320
export { projectKeyFor, projectSessionsRoot, projectsRoot, projectRootFor } from "./project-key.js";

src/session/list-sessions.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,83 @@ test("listSessions stays silent when many sibling run.json files are unreadable"
134134
expect(text).not.toContain("ignoring unreadable");
135135
expect(text).not.toContain(home);
136136
});
137+
138+
test("listSessions includes a failed run that recorded an error", async () => {
139+
const sessionId = generateSessionId();
140+
await initSessionDir(cwd, sessionId, home);
141+
await writeFile(
142+
join(sessionDir(cwd, sessionId, home), "run.json"),
143+
JSON.stringify({
144+
status: "failed",
145+
turnsUsed: 2,
146+
task: "failed work",
147+
startedAt: 1_700_000_000_000,
148+
finishedAt: 1_700_000_005_000,
149+
error: "Cycle commit failed\nhook dump: pre-commit rejected",
150+
}),
151+
);
152+
const listed = await listSessions(cwd, home);
153+
const row = listed.find((s) => s.sessionId === sessionId);
154+
expect(row?.status).toBe("failed");
155+
expect(row?.task).toBe("failed work");
156+
});
157+
158+
test("listSessions includes a crashed run that recorded an error", async () => {
159+
const sessionId = generateSessionId();
160+
await initSessionDir(cwd, sessionId, home);
161+
await writeFile(
162+
join(sessionDir(cwd, sessionId, home), "run.json"),
163+
JSON.stringify({
164+
status: "crashed",
165+
turnsUsed: 1,
166+
task: "crashed work",
167+
startedAt: 1_700_000_000_000,
168+
finishedAt: 1_700_000_005_000,
169+
error: "uncaughtException: boom",
170+
}),
171+
);
172+
const listed = await listSessions(cwd, home);
173+
const row = listed.find((s) => s.sessionId === sessionId);
174+
expect(row?.status).toBe("crashed");
175+
expect(row?.task).toBe("crashed work");
176+
});
177+
178+
test("listSessions stays silent when many sibling runs failed with an error", async () => {
179+
const ids: string[] = [];
180+
for (let i = 0; i < 8; i++) {
181+
const id = generateSessionId();
182+
ids.push(id);
183+
await initSessionDir(cwd, id, home);
184+
await writeFile(
185+
join(sessionDir(cwd, id, home), "run.json"),
186+
JSON.stringify({
187+
status: "failed",
188+
turnsUsed: 1,
189+
task: `failed ${i}`,
190+
startedAt: 1_700_000_000_000 + i,
191+
finishedAt: 1_700_000_005_000 + i,
192+
error: "Cycle commit failed\nhook dump",
193+
}),
194+
);
195+
}
196+
197+
const chunks: string[] = [];
198+
const orig = process.stderr.write.bind(process.stderr);
199+
process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => {
200+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
201+
return orig(chunk, ...(rest as []));
202+
}) as typeof process.stderr.write;
203+
let listed: Awaited<ReturnType<typeof listSessions>> = [];
204+
try {
205+
listed = await listSessions(cwd, home);
206+
} finally {
207+
process.stderr.write = orig;
208+
}
209+
210+
expect(listed.map((s) => s.sessionId).sort()).toEqual([...ids].sort());
211+
expect(listed.every((s) => s.status === "failed")).toBe(true);
212+
const text = chunks.join("");
213+
expect(text).not.toContain("ignoring unreadable");
214+
expect(text).not.toContain(home);
215+
expect(text).not.toContain("invalid shape");
216+
});

0 commit comments

Comments
 (0)