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 +70 -0
- package/agent-instance.mjs +61 -1
- package/c8ctl-plugin.js +691 -14
- package/package.json +8 -8
- package/supervisor.dist.js +8 -8
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
|
|
package/agent-instance.mjs
CHANGED
|
@@ -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,
|