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.
- package/README.md +5 -4
- package/dist/agents.d.ts +2 -2
- package/dist/agents.d.ts.map +1 -1
- package/dist/build-info.d.ts +48 -0
- package/dist/build-info.d.ts.map +1 -0
- package/dist/build-info.js +112 -0
- package/dist/build-info.js.map +1 -0
- package/dist/build-info.json +4 -0
- package/dist/detached-control.d.ts +52 -0
- package/dist/detached-control.d.ts.map +1 -0
- package/dist/detached-control.js +251 -0
- package/dist/detached-control.js.map +1 -0
- package/dist/detached-runner.js +183 -30
- package/dist/detached-runner.js.map +1 -1
- package/dist/exec/driver.d.ts +5 -0
- package/dist/exec/driver.d.ts.map +1 -1
- package/dist/exec/driver.js +27 -16
- package/dist/exec/driver.js.map +1 -1
- package/dist/exec/kernel-policy.d.ts.map +1 -1
- package/dist/exec/kernel-policy.js +10 -0
- package/dist/exec/kernel-policy.js.map +1 -1
- package/dist/exec/step-kinds.d.ts +14 -1
- package/dist/exec/step-kinds.d.ts.map +1 -1
- package/dist/exec/step-kinds.js +98 -3
- package/dist/exec/step-kinds.js.map +1 -1
- package/dist/exec/step.d.ts +21 -0
- package/dist/exec/step.d.ts.map +1 -1
- package/dist/exec/step.js +22 -5
- package/dist/exec/step.js.map +1 -1
- package/dist/final-output.d.ts +53 -0
- package/dist/final-output.d.ts.map +1 -0
- package/dist/final-output.js +65 -0
- package/dist/final-output.js.map +1 -0
- package/dist/flowir/canonical-hash.d.ts.map +1 -1
- package/dist/flowir/canonical-hash.js +2 -0
- package/dist/flowir/canonical-hash.js.map +1 -1
- package/dist/flowir/compile.d.ts.map +1 -1
- package/dist/flowir/compile.js +4 -0
- package/dist/flowir/compile.js.map +1 -1
- package/dist/flowir/meta.d.ts +3 -0
- package/dist/flowir/meta.d.ts.map +1 -1
- package/dist/flowir/schema.d.ts +3 -0
- package/dist/flowir/schema.d.ts.map +1 -1
- package/dist/flowir/schema.js.map +1 -1
- package/dist/flowir/translate.d.ts.map +1 -1
- package/dist/flowir/translate.js +4 -0
- package/dist/flowir/translate.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/resume.d.ts +76 -0
- package/dist/resume.d.ts.map +1 -0
- package/dist/resume.js +190 -0
- package/dist/resume.js.map +1 -0
- package/dist/runner-core.d.ts.map +1 -1
- package/dist/runner-core.js +10 -0
- package/dist/runner-core.js.map +1 -1
- package/dist/runtime/phases/parallel.d.ts +3 -0
- package/dist/runtime/phases/parallel.d.ts.map +1 -1
- package/dist/runtime/phases/parallel.js.map +1 -1
- package/dist/runtime/phases/reduce.d.ts +47 -0
- package/dist/runtime/phases/reduce.d.ts.map +1 -0
- package/dist/runtime/phases/reduce.js +195 -0
- package/dist/runtime/phases/reduce.js.map +1 -0
- package/dist/runtime.d.ts +28 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +412 -92
- package/dist/runtime.js.map +1 -1
- package/dist/schema.d.ts +67 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +177 -1
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +90 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +494 -181
- package/dist/store.js.map +1 -1
- 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
|
-
/**
|
|
70
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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.
|
|
381
|
+
const stat = fs.lstatSync(lockPath);
|
|
164
382
|
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
198
|
-
*
|
|
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
|
|
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
|
-
//
|
|
242
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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.
|
|
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
|
|
365
|
-
*
|
|
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
|
|
369
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
468
|
-
const dirPath = path.dirname(
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
|
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
|
|
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
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
941
|
-
return
|
|
1242
|
+
signalZero(pid, 0);
|
|
1243
|
+
return "alive";
|
|
942
1244
|
}
|
|
943
|
-
catch {
|
|
944
|
-
|
|
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
|