@adhdev/daemon-core 1.0.18-rc.2 → 1.0.18-rc.20

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.
Files changed (82) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  3. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  4. package/dist/commands/cli-manager.d.ts +9 -0
  5. package/dist/commands/process-lifecycle.d.ts +43 -0
  6. package/dist/commands/upgrade-helper.d.ts +1 -0
  7. package/dist/commands/windows-atomic-upgrade.d.ts +17 -1
  8. package/dist/config/mesh-json-config.d.ts +62 -0
  9. package/dist/index.d.ts +4 -2
  10. package/dist/index.js +6674 -4237
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +6361 -3927
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  15. package/dist/mesh/mesh-active-work.d.ts +1 -1
  16. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  17. package/dist/mesh/mesh-ledger.d.ts +28 -1
  18. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  19. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  20. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  22. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  23. package/dist/providers/auto-approve-modes.d.ts +14 -0
  24. package/dist/providers/chat-message-normalization.d.ts +22 -0
  25. package/dist/providers/cli-provider-instance-types.d.ts +4 -0
  26. package/dist/providers/cli-provider-instance.d.ts +78 -0
  27. package/dist/providers/contracts.d.ts +17 -0
  28. package/dist/providers/provider-schema.d.ts +1 -0
  29. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  30. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  31. package/dist/repo-mesh-types.d.ts +38 -6
  32. package/dist/shared-types.d.ts +4 -2
  33. package/package.json +3 -3
  34. package/src/boot/daemon-lifecycle.ts +10 -0
  35. package/src/cli-adapters/cli-state-engine.ts +220 -3
  36. package/src/cli-adapters/provider-cli-adapter.ts +41 -11
  37. package/src/cli-adapters/provider-cli-shared.ts +48 -0
  38. package/src/cli-adapters/pty-transport.d.ts +2 -1
  39. package/src/cli-adapters/pty-transport.ts +1 -1
  40. package/src/cli-adapters/session-host-transport.ts +8 -3
  41. package/src/commands/cli-manager.ts +72 -8
  42. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  43. package/src/commands/high-family/mesh-events.ts +13 -2
  44. package/src/commands/med-family/mesh-crud.ts +155 -0
  45. package/src/commands/med-family/mesh-queue.ts +74 -1
  46. package/src/commands/process-lifecycle.ts +248 -0
  47. package/src/commands/router-refine.ts +71 -4
  48. package/src/commands/router.ts +8 -0
  49. package/src/commands/upgrade-helper.ts +106 -82
  50. package/src/commands/windows-atomic-upgrade.ts +281 -35
  51. package/src/config/mesh-json-config.ts +111 -0
  52. package/src/index.ts +21 -1
  53. package/src/mesh/coordinator-prompt.ts +258 -11
  54. package/src/mesh/mesh-active-work.ts +11 -1
  55. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  56. package/src/mesh/mesh-disk-retention.ts +370 -0
  57. package/src/mesh/mesh-event-classify.ts +19 -0
  58. package/src/mesh/mesh-event-forwarding.ts +64 -6
  59. package/src/mesh/mesh-events-utils.ts +42 -0
  60. package/src/mesh/mesh-ledger.ts +77 -0
  61. package/src/mesh/mesh-node-identity.ts +49 -0
  62. package/src/mesh/mesh-queue-assignment.ts +210 -11
  63. package/src/mesh/mesh-reconcile-loop.ts +306 -3
  64. package/src/mesh/mesh-refine-gates.ts +300 -0
  65. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  66. package/src/mesh/mesh-runtime-store.ts +21 -0
  67. package/src/mesh/mesh-work-queue.ts +85 -2
  68. package/src/providers/auto-approve-modes.ts +103 -0
  69. package/src/providers/chat-message-normalization.ts +53 -0
  70. package/src/providers/cli-provider-instance-types.ts +53 -0
  71. package/src/providers/cli-provider-instance.ts +497 -15
  72. package/src/providers/contracts.ts +25 -1
  73. package/src/providers/provider-schema.ts +83 -0
  74. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  75. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  76. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  77. package/src/providers/types/interactive-prompt.ts +77 -0
  78. package/src/repo-mesh-types.ts +112 -12
  79. package/src/session-host/managed-host.ts +34 -0
  80. package/src/shared-types.ts +9 -1
  81. package/src/status/reporter.ts +1 -0
  82. package/src/status/snapshot.ts +2 -0
