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/README.md +32 -3
- package/c8ctl-plugin.js +859 -94
- package/package.json +8 -8
- package/supervisor.dist.js +5 -5
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
|
-
//
|
|
4427
|
-
//
|
|
4428
|
-
//
|
|
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
|
|
7170
|
-
//
|
|
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 =
|
|
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 =
|
|
7176
|
-
if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped}
|
|
7587
|
+
const r = reapOwnedNamespace(workerNsDir, { maxAgeMs: reapAgeMs, liveRunDirs, logger, incarnation: workerIncarnation });
|
|
7588
|
+
if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped} aged, not-in-flight job workspace(s) from this worker's namespace.`);
|
|
7589
|
+
if (r.error) logger.warn(`Workspace reaper warning: ${r.error}`);
|
|
7590
|
+
const rc = reclaimOrphanNamespaces({ selfIncarnation: workerIncarnation, minAgeMs: reapAgeMs, logger });
|
|
7591
|
+
if (rc.reclaimed.length > 0) logger.info(`Reclaimed ${rc.reclaimed.length} abandoned worker namespace(s).`);
|
|
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(
|
|
7675
|
-
runDir = mkdtempSync(join(
|
|
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(
|
|
7725
|
-
runDir = mkdtempSync(join(
|
|
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
|
|
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(
|
|
7756
|
-
resultDir = mkdtempSync(join(
|
|
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
|
|
8091
|
-
//
|
|
8092
|
-
//
|
|
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
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
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
|
-
|
|
8119
|
-
|
|
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
|
-
|
|
8864
|
-
|
|
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)) {
|
|
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
|
|
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
|
-
|
|
9128
|
-
|
|
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)
|
|
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
|
-
|
|
9240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9634
|
-
|
|
9635
|
-
|
|
9636
|
-
|
|
9637
|
-
|
|
9638
|
-
|
|
9639
|
-
|
|
9640
|
-
|
|
9641
|
-
|
|
9642
|
-
|
|
9643
|
-
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
if (
|
|
9648
|
-
|
|
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
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
|
|
9654
|
-
|
|
9655
|
-
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
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' },
|