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.
Files changed (3) hide show
  1. package/README.md +32 -3
  2. package/c8ctl-plugin.js +417 -20
  3. 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. Workspaces under
896
- `<state>/agent-runs` are removed after each job and swept at startup + on
897
- `--reap-interval` (leftovers older than `--reap-age`, in-flight dirs skipped).
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
- // Reap leftover job workspaces under the runs root. Age-gated, skips in-flight
4431
- // run dirs, best-effort, bounded to our own directory (never touches anything we
4432
- // did not create).
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 the runs root, and a crashed
7223
- // worker can leave one behind. Bounded to our own directory, age-gated.
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 = reapAgentRunDirs({ maxAgeMs: reapAgeMs, liveRunDirs });
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 = reapAgentRunDirs({ maxAgeMs: reapAgeMs, liveRunDirs });
7229
- if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped} finished job workspace(s).`);
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(agentRunsRoot(), { recursive: true });
7728
- runDir = mkdtempSync(join(agentRunsRoot(), 'run-'));
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(agentRunsRoot(), { recursive: true });
7778
- runDir = mkdtempSync(join(agentRunsRoot(), 'run-'));
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 runs root: ${err.message}`;
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(agentRunsRoot(), { recursive: true });
7809
- resultDir = mkdtempSync(join(agentRunsRoot(), 'res-'));
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.0",
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.0",
76
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.57.0",
77
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.57.0",
78
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.57.0",
79
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.57.0",
80
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.57.0",
81
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.57.0"
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
  }