taskflow-core 0.2.2 → 0.2.4

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 (59) hide show
  1. package/README.md +4 -3
  2. package/dist/agents.d.ts +2 -2
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/build-info.json +2 -2
  5. package/dist/compile.d.ts +8 -1
  6. package/dist/compile.d.ts.map +1 -1
  7. package/dist/compile.js +75 -9
  8. package/dist/compile.js.map +1 -1
  9. package/dist/detached-control.d.ts +52 -0
  10. package/dist/detached-control.d.ts.map +1 -0
  11. package/dist/detached-control.js +251 -0
  12. package/dist/detached-control.js.map +1 -0
  13. package/dist/detached-runner.js +183 -30
  14. package/dist/detached-runner.js.map +1 -1
  15. package/dist/exec/driver.d.ts +5 -0
  16. package/dist/exec/driver.d.ts.map +1 -1
  17. package/dist/exec/driver.js +98 -52
  18. package/dist/exec/driver.js.map +1 -1
  19. package/dist/exec/kernel-policy.d.ts.map +1 -1
  20. package/dist/exec/kernel-policy.js +20 -20
  21. package/dist/exec/kernel-policy.js.map +1 -1
  22. package/dist/exec/step-kinds.d.ts.map +1 -1
  23. package/dist/exec/step-kinds.js +59 -22
  24. package/dist/exec/step-kinds.js.map +1 -1
  25. package/dist/exec/step.d.ts.map +1 -1
  26. package/dist/exec/step.js +44 -10
  27. package/dist/exec/step.js.map +1 -1
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/runner-core.d.ts.map +1 -1
  33. package/dist/runner-core.js +10 -0
  34. package/dist/runner-core.js.map +1 -1
  35. package/dist/runtime.d.ts +13 -0
  36. package/dist/runtime.d.ts.map +1 -1
  37. package/dist/runtime.js +81 -38
  38. package/dist/runtime.js.map +1 -1
  39. package/dist/store.d.ts +41 -2
  40. package/dist/store.d.ts.map +1 -1
  41. package/dist/store.js +489 -180
  42. package/dist/store.js.map +1 -1
  43. package/dist/verifiers/discover.d.ts +44 -0
  44. package/dist/verifiers/discover.d.ts.map +1 -0
  45. package/dist/verifiers/discover.js +152 -0
  46. package/dist/verifiers/discover.js.map +1 -0
  47. package/dist/verifiers/index.d.ts +12 -0
  48. package/dist/verifiers/index.d.ts.map +1 -0
  49. package/dist/verifiers/index.js +12 -0
  50. package/dist/verifiers/index.js.map +1 -0
  51. package/dist/verifiers/script-lint.d.ts +24 -0
  52. package/dist/verifiers/script-lint.d.ts.map +1 -0
  53. package/dist/verifiers/script-lint.js +311 -0
  54. package/dist/verifiers/script-lint.js.map +1 -0
  55. package/dist/verify.d.ts +55 -3
  56. package/dist/verify.d.ts.map +1 -1
  57. package/dist/verify.js +201 -2
  58. package/dist/verify.js.map +1 -1
  59. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -20,6 +20,7 @@ import * as path from "node:path";
20
20
  import { parseJsonc } from "./jsonc.js";
21
21
  import { getAgentDir } from "./paths.js";
22
22
  import { parseStrict } from "./interpolate.js";
23
+ import { directoryIdentity } from "./cwd-bridge.js";
23
24
  /** Build a single-line, user-facing message from a failed `LoadResult`. */
