Skip to content

Commit 5fb9612

Browse files
committed
Merge origin/main into cl-6909-thinking-only-replay-guard
2 parents 91d820f + 31ea722 commit 5fb9612

6 files changed

Lines changed: 125 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,21 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
3737
unset; values must still be integers ≥1. `task(maxTurns)`, profile
3838
`maxTurns`, and `settings.subagentMaxTurns` may exceed 100 for long jobs.
3939

40+
### Internal
41+
42+
- **`inference.error` partials keep the provider error.** `partial.jsonl`
43+
records for `inference-error` now include `error` (`category`, `message`,
44+
`statusCode` when present) even when the cycle streamed no text.
45+
- **Exec `turnsUsed` follows the run-sink.** Mid-run and terminal `run.json`
46+
snapshots use `getTurnCount()` the same way the TUI does, instead of
47+
writing the initial zero until send finishes.
48+
49+
### Docs
50+
51+
- **`latest` is a symlink, not a session.** Naive globs of a project
52+
sessions directory double-count unless they skip `latest` (`listSessions`
53+
already does).
54+
4055
## [0.2.104] - 2026-08-23
4156

4257
### TUI

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,7 @@ Session runtime state lives under the global projects tree (not in the repo):
353353
- `~/.corbits/projects/<project-key>/<session-id>/run.json``RunState`
354354
- `~/.corbits/projects/<project-key>/<session-id>/context/` — git-backed conversation context (`@intx/storage-isogit`)
355355
- Project key: slug + short hash of this checkout's git toplevel (from `--show-toplevel`, so linked worktrees have distinct keys; workspace realpath when not a git tree)
356+
- `latest` is a symlink in that same project sessions directory (not a session of its own). Naive globs over the directory double-count unless they skip `latest` (as `listSessions` does).
356357

357358
- Migration: if a session exists only under in-repo `.agent-state/<session-id>/`, it is moved into the global tree on open/list
358359
- Atomic JSON writes with schema validation on load

