@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.
@@ -2,7 +2,7 @@ import { realpathSync } from 'node:fs';
2
2
  import { envModelIntent, envNodeCwd, envProfileId } from '../../../shared/env.js';
3
3
  import { isContextOverflow } from '@earendil-works/pi-ai';
4
4
  import { classify } from '../../fault-classifier.js';
5
- import { admitProviderRetryEpisode, clearFault, clearProviderRetryEpisode, invalidateProviderRetryEpisode, isModelNotFoundError, isProviderUnconfiguredError, readFault, readProviderRetryEpisode, recordFault, recordPendingProviderRetryFault, } from '../fault.js';
5
+ import { admitProviderRetryEpisode, clearFault, clearProviderRetryEpisode, invalidateProviderRetryEpisode, isModelNotFoundError, isProviderUnconfiguredError, readFault, readProviderRetryEpisode, recordFault, recordPendingProviderRetryFault, reopenProviderRetryEpisode, } from '../fault.js';
6
6
  import { extractCoolingDeadline } from '../managed-provider-cooling.js';
7
7
  import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../model-routes.js';
8
8
  import { operationIdContext } from '../../events/operation-id.js';
@@ -143,13 +143,22 @@ export class FaultRetry {
143
143
  recordFault(this.deps.nodeId, faultInput);
144
144
  }
145
145
  }
146
- /** Startup re-drive reads only a durable pending episode. Revive deliberately
147
- * clears the ordinary fault marker before this boundary, so the episode is
148
- * the recovery authority and admitted/invalidated records remain inert. */
149
- recoverPendingOnStartup() {
146
+ /** Startup re-drive reads the durable episode surviving from the process this
147
+ * replaces. Revive deliberately clears the ordinary fault marker before this
148
+ * boundary, so the episode is the recovery authority. A `pending` episode
149
+ * re-arms unconditionally, exactly as before. An `admitted` episode re-arms
150
+ * too, but only under a no-progress proof: reopened to `pending` when the
151
+ * branch segment from its anchor to the current leaf carries no assistant
152
+ * content and no tool activity — the dead attempt did nothing that rolling
153
+ * back would destroy. An anchor absent from the loaded branch cannot happen
154
+ * in production (session-file identity and the new-cycle guard already rule
155
+ * it out below), so it is treated the same as "no progress" rather than
156
+ * blocking the ladder. Returns 'retry-armed' when the timer now owns the
157
+ * first turn, 'none' when the caller's default kickoff should proceed. */
158
+ recoverOnStartup() {
150
159
  const generation = this.deps.installedGeneration();
151
160
  if (generation === undefined)
152
- return;
161
+ return 'none';
153
162
  const episode = readProviderRetryEpisode(this.deps.nodeId);
154
163
  const sessionFile = this.sessionFile(generation.session);
155
164
  // A new-cycle launch deliberately roots a new transcript branch even though
@@ -160,12 +169,48 @@ export class FaultRetry {
160
169
  if (episode !== null && sessionFile !== null && episode.sessionFile === sessionFile) {
161
170
  invalidateProviderRetryEpisode(this.deps.nodeId, sessionFile);
162
171
  }
163
- return;
172
+ return 'none';
173
+ }
174
+ if (episode === null || sessionFile === null || episode.sessionFile !== sessionFile)
175
+ return 'none';
176
+ if (episode.state === 'admitted') {
177
+ if (episode.anchorEntryId !== null && !this.hasProgressPastAnchor(generation.session, episode.anchorEntryId)) {
178
+ if (!reopenProviderRetryEpisode(this.deps.nodeId, sessionFile))
179
+ return 'none';
180
+ }
181
+ else {
182
+ // Progress detected past the anchor (or no anchor to prove against) — the
183
+ // dead attempt did real work. Never roll that back; clear the episode and
184
+ // let the caller's default kickoff continue where it actually got to.
185
+ clearProviderRetryEpisode(this.deps.nodeId);
186
+ return 'none';
187
+ }
188
+ }
189
+ else if (episode.state !== 'pending') {
190
+ return 'none';
164
191
  }
165
- if (episode === null || sessionFile === null || episode.state !== 'pending' || episode.sessionFile !== sessionFile)
166
- return;
167
192
  recordFault(this.deps.nodeId, episode.fault);
168
193
  this.schedule();
194
+ return 'retry-armed';
195
+ }
196
+ /** True iff the branch segment strictly after `anchorEntryId` (root→leaf,
197
+ * anchor exclusive) contains an assistant message with non-empty content or
198
+ * a tool result — the two shapes a runtime card and a `content: []`
199
+ * error-settled assistant message are not. An anchor missing from the loaded
200
+ * branch cannot be distinguished from "no progress" here; callers treat it
201
+ * as no progress rather than as a positive finding. */
202
+ hasProgressPastAnchor(session, anchorEntryId) {
203
+ const branch = session.sessionManager.getBranch();
204
+ const anchorIndex = branch.findIndex((entry) => entry.id === anchorEntryId);
205
+ const segment = anchorIndex === -1 ? branch : branch.slice(anchorIndex + 1);
206
+ return segment.some((entry) => {
207
+ if (entry.type !== 'message')
208
+ return false;
209
+ const { message } = entry;
210
+ if (message.role === 'toolResult')
211
+ return true;
212
+ return message.role === 'assistant' && Array.isArray(message.content) && message.content.length > 0;
213
+ });
169
214
  }
170
215
  schedule() {
171
216
  const generation = this.deps.installedGeneration();
@@ -408,7 +453,11 @@ export class FaultRetry {
408
453
  return Promise.resolve();
409
454
  }
410
455
  recordFault(this.deps.nodeId, plan.fault);
411
- const retryCard = formatCard('recovery', {}, fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY);
456
+ // The attempt facts are what makes a rolled-back retry legible: every
457
+ // superseded attempt falls off the active branch, so this one card is the
458
+ // whole visible record of the sequence, and without them a reader cannot
459
+ // tell the third attempt from the first.
460
+ const retryCard = formatCard('recovery', { attempt: plan.attempt, max: plan.max }, fault.kind === 'connection' ? CONNECTION_FAULT_RECOVERY_BODY : PROVIDER_FAULT_RECOVERY_BODY);
412
461
  announceMessageOrigin(retryCard, { type: 'runtime', kind: 'recovery' });
413
462
  return operationIdContext.fresh(() => candidateSession.prompt(retryCard, { preflightResult: release }));
414
463
  });
