@north-light/crouter 0.3.296 → 0.3.298
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clients/attach/render/card-presentation.js +23 -6
- package/dist/clients/attach/viewer.js +996 -1044
- package/dist/clients/inbox/review/document-surface.d.ts +14 -0
- package/dist/clients/inbox/review/document-surface.js +84 -4
- package/dist/clients/inbox/review/frame.js +1 -1
- package/dist/clients/inbox/review/state.d.ts +10 -1
- package/dist/clients/inbox/review/state.js +45 -1
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +53 -8
- package/dist/core/__tests__/seam/broker-attach-multiclient.test.js +11 -5
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +80 -4
- package/dist/core/runtime/broker/fault-retry.d.ts +20 -4
- package/dist/core/runtime/broker/fault-retry.js +59 -10
- package/dist/core/runtime/broker.js +15 -5
- package/dist/core/runtime/fault.d.ts +11 -1
- package/dist/core/runtime/fault.js +16 -1
- package/dist/core/runtime/launch.js +2 -2
- package/dist/types.js +1 -1
- package/package.json +8 -8
- package/runtime.lock.json +1430 -468
|
@@ -62,6 +62,11 @@ export declare class ReviewDocumentSurface implements SurfaceComponent {
|
|
|
62
62
|
* `paneOrigin` is status-row copy only: teardown reads `paneId`. */
|
|
63
63
|
private paneId;
|
|
64
64
|
private paneOrigin;
|
|
65
|
+
/** Row increments of a half-page move still to be painted, and the timer
|
|
66
|
+
* walking them. A fresh press settles whatever is in flight before starting
|
|
67
|
+
* its own move, so a held key cannot pile up overlapping animations. */
|
|
68
|
+
private scrollSteps;
|
|
69
|
+
private scrollTimer;
|
|
65
70
|
private state;
|
|
66
71
|
private cache;
|
|
67
72
|
/** Every non-deleted comment the daemon currently projects, newest last. */
|
|
@@ -98,6 +103,15 @@ export declare class ReviewDocumentSurface implements SurfaceComponent {
|
|
|
98
103
|
/** A mounted review consumes every notch, ready or not: the transcript
|
|
99
104
|
* beneath it must never scroll while the document stands over it. */
|
|
100
105
|
handleWheel(direction: 'up' | 'down', lines: number): boolean;
|
|
106
|
+
/** A half-page move of the view and the selection together, painted at three
|
|
107
|
+
* midpoints before its destination so the document slides rather than jumps.
|
|
108
|
+
* Whatever is still in flight lands first, keeping every press exact. */
|
|
109
|
+
private startScrollAnimation;
|
|
110
|
+
private scheduleScrollStep;
|
|
111
|
+
/** Land every step still to be painted on the state at once. */
|
|
112
|
+
private settleScrollAnimation;
|
|
113
|
+
private stopScrollAnimation;
|
|
114
|
+
private applyScrollRows;
|
|
101
115
|
/** Repaints are scheduled by the host; this surface never draws directly. */
|
|
102
116
|
invalidate(): void;
|
|
103
117
|
/** The mounted handle is this surface's repaint and close channel, and the
|
|
@@ -11,9 +11,15 @@ import { matchReviewKey, reviewKeyText } from './keys.js';
|
|
|
11
11
|
import { renderInputBuffer } from '../tui/render.js';
|
|
12
12
|
import { openReviewedFileInEditor } from './nvim.js';
|
|
13
13
|
import { buildLineMap, remapRange } from './remap.js';
|
|
14
|
-
import { cleanPastedText, closeCompose, closeHelp, closeList, commitCompose, ensureAnchorVisible, initReviewState, moveActiveUnit, moveListIndex, openComposeEdit, openComposeNew, openHelp, openList, scrollBy, textBackspace, textDelete, textEnd, textHome, textInsert, textVerticalWrapped, textWordBackspace, textWordLeft, textWordRight, deleteAtListIndex, undoLast, } from './state.js';
|
|
14
|
+
import { cleanPastedText, closeCompose, closeHelp, closeList, commitCompose, ensureAnchorVisible, halfPageRows, initReviewState, moveActiveUnit, moveListIndex, moveViewRows, openComposeEdit, openComposeNew, openHelp, openList, scrollBy, textBackspace, textDelete, textEnd, textHome, textInsert, textVerticalWrapped, textWordBackspace, textWordLeft, textWordRight, deleteAtListIndex, undoLast, } from './state.js';
|
|
15
15
|
/** The widest the document is ever rendered, however wide the pane is. */
|
|
16
16
|
const DOC_MAX_COLS = 126;
|
|
17
|
+
/** How many places a half-page move is painted at: three midpoints, then the
|
|
18
|
+
* destination. A move of fewer rows than this uses one step per row. */
|
|
19
|
+
const SCROLL_STEPS = 4;
|
|
20
|
+
/** Milliseconds between painted steps. pi-tui paints at most every 16ms, so a
|
|
21
|
+
* shorter gap would drop steps instead of showing them. */
|
|
22
|
+
const SCROLL_STEP_MS = 24;
|
|
17
23
|
/** The one mounted review surface: existing review rendering and navigation
|
|
18
24
|
* over a live file, with comments projected from the daemon and ranges
|
|
19
25
|
* remapped by this terminal as the file changes, plus a status row naming the
|
|
@@ -54,6 +60,11 @@ export class ReviewDocumentSurface {
|
|
|
54
60
|
* `paneOrigin` is status-row copy only: teardown reads `paneId`. */
|
|
55
61
|
paneId;
|
|
56
62
|
paneOrigin;
|
|
63
|
+
/** Row increments of a half-page move still to be painted, and the timer
|
|
64
|
+
* walking them. A fresh press settles whatever is in flight before starting
|
|
65
|
+
* its own move, so a held key cannot pile up overlapping animations. */
|
|
66
|
+
scrollSteps = [];
|
|
67
|
+
scrollTimer;
|
|
57
68
|
state;
|
|
58
69
|
cache;
|
|
59
70
|
/** Every non-deleted comment the daemon currently projects, newest last. */
|
|
@@ -237,12 +248,61 @@ export class ReviewDocumentSurface {
|
|
|
237
248
|
const state = this.state;
|
|
238
249
|
if (this.status !== 'ready' || state === undefined)
|
|
239
250
|
return true;
|
|
240
|
-
|
|
251
|
+
this.settleScrollAnimation();
|
|
252
|
+
const current = this.state ?? state;
|
|
253
|
+
const doc = this.document(this.cols, current.sourceRevision);
|
|
241
254
|
const delta = (direction === 'up' ? -1 : 1) * Math.max(1, Math.floor(lines));
|
|
242
|
-
this.state = scrollBy(
|
|
255
|
+
this.state = scrollBy(current, delta, this.bodyHeight(current), doc.lines.length);
|
|
243
256
|
this.repaint();
|
|
244
257
|
return true;
|
|
245
258
|
}
|
|
259
|
+
/** A half-page move of the view and the selection together, painted at three
|
|
260
|
+
* midpoints before its destination so the document slides rather than jumps.
|
|
261
|
+
* Whatever is still in flight lands first, keeping every press exact. */
|
|
262
|
+
startScrollAnimation(rows) {
|
|
263
|
+
this.settleScrollAnimation();
|
|
264
|
+
const steps = splitScrollSteps(rows);
|
|
265
|
+
const first = steps.shift();
|
|
266
|
+
if (first === undefined)
|
|
267
|
+
return;
|
|
268
|
+
this.applyScrollRows(first);
|
|
269
|
+
this.scrollSteps = steps;
|
|
270
|
+
this.scheduleScrollStep();
|
|
271
|
+
}
|
|
272
|
+
scheduleScrollStep() {
|
|
273
|
+
if (this.scrollSteps.length === 0)
|
|
274
|
+
return;
|
|
275
|
+
this.scrollTimer = setTimeout(() => {
|
|
276
|
+
this.scrollTimer = undefined;
|
|
277
|
+
const rows = this.scrollSteps.shift();
|
|
278
|
+
if (rows === undefined || this.closed)
|
|
279
|
+
return;
|
|
280
|
+
this.applyScrollRows(rows);
|
|
281
|
+
this.repaint();
|
|
282
|
+
this.scheduleScrollStep();
|
|
283
|
+
}, SCROLL_STEP_MS);
|
|
284
|
+
}
|
|
285
|
+
/** Land every step still to be painted on the state at once. */
|
|
286
|
+
settleScrollAnimation() {
|
|
287
|
+
this.stopScrollAnimation();
|
|
288
|
+
const pending = this.scrollSteps;
|
|
289
|
+
this.scrollSteps = [];
|
|
290
|
+
for (const rows of pending)
|
|
291
|
+
this.applyScrollRows(rows);
|
|
292
|
+
}
|
|
293
|
+
stopScrollAnimation() {
|
|
294
|
+
if (this.scrollTimer === undefined)
|
|
295
|
+
return;
|
|
296
|
+
clearTimeout(this.scrollTimer);
|
|
297
|
+
this.scrollTimer = undefined;
|
|
298
|
+
}
|
|
299
|
+
applyScrollRows(rows) {
|
|
300
|
+
const state = this.state;
|
|
301
|
+
if (this.status !== 'ready' || state === undefined)
|
|
302
|
+
return;
|
|
303
|
+
const doc = this.document(this.cols, state.sourceRevision);
|
|
304
|
+
this.state = moveViewRows(state, rows, this.bodyHeight(state), doc.lines.length);
|
|
305
|
+
}
|
|
246
306
|
/** Repaints are scheduled by the host; this surface never draws directly. */
|
|
247
307
|
invalidate() {
|
|
248
308
|
if (!this.closed)
|
|
@@ -356,6 +416,8 @@ export class ReviewDocumentSurface {
|
|
|
356
416
|
// surface to every already-in-flight asynchronous continuation.
|
|
357
417
|
this.source.close();
|
|
358
418
|
this.closed = true;
|
|
419
|
+
this.stopScrollAnimation();
|
|
420
|
+
this.scrollSteps = [];
|
|
359
421
|
this.unsubscribeComments();
|
|
360
422
|
this.cache = undefined;
|
|
361
423
|
}
|
|
@@ -797,7 +859,7 @@ export class ReviewDocumentSurface {
|
|
|
797
859
|
this.state = ensureAnchorVisible(moveActiveUnit(state, key.delta, true), doc, bodyHeight);
|
|
798
860
|
}
|
|
799
861
|
else if (key.type === 'scrollPage') {
|
|
800
|
-
this.
|
|
862
|
+
this.startScrollAnimation(key.delta * halfPageRows(bodyHeight));
|
|
801
863
|
}
|
|
802
864
|
else if ((key.type === 'pan' || (key.type === 'arrow' && (key.direction === 'left' || key.direction === 'right')))
|
|
803
865
|
&& maxHScroll(state, doc, this.cols, this.rows, this.gutterWidth()) > 0) {
|
|
@@ -1000,6 +1062,24 @@ export class ReviewDocumentSurface {
|
|
|
1000
1062
|
}
|
|
1001
1063
|
}
|
|
1002
1064
|
// ── Pure helpers ─────────────────────────────────────────────────────────────
|
|
1065
|
+
/** Split a row move into increments summing exactly to it — one per painted
|
|
1066
|
+
* step, and one per row when the move is shorter than the step count (a jump
|
|
1067
|
+
* of a single row is painted once, not animated). */
|
|
1068
|
+
function splitScrollSteps(rows) {
|
|
1069
|
+
const magnitude = Math.abs(rows);
|
|
1070
|
+
if (magnitude === 0)
|
|
1071
|
+
return [];
|
|
1072
|
+
const direction = Math.sign(rows);
|
|
1073
|
+
const count = Math.min(SCROLL_STEPS, magnitude);
|
|
1074
|
+
const steps = [];
|
|
1075
|
+
let painted = 0;
|
|
1076
|
+
for (let i = 1; i <= count; i++) {
|
|
1077
|
+
const reached = Math.round((magnitude * i) / count);
|
|
1078
|
+
steps.push((reached - painted) * direction);
|
|
1079
|
+
painted = reached;
|
|
1080
|
+
}
|
|
1081
|
+
return steps;
|
|
1082
|
+
}
|
|
1003
1083
|
/** Exactly `rows` lines, each exactly `width` cells. */
|
|
1004
1084
|
function exactRows(lines, rows, width) {
|
|
1005
1085
|
const frame = lines.slice(0, rows).map((line) => padTo(line, width));
|
|
@@ -129,7 +129,7 @@ export function renderReviewFrame(state, fileLabel, doc, cols, rows, opts) {
|
|
|
129
129
|
else if (state.mode === 'help') {
|
|
130
130
|
footer.push(' Keys:');
|
|
131
131
|
footer.push(` ${DIM}j/k${RESET} move anchor ${DIM}shift-j/k${RESET} extend selection`);
|
|
132
|
-
footer.push(` ${DIM}u/d${RESET}
|
|
132
|
+
footer.push(` ${DIM}u/d${RESET} move half a page ${DIM}h/l${RESET} pan visible wide diagrams`);
|
|
133
133
|
footer.push(` ${DIM}space c${RESET} compose comment ${DIM}space l${RESET} list comments`);
|
|
134
134
|
footer.push(` ${DIM}space u${RESET} undo last comment ${DIM}space e${RESET} edit in $EDITOR`);
|
|
135
135
|
footer.push(` ${DIM}space s${RESET} submit review`);
|
|
@@ -80,9 +80,18 @@ export declare function anchorRowRange(units: AnchorUnit[], lo: number, hi: numb
|
|
|
80
80
|
* clamped to the document. */
|
|
81
81
|
export declare function scrollToReveal(scroll: number, first: number, last: number, bodyHeight: number, total: number, margin?: number): number;
|
|
82
82
|
/** Auto-scroll so the active unit (the moving edge of a selection) is
|
|
83
|
-
* visible.
|
|
83
|
+
* visible. The wheel free-scrolls and is untouched until the next anchor move. */
|
|
84
84
|
export declare function ensureAnchorVisible(state: ReviewState, doc: RenderedDoc, bodyHeight: number): ReviewState;
|
|
85
85
|
export declare function scrollBy(state: ReviewState, delta: number, bodyHeight: number, totalLines: number): ReviewState;
|
|
86
|
+
/** Rows one `u`/`d` press travels: half the visible body, and never fewer than
|
|
87
|
+
* one row, so a diagram taller than the pane cannot hold the document still. */
|
|
88
|
+
export declare function halfPageRows(bodyHeight: number): number;
|
|
89
|
+
/** Move the view by `rows` and carry the anchored unit the same distance, so
|
|
90
|
+
* the selection keeps its place on screen rather than being left behind. The
|
|
91
|
+
* selection lands on the unit owning the row it would occupy after the move,
|
|
92
|
+
* clamped into the rows the frame actually paints; at the end of the document,
|
|
93
|
+
* where the view can no longer move, the selection walks the rest on its own. */
|
|
94
|
+
export declare function moveViewRows(state: ReviewState, rows: number, bodyHeight: number, totalRows: number): ReviewState;
|
|
86
95
|
export declare function openComposeNew(state: ReviewState): ReviewState;
|
|
87
96
|
export declare function openComposeEdit(state: ReviewState, index: number): ReviewState;
|
|
88
97
|
export declare function closeCompose(state: ReviewState): ReviewState;
|
|
@@ -106,7 +106,7 @@ export function scrollToReveal(scroll, first, last, bodyHeight, total, margin =
|
|
|
106
106
|
return Math.max(0, Math.min(next, maxScroll));
|
|
107
107
|
}
|
|
108
108
|
/** Auto-scroll so the active unit (the moving edge of a selection) is
|
|
109
|
-
* visible.
|
|
109
|
+
* visible. The wheel free-scrolls and is untouched until the next anchor move. */
|
|
110
110
|
export function ensureAnchorVisible(state, doc, bodyHeight) {
|
|
111
111
|
const range = anchorRowRange(state.units, state.activeUnit, state.activeUnit);
|
|
112
112
|
if (range === null)
|
|
@@ -118,6 +118,50 @@ export function scrollBy(state, delta, bodyHeight, totalLines) {
|
|
|
118
118
|
const maxScroll = Math.max(0, totalLines - bodyHeight);
|
|
119
119
|
return { ...state, scroll: Math.max(0, Math.min(state.scroll + delta, maxScroll)) };
|
|
120
120
|
}
|
|
121
|
+
/** Rows one `u`/`d` press travels: half the visible body, and never fewer than
|
|
122
|
+
* one row, so a diagram taller than the pane cannot hold the document still. */
|
|
123
|
+
export function halfPageRows(bodyHeight) {
|
|
124
|
+
return Math.max(1, Math.floor(bodyHeight / 2));
|
|
125
|
+
}
|
|
126
|
+
/** Move the view by `rows` and carry the anchored unit the same distance, so
|
|
127
|
+
* the selection keeps its place on screen rather than being left behind. The
|
|
128
|
+
* selection lands on the unit owning the row it would occupy after the move,
|
|
129
|
+
* clamped into the rows the frame actually paints; at the end of the document,
|
|
130
|
+
* where the view can no longer move, the selection walks the rest on its own. */
|
|
131
|
+
export function moveViewRows(state, rows, bodyHeight, totalRows) {
|
|
132
|
+
if (rows === 0)
|
|
133
|
+
return state;
|
|
134
|
+
const direction = rows > 0 ? 1 : -1;
|
|
135
|
+
const maxScroll = Math.max(0, totalRows - bodyHeight);
|
|
136
|
+
const scroll = Math.max(0, Math.min(state.scroll + rows, maxScroll));
|
|
137
|
+
// The frame overwrites the first and last body rows with its `more above` /
|
|
138
|
+
// `more below` indicators, and an indicator row carries no cursor, so the
|
|
139
|
+
// selection is kept inside the rows that remain the document's own.
|
|
140
|
+
const lo = scroll + (scroll > 0 ? 1 : 0);
|
|
141
|
+
const hi = Math.max(lo, scroll + bodyHeight - 1 - (scroll + bodyHeight < totalRows ? 1 : 0));
|
|
142
|
+
const anchorRow = state.units[state.activeUnit]?.firstRow ?? state.scroll;
|
|
143
|
+
const target = Math.max(lo, Math.min(anchorRow + rows, hi));
|
|
144
|
+
const activeUnit = unitNearRow(state, target, lo, hi, direction) ?? state.activeUnit;
|
|
145
|
+
if (scroll === state.scroll && activeUnit === state.activeUnit)
|
|
146
|
+
return state;
|
|
147
|
+
return { ...state, scroll, activeUnit, selectionAnchor: null };
|
|
148
|
+
}
|
|
149
|
+
/** The unit owning the row nearest `row` within `lo..hi`, preferring the
|
|
150
|
+
* direction of travel. Separator rows own no unit, hence the search. */
|
|
151
|
+
function unitNearRow(state, row, lo, hi, direction) {
|
|
152
|
+
const last = Math.max(0, state.units.length - 1);
|
|
153
|
+
for (let step = 0; step <= hi - lo; step++) {
|
|
154
|
+
for (const candidate of [row + step * direction, row - step * direction]) {
|
|
155
|
+
if (candidate < lo || candidate > hi)
|
|
156
|
+
continue;
|
|
157
|
+
const unit = state.rowUnits[candidate];
|
|
158
|
+
if (unit === null || unit === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
return Math.max(0, Math.min(last, unit));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
121
165
|
export function openComposeNew(state) {
|
|
122
166
|
return { ...state, mode: 'compose', compose: { buffer: '', cursor: 0, editingId: null, returnMode: 'view', anchor: currentAnchor(state) } };
|
|
123
167
|
}
|
|
@@ -119,6 +119,8 @@ interface FakeSessionManager {
|
|
|
119
119
|
getLeafId: () => string | null;
|
|
120
120
|
getEntry: (id: string) => TreeEntry | undefined;
|
|
121
121
|
getTree: () => SessionTreeNode[];
|
|
122
|
+
getBranch: (fromId?: string) => TreeEntry[];
|
|
123
|
+
getEntries: () => TreeEntry[];
|
|
122
124
|
getUserMessagesForForking: () => Array<{
|
|
123
125
|
entryId: string;
|
|
124
126
|
text: string;
|
|
@@ -194,6 +196,10 @@ declare class FakeSession {
|
|
|
194
196
|
get isStreaming(): boolean;
|
|
195
197
|
getSessionStats(): Record<string, unknown>;
|
|
196
198
|
private syncTreeSnapshot;
|
|
199
|
+
/** Reads the crashed process's last `fake-pi.tree.json` (if any) and rebuilds
|
|
200
|
+
* this process's tree from it. Best-effort: a first boot (no snapshot yet)
|
|
201
|
+
* leaves the tree empty, matching today's behavior. */
|
|
202
|
+
private hydrateFromPriorSnapshot;
|
|
197
203
|
setSessionName(_name: string): void;
|
|
198
204
|
prompt(text: string, options?: {
|
|
199
205
|
preflightResult?: (success: boolean) => void;
|
|
@@ -192,6 +192,20 @@ class SessionTree {
|
|
|
192
192
|
this.leafId = entry.id;
|
|
193
193
|
return entry;
|
|
194
194
|
}
|
|
195
|
+
/** Reconstructs a prior process's tree from its persisted snapshot — the
|
|
196
|
+
* fake's analog of the real SessionManager.open() reading every entry back
|
|
197
|
+
* from the .jsonl. A genuine resume (not a fresh cycle) must see this
|
|
198
|
+
* broker's actual history, not an empty tree, or nothing downstream that
|
|
199
|
+
* reads the branch past a crash can ever observe real prior content. */
|
|
200
|
+
hydrate(entries, leafId) {
|
|
201
|
+
for (const entry of entries) {
|
|
202
|
+
this.entries.set(entry.id, entry);
|
|
203
|
+
const siblings = this.children.get(entry.parentId) ?? [];
|
|
204
|
+
siblings.push(entry.id);
|
|
205
|
+
this.children.set(entry.parentId, siblings);
|
|
206
|
+
}
|
|
207
|
+
this.leafId = leafId;
|
|
208
|
+
}
|
|
195
209
|
/** Synthetic in-place rewind for the broker navigate_tree regression lock:
|
|
196
210
|
* drop the last turn (assistant leaf + its user parent) by re-anchoring the
|
|
197
211
|
* leaf past it. The abandoned tail is left in the map (not deleted), mirroring
|
|
@@ -228,17 +242,18 @@ class SessionTree {
|
|
|
228
242
|
/** The active branch root→leaf — the real SessionManager.getBranch(). The
|
|
229
243
|
* extensions that scan for their own marker read THIS, not the whole file:
|
|
230
244
|
* a new cycle must not see a previous branch's markers. */
|
|
231
|
-
getBranch() {
|
|
232
|
-
return this.branchIds().map((id) => this.entries.get(id)).filter((entry) => entry !== undefined);
|
|
245
|
+
getBranch(fromId) {
|
|
246
|
+
return this.branchIds(fromId).map((id) => this.entries.get(id)).filter((entry) => entry !== undefined);
|
|
233
247
|
}
|
|
234
248
|
branchMessages() {
|
|
235
249
|
return this.branchIds().map((id) => this.entries.get(id)?.message).filter((m) => m !== undefined);
|
|
236
250
|
}
|
|
237
|
-
/**
|
|
238
|
-
|
|
251
|
+
/** One branch's entry ids, root→leaf, from `fromId` (default: the current
|
|
252
|
+
* leaf) — the real SessionManager.getBranch(fromId?) contract. */
|
|
253
|
+
branchIds(fromId) {
|
|
239
254
|
const ids = [];
|
|
240
|
-
let cur = this.leafId;
|
|
241
|
-
while (cur !== null) {
|
|
255
|
+
let cur = fromId ?? this.leafId;
|
|
256
|
+
while (cur !== null && cur !== undefined) {
|
|
242
257
|
const entry = this.entries.get(cur);
|
|
243
258
|
if (entry === undefined)
|
|
244
259
|
break;
|
|
@@ -324,6 +339,14 @@ class FakeSession {
|
|
|
324
339
|
this.recordModel();
|
|
325
340
|
const configured = Number(process.env['CRTR_FAKE_ENGINE_PROMPT_FAILS'] ?? '0');
|
|
326
341
|
this.promptFailuresRemaining = Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 0;
|
|
342
|
+
// A genuine resume (not a fresh cycle) must see this broker's own prior
|
|
343
|
+
// history — the fake's analog of the real SessionManager.open() replaying
|
|
344
|
+
// every persisted entry. A cycling revive has already rooted a fresh cycle
|
|
345
|
+
// marker on `sm` before this construction (broker.ts), which is exactly what
|
|
346
|
+
// distinguishes it here: `sm.getLeafId()` is non-null only on that path, so
|
|
347
|
+
// a cycling boot deliberately skips hydration and starts clean.
|
|
348
|
+
if (sm.resumed && sm.getLeafId() === null)
|
|
349
|
+
this.hydrateFromPriorSnapshot();
|
|
327
350
|
this.syncTreeSnapshot();
|
|
328
351
|
}
|
|
329
352
|
// --- broker-read getters. buildSnapshot (on a client hello) reads messages/
|
|
@@ -348,6 +371,8 @@ class FakeSession {
|
|
|
348
371
|
getLeafId: () => this.tree.getLeafId(),
|
|
349
372
|
getEntry: (id) => this.tree.getEntry(id),
|
|
350
373
|
getTree: () => this.tree.getTree(),
|
|
374
|
+
getBranch: (fromId) => this.tree.getBranch(fromId),
|
|
375
|
+
getEntries: () => this.tree.getEntries(),
|
|
351
376
|
getUserMessagesForForking: () => this.tree.getUserMessagesForForking(),
|
|
352
377
|
getSessionId: () => this.sm.getSessionId(),
|
|
353
378
|
getSessionFile: () => this.sm.getSessionFile(),
|
|
@@ -439,6 +464,10 @@ class FakeSession {
|
|
|
439
464
|
leafId: this.tree.getLeafId(),
|
|
440
465
|
activeBranch: this.tree.buildSessionContext().messages,
|
|
441
466
|
tree: this.tree.getTree(),
|
|
467
|
+
// Flat entries + leafId are the reload shape a future process's
|
|
468
|
+
// hydrateFromPriorSnapshot() reads back — getTree()'s nested form is
|
|
469
|
+
// proof-only and not reconstructed from.
|
|
470
|
+
entries: this.tree.getEntries(),
|
|
442
471
|
userMessagesForForking: this.tree.getUserMessagesForForking(),
|
|
443
472
|
}, null, 2));
|
|
444
473
|
}
|
|
@@ -446,6 +475,21 @@ class FakeSession {
|
|
|
446
475
|
/* best effort */
|
|
447
476
|
}
|
|
448
477
|
}
|
|
478
|
+
/** Reads the crashed process's last `fake-pi.tree.json` (if any) and rebuilds
|
|
479
|
+
* this process's tree from it. Best-effort: a first boot (no snapshot yet)
|
|
480
|
+
* leaves the tree empty, matching today's behavior. */
|
|
481
|
+
hydrateFromPriorSnapshot() {
|
|
482
|
+
try {
|
|
483
|
+
const raw = readFileSync(join(this.dir, 'fake-pi.tree.json'), 'utf8');
|
|
484
|
+
const parsed = JSON.parse(raw);
|
|
485
|
+
if (!Array.isArray(parsed.entries) || parsed.entries.length === 0)
|
|
486
|
+
return;
|
|
487
|
+
this.tree.hydrate(parsed.entries, parsed.leafId ?? null);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
/* no prior snapshot — fresh tree */
|
|
491
|
+
}
|
|
492
|
+
}
|
|
449
493
|
// naming is cosmetic (broker calls it in a try/catch)
|
|
450
494
|
setSessionName(_name) {
|
|
451
495
|
/* no-op recording stub */
|
|
@@ -1197,6 +1241,7 @@ const FAKE_REGISTERED_MODELS = [
|
|
|
1197
1241
|
{ provider: 'anthropic', id: 'claude-opus-4-8' },
|
|
1198
1242
|
{ provider: 'anthropic', id: 'claude-sonnet-5' },
|
|
1199
1243
|
{ provider: 'anthropic', id: 'claude-haiku-4-5' },
|
|
1244
|
+
{ provider: 'openai-codex', id: 'gpt-6-astra' },
|
|
1200
1245
|
{ provider: 'openai-codex', id: 'gpt-5.6-sol' },
|
|
1201
1246
|
{ provider: 'openai-codex', id: 'gpt-5.6-terra' },
|
|
1202
1247
|
{ provider: 'openai-codex', id: 'gpt-5.6-luna' },
|
|
@@ -1268,8 +1313,8 @@ export async function createAgentSessionFromServices(options) {
|
|
|
1268
1313
|
// Parity with the real SDK: a construction-time thinking level IS the
|
|
1269
1314
|
// session's thinking level from the first turn on. The broker formats
|
|
1270
1315
|
// `model:thinking` off this getter, and that suffix is what distinguishes two
|
|
1271
|
-
// ladder cells sharing one model id
|
|
1272
|
-
//
|
|
1316
|
+
// ladder cells sharing one model id — dropping it here silently misroutes
|
|
1317
|
+
// turn-time rotation.
|
|
1273
1318
|
if (options.thinkingLevel !== undefined)
|
|
1274
1319
|
session.setThinkingLevel(options.thinkingLevel);
|
|
1275
1320
|
return { session };
|
|
@@ -245,12 +245,18 @@ test('G10 — retained extension display state rides IN welcome.snapshot.display
|
|
|
245
245
|
assert.equal(live.method, 'setStatus');
|
|
246
246
|
assert.equal(live.statusText, 'painted while attached');
|
|
247
247
|
});
|
|
248
|
-
|
|
249
|
-
|
|
248
|
+
// snapshot_tail — a viewer that asks for the last N messages gets exactly them,
|
|
249
|
+
// each with the stable id it needs to page the omitted history back in. The
|
|
250
|
+
// broker serves a tail only when the session yields those ids; it never invents
|
|
251
|
+
// one, so a tail can never strand its recipient on history it cannot reach.
|
|
252
|
+
test('snapshot_tail serves the last N messages with the stable ids that page the rest back', async () => {
|
|
253
|
+
const token = tok('snapshot-tail');
|
|
250
254
|
h.fakeCmd(id, { cmd: 'stream', text: token });
|
|
251
255
|
const full = await attachUntil(id, 'observer', 'snapshot-tail-full', (a) => JSON.stringify(a.welcome.snapshot.messages).includes(token), 'full snapshot after a tail-less attach');
|
|
252
|
-
|
|
256
|
+
const fullIds = full.welcome.snapshot.messageIds;
|
|
257
|
+
assert.ok(fullIds !== undefined && fullIds.length > 1, 'the session yields a stable id per message');
|
|
258
|
+
assert.equal(fullIds.length, full.welcome.snapshot.messages.length, 'every message in the welcome carries an id');
|
|
253
259
|
const tailed = await attach(id, 'observer', 'snapshot-tail-request', { snapshotTail: 1 });
|
|
254
|
-
assert.
|
|
255
|
-
assert.deepEqual(tailed.welcome.snapshot.messages, full.welcome.snapshot.messages, '
|
|
260
|
+
assert.deepEqual(tailed.welcome.snapshot.messageIds, fullIds.slice(-1), 'the tail carries the ids of the messages it kept');
|
|
261
|
+
assert.deepEqual(tailed.welcome.snapshot.messages, full.welcome.snapshot.messages.slice(-1), 'the tail is the last message alone, not the whole history');
|
|
256
262
|
});
|
|
@@ -15,6 +15,8 @@ import { defaultModelLaddersConfig } from '../../../types.js';
|
|
|
15
15
|
import { admitProviderRetryEpisode, readFault, readProviderRetryEpisode } from '../../runtime/fault.js';
|
|
16
16
|
import { buildPiArgv, normalizeModel } from '../../runtime/launch.js';
|
|
17
17
|
import { encodeFrame } from '../../runtime/broker-protocol.js';
|
|
18
|
+
import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY, generatedContextText, parseCard } from '../../../shared/generated-context.js';
|
|
19
|
+
import { RUNTIME_RESTART_CONTINUATION } from '../../runtime/kickoff.js';
|
|
18
20
|
const MODEL_LADDERS = defaultModelLaddersConfig();
|
|
19
21
|
function resolvedModel(spec) {
|
|
20
22
|
const normalized = normalizeModel(spec, MODEL_LADDERS);
|
|
@@ -131,7 +133,7 @@ test('a managed-cooling terminal authors a rate-limit auto fault whose deadline
|
|
|
131
133
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
132
134
|
assert.equal(agentStartCount(child), baselineStarts + 1, 'the fault-retry timer re-drives the turn exactly once');
|
|
133
135
|
});
|
|
134
|
-
test('a crashed broker re-drives
|
|
136
|
+
test('a crashed broker re-drives a pending episode and a no-progress admitted one, never a progressed or invalidated episode', { timeout: 90_000 }, async () => {
|
|
135
137
|
const parent = h.spawnRoot('durable provider episode parent');
|
|
136
138
|
seedPool('anthropic', false);
|
|
137
139
|
seedPool('openai-codex', false);
|
|
@@ -149,7 +151,13 @@ test('a crashed broker re-drives only its pending provider episode, never admitt
|
|
|
149
151
|
await h.waitFor(() => agentStartCount(pendingNode) >= pendingStarts + 1, { label: 'replacement re-drove the pending episode' });
|
|
150
152
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
151
153
|
assert.equal(agentStartCount(pendingNode), pendingStarts + 1, 'a pending episode re-drives exactly once after broker death');
|
|
152
|
-
|
|
154
|
+
// An admitted episode is re-openable, guarded by a no-progress proof: the
|
|
155
|
+
// branch segment strictly after its anchor must carry no assistant content
|
|
156
|
+
// and no tool activity. Case A proves the no-progress half re-drives exactly
|
|
157
|
+
// once, one attempt higher, with the restart continuation suppressed. Case B
|
|
158
|
+
// proves an admitted episode whose dead attempt DID produce real work is
|
|
159
|
+
// never replayed — the ordinary continuation delivers instead, unchanged.
|
|
160
|
+
const admittedNode = await h.spawnHeadlessChild(parent, 'admitted provider episode (no progress)', { model: OPENAI_STRONG.spec });
|
|
153
161
|
await h.waitFor(() => agentStartCount(admittedNode) >= 1, { label: 'admitted episode fixture started' });
|
|
154
162
|
const admittedKickoffObserver = await attachUntil(admittedNode, 'observer', 'admitted-provider-kickoff-settled', (a) => a.welcome.snapshot.state.isStreaming === false, 'admitted provider episode kickoff settled');
|
|
155
163
|
admittedKickoffObserver.close();
|
|
@@ -160,11 +168,79 @@ test('a crashed broker re-drives only its pending provider episode, never admitt
|
|
|
160
168
|
const admittedPending = await h.waitFor(() => readProviderRetryEpisode(admittedNode)?.state === 'pending' ? readProviderRetryEpisode(admittedNode) : null, { label: 'admitted episode pending record persisted' });
|
|
161
169
|
const admittedFault = readFault(admittedNode);
|
|
162
170
|
assert.ok(admittedFault !== null, 'the pending episode retains its retry fault before admission');
|
|
163
|
-
|
|
171
|
+
// The 60s-future deadline above keeps the ORIGINAL process's own in-process
|
|
172
|
+
// timer from firing before this synthetic admission runs. The episode's own
|
|
173
|
+
// fault gets its nextAt rewritten into the past, matching production: by the
|
|
174
|
+
// time a REAL replacement broker boots, the original deadline has typically
|
|
175
|
+
// already elapsed (see the design's "effectively zero delay" note), so this
|
|
176
|
+
// proves the re-drive without a real ~60s wait.
|
|
177
|
+
const admittedFaultForEpisode = { ...admittedFault, retry: { ...admittedFault.retry, nextAt: new Date(Date.now() - 1_000).toISOString() } };
|
|
178
|
+
assert.equal(admitProviderRetryEpisode(admittedNode, admittedPending.sessionFile, admittedFaultForEpisode), true, 'the dispatch boundary durably admits the matching episode');
|
|
164
179
|
assert.equal(readProviderRetryEpisode(admittedNode)?.state, 'admitted');
|
|
165
180
|
await crashAndRespawn(admittedNode, h.bootCount(admittedNode));
|
|
181
|
+
await h.waitFor(() => agentStartCount(admittedNode) >= admittedStarts + 1, { label: 'replacement re-drove the no-progress admitted episode' });
|
|
182
|
+
// A persistent re-arm would re-drive again immediately (nextAt is now in the
|
|
183
|
+
// past), so a spurious second re-drive would land within a poll interval.
|
|
184
|
+
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
185
|
+
assert.equal(agentStartCount(admittedNode), admittedStarts + 1, 'a no-progress admitted episode re-drives exactly once, never more');
|
|
186
|
+
const admittedBranch = JSON.parse(readFileSync(join(h.home, 'nodes', admittedNode, 'fake-pi.tree.json'), 'utf8')).activeBranch;
|
|
187
|
+
const admittedUserMessages = admittedBranch.filter((message) => message.role === 'user');
|
|
188
|
+
const newestAdmittedUserMessage = admittedUserMessages[admittedUserMessages.length - 1];
|
|
189
|
+
assert.ok(newestAdmittedUserMessage !== undefined, 'the re-drive delivers a user-role recovery message');
|
|
190
|
+
const expectedRecoveryBody = admittedFault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY;
|
|
191
|
+
const redriveCard = parseCard(newestAdmittedUserMessage);
|
|
192
|
+
assert.equal(redriveCard?.kind, 'recovery', 'the re-drive delivers the recovery card, not a duplicate failure entry');
|
|
193
|
+
assert.equal(redriveCard?.body, expectedRecoveryBody, 'the recovery card carries the body matching the fault kind');
|
|
194
|
+
// Every superseded attempt leaves the active branch, so this card is the only
|
|
195
|
+
// visible record of the sequence — it must name which attempt it is. The
|
|
196
|
+
// exact number depends on how far the ladder had already advanced, so assert
|
|
197
|
+
// that the facts are present and coherent rather than pinning a literal.
|
|
198
|
+
const redriveAttempt = Number(redriveCard?.facts['attempt']);
|
|
199
|
+
const redriveMax = Number(redriveCard?.facts['max']);
|
|
200
|
+
assert.ok(Number.isInteger(redriveAttempt) && redriveAttempt >= 1, 'the recovery card names which attempt it is');
|
|
201
|
+
assert.ok(Number.isInteger(redriveMax) && redriveMax >= redriveAttempt, 'the recovery card names the ladder length it counts against');
|
|
202
|
+
assert.ok(!admittedBranch.some((message) => generatedContextText(message) === RUNTIME_RESTART_CONTINUATION), 'a no-progress re-drive suppresses the restart continuation entirely');
|
|
203
|
+
// The fake engine's recovery-card prompt runs a genuine successful turn (no
|
|
204
|
+
// forced failure is queued for it), so the episode clears at settlement —
|
|
205
|
+
// Flow A's success ending, not another failure round. `nextFaultRetry`
|
|
206
|
+
// already advances `fault.retry.attempt` on every admission regardless of
|
|
207
|
+
// this design (unchanged, pre-existing ladder logic); this settle just
|
|
208
|
+
// proves the sequence can end cleanly through the reopened path too.
|
|
209
|
+
assert.equal(readProviderRetryEpisode(admittedNode), null, 'the successful re-drive clears the episode at settlement');
|
|
210
|
+
const progressNode = await h.spawnHeadlessChild(parent, 'admitted provider episode (progress)', { model: OPENAI_STRONG.spec });
|
|
211
|
+
await h.waitFor(() => agentStartCount(progressNode) >= 1, { label: 'progress episode fixture started' });
|
|
212
|
+
const progressKickoffObserver = await attachUntil(progressNode, 'observer', 'progress-provider-kickoff-settled', (a) => a.welcome.snapshot.state.isStreaming === false, 'progress episode kickoff settled');
|
|
213
|
+
progressKickoffObserver.close();
|
|
214
|
+
h.fakeCmd(progressNode, {
|
|
215
|
+
cmd: 'stop', reason: 'error', errorMessage: 'Managed provider pools are temporarily unavailable.', deadline: Date.now() + 60_000,
|
|
216
|
+
});
|
|
217
|
+
const progressPending = await h.waitFor(() => readProviderRetryEpisode(progressNode)?.state === 'pending' ? readProviderRetryEpisode(progressNode) : null, { label: 'progress episode pending record persisted' });
|
|
218
|
+
const progressFault = readFault(progressNode);
|
|
219
|
+
assert.ok(progressFault !== null, 'the pending episode retains its retry fault before admission');
|
|
220
|
+
assert.equal(admitProviderRetryEpisode(progressNode, progressPending.sessionFile, progressFault), true, 'the dispatch boundary durably admits the matching episode');
|
|
221
|
+
assert.equal(readProviderRetryEpisode(progressNode)?.state, 'admitted');
|
|
222
|
+
// A real successful turn past the anchor: proves the dead attempt's slot is
|
|
223
|
+
// no longer the branch tail, so recoverOnStartup's no-progress proof must
|
|
224
|
+
// find this assistant content and refuse to roll it back.
|
|
225
|
+
const progressController = await kit.attach(progressNode, 'controller', 'progress-provider-episode');
|
|
226
|
+
progressController.send({ type: 'prompt', text: `progress past anchor ${Date.now()}` });
|
|
227
|
+
await progressController.waitFrame((f) => f.type === 'agent_end', 'the progress turn settles');
|
|
228
|
+
progressController.close();
|
|
229
|
+
// The natural settle above already clears the episode (any non-error settle
|
|
230
|
+
// clears it unconditionally) — expected, and orthogonal to what this half
|
|
231
|
+
// proves. Restore `admitted` with the SAME original anchor/fault by writing
|
|
232
|
+
// the job-dir file directly, bypassing the guarded API (mirrors the
|
|
233
|
+
// `reload_auth leaves a parked auth fault` precedent above in this file), so
|
|
234
|
+
// the crash below tests the no-progress proof against a genuine post-anchor
|
|
235
|
+
// assistant turn instead of an unreliable real-crash race.
|
|
236
|
+
await h.waitFor(() => readProviderRetryEpisode(progressNode) === null, { label: 'the settled turn cleared the episode' });
|
|
237
|
+
writeFileSync(join(h.home, 'nodes', progressNode, 'job', 'provider-retry'), JSON.stringify({ state: 'admitted', sessionFile: progressPending.sessionFile, anchorEntryId: progressPending.anchorEntryId, fault: progressFault }));
|
|
238
|
+
assert.equal(readProviderRetryEpisode(progressNode)?.state, 'admitted', 'the direct write restores the admitted episode for the crash below');
|
|
239
|
+
const progressStartsBeforeCrash = agentStartCount(progressNode);
|
|
240
|
+
await crashAndRespawn(progressNode, h.bootCount(progressNode));
|
|
166
241
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
167
|
-
assert.equal(agentStartCount(
|
|
242
|
+
assert.equal(agentStartCount(progressNode), progressStartsBeforeCrash, 'progress past the anchor is never replayed by replacement startup');
|
|
243
|
+
assert.equal(readProviderRetryEpisode(progressNode), null, 'the progress branch clears the episode rather than leaving it inert');
|
|
168
244
|
const invalidatedNode = await h.spawnHeadlessChild(parent, 'invalidated provider episode', { model: OPENAI_STRONG.spec });
|
|
169
245
|
await h.waitFor(() => agentStartCount(invalidatedNode) >= 1, { label: 'invalidated episode fixture started' });
|
|
170
246
|
const invalidatedKickoffObserver = await attachUntil(invalidatedNode, 'observer', 'invalidated-provider-kickoff-settled', (a) => a.welcome.snapshot.state.isStreaming === false, 'invalidated provider episode kickoff settled');
|
|
@@ -31,10 +31,26 @@ export declare class FaultRetry {
|
|
|
31
31
|
onEvent(event: BrokerToClient): void;
|
|
32
32
|
isActiveAutoFault(fault: ReturnType<typeof readFault>): fault is NonNullable<ReturnType<typeof readFault>>;
|
|
33
33
|
publishSettledProviderFault(generation: SubscriptionGeneration): void;
|
|
34
|
-
/** Startup re-drive reads
|
|
35
|
-
* clears the ordinary fault marker before this
|
|
36
|
-
* the
|
|
37
|
-
|
|
34
|
+
/** Startup re-drive reads the durable episode surviving from the process this
|
|
35
|
+
* replaces. Revive deliberately clears the ordinary fault marker before this
|
|
36
|
+
* boundary, so the episode is the recovery authority. A `pending` episode
|
|
37
|
+
* re-arms unconditionally, exactly as before. An `admitted` episode re-arms
|
|
38
|
+
* too, but only under a no-progress proof: reopened to `pending` when the
|
|
39
|
+
* branch segment from its anchor to the current leaf carries no assistant
|
|
40
|
+
* content and no tool activity — the dead attempt did nothing that rolling
|
|
41
|
+
* back would destroy. An anchor absent from the loaded branch cannot happen
|
|
42
|
+
* in production (session-file identity and the new-cycle guard already rule
|
|
43
|
+
* it out below), so it is treated the same as "no progress" rather than
|
|
44
|
+
* blocking the ladder. Returns 'retry-armed' when the timer now owns the
|
|
45
|
+
* first turn, 'none' when the caller's default kickoff should proceed. */
|
|
46
|
+
recoverOnStartup(): 'retry-armed' | 'none';
|
|
47
|
+
/** True iff the branch segment strictly after `anchorEntryId` (root→leaf,
|
|
48
|
+
* anchor exclusive) contains an assistant message with non-empty content or
|
|
49
|
+
* a tool result — the two shapes a runtime card and a `content: []`
|
|
50
|
+
* error-settled assistant message are not. An anchor missing from the loaded
|
|
51
|
+
* branch cannot be distinguished from "no progress" here; callers treat it
|
|
52
|
+
* as no progress rather than as a positive finding. */
|
|
53
|
+
private hasProgressPastAnchor;
|
|
38
54
|
schedule(): void;
|
|
39
55
|
private maybeSwitchProviderForRetry;
|
|
40
56
|
private maybeFallbackOnUnusableModel;
|