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.
Files changed (3) hide show
  1. package/README.md +22 -0
  2. package/c8ctl-plugin.js +402 -18
  3. 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
- const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
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 (!ch || !agenticChannelIsStale({
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
- const downFor = since != null ? Math.round((now() - since) / 1000) : '?';
6210
- logger?.warn?.(` agentic channel: no reconnect ${downFor}s after drop forcing re-discovery (the client lib did not self-heal; likely a half-open drop).`);
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(() => { markAgentic('connected'); agenticDisconnectedSince = null; });
6847
- workChannel.onReconnect(() => { markAgentic('connected'); agenticDisconnectedSince = null; });
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()) { markAgentic('connected'); agenticDisconnectedSince = null; }
6853
- else if (workChannel.everConnected()) {
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
- result = await runAgentJob(profile, job, {
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.9",
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.9",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.9",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.9",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.9",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.9",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.9",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.9"
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
  }