24
25
  export function describeLoadFailure(r, what) {
25
26
  return r.reason === "missing"
@@ -54,11 +55,16 @@ const LOCK_STALE_MS = 30_000;
54
55
  const LOCK_POLL_MS = 50;
55
56
  /** Default acquisition timeout before throwing. */
56
57
  const LOCK_TIMEOUT_MS = 10_000;
58
+ /** Retention is opportunistic: never stall a foreground save behind cleanup. */
59
+ const CLEANUP_LOCK_TIMEOUT_MS = 250;
57
60
  // ---------------------------------------------------------------------------
58
61
  // Cleanup throttle
59
62
  // ---------------------------------------------------------------------------
60
63
  /** Minimum ms between opportunistic cleanup runs (called inside saveRun). */
61
64
  const CLEANUP_INTERVAL_MS = 60_000;
65
+ /** Bound the project-keyed throttle so a long-lived multi-project host cannot
66
+ * retain one map entry for every directory it has ever visited. */
67
+ const CLEANUP_THROTTLE_MAX_ROOTS = 256;
62
68
  /** Retain at most this many terminal runs by default. */
63
69
  const DEFAULT_MAX_KEPT_TERMINAL = 100;
64
70
  /** Remove terminal runs older than this (days). */
@@ -66,8 +72,9 @@ const DEFAULT_MAX_AGE_DAYS = 30;
66
72
  // Re-exported for use in TaskflowSettings defaults (agents.ts).
67
73
  export const DEFAULT_KEPT_RUNS = DEFAULT_MAX_KEPT_TERMINAL;
68
74
  export const DEFAULT_RUN_AGE_DAYS = DEFAULT_MAX_AGE_DAYS;
69
- /** Last cleanup timestamp — module-level so it persists across calls. */
70
- let lastCleanupAt = 0;
75
+ /** Per-runs-root cleanup timestamps. A process-global scalar lets one busy
76
+ * project suppress retention in every other project served by the same host. */
77
+ const lastCleanupAtByRoot = new Map();
71
78
  /** Shared buffer for Atomics.wait in acquireLock busy-wait (Finding 6). */
72
79
  const LOCK_WAIT_BUF = new Int32Array(new SharedArrayBuffer(4));
73
80
  // ---------------------------------------------------------------------------
@@ -120,27 +127,220 @@ function lockPathForRun(runsRoot, flowName, runId) {
120
127
  * Legitimate runIds are produced by newRunId() and contain only [A-Za-z0-9._-].
121
128
  */
122
129
  export function validateRunId(runId) {
123
- return (typeof runId === "string" &&
124
- runId.length > 0 &&
125
- !runId.includes("/") &&
126
- !runId.includes("\\") &&
127
- !runId.includes("\0") &&
128
- !runId.includes(".."));
130
+ // A single leading/trailing dot is safe once the id is used as
131
+ // `${runId}.json`, and `newRunId()` has historically produced leading-dot
132
+ // ids for valid flow names such as `.ci`. Reject separators and dot-dot
133
+ // traversal, but retain compatibility with those already-persisted runs.
134
+ return typeof runId === "string" && runId.length > 0 && runId.length <= 160 &&
135
+ /^[A-Za-z0-9._-]+$/.test(runId) && !runId.includes("..");
136
+ }
137
+ /**
138
+ * Validate an index path before it is joined to the runs root.
139
+ *
140
+ * Index files live in a project-controlled directory and may be stale or
141
+ * manually edited. Only the two layouts Taskflow itself has ever emitted are
142
+ * accepted: `<flowDir>/<runId>.json` and the legacy `<runId>.json`. Keeping
143
+ * this check independent from the host OS also makes an index written on one
144
+ * platform safe to consume on another (generated index paths always use `/`).
145
+ */
146
+ function isSafeRunIndexRelPath(relPath, runId) {
147
+ if (!validateRunId(runId) || relPath.length === 0 || relPath.length > 420)
148
+ return false;
149
+ if (path.isAbsolute(relPath) || relPath.includes("\\"))
150
+ return false;
151
+ const parts = relPath.split("/");
152
+ if (parts.some((part) => part.length === 0 || part === "." || part === ".."))
153
+ return false;
154
+ if (parts.length === 1)
155
+ return parts[0] === `${runId}.json`;
156
+ if (parts.length !== 2)
157
+ return false;
158
+ const [flowDir, fileName] = parts;
159
+ return flowDir.length <= 255 && safeFlowDirName(flowDir) === flowDir && fileName === `${runId}.json`;
160
+ }
161
+ /** Join an already-validated, platform-neutral index path to the runs root. */
162
+ function runIndexFilePath(runsRoot, relPath) {
163
+ return path.join(runsRoot, ...relPath.split("/"));
164
+ }
165
+ /** Canonical, bounded-LRU throttle for opportunistic per-project cleanup. */
166
+ function shouldRunCleanup(runsRoot, now) {
167
+ let key;
168
+ try {
169
+ key = fs.realpathSync(runsRoot);
170
+ }
171
+ catch {
172
+ key = path.resolve(runsRoot);
173
+ }
174
+ const previous = lastCleanupAtByRoot.get(key);
175
+ if (previous !== undefined && now - previous < CLEANUP_INTERVAL_MS)
176
+ return false;
177
+ // Refresh insertion order for simple LRU eviction.
178
+ lastCleanupAtByRoot.delete(key);
179
+ lastCleanupAtByRoot.set(key, now);
180
+ while (lastCleanupAtByRoot.size > CLEANUP_THROTTLE_MAX_ROOTS) {
181
+ const oldest = lastCleanupAtByRoot.keys().next().value;
182
+ if (oldest === undefined)
183
+ break;
184
+ lastCleanupAtByRoot.delete(oldest);
185
+ }
186
+ return true;
187
+ }
188
+ /**
189
+ * Resolve a physical directory only when it is a non-symlink descendant of
190
+ * runsRoot. Retention performs destructive operations, so lexical containment
191
+ * alone is insufficient: a checked-in `runs/<flow>` symlink could otherwise
192
+ * redirect the run lock and unlink to an arbitrary directory.
193
+ */
194
+ function physicalDirectoryInsideRunsRoot(runsRoot, candidate) {
195
+ try {
196
+ const rootReal = fs.realpathSync(runsRoot);
197
+ const stat = fs.lstatSync(candidate);
198
+ if (!stat.isDirectory() || stat.isSymbolicLink())
199
+ return null;
200
+ const realPath = fs.realpathSync(candidate);
201
+ const rel = path.relative(rootReal, realPath);
202
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
203
+ return null;
204
+ return { realPath, device: stat.dev, inode: stat.ino };
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ }
210
+ function physicalDirectoryStillMatches(runsRoot, candidate, snapshot) {
211
+ const current = physicalDirectoryInsideRunsRoot(runsRoot, candidate);
212
+ return Boolean(current && current.realPath === snapshot.realPath &&
213
+ current.device === snapshot.device && current.inode === snapshot.inode);
214
+ }
215
+ /** Remove a Taskflow-owned artifact tree without following a project symlink. */
216
+ function removeArtifactDirectoryInsideRunsRoot(runsRoot, target) {
217
+ const parent = path.dirname(target);
218
+ const parentSnapshot = physicalDirectoryInsideRunsRoot(runsRoot, parent);
219
+ if (!parentSnapshot)
220
+ return;
221
+ try {
222
+ const stat = fs.lstatSync(target);
223
+ if (!stat.isDirectory() || stat.isSymbolicLink())
224
+ return;
225
+ const targetSnapshot = physicalDirectoryInsideRunsRoot(runsRoot, target);
226
+ if (!targetSnapshot || !physicalDirectoryStillMatches(runsRoot, parent, parentSnapshot))
227
+ return;
228
+ if (!physicalDirectoryStillMatches(runsRoot, target, targetSnapshot))
229
+ return;
230
+ fs.rmSync(target, { recursive: true, force: true });
231
+ }
232
+ catch { /* missing / concurrently changed */ }
233
+ }
234
+ /** Accept a persisted cwd for control-record cleanup only when it resolves to
235
+ * the same project run store currently being retained. */
236
+ function controlCwdForRunsRoot(runsRoot, candidate) {
237
+ const fallback = path.dirname(path.dirname(path.dirname(runsRoot)));
238
+ if (typeof candidate !== "string" || candidate.length === 0)
239
+ return fallback;
240
+ try {
241
+ if (fs.realpathSync(runsDir(candidate)) === fs.realpathSync(runsRoot))
242
+ return candidate;
243
+ }
244
+ catch { /* malformed, missing, or from another project */ }
245
+ return fallback;
129
246
  }
130
247
  // ---------------------------------------------------------------------------
131
248
  // File-lock primitives — zero-dependency, using O_CREAT|O_EXCL (atomic)
132
249
  // ---------------------------------------------------------------------------
250
+ function readLockOwner(lockPath) {
251
+ try {
252
+ const stat = fs.lstatSync(lockPath);
253
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4_096)
254
+ return null;
255
+ const raw = fs.readFileSync(lockPath, "utf-8");
256
+ if (Buffer.byteLength(raw, "utf-8") > 4_096)
257
+ return null;
258
+ const parsed = JSON.parse(raw);
259
+ if (!Number.isSafeInteger(parsed.pid) || typeof parsed.ts !== "number")
260
+ return null;
261
+ return {
262
+ pid: parsed.pid,
263
+ ts: parsed.ts,
264
+ ...(typeof parsed.token === "string" ? { token: parsed.token } : {}),
265
+ };
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ function sameLockOwner(left, right) {
272
+ return Boolean(left && left.pid === right.pid && left.ts === right.ts && left.token === right.token);
273
+ }
274
+ /**
275
+ * Serialize stale-lock stealers for one observed lock generation. Without this
276
+ * claim, contender B can replace a dead lock and contender C — acting on its
277
+ * earlier observation — can then rename B's fresh live lock.
278
+ */
279
+ function tryStealDeadLock(lockPath, observed, owner) {
280
+ if (probeProcess(owner.pid) !== "dead")
281
+ return false;
282
+ const generation = crypto.createHash("sha256")
283
+ .update(`${observed.dev}\0${observed.ino}\0${owner.pid}\0${owner.ts}\0${owner.token ?? ""}`)
284
+ .digest("hex")
285
+ .slice(0, 16);
286
+ const claimPath = `${lockPath}.steal.${generation}`;
287
+ let claimFd;
288
+ try {
289
+ claimFd = fs.openSync(claimPath, "wx");
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ const claimToken = crypto.randomBytes(16).toString("hex");
295
+ let claimHandle;
296
+ try {
297
+ fs.writeFileSync(claimFd, JSON.stringify({ pid: process.pid, ts: Date.now(), token: claimToken }));
298
+ const claimStat = fs.fstatSync(claimFd);
299
+ claimHandle = { device: claimStat.dev, inode: claimStat.ino, token: claimToken };
300
+ fs.closeSync(claimFd);
301
+ claimFd = -1;
302
+ const current = fs.lstatSync(lockPath);
303
+ if (current.dev !== observed.dev || current.ino !== observed.ino)
304
+ return false;
305
+ if (!sameLockOwner(readLockOwner(lockPath), owner))
306
+ return false;
307
+ const grave = `${lockPath}.stale.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
308
+ fs.renameSync(lockPath, grave);
309
+ try {
310
+ fs.unlinkSync(grave);
311
+ }
312
+ catch { /* best-effort grave cleanup */ }
313
+ return true;
314
+ }
315
+ catch {
316
+ return false;
317
+ }
318
+ finally {
319
+ if (claimFd >= 0) {
320
+ try {
321
+ fs.closeSync(claimFd);
322
+ }
323
+ catch { /* ignore */ }
324
+ }
325
+ if (claimHandle)
326
+ releaseLock(claimPath, claimHandle);
327
+ else {
328
+ // Initialization did not finish; only this exclusive creator can own it.
329
+ try {
330
+ fs.unlinkSync(claimPath);
331
+ }
332
+ catch { /* ignore */ }
333
+ }
334
+ }
335
+ }
133
336
  /**
134
337
  * Acquire a file lock by atomically creating a lock file.
135
338
  *
136
339
  * Uses O_CREAT|O_EXCL (`wx` flag) which is atomic on POSIX and NTFS.
137
- * Stale locks (> LOCK_STALE_MS) are stolen via an atomic rename rather than a
138
- * naive unlink-then-create: a plain `unlinkSync` + `openSync('wx')` has a
139
- * TOCTOU window where two processes both unlink the same stale lock and both
140
- * then create a fresh one, yielding two simultaneous holders (risk-reviewer
141
- * v0.0.9 audit, L1). `rename` is atomic and removes the *specific* inode the
142
- * caller observed: only one racing process can win the rename of that exact
143
- * stale file, so at most one process proceeds to re-create the lock.
340
+ * Stale locks (> LOCK_STALE_MS) are stolen only after their recorded owner PID
341
+ * is definitively dead, then moved via an atomic rename. Age alone cannot prove
342
+ * abandonment: stealing from a slow but live holder creates two simultaneous
343
+ * critical sections, and the old holder can later unlink the new holder's lock.
144
344
  * Throws on timeout.
145
345
  */
146
346
  function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
@@ -151,33 +351,38 @@ function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
151
351
  while (true) {
152
352
  try {
153
353
  const fd = fs.openSync(lockPath, "wx");
154
- fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
155
- fs.closeSync(fd);
156
- return; // lock acquired
354
+ const token = crypto.randomBytes(16).toString("hex");
355
+ try {
356
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now(), token }));
357
+ const stat = fs.fstatSync(fd);
358
+ return { device: stat.dev, inode: stat.ino, token };
359
+ }
360
+ catch (error) {
361
+ // Creation succeeded but initialization did not. Remove only the inode
362
+ // this process created so a partial lock cannot become unstealable.
363
+ try {
364
+ const held = fs.fstatSync(fd);
365
+ const current = fs.lstatSync(lockPath);
366
+ if (current.dev === held.dev && current.ino === held.ino)
367
+ fs.unlinkSync(lockPath);
368
+ }
369
+ catch { /* best-effort rollback */ }
370
+ throw error;
371
+ }
372
+ finally {
373
+ fs.closeSync(fd);
374
+ }
157
375
  }
158
376
  catch (e) {
159
377
  if (e.code !== "EEXIST")
160
378
  throw e;
161
379
  // Lock file exists — check if stale.
162
380
  try {
163
- const stat = fs.statSync(lockPath);
381
+ const stat = fs.lstatSync(lockPath);
164
382
  if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
165
- // Stale lock — steal it via atomic rename so only one racing
166
- // stealer can win (L1). The "graveyard" name is unique per
167
- // process+attempt; the winner unlinks it, losers see ENOENT
168
- // on their own rename and simply retry the acquire loop.
169
- const grave = `${lockPath}.stale.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
170
- try {
171
- fs.renameSync(lockPath, grave);
172
- // We won the steal — discard the graveyard copy and retry
173
- // the loop, where openSync('wx') will create a fresh lock.
174
- try {
175
- fs.unlinkSync(grave);
176
- }
177
- catch { /* ignore */ }
178
- }
179
- catch { /* lost the steal race (ENOENT) — just retry */ }
180
- continue;
383
+ const owner = readLockOwner(lockPath);
384
+ if (owner && tryStealDeadLock(lockPath, stat, owner))
385
+ continue;
181
386
  }
182
387
  }
183
388
  catch {
@@ -194,25 +399,31 @@ function acquireLock(lockPath, timeoutMs = LOCK_TIMEOUT_MS) {
194
399
  }
195
400
  }
196
401
  /**
197
- * Release a file lock by deleting the lock file. Ignores ENOENT (already
198
- * released by another process or stolen due to staleness).
402
+ * Release a file lock only when the path still carries this holder's inode and
403
+ * random ownership token. A dead-owner steal must never let an old finally
404
+ * block delete the successor's lock.
199
405
  */
200
- function releaseLock(lockPath) {
406
+ function releaseLock(lockPath, handle) {
201
407
  try {
408
+ const stat = fs.lstatSync(lockPath);
409
+ if (!stat.isFile() || stat.dev !== handle.device || stat.ino !== handle.inode)
410
+ return;
411
+ if (readLockOwner(lockPath)?.token !== handle.token)
412
+ return;
202
413
  fs.unlinkSync(lockPath);
203
414
  }
204
- catch { /* ENOENT or other — ignore */ }
415
+ catch { /* ENOENT, replaced, or corrupt — never unlink another owner's lock */ }
205
416
  }
206
417
  /**
207
418
  * Execute `fn` while holding a file lock. Guarantees release even on throw.
208
419
  */
209
- export function withLock(lockPath, fn) {
210
- acquireLock(lockPath);
420
+ export function withLock(lockPath, fn, timeoutMs = LOCK_TIMEOUT_MS) {
421
+ const handle = acquireLock(lockPath, timeoutMs);
211
422
  try {
212
423
  return fn();
213
424
  }
214
425
  finally {
215
- releaseLock(lockPath);
426
+ releaseLock(lockPath, handle);
216
427
  }
217
428
  }
218
429
  // ---------------------------------------------------------------------------
@@ -226,6 +437,7 @@ export function extractIndexEntry(state, relPath) {
226
437
  return {
227
438
  runId: state.runId,
228
439
  flowName: state.flowName,
440
+ cwd: state.cwd,
229
441
  status: state.status,
230
442
  createdAt: state.createdAt,
231
443
  updatedAt: state.updatedAt,
@@ -242,8 +454,10 @@ function readIndex(runsRoot) {
242
454
  const parsed = JSON.parse(raw);
243
455
  if (!Array.isArray(parsed))
244
456
  return [];
245
- // Validate each entry minimally.
246
- return parsed.filter((e) => e && typeof e.runId === "string" && typeof e.relPath === "string");
457
+ // Treat the project-controlled index as untrusted input. In particular,
458
+ // never let a crafted relPath escape runsRoot during load or retention.
459
+ return parsed.filter((e) => e && typeof e.runId === "string" && typeof e.relPath === "string" &&
460
+ isSafeRunIndexRelPath(e.relPath, e.runId));
247
461
  }
248
462
  catch {
249
463
  return [];
@@ -306,14 +520,10 @@ function rebuildIndex(runsRoot) {
306
520
  continue;
307
521
  }
308
522
  for (const file of files) {
309
- try {
310
- const raw = fs.readFileSync(path.join(dirPath, file), "utf-8");
311
- const state = JSON.parse(raw);
312
- if (state && typeof state.runId === "string") {
313
- entries.set(state.runId, extractIndexEntry(state, `${dirName}/${file}`));
314
- }
523
+ const loaded = tryReadRunFile(runsRoot, path.join(dirPath, file));
524
+ if (loaded.ok && validateRunId(loaded.value.runId) && file === `${loaded.value.runId}.json`) {
525
+ entries.set(loaded.value.runId, extractIndexEntry(loaded.value, `${dirName}/${file}`));
315
526
  }
316
- catch { /* skip corrupt */ }
317
527
  }
318
528
  }
319
529
  // Scan legacy flat files (runs/*.json, skip index.json).
@@ -327,14 +537,11 @@ function rebuildIndex(runsRoot) {
327
537
  for (const file of flatFiles) {
328
538
  if (entries.has(file.replace(/\.json$/, "")))
329
539
  continue; // prefer subdir entry
330
- try {
331
- const raw = fs.readFileSync(path.join(runsRoot, file), "utf-8");
332
- const state = JSON.parse(raw);
333
- if (state && typeof state.runId === "string" && !entries.has(state.runId)) {
334
- entries.set(state.runId, extractIndexEntry(state, file));
335
- }
540
+ const loaded = tryReadRunFile(runsRoot, path.join(runsRoot, file));
541
+ if (loaded.ok && validateRunId(loaded.value.runId) && file === `${loaded.value.runId}.json` &&
542
+ !entries.has(loaded.value.runId)) {
543
+ entries.set(loaded.value.runId, extractIndexEntry(loaded.value, file));
336
544
  }
337
- catch { /* skip corrupt */ }
338
545
  }
339
546
  const scanned = Array.from(entries.values());
340
547
  // Persist the rebuilt index under the index lock. Re-read the current
@@ -355,127 +562,203 @@ function rebuildIndex(runsRoot) {
355
562
  // TTL / cap cleanup
356
563
  // ---------------------------------------------------------------------------
357
564
  /**
358
- * Remove excess and expired terminal (completed/failed) runs.
565
+ * Remove excess and expired inactive runs.
359
566
  *
360
567
  * Called opportunistically at the end of saveRun. Throttled to at most once
361
- * per CLEANUP_INTERVAL_MS. Active runs (running/paused/blocked) are never
362
- * touched.
568
+ * per CLEANUP_INTERVAL_MS. Only actually executing (`running`) runs are never
569
+ * touched. Paused and blocked runs remain resumable/inspectable inside the
570
+ * configured retention window, but no longer bypass it forever.
363
571
  *
364
572
  * The index read-modify-write is performed under the index lock so it cannot
365
573
  * race a concurrent updateIndexEntry and clobber a freshly-added entry (M1).
366
574
  * We re-read the index *inside* the lock (rather than trusting a snapshot read
367
575
  * before locking) so the rewrite reflects the latest committed state. File and
368
- * directory unlinks happen after the lock is released to keep the critical
369
- * section short; deleting a file that is no longer in the index is harmless.
576
+ * directory unlinks happen after the index lock is released, but each candidate
577
+ * is revalidated while holding its own run lock. This prevents cleanup from
578
+ * unlinking a run that was resumed or otherwise saved after selection.
370
579
  */
371
580
  function cleanupTerminalRuns(runsRoot, maxKeep = DEFAULT_MAX_KEPT_TERMINAL, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
372
- const cleanupStarted = Date.now();
373
- const now = cleanupStarted;
374
- if (now - lastCleanupAt < CLEANUP_INTERVAL_MS)
581
+ const now = Date.now();
582
+ if (!shouldRunCleanup(runsRoot, now))
375
583
  return;
376
- lastCleanupAt = now;
377
584
  const maxAgeMs = maxAgeDays * 86_400_000;
378
585
  let toRemove = [];
379
- withLock(indexLockPath(runsRoot), () => {
380
- const entries = readIndex(runsRoot);
381
- const terminal = [];
382
- const active = [];
383
- for (const e of entries) {
384
- if (e.status === "completed" || e.status === "failed") {
385
- terminal.push(e);
386
- }
387
- else {
388
- active.push(e);
389
- }
390
- }
391
- // Sort terminal by updatedAt desc (newest first).
392
- // Filter out entries with corrupt updatedAt (non-numeric/NaN) BEFORE sorting
393
- // to prevent NaN from corrupting sort order. Corrupt entries cannot be
394
- // reliably aged, so they are always moved to toRemove.
395
- const cleanTerminal = [];
396
- for (const e of terminal) {
397
- if (typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt)) {
398
- cleanTerminal.push(e);
586
+ try {
587
+ withLock(indexLockPath(runsRoot), () => {
588
+ const entries = readIndex(runsRoot);
589
+ const terminal = [];
590
+ const active = [];
591
+ for (const e of entries) {
592
+ if (e.status !== "running") {
593
+ terminal.push(e);
594
+ }
595
+ else {
596
+ active.push(e);
597
+ }
399
598
  }
400
- else {
401
- toRemove.push(e);
599
+ // Sort terminal by updatedAt desc (newest first).
600
+ // Filter out entries with corrupt updatedAt (non-numeric/NaN) BEFORE sorting
601
+ // to prevent NaN from corrupting sort order. Corrupt entries cannot be
602
+ // reliably aged, so they are always moved to toRemove.
603
+ const cleanTerminal = [];
604
+ for (const e of terminal) {
605
+ if (typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt)) {
606
+ cleanTerminal.push(e);
607
+ }
608
+ else {
609
+ toRemove.push(e);
610
+ }
402
611
  }
403
- }
404
- cleanTerminal.sort((a, b) => b.updatedAt - a.updatedAt);
405
- for (let i = 0; i < cleanTerminal.length; i++) {
406
- const e = cleanTerminal[i];
407
- const expiredByAge = now - e.updatedAt > maxAgeMs;
408
- const excessByCount = i >= maxKeep;
409
- if (expiredByAge || excessByCount) {
410
- toRemove.push(e);
612
+ cleanTerminal.sort((a, b) => b.updatedAt - a.updatedAt);
613
+ for (let i = 0; i < cleanTerminal.length; i++) {
614
+ const e = cleanTerminal[i];
615
+ const expiredByAge = maxAgeDays > 0 && now - e.updatedAt > maxAgeMs;
616
+ const excessByCount = maxKeep > 0 && i >= maxKeep;
617
+ if (expiredByAge || excessByCount) {
618
+ toRemove.push(e);
619
+ }
411
620
  }
412
- }
413
- if (toRemove.length === 0)
414
- return;
415
- // Commit the pruned index while holding the lock so a concurrent
416
- // updateIndexEntry cannot interleave and lose entries.
417
- const remaining = cleanTerminal.filter((e) => !toRemove.includes(e));
418
- writeIndex(runsRoot, [...active, ...remaining]);
419
- });
621
+ if (toRemove.length === 0)
622
+ return;
623
+ // Commit the pruned index while holding the lock so a concurrent
624
+ // updateIndexEntry cannot interleave and lose entries.
625
+ const removalSet = new Set(toRemove);
626
+ const remaining = cleanTerminal.filter((e) => !removalSet.has(e));
627
+ writeIndex(runsRoot, [...active, ...remaining]);
628
+ }, CLEANUP_LOCK_TIMEOUT_MS);
629
+ }
630
+ catch {
631
+ // Retention is opportunistic. A busy index must not stall or fail saveRun.
632
+ return;
633
+ }
420
634
  if (toRemove.length === 0)
421
635
  return;
422
- console.warn(`[taskflow] Cleaning up ${toRemove.length} old run(s) ` +
423
- `(max ${maxKeep} runs, ${maxAgeDays} day age limit). ` +
424
- `Configure 'taskflow.maxKeptRuns' / 'taskflow.maxRunAgeDays' in settings.json (0 = keep all).`);
425
- // Delete run files + lock files (outside the index lock).
636
+ // Delete artifacts outside the index lock, but under the exact per-run lock
637
+ // used by saveRun. Only the entries whose on-disk snapshots still match the
638
+ // selected index entries are safe to remove.
639
+ const removed = [];
426
640
  for (const e of toRemove) {
427
- const filePath = path.join(runsRoot, e.relPath);
428
- // Race guard: skip files modified after cleanup started (Finding 2).
429
- try {
430
- if (fs.statSync(filePath).mtimeMs > cleanupStarted)
431
- continue;
432
- }
433
- catch {
434
- continue;
435
- }
436
- try {
437
- fs.unlinkSync(filePath);
438
- }
439
- catch { /* already gone */ }
440
- // Also remove any orphaned lock file.
441
- try {
442
- fs.unlinkSync(filePath + ".lock");
443
- }
444
- catch { /* ignore */ }
445
- // Also remove the deterministic-replay trace (sibling .trace.jsonl +
446
- // its append lock) so pruned runs don't accumulate trace files.
447
- try {
448
- fs.unlinkSync(filePath.replace(/\.json$/, ".trace.jsonl"));
449
- }
450
- catch { /* ignore */ }
451
- try {
452
- fs.unlinkSync(filePath.replace(/\.json$/, ".trace.jsonl.lock"));
453
- }
454
- catch { /* ignore */ }
455
- // Also remove the per-run Shared Context Tree directory (C6). Orphaned
456
- // ctx dirs would otherwise accumulate under runs/ctx/ over many runs.
457
- try {
458
- fs.rmSync(path.join(runsRoot, "ctx", e.runId), { recursive: true, force: true });
459
- }
460
- catch { /* ignore */ }
461
- // Also remove the per-run isolated-workspace dir tree (cwd:"dedicated").
462
- // `dedicated` workspaces are persistent by design; reclaim them once the
463
- // run is pruned. The dir name uses the same sanitization as workspace.ts.
464
- try {
465
- const wsSeg = e.runId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 100) || "phase";
466
- fs.rmSync(path.join(runsRoot, "ws", wsSeg), { recursive: true, force: true });
467
- }
468
- catch { /* ignore */ }
641
+ if (cleanupRunArtifactsIfSnapshotMatches(runsRoot, e))
642
+ removed.push(e);
643
+ }
644
+ if (removed.length > 0) {
645
+ console.warn(`[taskflow] Cleaning up ${removed.length} old run(s) ` +
646
+ `(max ${maxKeep} runs, ${maxAgeDays} day age limit). ` +
647
+ `Configure 'taskflow.maxKeptRuns' / 'taskflow.maxRunAgeDays' in settings.json (0 = keep all).`);
469
648
  }
470
649
  // Remove empty flow subdirectories.
471
- for (const e of toRemove) {
472
- const dirPath = path.dirname(path.join(runsRoot, e.relPath));
650
+ for (const e of removed) {
651
+ const dirPath = path.dirname(runIndexFilePath(runsRoot, e.relPath));
473
652
  try {
474
653
  fs.rmdirSync(dirPath);
475
654
  }
476
655
  catch { /* ENOTEMPTY or ENOENT — ignore */ }
477
656
  }
478
657
  }
658
+ /**
659
+ * Remove one retention candidate if it is still the exact state selected from
660
+ * the index. Returning false is deliberately fail-open: the run remains on
661
+ * disk, and a changed valid snapshot is restored to the index.
662
+ */
663
+ function cleanupRunArtifactsIfSnapshotMatches(runsRoot, entry) {
664
+ if (!isSafeRunIndexRelPath(entry.relPath, entry.runId))
665
+ return false;
666
+ const filePath = runIndexFilePath(runsRoot, entry.relPath);
667
+ const fileDir = path.dirname(filePath);
668
+ const directorySnapshot = physicalDirectoryInsideRunsRoot(runsRoot, fileDir);
669
+ if (!directorySnapshot)
670
+ return false;
671
+ const restore = (state) => {
672
+ try {
673
+ updateIndexEntry(runsRoot, extractIndexEntry(state, entry.relPath));
674
+ }
675
+ catch { /* best effort */ }
676
+ };
677
+ try {
678
+ return withLock(`${filePath}.lock`, () => {
679
+ if (!physicalDirectoryStillMatches(runsRoot, fileDir, directorySnapshot))
680
+ return false;
681
+ // saveRun holds this same run lock until its index upsert commits. If a
682
+ // save won the race after cleanup pruned the old entry, that fresh entry
683
+ // is now visible and owns the file even when Date.now() reused the same
684
+ // millisecond/status values. Never remove a re-indexed candidate.
685
+ const wasReindexed = withLock(indexLockPath(runsRoot), () => readIndex(runsRoot).some((current) => current.runId === entry.runId), CLEANUP_LOCK_TIMEOUT_MS);
686
+ if (wasReindexed)
687
+ return false;
688
+ let state;
689
+ let fileSnapshot;
690
+ try {
691
+ const stat = fs.lstatSync(filePath);
692
+ if (!stat.isFile() || stat.isSymbolicLink())
693
+ return false;
694
+ fileSnapshot = { device: stat.dev, inode: stat.ino };
695
+ const loaded = tryReadRunFile(runsRoot, filePath);
696
+ if (!loaded.ok)
697
+ return false;
698
+ state = loaded.value;
699
+ }
700
+ catch {
701
+ return false;
702
+ }
703
+ if (state.runId !== entry.runId)
704
+ return false;
705
+ if (state.status === "running" || state.status !== entry.status || state.updatedAt !== entry.updatedAt) {
706
+ restore(state);
707
+ return false;
708
+ }
709
+ try {
710
+ if (!physicalDirectoryStillMatches(runsRoot, fileDir, directorySnapshot)) {
711
+ restore(state);
712
+ return false;
713
+ }
714
+ const currentFile = fs.lstatSync(filePath);
715
+ if (!currentFile.isFile() || currentFile.isSymbolicLink() ||
716
+ currentFile.dev !== fileSnapshot.device || currentFile.ino !== fileSnapshot.inode) {
717
+ restore(state);
718
+ return false;
719
+ }
720
+ fs.unlinkSync(filePath);
721
+ }
722
+ catch {
723
+ restore(state);
724
+ return false;
725
+ }
726
+ // Remove deterministic-replay trace while respecting its append lock.
727
+ const tracePath = filePath.replace(/\.json$/, ".trace.jsonl");
728
+ try {
729
+ withLock(`${tracePath}.lock`, () => { try {
730
+ fs.unlinkSync(tracePath);
731
+ }
732
+ catch { /* missing */ } }, CLEANUP_LOCK_TIMEOUT_MS);
733
+ }
734
+ catch { /* best effort */ }
735
+ // Remove per-run Shared Context Tree and isolated-workspace artifacts.
736
+ removeArtifactDirectoryInsideRunsRoot(runsRoot, path.join(runsRoot, "ctx", entry.runId));
737
+ const wsSeg = entry.runId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 100) || "phase";
738
+ removeArtifactDirectoryInsideRunsRoot(runsRoot, path.join(runsRoot, "ws", wsSeg));
739
+ // Remove user-private detached control records left by an interrupted worker.
740
+ const controlCwd = controlCwdForRunsRoot(runsRoot, state.cwd);
741
+ const controlDir = detachedControlDir(controlCwd);
742
+ try {
743
+ fs.unlinkSync(path.join(controlDir, `${entry.runId}.cancel.json`));
744
+ }
745
+ catch { /* ignore */ }
746
+ try {
747
+ fs.unlinkSync(path.join(controlDir, `${entry.runId}.processes.json`));
748
+ }
749
+ catch { /* ignore */ }
750
+ try {
751
+ fs.rmdirSync(controlDir);
752
+ }
753
+ catch { /* other runs / missing */ }
754
+ return true;
755
+ }, CLEANUP_LOCK_TIMEOUT_MS);
756
+ }
757
+ catch {
758
+ // Retention is opportunistic and must never make saveRun fail.
759
+ return false;
760
+ }
761
+ }
479
762
  // ---------------------------------------------------------------------------
480
763
  // Original helpers (unchanged)
481
764
  // ---------------------------------------------------------------------------
@@ -722,6 +1005,22 @@ export function runsDir(cwd) {
722
1005
  const projDir = findProjectFlowsDir(cwd, true);
723
1006
  return path.join(projDir, "runs");
724
1007
  }
1008
+ /**
1009
+ * User-private control directory for one invocation root.
1010
+ *
1011
+ * Cancellation and process-registry markers must not live below a repository:
1012
+ * a checked-out `.pi` tree can contain symlinks controlled by project content.
1013
+ * Bind the control plane to the canonical directory identity and keep only its
1014
+ * digest in the user agent directory, so sibling worktrees remain isolated.
1015
+ */
1016
+ export function detachedControlDir(cwd) {
1017
+ const identity = directoryIdentity(cwd);
1018
+ const source = identity
1019
+ ? `${identity.canonicalPath}\0${identity.device}\0${identity.inode}`
1020
+ : path.resolve(cwd);
1021
+ const key = crypto.createHash("sha256").update(source).digest("hex");
1022
+ return path.join(getAgentDir(), "taskflow-control", key);
1023
+ }
725
1024
  /** Root dir for the cross-run memoization cache (sibling of `runs`). */
726
1025
  export function cacheDir(cwd) {
727
1026
  const projDir = findProjectFlowsDir(cwd, true);
@@ -797,8 +1096,9 @@ export function loadRunDiagnosed(cwd, runId) {
797
1096
  let corrupt = null;
798
1097
  const probe = (filePath) => {
799
1098
  const r = tryReadRunFile(root, filePath);
1099
+ // A filename/index record must never alias a different run's state.
800
1100
  if (r.ok)
801
- return r.value;
1101
+ return r.value.runId === runId ? r.value : undefined;
802
1102
  if (r.reason === "unparseable" && !corrupt)
803
1103
  corrupt = r;
804
1104
  return undefined;
@@ -901,31 +1201,30 @@ export function listRuns(cwd, limit = 20) {
901
1201
  const runIdFromName = file.replace(/\.json$/, "");
902
1202
  if (indexRunIds.has(runIdFromName))
903
1203
  continue;
904
- try {
905
- const raw = fs.readFileSync(path.join(root, file), "utf-8");
906
- const state = JSON.parse(raw);
907
- if (state && typeof state.runId === "string" && !indexRunIds.has(state.runId)) {
908
- entries.push(extractIndexEntry(state, file));
909
- indexRunIds.add(state.runId);
910
- }
1204
+ const loaded = tryReadRunFile(root, path.join(root, file));
1205
+ if (loaded.ok && validateRunId(loaded.value.runId) && file === `${loaded.value.runId}.json` &&
1206
+ !indexRunIds.has(loaded.value.runId)) {
1207
+ entries.push(extractIndexEntry(loaded.value, file));
1208
+ indexRunIds.add(loaded.value.runId);
911
1209
  }
912
- catch { /* skip corrupt */ }
913
1210
  }
914
- // Sort by updatedAt desc, slice to limit.
1211
+ // Sort by updatedAt desc. Invalid/unreadable files do not consume the limit:
1212
+ // otherwise one corrupt or replaced high-ranked entry could hide healthy
1213
+ // history that follows it.
915
1214
  // Filter out entries with non-numeric/NaN updatedAt BEFORE sorting to
916
1215
  // prevent NaN from corrupting V8's sort order (which can displace valid
917
1216
  // entries when a limit is applied).
918
1217
  const valid = entries.filter((e) => typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt));
919
1218
  valid.sort((a, b) => b.updatedAt - a.updatedAt);
920
- const sliced = valid.slice(0, limit);
1219
+ const targetLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : valid.length;
921
1220
  // Read full RunState for each entry.
922
1221
  const runs = [];
923
- for (const e of sliced) {
924
- try {
925
- const raw = fs.readFileSync(path.join(root, e.relPath), "utf-8");
926
- runs.push(JSON.parse(raw));
927
- }
928
- catch { /* file may have been deleted since index was built — skip */ }
1222
+ for (const e of valid) {
1223
+ if (runs.length >= targetLimit)
1224
+ break;
1225
+ const loaded = tryReadRunFile(root, runIndexFilePath(root, e.relPath));
1226
+ if (loaded.ok && loaded.value.runId === e.runId)
1227
+ runs.push(loaded.value);
929
1228
  }
930
1229
  // F-010: filter out records with non-numeric/NaN updatedAt.
931
1230
  return runs.filter((r) => typeof r.updatedAt === "number" && !Number.isNaN(r.updatedAt));
@@ -934,20 +1233,30 @@ export function listRuns(cwd, limit = 20) {
934
1233
  export function hashInput(...parts) {
935
1234
  return crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
936
1235
  }
937
- /**
938
- * Check whether a process with the given PID is still alive.
939
- * Uses signal 0 (no signal sent) — succeeds if the process exists and we have
940
- * permission to signal it, throws ESRCH if it doesn't exist.
941
- */
942
- export function isProcessAlive(pid) {
1236
+ export function probeProcess(pid, signalZero = (candidate, signal) => process.kill(candidate, signal)) {
1237
+ // Node/libuv rejects values outside the signed 32-bit PID range before an
1238
+ // OS probe occurs; those are definitively not live process identifiers.
1239
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fffffff)
1240
+ return "dead";
943
1241
  try {
944
- process.kill(pid, 0);
945
- return true;
1242
+ signalZero(pid, 0);
1243
+ return "alive";
946
1244
  }
947
- catch {
948
- return false;
1245
+ catch (error) {
1246
+ const code = error && typeof error === "object" && "code" in error
1247
+ ? String(error.code ?? "")
1248
+ : "";
1249
+ if (code === "ESRCH")
1250
+ return "dead";
1251
+ // EPERM proves that a process exists but its identity is not observable.
1252
+ // Unknown platform errors must likewise never terminalize a live run.
1253
+ return "unknown";
949
1254
  }
950
1255
  }
1256
+ /** Back-compatible boolean probe. Unknown is conservatively treated as alive. */
1257
+ export function isProcessAlive(pid) {
1258
+ return probeProcess(pid) !== "dead";
1259
+ }
951
1260
  /**
952
1261
  * Write a file atomically: write to a unique temp file in the same directory,
953
1262
  * then rename over the target (rename is atomic on the same filesystem). Prevents