c8ctl-plugin-nano 1.64.0 → 1.65.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
@@ -159,6 +159,19 @@ const STOP_GRACE_MS = 8_000;
159
159
  // `nano work` child to quiesce it — stop leasing new jobs, finish in-flight work,
160
160
  // then exit. SIGTERM/SIGINT remain the FORCE abort (kill harness, yield jobs).
161
161
  const SUPERVISOR_DRAIN_SIGNAL = 'SIGUSR2';
162
+ // Hot-reload readiness gate: after a rolling `supervisor reload` respawns a
163
+ // worker, the daemon waits (bounded) for the replacement to STAMP `readyAt` on
164
+ // its activity marker — i.e. its activation loop is up and leasing jobs — before
165
+ // draining the NEXT worker, so at most one worker is ever unavailable at a time.
166
+ // A bare spawn/PID is not readiness: the child still has to import the plugin,
167
+ // build its SDK client and start its activation loop. The wait is bounded so a
168
+ // slow/never-ready replacement (e.g. a wedged engine) can't stall the roll — on
169
+ // timeout the daemon advances anyway (degrading to the old spawn-and-advance).
170
+ const SUPERVISOR_RELOAD_READY_POLL_MS = 100;
171
+ const SUPERVISOR_RELOAD_READY_TIMEOUT_MS = Math.max(
172
+ 0,
173
+ Number.parseInt(process.env.NANO_SUPERVISOR_RELOAD_READY_TIMEOUT_MS ?? '', 10) || 30_000,
174
+ );
162
175
  // Upper bound on one `--auto` engine-read reconcile (enumerate deployed
163
176
  // definitions + fetch each BPMN). A read that stalls past this is treated as a
164
177
  // transient failure so the running poller set is KEPT and, crucially, shutdown
@@ -325,6 +338,19 @@ function readWorkerActivity(id) {
325
338
  }
326
339
  }
327
340
 
341
+ /**
342
+ * Whether an activity marker proves a specific child (`pid`) is up and leasing.
343
+ * Both conditions are required (#253): a finite `readyAt` (the runtime's
344
+ * first-activation handshake fired) AND `act.pid === pid` (the marker belongs to
345
+ * THIS child, not a stale one a failed best-effort delete left behind from a
346
+ * previous incarnation — which would otherwise let the rolling reload advance
347
+ * before the fresh replacement has actually reported ready). Pure so it can be
348
+ * unit-tested directly.
349
+ */
350
+ function activityMarkerReadyFor(act, pid) {
351
+ return !!(act && act.pid === pid && Number.isFinite(act.readyAt));
352
+ }
353
+
328
354
  /**
329
355
  * Deterministic control-socket path shared by the daemon and every client.
330
356
  * Derived from a hash of the (possibly overridden) state home so distinct
@@ -3589,6 +3615,11 @@ async function createSupervisorDeps(opts = {}) {
3589
3615
  config,
3590
3616
  fetchImpl,
3591
3617
  env = process.env,
3618
+ // A plain JS thunk (Effect-free, monolith-supplied) fired ONCE when the
3619
+ // activation loop begins leasing — lifted below into the runtime's
3620
+ // `onFirstActivation` Effect. Used as the rolling-reload readiness handshake
3621
+ // (#253): stamping readiness only when the runtime is actually serving.
3622
+ onFirstActivation,
3592
3623
  } = opts;
3593
3624
  if (!runner || typeof runner.run !== 'function') {
3594
3625
  throw new TypeError('createSupervisorDeps: `runner` must be a raw job runner `{ run(job): Promise<void> }`');
@@ -3597,7 +3628,6 @@ async function createSupervisorDeps(opts = {}) {
3597
3628
  const rt = await loadSupervisorRuntime();
3598
3629
  const { demand } = await import('./agentic.mjs');
3599
3630
  const { createRawEngineClient } = await import('./supervisor-engine.mjs');
3600
-
3601
3631
  // Base/auth: the single canonical worker-engine chain (explicit restConfig →
3602
3632
  // profile restAddress → localhost), ALWAYS run through the token same-origin
3603
3633
  // gate — even when a caller pins the base via `restConfig` — so token
@@ -3661,6 +3691,11 @@ async function createSupervisorDeps(opts = {}) {
3661
3691
  agenticEndpoint,
3662
3692
  agenticConfig,
3663
3693
  config: scope ? { ...config, scope } : config,
3694
+ // Lift the plain readiness thunk into an Effect the runtime runs on its own
3695
+ // fiber the instant it starts leasing (#253). Effect-free JS in, Effect out —
3696
+ // the same "monolith supplies plain JS, TS lifts it" seam as the other ports.
3697
+ onFirstActivation:
3698
+ typeof onFirstActivation === 'function' ? rt.Effect.sync(onFirstActivation) : undefined,
3664
3699
  });
3665
3700
 
3666
3701
  // The `settle` seam (issue #156, escalation answer (a)): the runner settles a
@@ -8386,7 +8421,7 @@ function agenticStateForTarget(target, safeUrl = (u) => u) {
8386
8421
  * list; `busy` is derived so callers can't desync it from `jobs`.
8387
8422
  * @param {{ pid:number, updatedAt:number, jobs:Array<{key:string,type:string,since:number}>, engine:(string|null), agentic:object }} fields
8388
8423
  */
