Skip to content

Commit 068eee0

Browse files
committed
Merge remote-tracking branch 'origin/main' into cl-7288-make-corbits-resume-a-named-recent-10-picker
2 parents 594c777 + 6bab08a commit 068eee0

28 files changed

Lines changed: 710 additions & 129 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2020
`--force` is no longer required to see finished threads. The picker shows
2121
the 10 most recent sessions and type-to-filter narrows that list.
2222

23+
### Fixed
24+
25+
- Failed sessions with an `error` string in `run.json` are valid resume
26+
candidates, not corrupt files. A truly unreadable session id prints one
27+
recovery line; parse diagnostics go to the structured log, not the
28+
terminal.
29+
2330
## [0.3.10] - 2026-08-30
2431

2532
### Fixed

docs/PRODUCT.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ checkout, including completed ones. Type to filter by name. Plain
8181
`corbits` always starts a fresh conversation; `corbits resume <session-id>`
8282
is the direct, explicit resume path.
8383

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

8691
- **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.
@@ -129,7 +134,9 @@ The exact turn thresholds are model-family-dependent (tighter for models with ob
129134

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

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

134141
## Configuration
135142

src/agent/environment.test.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { join } from "node:path";
66
import { promisify } from "node:util";
77

88
import { gatherEnvironment, getGitBranch } from "./environment.js";
9+
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
910

1011
const run = promisify(execFile);
1112

@@ -22,9 +23,7 @@ test("gatherEnvironment detects a git work tree and lists its top level", async
2223
// a runner may check out a detached HEAD, which has no branch name.
2324
const dir = await mkdtemp(join(tmpdir(), "corbits-env-repo-"));
2425
try {
25-
await run("git", ["init"], { cwd: dir });
26-
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
27-
await run("git", ["config", "user.name", "t"], { cwd: dir });
26+
initTemporaryGitRepo(dir);
2827
await run("git", ["checkout", "-b", "trunk"], { cwd: dir });
2928
await mkdir(join(dir, "src"));
3029
await writeFile(join(dir, "src", "seed.ts"), "export const seed = 1;\n");
@@ -43,9 +42,7 @@ test("gatherEnvironment detects a git work tree and lists its top level", async
4342
test("gatherEnvironment gathers branch and dirty status from the same work tree", async () => {
4443
const dir = await mkdtemp(join(tmpdir(), "corbits-env-"));
4544
try {
46-
await run("git", ["init"], { cwd: dir });
47-
await run("git", ["config", "user.email", "t@t.test"], { cwd: dir });
48-
await run("git", ["config", "user.name", "t"], { cwd: dir });
45+
initTemporaryGitRepo(dir);
4946
await run("git", ["checkout", "-b", "trunk"], { cwd: dir });
5047
await writeFile(join(dir, "seed.txt"), "seed");
5148
await run("git", ["add", "."], { cwd: dir });

src/config.test.ts

Lines changed: 139 additions & 1 deletion
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,
@@ -26,7 +27,7 @@ import {
2627
type Settings,
2728
} from "./config/settings.js";
2829
import { OPENCODE_GO_BASE_URL } from "../packages/opencode-go/src/index.js";
29-
import { generateSessionId, initSessionDir } from "./session/index.js";
30+
import { generateSessionId, initSessionDir, sessionDir } from "./session/index.js";
3031
import { saveState } from "./session/state.js";
3132
import { filterMcpServersForConnect } from "./trust/project-trust.js";
3233
import { createExaMCPServerConfig } from "./mcp/exa.js";
@@ -467,6 +468,94 @@ describe("loadConfig", () => {
467468
}
468469
});
469470

471+
test("resume <id> --force reopens a failed session that recorded an error", async () => {
472+
const cwd = await emptyCwd();
473+
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
474+
try {
475+
const globalPath = await writeGlobalSettings(cwd);
476+
const sessionId = generateSessionId();
477+
await initSessionDir(cwd, sessionId, home);
478+
await saveState(
479+
cwd,
480+
sessionId,
481+
{
482+
status: "failed",
483+
turnsUsed: 4,
484+
task: "ship resume after failure",
485+
startedAt: Date.now() - 1_000,
486+
finishedAt: Date.now(),
487+
error: "Cycle commit failed\nhook dump: pre-commit rejected",
488+
},
489+
home,
490+
);
491+
const config = await loadConfig(["resume", sessionId, "--force", "--cwd", cwd], {
492+
globalSettingsPath: globalPath,
493+
home,
494+
});
495+
assertConfigured(config);
496+
expect(config.resumeMode).toBe("id");
497+
expect(config.sessionId).toBe(sessionId);
498+
expect(config.skipInitialTask).toBe(true);
499+
expect(config.task).toBe("ship resume after failure");
500+
expect(config.force).toBe(true);
501+
} finally {
502+
await rm(cwd, { recursive: true, force: true });
503+
await rm(home, { recursive: true, force: true });
504+
}
505+
});
506+
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+
470559
test("--resume opens the picker", async () => {
471560
const cwd = await emptyCwd();
472561
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
@@ -558,6 +647,55 @@ describe("loadConfig", () => {
558647
}
559648
});
560649

650+
test("resume <id> of an unreadable session throws a short recovery line", async () => {
651+
const cwd = await emptyCwd();
652+
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
653+
try {
654+
const globalPath = await writeGlobalSettings(cwd);
655+
const sessionId = generateSessionId();
656+
await initSessionDir(cwd, sessionId, home);
657+
await writeFile(join(sessionDir(cwd, sessionId, home), "run.json"), "{ not json");
658+
659+
const chunks: string[] = [];
660+
const orig = process.stderr.write.bind(process.stderr);
661+
process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => {
662+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
663+
return orig(chunk, ...(rest as []));
664+
}) as typeof process.stderr.write;
665+
let thrown: unknown;
666+
try {
667+
await loadConfig(["resume", sessionId, "--force", "--cwd", cwd], {
668+
globalSettingsPath: globalPath,
669+
home,
670+
});
671+
} catch (err) {
672+
thrown = err;
673+
} finally {
674+
process.stderr.write = orig;
675+
}
676+
677+
expect(thrown).toBeInstanceOf(CliUserError);
678+
const message = thrown instanceof Error ? thrown.message : String(thrown);
679+
expect(message).toBe(
680+
`Session ${sessionId} is unreadable. Use \`corbits resume\` to choose another.`,
681+
);
682+
expect(message).not.toMatch(/No session/);
683+
expect(message).not.toContain("ignoring unreadable");
684+
expect(message).not.toContain("invalid shape");
685+
expect(message).not.toContain(home);
686+
expect(message.split("\n")).toHaveLength(1);
687+
if (thrown instanceof CliUserError) {
688+
expect(thrown.exitCode).toBe(1);
689+
}
690+
const text = chunks.join("");
691+
expect(text).not.toContain("ignoring unreadable");
692+
expect(text).not.toContain(home);
693+
} finally {
694+
await rm(cwd, { recursive: true, force: true });
695+
await rm(home, { recursive: true, force: true });
696+
}
697+
});
698+
561699
test("resume rejects a non-id positional instead of treating it as last", async () => {
562700
const cwd = await emptyCwd();
563701
try {

src/config/index.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
33
import type { InferenceSource } from "@intx/types/runtime";
44
import { generateSessionId, isSessionId, migrateLegacySessionIfNeeded } from "../session/index.js";
55
import { loadState } from "../session/state.js";
6+
import { COMMAND_NAME } from "../branding.js";
67

78
import { isDirectorId } from "../agent/directors/registry.js";
89
import { DIRECTOR_IDS, type DirectorId } from "../agent/directors/types.js";
@@ -525,6 +526,19 @@ export class CliHelpError extends Error {
525526
}
526527
}
527528

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+
528542
export interface LoadConfigOptions {
529543
// Override the global settings file location (for tests / non-standard homes).
530544
globalSettingsPath?: string;
@@ -838,12 +852,18 @@ export async function loadConfig(
838852
} else if (resumeMode === "id") {
839853
const id = resumeSessionId!;
840854
await migrateLegacySessionIfNeeded(cwd, id, options.home);
841-
const state = await loadState(cwd, id, options.home);
842-
if (state === null) {
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") {
843862
throw new Error(
844-
`No session ${id} for this project. Sessions are stored under ~/.corbits/projects/<project-key>/ (this checkout's git toplevel). Use \`corbits resume\` to choose one.`,
863+
`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.`,
845864
);
846865
}
866+
const state = loaded.state;
847867
sessionId = id;
848868
skipInitialTask = true;
849869
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/permission/gate.test.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { createPermissionGate, isRequestCoveredByGrant, preGrantGuardReason } fr
88
import { createPathRestriction } from "./path-restriction.js";
99
import { createWorktreeRootsProvider } from "./worktree-roots.js";
1010
import type { Approval, PermissionRequest } from "./types.js";
11+
import { initTemporaryGitRepo } from "../../tests/helpers/temporary-git-repo.js";
1112

1213
const shellCall = (command: string): ToolCall => ({
1314
id: "c",
@@ -112,9 +113,7 @@ describe("grant coverage rebinds relative paths to the request process cwd", ()
112113
const sessionCwd = join(root, "main");
113114
const git = (args: string[], cwd: string) => execFileSync("git", args, { cwd, stdio: "ignore" });
114115
mkdirSync(sessionCwd);
115-
git(["init", "-q"], sessionCwd);
116-
git(["config", "user.email", "t@example.com"], sessionCwd);
117-
git(["config", "user.name", "t"], sessionCwd);
116+
initTemporaryGitRepo(sessionCwd, { initArgs: ["-q"] });
118117
writeFileSync(join(sessionCwd, "seed.txt"), "seed\n");
119118
git(["add", "."], sessionCwd);
120119
git(["commit", "-qm", "seed"], sessionCwd);
@@ -171,9 +170,7 @@ describe("standing grant covers a later git worktree command (CL-5638)", () => {
171170
const sessionCwd = join(root, "main");
172171
const git = (args: string[], cwd: string) => execFileSync("git", args, { cwd, stdio: "ignore" });
173172
mkdirSync(sessionCwd);
174-
git(["init", "-q"], sessionCwd);
175-
git(["config", "user.email", "t@example.com"], sessionCwd);
176-
git(["config", "user.name", "t"], sessionCwd);
173+
initTemporaryGitRepo(sessionCwd, { initArgs: ["-q"] });
177174
writeFileSync(join(sessionCwd, "seed.txt"), "seed\n");
178175
git(["add", "."], sessionCwd);
179176
git(["commit", "-qm", "seed"], sessionCwd);

0 commit comments

Comments
 (0)