@@ -634,13 +634,13 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
634
634
  // post-bind failure is re-recorded by the next failed client op.
635
635
  clearFault(nodeId, { link: 'viewer↔broker' });
636
636
  clearFault(nodeId, { link: 'relay↔broker' });
637
- // The listener is also the broker startup boundary: a pending provider
638
- // episode recovered after an instance death is re-armed only once this
637
+ // The listener is also the broker startup boundary: a pending or
638
+ // surviving-admitted provider episode is re-armed only once this
639
639
  // replacement can accept its normal viewer/daemon traffic. Independent of
640
640
  // the link faults cleared above — those are viewer/relay socket markers,
641
- // this is the provider retry episode. Admitted and invalidated records are
641
+ // this is the provider retry episode. An invalidated record is
642
642
  // intentionally inert inside FaultRetry.
643
- faultRetry.recoverPendingOnStartup();
643
+ const recovery = faultRetry.recoverOnStartup();
644
644
  // A launch prompt delivers only after the listener is accepting, so a
645
645
  // root-spawn readiness proof and prompt admission share one boundary. This
646
646
  // includes the one continuation carried by a clean runtime-abort resume.
@@ -648,7 +648,17 @@ export async function runBroker(nodeId, startupAt = process.hrtime.bigint(), sta
648
648
  // pending in kickoffPending; drivePendingKickoff is a no-op until the
649
649
  // reload_auth re-drive resolves a model, at which point it fires. For the
650
650
  // common model-present path this fires immediately — unchanged behavior.
651
- if (cfg.firstPrompt !== undefined && cfg.firstPrompt !== '') {
651
+ //
652
+ // recoverOnStartup returning 'retry-armed' means a resuming, non-cycling
653
+ // replacement is re-driving a provider-fault recovery card instead — this
654
+ // can never discard a birth mandate, a wake card, or a revive kickoff,
655
+ // because every kickoff-composing branch other than the runtime-restart-abort
656
+ // continuation is either !hasSessionPath or newCycle, both of which
657
+ // recoverOnStartup already refuses outright by returning 'none' before an
658
+ // episode can even be considered. So the only prompt this suppression can
659
+ // ever suppress is that one continuation, and only when the episode proves
660
+ // the resume made no progress past its anchor.
661
+ if (recovery !== 'retry-armed' && cfg.firstPrompt !== undefined && cfg.firstPrompt !== '') {
652
662
  // The spawner recorded who authored this kickoff (a birth mandate, a
653
663
  // cron wake, a revive card); announce it against the exact text so the
654
664
  // stamp writer claims it on the message pi persists.
@@ -36,8 +36,18 @@ export declare function readFault(nodeId: string): Fault | null;
36
36
  export declare function readProviderRetryEpisode(nodeId: string): ProviderRetryEpisode | null;
37
37
  /** Persist a newly scheduled provider retry before its broker-local timer is armed. */
38
38
  export declare function recordPendingProviderRetryEpisode(nodeId: string, episode: Omit<ProviderRetryEpisode, 'state'>): boolean;
39
- /** Admission is the irreversible boundary: an admitted retry is never replayed. */
39
+ /** Admission is irreversible EXCEPT through a proven no-progress rollback: a
40
+ * broker startup that finds no assistant content or tool activity past the
41
+ * episode's anchor may reopen it via `reopenProviderRetryEpisode` below. Short
42
+ * of that proof, an admitted episode is never replayed. */
40
43
  export declare function admitProviderRetryEpisode(nodeId: string, sessionFile: string, fault: Fault): boolean;
44
+ /** Reopens an `admitted` episode to `pending` so a surviving broker startup can
45
+ * re-drive it exactly like a never-admitted one. Callable only under the same
46
+ * no-progress proof `recoverOnStartup` establishes — the branch segment from
47
+ * `anchorEntryId` to the current leaf carries no assistant content and no tool
48
+ * activity. Guarded by the same transcript-identity coordinate `admitProviderRetryEpisode`
49
+ * uses, so a foreign or already-invalidated episode is never reopened. */
50
+ export declare function reopenProviderRetryEpisode(nodeId: string, sessionFile: string): boolean;
41
51
  /** A replacement explicitly invalidates its old transcript's retry episode. */
42
52
  export declare function invalidateProviderRetryEpisode(nodeId: string, sessionFile: string): boolean;
43
53
  export declare function clearProviderRetryEpisode(nodeId: string): void;
@@ -279,7 +279,10 @@ export function readProviderRetryEpisode(nodeId) {
279
279
  export function recordPendingProviderRetryEpisode(nodeId, episode) {
280
280
  return writeProviderRetryEpisode(nodeId, { ...episode, sessionFile: canonicalSessionFile(episode.sessionFile), state: 'pending' });
281
281
  }
282
- /** Admission is the irreversible boundary: an admitted retry is never replayed. */
282
+ /** Admission is irreversible EXCEPT through a proven no-progress rollback: a
283
+ * broker startup that finds no assistant content or tool activity past the
284
+ * episode's anchor may reopen it via `reopenProviderRetryEpisode` below. Short
285
+ * of that proof, an admitted episode is never replayed. */
283
286
  export function admitProviderRetryEpisode(nodeId, sessionFile, fault) {
284
287
  const current = currentProviderRetryEpisode(nodeId);
285
288
  if (current === null ||
@@ -295,6 +298,18 @@ export function admitProviderRetryEpisode(nodeId, sessionFile, fault) {
295
298
  fault,
296
299
  });
297
300
  }
301
+ /** Reopens an `admitted` episode to `pending` so a surviving broker startup can
302
+ * re-drive it exactly like a never-admitted one. Callable only under the same
303
+ * no-progress proof `recoverOnStartup` establishes — the branch segment from
304
+ * `anchorEntryId` to the current leaf carries no assistant content and no tool
305
+ * activity. Guarded by the same transcript-identity coordinate `admitProviderRetryEpisode`
306
+ * uses, so a foreign or already-invalidated episode is never reopened. */
307
+ export function reopenProviderRetryEpisode(nodeId, sessionFile) {
308
+ const current = currentProviderRetryEpisode(nodeId);
309
+ if (current === null || current.state !== 'admitted' || current.sessionFile !== canonicalSessionFile(sessionFile))
310
+ return false;
311
+ return writeProviderRetryEpisode(nodeId, { ...current, state: 'pending' });
312
+ }
298
313
  /** A replacement explicitly invalidates its old transcript's retry episode. */
299
314
  export function invalidateProviderRetryEpisode(nodeId, sessionFile) {
300
315
  const current = currentProviderRetryEpisode(nodeId);
@@ -47,8 +47,8 @@ function ladderFor(provider, ladders = modelLadders()) {
47
47
  const MODEL_STRENGTH_RANK = { light: 0, medium: 1, strong: 2, ultra: 3 };
48
48
  /** Back-map a concrete model spec to the provider×strength ladder cell it came
49
49
  * from. Two cells may legitimately name the SAME model id at different thinking
50
- * levels (the openai ladder's `ultra` and `strong` are both gpt-5.6-sol, at
51
- * `max` and `high`), so the thinking level is part of the identity: every cell
50
+ * levels (a ladder is free to serve `ultra` and `strong` off one model at `max`
51
+ * and `high`), so the thinking level is part of the identity: every cell
52
52
  * is compared WITH its suffix first, and only when nothing matches exactly does
53
53
  * the id-only comparison decide — which keeps a spec whose thinking level has
54
54
  * since drifted (or was never set, as on a launch fallback picked straight off
package/dist/types.js CHANGED
@@ -173,7 +173,7 @@ export function defaultModelLaddersConfig() {
173
173
  light: 'anthropic/claude-haiku-4-5:high',
174
174
  },
175
175
  openai: {
176
- ultra: 'openai-codex/gpt-5.6-sol:max',
176
+ ultra: 'openai-codex/gpt-6-astra:max',
177
177
  strong: 'openai-codex/gpt-5.6-sol:high',
178
178
  medium: 'openai-codex/gpt-5.6-terra:medium',
179
179
  light: 'openai-codex/gpt-5.6-luna:low',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.295",
3
+ "version": "0.3.297",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -98,9 +98,9 @@
98
98
  "test:all": "npm test && npm run test:integration && npm run test:seam"
99
99
  },
100
100
  "overrides": {
101
- "@earendil-works/pi-ai": "0.84.4",
102
- "@earendil-works/pi-agent-core": "0.84.4",
103
- "@earendil-works/pi-tui": "0.84.4"
101
+ "@earendil-works/pi-ai": "0.85.1",
102
+ "@earendil-works/pi-agent-core": "0.85.1",
103
+ "@earendil-works/pi-tui": "0.85.1"
104
104
  },
105
105
  "repository": {
106
106
  "type": "git",
@@ -111,10 +111,10 @@
111
111
  },
112
112
  "license": "MIT",
113
113
  "dependencies": {
114
- "@earendil-works/pi-agent-core": "0.84.4",
115
- "@earendil-works/pi-ai": "0.84.4",
116
- "@earendil-works/pi-coding-agent": "0.84.4",
117
- "@earendil-works/pi-tui": "0.84.4",
114
+ "@earendil-works/pi-agent-core": "0.85.1",
115
+ "@earendil-works/pi-ai": "0.85.1",
116
+ "@earendil-works/pi-coding-agent": "0.85.1",
117
+ "@earendil-works/pi-tui": "0.85.1",
118
118
  "cron-parser": "^5.6.0",
119
119
  "esbuild": "^0.27.7",
120
120
  "proper-lockfile": "4.1.2",