byterover-cli 3.10.3 → 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.
- package/README.md +4 -2
- package/dist/agent/core/domain/llm/registry.d.ts +12 -0
- package/dist/agent/core/domain/llm/registry.js +49 -0
- package/dist/agent/core/domain/llm/types.d.ts +6 -0
- package/dist/agent/core/interfaces/i-content-generator.d.ts +8 -0
- package/dist/agent/infra/llm/agent-llm-service.js +18 -6
- package/dist/agent/infra/llm/context/context-manager.d.ts +4 -1
- package/dist/agent/infra/llm/context/context-manager.js +5 -1
- package/dist/agent/infra/llm/generators/ai-sdk-content-generator.d.ts +13 -0
- package/dist/agent/infra/llm/generators/ai-sdk-content-generator.js +19 -6
- package/dist/agent/infra/llm/generators/ai-sdk-message-converter.js +16 -4
- package/dist/agent/infra/llm/generators/byterover-content-generator.d.ts +1 -0
- package/dist/agent/infra/llm/generators/byterover-content-generator.js +4 -1
- package/dist/agent/infra/llm/model-capabilities.d.ts +2 -1
- package/dist/agent/infra/llm/model-capabilities.js +6 -4
- package/dist/agent/infra/llm/providers/anthropic.js +2 -0
- package/dist/agent/infra/llm/providers/deepseek.d.ts +10 -0
- package/dist/agent/infra/llm/providers/deepseek.js +33 -0
- package/dist/agent/infra/llm/providers/glm-coding-plan.d.ts +9 -0
- package/dist/agent/infra/llm/providers/glm-coding-plan.js +32 -0
- package/dist/agent/infra/llm/providers/index.js +4 -0
- package/dist/agent/infra/llm/providers/openrouter.js +2 -0
- package/dist/agent/infra/tools/implementations/curate-tool.js +18 -8
- package/dist/oclif/commands/query.js +7 -1
- package/dist/oclif/lib/task-client.d.ts +9 -0
- package/dist/oclif/lib/task-client.js +11 -1
- package/dist/server/constants.d.ts +6 -0
- package/dist/server/constants.js +11 -0
- package/dist/server/core/domain/entities/provider-registry.js +26 -0
- package/dist/server/core/domain/entities/task-history-entry.d.ts +775 -0
- package/dist/server/core/domain/entities/task-history-entry.js +88 -0
- package/dist/server/core/domain/transport/schemas.d.ts +1403 -11
- package/dist/server/core/domain/transport/schemas.js +157 -6
- package/dist/server/core/domain/transport/task-info.d.ts +18 -0
- package/dist/server/core/interfaces/process/i-task-lifecycle-hook.d.ts +7 -0
- package/dist/server/core/interfaces/storage/i-task-history-store.d.ts +62 -0
- package/dist/server/core/interfaces/storage/i-task-history-store.js +1 -0
- package/dist/server/infra/daemon/brv-server.js +43 -18
- package/dist/server/infra/dream/dream-response-schemas.d.ts +24 -0
- package/dist/server/infra/dream/dream-response-schemas.js +7 -0
- package/dist/server/infra/dream/operations/consolidate.js +21 -8
- package/dist/server/infra/dream/operations/synthesize.js +35 -8
- package/dist/server/infra/http/provider-model-fetcher-registry.js +5 -0
- package/dist/server/infra/http/provider-model-fetchers.js +54 -27
- package/dist/server/infra/process/query-log-handler.d.ts +6 -0
- package/dist/server/infra/process/query-log-handler.js +23 -0
- package/dist/server/infra/process/task-history-entry-builder.d.ts +36 -0
- package/dist/server/infra/process/task-history-entry-builder.js +101 -0
- package/dist/server/infra/process/task-history-hook.d.ts +37 -0
- package/dist/server/infra/process/task-history-hook.js +70 -0
- package/dist/server/infra/process/task-history-store-cache.d.ts +25 -0
- package/dist/server/infra/process/task-history-store-cache.js +106 -0
- package/dist/server/infra/process/task-router.d.ts +72 -0
- package/dist/server/infra/process/task-router.js +690 -15
- package/dist/server/infra/process/transport-handlers.d.ts +8 -0
- package/dist/server/infra/process/transport-handlers.js +2 -0
- package/dist/server/infra/storage/file-task-history-store.d.ts +294 -0
- package/dist/server/infra/storage/file-task-history-store.js +912 -0
- package/dist/shared/transport/events/index.d.ts +5 -0
- package/dist/shared/transport/events/task-events.d.ts +204 -1
- package/dist/shared/transport/events/task-events.js +11 -0
- package/dist/tui/features/tasks/hooks/use-task-subscriptions.js +7 -0
- package/dist/tui/features/tasks/stores/tasks-store.d.ts +4 -16
- package/dist/tui/features/tasks/stores/tasks-store.js +7 -0
- package/dist/tui/types/messages.d.ts +2 -9
- package/dist/webui/assets/index-DyVvFoM6.css +1 -0
- package/dist/webui/assets/index-lr0byHh9.js +130 -0
- package/dist/webui/index.html +2 -2
- package/dist/webui/sw.js +1 -1
- package/dist/webui/workbox-9c191d2f.js +1 -0
- package/oclif.manifest.json +985 -985
- package/package.json +1 -1
- package/dist/webui/assets/index-CvcqpMYn.css +0 -1
- package/dist/webui/assets/index-thSZZahh.js +0 -130
- package/dist/webui/workbox-8c29f6e4.js +0 -1
|
@@ -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
|
+
}
|