c8ctl-plugin-nano 1.56.4 → 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/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,
@@ -141,6 +142,10 @@ const READINESS_TIMEOUT_MS = 60_000;
141
142
  const READINESS_POLL_MS = 500;
142
143
  const HEALTH_TIMEOUT_MS = 1_500;
143
144
  const STOP_GRACE_MS = 8_000;
145
+ // #202: the signal a graceful `supervisor stop`/`workforce stop` sends each
146
+ // `nano work` child to quiesce it — stop leasing new jobs, finish in-flight work,
147
+ // then exit. SIGTERM/SIGINT remain the FORCE abort (kill harness, yield jobs).
148
+ const SUPERVISOR_DRAIN_SIGNAL = 'SIGUSR2';
144
149
  // Upper bound on one `--auto` engine-read reconcile (enumerate deployed
145
150
  // definitions + fetch each BPMN). A read that stalls past this is treated as a
146
151
  // transient failure so the running poller set is KEPT and, crucially, shutdown
@@ -4423,9 +4428,16 @@ function finalizeGit({ workspaceDir, gitEnv, startSha, workingBranch, envelope,
4423
4428
  return out;
4424
4429
  }
4425
4430
 
4426
- // Reap leftover job workspaces under the runs root. Age-gated, skips in-flight
4427
- // run dirs, best-effort, bounded to our own directory (never touches anything we
4428
- // 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.
4429
4441
  function reapAgentRunDirs({ maxAgeMs = 0, liveRunDirs = new Set() } = {}) {
4430
4442
  let reaped = 0;
4431
4443
  const root = agentRunsRoot();
@@ -4454,13 +4466,321 @@ function reapAgentRunDirs({ maxAgeMs = 0, liveRunDirs = new Set() } = {}) {
4454
4466
  return { reaped };
4455
4467
  }
4456
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
+
4457
4777
  // ---- One-shot capture (shared by host + container executors) ---------------
4458
4778
  const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
4459
4779
 
4460
4780
  // Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
4461
4781
  // timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
4462
4782
  // uniform result. Used by both the host and container executors.
4463
- function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr, relayTap = 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 }) {
4464
4784
  return new Promise((resolve) => {
4465
4785
  let child;
4466
4786
  const stdoutChunks = [];
@@ -4472,6 +4792,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
4472
4792
  let settled = false;
4473
4793
  let timer = null;
4474
4794
  let idleMon = null;
4795
+ let onAbort = null;
4475
4796
 
4476
4797
  // Live "spy" tee (--stream): mirror the child's output line-by-line to a
4477
4798
  // caller-supplied emitter (the worker routes these through c8ctl's
@@ -4509,6 +4830,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
4509
4830
  settled = true;
4510
4831
  if (timer) clearTimeout(timer);
4511
4832
  if (idleMon) idleMon.stop();
4833
+ if (onAbort && abortSignal) { try { abortSignal.removeEventListener('abort', onAbort); } catch { /* best effort */ } onAbort = null; }
4512
4834
  if (teeOut) teeOut('', true);
4513
4835
  if (teeErr) teeErr('', true);
4514
4836
  resolve(result);
@@ -4520,6 +4842,25 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
4520
4842
  finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
4521
4843
  return;
4522
4844
  }
4845
+ if (onSpawn) { try { onSpawn(child.pid); } catch { /* best effort */ } }
4846
+
4847
+ // #202: a `stop --force`/abort aborts this signal — kill the harness process
4848
+ // group (via the same onTimeout kill that the hard-cap/idle paths use) and
4849
+ // settle as aborted so the worker fails/yields the job for immediate retry
4850
+ // instead of letting the detached child outlive the interrupt (orphaned to
4851
+ // init) and the lock lapse.
4852
+ if (abortSignal) {
4853
+ if (abortSignal.aborted) {
4854
+ try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
4855
+ finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: 'aborted', aborted: true, truncated: stdoutTruncated, stderrTruncated });
4856
+ return;
4857
+ }
4858
+ onAbort = () => {
4859
+ try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
4860
+ finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: 'aborted', aborted: true, truncated: stdoutTruncated, stderrTruncated });
4861
+ };
4862
+ abortSignal.addEventListener('abort', onAbort);
4863
+ }
4523
4864
 
4524
4865
  timer = timeoutMs && timeoutMs > 0
4525
4866
  ? setTimeout(() => {
@@ -4630,7 +4971,7 @@ function ptyAvailable(ptyFactory) {
4630
4971
  // spawnCaptureOneShot. A PTY merges stdout+stderr into one stream, so stderr is
4631
4972
  // always '' here; that is expected for a live terminal. `ptyFactory` is
4632
4973
  // injectable for tests (defaults to node-pty).
4633
- function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, cols = 120, rows = 30, ptyFactory, relayTap = null, stream = false, streamPrefix = '', onStreamOut }) {
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 }) {
4634
4975
  return new Promise((resolve) => {
4635
4976
  const factory = ptyFactory || loadPtyModule();
4636
4977
  if (!factory || typeof factory.spawn !== 'function') {
@@ -4646,6 +4987,7 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
4646
4987
  let idleMon = null;
4647
4988
  let detachSteer = null;
4648
4989
  let term;
4990
+ let onAbort = null;
4649
4991
 
4650
4992
  // Live "spy" tee (--stream), line-buffered, mirroring spawnCaptureOneShot.
4651
4993
  const STREAM_TEE_LINE_CAP = 64 * 1024;
@@ -4676,6 +5018,7 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
4676
5018
  if (timer) clearTimeout(timer);
4677
5019
  if (idleMon) idleMon.stop();
4678
5020
  if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
5021
+ if (onAbort && abortSignal) { try { abortSignal.removeEventListener('abort', onAbort); } catch { /* best effort */ } onAbort = null; }
4679
5022
  if (teeSink) tee('', true);
4680
5023
  resolve(result);
4681
5024
  };
@@ -4686,6 +5029,19 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
4686
5029
  finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: `pty spawn failed: ${err?.message || err}`, truncated: false, stderrTruncated: false });
4687
5030
  return;
4688
5031
  }
5032
+ if (onSpawn) { try { onSpawn(term?.pid); } catch { /* best effort */ } }
5033
+
5034
+ // #202: abort (stop --force) kills the PTY and settles as aborted so the job
5035
+ // is failed/yielded for immediate retry rather than left to lock-lapse.
5036
+ if (abortSignal) {
5037
+ const abortNow = () => {
5038
+ killTerm();
5039
+ finish({ ok: false, exitCode: null, stdout: joinCapped(chunks), stderr: '', error: 'aborted', aborted: true, truncated, stderrTruncated: false });
5040
+ };
5041
+ if (abortSignal.aborted) { abortNow(); return; }
5042
+ onAbort = abortNow;
5043
+ abortSignal.addEventListener('abort', onAbort);
5044
+ }
4689
5045
 