src/exec/runner.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ import {
7777
sessionDir,
7878
} from "../session/index.js";
7979
import { saveState, type ConnectedMcpServer } from "../session/state.js";
80-
import { createRunSink, resolveExecRunStatus } from "../session/run-sink.js";
80+
import { createRunSink, resolveExecRunStatus, type RunSink } from "../session/run-sink.js";
8181
import {
8282
createLifecycleHookManager,
8383
createRunSummary,
@@ -244,6 +244,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
244244
let textOut = "";
245245
let finalized = false;
246246
let turnsUsed = 0;
247+
let runSink: RunSink | null = null;
247248

248249
const persist = async (
249250
status: "running" | "done" | "failed" | "cancelled",
@@ -253,7 +254,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
253254
if (status !== "running") finalized = true;
254255
await saveState(config.cwd, sessionId, {
255256
status,
256-
turnsUsed,
257+
turnsUsed: runSink?.getTurnCount() ?? turnsUsed,
257258
task,
258259
startedAt,
259260
model: `${config.providerName}:${config.model}`,
@@ -645,7 +646,14 @@ export async function runExec(config: Config): Promise<ExecResult> {
645646
const hookManager = createLifecycleHookManager({
646647
hooks: await discoverLifecycleHooks(hookDirectories(config.cwd)),
647648
});
648-
const runSink = createRunSink({ emitter, hookManager });
649+
const liveSink = createRunSink({
650+
emitter,
651+
hookManager,
652+
onTurnBoundarySnapshot: () => {
653+
void persist("running");
654+
},
655+
});
656+
runSink = liveSink;
649657

650658
currentAgent = await buildAgent();
651659
agent = currentAgent;
@@ -680,7 +688,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
680688
// its partial output in partial.jsonl instead of vanishing.
681689
const cycleRecorder = createCycleTextRecorder(() => workdir);
682690
const sink = (event: ReactorEmittedEvent): void => {
683-
runSink.sink(event);
691+
liveSink.sink(event);
684692
cycleRecorder.handleEvent(event);
685693
if (event.type === "inference.text.delta") {
686694
const token = (event.data as { token?: string }).token;
@@ -701,7 +709,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
701709
// sticky inference.error and would hide a real failure.
702710
let sendCompleted = false;
703711
let runError: string | undefined;
704-
let sinkStatus: ReturnType<typeof runSink.getStatus> = "cancelled";
712+
let sinkStatus: ReturnType<typeof liveSink.getStatus> = "cancelled";
705713
try {
706714
// Final OAuth refresh immediately before send (token may have aged during MCP).
707715
if (initialCodexProfile !== undefined) {
@@ -845,7 +853,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
845853
error: message,
846854
status: "failed",
847855
durationMs: Date.now() - startedAt,
848-
turnsUsed,
856+
turnsUsed: runSink?.getTurnCount() ?? turnsUsed,
849857
toolCallCount: 0,
850858
tokenUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, thinking: 0 },
851859
provider: config.providerName,

src/session/run-sink.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,31 @@ describe("createRunSink", () => {
8787
});
8888
});
8989

90+
test("onTurnBoundarySnapshot reads getTurnCount after the turn, not the initial zero", () => {
91+
// Exec persist now snapshots from this callback (same as TUI). A closed-over
92+
// turnsUsed: 0 would write run.json as still-zero mid-run.
93+
const snapshots: number[] = [];
94+
const runSink = createRunSink({
95+
emitter: new EventEmitter(),
96+
hookManager: stubHookManager([]),
97+
onTurnBoundarySnapshot: () => {
98+
snapshots.push(runSink.getTurnCount());
99+
},
100+
});
101+
102+
expect(runSink.getTurnCount()).toBe(0);
103+
runSink.sink(
104+
event("inference.done", {
105+
turn: { role: "assistant", content: [], model: "test", timestamp: 0 },
106+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 },
107+
source: { provider: "test", model: "test" },
108+
}),
109+
);
110+
111+
expect(snapshots).toEqual([1]);
112+
expect(runSink.getTurnCount()).toBe(1);
113+
});
114+
90115
test("reports the in-flight turn to onTurnFailed when a turn errors instead of completing", () => {
91116
const failures: { turnIndex: number; error: string }[] = [];
92117
const runSink = createRunSink({

src/session/stream-journal.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ function thinkingDelta(token: string): ReactorEmittedEvent {
3131
}
3232

3333
async function readPartialRecords(): Promise<
34-
{ reason: string; text: string; thinkingText?: string; thinkingChars?: number }[]
34+
{
35+
reason: string;
36+
text: string;
37+
thinkingText?: string;
38+
thinkingChars?: number;
39+
error?: { category?: string; message?: string; statusCode?: number };
40+
}[]
3541
> {
3642
const raw = await readFile(join(dir, PARTIAL_FILE), "utf8");
3743
return raw
@@ -44,6 +50,7 @@ async function readPartialRecords(): Promise<
4450
text: string;
4551
thinkingText?: string;
4652
thinkingChars?: number;
53+
error?: { category?: string; message?: string; statusCode?: number };
4754
},
4855
);
4956
}
@@ -90,6 +97,31 @@ describe("createCycleTextRecorder", () => {
9097
const records = await readPartialRecords();
9198
expect(records[0]?.reason).toBe("inference-error");
9299
expect(records[0]?.text).toBe("partial before failure");
100+
expect(records[0]?.error?.category).toBe("aborted");
101+
expect(records[0]?.error?.message).toBe("aborted");
102+
});
103+
104+
test("inference.error with empty cycle text still writes a partial with the error payload", async () => {
105+
// Observed live: ~20 unattributable episodes had inference.error with no
106+
// streamed text. The partial must still land so category/message survive.
107+
const recorder = createCycleTextRecorder(() => dir);
108+
recorder.handleEvent({
109+
type: "inference.error",
110+
data: {
111+
error: { category: "rate_limit", message: "429 too many requests", statusCode: 429 },
112+
},
113+
} as unknown as ReactorEmittedEvent);
114+
115+
await Bun.sleep(20);
116+
const records = await readPartialRecords();
117+
expect(records).toHaveLength(1);
118+
expect(records[0]?.reason).toBe("inference-error");
119+
expect(records[0]?.text).toBe("");
120+
expect(records[0]?.error).toEqual({
121+
category: "rate_limit",
122+
message: "429 too many requests",
123+
statusCode: 429,
124+
});
93125
});
94126

95127
test("dispose flushes the entry snapshot with the given reason and returns it", async () => {

src/session/stream-journal.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,33 @@ export type PartialFlushReason =
4343
| "send-failed"
4444
| "inference-error";
4545

46+
/** Fields copied from `inference.error` `data.error` onto a partial.jsonl record. */
47+
export interface PartialInferenceError {
48+
category?: string;
49+
message?: string;
50+
statusCode?: number;
51+
}
52+
53+
function inferenceErrorFromEvent(event: ReactorEmittedEvent): PartialInferenceError | undefined {
54+
const data = event.data as { error?: unknown } | undefined;
55+
if (data === undefined || typeof data !== "object" || data === null) return undefined;
56+
const raw = data.error;
57+
if (raw === undefined || typeof raw !== "object" || raw === null) return undefined;
58+
const rec = raw as Record<string, unknown>;
59+
const error: PartialInferenceError = {};
60+
if (typeof rec.category === "string") error.category = rec.category;
61+
if (typeof rec.message === "string") error.message = rec.message;
62+
if (typeof rec.statusCode === "number") error.statusCode = rec.statusCode;
63+
if (
64+
error.category === undefined &&
65+
error.message === undefined &&
66+
error.statusCode === undefined
67+
) {
68+
return undefined;
69+
}
70+
return error;
71+
}
72+
4673
export interface CycleTextRecorder {
4774
/** Feed every stream event; buffers deltas, resets on done, flushes on error. */
4875
handleEvent: (event: ReactorEmittedEvent) => void;
@@ -80,8 +107,10 @@ export function createCycleTextRecorder(
80107
reason: PartialFlushReason,
81108
text: string,
82109
thinkingText: string,
110+
error?: PartialInferenceError,
83111
): Promise<void> => {
84-
if (text.trim().length === 0 && thinkingText.trim().length === 0) return;
112+
const hasErrorPayload = reason === "inference-error" && error !== undefined;
113+
if (text.trim().length === 0 && thinkingText.trim().length === 0 && !hasErrorPayload) return;
85114
const record: Record<string, unknown> = { reason, chars: text.length, text };
86115
// Omitted when empty: a text-only abort (the common case) keeps the
87116
// existing record shape, and diagnosing a thinking-loop abort needs the
@@ -90,6 +119,7 @@ export function createCycleTextRecorder(
90119
record.thinkingChars = thinkingText.length;
91120
record.thinkingText = thinkingText;
92121
}
122+
if (error !== undefined) record.error = error;
93123
try {
94124
await appendFile(
95125
join(resolveContextDir(), PARTIAL_FILE),
@@ -104,12 +134,15 @@ export function createCycleTextRecorder(
104134
}
105135
};
106136

107-
const flush = async (reason: PartialFlushReason): Promise<void> => {
137+
const flush = async (
138+
reason: PartialFlushReason,
139+
error?: PartialInferenceError,
140+
): Promise<void> => {
108141
const text = cycleText;
109142
const thinkingText = cycleThinkingText;
110143
cycleText = "";
111144
cycleThinkingText = "";
112-
await writeRecord(reason, text, thinkingText);
145+
await writeRecord(reason, text, thinkingText, error);
113146
};
114147

115148
const handleEvent = (event: ReactorEmittedEvent): void => {
@@ -130,7 +163,7 @@ export function createCycleTextRecorder(
130163
return;
131164
}
132165
if (event.type === "inference.error") {
133-
void flush("inference-error");
166+
void flush("inference-error", inferenceErrorFromEvent(event));
134167
}
135168
};
136169

0 commit comments

Comments
 (0)