c8ctl-plugin-nano 1.44.9 → 1.44.11
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 +22 -0
- package/c8ctl-plugin.js +402 -18
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -377,6 +377,28 @@ export NANO_AGENTIC_STALE_MS=60000 # force re-discovery if a drop hasn't rec
|
|
|
377
377
|
export NANO_AGENTIC_WATCHDOG_MS=15000 # how often the watchdog checks channel liveness (default)
|
|
378
378
|
```
|
|
379
379
|
|
|
380
|
+
**Lossy-link hardening (reconnect churn that never re-lands presence).** A
|
|
381
|
+
distinct failure mode shows up on a **lossy/roaming WiFi (or NAT) link**: the drop
|
|
382
|
+
*is* detected (an abnormal closure, code `1006`), the client *does* reconnect and
|
|
383
|
+
re-announce — yet presence never re-lands on the hub, so the worker stays absent
|
|
384
|
+
even though its log shows it re-announcing. The sustained-drop watchdog above does
|
|
385
|
+
not catch this, because each brief reconnect keeps resetting its drop clock. Two
|
|
386
|
+
extra safeguards close the gap:
|
|
387
|
+
|
|
388
|
+
- **Presence-keyed watchdog trigger.** A reconnect only counts as recovered once
|
|
389
|
+
the socket *holds* for a short grace window (so a `1006` blip that immediately
|
|
390
|
+
re-drops does not mask an unrecovered presence). When presence has not been
|
|
391
|
+
confirmed within a threshold — regardless of the reconnect flapping — the
|
|
392
|
+
watchdog forces the same full re-discovery + reopen.
|
|
393
|
+
- **Jittered reconnect backoff.** Reconnect attempts are spread with equal-jitter
|
|
394
|
+
backoff so a fleet dropped on the same link does not reconnect in lockstep and
|
|
395
|
+
re-congest it.
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
export NANO_AGENTIC_PRESENCE_STALE_MS=60000 # force re-discovery if presence isn't re-confirmed within 60s (default)
|
|
399
|
+
export NANO_AGENTIC_PRESENCE_GRACE_MS=5000 # how long a reconnect must hold before presence counts as landed (default)
|
|
400
|
+
```
|
|
401
|
+
|
|
380
402
|
**Secure mode (opt-in).** For a deployment where you want the visibility channel
|
|
381
403
|
authenticated (rather than open on the LAN), start the server **and** every worker
|
|
382
404
|
box with the **same** `NANO_AGENTIC_SECRET` — same env-var name, same value on both
|
package/c8ctl-plugin.js
CHANGED
|
@@ -2587,6 +2587,135 @@ const SANDBOXES = ['none', 'docker', 'podman'];
|
|
|
2587
2587
|
// Only container-based sandboxes need an image / disk hygiene / a runtime bin.
|
|
2588
2588
|
const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
|
|
2589
2589
|
|
|
2590
|
+
// Result-nudge (#678). A weak / non-Claude agent can finish a result-contract
|
|
2591
|
+
// job (exit 0, real work done) yet never emit the machine-readable result — the
|
|
2592
|
+
// downstream gateway then falls through to its default (e.g. a completed review
|
|
2593
|
+
// silently re-waits instead of converging). Rather than accept the empty result,
|
|
2594
|
+
// give the agent exactly ONE bounded "emit your result now" turn, feeding back
|
|
2595
|
+
// its own prior output, in the SAME workspace. This never redoes work; it only
|
|
2596
|
+
// recovers a dropped result. Bounded independently of the main run so a second
|
|
2597
|
+
// hang can't double a long idle window.
|
|
2598
|
+
const NUDGE_IDLE_TIMEOUT_MS = 120_000;
|
|
2599
|
+
const NUDGE_HARD_CAP_MS = 300_000;
|
|
2600
|
+
// Cap the prior transcript we echo back so a huge run can't blow the nudge prompt.
|
|
2601
|
+
// This is a UTF-16 code-unit (character) cap, not a byte cap: `String.slice`
|
|
2602
|
+
// counts code units, so with multi-byte output the byte size may be larger.
|
|
2603
|
+
const NUDGE_CONTEXT_CAP_CHARS = 24_000;
|
|
2604
|
+
|
|
2605
|
+
// The bespoke prompt for the re-emit turn: derive the status from the work the
|
|
2606
|
+
// agent already did, write ONLY the result, change nothing else.
|
|
2607
|
+
function buildResultNudgePrompt(priorStdout, { hasResultFile = true } = {}) {
|
|
2608
|
+
const ctx = typeof priorStdout === 'string' ? priorStdout.slice(-NUDGE_CONTEXT_CAP_CHARS) : '';
|
|
2609
|
+
const intro = [
|
|
2610
|
+
'You already completed the task in your previous turn, but you did NOT emit a',
|
|
2611
|
+
'machine-readable result, so the orchestrator cannot read your status and the',
|
|
2612
|
+
'run cannot advance.',
|
|
2613
|
+
'',
|
|
2614
|
+
'Do NOT redo the work, re-run tools, edit files, push, or open/modify a PR. Just',
|
|
2615
|
+
'emit the result for the work you already did: a single flat JSON object of your',
|
|
2616
|
+
'result variables (at minimum {"status":"..."}).',
|
|
2617
|
+
'',
|
|
2618
|
+
];
|
|
2619
|
+
// When the harness could not create the result file, AGENT_RESULT_FILE is NOT
|
|
2620
|
+
// exported (runAgentJob only sets it for a truthy resultFile), so the usual
|
|
2621
|
+
// "write to $AGENT_RESULT_FILE" instruction is impossible. Lead with the stdout
|
|
2622
|
+
// sentinel in that case so weaker agents don't waste the turn chasing an unset
|
|
2623
|
+
// env var; otherwise keep the file the primary path with the sentinel fallback.
|
|
2624
|
+
const how = hasResultFile
|
|
2625
|
+
? [
|
|
2626
|
+
'Write it to the file named by the AGENT_RESULT_FILE environment variable, e.g.:',
|
|
2627
|
+
'',
|
|
2628
|
+
' printf \'%s\' \'{"status":"...","summary":"..."}\' > "$AGENT_RESULT_FILE"',
|
|
2629
|
+
'',
|
|
2630
|
+
'If you truly cannot write that file, print exactly one line: ::nano:result:: {json}',
|
|
2631
|
+
]
|
|
2632
|
+
: [
|
|
2633
|
+
'The AGENT_RESULT_FILE environment variable is unset/empty in this run, so you',
|
|
2634
|
+
'CANNOT write a result file. Instead, print exactly one line to stdout:',
|
|
2635
|
+
'',
|
|
2636
|
+
' ::nano:result:: {"status":"...","summary":"..."}',
|
|
2637
|
+
];
|
|
2638
|
+
return [
|
|
2639
|
+
...intro,
|
|
2640
|
+
...how,
|
|
2641
|
+
'',
|
|
2642
|
+
'Your previous output (reference — derive the status/summary from it):',
|
|
2643
|
+
'-----',
|
|
2644
|
+
ctx,
|
|
2645
|
+
].join('\n');
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
// Given a finished agent run, decide whether it dropped its result and, if so,
|
|
2649
|
+
// perform ONE re-emit nudge via the injected `rerun(nudgeText)` (which re-invokes
|
|
2650
|
+
// the agent in the same workspace, writing to the same AGENT_RESULT_FILE).
|
|
2651
|
+
// `rerun` is injected so this is unit-testable without a real model. Returns the
|
|
2652
|
+
// (possibly appended) stdout and whether a nudge was attempted; the caller reads
|
|
2653
|
+
// the structured result from the result file / stdout afterwards as usual.
|
|
2654
|
+
// Cap a string to MAX_CAPTURE_BYTES of UTF-8 keeping the TAIL (so a trailing
|
|
2655
|
+
// `::nano:result::` sentinel / fenced JSON block survives the trim) and skipping
|
|
2656
|
+
// any partial leading continuation byte so we start on a char boundary. Mirrors
|
|
2657
|
+
// the per-stream capture cap for the nudge's post-concatenation stdout. Returns
|
|
2658
|
+
// `{ text, truncated }`.
|
|
2659
|
+
function capStdoutTail(s) {
|
|
2660
|
+
const buf = Buffer.from(s, 'utf8');
|
|
2661
|
+
if (buf.length <= MAX_CAPTURE_BYTES) return { text: s, truncated: false };
|
|
2662
|
+
let start = buf.length - MAX_CAPTURE_BYTES;
|
|
2663
|
+
while (start < buf.length && (buf[start] & 0xc0) === 0x80) start += 1;
|
|
2664
|
+
return { text: buf.subarray(start).toString('utf8'), truncated: true };
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
async function resolveAgentResultWithNudge({ result, resultFile, rerun, logger, logPrefix = '' }) {
|
|
2668
|
+
const stdout0 = result && typeof result.stdout === 'string' ? result.stdout : '';
|
|
2669
|
+
// A parsed result only counts as usable if it still carries at least one
|
|
2670
|
+
// *effective* var after sanitizeResultVars strips the reserved / io.nanobpm.* /
|
|
2671
|
+
// proto keys. An empty `{}` or a reserved-keys-only object leaves downstream
|
|
2672
|
+
// gateways with no status/decision vars — exactly the dropped-result case the
|
|
2673
|
+
// nudge exists to recover — so gate on effective vars, not raw object presence.
|
|
2674
|
+
const hasUsableResult = (parsed) => Object.keys(sanitizeResultVars(parsed)).length > 0;
|
|
2675
|
+
const already = readAgentResultFile(resultFile) ?? parseResultFromStdout(stdout0);
|
|
2676
|
+
// Only nudge a clean run that produced NO usable result but DID produce output
|
|
2677
|
+
// (silence means a crash/hang the idle path already handles, not a dropped result).
|
|
2678
|
+
// A null `resultFile` (temp-dir creation failed) is NOT a reason to skip: the
|
|
2679
|
+
// read-back already falls through to `parseResultFromStdout`, and the nudge
|
|
2680
|
+
// prompt explicitly offers the `::nano:result::` stdout sentinel, so recovery
|
|
2681
|
+
// still works in stdout-sentinel-only mode.
|
|
2682
|
+
if (hasUsableResult(already) || !result?.ok || !stdout0.trim() || typeof rerun !== 'function') {
|
|
2683
|
+
// No nudge: `stdout0` is the incoming stdout verbatim, so keep the returned
|
|
2684
|
+
// `truncated` flag consistent with it — echo the incoming `result.truncated`
|
|
2685
|
+
// rather than hardcoding false, so callers that trust the return value don't
|
|
2686
|
+
// see an already-truncated stdout reported as untruncated.
|
|
2687
|
+
return { stdout: stdout0, nudged: false, truncated: result?.truncated === true };
|
|
2688
|
+
}
|
|
2689
|
+
let nudge = null;
|
|
2690
|
+
let nudgeError = null;
|
|
2691
|
+
try { nudge = await rerun(buildResultNudgePrompt(stdout0, { hasResultFile: resultFile != null })); } catch (err) { nudge = null; nudgeError = err; }
|
|
2692
|
+
const nudgeOut = nudge && typeof nudge.stdout === 'string' ? nudge.stdout : '';
|
|
2693
|
+
// Re-apply the per-stream capture cap after concatenation: `result.stdout` is
|
|
2694
|
+
// forwarded verbatim into the job vars (`output`) and the audit envelope, so
|
|
2695
|
+
// the nudge must not let the combined output bypass MAX_CAPTURE_BYTES. Keep the
|
|
2696
|
+
// tail so a `::nano:result::` sentinel emitted by the nudge survives the trim,
|
|
2697
|
+
// and surface truncation so `result.truncated` stays honest.
|
|
2698
|
+
const capped = capStdoutTail(nudgeOut ? `${stdout0}\n${nudgeOut}` : stdout0);
|
|
2699
|
+
const stdout = capped.text;
|
|
2700
|
+
// The returned `stdout` still starts with `stdout0`, so if the FIRST turn was
|
|
2701
|
+
// already truncated the returned output is truncated regardless of whether the
|
|
2702
|
+
// post-concatenation cap trimmed anything: an empty/short nudge leaves
|
|
2703
|
+
// `capped.truncated` false even though `stdout0` was clipped. OR in the incoming
|
|
2704
|
+
// flag so `truncated` stays consistent with the returned stdout (and with the
|
|
2705
|
+
// no-nudge early return above).
|
|
2706
|
+
const truncated = result?.truncated === true || capped.truncated;
|
|
2707
|
+
const recovered = hasUsableResult(readAgentResultFile(resultFile) ?? parseResultFromStdout(stdout));
|
|
2708
|
+
if (nudgeError && logger?.warn) {
|
|
2709
|
+
logger.warn(`${logPrefix} re-emit nudge rerun threw — ${nudgeError?.message ?? nudgeError}`);
|
|
2710
|
+
}
|
|
2711
|
+
if (logger?.info) {
|
|
2712
|
+
logger.info(recovered
|
|
2713
|
+
? `${logPrefix} no result on the first turn — recovered it via one re-emit nudge`
|
|
2714
|
+
: `${logPrefix} no result on the first turn — re-emit nudge did not recover one`);
|
|
2715
|
+
}
|
|
2716
|
+
return { stdout, nudged: true, truncated };
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2590
2719
|
function coerceBool(v, dflt = false) {
|
|
2591
2720
|
if (typeof v === 'boolean') return v;
|
|
2592
2721
|
if (v == null) return dflt;
|
|
@@ -5159,6 +5288,31 @@ function buildAgentPayload(profile, job, envelope) {
|
|
|
5159
5288
|
};
|
|
5160
5289
|
}
|
|
5161
5290
|
|
|
5291
|
+
// Build the exact bytes handed to the harness on stdin for a run (#678).
|
|
5292
|
+
//
|
|
5293
|
+
// Normal run: the JSON job envelope from `buildAgentPayload`. Every non-ACP
|
|
5294
|
+
// harness (pipe/PTY/container) reads that JSON off stdin, so the shape is a
|
|
5295
|
+
// contract — a re-emit nudge must NOT replace it with a bare string or the
|
|
5296
|
+
// harness fails to parse its job. So when a `nudgePayload` (the bounded "re-emit
|
|
5297
|
+
// your result" prompt) is present:
|
|
5298
|
+
// - ACP delivers stdin verbatim as the `session/prompt` text, so it takes the
|
|
5299
|
+
// raw nudge string.
|
|
5300
|
+
// - Non-ACP keeps the JSON envelope and overrides its prompt fields (top-level
|
|
5301
|
+
// `prompt` and the reserved `task.task.prompt`, i.e. `envelope.task.prompt`)
|
|
5302
|
+
// so a harness that dispatches on either sees the nudge. The shared envelope
|
|
5303
|
+
// is copied, never mutated.
|
|
5304
|
+
function buildAgentStdin(profile, job, envelope, { nudgePayload = null, acp = false } = {}) {
|
|
5305
|
+
if (nudgePayload == null) return JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
5306
|
+
const nudgeText = String(nudgePayload);
|
|
5307
|
+
if (acp) return nudgeText;
|
|
5308
|
+
const base = buildAgentPayload(profile, job, envelope);
|
|
5309
|
+
base.prompt = nudgeText;
|
|
5310
|
+
if (isPlainObject(base.task) && isPlainObject(base.task.task)) {
|
|
5311
|
+
base.task = { ...base.task, task: { ...base.task.task, prompt: nudgeText } };
|
|
5312
|
+
}
|
|
5313
|
+
return JSON.stringify(base);
|
|
5314
|
+
}
|
|
5315
|
+
|
|
5162
5316
|
function baseAgentEnv(profile, job) {
|
|
5163
5317
|
return {
|
|
5164
5318
|
AGENT_PROFILE: profile.name,
|
|
@@ -5254,10 +5408,17 @@ function startLockExtender(job, windowMs, intervalMs, tag, logger) {
|
|
|
5254
5408
|
* Both paths resolve to the same result contract.
|
|
5255
5409
|
*/
|
|
5256
5410
|
function runAgentJob(profile, job, opts = {}) {
|
|
5257
|
-
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 } = opts;
|
|
5411
|
+
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 } = opts;
|
|
5258
5412
|
// #110: `protocol`/`permission` drive the ACP executor branch below. The
|
|
5259
5413
|
// pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
|
|
5260
|
-
|
|
5414
|
+
// A `nudgePayload` (#678) carries the bespoke "re-emit your result" prompt for a
|
|
5415
|
+
// bounded second turn in the SAME workspace; everything else (env, cwd, command,
|
|
5416
|
+
// result file) is identical to the main run. ACP delivers stdin as the prompt
|
|
5417
|
+
// text so it takes the raw nudge; every non-ACP harness reads the JSON envelope
|
|
5418
|
+
// off stdin, so there we keep the envelope and override its prompt fields
|
|
5419
|
+
// (buildAgentStdin). Container sandboxes ignore `protocol` (pipe-only today).
|
|
5420
|
+
const acpStdin = protocol === 'acp' && !CONTAINER_SANDBOXES.has(sandbox);
|
|
5421
|
+
const payload = buildAgentStdin(profile, job, envelope, { nudgePayload, acp: acpStdin });
|
|
5261
5422
|
const agentEnv = baseAgentEnv(profile, job);
|
|
5262
5423
|
// The harness command line: the profile command plus its structured switches
|
|
5263
5424
|
// (persisted `--arg`s, possibly extended at work time via opts.args), each
|
|
@@ -6098,6 +6259,20 @@ async function rediscoverAgenticUntilConnected({
|
|
|
6098
6259
|
// client lib alone. Overridable via NANO_AGENTIC_STALE_MS / NANO_AGENTIC_WATCHDOG_MS.
|
|
6099
6260
|
const DEFAULT_AGENTIC_STALE_MS = 60_000;
|
|
6100
6261
|
const DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS = 15_000;
|
|
6262
|
+
// #147 presence-keyed staleness. #144's stale trigger keys on a SUSTAINED socket
|
|
6263
|
+
// drop (`disconnectedSince` ages past the threshold). On a lossy WiFi/NAT link
|
|
6264
|
+
// that drops `1006` repeatedly, the client lib reconnects and re-announces, but
|
|
6265
|
+
// each brief reconnect clears the drop clock — so the sustained-drop trigger
|
|
6266
|
+
// never fires even though presence never actually re-lands on the hub (the
|
|
6267
|
+
// worker stays absent from the Workers view). The presence-keyed trigger closes
|
|
6268
|
+
// that gap: a connection only counts as "presence confirmed" once it HOLDS for
|
|
6269
|
+
// the grace window, so a reconnect that immediately re-drops cannot mask an
|
|
6270
|
+
// unrecovered presence. When presence has not been confirmed within the stale
|
|
6271
|
+
// threshold — regardless of transient reconnect flapping — the watchdog forces a
|
|
6272
|
+
// full re-discovery + reopen. Overridable via NANO_AGENTIC_PRESENCE_STALE_MS /
|
|
6273
|
+
// NANO_AGENTIC_PRESENCE_GRACE_MS.
|
|
6274
|
+
const DEFAULT_AGENTIC_PRESENCE_STALE_MS = DEFAULT_AGENTIC_STALE_MS;
|
|
6275
|
+
const DEFAULT_AGENTIC_PRESENCE_GRACE_MS = 5_000;
|
|
6101
6276
|
|
|
6102
6277
|
/**
|
|
6103
6278
|
* Decide whether a worker's agentic channel is *stale* — i.e. it once connected,
|
|
@@ -6132,6 +6307,78 @@ function agenticChannelIsStale({
|
|
|
6132
6307
|
return now() - since >= staleAfterMs;
|
|
6133
6308
|
}
|
|
6134
6309
|
|
|
6310
|
+
/**
|
|
6311
|
+
* Decide whether a worker's agentic channel is *presence-stale* (#147) — i.e. it
|
|
6312
|
+
* once connected but its presence has NOT been re-confirmed on the hub within
|
|
6313
|
+
* `staleAfterMs`, even though the socket may be intermittently reconnecting. This
|
|
6314
|
+
* is the churn counterpart to {@link agenticChannelIsStale}: on a lossy link the
|
|
6315
|
+
* client lib reconnects and re-announces after every `1006`, which keeps clearing
|
|
6316
|
+
* the sustained-drop clock, so the socket-keyed trigger never fires — yet
|
|
6317
|
+
* presence never actually lands. `presenceHealthySince` is the epoch-ms of the
|
|
6318
|
+
* last time the channel was observed *stably* connected long enough for presence
|
|
6319
|
+
* to be considered landed. The first open seeds it immediately (that open drains
|
|
6320
|
+
* the buffered REGISTER, so presence lands with it); thereafter it advances only
|
|
6321
|
+
* once a connection has *held* past a grace window (via `onPresenceHealthy`), so a
|
|
6322
|
+
* reconnect that immediately re-drops does not count. Pure so the trigger is
|
|
6323
|
+
* unit-testable
|
|
6324
|
+
* without timers or sockets. A channel that never opened is not presence-stale
|
|
6325
|
+
* (the initial connect owns it); a null `presenceHealthySince` (never confirmed,
|
|
6326
|
+
* or reset by a heal) also reads as not-stale so a fresh open is given its grace.
|
|
6327
|
+
*
|
|
6328
|
+
* @param {{
|
|
6329
|
+
* everConnected: () => boolean,
|
|
6330
|
+
* presenceHealthySince?: () => (number|null),
|
|
6331
|
+
* now?: () => number,
|
|
6332
|
+
* staleAfterMs?: number,
|
|
6333
|
+
* }} opts
|
|
6334
|
+
* @returns {boolean}
|
|
6335
|
+
*/
|
|
6336
|
+
function agenticPresenceIsStale({
|
|
6337
|
+
everConnected,
|
|
6338
|
+
presenceHealthySince,
|
|
6339
|
+
now = () => Date.now(),
|
|
6340
|
+
staleAfterMs = DEFAULT_AGENTIC_PRESENCE_STALE_MS,
|
|
6341
|
+
}) {
|
|
6342
|
+
if (typeof everConnected !== 'function') return false;
|
|
6343
|
+
if (!everConnected()) return false; // never opened → the initial connect owns it
|
|
6344
|
+
const since = typeof presenceHealthySince === 'function' ? presenceHealthySince() : null;
|
|
6345
|
+
if (since == null) return false; // never confirmed / freshly reset → give the open its grace
|
|
6346
|
+
return now() - since >= staleAfterMs;
|
|
6347
|
+
}
|
|
6348
|
+
|
|
6349
|
+
/**
|
|
6350
|
+
* Equal-jitter backoff (#147). Given a base backoff `baseMs`, keep half of it and
|
|
6351
|
+
* randomise the other half: `baseMs/2 + rand()*baseMs/2`. A fleet of workers that
|
|
6352
|
+
* all dropped on the same lossy link would otherwise reconnect in lockstep (the
|
|
6353
|
+
* client lib's exponential backoff is deterministic), re-congesting the link and
|
|
6354
|
+
* re-triggering the `1006` drops that stranded them. Spreading the reconnect
|
|
6355
|
+
* attempts bounds that thundering-herd churn while preserving the exponential
|
|
6356
|
+
* growth of the underlying policy. Pure (`rand` injectable) so it is testable.
|
|
6357
|
+
*
|
|
6358
|
+
* @param {number} baseMs the deterministic backoff the client lib computed
|
|
6359
|
+
* @param {{ rand?: () => number }} [opts]
|
|
6360
|
+
* @returns {number} the jittered delay in ms (0 when baseMs is non-positive)
|
|
6361
|
+
*/
|
|
6362
|
+
function jitteredDelay(baseMs, { rand = Math.random } = {}) {
|
|
6363
|
+
const b = Number.isFinite(baseMs) && baseMs > 0 ? baseMs : 0;
|
|
6364
|
+
if (b === 0) return 0;
|
|
6365
|
+
const half = b / 2;
|
|
6366
|
+
return Math.round(half + rand() * half);
|
|
6367
|
+
}
|
|
6368
|
+
|
|
6369
|
+
/**
|
|
6370
|
+
* Build a reconnect `schedule` function that applies {@link jitteredDelay} to the
|
|
6371
|
+
* backoff the client lib passes, so the worker's reconnect attempts on a lossy
|
|
6372
|
+
* link are de-synchronised (#147). Drops straight into `createWorkChannel`'s
|
|
6373
|
+
* injectable `schedule` seam; the timer is injectable for tests.
|
|
6374
|
+
*
|
|
6375
|
+
* @param {{ rand?: () => number, timer?: (fn: () => void, ms: number) => any }} [opts]
|
|
6376
|
+
* @returns {(fn: () => void, ms: number) => void}
|
|
6377
|
+
*/
|
|
6378
|
+
function makeJitteredReconnectSchedule({ rand = Math.random, timer = setTimeout } = {}) {
|
|
6379
|
+
return (fn, ms) => { timer(fn, jitteredDelay(ms, { rand })); };
|
|
6380
|
+
}
|
|
6381
|
+
|
|
6135
6382
|
/**
|
|
6136
6383
|
* Start the worker-side agentic-channel liveness watchdog (#144). On a fixed
|
|
6137
6384
|
* interval it asks {@link agenticChannelIsStale} whether the channel dropped and
|
|
@@ -6149,9 +6396,23 @@ function agenticChannelIsStale({
|
|
|
6149
6396
|
* prevent a stale-channel resurrection mid-teardown), and `tick()` runs a single
|
|
6150
6397
|
* check (tests drive it directly).
|
|
6151
6398
|
*
|
|
6399
|
+
* #147 adds a second, presence-keyed trigger alongside the #144 sustained-drop
|
|
6400
|
+
* one: each tick, a channel observed *stably* connected (connected for at least
|
|
6401
|
+
* `presenceGraceMs` since `connectedSince()`) advances the presence-health clock
|
|
6402
|
+
* via `onPresenceHealthy()`; when presence has not been confirmed within
|
|
6403
|
+
* `presenceStaleAfterMs` — even while the socket flaps `1006` reconnects — the
|
|
6404
|
+
* watchdog heals just as it does for a sustained drop. The presence accessors are
|
|
6405
|
+
* optional: omitting them leaves the #147 trigger inert, so the #144 behaviour is
|
|
6406
|
+
* unchanged.
|
|
6407
|
+
*
|
|
6152
6408
|
* @param {{
|
|
6153
6409
|
* getChannel: () => (import('./work-channel.mjs').WorkChannel | null),
|
|
6154
6410
|
* disconnectedSince: () => (number|null),
|
|
6411
|
+
* connectedSince?: () => (number|null),
|
|
6412
|
+
* presenceHealthySince?: () => (number|null),
|
|
6413
|
+
* onPresenceHealthy?: () => void,
|
|
6414
|
+
* presenceGraceMs?: number,
|
|
6415
|
+
* presenceStaleAfterMs?: number,
|
|
6155
6416
|
* onStale: () => (void|Promise<void>),
|
|
6156
6417
|
* staleAfterMs?: number,
|
|
6157
6418
|
* intervalMs?: number,
|
|
@@ -6166,6 +6427,11 @@ function startAgenticChannelWatchdog({
|
|
|
6166
6427
|
getChannel,
|
|
6167
6428
|
disconnectedSince,
|
|
6168
6429
|
onStale,
|
|
6430
|
+
connectedSince = null,
|
|
6431
|
+
presenceHealthySince = null,
|
|
6432
|
+
onPresenceHealthy = null,
|
|
6433
|
+
presenceGraceMs = DEFAULT_AGENTIC_PRESENCE_GRACE_MS,
|
|
6434
|
+
presenceStaleAfterMs = DEFAULT_AGENTIC_PRESENCE_STALE_MS,
|
|
6169
6435
|
staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
|
|
6170
6436
|
intervalMs = DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS,
|
|
6171
6437
|
now = () => Date.now(),
|
|
@@ -6186,17 +6452,39 @@ function startAgenticChannelWatchdog({
|
|
|
6186
6452
|
const tick = async () => {
|
|
6187
6453
|
if (stopped || healing) return; // shutting down, or a heal is in flight — don't stack a second re-discovery
|
|
6188
6454
|
const ch = typeof getChannel === 'function' ? getChannel() : null;
|
|
6455
|
+
// #147: advance the presence-health clock when the channel is observed
|
|
6456
|
+
// STABLY connected (held for the grace window). A churny link that reconnects
|
|
6457
|
+
// but immediately re-drops never accrues the grace, so its health clock ages
|
|
6458
|
+
// out and the presence-keyed trigger below fires — unlike the sustained-drop
|
|
6459
|
+
// trigger, which the churn keeps resetting. Inert unless the accessors are wired.
|
|
6460
|
+
if (ch && typeof onPresenceHealthy === 'function' && typeof connectedSince === 'function') {
|
|
6461
|
+
try {
|
|
6462
|
+
const cs = connectedSince();
|
|
6463
|
+
if (ch.connected() && cs != null && now() - cs >= presenceGraceMs) onPresenceHealthy();
|
|
6464
|
+
} catch { /* best effort — never let a health probe break the tick */ }
|
|
6465
|
+
}
|
|
6466
|
+
// The channel is stale when EITHER trigger fires: #144's sustained socket
|
|
6467
|
+
// drop, or #147's presence-not-confirmed-despite-reconnect-churn.
|
|
6468
|
+
const stale = !!ch && (
|
|
6469
|
+
agenticChannelIsStale({
|
|
6470
|
+
connected: () => ch.connected(),
|
|
6471
|
+
everConnected: () => ch.everConnected(),
|
|
6472
|
+
disconnectedSince,
|
|
6473
|
+
now,
|
|
6474
|
+
staleAfterMs,
|
|
6475
|
+
})
|
|
6476
|
+
|| agenticPresenceIsStale({
|
|
6477
|
+
everConnected: () => ch.everConnected(),
|
|
6478
|
+
presenceHealthySince,
|
|
6479
|
+
now,
|
|
6480
|
+
staleAfterMs: presenceStaleAfterMs,
|
|
6481
|
+
})
|
|
6482
|
+
);
|
|
6189
6483
|
// No channel object → the initial open or the cold-start self-heal loop owns
|
|
6190
6484
|
// recovery; the watchdog only guards a channel that HAS connected and stalled.
|
|
6191
6485
|
// A missing or non-stale (healthy / recovered / still-connecting) channel also
|
|
6192
6486
|
// ends any current stale episode, so re-arm the latch for the next one.
|
|
6193
|
-
if (!
|
|
6194
|
-
connected: () => ch.connected(),
|
|
6195
|
-
everConnected: () => ch.everConnected(),
|
|
6196
|
-
disconnectedSince,
|
|
6197
|
-
now,
|
|
6198
|
-
staleAfterMs,
|
|
6199
|
-
})) {
|
|
6487
|
+
if (!stale) {
|
|
6200
6488
|
firedForEpisode = false;
|
|
6201
6489
|
return;
|
|
6202
6490
|
}
|
|
@@ -6205,9 +6493,16 @@ function startAgenticChannelWatchdog({
|
|
|
6205
6493
|
firedForEpisode = true;
|
|
6206
6494
|
try {
|
|
6207
6495
|
if (stopped) return; // shutdown raced us between the checks — do not heal
|
|
6208
|
-
const since = disconnectedSince();
|
|
6209
|
-
|
|
6210
|
-
|
|
6496
|
+
const since = typeof disconnectedSince === 'function' ? disconnectedSince() : null;
|
|
6497
|
+
// During reconnect churn `disconnectedSince` can be null (no sustained drop)
|
|
6498
|
+
// or reset by the latest blip, so it under-reports the real staleness. Fall
|
|
6499
|
+
// back to the presence-health clock — the signal we actually acted on — so
|
|
6500
|
+
// the logged age reflects how long presence has genuinely been unconfirmed.
|
|
6501
|
+
const staleSince = since != null
|
|
6502
|
+
? since
|
|
6503
|
+
: (typeof presenceHealthySince === 'function' ? presenceHealthySince() : null);
|
|
6504
|
+
const downFor = staleSince != null ? Math.round((now() - staleSince) / 1000) : '?';
|
|
6505
|
+
logger?.warn?.(` agentic channel: presence not confirmed within threshold (down ${downFor}s / reconnect churn) — forcing re-discovery (the client lib did not self-heal; likely a lossy-link 1006 churn or half-open drop).`);
|
|
6211
6506
|
await onStale?.();
|
|
6212
6507
|
} catch (err) {
|
|
6213
6508
|
firedForEpisode = false; // heal failed → re-arm so a later tick retries this episode
|
|
@@ -6727,6 +7022,16 @@ async function workAgent(req, flags) {
|
|
|
6727
7022
|
// shutdown); `agenticSelfHealing` guards against two concurrent re-discovery
|
|
6728
7023
|
// loops (the cold-start one and a watchdog-triggered one).
|
|
6729
7024
|
let agenticDisconnectedSince = null;
|
|
7025
|
+
// #147 presence-keyed watchdog state. `agenticConnectedSince` is the epoch-ms
|
|
7026
|
+
// the channel last (re)connected (null while down); the watchdog uses it to
|
|
7027
|
+
// require a stable connection to have held for a grace window before counting
|
|
7028
|
+
// presence as confirmed. `agenticPresenceHealthyAt` is the epoch-ms presence
|
|
7029
|
+
// was last confirmed healthy — advanced on the first connect (buffered REGISTER
|
|
7030
|
+
// drains) and by the watchdog whenever a stable connection is observed, and
|
|
7031
|
+
// reset by a heal. When it ages past the presence-stale threshold — even while
|
|
7032
|
+
// the socket flaps `1006` reconnects — the watchdog forces a re-discovery.
|
|
7033
|
+
let agenticConnectedSince = null;
|
|
7034
|
+
let agenticPresenceHealthyAt = null;
|
|
6730
7035
|
/** @type {{ stop: () => void } | null} */
|
|
6731
7036
|
let agenticWatchdog = null;
|
|
6732
7037
|
let agenticSelfHealing = false;
|
|
@@ -6824,6 +7129,11 @@ async function workAgent(req, flags) {
|
|
|
6824
7129
|
token: cfg.token,
|
|
6825
7130
|
credential: cfg.credential,
|
|
6826
7131
|
bufferCapacity: cfg.bufferCapacity,
|
|
7132
|
+
// #147: de-synchronise reconnect attempts with equal-jitter backoff so a
|
|
7133
|
+
// fleet dropped on the same lossy link does not reconnect in lockstep and
|
|
7134
|
+
// re-congest it. Wraps the client lib's own exponential policy (which has
|
|
7135
|
+
// no jitter of its own); the base delays/factor stay the lib's defaults.
|
|
7136
|
+
schedule: makeJitteredReconnectSchedule(),
|
|
6827
7137
|
logger,
|
|
6828
7138
|
});
|
|
6829
7139
|
const shown = redactAgenticUrl(buildAgenticUrl(cfg.url, {}));
|
|
@@ -6843,16 +7153,38 @@ async function workAgent(req, flags) {
|
|
|
6843
7153
|
// a disconnect starts it (first drop wins, so the watchdog measures from the
|
|
6844
7154
|
// ORIGINAL drop, not the latest of a reconnect storm). The watchdog reads
|
|
6845
7155
|
// this to decide when the client lib has failed to self-heal.
|
|
6846
|
-
workChannel.onConnect(() => {
|
|
6847
|
-
|
|
7156
|
+
workChannel.onConnect(() => {
|
|
7157
|
+
markAgentic('connected');
|
|
7158
|
+
agenticDisconnectedSince = null;
|
|
7159
|
+
agenticConnectedSince = Date.now();
|
|
7160
|
+
// First open drains the buffered REGISTER → presence lands; seed the
|
|
7161
|
+
// presence-health clock so the #147 trigger measures from here (#147).
|
|
7162
|
+
agenticPresenceHealthyAt = Date.now();
|
|
7163
|
+
});
|
|
7164
|
+
workChannel.onReconnect(() => {
|
|
7165
|
+
markAgentic('connected');
|
|
7166
|
+
agenticDisconnectedSince = null;
|
|
7167
|
+
agenticConnectedSince = Date.now();
|
|
7168
|
+
// Deliberately do NOT advance agenticPresenceHealthyAt here: a reconnect
|
|
7169
|
+
// only CLAIMS presence (re-announces). On a lossy link the socket may
|
|
7170
|
+
// re-drop `1006` before presence actually lands, so the watchdog confirms
|
|
7171
|
+
// it only once a connection HOLDS for the grace window — a reconnect that
|
|
7172
|
+
// immediately re-drops must not mask an unrecovered presence (#147).
|
|
7173
|
+
});
|
|
6848
7174
|
workChannel.onDisconnect((info) => {
|
|
6849
7175
|
markAgentic('disconnected', normalizeAgenticMessage(info));
|
|
6850
7176
|
if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
|
|
7177
|
+
agenticConnectedSince = null;
|
|
6851
7178
|
});
|
|
6852
|
-
if (workChannel.connected()) {
|
|
6853
|
-
|
|
7179
|
+
if (workChannel.connected()) {
|
|
7180
|
+
markAgentic('connected');
|
|
7181
|
+
agenticDisconnectedSince = null;
|
|
7182
|
+
agenticConnectedSince = Date.now();
|
|
7183
|
+
if (agenticPresenceHealthyAt == null) agenticPresenceHealthyAt = Date.now();
|
|
7184
|
+
} else if (workChannel.everConnected()) {
|
|
6854
7185
|
markAgentic('disconnected');
|
|
6855
7186
|
if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
|
|
7187
|
+
agenticConnectedSince = null;
|
|
6856
7188
|
}
|
|
6857
7189
|
} catch (err) {
|
|
6858
7190
|
// Never let a channel failure stop the worker from doing its actual job.
|
|
@@ -6932,6 +7264,8 @@ async function workAgent(req, flags) {
|
|
|
6932
7264
|
if (!stale) return;
|
|
6933
7265
|
workChannel = null; // re-arms armAgenticSelfHeal()'s shouldContinue gate
|
|
6934
7266
|
agenticDisconnectedSince = null; // reset the clock; the fresh open restarts it
|
|
7267
|
+
agenticConnectedSince = null; // #147: the fresh open re-seeds it
|
|
7268
|
+
agenticPresenceHealthyAt = null; // #147: the fresh open re-confirms presence
|
|
6935
7269
|
try { bufferMonitor?.stop(); } catch { /* best effort */ }
|
|
6936
7270
|
bufferMonitor = null;
|
|
6937
7271
|
markAgentic('disconnected', 'stale channel — re-discovering hub');
|
|
@@ -6945,9 +7279,18 @@ async function workAgent(req, flags) {
|
|
|
6945
7279
|
if (agenticWatchdog) return;
|
|
6946
7280
|
const staleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_STALE_MS, DEFAULT_AGENTIC_STALE_MS));
|
|
6947
7281
|
const intervalMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_WATCHDOG_MS, DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS));
|
|
7282
|
+
const presenceStaleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_STALE_MS, DEFAULT_AGENTIC_PRESENCE_STALE_MS));
|
|
7283
|
+
const presenceGraceMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_GRACE_MS, DEFAULT_AGENTIC_PRESENCE_GRACE_MS));
|
|
6948
7284
|
agenticWatchdog = startAgenticChannelWatchdog({
|
|
6949
7285
|
getChannel: () => workChannel,
|
|
6950
7286
|
disconnectedSince: () => agenticDisconnectedSince,
|
|
7287
|
+
// #147: presence-keyed trigger — heal reconnect-churn that never re-lands
|
|
7288
|
+
// presence, not just a sustained socket drop.
|
|
7289
|
+
connectedSince: () => agenticConnectedSince,
|
|
7290
|
+
presenceHealthySince: () => agenticPresenceHealthyAt,
|
|
7291
|
+
onPresenceHealthy: () => { agenticPresenceHealthyAt = Date.now(); },
|
|
7292
|
+
presenceStaleAfterMs,
|
|
7293
|
+
presenceGraceMs,
|
|
6951
7294
|
onStale: healStaleAgenticChannel,
|
|
6952
7295
|
staleAfterMs,
|
|
6953
7296
|
intervalMs,
|
|
@@ -7255,7 +7598,7 @@ async function workAgent(req, flags) {
|
|
|
7255
7598
|
liveRunDirs.add(resultDir);
|
|
7256
7599
|
} catch { resultDir = null; resultFile = null; }
|
|
7257
7600
|
|
|
7258
|
-
|
|
7601
|
+
const runOpts = {
|
|
7259
7602
|
timeoutMs: effectiveHardCapMs,
|
|
7260
7603
|
idleTimeoutMs: effectiveIdleTimeoutMs,
|
|
7261
7604
|
recoveryWindowMs: effectiveRecoveryWindowMs,
|
|
@@ -7286,7 +7629,42 @@ async function workAgent(req, flags) {
|
|
|
7286
7629
|
// spying never corrupts a structured/JSON output mode.
|
|
7287
7630
|
onStreamOut: stream ? (line) => logger.info(line) : undefined,
|
|
7288
7631
|
onStreamErr: stream ? (line) => logger.warn(line) : undefined,
|
|
7289
|
-
}
|
|
7632
|
+
};
|
|
7633
|
+
result = await runAgentJob(profile, job, runOpts);
|
|
7634
|
+
|
|
7635
|
+
// Gap 2 (#678): a clean run that emitted no machine-readable result gets
|
|
7636
|
+
// ONE bounded re-emit nudge in the same workspace, feeding back its own
|
|
7637
|
+
// output, before we accept an empty result. Runs before finalizeGit so
|
|
7638
|
+
// the workspace/result file are still live; the nudge changes no code.
|
|
7639
|
+
// Not gated on `resultFile`: when the temp dir/file could not be created
|
|
7640
|
+
// the result is recoverable only via the stdout `::nano:result::`
|
|
7641
|
+
// sentinel, which `resolveAgentResultWithNudge` handles directly.
|
|
7642
|
+
if (result.ok) {
|
|
7643
|
+
const { stdout, nudged, truncated } = await resolveAgentResultWithNudge({
|
|
7644
|
+
result,
|
|
7645
|
+
resultFile,
|
|
7646
|
+
logger,
|
|
7647
|
+
logPrefix: `[${jobType}] job ${job.jobKey}:`,
|
|
7648
|
+
rerun: (nudgeText) => runAgentJob(profile, job, {
|
|
7649
|
+
...runOpts,
|
|
7650
|
+
nudgePayload: nudgeText,
|
|
7651
|
+
stream: false,
|
|
7652
|
+
idleTimeoutMs: Math.min(effectiveIdleTimeoutMs || NUDGE_IDLE_TIMEOUT_MS, NUDGE_IDLE_TIMEOUT_MS),
|
|
7653
|
+
// Cap the recovery/probe window to the same 120s idle bound: it is
|
|
7654
|
+
// used by createIdleLivenessMonitor as the probe window when >0, so
|
|
7655
|
+
// inheriting the (possibly minutes-long) `effectiveRecoveryWindowMs`
|
|
7656
|
+
// from runOpts would let a silent nudge run outlive the intended
|
|
7657
|
+
// 120s idle bound and delay convergence on a wedged second turn.
|
|
7658
|
+
recoveryWindowMs: Math.min(effectiveRecoveryWindowMs || NUDGE_IDLE_TIMEOUT_MS, NUDGE_IDLE_TIMEOUT_MS),
|
|
7659
|
+
timeoutMs: Math.min(effectiveHardCapMs || NUDGE_HARD_CAP_MS, NUDGE_HARD_CAP_MS) || NUDGE_HARD_CAP_MS,
|
|
7660
|
+
}),
|
|
7661
|
+
});
|
|
7662
|
+
result.stdout = stdout;
|
|
7663
|
+
if (nudged) {
|
|
7664
|
+
result.nudgedForResult = true;
|
|
7665
|
+
if (truncated) result.truncated = true;
|
|
7666
|
+
}
|
|
7667
|
+
}
|
|
7290
7668
|
|
|
7291
7669
|
// Finalize git only when the harness succeeded — never push a
|
|
7292
7670
|
// half-finished workspace.
|
|
@@ -11958,6 +12336,9 @@ export {
|
|
|
11958
12336
|
rediscoverAgenticUntilConnected,
|
|
11959
12337
|
defaultAgenticRediscoveryDelays,
|
|
11960
12338
|
agenticChannelIsStale,
|
|
12339
|
+
agenticPresenceIsStale,
|
|
12340
|
+
jitteredDelay,
|
|
12341
|
+
makeJitteredReconnectSchedule,
|
|
11961
12342
|
startAgenticChannelWatchdog,
|
|
11962
12343
|
};
|
|
11963
12344
|
export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
|
|
@@ -11991,11 +12372,14 @@ export {
|
|
|
11991
12372
|
makeSecretResolver,
|
|
11992
12373
|
hostEnvSecretResolver,
|
|
11993
12374
|
buildAgentPayload,
|
|
12375
|
+
buildAgentStdin,
|
|
11994
12376
|
buildResultEnvelope,
|
|
11995
12377
|
parseAgentResultObject,
|
|
11996
12378
|
readAgentResultFile,
|
|
11997
12379
|
parseResultFromStdout,
|
|
11998
12380
|
sanitizeResultVars,
|
|
12381
|
+
buildResultNudgePrompt,
|
|
12382
|
+
resolveAgentResultWithNudge,
|
|
11999
12383
|
parseEnvPairs,
|
|
12000
12384
|
normalizeEnvMap,
|
|
12001
12385
|
normalizeArgList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.44.
|
|
3
|
+
"version": "1.44.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
|
|
6
6
|
"main": "c8ctl-plugin.js",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"node-pty": "^1.0.0",
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.
|
|
66
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.11",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.11",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.11",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.11",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.11",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.11",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.11"
|
|
67
67
|
}
|
|
68
68
|
}
|