@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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recovery-decisions.ts — a durable record of the recovery snapshots the user
|
|
3
|
+
* has already said "Remove" to.
|
|
4
|
+
*
|
|
5
|
+
* The defect this exists for: answering Remove deletes the snapshot file, and
|
|
6
|
+
* that was the whole memory of the decision. Anything that puts the file back
|
|
7
|
+
* — a session still running on an older build rewriting its snapshot every
|
|
8
|
+
* 60s, a restored backup, a synced directory — produced the same question
|
|
9
|
+
* again on the next launch, and again after the next removal, with no way for
|
|
10
|
+
* the user to make it stop. A decision the product forgets the instant it acts
|
|
11
|
+
* on it is not a decision the user gets to make.
|
|
12
|
+
*
|
|
13
|
+
* So Remove now writes a record that outlives the process, and a recorded
|
|
14
|
+
* snapshot found on disk again is deleted without asking. "Keep" is
|
|
15
|
+
* deliberately NOT recorded here: Keep means "leave it, ask me next launch",
|
|
16
|
+
* and recovery-prompt.ts's in-process set already holds it for the rest of the
|
|
17
|
+
* run.
|
|
18
|
+
*
|
|
19
|
+
* WHERE IT LIVES, and why not under `surface.stateDir`:
|
|
20
|
+
* `<homeDirectory>/.goodvibes/<surfaceRoot>/recovery-decisions.json` — home-
|
|
21
|
+
* anchored, matching session-liveness-marker.ts, and deliberately NOT the
|
|
22
|
+
* project-anchored `surface.stateDir`. The snapshots this defends against
|
|
23
|
+
* include the ones in the SDK's legacy shared recovery directory, which is
|
|
24
|
+
* itself home-anchored and shared by every project that ever used this
|
|
25
|
+
* surfaceRoot; the current build dual-reads it, so ONE such snapshot is
|
|
26
|
+
* offered at the launch of EVERY project. A project-scoped record could only
|
|
27
|
+
* ever silence one project at a time, which is the same nag wearing a
|
|
28
|
+
* different hat. Records are matched on session id alone; ids are unique, so
|
|
29
|
+
* a record made in one project cannot suppress a different snapshot in
|
|
30
|
+
* another. Each record still carries the workspace it was made in, so the file
|
|
31
|
+
* says plainly where the decision came from.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { dirname, join } from 'node:path';
|
|
35
|
+
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
36
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
37
|
+
|
|
38
|
+
/** One "the user removed this snapshot" decision. */
|
|
39
|
+
export interface RecoveryRemovalRecord {
|
|
40
|
+
readonly sessionId: string;
|
|
41
|
+
/** The project the decision was made in. Provenance — matching is on sessionId alone. */
|
|
42
|
+
readonly workspace: string;
|
|
43
|
+
readonly removedAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Bounds on the ledger. It is append-mostly and never read by anything but
|
|
48
|
+
* the boot offer, so it stays small by construction; these are the guards
|
|
49
|
+
* against a pathological case, not a routine cleanup.
|
|
50
|
+
*
|
|
51
|
+
* Note what is deliberately NOT the pruning rule: "drop records whose snapshot
|
|
52
|
+
* file no longer exists". Every record in here describes a file that was just
|
|
53
|
+
* deleted, so that rule would empty the ledger immediately and reinstate the
|
|
54
|
+
* exact defect it was written to fix — the point is precisely to still
|
|
55
|
+
* remember once the file comes back. Age and count are the honest bounds.
|
|
56
|
+
*
|
|
57
|
+
* Both bounds are enforced wherever records actually leave the disk —
|
|
58
|
+
* pruneRecoveryDecisions (once per boot) and recordRecoveryRemoval (every
|
|
59
|
+
* write) — and both DISCLOSE what they discarded. A deletion nobody reports is
|
|
60
|
+
* indistinguishable from data loss, so the counts go in the log with the ledger
|
|
61
|
+
* path, the TTL and the cap alongside them.
|
|
62
|
+
*/
|
|
63
|
+
const MAX_RECORDS = 200;
|
|
64
|
+
const RECORD_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
/** Absolute path to this surface's removal ledger. */
|
|
67
|
+
export function recoveryDecisionsPathFor(surface: SessionSurface): string {
|
|
68
|
+
return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'recovery-decisions.json');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isRecord(value: unknown): value is RecoveryRemovalRecord {
|
|
72
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
73
|
+
const candidate = value as Partial<RecoveryRemovalRecord>;
|
|
74
|
+
return typeof candidate.sessionId === 'string'
|
|
75
|
+
&& candidate.sessionId.length > 0
|
|
76
|
+
&& typeof candidate.workspace === 'string'
|
|
77
|
+
&& typeof candidate.removedAt === 'number'
|
|
78
|
+
&& Number.isFinite(candidate.removedAt);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What one load of the ledger found, including everything it discarded. */
|
|
82
|
+
interface LedgerLoad {
|
|
83
|
+
/** Records still in force, oldest first. */
|
|
84
|
+
readonly kept: RecoveryRemovalRecord[];
|
|
85
|
+
/** Records dropped because they aged past the TTL. */
|
|
86
|
+
readonly expired: number;
|
|
87
|
+
/** Entries dropped because they were not shaped like a record at all. */
|
|
88
|
+
readonly malformed: number;
|
|
89
|
+
/** The ledger file was present but could not be read or parsed as an array. */
|
|
90
|
+
readonly unreadable: boolean;
|
|
91
|
+
/** The ledger file exists on disk. */
|
|
92
|
+
readonly present: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Load and filter the ledger. Validation is by CONTENT — the file is parsed and
|
|
97
|
+
* every entry is shape-checked — so a torn, truncated or zero-byte ledger left
|
|
98
|
+
* by a crash reads as "no decisions recorded" instead of being trusted because
|
|
99
|
+
* it exists. Never throws: a corrupt file must not be able to take a boot down,
|
|
100
|
+
* and the worst outcome of losing it is that the user is asked once more.
|
|
101
|
+
*/
|
|
102
|
+
function loadLedger(surface: SessionSurface, nowMs: number): LedgerLoad {
|
|
103
|
+
const path = recoveryDecisionsPathFor(surface);
|
|
104
|
+
let present = false;
|
|
105
|
+
try {
|
|
106
|
+
present = existsSync(path);
|
|
107
|
+
if (!present) return { kept: [], expired: 0, malformed: 0, unreadable: false, present: false };
|
|
108
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
109
|
+
if (!Array.isArray(parsed)) return { kept: [], expired: 0, malformed: 0, unreadable: true, present };
|
|
110
|
+
const wellFormed = parsed.filter(isRecord);
|
|
111
|
+
const kept = wellFormed.filter((record) => nowMs - record.removedAt < RECORD_TTL_MS);
|
|
112
|
+
return {
|
|
113
|
+
kept,
|
|
114
|
+
expired: wellFormed.length - kept.length,
|
|
115
|
+
malformed: parsed.length - wellFormed.length,
|
|
116
|
+
unreadable: false,
|
|
117
|
+
present,
|
|
118
|
+
};
|
|
119
|
+
} catch {
|
|
120
|
+
return { kept: [], expired: 0, malformed: 0, unreadable: true, present };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Every removal decision still in force, oldest first. A missing, unreadable
|
|
126
|
+
* or malformed ledger reads as "no decisions recorded" rather than throwing:
|
|
127
|
+
* a corrupt file must not be able to take a boot down, and the worst outcome
|
|
128
|
+
* of losing it is that the user is asked once more.
|
|
129
|
+
*
|
|
130
|
+
* This is a filtered READ — it discards nothing from disk, so it stays silent.
|
|
131
|
+
* Disclosure belongs to the calls that actually delete: {@link pruneRecoveryDecisions}
|
|
132
|
+
* and {@link recordRecoveryRemoval}.
|
|
133
|
+
*/
|
|
134
|
+
export function readRecoveryRemovals(surface: SessionSurface, nowMs: number = Date.now()): RecoveryRemovalRecord[] {
|
|
135
|
+
return loadLedger(surface, nowMs).kept;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Write `records` to the ledger through a temp file and an atomic rename.
|
|
140
|
+
*
|
|
141
|
+
* A plain `writeFileSync` onto the live path truncates first and fills after,
|
|
142
|
+
* so a crash — or a second TUI in another terminal writing the same home-
|
|
143
|
+
* anchored ledger at the same moment — could leave a half-written array that a
|
|
144
|
+
* later boot reads as an empty or corrupt ledger, silently forgetting every
|
|
145
|
+
* decision the user made. `rename` is atomic on the same filesystem: a reader
|
|
146
|
+
* sees either the whole old ledger or the whole new one. The temp name carries
|
|
147
|
+
* the pid so two writers cannot interleave into one temp file.
|
|
148
|
+
*/
|
|
149
|
+
function writeLedgerAtomically(path: string, records: readonly RecoveryRemovalRecord[]): void {
|
|
150
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
151
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
152
|
+
try {
|
|
153
|
+
writeFileSync(temp, JSON.stringify(records, null, 2) + '\n', { mode: 0o600 });
|
|
154
|
+
renameSync(temp, path);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
try {
|
|
157
|
+
rmSync(temp, { force: true });
|
|
158
|
+
} catch {
|
|
159
|
+
// The temp file is inert; a failed cleanup is not worth a second error.
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** What a prune pass discarded, returned so callers (and tests) can assert it. */
|
|
166
|
+
export interface RecoveryDecisionsPruneOutcome {
|
|
167
|
+
/** Records dropped because they aged past the 90-day TTL. */
|
|
168
|
+
readonly expired: number;
|
|
169
|
+
/** Entries dropped because they were not shaped like a record. */
|
|
170
|
+
readonly malformed: number;
|
|
171
|
+
/** Records dropped because the ledger was over the 200-record cap. */
|
|
172
|
+
readonly overCap: number;
|
|
173
|
+
/** Records still in force after the prune. */
|
|
174
|
+
readonly kept: number;
|
|
175
|
+
/** The ledger file was present but unreadable or not an array. */
|
|
176
|
+
readonly unreadable: boolean;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Apply both bounds to the on-disk ledger and say what was discarded.
|
|
181
|
+
*
|
|
182
|
+
* `readRecoveryRemovals` filters in memory on every call, so the expired
|
|
183
|
+
* records were already inert — but they stayed on disk until the next time the
|
|
184
|
+
* user happened to answer "Remove", which on a machine where that never happens
|
|
185
|
+
* again is never. This makes the reap explicit and, crucially, VISIBLE: a
|
|
186
|
+
* silent deletion is indistinguishable from data loss, so every pass that drops
|
|
187
|
+
* anything logs the counts and the ledger path.
|
|
188
|
+
*
|
|
189
|
+
* Idempotent and safe to run from two processes at once: it is a pure function
|
|
190
|
+
* of the file's content plus `nowMs`, the rewrite is a temp-file-plus-rename,
|
|
191
|
+
* and a second run over an already-pruned ledger drops nothing and writes
|
|
192
|
+
* nothing. Best-effort — a ledger that cannot be pruned is left exactly as it
|
|
193
|
+
* was and never breaks a boot.
|
|
194
|
+
*/
|
|
195
|
+
export function pruneRecoveryDecisions(surface: SessionSurface, nowMs: number = Date.now()): RecoveryDecisionsPruneOutcome {
|
|
196
|
+
const path = recoveryDecisionsPathFor(surface);
|
|
197
|
+
const load = loadLedger(surface, nowMs);
|
|
198
|
+
const overCap = Math.max(0, load.kept.length - MAX_RECORDS);
|
|
199
|
+
const bounded = load.kept.slice(-MAX_RECORDS);
|
|
200
|
+
const outcome: RecoveryDecisionsPruneOutcome = {
|
|
201
|
+
expired: load.expired,
|
|
202
|
+
malformed: load.malformed,
|
|
203
|
+
overCap,
|
|
204
|
+
kept: bounded.length,
|
|
205
|
+
unreadable: load.unreadable,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (load.unreadable) {
|
|
209
|
+
// Do NOT rewrite: an unreadable ledger might be a transient read failure,
|
|
210
|
+
// and replacing it with `[]` would turn "could not read" into "forgot every
|
|
211
|
+
// decision the user made". Say so and leave it alone.
|
|
212
|
+
logger.warn('recovery decisions: ledger present but unreadable — left untouched, decisions may be re-asked', {
|
|
213
|
+
ledger: path,
|
|
214
|
+
});
|
|
215
|
+
return outcome;
|
|
216
|
+
}
|
|
217
|
+
const dropped = load.expired + load.malformed + overCap;
|
|
218
|
+
if (dropped === 0 || !load.present) return outcome;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
writeLedgerAtomically(path, bounded);
|
|
222
|
+
} catch {
|
|
223
|
+
// The in-memory filter already ignores these records; failing to persist
|
|
224
|
+
// the prune costs disk space, not correctness.
|
|
225
|
+
return outcome;
|
|
226
|
+
}
|
|
227
|
+
logger.info('recovery decisions: pruned the removal ledger', {
|
|
228
|
+
ledger: path,
|
|
229
|
+
expiredRecords: load.expired,
|
|
230
|
+
malformedEntries: load.malformed,
|
|
231
|
+
overCapRecords: overCap,
|
|
232
|
+
keptRecords: bounded.length,
|
|
233
|
+
ttlDays: Math.round(RECORD_TTL_MS / (24 * 60 * 60 * 1000)),
|
|
234
|
+
maxRecords: MAX_RECORDS,
|
|
235
|
+
});
|
|
236
|
+
return outcome;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Has the user already chosen Remove for this snapshot's session, in this or
|
|
241
|
+
* any earlier run?
|
|
242
|
+
*/
|
|
243
|
+
export function isRecoveryRemovalRecorded(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): boolean {
|
|
244
|
+
if (!sessionId.trim()) return false;
|
|
245
|
+
return readRecoveryRemovals(surface, nowMs).some((record) => record.sessionId === sessionId);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Record that the user chose Remove for `sessionId`. Idempotent — a repeat
|
|
250
|
+
* removal of a snapshot that came back refreshes the existing record's
|
|
251
|
+
* timestamp rather than adding a second one, so the ledger cannot grow by one
|
|
252
|
+
* entry per reappearance.
|
|
253
|
+
*
|
|
254
|
+
* Best-effort: a ledger that cannot be written must not break the removal the
|
|
255
|
+
* user actually asked for. The cost of a failed write is one more question on
|
|
256
|
+
* a future launch, which is exactly the behaviour that shipped before this
|
|
257
|
+
* file existed.
|
|
258
|
+
*/
|
|
259
|
+
export function recordRecoveryRemoval(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): void {
|
|
260
|
+
if (!sessionId.trim()) return;
|
|
261
|
+
const path = recoveryDecisionsPathFor(surface);
|
|
262
|
+
try {
|
|
263
|
+
const load = loadLedger(surface, nowMs);
|
|
264
|
+
if (load.unreadable) {
|
|
265
|
+
// Appending onto an unreadable ledger would rewrite it as a one-record
|
|
266
|
+
// array, silently discarding whatever the user decided before. Say so
|
|
267
|
+
// rather than quietly resetting their history.
|
|
268
|
+
logger.warn('recovery decisions: ledger unreadable — recording this removal replaces it', {
|
|
269
|
+
ledger: path,
|
|
270
|
+
sessionId,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const kept = load.kept.filter((record) => record.sessionId !== sessionId);
|
|
274
|
+
kept.push({ sessionId, workspace: surface.workingDirectory, removedAt: nowMs });
|
|
275
|
+
// Oldest decisions fall off first; the newest are the ones a reappearing
|
|
276
|
+
// snapshot is most likely to match.
|
|
277
|
+
const overCap = Math.max(0, kept.length - MAX_RECORDS);
|
|
278
|
+
const bounded = kept.slice(-MAX_RECORDS);
|
|
279
|
+
writeLedgerAtomically(path, bounded);
|
|
280
|
+
// Disclosure: this write is where records actually leave the disk, so it
|
|
281
|
+
// is where the counts get said out loud.
|
|
282
|
+
const dropped = load.expired + load.malformed + overCap;
|
|
283
|
+
if (dropped > 0) {
|
|
284
|
+
logger.info('recovery decisions: dropped records while recording a removal', {
|
|
285
|
+
ledger: path,
|
|
286
|
+
expiredRecords: load.expired,
|
|
287
|
+
malformedEntries: load.malformed,
|
|
288
|
+
overCapRecords: overCap,
|
|
289
|
+
keptRecords: bounded.length,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
293
|
+
// Best-effort by construction — see the doc comment above.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -39,10 +39,21 @@
|
|
|
39
39
|
* without reading keeps the snapshot.
|
|
40
40
|
* - Keep (or a dismissal) stays quiet for the rest of the run. The snapshot
|
|
41
41
|
* is offered again on the next launch, not again in this one.
|
|
42
|
+
* - Remove is permanent, not just for this file. The decision is written to
|
|
43
|
+
* the durable ledger in recovery-decisions.ts, so a snapshot that comes
|
|
44
|
+
* back byte-for-byte — because a session still running on an older build
|
|
45
|
+
* is rewriting it every minute — is deleted on sight instead of asked
|
|
46
|
+
* about again. Deleting the file used to be the entire memory of the
|
|
47
|
+
* answer, which is how the same question survived being answered three
|
|
48
|
+
* times.
|
|
49
|
+
* - The offer is also where that ledger gets swept. Every run prunes it to
|
|
50
|
+
* its age and count bounds and logs what it dropped, so the record of past
|
|
51
|
+
* decisions cannot quietly grow forever and nothing is deleted in silence.
|
|
42
52
|
*/
|
|
43
53
|
import { statSync } from 'node:fs';
|
|
44
54
|
import { checkRecoveryFile, checkRecoveryForSession, consumeRecovery, removeRecoveryPoint } from '@/runtime/index.ts';
|
|
45
55
|
import type { RecoveryFileInfo, SessionSurface } from '@/runtime/index.ts';
|
|
56
|
+
import { isRecoveryRemovalRecorded, pruneRecoveryDecisions, recordRecoveryRemoval } from './recovery-decisions.ts';
|
|
46
57
|
import { checkSessionLiveness } from './session-liveness-marker.ts';
|
|
47
58
|
import type { SelectionItem, SelectionResult } from '../input/selection-modal.ts';
|
|
48
59
|
|
|
@@ -213,11 +224,50 @@ function defaultSnapshotBytes(surface: SessionSurface, sessionId: string): numbe
|
|
|
213
224
|
*/
|
|
214
225
|
const answeredThisRun = new Set<string>();
|
|
215
226
|
|
|
216
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Test seam: a fresh process has no answered offers. Only clears the
|
|
229
|
+
* in-process Keep memory — removal decisions are durable on purpose and live
|
|
230
|
+
* in the surface's own directory, so a test gets a clean ledger by using a
|
|
231
|
+
* fresh temp surface, the same way a real user gets one by using a different
|
|
232
|
+
* machine.
|
|
233
|
+
*/
|
|
217
234
|
export function resetAnsweredRecoveryOffersForTest(): void {
|
|
218
235
|
answeredThisRun.clear();
|
|
219
236
|
}
|
|
220
237
|
|
|
238
|
+
/**
|
|
239
|
+
* How many times one offer pass will delete a recorded-removed snapshot and
|
|
240
|
+
* look again. A bound rather than a `while (true)`: the same session can have
|
|
241
|
+
* a snapshot in both the canonical and the legacy shared directory, so one
|
|
242
|
+
* removal can legitimately need a second pass, but nothing here should be able
|
|
243
|
+
* to spin if a delete ever fails to take.
|
|
244
|
+
*/
|
|
245
|
+
const MAX_RECORDED_REMOVAL_PASSES = 8;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The snapshot to actually ask about, after honouring decisions already made.
|
|
249
|
+
*
|
|
250
|
+
* A snapshot whose session is in the removal ledger is deleted here and NOT
|
|
251
|
+
* offered: the user answered that question already, and re-asking because the
|
|
252
|
+
* file came back is the defect. Deleting rather than merely skipping matters —
|
|
253
|
+
* skipping would leave the file to mask an older, genuinely-orphaned snapshot
|
|
254
|
+
* underneath it forever.
|
|
255
|
+
*/
|
|
256
|
+
function findOfferableSnapshot(deps: RecoveryPromptDeps): RecoveryFileInfo | null {
|
|
257
|
+
for (let pass = 0; pass < MAX_RECORDED_REMOVAL_PASSES; pass += 1) {
|
|
258
|
+
const info = deps.targetSessionId !== undefined
|
|
259
|
+
? checkRecoveryForSession(deps.surface, deps.targetSessionId)
|
|
260
|
+
: checkRecoveryFile({ surface: deps.surface });
|
|
261
|
+
if (!info) return null;
|
|
262
|
+
if (!isRecoveryRemovalRecorded(deps.surface, info.sessionId)) return info;
|
|
263
|
+
// Silently, because the user already said remove. A receipt here would be
|
|
264
|
+
// a notification about a decision they made and do not need re-confirmed.
|
|
265
|
+
const { removed } = removeRecoveryPoint(deps.surface, info.sessionId);
|
|
266
|
+
if (!removed) return null;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
221
271
|
/**
|
|
222
272
|
* Run the startup recovery offer. Resolves once the user has answered (or
|
|
223
273
|
* once it is established that there is nothing to ask about). Never throws:
|
|
@@ -225,9 +275,14 @@ export function resetAnsweredRecoveryOffersForTest(): void {
|
|
|
225
275
|
*/
|
|
226
276
|
export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<RecoveryPromptOutcome> {
|
|
227
277
|
try {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
278
|
+
// Housekeeping first: recovery time is when the removal ledger gets its
|
|
279
|
+
// bounds actually applied to disk and says what it discarded. Without this
|
|
280
|
+
// the expired records were filtered on read but never removed, so on a
|
|
281
|
+
// machine where the user never answers "Remove" again the file only ever
|
|
282
|
+
// grew. Idempotent, so the second call in a run (targeted --continue offer
|
|
283
|
+
// plus the general startup offer) drops nothing and writes nothing.
|
|
284
|
+
pruneRecoveryDecisions(deps.surface, deps.now?.() ?? Date.now());
|
|
285
|
+
const info = findOfferableSnapshot(deps);
|
|
231
286
|
if (!info) return 'none';
|
|
232
287
|
// Already answered about this snapshot earlier in this same run (the
|
|
233
288
|
// targeted --continue offer and the general startup offer can both find
|
|
@@ -270,9 +325,14 @@ export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<R
|
|
|
270
325
|
const retire = await ask(open, RECOVERY_RETIRE_TITLE, buildRecoveryRetireItems(facts));
|
|
271
326
|
if (retire === 'remove') {
|
|
272
327
|
const { removed } = removeRecoveryPoint(deps.surface, info.sessionId);
|
|
328
|
+
// Recorded whether or not a file was there to delete: the answer is
|
|
329
|
+
// about this snapshot, and a snapshot that vanished between the question
|
|
330
|
+
// and the answer can still be rewritten a minute later by whatever wrote
|
|
331
|
+
// it in the first place.
|
|
332
|
+
recordRecoveryRemoval(deps.surface, info.sessionId);
|
|
273
333
|
deps.receipt(removed
|
|
274
|
-
? `Recovery point removed (session ${info.sessionId}).`
|
|
275
|
-
: `No recovery point was found to remove (session ${info.sessionId}).`);
|
|
334
|
+
? `Recovery point removed (session ${info.sessionId}) — it will not be offered again, even if the file reappears.`
|
|
335
|
+
: `No recovery point was found to remove (session ${info.sessionId}) — if one reappears for that session it will be discarded, not offered.`);
|
|
276
336
|
deps.render();
|
|
277
337
|
return 'removed';
|
|
278
338
|
}
|
package/src/runtime/services.ts
CHANGED
|
@@ -96,6 +96,8 @@ export interface RuntimeServicesOptions {
|
|
|
96
96
|
/** Host power seam opt-in. Fork mirrors the SDK: non-spawning unavailable-seam
|
|
97
97
|
* default (idle-power-services.ts); daemon + embedded runtime pass createHostPowerSeam(). */
|
|
98
98
|
readonly powerSeam?: Parameters<typeof wireIdlePowerAndLiveTurn>[0]['powerSeam'];
|
|
99
|
+
/** Live session id, read per crash-residue sweep so the running session is exempt — see durability-services.ts. */
|
|
100
|
+
readonly currentSessionId?: (() => string | null) | undefined;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
export interface RuntimeServices {
|
|
@@ -207,6 +209,9 @@ export interface RuntimeServices {
|
|
|
207
209
|
readonly codeIndexReindexScheduler: CodeIndexReindexScheduler; // tool-site reindex
|
|
208
210
|
/** Daily snapshots of every SQLite store this runtime writes, with bounded retention; unref'd timers (mirrors the SDK composition — hosts that tear down a runtime stop() it themselves). */
|
|
209
211
|
readonly storeSnapshotScheduler: StoreSnapshotScheduler;
|
|
212
|
+
readonly appendOnlyRetentionScheduler: ReturnType<typeof createDurabilityServices>['appendOnlyRetentionScheduler']; // periodic append-only sweep; unref'd timers, stop() on teardown
|
|
213
|
+
/** Stops the recurring crash-residue sweep; idempotent, unref'd timer (hosts that tear a runtime down call it). */
|
|
214
|
+
readonly stopDurabilityHousekeeping: () => void;
|
|
210
215
|
readonly memoryConsolidationScheduler: MemoryConsolidationScheduler;
|
|
211
216
|
readonly powerManager: PowerManager;
|
|
212
217
|
/** The daemon's memory governor (default ON). Backs ops.memory.get and defends the daemon's footprint by tier. */
|
|
@@ -566,10 +571,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
566
571
|
// with MemoryStore above. Auto-build is config-gated (default off) — see
|
|
567
572
|
// code-index-services.ts's header doc.
|
|
568
573
|
const { codeIndexStore, codeIndexReindexScheduler } = createCodeIndexServices({ workingDirectory, configManager, memoryEmbeddingRegistry, isReindexPaused: () => pauseController.isPaused('code-index-reindex'), admitExpensiveWork });
|
|
569
|
-
// Store snapshots
|
|
570
|
-
|
|
571
|
-
const { storeSnapshotScheduler, userPermissionRuleStore } = createDurabilityServices({
|
|
574
|
+
// Store snapshots, the periodic append-only sweep, durable remembered-approval rules + the live credential chain — see durability-services.ts.
|
|
575
|
+
const { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping } = createDurabilityServices({
|
|
572
576
|
configManager, secretsManager, providerRegistry, memoryDbPath, codeIndexDbPath: codeIndexDbPath(workingDirectory), surface, shellPaths, // + retention-sweep roots & live config watch (mirrors the SDK)
|
|
577
|
+
...(options.currentSessionId ? { currentSessionId: options.currentSessionId } : {}), // exempts the running session from crash-residue reaping
|
|
573
578
|
});
|
|
574
579
|
const codeInjectionOrchestratorDeps = { codeIndex: codeIndexStore, isCodeInjectionSettingEnabled: () => isCodeInjectionSettingEnabled(configManager), codeIndexReindexScheduler }; // Code-injection seam (agent here; main via orchestrator-core-services.ts)
|
|
575
580
|
const { processRegistry } = createFleetServices({ // Shared archive-aware fleet registry (+ daemon observed rows) — see fleet-services.ts
|
|
@@ -778,7 +783,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
778
783
|
workstreamCommands,
|
|
779
784
|
codeIndexStore,
|
|
780
785
|
codeIndexReindexScheduler,
|
|
781
|
-
storeSnapshotScheduler,
|
|
786
|
+
storeSnapshotScheduler, appendOnlyRetentionScheduler, stopDurabilityHousekeeping,
|
|
782
787
|
memoryConsolidationScheduler,
|
|
783
788
|
powerManager,
|
|
784
789
|
memoryGovernor,
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* recovery snapshots they describe — mirrors the transcript-journal.ts
|
|
25
25
|
* convention (homeDirectory-scoped, under the surface's own directory).
|
|
26
26
|
*/
|
|
27
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
28
28
|
import { dirname, join } from 'node:path';
|
|
29
29
|
import type { SessionSurface } from '@/runtime/index.ts';
|
|
30
30
|
|
|
@@ -42,9 +42,14 @@ export interface LivenessMarker {
|
|
|
42
42
|
*/
|
|
43
43
|
export const LIVENESS_STALE_AFTER_MS = 150_000;
|
|
44
44
|
|
|
45
|
+
/** Absolute path to the directory holding every session's liveness marker. */
|
|
46
|
+
export function livenessMarkerDirFor(surface: SessionSurface): string {
|
|
47
|
+
return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'liveness');
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
/** Absolute path to a session's liveness marker file. */
|
|
46
51
|
export function livenessMarkerPathFor(surface: SessionSurface, sessionId: string): string {
|
|
47
|
-
return join(surface
|
|
52
|
+
return join(livenessMarkerDirFor(surface), `${sessionId}.json`);
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/** Refresh (creating if needed) the liveness marker for this session/pid. Best-effort — never throws. */
|
|
@@ -69,18 +74,27 @@ export function removeLivenessMarker(surface: SessionSurface, sessionId: string)
|
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
|
|
72
|
-
|
|
77
|
+
/** Parse one marker file by absolute path. Returns null for missing, empty, torn, or wrong-shaped content. */
|
|
78
|
+
function parseMarkerFile(path: string): LivenessMarker | null {
|
|
73
79
|
try {
|
|
74
|
-
const path = livenessMarkerPathFor(surface, sessionId);
|
|
75
80
|
if (!existsSync(path)) return null;
|
|
76
|
-
const
|
|
81
|
+
const text = readFileSync(path, 'utf-8');
|
|
82
|
+
// A crash between create and write leaves a zero-byte file. Content, not
|
|
83
|
+
// existence, decides whether this marker says anything at all.
|
|
84
|
+
if (text.trim().length === 0) return null;
|
|
85
|
+
const parsed = JSON.parse(text) as Partial<LivenessMarker>;
|
|
77
86
|
if (typeof parsed.sessionId !== 'string' || typeof parsed.pid !== 'number' || typeof parsed.updatedAt !== 'number') return null;
|
|
87
|
+
if (!Number.isFinite(parsed.pid) || !Number.isFinite(parsed.updatedAt)) return null;
|
|
78
88
|
return { sessionId: parsed.sessionId, pid: parsed.pid, updatedAt: parsed.updatedAt };
|
|
79
89
|
} catch {
|
|
80
90
|
return null;
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
function readLivenessMarker(surface: SessionSurface, sessionId: string): LivenessMarker | null {
|
|
95
|
+
return parseMarkerFile(livenessMarkerPathFor(surface, sessionId));
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
/** Best-effort check that a pid still refers to a running process. Injectable for tests. */
|
|
85
99
|
export function isPidAlive(pid: number, kill: (pid: number, signal: 0) => void = process.kill.bind(process)): boolean {
|
|
86
100
|
try {
|
|
@@ -118,3 +132,126 @@ export function checkSessionLiveness(
|
|
|
118
132
|
if (!aliveCheck(marker.pid)) return { live: false, pid: null };
|
|
119
133
|
return { live: true, pid: marker.pid };
|
|
120
134
|
}
|
|
135
|
+
|
|
136
|
+
// ─── Reaping ────────────────────────────────────────────────────────────────
|
|
137
|
+
//
|
|
138
|
+
// `removeLivenessMarker` only runs on a CLEAN exit (process-lifecycle.ts). A
|
|
139
|
+
// SIGKILL, a panic, or a pulled power cord leaves the marker behind forever,
|
|
140
|
+
// and `checkSessionLiveness` treating it as not-live does not remove the file
|
|
141
|
+
// — so the liveness directory grows one file per session id ever opened. The
|
|
142
|
+
// sweep below is the reclaim half of that lifecycle.
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Hard ceiling on marker files kept after a sweep, oldest dropped first.
|
|
146
|
+
*
|
|
147
|
+
* The liveness rule below already reclaims ordinary crash residue, but it can
|
|
148
|
+
* be defeated: `isPidAlive` deliberately reports EPERM (a pid this user cannot
|
|
149
|
+
* signal) as alive, so on a busy host a reused pid can keep a dead session's
|
|
150
|
+
* marker "live" indefinitely. This cap is the second, unconditional bound so
|
|
151
|
+
* the directory can never grow without limit whatever the pid probe says.
|
|
152
|
+
*/
|
|
153
|
+
export const LIVENESS_MARKER_MAX_FILES = 200;
|
|
154
|
+
|
|
155
|
+
export interface LivenessReapResult {
|
|
156
|
+
/** Marker files examined this sweep. */
|
|
157
|
+
readonly scanned: number;
|
|
158
|
+
/** Marker files deleted this sweep. */
|
|
159
|
+
readonly reaped: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface LivenessReapOptions {
|
|
163
|
+
readonly now?: () => number;
|
|
164
|
+
readonly isPidAliveFn?: typeof isPidAlive;
|
|
165
|
+
/**
|
|
166
|
+
* Session ids that must never be reaped regardless of what the marker says
|
|
167
|
+
* — normally just the current session's id. Belt-and-braces: the current
|
|
168
|
+
* session refreshes its own marker every 60s, so the liveness rule already
|
|
169
|
+
* keeps it.
|
|
170
|
+
*/
|
|
171
|
+
readonly keepSessionIds?: readonly string[];
|
|
172
|
+
/** Override the count cap (tests). */
|
|
173
|
+
readonly maxFiles?: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Delete liveness markers that no longer describe a running session.
|
|
178
|
+
*
|
|
179
|
+
* A marker is reaped when it is either:
|
|
180
|
+
* - unreadable, empty, or not shaped like a marker (crash residue that can
|
|
181
|
+
* never resolve to a live session again — validated by parsing the
|
|
182
|
+
* content, not by the file merely existing); or
|
|
183
|
+
* - definitively not live: older than `LIVENESS_STALE_AFTER_MS` AND its pid
|
|
184
|
+
* no longer resolves to a running process.
|
|
185
|
+
*
|
|
186
|
+
* Both halves of that second rule are required on purpose. Deleting is
|
|
187
|
+
* destructive while a liveness answer is only advisory, so a marker that is
|
|
188
|
+
* stale-but-pid-alive (a wedged owner still holding the session) or
|
|
189
|
+
* fresh-but-pid-dead (a process that died seconds ago, or a pid probe that
|
|
190
|
+
* failed transiently) is left for a later sweep. A genuinely crashed session
|
|
191
|
+
* satisfies both conditions within `LIVENESS_STALE_AFTER_MS` and is reclaimed
|
|
192
|
+
* on the next sweep after that.
|
|
193
|
+
*
|
|
194
|
+
* Idempotent and safe to run concurrently from several processes: a marker
|
|
195
|
+
* another sweeper unlinked between the directory listing and this unlink
|
|
196
|
+
* (ENOENT) counts as reaped, never as an error.
|
|
197
|
+
*/
|
|
198
|
+
export function reapStaleLivenessMarkers(surface: SessionSurface, opts: LivenessReapOptions = {}): LivenessReapResult {
|
|
199
|
+
const dir = livenessMarkerDirFor(surface);
|
|
200
|
+
let names: string[];
|
|
201
|
+
try {
|
|
202
|
+
names = readdirSync(dir).filter((name) => name.endsWith('.json'));
|
|
203
|
+
} catch {
|
|
204
|
+
// No liveness directory yet, or it is unreadable — nothing to reclaim.
|
|
205
|
+
return { scanned: 0, reaped: 0 };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const now = opts.now?.() ?? Date.now();
|
|
209
|
+
const aliveCheck = opts.isPidAliveFn ?? isPidAlive;
|
|
210
|
+
const keep = new Set(opts.keepSessionIds ?? []);
|
|
211
|
+
const maxFiles = opts.maxFiles ?? LIVENESS_MARKER_MAX_FILES;
|
|
212
|
+
|
|
213
|
+
let reaped = 0;
|
|
214
|
+
/** Survivors, so the count cap below can drop the oldest of them. */
|
|
215
|
+
const survivors: { readonly path: string; readonly updatedAt: number }[] = [];
|
|
216
|
+
|
|
217
|
+
for (const name of names) {
|
|
218
|
+
const sessionId = name.slice(0, -'.json'.length);
|
|
219
|
+
if (keep.has(sessionId)) continue;
|
|
220
|
+
const path = join(dir, name);
|
|
221
|
+
const marker = parseMarkerFile(path);
|
|
222
|
+
if (marker === null) {
|
|
223
|
+
if (unlinkIfPresent(path)) reaped++;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const stale = now - marker.updatedAt > LIVENESS_STALE_AFTER_MS;
|
|
227
|
+
if (stale && !aliveCheck(marker.pid)) {
|
|
228
|
+
if (unlinkIfPresent(path)) reaped++;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
survivors.push({ path, updatedAt: marker.updatedAt });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (survivors.length > maxFiles) {
|
|
235
|
+
survivors.sort((a, b) => a.updatedAt - b.updatedAt); // oldest first
|
|
236
|
+
for (const victim of survivors.slice(0, survivors.length - maxFiles)) {
|
|
237
|
+
if (unlinkIfPresent(victim.path)) reaped++;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { scanned: names.length, reaped };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Best-effort unlink shared by the sweep. Returns true when the file is gone
|
|
246
|
+
* because of (or by the time of) this call. A concurrent sweeper winning the
|
|
247
|
+
* race (ENOENT) is success, not failure; any other error leaves the file for
|
|
248
|
+
* the next sweep.
|
|
249
|
+
*/
|
|
250
|
+
function unlinkIfPresent(path: string): boolean {
|
|
251
|
+
try {
|
|
252
|
+
unlinkSync(path);
|
|
253
|
+
return true;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
|
|
256
|
+
}
|
|
257
|
+
}
|