taskflow-core 0.2.1 → 0.2.3

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 (78) hide show
  1. package/README.md +5 -4
  2. package/dist/agents.d.ts +2 -2
  3. package/dist/agents.d.ts.map +1 -1
  4. package/dist/build-info.d.ts +48 -0
  5. package/dist/build-info.d.ts.map +1 -0
  6. package/dist/build-info.js +112 -0
  7. package/dist/build-info.js.map +1 -0
  8. package/dist/build-info.json +4 -0
  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 +27 -16
  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 +10 -0
  21. package/dist/exec/kernel-policy.js.map +1 -1
  22. package/dist/exec/step-kinds.d.ts +14 -1
  23. package/dist/exec/step-kinds.d.ts.map +1 -1
  24. package/dist/exec/step-kinds.js +98 -3
  25. package/dist/exec/step-kinds.js.map +1 -1
  26. package/dist/exec/step.d.ts +21 -0
  27. package/dist/exec/step.d.ts.map +1 -1
  28. package/dist/exec/step.js +22 -5
  29. package/dist/exec/step.js.map +1 -1
  30. package/dist/final-output.d.ts +53 -0
  31. package/dist/final-output.d.ts.map +1 -0
  32. package/dist/final-output.js +65 -0
  33. package/dist/final-output.js.map +1 -0
  34. package/dist/flowir/canonical-hash.d.ts.map +1 -1
  35. package/dist/flowir/canonical-hash.js +2 -0
  36. package/dist/flowir/canonical-hash.js.map +1 -1
  37. package/dist/flowir/compile.d.ts.map +1 -1
  38. package/dist/flowir/compile.js +4 -0
  39. package/dist/flowir/compile.js.map +1 -1
  40. package/dist/flowir/meta.d.ts +3 -0
  41. package/dist/flowir/meta.d.ts.map +1 -1
  42. package/dist/flowir/schema.d.ts +3 -0
  43. package/dist/flowir/schema.d.ts.map +1 -1
  44. package/dist/flowir/schema.js.map +1 -1
  45. package/dist/flowir/translate.d.ts.map +1 -1
  46. package/dist/flowir/translate.js +4 -0
  47. package/dist/flowir/translate.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/resume.d.ts +76 -0
  53. package/dist/resume.d.ts.map +1 -0
  54. package/dist/resume.js +190 -0
  55. package/dist/resume.js.map +1 -0
  56. package/dist/runner-core.d.ts.map +1 -1
  57. package/dist/runner-core.js +10 -0
  58. package/dist/runner-core.js.map +1 -1
  59. package/dist/runtime/phases/parallel.d.ts +3 -0
  60. package/dist/runtime/phases/parallel.d.ts.map +1 -1
  61. package/dist/runtime/phases/parallel.js.map +1 -1
  62. package/dist/runtime/phases/reduce.d.ts +47 -0
  63. package/dist/runtime/phases/reduce.d.ts.map +1 -0
  64. package/dist/runtime/phases/reduce.js +195 -0
  65. package/dist/runtime/phases/reduce.js.map +1 -0
  66. package/dist/runtime.d.ts +28 -1
  67. package/dist/runtime.d.ts.map +1 -1
  68. package/dist/runtime.js +412 -92
  69. package/dist/runtime.js.map +1 -1
  70. package/dist/schema.d.ts +67 -0
  71. package/dist/schema.d.ts.map +1 -1
  72. package/dist/schema.js +177 -1
  73. package/dist/schema.js.map +1 -1
  74. package/dist/store.d.ts +90 -2
  75. package/dist/store.d.ts.map +1 -1
  76. package/dist/store.js +494 -181
  77. package/dist/store.js.map +1 -1
  78. package/package.json +2 -2
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
  // ---------------------------------------------------------------------------
