@pellux/goodvibes-tui 1.19.6 → 1.20.1
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 +13 -0
- package/LICENSE +21 -0
- package/README.md +143 -1613
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
- package/src/cli/entrypoint.ts +5 -1
- package/src/cli/launch-auto-update.ts +63 -4
- package/src/cli/management.ts +1 -1
- package/src/cli/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-line-cache.ts +1 -1
- package/src/core/conversation-rendering.ts +70 -24
- package/src/core/conversation-tool-groups.ts +23 -10
- package/src/core/conversation-types.ts +9 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +20 -17
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +59 -2
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/stream-event-wiring.ts +174 -14
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +32 -6
- package/src/core/turn-event-wiring.ts +55 -12
- package/src/input/command-args-hint.ts +11 -2
- package/src/input/command-registry.ts +19 -0
- package/src/input/commands/checkpoint-runtime.ts +3 -3
- package/src/input/commands/health-runtime.ts +1 -3
- package/src/input/commands/local-runtime.ts +1 -1
- package/src/input/commands/rewind-runtime.ts +132 -1
- package/src/input/commands/runtime-services.ts +11 -1
- package/src/input/commands/secret-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +9 -1
- package/src/input/commands/session-picker-filter.ts +28 -0
- package/src/input/commands/session-workflow.ts +62 -73
- package/src/input/commands/session.ts +36 -1
- package/src/input/commands/shell-core.ts +57 -5
- package/src/input/commands/update-runtime.ts +120 -4
- package/src/input/commands/websearch-runtime.ts +1 -1
- package/src/input/handler-content-actions.ts +63 -39
- package/src/input/handler-feed-routes.ts +17 -3
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +19 -23
- package/src/input/handler-modal-token-routes.ts +5 -0
- package/src/input/handler-types.ts +4 -1
- package/src/input/handler-ui-state.ts +6 -2
- package/src/input/handler.ts +19 -9
- package/src/input/search.ts +94 -2
- package/src/input/selection-modal-queue.ts +176 -0
- package/src/main.ts +66 -61
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/renderer/block-actions-overlay.ts +59 -0
- package/src/renderer/block-actions.ts +1 -3
- package/src/renderer/context-status-hint.ts +73 -5
- package/src/renderer/conversation-layout.ts +16 -0
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +51 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +26 -79
- package/src/runtime/bootstrap-command-context.ts +5 -1
- package/src/runtime/bootstrap-command-parts.ts +5 -2
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +62 -44
- package/src/runtime/bootstrap-shell.ts +31 -16
- package/src/runtime/bootstrap.ts +13 -18
- package/src/runtime/code-index-services.ts +19 -0
- package/src/runtime/durability-services.ts +11 -7
- package/src/runtime/index.ts +12 -0
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-decisions.ts +126 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +362 -0
- package/src/runtime/resume-notice.ts +28 -93
- package/src/runtime/services.ts +14 -16
- package/src/runtime/session-liveness-marker.ts +120 -0
- package/src/runtime/session-pointer-surface.ts +22 -0
- package/src/runtime/session-resume-liveness-confirm.ts +84 -0
- package/src/runtime/session-storage-services.ts +41 -0
- package/src/runtime/workspace-checkpointing.ts +10 -2
- package/src/shell/recovery-input-helpers.ts +7 -3
- package/src/shell/retry-affordance.ts +123 -0
- package/src/shell/ui-openers.ts +9 -0
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-resume-core.ts — the ONE resume routine both resume seams call.
|
|
3
|
+
*
|
|
4
|
+
* Two independent call sites used to duplicate (and diverge from) this
|
|
5
|
+
* sequence: `/session resume` (session-workflow.ts) and the panel/session-
|
|
6
|
+
* browser resume (bootstrap-hook-bridge.ts's `createResumeSessionHandler`).
|
|
7
|
+
* Divergences found by audit:
|
|
8
|
+
* - the panel seam skipped `restoreTurnAnchors` (message-anchored /rewind
|
|
9
|
+
* silently had no anchors after a panel resume)
|
|
10
|
+
* - the panel seam skipped `conversation.resetAll()` before `fromJSON()`
|
|
11
|
+
* - the panel seam skipped the `selectModel` reselection fallback (it set
|
|
12
|
+
* `runtime.model` straight from the saved meta, never re-resolving
|
|
13
|
+
* through the live provider registry)
|
|
14
|
+
* - the panel seam duplicated the panel-reopen loop WITHOUT the
|
|
15
|
+
* modal-redirect skip, so a MIGRATE-TO-MODAL id could pop a modal
|
|
16
|
+
* mid-resume
|
|
17
|
+
*
|
|
18
|
+
* Both seams now call this module so those four behaviors (plus the
|
|
19
|
+
* panel-reopen-cap honesty note) are guaranteed identical by construction,
|
|
20
|
+
* not by copy-paste discipline between two files.
|
|
21
|
+
*
|
|
22
|
+
* Callers own everything ABOVE and AROUND this core sequence: printing or
|
|
23
|
+
* logging the outcome in their own idiom (`ctx.print` vs `conversation.log`),
|
|
24
|
+
* and any extra plumbing that only one seam performs (hookDispatcher.fire,
|
|
25
|
+
* sessionSpine.reopen, sharedSessionBroker.reopenSession,
|
|
26
|
+
* writeLastSessionPointer — all bootstrap-hook-bridge-only, pre-existing
|
|
27
|
+
* asymmetries out of this module's scope).
|
|
28
|
+
*/
|
|
29
|
+
import type { SessionManager, SessionMeta } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
30
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
31
|
+
import type { ConversationManager } from './conversation.ts';
|
|
32
|
+
import { restoreTurnAnchors } from './rewind-turn-anchors.ts';
|
|
33
|
+
import { replayJournalForSession, type ReplayIntoConversationResult } from './session-recovery.ts';
|
|
34
|
+
|
|
35
|
+
export interface SessionResumeRuntime {
|
|
36
|
+
sessionId: string;
|
|
37
|
+
model: string;
|
|
38
|
+
provider: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SessionResumePanelManager {
|
|
42
|
+
open(id: string): unknown;
|
|
43
|
+
show(): void;
|
|
44
|
+
getModalRedirect(id: string): string | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SessionResumeDeps {
|
|
48
|
+
readonly sessionManager: Pick<SessionManager, 'load' | 'save'>;
|
|
49
|
+
readonly conversation: ConversationManager;
|
|
50
|
+
readonly runtime: SessionResumeRuntime;
|
|
51
|
+
/**
|
|
52
|
+
* The app's declare-once session-storage handle. Both resume seams pass the
|
|
53
|
+
* SAME one the runtime writes through, so the anchor sidecar and the
|
|
54
|
+
* transcript journal this routine reads are guaranteed to be the files the
|
|
55
|
+
* live session actually wrote — the scope can no longer be re-guessed per
|
|
56
|
+
* seam.
|
|
57
|
+
*/
|
|
58
|
+
readonly surface: SessionSurface;
|
|
59
|
+
readonly panelManager: SessionResumePanelManager;
|
|
60
|
+
/**
|
|
61
|
+
* Reselects the saved model through the live provider registry, falling
|
|
62
|
+
* back to the raw saved id on failure (a saved model may no longer exist
|
|
63
|
+
* locally). Omit to use the saved id directly without reselection.
|
|
64
|
+
*/
|
|
65
|
+
readonly selectModel?: (model: string) => Promise<{ readonly registryKey: string; readonly providerId: string }>;
|
|
66
|
+
readonly hydrateSessionUsage?: () => void;
|
|
67
|
+
/**
|
|
68
|
+
* Deliberate cap on how many saved panels are reopened at once — resuming
|
|
69
|
+
* into a workspace crowded with every panel that happened to be open is
|
|
70
|
+
* its own kind of surprise. Overflow beyond the cap is reported in the
|
|
71
|
+
* outcome (`panels.notReopened`), never silently dropped. Defaults to 4.
|
|
72
|
+
*/
|
|
73
|
+
readonly panelReopenLimit?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PanelReopenOutcome {
|
|
77
|
+
readonly reopened: readonly string[];
|
|
78
|
+
readonly movedToModal: readonly string[];
|
|
79
|
+
/** Saved panel ids beyond `panelReopenLimit` — not attempted, honestly reported (see /panels to open the rest). */
|
|
80
|
+
readonly notReopened: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SessionResumeOutcome {
|
|
84
|
+
readonly meta: SessionMeta;
|
|
85
|
+
readonly resumedMessageCount: number;
|
|
86
|
+
readonly restoredAnchorCount: number;
|
|
87
|
+
readonly journalReplay: ReplayIntoConversationResult;
|
|
88
|
+
readonly panels: PanelReopenOutcome;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Exported so both session-workflow.ts's standalone `reopenPanelsFromReturnContext`
|
|
93
|
+
* (kept for its existing direct unit-test coverage — see
|
|
94
|
+
* session-workflow-panel-restore.test.ts) and `resumeSessionCore` below share
|
|
95
|
+
* the exact same default cap.
|
|
96
|
+
*/
|
|
97
|
+
export const DEFAULT_PANEL_REOPEN_LIMIT = 4;
|
|
98
|
+
|
|
99
|
+
export function reopenPanelsWithModalSkip(
|
|
100
|
+
panelManager: SessionResumePanelManager,
|
|
101
|
+
openPanelIds: readonly string[] | undefined,
|
|
102
|
+
limit: number,
|
|
103
|
+
): PanelReopenOutcome {
|
|
104
|
+
if (!openPanelIds || openPanelIds.length === 0) return { reopened: [], movedToModal: [], notReopened: [] };
|
|
105
|
+
const within = openPanelIds.slice(0, limit);
|
|
106
|
+
const overflow = openPanelIds.slice(limit);
|
|
107
|
+
const reopened: string[] = [];
|
|
108
|
+
const movedToModal: string[] = [];
|
|
109
|
+
for (const panelId of within) {
|
|
110
|
+
// A MIGRATE-TO-MODAL id has no panel to restore — a modal is not part of
|
|
111
|
+
// the saved panel layout. Skip it (don't pop a modal mid-resume) and note
|
|
112
|
+
// it once, rather than firing open() and revealing an empty workspace.
|
|
113
|
+
if (panelManager.getModalRedirect(panelId) !== undefined) {
|
|
114
|
+
movedToModal.push(panelId);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
panelManager.open(panelId);
|
|
119
|
+
reopened.push(panelId);
|
|
120
|
+
} catch {
|
|
121
|
+
// Ignore unknown or currently unavailable panel ids during resume.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (reopened.length > 0) panelManager.show();
|
|
125
|
+
return { reopened, movedToModal, notReopened: overflow };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The canonical resume sequence: load, reset + restore the conversation,
|
|
130
|
+
* restore rewind anchors, replay any post-snapshot journal records, hydrate
|
|
131
|
+
* footer usage, reselect the model, and reopen saved panels (modal-redirect
|
|
132
|
+
* aware, cap honestly reported).
|
|
133
|
+
*/
|
|
134
|
+
export async function resumeSessionCore(sessionId: string, deps: SessionResumeDeps): Promise<SessionResumeOutcome> {
|
|
135
|
+
const { meta, messages } = deps.sessionManager.load(sessionId);
|
|
136
|
+
|
|
137
|
+
deps.conversation.resetAll();
|
|
138
|
+
deps.conversation.fromJSON({
|
|
139
|
+
messages: messages as never[],
|
|
140
|
+
title: meta.title,
|
|
141
|
+
titleSource: meta.titleSource,
|
|
142
|
+
});
|
|
143
|
+
deps.conversation.rebuildHistory();
|
|
144
|
+
deps.runtime.sessionId = sessionId;
|
|
145
|
+
|
|
146
|
+
// Restore this session's message-anchored rewind anchors from its sidecar
|
|
147
|
+
// so /rewind works identically before and after the resume — the in-memory
|
|
148
|
+
// registry is process-local, so a fresh process starts with none for the
|
|
149
|
+
// loaded session.
|
|
150
|
+
const restoredAnchorCount = restoreTurnAnchors(sessionId, deps.surface);
|
|
151
|
+
|
|
152
|
+
// Journal replay: recover turns that post-date the loaded snapshot.
|
|
153
|
+
const journalReplay = replayJournalForSession({
|
|
154
|
+
surface: deps.surface,
|
|
155
|
+
sessionId,
|
|
156
|
+
snapshotTimestamp: meta.timestamp ?? 0,
|
|
157
|
+
conversation: deps.conversation,
|
|
158
|
+
persistSnapshot: (replayedMessages) => {
|
|
159
|
+
deps.sessionManager.save(sessionId, replayedMessages as never[], {
|
|
160
|
+
title: deps.conversation.title || meta.title,
|
|
161
|
+
model: meta.model,
|
|
162
|
+
provider: meta.provider,
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
titleSource: meta.titleSource,
|
|
165
|
+
returnContext: meta.returnContext,
|
|
166
|
+
// Machinery: this write exists only to durably close the journal gap
|
|
167
|
+
// the replay just filled. The user's own save/fork/rename acts stamp
|
|
168
|
+
// 'user' at their own call sites (session-workflow.ts).
|
|
169
|
+
saveSource: 'auto',
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Hydrate the footer's token counters from the resumed (+ journal-replayed)
|
|
175
|
+
// history now, before the caller renders.
|
|
176
|
+
deps.hydrateSessionUsage?.();
|
|
177
|
+
|
|
178
|
+
if (meta.model) {
|
|
179
|
+
if (deps.selectModel) {
|
|
180
|
+
try {
|
|
181
|
+
const selected = await deps.selectModel(meta.model);
|
|
182
|
+
deps.runtime.model = selected.registryKey;
|
|
183
|
+
deps.runtime.provider = selected.providerId;
|
|
184
|
+
} catch {
|
|
185
|
+
deps.runtime.model = meta.model; // model may not exist locally
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
deps.runtime.model = meta.model;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (meta.provider) deps.runtime.provider = meta.provider;
|
|
192
|
+
|
|
193
|
+
const panels = reopenPanelsWithModalSkip(
|
|
194
|
+
deps.panelManager,
|
|
195
|
+
meta.returnContext?.openPanels,
|
|
196
|
+
deps.panelReopenLimit ?? DEFAULT_PANEL_REOPEN_LIMIT,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
meta,
|
|
201
|
+
resumedMessageCount: deps.conversation.getMessageCount(),
|
|
202
|
+
restoredAnchorCount,
|
|
203
|
+
journalReplay,
|
|
204
|
+
panels,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -2,6 +2,8 @@ import type { UiRuntimeEvents } from '@/runtime/index.ts';
|
|
|
2
2
|
import { createStreamStallWatchdog } from './stream-stall-watchdog.ts';
|
|
3
3
|
import { buildRoutingChip } 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';
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -101,6 +103,14 @@ interface FailoverOptimizer {
|
|
|
101
103
|
interface StreamSystemMessageRouter {
|
|
102
104
|
high(message: string): void;
|
|
103
105
|
low(message: string): void;
|
|
106
|
+
/**
|
|
107
|
+
* Unconditional conversation delivery (see system-message-router.ts). Used
|
|
108
|
+
* for provider-switch notices: which backend serves a turn — and therefore
|
|
109
|
+
* who bills for it — is never ambient chatter that a routing preference or
|
|
110
|
+
* the noise gate may filter out. Optional so bare test doubles that supply
|
|
111
|
+
* only high/low still type-check; announce() below falls back to high().
|
|
112
|
+
*/
|
|
113
|
+
userReceipt?(message: string): void;
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
/**
|
|
@@ -166,8 +176,15 @@ export interface WireStreamEventMetricsOptions {
|
|
|
166
176
|
* Callback the caller provides to re-submit the last user turn on a
|
|
167
177
|
* different provider after a successful failover switch. Called only when
|
|
168
178
|
* the optimizer is enabled and a viable next provider exists in the chain.
|
|
179
|
+
*
|
|
180
|
+
* The re-submission rolls the conversation back to its pre-submission
|
|
181
|
+
* message count, which deletes everything the failed turn added — including
|
|
182
|
+
* a failover notice appended before the call. So the notice is passed IN
|
|
183
|
+
* rather than emitted before: the caller re-posts it after its rollback, and
|
|
184
|
+
* it survives to be read. Implementations that do not roll back may ignore
|
|
185
|
+
* the argument, but must then post the notice themselves.
|
|
169
186
|
*/
|
|
170
|
-
readonly retryTurn?: () => void;
|
|
187
|
+
readonly retryTurn?: (notice?: string) => void;
|
|
171
188
|
/**
|
|
172
189
|
* Optional cost catalog for attaching per-1M-token cost information to
|
|
173
190
|
* the failover notice. When provided and both models have non-zero pricing,
|
|
@@ -189,6 +206,22 @@ export interface WireStreamEventMetricsOptions {
|
|
|
189
206
|
* callers omit it.
|
|
190
207
|
*/
|
|
191
208
|
readonly stallThresholdMs?: number | undefined;
|
|
209
|
+
/**
|
|
210
|
+
* Shared holder for the live failover record (core/active-model-identity.ts).
|
|
211
|
+
* Set when failover switches the registry off the user's configured
|
|
212
|
+
* selection, cleared once serving is restored to it. The render frame reads
|
|
213
|
+
* the same object, so the header and footer describe the switch while it is
|
|
214
|
+
* in effect. Omitted by unit tests that only exercise notice content.
|
|
215
|
+
*/
|
|
216
|
+
readonly failoverState?: FailoverTurnState;
|
|
217
|
+
/**
|
|
218
|
+
* The user's configured model selection (config `provider.model`) as a
|
|
219
|
+
* registry key. Read at the moment of a failover switch so the turn-end
|
|
220
|
+
* restore targets what the user actually chose — not whatever the registry
|
|
221
|
+
* happened to hold. Without it, failover still works but no restore or
|
|
222
|
+
* divergence marker is possible, so both are skipped rather than guessed at.
|
|
223
|
+
*/
|
|
224
|
+
readonly getConfiguredRegistryKey?: () => string | undefined;
|
|
192
225
|
}
|
|
193
226
|
|
|
194
227
|
/** Result of wireStreamEventMetrics. */
|
|
@@ -196,10 +229,12 @@ export interface WireStreamEventMetricsResult {
|
|
|
196
229
|
/** Unsubscribe functions; push into the parent unsubs array for cleanup on exit. */
|
|
197
230
|
readonly unsubs: ReadonlyArray<() => void>;
|
|
198
231
|
/**
|
|
199
|
-
* Clear the per-turn failover visited-provider set
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
232
|
+
* Clear the per-turn failover visited-provider set AND restore the user's
|
|
233
|
+
* configured model selection if a failover left serving somewhere else.
|
|
234
|
+
* Call this on every new user submission so neither the visited set nor a
|
|
235
|
+
* per-turn provider switch bleeds across independent turns (both are also
|
|
236
|
+
* handled on TURN_COMPLETED/TURN_CANCEL, but a new submission may arrive
|
|
237
|
+
* before either fires).
|
|
203
238
|
*/
|
|
204
239
|
readonly clearFailoverVisited: () => void;
|
|
205
240
|
/**
|
|
@@ -253,6 +288,33 @@ function buildCostDeltaSuffix(
|
|
|
253
288
|
return ` [cost/1M: input ${fmt(fromCost.input)}→${fmt(toCost.input)}, output ${fmt(fromCost.output)}→${fmt(toCost.output)}]`;
|
|
254
289
|
}
|
|
255
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Build the billing-class segment of a failover notice, e.g.
|
|
293
|
+
* ` [billing: API key → Subscription — billing class changed]`.
|
|
294
|
+
*
|
|
295
|
+
* This path is NOT the synthetic provider's tier-isolated failover, which is
|
|
296
|
+
* where the documented "free, paid and subscription tiers never mix" contract
|
|
297
|
+
* lives (docs/providers-and-routing.md:77-128, enforced in the SDK's
|
|
298
|
+
* synthetic.ts by CanonicalModel.tier). The optimizer chain consumed here
|
|
299
|
+
* carries no tier metadata at all — its nodes are
|
|
300
|
+
* `{ position, providerId, modelId, capable, explanation }` and `explanation`
|
|
301
|
+
* describes functional capability (streaming, tool calling, context size),
|
|
302
|
+
* never billing. So the switch cannot be constrained by a tier it cannot see;
|
|
303
|
+
* what it CAN do is say out loud which billing class it moved to, so a user
|
|
304
|
+
* who does not want their subscription spent on an automatic retry can object
|
|
305
|
+
* and turn the optimizer off.
|
|
306
|
+
*
|
|
307
|
+
* Classification comes from providers/provider-classification.ts, which is
|
|
308
|
+
* honest about ignorance: an unrecognised provider id reports "Unknown" rather
|
|
309
|
+
* than being quietly assumed safe.
|
|
310
|
+
*/
|
|
311
|
+
function buildBillingSuffix(fromProviderId: string, toProviderId: string): string {
|
|
312
|
+
const from = classifyProviderSetup({ providerId: fromProviderId }).setupLabel;
|
|
313
|
+
const to = classifyProviderSetup({ providerId: toProviderId }).setupLabel;
|
|
314
|
+
const changed = from !== to ? ' — billing class changed' : '';
|
|
315
|
+
return ` [billing: ${from} → ${to}${changed}]`;
|
|
316
|
+
}
|
|
317
|
+
|
|
256
318
|
/**
|
|
257
319
|
* Wire STREAM_* and TOOL_* runtime events to the provided StreamMetrics object
|
|
258
320
|
* and install the stream-stall watchdog. The caller owns the metrics object
|
|
@@ -273,11 +335,73 @@ export function wireStreamEventMetrics(
|
|
|
273
335
|
const {
|
|
274
336
|
events, metrics, orchestrator, providerRegistry,
|
|
275
337
|
systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
|
|
276
|
-
isApprovalPending, stallThresholdMs,
|
|
338
|
+
isApprovalPending, stallThresholdMs, failoverState, getConfiguredRegistryKey,
|
|
277
339
|
} = options;
|
|
278
340
|
|
|
279
341
|
const unsubs: Array<() => void> = [];
|
|
280
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Deliver a provider-switch notice to the conversation unconditionally.
|
|
345
|
+
* Prefers the router's userReceipt channel, which skips the noise gate and
|
|
346
|
+
* the ui.operationalMessages routing target entirely, so the line cannot be
|
|
347
|
+
* filtered away by a preference and is treated as real content even while
|
|
348
|
+
* the splash still owns the screen. Falls back to high() for bare doubles.
|
|
349
|
+
*/
|
|
350
|
+
const announce = (message: string): void => {
|
|
351
|
+
if (systemMessageRouter.userReceipt) systemMessageRouter.userReceipt(message);
|
|
352
|
+
else systemMessageRouter.high(message);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Put serving back on the user's configured selection after a failed-over
|
|
357
|
+
* turn ends.
|
|
358
|
+
*
|
|
359
|
+
* Failover is per-turn recovery, not a permanent re-selection. Without this,
|
|
360
|
+
* the one setCurrentModel() call that rescued a single failed turn stayed in
|
|
361
|
+
* force for the rest of the session: every later turn ran on the fallback
|
|
362
|
+
* backend while nothing in the session state or the footer had changed to
|
|
363
|
+
* say so. The user's configured selection is authoritative, so the NEXT turn
|
|
364
|
+
* starts from it again. If that backend is still unhealthy, the normal
|
|
365
|
+
* per-turn failover handles it again — visibly, with a fresh notice — which
|
|
366
|
+
* is exactly the behaviour a sticky override was hiding.
|
|
367
|
+
*
|
|
368
|
+
* The record is kept (not cleared) when the restore itself fails, so the
|
|
369
|
+
* header and footer keep reporting the real serving backend rather than
|
|
370
|
+
* claiming a revert that did not happen.
|
|
371
|
+
*/
|
|
372
|
+
/**
|
|
373
|
+
* The registry key the restore below is switching back to, held only across
|
|
374
|
+
* that one setCurrentModel call. The MODEL_CHANGED listener reads it to keep
|
|
375
|
+
* the routing chip quiet for this change: the chip's copy is "reason
|
|
376
|
+
* unknown", which would be false here — the reason is known and the
|
|
377
|
+
* `[Failover] Restored …` line states it.
|
|
378
|
+
*/
|
|
379
|
+
let pendingRestoreKey: string | null = null;
|
|
380
|
+
|
|
381
|
+
const restoreConfiguredSelection = (): void => {
|
|
382
|
+
const record = failoverState?.current();
|
|
383
|
+
if (!record || !failoverState) return;
|
|
384
|
+
if (providerRegistry.getCurrentModel().registryKey === record.configuredRegistryKey) {
|
|
385
|
+
failoverState.clear();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
pendingRestoreKey = record.configuredRegistryKey;
|
|
390
|
+
providerRegistry.setCurrentModel(record.configuredRegistryKey);
|
|
391
|
+
failoverState.clear();
|
|
392
|
+
announce(`[Failover] Restored ${record.configuredRegistryKey} for the next turn.`);
|
|
393
|
+
} catch (restoreErr) {
|
|
394
|
+
pendingRestoreKey = null;
|
|
395
|
+
logger.debug('failover restore failed', {
|
|
396
|
+
configuredRegistryKey: record.configuredRegistryKey, error: String(restoreErr),
|
|
397
|
+
});
|
|
398
|
+
announce(
|
|
399
|
+
`[Failover] Could not switch back to ${record.configuredRegistryKey}; still serving ${record.servingRegistryKey}.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
render();
|
|
403
|
+
};
|
|
404
|
+
|
|
281
405
|
unsubs.push(events.turns.on('STREAM_START', () => {
|
|
282
406
|
metrics.startTime = Date.now();
|
|
283
407
|
metrics.deltaCount = 0;
|
|
@@ -318,8 +442,20 @@ export function wireStreamEventMetrics(
|
|
|
318
442
|
// (caller clears via clearFailoverVisited(), wired in main.ts).
|
|
319
443
|
const failoverVisited = new Set<string>();
|
|
320
444
|
|
|
445
|
+
// Both terminal turn outcomes end the failover's authority over the
|
|
446
|
+
// registry: a completed turn got its answer, a cancelled one will not.
|
|
447
|
+
// (TURN_COMPLETED and TURN_ERROR are mutually exclusive per turn in the SDK
|
|
448
|
+
// — TURN_COMPLETED is emitted only on the success path in
|
|
449
|
+
// orchestrator-turn-helpers, TURN_ERROR only from the orchestrator's catch —
|
|
450
|
+
// so restoring here can never undo a switch whose retry has not run yet.)
|
|
321
451
|
unsubs.push(events.turns.on('TURN_COMPLETED', () => {
|
|
322
452
|
failoverVisited.clear();
|
|
453
|
+
restoreConfiguredSelection();
|
|
454
|
+
}));
|
|
455
|
+
|
|
456
|
+
unsubs.push(events.turns.on('TURN_CANCEL', () => {
|
|
457
|
+
failoverVisited.clear();
|
|
458
|
+
restoreConfiguredSelection();
|
|
323
459
|
}));
|
|
324
460
|
|
|
325
461
|
// Routing chip (never-silent model change): every MODEL_CHANGED that is not
|
|
@@ -330,6 +466,11 @@ export function wireStreamEventMetrics(
|
|
|
330
466
|
// Degrade gracefully when a bare test double omits the providers feed.
|
|
331
467
|
if (events.providers) {
|
|
332
468
|
unsubs.push(events.providers.on('MODEL_CHANGED', (change) => {
|
|
469
|
+
// Listeners run synchronously inside setCurrentModel, so this reads the
|
|
470
|
+
// restore flag before anything else can touch it.
|
|
471
|
+
const wasRestore = pendingRestoreKey !== null && change.registryKey === pendingRestoreKey;
|
|
472
|
+
pendingRestoreKey = null;
|
|
473
|
+
if (wasRestore) return;
|
|
333
474
|
queueMicrotask(() => {
|
|
334
475
|
const chip = buildRoutingChip(change, providerOptimizer?.fallbackLog ?? [], Date.now());
|
|
335
476
|
if (chip === null) return;
|
|
@@ -373,30 +514,45 @@ export function wireStreamEventMetrics(
|
|
|
373
514
|
try {
|
|
374
515
|
providerRegistry.setCurrentModel(toRegistryKey);
|
|
375
516
|
} catch (switchErr) {
|
|
376
|
-
// Switch failed — fall through to honest error display.
|
|
517
|
+
// Switch failed — fall through to honest error display. This ends the
|
|
518
|
+
// turn, so an EARLIER hop's switch (if this is a second failover
|
|
519
|
+
// within the same turn) loses its authority here just as it would on
|
|
520
|
+
// any other terminal outcome.
|
|
377
521
|
logger.debug('failover setCurrentModel failed', { toRegistryKey, error: String(switchErr) });
|
|
378
522
|
systemMessageRouter.high(`[Error] ${errorClass}`);
|
|
523
|
+
restoreConfiguredSelection();
|
|
379
524
|
render();
|
|
380
525
|
return;
|
|
381
526
|
}
|
|
382
527
|
// Record the selected provider as visited before the retry fires so
|
|
383
528
|
// a subsequent TURN_ERROR from that provider also skips it.
|
|
384
529
|
failoverVisited.add(next.providerId);
|
|
530
|
+
// Remember the user's configured selection so the turn-end restore
|
|
531
|
+
// targets it, and so both shell surfaces can name it while the switch
|
|
532
|
+
// is in force. Sticky across a second hop within the same turn.
|
|
533
|
+
const configuredRegistryKey = getConfiguredRegistryKey?.();
|
|
534
|
+
if (configuredRegistryKey) {
|
|
535
|
+
failoverState?.begin({ configuredRegistryKey, servingRegistryKey: toRegistryKey });
|
|
536
|
+
}
|
|
385
537
|
providerOptimizer.recordFallbackTransition(fromProvider, next.providerId, errorClass);
|
|
386
538
|
const costSuffix = buildCostDeltaSuffix(costLookup, fromRegistryKey, toRegistryKey);
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
539
|
+
const billingSuffix = buildBillingSuffix(fromProvider, next.providerId);
|
|
540
|
+
// Re-submit the last user turn on the new provider, handing the notice
|
|
541
|
+
// to retryTurn so it outlives that call's transcript rollback (see the
|
|
542
|
+
// retryTurn option doc). Emitting it here instead would delete it.
|
|
543
|
+
retryTurn(`[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${billingSuffix}${costSuffix}`);
|
|
390
544
|
render();
|
|
391
|
-
// Re-submit the last user turn on the new provider.
|
|
392
|
-
retryTurn();
|
|
393
545
|
return;
|
|
394
546
|
}
|
|
395
547
|
|
|
396
548
|
// Chain exhausted — all capable candidates have been visited or none exist.
|
|
397
|
-
|
|
549
|
+
// The turn is over, so any switch made earlier in it loses its authority:
|
|
550
|
+
// restore the configured selection before surfacing the error, or the
|
|
551
|
+
// user's next turn would silently start on the last fallback tried.
|
|
552
|
+
announce(
|
|
398
553
|
`[Failover] Chain exhausted — no alternative provider available. Original error: ${formatUserFacingErrorLine(errVal)}`,
|
|
399
554
|
);
|
|
555
|
+
restoreConfiguredSelection();
|
|
400
556
|
notifyErrorSurfaced(true);
|
|
401
557
|
render();
|
|
402
558
|
return;
|
|
@@ -494,7 +650,11 @@ export function wireStreamEventMetrics(
|
|
|
494
650
|
function notifyErrorSurfaced(exhausted: boolean) { _errorSurfacedCb?.(exhausted); }
|
|
495
651
|
return {
|
|
496
652
|
unsubs,
|
|
497
|
-
|
|
653
|
+
// A new user submission is also a turn boundary: restore here too, so a
|
|
654
|
+
// turn that ended without any terminal event (an aborted stream that
|
|
655
|
+
// emitted neither TURN_COMPLETED nor TURN_CANCEL) still cannot leave the
|
|
656
|
+
// next turn silently pinned to a fallback backend.
|
|
657
|
+
clearFailoverVisited: () => { failoverVisited.clear(); restoreConfiguredSelection(); },
|
|
498
658
|
onErrorSurfaced: (cb: (exhausted: boolean) => void) => { _errorSurfacedCb = cb; },
|
|
499
659
|
};
|
|
500
660
|
}
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
} from 'node:fs';
|
|
68
68
|
import { dirname, join } from 'node:path';
|
|
69
69
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
70
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
70
71
|
|
|
71
72
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
72
73
|
|
|
@@ -128,6 +129,17 @@ export interface TranscriptJournal {
|
|
|
128
129
|
|
|
129
130
|
/** Absolute path to the journal file. */
|
|
130
131
|
readonly path: string;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Point this journal at a different session's file, resetting the sequence
|
|
135
|
+
* counter and initialization flag. Used when the session a journal was
|
|
136
|
+
* opened for is switched out from under it (e.g. `/session resume` or
|
|
137
|
+
* `/session fork` reassigning `runtime.sessionId`) — without this, the
|
|
138
|
+
* journal keeps appending the NEW session's records into the OLD session's
|
|
139
|
+
* file. Does not touch the old file on disk; it is simply no longer
|
|
140
|
+
* written to.
|
|
141
|
+
*/
|
|
142
|
+
rebind(journalPath: string, sessionId: string): void;
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
@@ -148,11 +160,15 @@ export function openTranscriptJournal(
|
|
|
148
160
|
/**
|
|
149
161
|
* Build the canonical journal path for a session.
|
|
150
162
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
163
|
+
* Both the home directory and the scope segment come off the caller's
|
|
164
|
+
* SessionSurface, so the journal lands beside the sessions and recovery
|
|
165
|
+
* snapshots it fills gaps for rather than under a separately-spelled scope.
|
|
166
|
+
*
|
|
167
|
+
* @param surface The app's declare-once session-storage handle.
|
|
168
|
+
* @param sessionId The session identifier.
|
|
153
169
|
*/
|
|
154
|
-
export function journalPathFor(
|
|
155
|
-
return join(homeDirectory, '.goodvibes',
|
|
170
|
+
export function journalPathFor(surface: SessionSurface, sessionId: string): string {
|
|
171
|
+
return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, `transcript-${sessionId}.journal`);
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
/**
|
|
@@ -248,8 +264,11 @@ export function replayJournal(
|
|
|
248
264
|
// ─── Implementation ─────────────────────────────────────────────────────────
|
|
249
265
|
|
|
250
266
|
class TranscriptJournalImpl implements TranscriptJournal {
|
|
251
|
-
readonly
|
|
252
|
-
|
|
267
|
+
// Not `readonly` at the class level (unlike the public interface) so
|
|
268
|
+
// rebind() can repoint it; external callers only ever see it through the
|
|
269
|
+
// `TranscriptJournal` interface, which keeps it read-only for them.
|
|
270
|
+
path: string;
|
|
271
|
+
private _sessionId: string;
|
|
253
272
|
private _seq = 0;
|
|
254
273
|
private _initialised = false;
|
|
255
274
|
|
|
@@ -258,6 +277,13 @@ class TranscriptJournalImpl implements TranscriptJournal {
|
|
|
258
277
|
this._sessionId = sessionId;
|
|
259
278
|
}
|
|
260
279
|
|
|
280
|
+
rebind(journalPath: string, sessionId: string): void {
|
|
281
|
+
this.path = journalPath;
|
|
282
|
+
this._sessionId = sessionId;
|
|
283
|
+
this._seq = 0;
|
|
284
|
+
this._initialised = false;
|
|
285
|
+
}
|
|
286
|
+
|
|
261
287
|
appendRecord(type: JournalEventType, messages: ConversationMessageSnapshot[]): void {
|
|
262
288
|
try {
|
|
263
289
|
this._ensureInitialised();
|