@north-light/crouter 0.3.295 → 0.3.297

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.
@@ -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
- const doc = this.document(this.cols, state.sourceRevision);
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(state, delta, this.bodyHeight(state), doc.lines.length);
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.state = scrollBy(state, key.delta * bodyHeight, bodyHeight, doc.lines.length);
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} scroll document ${DIM}h/l${RESET} pan visible wide diagrams`);
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. Free-scrolling (`u`/`d`) is untouched until the next anchor move. */
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. Free-scrolling (`u`/`d`) is untouched until the next anchor move. */
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
- /** The active branch's entry ids, root→leaf. */
238
- branchIds() {
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 (openai ultra/strong are both
1272
- // gpt-5.6-sol) — dropping it here silently misroutes turn-time rotation.
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
- test('snapshot_tail is ignored when the session has no stable message ids', async () => {
249
- const token = tok('snapshot-tail-no-ids');
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
- assert.equal(full.welcome.snapshot.messageIds, undefined, 'the fake session has no stable ids');
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.equal(tailed.welcome.snapshot.messageIds, undefined, 'the tail request cannot invent stable ids');
255
- assert.deepEqual(tailed.welcome.snapshot.messages, full.welcome.snapshot.messages, 'a welcome without stable ids keeps the full history instead of stranding an unpageable tail');
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 only its pending provider episode, never admitted or invalidated episodes', { timeout: 90_000 }, async () => {
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
- const admittedNode = await h.spawnHeadlessChild(parent, 'admitted provider episode', { model: OPENAI_STRONG.spec });
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
- assert.equal(admitProviderRetryEpisode(admittedNode, admittedPending.sessionFile, admittedFault), true, 'the dispatch boundary durably admits the matching episode');
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(admittedNode), admittedStarts, 'an admitted episode is never replayed by replacement startup');
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 only a durable pending episode. Revive deliberately
35
- * clears the ordinary fault marker before this boundary, so the episode is
36
- * the recovery authority and admitted/invalidated records remain inert. */
37
- recoverPendingOnStartup(): void;
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;