@pellux/goodvibes-tui 1.20.0 → 1.21.0
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/CHANGELOG.md +20 -1
- package/README.md +7 -7
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/doctor.ts +7 -1
- package/src/cli/management.ts +1 -1
- package/src/cli/package-verification.ts +92 -7
- package/src/cli/status.ts +16 -0
- package/src/config/read-versioned.ts +115 -2
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-event-navigation.ts +42 -0
- package/src/core/conversation-search-expansion.ts +67 -0
- package/src/core/conversation-splash-state.ts +58 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +43 -40
- package/src/core/model-routing-chip.ts +38 -18
- package/src/core/rewind-turn-anchors.ts +155 -3
- package/src/core/stream-event-wiring.ts +298 -17
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +145 -7
- package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
- package/src/daemon/handlers/inbox/index.ts +14 -1
- package/src/daemon/handlers/sqlite-store.ts +160 -6
- package/src/input/command-registry.ts +8 -0
- package/src/input/commands/local-runtime.ts +6 -1
- package/src/input/commands/schedule-runtime.ts +15 -2
- package/src/input/commands/shell-core.ts +87 -38
- package/src/input/composer-capture.ts +47 -0
- package/src/input/handler-command-route.ts +4 -0
- package/src/input/handler-content-actions.ts +9 -0
- package/src/input/handler-feed-routes.ts +1 -0
- package/src/input/handler-modal-routes.ts +12 -1
- package/src/input/handler-picker-routes.ts +43 -5
- package/src/input/handler-shortcuts.ts +1 -1
- package/src/input/handler-ui-state.ts +8 -3
- package/src/input/model-picker-items.ts +17 -2
- package/src/input/model-picker.ts +19 -2
- package/src/input/search.ts +183 -89
- package/src/main.ts +31 -38
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/markdown-inline.ts +143 -0
- package/src/renderer/markdown-table.ts +459 -0
- package/src/renderer/markdown.ts +6 -335
- package/src/renderer/model-workspace.ts +48 -5
- package/src/renderer/shell-surface.ts +36 -0
- package/src/renderer/ui-factory.ts +25 -78
- package/src/runtime/bootstrap-command-parts.ts +36 -4
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/durability-housekeeping.ts +183 -0
- package/src/runtime/durability-services.ts +68 -11
- package/src/runtime/legacy-daemon-reconcile.ts +16 -7
- package/src/runtime/provider-fallback.ts +16 -2
- package/src/runtime/recovery-decisions.ts +295 -0
- package/src/runtime/recovery-prompt.ts +66 -6
- package/src/runtime/services.ts +9 -4
- package/src/runtime/session-liveness-marker.ts +142 -5
- package/src/runtime/workstream-draft-store.ts +198 -25
- package/src/runtime/workstream-services.ts +17 -2
- package/src/shell/ui-openers.ts +1 -1
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +334 -16
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { UiRuntimeEvents } from '@/runtime/index.ts';
|
|
2
2
|
import { createStreamStallWatchdog } from './stream-stall-watchdog.ts';
|
|
3
|
-
import { buildRoutingChip } from './model-routing-chip.ts';
|
|
3
|
+
import { buildRoutingChip, FALLBACK_CORRELATION_WINDOW_MS } from './model-routing-chip.ts';
|
|
4
4
|
import { formatUserFacingErrorLine } from './format-user-error.ts';
|
|
5
|
+
import { classifyProviderSetup } from '../providers/provider-classification.ts';
|
|
6
|
+
import type { FailoverTurnState } from './active-model-identity.ts';
|
|
5
7
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
8
|
+
import type { ReasoningEffortSpec } from '@pellux/goodvibes-sdk/platform/providers';
|
|
9
|
+
import {
|
|
10
|
+
publishActiveEffortOptions,
|
|
11
|
+
remapEffortForServingModel,
|
|
12
|
+
} from '../providers/reasoning-effort-surface.ts';
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* Live stream and tool-execution metrics maintained by wireStreamEventMetrics.
|
|
@@ -73,7 +80,20 @@ interface StreamOrchestrator {
|
|
|
73
80
|
|
|
74
81
|
/** Minimal provider surface required for the stream stall watchdog and failover switching. */
|
|
75
82
|
interface StreamProviderRegistry {
|
|
76
|
-
getCurrentModel(): {
|
|
83
|
+
getCurrentModel(): {
|
|
84
|
+
readonly provider: string;
|
|
85
|
+
readonly registryKey?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Model id, display name and reasoning-effort spec, all optional so a test
|
|
88
|
+
* double carrying only provider/registryKey still satisfies this surface.
|
|
89
|
+
* When the id is present the failover path can re-resolve the configured
|
|
90
|
+
* reasoning level against the model that is about to serve; when it is
|
|
91
|
+
* absent that step is skipped rather than guessed at.
|
|
92
|
+
*/
|
|
93
|
+
readonly id?: string;
|
|
94
|
+
readonly displayName?: string;
|
|
95
|
+
readonly reasoningEffort?: ReasoningEffortSpec | undefined;
|
|
96
|
+
};
|
|
77
97
|
setCurrentModel(registryKey: string): void;
|
|
78
98
|
}
|
|
79
99
|
|
|
@@ -101,6 +121,14 @@ interface FailoverOptimizer {
|
|
|
101
121
|
interface StreamSystemMessageRouter {
|
|
102
122
|
high(message: string): void;
|
|
103
123
|
low(message: string): void;
|
|
124
|
+
/**
|
|
125
|
+
* Unconditional conversation delivery (see system-message-router.ts). Used
|
|
126
|
+
* for provider-switch notices: which backend serves a turn — and therefore
|
|
127
|
+
* who bills for it — is never ambient chatter that a routing preference or
|
|
128
|
+
* the noise gate may filter out. Optional so bare test doubles that supply
|
|
129
|
+
* only high/low still type-check; announce() below falls back to high().
|
|
130
|
+
*/
|
|
131
|
+
userReceipt?(message: string): void;
|
|
104
132
|
}
|
|
105
133
|
|
|
106
134
|
/**
|
|
@@ -166,8 +194,21 @@ export interface WireStreamEventMetricsOptions {
|
|
|
166
194
|
* Callback the caller provides to re-submit the last user turn on a
|
|
167
195
|
* different provider after a successful failover switch. Called only when
|
|
168
196
|
* the optimizer is enabled and a viable next provider exists in the chain.
|
|
197
|
+
*
|
|
198
|
+
* The re-submission rolls the conversation back to its pre-submission
|
|
199
|
+
* message count, which deletes everything the failed turn added — including
|
|
200
|
+
* a failover notice appended before the call. So the notice is passed IN
|
|
201
|
+
* rather than emitted before: the caller re-posts it after its rollback, and
|
|
202
|
+
* it survives to be read. Implementations that do not roll back may ignore
|
|
203
|
+
* the argument, but must then post the notice themselves.
|
|
204
|
+
*
|
|
205
|
+
* Returns whether the turn was actually re-submitted. False means there was
|
|
206
|
+
* nothing to retry (no pre-submission snapshot — the failed turn did not
|
|
207
|
+
* come from the composer) and the notice was NOT posted, so the caller must
|
|
208
|
+
* narrate the switch and surface the error itself rather than let a turn end
|
|
209
|
+
* in silence on a backend the user did not choose.
|
|
169
210
|
*/
|
|
170
|
-
readonly retryTurn?: () =>
|
|
211
|
+
readonly retryTurn?: (notice?: string) => boolean;
|
|
171
212
|
/**
|
|
172
213
|
* Optional cost catalog for attaching per-1M-token cost information to
|
|
173
214
|
* the failover notice. When provided and both models have non-zero pricing,
|
|
@@ -189,6 +230,31 @@ export interface WireStreamEventMetricsOptions {
|
|
|
189
230
|
* callers omit it.
|
|
190
231
|
*/
|
|
191
232
|
readonly stallThresholdMs?: number | undefined;
|
|
233
|
+
/**
|
|
234
|
+
* Shared holder for the live failover record (core/active-model-identity.ts).
|
|
235
|
+
* Set when failover switches the registry off the user's configured
|
|
236
|
+
* selection, cleared once serving is restored to it. The render frame reads
|
|
237
|
+
* the same object, so the header and footer describe the switch while it is
|
|
238
|
+
* in effect. Omitted by unit tests that only exercise notice content.
|
|
239
|
+
*/
|
|
240
|
+
readonly failoverState?: FailoverTurnState;
|
|
241
|
+
/**
|
|
242
|
+
* The user's configured model selection (config `provider.model`) as a
|
|
243
|
+
* registry key. Read at the moment of a failover switch so the turn-end
|
|
244
|
+
* restore targets what the user actually chose — not whatever the registry
|
|
245
|
+
* happened to hold. Without it, failover still works but no restore or
|
|
246
|
+
* divergence marker is possible, so both are skipped rather than guessed at.
|
|
247
|
+
*/
|
|
248
|
+
readonly getConfiguredRegistryKey?: () => string | undefined;
|
|
249
|
+
/**
|
|
250
|
+
* The user's configured reasoning level (config `provider.reasoningEffort`).
|
|
251
|
+
* Read at the moment of a failover switch so the level can be re-resolved
|
|
252
|
+
* against the model that is about to serve: a level the configured model
|
|
253
|
+
* offers may not exist on the fallback, and sending it unchanged is how a
|
|
254
|
+
* failover turns into a provider-side 400 the user cannot explain. Omitted
|
|
255
|
+
* means the re-resolution is skipped entirely, never guessed.
|
|
256
|
+
*/
|
|
257
|
+
readonly getConfiguredReasoningEffort?: () => string | undefined;
|
|
192
258
|
}
|
|
193
259
|
|
|
194
260
|
/** Result of wireStreamEventMetrics. */
|
|
@@ -196,10 +262,12 @@ export interface WireStreamEventMetricsResult {
|
|
|
196
262
|
/** Unsubscribe functions; push into the parent unsubs array for cleanup on exit. */
|
|
197
263
|
readonly unsubs: ReadonlyArray<() => void>;
|
|
198
264
|
/**
|
|
199
|
-
* Clear the per-turn failover visited-provider set
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
265
|
+
* Clear the per-turn failover visited-provider set AND restore the user's
|
|
266
|
+
* configured model selection if a failover left serving somewhere else.
|
|
267
|
+
* Call this on every new user submission so neither the visited set nor a
|
|
268
|
+
* per-turn provider switch bleeds across independent turns (both are also
|
|
269
|
+
* handled on TURN_COMPLETED/TURN_CANCEL, but a new submission may arrive
|
|
270
|
+
* before either fires).
|
|
203
271
|
*/
|
|
204
272
|
readonly clearFailoverVisited: () => void;
|
|
205
273
|
/**
|
|
@@ -253,6 +321,33 @@ function buildCostDeltaSuffix(
|
|
|
253
321
|
return ` [cost/1M: input ${fmt(fromCost.input)}→${fmt(toCost.input)}, output ${fmt(fromCost.output)}→${fmt(toCost.output)}]`;
|
|
254
322
|
}
|
|
255
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Build the billing-class segment of a failover notice, e.g.
|
|
326
|
+
* ` [billing: API key → Subscription — billing class changed]`.
|
|
327
|
+
*
|
|
328
|
+
* This path is NOT the synthetic provider's tier-isolated failover, which is
|
|
329
|
+
* where the documented "free, paid and subscription tiers never mix" contract
|
|
330
|
+
* lives (docs/providers-and-routing.md:77-128, enforced in the SDK's
|
|
331
|
+
* synthetic.ts by CanonicalModel.tier). The optimizer chain consumed here
|
|
332
|
+
* carries no tier metadata at all — its nodes are
|
|
333
|
+
* `{ position, providerId, modelId, capable, explanation }` and `explanation`
|
|
334
|
+
* describes functional capability (streaming, tool calling, context size),
|
|
335
|
+
* never billing. So the switch cannot be constrained by a tier it cannot see;
|
|
336
|
+
* what it CAN do is say out loud which billing class it moved to, so a user
|
|
337
|
+
* who does not want their subscription spent on an automatic retry can object
|
|
338
|
+
* and turn the optimizer off.
|
|
339
|
+
*
|
|
340
|
+
* Classification comes from providers/provider-classification.ts, which is
|
|
341
|
+
* honest about ignorance: an unrecognised provider id reports "Unknown" rather
|
|
342
|
+
* than being quietly assumed safe.
|
|
343
|
+
*/
|
|
344
|
+
function buildBillingSuffix(fromProviderId: string, toProviderId: string): string {
|
|
345
|
+
const from = classifyProviderSetup({ providerId: fromProviderId }).setupLabel;
|
|
346
|
+
const to = classifyProviderSetup({ providerId: toProviderId }).setupLabel;
|
|
347
|
+
const changed = from !== to ? ' — billing class changed' : '';
|
|
348
|
+
return ` [billing: ${from} → ${to}${changed}]`;
|
|
349
|
+
}
|
|
350
|
+
|
|
256
351
|
/**
|
|
257
352
|
* Wire STREAM_* and TOOL_* runtime events to the provided StreamMetrics object
|
|
258
353
|
* and install the stream-stall watchdog. The caller owns the metrics object
|
|
@@ -273,11 +368,149 @@ export function wireStreamEventMetrics(
|
|
|
273
368
|
const {
|
|
274
369
|
events, metrics, orchestrator, providerRegistry,
|
|
275
370
|
systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
|
|
276
|
-
isApprovalPending, stallThresholdMs,
|
|
371
|
+
isApprovalPending, stallThresholdMs, failoverState, getConfiguredRegistryKey,
|
|
372
|
+
getConfiguredReasoningEffort,
|
|
277
373
|
} = options;
|
|
278
374
|
|
|
279
375
|
const unsubs: Array<() => void> = [];
|
|
280
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Deliver a provider-switch notice to the conversation unconditionally.
|
|
379
|
+
* Prefers the router's userReceipt channel, which skips the noise gate and
|
|
380
|
+
* the ui.operationalMessages routing target entirely, so the line cannot be
|
|
381
|
+
* filtered away by a preference and is treated as real content even while
|
|
382
|
+
* the splash still owns the screen. Falls back to high() for bare doubles.
|
|
383
|
+
*/
|
|
384
|
+
const announce = (message: string): void => {
|
|
385
|
+
if (systemMessageRouter.userReceipt) systemMessageRouter.userReceipt(message);
|
|
386
|
+
else systemMessageRouter.high(message);
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Put serving back on the user's configured selection after a failed-over
|
|
391
|
+
* turn ends.
|
|
392
|
+
*
|
|
393
|
+
* Failover is per-turn recovery, not a permanent re-selection. Without this,
|
|
394
|
+
* the one setCurrentModel() call that rescued a single failed turn stayed in
|
|
395
|
+
* force for the rest of the session: every later turn ran on the fallback
|
|
396
|
+
* backend while nothing in the session state or the footer had changed to
|
|
397
|
+
* say so. The user's configured selection is authoritative, so the NEXT turn
|
|
398
|
+
* starts from it again. If that backend is still unhealthy, the normal
|
|
399
|
+
* per-turn failover handles it again — visibly, with a fresh notice — which
|
|
400
|
+
* is exactly the behaviour a sticky override was hiding.
|
|
401
|
+
*
|
|
402
|
+
* The record is kept (not cleared) when the restore itself fails, so the
|
|
403
|
+
* header and footer keep reporting the real serving backend rather than
|
|
404
|
+
* claiming a revert that did not happen.
|
|
405
|
+
*/
|
|
406
|
+
/**
|
|
407
|
+
* Registry keys THIS module switched to and narrated itself, with the time
|
|
408
|
+
* of the switch. The MODEL_CHANGED listener consumes an entry instead of
|
|
409
|
+
* emitting the generic routing chip, so neither half of a failover is
|
|
410
|
+
* narrated twice: the switch out is announced by `[Failover] from -> to
|
|
411
|
+
* (reason)` and the switch back by `[Failover] Restored …`, and a second
|
|
412
|
+
* line reading "(reason unknown)" for the same event would be both a
|
|
413
|
+
* duplicate and a lie — the reason is known in both cases.
|
|
414
|
+
*
|
|
415
|
+
* A timestamped map rather than a flag cleared around the setCurrentModel
|
|
416
|
+
* call, because MODEL_CHANGED does NOT arrive synchronously: the TUI reads
|
|
417
|
+
* it through the runtime event feed, which delivers after the emitting call
|
|
418
|
+
* has returned (observed live — a flag cleared in a finally block was always
|
|
419
|
+
* already null by the time the listener ran, and both failover halves got a
|
|
420
|
+
* duplicate chip). Entries expire on the same window the fallback-log
|
|
421
|
+
* correlation uses, so a switch whose event never arrives cannot silence an
|
|
422
|
+
* unrelated later change to the same model.
|
|
423
|
+
*/
|
|
424
|
+
const selfNarratedSwitches = new Map<string, number>();
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Run a registry switch this module narrates itself, with the chip suppressed
|
|
428
|
+
* for it. Returns the effort-remap sentence when the switch changed the level
|
|
429
|
+
* that goes on the wire, for the CALLER to place — see
|
|
430
|
+
* reconcileEffortWithServingModel for why it is not announced here.
|
|
431
|
+
*/
|
|
432
|
+
const switchNarrated = (registryKey: string): string | undefined => {
|
|
433
|
+
selfNarratedSwitches.set(registryKey, Date.now());
|
|
434
|
+
try {
|
|
435
|
+
providerRegistry.setCurrentModel(registryKey);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
selfNarratedSwitches.delete(registryKey); // no switch happened, nothing to suppress
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
return reconcileEffortWithServingModel();
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Re-resolve the REQUESTED reasoning level against whichever model is now
|
|
445
|
+
* serving, and hand back the sentence that says so when it had to change.
|
|
446
|
+
*
|
|
447
|
+
* Both halves of a failover come through switchNarrated, so this covers the
|
|
448
|
+
* switch out and the switch back. The requested level is left untouched in
|
|
449
|
+
* config — the fallback is temporary and the user's choice must survive it —
|
|
450
|
+
* but the level actually sent is the resolved one, and the SDK's own sentence
|
|
451
|
+
* explaining the remap is surfaced verbatim rather than reworded.
|
|
452
|
+
*
|
|
453
|
+
* The sentence is RETURNED rather than announced. On the failover-out path
|
|
454
|
+
* this function runs before retryTurn, and retryTurn rolls the conversation
|
|
455
|
+
* back to its pre-submission message count — which deleted this notice every
|
|
456
|
+
* time, exactly as it used to delete the failover notice itself. The caller
|
|
457
|
+
* folds it into the notice it hands to retryTurn so it survives the rollback;
|
|
458
|
+
* the restore path, which has no rollback after it, announces it directly.
|
|
459
|
+
*/
|
|
460
|
+
const reconcileEffortWithServingModel = (): string | undefined => {
|
|
461
|
+
const serving = providerRegistry.getCurrentModel();
|
|
462
|
+
if (!serving.id) return undefined; // no model id on this surface: nothing to resolve against
|
|
463
|
+
const model = {
|
|
464
|
+
id: serving.id,
|
|
465
|
+
provider: serving.provider,
|
|
466
|
+
...(serving.displayName ? { displayName: serving.displayName } : {}),
|
|
467
|
+
...(serving.reasoningEffort ? { reasoningEffort: serving.reasoningEffort } : {}),
|
|
468
|
+
};
|
|
469
|
+
publishActiveEffortOptions(model);
|
|
470
|
+
// getConfiguredReasoningEffort reads config `provider.reasoningEffort` —
|
|
471
|
+
// the REQUESTED level. It must never be re-seeded from a previously snapped
|
|
472
|
+
// effective value, or a single failover onto a capped model would ratchet
|
|
473
|
+
// the level down for the rest of the session.
|
|
474
|
+
const requested = getConfiguredReasoningEffort?.();
|
|
475
|
+
if (requested === undefined || requested === '') return undefined;
|
|
476
|
+
return remapEffortForServingModel(requested, model).note;
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/** True when this MODEL_CHANGED is one of our own narrated switches (consumes the record). */
|
|
480
|
+
const wasSelfNarrated = (registryKey: string): boolean => {
|
|
481
|
+
const narratedAt = selfNarratedSwitches.get(registryKey);
|
|
482
|
+
if (narratedAt === undefined) return false;
|
|
483
|
+
selfNarratedSwitches.delete(registryKey);
|
|
484
|
+
return Date.now() - narratedAt <= FALLBACK_CORRELATION_WINDOW_MS;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const restoreConfiguredSelection = (): void => {
|
|
488
|
+
const record = failoverState?.current();
|
|
489
|
+
if (!record || !failoverState) return;
|
|
490
|
+
if (providerRegistry.getCurrentModel().registryKey === record.configuredRegistryKey) {
|
|
491
|
+
failoverState.clear();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
const effortNote = switchNarrated(record.configuredRegistryKey);
|
|
496
|
+
failoverState.clear();
|
|
497
|
+
// Nothing rolls the transcript back after this point, so the restore line
|
|
498
|
+
// and any effort remap that came with it are announced directly.
|
|
499
|
+
announce(
|
|
500
|
+
`[Failover] Restored ${record.configuredRegistryKey} for the next turn.`
|
|
501
|
+
+ (effortNote ? `\n[Failover] ${effortNote}` : ''),
|
|
502
|
+
);
|
|
503
|
+
} catch (restoreErr) {
|
|
504
|
+
logger.debug('failover restore failed', {
|
|
505
|
+
configuredRegistryKey: record.configuredRegistryKey, error: String(restoreErr),
|
|
506
|
+
});
|
|
507
|
+
announce(
|
|
508
|
+
`[Failover] Could not switch back to ${record.configuredRegistryKey}; still serving ${record.servingRegistryKey}.`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
render();
|
|
512
|
+
};
|
|
513
|
+
|
|
281
514
|
unsubs.push(events.turns.on('STREAM_START', () => {
|
|
282
515
|
metrics.startTime = Date.now();
|
|
283
516
|
metrics.deltaCount = 0;
|
|
@@ -318,8 +551,20 @@ export function wireStreamEventMetrics(
|
|
|
318
551
|
// (caller clears via clearFailoverVisited(), wired in main.ts).
|
|
319
552
|
const failoverVisited = new Set<string>();
|
|
320
553
|
|
|
554
|
+
// Both terminal turn outcomes end the failover's authority over the
|
|
555
|
+
// registry: a completed turn got its answer, a cancelled one will not.
|
|
556
|
+
// (TURN_COMPLETED and TURN_ERROR are mutually exclusive per turn in the SDK
|
|
557
|
+
// — TURN_COMPLETED is emitted only on the success path in
|
|
558
|
+
// orchestrator-turn-helpers, TURN_ERROR only from the orchestrator's catch —
|
|
559
|
+
// so restoring here can never undo a switch whose retry has not run yet.)
|
|
321
560
|
unsubs.push(events.turns.on('TURN_COMPLETED', () => {
|
|
322
561
|
failoverVisited.clear();
|
|
562
|
+
restoreConfiguredSelection();
|
|
563
|
+
}));
|
|
564
|
+
|
|
565
|
+
unsubs.push(events.turns.on('TURN_CANCEL', () => {
|
|
566
|
+
failoverVisited.clear();
|
|
567
|
+
restoreConfiguredSelection();
|
|
323
568
|
}));
|
|
324
569
|
|
|
325
570
|
// Routing chip (never-silent model change): every MODEL_CHANGED that is not
|
|
@@ -330,6 +575,7 @@ export function wireStreamEventMetrics(
|
|
|
330
575
|
// Degrade gracefully when a bare test double omits the providers feed.
|
|
331
576
|
if (events.providers) {
|
|
332
577
|
unsubs.push(events.providers.on('MODEL_CHANGED', (change) => {
|
|
578
|
+
if (wasSelfNarrated(change.registryKey)) return; // a [Failover] line already said this, with its reason
|
|
333
579
|
queueMicrotask(() => {
|
|
334
580
|
const chip = buildRoutingChip(change, providerOptimizer?.fallbackLog ?? [], Date.now());
|
|
335
581
|
if (chip === null) return;
|
|
@@ -370,33 +616,64 @@ export function wireStreamEventMetrics(
|
|
|
370
616
|
const errorClass = formatUserFacingErrorLine(errVal);
|
|
371
617
|
// Capture FROM registry key before switching — needed for cost comparison.
|
|
372
618
|
const fromRegistryKey = providerRegistry.getCurrentModel().registryKey;
|
|
619
|
+
// The effort remap that comes with the switch is carried, not announced:
|
|
620
|
+
// retryTurn's rollback below would delete it (see
|
|
621
|
+
// reconcileEffortWithServingModel).
|
|
622
|
+
let effortNote: string | undefined;
|
|
373
623
|
try {
|
|
374
|
-
|
|
624
|
+
effortNote = switchNarrated(toRegistryKey);
|
|
375
625
|
} catch (switchErr) {
|
|
376
|
-
// Switch failed — fall through to honest error display.
|
|
626
|
+
// Switch failed — fall through to honest error display. This ends the
|
|
627
|
+
// turn, so an EARLIER hop's switch (if this is a second failover
|
|
628
|
+
// within the same turn) loses its authority here just as it would on
|
|
629
|
+
// any other terminal outcome.
|
|
377
630
|
logger.debug('failover setCurrentModel failed', { toRegistryKey, error: String(switchErr) });
|
|
378
631
|
systemMessageRouter.high(`[Error] ${errorClass}`);
|
|
632
|
+
restoreConfiguredSelection();
|
|
379
633
|
render();
|
|
380
634
|
return;
|
|
381
635
|
}
|
|
382
636
|
// Record the selected provider as visited before the retry fires so
|
|
383
637
|
// a subsequent TURN_ERROR from that provider also skips it.
|
|
384
638
|
failoverVisited.add(next.providerId);
|
|
639
|
+
// Remember the user's configured selection so the turn-end restore
|
|
640
|
+
// targets it, and so both shell surfaces can name it while the switch
|
|
641
|
+
// is in force. Sticky across a second hop within the same turn.
|
|
642
|
+
const configuredRegistryKey = getConfiguredRegistryKey?.();
|
|
643
|
+
if (configuredRegistryKey) {
|
|
644
|
+
failoverState?.begin({ configuredRegistryKey, servingRegistryKey: toRegistryKey });
|
|
645
|
+
}
|
|
385
646
|
providerOptimizer.recordFallbackTransition(fromProvider, next.providerId, errorClass);
|
|
386
647
|
const costSuffix = buildCostDeltaSuffix(costLookup, fromRegistryKey, toRegistryKey);
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
648
|
+
const billingSuffix = buildBillingSuffix(fromProvider, next.providerId);
|
|
649
|
+
// Re-submit the last user turn on the new provider, handing the notice
|
|
650
|
+
// to retryTurn so it outlives that call's transcript rollback (see the
|
|
651
|
+
// retryTurn option doc). Emitting it here instead would delete it.
|
|
652
|
+
const failoverNotice = `[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${billingSuffix}${costSuffix}`
|
|
653
|
+
+ (effortNote ? `\n[Failover] ${effortNote}` : '');
|
|
654
|
+
if (!retryTurn(failoverNotice)) {
|
|
655
|
+
// No turn to re-submit (the failed turn did not come from the
|
|
656
|
+
// composer, so there is no pre-submission snapshot to roll back to).
|
|
657
|
+
// The registry has still MOVED, so the switch gets narrated here and
|
|
658
|
+
// the original error surfaces — silence would leave the user on a
|
|
659
|
+
// different backend with no turn running and nothing said about it.
|
|
660
|
+
announce(failoverNotice);
|
|
661
|
+
systemMessageRouter.high(`[Error] ${errorClass}`);
|
|
662
|
+
restoreConfiguredSelection();
|
|
663
|
+
notifyErrorSurfaced(false);
|
|
664
|
+
}
|
|
390
665
|
render();
|
|
391
|
-
// Re-submit the last user turn on the new provider.
|
|
392
|
-
retryTurn();
|
|
393
666
|
return;
|
|
394
667
|
}
|
|
395
668
|
|
|
396
669
|
// Chain exhausted — all capable candidates have been visited or none exist.
|
|
397
|
-
|
|
670
|
+
// The turn is over, so any switch made earlier in it loses its authority:
|
|
671
|
+
// restore the configured selection before surfacing the error, or the
|
|
672
|
+
// user's next turn would silently start on the last fallback tried.
|
|
673
|
+
announce(
|
|
398
674
|
`[Failover] Chain exhausted — no alternative provider available. Original error: ${formatUserFacingErrorLine(errVal)}`,
|
|
399
675
|
);
|
|
676
|
+
restoreConfiguredSelection();
|
|
400
677
|
notifyErrorSurfaced(true);
|
|
401
678
|
render();
|
|
402
679
|
return;
|
|
@@ -494,7 +771,11 @@ export function wireStreamEventMetrics(
|
|
|
494
771
|
function notifyErrorSurfaced(exhausted: boolean) { _errorSurfacedCb?.(exhausted); }
|
|
495
772
|
return {
|
|
496
773
|
unsubs,
|
|
497
|
-
|
|
774
|
+
// A new user submission is also a turn boundary: restore here too, so a
|
|
775
|
+
// turn that ended without any terminal event (an aborted stream that
|
|
776
|
+
// emitted neither TURN_COMPLETED nor TURN_CANCEL) still cannot leave the
|
|
777
|
+
// next turn silently pinned to a fallback backend.
|
|
778
|
+
clearFailoverVisited: () => { failoverVisited.clear(); restoreConfiguredSelection(); },
|
|
498
779
|
onErrorSurfaced: (cb: (exhausted: boolean) => void) => { _errorSurfacedCb = cb; },
|
|
499
780
|
};
|
|
500
781
|
}
|
|
@@ -224,6 +224,28 @@ export class SystemMessageRouter {
|
|
|
224
224
|
wrfc(message: string, priority: SystemMessagePriority = 'high'): void {
|
|
225
225
|
this.routeTypedSystemMessage(message, priority, 'wrfc');
|
|
226
226
|
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Route a message that must reach the conversation unconditionally. Skips
|
|
230
|
+
* the noise gate and the configured routing target, and marks the message
|
|
231
|
+
* `isUserReceipt` so the splash check in
|
|
232
|
+
* ConversationManager.rebuildHistory() treats it as real visible content —
|
|
233
|
+
* otherwise it would be invisible while the splash still owns the screen.
|
|
234
|
+
*
|
|
235
|
+
* Two callers qualify today:
|
|
236
|
+
* - a receipt for an explicit user action at this shell (answering the
|
|
237
|
+
* startup recovery modal's Resume/Keep/Remove choice): the answer to a
|
|
238
|
+
* question the user was just asked, never ambient chatter;
|
|
239
|
+
* - an automatic provider failover switch (stream-event-wiring.ts):
|
|
240
|
+
* which backend serves a turn decides who bills for it, so it must not
|
|
241
|
+
* be filterable by a routing preference. `[Failover]` is also in
|
|
242
|
+
* FORCE_CONVERSATION_PREFIXES, but that list is a per-prefix opt-in
|
|
243
|
+
* applied AFTER the noise gate; this channel is the structural
|
|
244
|
+
* guarantee rather than a second string match.
|
|
245
|
+
*/
|
|
246
|
+
userReceipt(message: string): void {
|
|
247
|
+
this.conversation.addTypedSystemMessage(message, classifySystemMessageKind(message), { isUserReceipt: true });
|
|
248
|
+
}
|
|
227
249
|
}
|
|
228
250
|
|
|
229
251
|
// ---------------------------------------------------------------------------
|
|
@@ -62,11 +62,14 @@ import {
|
|
|
62
62
|
mkdirSync,
|
|
63
63
|
openSync,
|
|
64
64
|
readFileSync,
|
|
65
|
+
readdirSync,
|
|
65
66
|
renameSync,
|
|
67
|
+
statSync,
|
|
66
68
|
unlinkSync,
|
|
67
69
|
} from 'node:fs';
|
|
68
70
|
import { dirname, join } from 'node:path';
|
|
69
71
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
72
|
+
import { UNRECOGNIZED_SUFFIX } from '@/config/read-versioned.ts';
|
|
70
73
|
import type { SessionSurface } from '@/runtime/index.ts';
|
|
71
74
|
|
|
72
75
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
@@ -320,7 +323,7 @@ class TranscriptJournalImpl implements TranscriptJournal {
|
|
|
320
323
|
}
|
|
321
324
|
|
|
322
325
|
private _ensureInitialised(): void {
|
|
323
|
-
if (this._initialised &&
|
|
326
|
+
if (this._initialised && journalHasContent(this.path)) return;
|
|
324
327
|
|
|
325
328
|
mkdirSync(dirname(this.path), { recursive: true });
|
|
326
329
|
const header: JournalHeader = {
|
|
@@ -328,11 +331,20 @@ class TranscriptJournalImpl implements TranscriptJournal {
|
|
|
328
331
|
sessionId: this._sessionId,
|
|
329
332
|
createdAt: Date.now(),
|
|
330
333
|
};
|
|
331
|
-
// Append the header as the first line. If the file already exists
|
|
332
|
-
// process restarted mid-session), we start appending records
|
|
333
|
-
// whatever is already there — the replay function handles seq
|
|
334
|
-
//
|
|
335
|
-
|
|
334
|
+
// Append the header as the first line. If the file already exists with
|
|
335
|
+
// content (e.g. process restarted mid-session), we start appending records
|
|
336
|
+
// after whatever is already there — the replay function handles seq
|
|
337
|
+
// ordering.
|
|
338
|
+
//
|
|
339
|
+
// The guard is `journalHasContent`, not `existsSync`: a crash between
|
|
340
|
+
// creating the file and writing the header leaves a zero-byte journal, and
|
|
341
|
+
// an existence-only check would then skip the header forever. Every record
|
|
342
|
+
// appended after that would sit in a header-less file, so the next replay
|
|
343
|
+
// would read record 0 where the header belongs, fail the version gate, and
|
|
344
|
+
// quarantine the entire journal — losing exactly the turns this module
|
|
345
|
+
// exists to preserve. Deciding on content instead means a zero-byte
|
|
346
|
+
// journal is re-headered and stays replayable.
|
|
347
|
+
if (!journalHasContent(this.path)) {
|
|
336
348
|
appendFileSync(this.path, JSON.stringify(header) + '\n', { mode: 0o600 });
|
|
337
349
|
}
|
|
338
350
|
this._initialised = true;
|
|
@@ -358,8 +370,134 @@ function isValidRecord(value: unknown): value is JournalRecord {
|
|
|
358
370
|
|
|
359
371
|
function quarantineJournal(journalPath: string): void {
|
|
360
372
|
try {
|
|
361
|
-
renameSync(journalPath, `${journalPath}
|
|
373
|
+
renameSync(journalPath, `${journalPath}${UNRECOGNIZED_SUFFIX}`);
|
|
362
374
|
} catch {
|
|
363
375
|
// Best-effort — if rename fails, proceed silently.
|
|
364
376
|
}
|
|
365
377
|
}
|
|
378
|
+
|
|
379
|
+
/** True when the journal file exists AND holds at least one byte. */
|
|
380
|
+
function journalHasContent(journalPath: string): boolean {
|
|
381
|
+
try {
|
|
382
|
+
return statSync(journalPath).size > 0;
|
|
383
|
+
} catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ─── Reaping orphaned journals ──────────────────────────────────────────────
|
|
389
|
+
//
|
|
390
|
+
// `rotate()` deletes a journal once its records have been folded into a
|
|
391
|
+
// snapshot or replayed. A session that crashes and is never resumed never
|
|
392
|
+
// reaches either, so its journal stays on disk forever — one file per
|
|
393
|
+
// abandoned session. The SDK's registered append-only retention sweep does not
|
|
394
|
+
// cover this home-scoped path, so the reclaim happens here.
|
|
395
|
+
|
|
396
|
+
const JOURNAL_PREFIX = 'transcript-';
|
|
397
|
+
const JOURNAL_SUFFIX = '.journal';
|
|
398
|
+
|
|
399
|
+
/** How long an untouched journal for a non-live session is kept: 7 days. */
|
|
400
|
+
export const JOURNAL_ORPHAN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Hard ceiling on reapable journals kept after the age rule, newest kept.
|
|
404
|
+
* A burst of crashes inside the age window is still bounded.
|
|
405
|
+
*/
|
|
406
|
+
export const JOURNAL_ORPHAN_MAX_FILES = 50;
|
|
407
|
+
|
|
408
|
+
export interface JournalReapResult {
|
|
409
|
+
/** Journal files examined this sweep. */
|
|
410
|
+
readonly scanned: number;
|
|
411
|
+
/** Journal files deleted this sweep. */
|
|
412
|
+
readonly reaped: number;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export interface JournalReapOptions {
|
|
416
|
+
/**
|
|
417
|
+
* Is this session open in a still-running process? Injected rather than
|
|
418
|
+
* imported so this module keeps no dependency on the liveness marker — the
|
|
419
|
+
* composition point (runtime/durability-housekeeping.ts) supplies the real
|
|
420
|
+
* check, and tests supply their own.
|
|
421
|
+
*/
|
|
422
|
+
readonly isSessionLive: (sessionId: string) => boolean;
|
|
423
|
+
/** The session this process is writing right now; never reaped. */
|
|
424
|
+
readonly currentSessionId?: string | null;
|
|
425
|
+
readonly now?: () => number;
|
|
426
|
+
/** Override the age window (tests). */
|
|
427
|
+
readonly maxAgeMs?: number;
|
|
428
|
+
/** Override the count cap (tests). */
|
|
429
|
+
readonly maxFiles?: number;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Delete transcript journals belonging to sessions that crashed and were never
|
|
434
|
+
* resumed.
|
|
435
|
+
*
|
|
436
|
+
* A journal is reapable only when it is neither the current session's nor
|
|
437
|
+
* apparently open in another running process. Of those, one is deleted when it
|
|
438
|
+
* is empty (a zero-byte file holds no records, so nothing can be lost) or
|
|
439
|
+
* untouched for longer than the age window; whatever survives both rules is
|
|
440
|
+
* then capped by count, newest kept.
|
|
441
|
+
*
|
|
442
|
+
* The rules are deliberately mtime- and liveness-based, never parse-based: an
|
|
443
|
+
* unparseable TAIL is the normal, expected shape of a journal killed
|
|
444
|
+
* mid-append and is exactly the data replay is there to salvage, so a parse
|
|
445
|
+
* failure must never make a journal reapable.
|
|
446
|
+
*
|
|
447
|
+
* Idempotent and concurrency-safe: a journal another sweeper unlinked between
|
|
448
|
+
* the listing and this unlink (ENOENT) counts as reaped, not as an error.
|
|
449
|
+
*/
|
|
450
|
+
export function reapOrphanedJournals(surface: SessionSurface, options: JournalReapOptions): JournalReapResult {
|
|
451
|
+
const dir = join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot);
|
|
452
|
+
let names: string[];
|
|
453
|
+
try {
|
|
454
|
+
names = readdirSync(dir).filter((n) => n.startsWith(JOURNAL_PREFIX) && n.endsWith(JOURNAL_SUFFIX));
|
|
455
|
+
} catch {
|
|
456
|
+
return { scanned: 0, reaped: 0 };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const now = options.now?.() ?? Date.now();
|
|
460
|
+
const maxAgeMs = options.maxAgeMs ?? JOURNAL_ORPHAN_MAX_AGE_MS;
|
|
461
|
+
const maxFiles = options.maxFiles ?? JOURNAL_ORPHAN_MAX_FILES;
|
|
462
|
+
|
|
463
|
+
let reaped = 0;
|
|
464
|
+
const survivors: { readonly path: string; readonly mtimeMs: number }[] = [];
|
|
465
|
+
|
|
466
|
+
for (const name of names) {
|
|
467
|
+
const sessionId = name.slice(JOURNAL_PREFIX.length, name.length - JOURNAL_SUFFIX.length);
|
|
468
|
+
if (sessionId.length === 0) continue;
|
|
469
|
+
if (options.currentSessionId && sessionId === options.currentSessionId) continue;
|
|
470
|
+
if (options.isSessionLive(sessionId)) continue;
|
|
471
|
+
|
|
472
|
+
const path = join(dir, name);
|
|
473
|
+
let stats: { size: number; mtimeMs: number };
|
|
474
|
+
try {
|
|
475
|
+
stats = statSync(path);
|
|
476
|
+
} catch {
|
|
477
|
+
continue; // vanished under us
|
|
478
|
+
}
|
|
479
|
+
if (stats.size === 0 || now - stats.mtimeMs > maxAgeMs) {
|
|
480
|
+
if (unlinkJournalIfPresent(path)) reaped++;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
survivors.push({ path, mtimeMs: stats.mtimeMs });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (survivors.length > maxFiles) {
|
|
487
|
+
survivors.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
488
|
+
for (const victim of survivors.slice(0, survivors.length - maxFiles)) {
|
|
489
|
+
if (unlinkJournalIfPresent(victim.path)) reaped++;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return { scanned: names.length, reaped };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function unlinkJournalIfPresent(path: string): boolean {
|
|
497
|
+
try {
|
|
498
|
+
unlinkSync(path);
|
|
499
|
+
return true;
|
|
500
|
+
} catch (err) {
|
|
501
|
+
return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
|
|
502
|
+
}
|
|
503
|
+
}
|