c8ctl-plugin-nano 1.57.0 → 1.57.1
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 +32 -3
- package/c8ctl-plugin.js +417 -20
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -892,9 +892,38 @@ quoting isn't honoured by `cmd.exe` — so use a container sandbox
|
|
|
892
892
|
(`--sandbox docker|podman`) or bake the switches into `--command` there.
|
|
893
893
|
|
|
894
894
|
**Disk hygiene.** Host job **workspaces** and container sandboxes both get
|
|
895
|
-
automatic cleanup so leaked artifacts can't fill the disk.
|
|
896
|
-
|
|
897
|
-
|
|
895
|
+
automatic cleanup so leaked artifacts can't fill the disk. Each worker
|
|
896
|
+
**process** gets its own private namespace under
|
|
897
|
+
`<state>/agent-runs/worker-<incarnation>/` (a fresh incarnation id every process
|
|
898
|
+
start, published with an immutable `owner.json` before any child dir appears);
|
|
899
|
+
its `run-*` job workspaces and `res-*` result channels live there and are removed
|
|
900
|
+
after each job and swept at startup + on `--reap-interval` (leftovers older than
|
|
901
|
+
`--reap-age`, in-flight dirs skipped). `--keep-runs` only skips the *per-job*
|
|
902
|
+
deletion (so a finished job's workspace survives for inspection); the age-based
|
|
903
|
+
owner-scoped sweep still applies, so a kept dir is eventually reaped once it ages
|
|
904
|
+
past `--reap-age`. That
|
|
905
|
+
ordinary sweep is **owner-scoped** — a worker only ever reaps *its own*
|
|
906
|
+
namespace, so it can never delete a sibling worker's active checkout or result
|
|
907
|
+
channel out from under an in-flight job (the cross-worker data-loss defect fixed
|
|
908
|
+
in [#205](https://github.com/jwulf/c8ctl-plugin-nano/issues/205); age is **not**
|
|
909
|
+
evidence of completion — editing files inside a checkout does not refresh the
|
|
910
|
+
enclosing dir's mtime). Reclaiming an *abandoned* namespace left by a crashed
|
|
911
|
+
worker is a **separate, cross-process-safe** operation: it deletes only a
|
|
912
|
+
namespace whose owning process is *provably* dead (PID-reuse-safe, via a recorded
|
|
913
|
+
process-start token) **and** has no surviving harness, under an exclusive lock
|
|
914
|
+
with a final recheck. Anything uncertain — a live/unknown owner, a possibly-alive
|
|
915
|
+
harness, missing/malformed ownership, a lock held by another reclaimer — is
|
|
916
|
+
**retained with a diagnostic**, never guessed away.
|
|
917
|
+
|
|
918
|
+
> **Mixed-version rollout.** The `worker-*` namespace is deliberately invisible
|
|
919
|
+
> to the old flat `run-*`/`res-*` sweep, and the new reclaimer never deletes
|
|
920
|
+
> unowned legacy flat `run-*`/`res-*` directories. This makes an upgrade safe
|
|
921
|
+
> while **old** worker processes are still running the pre-#205 code. Updating the
|
|
922
|
+
> package on disk does **not** replace code already loaded by a running worker:
|
|
923
|
+
> every old worker must be **drained/restarted onto the fixed version** before any
|
|
924
|
+
> leftover legacy flat directories can be cleaned up, and legacy flat dirs whose
|
|
925
|
+
> owner cannot be proven dead are never auto-migrated or deleted.
|
|
926
|
+
|
|
898
927
|
For container sandboxes a **label-scoped** reaper runs at worker startup
|
|
899
928
|
and on an interval (`--reap-interval`, **milliseconds**, default `300000` = 5m),
|
|
900
929
|
removing finished/`exited` containers older than `--reap-age` (**milliseconds**,
|
package/c8ctl-plugin.js
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
readdirSync,
|
|
43
43
|
chmodSync,
|
|
44
44
|
renameSync,
|
|
45
|
+
linkSync,
|
|
45
46
|
realpathSync,
|
|
46
47
|
statfsSync,
|
|
47
48
|
lstatSync,
|
|
@@ -4427,9 +4428,16 @@ function finalizeGit({ workspaceDir, gitEnv, startSha, workingBranch, envelope,
|
|
|
4427
4428
|
return out;
|
|
4428
4429
|
}
|
|
4429
4430
|
|
|
4430
|
-
//
|
|
4431
|
-
//
|
|
4432
|
-
//
|
|
4431
|
+
// LEGACY flat reaper (pre-issue-#205). Reaps `run-*`/`res-*` directly under the
|
|
4432
|
+
// runs ROOT — the shared-namespace design that caused the cross-worker data-loss
|
|
4433
|
+
// incident. It is NO LONGER CALLED by the worker (which now allocates a private
|
|
4434
|
+
// `worker-<incarnation>/` namespace and uses `reapOwnedNamespace` +
|
|
4435
|
+
// `reclaimOrphanNamespaces`), and is retained ONLY so that (a) a mixed-version
|
|
4436
|
+
// host with an OLD worker still running this code stays interoperable, and (b)
|
|
4437
|
+
// the mixed-version regression test can assert this flat sweep never descends
|
|
4438
|
+
// into a new `worker-*` namespace (it only matches the `run-`/`res-` prefixes at
|
|
4439
|
+
// the root, so `worker-*` dirs are invisible to it). Do not reintroduce it into
|
|
4440
|
+
// the worker cleanup path.
|
|
4433
4441
|
function reapAgentRunDirs({ maxAgeMs = 0, liveRunDirs = new Set() } = {}) {
|
|
4434
4442
|
let reaped = 0;
|
|
4435
4443
|
const root = agentRunsRoot();
|
|
@@ -4458,13 +4466,321 @@ function reapAgentRunDirs({ maxAgeMs = 0, liveRunDirs = new Set() } = {}) {
|
|
|
4458
4466
|
return { reaped };
|
|
4459
4467
|
}
|
|
4460
4468
|
|
|
4469
|
+
// ---- Cross-process-safe worker run/result namespaces (issue #205) ----------
|
|
4470
|
+
// The old shared flat `agent-runs/{run,res}-*` layout let ANY worker process's
|
|
4471
|
+
// age-gated reaper delete another live worker's active checkout or result
|
|
4472
|
+
// channel: `reapAgentRunDirs` excludes only ITS OWN caller's in-flight dirs, so
|
|
4473
|
+
// a sibling process (empty `liveRunDirs`) treated an aged-but-active dir as
|
|
4474
|
+
// garbage and removed it (data loss — see the incident in issue #205). Age is
|
|
4475
|
+
// not evidence of completion; editing files inside a checkout does not refresh
|
|
4476
|
+
// the enclosing dir's mtime.
|
|
4477
|
+
//
|
|
4478
|
+
// The fix isolates each worker PROCESS INCARNATION under its own
|
|
4479
|
+
// `agent-runs/worker-<incarnation>/` namespace and splits cleanup in two:
|
|
4480
|
+
//
|
|
4481
|
+
// * OWNER-SCOPED ordinary cleanup — a worker's startup/periodic reaper only
|
|
4482
|
+
// ever traverses its OWN namespace, where its `liveRunDirs` set is the sole
|
|
4483
|
+
// authority. It can never see, let alone delete, a sibling's dir.
|
|
4484
|
+
// * ORPHAN RECLAMATION — a separate, cross-process-safe sweep that may reclaim
|
|
4485
|
+
// ANOTHER incarnation's namespace ONLY on positive proof the owner process
|
|
4486
|
+
// is dead (PID-reuse-safe) and no harness it spawned survives, under an
|
|
4487
|
+
// exclusive lock with a final recheck. Unknown ownership/liveness always
|
|
4488
|
+
// RETAINS and logs an actionable diagnostic, never deletes.
|
|
4489
|
+
//
|
|
4490
|
+
// The `worker-` namespace prefix is deliberately invisible to the legacy flat
|
|
4491
|
+
// `run-*`/`res-*` sweep, so an OLD worker still running the pre-fix code cannot
|
|
4492
|
+
// enter a new namespace, and the new reaper never touches unowned legacy flat
|
|
4493
|
+
// dirs. See issue #205 and the mixed-version rollout note in the README.
|
|
4494
|
+
|
|
4495
|
+
const WORKER_NS_PREFIX = 'worker-';
|
|
4496
|
+
const OWNER_RECORD = 'owner.json';
|
|
4497
|
+
const RECLAIM_LOCK = '.reclaiming';
|
|
4498
|
+
const LIVE_MARKER_DIR = 'live';
|
|
4499
|
+
|
|
4500
|
+
// A process-start fingerprint for `pid`, used to defeat PID reuse: a recorded
|
|
4501
|
+
// owner is only "the same process" if the PID is alive AND its start token still
|
|
4502
|
+
// matches. Best-effort + cross-platform: Linux reads field 22 (starttime) from
|
|
4503
|
+
// `/proc/<pid>/stat`; elsewhere (macOS/BSD) it shells out to `ps -o lstart=`.
|
|
4504
|
+
// Returns null when neither source is available — callers treat a null/absent
|
|
4505
|
+
// token conservatively (cannot prove reuse ⇒ never reclaim).
|
|
4506
|
+
function pidStartToken(pid = process.pid) {
|
|
4507
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
4508
|
+
try {
|
|
4509
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf-8');
|
|
4510
|
+
// comm (field 2) is parenthesised and may itself contain spaces/parens, so
|
|
4511
|
+
// split AFTER the last ')'. starttime is overall field 22 ⇒ index 19 of the
|
|
4512
|
+
// remaining whitespace-separated fields.
|
|
4513
|
+
const rparen = stat.lastIndexOf(')');
|
|
4514
|
+
if (rparen !== -1) {
|
|
4515
|
+
const rest = stat.slice(rparen + 2).trim().split(/\s+/);
|
|
4516
|
+
const starttime = rest[19];
|
|
4517
|
+
if (starttime && /^\d+$/.test(starttime)) return `lx:${starttime}`;
|
|
4518
|
+
}
|
|
4519
|
+
} catch { /* not Linux / no procfs */ }
|
|
4520
|
+
try {
|
|
4521
|
+
const out = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf-8', timeout: 5_000 }).trim();
|
|
4522
|
+
if (out) return `ps:${out}`;
|
|
4523
|
+
} catch { /* best effort */ }
|
|
4524
|
+
return null;
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
// A fresh, unique-per-process incarnation id. NOT the reusable configured worker
|
|
4528
|
+
// name: two processes running the same profile must land in distinct namespaces.
|
|
4529
|
+
function newIncarnationId() {
|
|
4530
|
+
return `${process.pid}-${Date.now().toString(36)}-${randomBytes(6).toString('hex')}`;
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
// Sanitise an incarnation id for safe use as a single path segment.
|
|
4534
|
+
function workerNamespaceDir(incarnation, root = agentRunsRoot()) {
|
|
4535
|
+
return join(root, `${WORKER_NS_PREFIX}${String(incarnation).replace(/[^\w.#-]/g, '_')}`);
|
|
4536
|
+
}
|
|
4537
|
+
|
|
4538
|
+
function readOwnerRecord(nsDir) {
|
|
4539
|
+
try {
|
|
4540
|
+
const rec = JSON.parse(readFileSync(join(nsDir, OWNER_RECORD), 'utf-8'));
|
|
4541
|
+
if (!rec || typeof rec !== 'object' || typeof rec.incarnation !== 'string') return null;
|
|
4542
|
+
return rec;
|
|
4543
|
+
} catch { return null; }
|
|
4544
|
+
}
|
|
4545
|
+
|
|
4546
|
+
// Allocate (idempotently) this incarnation's namespace and publish its immutable
|
|
4547
|
+
// ownership record ATOMICALLY *before* any reapable child dir can exist inside
|
|
4548
|
+
// it. A concurrent reclaimer that catches the dir mid-creation sees no owner
|
|
4549
|
+
// record and RETAINS it (never reclaims an incompletely-registered namespace).
|
|
4550
|
+
function allocateWorkerNamespace({ incarnation, worker = null, pid = process.pid, pidStart = null, version = null, root = agentRunsRoot() } = {}) {
|
|
4551
|
+
const nsDir = workerNamespaceDir(incarnation, root);
|
|
4552
|
+
mkdirSync(nsDir, { recursive: true });
|
|
4553
|
+
const ownerFile = join(nsDir, OWNER_RECORD);
|
|
4554
|
+
if (!existsSync(ownerFile)) {
|
|
4555
|
+
const owner = { schema: 1, incarnation, worker, pid, pidStart, host: hostname(), createdAt: new Date().toISOString(), version };
|
|
4556
|
+
const tmp = `${ownerFile}.${process.pid}.${Date.now()}.tmp`;
|
|
4557
|
+
writeFileSync(tmp, JSON.stringify(owner, null, 2));
|
|
4558
|
+
// Publish EXCLUSIVELY: linkSync is an atomic create-if-absent on POSIX and
|
|
4559
|
+
// Windows, so a racing allocator can never overwrite an already-published
|
|
4560
|
+
// owner record (the immutability guarantee). EEXIST ⇒ a peer won the race,
|
|
4561
|
+
// which is success — the record is immutable, so whoever wrote it is fine.
|
|
4562
|
+
try {
|
|
4563
|
+
linkSync(tmp, ownerFile);
|
|
4564
|
+
} catch (err) {
|
|
4565
|
+
if (err?.code !== 'EEXIST') { try { rmSync(tmp, { force: true }); } catch { /* */ } throw err; }
|
|
4566
|
+
} finally {
|
|
4567
|
+
try { rmSync(tmp, { force: true }); } catch { /* */ }
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
mkdirSync(join(nsDir, LIVE_MARKER_DIR), { recursive: true });
|
|
4571
|
+
return { nsDir, owner: readOwnerRecord(nsDir) };
|
|
4572
|
+
}
|
|
4573
|
+
|
|
4574
|
+
// Classify a recorded owner's incarnation as 'alive' | 'dead' | 'unknown'.
|
|
4575
|
+
// PID-reuse-safe: a live PID whose start token no longer matches the record is a
|
|
4576
|
+
// DIFFERENT process ⇒ the recorded incarnation is 'dead'. A missing pid, or a
|
|
4577
|
+
// live pid we cannot re-fingerprint (null token on either side), is 'unknown' —
|
|
4578
|
+
// never 'dead' — so reclamation errs toward preservation.
|
|
4579
|
+
function incarnationLiveness(owner, { isAlive = isPidAlive, startToken = pidStartToken } = {}) {
|
|
4580
|
+
if (!owner || !Number.isInteger(owner.pid) || owner.pid <= 0) return 'unknown';
|
|
4581
|
+
let alive;
|
|
4582
|
+
try { alive = isAlive(owner.pid); } catch { return 'unknown'; }
|
|
4583
|
+
if (!alive) return 'dead';
|
|
4584
|
+
if (owner.pidStart == null) return 'unknown'; // never recorded ⇒ can't disprove reuse
|
|
4585
|
+
let current;
|
|
4586
|
+
try { current = startToken(owner.pid); } catch { current = null; }
|
|
4587
|
+
if (current == null) return 'unknown'; // can't re-fingerprint ⇒ conservative
|
|
4588
|
+
return current === owner.pidStart ? 'alive' : 'dead';
|
|
4589
|
+
}
|
|
4590
|
+
|
|
4591
|
+
function jobMarkerPath(nsDir, jobKey) {
|
|
4592
|
+
return join(nsDir, LIVE_MARKER_DIR, `${String(jobKey).replace(/[^\w.#-]/g, '_')}.json`);
|
|
4593
|
+
}
|
|
4594
|
+
|
|
4595
|
+
// Record that a job is in-flight in this namespace (a harness may be spawned).
|
|
4596
|
+
// Written BEFORE the harness starts so a crash mid-spawn still leaves evidence
|
|
4597
|
+
// that a harness could be orphaned (an empty `harnessPids` ⇒ retain conservatively).
|
|
4598
|
+
function writeJobMarker(nsDir, { jobKey, workerPid = process.pid, incarnation = null }) {
|
|
4599
|
+
try {
|
|
4600
|
+
mkdirSync(join(nsDir, LIVE_MARKER_DIR), { recursive: true });
|
|
4601
|
+
const p = jobMarkerPath(nsDir, jobKey);
|
|
4602
|
+
writeFileSync(p, JSON.stringify({ jobKey: String(jobKey), workerPid, incarnation, harnessPids: [], startedAt: new Date().toISOString() }));
|
|
4603
|
+
return p;
|
|
4604
|
+
} catch { return null; }
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
// Append the spawned harness's PID (its process-group leader) to the in-flight
|
|
4608
|
+
// job marker, so orphan reclamation can probe whether it survived the worker.
|
|
4609
|
+
function recordHarnessPid(nsDir, jobKey, pid) {
|
|
4610
|
+
if (!nsDir || !Number.isInteger(pid) || pid <= 0) return;
|
|
4611
|
+
try {
|
|
4612
|
+
const p = jobMarkerPath(nsDir, jobKey);
|
|
4613
|
+
const rec = JSON.parse(readFileSync(p, 'utf-8'));
|
|
4614
|
+
if (!Array.isArray(rec.harnessPids)) rec.harnessPids = [];
|
|
4615
|
+
if (!rec.harnessPids.includes(pid)) {
|
|
4616
|
+
rec.harnessPids.push(pid);
|
|
4617
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
4618
|
+
writeFileSync(tmp, JSON.stringify(rec));
|
|
4619
|
+
renameSync(tmp, p);
|
|
4620
|
+
}
|
|
4621
|
+
} catch { /* best effort */ }
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
function removeJobMarker(nsDir, jobKey) {
|
|
4625
|
+
try { rmSync(jobMarkerPath(nsDir, jobKey), { force: true }); } catch { /* best effort */ }
|
|
4626
|
+
}
|
|
4627
|
+
|
|
4628
|
+
function readJobMarkers(nsDir) {
|
|
4629
|
+
let names;
|
|
4630
|
+
try { names = readdirSync(join(nsDir, LIVE_MARKER_DIR)); }
|
|
4631
|
+
catch (err) {
|
|
4632
|
+
// ENOENT ⇒ the live/ dir never existed: genuinely no in-flight markers.
|
|
4633
|
+
// Any other error (EACCES, transient FS) means marker liveness is UNKNOWN,
|
|
4634
|
+
// so return a synthetic malformed marker to force a conservative retain
|
|
4635
|
+
// (never reclaim a namespace whose harness state we couldn't determine).
|
|
4636
|
+
if (err && err.code === 'ENOENT') return [];
|
|
4637
|
+
return [{ jobKey: '(unreadable)', harnessPids: [], malformed: true }];
|
|
4638
|
+
}
|
|
4639
|
+
const out = [];
|
|
4640
|
+
for (const n of names) {
|
|
4641
|
+
if (!n.endsWith('.json')) continue;
|
|
4642
|
+
try { out.push(JSON.parse(readFileSync(join(nsDir, LIVE_MARKER_DIR, n), 'utf-8'))); }
|
|
4643
|
+
catch { out.push({ jobKey: n, harnessPids: [], malformed: true }); }
|
|
4644
|
+
}
|
|
4645
|
+
return out;
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
// Does a (presumed-dead-owner) namespace still have a possibly-live harness? An
|
|
4649
|
+
// in-flight marker that is malformed or records no harness pid (a registration
|
|
4650
|
+
// race — the job started but the harness pid was not yet recorded) is treated as
|
|
4651
|
+
// possibly-live (conservative). Otherwise probe each recorded pid.
|
|
4652
|
+
function namespaceHasLiveHarness(nsDir, { isAlive = isPidAlive } = {}) {
|
|
4653
|
+
for (const m of readJobMarkers(nsDir)) {
|
|
4654
|
+
const pids = Array.isArray(m.harnessPids) ? m.harnessPids : [];
|
|
4655
|
+
if (m.malformed || pids.length === 0) return true;
|
|
4656
|
+
for (const pid of pids) { try { if (isAlive(pid)) return true; } catch { return true; } }
|
|
4657
|
+
}
|
|
4658
|
+
return false;
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
// Shared eligibility used by BOTH the startup and periodic owner-scoped sweeps
|
|
4662
|
+
// (so they can never drift): reap the `run-*`/`res-*` children of `dir` that are
|
|
4663
|
+
// (a) not in `liveRunDirs`, (b) older than `maxAgeMs`, (c) a real directory (an
|
|
4664
|
+
// lstat rejects a symlink — never followed). `onReap(path)` fires per removal for
|
|
4665
|
+
// evidence logging. Confined to `dir`; unrelated entries (owner.json, live/,
|
|
4666
|
+
// .reclaiming, operator files) are ignored.
|
|
4667
|
+
function reapChildRunDirs(dir, { maxAgeMs = 0, liveRunDirs = new Set(), now = Date.now(), onReap } = {}) {
|
|
4668
|
+
let reaped = 0;
|
|
4669
|
+
try {
|
|
4670
|
+
if (!existsSync(dir)) return { reaped };
|
|
4671
|
+
for (const name of readdirSync(dir)) {
|
|
4672
|
+
if (!name.startsWith('run-') && !name.startsWith('res-')) continue;
|
|
4673
|
+
const p = join(dir, name);
|
|
4674
|
+
if (liveRunDirs.has(p)) continue;
|
|
4675
|
+
try {
|
|
4676
|
+
const st = lstatSync(p);
|
|
4677
|
+
if (!st.isDirectory()) continue;
|
|
4678
|
+
if (maxAgeMs > 0 && now - st.mtimeMs < maxAgeMs) continue;
|
|
4679
|
+
rmSync(p, { recursive: true, force: true });
|
|
4680
|
+
reaped++;
|
|
4681
|
+
if (onReap) { try { onReap(p); } catch { /* */ } }
|
|
4682
|
+
} catch { /* skip */ }
|
|
4683
|
+
}
|
|
4684
|
+
} catch (err) {
|
|
4685
|
+
return { reaped, error: err.message };
|
|
4686
|
+
}
|
|
4687
|
+
return { reaped };
|
|
4688
|
+
}
|
|
4689
|
+
|
|
4690
|
+
// OWNER-SCOPED ordinary cleanup: reap aged, not-in-flight job/result dirs inside THIS
|
|
4691
|
+
// worker's own namespace only. Safe by construction — no sibling shares this
|
|
4692
|
+
// namespace, and `liveRunDirs` authoritatively excludes in-flight dirs.
|
|
4693
|
+
function reapOwnedNamespace(nsDir, { maxAgeMs = 0, liveRunDirs = new Set(), now = Date.now(), logger, incarnation } = {}) {
|
|
4694
|
+
return reapChildRunDirs(nsDir, {
|
|
4695
|
+
maxAgeMs,
|
|
4696
|
+
liveRunDirs,
|
|
4697
|
+
now,
|
|
4698
|
+
onReap: (p) => { if (logger) logger.info(`[reaper] ${new Date(now).toISOString()} incarnation=${incarnation ?? '?'} removed own aged, not-in-flight ${basename(p)} (owner-scoped)`); },
|
|
4699
|
+
});
|
|
4700
|
+
}
|
|
4701
|
+
|
|
4702
|
+
// ORPHAN RECLAMATION (cross-process-safe). Sweep sibling `worker-*` namespaces
|
|
4703
|
+
// and reclaim ONLY those whose owner incarnation is provably dead and which have
|
|
4704
|
+
// no surviving harness, under an exclusive per-namespace lock with a final
|
|
4705
|
+
// recheck. Everything uncertain — a missing/malformed owner record, a live or
|
|
4706
|
+
// unknown owner, a possibly-live harness, a lock held by another reclaimer, a
|
|
4707
|
+
// namespace younger than `minAgeMs`, a symlink, or a path escaping `root` — is
|
|
4708
|
+
// RETAINED with a diagnostic. Legacy flat `run-*`/`res-*` (no `worker-` prefix)
|
|
4709
|
+
// are never considered. Injectable probes (`liveness`, `harnessAlive`, `now`)
|
|
4710
|
+
// make every branch deterministically testable.
|
|
4711
|
+
function reclaimOrphanNamespaces({
|
|
4712
|
+
root = agentRunsRoot(),
|
|
4713
|
+
selfIncarnation = null,
|
|
4714
|
+
now = Date.now(),
|
|
4715
|
+
minAgeMs = 0,
|
|
4716
|
+
liveness = incarnationLiveness,
|
|
4717
|
+
harnessAlive = isPidAlive,
|
|
4718
|
+
logger,
|
|
4719
|
+
} = {}) {
|
|
4720
|
+
const reclaimed = [];
|
|
4721
|
+
const retained = [];
|
|
4722
|
+
const stamp = () => new Date(now).toISOString();
|
|
4723
|
+
const note = (name, reason, owner) => {
|
|
4724
|
+
retained.push({ name, reason });
|
|
4725
|
+
if (logger) logger.info(`[reclaim] ${stamp()} actor=${selfIncarnation ?? '?'} target=${name} owner=${owner?.incarnation ?? 'unknown'}(pid ${owner?.pid ?? '?'}) RETAINED — ${reason}`);
|
|
4726
|
+
};
|
|
4727
|
+
let rootReal;
|
|
4728
|
+
try { rootReal = realpathSync(root); } catch (err) {
|
|
4729
|
+
if (logger) logger.info(`[reclaim] ${stamp()} actor=${selfIncarnation ?? '?'} RETAINED all — could not resolve runs root ${root} (${err.code || err.message})`);
|
|
4730
|
+
return { reclaimed, retained };
|
|
4731
|
+
}
|
|
4732
|
+
let names;
|
|
4733
|
+
try { names = readdirSync(root); } catch (err) {
|
|
4734
|
+
if (logger) logger.info(`[reclaim] ${stamp()} actor=${selfIncarnation ?? '?'} RETAINED all — could not scan runs root ${root} (${err.code || err.message})`);
|
|
4735
|
+
return { reclaimed, retained };
|
|
4736
|
+
}
|
|
4737
|
+
for (const name of names) {
|
|
4738
|
+
if (!name.startsWith(WORKER_NS_PREFIX)) continue;
|
|
4739
|
+
const nsDir = join(root, name);
|
|
4740
|
+
let st;
|
|
4741
|
+
try { st = lstatSync(nsDir); } catch (err) { note(name, `could not lstat (${err.code || err.message}) — retained`, null); continue; }
|
|
4742
|
+
if (!st.isDirectory()) { note(name, 'not a directory (symlink?) — skipped', null); continue; }
|
|
4743
|
+
if (minAgeMs > 0 && now - st.mtimeMs < minAgeMs) { note(name, 'younger than min reclaim age', null); continue; }
|
|
4744
|
+
// Containment: the resolved namespace must sit directly under the resolved root.
|
|
4745
|
+
let nsReal;
|
|
4746
|
+
try { nsReal = realpathSync(nsDir); } catch (err) { note(name, `could not resolve real path (${err.code || err.message}) — retained`, null); continue; }
|
|
4747
|
+
if (dirname(nsReal) !== rootReal) { note(name, 'path escapes runs root — skipped', null); continue; }
|
|
4748
|
+
const owner = readOwnerRecord(nsDir);
|
|
4749
|
+
if (!owner) { note(name, 'missing/malformed owner record — not garbage', null); continue; }
|
|
4750
|
+
if (selfIncarnation && owner.incarnation === selfIncarnation) continue; // never our own
|
|
4751
|
+
const state = liveness(owner);
|
|
4752
|
+
if (state !== 'dead') { note(name, `owner ${state}`, owner); continue; }
|
|
4753
|
+
if (namespaceHasLiveHarness(nsDir, { isAlive: harnessAlive })) { note(name, 'a spawned harness may still be alive', owner); continue; }
|
|
4754
|
+
// Exclusive reclamation ownership: an atomic mkdir lock. A loser retains.
|
|
4755
|
+
const lock = join(nsDir, RECLAIM_LOCK);
|
|
4756
|
+
try { mkdirSync(lock); } catch { note(name, 'another reclaimer holds the lock', owner); continue; }
|
|
4757
|
+
try {
|
|
4758
|
+
// FINAL recheck under the lock: identity + liveness + harness must all hold.
|
|
4759
|
+
const owner2 = readOwnerRecord(nsDir);
|
|
4760
|
+
if (!owner2 || owner2.incarnation !== owner.incarnation) { note(name, 'ownership changed under lock', owner2 || owner); continue; }
|
|
4761
|
+
if (liveness(owner2) !== 'dead') { note(name, 'owner became alive under lock', owner2); continue; }
|
|
4762
|
+
if (namespaceHasLiveHarness(nsDir, { isAlive: harnessAlive })) { note(name, 'harness became live under lock', owner2); continue; }
|
|
4763
|
+
let real2;
|
|
4764
|
+
try { real2 = realpathSync(nsDir); } catch (err) { note(name, `could not resolve real path under lock (${err.code || err.message}) — retained`, owner2); continue; }
|
|
4765
|
+
if (dirname(real2) !== rootReal) { note(name, 'path escaped runs root under lock', owner2); continue; }
|
|
4766
|
+
rmSync(nsDir, { recursive: true, force: true });
|
|
4767
|
+
reclaimed.push({ name, owner: owner2 });
|
|
4768
|
+
if (logger) logger.info(`[reclaim] ${stamp()} actor=${selfIncarnation ?? '?'} target=${name} owner=${owner2.incarnation}(pid ${owner2.pid}) RECLAIMED — owner proven dead, no surviving harness`);
|
|
4769
|
+
} finally {
|
|
4770
|
+
// Drop the lock unless the whole namespace was reclaimed (lock gone with it).
|
|
4771
|
+
try { if (existsSync(nsDir)) rmSync(lock, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
4772
|
+
}
|
|
4773
|
+
}
|
|
4774
|
+
return { reclaimed, retained };
|
|
4775
|
+
}
|
|
4776
|
+
|
|
4461
4777
|
// ---- One-shot capture (shared by host + container executors) ---------------
|
|
4462
4778
|
const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
4463
4779
|
|
|
4464
4780
|
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
4465
4781
|
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
4466
4782
|
// uniform result. Used by both the host and container executors.
|
|
4467
|
-
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr, relayTap = null, abortSignal = null }) {
|
|
4783
|
+
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr, relayTap = null, abortSignal = null, onSpawn = null }) {
|
|
4468
4784
|
return new Promise((resolve) => {
|
|
4469
4785
|
let child;
|
|
4470
4786
|
const stdoutChunks = [];
|
|
@@ -4526,6 +4842,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
4526
4842
|
finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
|
|
4527
4843
|
return;
|
|
4528
4844
|
}
|
|
4845
|
+
if (onSpawn) { try { onSpawn(child.pid); } catch { /* best effort */ } }
|
|
4529
4846
|
|
|
4530
4847
|
// #202: a `stop --force`/abort aborts this signal — kill the harness process
|
|
4531
4848
|
// group (via the same onTimeout kill that the hard-cap/idle paths use) and
|
|
@@ -4654,7 +4971,7 @@ function ptyAvailable(ptyFactory) {
|
|
|
4654
4971
|
// spawnCaptureOneShot. A PTY merges stdout+stderr into one stream, so stderr is
|
|
4655
4972
|
// always '' here; that is expected for a live terminal. `ptyFactory` is
|
|
4656
4973
|
// injectable for tests (defaults to node-pty).
|
|
4657
|
-
function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, cols = 120, rows = 30, ptyFactory, relayTap = null, stream = false, streamPrefix = '', onStreamOut, abortSignal = null }) {
|
|
4974
|
+
function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, cols = 120, rows = 30, ptyFactory, relayTap = null, stream = false, streamPrefix = '', onStreamOut, abortSignal = null, onSpawn = null }) {
|
|
4658
4975
|
return new Promise((resolve) => {
|
|
4659
4976
|
const factory = ptyFactory || loadPtyModule();
|
|
4660
4977
|
if (!factory || typeof factory.spawn !== 'function') {
|
|
@@ -4712,6 +5029,7 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4712
5029
|
finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: `pty spawn failed: ${err?.message || err}`, truncated: false, stderrTruncated: false });
|
|
4713
5030
|
return;
|
|
4714
5031
|
}
|
|
5032
|
+
if (onSpawn) { try { onSpawn(term?.pid); } catch { /* best effort */ } }
|
|
4715
5033
|
|
|
4716
5034
|
// #202: abort (stop --force) kills the PTY and settles as aborted so the job
|
|
4717
5035
|
// is failed/yielded for immediate retry rather than left to lock-lapse.
|
|
@@ -4921,7 +5239,7 @@ const ACP_MAX_LINE_BYTES = 8 * 1024 * 1024; // 8 MiB
|
|
|
4921
5239
|
// and every caller work unchanged. Because the raw stream is JSON-RPC (not human
|
|
4922
5240
|
// output), `stdout` here is the accumulated human-readable transcript text (what
|
|
4923
5241
|
// we relay), and `stderr` is the child's real stderr (agent diagnostics).
|
|
4924
|
-
function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false, onAcpUpdate = null, abortSignal = null }) {
|
|
5242
|
+
function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false, onAcpUpdate = null, abortSignal = null, onSpawn = null }) {
|
|
4925
5243
|
return new Promise((resolve) => {
|
|
4926
5244
|
const logger = getLogger();
|
|
4927
5245
|
const humanChunks = [];
|
|
@@ -5293,6 +5611,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
5293
5611
|
finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
|
|
5294
5612
|
return;
|
|
5295
5613
|
}
|
|
5614
|
+
if (onSpawn) { try { onSpawn(child.pid); } catch { /* best effort */ } }
|
|
5296
5615
|
|
|
5297
5616
|
// #202: abort (stop --force) — finish() reaps the still-alive child via
|
|
5298
5617
|
// killTree, so settle as aborted and let it reap the ACP harness group.
|
|
@@ -5603,7 +5922,7 @@ function baseAgentEnv(profile, job) {
|
|
|
5603
5922
|
* Both paths resolve to the same result contract.
|
|
5604
5923
|
*/
|
|
5605
5924
|
function runAgentJob(profile, job, opts = {}) {
|
|
5606
|
-
const { timeoutMs, idleTimeoutMs, recoveryWindowMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory, nudgePayload = null, onAcpUpdate = null, abortSignal = null } = opts;
|
|
5925
|
+
const { timeoutMs, idleTimeoutMs, recoveryWindowMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory, nudgePayload = null, onAcpUpdate = null, abortSignal = null, onSpawn = null } = opts;
|
|
5607
5926
|
// #110: `protocol`/`permission` drive the ACP executor branch below. The
|
|
5608
5927
|
// pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
|
|
5609
5928
|
// A `nudgePayload` (#678) carries the bespoke "re-emit your result" prompt for a
|
|
@@ -5693,6 +6012,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5693
6012
|
permission,
|
|
5694
6013
|
onAcpUpdate,
|
|
5695
6014
|
abortSignal,
|
|
6015
|
+
onSpawn,
|
|
5696
6016
|
});
|
|
5697
6017
|
}
|
|
5698
6018
|
|
|
@@ -5716,6 +6036,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5716
6036
|
streamPrefix,
|
|
5717
6037
|
onStreamOut,
|
|
5718
6038
|
abortSignal,
|
|
6039
|
+
onSpawn,
|
|
5719
6040
|
});
|
|
5720
6041
|
}
|
|
5721
6042
|
|
|
@@ -5740,6 +6061,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5740
6061
|
onStreamErr,
|
|
5741
6062
|
relayTap,
|
|
5742
6063
|
abortSignal,
|
|
6064
|
+
onSpawn,
|
|
5743
6065
|
});
|
|
5744
6066
|
}
|
|
5745
6067
|
|
|
@@ -5811,6 +6133,11 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5811
6133
|
onStreamOut,
|
|
5812
6134
|
onStreamErr,
|
|
5813
6135
|
relayTap,
|
|
6136
|
+
// #205: thread the harness-PID callback through the container path too, so a
|
|
6137
|
+
// crashed worker's in-flight job marker records the spawned client PID and
|
|
6138
|
+
// orphan reclamation can tell a possibly-surviving harness from an abandoned
|
|
6139
|
+
// namespace (an empty `harnessPids` otherwise forces indefinite retention).
|
|
6140
|
+
onSpawn,
|
|
5814
6141
|
onTimeout: (child) => {
|
|
5815
6142
|
try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
|
|
5816
6143
|
try { killTree(child); } catch { /* best effort */ }
|
|
@@ -7218,15 +7545,50 @@ async function workAgent(req, flags) {
|
|
|
7218
7545
|
let reaperTimer = null;
|
|
7219
7546
|
let runDirTimer = null;
|
|
7220
7547
|
|
|
7548
|
+
// --- Cross-process-safe run/result namespace (issue #205) ----------------
|
|
7549
|
+
// Every worker PROCESS gets a fresh incarnation id and its OWN namespace under
|
|
7550
|
+
// agent-runs/worker-<incarnation>/. All of this process's run-*/res- dirs live
|
|
7551
|
+
// there, and its ordinary reaper only ever traverses that namespace, so it can
|
|
7552
|
+
// never delete a sibling worker's active checkout or result channel (the
|
|
7553
|
+
// data-loss defect this issue fixes). The immutable ownership record is
|
|
7554
|
+
// published atomically before any child dir is created.
|
|
7555
|
+
const workerIncarnation = newIncarnationId();
|
|
7556
|
+
const workerPidStart = pidStartToken(process.pid);
|
|
7557
|
+
let pluginVersion = null;
|
|
7558
|
+
try { pluginVersion = JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf-8')).version ?? null; } catch { /* best effort */ }
|
|
7559
|
+
let workerNsDir;
|
|
7560
|
+
try {
|
|
7561
|
+
({ nsDir: workerNsDir } = allocateWorkerNamespace({
|
|
7562
|
+
incarnation: workerIncarnation,
|
|
7563
|
+
worker: profile?.name ?? null,
|
|
7564
|
+
pid: process.pid,
|
|
7565
|
+
pidStart: workerPidStart,
|
|
7566
|
+
version: pluginVersion,
|
|
7567
|
+
}));
|
|
7568
|
+
logger.info(`Worker namespace ${basename(workerNsDir)} (incarnation ${workerIncarnation}, pid ${process.pid}) — run/result dirs are isolated here.`);
|
|
7569
|
+
} catch (err) {
|
|
7570
|
+
logger.error(`Could not allocate the worker run/result namespace under ${agentRunsRoot()}: ${err.message}`);
|
|
7571
|
+
process.exit(1);
|
|
7572
|
+
}
|
|
7573
|
+
|
|
7221
7574
|
// Run-dir hygiene runs regardless of sandbox: any sandbox=none job that carries
|
|
7222
|
-
// a repository clones a throwaway workspace under
|
|
7223
|
-
//
|
|
7575
|
+
// a repository clones a throwaway workspace under this worker's namespace, and a
|
|
7576
|
+
// crashed job handler can leave one behind. OWNER-SCOPED: bounded to our own
|
|
7577
|
+
// namespace, age-gated, and skipping in-flight dirs (liveRunDirs) — never a
|
|
7578
|
+
// sibling's namespace or a legacy flat run-*/res- dir. A SEPARATE,
|
|
7579
|
+
// cross-process-safe reclamation sweep handles other incarnations' abandoned namespaces.
|
|
7224
7580
|
{
|
|
7225
|
-
const initialRuns =
|
|
7226
|
-
if (initialRuns.reaped > 0) logger.info(`Reaped ${initialRuns.reaped} leftover job workspace(s) at startup.`);
|
|
7581
|
+
const initialRuns = reapOwnedNamespace(workerNsDir, { maxAgeMs: reapAgeMs, liveRunDirs, logger, incarnation: workerIncarnation });
|
|
7582
|
+
if (initialRuns.reaped > 0) logger.info(`Reaped ${initialRuns.reaped} leftover job workspace(s) in this worker's namespace at startup.`);
|
|
7583
|
+
if (initialRuns.error) logger.warn(`Startup workspace reap warning: ${initialRuns.error}`);
|
|
7584
|
+
const reclaimStartup = reclaimOrphanNamespaces({ selfIncarnation: workerIncarnation, minAgeMs: reapAgeMs, logger });
|
|
7585
|
+
if (reclaimStartup.reclaimed.length > 0) logger.info(`Reclaimed ${reclaimStartup.reclaimed.length} abandoned worker namespace(s) at startup.`);
|
|
7227
7586
|
runDirTimer = setInterval(() => {
|
|
7228
|
-
const r =
|
|
7229
|
-
if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped}
|
|
7587
|
+
const r = reapOwnedNamespace(workerNsDir, { maxAgeMs: reapAgeMs, liveRunDirs, logger, incarnation: workerIncarnation });
|
|
7588
|
+
if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped} aged, not-in-flight job workspace(s) from this worker's namespace.`);
|
|
7589
|
+
if (r.error) logger.warn(`Workspace reaper warning: ${r.error}`);
|
|
7590
|
+
const rc = reclaimOrphanNamespaces({ selfIncarnation: workerIncarnation, minAgeMs: reapAgeMs, logger });
|
|
7591
|
+
if (rc.reclaimed.length > 0) logger.info(`Reclaimed ${rc.reclaimed.length} abandoned worker namespace(s).`);
|
|
7230
7592
|
}, reapIntervalMs);
|
|
7231
7593
|
if (typeof runDirTimer.unref === 'function') runDirTimer.unref();
|
|
7232
7594
|
}
|
|
@@ -7724,8 +8086,8 @@ async function workAgent(req, flags) {
|
|
|
7724
8086
|
const authRef = envelope.repository.authRef;
|
|
7725
8087
|
repoToken = githubCloneToken({ provider, authRef, secretResolver }); // absent → anonymous clone
|
|
7726
8088
|
try {
|
|
7727
|
-
mkdirSync(
|
|
7728
|
-
runDir = mkdtempSync(join(
|
|
8089
|
+
mkdirSync(workerNsDir, { recursive: true });
|
|
8090
|
+
runDir = mkdtempSync(join(workerNsDir, 'run-'));
|
|
7729
8091
|
liveRunDirs.add(runDir);
|
|
7730
8092
|
provisioned = provisionRepo({ envelope, token: repoToken, runDir, timeoutMs: cloneTimeoutMs });
|
|
7731
8093
|
if (provisioned.baseFetchError) {
|
|
@@ -7774,14 +8136,14 @@ async function workAgent(req, flags) {
|
|
|
7774
8136
|
// `cd` to a known absolute path). True confinement is the container
|
|
7775
8137
|
// increment; a provisioned repository envelope stays the preferred path.
|
|
7776
8138
|
try {
|
|
7777
|
-
mkdirSync(
|
|
7778
|
-
runDir = mkdtempSync(join(
|
|
8139
|
+
mkdirSync(workerNsDir, { recursive: true });
|
|
8140
|
+
runDir = mkdtempSync(join(workerNsDir, 'run-'));
|
|
7779
8141
|
liveRunDirs.add(runDir);
|
|
7780
8142
|
cwd = runDir;
|
|
7781
8143
|
} catch (err) {
|
|
7782
8144
|
if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); runDir = null; }
|
|
7783
8145
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
7784
|
-
const msg = `could not create a temp workspace under the
|
|
8146
|
+
const msg = `could not create a temp workspace under the worker namespace: ${err.message}`;
|
|
7785
8147
|
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
7786
8148
|
return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
7787
8149
|
}
|
|
@@ -7805,18 +8167,29 @@ async function workAgent(req, flags) {
|
|
|
7805
8167
|
let resultFile = null;
|
|
7806
8168
|
try {
|
|
7807
8169
|
try {
|
|
7808
|
-
mkdirSync(
|
|
7809
|
-
resultDir = mkdtempSync(join(
|
|
8170
|
+
mkdirSync(workerNsDir, { recursive: true });
|
|
8171
|
+
resultDir = mkdtempSync(join(workerNsDir, 'res-'));
|
|
7810
8172
|
resultFile = join(resultDir, 'result.json');
|
|
7811
8173
|
// Track it so the run-dir reaper skips it while in-flight and reaps it
|
|
7812
8174
|
// (as a `res-*` dir) if this worker crashes before the cleanup below.
|
|
7813
8175
|
liveRunDirs.add(resultDir);
|
|
7814
8176
|
} catch { resultDir = null; resultFile = null; }
|
|
7815
8177
|
|
|
8178
|
+
// Record an in-flight job marker in this worker's namespace BEFORE the
|
|
8179
|
+
// harness starts (#205). A later orphan reclamation of a dead owner uses
|
|
8180
|
+
// it to detect a possibly-surviving harness: an empty `harnessPids`
|
|
8181
|
+
// (registration race) forces conservative retention; once `onSpawn`
|
|
8182
|
+
// records the harness PID, reclamation can probe whether it outlived us.
|
|
8183
|
+
writeJobMarker(workerNsDir, { jobKey: job.jobKey, workerPid: process.pid, incarnation: workerIncarnation });
|
|
8184
|
+
|
|
7816
8185
|
const runOpts = {
|
|
7817
8186
|
timeoutMs: effectiveHardCapMs,
|
|
7818
8187
|
idleTimeoutMs: effectiveIdleTimeoutMs,
|
|
7819
8188
|
recoveryWindowMs: effectiveRecoveryWindowMs,
|
|
8189
|
+
// #205: record the spawned harness's PID (its process-group leader) in
|
|
8190
|
+
// the in-flight job marker so cross-process orphan reclamation can tell
|
|
8191
|
+
// a still-running orphaned harness from a genuinely abandoned namespace.
|
|
8192
|
+
onSpawn: (pid) => recordHarnessPid(workerNsDir, job.jobKey, pid),
|
|
7820
8193
|
// #202: the supervisor fiber's interruption AbortSignal (a
|
|
7821
8194
|
// `stop --force`/abort). runAgentJob wires it to killTree the harness
|
|
7822
8195
|
// process group so an abort CANCELS the work instead of orphaning the
|
|
@@ -7931,6 +8304,10 @@ async function workAgent(req, flags) {
|
|
|
7931
8304
|
}
|
|
7932
8305
|
} finally {
|
|
7933
8306
|
if (isContainer) liveRunIds.delete(runId);
|
|
8307
|
+
// #205: clear the in-flight job marker now the harness has stopped — the
|
|
8308
|
+
// owning lifecycle's finally is the authoritative "job finished" signal,
|
|
8309
|
+
// so this namespace no longer holds a surviving harness for this job.
|
|
8310
|
+
removeJobMarker(workerNsDir, job.jobKey);
|
|
7934
8311
|
if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
7935
8312
|
if (runDir) liveRunDirs.delete(runDir);
|
|
7936
8313
|
// Emit the relay session's `phase:close` lifecycle event and drain its
|
|
@@ -8189,6 +8566,12 @@ async function workAgent(req, flags) {
|
|
|
8189
8566
|
} catch (err) {
|
|
8190
8567
|
logger.warn(`supervisor shutdown error — runtime loop may not have shut down cleanly: ${err?.message || err}`);
|
|
8191
8568
|
}
|
|
8569
|
+
// #205: tear down this worker's own namespace on a clean exit. All in-flight
|
|
8570
|
+
// jobs' finallys have run by now (markers cleared, run/res dirs removed), so
|
|
8571
|
+
// nothing here is in use. Skipped under --keep-runs so kept workspaces stay
|
|
8572
|
+
// for debugging and age out via cross-process reclamation, exactly like the
|
|
8573
|
+
// pre-fix age-gated behavior.
|
|
8574
|
+
if (workerNsDir && !keepRuns) { try { rmSync(workerNsDir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
8192
8575
|
resolve();
|
|
8193
8576
|
};
|
|
8194
8577
|
|
|
@@ -13422,6 +13805,20 @@ export {
|
|
|
13422
13805
|
isPlaceholderEmail,
|
|
13423
13806
|
postAgentAttribution,
|
|
13424
13807
|
reapAgentRunDirs,
|
|
13808
|
+
reapChildRunDirs,
|
|
13809
|
+
reapOwnedNamespace,
|
|
13810
|
+
reclaimOrphanNamespaces,
|
|
13811
|
+
allocateWorkerNamespace,
|
|
13812
|
+
workerNamespaceDir,
|
|
13813
|
+
readOwnerRecord,
|
|
13814
|
+
incarnationLiveness,
|
|
13815
|
+
pidStartToken,
|
|
13816
|
+
newIncarnationId,
|
|
13817
|
+
writeJobMarker,
|
|
13818
|
+
recordHarnessPid,
|
|
13819
|
+
removeJobMarker,
|
|
13820
|
+
readJobMarkers,
|
|
13821
|
+
namespaceHasLiveHarness,
|
|
13425
13822
|
authUrl,
|
|
13426
13823
|
githubCloneToken,
|
|
13427
13824
|
ghAuthTokenFromCli,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.57.
|
|
3
|
+
"version": "1.57.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
|
|
6
6
|
"main": "c8ctl-plugin.js",
|
|
@@ -72,12 +72,12 @@
|
|
|
72
72
|
},
|
|
73
73
|
"optionalDependencies": {
|
|
74
74
|
"node-pty": "^1.0.0",
|
|
75
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.57.
|
|
76
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.57.
|
|
77
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.57.
|
|
78
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.57.
|
|
79
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.57.
|
|
80
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.57.
|
|
81
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.57.
|
|
75
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.57.1",
|
|
76
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.57.1",
|
|
77
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.57.1",
|
|
78
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.57.1",
|
|
79
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.57.1",
|
|
80
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.57.1",
|
|
81
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.57.1"
|
|
82
82
|
}
|
|
83
83
|
}
|