c8ctl-plugin-nano 1.63.2 → 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/README.md CHANGED
@@ -1060,6 +1060,8 @@ c8ctl nano supervisor add reviewer # add + spawn a worker (forw
1060
1060
  c8ctl nano supervisor add reviewer --name reviewer-2 # a SECOND reviewer, named so it stays distinct
1061
1061
  c8ctl nano supervisor add reviewer --instances 3 # add 3 distinct auto-named reviewers in one call
1062
1062
  c8ctl nano supervisor restart reviewer # by worker id or profile name
1063
+ c8ctl nano supervisor reload # adopt new code fleet-wide (rolling drain+respawn, zero downtime)
1064
+ c8ctl nano supervisor reload reviewer # reload just one worker/profile
1063
1065
  c8ctl nano supervisor remove coder # stop + drop a worker (also: `all`)
1064
1066
  c8ctl nano supervisor logs reviewer --follow # tail a worker's log (or the daemon's)
1065
1067
  c8ctl nano supervisor stop # stop the daemon and every worker
@@ -1109,6 +1111,73 @@ How it works and where things live:
1109
1111
  - Stopping is SIGTERM → grace → SIGKILL, per worker and for the daemon; `stop`
1110
1112
  always clears `supervisor.json` so a stale marker never wedges a future start.
1111
1113
 
