@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,4 +1,10 @@
|
|
|
1
1
|
import { getConfigSnapshot } from '../config/index.ts';
|
|
2
|
+
import {
|
|
3
|
+
describeServingEffort,
|
|
4
|
+
publishActiveEffortOptions,
|
|
5
|
+
resolveRequestedEffortForServingModel,
|
|
6
|
+
toEffortModel,
|
|
7
|
+
} from '../providers/reasoning-effort-surface.ts';
|
|
2
8
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
3
9
|
import type { AdaptivePlanner } from '@pellux/goodvibes-sdk/platform/core';
|
|
4
10
|
import type { ConversationManager } from '../core/conversation';
|
|
@@ -227,7 +233,7 @@ export function createBootstrapCommandActions(
|
|
|
227
233
|
clearScreen: () => unwiredShellAction('clearScreen'),
|
|
228
234
|
activatePlan,
|
|
229
235
|
requestPermission: (request) => requestPermission(request),
|
|
230
|
-
completeModelSelection: ({ model, effort, contextCap, target }) => {
|
|
236
|
+
completeModelSelection: ({ model, effort, contextCap, target, effortChosenByUser }) => {
|
|
231
237
|
if (!model) return;
|
|
232
238
|
const def = model;
|
|
233
239
|
const key = def.registryKey ?? `${def.provider}:${def.id}`;
|
|
@@ -257,13 +263,39 @@ export function createBootstrapCommandActions(
|
|
|
257
263
|
providerRegistry.setCurrentModel(key);
|
|
258
264
|
runtime.model = key;
|
|
259
265
|
runtime.provider = def.provider;
|
|
260
|
-
|
|
266
|
+
// Two levels, kept apart on purpose. Config `provider.reasoningEffort`
|
|
267
|
+
// holds what the USER asked for; `runtime.reasoningEffort` holds what
|
|
268
|
+
// the model now serving will actually receive, which is the requested
|
|
269
|
+
// level snapped DOWN to this model's own levels.
|
|
270
|
+
//
|
|
271
|
+
// Only the picker's effort STEP is a user choice, and only it writes
|
|
272
|
+
// the preference. Every other route into this callback (model-only
|
|
273
|
+
// commit, context-cap commit) carries a level merely carried over
|
|
274
|
+
// from the previous model; storing that is what used to ratchet the
|
|
275
|
+
// preference down for good — one hop through a model that caps at
|
|
276
|
+
// 'medium' and 'xhigh' was gone from config, so hopping back could
|
|
277
|
+
// not restore it.
|
|
278
|
+
//
|
|
279
|
+
// A stored 'xhigh' that becomes 'high' on the wire would make a
|
|
280
|
+
// single-value display a lie, so the display carries BOTH values with
|
|
281
|
+
// their provenance instead (describeServingEffort) rather than the
|
|
282
|
+
// preference being corrupted to match.
|
|
283
|
+
const effortModel = toEffortModel(def);
|
|
284
|
+
// Publish this model's real levels first, so both the preference
|
|
285
|
+
// write below and a later `config set provider.reasoningEffort` are
|
|
286
|
+
// validated against them.
|
|
287
|
+
publishActiveEffortOptions(effortModel);
|
|
288
|
+
if (effortChosenByUser && effort) configManager.set('provider.reasoningEffort', effort);
|
|
289
|
+
const serving = resolveRequestedEffortForServingModel(configManager, effortModel);
|
|
290
|
+
runtime.reasoningEffort = serving.effective ?? '';
|
|
261
291
|
configManager.set('provider.model', key);
|
|
262
|
-
configManager.set('provider.reasoningEffort', effort as 'instant' | 'low' | 'medium' | 'high');
|
|
263
292
|
const ctxNote = contextCap != null && contextCap > 0
|
|
264
293
|
? `, context cap: ${contextCap.toLocaleString()}`
|
|
265
294
|
: '';
|
|
266
|
-
conversation.log(`Switched to model: ${def.displayName} (${def.provider}), effort: ${
|
|
295
|
+
conversation.log(`Switched to model: ${def.displayName} (${def.provider}), effort: ${describeServingEffort(serving, effortModel)}${ctxNote}`, { fg: '135' });
|
|
296
|
+
// The SDK's own sentence, printed verbatim so the explanation cannot
|
|
297
|
+
// drift from the resolution that produced it.
|
|
298
|
+
if (serving.note) conversation.log(serving.note, { fg: '214' });
|
|
267
299
|
}
|
|
268
300
|
} catch (e) {
|
|
269
301
|
conversation.log(`Error switching model: ${summarizeError(e)}`, { fg: '#ef4444' });
|
|
@@ -350,7 +350,7 @@ export async function initializeBootstrapCore(
|
|
|
350
350
|
|
|
351
351
|
const toolRegistry = new ToolRegistry();
|
|
352
352
|
const { fileCache, projectIndex } = registerAllTools(toolRegistry, {
|
|
353
|
-
surfaceRoot: services.surface.surfaceRoot,
|
|
353
|
+
resolveSessionId: () => runtimeSessionIdRef.value, surfaceRoot: services.surface.surfaceRoot, // task refs follow the LIVE runtime session (recovery reassigns it in place); without this they fall into the shared legacy namespace
|
|
354
354
|
localhostFetchApproval: services.localhostFetchApproval, // loopback-fetch ask, built once in the services composition
|
|
355
355
|
execPromptAnswerHandler: services.execPromptAnswerHandler, // terminal prompt-answer path, built once in the services composition
|
|
356
356
|
fileUndoManager: services.fileUndoManager,
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* durability-housekeeping.ts — the reclaim half of every crash-durability
|
|
3
|
+
* artefact this product writes to disk.
|
|
4
|
+
*
|
|
5
|
+
* The standing rule this module implements: if something is persisted across
|
|
6
|
+
* restarts and crashes, recovery has to do the housekeeping. Each individual
|
|
7
|
+
* store already knew how to WRITE its artefact and how to retire it on a clean
|
|
8
|
+
* path; none of them reclaimed what a SIGKILL left behind. Four stores, four
|
|
9
|
+
* kinds of residue:
|
|
10
|
+
*
|
|
11
|
+
* - liveness markers `<home>/.goodvibes/<root>/liveness/<id>.json`
|
|
12
|
+
* removed only on a clean exit, so a crash leaks one
|
|
13
|
+
* file per session id ever opened.
|
|
14
|
+
* - transcript journals `<home>/.goodvibes/<root>/transcript-<id>.journal`
|
|
15
|
+
* deleted on snapshot or replay, so a session that
|
|
16
|
+
* crashes and is never resumed leaks its journal.
|
|
17
|
+
* - quarantine files `<path>.unrecognized` — written so a human can
|
|
18
|
+
* inspect a corrupt file, never removed afterwards.
|
|
19
|
+
* - anchor sidecars `<sessionsDir>/<id>.anchors.json` — outlives the
|
|
20
|
+
* session JSONL that owns it.
|
|
21
|
+
*
|
|
22
|
+
* Every reap here is bounded by BOTH an age window and a count cap, decides
|
|
23
|
+
* what to keep by reading content rather than by a file merely existing, never
|
|
24
|
+
* touches the current session, tolerates a competing sweeper deleting the same
|
|
25
|
+
* file first, and reports what it removed. `startDurabilityHousekeeping` runs
|
|
26
|
+
* the sweep at startup and then on a repeating timer, because a long-lived
|
|
27
|
+
* process that only sweeps at boot never sweeps.
|
|
28
|
+
*
|
|
29
|
+
* Best-effort throughout: a failure in one reap never aborts the others and
|
|
30
|
+
* nothing here ever throws to its caller.
|
|
31
|
+
*/
|
|
32
|
+
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { reapQuarantinedFiles } from '@/config/read-versioned.ts';
|
|
35
|
+
import { reapOrphanedAnchorSidecars } from '@/core/rewind-turn-anchors.ts';
|
|
36
|
+
import { reapOrphanedJournals } from '@/core/transcript-journal.ts';
|
|
37
|
+
import { checkSessionLiveness, reapStaleLivenessMarkers } from '@/runtime/session-liveness-marker.ts';
|
|
38
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* How often the sweep repeats after startup: 6 hours. Hours, not minutes — the
|
|
42
|
+
* residue this reclaims accumulates per crashed session, not continuously, and
|
|
43
|
+
* a directory scan is not something a foreground TUI should be doing often.
|
|
44
|
+
*/
|
|
45
|
+
export const DURABILITY_HOUSEKEEPING_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
export interface DurabilityHousekeepingInput {
|
|
48
|
+
/** The app's declare-once storage handle; every swept path derives from it. */
|
|
49
|
+
readonly surface: SessionSurface;
|
|
50
|
+
/**
|
|
51
|
+
* The session this process is using right now, read at sweep time rather
|
|
52
|
+
* than captured once — a resume or fork can repoint it between sweeps.
|
|
53
|
+
* Returning null means "not known yet", which is safe: the age and liveness
|
|
54
|
+
* rules independently protect an actively written artefact.
|
|
55
|
+
*/
|
|
56
|
+
readonly currentSessionId?: () => string | null;
|
|
57
|
+
/** Extra directories to scan for `.unrecognized` quarantine files. */
|
|
58
|
+
readonly extraQuarantineDirs?: readonly string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DurabilityHousekeepingOutcome {
|
|
62
|
+
readonly livenessMarkers: number;
|
|
63
|
+
readonly transcriptJournals: number;
|
|
64
|
+
readonly quarantineFiles: number;
|
|
65
|
+
readonly anchorSidecars: number;
|
|
66
|
+
/** Sum of the four counts above — zero means nothing was reclaimed. */
|
|
67
|
+
readonly total: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run all four reaps once and return what each reclaimed.
|
|
72
|
+
*
|
|
73
|
+
* Each reap is isolated: one throwing is logged at warn level and counted as
|
|
74
|
+
* zero, and the remaining reaps still run. The caller never sees an exception.
|
|
75
|
+
*/
|
|
76
|
+
export function runDurabilityHousekeeping(input: DurabilityHousekeepingInput): DurabilityHousekeepingOutcome {
|
|
77
|
+
const { surface } = input;
|
|
78
|
+
const currentSessionId = safely('current session id', () => input.currentSessionId?.() ?? null, null);
|
|
79
|
+
|
|
80
|
+
const livenessMarkers = safely('liveness markers', () =>
|
|
81
|
+
reapStaleLivenessMarkers(surface, {
|
|
82
|
+
keepSessionIds: currentSessionId ? [currentSessionId] : [],
|
|
83
|
+
}).reaped, 0);
|
|
84
|
+
|
|
85
|
+
const transcriptJournals = safely('transcript journals', () =>
|
|
86
|
+
reapOrphanedJournals(surface, {
|
|
87
|
+
currentSessionId,
|
|
88
|
+
// Composition happens here so transcript-journal.ts keeps no dependency
|
|
89
|
+
// on the liveness marker: a journal whose session is open in another
|
|
90
|
+
// running instance is live state, not residue.
|
|
91
|
+
isSessionLive: (sessionId) => checkSessionLiveness(surface, sessionId).live,
|
|
92
|
+
}).reaped, 0);
|
|
93
|
+
|
|
94
|
+
const quarantineFiles = safely('quarantine files', () =>
|
|
95
|
+
reapQuarantinedFiles(quarantineDirsFor(input)).reaped, 0);
|
|
96
|
+
|
|
97
|
+
const anchorSidecars = safely('anchor sidecars', () =>
|
|
98
|
+
reapOrphanedAnchorSidecars(surface, { currentSessionId }).reaped, 0);
|
|
99
|
+
|
|
100
|
+
const outcome: DurabilityHousekeepingOutcome = {
|
|
101
|
+
livenessMarkers,
|
|
102
|
+
transcriptJournals,
|
|
103
|
+
quarantineFiles,
|
|
104
|
+
anchorSidecars,
|
|
105
|
+
total: livenessMarkers + transcriptJournals + quarantineFiles + anchorSidecars,
|
|
106
|
+
};
|
|
107
|
+
discloseHousekeeping(outcome);
|
|
108
|
+
return outcome;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Start durability housekeeping: one sweep now, then a repeating sweep every
|
|
113
|
+
* `DURABILITY_HOUSEKEEPING_INTERVAL_MS`. Returns a disposer that stops the
|
|
114
|
+
* timer; calling it more than once is harmless.
|
|
115
|
+
*
|
|
116
|
+
* The timer is `unref()`d, so an undisposed sweeper can never hold the process
|
|
117
|
+
* open — same convention the store-snapshot scheduler and the config-file
|
|
118
|
+
* watchers in durability-services.ts use.
|
|
119
|
+
*/
|
|
120
|
+
export function startDurabilityHousekeeping(
|
|
121
|
+
input: DurabilityHousekeepingInput,
|
|
122
|
+
options: { readonly intervalMs?: number } = {},
|
|
123
|
+
): () => void {
|
|
124
|
+
runDurabilityHousekeeping(input);
|
|
125
|
+
const timer = setInterval(
|
|
126
|
+
() => { runDurabilityHousekeeping(input); },
|
|
127
|
+
options.intervalMs ?? DURABILITY_HOUSEKEEPING_INTERVAL_MS,
|
|
128
|
+
);
|
|
129
|
+
timer.unref?.();
|
|
130
|
+
let stopped = false;
|
|
131
|
+
return () => {
|
|
132
|
+
if (stopped) return;
|
|
133
|
+
stopped = true;
|
|
134
|
+
clearInterval(timer);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Disclose what was reclaimed — silent deletion is indistinguishable from data
|
|
140
|
+
* loss. Paths and counts only; nothing about a reclaimed file's contents.
|
|
141
|
+
*
|
|
142
|
+
* A sweep that reclaimed nothing logs nothing: the overwhelmingly common boot
|
|
143
|
+
* has no residue at all, and a per-boot "reclaimed 0" line would train readers
|
|
144
|
+
* to ignore the line that actually matters.
|
|
145
|
+
*/
|
|
146
|
+
function discloseHousekeeping(outcome: DurabilityHousekeepingOutcome): void {
|
|
147
|
+
if (outcome.total === 0) return;
|
|
148
|
+
logger.info('durability housekeeping reclaimed crash residue', {
|
|
149
|
+
livenessMarkers: outcome.livenessMarkers,
|
|
150
|
+
transcriptJournals: outcome.transcriptJournals,
|
|
151
|
+
quarantineFiles: outcome.quarantineFiles,
|
|
152
|
+
anchorSidecars: outcome.anchorSidecars,
|
|
153
|
+
total: outcome.total,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every directory that can accumulate `.unrecognized` files: the home-scoped
|
|
159
|
+
* surface root (transcript-journal quarantines) and the per-project session,
|
|
160
|
+
* state, recovery and checkpoint directories (versioned-config quarantines),
|
|
161
|
+
* plus whatever the caller adds.
|
|
162
|
+
*/
|
|
163
|
+
function quarantineDirsFor(input: DurabilityHousekeepingInput): readonly string[] {
|
|
164
|
+
const { surface } = input;
|
|
165
|
+
return [
|
|
166
|
+
join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot),
|
|
167
|
+
surface.sessionsDir,
|
|
168
|
+
surface.stateDir,
|
|
169
|
+
surface.recoveryDir,
|
|
170
|
+
surface.checkpointsDir,
|
|
171
|
+
...(input.extraQuarantineDirs ?? []),
|
|
172
|
+
].filter((dir): dir is string => typeof dir === 'string' && dir.length > 0);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Run one reap in isolation: never throws, returns `fallback` if it would have. */
|
|
176
|
+
function safely<T>(label: string, run: () => T, fallback: T): T {
|
|
177
|
+
try {
|
|
178
|
+
return run();
|
|
179
|
+
} catch (error) {
|
|
180
|
+
logger.warn('durability housekeeping step failed', { step: label, error: summarizeError(error) });
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -18,6 +18,12 @@
|
|
|
18
18
|
* EVERY root the composition knows (working dir, surface root, home,
|
|
19
19
|
* logDir, telemetryDir) — omitting logDir/telemetryDir/home would silently
|
|
20
20
|
* skip the activity-log, telemetry-ledger, and recovery-snapshot stores.
|
|
21
|
+
* - Durability housekeeping: the crash-residue reclaim the retention janitor
|
|
22
|
+
* above does NOT cover — stale liveness markers, orphaned transcript
|
|
23
|
+
* journals, `.unrecognized` quarantine files, and anchor sidecars whose
|
|
24
|
+
* session is gone (see durability-housekeeping.ts). It runs at startup AND
|
|
25
|
+
* on a repeating unref'd timer, because a long-lived process that only
|
|
26
|
+
* sweeps at boot never sweeps; the returned disposer stops that timer.
|
|
21
27
|
* - Live config-file watch: external edits to the settings file apply through
|
|
22
28
|
* the same subscribe() pipeline an in-process set() uses — no restart. The
|
|
23
29
|
* underlying watchers are unref'd, so this never pins the event loop.
|
|
@@ -28,6 +34,7 @@ import { resolveMemoryVectorDbPath } from '@pellux/goodvibes-sdk/platform/state'
|
|
|
28
34
|
import { StoreSnapshotScheduler } from '@pellux/goodvibes-sdk/platform/state/store-snapshots';
|
|
29
35
|
import { UserPermissionRuleStore } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
30
36
|
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
37
|
+
import { startDurabilityHousekeeping } from './durability-housekeeping.ts';
|
|
31
38
|
import type { SessionSurface } from '@/runtime/index.ts';
|
|
32
39
|
|
|
33
40
|
export interface DurabilityServicesInput {
|
|
@@ -48,11 +55,44 @@ export interface DurabilityServicesInput {
|
|
|
48
55
|
*/
|
|
49
56
|
readonly surface: SessionSurface;
|
|
50
57
|
readonly shellPaths: { resolveUserPath(...segments: string[]): string };
|
|
58
|
+
/**
|
|
59
|
+
* The session id this process is currently using, read at each sweep. When
|
|
60
|
+
* omitted the crash-residue reap still protects live artefacts through its
|
|
61
|
+
* age and liveness rules — this is the explicit belt-and-braces guard.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Resolves the live session id, read fresh on every crash-residue sweep so
|
|
65
|
+
* the running session's own transcript journal and liveness marker are
|
|
66
|
+
* exempt from reaping.
|
|
67
|
+
*
|
|
68
|
+
* A getter, not a value: the id is reassigned in place when a recovery
|
|
69
|
+
* snapshot is accepted, and this sweep repeats for the life of the process.
|
|
70
|
+
*
|
|
71
|
+
* Omitting it is not merely untidy. The journal reaper's other guard is the
|
|
72
|
+
* liveness marker, and that marker goes stale after 150 seconds — so a host
|
|
73
|
+
* that passes nothing here is trusting a heartbeat that a single long
|
|
74
|
+
* blocking turn can outrun, and an in-process sweep landing in that window
|
|
75
|
+
* would delete the journal of the session currently writing it. Passing this
|
|
76
|
+
* makes the exemption unconditional instead of timing-dependent.
|
|
77
|
+
*/
|
|
78
|
+
readonly currentSessionId?: () => string | null;
|
|
51
79
|
}
|
|
52
80
|
|
|
53
81
|
export interface DurabilityServices {
|
|
54
82
|
readonly storeSnapshotScheduler: StoreSnapshotScheduler;
|
|
83
|
+
/**
|
|
84
|
+
* Re-sweeps every registered append-only store on a cadence. The start-time
|
|
85
|
+
* sweep alone never prunes again in a process that stays up, which is the
|
|
86
|
+
* window in which those stores grow. Unref'd timers; teardown stop()s it.
|
|
87
|
+
*/
|
|
88
|
+
readonly appendOnlyRetentionScheduler: InstanceType<typeof operations.AppendOnlyRetentionScheduler>;
|
|
55
89
|
readonly userPermissionRuleStore: UserPermissionRuleStore;
|
|
90
|
+
/**
|
|
91
|
+
* Stops the repeating crash-residue sweep. The timer is unref'd, so a host
|
|
92
|
+
* that never calls this is not held open by it; teardown calls it to stop
|
|
93
|
+
* the work rather than to release the loop.
|
|
94
|
+
*/
|
|
95
|
+
readonly stopDurabilityHousekeeping: () => void;
|
|
56
96
|
}
|
|
57
97
|
|
|
58
98
|
export function createDurabilityServices(input: DurabilityServicesInput): DurabilityServices {
|
|
@@ -73,16 +113,33 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
|
|
|
73
113
|
// activity-log, telemetry-ledger, and recovery-snapshot stores are all swept.
|
|
74
114
|
// Best-effort — a retention failure never takes startup down (the sweep
|
|
75
115
|
// swallows its own errors).
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
116
|
+
const appendOnlyRetentionRoots = {
|
|
117
|
+
workingDirectory: input.surface.workingDirectory,
|
|
118
|
+
surfaceRoot: input.surface.surfaceRoot,
|
|
119
|
+
homeDirectory: input.surface.homeDirectory,
|
|
120
|
+
logDir: input.shellPaths.resolveUserPath('logs'),
|
|
121
|
+
telemetryDir: input.shellPaths.resolveUserPath('telemetry'),
|
|
122
|
+
};
|
|
123
|
+
const appendOnlyRetentionConfigGet = (k: string): unknown => configManager.get(k as never);
|
|
124
|
+
operations.runStartupAppendOnlySweep(appendOnlyRetentionRoots, appendOnlyRetentionConfigGet);
|
|
125
|
+
// ...and again on a cadence for as long as this process lives. A start-time
|
|
126
|
+
// sweep alone never prunes a long-lived session again after boot, which is
|
|
127
|
+
// exactly the window in which these stores grow. Unref'd timers; the disposer
|
|
128
|
+
// below stops it, same posture as storeSnapshotScheduler.
|
|
129
|
+
const appendOnlyRetentionScheduler = new operations.AppendOnlyRetentionScheduler({
|
|
130
|
+
roots: appendOnlyRetentionRoots,
|
|
131
|
+
configGet: appendOnlyRetentionConfigGet,
|
|
132
|
+
});
|
|
133
|
+
appendOnlyRetentionScheduler.start();
|
|
134
|
+
// Crash-residue reclaim the sweep above does not cover (liveness markers,
|
|
135
|
+
// orphaned transcript journals, .unrecognized quarantine files, anchor
|
|
136
|
+
// sidecars whose session file is gone). Runs once now and every few hours
|
|
137
|
+
// after that; the timer is unref'd and the disposer stops it.
|
|
138
|
+
const stopDurabilityHousekeeping = startDurabilityHousekeeping({
|
|
139
|
+
surface: input.surface,
|
|
140
|
+
currentSessionId: input.currentSessionId,
|
|
141
|
+
extraQuarantineDirs: [configManager.getControlPlaneConfigDir()],
|
|
142
|
+
});
|
|
86
143
|
// External config edits apply LIVE through the same subscribe() pipeline an
|
|
87
144
|
// in-process set() uses; the underlying watchers are unref'd.
|
|
88
145
|
configManager.watchConfigFiles();
|
|
@@ -90,5 +147,5 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
|
|
|
90
147
|
const userPermissionRuleStore = new UserPermissionRuleStore(join(configManager.getControlPlaneConfigDir(), 'permission-rules.json'));
|
|
91
148
|
void userPermissionRuleStore.init().catch((error) => logger.warn('user permission rule store init failed; asks will prompt', { error: summarizeError(error) }));
|
|
92
149
|
|
|
93
|
-
return { storeSnapshotScheduler, userPermissionRuleStore };
|
|
150
|
+
return { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping };
|
|
94
151
|
}
|
|
@@ -101,6 +101,14 @@ export interface ReconcileRedundantLegacyUnitInput {
|
|
|
101
101
|
readonly readOwnCgroup?: (() => string) | undefined;
|
|
102
102
|
/** Timeout applied to the DEFAULT systemctl runner only. Defaults to SYSTEMCTL_TIMEOUT_MS. */
|
|
103
103
|
readonly systemctlTimeoutMs?: number | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Program the DEFAULT runner executes. Defaults to `systemctl` (resolved on
|
|
106
|
+
* PATH). Tests that deliberately exercise the default (non-injected) runner
|
|
107
|
+
* point this at an absolute stub path: Bun resolves a spawned program from
|
|
108
|
+
* the PATH captured at process start, so mutating `process.env.PATH` inside
|
|
109
|
+
* a test cannot redirect the call and the host's real systemctl would answer.
|
|
110
|
+
*/
|
|
111
|
+
readonly systemctlCommand?: string | undefined;
|
|
104
112
|
/** Cumulative budget for the whole pass. Defaults to RECONCILE_DEADLINE_MS. */
|
|
105
113
|
readonly deadlineMs?: number | undefined;
|
|
106
114
|
}
|
|
@@ -201,6 +209,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
201
209
|
input.systemctlTimeoutMs ?? SYSTEMCTL_TIMEOUT_MS,
|
|
202
210
|
input.deadlineMs ?? RECONCILE_DEADLINE_MS,
|
|
203
211
|
);
|
|
212
|
+
const systemctl = input.systemctlCommand ?? 'systemctl';
|
|
204
213
|
const canonicalUnit = `${input.trackedServiceName}.service`;
|
|
205
214
|
const legacyUnit = `${LEGACY_SERVICE_UNIT_NAME}.service`;
|
|
206
215
|
const deadlineNote = (): string[] =>
|
|
@@ -210,7 +219,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
210
219
|
|
|
211
220
|
// Guard 1: the canonical unit must report active. (A timed-out, skipped, or
|
|
212
221
|
// failed probe — e.g. a wedged user bus — lands here too and refuses.)
|
|
213
|
-
const probe = run(
|
|
222
|
+
const probe = run(systemctl, ['--user', 'is-active', canonicalUnit]);
|
|
214
223
|
const canonicalActive = (probe.status ?? 1) === 0 && (probe.stdout ?? '').trim() === 'active';
|
|
215
224
|
if (!canonicalActive) {
|
|
216
225
|
return {
|
|
@@ -227,7 +236,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
227
236
|
// Guard 2: 'active' alone does not prove the canonical unit is the daemon
|
|
228
237
|
// actually serving (Type=simple reports active from fork onward). Require
|
|
229
238
|
// its MainPID to resolve and be a live process.
|
|
230
|
-
const canonicalPid = parseMainPid(run(
|
|
239
|
+
const canonicalPid = parseMainPid(run(systemctl, ['--user', 'show', '-p', 'MainPID', '--value', canonicalUnit]));
|
|
231
240
|
const alive = input.processAlive ?? defaultProcessAlive;
|
|
232
241
|
if (canonicalPid === undefined || !alive(canonicalPid)) {
|
|
233
242
|
return {
|
|
@@ -253,7 +262,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
253
262
|
// it: in the wrong-port state the legacy daemon itself answers the
|
|
254
263
|
// configured-endpoint probe). Unknown refuses.
|
|
255
264
|
const ownPid = input.ownPid ?? process.pid;
|
|
256
|
-
const legacyPidReply = run(
|
|
265
|
+
const legacyPidReply = run(systemctl, ['--user', 'show', '-p', 'MainPID', '--value', legacyUnit]);
|
|
257
266
|
const legacyPidKnown = (legacyPidReply.status ?? 1) === 0;
|
|
258
267
|
const legacyPid = parseMainPid(legacyPidReply);
|
|
259
268
|
const ownCgroup = (input.readOwnCgroup ?? defaultReadOwnCgroup)();
|
|
@@ -359,7 +368,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
359
368
|
// unit file if the disable actually succeeded — otherwise the enablement
|
|
360
369
|
// symlink dangles at a deleted file and this tool can never repair it (the
|
|
361
370
|
// next pass no-ops at the file-exists check).
|
|
362
|
-
const disableResult = run(
|
|
371
|
+
const disableResult = run(systemctl, ['--user', 'disable', '--now', legacyUnit]);
|
|
363
372
|
const disableStatus = disableResult.status ?? 1;
|
|
364
373
|
if (disableStatus !== 0) {
|
|
365
374
|
if (disableResult.status === null) {
|
|
@@ -367,7 +376,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
367
376
|
// removes the enablement symlinks synchronously BEFORE waiting on the
|
|
368
377
|
// stop job, so the disable may well have taken effect even though the
|
|
369
378
|
// client was killed. Re-inspect instead of printing a blanket denial.
|
|
370
|
-
const reinspect = run(
|
|
379
|
+
const reinspect = run(systemctl, ['--user', 'is-enabled', legacyUnit]);
|
|
371
380
|
const reinspectOut = (reinspect.stdout ?? '').trim();
|
|
372
381
|
const confirmedDisabled = (reinspect.status !== null)
|
|
373
382
|
&& (reinspectOut === 'disabled' || reinspectOut === 'not-found');
|
|
@@ -381,7 +390,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
381
390
|
} catch (error) {
|
|
382
391
|
removeErrorAfterTimeout = summarizeError(error);
|
|
383
392
|
}
|
|
384
|
-
run(
|
|
393
|
+
run(systemctl, ['--user', 'daemon-reload']);
|
|
385
394
|
const lines = [
|
|
386
395
|
`reconciled: the disable command timed out waiting on the stop job, but re-inspection confirms the ` +
|
|
387
396
|
`installer-managed ${legacyUnit} is no longer enabled${removeErrorAfterTimeout ? '' : ` — its unit file was removed (${path})`}. ` +
|
|
@@ -425,7 +434,7 @@ export async function reconcileRedundantLegacyUnit(
|
|
|
425
434
|
} catch (error) {
|
|
426
435
|
removeError = summarizeError(error);
|
|
427
436
|
}
|
|
428
|
-
run(
|
|
437
|
+
run(systemctl, ['--user', 'daemon-reload']);
|
|
429
438
|
|
|
430
439
|
const lines = [
|
|
431
440
|
`reconciled: ${canonicalUnit} is active and serving, so the redundant installer-managed ${legacyUnit} ` +
|
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
* compaction denominator agree with the post-catalog window.
|
|
7
7
|
*/
|
|
8
8
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
inferFallbackContextWindow,
|
|
11
|
+
resolveReasoningEffortSpec,
|
|
12
|
+
type ModelDefinition,
|
|
13
|
+
type ProviderRegistry,
|
|
14
|
+
} from '@pellux/goodvibes-sdk/platform/providers';
|
|
10
15
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
11
16
|
|
|
12
17
|
function buildFallbackModelDefinition(provider: string, modelId: string): ModelDefinition {
|
|
@@ -32,7 +37,16 @@ function buildFallbackModelDefinition(provider: string, modelId: string): ModelD
|
|
|
32
37
|
contextWindowProvenance: 'fallback',
|
|
33
38
|
selectable: true,
|
|
34
39
|
tier: 'standard',
|
|
35
|
-
|
|
40
|
+
// Which levels this model accepts is a property of the model, not of the
|
|
41
|
+
// provider it sits behind: the hardcoded four ('instant', 'low', 'medium',
|
|
42
|
+
// 'high') offered 'instant' to models that reject it and hid 'xhigh',
|
|
43
|
+
// 'max' and 'none' from models that accept them. The SDK resolver answers
|
|
44
|
+
// from the curated family table when the catalog has not loaded yet, and
|
|
45
|
+
// otherwise returns its own labelled best guess, which the adapters treat
|
|
46
|
+
// as "send nothing" rather than as verified levels.
|
|
47
|
+
...(isReasoningProvider
|
|
48
|
+
? { reasoningEffort: resolveReasoningEffortSpec({ modelId }) }
|
|
49
|
+
: {}),
|
|
36
50
|
};
|
|
37
51
|
}
|
|
38
52
|
|