Skip to content

Commit e68d109

Browse files
Continue on non-fatal reactor errors and skip empty checkpoints (#892)
* Continue on non-fatal reactor errors and skip empty checkpoints reactor.error with fatal:false is a recoverable stream signal; treating it as terminal idle/failed the TUI and run sink while the reactor kept going. Empty managed checkpoints were also creating new commits and risking session junk like partial.jsonl riding along — skip when no allowlisted path differs from HEAD. * Keep text-delta suppression across non-fatal reactor errors * Keep unpublished rewrites out of the empty checkpoint skip Empty managed checkpoints skip the git commit when no allowlisted path differs from HEAD. A staged rewrite is still unpublished on disk at that check, so skipping would publish it in memory without writing or committing. Only skip when nothing is staged to land.
1 parent e60971f commit e68d109

10 files changed

Lines changed: 254 additions & 3 deletions

src/agent/reactor-events.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { describe, expect, test } from "bun:test";
22
import type { ReactorEmittedEvent } from "@intx/inference";
33
import type { ReactorInboundEvent } from "@intx/types/runtime";
4-
import { onReactorShutdown, onTurnBoundary } from "./reactor-events.js";
4+
import {
5+
isReactorErrorFatal,
6+
onReactorShutdown,
7+
onTurnBoundary,
8+
} from "./reactor-events.js";
59

610
// Bare `{ type: string }` literals only prove the string comparison works.
711
// The generic exists so the guards narrow across both `ReactorInboundEvent`
@@ -115,3 +119,27 @@ describe("onReactorShutdown", () => {
115119
expect(shutdowns.length).toBe(1);
116120
});
117121
});
122+
123+
describe("isReactorErrorFatal", () => {
124+
test("fatal:false continues", () => {
125+
expect(
126+
isReactorErrorFatal({ error: "transient write", fatal: false }),
127+
).toBe(false);
128+
});
129+
130+
test("fatal:true stays terminal", () => {
131+
expect(isReactorErrorFatal({ error: "gave up", fatal: true })).toBe(true);
132+
});
133+
134+
test("missing fatal stays terminal", () => {
135+
expect(isReactorErrorFatal({ error: "gave up" })).toBe(true);
136+
});
137+
138+
test("malformed payloads stay terminal", () => {
139+
expect(isReactorErrorFatal(undefined)).toBe(true);
140+
expect(isReactorErrorFatal(null)).toBe(true);
141+
expect(isReactorErrorFatal("boom")).toBe(true);
142+
expect(isReactorErrorFatal({ fatal: "false" })).toBe(true);
143+
expect(isReactorErrorFatal({ fatal: 0 })).toBe(true);
144+
});
145+
});

src/agent/reactor-events.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* call sites without re-declaring the union here.
1414
*/
1515

16+
import { type } from "arktype";
17+
1618
/** True when `event` is the turn boundary — fires once per turn, every turn. */
1719
export const onTurnBoundary = <E extends { type: string }>(
1820
event: E,
@@ -24,3 +26,19 @@ export const onReactorShutdown = <E extends { type: string }>(
2426
event: E,
2527
): event is Extract<E, { type: "reactor.done" }> =>
2628
event.type === "reactor.done";
29+
30+
/**
31+
* Explicit non-fatal reactor.error payload. Only `fatal: false` continues;
32+
* missing, malformed, or any other value stays terminal.
33+
*/
34+
const NonFatalReactorErrorData = type({
35+
fatal: "false",
36+
});
37+
38+
/**
39+
* Whether a `reactor.error` payload should terminate the turn/shell.
40+
* Returns false only when the payload explicitly carries `fatal: false`.
41+
*/
42+
export function isReactorErrorFatal(data: unknown): boolean {
43+
return NonFatalReactorErrorData(data) instanceof type.errors;
44+
}

src/session/optimized-context-store.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,24 @@ describe("createOptimizedContextStore checkpoint", () => {
710710
}
711711
expect(gitSpawns).toEqual([]);
712712
});
713+
714+
test("skips commit when no managed path differs and never stages partial.jsonl", async () => {
715+
const dir = tempDir();
716+
const store = await createOptimizedContextStore(dir);
717+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
718+
const first = await store.commit({ message: "first managed checkpoint" });
719+
720+
fs.writeFileSync(path.join(dir, "partial.jsonl"), '{"reason":"abort"}\n');
721+
fs.writeFileSync(path.join(dir, "untracked-junk.txt"), "session junk\n");
722+
await store.writeMetadata(EMPTY_CHECKPOINT_METADATA);
723+
724+
const second = await store.commit({ message: "empty managed checkpoint" });
725+
expect(second.hash).toBe(first.hash);
726+
727+
const tree = await gitLsTree(dir);
728+
expect(tree).not.toContain("partial.jsonl");
729+
expect(tree).not.toContain("untracked-junk.txt");
730+
});
713731
});
714732