@@ -220,15 +431,20 @@ export function withLock(lockPath, fn) {
220
431
  // ---------------------------------------------------------------------------
221
432
  /**
222
433
  * Extract a RunIndexEntry from a RunState + computed relative path.
434
+ * Exported for tests; pure.
223
435
  */
224
- function extractIndexEntry(state, relPath) {
436
+ export function extractIndexEntry(state, relPath) {
225
437
  return {
226
438
  runId: state.runId,
227
439
  flowName: state.flowName,
440
+ cwd: state.cwd,
228
441
  status: state.status,
229
442
  createdAt: state.createdAt,
230
443
  updatedAt: state.updatedAt,
231
444
  relPath,
445
+ ...(state.host !== undefined ? { host: state.host } : {}),
446
+ ...(state.packageVersion !== undefined ? { packageVersion: state.packageVersion } : {}),
447
+ ...(state.parentRunId !== undefined ? { parentRunId: state.parentRunId } : {}),
232
448
  };
233
449
  }
234
450
  /** Read the index file; return [] on any error (missing, corrupt, etc.). */
@@ -238,8 +454,10 @@ function readIndex(runsRoot) {
238
454
  const parsed = JSON.parse(raw);
239
455
  if (!Array.isArray(parsed))
240
456
  return [];
241
- // Validate each entry minimally.
242
- 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));
243
461
  }
