byterover-cli 3.11.0 → 3.12.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.
Files changed (43) hide show
  1. package/dist/agent/infra/tools/implementations/curate-tool.js +18 -8
  2. package/dist/server/constants.d.ts +6 -0
  3. package/dist/server/constants.js +11 -0
  4. package/dist/server/core/domain/entities/task-history-entry.d.ts +775 -0
  5. package/dist/server/core/domain/entities/task-history-entry.js +88 -0
  6. package/dist/server/core/domain/transport/schemas.d.ts +1403 -11
  7. package/dist/server/core/domain/transport/schemas.js +157 -6
  8. package/dist/server/core/domain/transport/task-info.d.ts +18 -0
  9. package/dist/server/core/interfaces/process/i-task-lifecycle-hook.d.ts +7 -0
  10. package/dist/server/core/interfaces/storage/i-task-history-store.d.ts +62 -0
  11. package/dist/server/core/interfaces/storage/i-task-history-store.js +1 -0
  12. package/dist/server/infra/daemon/brv-server.js +43 -18
  13. package/dist/server/infra/dream/dream-response-schemas.d.ts +24 -0
  14. package/dist/server/infra/dream/dream-response-schemas.js +7 -0
  15. package/dist/server/infra/dream/operations/consolidate.js +21 -8
  16. package/dist/server/infra/dream/operations/synthesize.js +35 -8
  17. package/dist/server/infra/process/task-history-entry-builder.d.ts +36 -0
  18. package/dist/server/infra/process/task-history-entry-builder.js +101 -0
  19. package/dist/server/infra/process/task-history-hook.d.ts +37 -0
  20. package/dist/server/infra/process/task-history-hook.js +70 -0
  21. package/dist/server/infra/process/task-history-store-cache.d.ts +25 -0
  22. package/dist/server/infra/process/task-history-store-cache.js +106 -0
  23. package/dist/server/infra/process/task-router.d.ts +72 -0
  24. package/dist/server/infra/process/task-router.js +690 -15
  25. package/dist/server/infra/process/transport-handlers.d.ts +8 -0
  26. package/dist/server/infra/process/transport-handlers.js +2 -0
  27. package/dist/server/infra/storage/file-task-history-store.d.ts +294 -0
  28. package/dist/server/infra/storage/file-task-history-store.js +912 -0
  29. package/dist/shared/transport/events/index.d.ts +5 -0
  30. package/dist/shared/transport/events/task-events.d.ts +204 -1
  31. package/dist/shared/transport/events/task-events.js +11 -0
  32. package/dist/tui/features/tasks/hooks/use-task-subscriptions.js +7 -0
  33. package/dist/tui/features/tasks/stores/tasks-store.d.ts +4 -16
  34. package/dist/tui/features/tasks/stores/tasks-store.js +7 -0
  35. package/dist/tui/types/messages.d.ts +2 -9
  36. package/dist/webui/assets/index-DyVvFoM6.css +1 -0
  37. package/dist/webui/assets/index-lr0byHh9.js +130 -0
  38. package/dist/webui/index.html +2 -2
  39. package/dist/webui/sw.js +1 -1
  40. package/oclif.manifest.json +665 -665
  41. package/package.json +1 -1
  42. package/dist/webui/assets/index--sXE__bc.css +0 -1
  43. package/dist/webui/assets/index-Bkkx961b.js +0 -130