1114
+ ### Hot code reload: `supervisor reload` / `workforce reload`
1115
+
1116
+ When you update the plugin on a machine that's already running a fleet
1117
+ (`c8ctl nano update`, or otherwise replacing the installed `c8ctl-plugin-nano`),
1118
+ `supervisor reload` adopts the new code **without stopping the fleet**:
1119
+
1120
+ ```bash
1121
+ c8ctl nano update # pull the new harness onto disk
1122
+ c8ctl nano supervisor reload # roll it into the running fleet, zero downtime
1123
+ ```
1124
+
1125
+ - Each supervised worker is a **separate `nano work` process** that reads the
1126
+ plugin from disk when it starts, so the daemon adopts new code by **rolling
1127
+ through the workers one at a time** — gracefully draining each (the same
1128
+ `SIGUSR2` quiesce as `stop`: it stops leasing new jobs, finishes the ones in
1129
+ flight, and exits) and respawning it, which re-reads the updated
1130
+ `c8ctl-plugin.js`, its sidecars, and `supervisor.dist.js`. Because only one
1131
+ worker is down at a time, the rest of the fleet keeps serving — **zero fleet
1132
+ downtime**. To keep it genuinely one-at-a-time, after respawning a worker the
1133
+ daemon **waits for the replacement to report ready** (its activation loop is up
1134
+ and leasing) before draining the next one — a bare spawn/PID is not readiness,
1135
+ so on the normal readiness path a slow replacement can never leave two workers
1136
+ down at once. That wait is
1137
+ **bounded** (`NANO_SUPERVISOR_RELOAD_READY_TIMEOUT_MS`, default 30s): a
1138
+ never-ready replacement can't wedge the roll — the daemon advances anyway. That
1139
+ timeout fallback is the one exception to the guarantee above: when a
1140
+ replacement never reports ready the daemon drains the next worker while the
1141
+ previous one is still unready, so **two (or more) workers can be temporarily
1142
+ unavailable** until the slow replacement catches up. This
1143
+ is a *fleet-level* guarantee: a worker is drained **before** its replacement
1144
+ spawns, so a **single-worker fleet** (or a job type served by only one worker)
1145
+ does lose that capacity for the drain+boot window. Run more than
1146
+ one worker for a type if you need it served continuously across a reload.
1147
+ - It **never kills in-flight work**: a reload waits indefinitely for each
1148
+ worker's jobs to finish (adopting new code is never worth losing a running
1149
+ job). The command **streams progress** and **Ctrl-C detaches** — the daemon
1150
+ keeps rolling in the background (rerun `supervisor status` to check). A reload
1151
+ is refused while another is already in progress.
1152
+ - It **stops the roll on a failed reload** (a canary): if a worker's replacement
1153
+ crashes, fails to spawn, or is swapped out from under the roll — i.e. it has no
1154
+ confirmed serving child — the daemon aborts the remaining pass rather than drain
1155
+ the next worker on top of that gap (which would both break one-at-a-time and
1156
+ risk rolling a broken replacement across the whole fleet). The remaining workers
1157
+ are reported **skipped** and the terminal frame reports a partial failure
1158
+ (`ok:false`), so automation sees it. A readiness *timeout* on a still-live
1159
+ replacement is **not** a failure — it counts as reloaded and the roll continues.
1160
+ - `reload [target]` defaults to the whole fleet; pass a worker id or profile to
1161
+ reload just those. `workforce reload` rolls only the workers a manifest owns.
1162
+ - **Not supported on Windows.** The graceful drain relies on `SIGUSR2` to quiesce
1163
+ each worker, which Windows cannot deliver (Node maps a non-zero signal there to
1164
+ a forceful, SIGKILL-like termination, so the child's drain handler never fires).
1165
+ The daemon therefore **rejects `supervisor reload` / `workforce reload` on
1166
+ Windows** rather than hang or hard-kill in-flight work — use
1167
+ `supervisor restart <target>`, or a full `supervisor stop` + `start`, to adopt
1168
+ new code there.
1169
+ - **Scope — workers, not the daemon.** A reload adopts all **worker-side** code
1170
+ (job running, agent instances, git/container provisioning, the agentic
1171
+ connection, the Effect runtime workers load — the bulk of the harness). The
1172
+ supervisor **daemon** keeps running the code it started with: its workers are
1173
+ its children and watch its pid, so re-exec'ing the daemon would take the fleet
1174
+ down with it. To adopt new **supervisor** code, do a full restart
1175
+ (`c8ctl nano supervisor stop && c8ctl nano supervisor start`) — a rare event,
1176
+ since the daemon is a thin process manager. `supervisor status` shows an
1177
+ `on disk:` line flagging "update available" whenever the code on disk has
1178
+ advanced past the running daemon, so you know when a reload (or restart) is
1179
+ worthwhile.
1180
+
1112
1181
  ### Surviving SSH logout: `supervisor install` / `uninstall`
1113
1182
 
1114
1183
  On **macOS**, a supervisor started over SSH is bound to your SSH login session's
@@ -1216,6 +1285,7 @@ c8ctl nano workforce list # print the manifest (+ --json)
1216
1285
  c8ctl nano workforce start # ensure the daemon is up, then reconcile
1217
1286
  c8ctl nano workforce status # desired vs actual, per worker (+ --json)
1218
1287
  c8ctl nano workforce stop # remove this manifest's workers (+ stop an empty daemon)
1288
+ c8ctl nano workforce reload # hot-adopt new code into this manifest's workers (rolling, zero downtime)
1219
1289
  c8ctl nano workforce remove qwen # drop an entry ("all" clears the manifest)
1220
1290
  ```
1221
1291
 
@@ -19,6 +19,8 @@
19
19
  // append an AgentInstance must NEVER crash the harness or change `job.complete`
20
20
  // behaviour — the AgentInstance lifecycle is orthogonal to job completion.
21
21
 
22
+ import { hostname } from 'node:os';
23
+
22
24
  import { sessionAcp as defaultSessionAcp } from './agentic.mjs';
23
25
 
24
26
  // The two AgentInstance surfaces we call on the host SDK client. A client missing
@@ -296,6 +298,49 @@ export function deriveAgentDefinition({ profile, envelope } = {}) {
296
298
  return { model, provider, systemPrompt };
297
299
  }
298
300
 
301
+ // #243: marker discriminating the provenance blob inside a CONFIGURATION turn's
302
+ // `content[]` from an ordinary message/tool OBJECT block. Versioned so a consumer can
303
+ // evolve the shape without ambiguity.
304
+ export const PROVENANCE_KIND = 'nanobpm.provenance/v1';
305
+
306
+ /**
307
+ * #243: build the parity-safe provenance content block for the opening CONFIGURATION
308
+ * turn.
309
+ *
310
+ * Camunda's AgentInstance schema pins the CONFIGURATION `definition` to
311
+ * model/provider/systemPrompt (no metadata/attributes field), and nanobpmn promises
312
+ * parity — so producer-invented top-level fields are off-limits. BUT a history item's
313
+ * `content[]` is a discriminated union that already includes an `OBJECT` variant
314
+ * (`{ contentType: 'OBJECT', object: <arbitrary JSON> }`) — the SAME shape the producer
315
+ * already emits for a structured tool result (`contentForResult`). Ride it to attribute
316
+ * a run to the agent harness (`agentName`), the nano runtime (`runtimeVersion` — the
317
+ * plugin `package.json` version, which is one and the same as the nano supervisor
318
+ * version), and, best-effort, the underlying agent CLI (`agentCliVersion`), WITHOUT
319
+ * diverging from the Camunda API.
320
+ *
321
+ * Returns `null` when no substantive identity field is present, so a bare `host`/`pid`
322
+ * (diagnostics only) never emits a noisy content block.
323
+ *
324
+ * @param {object} [p]
325
+ * @param {object} [p.profile] Worker profile (`name` → `agentName`).
326
+ * @param {string} [p.runtimeVersion] Nano plugin/supervisor version (`pluginVersion`).
327
+ * @param {string} [p.agentCliVersion] Best-effort underlying-CLI version (may be blank).
328
+ * @param {string} [p.host] Host name (diagnostic).
329
+ * @param {number} [p.pid] Worker pid (diagnostic).
330
+ * @returns {{ contentType: 'OBJECT', object: object } | null}
331
+ */
332
+ export function buildProvenanceContent({ profile, runtimeVersion, agentCliVersion, host, pid } = {}) {
333
+ const object = { kind: PROVENANCE_KIND };
334
+ if (isNonBlank(profile?.name)) object.agentName = String(profile.name);
335
+ if (isNonBlank(runtimeVersion)) object.runtimeVersion = String(runtimeVersion);
336
+ if (isNonBlank(agentCliVersion)) object.agentCliVersion = String(agentCliVersion);
337
+ if (isNonBlank(host)) object.host = String(host);
338
+ if (Number.isInteger(pid) && pid > 0) object.pid = pid;
339
+ // Only agent/runtime identity justifies a block; host/pid alone are diagnostics.
340
+ if (!(object.agentName || object.runtimeVersion || object.agentCliVersion)) return null;
341
+ return { contentType: 'OBJECT', object };
342
+ }
343
+
299
344
  // Map the ACP classifier's message role to the AgentHistory role enum. ACP has no
300
345
  // distinct REASONING role, so a `reasoning` chunk folds into ASSISTANT.
301
346
  function historyRole(acpRole) {
@@ -396,6 +441,15 @@ export function createAgentInstanceProducer(opts = {}) {
396
441
  logger = console,
397
442
  now = () => Date.now(),
398
443
  sessionAcp = defaultSessionAcp,
444
+ // #243: durable-transcript provenance for the opening CONFIGURATION turn. The nano
445
+ // runtime version (== the supervisor version) and the agent harness name/CLI version
446
+ // ride a parity-safe OBJECT content block (see `buildProvenanceContent`). All are
447
+ // best-effort/optional — a blank value simply omits its field, and if none are
448
+ // present no provenance block is emitted. `host`/`pid` default to this process.
449
+ runtimeVersion = '',
450
+ agentCliVersion = '',
451
+ host = hostname(),
452
+ pid = process.pid,
399
453
  createRetryBaseMs = DEFAULT_CREATE_RETRY_BASE_MS,
400
454
  createRetryMaxMs = DEFAULT_CREATE_RETRY_MAX_MS,
401
455
  preMintBufferMax = DEFAULT_PRE_MINT_BUFFER_MAX,
@@ -743,11 +797,17 @@ export function createAgentInstanceProducer(opts = {}) {
743
797
  // Build the opening CONFIGURATION turn from the concrete runtime definition.
744
798
  const buildConfigTurn = () => {
745
799
  const def = deriveAgentDefinition({ profile, envelope });
800
+ // #243: a parity-safe provenance OBJECT (agent name, nano runtime/supervisor
801
+ // version, best-effort agent-CLI version) rides the CONFIGURATION turn's content[]
802
+ // — the same OBJECT content variant the producer already emits for tool results —
803
+ // so no field is invented on the Camunda-pinned CONFIGURATION definition. Omitted
804
+ // entirely when no substantive identity is available.
805
+ const provenance = buildProvenanceContent({ profile, runtimeVersion, agentCliVersion, host, pid });
746
806
  const configTurn = {
747
807
  historyItemId: `configuration:${elementInstanceKey}`,
748
808
  loopIteration: 1,
749
809
  role: 'CONFIGURATION',
750
- content: [],
810
+ content: provenance ? [provenance] : [],
751
811
  producedAt: iso(),
752
812
  model: def.model,
753
813
  provider: def.provider,