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 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, then interrupt the runtime loop
8091
- // and tear down visibility. Interrupting the supervisor fiber runs the runtime's
8092
- // bracketed teardown (release slots, stop the heartbeat + agentic scope).
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
- const stop = async (signal) => {
8095
- if (draining) return;
8096
- draining = true;
8097
- if (!autoMode) unwatchFile(configFile);
8098
- logger.info(`Received ${signal} stopping worker...`);
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
- process.once('SIGINT', () => { stop('SIGINT'); });
8119
- process.once('SIGTERM', () => { stop('SIGTERM'); });
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
- const finish = () => { if (done) return; done = true; clearTimeout(t); resolve(); };
8864
- const t = setTimeout(finish, timeoutMs);
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)) { persist(); return; }
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 stopWorker = async (id) => {
9113
- const w = workers.get(id);
9114
- if (!w) return false;
9115
- w.stopping = true;
9116
- if (w.restartTimer) { clearTimeout(w.restartTimer); w.restartTimer = null; }
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
- if (!workers.has(id)) return false;
9128
- await stopWorker(id);
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) return;
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
- sock.write(encodeFrame({ ok: true, type: 'stopping', final: true }));
9240
- setTimeout(() => shutdown('stop'), 50);
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
- async function supervisorStopCmd() {
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
- try {
9634
- await supervisorRequest({ op: 'stop' });
9635
- } catch {
9636
- // Socket unreachable fall back to signalling the daemon pid directly.
9637
- try { process.kill(running.pid, 'SIGTERM'); } catch { /* already gone */ }
9638
- }
9639
- // Gate the wait loop and the SIGKILL fallback on the daemon pid we captured,
9640
- // not on runningSupervisor()/the state file: the daemon clears its state file
9641
- // as part of shutting down (and liveSupervisor()/external cleanup can remove
9642
- // it too), so a state-file check can report "gone" while the process is still
9643
- // alive which would break the loop early and skip the SIGKILL fallback,
9644
- // leaving a wedged daemon and its worker process group running.
9645
- const deadline = Date.now() + STOP_GRACE_MS + 2_000;
9646
- while (Date.now() < deadline) {
9647
- if (!isPidAlive(running.pid)) break;
9648
- await new Promise((r) => setTimeout(r, 150));
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
- if (isPidAlive(running.pid)) {
9651
- logger.warn(`Supervisor (pid ${running.pid}) did not stop gracefully sending SIGKILL.`);
9652
- // The daemon is spawned detached (a process-group leader) and its workers
9653
- // are children in that group, so SIGKILL the whole group to avoid orphaning
9654
- // `nano work` processes. Fall back to the bare pid (e.g. on Windows, or if
9655
- // the daemon isn't a group leader).
9656
- let killedGroup = false;
9657
- if (osPlatform() !== 'win32') {
9658
- try { process.kill(-running.pid, 'SIGKILL'); killedGroup = true; } catch { /* fall back below */ }
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' },