@@ -0,0 +1,912 @@
1
+ /**
2
+ * File-based implementation of `ITaskHistoryStore`.
3
+ *
4
+ * Two-tier on-disk format:
5
+ * - `_index.jsonl` — append-only summary file. One JSON-line per save.
6
+ * Cheap to scan for list views.
7
+ * - `data/tsk-${taskId}.json` — full Level 2 detail per task (response,
8
+ * tool calls, reasoning) for the detail panel.
9
+ *
10
+ * Save ordering (data first, index second) bounds the failure mode to
11
+ * orphan data files (invisible to `list()`); compaction in M2.03 will
12
+ * sweep them. The reverse order would create dangling index entries
13
+ * pointing at non-existent data files.
14
+ *
15
+ * M2.02 scope: save / getById / list. Prune (M2.03), stale recovery
16
+ * (M2.04), and delete/clear (M2.05) land in subsequent tickets.
17
+ */
18
+ import { randomUUID } from 'node:crypto';
19
+ import { appendFile, copyFile, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import { z } from 'zod';
22
+ import { TASK_HISTORY_DEFAULT_MAX_AGE_DAYS, TASK_HISTORY_DEFAULT_MAX_ENTRIES, TASK_HISTORY_DEFAULT_MAX_INDEX_BLOAT_RATIO, TASK_HISTORY_DIR, TASK_HISTORY_STALE_THRESHOLD_MS, } from '../../constants.js';
23
+ import { TASK_HISTORY_SCHEMA_VERSION, TaskHistoryEntrySchema } from '../../core/domain/entities/task-history-entry.js';
24
+ import { TaskErrorDataSchema } from '../../core/domain/transport/schemas.js';
25
+ import { transportLog } from '../../utils/process-logger.js';
26
+ const STATUS_VALUES = ['cancelled', 'completed', 'created', 'error', 'started'];
27
+ const StatusSchema = z.enum(STATUS_VALUES);
28
+ /** `clear()` default — terminal statuses only; active tasks (created/started) are preserved. */
29
+ const DEFAULT_TERMINAL_STATUSES = ['cancelled', 'completed', 'error'];
30
+ /**
31
+ * Index summary line — one per save. Drops heavy fields (responseContent,
32
+ * toolCalls, reasoningContents, sessionId, result) that the list view
33
+ * never renders. Detail panel fetches those via `getById`.
34
+ */
35
+ const IndexDataLineSchema = z.object({
36
+ completedAt: z.number().optional(),
37
+ content: z.string(),
38
+ createdAt: z.number(),
39
+ error: TaskErrorDataSchema.optional(),
40
+ files: z.array(z.string()).optional(),
41
+ folderPath: z.string().optional(),
42
+ /**
43
+ * Wall-clock time the line was appended to `_index.jsonl`. Optional for
44
+ * back-compat with legacy lines (treated as `undefined` → eligible for
45
+ * stale recovery if otherwise stale by age). Used by the daemon-startup
46
+ * gate to skip recovery for entries written by the CURRENT daemon
47
+ * (post-boot), which by definition belong to in-memory active tasks.
48
+ */
49
+ lastSavedAt: z.number().optional(),
50
+ model: z.string().optional(),
51
+ projectPath: z.string(),
52
+ provider: z.string().optional(),
53
+ schemaVersion: z.literal(TASK_HISTORY_SCHEMA_VERSION),
54
+ startedAt: z.number().optional(),
55
+ status: StatusSchema,
56
+ taskId: z.string(),
57
+ type: z.string(),
58
+ });
59
+ /**
60
+ * Tombstone written by delete/deleteMany/clear. List skips taskIds whose final line is a tombstone.
61
+ * `deletedAt` and `schemaVersion` are optional for back-compat with bare `{_deleted, taskId}` lines
62
+ * that may exist in older indexes or be appended by tests.
63
+ */
64
+ const IndexTombstoneSchema = z.object({
65
+ _deleted: z.literal(true),
66
+ deletedAt: z.number().optional(),
67
+ schemaVersion: z.literal(TASK_HISTORY_SCHEMA_VERSION).optional(),
68
+ taskId: z.string(),
69
+ });
70
+ const IndexLineSchema = z.union([IndexTombstoneSchema, IndexDataLineSchema]);
71
+ /** Path-traversal guard. taskIds are typically UUIDs; restrict to alphanumeric + underscore + hyphen. */
72
+ const TASK_ID_PATTERN = /^[\w-]+$/;
73
+ const INDEX_FILE = '_index.jsonl';
74
+ const DATA_DIR = 'data';
75
+ const FILENAME_PREFIX = 'tsk-';
76
+ /**
77
+ * Per-`appendFile`-call byte ceiling. 3.5 KB leaves comfortable headroom under
78
+ * the historical 4 KB PIPE_BUF threshold that most kernels still honor for
79
+ * single-syscall write atomicity to regular files in append mode.
80
+ */
81
+ const MAX_APPEND_CHUNK_BYTES = 3584;
82
+ /**
83
+ * Group a list of pre-serialized index lines into batches whose total UTF-8
84
+ * byte length stays under `maxBytes`. A single line larger than `maxBytes` is
85
+ * emitted as its own batch (we never split a line — the caller relies on
86
+ * line-level atomicity).
87
+ *
88
+ * Exported for direct unit-testing of the chunk boundaries.
89
+ */
90
+ export function chunkLinesByBytes(lines, maxBytes) {
91
+ const chunks = [];
92
+ let current = [];
93
+ let currentBytes = 0;
94
+ for (const line of lines) {
95
+ const lineBytes = Buffer.byteLength(line, 'utf8');
96
+ if (current.length > 0 && currentBytes + lineBytes > maxBytes) {
97
+ chunks.push(current);
98
+ current = [];
99
+ currentBytes = 0;
100
+ }
101
+ current.push(line);
102
+ currentBytes += lineBytes;
103
+ }
104
+ if (current.length > 0)
105
+ chunks.push(current);
106
+ return chunks;
107
+ }
108
+ export class FileTaskHistoryStore {
109
+ daemonStartedAt;
110
+ dataDir;
111
+ /** Dedup-by-taskId result of the last index read. Invalidated on save/delete/recovery/prune. */
112
+ indexCache;
113
+ /**
114
+ * In-flight `readIndexDedup` promise — concurrent callers (e.g. `getById`
115
+ * and a parallel `prune` timer) share the same pass so the embedded
116
+ * stale-recovery side effect cannot double-append index lines for the
117
+ * same taskId.
118
+ */
119
+ indexDedupInFlight;
120
+ /**
121
+ * Monotonic counter bumped on every write to the index (save / tombstone /
122
+ * recovery / compaction). `doReadIndexDedup` samples it at start and at
123
+ * end of its pass; if the epoch advanced during the pass, the snapshot
124
+ * the pass built is stale relative to disk and MUST NOT replace the
125
+ * `indexCache`. Without this guard, a `firePrune` pass that began before
126
+ * a concurrent `save()` can finish AFTER that save's cache invalidation
127
+ * and overwrite the invalidation with a snapshot missing the just-saved
128
+ * row — visible to callers as "list() returns N-1 entries after N awaited
129
+ * saves" (the Category B race).
130
+ */
131
+ indexEpoch = 0;
132
+ indexPath;
133
+ maxAgeDays;
134
+ maxEntries;
135
+ maxIndexBloatRatio;
136
+ /**
137
+ * Promise-chain lock serializing operations that mutate the index file
138
+ * AND the data dir together — compaction (snapshot → rewrite → post-rewrite
139
+ * recovery → orphan sweep) and tombstoneAndUnlink (append + unlink). These
140
+ * two operations cannot interleave: a tombstone landing mid-rewrite would
141
+ * be wiped by `rename`, and `recoverPreRenameSaves` cannot recover the
142
+ * tombstone (the data file has been unlinked) — leaving a phantom row
143
+ * (B2). `save()` is intentionally NOT locked: its data file persists, so
144
+ * the C1 fix recovers from the data dir if the index line is wiped.
145
+ */
146
+ operationLock = Promise.resolve();
147
+ /**
148
+ * Promise of the most recently scheduled prune+compaction pass. Each
149
+ * `firePrune()` call wraps its `setTimeout` in a promise and assigns it
150
+ * here; re-entrance via `pruneRequested` extends the chain through the
151
+ * tail-recursive `firePrune()` in the `.finally`. Tests await on this via
152
+ * `flushPendingOperations()` to drain pending work deterministically
153
+ * instead of polling.
154
+ */
155
+ pruneChain = Promise.resolve();
156
+ /** Dedupes concurrent prune passes — only one runs at a time. */
157
+ pruneInFlight = false;
158
+ /** Set when a save fires while a prune is in flight; triggers a re-run after current pass. */
159
+ pruneRequested = false;
160
+ staleThresholdMs;
161
+ storeDir;
162
+ constructor(opts) {
163
+ this.storeDir = join(opts.baseDir, TASK_HISTORY_DIR);
164
+ this.indexPath = join(this.storeDir, INDEX_FILE);
165
+ this.dataDir = join(this.storeDir, DATA_DIR);
166
+ this.daemonStartedAt = opts.daemonStartedAt ?? Date.now();
167
+ this.maxAgeDays = opts.maxAgeDays ?? TASK_HISTORY_DEFAULT_MAX_AGE_DAYS;
168
+ this.maxEntries = opts.maxEntries ?? TASK_HISTORY_DEFAULT_MAX_ENTRIES;
169
+ this.maxIndexBloatRatio = opts.maxIndexBloatRatio ?? TASK_HISTORY_DEFAULT_MAX_INDEX_BLOAT_RATIO;
170
+ this.staleThresholdMs = opts.staleThresholdMs ?? TASK_HISTORY_STALE_THRESHOLD_MS;
171
+ }
172
+ // ── Delete + clear (M2.05) ─────────────────────────────────────────────────
173
+ async clear(options = {}) {
174
+ const { projectPath, statuses = DEFAULT_TERMINAL_STATUSES } = options;
175
+ const dedup = await this.readIndexDedup();
176
+ const targets = [];
177
+ for (const line of dedup.values()) {
178
+ if ('_deleted' in line)
179
+ continue;
180
+ if (projectPath !== undefined && line.projectPath !== projectPath)
181
+ continue;
182
+ if (!statuses.includes(line.status))
183
+ continue;
184
+ targets.push(line.taskId);
185
+ }
186
+ if (targets.length > 0)
187
+ await this.tombstoneAndUnlink(targets);
188
+ return { deletedCount: targets.length, taskIds: targets };
189
+ }
190
+ async delete(taskId) {
191
+ if (!TASK_ID_PATTERN.test(taskId))
192
+ return false;
193
+ const dedup = await this.readIndexDedup();
194
+ const line = dedup.get(taskId);
195
+ const wasLive = line !== undefined && !('_deleted' in line);
196
+ if (!wasLive)
197
+ return false;
198
+ await this.tombstoneAndUnlink([taskId]);
199
+ return true;
200
+ }
201
+ async deleteMany(taskIds) {
202
+ const valid = taskIds.filter((id) => TASK_ID_PATTERN.test(id));
203
+ if (valid.length === 0)
204
+ return [];
205
+ const dedup = await this.readIndexDedup();
206
+ const live = valid.filter((id) => {
207
+ const line = dedup.get(id);
208
+ return line !== undefined && !('_deleted' in line);
209
+ });
210
+ if (live.length > 0)
211
+ await this.tombstoneAndUnlink(live);
212
+ return live;
213
+ }
214
+ /**
215
+ * Deterministically wait for all in-flight prune/compaction work to drain.
216
+ * Loops because each prune pass may schedule the next via `pruneRequested`,
217
+ * which `firePrune()` reschedules at the tail of `pruneChain`. Also drains
218
+ * `operationLock` so any compaction/tombstone serializer queued behind a
219
+ * prune has finished its writes before the caller proceeds.
220
+ *
221
+ * Test-only contract: production daemon never calls this — saves are
222
+ * fire-and-forget by design.
223
+ */
224
+ async flushPendingOperations() {
225
+ let last = this.pruneChain;
226
+ await last;
227
+ while (last !== this.pruneChain) {
228
+ last = this.pruneChain;
229
+ // eslint-disable-next-line no-await-in-loop
230
+ await last;
231
+ }
232
+ await this.operationLock;
233
+ }
234
+ // ── M2.02 scope ────────────────────────────────────────────────────────────
235
+ async getById(taskId) {
236
+ if (!TASK_ID_PATTERN.test(taskId))
237
+ return undefined;
238
+ // readIndexDedup is the canonical recovery driver — it honors the
239
+ // daemon-startup gate (C0) and rewrites the data file for any genuinely
240
+ // orphaned entries from a previous daemon boot. After it runs, reading
241
+ // the data file gives the recovered (or live, post-boot) shape.
242
+ const dedup = await this.readIndexDedup();
243
+ const line = dedup.get(taskId);
244
+ if (line === undefined || '_deleted' in line)
245
+ return undefined;
246
+ try {
247
+ const raw = await readFile(this.dataPath(taskId), 'utf8');
248
+ const parsed = TaskHistoryEntrySchema.safeParse(JSON.parse(raw));
249
+ return parsed.success ? parsed.data : undefined;
250
+ }
251
+ catch {
252
+ return undefined;
253
+ }
254
+ }
255
+ async list(options = {}) {
256
+ const dedup = await this.readIndexDedup();
257
+ const matches = [];
258
+ for (const line of dedup.values()) {
259
+ if ('_deleted' in line)
260
+ continue;
261
+ if (options.projectPath !== undefined && line.projectPath !== options.projectPath)
262
+ continue;
263
+ if (options.status?.length && !options.status.includes(line.status))
264
+ continue;
265
+ if (options.type?.length && !options.type.includes(line.type))
266
+ continue;
267
+ if (options.provider?.length && (line.provider === undefined || !options.provider.includes(line.provider))) {
268
+ continue;
269
+ }
270
+ if (options.model?.length && (line.model === undefined || !options.model.includes(line.model)))
271
+ continue;
272
+ if (options.createdAfter !== undefined && line.createdAt < options.createdAfter)
273
+ continue;
274
+ if (options.createdBefore !== undefined && line.createdAt > options.createdBefore)
275
+ continue;
276
+ matches.push(line);
277
+ }
278
+ matches.sort((a, b) => b.createdAt - a.createdAt);
279
+ return matches.map((line) => toTaskListItem(line));
280
+ }
281
+ async save(entry) {
282
+ // Validate at the write boundary so corrupt data files never get written.
283
+ const validated = TaskHistoryEntrySchema.parse(entry);
284
+ if (!TASK_ID_PATTERN.test(validated.taskId)) {
285
+ throw new Error(`Invalid taskId for storage: ${validated.taskId}`);
286
+ }
287
+ await mkdir(this.dataDir, { recursive: true });
288
+ // Step 1: write the data file atomically (UUID temp → rename).
289
+ await this.writeAtomic(this.dataPath(validated.taskId), JSON.stringify(validated, null, 2));
290
+ // Step 2: append the summary line (single ≤4KB POSIX append, atomic per
291
+ // PIPE_BUF). `lastSavedAt` is the wall-clock time of THIS append — the
292
+ // daemon-startup gate in `isStaleAndRecoverable` uses it to skip recovery
293
+ // for entries the current daemon is actively writing (live in-memory
294
+ // tasks). The C1 race vs. compaction is closed by `maybeCompact`'s
295
+ // post-rewrite re-read of the index.
296
+ const summary = { ...projectToIndexLine(validated), lastSavedAt: Date.now() };
297
+ await appendFile(this.indexPath, JSON.stringify(summary) + '\n', 'utf8');
298
+ this.invalidateIndexCaches();
299
+ // Step 3: schedule prune+compaction in background (fire-and-forget, dedup'd).
300
+ this.firePrune();
301
+ }
302
+ /**
303
+ * Construct the recovered (status='error') variant of a stale entry.
304
+ * Uses Zod parse to narrow to the discriminated 'error' branch without an `as` cast.
305
+ */
306
+ buildRecovered(entry, now) {
307
+ return TaskHistoryEntrySchema.parse({
308
+ ...entry,
309
+ completedAt: now,
310
+ error: {
311
+ code: 'INTERRUPTED',
312
+ message: 'Interrupted (daemon terminated)',
313
+ name: 'TaskError',
314
+ },
315
+ status: 'error',
316
+ });
317
+ }
318
+ dataPath(taskId) {
319
+ return join(this.dataDir, `${FILENAME_PREFIX}${taskId}.json`);
320
+ }
321
+ async doReadIndexDedup() {
322
+ // Capture the index epoch BEFORE any I/O. If the epoch advances during
323
+ // this pass (concurrent save/tombstone/recovery write), the snapshot we
324
+ // build is stale relative to disk and MUST NOT be cached — caching it
325
+ // would silently overwrite the writer's `indexCache = undefined` and
326
+ // make the just-written row invisible to the next reader.
327
+ const startEpoch = this.indexEpoch;
328
+ const map = new Map();
329
+ let raw;
330
+ try {
331
+ raw = await readFile(this.indexPath, 'utf8');
332
+ }
333
+ catch {
334
+ // No index yet — return empty map and cache it (only if no concurrent write).
335
+ if (this.indexEpoch === startEpoch)
336
+ this.indexCache = map;
337
+ return map;
338
+ }
339
+ for (const lineRaw of raw.split('\n')) {
340
+ const trimmed = lineRaw.trim();
341
+ if (!trimmed)
342
+ continue;
343
+ let json;
344
+ try {
345
+ json = JSON.parse(trimmed);
346
+ }
347
+ catch {
348
+ continue;
349
+ }
350
+ const parsed = IndexLineSchema.safeParse(json);
351
+ if (!parsed.success)
352
+ continue;
353
+ // Last line wins per taskId (dedup).
354
+ map.set(parsed.data.taskId, parsed.data);
355
+ }
356
+ // Stale recovery — sequential within this pass to keep index appends atomic.
357
+ // The daemon-startup gate (C0) inside `isStaleAndRecoverable` skips entries
358
+ // saved post-boot so live in-memory tasks (>10 min old createdAt but actively
359
+ // writing throttled updates) are not falsely tombstoned to INTERRUPTED.
360
+ const now = Date.now();
361
+ const staleTaskIds = [];
362
+ for (const [taskId, line] of map) {
363
+ if ('_deleted' in line)
364
+ continue;
365
+ if (this.isStaleAndRecoverable(line, now))
366
+ staleTaskIds.push(taskId);
367
+ }
368
+ for (const taskId of staleTaskIds) {
369
+ // eslint-disable-next-line no-await-in-loop -- sequential is intentional (atomicity)
370
+ const recovered = await this.recoverViaTaskId(taskId, now);
371
+ if (recovered !== undefined)
372
+ map.set(taskId, recovered);
373
+ }
374
+ // Only cache when no concurrent writer bumped the epoch during this pass.
375
+ // Recovery itself bumps `indexEpoch` (see `persistRecovery`), so a pass
376
+ // that triggered recovery will fall through to `return map` without
377
+ // caching — the next call re-reads the freshly-rewritten index.
378
+ if (this.indexEpoch === startEpoch)
379
+ this.indexCache = map;
380
+ return map;
381
+ }
382
+ /**
383
+ * Schedule an asynchronous prune+compaction pass without blocking the caller.
384
+ * Deduplicates concurrent calls — only one pass runs at a time. If a save
385
+ * fires while a pass is in-flight, `pruneRequested` is set so a follow-up
386
+ * pass runs once the current one finishes (catches saves that landed mid-pass).
387
+ *
388
+ * Uses `setTimeout(fn, 0)` to defer the pass to the next macrotask, ensuring
389
+ * all pending microtasks (e.g. a follow-up `getById` that triggers M2.04
390
+ * recovery on the same task) drain before prune runs. Without this, the
391
+ * prune's own `readIndexDedup` could trigger a parallel recovery race.
392
+ */
393
+ firePrune() {
394
+ if (this.pruneInFlight) {
395
+ this.pruneRequested = true;
396
+ return;
397
+ }
398
+ this.pruneInFlight = true;
399
+ this.pruneRequested = false;
400
+ this.pruneChain = new Promise((resolve) => {
401
+ const timer = setTimeout(() => {
402
+ this.pruneAndCompact()
403
+ .catch((error) => {
404
+ transportLog(`task-history: prune+compaction failed: ${error instanceof Error ? error.message : String(error)}`);
405
+ })
406
+ .finally(() => {
407
+ this.pruneInFlight = false;
408
+ if (this.pruneRequested) {
409
+ this.pruneRequested = false;
410
+ this.firePrune();
411
+ }
412
+ resolve();
413
+ });
414
+ }, 0);
415
+ // Don't keep the event loop alive for a pending prune at process exit.
416
+ timer.unref?.();
417
+ });
418
+ }
419
+ /**
420
+ * Drop all cached/in-flight reads of the index AND bump `indexEpoch` so any
421
+ * concurrent `doReadIndexDedup` pass that sampled the old epoch will skip
422
+ * its setCache step. Called by every write path (save / tombstone /
423
+ * recovery / compaction). Both layers must clear together — leaving
424
+ * `indexDedupInFlight` set after a write would let a list() call hit the
425
+ * pre-write promise and silently return the stale snapshot.
426
+ */
427
+ invalidateIndexCaches() {
428
+ this.indexCache = undefined;
429
+ this.indexDedupInFlight = undefined;
430
+ this.indexEpoch++;
431
+ }
432
+ isStale(status, createdAt, now) {
433
+ return (status === 'created' || status === 'started') && now - createdAt > this.staleThresholdMs;
434
+ }
435
+ /**
436
+ * Daemon-startup gate (C0): only entries whose `lastSavedAt` predates this
437
+ * daemon's boot are eligible for stale recovery. An entry written by the
438
+ * CURRENT daemon (post-boot) belongs to an in-memory active task whose
439
+ * lifecycle hook is still firing throttled saves — recovering it would
440
+ * ping-pong the on-disk state against the next save.
441
+ *
442
+ * Legacy lines (no `lastSavedAt`) fall back to the age-only check so
443
+ * existing index files from before this field was introduced behave as
444
+ * they did pre-C0.
445
+ */
446
+ isStaleAndRecoverable(line, now) {
447
+ if (!this.isStale(line.status, line.createdAt, now))
448
+ return false;
449
+ if (line.lastSavedAt !== undefined && line.lastSavedAt >= this.daemonStartedAt)
450
+ return false;
451
+ return true;
452
+ }
453
+ /**
454
+ * Rewrite `_index.jsonl` keeping one line per live entry when bloat exceeds
455
+ * the configured ratio. Sweeps orphan data files (taskId not in live map)
456
+ * after the rewrite so the data dir stays in sync.
457
+ *
458
+ * Locked against `tombstoneAndUnlink` via `operationLock` — see B2 comment
459
+ * on the field declaration.
460
+ */
461
+ async maybeCompact() {
462
+ if (!Number.isFinite(this.maxIndexBloatRatio))
463
+ return;
464
+ await this.withOperationLock(async () => {
465
+ let raw;
466
+ try {
467
+ raw = await readFile(this.indexPath, 'utf8');
468
+ }
469
+ catch {
470
+ return;
471
+ }
472
+ const allLines = [];
473
+ const liveMap = new Map();
474
+ for (const lineRaw of raw.split('\n')) {
475
+ const trimmed = lineRaw.trim();
476
+ if (!trimmed)
477
+ continue;
478
+ let json;
479
+ try {
480
+ json = JSON.parse(trimmed);
481
+ }
482
+ catch {
483
+ continue;
484
+ }
485
+ const parsed = IndexLineSchema.safeParse(json);
486
+ if (!parsed.success)
487
+ continue;
488
+ allLines.push(parsed.data);
489
+ if ('_deleted' in parsed.data) {
490
+ liveMap.delete(parsed.data.taskId);
491
+ }
492
+ else {
493
+ liveMap.set(parsed.data.taskId, parsed.data);
494
+ }
495
+ }
496
+ const liveCount = liveMap.size;
497
+ const totalCount = allLines.length;
498
+ // Avoid divide-by-zero; skip when nothing to compact.
499
+ if (liveCount === 0 || totalCount / liveCount <= this.maxIndexBloatRatio)
500
+ return;
501
+ const liveLines = [...liveMap.values()];
502
+ await this.rewriteIndex(liveLines);
503
+ // C1 fix — close BOTH race windows around the rename:
504
+ // (1) saves whose appendFile landed AFTER the rename are visible in the
505
+ // post-rewrite index → picked up by the re-read.
506
+ // (2) saves whose appendFile landed BEFORE the rename were overwritten
507
+ // by the rename, but their data file (`tsk-{taskId}.json`) still
508
+ // exists on disk. We detect them as "data-file present, taskId not
509
+ // in post-rewrite index AND not tombstoned", read the data file,
510
+ // and re-append the index line. The data file is preserved by the
511
+ // subsequent sweep because the recovered taskId is in the live set.
512
+ const postRewriteLiveIds = new Set(liveMap.keys());
513
+ const postRewriteTombstones = new Set();
514
+ try {
515
+ const postRaw = await readFile(this.indexPath, 'utf8');
516
+ for (const lineRaw of postRaw.split('\n')) {
517
+ const trimmed = lineRaw.trim();
518
+ if (!trimmed)
519
+ continue;
520
+ let json;
521
+ try {
522
+ json = JSON.parse(trimmed);
523
+ }
524
+ catch {
525
+ continue;
526
+ }
527
+ const parsed = IndexLineSchema.safeParse(json);
528
+ if (!parsed.success)
529
+ continue;
530
+ if ('_deleted' in parsed.data) {
531
+ postRewriteLiveIds.delete(parsed.data.taskId);
532
+ postRewriteTombstones.add(parsed.data.taskId);
533
+ }
534
+ else {
535
+ postRewriteLiveIds.add(parsed.data.taskId);
536
+ }
537
+ }
538
+ }
539
+ catch {
540
+ // Fall back to the snapshot if the post-rewrite read fails.
541
+ }
542
+ await this.recoverPreRenameSaves(postRewriteLiveIds, postRewriteTombstones);
543
+ await this.sweepOrphanData(postRewriteLiveIds);
544
+ this.invalidateIndexCaches();
545
+ });
546
+ }
547
+ /**
548
+ * Best-effort: write the recovered shape to the data file FIRST, then
549
+ * append the recovery line to the index. Sequential ordering matches
550
+ * `save()` and bounds the failure mode: if the data-file write fails,
551
+ * we return BEFORE the index append so the index never gains an orphan
552
+ * recovery line pointing to an unmutated data file (which would split
553
+ * `list()` from `getById()` — N1). Each step swallows its own error
554
+ * and logs via `transportLog`; never throws to caller.
555
+ */
556
+ async persistRecovery(recovered) {
557
+ try {
558
+ await this.writeAtomic(this.dataPath(recovered.taskId), JSON.stringify(recovered, null, 2));
559
+ }
560
+ catch (error) {
561
+ transportLog(`stale recovery: failed to write data file for ${recovered.taskId}: ${error instanceof Error ? error.message : String(error)}`);
562
+ return;
563
+ }
564
+ const recoveryLine = { ...projectToIndexLine(recovered), lastSavedAt: Date.now() };
565
+ try {
566
+ await appendFile(this.indexPath, JSON.stringify(recoveryLine) + '\n', 'utf8');
567
+ }
568
+ catch (error) {
569
+ transportLog(`stale recovery: failed to append index line for ${recovered.taskId}: ${error instanceof Error ? error.message : String(error)}`);
570
+ // Index stays stale; next read will re-attempt via recoverViaTaskId,
571
+ // whose terminal-status short-circuit returns the data file's now-
572
+ // recovered projection without re-writing.
573
+ }
574
+ this.invalidateIndexCaches();
575
+ }
576
+ /**
577
+ * Phase 1 (age) + Phase 2 (count) prune. Builds the dead-taskId set from
578
+ * the dedup'd live map and delegates to `tombstoneAndUnlink`.
579
+ */
580
+ async prune() {
581
+ const dedup = await this.readIndexDedup();
582
+ const liveEntries = [];
583
+ for (const line of dedup.values()) {
584
+ if ('_deleted' in line)
585
+ continue;
586
+ liveEntries.push(line);
587
+ }
588
+ const dead = [];
589
+ // Phase 1: age prune.
590
+ if (this.maxAgeDays > 0) {
591
+ const cutoff = Date.now() - this.maxAgeDays * 86_400_000;
592
+ for (const line of liveEntries) {
593
+ if (line.createdAt < cutoff)
594
+ dead.push(line.taskId);
595
+ }
596
+ }
597
+ // Phase 2: count prune. Survivors = entries NOT already marked dead by phase 1.
598
+ const survivors = liveEntries.filter((line) => !dead.includes(line.taskId));
599
+ if (Number.isFinite(this.maxEntries) && survivors.length > this.maxEntries) {
600
+ // Oldest excess: sort asc by createdAt and take the head.
601
+ const sorted = [...survivors].sort((a, b) => a.createdAt - b.createdAt);
602
+ const excessCount = survivors.length - this.maxEntries;
603
+ for (let i = 0; i < excessCount; i++)
604
+ dead.push(sorted[i].taskId);
605
+ }
606
+ if (dead.length > 0)
607
+ await this.tombstoneAndUnlink(dead);
608
+ }
609
+ async pruneAndCompact() {
610
+ await this.prune();
611
+ await this.maybeCompact();
612
+ // Always invalidate the cache after a pass so subsequent reads re-read
613
+ // disk — protects against external writes (e.g. tests appending tombstones
614
+ // out-of-band) that happen between prune phases.
615
+ this.invalidateIndexCaches();
616
+ }
617
+ async readIndexDedup() {
618
+ if (this.indexCache)
619
+ return this.indexCache;
620
+ // Re-entrancy: if a pass is already in flight (e.g. one started by getById
621
+ // and a parallel one about to start from the firePrune timer), reuse it.
622
+ // Without this, both passes find the same stale entry and both call
623
+ // `persistRecovery` → two recovery lines appended for the same taskId.
624
+ if (this.indexDedupInFlight)
625
+ return this.indexDedupInFlight;
626
+ this.indexDedupInFlight = this.doReadIndexDedup();
627
+ try {
628
+ return await this.indexDedupInFlight;
629
+ }
630
+ finally {
631
+ this.indexDedupInFlight = undefined;
632
+ }
633
+ }
634
+ /**
635
+ * Detect data files whose index line was overwritten by the compaction
636
+ * rename (race window: save's `appendFile` landed BEFORE compaction's
637
+ * `rename`). For each, parse the data file and either:
638
+ * - C1 path (current-boot save): re-append as live with `lastSavedAt = now`.
639
+ * - N2 path (prior-boot orphan): delegate to `recoverViaTaskId`, which
640
+ * mutates the data file to `status: 'error'` and persists the recovery
641
+ * line. Without this gate, an old `'started'` orphan would be re-stamped
642
+ * `lastSavedAt = Date.now()` and the C0 daemon-startup check would then
643
+ * forever protect it as a live current-boot task.
644
+ *
645
+ * Distinguishing C1 vs N2: synthesize a probe `IndexDataLine` from the data
646
+ * file with no `lastSavedAt` and feed it through `isStaleAndRecoverable`.
647
+ * Recent saves (createdAt within `staleThresholdMs`) fall through to the
648
+ * C1 branch; old `'created'`/`'started'` orphans take the N2 branch.
649
+ */
650
+ async recoverPreRenameSaves(liveIds, tombstones) {
651
+ let dataFilenames;
652
+ try {
653
+ dataFilenames = await readdir(this.dataDir);
654
+ }
655
+ catch {
656
+ return;
657
+ }
658
+ const now = Date.now();
659
+ for (const filename of dataFilenames) {
660
+ if (!filename.startsWith(FILENAME_PREFIX) || !filename.endsWith('.json'))
661
+ continue;
662
+ const taskId = filename.slice(FILENAME_PREFIX.length, -'.json'.length);
663
+ if (liveIds.has(taskId) || tombstones.has(taskId))
664
+ continue;
665
+ // Orphan candidate — read the data file and try to recover.
666
+ let raw;
667
+ try {
668
+ // eslint-disable-next-line no-await-in-loop
669
+ raw = await readFile(this.dataPath(taskId), 'utf8');
670
+ }
671
+ catch {
672
+ continue;
673
+ }
674
+ let parsed;
675
+ try {
676
+ parsed = TaskHistoryEntrySchema.safeParse(JSON.parse(raw));
677
+ }
678
+ catch {
679
+ continue;
680
+ }
681
+ if (!parsed.success)
682
+ continue;
683
+ const projected = projectToIndexLine(parsed.data);
684
+ // N2: prior-boot stale orphan → recover to 'error' before re-appending.
685
+ if (this.isStaleAndRecoverable(projected, now)) {
686
+ // eslint-disable-next-line no-await-in-loop
687
+ const recoveredLine = await this.recoverViaTaskId(taskId, now);
688
+ if (recoveredLine !== undefined)
689
+ liveIds.add(taskId);
690
+ continue;
691
+ }
692
+ // C1: current-boot orphan from the rewrite race → re-append as live.
693
+ const recovered = { ...projected, lastSavedAt: now };
694
+ try {
695
+ // eslint-disable-next-line no-await-in-loop
696
+ await appendFile(this.indexPath, JSON.stringify(recovered) + '\n', 'utf8');
697
+ liveIds.add(taskId);
698
+ }
699
+ catch (error) {
700
+ transportLog(`task-history: pre-rename save recovery append failed for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
701
+ }
702
+ }
703
+ }
704
+ /**
705
+ * Read the data file for a stale candidate, mutate to error, persist.
706
+ * Returns the summary projection of the recovered entry. Returns undefined
707
+ * if the data file is missing/corrupt. If the data file already shows a
708
+ * terminal status (a prior partial-success recovery), returns its projection
709
+ * without re-recovering — defensive idempotency restoration.
710
+ */
711
+ async recoverViaTaskId(taskId, now) {
712
+ let entry;
713
+ try {
714
+ const raw = await readFile(this.dataPath(taskId), 'utf8');
715
+ const parsed = TaskHistoryEntrySchema.safeParse(JSON.parse(raw));
716
+ if (!parsed.success)
717
+ return undefined;
718
+ entry = parsed.data;
719
+ }
720
+ catch {
721
+ return undefined;
722
+ }
723
+ if (entry.status !== 'created' && entry.status !== 'started') {
724
+ return projectToIndexLine(entry);
725
+ }
726
+ const recovered = this.buildRecovered(entry, now);
727
+ await this.persistRecovery(recovered);
728
+ return projectToIndexLine(recovered);
729
+ }
730
+ /**
731
+ * Atomically replace `_index.jsonl` with a fresh file containing exactly
732
+ * one line per live entry. Preserves the previous main as `_index.jsonl.bak`
733
+ * for one cycle. Sequence (best-effort .bak; atomic main swap):
734
+ * 1. Write `_index.jsonl.tmp` with the new content
735
+ * 2. copyFile `_index.jsonl` → `_index.jsonl.bak` (best-effort)
736
+ * 3. rename `_index.jsonl.tmp` → `_index.jsonl` (single atomic syscall)
737
+ */
738
+ async rewriteIndex(liveLines) {
739
+ const tmpPath = `${this.indexPath}.tmp`;
740
+ const bakPath = `${this.indexPath}.bak`;
741
+ const newContent = liveLines.map((line) => JSON.stringify(line)).join('\n') + (liveLines.length > 0 ? '\n' : '');
742
+ try {
743
+ await writeFile(tmpPath, newContent, 'utf8');
744
+ }
745
+ catch (error) {
746
+ transportLog(`task-history: rewriteIndex tmp write failed: ${error instanceof Error ? error.message : String(error)}`);
747
+ return;
748
+ }
749
+ // Best-effort .bak copy — non-fatal if it fails.
750
+ await copyFile(this.indexPath, bakPath).catch((error) => {
751
+ transportLog(`task-history: rewriteIndex .bak copy failed: ${error instanceof Error ? error.message : String(error)}`);
752
+ });
753
+ try {
754
+ await rename(tmpPath, this.indexPath);
755
+ }
756
+ catch (error) {
757
+ transportLog(`task-history: rewriteIndex rename failed: ${error instanceof Error ? error.message : String(error)}`);
758
+ // Clean up stranded .tmp.
759
+ await rm(tmpPath, { force: true }).catch(() => { });
760
+ }
761
+ }
762
+ /**
763
+ * After compaction, unlink any `data/tsk-${taskId}.json` whose taskId is
764
+ * not in the live map. Best-effort per file — ENOENT etc. is swallowed.
765
+ */
766
+ async sweepOrphanData(liveTaskIds) {
767
+ let entries;
768
+ try {
769
+ entries = await readdir(this.dataDir);
770
+ }
771
+ catch {
772
+ return;
773
+ }
774
+ const toUnlink = [];
775
+ for (const filename of entries) {
776
+ if (!filename.startsWith(FILENAME_PREFIX) || !filename.endsWith('.json'))
777
+ continue;
778
+ const taskId = filename.slice(FILENAME_PREFIX.length, -'.json'.length);
779
+ if (!liveTaskIds.has(taskId))
780
+ toUnlink.push(filename);
781
+ }
782
+ await Promise.all(toUnlink.map((filename) => rm(join(this.dataDir, filename), { force: true }).catch(() => { })));
783
+ }
784
+ /**
785
+ * Tombstone the given taskIds, then unlink each data file in parallel. Order
786
+ * matters: tombstone first so list/getById skip the entry even if the unlink
787
+ * fails (orphan data files are swept by M2.03 compaction). Reverse order
788
+ * would leave the row visible to list while getById returns undefined.
789
+ *
790
+ * Tombstones are appended in size-bounded chunks (each <`MAX_APPEND_CHUNK_BYTES`).
791
+ * POSIX guarantees nothing about regular-file write atomicity beyond pipes
792
+ * (PIPE_BUF applies to FIFOs/sockets only). Linux ext4 / macOS APFS happen
793
+ * to serialize appends per inode, but other filesystems may interleave a
794
+ * concurrent unlocked `save()` into the middle of a large multi-line write,
795
+ * corrupting a tombstone JSON line. Keeping every individual `appendFile`
796
+ * call comfortably under 4 KB lets the kernel page-cache write path treat
797
+ * each call as a single sector-level write, which most filesystems handle
798
+ * atomically.
799
+ *
800
+ * Locked against `maybeCompact` via `operationLock` (B2): if our appendFile
801
+ * landed mid-rewrite the tombstone would be wiped by `rename`, and our
802
+ * subsequent unlink would orphan the index entry `recoverPreRenameSaves`
803
+ * cannot detect (data file gone). The lock spans the entire chunked append
804
+ * + unlink sequence so all tombstones and unlinks are durable before any
805
+ * compaction consumes the snapshot.
806
+ */
807
+ async tombstoneAndUnlink(taskIds) {
808
+ if (taskIds.length === 0)
809
+ return;
810
+ await this.withOperationLock(async () => {
811
+ const now = Date.now();
812
+ const lines = taskIds.map((taskId) => JSON.stringify({ _deleted: true, deletedAt: now, schemaVersion: TASK_HISTORY_SCHEMA_VERSION, taskId }) + '\n');
813
+ for (const chunk of chunkLinesByBytes(lines, MAX_APPEND_CHUNK_BYTES)) {
814
+ // eslint-disable-next-line no-await-in-loop -- sequential is intentional (atomicity)
815
+ await appendFile(this.indexPath, chunk.join(''), 'utf8');
816
+ }
817
+ await Promise.all(taskIds.map((id) => rm(this.dataPath(id), { force: true }).catch(() => { })));
818
+ this.invalidateIndexCaches();
819
+ });
820
+ }
821
+ async withOperationLock(fn) {
822
+ const previous = this.operationLock;
823
+ // Lazy-init: the Promise constructor invokes its executor synchronously,
824
+ // so `release` is guaranteed assigned before any await. CLAUDE.md exception
825
+ // for definite-assignment in lazy-init patterns applies.
826
+ let release;
827
+ this.operationLock = new Promise((resolve) => {
828
+ release = resolve;
829
+ });
830
+ try {
831
+ await previous;
832
+ return await fn();
833
+ }
834
+ finally {
835
+ release();
836
+ }
837
+ }
838
+ async writeAtomic(filePath, content) {
839
+ const tmpPath = `${filePath}.${randomUUID()}.tmp`;
840
+ try {
841
+ await writeFile(tmpPath, content, 'utf8');
842
+ await rename(tmpPath, filePath);
843
+ }
844
+ catch (error) {
845
+ await rm(tmpPath, { force: true }).catch(() => { });
846
+ throw error;
847
+ }
848
+ }
849
+ }
850
+ // ── Pure projection helpers ──────────────────────────────────────────────────
851
+ function projectToIndexLine(entry) {
852
+ const line = {
853
+ content: entry.content,
854
+ createdAt: entry.createdAt,
855
+ projectPath: entry.projectPath,
856
+ schemaVersion: entry.schemaVersion,
857
+ status: entry.status,
858
+ taskId: entry.taskId,
859
+ type: entry.type,
860
+ ...(entry.files === undefined ? {} : { files: entry.files }),
861
+ ...(entry.folderPath === undefined ? {} : { folderPath: entry.folderPath }),
862
+ ...(entry.provider === undefined ? {} : { provider: entry.provider }),
863
+ ...(entry.model === undefined ? {} : { model: entry.model }),
864
+ };
865
+ // Branch-aware extraction — `created` has no startedAt; terminal branches
866
+ // have startedAt? optional and completedAt required (plus error payload on error).
867
+ switch (entry.status) {
868
+ case 'cancelled':
869
+ case 'completed': {
870
+ line.completedAt = entry.completedAt;
871
+ if (entry.startedAt !== undefined)
872
+ line.startedAt = entry.startedAt;
873
+ break;
874
+ }
875
+ case 'created': {
876
+ break;
877
+ }
878
+ case 'error': {
879
+ line.completedAt = entry.completedAt;
880
+ line.error = entry.error;
881
+ if (entry.startedAt !== undefined)
882
+ line.startedAt = entry.startedAt;
883
+ break;
884
+ }
885
+ case 'started': {
886
+ line.startedAt = entry.startedAt;
887
+ break;
888
+ }
889
+ }
890
+ return line;
891
+ }
892
+ function toTaskListItem(line) {
893
+ // Project the persisted summary into the wire-friendly shape.
894
+ // Drops `schemaVersion` (storage detail) and never includes `id` /
895
+ // heavy fields (already absent from the index).
896
+ const { status } = line;
897
+ return {
898
+ content: line.content,
899
+ createdAt: line.createdAt,
900
+ projectPath: line.projectPath,
901
+ status,
902
+ taskId: line.taskId,
903
+ type: line.type,
904
+ ...(line.completedAt === undefined ? {} : { completedAt: line.completedAt }),
905
+ ...(line.startedAt === undefined ? {} : { startedAt: line.startedAt }),
906
+ ...(line.files === undefined ? {} : { files: line.files }),
907
+ ...(line.folderPath === undefined ? {} : { folderPath: line.folderPath }),
908
+ ...(line.provider === undefined ? {} : { provider: line.provider }),
909
+ ...(line.model === undefined ? {} : { model: line.model }),
910
+ ...(line.error === undefined ? {} : { error: line.error }),
911
+ };
912
+ }