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

## [Unreleased]

### Fixed

- Failed sessions with an `error` string in `run.json` are valid resume
candidates, not corrupt files. A truly unreadable session id prints one
recovery line; parse diagnostics go to the structured log, not the
terminal.

## [0.3.10] - 2026-08-30

### Fixed
Expand Down
9 changes: 8 additions & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ Opens a picker of saved conversations for the working directory. Plain
`corbits` always starts a fresh conversation; `corbits resume <session-id>`
is the direct, explicit resume path.

A session that ended in `failed` (including one that recorded an `error`
string in `run.json`) is a failed session, not a corrupt one — it still
appears in the picker. Passing a corrupt session id prints one short
recovery line instead of dumping the file path and parse details.

## Safety Model

- **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.
Expand Down Expand Up @@ -128,7 +133,9 @@ The exact turn thresholds are model-family-dependent (tighter for models with ob

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

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

## Configuration

Expand Down
140 changes: 139 additions & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
buildProviderCatalog,
catalogEntryAsProviderSettings,
CliHelpError,
CliUserError,
CLI_HELP_TEXT,
KEYLESS_API_KEY,
loadConfig,
Expand All @@ -26,7 +27,7 @@ import {
type Settings,
} from "./config/settings.js";
import { OPENCODE_GO_BASE_URL } from "../packages/opencode-go/src/index.js";
import { generateSessionId, initSessionDir } from "./session/index.js";
import { generateSessionId, initSessionDir, sessionDir } from "./session/index.js";
import { saveState } from "./session/state.js";
import { filterMcpServersForConnect } from "./trust/project-trust.js";
import { createExaMCPServerConfig } from "./mcp/exa.js";
Expand Down Expand Up @@ -467,6 +468,94 @@ describe("loadConfig", () => {
}
});

