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.
- package/README.md +4 -3
- package/dist/agents.d.ts +2 -2
- package/dist/agents.d.ts.map +1 -1
- package/dist/build-info.json +2 -2
- package/dist/compile.d.ts +8 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +75 -9
- package/dist/compile.js.map +1 -1
- 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 +98 -52
- package/dist/exec/driver.js.map +1 -1
- package/dist/exec/kernel-policy.d.ts.map +1 -1
- package/dist/exec/kernel-policy.js +20 -20
- package/dist/exec/kernel-policy.js.map +1 -1
- package/dist/exec/step-kinds.d.ts.map +1 -1
- package/dist/exec/step-kinds.js +59 -22
- package/dist/exec/step-kinds.js.map +1 -1
- package/dist/exec/step.d.ts.map +1 -1
- package/dist/exec/step.js +44 -10
- package/dist/exec/step.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- 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.d.ts +13 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +81 -38
- package/dist/runtime.js.map +1 -1
- package/dist/store.d.ts +41 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +489 -180
- package/dist/store.js.map +1 -1
- package/dist/verifiers/discover.d.ts +44 -0
- package/dist/verifiers/discover.d.ts.map +1 -0
- package/dist/verifiers/discover.js +152 -0
- package/dist/verifiers/discover.js.map +1 -0
- package/dist/verifiers/index.d.ts +12 -0
- package/dist/verifiers/index.d.ts.map +1 -0
- package/dist/verifiers/index.js +12 -0
- package/dist/verifiers/index.js.map +1 -0
- package/dist/verifiers/script-lint.d.ts +24 -0
- package/dist/verifiers/script-lint.d.ts.map +1 -0
- package/dist/verifiers/script-lint.js +311 -0
- package/dist/verifiers/script-lint.js.map +1 -0
- package/dist/verify.d.ts +55 -3
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +201 -2
- package/dist/verify.js.map +1 -1
- 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
|
-
/**
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
-
//
|
|
246
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
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.
|
|
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
|
|
369
|
-
*
|
|
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
|
|
373
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
472
|
-
const dirPath = path.dirname(
|
|
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
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
|
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
|
|
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
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
945
|
-
return
|
|
1242
|
+
signalZero(pid, 0);
|
|
1243
|
+
return "alive";
|
|
946
1244
|
}
|
|
947
|
-
catch {
|
|
948
|
-
|
|
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
|