c8ctl-plugin-nano 1.56.4 → 1.57.0
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 +446 -78
- package/package.json +8 -8
- package/supervisor.dist.js +5 -5
package/c8ctl-plugin.js
CHANGED
|
@@ -141,6 +141,10 @@ const READINESS_TIMEOUT_MS = 60_000;
|
|
|
141
141
|
const READINESS_POLL_MS = 500;
|
|
142
142
|
const HEALTH_TIMEOUT_MS = 1_500;
|
|
143
143
|
const STOP_GRACE_MS = 8_000;
|
|
144
|
+
// #202: the signal a graceful `supervisor stop`/`workforce stop` sends each
|
|
145
|
+
// `nano work` child to quiesce it — stop leasing new jobs, finish in-flight work,
|
|
146
|
+
// then exit. SIGTERM/SIGINT remain the FORCE abort (kill harness, yield jobs).
|
|
147
|
+
const SUPERVISOR_DRAIN_SIGNAL = 'SIGUSR2';
|
|
144
148
|
// Upper bound on one `--auto` engine-read reconcile (enumerate deployed
|
|
145
149
|
// definitions + fetch each BPMN). A read that stalls past this is treated as a
|
|
146
150
|
// transient failure so the running poller set is KEPT and, crucially, shutdown
|
|
@@ -4460,7 +4464,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
|
4460
4464
|
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
4461
4465
|
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
4462
4466
|
// 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 }) {
|
|
4467
|
+
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr, relayTap = null, abortSignal = null }) {
|
|
4464
4468
|
return new Promise((resolve) => {
|
|
4465
4469
|
let child;
|
|
4466
4470
|
const stdoutChunks = [];
|
|
@@ -4472,6 +4476,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
4472
4476
|
let settled = false;
|
|
4473
4477
|
let timer = null;
|
|
4474
4478
|
let idleMon = null;
|
|
4479
|
+
let onAbort = null;
|
|
4475
4480
|
|
|
4476
4481
|
// Live "spy" tee (--stream): mirror the child's output line-by-line to a
|
|
4477
4482
|
// caller-supplied emitter (the worker routes these through c8ctl's
|
|
@@ -4509,6 +4514,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
4509
4514
|
settled = true;
|
|
4510
4515
|
if (timer) clearTimeout(timer);
|
|
4511
4516
|
if (idleMon) idleMon.stop();
|
|
4517
|
+
if (onAbort && abortSignal) { try { abortSignal.removeEventListener('abort', onAbort); } catch { /* best effort */ } onAbort = null; }
|
|
4512
4518
|
if (teeOut) teeOut('', true);
|
|
4513
4519
|
if (teeErr) teeErr('', true);
|
|
4514
4520
|
resolve(result);
|
|
@@ -4521,6 +4527,24 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
4521
4527
|
return;
|
|
4522
4528
|
}
|
|
4523
4529
|
|
|
4530
|
+
// #202: a `stop --force`/abort aborts this signal — kill the harness process
|
|
4531
|
+
// group (via the same onTimeout kill that the hard-cap/idle paths use) and
|
|
4532
|
+
// settle as aborted so the worker fails/yields the job for immediate retry
|
|
4533
|
+
// instead of letting the detached child outlive the interrupt (orphaned to
|
|
4534
|
+
// init) and the lock lapse.
|
|
4535
|
+
if (abortSignal) {
|
|
4536
|
+
if (abortSignal.aborted) {
|
|
4537
|
+
try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
|
|
4538
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: 'aborted', aborted: true, truncated: stdoutTruncated, stderrTruncated });
|
|
4539
|
+
return;
|
|
4540
|
+
}
|
|
4541
|
+
onAbort = () => {
|
|
4542
|
+
try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
|
|
4543
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: 'aborted', aborted: true, truncated: stdoutTruncated, stderrTruncated });
|
|
4544
|
+
};
|
|
4545
|
+
abortSignal.addEventListener('abort', onAbort);
|
|
4546
|
+
}
|
|
4547
|
+
|
|
4524
4548
|
timer = timeoutMs && timeoutMs > 0
|
|
4525
4549
|
? setTimeout(() => {
|
|
4526
4550
|
try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
|
|
@@ -4630,7 +4654,7 @@ function ptyAvailable(ptyFactory) {
|
|
|
4630
4654
|
// spawnCaptureOneShot. A PTY merges stdout+stderr into one stream, so stderr is
|
|
4631
4655
|
// always '' here; that is expected for a live terminal. `ptyFactory` is
|
|
4632
4656
|
// 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 }) {
|
|
4657
|
+
function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, cols = 120, rows = 30, ptyFactory, relayTap = null, stream = false, streamPrefix = '', onStreamOut, abortSignal = null }) {
|
|
4634
4658
|
return new Promise((resolve) => {
|
|
4635
4659
|
const factory = ptyFactory || loadPtyModule();
|
|
4636
4660
|
if (!factory || typeof factory.spawn !== 'function') {
|
|
@@ -4646,6 +4670,7 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4646
4670
|
let idleMon = null;
|
|
4647
4671
|
let detachSteer = null;
|
|
4648
4672
|
let term;
|
|
4673
|
+
let onAbort = null;
|
|
4649
4674
|
|
|
4650
4675
|
// Live "spy" tee (--stream), line-buffered, mirroring spawnCaptureOneShot.
|
|
4651
4676
|
const STREAM_TEE_LINE_CAP = 64 * 1024;
|
|
@@ -4676,6 +4701,7 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4676
4701
|
if (timer) clearTimeout(timer);
|
|
4677
4702
|
if (idleMon) idleMon.stop();
|
|
4678
4703
|
if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
|
|
4704
|
+
if (onAbort && abortSignal) { try { abortSignal.removeEventListener('abort', onAbort); } catch { /* best effort */ } onAbort = null; }
|
|
4679
4705
|
if (teeSink) tee('', true);
|
|
4680
4706
|
resolve(result);
|
|
4681
4707
|
};
|
|
@@ -4687,6 +4713,18 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4687
4713
|
return;
|
|
4688
4714
|
}
|
|
4689
4715
|
|
|
4716
|
+
// #202: abort (stop --force) kills the PTY and settles as aborted so the job
|
|
4717
|
+
// is failed/yielded for immediate retry rather than left to lock-lapse.
|
|
4718
|
+
if (abortSignal) {
|
|
4719
|
+
const abortNow = () => {
|
|
4720
|
+
killTerm();
|
|
4721
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(chunks), stderr: '', error: 'aborted', aborted: true, truncated, stderrTruncated: false });
|
|
4722
|
+
};
|
|
4723
|
+
if (abortSignal.aborted) { abortNow(); return; }
|
|
4724
|
+
onAbort = abortNow;
|
|
4725
|
+
abortSignal.addEventListener('abort', onAbort);
|
|
4726
|
+
}
|
|
4727
|
+
|
|
4690
4728
|
timer = timeoutMs && timeoutMs > 0
|
|
4691
4729
|
? setTimeout(() => {
|
|
4692
4730
|
killTerm();
|
|
@@ -4883,7 +4921,7 @@ const ACP_MAX_LINE_BYTES = 8 * 1024 * 1024; // 8 MiB
|
|
|
4883
4921
|
// and every caller work unchanged. Because the raw stream is JSON-RPC (not human
|
|
4884
4922
|
// output), `stdout` here is the accumulated human-readable transcript text (what
|
|
4885
4923
|
// 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 }) {
|
|
4924
|
+
function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false, onAcpUpdate = null, abortSignal = null }) {
|
|
4887
4925
|
return new Promise((resolve) => {
|
|
4888
4926
|
const logger = getLogger();
|
|
4889
4927
|
const humanChunks = [];
|
|
@@ -4897,6 +4935,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4897
4935
|
let idleMon = null;
|
|
4898
4936
|
let detachSteer = null;
|
|
4899
4937
|
let child;
|
|
4938
|
+
let onAbort = null;
|
|
4900
4939
|
let sessionId = null;
|
|
4901
4940
|
let nextId = 1;
|
|
4902
4941
|
const pending = new Map();
|
|
@@ -4985,6 +5024,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4985
5024
|
if (idleMon) idleMon.stop();
|
|
4986
5025
|
if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
|
|
4987
5026
|
if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
|
|
5027
|
+
if (onAbort && abortSignal) { try { abortSignal.removeEventListener('abort', onAbort); } catch { /* best effort */ } onAbort = null; }
|
|
4988
5028
|
// #137: a turn that completed but produced no mappable session/update gets a
|
|
4989
5029
|
// synthesised structured floor so the cockpit drill-in isn't empty. Only for
|
|
4990
5030
|
// a resolved turn (`promptResolved`) — a handshake/spawn failure has nothing
|
|
@@ -5254,6 +5294,15 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
5254
5294
|
return;
|
|
5255
5295
|
}
|
|
5256
5296
|
|
|
5297
|
+
// #202: abort (stop --force) — finish() reaps the still-alive child via
|
|
5298
|
+
// killTree, so settle as aborted and let it reap the ACP harness group.
|
|
5299
|
+
if (abortSignal) {
|
|
5300
|
+
const abortNow = () => finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: 'aborted', aborted: true, truncated: humanTruncated, stderrTruncated });
|
|
5301
|
+
if (abortSignal.aborted) { abortNow(); return; }
|
|
5302
|
+
onAbort = abortNow;
|
|
5303
|
+
abortSignal.addEventListener('abort', onAbort);
|
|
5304
|
+
}
|
|
5305
|
+
|
|
5257
5306
|
timer = timeoutMs && timeoutMs > 0
|
|
5258
5307
|
? setTimeout(() => {
|
|
5259
5308
|
try { killTree(child); } catch { /* best effort */ }
|
|
@@ -5554,7 +5603,7 @@ function baseAgentEnv(profile, job) {
|
|
|
5554
5603
|
* Both paths resolve to the same result contract.
|
|
5555
5604
|
*/
|
|
5556
5605
|
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;
|
|
5606
|
+
const { timeoutMs, idleTimeoutMs, recoveryWindowMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory, nudgePayload = null, onAcpUpdate = null, abortSignal = null } = opts;
|
|
5558
5607
|
// #110: `protocol`/`permission` drive the ACP executor branch below. The
|
|
5559
5608
|
// pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
|
|
5560
5609
|
// A `nudgePayload` (#678) carries the bespoke "re-emit your result" prompt for a
|
|
@@ -5643,6 +5692,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5643
5692
|
onStreamErr,
|
|
5644
5693
|
permission,
|
|
5645
5694
|
onAcpUpdate,
|
|
5695
|
+
abortSignal,
|
|
5646
5696
|
});
|
|
5647
5697
|
}
|
|
5648
5698
|
|
|
@@ -5665,6 +5715,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5665
5715
|
stream,
|
|
5666
5716
|
streamPrefix,
|
|
5667
5717
|
onStreamOut,
|
|
5718
|
+
abortSignal,
|
|
5668
5719
|
});
|
|
5669
5720
|
}
|
|
5670
5721
|
|
|
@@ -5688,6 +5739,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5688
5739
|
onStreamOut,
|
|
5689
5740
|
onStreamErr,
|
|
5690
5741
|
relayTap,
|
|
5742
|
+
abortSignal,
|
|
5691
5743
|
});
|
|
5692
5744
|
}
|
|
5693
5745
|
|
|
@@ -5763,6 +5815,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5763
5815
|
try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
|
|
5764
5816
|
try { killTree(child); } catch { /* best effort */ }
|
|
5765
5817
|
},
|
|
5818
|
+
abortSignal,
|
|
5766
5819
|
});
|
|
5767
5820
|
}
|
|
5768
5821
|
|
|
@@ -7376,7 +7429,7 @@ async function workAgent(req, flags) {
|
|
|
7376
7429
|
// dispatch claim/release lifecycle keyed by this worker's instance drives the
|
|
7377
7430
|
// cockpit's jobKeys — so the recorders no longer poke a per-process channel.
|
|
7378
7431
|
const recordJobStart = (job, jobType) => {
|
|
7379
|
-
activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now() });
|
|
7432
|
+
activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now(), retries: Number(job.retries) });
|
|
7380
7433
|
writeActivity();
|
|
7381
7434
|
};
|
|
7382
7435
|
const recordJobEnd = (job) => {
|
|
@@ -7525,7 +7578,7 @@ async function workAgent(req, flags) {
|
|
|
7525
7578
|
// the per-process 1+N reconcile crawl, and the per-job lock extender.
|
|
7526
7579
|
let settle;
|
|
7527
7580
|
const runner = {
|
|
7528
|
-
run: async (job) => {
|
|
7581
|
+
run: async (job, abortSignal) => {
|
|
7529
7582
|
const jobType = job.type;
|
|
7530
7583
|
recordJobStart(job, jobType);
|
|
7531
7584
|
try {
|
|
@@ -7764,6 +7817,12 @@ async function workAgent(req, flags) {
|
|
|
7764
7817
|
timeoutMs: effectiveHardCapMs,
|
|
7765
7818
|
idleTimeoutMs: effectiveIdleTimeoutMs,
|
|
7766
7819
|
recoveryWindowMs: effectiveRecoveryWindowMs,
|
|
7820
|
+
// #202: the supervisor fiber's interruption AbortSignal (a
|
|
7821
|
+
// `stop --force`/abort). runAgentJob wires it to killTree the harness
|
|
7822
|
+
// process group so an abort CANCELS the work instead of orphaning the
|
|
7823
|
+
// detached agent grandchild to init. Absent (undefined) on the normal
|
|
7824
|
+
// and graceful-drain paths, where the harness runs to completion.
|
|
7825
|
+
abortSignal,
|
|
7767
7826
|
envelope,
|
|
7768
7827
|
sandbox,
|
|
7769
7828
|
image,
|
|
@@ -7799,6 +7858,17 @@ async function workAgent(req, flags) {
|
|
|
7799
7858
|
};
|
|
7800
7859
|
result = await runAgentJob(profile, job, runOpts);
|
|
7801
7860
|
|
|
7861
|
+
// #202: a `stop --force`/abort killed the harness mid-run. Do NOT settle
|
|
7862
|
+
// here — the worker's force-stop handler yields the job (settle.fail with
|
|
7863
|
+
// its retries preserved) so it is immediately retryable, and the process
|
|
7864
|
+
// is about to exit. Settling here (or nudging/finalizing git) would race
|
|
7865
|
+
// that yield. The finally below still runs (container/run-dir/relay
|
|
7866
|
+
// cleanup); recordJobEnd fires in the outer finally.
|
|
7867
|
+
if (result && result.aborted) {
|
|
7868
|
+
logger.warn(`[${jobType}] job ${job.jobKey} aborted (worker force-stop) — harness killed; yielding job for retry.`);
|
|
7869
|
+
return;
|
|
7870
|
+
}
|
|
7871
|
+
|
|
7802
7872
|
// Gap 2 (#678): a clean run that emitted no machine-readable result gets
|
|
7803
7873
|
// ONE bounded re-emit nudge in the same workspace, feeding back its own
|
|
7804
7874
|
// output, before we accept an empty result. Runs before finalizeGit so
|
|
@@ -8087,23 +8157,29 @@ async function workAgent(req, flags) {
|
|
|
8087
8157
|
});
|
|
8088
8158
|
}
|
|
8089
8159
|
|
|
8090
|
-
// Keep the process alive until a stop signal
|
|
8091
|
-
//
|
|
8092
|
-
//
|
|
8160
|
+
// Keep the process alive until a stop signal. Three stop modes (issue #202):
|
|
8161
|
+
// - SIGUSR2 → GRACEFUL DRAIN: quiesce the activation loop (lease no new job),
|
|
8162
|
+
// wait for in-flight jobs to settle normally, then interrupt the idle runtime
|
|
8163
|
+
// and exit 0. No harness is killed — work completes.
|
|
8164
|
+
// - SIGTERM / SIGINT → FORCE ABORT: interrupt the runtime (which aborts each
|
|
8165
|
+
// running job's AbortSignal → runAgentJob killTree's the harness process
|
|
8166
|
+
// group) and yield each in-flight job (settle.fail, retries preserved) so it
|
|
8167
|
+
// is immediately retryable, then exit.
|
|
8168
|
+
// A SIGTERM arriving mid-drain ESCALATES the drain to a force abort.
|
|
8093
8169
|
await new Promise((resolve) => {
|
|
8094
|
-
|
|
8095
|
-
|
|
8096
|
-
|
|
8097
|
-
|
|
8098
|
-
|
|
8170
|
+
let quiescing = false;
|
|
8171
|
+
let aborting = false;
|
|
8172
|
+
let finished = false;
|
|
8173
|
+
|
|
8174
|
+
// Shared teardown: stop timers, deregister presence over the still-live
|
|
8175
|
+
// connection, interrupt the runtime fiber (bracketed teardown: release slots,
|
|
8176
|
+
// stop heartbeats, tear down the agentic scope), then resolve.
|
|
8177
|
+
const teardownAndExit = async (signal) => {
|
|
8178
|
+
if (finished) return;
|
|
8179
|
+
finished = true;
|
|
8180
|
+
if (!autoMode) { try { unwatchFile(configFile); } catch { /* best effort */ } }
|
|
8099
8181
|
if (reaperTimer) clearInterval(reaperTimer);
|
|
8100
8182
|
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
8183
|
if (agenticPlane) {
|
|
8108
8184
|
try { agenticPlane.deregister(`worker stopped (${signal})`); } catch { /* best effort */ }
|
|
8109
8185
|
}
|
|
@@ -8115,8 +8191,76 @@ async function workAgent(req, flags) {
|
|
|
8115
8191
|
}
|
|
8116
8192
|
resolve();
|
|
8117
8193
|
};
|
|
8118
|
-
|
|
8119
|
-
|
|
8194
|
+
|
|
8195
|
+
const inFlightCount = () => {
|
|
8196
|
+
try { return SupervisorEffect.runSync(workerRegistry.activeCount); }
|
|
8197
|
+
catch { return activeJobs.size; }
|
|
8198
|
+
};
|
|
8199
|
+
|
|
8200
|
+
const forceAbort = async (signal) => {
|
|
8201
|
+
if (aborting || finished) return;
|
|
8202
|
+
aborting = true;
|
|
8203
|
+
draining = true;
|
|
8204
|
+
if (!autoMode) { try { unwatchFile(configFile); } catch { /* best effort */ } }
|
|
8205
|
+
logger.info(`Received ${signal} — aborting in-flight work and stopping worker...`);
|
|
8206
|
+
// Snapshot in-flight jobs (with their retry budget) BEFORE the interrupt
|
|
8207
|
+
// clears the ownership registry, so we can yield each one afterwards.
|
|
8208
|
+
const inflight = [...activeJobs.entries()].map(([jobKey, info]) => ({ jobKey, retries: info?.retries }));
|
|
8209
|
+
// Interrupt the runtime: this aborts each running job's AbortSignal (the
|
|
8210
|
+
// makeJobRunner seam) so runAgentJob killTree's the harness process group,
|
|
8211
|
+
// and runs dispatch's bracketed teardown (release ownership + slot). The
|
|
8212
|
+
// runner sees the aborted result and, by contract, does NOT settle — we do.
|
|
8213
|
+
if (agenticPlane) { try { agenticPlane.deregister(`worker aborted (${signal})`); } catch { /* best effort */ } }
|
|
8214
|
+
try {
|
|
8215
|
+
await SupervisorEffect.runPromise(SupervisorFiber.interrupt(supervisorFiber));
|
|
8216
|
+
} catch (err) {
|
|
8217
|
+
logger.warn(`supervisor abort error — runtime loop may not have shut down cleanly: ${err?.message || err}`);
|
|
8218
|
+
}
|
|
8219
|
+
// Yield each in-flight job so the broker re-activates it at once (retries
|
|
8220
|
+
// preserved — a force-stop doesn't consume an attempt). Best-effort: a
|
|
8221
|
+
// failed yield just lets the lock lapse (the honest fallback).
|
|
8222
|
+
for (const { jobKey, retries } of inflight) {
|
|
8223
|
+
try {
|
|
8224
|
+
await SupervisorEffect.runPromise(settle.fail(jobKey, {
|
|
8225
|
+
errorMessage: `worker force-stopped (${signal}); job yielded for retry`,
|
|
8226
|
+
retries: Number.isFinite(retries) && retries > 0 ? retries : 1,
|
|
8227
|
+
retryBackOff: 0,
|
|
8228
|
+
}));
|
|
8229
|
+
logger.info(` yielded job ${jobKey} for immediate retry.`);
|
|
8230
|
+
} catch (err) {
|
|
8231
|
+
logger.warn(` could not yield job ${jobKey} (${err?.message || err}); its lock will lapse and the broker will reclaim it.`);
|
|
8232
|
+
}
|
|
8233
|
+
}
|
|
8234
|
+
finished = true; // teardown already interrupted the fiber; just resolve.
|
|
8235
|
+
logger.info('Worker stopped.');
|
|
8236
|
+
resolve();
|
|
8237
|
+
};
|
|
8238
|
+
|
|
8239
|
+
const gracefulDrain = async (signal) => {
|
|
8240
|
+
if (quiescing || aborting || finished) return;
|
|
8241
|
+
quiescing = true;
|
|
8242
|
+
draining = true;
|
|
8243
|
+
// Authoritative quiesce: the activation loop leases no new job even if the
|
|
8244
|
+
// --auto reconcile rewrites this worker's job types (registry.quiesce wins).
|
|
8245
|
+
try { SupervisorEffect.runSync(workerRegistry.quiesce()); } catch { /* best effort */ }
|
|
8246
|
+
if (!autoMode) { try { unwatchFile(configFile); } catch { /* best effort */ } }
|
|
8247
|
+
const n0 = inFlightCount();
|
|
8248
|
+
logger.info(`Received ${signal} — draining: polling stopped; waiting on ${n0} in-flight job(s) to finish (send SIGTERM to abort).`);
|
|
8249
|
+
// Poll until every in-flight job has settled and released its slot, then
|
|
8250
|
+
// interrupt the now-idle runtime and exit. Wait INDEFINITELY — a force
|
|
8251
|
+
// abort (SIGTERM) is the only escape hatch.
|
|
8252
|
+
const tick = async () => {
|
|
8253
|
+
if (aborting || finished) return; // escalated to a force abort — let it own exit
|
|
8254
|
+
const n = inFlightCount();
|
|
8255
|
+
if (n <= 0) { await teardownAndExit(signal); return; }
|
|
8256
|
+
setTimeout(tick, 200);
|
|
8257
|
+
};
|
|
8258
|
+
setTimeout(tick, 200);
|
|
8259
|
+
};
|
|
8260
|
+
|
|
8261
|
+
process.once(SUPERVISOR_DRAIN_SIGNAL, () => { gracefulDrain(SUPERVISOR_DRAIN_SIGNAL); });
|
|
8262
|
+
process.once('SIGINT', () => { forceAbort('SIGINT'); });
|
|
8263
|
+
process.once('SIGTERM', () => { forceAbort('SIGTERM'); });
|
|
8120
8264
|
});
|
|
8121
8265
|
}
|
|
8122
8266
|
|
|
@@ -8860,8 +9004,10 @@ function waitForChildExit(child, timeoutMs) {
|
|
|
8860
9004
|
return new Promise((resolve) => {
|
|
8861
9005
|
if (!child || child.exitCode !== null || child.signalCode !== null) return resolve();
|
|
8862
9006
|
let done = false;
|
|
8863
|
-
|
|
8864
|
-
|
|
9007
|
+
// #202: a null/undefined timeout means WAIT INDEFINITELY (graceful drain) —
|
|
9008
|
+
// no timer is armed, so we only resolve when the child actually exits.
|
|
9009
|
+
const t = timeoutMs == null ? null : setTimeout(() => finish(), timeoutMs);
|
|
9010
|
+
function finish() { if (done) return; done = true; if (t) clearTimeout(t); resolve(); }
|
|
8865
9011
|
child.once('exit', finish);
|
|
8866
9012
|
});
|
|
8867
9013
|
}
|
|
@@ -8939,7 +9085,21 @@ async function runSupervisorDaemon() {
|
|
|
8939
9085
|
|
|
8940
9086
|
const workers = new Map();
|
|
8941
9087
|
const attachClients = new Set();
|
|
9088
|
+
// #202: sockets that issued a `stop`/drain and are waiting for the daemon to
|
|
9089
|
+
// finish. They get a terminal `stopped` (final) frame when shutdown completes,
|
|
9090
|
+
// so the streaming `stop` client (which waits indefinitely) sees a clean
|
|
9091
|
+
// end-of-response instead of a bare socket close. A one-shot `supervisorRequest`
|
|
9092
|
+
// has a fixed 15s `SUPERVISOR_RESPONSE_TIMEOUT_MS` deadline, so it only observes
|
|
9093
|
+
// that frame when the drain completes within it; a longer drain times out
|
|
9094
|
+
// client-side while the daemon keeps draining in the background.
|
|
9095
|
+
const stopClients = new Set();
|
|
8942
9096
|
let shuttingDown = false;
|
|
9097
|
+
// #202: a graceful-drain shutdown is in progress (SIGUSR2 sent to workers,
|
|
9098
|
+
// awaiting them to finish in-flight jobs and exit). `forcing` records that a
|
|
9099
|
+
// `stop --force` has escalated that drain to a hard abort. Both gate the
|
|
9100
|
+
// restart-on-exit path and let a second `stop --force` escalate a live drain.
|
|
9101
|
+
let draining = false;
|
|
9102
|
+
let forcing = false;
|
|
8943
9103
|
// Live-view monitor: tracks the last-broadcast fleet signature so we push a
|
|
8944
9104
|
// refreshed status to attached consoles only on real change (see below).
|
|
8945
9105
|
let monitorTimer = null;
|
|
@@ -9070,7 +9230,23 @@ async function runSupervisorDaemon() {
|
|
|
9070
9230
|
try { rmSync(w.activityFile || supervisorWorkerActivityFile(w.id), { force: true }); } catch { /* best effort */ }
|
|
9071
9231
|
const ranMs = Date.now() - (w.spawnedAt || Date.now());
|
|
9072
9232
|
if (ranMs >= SUPERVISOR_HEALTHY_UPTIME_MS) w.restarts = 0;
|
|
9073
|
-
if (w.stopping || shuttingDown || !workers.has(w.id)) {
|
|
9233
|
+
if (w.stopping || shuttingDown || !workers.has(w.id)) {
|
|
9234
|
+
// #202 drain-remove: a worker flagged for removal has now drained and
|
|
9235
|
+
// exited — delete it and announce its removal (mirrors the synchronous
|
|
9236
|
+
// force-remove path). Do this before persist() so the state reflects it.
|
|
9237
|
+
if (w.removeOnExit && workers.get(w.id) === w) {
|
|
9238
|
+
workers.delete(w.id);
|
|
9239
|
+
try { rmSync(w.activityFile || supervisorWorkerActivityFile(w.id), { force: true }); } catch { /* best effort */ }
|
|
9240
|
+
dlog(`worker '${w.id}' removed (drained)`);
|
|
9241
|
+
broadcast({ type: 'event', event: 'worker-remove', id: w.id });
|
|
9242
|
+
}
|
|
9243
|
+
persist();
|
|
9244
|
+
// #202: during a graceful drain, push a fresh status so an attached
|
|
9245
|
+
// `stop` client sees the in-flight count shrink as each worker finishes
|
|
9246
|
+
// and exits — even when the periodic monitor is disabled.
|
|
9247
|
+
if (draining) { try { broadcast(statusFrame(false)); } catch { /* best effort */ } }
|
|
9248
|
+
return;
|
|
9249
|
+
}
|
|
9074
9250
|
const delay = supervisorBackoffMs(w.restarts);
|
|
9075
9251
|
w.restarts += 1;
|
|
9076
9252
|
dlog(`worker '${w.id}' down (${reason}); restarting in ${delay}ms (restart #${w.restarts})`);
|
|
@@ -9109,23 +9285,55 @@ async function runSupervisorDaemon() {
|
|
|
9109
9285
|
return w;
|
|
9110
9286
|
};
|
|
9111
9287
|
|
|
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; }
|
|
9288
|
+
const forceStopWorker = async (w) => {
|
|
9117
9289
|
const pid = w.pid;
|
|
9118
9290
|
if (w.child && pid) {
|
|
9119
9291
|
try { process.kill(pid, 'SIGTERM'); } catch { /* already gone */ }
|
|
9120
9292
|
await waitForChildExit(w.child, STOP_GRACE_MS);
|
|
9121
9293
|
if (isPidAlive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } }
|
|
9122
9294
|
}
|
|
9295
|
+
};
|
|
9296
|
+
|
|
9297
|
+
const stopWorker = async (id, { force = true } = {}) => {
|
|
9298
|
+
const w = workers.get(id);
|
|
9299
|
+
if (!w) return false;
|
|
9300
|
+
w.stopping = true;
|
|
9301
|
+
if (w.restartTimer) { clearTimeout(w.restartTimer); w.restartTimer = null; }
|
|
9302
|
+
const pid = w.pid;
|
|
9303
|
+
if (!force) {
|
|
9304
|
+
// #202 graceful drain: send SIGUSR2 so the child quiesces its activation
|
|
9305
|
+
// loop, lets in-flight jobs finish, and exits on its own. Wait INDEFINITELY
|
|
9306
|
+
// — a `stop --force` escalation (forceStopWorker) is the only way to cut a
|
|
9307
|
+
// stuck drain short, and it resolves this same wait when the child dies.
|
|
9308
|
+
if (w.child && pid) {
|
|
9309
|
+
try { process.kill(pid, SUPERVISOR_DRAIN_SIGNAL); } catch { /* already gone */ }
|
|
9310
|
+
await waitForChildExit(w.child, null);
|
|
9311
|
+
}
|
|
9312
|
+
return true;
|
|
9313
|
+
}
|
|
9314
|
+
await forceStopWorker(w);
|
|
9123
9315
|
return true;
|
|
9124
9316
|
};
|
|
9125
9317
|
|
|
9126
|
-
const removeWorker = async (id) => {
|
|
9127
|
-
|
|
9128
|
-
|
|
9318
|
+
const removeWorker = async (id, { force = true } = {}) => {
|
|
9319
|
+
const w = workers.get(id);
|
|
9320
|
+
if (!w) return false;
|
|
9321
|
+
if (!force) {
|
|
9322
|
+
// #202 drain-remove (used by `workforce stop`): quiesce this worker and let
|
|
9323
|
+
// it finish its in-flight jobs, then let the death handler delete it once
|
|
9324
|
+
// it exits. Return immediately so the control loop keeps serving (the
|
|
9325
|
+
// client polls status to watch the worker disappear) rather than blocking
|
|
9326
|
+
// the request queue for the whole — possibly long — drain.
|
|
9327
|
+
w.stopping = true;
|
|
9328
|
+
w.removeOnExit = true;
|
|
9329
|
+
if (w.restartTimer) { clearTimeout(w.restartTimer); w.restartTimer = null; }
|
|
9330
|
+
const pid = w.pid;
|
|
9331
|
+
if (w.child && pid) { try { process.kill(pid, SUPERVISOR_DRAIN_SIGNAL); } catch { /* already gone */ } }
|
|
9332
|
+
else { workers.delete(id); try { rmSync(supervisorWorkerActivityFile(id), { force: true }); } catch { /* best effort */ } broadcast({ type: 'event', event: 'worker-remove', id }); persist(); }
|
|
9333
|
+
dlog(`worker '${id}' draining for removal`);
|
|
9334
|
+
return true;
|
|
9335
|
+
}
|
|
9336
|
+
await stopWorker(id, { force: true });
|
|
9129
9337
|
workers.delete(id);
|
|
9130
9338
|
try { rmSync(supervisorWorkerActivityFile(id), { force: true }); } catch { /* best effort */ }
|
|
9131
9339
|
dlog(`worker '${id}' removed`);
|
|
@@ -9166,16 +9374,45 @@ async function runSupervisorDaemon() {
|
|
|
9166
9374
|
...(final ? { final: true } : {}),
|
|
9167
9375
|
});
|
|
9168
9376
|
|
|
9169
|
-
const shutdown = async (signal) => {
|
|
9170
|
-
if (shuttingDown)
|
|
9377
|
+
const shutdown = async (signal, { force = true } = {}) => {
|
|
9378
|
+
if (shuttingDown) {
|
|
9379
|
+
// A shutdown is already running. A `stop --force` arriving mid-DRAIN
|
|
9380
|
+
// escalates it: hard-stop every worker still finishing its jobs so the
|
|
9381
|
+
// operator isn't stuck waiting. Idempotent — only the first force escalates.
|
|
9382
|
+
if (force && draining && !forcing) {
|
|
9383
|
+
forcing = true;
|
|
9384
|
+
dlog('stop --force received during drain — escalating to hard abort');
|
|
9385
|
+
broadcast({ type: 'event', event: 'draining-escalated' });
|
|
9386
|
+
await Promise.all([...workers.values()].map((w) => forceStopWorker(w)));
|
|
9387
|
+
}
|
|
9388
|
+
return;
|
|
9389
|
+
}
|
|
9171
9390
|
shuttingDown = true;
|
|
9391
|
+
draining = !force;
|
|
9172
9392
|
// Let any in-flight mutation finish before we snapshot the worker set, so
|
|
9173
9393
|
// an add/restart racing the shutdown can't leave an orphaned child behind.
|
|
9174
9394
|
try { await opQueue; } catch { /* mutation already logged */ }
|
|
9395
|
+
dlog(`received ${signal || 'stop'} — ${force ? 'stopping' : 'draining'} ${workers.size} worker(s)`);
|
|
9396
|
+
if (!force) broadcast({ type: 'event', event: 'draining', workers: [...workers.values()].map(workerPublic) });
|
|
9397
|
+
await Promise.all([...workers.keys()].map((id) => stopWorker(id, { force })));
|
|
9175
9398
|
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
9399
|
broadcast({ type: 'event', event: 'daemon-stop' });
|
|
9400
|
+
// Terminal frame for every waiting `stop` client (streaming or one-shot).
|
|
9401
|
+
// We must FLUSH these before exiting: `process.exit()` does NOT drain pending
|
|
9402
|
+
// socket I/O, so an immediate exit can drop the final frame and turn the
|
|
9403
|
+
// clean end-of-response the streaming stop client waits for into a bare
|
|
9404
|
+
// socket close. `end(frame, cb)` writes the frame then sends FIN, and its
|
|
9405
|
+
// callback fires once the bytes are handed off; await them all (with a short
|
|
9406
|
+
// timeout guard so a wedged/slow client can't block the exit indefinitely).
|
|
9407
|
+
await Promise.all([...stopClients].map((s) => new Promise((resolve) => {
|
|
9408
|
+
let done = false;
|
|
9409
|
+
const finish = () => { if (!done) { done = true; resolve(); } };
|
|
9410
|
+
try {
|
|
9411
|
+
const timer = setTimeout(finish, 2000);
|
|
9412
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
9413
|
+
s.end(encodeFrame({ ok: true, type: 'stopped', final: true }), () => { clearTimeout(timer); finish(); });
|
|
9414
|
+
} catch { finish(); /* client gone */ }
|
|
9415
|
+
})));
|
|
9179
9416
|
try { server.close(); } catch { /* ignore */ }
|
|
9180
9417
|
if (osPlatform() !== 'win32') { try { rmSync(socketPath, { force: true }); } catch { /* ignore */ } }
|
|
9181
9418
|
clearSupervisorState();
|
|
@@ -9213,12 +9450,17 @@ async function runSupervisorDaemon() {
|
|
|
9213
9450
|
}
|
|
9214
9451
|
case 'remove': {
|
|
9215
9452
|
if (shuttingDown) { sock.write(encodeFrame({ ok: false, error: 'supervisor is shutting down', final: true })); break; }
|
|
9453
|
+
// #202: `req.force === false` drains each worker (finish in-flight jobs
|
|
9454
|
+
// then exit); the default remains a fast force-stop (used by reconcile
|
|
9455
|
+
// and interactive remove). A drain-remove returns immediately and the
|
|
9456
|
+
// worker disappears from status once it has drained.
|
|
9457
|
+
const removeForce = req.force !== false;
|
|
9216
9458
|
const removed = await serializeOp(async () => {
|
|
9217
9459
|
const ids = resolveTargets(req.target);
|
|
9218
|
-
for (const id of ids) await removeWorker(id);
|
|
9460
|
+
for (const id of ids) await removeWorker(id, { force: removeForce });
|
|
9219
9461
|
return ids;
|
|
9220
9462
|
});
|
|
9221
|
-
sock.write(encodeFrame({ ok: true, type: 'removed', removed, final: true }));
|
|
9463
|
+
sock.write(encodeFrame({ ok: true, type: 'removed', removed, draining: !removeForce, final: true }));
|
|
9222
9464
|
break;
|
|
9223
9465
|
}
|
|
9224
9466
|
case 'restart': {
|
|
@@ -9235,10 +9477,25 @@ async function runSupervisorDaemon() {
|
|
|
9235
9477
|
attachClients.add(sock);
|
|
9236
9478
|
sock.write(encodeFrame(statusFrame(false)));
|
|
9237
9479
|
break;
|
|
9238
|
-
case 'stop':
|
|
9239
|
-
|
|
9240
|
-
|
|
9480
|
+
case 'stop': {
|
|
9481
|
+
// #202: default is a GRACEFUL DRAIN — quiesce workers, let in-flight
|
|
9482
|
+
// jobs finish, exit when idle. `req.force` hard-aborts (kill harness,
|
|
9483
|
+
// yield jobs) and can also ESCALATE a drain already in progress.
|
|
9484
|
+
const force = !!req.force;
|
|
9485
|
+
// Register this client as an attach consumer so it streams drain
|
|
9486
|
+
// progress (shrinking in-flight counts), and as a stop client so it
|
|
9487
|
+
// gets a terminal `stopped` frame when the daemon has finished.
|
|
9488
|
+
attachClients.add(sock);
|
|
9489
|
+
stopClients.add(sock);
|
|
9490
|
+
sock.write(encodeFrame(force
|
|
9491
|
+
? { ok: true, type: 'stopping', force: true }
|
|
9492
|
+
: { ok: true, type: 'draining' }));
|
|
9493
|
+
sock.write(encodeFrame(statusFrame(false)));
|
|
9494
|
+
// Kick the shutdown asynchronously; don't await it here so the control
|
|
9495
|
+
// loop keeps serving (streaming status, accepting a force escalation).
|
|
9496
|
+
setTimeout(() => { shutdown('stop', { force }); }, 20);
|
|
9241
9497
|
break;
|
|
9498
|
+
}
|
|
9242
9499
|
default:
|
|
9243
9500
|
sock.write(encodeFrame({ ok: false, error: `unknown op "${op}"`, final: true }));
|
|
9244
9501
|
}
|
|
@@ -9276,8 +9533,8 @@ async function runSupervisorDaemon() {
|
|
|
9276
9533
|
queue = queue.then(() => handleRequest(req, sock)).catch((err) => dlog(`request error: ${err?.message || err}`));
|
|
9277
9534
|
}
|
|
9278
9535
|
});
|
|
9279
|
-
sock.on('close', () => attachClients.delete(sock));
|
|
9280
|
-
sock.on('error', () => attachClients.delete(sock));
|
|
9536
|
+
sock.on('close', () => { attachClients.delete(sock); stopClients.delete(sock); });
|
|
9537
|
+
sock.on('error', () => { attachClients.delete(sock); stopClients.delete(sock); });
|
|
9281
9538
|
});
|
|
9282
9539
|
|
|
9283
9540
|
// Create the control socket owner-only from the start. The socket file lives
|
|
@@ -9622,7 +9879,29 @@ async function supervisorRestartCmd(req) {
|
|
|
9622
9879
|
else { logger.error(res.error); process.exit(1); }
|
|
9623
9880
|
}
|
|
9624
9881
|
|
|
9625
|
-
|
|
9882
|
+
/**
|
|
9883
|
+
* Count the in-flight jobs across a fleet snapshot (array of
|
|
9884
|
+
* `summarizeSupervisorWorker` results) — the number an operator is waiting on
|
|
9885
|
+
* when draining.
|
|
9886
|
+
*/
|
|
9887
|
+
function countSupervisorInFlight(workers) {
|
|
9888
|
+
let n = 0;
|
|
9889
|
+
for (const w of workers || []) {
|
|
9890
|
+
if (w && w.activity && Array.isArray(w.activity.jobs)) n += w.activity.jobs.length;
|
|
9891
|
+
}
|
|
9892
|
+
return n;
|
|
9893
|
+
}
|
|
9894
|
+
|
|
9895
|
+
/**
|
|
9896
|
+
* Stop the supervisor. By default (issue #202) this GRACEFULLY DRAINS: the
|
|
9897
|
+
* daemon quiesces its workers (they stop leasing new jobs, finish the ones in
|
|
9898
|
+
* flight, and exit), streaming the shrinking in-flight count to this client the
|
|
9899
|
+
* whole time. Ctrl-C DETACHES — the daemon keeps draining in the background.
|
|
9900
|
+
* `force` hard-aborts instead: each worker's harness is killed and its job is
|
|
9901
|
+
* yielded for immediate retry. A `force` request also escalates a drain that is
|
|
9902
|
+
* already in progress.
|
|
9903
|
+
*/
|
|
9904
|
+
async function supervisorStopCmd(force = false) {
|
|
9626
9905
|
const logger = getLogger();
|
|
9627
9906
|
const running = await liveSupervisor();
|
|
9628
9907
|
if (!running) {
|
|
@@ -9630,35 +9909,100 @@ async function supervisorStopCmd() {
|
|
|
9630
9909
|
else logger.warn('Supervisor is not running — nothing to stop.');
|
|
9631
9910
|
return;
|
|
9632
9911
|
}
|
|
9633
|
-
|
|
9634
|
-
|
|
9635
|
-
|
|
9636
|
-
|
|
9637
|
-
|
|
9638
|
-
|
|
9639
|
-
|
|
9640
|
-
|
|
9641
|
-
|
|
9642
|
-
|
|
9643
|
-
|
|
9644
|
-
|
|
9645
|
-
|
|
9646
|
-
|
|
9647
|
-
if (
|
|
9648
|
-
|
|
9912
|
+
const socketPath = running.socket || getSupervisorSocketPath();
|
|
9913
|
+
const daemonPid = running.pid;
|
|
9914
|
+
|
|
9915
|
+
// Stream drain/stop progress. Resolves with the outcome that ended the stream.
|
|
9916
|
+
const outcome = await new Promise((resolve) => {
|
|
9917
|
+
let sock = null;
|
|
9918
|
+
let buf = '';
|
|
9919
|
+
let done = false;
|
|
9920
|
+
let lastCount = null;
|
|
9921
|
+
let onSigint = null;
|
|
9922
|
+
const cleanup = () => {
|
|
9923
|
+
if (onSigint) { try { process.removeListener('SIGINT', onSigint); } catch { /* ignore */ } }
|
|
9924
|
+
try { if (sock) sock.end(); } catch { /* ignore */ }
|
|
9925
|
+
};
|
|
9926
|
+
const finish = (result) => { if (done) return; done = true; cleanup(); resolve(result); };
|
|
9927
|
+
|
|
9928
|
+
supervisorConnect(socketPath).then((s) => {
|
|
9929
|
+
sock = s;
|
|
9930
|
+
sock.setEncoding('utf8');
|
|
9931
|
+
// Ctrl-C detaches the client only — the daemon keeps draining. (Force
|
|
9932
|
+
// stops don't wait on the operator, so a Ctrl-C there just stops watching.)
|
|
9933
|
+
onSigint = () => {
|
|
9934
|
+
if (force) {
|
|
9935
|
+
logger.info('Detached — supervisor is aborting in the background.');
|
|
9936
|
+
} else {
|
|
9937
|
+
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.');
|
|
9938
|
+
}
|
|
9939
|
+
finish('detached');
|
|
9940
|
+
};
|
|
9941
|
+
process.on('SIGINT', onSigint);
|
|
9942
|
+
|
|
9943
|
+
sock.on('data', (chunk) => {
|
|
9944
|
+
buf += chunk;
|
|
9945
|
+
const { frames, rest } = decodeFrames(buf);
|
|
9946
|
+
buf = rest;
|
|
9947
|
+
for (const frame of frames) {
|
|
9948
|
+
if (frame && frame.type === 'stopping') {
|
|
9949
|
+
logger.info('Aborting in-flight work — killing harnesses and yielding jobs for retry...');
|
|
9950
|
+
} else if (frame && frame.event === 'draining-escalated') {
|
|
9951
|
+
logger.info('Escalating to --force — aborting in-flight work...');
|
|
9952
|
+
} else if (frame && (frame.type === 'status' || frame.event === 'draining')) {
|
|
9953
|
+
const n = countSupervisorInFlight(frame.workers);
|
|
9954
|
+
if (!force && n !== lastCount) {
|
|
9955
|
+
lastCount = n;
|
|
9956
|
+
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.`);
|
|
9957
|
+
else logger.info('No jobs in flight — stopping.');
|
|
9958
|
+
}
|
|
9959
|
+
} else if (frame && frame.event === 'daemon-stop') {
|
|
9960
|
+
finish('stopped');
|
|
9961
|
+
} else if (frame && (frame.type === 'stopped' || frame.final)) {
|
|
9962
|
+
finish('stopped');
|
|
9963
|
+
}
|
|
9964
|
+
}
|
|
9965
|
+
});
|
|
9966
|
+
sock.on('error', () => finish('closed'));
|
|
9967
|
+
sock.on('close', () => finish('closed'));
|
|
9968
|
+
sock.write(encodeFrame({ op: 'stop', force: !!force }));
|
|
9969
|
+
}).catch(() => finish('unreachable'));
|
|
9970
|
+
});
|
|
9971
|
+
|
|
9972
|
+
if (outcome === 'detached') return;
|
|
9973
|
+
|
|
9974
|
+
if (outcome === 'unreachable') {
|
|
9975
|
+
if (force) {
|
|
9976
|
+
// Socket unreachable — fall back to signalling the daemon pid directly.
|
|
9977
|
+
try { process.kill(daemonPid, 'SIGTERM'); } catch { /* already gone */ }
|
|
9978
|
+
} else {
|
|
9979
|
+
logger.warn('Could not reach the supervisor control socket to drain it. Rerun with --force to abort, or stop it manually.');
|
|
9980
|
+
return;
|
|
9981
|
+
}
|
|
9649
9982
|
}
|
|
9650
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
|
|
9654
|
-
|
|
9655
|
-
|
|
9656
|
-
|
|
9657
|
-
|
|
9658
|
-
|
|
9983
|
+
|
|
9984
|
+
// Wait for the daemon process itself to exit. A drain waits INDEFINITELY (the
|
|
9985
|
+
// operator opted to wait); a force stop applies the grace window + a SIGKILL
|
|
9986
|
+
// backstop so a wedged daemon/worker group can't linger.
|
|
9987
|
+
if (force) {
|
|
9988
|
+
const deadline = Date.now() + STOP_GRACE_MS + 2_000;
|
|
9989
|
+
while (Date.now() < deadline) {
|
|
9990
|
+
if (!isPidAlive(daemonPid)) break;
|
|
9991
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
9992
|
+
}
|
|
9993
|
+
if (isPidAlive(daemonPid)) {
|
|
9994
|
+
logger.warn(`Supervisor (pid ${daemonPid}) did not stop gracefully — sending SIGKILL.`);
|
|
9995
|
+
let killedGroup = false;
|
|
9996
|
+
if (osPlatform() !== 'win32') {
|
|
9997
|
+
try { process.kill(-daemonPid, 'SIGKILL'); killedGroup = true; } catch { /* fall back below */ }
|
|
9998
|
+
}
|
|
9999
|
+
if (!killedGroup) { try { process.kill(daemonPid, 'SIGKILL'); } catch { /* ignore */ } }
|
|
10000
|
+
clearSupervisorState();
|
|
10001
|
+
}
|
|
10002
|
+
} else {
|
|
10003
|
+
while (isPidAlive(daemonPid)) {
|
|
10004
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
9659
10005
|
}
|
|
9660
|
-
if (!killedGroup) { try { process.kill(running.pid, 'SIGKILL'); } catch { /* ignore */ } }
|
|
9661
|
-
clearSupervisorState();
|
|
9662
10006
|
}
|
|
9663
10007
|
logger.info('Supervisor stopped.');
|
|
9664
10008
|
}
|
|
@@ -10335,7 +10679,7 @@ async function supervisorCommand(req, flags) {
|
|
|
10335
10679
|
await supervisorRestartCmd(req);
|
|
10336
10680
|
return;
|
|
10337
10681
|
case 'stop':
|
|
10338
|
-
await supervisorStopCmd();
|
|
10682
|
+
await supervisorStopCmd(coerceBool(flags?.force, false));
|
|
10339
10683
|
return;
|
|
10340
10684
|
case 'logs':
|
|
10341
10685
|
case 'log':
|
|
@@ -11234,14 +11578,38 @@ async function workforceStopCmd(req, flags, manifestName) {
|
|
|
11234
11578
|
})
|
|
11235
11579
|
.map((w) => w.id);
|
|
11236
11580
|
let hadError = false;
|
|
11581
|
+
const force = coerceBool(flags?.force, false);
|
|
11237
11582
|
if (owned.length === 0) {
|
|
11238
11583
|
logger.info(`No workers from workforce "${manifestName}" are running.`);
|
|
11239
|
-
} else {
|
|
11584
|
+
} else if (force) {
|
|
11240
11585
|
for (const id of owned) {
|
|
11241
|
-
const res = await supervisorRequest({ op: 'remove', target: id });
|
|
11242
|
-
if (res && res.ok) logger.info(`Removed worker "${id}".`);
|
|
11586
|
+
const res = await supervisorRequest({ op: 'remove', target: id, force: true });
|
|
11587
|
+
if (res && res.ok) logger.info(`Removed worker "${id}" (aborted in-flight work).`);
|
|
11243
11588
|
else { logger.error(`Could not remove "${id}": ${(res && res.error) || 'unknown error'}`); hadError = true; }
|
|
11244
11589
|
}
|
|
11590
|
+
} else {
|
|
11591
|
+
// #202 graceful drain: ask the daemon to quiesce each owned worker (finish
|
|
11592
|
+
// in-flight jobs, then exit) and poll status until they've all drained away.
|
|
11593
|
+
for (const id of owned) {
|
|
11594
|
+
const res = await supervisorRequest({ op: 'remove', target: id, force: false });
|
|
11595
|
+
if (res && res.ok) logger.info(`Draining worker "${id}"...`);
|
|
11596
|
+
else { logger.error(`Could not drain "${id}": ${(res && res.error) || 'unknown error'}`); hadError = true; }
|
|
11597
|
+
}
|
|
11598
|
+
const ownedSet = new Set(owned);
|
|
11599
|
+
let lastInFlight = null;
|
|
11600
|
+
for (;;) {
|
|
11601
|
+
const snap = await fetchSupervisorWorkers();
|
|
11602
|
+
if (!snap.running || !snap.reachable) break;
|
|
11603
|
+
const remaining = (snap.workers || []).filter((w) => w && ownedSet.has(w.id));
|
|
11604
|
+
if (remaining.length === 0) break;
|
|
11605
|
+
const inFlight = countSupervisorInFlight(remaining);
|
|
11606
|
+
if (inFlight !== lastInFlight) {
|
|
11607
|
+
lastInFlight = inFlight;
|
|
11608
|
+
logger.info(`Draining "${manifestName}" — ${remaining.length} worker(s) still finishing ${inFlight} in-flight job(s). Rerun with --force to abort.`);
|
|
11609
|
+
}
|
|
11610
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
11611
|
+
}
|
|
11612
|
+
if (lastInFlight !== null) logger.info(`Workforce "${manifestName}" drained.`);
|
|
11245
11613
|
}
|
|
11246
11614
|
// If no supervised workers remain, stop the daemon too — but only when the
|
|
11247
11615
|
// status socket actually answered. A `{ workers: [] }` from an *unreachable*
|
|
@@ -11257,7 +11625,7 @@ async function workforceStopCmd(req, flags, manifestName) {
|
|
|
11257
11625
|
logger.warn('Supervisor status socket became unreachable; leaving the daemon running.');
|
|
11258
11626
|
} else if (remaining.length === 0) {
|
|
11259
11627
|
logger.info('No supervised workers remain — stopping the supervisor daemon.');
|
|
11260
|
-
await supervisorStopCmd();
|
|
11628
|
+
await supervisorStopCmd(force);
|
|
11261
11629
|
} else {
|
|
11262
11630
|
logger.info(`${remaining.length} other supervised worker(s) remain; leaving the daemon running.`);
|
|
11263
11631
|
}
|
|
@@ -13281,7 +13649,7 @@ export const commands = {
|
|
|
13281
13649
|
console: { type: 'string', description: 'start: runtime console profile off|observe|studio (NANOBPMN_CONSOLE; default studio)' },
|
|
13282
13650
|
follow: { type: 'boolean', description: 'logs: stream output (tail -F)', short: 'f' },
|
|
13283
13651
|
purge: { type: 'boolean', description: 'stop/restart: also delete per-node engine data' },
|
|
13284
|
-
force: { type: 'boolean', description: 'start: stop any existing cluster first' },
|
|
13652
|
+
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
13653
|
workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
|
|
13286
13654
|
check: { type: 'boolean', description: 'update: report whether a new release is available (with the changelog since the installed version); do not install' },
|
|
13287
13655
|
binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
|