244
462
  catch {
245
463
  return [];
@@ -302,14 +520,10 @@ function rebuildIndex(runsRoot) {
302
520
  continue;
303
521
  }
304
522
  for (const file of files) {
305
- try {
306
- const raw = fs.readFileSync(path.join(dirPath, file), "utf-8");
307
- const state = JSON.parse(raw);
308
- if (state && typeof state.runId === "string") {
309
- entries.set(state.runId, extractIndexEntry(state, `${dirName}/${file}`));
310
- }
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}`));
311
526
  }
312
- catch { /* skip corrupt */ }
313
527
  }
314
528
  }
315
529
  // Scan legacy flat files (runs/*.json, skip index.json).
@@ -323,14 +537,11 @@ function rebuildIndex(runsRoot) {
323
537
  for (const file of flatFiles) {
324
538
  if (entries.has(file.replace(/\.json$/, "")))
325
539
  continue; // prefer subdir entry
326
- try {
327
- const raw = fs.readFileSync(path.join(runsRoot, file), "utf-8");
328
- const state = JSON.parse(raw);
329
- if (state && typeof state.runId === "string" && !entries.has(state.runId)) {
330
- entries.set(state.runId, extractIndexEntry(state, file));
331
- }
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));
332
544
  }
333
- catch { /* skip corrupt */ }
334
545
  }
335
546
  const scanned = Array.from(entries.values());
336
547
  // Persist the rebuilt index under the index lock. Re-read the current
@@ -351,127 +562,203 @@ function rebuildIndex(runsRoot) {
351
562
  // TTL / cap cleanup
352
563
  // ---------------------------------------------------------------------------
353
564
  /**
354
- * Remove excess and expired terminal (completed/failed) runs.
565
+ * Remove excess and expired inactive runs.
355
566
  *
356
567
  * Called opportunistically at the end of saveRun. Throttled to at most once
357
- * per CLEANUP_INTERVAL_MS. Active runs (running/paused/blocked) are never
358
- * 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.
359
571
  *
360
572
  * The index read-modify-write is performed under the index lock so it cannot
361
573
  * race a concurrent updateIndexEntry and clobber a freshly-added entry (M1).
362
574
  * We re-read the index *inside* the lock (rather than trusting a snapshot read
363
575
  * before locking) so the rewrite reflects the latest committed state. File and
364
- * directory unlinks happen after the lock is released to keep the critical
365
- * 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.
366
579
  */
367
580
  function cleanupTerminalRuns(runsRoot, maxKeep = DEFAULT_MAX_KEPT_TERMINAL, maxAgeDays = DEFAULT_MAX_AGE_DAYS) {
368
- const cleanupStarted = Date.now();
369
- const now = cleanupStarted;
370
- if (now - lastCleanupAt < CLEANUP_INTERVAL_MS)
581
+ const now = Date.now();
582
+ if (!shouldRunCleanup(runsRoot, now))
371
583
  return;
372
- lastCleanupAt = now;
373
584
  const maxAgeMs = maxAgeDays * 86_400_000;
374
585
  let toRemove = [];
375
- withLock(indexLockPath(runsRoot), () => {
376
- const entries = readIndex(runsRoot);
377
- const terminal = [];
378
- const active = [];
379
- for (const e of entries) {
380
- if (e.status === "completed" || e.status === "failed") {
381
- terminal.push(e);
382
- }
383
- else {
384
- active.push(e);
385
- }
386
- }
387
- // Sort terminal by updatedAt desc (newest first).
388
- // Filter out entries with corrupt updatedAt (non-numeric/NaN) BEFORE sorting
389
- // to prevent NaN from corrupting sort order. Corrupt entries cannot be
390
- // reliably aged, so they are always moved to toRemove.
391
- const cleanTerminal = [];
392
- for (const e of terminal) {
393
- if (typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt)) {
394
- 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
+ }
395
598
  }
396
- else {
397
- 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
+ }
398
611
  }
399
- }
400
- cleanTerminal.sort((a, b) => b.updatedAt - a.updatedAt);
401
- for (let i = 0; i < cleanTerminal.length; i++) {
402
- const e = cleanTerminal[i];
403
- const expiredByAge = now - e.updatedAt > maxAgeMs;
404
- const excessByCount = i >= maxKeep;
405
- if (expiredByAge || excessByCount) {
406
- 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
+ }
407
620
  }
408
- }
409
- if (toRemove.length === 0)
410
- return;
411
- // Commit the pruned index while holding the lock so a concurrent
412
- // updateIndexEntry cannot interleave and lose entries.
413
- const remaining = cleanTerminal.filter((e) => !toRemove.includes(e));
414
- writeIndex(runsRoot, [...active, ...remaining]);
415
- });
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
+ }
416
634
  if (toRemove.length === 0)
417
635
  return;
418
- console.warn(`[taskflow] Cleaning up ${toRemove.length} old run(s) ` +
419
- `(max ${maxKeep} runs, ${maxAgeDays} day age limit). ` +
420
- `Configure 'taskflow.maxKeptRuns' / 'taskflow.maxRunAgeDays' in settings.json (0 = keep all).`);
421
- // 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 = [];
422
640
  for (const e of toRemove) {
423
- const filePath = path.join(runsRoot, e.relPath);
424
- // Race guard: skip files modified after cleanup started (Finding 2).
425
- try {
426
- if (fs.statSync(filePath).mtimeMs > cleanupStarted)
427
- continue;
428
- }
429
- catch {
430
- continue;
431
- }
432
- try {
433
- fs.unlinkSync(filePath);
434
- }
435
- catch { /* already gone */ }
436
- // Also remove any orphaned lock file.
437
- try {
438
- fs.unlinkSync(filePath + ".lock");
439
- }
440
- catch { /* ignore */ }
441
- // Also remove the deterministic-replay trace (sibling .trace.jsonl +
442
- // its append lock) so pruned runs don't accumulate trace files.
443
- try {
444
- fs.unlinkSync(filePath.replace(/\.json$/, ".trace.jsonl"));
445
- }
446
- catch { /* ignore */ }
447
- try {
448
- fs.unlinkSync(filePath.replace(/\.json$/, ".trace.jsonl.lock"));
449
- }
450
- catch { /* ignore */ }
451
- // Also remove the per-run Shared Context Tree directory (C6). Orphaned
452
- // ctx dirs would otherwise accumulate under runs/ctx/ over many runs.
453
- try {
454
- fs.rmSync(path.join(runsRoot, "ctx", e.runId), { recursive: true, force: true });
455
- }
456
- catch { /* ignore */ }
457
- // Also remove the per-run isolated-workspace dir tree (cwd:"dedicated").
458
- // `dedicated` workspaces are persistent by design; reclaim them once the
459
- // run is pruned. The dir name uses the same sanitization as workspace.ts.
460
- try {
461
- const wsSeg = e.runId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "_").slice(0, 100) || "phase";
462
- fs.rmSync(path.join(runsRoot, "ws", wsSeg), { recursive: true, force: true });
463
- }
464
- 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).`);
465
648
  }
466
649
  // Remove empty flow subdirectories.
