@adhdev/daemon-core 1.0.18-rc.5 → 1.0.18-rc.7

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.
@@ -0,0 +1,370 @@
1
+ // ---------------------------------------------------------------------------
2
+ // mesh-disk-retention — periodic disk / worktree retention for ~/.adhdev
3
+ // ---------------------------------------------------------------------------
4
+ // Legacy on-disk artifacts under ~/.adhdev accumulated with NO lifetime/GC and
5
+ // grew the data volume until a refine bootstrap failed at 98% disk (mission
6
+ // 86def38d). Immediate reclaim was manual; this module implements the code-level
7
+ // RETENTION so it does not recur.
8
+ //
9
+ // What it prunes (all thresholds defensive — never touches a live/in-use file):
10
+ // 1. JSONL ledger files ~/.adhdev/mesh-ledger/*.jsonl older than 30 days
11
+ // (legacy after the SQLite ledger; zero lifetime before this).
12
+ // 2. session-host runtimes ~/.adhdev/session-host/*/runtimes/*.json for
13
+ // TERMINATED (dead) runtimes older than 14 days (live runtimes never touched).
14
+ // 3. DB backups ~/.adhdev/mesh-ledger/mesh-runtime.db.bak-* older
15
+ // than 7 days.
16
+ // Plus DETECTION-ONLY orphan-worktree signalling (see mesh-reconcile-loop.ts):
17
+ // 4. a worktree present on disk with no matching live mesh node is reported as a
18
+ // cleanup_candidate ledger entry — NEVER auto-deleted (that stays manual /
19
+ // coordinator-driven).
20
+ //
21
+ // The functions are split into PURE core selectors (age/orphan decisions, taking
22
+ // explicit paths + a `now` timestamp so they are deterministic and require no fs
23
+ // mocking in tests) and thin runtime wrappers that resolve the real ~/.adhdev
24
+ // paths and perform the unlink. The pure selectors are the unit-tested surface.
25
+ // ---------------------------------------------------------------------------
26
+
27
+ import {
28
+ existsSync,
29
+ readdirSync,
30
+ statSync,
31
+ unlinkSync,
32
+ readFileSync,
33
+ } from 'fs';
34
+ import { join } from 'path';
35
+ import { getConfigDir } from '../config/config.js';
36
+ import { getLedgerDir, appendLedgerEntry, readLedgerEntries } from './mesh-ledger.js';
37
+ import { isSessionHostLiveRuntime } from '../session-host/runtime-surface.js';
38
+ import type { SessionHostSurfaceRecordLike } from '../session-host/runtime-surface.js';
39
+ import { listWorktrees } from '../git/git-worktree.js';
40
+ import type { LocalMeshEntry } from '../repo-mesh-types.js';
41
+ import { LOG } from '../logging/logger.js';
42
+
43
+ // ─── Thresholds (all defensive) ────────────────────────────────────────────
44
+ export const DAY_MS = 24 * 60 * 60 * 1000;
45
+ /** JSONL ledger files are legacy after the SQLite ledger — 30-day lifetime. */
46
+ export const LEDGER_JSONL_MAX_AGE_MS = 30 * DAY_MS;
47
+ /** Terminated session-host runtimes: conservative 14-day retention. */
48
+ export const SESSION_HOST_RUNTIME_MAX_AGE_MS = 14 * DAY_MS;
49
+ /** mesh-runtime.db.bak-* backups: 7-day retention. */
50
+ export const DB_BAK_MAX_AGE_MS = 7 * DAY_MS;
51
+
52
+ // ─── (1) JSONL ledger retention ─────────────────────────────────────────────
53
+
54
+ /** A file candidate for age-based pruning: absolute path + last-modified epoch ms. */
55
+ export interface AgedFile {
56
+ path: string;
57
+ mtimeMs: number;
58
+ }
59
+
60
+ /**
61
+ * PURE. Select the JSONL ledger files whose mtime is older than `maxAgeMs`
62
+ * relative to `now`. A file exactly at the threshold is KEPT (strict `>`), so a
63
+ * 30-day-old file survives its 30th day and is pruned on the 31st. Deterministic:
64
+ * no fs access, no clock read.
65
+ */
66
+ export function selectExpiredLedgerJsonl(
67
+ files: AgedFile[],
68
+ now: number,
69
+ maxAgeMs: number = LEDGER_JSONL_MAX_AGE_MS,
70
+ ): AgedFile[] {
71
+ return files.filter(f => now - f.mtimeMs > maxAgeMs);
72
+ }
73
+
74
+ // ─── (2) session-host runtime retention ─────────────────────────────────────
75
+
76
+ /** A parsed session-host runtime file: its path, mtime, and the wrapped record. */
77
+ export interface SessionHostRuntimeFile {
78
+ path: string;
79
+ mtimeMs: number;
80
+ /** The `record` object from the on-disk `{ record, snapshot, updatedAt }` file. */
81
+ record: SessionHostSurfaceRecordLike | null;
82
+ }
83
+
84
+ /**
85
+ * PURE. Select the session-host runtime files safe to delete: ONLY those whose
86
+ * runtime is terminated/dead (NOT a live runtime — decided by the session-host-core
87
+ * SSOT `isSessionHostLiveRuntime`) AND older than `maxAgeMs`. A live runtime, or a
88
+ * dead-but-recent one, is always kept. A file whose record failed to parse is treated
89
+ * as NON-live but is still age-gated, so a corrupt-but-fresh file is never removed.
90
+ */
91
+ export function selectExpiredSessionHostRuntimes(
92
+ files: SessionHostRuntimeFile[],
93
+ now: number,
94
+ maxAgeMs: number = SESSION_HOST_RUNTIME_MAX_AGE_MS,
95
+ ): SessionHostRuntimeFile[] {
96
+ return files.filter(f => {
97
+ // Never delete a live runtime, regardless of age.
98
+ if (isSessionHostLiveRuntime(f.record ?? undefined)) return false;
99
+ // Terminated/dead: age-gate it.
100
+ return now - f.mtimeMs > maxAgeMs;
101
+ });
102
+ }
103
+
104
+ // ─── (3) DB backup retention ────────────────────────────────────────────────
105
+
106
+ /** True for a `mesh-runtime.db.bak-*` backup filename (basename only). */
107
+ export function isDbBackupFileName(name: string): boolean {
108
+ return /^mesh-runtime\.db\.bak-/.test(name);
109
+ }
110
+
111
+ /** PURE. Select `.bak-*` backups older than `maxAgeMs`. Strict `>` (see ledger). */
112
+ export function selectExpiredDbBackups(
113
+ files: AgedFile[],
114
+ now: number,
115
+ maxAgeMs: number = DB_BAK_MAX_AGE_MS,
116
+ ): AgedFile[] {
117
+ return files.filter(f => now - f.mtimeMs > maxAgeMs);
118
+ }
119
+
120
+ // ─── (4) orphan worktree detection (detection-only) ─────────────────────────
121
+
122
+ /** Minimal shape needed to decide whether a worktree path is orphaned. */
123
+ export interface WorktreePathLike {
124
+ path: string;
125
+ bare?: boolean;
126
+ }
127
+
128
+ /** A live mesh node's known workspace paths (self workspace + repoRoot, normalized). */
129
+ export interface LiveNodeWorkspaceLike {
130
+ workspace?: string;
131
+ repoRoot?: string;
132
+ }
133
+
134
+ function normalizePath(p: string): string {
135
+ // Trim a single trailing separator so "/a/b/" and "/a/b" compare equal.
136
+ // Case-preserving (git worktree list + meshes.json are both raw paths from the
137
+ // same daemon, so a case-fold would be over-eager on case-sensitive FS).
138
+ return p.replace(/[/\\]+$/, '');
139
+ }
140
+
141
+ /**
142
+ * PURE. Given the worktrees git reports on disk and the set of live-node workspace
143
+ * paths, return the worktrees that have NO matching live node — the orphan
144
+ * cleanup_candidates.
145
+ *
146
+ * SAFETY:
147
+ * - `mainWorktreePath` (the primary repo checkout, i.e. worktree[0]) is NEVER an
148
+ * orphan — it is the base repo, not a mesh clone.
149
+ * - `bare` worktrees are skipped (git's internal bookkeeping, not a node checkout).
150
+ * - Matching is path-equality after trailing-separator normalization against the
151
+ * union of every live node's `workspace` and `repoRoot`.
152
+ * This is DETECTION ONLY — the caller signals a cleanup_candidate; it must not delete.
153
+ */
154
+ export function detectOrphanWorktrees(
155
+ worktrees: WorktreePathLike[],
156
+ liveNodes: LiveNodeWorkspaceLike[],
157
+ mainWorktreePath: string,
158
+ ): WorktreePathLike[] {
159
+ const liveePaths = new Set<string>();
160
+ for (const n of liveNodes) {
161
+ if (n.workspace) liveePaths.add(normalizePath(n.workspace));
162
+ if (n.repoRoot) liveePaths.add(normalizePath(n.repoRoot));
163
+ }
164
+ const mainNorm = normalizePath(mainWorktreePath);
165
+ return worktrees.filter(wt => {
166
+ if (wt.bare) return false;
167
+ const norm = normalizePath(wt.path);
168
+ if (norm === mainNorm) return false; // base repo checkout — never an orphan
169
+ return !liveePaths.has(norm);
170
+ });
171
+ }
172
+
173
+ // ─── Runtime wrappers (resolve real paths + perform the unlink) ──────────────
174
+ // These are the side-effecting callers used by the reconcile loop. They are thin:
175
+ // gather → delegate to a PURE selector → unlink. Kept out of the unit tests (which
176
+ // target the deterministic selectors); their I/O is exercised by the daemon at runtime.
177
+
178
+ function safeUnlink(path: string): boolean {
179
+ try {
180
+ unlinkSync(path);
181
+ return true;
182
+ } catch (e: any) {
183
+ LOG.warn('DiskRetention', `Failed to delete ${path}: ${e?.message || e}`);
184
+ return false;
185
+ }
186
+ }
187
+
188
+ function listDirFiles(dir: string): AgedFile[] {
189
+ if (!existsSync(dir)) return [];
190
+ const out: AgedFile[] = [];
191
+ let names: string[];
192
+ try {
193
+ names = readdirSync(dir);
194
+ } catch {
195
+ return [];
196
+ }
197
+ for (const name of names) {
198
+ const path = join(dir, name);
199
+ try {
200
+ const st = statSync(path);
201
+ if (st.isFile()) out.push({ path, mtimeMs: st.mtimeMs });
202
+ } catch {
203
+ // vanished between readdir and stat — skip
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ /**
210
+ * Prune expired legacy JSONL ledger files under ~/.adhdev/mesh-ledger/.
211
+ * Matches only `*.jsonl` (never the SQLite .db / -wal / -shm files). Returns the
212
+ * count deleted. Best-effort: individual unlink failures are logged, not thrown.
213
+ */
214
+ export function pruneExpiredLedgerJsonl(now: number = Date.now()): number {
215
+ const dir = getLedgerDir();
216
+ const jsonl = listDirFiles(dir).filter(f => f.path.endsWith('.jsonl'));
217
+ const expired = selectExpiredLedgerJsonl(jsonl, now);
218
+ let deleted = 0;
219
+ for (const f of expired) if (safeUnlink(f.path)) deleted++;
220
+ if (deleted > 0) LOG.info('DiskRetention', `Pruned ${deleted} JSONL ledger file(s) older than 30d`);
221
+ return deleted;
222
+ }
223
+
224
+ /**
225
+ * Prune expired `mesh-runtime.db.bak-*` backups under ~/.adhdev/mesh-ledger/.
226
+ * Never touches the live DB (only names matching the .bak- prefix). Returns count.
227
+ */
228
+ export function pruneExpiredDbBackups(now: number = Date.now()): number {
229
+ const dir = getLedgerDir();
230
+ const baks = listDirFiles(dir).filter(f => isDbBackupFileName(f.path.split(/[/\\]/).pop() || ''));
231
+ const expired = selectExpiredDbBackups(baks, now);
232
+ let deleted = 0;
233
+ for (const f of expired) if (safeUnlink(f.path)) deleted++;
234
+ if (deleted > 0) LOG.info('DiskRetention', `Pruned ${deleted} mesh-runtime.db.bak-* backup(s) older than 7d`);
235
+ return deleted;
236
+ }
237
+
238
+ /**
239
+ * Prune terminated session-host runtime files older than 14 days across every
240
+ * ~/.adhdev/session-host/<app>/runtimes/ directory. A LIVE runtime is never deleted
241
+ * regardless of age (isSessionHostLiveRuntime SSOT). Returns count deleted.
242
+ */
243
+ export function pruneExpiredSessionHostRuntimes(now: number = Date.now()): number {
244
+ const root = join(getConfigDir(), 'session-host');
245
+ if (!existsSync(root)) return 0;
246
+ let apps: string[];
247
+ try {
248
+ apps = readdirSync(root);
249
+ } catch {
250
+ return 0;
251
+ }
252
+ const candidates: SessionHostRuntimeFile[] = [];
253
+ for (const app of apps) {
254
+ const runtimesDir = join(root, app, 'runtimes');
255
+ if (!existsSync(runtimesDir)) continue;
256
+ for (const f of listDirFiles(runtimesDir)) {
257
+ if (!f.path.endsWith('.json')) continue;
258
+ let record: SessionHostRuntimeFile['record'] = null;
259
+ try {
260
+ const parsed = JSON.parse(readFileSync(f.path, 'utf-8'));
261
+ const rec = parsed && typeof parsed === 'object' ? parsed.record : null;
262
+ record = rec && typeof rec === 'object' ? (rec as SessionHostSurfaceRecordLike) : null;
263
+ } catch {
264
+ // Unparseable → treat as non-live; still age-gated by the selector,
265
+ // so a corrupt-but-fresh file is preserved.
266
+ record = null;
267
+ }
268
+ candidates.push({ path: f.path, mtimeMs: f.mtimeMs, record });
269
+ }
270
+ }
271
+ const expired = selectExpiredSessionHostRuntimes(candidates, now);
272
+ let deleted = 0;
273
+ for (const f of expired) if (safeUnlink(f.path)) deleted++;
274
+ if (deleted > 0) LOG.info('DiskRetention', `Pruned ${deleted} terminated session-host runtime file(s) older than 14d`);
275
+ return deleted;
276
+ }
277
+
278
+ /**
279
+ * Run the file-deleting retention passes (JSONL ledger, DB backups, session-host
280
+ * runtimes) once. Each pass is isolated so one failing pass never blocks the others.
281
+ * Orphan-worktree DETECTION is driven separately in the reconcile loop (it needs the
282
+ * live mesh config + git worktree list and emits a ledger signal rather than deleting).
283
+ */
284
+ export function runDiskRetentionSweep(now: number = Date.now()): { ledgerJsonl: number; dbBackups: number; sessionHostRuntimes: number } {
285
+ let ledgerJsonl = 0;
286
+ let dbBackups = 0;
287
+ let sessionHostRuntimes = 0;
288
+ try { ledgerJsonl = pruneExpiredLedgerJsonl(now); } catch (e: any) { LOG.warn('DiskRetention', `Ledger JSONL prune failed: ${e?.message || e}`); }
289
+ try { dbBackups = pruneExpiredDbBackups(now); } catch (e: any) { LOG.warn('DiskRetention', `DB backup prune failed: ${e?.message || e}`); }
290
+ try { sessionHostRuntimes = pruneExpiredSessionHostRuntimes(now); } catch (e: any) { LOG.warn('DiskRetention', `Session-host runtime prune failed: ${e?.message || e}`); }
291
+ return { ledgerJsonl, dbBackups, sessionHostRuntimes };
292
+ }
293
+
294
+ // ─── Orphan worktree detection (detection-only, emits cleanup_candidate) ──────
295
+
296
+ /** How many recent worktree_cleanup_candidate entries to scan for the re-emit guard. */
297
+ const ORPHAN_DEDUPE_WINDOW = 200;
298
+
299
+ /**
300
+ * Detect orphaned worktrees for ONE mesh and emit a `worktree_cleanup_candidate`
301
+ * ledger signal for each — DETECTION ONLY, never deletes. An orphan is a git worktree
302
+ * on disk with no matching live mesh node (compared by path). It:
303
+ * 1. picks a base (non-worktree) node owned by this mesh to anchor `git worktree list`;
304
+ * 2. diffs the reported worktrees against the union of every node's workspace/repoRoot;
305
+ * 3. skips the main worktree + bare entries (detectOrphanWorktrees safety);
306
+ * 4. suppresses a repeat for a worktreePath already signalled within the recent window
307
+ * (idempotent re-emit guard), so the hourly sweep doesn't spam the ledger.
308
+ * Returns the list of newly-signalled orphan paths. Best-effort: git/ledger failures are
309
+ * logged and yield an empty result, never thrown.
310
+ */
311
+ export async function detectAndSignalOrphanWorktrees(
312
+ mesh: LocalMeshEntry,
313
+ now: number = Date.now(),
314
+ ): Promise<string[]> {
315
+ const nodes = Array.isArray(mesh.nodes) ? mesh.nodes : [];
316
+ // Anchor: a base (non-worktree) node's repoRoot (preferred) or workspace. The base
317
+ // node is the primary checkout; its git dir enumerates every worktree of the repo.
318
+ const baseNode = nodes.find(n => !n.isLocalWorktree && (n.repoRoot || n.workspace));
319
+ const repoRoot = baseNode?.repoRoot || baseNode?.workspace;
320
+ if (!repoRoot) return []; // no local base checkout for this mesh on this daemon
321
+
322
+ let worktrees: WorktreePathLike[];
323
+ try {
324
+ worktrees = await listWorktrees(repoRoot);
325
+ } catch (e: any) {
326
+ LOG.warn('DiskRetention', `git worktree list failed for mesh ${mesh.id} (${repoRoot}): ${e?.message || e}`);
327
+ return [];
328
+ }
329
+ // git worktree list emits the main worktree first; treat it as the base repo.
330
+ const mainWorktreePath = worktrees[0]?.path || repoRoot;
331
+ const liveNodes: LiveNodeWorkspaceLike[] = nodes.map(n => ({ workspace: n.workspace, repoRoot: n.repoRoot }));
332
+ const orphans = detectOrphanWorktrees(worktrees, liveNodes, mainWorktreePath);
333
+ if (orphans.length === 0) return [];
334
+
335
+ // Re-emit guard: skip a worktreePath already signalled in the recent window so the
336
+ // hourly sweep is idempotent and does not flood the ledger with duplicates.
337
+ let recentPaths = new Set<string>();
338
+ try {
339
+ const recent = readLedgerEntries(mesh.id, { kind: ['worktree_cleanup_candidate'], tail: ORPHAN_DEDUPE_WINDOW });
340
+ for (const e of recent) {
341
+ const p = typeof e.payload?.worktreePath === 'string' ? e.payload.worktreePath : '';
342
+ if (p) recentPaths.add(p);
343
+ }
344
+ } catch {
345
+ recentPaths = new Set();
346
+ }
347
+
348
+ const signalled: string[] = [];
349
+ for (const wt of orphans) {
350
+ if (recentPaths.has(wt.path)) continue;
351
+ try {
352
+ appendLedgerEntry(mesh.id, {
353
+ kind: 'worktree_cleanup_candidate',
354
+ payload: {
355
+ worktreePath: wt.path,
356
+ reason: 'no_matching_live_node',
357
+ state: 'cleanup_candidate',
358
+ detectedAt: new Date(now).toISOString(),
359
+ },
360
+ });
361
+ signalled.push(wt.path);
362
+ } catch (e: any) {
363
+ LOG.warn('DiskRetention', `Failed to record orphan worktree signal for ${wt.path}: ${e?.message || e}`);
364
+ }
365
+ }
366
+ if (signalled.length > 0) {
367
+ LOG.info('DiskRetention', `Detected ${signalled.length} orphan worktree(s) for mesh ${mesh.id} (cleanup_candidate — NOT deleted): ${signalled.join(', ')}`);
368
+ }
369
+ return signalled;
370
+ }
@@ -88,6 +88,14 @@ export type MeshLedgerKind =
88
88
  // payload: { keys: string[], hasDestructive: boolean, result: 'injected'|'refused'|'error',
89
89
  // refused?: string, submits?: boolean, confirmDestructive?: boolean }
90
90
  | 'key_injection'
91
+ // Disk/worktree retention (mission 86def38d): DETECTION-ONLY signal that a git
92
+ // worktree present on disk has no matching live mesh node — an orphan cleanup
93
+ // candidate. The reconcile loop emits this so the coordinator can decide whether
94
+ // to remove it; retention NEVER auto-deletes a worktree (manual/coordinator-driven).
95
+ // Keyed by worktreePath so a re-emit for the same orphan is idempotent (a prior
96
+ // unresolved entry within the dedupe window suppresses the repeat).
97
+ // payload: { worktreePath, branch?, head?, reason: 'no_matching_live_node', state: 'cleanup_candidate' }
98
+ | 'worktree_cleanup_candidate'
91
99
  ;
92
100
 
93
101
  export interface MeshLedgerEntry {
@@ -317,6 +325,70 @@ export const OPERATING_NOTE_DEDUPE_WINDOW = 40;
317
325
  // a coordinator actually sees while still capping unbounded store growth.
318
326
  export const OPERATING_NOTE_KEEP_LATEST = 100;
319
327
 
328
+ // ─── Operating-note lifecycle: category TTL + expiry (read-side only) ──────────
329
+ // Minimal first cut of the operating-notes lifecycle. Expiry is READ/INJECTION
330
+ // side ONLY — the store prune (keep-latest-100 above) stays purely count-based
331
+ // and NEVER deletes by age, so audit history is preserved. isNoteExpired decides
332
+ // whether an UNPINNED note still rides into a coordinator prompt.
333
+ //
334
+ // Per-category retention (days). A category not listed here — including the
335
+ // uncategorized case — is durable (never expires). provider_quirk is durable
336
+ // because a runtime quirk stays true until the provider changes.
337
+ export const OPERATING_NOTE_CATEGORY_TTL_DAYS: Readonly<Record<string, number>> = {
338
+ recovery_lesson: 14,
339
+ pattern_to_avoid: 30,
340
+ // provider_quirk: durable (intentionally absent → never expires)
341
+ };
342
+
343
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
344
+
345
+ /**
346
+ * Shape isNoteExpired reads. Structural so mesh-ledger stays free of a
347
+ * coordinator-prompt import (CoordinatorOperatingNote satisfies this).
348
+ */
349
+ export interface OperatingNoteExpiryInput {
350
+ category?: string;
351
+ pinned?: boolean;
352
+ createdAt?: string;
353
+ /** Explicit expiry override; wins over the category TTL when parseable. */
354
+ expiresAt?: string;
355
+ /** Fallback creation time (ledger entry timestamp) when createdAt absent. */
356
+ timestamp?: string;
357
+ }
358
+
359
+ /**
360
+ * Pure helper: is this UNPINNED operating note expired as of `now` (epoch ms)?
361
+ *
362
+ * Rules:
363
+ * - pinned notes NEVER expire (always false).
364
+ * - an explicit, parseable `expiresAt` in the past → expired.
365
+ * - otherwise the category TTL applies; a durable category (provider_quirk,
366
+ * uncategorized, or any category not in the TTL map) never expires.
367
+ * - age is measured from createdAt, falling back to `timestamp` (ledger entry
368
+ * time). If neither is a valid date, the note is treated as NOT expired
369
+ * (never silently drop a note we cannot age).
370
+ */
371
+ export function isNoteExpired(note: OperatingNoteExpiryInput, now: number): boolean {
372
+ if (!note || note.pinned) return false;
373
+
374
+ // Explicit expiresAt wins when present and parseable.
375
+ if (typeof note.expiresAt === 'string') {
376
+ const exp = new Date(note.expiresAt).getTime();
377
+ if (!Number.isNaN(exp)) return exp <= now;
378
+ }
379
+
380
+ const ttlDays = note.category ? OPERATING_NOTE_CATEGORY_TTL_DAYS[note.category] : undefined;
381
+ if (typeof ttlDays !== 'number' || !Number.isFinite(ttlDays)) {
382
+ // Durable category (provider_quirk / uncategorized / unknown) → never expires.
383
+ return false;
384
+ }
385
+
386
+ const created = new Date(note.createdAt ?? note.timestamp ?? '').getTime();
387
+ if (Number.isNaN(created)) return false; // cannot age → keep
388
+
389
+ return now - created >= ttlDays * MS_PER_DAY;
390
+ }
391
+
320
392
  // ─── Path Helpers ───────────────────────────────
321
393
 
322
394
  export function getLedgerDir(): string {
@@ -76,6 +76,7 @@ import {
76
76
  resolveReconcileIntervalMs,
77
77
  } from './mesh-reconcile-config.js';
78
78
  import { pullRemoteNodeQueues } from './mesh-remote-event-pull.js';
79
+ import { runDiskRetentionSweep, detectAndSignalOrphanWorktrees } from './mesh-disk-retention.js';
79
80
  import {
80
81
  reconcileUnterminatedDirectDispatches,
81
82
  autoPruneStaleDirectDispatches,
@@ -126,6 +127,16 @@ interface LiveCoordinator {
126
127
  // "restart does not clear it" symptom the operator needs visibility into).
127
128
  const coordinatorModalParkState = new Map<string, boolean>();
128
129
 
130
+ // Disk/worktree retention throttle. The reconcile tick runs every ~4s, but the
131
+ // retention sweep (fs walks + git worktree list) is far too heavy for that cadence
132
+ // and its artifacts age in days, so it runs at most once per hour. `undefined`
133
+ // means "never run yet" → runs on the first tick after daemon start so a long-lived
134
+ // backlog is reclaimed promptly rather than an hour later. Per-process; a restart
135
+ // re-runs it immediately, which is the desired behavior (a restart is exactly when
136
+ // stale artifacts from the previous run should be swept).
137
+ const DISK_RETENTION_INTERVAL_MS = 60 * 60 * 1000; // 1h
138
+ let lastDiskRetentionRunAt: number | undefined;
139
+
129
140
  // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
130
141
  function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
131
142
  const out: LiveCoordinator[] = [];
@@ -1354,6 +1365,44 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1354
1365
  }
1355
1366
  }
1356
1367
 
1368
+ // ── PHASE 6: disk / worktree retention (hourly, mission 86def38d) ──────────
1369
+ // Legacy on-disk artifacts under ~/.adhdev accumulated with NO lifetime and grew
1370
+ // the data volume until a refine bootstrap failed at 98% disk. This throttled pass
1371
+ // (≤1×/hour — the artifacts age in days and the fs/git walk is too heavy for the 4s
1372
+ // tick) reclaims them:
1373
+ // • JSONL ledger files older than 30d (legacy after the SQLite ledger)
1374
+ // • terminated session-host runtime files older than 14d (live never touched)
1375
+ // • mesh-runtime.db.bak-* backups older than 7d
1376
+ // • orphan worktree DETECTION → cleanup_candidate ledger signal (NEVER deleted).
1377
+ // Runs BEFORE PHASE 2's early return so it fires whether or not a live CLI
1378
+ // coordinator exists on this daemon. Isolated in try/catch so it can never kill the
1379
+ // tick. All file deletions are defensive (age + live-runtime guards in the pure
1380
+ // selectors); worktree cleanup stays manual/coordinator-driven.
1381
+ {
1382
+ const nowMs = Date.now();
1383
+ const due = lastDiskRetentionRunAt === undefined
1384
+ || (nowMs - lastDiskRetentionRunAt) >= DISK_RETENTION_INTERVAL_MS;
1385
+ if (due) {
1386
+ lastDiskRetentionRunAt = nowMs;
1387
+ try {
1388
+ runDiskRetentionSweep(nowMs);
1389
+ } catch (e: any) {
1390
+ LOG.warn('MeshReconcile', `Disk retention sweep failed: ${e?.message || e}`);
1391
+ }
1392
+ // Orphan-worktree detection is per-mesh (needs the mesh's base repo + node set)
1393
+ // and only for meshes this daemon hosts (its local base checkout is the git anchor).
1394
+ for (const mesh of listMeshes()) {
1395
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
1396
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
1397
+ try {
1398
+ await detectAndSignalOrphanWorktrees(mesh, nowMs);
1399
+ } catch (e: any) {
1400
+ LOG.warn('MeshReconcile', `Orphan worktree detection failed for mesh ${mesh.id}: ${e?.message || e}`);
1401
+ }
1402
+ }
1403
+ }
1404
+ }
1405
+
1357
1406
  // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
1358
1407
  const coordinators = findLiveCoordinators(components);
1359
1408
  if (coordinators.length === 0) {
@@ -174,6 +174,27 @@ export class MeshRuntimeStore {
174
174
  this.instance = undefined;
175
175
  }
176
176
 
177
+ /**
178
+ * VACUUM the SQLite database to reclaim on-disk space. Retention prunes rows
179
+ * with DELETE, which frees pages inside the file but does NOT shrink it — the
180
+ * mesh-runtime.db grew to hundreds of MB (mission 86def38d disk-accumulation
181
+ * bootstrap failure) precisely because the file was never compacted. This
182
+ * rewrites the DB into a minimal footprint. Best-effort: a VACUUM failure (e.g.
183
+ * insufficient temp space, a read lock) is logged and swallowed so it can never
184
+ * block daemon shutdown. Called once on shutdown (see daemon-lifecycle), never
185
+ * on the hot path — VACUUM takes an exclusive lock and rewrites the whole file.
186
+ */
187
+ vacuum(): void {
188
+ try {
189
+ // Fold the WAL back into the main DB first so VACUUM reclaims those pages too.
190
+ try { this.db.pragma('wal_checkpoint(TRUNCATE)'); } catch { /* checkpoint best-effort */ }
191
+ this.db.exec('VACUUM;');
192
+ LOG.info('MeshRuntimeStore', 'VACUUM completed on shutdown');
193
+ } catch (err: any) {
194
+ LOG.warn('MeshRuntimeStore', `VACUUM on shutdown failed (ignored): ${err?.message || err}`);
195
+ }
196
+ }
197
+
177
198
  close(): void {
178
199
  this.db.close();
179
200
  }
@@ -120,6 +120,31 @@ export const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
120
120
  // met. Scoped (at the call site) to autonomous mesh sessions, so interactive sessions are
121
121
  // untouched.
122
122
  export const PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS = 1200;
123
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) Minimum PTY quiet dwell required before the
124
+ // COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) cap may RELEASE an antigravity `holdForTranscript`
125
+ // block into the forced weak completion. Antigravity is a native-source provider: its
126
+ // idle/generating verdict is PTY-screen-derived, but its assistant answer lands in
127
+ // native-history and can legitimately lag past 30s on a long turn (tool phase + slow reply).
128
+ // The 30s cap releases on ELAPSED TIME, not proof-of-idle — so on a long turn it fired a
129
+ // premature completed/idle to the coordinator while the PTY was still generating (live-repro:
130
+ // a routed RCA task emitted "completed" in 10s with no result). Require instead that the PTY
131
+ // has been quiet (no raw output) for at least this long AND the adapter reports no pending
132
+ // response before the cap may force-emit. A genuinely quiescent tool-only turn (no assistant
133
+ // bubble) has a stable screen well past this bound and still finalizes; a turn whose PTY is
134
+ // still producing output keeps holding past 30s (up to ANTIGRAVITY_HOLD_HARD_CAP_MS). Scoped
135
+ // (at the call site) to antigravity-cli holdForTranscript blocks, so claude-cli/codex-cli
136
+ // completion timing is untouched.
137
+ export const ANTIGRAVITY_HOLD_QUIET_DWELL_MS = 3000;
138
+ // (ANTIGRAVITY-30S-CAP-PREMATURE) Absolute upper bound on how long an antigravity
139
+ // `holdForTranscript` block may be held once the 30s cap is reached but the PTY is still
140
+ // active. The quiet-dwell gate above keeps holding while the PTY keeps emitting output, so a
141
+ // truly runaway turn (PTY never falls quiet) must still eventually release rather than wedge
142
+ // the session in generating forever — strictly worse than the original premature-emit bug.
143
+ // Once a pending completion has been held this long we force-emit regardless of PTY activity.
144
+ // Sized generously (5 min) so realistic long antigravity turns (multi-tool + slow reply)
145
+ // finish and let the transcript land / the PTY fall quiet naturally, while still guaranteeing
146
+ // eventual release. Mirrors BACKGROUND_TASK_HOLD_MAX_MS's escape-hatch rationale.
147
+ export const ANTIGRAVITY_HOLD_HARD_CAP_MS = 5 * 60_000;
123
148
  // (FALSE-IDLE-BACKGROUND-CMD) Hard cap on how long a pending completion may be HELD
124
149
  // solely because the claude-cli transcript still shows an unresolved run_in_background
125
150
  // bash job (backgroundTaskActive). The hold is the correct behaviour while the job is