@pellux/goodvibes-tui 1.20.1 → 1.21.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 +22 -1
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/doctor.ts +7 -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/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.ts +23 -23
- 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 +141 -20
- 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/input/settings-modal-types.ts +2 -1
- package/src/main.ts +12 -20
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- 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/settings-modal-helpers.ts +53 -1
- package/src/renderer/settings-modal.ts +1 -50
- 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 +180 -11
- package/src/runtime/recovery-prompt.ts +11 -1
- 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
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* `messageCount` boundaries stay valid because a resume rehydrates the same
|
|
22
22
|
* message history the anchors were recorded against.
|
|
23
23
|
*/
|
|
24
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
26
|
import type { SessionSurface } from '@/runtime/index.ts';
|
|
27
27
|
|
|
@@ -92,12 +92,14 @@ export function clearTurnAnchors(sessionId: string): void {
|
|
|
92
92
|
const ANCHOR_SIDECAR_VERSION = 1;
|
|
93
93
|
|
|
94
94
|
/** Absolute path to a session's anchor sidecar, or null when inputs are unusable. */
|
|
95
|
+
const ANCHOR_SIDECAR_SUFFIX = '.anchors.json';
|
|
96
|
+
|
|
95
97
|
function anchorSidecarPath(sessionId: string, surface: SessionSurface): string | null {
|
|
96
98
|
if (!sessionId || !surface.sessionsDir) return null;
|
|
97
99
|
// A sessionId is a filename component; refuse anything with path separators so
|
|
98
100
|
// a malformed id can never escape the sessions directory.
|
|
99
101
|
if (/[\\/]/.test(sessionId)) return null;
|
|
100
|
-
return join(surface.sessionsDir, `${sessionId}
|
|
102
|
+
return join(surface.sessionsDir, `${sessionId}${ANCHOR_SIDECAR_SUFFIX}`);
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
function isTurnAnchor(value: unknown): value is TurnAnchor {
|
|
@@ -125,7 +127,7 @@ export function persistTurnAnchors(sessionId: string, surface: SessionSurface):
|
|
|
125
127
|
try {
|
|
126
128
|
mkdirSync(surface.sessionsDir, { recursive: true });
|
|
127
129
|
const payload = JSON.stringify({ version: ANCHOR_SIDECAR_VERSION, sessionId, anchors: list });
|
|
128
|
-
const tmp = `${path}
|
|
130
|
+
const tmp = `${path}${ANCHOR_SIDECAR_TMP_MARKER}${process.pid}`;
|
|
129
131
|
writeFileSync(tmp, payload);
|
|
130
132
|
renameSync(tmp, path);
|
|
131
133
|
} catch {
|
|
@@ -157,3 +159,153 @@ export function restoreTurnAnchors(sessionId: string, surface: SessionSurface):
|
|
|
157
159
|
return 0;
|
|
158
160
|
}
|
|
159
161
|
}
|
|
162
|
+
|
|
163
|
+
// ─── Reaping orphaned sidecars ────────────────────────────────────────────────
|
|
164
|
+
//
|
|
165
|
+
// A sidecar's OWNER is the session JSONL it sits beside. Deleting a session
|
|
166
|
+
// removes the JSONL but nothing ever removed the sidecar, so anchors for
|
|
167
|
+
// sessions that no longer exist accumulate in the sessions directory forever.
|
|
168
|
+
// The sweep below reclaims them, plus the `.tmp-<pid>` staging files a crash
|
|
169
|
+
// between write and rename leaves behind.
|
|
170
|
+
|
|
171
|
+
/** Infix for the atomic-write staging file, before the writing process's pid. */
|
|
172
|
+
const ANCHOR_SIDECAR_TMP_MARKER = '.tmp-';
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* How long a staging file is tolerated before it is treated as crash residue:
|
|
176
|
+
* 1 hour. `persistTurnAnchors` renames within microseconds of writing, so
|
|
177
|
+
* anything this old was interrupted, and the completed sidecar (if the write
|
|
178
|
+
* ever finished) is a separate file.
|
|
179
|
+
*/
|
|
180
|
+
export const ANCHOR_TMP_MAX_AGE_MS = 60 * 60 * 1000;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* How settled a sidecar must be before this sweep will delete it: 1 hour.
|
|
184
|
+
*
|
|
185
|
+
* Another instance can be persisting anchors for a session this process knows
|
|
186
|
+
* nothing about, and the window between reading a sidecar's content and
|
|
187
|
+
* unlinking it is not atomic. Requiring the file to have been untouched for an
|
|
188
|
+
* hour means a sidecar that some other instance is actively rewriting is never
|
|
189
|
+
* a candidate, while genuine residue — whose writer is long gone — always is.
|
|
190
|
+
* The sweep repeats, so the delay costs nothing.
|
|
191
|
+
*/
|
|
192
|
+
export const ANCHOR_SIDECAR_SETTLE_MS = 60 * 60 * 1000;
|
|
193
|
+
|
|
194
|
+
export interface AnchorSidecarReapResult {
|
|
195
|
+
/** Sidecar and staging files examined this sweep. */
|
|
196
|
+
readonly scanned: number;
|
|
197
|
+
/** Files deleted this sweep. */
|
|
198
|
+
readonly reaped: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface AnchorSidecarReapOptions {
|
|
202
|
+
/** The session this process is using right now; its sidecar is never reaped. */
|
|
203
|
+
readonly currentSessionId?: string | null;
|
|
204
|
+
readonly now?: () => number;
|
|
205
|
+
/** Override the staging-file age window (tests). */
|
|
206
|
+
readonly tmpMaxAgeMs?: number;
|
|
207
|
+
/** Override how long a sidecar must be untouched before it can be reaped (tests). */
|
|
208
|
+
readonly settleMs?: number;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Delete anchor sidecars whose owning session file is gone, sidecars that hold
|
|
213
|
+
* nothing readable, and abandoned staging files.
|
|
214
|
+
*
|
|
215
|
+
* A sidecar survives when `<sessionsDir>/<sessionId>.jsonl` still exists AND
|
|
216
|
+
* the sidecar itself parses into at least one usable anchor — content, not
|
|
217
|
+
* mere existence, because a sidecar truncated by a crash restores nothing and
|
|
218
|
+
* would otherwise sit there indefinitely looking like valid state. It also
|
|
219
|
+
* survives while it is still fresh (see `ANCHOR_SIDECAR_SETTLE_MS`), which
|
|
220
|
+
* keeps a sidecar another instance is mid-rewrite out of reach.
|
|
221
|
+
*
|
|
222
|
+
* The current session's sidecar is never touched, and an unreadable or absent
|
|
223
|
+
* sessions directory simply reclaims nothing. Idempotent and concurrency-safe:
|
|
224
|
+
* a file another sweeper unlinked first (ENOENT) counts as reaped.
|
|
225
|
+
*/
|
|
226
|
+
export function reapOrphanedAnchorSidecars(
|
|
227
|
+
surface: SessionSurface,
|
|
228
|
+
options: AnchorSidecarReapOptions = {},
|
|
229
|
+
): AnchorSidecarReapResult {
|
|
230
|
+
const dir = surface.sessionsDir;
|
|
231
|
+
if (!dir) return { scanned: 0, reaped: 0 };
|
|
232
|
+
|
|
233
|
+
let names: string[];
|
|
234
|
+
try {
|
|
235
|
+
names = readdirSync(dir).filter(
|
|
236
|
+
(n) => n.endsWith(ANCHOR_SIDECAR_SUFFIX) || n.includes(`${ANCHOR_SIDECAR_SUFFIX}${ANCHOR_SIDECAR_TMP_MARKER}`),
|
|
237
|
+
);
|
|
238
|
+
} catch {
|
|
239
|
+
return { scanned: 0, reaped: 0 };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const now = options.now?.() ?? Date.now();
|
|
243
|
+
const tmpMaxAgeMs = options.tmpMaxAgeMs ?? ANCHOR_TMP_MAX_AGE_MS;
|
|
244
|
+
const settleMs = options.settleMs ?? ANCHOR_SIDECAR_SETTLE_MS;
|
|
245
|
+
let reaped = 0;
|
|
246
|
+
|
|
247
|
+
for (const name of names) {
|
|
248
|
+
const path = join(dir, name);
|
|
249
|
+
|
|
250
|
+
if (!name.endsWith(ANCHOR_SIDECAR_SUFFIX)) {
|
|
251
|
+
// A `<sessionId>.anchors.json.tmp-<pid>` staging file. Age alone decides:
|
|
252
|
+
// a young one may belong to another instance's in-flight write.
|
|
253
|
+
let mtimeMs: number;
|
|
254
|
+
try {
|
|
255
|
+
mtimeMs = statSync(path).mtimeMs;
|
|
256
|
+
} catch {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (now - mtimeMs > tmpMaxAgeMs && unlinkAnchorFile(path)) reaped++;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const sessionId = name.slice(0, name.length - ANCHOR_SIDECAR_SUFFIX.length);
|
|
264
|
+
if (sessionId.length === 0) continue;
|
|
265
|
+
if (options.currentSessionId && sessionId === options.currentSessionId) continue;
|
|
266
|
+
|
|
267
|
+
// A sidecar written moments ago belongs to a writer that is still around —
|
|
268
|
+
// possibly another instance whose session this process cannot see.
|
|
269
|
+
let sidecarMtimeMs: number;
|
|
270
|
+
try {
|
|
271
|
+
sidecarMtimeMs = statSync(path).mtimeMs;
|
|
272
|
+
} catch {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (now - sidecarMtimeMs <= settleMs) continue;
|
|
276
|
+
|
|
277
|
+
if (!existsSync(join(dir, `${sessionId}.jsonl`))) {
|
|
278
|
+
// The owning session is gone — this sidecar can never be used again.
|
|
279
|
+
if (unlinkAnchorFile(path)) reaped++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (!sidecarHoldsAnchors(path)) {
|
|
283
|
+
// The session survives but the sidecar is empty or torn: it restores
|
|
284
|
+
// nothing, so keeping it only hides the loss.
|
|
285
|
+
if (unlinkAnchorFile(path)) reaped++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { scanned: names.length, reaped };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Content check: does this sidecar parse into at least one usable anchor? */
|
|
293
|
+
function sidecarHoldsAnchors(path: string): boolean {
|
|
294
|
+
try {
|
|
295
|
+
const text = readFileSync(path, 'utf8');
|
|
296
|
+
if (text.trim().length === 0) return false;
|
|
297
|
+
const parsed = JSON.parse(text) as { anchors?: unknown };
|
|
298
|
+
return Array.isArray(parsed?.anchors) && parsed.anchors.some(isTurnAnchor);
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function unlinkAnchorFile(path: string): boolean {
|
|
305
|
+
try {
|
|
306
|
+
unlinkSync(path);
|
|
307
|
+
return true;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -1,10 +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
5
|
import { classifyProviderSetup } from '../providers/provider-classification.ts';
|
|
6
6
|
import type { FailoverTurnState } from './active-model-identity.ts';
|
|
7
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';
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Live stream and tool-execution metrics maintained by wireStreamEventMetrics.
|
|
@@ -75,7 +80,20 @@ interface StreamOrchestrator {
|
|
|
75
80
|
|
|
76
81
|
/** Minimal provider surface required for the stream stall watchdog and failover switching. */
|
|
77
82
|
interface StreamProviderRegistry {
|
|
78
|
-
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
|
+
};
|
|
79
97
|
setCurrentModel(registryKey: string): void;
|
|
80
98
|
}
|
|
81
99
|
|
|
@@ -183,8 +201,14 @@ export interface WireStreamEventMetricsOptions {
|
|
|
183
201
|
* rather than emitted before: the caller re-posts it after its rollback, and
|
|
184
202
|
* it survives to be read. Implementations that do not roll back may ignore
|
|
185
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.
|
|
186
210
|
*/
|
|
187
|
-
readonly retryTurn?: (notice?: string) =>
|
|
211
|
+
readonly retryTurn?: (notice?: string) => boolean;
|
|
188
212
|
/**
|
|
189
213
|
* Optional cost catalog for attaching per-1M-token cost information to
|
|
190
214
|
* the failover notice. When provided and both models have non-zero pricing,
|
|
@@ -222,6 +246,15 @@ export interface WireStreamEventMetricsOptions {
|
|
|
222
246
|
* divergence marker is possible, so both are skipped rather than guessed at.
|
|
223
247
|
*/
|
|
224
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;
|
|
225
258
|
}
|
|
226
259
|
|
|
227
260
|
/** Result of wireStreamEventMetrics. */
|
|
@@ -336,6 +369,7 @@ export function wireStreamEventMetrics(
|
|
|
336
369
|
events, metrics, orchestrator, providerRegistry,
|
|
337
370
|
systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
|
|
338
371
|
isApprovalPending, stallThresholdMs, failoverState, getConfiguredRegistryKey,
|
|
372
|
+
getConfiguredReasoningEffort,
|
|
339
373
|
} = options;
|
|
340
374
|
|
|
341
375
|
const unsubs: Array<() => void> = [];
|
|
@@ -370,13 +404,85 @@ export function wireStreamEventMetrics(
|
|
|
370
404
|
* claiming a revert that did not happen.
|
|
371
405
|
*/
|
|
372
406
|
/**
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
* the routing chip
|
|
376
|
-
*
|
|
377
|
-
* `[Failover] Restored
|
|
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.
|
|
378
459
|
*/
|
|
379
|
-
|
|
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
|
+
};
|
|
380
486
|
|
|
381
487
|
const restoreConfiguredSelection = (): void => {
|
|
382
488
|
const record = failoverState?.current();
|
|
@@ -386,12 +492,15 @@ export function wireStreamEventMetrics(
|
|
|
386
492
|
return;
|
|
387
493
|
}
|
|
388
494
|
try {
|
|
389
|
-
|
|
390
|
-
providerRegistry.setCurrentModel(record.configuredRegistryKey);
|
|
495
|
+
const effortNote = switchNarrated(record.configuredRegistryKey);
|
|
391
496
|
failoverState.clear();
|
|
392
|
-
|
|
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
|
+
);
|
|
393
503
|
} catch (restoreErr) {
|
|
394
|
-
pendingRestoreKey = null;
|
|
395
504
|
logger.debug('failover restore failed', {
|
|
396
505
|
configuredRegistryKey: record.configuredRegistryKey, error: String(restoreErr),
|
|
397
506
|
});
|
|
@@ -466,11 +575,7 @@ export function wireStreamEventMetrics(
|
|
|
466
575
|
// Degrade gracefully when a bare test double omits the providers feed.
|
|
467
576
|
if (events.providers) {
|
|
468
577
|
unsubs.push(events.providers.on('MODEL_CHANGED', (change) => {
|
|
469
|
-
//
|
|
470
|
-
// restore flag before anything else can touch it.
|
|
471
|
-
const wasRestore = pendingRestoreKey !== null && change.registryKey === pendingRestoreKey;
|
|
472
|
-
pendingRestoreKey = null;
|
|
473
|
-
if (wasRestore) return;
|
|
578
|
+
if (wasSelfNarrated(change.registryKey)) return; // a [Failover] line already said this, with its reason
|
|
474
579
|
queueMicrotask(() => {
|
|
475
580
|
const chip = buildRoutingChip(change, providerOptimizer?.fallbackLog ?? [], Date.now());
|
|
476
581
|
if (chip === null) return;
|
|
@@ -511,8 +616,12 @@ export function wireStreamEventMetrics(
|
|
|
511
616
|
const errorClass = formatUserFacingErrorLine(errVal);
|
|
512
617
|
// Capture FROM registry key before switching — needed for cost comparison.
|
|
513
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;
|
|
514
623
|
try {
|
|
515
|
-
|
|
624
|
+
effortNote = switchNarrated(toRegistryKey);
|
|
516
625
|
} catch (switchErr) {
|
|
517
626
|
// Switch failed — fall through to honest error display. This ends the
|
|
518
627
|
// turn, so an EARLIER hop's switch (if this is a second failover
|
|
@@ -540,7 +649,19 @@ export function wireStreamEventMetrics(
|
|
|
540
649
|
// Re-submit the last user turn on the new provider, handing the notice
|
|
541
650
|
// to retryTurn so it outlives that call's transcript rollback (see the
|
|
542
651
|
// retryTurn option doc). Emitting it here instead would delete it.
|
|
543
|
-
|
|
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
|
+
}
|
|
544
665
|
render();
|
|
545
666
|
return;
|
|
546
667
|
}
|
|
@@ -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
|
+
}
|