467
- for (const e of toRemove) {
468
- const dirPath = path.dirname(path.join(runsRoot, e.relPath));
650
+ for (const e of removed) {
651
+ const dirPath = path.dirname(runIndexFilePath(runsRoot, e.relPath));
469
652
  try {
470
653
  fs.rmdirSync(dirPath);
471
654
  }
472
655
  catch { /* ENOTEMPTY or ENOENT — ignore */ }
473
656
  }
474
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
+ }
475
762
  // ---------------------------------------------------------------------------
476
763
  // Original helpers (unchanged)
477
764
  // ---------------------------------------------------------------------------
@@ -718,6 +1005,22 @@ export function runsDir(cwd) {
718
1005
  const projDir = findProjectFlowsDir(cwd, true);
719
1006
  return path.join(projDir, "runs");
720
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
+ }
721
1024
  /** Root dir for the cross-run memoization cache (sibling of `runs`). */
722
1025
  export function cacheDir(cwd) {
723
1026
  const projDir = findProjectFlowsDir(cwd, true);
@@ -793,8 +1096,9 @@ export function loadRunDiagnosed(cwd, runId) {
793
1096
  let corrupt = null;
794
1097
  const probe = (filePath) => {
795
1098
  const r = tryReadRunFile(root, filePath);
1099
+ // A filename/index record must never alias a different run's state.
796
1100
  if (r.ok)
797
- return r.value;
1101
+ return r.value.runId === runId ? r.value : undefined;
798
1102
  if (r.reason === "unparseable" && !corrupt)
799
1103
  corrupt = r;
800
1104
  return undefined;
@@ -897,31 +1201,30 @@ export function listRuns(cwd, limit = 20) {
897
1201
  const runIdFromName = file.replace(/\.json$/, "");
898
1202
  if (indexRunIds.has(runIdFromName))
899
1203
  continue;
900
- try {
901
- const raw = fs.readFileSync(path.join(root, file), "utf-8");
902
- const state = JSON.parse(raw);
903
- if (state && typeof state.runId === "string" && !indexRunIds.has(state.runId)) {
904
- entries.push(extractIndexEntry(state, file));
905
- indexRunIds.add(state.runId);
906
- }
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);
907
1209
  }
908
- catch { /* skip corrupt */ }
909
1210
  }
910
- // 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.
911
1214
  // Filter out entries with non-numeric/NaN updatedAt BEFORE sorting to
912
1215
  // prevent NaN from corrupting V8's sort order (which can displace valid
913
1216
  // entries when a limit is applied).
914
1217
  const valid = entries.filter((e) => typeof e.updatedAt === "number" && !Number.isNaN(e.updatedAt));
915
1218
  valid.sort((a, b) => b.updatedAt - a.updatedAt);
916
- const sliced = valid.slice(0, limit);
1219
+ const targetLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : valid.length;
917
1220
  // Read full RunState for each entry.
918
1221
  const runs = [];
919
- for (const e of sliced) {
920
- try {
921
- const raw = fs.readFileSync(path.join(root, e.relPath), "utf-8");
922
- runs.push(JSON.parse(raw));
923
- }
924
- 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);
925
1228
  }
926
1229
  // F-010: filter out records with non-numeric/NaN updatedAt.
927
1230
  return runs.filter((r) => typeof r.updatedAt === "number" && !Number.isNaN(r.updatedAt));
@@ -930,20 +1233,30 @@ export function listRuns(cwd, limit = 20) {
930
1233
  export function hashInput(...parts) {
931
1234
  return crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
932
1235
  }
933
- /**
934
- * Check whether a process with the given PID is still alive.
935
- * Uses signal 0 (no signal sent) — succeeds if the process exists and we have
936
- * permission to signal it, throws ESRCH if it doesn't exist.
937
- */
938
- 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";
939
1241
  try {
940
- process.kill(pid, 0);
941
- return true;
1242
+ signalZero(pid, 0);
1243
+ return "alive";
942
1244
  }
943
- catch {
944
- 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";
945
1254
  }
946
1255
  }
1256
+ /** Back-compatible boolean probe. Unknown is conservatively treated as alive. */
1257
+ export function isProcessAlive(pid) {
1258
+ return probeProcess(pid) !== "dead";
1259
+ }
947
1260
  /**
948
1261
  * Write a file atomically: write to a unique temp file in the same directory,
949
1262
  * then rename over the target (rename is atomic on the same filesystem). Prevents