@@ -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
+ }
@@ -8,6 +8,11 @@ const MESH_COORDINATOR_EVENTS = new Set([
8
8
  'agent:generating_started',
9
9
  'agent:generating_completed',
10
10
  'agent:waiting_approval',
11
+ // A worker parked on an AskUserQuestion multi-choice prompt. DISTINCT from
12
+ // agent:waiting_approval (a yes/no tool-consent modal): a question is answered
13
+ // with mesh_answer_question, never mesh_approve (mission f1d25e11). Carries the
14
+ // full InteractivePrompt payload so the coordinator can render + answer.
15
+ 'agent:waiting_choice',
11
16
  'agent:stopped',
12
17
  'agent:ready',
13
18
  'monitor:no_progress',
@@ -26,6 +31,7 @@ const MESH_COORDINATOR_EVENTS = new Set([
26
31
  export const EVENT_TO_LEDGER_KIND: Record<string, MeshLedgerKind> = {
27
32
  'agent:generating_completed': 'task_completed',
28
33
  'agent:waiting_approval': 'task_approval_needed',
34
+ 'agent:waiting_choice': 'task_question_pending',
29
35
  'agent:stopped': 'task_failed',
30
36
  'monitor:no_progress': 'task_stalled',
31
37
  };
@@ -45,6 +51,11 @@ export const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string> = new Set([
45
51
  'agent:generating_completed',
46
52
  'agent:stopped',
47
53
  'agent:waiting_approval',
54
+ // A worker's question (waiting_choice) is real-time and the coordinator may be
55
+ // generating when it arrives; force-inject it like approval so the busy coordinator
56
+ // learns it must answer (mesh_answer_question) rather than the nudge sitting in the
57
+ // adapter's outbound queue until the coordinator idles on its own (mission f1d25e11).
58
+ 'agent:waiting_choice',
48
59
  'refine:completed',
49
60
  'refine:failed',
50
61
  'worktree_bootstrap_complete',
@@ -71,6 +82,14 @@ export function shouldForceInjectMeshEvent(eventName: unknown): boolean {
71
82
  // require, and why a stale/resolved approval nudge can simply be dropped.
72
83
  export const MESH_APPROVAL_EVENTS: ReadonlySet<string> = new Set([
73
84
  'agent:waiting_approval',
85
+ // agent:waiting_choice (a worker's AskUserQuestion) shares the approval class's
86
+ // real-time-nudge / level-backed contract: its authoritative state is recorded at
87
+ // LEVEL the moment it is processed (task_question_pending → mesh_status
88
+ // awaiting_choice), so the pending event is only a NUDGE and can be delivered to a
89
+ // busy coordinator's inbox (and dropped) without data loss — the level state
90
+ // re-derives it. It is therefore exempt from the idle-edge hold completions require,
91
+ // exactly like an approval nudge (mission f1d25e11).
92
+ 'agent:waiting_choice',
74
93
  ]);
75
94
 
76
95
  export function isMeshApprovalEvent(eventName: unknown): boolean {
@@ -16,7 +16,8 @@ import { resolveMeshHostStatus } from './mesh-host-ownership.js';
16
16
  import { enqueueUnresolvedDelegateForward, nudgeUnresolvedForwardRetry } from './mesh-unresolved-forward-outbox.js';
17
17
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
18
18
  import { getLastDisplayMessage } from '../status/snapshot.js';
19
- import { resolveDelegatedWorkerAutoApprove } from '../repo-mesh-types.js';
19
+ import { delegatedWorkerAutoApproveSettings } from '../repo-mesh-types.js';
20
+ import { loadRepoMeshJsonConfig } from '../config/mesh-json-config.js';
20
21
  import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, sessionIdsEquivalent, withStatusProbeMarker, type MeshNodeIdentified } from '@adhdev/mesh-shared';
21
22
  import {
22
23
  findRecentTerminalLedgerEvidence,
@@ -1071,10 +1072,44 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1071
1072
  // C2: prefer an exact taskId match when the completion event carries one —
1072
1073
  // it's immune to coordinator↔worker clock skew that can hide the assigned row.
1073
1074
  const eventTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
1074
- const task = updateSessionTaskStatus(args.meshId, sessionId, outcome, {
1075
- occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1076
- taskId: eventTaskId,
1077
- });
1075
+ // WEAK-QUEUE-TENTATIVE (early-completed safety net, symmetric with the direct-dispatch
1076
+ // `tentativeIfDirect` guard below): a `completed` outcome whose evidence is WEAK — a
1077
+ // false-idle / missing_final_assistant emit (the CANON-C decoupled-immediate path that
1078
+ // stamps evidenceLevel=insufficient) — must NOT hard-flip a matched QUEUE row to
1079
+ // terminal. Until this net, only DIRECT dispatches were kept tentative on weak evidence;
1080
+ // a queue-claimed task's session flipped its row to 'completed' unconditionally, so a
1081
+ // worker that dropped to idle ~13s in with no final assistant closed the task early even
1082
+ // though it never produced an answer. Leaving the row 'assigned' hands it to the reconcile
1083
+ // loop's proven net (PHASE 4 records the GENUINE completion once the transcript lands via
1084
+ // findTerminalLedgerEvidenceForTask; PHASE 2.5 reclaims a truly-stranded row back to
1085
+ // 'pending' for re-dispatch, bounded by MAX_STRANDED_RECLAIMS) — never a permanent wedge.
1086
+ //
1087
+ // CRUCIAL SCOPE: this covers only the PREMATURE decoupled-immediate emit
1088
+ // (emittedAfterFinalizationTimeout !== true). A weak completion that already waited out the
1089
+ // full 30s COMPLETED_FINALIZATION_MAX_WAIT_MS window (emittedAfterFinalizationTimeout=true)
1090
+ // is a GENUINE — if answerless — terminal: the worker exhausted its finalization wait and
1091
+ // is done, so it flips 'completed' as before (a tool-only turn that never produced an
1092
+ // assistant bubble must still close its task). A `failed` outcome or a completion with
1093
+ // genuine evidence flips terminal as before too.
1094
+ const completionDiagnostic = args.metadataEvent.completionDiagnostic && typeof args.metadataEvent.completionDiagnostic === 'object'
1095
+ ? args.metadataEvent.completionDiagnostic as Record<string, unknown>
1096
+ : undefined;
1097
+ const emittedAfterFinalizationTimeout = completionDiagnostic?.emittedAfterFinalizationTimeout === true;
1098
+ const weakCompleted = outcome === 'completed'
1099
+ && isWeakCompletionEvidence(args.metadataEvent)
1100
+ && !emittedAfterFinalizationTimeout;
1101
+ const task = weakCompleted
1102
+ ? updateSessionTaskStatus(args.meshId, sessionId, 'assigned', {
1103
+ occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1104
+ taskId: eventTaskId,
1105
+ })
1106
+ : updateSessionTaskStatus(args.meshId, sessionId, outcome, {
1107
+ occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
1108
+ taskId: eventTaskId,
1109
+ });
1110
+ if (weakCompleted && task) {
1111
+ LOG.info('MeshQueue', `Weak completion (${readNonEmptyString(args.metadataEvent.evidenceLevel) || 'missing_final_assistant'}) kept queue task ${task.id} tentative (session ${sessionId}); reconcile owns the genuine terminal`);
1112
+ }
1078
1113
  // Fix A (early-terminal prevention): a false-idle completion (no confirmed final
1079
1114
  // assistant) for a DIRECT dispatch — i.e. no work-queue row matched — must not flip the
1080
1115
  // dispatch row terminal. Leaving it active lets the reconcile loop (PHASE 4) re-read the
@@ -1580,7 +1615,22 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1580
1615
  spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
1581
1616
  // Coordinator-dispatched recovery relaunch: same auto-approve
1582
1617
  // policy as the primary worker launch path.
1583
- autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
1618
+ ...delegatedWorkerAutoApproveSettings(
1619
+ mesh?.policy,
1620
+ node?.policy,
1621
+ components.providerLoader?.getMeta(recoveryContext.failedProviderType),
1622
+ // Recovery relaunch: same repo-declared requested mode as the
1623
+ // primary path. node.workspace missing → null → provider default.
1624
+ (() => {
1625
+ const ws = typeof node?.workspace === 'string' && node.workspace.trim() ? node.workspace.trim() : '';
1626
+ if (!ws) return null;
1627
+ try {
1628
+ const r = loadRepoMeshJsonConfig(ws);
1629
+ return r.sourceType === 'repo_file' && r.config ? r.config : null;
1630
+ } catch { return null; }
1631
+ })(),
1632
+ recoveryContext.failedProviderType,
1633
+ ),
1584
1634
  launchedByCoordinator: true,
1585
1635
  }
1586
1636
  }).catch((e: any) => LOG.error('MeshRecovery', `Failed to auto-relaunch session for ${node.id}: ${e?.message}`));
@@ -1754,6 +1804,14 @@ export function buildRelayMetadataEvent(payload: Record<string, unknown>): Recor
1754
1804
  retryOfJobId: readNonEmptyString(payload.retryOfJobId),
1755
1805
  ...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
1756
1806
  ...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
1807
+ // agent:waiting_choice (mission f1d25e11): carry the FULL structured question
1808
+ // payload across the machine boundary so a REMOTE worker's AskUserQuestion reaches
1809
+ // the coordinator with every question + option intact — the coordinator renders
1810
+ // these and answers with mesh_answer_question. The local in-process forward path
1811
+ // preserves the whole event for free; this mirrors the fields for the remote relay.
1812
+ ...(payload.interactivePrompt && typeof payload.interactivePrompt === 'object' && !Array.isArray(payload.interactivePrompt) ? { interactivePrompt: payload.interactivePrompt } : {}),
1813
+ ...(readNonEmptyString(payload.promptId) ? { promptId: readNonEmptyString(payload.promptId) } : {}),
1814
+ ...(payload.multiSelect === true ? { multiSelect: true } : {}),
1757
1815
  ...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
1758
1816
  ...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
1759
1817
  ...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
@@ -330,6 +330,48 @@ export function buildMeshSystemMessage(args: {
330
330
  if (args.event === 'agent:waiting_approval') {
331
331
  return `[System] ${args.nodeLabel} is waiting for approval to proceed${metadata}. You may use mesh_read_chat and mesh_approve to handle it.`;
332
332
  }
333
+ if (args.event === 'agent:waiting_choice') {
334
+ // A multi-choice QUESTION (AskUserQuestion) — NOT an approval. It is answered with
335
+ // mesh_answer_question (never mesh_approve). Surface the question text + choices
336
+ // inline so the coordinator can decide without a mesh_read_chat round-trip, and
337
+ // name the correct tool + promptId (mission f1d25e11).
338
+ const prompt = args.metadataEvent.interactivePrompt as
339
+ | { promptId?: unknown; questions?: Array<Record<string, unknown>> }
340
+ | undefined;
341
+ const promptId = readNonEmptyString(args.metadataEvent.promptId)
342
+ || (prompt && readNonEmptyString(prompt.promptId));
343
+ const lines: string[] = [
344
+ `[System] ${args.nodeLabel} is asking a question and is waiting for your answer${metadata}.`,
345
+ ];
346
+ const questions = Array.isArray(prompt?.questions) ? prompt!.questions! : [];
347
+ if (questions.length > 0) {
348
+ for (const q of questions) {
349
+ const header = readNonEmptyString(q.header);
350
+ const question = readNonEmptyString(q.question);
351
+ const multiSelect = q.multiSelect === true;
352
+ if (question) {
353
+ lines.push(`\n**${header ? `${header}: ` : ''}${question}**${multiSelect ? ' (select one or more)' : ''}`);
354
+ }
355
+ const options = Array.isArray(q.options) ? q.options : [];
356
+ options.forEach((opt, i) => {
357
+ const record = (opt && typeof opt === 'object') ? opt as Record<string, unknown> : {};
358
+ const label = readNonEmptyString(record.label);
359
+ if (!label) return;
360
+ const description = readNonEmptyString(record.description);
361
+ lines.push(` ${i + 1}. ${label}${description ? ` — ${description}` : ''}`);
362
+ });
363
+ }
364
+ } else {
365
+ const modalMessage = readNonEmptyString(args.metadataEvent.modalMessage);
366
+ if (modalMessage) lines.push(`\n${modalMessage}`);
367
+ }
368
+ lines.push(
369
+ `\nAnswer with mesh_answer_question(node_id, session_id${promptId ? `, promptId: "${promptId}"` : ''}, answers). ` +
370
+ `Do NOT use mesh_approve — that only resolves yes/no consent modals, not a question. ` +
371
+ `Use mesh_read_chat once if you need the full context first.`,
372
+ );
373
+ return lines.join('\n');
374
+ }
333
375
  if (args.event === 'agent:stopped') {
334
376
  const rc = args.recoveryContext;
335
377
  if (rc && rc.consecutiveNodeFailures > 0) {
@@ -33,6 +33,11 @@ export type MeshLedgerKind =
33
33
  | 'task_failed'
34
34
  | 'task_stalled'
35
35
  | 'task_approval_needed'
36
+ // A worker is parked on an AskUserQuestion multi-choice prompt (waiting_choice) —
37
+ // distinct from task_approval_needed (a yes/no tool-consent modal). The coordinator
38
+ // answers a question with mesh_answer_question, never mesh_approve (mission f1d25e11).
39
+ // payload carries the full InteractivePrompt (promptId + questions + options).
40
+ | 'task_question_pending'
36
41
  | 'p2p_dispatch_failed'
37
42
  | 'session_launched'
38
43
  | 'session_auto_launch'
@@ -88,6 +93,14 @@ export type MeshLedgerKind =
88
93
  // payload: { keys: string[], hasDestructive: boolean, result: 'injected'|'refused'|'error',
89
94
  // refused?: string, submits?: boolean, confirmDestructive?: boolean }
90
95
  | 'key_injection'
96
+ // Disk/worktree retention (mission 86def38d): DETECTION-ONLY signal that a git
97
+ // worktree present on disk has no matching live mesh node — an orphan cleanup
98
+ // candidate. The reconcile loop emits this so the coordinator can decide whether
99
+ // to remove it; retention NEVER auto-deletes a worktree (manual/coordinator-driven).
100
+ // Keyed by worktreePath so a re-emit for the same orphan is idempotent (a prior
101
+ // unresolved entry within the dedupe window suppresses the repeat).
102
+ // payload: { worktreePath, branch?, head?, reason: 'no_matching_live_node', state: 'cleanup_candidate' }
103
+ | 'worktree_cleanup_candidate'
91
104
  ;
92
105
 
93
106
  export interface MeshLedgerEntry {
@@ -317,6 +330,70 @@ export const OPERATING_NOTE_DEDUPE_WINDOW = 40;
317
330
  // a coordinator actually sees while still capping unbounded store growth.
318
331
  export const OPERATING_NOTE_KEEP_LATEST = 100;
319
332
 
333
+ // ─── Operating-note lifecycle: category TTL + expiry (read-side only) ──────────
334
+ // Minimal first cut of the operating-notes lifecycle. Expiry is READ/INJECTION
335
+ // side ONLY — the store prune (keep-latest-100 above) stays purely count-based
336
+ // and NEVER deletes by age, so audit history is preserved. isNoteExpired decides
337
+ // whether an UNPINNED note still rides into a coordinator prompt.
338
+ //
339
+ // Per-category retention (days). A category not listed here — including the
340
+ // uncategorized case — is durable (never expires). provider_quirk is durable
341
+ // because a runtime quirk stays true until the provider changes.
342
+ export const OPERATING_NOTE_CATEGORY_TTL_DAYS: Readonly<Record<string, number>> = {
343
+ recovery_lesson: 14,
344
+ pattern_to_avoid: 30,
345
+ // provider_quirk: durable (intentionally absent → never expires)
346
+ };
347
+
348
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
349
+
350
+ /**
351
+ * Shape isNoteExpired reads. Structural so mesh-ledger stays free of a
352
+ * coordinator-prompt import (CoordinatorOperatingNote satisfies this).
353
+ */
354
+ export interface OperatingNoteExpiryInput {
355
+ category?: string;
356
+ pinned?: boolean;
357
+ createdAt?: string;
358
+ /** Explicit expiry override; wins over the category TTL when parseable. */
359
+ expiresAt?: string;
360
+ /** Fallback creation time (ledger entry timestamp) when createdAt absent. */
361
+ timestamp?: string;
362
+ }
363
+
364
+ /**
365
+ * Pure helper: is this UNPINNED operating note expired as of `now` (epoch ms)?
366
+ *
367
+ * Rules:
368
+ * - pinned notes NEVER expire (always false).
369
+ * - an explicit, parseable `expiresAt` in the past → expired.
370
+ * - otherwise the category TTL applies; a durable category (provider_quirk,
371
+ * uncategorized, or any category not in the TTL map) never expires.
372
+ * - age is measured from createdAt, falling back to `timestamp` (ledger entry
373
+ * time). If neither is a valid date, the note is treated as NOT expired
374
+ * (never silently drop a note we cannot age).
375
+ */
376
+ export function isNoteExpired(note: OperatingNoteExpiryInput, now: number): boolean {
377
+ if (!note || note.pinned) return false;
378
+
379
+ // Explicit expiresAt wins when present and parseable.
380
+ if (typeof note.expiresAt === 'string') {
381
+ const exp = new Date(note.expiresAt).getTime();
382
+ if (!Number.isNaN(exp)) return exp <= now;
383
+ }
384
+
385
+ const ttlDays = note.category ? OPERATING_NOTE_CATEGORY_TTL_DAYS[note.category] : undefined;
386
+ if (typeof ttlDays !== 'number' || !Number.isFinite(ttlDays)) {
387
+ // Durable category (provider_quirk / uncategorized / unknown) → never expires.
388
+ return false;
389
+ }
390
+
391
+ const created = new Date(note.createdAt ?? note.timestamp ?? '').getTime();
392
+ if (Number.isNaN(created)) return false; // cannot age → keep
393
+
394
+ return now - created >= ttlDays * MS_PER_DAY;
395
+ }
396
+
320
397
  // ─── Path Helpers ───────────────────────────────
321
398
 
322
399
  export function getLedgerDir(): string {