715733
describe("createSessionStores", () => {

src/session/optimized-context-store.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,24 @@ function extraCommitPaths(paths: readonly string[]): string[] {
489489
return paths.filter((filepath) => !VENDOR_COMMIT_ROOT_FILES.has(filepath));
490490
}
491491

492+
/**
493+
* True when any allowlisted managed path differs between HEAD and the
494+
* worktree. Callers must pass only managed paths — never "." and never
495+
* session junk such as partial.jsonl.
496+
*/
497+
async function managedPathsDiffer(
498+
dir: string,
499+
filepaths: readonly string[],
500+
): Promise<boolean> {
501+
if (filepaths.length === 0) return false;
502+
const matrix = await git.statusMatrix({
503+
fs,
504+
dir,
505+
filepaths: [...filepaths],
506+
});
507+
return matrix.some(([, head, workdir]) => head !== workdir);
508+
}
509+
492510
export interface SessionStores {
493511
storage: ContextStore;
494512
audit: AuditStore;
@@ -724,6 +742,28 @@ export async function createSessionStores(
724742
const headBefore = stagedRewrite === null ? null : await headOid(dir);
725743
let extraPaths: string[] = [];
726744

745+
// Empty managed checkpoints must not create a new commit or stage
746+
// session junk such as partial.jsonl. A staged unpublished rewrite
747+
// is still unpublished on disk — skip would swallow the compact
748+
// without writing it, so that path always goes through commit.
749+
if (stagedRewrite === null) {
750+
const managedFilepaths = [
751+
...VENDOR_COMMIT_ROOT_FILES,
752+
TOOL_OUTPUT_DIR,
753+
EVIDENCE_ARCHIVE_DIR,
754+
...pendingSegmentPaths,
755+
...pendingBlobFilepaths,
756+
];
757+
if (!(await managedPathsDiffer(dir, managedFilepaths))) {
758+
const [head] = await base.log(1);
759+
if (head !== undefined) {
760+
pendingBlobFilepaths.clear();
761+
pendingSegmentPaths.clear();
762+
return head;
763+
}
764+
}
765+
}
766+
727767
try {
728768
if (stagedRewrite !== null) {
729769
await writeSegmented(writeTurnsSegmented, stagedRewrite);

src/session/run-sink.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,4 +532,47 @@ describe("createRunSink", () => {
532532
toolCallCount: 0,
533533
});
534534
});
535+
536+
test("non-fatal reactor.error does not sticky-fail the run", () => {
537+
const runSink = createRunSink({
538+
emitter: new EventEmitter(),
539+
hookManager: stubHookManager([]),
540+
});
541+
542+
runSink.sink(
543+
event("reactor.error", {
544+
error: "transient checkpoint write",
545+
fatal: false,
546+
}),
547+
);
548+
549+
expect(runSink.getRunError()).toBeUndefined();
550+
expect(runSink.getStatus()).not.toBe("failed");
551+
});
552+
553+
test("fatal reactor.error sticky-fails the run", () => {
554+
const runSink = createRunSink({
555+
emitter: new EventEmitter(),
556+
hookManager: stubHookManager([]),
557+
});
558+
559+
runSink.sink(
560+
event("reactor.error", { error: "reactor gave up", fatal: true }),
561+
);
562+
563+
expect(runSink.getRunError()).toBe("reactor gave up");
564+
expect(runSink.getStatus()).toBe("failed");
565+
});
566+
567+
test("reactor.error without fatal sticky-fails the run", () => {
568+
const runSink = createRunSink({
569+
emitter: new EventEmitter(),
570+
hookManager: stubHookManager([]),
571+
});
572+
573+
runSink.sink(event("reactor.error", { error: "reactor gave up" }));
574+
575+
expect(runSink.getRunError()).toBe("reactor gave up");
576+
expect(runSink.getStatus()).toBe("failed");
577+
});
535578
});

src/session/run-sink.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import type { EventEmitter } from "node:events";
22
import type { ReactorEmittedEvent } from "@intx/inference";
33
import type { LastCycleSource, TokenUsage } from "@intx/types/runtime";
44
import { createPerfReactorObserver } from "../perf/reactor-spans.js";
5-
import { onTurnBoundary } from "../agent/reactor-events.js";
5+
import {
6+
isReactorErrorFatal,
7+
onTurnBoundary,
8+
} from "../agent/reactor-events.js";
69
import {
710
createTurnContextCollector,
811
type LifecycleHookManager,
@@ -191,7 +194,7 @@ export function createRunSink(args: RunSinkArgs): RunSink {
191194
runError = undefined;
192195
onTurnBoundarySnapshot?.();
193196
}
194-
if (event.type === "reactor.error") {
197+
if (event.type === "reactor.error" && isReactorErrorFatal(event.data)) {
195198
const data = event.data as { error: string };
196199
runError = data.error;
197200
}

src/tui/stream-event-map.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,39 @@ describe("mapProductionEvent", () => {
9797
]);
9898
});
9999

100+
test("non-fatal reactor.error paints the error without idling", () => {
101+
expect(
102+
mapProductionEvent({
103+
type: "reactor.error",
104+
data: { error: "transient checkpoint write", fatal: false },
105+
}),
106+
).toEqual([{ type: "error", message: "transient checkpoint write" }]);
107+
});
108+
109+
test("fatal reactor.error paints the error and idles", () => {
110+
expect(
111+
mapProductionEvent({
112+
type: "reactor.error",
113+
data: { error: "gave up", fatal: true },
114+
}),
115+
).toEqual([
116+
{ type: "error", message: "gave up" },
117+
{ type: "run", state: "idle" },
118+
]);
119+
});
120+
121+
test("reactor.error without fatal paints the error and idles", () => {
122+
expect(
123+
mapProductionEvent({
124+
type: "reactor.error",
125+
data: { error: "gave up" },
126+
}),
127+
).toEqual([
128+
{ type: "error", message: "gave up" },
129+
{ type: "run", state: "idle" },
130+
]);
131+
});
132+
100133
test("connector.reply after deltas is skipped (already painted)", () => {
101134
const ctx = createStreamMapContext();
102135
mapProductionEvent(
@@ -111,6 +144,29 @@ describe("mapProductionEvent", () => {
111144
).toEqual([]);
112145
});
113146

147+
test("non-fatal reactor.error keeps connector.reply suppression after deltas", () => {
148+
const ctx = createStreamMapContext();
149+
mapProductionEvent(
150+
{ type: "inference.text.delta", data: { token: "partial" } },
151+
ctx,
152+
);
153+
expect(
154+
mapProductionEvent(
155+
{
156+
type: "reactor.error",
157+
data: { error: "transient checkpoint write", fatal: false },
158+
},
159+
ctx,
160+
),
161+
).toEqual([{ type: "error", message: "transient checkpoint write" }]);
162+
expect(
163+
mapProductionEvent(
164+
{ type: "connector.reply", data: { content: "final answer" } },
165+
ctx,
166+
),
167+
).toEqual([]);
168+
});
169+
114170
test("connector.reply without prior deltas becomes assistant", () => {
115171
expect(
116172
mapProductionEvent({

src/tui/stream-event-map.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
splitPendingControlTail,
1010
stripTerminalControlSequences,
1111
} from "../util/control-char-strip.js";
12+
import { isReactorErrorFatal } from "../agent/reactor-events.js";
1213
import { terminalProviderFailureMessage } from "../inference-error-message.js";
1314
import {
1415
normalizeInferenceErrorForTerminal,
@@ -574,6 +575,11 @@ function mapEvent(
574575
case "reactor.error": {
575576
const error =
576577
typeof data.error === "string" ? data.error : "reactor error";
578+
// Non-fatal errors don't end the turn: keep delta suppression so a
579+
// later connector.reply doesn't repaint already-streamed text.
580+
if (!isReactorErrorFatal(event.data)) {
581+
return [{ type: "error", message: error }];
582+
}
577583
if (ctx) ctx.hadTextDelta = false;
578584
return [
579585
...disarmAttempt(ctx),

src/tui/turn-state.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,4 +508,41 @@ describe("repetition tracking", () => {
508508
expect(restarted.repeatingSinceTokenCount).toBeNull();
509509
expect(restarted.streamText).toBe("");
510510
});
511+
512+
test("non-fatal reactor.error leaves the turn running", () => {
513+
const running = fold([
514+
{ type: "inference.start" },
515+
{ type: "inference.text.delta", data: { token: "hi" } },
516+
]);
517+
const continued = turnStateFromEvent(
518+
running,
519+
{
520+
type: "reactor.error",
521+
data: { error: "transient checkpoint write", fatal: false },
522+
},
523+
100,
524+
);
525+
expect(continued.status).toBe("running");
526+
expect(continued.isProcessing).toBe(true);
527+
});
528+
529+
test("fatal reactor.error fails the turn", () => {
530+
const running = fold([{ type: "inference.start" }]);
531+
const failed = turnStateFromEvent(
532+
running,
533+
{ type: "reactor.error", data: { error: "gave up", fatal: true } },
534+
100,
535+
);
536+
expect(failed.status).toBe("failed");
537+
});
538+
539+
test("reactor.error without fatal fails the turn", () => {
540+
const running = fold([{ type: "inference.start" }]);
541+
const failed = turnStateFromEvent(
542+
running,
543+
{ type: "reactor.error", data: { error: "gave up" } },
544+
100,
545+
);
546+
expect(failed.status).toBe("failed");
547+
});
511548
});

src/tui/turn-state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { type } from "arktype";
1313

14+
import { isReactorErrorFatal } from "../agent/reactor-events.js";
1415
import type { TurnStatus } from "./session-chrome.js";
1516

1617
// Bound on the accumulated stream text kept for the current cycle. Comfortably
@@ -687,6 +688,7 @@ export function turnStateFromEvent(
687688
});
688689

689690
case "reactor.error":
691+
if (!isReactorErrorFatal(event.data)) return state;
690692
return carryBlockedGateCount(state, {
691693
...initialTurnState(nowMs),
692694
status: "failed",

0 commit comments

Comments
 (0)