test("resume <id> --force reopens a failed session that recorded an error", async () => {
const cwd = await emptyCwd();
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
try {
const globalPath = await writeGlobalSettings(cwd);
const sessionId = generateSessionId();
await initSessionDir(cwd, sessionId, home);
await saveState(
cwd,
sessionId,
{
status: "failed",
turnsUsed: 4,
task: "ship resume after failure",
startedAt: Date.now() - 1_000,
finishedAt: Date.now(),
error: "Cycle commit failed\nhook dump: pre-commit rejected",
},
home,
);
const config = await loadConfig(["resume", sessionId, "--force", "--cwd", cwd], {
globalSettingsPath: globalPath,
home,
});
assertConfigured(config);
expect(config.resumeMode).toBe("id");
expect(config.sessionId).toBe(sessionId);
expect(config.skipInitialTask).toBe(true);
expect(config.task).toBe("ship resume after failure");
expect(config.force).toBe(true);
} finally {
await rm(cwd, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
}
});

test("resume <id> --force among failed siblings stays silent and reopens the target", async () => {
const cwd = await emptyCwd();
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
try {
const globalPath = await writeGlobalSettings(cwd);
const targetId = generateSessionId();
for (let i = 0; i < 6; i++) {
const id = i === 0 ? targetId : generateSessionId();
await initSessionDir(cwd, id, home);
await saveState(
cwd,
id,
{
status: "failed",
turnsUsed: 2,
task: i === 0 ? "target failed session" : `sibling failed ${i}`,
startedAt: Date.now() - 1_000 - i,
finishedAt: Date.now() - i,
error: "Cycle commit failed\nhook dump: pre-commit rejected",
},
home,
);
}

const chunks: string[] = [];
const orig = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => {
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
return orig(chunk, ...(rest as []));
}) as typeof process.stderr.write;
let config: Awaited<ReturnType<typeof loadConfig>>;
try {
config = await loadConfig(["resume", targetId, "--force", "--cwd", cwd], {
globalSettingsPath: globalPath,
home,
});
} finally {
process.stderr.write = orig;
}
assertConfigured(config);
expect(config.sessionId).toBe(targetId);
expect(config.task).toBe("target failed session");
const text = chunks.join("");
expect(text).not.toContain("ignoring unreadable");
expect(text).not.toContain(home);
expect(text).not.toContain("invalid shape");
} finally {
await rm(cwd, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
}
});

test("--resume opens the picker", async () => {
const cwd = await emptyCwd();
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
Expand Down Expand Up @@ -558,6 +647,55 @@ describe("loadConfig", () => {
}
});

test("resume <id> of an unreadable session throws a short recovery line", async () => {
const cwd = await emptyCwd();
const home = await mkdtemp(join(tmpdir(), "ic-resume-home-"));
try {
const globalPath = await writeGlobalSettings(cwd);
const sessionId = generateSessionId();
await initSessionDir(cwd, sessionId, home);
await writeFile(join(sessionDir(cwd, sessionId, home), "run.json"), "{ not json");

const chunks: string[] = [];
const orig = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => {
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
return orig(chunk, ...(rest as []));
}) as typeof process.stderr.write;
let thrown: unknown;
try {
await loadConfig(["resume", sessionId, "--force", "--cwd", cwd], {
globalSettingsPath: globalPath,
home,
});
} catch (err) {
thrown = err;
} finally {
process.stderr.write = orig;
}

expect(thrown).toBeInstanceOf(CliUserError);
const message = thrown instanceof Error ? thrown.message : String(thrown);
expect(message).toBe(
`Session ${sessionId} is unreadable. Use \`corbits resume\` to choose another.`,
);
expect(message).not.toMatch(/No session/);
expect(message).not.toContain("ignoring unreadable");
expect(message).not.toContain("invalid shape");
expect(message).not.toContain(home);
expect(message.split("\n")).toHaveLength(1);
if (thrown instanceof CliUserError) {
expect(thrown.exitCode).toBe(1);
}
const text = chunks.join("");
expect(text).not.toContain("ignoring unreadable");
expect(text).not.toContain(home);
} finally {
await rm(cwd, { recursive: true, force: true });
await rm(home, { recursive: true, force: true });
}
});

test("resume rejects a non-id positional instead of treating it as last", async () => {
const cwd = await emptyCwd();
try {
Expand Down
26 changes: 23 additions & 3 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { resolve } from "node:path";
import type { InferenceSource } from "@intx/types/runtime";
import { generateSessionId, isSessionId, migrateLegacySessionIfNeeded } from "../session/index.js";
import { loadState } from "../session/state.js";
import { COMMAND_NAME } from "../branding.js";

import { isDirectorId } from "../agent/directors/registry.js";
import { DIRECTOR_IDS, type DirectorId } from "../agent/directors/types.js";
Expand Down Expand Up @@ -525,6 +526,19 @@ export class CliHelpError extends Error {
}
}

/**
* Thrown for a recoverable operator mistake. Entry points must print
* `message` to stderr and exit 1 — not dump a stack.
*/
export class CliUserError extends Error {
readonly exitCode = 1 as const;

constructor(message: string) {
super(message);
this.name = "CliUserError";
}
}

export interface LoadConfigOptions {
// Override the global settings file location (for tests / non-standard homes).
globalSettingsPath?: string;
Expand Down Expand Up @@ -838,12 +852,18 @@ export async function loadConfig(
} else if (resumeMode === "id") {
const id = resumeSessionId!;
await migrateLegacySessionIfNeeded(cwd, id, options.home);
const state = await loadState(cwd, id, options.home);
if (state === null) {
const loaded = await loadState(cwd, id, options.home);
if (loaded.kind === "unreadable") {
throw new CliUserError(
`Session ${id} is unreadable. Use \`${COMMAND_NAME} resume\` to choose another.`,
);
}
if (loaded.kind === "missing") {
throw new Error(
`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.`,
`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.`,
);
}
const state = loaded.state;
sessionId = id;
skipInitialTask = true;
if (task.length === 0) resumeTask = state.task;
Expand Down
32 changes: 23 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { primeCrashReporting, writeCrashReport, type CrashKind } from "./crash/r
import { getActiveRun, markCrashed } from "./session/active-run.js";
import { getActiveDisposeHost } from "./session/active-host.js";
import { saveCrashState } from "./session/state.js";
import { loadConfig, CliHelpError } from "./config/index.js";
import { loadConfig, CliHelpError, CliUserError } from "./config/index.js";
import { ensureTelemetrySettings, globalSettingsPath } from "./config/settings.js";
import { installFileLogSink } from "./logging/sink.js";
import { flushPerfToOtel } from "./perf/index.js";
Expand Down Expand Up @@ -286,6 +286,24 @@ export function installSignalHandlers(): void {
}
}

export function cliCaughtExit(err: unknown): {
stream: "stdout" | "stderr";
text: string;
code: number;
} {
if (err instanceof CliHelpError) {
return { stream: "stdout", text: `${err.message}\n`, code: err.exitCode };
}
if (err instanceof CliUserError) {
return { stream: "stderr", text: `${err.message}\n`, code: err.exitCode };
}
return {
stream: "stderr",
text: `${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`,
code: 1,
};
}

if (import.meta.main) {
installCrashHandlers();
installSignalHandlers();
Expand All @@ -294,14 +312,10 @@ if (import.meta.main) {
try {
code = await main(process.argv.slice(2));
} catch (err: unknown) {
// Help is an intentional early exit, not a crash — stdout + 0.
if (err instanceof CliHelpError) {
process.stdout.write(`${err.message}\n`);
code = err.exitCode;
} else {
process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
code = 1;
}
const exit = cliCaughtExit(err);
const dest = exit.stream === "stdout" ? process.stdout : process.stderr;
dest.write(exit.text);
code = exit.code;
}
process.exit(code);
}
25 changes: 14 additions & 11 deletions src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,20 +240,23 @@ export async function listSessions(
const summaries: SessionSummary[] = [];
for (const entry of entries) {
await migrateLegacySessionIfNeeded(cwd, entry, home);
const state = await loadState(cwd, entry, home);
if (state !== null) {
const loaded = await loadState(cwd, entry, home);
if (loaded.kind === "ok") {
summaries.push({
sessionId: entry,
task: state.task,
startedAt: state.startedAt,
status: state.status,
task: loaded.state.task,
startedAt: loaded.state.startedAt,
status: loaded.state.status,
});
continue;
}
// A session directory with context/ but no readable run.json never
// reached its first saveState call (see src/tui/runner.ts's early
// "running" write) and therefore isn't actually running: report it as
// crashed rather than fabricating liveness.
if (loaded.kind === "unreadable") {
continue;
}
// Missing run.json: a session directory with context/ never reached its
// first saveState call (see src/tui/runner.ts's early "running" write)
// and therefore isn't actually running: report it as crashed rather
// than fabricating liveness.
try {
const dirStat = await stat(sessionDir(cwd, entry, home));
await stat(sessionContextDir(cwd, entry, home));
Expand Down Expand Up @@ -290,7 +293,7 @@ export async function renameSession(
}
await migrateLegacySessionIfNeeded(cwd, sessionId, home);
const existing = await loadState(cwd, sessionId, home);
if (existing === null) {
if (existing.kind !== "ok") {
let startedAt = Date.now();
try {
const dirStat = await stat(sessionDir(cwd, sessionId, home));
Expand All @@ -311,7 +314,7 @@ export async function renameSession(
);
return;
}
await saveState(cwd, sessionId, { ...existing, task: trimmed }, home);
await saveState(cwd, sessionId, { ...existing.state, task: trimmed }, home);
}

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