8389
- function buildActivityPayload({ pid, updatedAt, jobs, engine, agentic }) {
8424
+ function buildActivityPayload({ pid, updatedAt, jobs, engine, agentic, readyAt }) {
8390
8425
  const jobList = Array.isArray(jobs) ? jobs : [];
8391
8426
  return {
8392
8427
  pid,
@@ -8395,6 +8430,10 @@ function buildActivityPayload({ pid, updatedAt, jobs, engine, agentic }) {
8395
8430
  jobs: jobList,
8396
8431
  engine: engine ?? null,
8397
8432
  agentic,
8433
+ // When the worker's activation loop has started (it has imported, built its
8434
+ // SDK client and begun leasing), the producer stamps this; null until then.
8435
+ // The supervisor's rolling reload gates on it (a spawn/PID is not readiness).
8436
+ readyAt: readyAt ?? null,
8398
8437
  };
8399
8438
  }
8400
8439
 
@@ -9124,10 +9163,15 @@ async function workAgent(req, flags, ctx) {
9124
9163
  // is updated once the channel target is resolved and again on each
9125
9164
  // connect/disconnect below.
9126
9165
  let agenticState = { status: 'starting' };
9166
+ // Readiness handshake (Copilot review on #253): null until this worker's
9167
+ // activation loop is actually up and leasing; the supervisor's rolling reload
9168
+ // waits for this stamp before draining the next worker so a bare spawn/PID is
9169
+ // never mistaken for a serving replacement.
9170
+ let readyAt = null;
9127
9171
  const writeActivity = () => {
9128
9172
  if (!activityFile) return;
9129
9173
  const jobs = [...activeJobs.entries()].map(([key, v]) => ({ key, type: v.type, since: v.since }));
9130
- const payload = buildActivityPayload({ pid: process.pid, updatedAt: Date.now(), jobs, engine: workerEngine, agentic: agenticState });
9174
+ const payload = buildActivityPayload({ pid: process.pid, updatedAt: Date.now(), jobs, engine: workerEngine, agentic: agenticState, readyAt });
9131
9175
  const tmp = `${activityFile}.${process.pid}.tmp`;
9132
9176
  try {
9133
9177
  mkdirSync(dirname(activityFile), { recursive: true });
@@ -10198,6 +10242,16 @@ async function workAgent(req, flags, ctx) {
10198
10242
  // agentic target didn't resolve to a connect — the runtime then runs with no
10199
10243
  // agentic scope and presence/steer degrade to no-ops.
10200
10244
  agenticEndpoint: agenticEndpoint || undefined,
10245
+ // Readiness handshake (#253): the runtime fires this the instant its activation
10246
+ // loop begins leasing (on its OWN fiber, after reconcile/presence are forked;
10247
+ // under agentic the connect cycle is a concurrent child, so the connection may
10248
+ // still be connecting — readiness is LEASING-gated, not connection-gated). Stamp
10249
+ // `readyAt` on the
10250
+ // activity marker here so the supervisor's rolling `reload` waits for THIS
10251
+ // replacement to be genuinely serving before draining the next worker. A bare
10252
+ // `runFork` return (which only schedules the fiber) is NOT readiness.
10253
+ // Best-effort — a marker write never fails the worker.
10254
+ onFirstActivation: () => { readyAt = Date.now(); writeActivity(); },
10201
10255
  config: {
10202
10256
  activation: { requestTimeoutMs: pollTimeoutMs },
10203
10257
  dispatch: { recoveryWindowMs, extendIntervalMs: lockExtendIntervalMs },
@@ -10225,6 +10279,12 @@ async function workAgent(req, flags, ctx) {
10225
10279
  const supervisor = await SupervisorEffect.runPromise(makeSupervisorRuntime(supervisorDeps));
10226
10280
  const supervisorFiber = SupervisorEffect.runFork(supervisor.run);
10227
10281
 
10282
+ // Readiness is stamped by the runtime's `onFirstActivation` handshake (wired
10283
+ // into `createSupervisorDeps` above), NOT here: `runFork` only *schedules* the
10284
+ // fiber and can return before it has executed at all, so stamping `readyAt`
10285
+ // in this continuation could report the worker ready before its activation loop
10286
+ // is leasing — defeating the rolling reload's one-at-a-time guarantee (#253).
10287
+
10228
10288
  // Seed this worker's presence into the runtime's ownership registry (issue
10229
10289
  // #173) and late-bind the per-job relay seam to the running supervisor. The
10230
10290
  // presence-projection fiber announces (register) then heartbeats this instance
@@ -11118,6 +11178,19 @@ function formatSupervisorStatus(status) {
11118
11178
  lines.push('Supervisor:');
11119
11179
  lines.push(` daemon pid: ${d.pid ?? '-'} ${alive ? '(alive)' : '(dead — stale state)'}`);
11120
11180
  if (d.version) lines.push(` version: ${d.version}`);
11181
+ // Flag a code update the running daemon hasn't adopted yet: the plugin on disk
11182
+ // has advanced past the daemon's version (e.g. after `nano update`). A rolling
11183
+ // `supervisor reload` adopts the new WORKER code with zero downtime; a daemon
11184
+ // restart is needed for new SUPERVISOR code. `status.pluginVersion` is only
11185
+ // present on a live socket `status` frame; the socket-unreachable fallback
11186
+ // (`statusFromState()`) has no such field, so read the on-disk package version
11187
+ // locally as a fallback — otherwise the warning silently disappears exactly
11188
+ // when the daemon is alive but its control socket is briefly unreachable.
11189
+ const onDiskVersion = status.pluginVersion
11190
+ ?? (() => { try { return pluginPackage().version; } catch { return null; } })();
11191
+ if (onDiskVersion && d.version && onDiskVersion !== d.version) {
11192
+ lines.push(` on disk: ${onDiskVersion} (update available — run \`c8ctl nano supervisor reload\` to adopt new worker code; restart the daemon for new supervisor code)`);
11193
+ }
11121
11194
  if (d.startedAt) lines.push(` started: ${d.startedAt}`);
11122
11195
  if (d.socket) lines.push(` control: ${d.socket}`);
11123
11196
  const workers = Array.isArray(status.workers) ? status.workers : [];
@@ -11288,7 +11361,19 @@ function waitForChildExit(child, timeoutMs) {
11288
11361
  // #202: a null/undefined timeout means WAIT INDEFINITELY (graceful drain) —
11289
11362
  // no timer is armed, so we only resolve when the child actually exits.
11290
11363
  const t = timeoutMs == null ? null : setTimeout(() => finish(), timeoutMs);
11291
- function finish() { if (done) return; done = true; if (t) clearTimeout(t); resolve(); }
11364
+ // `finish` ALWAYS removes the `exit` listener, including the timeout path:
11365
+ // `child.once` only self-removes when the event fires, so a timed-out wait
11366
+ // would otherwise leave its listener attached. The readiness poll calls this
11367
+ // ~every 100ms for up to 30s, so a leaked listener per poll accumulates
11368
+ // hundreds on a long-lived child — a `MaxListenersExceededWarning` plus
11369
+ // retained closures (#253 review).
11370
+ function finish() {
11371
+ if (done) return;
11372
+ done = true;
11373
+ if (t) clearTimeout(t);
11374
+ child.removeListener('exit', finish);
11375
+ resolve();
11376
+ }
11292
11377
  child.once('exit', finish);
11293
11378
  });
11294
11379
  }
@@ -11386,6 +11471,11 @@ async function runSupervisorDaemon() {
11386
11471
  // restart-on-exit path and let a second `stop --force` escalate a live drain.
11387
11472
  let draining = false;
11388
11473
  let forcing = false;
11474
+ // Hot code reload (rolling drain+respawn). A `reload` op adopts new on-disk
11475
+ // plugin code into the worker children by gracefully draining and respawning
11476
+ // them one at a time (so the fleet keeps serving). This flag rejects a second
11477
+ // concurrent reload — a single rolling pass owns the fleet until it finishes.
11478
+ let reloading = false;
11389
11479
  // Live-view monitor: tracks the last-broadcast fleet signature so we push a
11390
11480
  // refreshed status to attached consoles only on real change (see below).
11391
11481
  let monitorTimer = null;
@@ -11638,6 +11728,216 @@ async function runSupervisorDaemon() {
11638
11728
  return true;
11639
11729
  };
11640
11730
 
11731
+ // Hot code reload of a single worker: GRACEFULLY drain it (SIGUSR2 — finish
11732
+ // in-flight jobs, then exit) and respawn it, so the new child re-reads the
11733
+ // updated plugin from disk. Unlike `restartWorker` (a force SIGTERM/SIGKILL
11734
+ // swap), this waits INDEFINITELY for the drain so no in-flight job is lost —
11735
+ // adopting new code is never worth killing running work.
11736
+ //
11737
+ // CAPACITY CAVEAT: this drains the worker BEFORE spawning its replacement, so
11738
+ // for the drain+boot window that worker serves no jobs. The fleet's "zero
11739
+ // downtime" guarantee is therefore a FLEET-level one — with >1 worker the rest
11740
+ // keep serving while one drains. A single-worker fleet (or a job type served by
11741
+ // only this one worker) does lose that type's serving capacity until the drain
11742
+ // finishes, `startWorker` runs, AND the replacement reports ready. Preserving an
11743
+ // overlapping serving replacement would need a two-child handoff; that is
11744
+ // intentionally out of scope here (documented in README/AGENTS).
11745
+ //
11746
+ // ONE-AT-A-TIME: after respawning, this waits (bounded) for the replacement to
11747
+ // stamp `readyAt` on its activity marker — it is up and leasing — before it
11748
+ // returns, so `runReload` never drains the NEXT worker while this one is still
11749
+ // booting. A bare spawn/PID is not readiness (Copilot review on #253).
11750
+ //
11751
+ // The drain runs OUTSIDE the op lock (it can be arbitrarily long) so a
11752
+ // `stop --force` or a `remove`/`restart` for this same worker isn't blocked
11753
+ // and can escalate/interrupt it. Because of that, the respawn is guarded by
11754
+ // the child-identity check (`w.child === child`): if a concurrent
11755
+ // force-stop/restart/remove already swapped or deleted this worker while we
11756
+ // drained, we must NOT respawn (that would leak a duplicate child or revive a
11757
+ // removed worker). We also skip the respawn when the daemon is shutting down.
11758
+ // Poll a freshly (re)spawned worker's activity marker until it stamps `readyAt`
11759
+ // (its activation loop is up and leasing), the child is swapped/exits, the
11760
+ // daemon starts shutting down, or the bounded deadline passes. Returns whether
11761
+ // it became ready; the caller advances regardless — readiness is a best-effort
11762
+ // gate, never a hard block. Used by the rolling reload so it does not drain the
11763
+ // next worker while this replacement is still booting (Copilot review on #253).
11764
+ const waitForWorkerReady = async (w, child, timeoutMs) => {
11765
+ const deadline = Date.now() + Math.max(0, timeoutMs);
11766
+ for (;;) {
11767
+ // Stop waiting if a concurrent restart/force-stop swapped this child, or it
11768
+ // already exited — it is no longer a booting replacement to gate on.
11769
+ if (w.child !== child) return false;
11770
+ if (child.exitCode !== null || child.signalCode !== null) return false;
11771
+ // Abort at once on a spawn failure (ENOENT/EMFILE/…): it emits only 'error'
11772
+ // with NO 'exit', so `exitCode`/`signalCode` stay null and the two checks
11773
+ // above never fire — without this the loop would poll the full ready-timeout
11774
+ // (~30s) before the final live-PID gate rejects a worker that never started
11775
+ // (#253 review). `handleDeath` nulls `w.pid` on that 'error' (and a failed
11776
+ // spawn has no `child.pid` to begin with), so a null `w.pid` for THIS still
11777
+ // -current child means the replacement is dead — stop waiting immediately.
11778
+ if (w.pid == null) return false;
11779
+ const act = readWorkerActivity(w.id);
11780
+ // Require the marker to be from THIS replacement child (`act.pid === child.pid`)
11781
+ // AND carry a finite `readyAt` (#253): a stale marker left by a previous
11782
+ // incarnation must not pass this gate before the freshly spawned child has
11783
+ // reported ready — see activityMarkerReadyFor.
11784
+ if (activityMarkerReadyFor(act, child.pid)) return true;
11785
+ if (shuttingDown || Date.now() >= deadline) {
11786
+ dlog(`worker '${w.id}' not ready within ${timeoutMs}ms after reload — advancing anyway`);
11787
+ return false;
11788
+ }
11789
+ // waitForChildExit doubles as a poll sleep: it resolves early if the child
11790
+ // exits (the loop-top guard then returns) so we never busy-spin on a dead child.
11791
+ await waitForChildExit(child, SUPERVISOR_RELOAD_READY_POLL_MS);
11792
+ }
11793
+ };
11794
+
11795
+ const reloadWorker = async (id) => {
11796
+ const w = workers.get(id);
11797
+ if (!w) return false;
11798
+ const child = w.child;
11799
+ w.stopping = true;
11800
+ if (w.restartTimer) { clearTimeout(w.restartTimer); w.restartTimer = null; }
11801
+ const pid = w.pid;
11802
+ if (child && pid) {
11803
+ try { process.kill(pid, SUPERVISOR_DRAIN_SIGNAL); } catch { /* already gone */ }
11804
+ await waitForChildExit(child, null);
11805
+ }
11806
+ // Only respawn if nobody else acted on this worker while we drained: it must
11807
+ // still exist, we must not be shutting down, and its child handle must still
11808
+ // be the one we drained (a concurrent restart/force-stop would have swapped
11809
+ // it). The startWorker+guard runs under the op lock so it can't interleave
11810
+ // with add/remove.
11811
+ const started = await serializeOp(async () => {
11812
+ const cur = workers.get(id);
11813
+ if (!cur || cur !== w || shuttingDown || w.child !== child) return null;
11814
+ w.stopping = false;
11815
+ w.restarts = 0;
11816
+ startWorker(w);
11817
+ dlog(`worker '${id}' respawned (awaiting readiness before adopting new code)`);
11818
+ return w.child;
11819
+ });
11820
+ if (!started) return false;
11821
+ // `startWorker` returns the instant the child is forked — but the replacement
11822
+ // still has to import the plugin, build its SDK client, and start its
11823
+ // activation loop before it serves jobs. Signalling "reloaded" here lets
11824
+ // `runReload` advance to drain the NEXT worker, so returning on the bare spawn
11825
+ // could leave the just-respawned worker AND the next (draining) worker down at
11826
+ // once, breaking the one-at-a-time guarantee (Copilot review on #253). Gate on
11827
+ // the replacement stamping `readyAt` on its activity marker (it is up and
11828
+ // leasing) before we return. Bounded so a slow/never-ready replacement can't
11829
+ // wedge the roll — on timeout we advance anyway (degrading to spawn-and-advance).
11830
+ await waitForWorkerReady(w, started, SUPERVISOR_RELOAD_READY_TIMEOUT_MS);
11831
+ // Report reloaded only when the child we spawned is STILL this worker's live
11832
+ // current child. `waitForWorkerReady` returns even when the replacement exited
11833
+ // (crash/spawn-fail) or was swapped by a concurrent restart/remove — in those
11834
+ // cases no replacement is actually running, so counting it as reloaded would
11835
+ // let the terminal frame claim success and let the roll drain the next worker
11836
+ // with this one down (#253 review). A readiness *timeout* on a still-live
11837
+ // current child still counts as success — readiness is best-effort.
11838
+ //
11839
+ // Gate on the LIVE PID, not just object identity + null exit/signal: a spawn
11840
+ // failure (ENOENT/EMFILE/…) emits only 'error' with NO 'exit', so
11841
+ // `exitCode`/`signalCode` stay null and `w.child` keeps pointing at the failed
11842
+ // ChildProcess until its backoff retry — the identity+exit check alone would
11843
+ // count that as reloaded (#253 review). `handleDeath` nulls `w.pid` on every
11844
+ // death (error OR exit), and a failed spawn has no `child.pid`, so requiring
11845
+ // `w.pid` to be non-null AND still equal to this child's pid rejects both a
11846
+ // failed spawn and a dead/retrying child while accepting a live replacement
11847
+ // (readiness timeout included).
11848
+ //
11849
+ // Also reject `w.stopping`: a concurrent `remove`/`restart`/`stop` sets that
11850
+ // flag (and clears the restart timer) BEFORE its kill signal lands, so for a
11851
+ // brief window `w.child`/`w.pid` still point at the live replacement we just
11852
+ // spawned. Counting that as reloaded would let the roll drain the NEXT worker
11853
+ // while this one is being torn down — a partial-fleet outage. A worker being
11854
+ // stopped has no confirmed serving replacement, so treat it as a failed
11855
+ // reload (the `runReload` else-branch then aborts the roll, #253 review).
11856
+ //
11857
+ // Also reject `shuttingDown`: a `stop` can begin DURING this replacement's
11858
+ // readiness wait, after which `waitForWorkerReady` still returns and (for a
11859
+ // last target) `w.stopping` may not be latched yet — so without this the gate
11860
+ // would report a clean reload while shutdown is already tearing the fleet
11861
+ // down. A daemon that is shutting down has no serving future for this worker,
11862
+ // so treat a shutdown observed before the gate as a failed/interrupted reload
11863
+ // (the `runReload` else-branch aborts the roll, #253 review).
11864
+ return w.child === started && !w.stopping && !shuttingDown && w.pid != null && w.pid === started.pid
11865
+ && started.exitCode === null && started.signalCode === null;
11866
+ };
11867
+
11868
+ // Rolling hot reload across a set of worker ids: drain+respawn each in turn
11869
+ // (one at a time, so the rest of the fleet keeps serving). Streams progress to
11870
+ // `sock` (registered as an attach consumer for the interleaved worker events)
11871
+ // and ends with a terminal `reloaded` frame. Aborts early if the daemon starts
11872
+ // shutting down. Never clears `supervisor.json` — a reload is not a stop.
11873
+ const runReload = async (ids, sock) => {
11874
+ reloading = true;
11875
+ const reloaded = [];
11876
+ const skipped = [];
11877
+ let interrupted = false;
11878
+ try {
11879
+ for (let i = 0; i < ids.length; i++) {
11880
+ const id = ids[i];
11881
+ // If the daemon starts shutting down mid-roll, the remaining workers
11882
+ // never adopt the new code — record them as skipped (and flag the roll
11883
+ // interrupted) so the terminal frame can't report a clean success for a
11884
+ // pass that stopped early.
11885
+ if (shuttingDown) {
11886
+ interrupted = true;
11887
+ for (let j = i; j < ids.length; j++) skipped.push(ids[j]);
11888
+ break;
11889
+ }
11890
+ // A target that has vanished mid-roll (only `remove`/drain-remove deletes
11891
+ // the entry — `restart` keeps it) has NO confirmed serving replacement,
11892
+ // exactly like the reload-failure branch below. Continuing to drain the
11893
+ // NEXT worker on top of that gap is the same partial-fleet risk the canary
11894
+ // exists to prevent, so treat a removed-mid-roll target uniformly: mark it
11895
+ // and every remaining id skipped and abort the roll (#253 review).
11896
+ if (!workers.has(id)) {
11897
+ interrupted = true;
11898
+ for (let j = i; j < ids.length; j++) skipped.push(ids[j]);
11899
+ break;
11900
+ }
11901
+ const ok = await reloadWorker(id);
11902
+ if (ok) {
11903
+ reloaded.push(id);
11904
+ // Emit the per-worker "reloaded" progress signal ONLY after the final
11905
+ // success gate above confirmed a live, still-current replacement — not
11906
+ // at spawn time. A crash/spawn-fail makes `reloadWorker` return false
11907
+ // and the worker is skipped, so broadcasting at spawn time would let the
11908
+ // streaming client print `reloaded "…" (adopted new code)` for a reload
11909
+ // that actually failed (#253 review).
11910
+ broadcast({ type: 'event', event: 'worker-reload', id });
11911
+ }
11912
+ else {
11913
+ // A reload FAILURE (not a mere readiness timeout — that returns true on
11914
+ // a still-live child) means this worker has NO confirmed serving
11915
+ // replacement: it crashed, failed to spawn, or was concurrently swapped.
11916
+ // Draining the NEXT worker on top of that gap breaks the one-at-a-time
11917
+ // guarantee AND could roll a broken replacement across the whole fleet.
11918
+ // Stop the roll here (a canary), marking this + every remaining id
11919
+ // skipped so the terminal frame reports a partial failure (#253 review).
11920
+ interrupted = true;
11921
+ for (let j = i; j < ids.length; j++) skipped.push(ids[j]);
11922
+ break;
11923
+ }
11924
+ try { sock.write(encodeFrame(statusFrame(false))); } catch { /* client gone */ }
11925
+ }
11926
+ } catch (err) {
11927
+ interrupted = true;
11928
+ dlog(`reload error: ${err?.message || err}`);
11929
+ } finally {
11930
+ reloading = false;
11931
+ // Terminal success requires BOTH a clean pass (not interrupted) AND nothing
11932
+ // skipped: a skipped worker (failed reload, or removed/absent mid-roll) is a
11933
+ // partial reload, so `ok: true` would let the streaming client exit zero and
11934
+ // hide it from automation (#253 review). Propagate the partial failure.
11935
+ const ok = !interrupted && skipped.length === 0;
11936
+ try { sock.write(encodeFrame({ ok, type: 'reloaded', reloaded, skipped, interrupted, final: true })); } catch { /* client gone */ }
11937
+ }
11938
+ };
11939
+
11940
+
11641
11941
  // Resolve a target token to worker ids: exact id, else all with that profile.
11642
11942
  const resolveTargets = (target) => {
11643
11943
  const t = String(target || '').trim();
@@ -11655,6 +11955,11 @@ async function runSupervisorDaemon() {
11655
11955
  ok: true,
11656
11956
  type: 'status',
11657
11957
  daemon: supervisorDaemonDescriptor({ pid: process.pid, startedAt, version: daemonVersion, socket: socketPath, logFile: daemonLogFile }),
11958
+ // The plugin version currently ON DISK (re-read each call), so `status` can
11959
+ // flag when a `nano update` has advanced the code past the running daemon —
11960
+ // i.e. a `supervisor reload` would adopt new worker code (and a daemon
11961
+ // restart new supervisor code). Best-effort; falls back to the daemon's own.
11962
+ pluginVersion: (() => { try { return pluginPackage().version; } catch { return daemonVersion; } })(),
11658
11963
  workers: pub || [...workers.values()].map(workerPublic),
11659
11964
  ...(final ? { final: true } : {}),
11660
11965
  });
@@ -11758,6 +12063,50 @@ async function runSupervisorDaemon() {
11758
12063
  sock.write(encodeFrame({ ok: true, type: 'restarted', restarted, final: true }));
11759
12064
  break;
11760
12065
  }
12066
+ case 'reload': {
12067
+ // Hot code adoption: rolling graceful drain+respawn so worker children
12068
+ // re-read the updated plugin from disk with zero fleet downtime.
12069
+ // Accepts a single `target` token (id|profile|all) or a `targets`
12070
+ // array (workforce passes its exact owned id list). Streams progress
12071
+ // and ends with a terminal `reloaded` frame — draining can take a long
12072
+ // time, so this MUST be a streaming op, not a one-shot request.
12073
+ if (shuttingDown) { sock.write(encodeFrame({ ok: false, error: 'supervisor is shutting down', final: true })); break; }
12074
+ if (reloading) { sock.write(encodeFrame({ ok: false, error: 'a reload is already in progress', final: true })); break; }
12075
+ // Reject reload explicitly on Windows BEFORE draining anything. The
12076
+ // rolling reload's graceful drain relies on SIGUSR2 (SUPERVISOR_DRAIN_SIGNAL)
12077
+ // to quiesce each worker child; Windows cannot deliver SIGUSR2 (Node maps
12078
+ // a non-zero signal there to a forceful, SIGKILL-like termination, and the
12079
+ // child's `process.once('SIGUSR2')` drain handler never fires), so a
12080
+ // "graceful" drain either hard-kills in-flight work or leaves `reloadWorker`
12081
+ // waiting forever for a child that was never asked to exit. Fail fast with
12082
+ // an actionable message instead of hanging the roll on the first worker
12083
+ // (#253 review). `restart`/`stop`+`start` remain the Windows path to adopt
12084
+ // new code.
12085
+ if (osPlatform() === 'win32') {
12086
+ sock.write(encodeFrame({ ok: false, error: 'hot reload is not supported on Windows (its graceful drain relies on SIGUSR2, which Windows cannot deliver) — use `supervisor restart <target>`, or `supervisor stop` + `start`, to adopt new code', final: true }));
12087
+ break;
12088
+ }
12089
+ const raw = Array.isArray(req.targets)
12090
+ ? req.targets.flatMap((t) => resolveTargets(t))
12091
+ : resolveTargets(req.target);
12092
+ const ids = [...new Set(raw)];
12093
+ if (ids.length === 0) { sock.write(encodeFrame({ ok: true, type: 'reloaded', reloaded: [], skipped: [], final: true })); break; }
12094
+ // Register as an attach consumer so the client also sees the
12095
+ // interleaved worker-reload/start events, then send an opening frame
12096
+ // and kick the rolling reload asynchronously (don't block the control
12097
+ // loop — a `stop`/`status` must still be serviceable meanwhile). Latch
12098
+ // `reloading` HERE, before scheduling: `runReload` only sets it once it
12099
+ // actually runs on a later tick, so a second `reload` socket arriving
12100
+ // in that window would otherwise still see `false` and start a duplicate
12101
+ // rolling pass over the same workers. The flag is reset in runReload's
12102
+ // `finally`.
12103
+ reloading = true;
12104
+ attachClients.add(sock);
12105
+ sock.write(encodeFrame({ ok: true, type: 'reloading', targets: ids }));
12106
+ sock.write(encodeFrame(statusFrame(false)));
12107
+ setTimeout(() => { runReload(ids, sock); }, 0);
12108
+ break;
12109
+ }
11761
12110
  case 'attach':
11762
12111
  attachClients.add(sock);
11763
12112
  sock.write(encodeFrame(statusFrame(false)));
@@ -11879,11 +12228,19 @@ async function runSupervisorDaemon() {
11879
12228
  return Number.isFinite(n) && n >= 0 ? Math.floor(n) : SUPERVISOR_MONITOR_INTERVAL_MS;
11880
12229
  })();
11881
12230
  if (monitorMs > 0) {
11882
- lastMonitorSig = supervisorStatusSignature([...workers.values()].map(workerPublic));
12231
+ // Fold the on-disk plugin version into the monitor signature so a `nano update`
12232
+ // that changes ONLY the on-disk package (no worker transition) still repaints
12233
+ // attached consoles with the new version + `supervisor reload` hint (#253
12234
+ // review). The worker-field signature alone never changes on a version-only
12235
+ // bump, so an idle fleet would otherwise hide an available reload until an
12236
+ // unrelated worker transition or a manual `status`.
12237
+ const monitorSignature = (pub) =>
12238
+ `${supervisorStatusSignature(pub)}\u0000${(() => { try { return pluginPackage().version; } catch { return daemonVersion; } })()}`;
12239
+ lastMonitorSig = monitorSignature([...workers.values()].map(workerPublic));
11883
12240
  monitorTimer = setInterval(() => {
11884
12241
  if (shuttingDown) return;
11885
12242
  const pub = [...workers.values()].map(workerPublic);
11886
- const sig = supervisorStatusSignature(pub);
12243
+ const sig = monitorSignature(pub);
11887
12244
  const changed = sig !== lastMonitorSig;
11888
12245
  lastMonitorSig = sig;
11889
12246
  if (changed && attachClients.size > 0) broadcast(statusFrame(false, pub));
@@ -12075,7 +12432,7 @@ async function supervisorStartCmd(req, flags, ctx) {
12075
12432
  await supervisorStatusCmd();
12076
12433
  logger.info('');
12077
12434
  logger.info('Attach an interactive console with: c8ctl nano supervisor');
12078
- logger.info('Manage without it: c8ctl nano supervisor add|remove|restart|status|stop');
12435
+ logger.info('Manage without it: c8ctl nano supervisor add|remove|restart|reload|status|stop');
12079
12436
  }
12080
12437
 
12081
12438
  async function supervisorStatusCmd() {
@@ -12168,6 +12525,97 @@ async function supervisorRestartCmd(req) {
12168
12525
  else { logger.error(res.error); process.exit(1); }
12169
12526
  }
12170
12527
 
12528
+ /**
12529
+ * Stream a rolling hot reload of the fleet and log its progress. Shared by
12530
+ * `supervisor reload` and `workforce reload`. Sends `req` (a `reload` op with a
12531
+ * `target` token or a `targets` array) and resolves with the outcome that ended
12532
+ * the stream ('reloaded' | 'detached' | 'closed' | 'unreachable'). Ctrl-C
12533
+ * DETACHES the client — the daemon keeps reloading in the background.
12534
+ */
12535
+ async function streamSupervisorReload(socketPath, req, logger, { label = 'fleet' } = {}) {
12536
+ return await new Promise((resolve) => {
12537
+ let sock = null;
12538
+ let buf = '';
12539
+ let done = false;
12540
+ let onSigint = null;
12541
+ const cleanup = () => {
12542
+ if (onSigint) { try { process.removeListener('SIGINT', onSigint); } catch { /* ignore */ } }
12543
+ try { if (sock) sock.end(); } catch { /* ignore */ }
12544
+ };
12545
+ const finish = (result) => { if (done) return; done = true; cleanup(); resolve(result); };
12546
+
12547
+ supervisorConnect(socketPath).then((s) => {
12548
+ sock = s;
12549
+ sock.setEncoding('utf8');
12550
+ onSigint = () => {
12551
+ logger.info('Detached — supervisor keeps reloading in the background. Rerun `nano supervisor status` to check progress.');
12552
+ finish('detached');
12553
+ };
12554
+ process.on('SIGINT', onSigint);
12555
+
12556
+ sock.on('data', (chunk) => {
12557
+ buf += chunk;
12558
+ const { frames, rest } = decodeFrames(buf);
12559
+ buf = rest;
12560
+ for (const frame of frames) {
12561
+ if (!frame) continue;
12562
+ // Handle the terminal `reloaded` frame BEFORE the generic `ok:false`
12563
+ // request-error guard: `runReload` emits a partial failure as a
12564
+ // terminal `{ type:'reloaded', final:true, ok:false, reloaded, skipped }`
12565
+ // frame, so the bare `ok === false` guard would swallow it as a generic
12566
+ // "reload failed" and hide which workers reloaded/skipped (#253 review).
12567
+ // We still exit non-zero for `ok:false` so automation sees the partial.
12568
+ // Match ONLY `type:'reloaded'`, never a bare `frame.final`: generic
12569
+ // terminal error frames (e.g. `{ok:false, error:'a reload is already in
12570
+ // progress', final:true}`) are also `final` but carry no reloaded/skipped
12571
+ // lists, so this branch would print "No workers were reloaded" and hide
12572
+ // `frame.error` — they must fall through to the `ok === false` guard below.
12573
+ if (frame.type === 'reloaded') {
12574
+ const reloaded = Array.isArray(frame.reloaded) ? frame.reloaded : [];
12575
+ const skipped = Array.isArray(frame.skipped) ? frame.skipped : [];
12576
+ if (reloaded.length > 0) logger.info(`Reloaded ${reloaded.length} worker(s): ${reloaded.join(', ')}.`);
12577
+ else logger.warn('No workers were reloaded.');
12578
+ if (skipped.length > 0) logger.warn(`Skipped (gone/changed, or roll aborted after a failed reload): ${skipped.join(', ')}.`);
12579
+ finish(frame.ok === false ? 'error' : 'reloaded');
12580
+ return;
12581
+ }
12582
+ if (frame.ok === false) { logger.error(frame.error || 'reload failed'); finish('error'); return; }
12583
+ if (frame.type === 'reloading') {
12584
+ const n = Array.isArray(frame.targets) ? frame.targets.length : 0;
12585
+ logger.info(`Reloading ${n} worker(s) in ${label} one at a time (draining in-flight jobs first). Press Ctrl-C to detach.`);
12586
+ } else if (frame.event === 'worker-reload' && frame.id) {
12587
+ logger.info(` reloaded "${frame.id}" (adopted new code).`);
12588
+ }
12589
+ }
12590
+ });
12591
+ sock.on('error', () => finish('closed'));
12592
+ sock.on('close', () => finish('closed'));
12593
+ sock.write(encodeFrame(req));
12594
+ }).catch(() => finish('unreachable'));
12595
+ });
12596
+ }
12597
+
12598
+ /**
12599
+ * Hot-adopt new plugin code into the running fleet with zero downtime: roll
12600
+ * through the target workers, gracefully draining (finish in-flight jobs) and
12601
+ * respawning each so the new child re-reads the updated `c8ctl-plugin.js` from
12602
+ * disk. Defaults to the whole fleet. Note this adopts new WORKER code only; the
12603
+ * supervisor daemon itself keeps running its startup code until a full restart
12604
+ * (`supervisor stop && supervisor start`).
12605
+ */
12606
+ async function supervisorReloadCmd(req) {
12607
+ const logger = getLogger();
12608
+ // Default to the whole fleet: "adopt new code" naturally means every worker.
12609
+ const target = req.positional[1] || 'all';
12610
+ const running = await liveSupervisor();
12611
+ if (!running) { logger.error('Supervisor is not running.'); process.exit(1); }
12612
+ const socketPath = running.socket || getSupervisorSocketPath();
12613
+ const outcome = await streamSupervisorReload(socketPath, { op: 'reload', target }, logger, { label: 'the fleet' });
12614
+ if (outcome === 'unreachable') { logger.error('Could not reach the supervisor control socket to reload it.'); process.exit(1); }
12615
+ if (outcome === 'closed') { logger.error('The supervisor closed the connection before the reload finished (daemon crash or concurrent stop?) — the roll may be incomplete. Rerun `nano supervisor status` to check the fleet.'); process.exit(1); }
12616
+ if (outcome === 'error') process.exit(1);
12617
+ }
12618
+
12171
12619
  /**
12172
12620
  * Count the in-flight jobs across a fleet snapshot (array of
12173
12621
  * `summarizeSupervisorWorker` results) — the number an operator is waiting on
@@ -12967,6 +13415,9 @@ async function supervisorCommand(req, flags, ctx) {
12967
13415
  case 'restart':
12968
13416
  await supervisorRestartCmd(req);
12969
13417
  return;
13418
+ case 'reload':
13419
+ await supervisorReloadCmd(req);
13420
+ return;
12970
13421
  case 'stop':
12971
13422
  await supervisorStopCmd(coerceBool(flags?.force, false));
12972
13423
  return;
@@ -12975,7 +13426,7 @@ async function supervisorCommand(req, flags, ctx) {
12975
13426
  supervisorLogsCmd(req);
12976
13427
  return;
12977
13428
  default:
12978
- getLogger().error(`Unknown supervisor action "${action}". Use: start|install|uninstall|status|add|remove|restart|stop|logs|attach`);
13429
+ getLogger().error(`Unknown supervisor action "${action}". Use: start|install|uninstall|status|add|remove|restart|reload|stop|logs|attach`);
12979
13430
  process.exit(1);
12980
13431
  }
12981
13432
  }
@@ -13924,6 +14375,41 @@ async function workforceStopCmd(req, flags, manifestName) {
13924
14375
  if (hadError) process.exit(1);
13925
14376
  }
13926
14377
 
14378
+ /**
14379
+ * Hot-adopt new plugin code into a workforce's running workers with zero
14380
+ * downtime: roll through the manifest-owned workers, gracefully draining and
14381
+ * respawning each so the new child re-reads the updated plugin from disk.
14382
+ * Mirrors `workforce stop`'s ownership resolution (longest-prefix match + a
14383
+ * live-profile collision guard) so it only ever reloads workers this manifest
14384
+ * owns.
14385
+ */
14386
+ async function workforceReloadCmd(req, flags, manifestName) {
14387
+ const logger = getLogger();
14388
+ const running = await liveSupervisor();
14389
+ if (!running) { logger.error('Supervisor is not running.'); process.exit(1); }
14390
+ const manifestNames = listWorkforceManifestNames();
14391
+ const { running: stillRunning, reachable, workers: live } = await fetchSupervisorWorkers();
14392
+ if (!stillRunning) { logger.warn('Supervisor is not running — nothing to reload.'); return; }
14393
+ if (!reachable) {
14394
+ logger.error('Supervisor is running but its status socket is unreachable — cannot enumerate workers.');
14395
+ process.exit(1);
14396
+ }
14397
+ const owned = live
14398
+ .filter((w) => w && typeof w.id === 'string' && isWorkforceOwnedWorker(w.id, manifestName, manifestNames))
14399
+ .filter((w) => {
14400
+ const embedded = workforceProfileFromWorkerName(manifestName, w.id);
14401
+ if (embedded != null && w.profile != null && w.profile !== embedded) return false;
14402
+ return true;
14403
+ })
14404
+ .map((w) => w.id);
14405
+ if (owned.length === 0) { logger.info(`No workers from workforce "${manifestName}" are running.`); return; }
14406
+ const socketPath = running.socket || getSupervisorSocketPath();
14407
+ const outcome = await streamSupervisorReload(socketPath, { op: 'reload', targets: owned }, logger, { label: `workforce "${manifestName}"` });
14408
+ if (outcome === 'unreachable') { logger.error('Could not reach the supervisor control socket to reload it.'); process.exit(1); }
14409
+ if (outcome === 'closed') { logger.error('The supervisor closed the connection before the reload finished (daemon crash or concurrent stop?) — the roll may be incomplete. Rerun `nano supervisor status` to check the fleet.'); process.exit(1); }
14410
+ if (outcome === 'error') process.exit(1);
14411
+ }
14412
+
13927
14413
  async function workforceCommand(req, flags) {
13928
14414
  const logger = getLogger();
13929
14415
  const action = (req.positional[0] || '').toLowerCase();
@@ -13961,8 +14447,11 @@ async function workforceCommand(req, flags) {
13961
14447
  case 'down':
13962
14448
  await workforceStopCmd(req, flags, manifestName);
13963
14449
  return;
14450
+ case 'reload':
14451
+ await workforceReloadCmd(req, flags, manifestName);
14452
+ return;
13964
14453
  default:
13965
- logger.error(`Unknown workforce action "${action}". Use: add|remove|list|start|status|stop`);
14454
+ logger.error(`Unknown workforce action "${action}". Use: add|remove|list|start|status|stop|reload`);
13966
14455
  process.exit(1);
13967
14456
  }
13968
14457
  }
@@ -15808,6 +16297,8 @@ export {
15808
16297
  agenticStateForTarget,
15809
16298
  normalizeAgenticMessage,
15810
16299
  buildActivityPayload,
16300
+ activityMarkerReadyFor,
16301
+ waitForChildExit,
15811
16302
  supervisorWorkerActivityFile,
15812
16303
  WORK_FORWARD_FLAGS,
15813
16304
  installParentDeathWatchdog,
@@ -15816,6 +16307,8 @@ export {
15816
16307
  supervisorRequest,
15817
16308
  supervisorStartCmd,
15818
16309
  supervisorAddCmd,
16310
+ supervisorReloadCmd,
16311
+ workforceReloadCmd,
15819
16312
  runningSupervisor,
15820
16313
  readSupervisorState,
15821
16314
  clearSupervisorState,
@@ -15922,6 +16415,7 @@ export const metadata = {
15922
16415
  { command: 'c8ctl nano supervisor add decider', description: 'Add a supervised worker (forwarding work flags) to the running supervisor' },
15923
16416
  { command: 'c8ctl nano supervisor add reviewer --instances 3', description: 'Add 3 distinct auto-named instances of a profile in one call' },
15924
16417
  { command: 'c8ctl nano supervisor restart reviewer', description: 'Restart a supervised worker by id or profile' },
16418
+ { command: 'c8ctl nano supervisor reload', description: 'Adopt updated harness code with zero downtime: after `nano update`, roll through the fleet draining in-flight jobs and respawning each worker so it re-reads the new plugin (workers only; restart the daemon for new supervisor code)' },
15925
16419
  { command: 'c8ctl nano supervisor stop', description: 'Stop the supervisor daemon and all its workers' },
15926
16420
  { command: 'c8ctl nano workforce add copilot --instances 5 --auto', description: 'Compose a reusable fleet: 5 copilot workers serving every deployed agent job type (--auto)' },
15927
16421
  { command: 'c8ctl nano workforce add qwen --instances 2 --roles pr-review,feature', description: "Add an entry mapped to explicit job types (<rank>:pr-review, <rank>:feature, where <rank> is the qwen hire's rank at start) — does not mutate the hired profile" },
@@ -15930,6 +16424,7 @@ export const metadata = {
15930
16424
  { command: 'c8ctl nano workforce status --json', description: 'Manifest entries joined against live supervisor status (desired vs actual), machine-readable for the install script / CI' },
15931
16425
  { command: 'c8ctl nano workforce list', description: 'Print the default manifest and list the manifests that exist on this machine' },
15932
16426
  { command: 'c8ctl nano workforce stop', description: "Remove this manifest's workers; stop the daemon too if no supervised workers remain" },
16427
+ { command: 'c8ctl nano workforce reload', description: "Hot-adopt updated code into this manifest's workers (rolling graceful drain+respawn, zero downtime)" },
15933
16428
  ],
15934
16429
  },
15935
16430
  processos: {
@@ -16161,8 +16656,8 @@ function printUsage() {
16161
16656
  console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--terminal pty|pipe] [--protocol pipe|acp] [--permission yolo|escalate|filter] [--env NAME=VALUE ...] [--list]');
16162
16657
  console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
16163
16658
  console.log(' c8ctl nano work <profileName> [--auto [--auto-scope <p>]] [--arg <switch> ...] [--recovery-window <ms>] [--idle-timeout <ms>] [--job-timeout <ms>] [--poll-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
16164
- console.log(' c8ctl nano supervisor [start|install|uninstall|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
16165
- console.log(' c8ctl nano workforce [add|remove|list|start|status|stop] ... [--manifest <manifest>] (declarative, reusable fleet manifests)');
16659
+ console.log(' c8ctl nano supervisor [start|install|uninstall|status|add|remove|restart|reload|stop|logs|attach] ... (manage many workers from one terminal)');
16660
+ console.log(' c8ctl nano workforce [add|remove|list|start|status|stop|reload] ... [--manifest <manifest>] (declarative, reusable fleet manifests)');
16166
16661
  console.log('');
16167
16662
  console.log('Subcommands:');
16168
16663
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
@@ -16181,7 +16676,7 @@ function printUsage() {
16181
16676
  console.log(' assign Grant new capabilities (roles) to an existing hire (additive; comma-separated; workers hot-reload)');
16182
16677
  console.log(' work Run a hired profile as Nano job workers, polling for work until Ctrl-C');
16183
16678
  console.log(' supervisor Run/manage a fleet of workers from one terminal (detachable console + non-interactive control)');
16184
- console.log(' workforce Compose a reusable, declarative fleet manifest and reconcile it up/down (add|remove|list|start|status|stop)');
16679
+ console.log(' workforce Compose a reusable, declarative fleet manifest and reconcile it up/down (add|remove|list|start|status|stop|reload)');
16185
16680
  console.log('');
16186
16681
  console.log('Options:');
16187
16682
  console.log(' <nodes> Number of nodes to start (default 1)');