4690
5046
  timer = timeoutMs && timeoutMs > 0
4691
5047
  ? setTimeout(() => {
@@ -4883,7 +5239,7 @@ const ACP_MAX_LINE_BYTES = 8 * 1024 * 1024; // 8 MiB
4883
5239
  // and every caller work unchanged. Because the raw stream is JSON-RPC (not human
4884
5240
  // output), `stdout` here is the accumulated human-readable transcript text (what
4885
5241
  // we relay), and `stderr` is the child's real stderr (agent diagnostics).
4886
- function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false, onAcpUpdate = 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 }) {
4887
5243
  return new Promise((resolve) => {
4888
5244
  const logger = getLogger();
4889
5245
  const humanChunks = [];
@@ -4897,6 +5253,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4897
5253
  let idleMon = null;
4898
5254
  let detachSteer = null;
4899
5255
  let child;
5256
+ let onAbort = null;
4900
5257
  let sessionId = null;
4901
5258
  let nextId = 1;
4902
5259
  const pending = new Map();
@@ -4985,6 +5342,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4985
5342
  if (idleMon) idleMon.stop();
4986
5343
  if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
4987
5344
  if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
5345
+ if (onAbort && abortSignal) { try { abortSignal.removeEventListener('abort', onAbort); } catch { /* best effort */ } onAbort = null; }
4988
5346
  // #137: a turn that completed but produced no mappable session/update gets a
4989
5347
  // synthesised structured floor so the cockpit drill-in isn't empty. Only for
4990
5348
  // a resolved turn (`promptResolved`) — a handshake/spawn failure has nothing
@@ -5253,6 +5611,16 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
5253
5611
  finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
5254
5612
  return;
5255
5613
  }
5614
+ if (onSpawn) { try { onSpawn(child.pid); } catch { /* best effort */ } }
5615
+
5616
+ // #202: abort (stop --force) — finish() reaps the still-alive child via
5617
+ // killTree, so settle as aborted and let it reap the ACP harness group.
5618
+ if (abortSignal) {
5619
+ const abortNow = () => finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: 'aborted', aborted: true, truncated: humanTruncated, stderrTruncated });
5620
+ if (abortSignal.aborted) { abortNow(); return; }
5621
+ onAbort = abortNow;
5622
+ abortSignal.addEventListener('abort', onAbort);
5623
+ }
5256
5624
 
5257
5625
  timer = timeoutMs && timeoutMs > 0
5258
5626
  ? setTimeout(() => {
@@ -5554,7 +5922,7 @@ function baseAgentEnv(profile, job) {
5554
5922
  * Both paths resolve to the same result contract.
5555
5923
  */
5556
5924
  function runAgentJob(profile, job, opts = {}) {
5557
- 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 } = 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;
5558
5926
  // #110: `protocol`/`permission` drive the ACP executor branch below. The
5559
5927
  // pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
5560
5928
  // A `nudgePayload` (#678) carries the bespoke "re-emit your result" prompt for a
@@ -5643,6 +6011,8 @@ function runAgentJob(profile, job, opts = {}) {
5643
6011
  onStreamErr,
5644
6012
  permission,
5645
6013
  onAcpUpdate,
6014
+ abortSignal,
6015
+ onSpawn,
5646
6016
  });
5647
6017
  }
5648
6018
 
@@ -5665,6 +6035,8 @@ function runAgentJob(profile, job, opts = {}) {
5665
6035
  stream,
5666
6036
  streamPrefix,
5667
6037
  onStreamOut,
6038
+ abortSignal,
6039
+ onSpawn,
5668
6040
  });
5669
6041
  }
5670
6042
 
@@ -5688,6 +6060,8 @@ function runAgentJob(profile, job, opts = {}) {
5688
6060
  onStreamOut,
5689
6061
  onStreamErr,
5690
6062
  relayTap,
6063
+ abortSignal,
6064
+ onSpawn,
5691
6065
  });
5692
6066
  }
5693
6067
 
@@ -5759,10 +6133,16 @@ function runAgentJob(profile, job, opts = {}) {
5759
6133
  onStreamOut,
5760
6134
  onStreamErr,
5761
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,
5762
6141
  onTimeout: (child) => {
5763
6142
  try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
5764
6143
  try { killTree(child); } catch { /* best effort */ }
5765
6144
  },
6145
+ abortSignal,
5766
6146
  });
5767
6147
  }
5768
6148
 
@@ -7165,15 +7545,50 @@ async function workAgent(req, flags) {
7165
7545
  let reaperTimer = null;
7166
7546
  let runDirTimer = null;
7167
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
+
7168
7574
  // Run-dir hygiene runs regardless of sandbox: any sandbox=none job that carries
7169
- // a repository clones a throwaway workspace under the runs root, and a crashed
7170
- // 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.
7171
7580
  {
7172
- const initialRuns = reapAgentRunDirs({ maxAgeMs: reapAgeMs, liveRunDirs });
7173
- 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.`);
7174
7586
  runDirTimer = setInterval(() => {
7175
- const r = reapAgentRunDirs({ maxAgeMs: reapAgeMs, liveRunDirs });
7176
- 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).`);
7177
7592
  }, reapIntervalMs);
7178
7593
  if (typeof runDirTimer.unref === 'function') runDirTimer.unref();
7179
7594
  }
@@ -7376,7 +7791,7 @@ async function workAgent(req, flags) {
7376
7791
  // dispatch claim/release lifecycle keyed by this worker's instance drives the
7377
7792
  // cockpit's jobKeys — so the recorders no longer poke a per-process channel.
7378
7793
  const recordJobStart = (job, jobType) => {
7379
- activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now() });
7794
+ activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now(), retries: Number(job.retries) });
7380
7795
  writeActivity();
7381
7796
  };
7382
7797
  const recordJobEnd = (job) => {
@@ -7525,7 +7940,7 @@ async function workAgent(req, flags) {
7525
7940
  // the per-process 1+N reconcile crawl, and the per-job lock extender.
7526
7941
  let settle;
7527
7942
  const runner = {
7528
- run: async (job) => {
7943
+ run: async (job, abortSignal) => {
7529
7944
  const jobType = job.type;
7530
7945
  recordJobStart(job, jobType);
7531
7946
  try {
@@ -7671,8 +8086,8 @@ async function workAgent(req, flags) {
7671
8086
  const authRef = envelope.repository.authRef;
7672
8087
  repoToken = githubCloneToken({ provider, authRef, secretResolver }); // absent → anonymous clone
7673
8088
  try {
7674
- mkdirSync(agentRunsRoot(), { recursive: true });
7675
- runDir = mkdtempSync(join(agentRunsRoot(), 'run-'));
8089
+ mkdirSync(workerNsDir, { recursive: true });
8090
+ runDir = mkdtempSync(join(workerNsDir, 'run-'));
7676
8091
  liveRunDirs.add(runDir);
7677
8092
  provisioned = provisionRepo({ envelope, token: repoToken, runDir, timeoutMs: cloneTimeoutMs });
7678
8093
  if (provisioned.baseFetchError) {
@@ -7721,14 +8136,14 @@ async function workAgent(req, flags) {
7721
8136
  // `cd` to a known absolute path). True confinement is the container
7722
8137
  // increment; a provisioned repository envelope stays the preferred path.
7723
8138
  try {
7724
- mkdirSync(agentRunsRoot(), { recursive: true });
7725
- runDir = mkdtempSync(join(agentRunsRoot(), 'run-'));
8139
+ mkdirSync(workerNsDir, { recursive: true });
8140
+ runDir = mkdtempSync(join(workerNsDir, 'run-'));
7726
8141
  liveRunDirs.add(runDir);
7727
8142
  cwd = runDir;
7728
8143
  } catch (err) {
7729
8144
  if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); runDir = null; }
7730
8145
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
7731
- 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}`;
7732
8147
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
7733
8148
  return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7734
8149
  }
@@ -7752,18 +8167,35 @@ async function workAgent(req, flags) {
7752
8167
  let resultFile = null;
7753
8168
  try {
7754
8169
  try {
7755
- mkdirSync(agentRunsRoot(), { recursive: true });
7756
- resultDir = mkdtempSync(join(agentRunsRoot(), 'res-'));
8170
+ mkdirSync(workerNsDir, { recursive: true });
8171
+ resultDir = mkdtempSync(join(workerNsDir, 'res-'));
7757
8172
  resultFile = join(resultDir, 'result.json');
7758
8173
  // Track it so the run-dir reaper skips it while in-flight and reaps it
7759
8174
  // (as a `res-*` dir) if this worker crashes before the cleanup below.
7760
8175
  liveRunDirs.add(resultDir);
7761
8176
  } catch { resultDir = null; resultFile = null; }
7762
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
+
7763
8185
  const runOpts = {
7764
8186
  timeoutMs: effectiveHardCapMs,
7765
8187
  idleTimeoutMs: effectiveIdleTimeoutMs,
7766
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),
8193
+ // #202: the supervisor fiber's interruption AbortSignal (a
8194
+ // `stop --force`/abort). runAgentJob wires it to killTree the harness
8195
+ // process group so an abort CANCELS the work instead of orphaning the
8196
+ // detached agent grandchild to init. Absent (undefined) on the normal
8197
+ // and graceful-drain paths, where the harness runs to completion.
8198
+ abortSignal,
7767
8199
  envelope,
7768
8200
  sandbox,
7769
8201
  image,
@@ -7799,6 +8231,17 @@ async function workAgent(req, flags) {
7799
8231
  };
7800
8232
  result = await runAgentJob(profile, job, runOpts);
7801
8233
 
8234
+ // #202: a `stop --force`/abort killed the harness mid-run. Do NOT settle
8235
+ // here — the worker's force-stop handler yields the job (settle.fail with
8236
+ // its retries preserved) so it is immediately retryable, and the process
8237
+ // is about to exit. Settling here (or nudging/finalizing git) would race
8238
+ // that yield. The finally below still runs (container/run-dir/relay
8239
+ // cleanup); recordJobEnd fires in the outer finally.
8240
+ if (result && result.aborted) {
8241
+ logger.warn(`[${jobType}] job ${job.jobKey} aborted (worker force-stop) — harness killed; yielding job for retry.`);
8242
+ return;
8243
+ }
8244
+
7802
8245
  // Gap 2 (#678): a clean run that emitted no machine-readable result gets
7803
8246
  // ONE bounded re-emit nudge in the same workspace, feeding back its own
7804
8247
  // output, before we accept an empty result. Runs before finalizeGit so
@@ -7861,6 +8304,10 @@ async function workAgent(req, flags) {
7861
8304
  }
7862
8305
  } finally {
7863
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);
7864
8311
  if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
7865
8312
  if (runDir) liveRunDirs.delete(runDir);
7866
8313
  // Emit the relay session's `phase:close` lifecycle event and drain its
@@ -8087,23 +8534,29 @@ async function workAgent(req, flags) {
8087
8534
  });
8088
8535
  }
8089
8536
 
8090
- // Keep the process alive until a stop signal, then interrupt the runtime loop
8091
- // and tear down visibility. Interrupting the supervisor fiber runs the runtime's
8092
- // bracketed teardown (release slots, stop the heartbeat + agentic scope).
8537
+ // Keep the process alive until a stop signal. Three stop modes (issue #202):
8538
+ // - SIGUSR2 → GRACEFUL DRAIN: quiesce the activation loop (lease no new job),
8539
+ // wait for in-flight jobs to settle normally, then interrupt the idle runtime
8540
+ // and exit 0. No harness is killed — work completes.
8541
+ // - SIGTERM / SIGINT → FORCE ABORT: interrupt the runtime (which aborts each
8542
+ // running job's AbortSignal → runAgentJob killTree's the harness process
8543
+ // group) and yield each in-flight job (settle.fail, retries preserved) so it
8544
+ // is immediately retryable, then exit.
8545
+ // A SIGTERM arriving mid-drain ESCALATES the drain to a force abort.
8093
8546
  await new Promise((resolve) => {
8094
- const stop = async (signal) => {
8095
- if (draining) return;
8096
- draining = true;
8097
- if (!autoMode) unwatchFile(configFile);
8098
- logger.info(`Received ${signal} — stopping worker...`);
8547
+ let quiescing = false;
8548
+ let aborting = false;
8549
+ let finished = false;
8550
+
8551
+ // Shared teardown: stop timers, deregister presence over the still-live
8552
+ // connection, interrupt the runtime fiber (bracketed teardown: release slots,
8553
+ // stop heartbeats, tear down the agentic scope), then resolve.
8554
+ const teardownAndExit = async (signal) => {
8555
+ if (finished) return;
8556
+ finished = true;
8557
+ if (!autoMode) { try { unwatchFile(configFile); } catch { /* best effort */ } }
8099
8558
  if (reaperTimer) clearInterval(reaperTimer);
8100
8559
  if (runDirTimer) clearInterval(runDirTimer);
8101
- // Deregister this worker's presence BEFORE interrupting the runtime: emit
8102
- // the explicit deregister while the multiplexed host connection is still
8103
- // live, so the worker disappears from the cockpit cleanly rather than
8104
- // lingering until its heartbeat lapses. Best-effort — teardown must never
8105
- // hang. Interrupting the fiber then runs the runtime's bracketed teardown
8106
- // (release slots, stop the heartbeat + tear down the agentic scope).
8107
8560
  if (agenticPlane) {
8108
8561
  try { agenticPlane.deregister(`worker stopped (${signal})`); } catch { /* best effort */ }
8109
8562
  }
@@ -8113,10 +8566,84 @@ async function workAgent(req, flags) {
8113
8566
  } catch (err) {
8114
8567
  logger.warn(`supervisor shutdown error — runtime loop may not have shut down cleanly: ${err?.message || err}`);
8115
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 */ } }
8575
+ resolve();
8576
+ };
8577
+
8578
+ const inFlightCount = () => {
8579
+ try { return SupervisorEffect.runSync(workerRegistry.activeCount); }
8580
+ catch { return activeJobs.size; }
8581
+ };
8582
+
8583
+ const forceAbort = async (signal) => {
8584
+ if (aborting || finished) return;
8585
+ aborting = true;
8586
+ draining = true;
8587
+ if (!autoMode) { try { unwatchFile(configFile); } catch { /* best effort */ } }
8588
+ logger.info(`Received ${signal} — aborting in-flight work and stopping worker...`);
8589
+ // Snapshot in-flight jobs (with their retry budget) BEFORE the interrupt
8590
+ // clears the ownership registry, so we can yield each one afterwards.
8591
+ const inflight = [...activeJobs.entries()].map(([jobKey, info]) => ({ jobKey, retries: info?.retries }));
8592
+ // Interrupt the runtime: this aborts each running job's AbortSignal (the
8593
+ // makeJobRunner seam) so runAgentJob killTree's the harness process group,
8594
+ // and runs dispatch's bracketed teardown (release ownership + slot). The
8595
+ // runner sees the aborted result and, by contract, does NOT settle — we do.
8596
+ if (agenticPlane) { try { agenticPlane.deregister(`worker aborted (${signal})`); } catch { /* best effort */ } }
8597
+ try {
8598
+ await SupervisorEffect.runPromise(SupervisorFiber.interrupt(supervisorFiber));
8599
+ } catch (err) {
8600
+ logger.warn(`supervisor abort error — runtime loop may not have shut down cleanly: ${err?.message || err}`);
8601
+ }
8602
+ // Yield each in-flight job so the broker re-activates it at once (retries
8603
+ // preserved — a force-stop doesn't consume an attempt). Best-effort: a
8604
+ // failed yield just lets the lock lapse (the honest fallback).
8605
+ for (const { jobKey, retries } of inflight) {
8606
+ try {
8607
+ await SupervisorEffect.runPromise(settle.fail(jobKey, {
8608
+ errorMessage: `worker force-stopped (${signal}); job yielded for retry`,
8609
+ retries: Number.isFinite(retries) && retries > 0 ? retries : 1,
8610
+ retryBackOff: 0,
8611
+ }));
8612
+ logger.info(` yielded job ${jobKey} for immediate retry.`);
8613
+ } catch (err) {
8614
+ logger.warn(` could not yield job ${jobKey} (${err?.message || err}); its lock will lapse and the broker will reclaim it.`);
8615
+ }
8616
+ }
8617
+ finished = true; // teardown already interrupted the fiber; just resolve.
8618
+ logger.info('Worker stopped.');
8116
8619
  resolve();
8117
8620
  };
8118
- process.once('SIGINT', () => { stop('SIGINT'); });
8119
- process.once('SIGTERM', () => { stop('SIGTERM'); });
8621
+
8622
+ const gracefulDrain = async (signal) => {
8623
+ if (quiescing || aborting || finished) return;
8624
+ quiescing = true;
8625
+ draining = true;
8626
+ // Authoritative quiesce: the activation loop leases no new job even if the
8627
+ // --auto reconcile rewrites this worker's job types (registry.quiesce wins).
8628
+ try { SupervisorEffect.runSync(workerRegistry.quiesce()); } catch { /* best effort */ }
8629
+ if (!autoMode) { try { unwatchFile(configFile); } catch { /* best effort */ } }
8630
+ const n0 = inFlightCount();
8631
+ logger.info(`Received ${signal} — draining: polling stopped; waiting on ${n0} in-flight job(s) to finish (send SIGTERM to abort).`);
8632
+ // Poll until every in-flight job has settled and released its slot, then
8633
+ // interrupt the now-idle runtime and exit. Wait INDEFINITELY — a force
8634
+ // abort (SIGTERM) is the only escape hatch.
8635
+ const tick = async () => {
8636
+ if (aborting || finished) return; // escalated to a force abort — let it own exit
8637
+ const n = inFlightCount();
8638
+ if (n <= 0) { await teardownAndExit(signal); return; }
8639
+ setTimeout(tick, 200);
8640
+ };
8641
+ setTimeout(tick, 200);
8642
+ };
8643
+
8644
+ process.once(SUPERVISOR_DRAIN_SIGNAL, () => { gracefulDrain(SUPERVISOR_DRAIN_SIGNAL); });
8645
+ process.once('SIGINT', () => { forceAbort('SIGINT'); });
8646
+ process.once('SIGTERM', () => { forceAbort('SIGTERM'); });
8120
8647
  });
8121
8648
  }
8122
8649
 
@@ -8860,8 +9387,10 @@ function waitForChildExit(child, timeoutMs) {
8860
9387
  return new Promise((resolve) => {
8861
9388
  if (!child || child.exitCode !== null || child.signalCode !== null) return resolve();
8862
9389
  let done = false;
8863
- const finish = () => { if (done) return; done = true; clearTimeout(t); resolve(); };
8864
- const t = setTimeout(finish, timeoutMs);
9390
+ // #202: a null/undefined timeout means WAIT INDEFINITELY (graceful drain) —
9391
+ // no timer is armed, so we only resolve when the child actually exits.
9392
+ const t = timeoutMs == null ? null : setTimeout(() => finish(), timeoutMs);
9393
+ function finish() { if (done) return; done = true; if (t) clearTimeout(t); resolve(); }
8865
9394
  child.once('exit', finish);
8866
9395
  });
8867
9396
  }
@@ -8939,7 +9468,21 @@ async function runSupervisorDaemon() {
8939
9468
 
8940
9469
  const workers = new Map();
8941
9470
  const attachClients = new Set();
9471
+ // #202: sockets that issued a `stop`/drain and are waiting for the daemon to
9472
+ // finish. They get a terminal `stopped` (final) frame when shutdown completes,
9473
+ // so the streaming `stop` client (which waits indefinitely) sees a clean
9474
+ // end-of-response instead of a bare socket close. A one-shot `supervisorRequest`
9475
+ // has a fixed 15s `SUPERVISOR_RESPONSE_TIMEOUT_MS` deadline, so it only observes
9476
+ // that frame when the drain completes within it; a longer drain times out
9477
+ // client-side while the daemon keeps draining in the background.
9478
+ const stopClients = new Set();
8942
9479
  let shuttingDown = false;
9480
+ // #202: a graceful-drain shutdown is in progress (SIGUSR2 sent to workers,
9481
+ // awaiting them to finish in-flight jobs and exit). `forcing` records that a
9482
+ // `stop --force` has escalated that drain to a hard abort. Both gate the
9483
+ // restart-on-exit path and let a second `stop --force` escalate a live drain.
9484
+ let draining = false;
9485
+ let forcing = false;
8943
9486
  // Live-view monitor: tracks the last-broadcast fleet signature so we push a
8944
9487
  // refreshed status to attached consoles only on real change (see below).
8945
9488
  let monitorTimer = null;
@@ -9070,7 +9613,23 @@ async function runSupervisorDaemon() {
9070
9613
  try { rmSync(w.activityFile || supervisorWorkerActivityFile(w.id), { force: true }); } catch { /* best effort */ }
9071
9614
  const ranMs = Date.now() - (w.spawnedAt || Date.now());
9072
9615
  if (ranMs >= SUPERVISOR_HEALTHY_UPTIME_MS) w.restarts = 0;
9073
- if (w.stopping || shuttingDown || !workers.has(w.id)) { persist(); return; }
9616
+ if (w.stopping || shuttingDown || !workers.has(w.id)) {
9617
+ // #202 drain-remove: a worker flagged for removal has now drained and
9618
+ // exited — delete it and announce its removal (mirrors the synchronous
9619
+ // force-remove path). Do this before persist() so the state reflects it.
9620
+ if (w.removeOnExit && workers.get(w.id) === w) {
9621
+ workers.delete(w.id);
9622
+ try { rmSync(w.activityFile || supervisorWorkerActivityFile(w.id), { force: true }); } catch { /* best effort */ }
9623
+ dlog(`worker '${w.id}' removed (drained)`);
9624
+ broadcast({ type: 'event', event: 'worker-remove', id: w.id });
9625
+ }
9626
+ persist();
9627
+ // #202: during a graceful drain, push a fresh status so an attached
9628
+ // `stop` client sees the in-flight count shrink as each worker finishes
9629
+ // and exits — even when the periodic monitor is disabled.
9630
+ if (draining) { try { broadcast(statusFrame(false)); } catch { /* best effort */ } }
9631
+ return;
9632
+ }
9074
9633
  const delay = supervisorBackoffMs(w.restarts);
9075
9634
  w.restarts += 1;
9076
9635
  dlog(`worker '${w.id}' down (${reason}); restarting in ${delay}ms (restart #${w.restarts})`);
@@ -9109,23 +9668,55 @@ async function runSupervisorDaemon() {
9109
9668
  return w;
9110
9669
  };
9111
9670
 
9112
- const stopWorker = async (id) => {
9113
- const w = workers.get(id);
9114
- if (!w) return false;
9115
- w.stopping = true;
9116
- if (w.restartTimer) { clearTimeout(w.restartTimer); w.restartTimer = null; }
9671
+ const forceStopWorker = async (w) => {
9117
9672
  const pid = w.pid;
9118
9673
  if (w.child && pid) {
9119
9674
  try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
9120
9675
  await waitForChildExit(w.child, STOP_GRACE_MS);
9121
9676
  if (isPidAlive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } }
9122
9677
  }
9678
+ };
9679
+
9680
+ const stopWorker = async (id, { force = true } = {}) => {
9681
+ const w = workers.get(id);
9682
+ if (!w) return false;
9683
+ w.stopping = true;
9684
+ if (w.restartTimer) { clearTimeout(w.restartTimer); w.restartTimer = null; }
9685
+ const pid = w.pid;
9686
+ if (!force) {
9687
+ // #202 graceful drain: send SIGUSR2 so the child quiesces its activation
9688
+ // loop, lets in-flight jobs finish, and exits on its own. Wait INDEFINITELY
9689
+ // — a `stop --force` escalation (forceStopWorker) is the only way to cut a
9690
+ // stuck drain short, and it resolves this same wait when the child dies.
9691
+ if (w.child && pid) {
9692
+ try { process.kill(pid, SUPERVISOR_DRAIN_SIGNAL); } catch { /* already gone */ }
9693
+ await waitForChildExit(w.child, null);
9694
+ }
9695
+ return true;
9696
+ }
9697
+ await forceStopWorker(w);
9123
9698
  return true;
9124
9699
  };
9125
9700
 
9126
- const removeWorker = async (id) => {
9127
- if (!workers.has(id)) return false;
9128
- await stopWorker(id);
9701
+ const removeWorker = async (id, { force = true } = {}) => {
9702
+ const w = workers.get(id);
9703
+ if (!w) return false;
9704
+ if (!force) {
9705
+ // #202 drain-remove (used by `workforce stop`): quiesce this worker and let
9706
+ // it finish its in-flight jobs, then let the death handler delete it once
9707
+ // it exits. Return immediately so the control loop keeps serving (the
9708
+ // client polls status to watch the worker disappear) rather than blocking
9709
+ // the request queue for the whole — possibly long — drain.
9710
+ w.stopping = true;
9711
+ w.removeOnExit = true;
9712
+ if (w.restartTimer) { clearTimeout(w.restartTimer); w.restartTimer = null; }
9713
+ const pid = w.pid;
9714
+ if (w.child && pid) { try { process.kill(pid, SUPERVISOR_DRAIN_SIGNAL); } catch { /* already gone */ } }
9715
+ else { workers.delete(id); try { rmSync(supervisorWorkerActivityFile(id), { force: true }); } catch { /* best effort */ } broadcast({ type: 'event', event: 'worker-remove', id }); persist(); }
9716
+ dlog(`worker '${id}' draining for removal`);
9717
+ return true;
9718
+ }
9719
+ await stopWorker(id, { force: true });
9129
9720
  workers.delete(id);
9130
9721
  try { rmSync(supervisorWorkerActivityFile(id), { force: true }); } catch { /* best effort */ }
9131
9722
  dlog(`worker '${id}' removed`);
@@ -9166,16 +9757,45 @@ async function runSupervisorDaemon() {
9166
9757
  ...(final ? { final: true } : {}),
9167
9758
  });
9168
9759
 
9169
- const shutdown = async (signal) => {
9170
- if (shuttingDown) return;
9760
+ const shutdown = async (signal, { force = true } = {}) => {
9761
+ if (shuttingDown) {
9762
+ // A shutdown is already running. A `stop --force` arriving mid-DRAIN
9763
+ // escalates it: hard-stop every worker still finishing its jobs so the
9764
+ // operator isn't stuck waiting. Idempotent — only the first force escalates.
9765
+ if (force && draining && !forcing) {
9766
+ forcing = true;
9767
+ dlog('stop --force received during drain — escalating to hard abort');
9768
+ broadcast({ type: 'event', event: 'draining-escalated' });
9769
+ await Promise.all([...workers.values()].map((w) => forceStopWorker(w)));
9770
+ }
9771
+ return;
9772
+ }
9171
9773
  shuttingDown = true;
9774
+ draining = !force;
9172
9775
  // Let any in-flight mutation finish before we snapshot the worker set, so
9173
9776
  // an add/restart racing the shutdown can't leave an orphaned child behind.
9174
9777
  try { await opQueue; } catch { /* mutation already logged */ }
9778
+ dlog(`received ${signal || 'stop'} — ${force ? 'stopping' : 'draining'} ${workers.size} worker(s)`);
9779
+ if (!force) broadcast({ type: 'event', event: 'draining', workers: [...workers.values()].map(workerPublic) });
9780
+ await Promise.all([...workers.keys()].map((id) => stopWorker(id, { force })));
9175
9781
  if (monitorTimer) { try { clearInterval(monitorTimer); } catch { /* ignore */ } monitorTimer = null; }
9176
- dlog(`received ${signal || 'stop'} — stopping ${workers.size} worker(s)`);
9177
- await Promise.all([...workers.keys()].map((id) => stopWorker(id)));
9178
9782
  broadcast({ type: 'event', event: 'daemon-stop' });
9783
+ // Terminal frame for every waiting `stop` client (streaming or one-shot).
9784
+ // We must FLUSH these before exiting: `process.exit()` does NOT drain pending
9785
+ // socket I/O, so an immediate exit can drop the final frame and turn the
9786
+ // clean end-of-response the streaming stop client waits for into a bare
9787
+ // socket close. `end(frame, cb)` writes the frame then sends FIN, and its
9788
+ // callback fires once the bytes are handed off; await them all (with a short
9789
+ // timeout guard so a wedged/slow client can't block the exit indefinitely).
9790
+ await Promise.all([...stopClients].map((s) => new Promise((resolve) => {
9791
+ let done = false;
9792
+ const finish = () => { if (!done) { done = true; resolve(); } };
9793
+ try {
9794
+ const timer = setTimeout(finish, 2000);
9795
+ if (typeof timer.unref === 'function') timer.unref();
9796
+ s.end(encodeFrame({ ok: true, type: 'stopped', final: true }), () => { clearTimeout(timer); finish(); });
9797
+ } catch { finish(); /* client gone */ }
9798
+ })));
9179
9799
  try { server.close(); } catch { /* ignore */ }
9180
9800
  if (osPlatform() !== 'win32') { try { rmSync(socketPath, { force: true }); } catch { /* ignore */ } }
9181
9801
  clearSupervisorState();
@@ -9213,12 +9833,17 @@ async function runSupervisorDaemon() {
9213
9833
  }
9214
9834
  case 'remove': {
9215
9835
  if (shuttingDown) { sock.write(encodeFrame({ ok: false, error: 'supervisor is shutting down', final: true })); break; }
9836
+ // #202: `req.force === false` drains each worker (finish in-flight jobs
9837
+ // then exit); the default remains a fast force-stop (used by reconcile
9838
+ // and interactive remove). A drain-remove returns immediately and the
9839
+ // worker disappears from status once it has drained.
9840
+ const removeForce = req.force !== false;
9216
9841
  const removed = await serializeOp(async () => {
9217
9842
  const ids = resolveTargets(req.target);
9218
- for (const id of ids) await removeWorker(id);
9843
+ for (const id of ids) await removeWorker(id, { force: removeForce });
9219
9844
  return ids;
9220
9845
  });
9221
- sock.write(encodeFrame({ ok: true, type: 'removed', removed, final: true }));
9846
+ sock.write(encodeFrame({ ok: true, type: 'removed', removed, draining: !removeForce, final: true }));
9222
9847
  break;
9223
9848
  }
9224
9849
  case 'restart': {
@@ -9235,10 +9860,25 @@ async function runSupervisorDaemon() {
9235
9860
  attachClients.add(sock);
9236
9861
  sock.write(encodeFrame(statusFrame(false)));
9237
9862
  break;
9238
- case 'stop':
9239
- sock.write(encodeFrame({ ok: true, type: 'stopping', final: true }));
9240
- setTimeout(() => shutdown('stop'), 50);
9863
+ case 'stop': {
9864
+ // #202: default is a GRACEFUL DRAIN — quiesce workers, let in-flight
9865
+ // jobs finish, exit when idle. `req.force` hard-aborts (kill harness,
9866
+ // yield jobs) and can also ESCALATE a drain already in progress.
9867
+ const force = !!req.force;
9868
+ // Register this client as an attach consumer so it streams drain
9869
+ // progress (shrinking in-flight counts), and as a stop client so it
9870
+ // gets a terminal `stopped` frame when the daemon has finished.
9871
+ attachClients.add(sock);
9872
+ stopClients.add(sock);
9873
+ sock.write(encodeFrame(force
9874
+ ? { ok: true, type: 'stopping', force: true }
9875
+ : { ok: true, type: 'draining' }));
9876
+ sock.write(encodeFrame(statusFrame(false)));
9877
+ // Kick the shutdown asynchronously; don't await it here so the control
9878
+ // loop keeps serving (streaming status, accepting a force escalation).
9879
+ setTimeout(() => { shutdown('stop', { force }); }, 20);
9241
9880
  break;
9881
+ }
9242
9882
  default:
9243
9883
  sock.write(encodeFrame({ ok: false, error: `unknown op "${op}"`, final: true }));
9244
9884
  }
@@ -9276,8 +9916,8 @@ async function runSupervisorDaemon() {
9276
9916
  queue = queue.then(() => handleRequest(req, sock)).catch((err) => dlog(`request error: ${err?.message || err}`));
9277
9917
  }
9278
9918
  });
9279
- sock.on('close', () => attachClients.delete(sock));
9280
- sock.on('error', () => attachClients.delete(sock));
9919
+ sock.on('close', () => { attachClients.delete(sock); stopClients.delete(sock); });
9920
+ sock.on('error', () => { attachClients.delete(sock); stopClients.delete(sock); });
9281
9921
  });
9282
9922
 
9283
9923
  // Create the control socket owner-only from the start. The socket file lives
@@ -9622,7 +10262,29 @@ async function supervisorRestartCmd(req) {
9622
10262
  else { logger.error(res.error); process.exit(1); }
9623
10263
  }
9624
10264
 
9625
- async function supervisorStopCmd() {
10265
+ /**
10266
+ * Count the in-flight jobs across a fleet snapshot (array of
10267
+ * `summarizeSupervisorWorker` results) — the number an operator is waiting on
10268
+ * when draining.
10269
+ */
10270
+ function countSupervisorInFlight(workers) {
10271
+ let n = 0;
10272
+ for (const w of workers || []) {
10273
+ if (w && w.activity && Array.isArray(w.activity.jobs)) n += w.activity.jobs.length;
10274
+ }
10275
+ return n;
10276
+ }
10277
+
10278
+ /**
10279
+ * Stop the supervisor. By default (issue #202) this GRACEFULLY DRAINS: the
10280
+ * daemon quiesces its workers (they stop leasing new jobs, finish the ones in
10281
+ * flight, and exit), streaming the shrinking in-flight count to this client the
10282
+ * whole time. Ctrl-C DETACHES — the daemon keeps draining in the background.
10283
+ * `force` hard-aborts instead: each worker's harness is killed and its job is
10284
+ * yielded for immediate retry. A `force` request also escalates a drain that is
10285
+ * already in progress.
10286
+ */
10287
+ async function supervisorStopCmd(force = false) {
9626
10288
  const logger = getLogger();
9627
10289
  const running = await liveSupervisor();
9628
10290
  if (!running) {
@@ -9630,35 +10292,100 @@ async function supervisorStopCmd() {
9630
10292
  else logger.warn('Supervisor is not running — nothing to stop.');
9631
10293
  return;
9632
10294
  }
9633
- try {
9634
- await supervisorRequest({ op: 'stop' });
9635
- } catch {
9636
- // Socket unreachable — fall back to signalling the daemon pid directly.
9637
- try { process.kill(running.pid, 'SIGTERM'); } catch { /* already gone */ }
9638
- }
9639
- // Gate the wait loop and the SIGKILL fallback on the daemon pid we captured,
9640
- // not on runningSupervisor()/the state file: the daemon clears its state file
9641
- // as part of shutting down (and liveSupervisor()/external cleanup can remove
9642
- // it too), so a state-file check can report "gone" while the process is still
9643
- // alive — which would break the loop early and skip the SIGKILL fallback,
9644
- // leaving a wedged daemon and its worker process group running.
9645
- const deadline = Date.now() + STOP_GRACE_MS + 2_000;
9646
- while (Date.now() < deadline) {
9647
- if (!isPidAlive(running.pid)) break;
9648
- await new Promise((r) => setTimeout(r, 150));
10295
+ const socketPath = running.socket || getSupervisorSocketPath();
10296
+ const daemonPid = running.pid;
10297
+
10298
+ // Stream drain/stop progress. Resolves with the outcome that ended the stream.
10299
+ const outcome = await new Promise((resolve) => {
10300
+ let sock = null;
10301
+ let buf = '';
10302
+ let done = false;
10303
+ let lastCount = null;
10304
+ let onSigint = null;
10305
+ const cleanup = () => {
10306
+ if (onSigint) { try { process.removeListener('SIGINT', onSigint); } catch { /* ignore */ } }
10307
+ try { if (sock) sock.end(); } catch { /* ignore */ }
10308
+ };
10309
+ const finish = (result) => { if (done) return; done = true; cleanup(); resolve(result); };
10310
+
10311
+ supervisorConnect(socketPath).then((s) => {
10312
+ sock = s;
10313
+ sock.setEncoding('utf8');
10314
+ // Ctrl-C detaches the client only — the daemon keeps draining. (Force
10315
+ // stops don't wait on the operator, so a Ctrl-C there just stops watching.)
10316
+ onSigint = () => {
10317
+ if (force) {
10318
+ logger.info('Detached — supervisor is aborting in the background.');
10319
+ } else {
10320
+ logger.info('Detached — supervisor keeps draining in the background. Rerun `nano supervisor stop` to watch again, or `nano supervisor stop --force` to abort in-flight work.');
10321
+ }
10322
+ finish('detached');
10323
+ };
10324
+ process.on('SIGINT', onSigint);
10325
+
10326
+ sock.on('data', (chunk) => {
10327
+ buf += chunk;
10328
+ const { frames, rest } = decodeFrames(buf);
10329
+ buf = rest;
10330
+ for (const frame of frames) {
10331
+ if (frame && frame.type === 'stopping') {
10332
+ logger.info('Aborting in-flight work — killing harnesses and yielding jobs for retry...');
10333
+ } else if (frame && frame.event === 'draining-escalated') {
10334
+ logger.info('Escalating to --force — aborting in-flight work...');
10335
+ } else if (frame && (frame.type === 'status' || frame.event === 'draining')) {
10336
+ const n = countSupervisorInFlight(frame.workers);
10337
+ if (!force && n !== lastCount) {
10338
+ lastCount = n;
10339
+ if (n > 0) logger.info(`Draining — waiting on ${n} in-flight job(s) to finish. Press Ctrl-C to detach, or rerun with --force to abort.`);
10340
+ else logger.info('No jobs in flight — stopping.');
10341
+ }
10342
+ } else if (frame && frame.event === 'daemon-stop') {
10343
+ finish('stopped');
10344
+ } else if (frame && (frame.type === 'stopped' || frame.final)) {
10345
+ finish('stopped');
10346
+ }
10347
+ }
10348
+ });
10349
+ sock.on('error', () => finish('closed'));
10350
+ sock.on('close', () => finish('closed'));
10351
+ sock.write(encodeFrame({ op: 'stop', force: !!force }));
10352
+ }).catch(() => finish('unreachable'));
10353
+ });
10354
+
10355
+ if (outcome === 'detached') return;
10356
+
10357
+ if (outcome === 'unreachable') {
10358
+ if (force) {
10359
+ // Socket unreachable — fall back to signalling the daemon pid directly.
10360
+ try { process.kill(daemonPid, 'SIGTERM'); } catch { /* already gone */ }
10361
+ } else {
10362
+ logger.warn('Could not reach the supervisor control socket to drain it. Rerun with --force to abort, or stop it manually.');
10363
+ return;
10364
+ }
9649
10365
  }
9650
- if (isPidAlive(running.pid)) {
9651
- logger.warn(`Supervisor (pid ${running.pid}) did not stop gracefully — sending SIGKILL.`);
9652
- // The daemon is spawned detached (a process-group leader) and its workers
9653
- // are children in that group, so SIGKILL the whole group to avoid orphaning
9654
- // `nano work` processes. Fall back to the bare pid (e.g. on Windows, or if
9655
- // the daemon isn't a group leader).
9656
- let killedGroup = false;
9657
- if (osPlatform() !== 'win32') {
9658
- try { process.kill(-running.pid, 'SIGKILL'); killedGroup = true; } catch { /* fall back below */ }
10366
+
10367
+ // Wait for the daemon process itself to exit. A drain waits INDEFINITELY (the
10368
+ // operator opted to wait); a force stop applies the grace window + a SIGKILL
10369
+ // backstop so a wedged daemon/worker group can't linger.
10370
+ if (force) {
10371
+ const deadline = Date.now() + STOP_GRACE_MS + 2_000;
10372
+ while (Date.now() < deadline) {
10373
+ if (!isPidAlive(daemonPid)) break;
10374
+ await new Promise((r) => setTimeout(r, 150));
10375
+ }
10376
+ if (isPidAlive(daemonPid)) {
10377
+ logger.warn(`Supervisor (pid ${daemonPid}) did not stop gracefully — sending SIGKILL.`);
10378
+ let killedGroup = false;
10379
+ if (osPlatform() !== 'win32') {
10380
+ try { process.kill(-daemonPid, 'SIGKILL'); killedGroup = true; } catch { /* fall back below */ }
10381
+ }
10382
+ if (!killedGroup) { try { process.kill(daemonPid, 'SIGKILL'); } catch { /* ignore */ } }
10383
+ clearSupervisorState();
10384
+ }
10385
+ } else {
10386
+ while (isPidAlive(daemonPid)) {
10387
+ await new Promise((r) => setTimeout(r, 150));
9659
10388
  }
9660
- if (!killedGroup) { try { process.kill(running.pid, 'SIGKILL'); } catch { /* ignore */ } }
9661
- clearSupervisorState();
9662
10389
  }
9663
10390
  logger.info('Supervisor stopped.');
9664
10391
  }
@@ -10335,7 +11062,7 @@ async function supervisorCommand(req, flags) {
10335
11062
  await supervisorRestartCmd(req);
10336
11063
  return;
10337
11064
  case 'stop':
10338
- await supervisorStopCmd();
11065
+ await supervisorStopCmd(coerceBool(flags?.force, false));
10339
11066
  return;
10340
11067
  case 'logs':
10341
11068
  case 'log':
@@ -11234,14 +11961,38 @@ async function workforceStopCmd(req, flags, manifestName) {
11234
11961
  })
11235
11962
  .map((w) => w.id);
11236
11963
  let hadError = false;
11964
+ const force = coerceBool(flags?.force, false);
11237
11965
  if (owned.length === 0) {
11238
11966
  logger.info(`No workers from workforce "${manifestName}" are running.`);
11239
- } else {
11967
+ } else if (force) {
11240
11968
  for (const id of owned) {
11241
- const res = await supervisorRequest({ op: 'remove', target: id });
11242
- if (res && res.ok) logger.info(`Removed worker "${id}".`);
11969
+ const res = await supervisorRequest({ op: 'remove', target: id, force: true });
11970
+ if (res && res.ok) logger.info(`Removed worker "${id}" (aborted in-flight work).`);
11243
11971
  else { logger.error(`Could not remove "${id}": ${(res && res.error) || 'unknown error'}`); hadError = true; }
11244
11972
  }
11973
+ } else {
11974
+ // #202 graceful drain: ask the daemon to quiesce each owned worker (finish
11975
+ // in-flight jobs, then exit) and poll status until they've all drained away.
11976
+ for (const id of owned) {
11977
+ const res = await supervisorRequest({ op: 'remove', target: id, force: false });
11978
+ if (res && res.ok) logger.info(`Draining worker "${id}"...`);
11979
+ else { logger.error(`Could not drain "${id}": ${(res && res.error) || 'unknown error'}`); hadError = true; }
11980
+ }
11981
+ const ownedSet = new Set(owned);
11982
+ let lastInFlight = null;
11983
+ for (;;) {
11984
+ const snap = await fetchSupervisorWorkers();
11985
+ if (!snap.running || !snap.reachable) break;
11986
+ const remaining = (snap.workers || []).filter((w) => w && ownedSet.has(w.id));
11987
+ if (remaining.length === 0) break;
11988
+ const inFlight = countSupervisorInFlight(remaining);
11989
+ if (inFlight !== lastInFlight) {
11990
+ lastInFlight = inFlight;
11991
+ logger.info(`Draining "${manifestName}" — ${remaining.length} worker(s) still finishing ${inFlight} in-flight job(s). Rerun with --force to abort.`);
11992
+ }
11993
+ await new Promise((r) => setTimeout(r, 300));
11994
+ }
11995
+ if (lastInFlight !== null) logger.info(`Workforce "${manifestName}" drained.`);
11245
11996
  }
11246
11997
  // If no supervised workers remain, stop the daemon too — but only when the
11247
11998
  // status socket actually answered. A `{ workers: [] }` from an *unreachable*
@@ -11257,7 +12008,7 @@ async function workforceStopCmd(req, flags, manifestName) {
11257
12008
  logger.warn('Supervisor status socket became unreachable; leaving the daemon running.');
11258
12009
  } else if (remaining.length === 0) {
11259
12010
  logger.info('No supervised workers remain — stopping the supervisor daemon.');
11260
- await supervisorStopCmd();
12011
+ await supervisorStopCmd(force);
11261
12012
  } else {
11262
12013
  logger.info(`${remaining.length} other supervised worker(s) remain; leaving the daemon running.`);
11263
12014
  }
@@ -13054,6 +13805,20 @@ export {
13054
13805
  isPlaceholderEmail,
13055
13806
  postAgentAttribution,
13056
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,
13057
13822
  authUrl,
13058
13823
  githubCloneToken,
13059
13824
  ghAuthTokenFromCli,
@@ -13281,7 +14046,7 @@ export const commands = {
13281
14046
  console: { type: 'string', description: 'start: runtime console profile off|observe|studio (NANOBPMN_CONSOLE; default studio)' },
13282
14047
  follow: { type: 'boolean', description: 'logs: stream output (tail -F)', short: 'f' },
13283
14048
  purge: { type: 'boolean', description: 'stop/restart: also delete per-node engine data' },
13284
- force: { type: 'boolean', description: 'start: stop any existing cluster first' },
14049
+ force: { type: 'boolean', description: 'start: stop any existing cluster first; supervisor/workforce stop: abort in-flight work (kill harness, yield jobs for retry) instead of the default graceful drain' },
13285
14050
  workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
13286
14051
  check: { type: 'boolean', description: 'update: report whether a new release is available (with the changelog since the installed version); do not install' },
13287
14052
  binary: { type: 'string', description: 'Path to the nanobpmn server binary' },