c8ctl-plugin-nano 1.44.8 → 1.44.10

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
@@ -352,7 +352,7 @@ one of:
352
352
  | `starting` | transient: the worker just spawned and hasn't resolved its channel target yet (pre-`connecting`) |
353
353
  | `connected` | presence is live on the hub — you should see this worker in the Cockpit |
354
354
  | `connecting` | resolved a hub, socket not open yet (or the hub is unreachable) |
355
- | `disconnected` | an established channel dropped (hub restart/outage) — it auto-reconnects; also set if the channel failed to start (bad URL/refused socket), in which case it stays disconnected until the worker restarts |
355
+ | `disconnected` | an established channel dropped (hub restart/outage) — it auto-reconnects, and a worker-side **liveness watchdog** force-re-discovers the hub if it stays down (see below); also set if the channel failed to start (bad URL/refused socket), which is **not** auto-recovered by the watchdog (it only guards a channel that has connected) — fix the target and restart the worker |
356
356
  | `advisory` | nothing discoverable at the engine — **not** in the Cockpit; set `NANO_AGENTIC_URL` |
357
357
  | `off` | visibility disabled (`NANO_AGENTIC=off`) |
358
358
  | `?` | a live worker not yet reporting, or an older build predating these fields |
@@ -361,6 +361,22 @@ If workers show `advisory` (or stay `connecting`) while jobs still run, that's t
361
361
  "connected to the engine but empty Cockpit" case: point them at the app with
362
362
  `export NANO_AGENTIC_URL=http://<engine-host>:<appUi.port>` (e.g. `:3000`).
363
363
 
364
+ **Liveness watchdog (auto-recovery from a wedged channel).** If the nano server
365
+ restarts, crashes, or a network partition drops the connection *without* a clean
366
+ close (a **half-open** socket), a worker's channel client can sit `disconnected`
367
+ forever — the worker vanishes from the Nano Workers view / Cockpit and, before
368
+ this, only a supervisor restart brought it back. Each worker now runs a
369
+ belt-and-suspenders watchdog: once a channel that had connected stays down past a
370
+ threshold (the client library's own reconnect never recovered it), the worker
371
+ tears the wedged channel down and re-runs full hub discovery + reopen — no restart
372
+ needed. The thresholds are tunable via env (sensible defaults; you rarely need
373
+ these):
374
+
375
+ ```bash
376
+ export NANO_AGENTIC_STALE_MS=60000 # force re-discovery if a drop hasn't recovered within 60s (default)
377
+ export NANO_AGENTIC_WATCHDOG_MS=15000 # how often the watchdog checks channel liveness (default)
378
+ ```
379
+
364
380
  **Secure mode (opt-in).** For a deployment where you want the visibility channel
365
381
  authenticated (rather than open on the LAN), start the server **and** every worker
366
382
  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
@@ -6089,6 +6250,138 @@ async function rediscoverAgenticUntilConnected({
6089
6250
  return null;
6090
6251
  }
6091
6252
 
6253
+ // Worker-side liveness watchdog defaults (jwulf/c8ctl-plugin-nano#144). A
6254
+ // previously-connected agentic channel that has been `disconnected` for longer
6255
+ // than the stale threshold — because the client lib's own reconnect never
6256
+ // brought it back (e.g. a half-open drop after a server restart/crash/partition,
6257
+ // or a reconnect that keeps failing) — is force-healed: the wedged channel is
6258
+ // torn down and full discovery + reopen is re-armed, instead of trusting the
6259
+ // client lib alone. Overridable via NANO_AGENTIC_STALE_MS / NANO_AGENTIC_WATCHDOG_MS.
6260
+ const DEFAULT_AGENTIC_STALE_MS = 60_000;
6261
+ const DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS = 15_000;
6262
+
6263
+ /**
6264
+ * Decide whether a worker's agentic channel is *stale* — i.e. it once connected,
6265
+ * is no longer connected, and has stayed down past `staleAfterMs`. Pure so the
6266
+ * watchdog's trigger condition is unit-testable without timers or sockets. A
6267
+ * channel that never opened (`everConnected() === false`) is NOT stale — it is
6268
+ * still doing its first connect, which the initial open / cold-start self-heal
6269
+ * owns. `disconnectedSince` is null whenever the channel is up (or never went
6270
+ * down), which also reads as not-stale.
6271
+ *
6272
+ * @param {{
6273
+ * connected: () => boolean,
6274
+ * everConnected: () => boolean,
6275
+ * disconnectedSince: () => (number|null),
6276
+ * now?: () => number,
6277
+ * staleAfterMs?: number,
6278
+ * }} opts
6279
+ * @returns {boolean}
6280
+ */
6281
+ function agenticChannelIsStale({
6282
+ connected,
6283
+ everConnected,
6284
+ disconnectedSince,
6285
+ now = () => Date.now(),
6286
+ staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
6287
+ }) {
6288
+ if (typeof connected !== 'function' || typeof everConnected !== 'function') return false;
6289
+ if (!everConnected()) return false; // never opened → the initial connect owns it
6290
+ if (connected()) return false; // healthy
6291
+ const since = typeof disconnectedSince === 'function' ? disconnectedSince() : null;
6292
+ if (since == null) return false; // no recorded drop → nothing to heal
6293
+ return now() - since >= staleAfterMs;
6294
+ }
6295
+
6296
+ /**
6297
+ * Start the worker-side agentic-channel liveness watchdog (#144). On a fixed
6298
+ * interval it asks {@link agenticChannelIsStale} whether the channel dropped and
6299
+ * never recovered within the threshold; when it has, it fires `onStale()` (which
6300
+ * tears the wedged channel down and re-runs discovery + reopen). It fires
6301
+ * `onStale` **exactly once per stale episode** — a per-episode latch is re-armed
6302
+ * only when the channel is next observed healthy (or gone), or when a heal
6303
+ * throws (a failed recovery retries on the next tick), so a persistent stale
6304
+ * condition with a successful heal does not retrigger the heal on every tick. Re-entrancy is
6305
+ * guarded so a slow heal never overlaps a later tick. Timers, clock, and the
6306
+ * channel accessors are injectable so this is unit-testable without real waits.
6307
+ * Returns a `{ stop, tick }` handle — `stop()` clears the timer AND latches the
6308
+ * watchdog stopped so any tick already scheduled or in flight around shutdown
6309
+ * becomes a no-op before it can reach `onStale` (shutdown relies on this to
6310
+ * prevent a stale-channel resurrection mid-teardown), and `tick()` runs a single
6311
+ * check (tests drive it directly).
6312
+ *
6313
+ * @param {{
6314
+ * getChannel: () => (import('./work-channel.mjs').WorkChannel | null),
6315
+ * disconnectedSince: () => (number|null),
6316
+ * onStale: () => (void|Promise<void>),
6317
+ * staleAfterMs?: number,
6318
+ * intervalMs?: number,
6319
+ * now?: () => number,
6320
+ * setIntervalFn?: typeof setInterval,
6321
+ * clearIntervalFn?: typeof clearInterval,
6322
+ * logger?: object|null,
6323
+ * }} opts
6324
+ * @returns {{ stop: () => void, tick: () => Promise<void> }}
6325
+ */
6326
+ function startAgenticChannelWatchdog({
6327
+ getChannel,
6328
+ disconnectedSince,
6329
+ onStale,
6330
+ staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
6331
+ intervalMs = DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS,
6332
+ now = () => Date.now(),
6333
+ setIntervalFn = setInterval,
6334
+ clearIntervalFn = clearInterval,
6335
+ logger = null,
6336
+ } = {}) {
6337
+ let healing = false;
6338
+ // Set once `stop()` runs so any tick already scheduled/in flight around
6339
+ // shutdown becomes a no-op and can never re-open the channel mid-teardown
6340
+ // (shutdown relies on `stop()` to prevent a stale-channel resurrection).
6341
+ let stopped = false;
6342
+ // Per-episode latch: fire `onStale` exactly once when a connected channel goes
6343
+ // stale, and don't fire again until it recovers (a fresh episode). Without this
6344
+ // the helper would re-heal on every tick whenever `onStale` does not itself
6345
+ // clear the staleness signal, causing repeated teardown/re-discovery attempts.
6346
+ let firedForEpisode = false;
6347
+ const tick = async () => {
6348
+ if (stopped || healing) return; // shutting down, or a heal is in flight — don't stack a second re-discovery
6349
+ const ch = typeof getChannel === 'function' ? getChannel() : null;
6350
+ // No channel object → the initial open or the cold-start self-heal loop owns
6351
+ // recovery; the watchdog only guards a channel that HAS connected and stalled.
6352
+ // A missing or non-stale (healthy / recovered / still-connecting) channel also
6353
+ // ends any current stale episode, so re-arm the latch for the next one.
6354
+ if (!ch || !agenticChannelIsStale({
6355
+ connected: () => ch.connected(),
6356
+ everConnected: () => ch.everConnected(),
6357
+ disconnectedSince,
6358
+ now,
6359
+ staleAfterMs,
6360
+ })) {
6361
+ firedForEpisode = false;
6362
+ return;
6363
+ }
6364
+ if (firedForEpisode) return; // already fired once for this stale episode
6365
+ healing = true;
6366
+ firedForEpisode = true;
6367
+ try {
6368
+ if (stopped) return; // shutdown raced us between the checks — do not heal
6369
+ const since = disconnectedSince();
6370
+ const downFor = since != null ? Math.round((now() - since) / 1000) : '?';
6371
+ 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).`);
6372
+ await onStale?.();
6373
+ } catch (err) {
6374
+ firedForEpisode = false; // heal failed → re-arm so a later tick retries this episode
6375
+ logger?.debug?.(`agentic watchdog heal failed: ${err?.message || err}`);
6376
+ } finally {
6377
+ healing = false;
6378
+ }
6379
+ };
6380
+ const timer = setIntervalFn(() => { tick().catch(() => {}); }, intervalMs);
6381
+ if (timer && typeof timer.unref === 'function') timer.unref();
6382
+ return { stop: () => { stopped = true; try { clearIntervalFn(timer); } catch { /* best effort */ } }, tick };
6383
+ }
6384
+
6092
6385
  /**
6093
6386
  * Collapse an agentic disconnect/failure detail into the single short string the
6094
6387
  * marker's `agentic.message` field carries (#99 contract). Accepts the close
@@ -6587,6 +6880,17 @@ async function workAgent(req, flags) {
6587
6880
  let workChannel = null;
6588
6881
  /** @type {import('./work-buffer.mjs').BufferMonitor | null} */
6589
6882
  let bufferMonitor = null;
6883
+ // #144 liveness watchdog state. `agenticDisconnectedSince` is the epoch-ms the
6884
+ // channel last dropped (null whenever it is up or has never opened); the
6885
+ // watchdog uses it to force a full re-discovery + reopen when the client lib's
6886
+ // own reconnect fails to bring a previously-connected channel back within the
6887
+ // stale threshold. `agenticWatchdog` is the running timer handle (stopped on
6888
+ // shutdown); `agenticSelfHealing` guards against two concurrent re-discovery
6889
+ // loops (the cold-start one and a watchdog-triggered one).
6890
+ let agenticDisconnectedSince = null;
6891
+ /** @type {{ stop: () => void } | null} */
6892
+ let agenticWatchdog = null;
6893
+ let agenticSelfHealing = false;
6590
6894
  // Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
6591
6895
  // file (gated inside writeActivity) AND the agentic presence frame's live
6592
6896
  // jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
@@ -6696,11 +7000,21 @@ async function workAgent(req, flags) {
6696
7000
  // (before these listeners existed), connected() is false but everConnected()
6697
7001
  // is true — record that as `disconnected` rather than leaving it stuck at
6698
7002
  // `connecting`.
6699
- workChannel.onConnect(() => markAgentic('connected'));
6700
- workChannel.onReconnect(() => markAgentic('connected'));
6701
- workChannel.onDisconnect((info) => markAgentic('disconnected', normalizeAgenticMessage(info)));
6702
- if (workChannel.connected()) markAgentic('connected');
6703
- else if (workChannel.everConnected()) markAgentic('disconnected');
7003
+ // #144: track the drop clock alongside presence — a (re)connect clears it,
7004
+ // a disconnect starts it (first drop wins, so the watchdog measures from the
7005
+ // ORIGINAL drop, not the latest of a reconnect storm). The watchdog reads
7006
+ // this to decide when the client lib has failed to self-heal.
7007
+ workChannel.onConnect(() => { markAgentic('connected'); agenticDisconnectedSince = null; });
7008
+ workChannel.onReconnect(() => { markAgentic('connected'); agenticDisconnectedSince = null; });
7009
+ workChannel.onDisconnect((info) => {
7010
+ markAgentic('disconnected', normalizeAgenticMessage(info));
7011
+ if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
7012
+ });
7013
+ if (workChannel.connected()) { markAgentic('connected'); agenticDisconnectedSince = null; }
7014
+ else if (workChannel.everConnected()) {
7015
+ markAgentic('disconnected');
7016
+ if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
7017
+ }
6704
7018
  } catch (err) {
6705
7019
  // Never let a channel failure stop the worker from doing its actual job.
6706
7020
  workChannel = null;
@@ -6732,16 +7046,16 @@ async function workAgent(req, flags) {
6732
7046
  }
6733
7047
  };
6734
7048
 
6735
- if (agenticCfg) {
6736
- await openAgenticChannel(agenticCfg);
6737
- } else if (agenticTarget.status === 'advisory') {
6738
- // (A) Self-heal a cold-start discovery miss (#133): discovery is one-shot at
6739
- // enrolment, so a worker that merely lost the cold-start race (e.g. a slow
6740
- // link-local candidate blew the budget) would otherwise run `advisory` for
6741
- // its whole lifetime the only recovery being a restart. Keep re-discovering
6742
- // in the background on a jittered backoff and, on a later success, upgrade
6743
- // advisory→connected WITHOUT a restart, flipping the AGENTIC status surface.
6744
- // A shared cache lets a brief blip reuse the last known-good hub (#133-C).
7049
+ // (A) Background self-heal loop, shared by the cold-start advisory path (#133)
7050
+ // AND the #144 liveness watchdog. Re-run discovery on a jittered backoff and,
7051
+ // on the first `connect` target, (re)open the channel WITHOUT a restart. The
7052
+ // `agenticSelfHealing` guard makes it idempotent: the watchdog can call it
7053
+ // after tearing a stale channel down without racing a still-running cold-start
7054
+ // loop. A shared cache lets a brief blip reuse the last known-good hub (#133-C).
7055
+ const armAgenticSelfHeal = () => {
7056
+ if (agenticSelfHealing) return; // a re-discovery loop is already running
7057
+ if (workChannel !== null) return; // a channel already exists nothing to heal
7058
+ agenticSelfHealing = true;
6745
7059
  const hubCache = new Map();
6746
7060
  rediscoverAgenticUntilConnected({
6747
7061
  resolveTarget: () => resolveAgenticTarget({ camunda, logger, cache: hubCache }),
@@ -6749,7 +7063,7 @@ async function workAgent(req, flags) {
6749
7063
  agenticCfg = target.config;
6750
7064
  agenticState = agenticStateForTarget(target, safeAgenticDisplayUrl);
6751
7065
  writeActivity();
6752
- logger.info(' agentic channel: background re-discovery succeeded — upgrading advisory → connecting.');
7066
+ logger.info(' agentic channel: background re-discovery succeeded — (re)opening channel.');
6753
7067
  await openAgenticChannel(agenticCfg);
6754
7068
  // openAgenticChannel swallows its own open failures (it nulls
6755
7069
  // workChannel and returns rather than throwing), so a failed open must
@@ -6762,7 +7076,57 @@ async function workAgent(req, flags) {
6762
7076
  // Stop as soon as a channel exists (loop won this or a prior attempt did).
6763
7077
  shouldContinue: () => workChannel === null,
6764
7078
  logger,
6765
- }).catch(() => { /* best-effort self-heal — never surfaces an error */ });
7079
+ })
7080
+ .catch(() => { /* best-effort self-heal — never surfaces an error */ })
7081
+ .finally(() => { agenticSelfHealing = false; });
7082
+ };
7083
+
7084
+ // (B) #144 liveness watchdog: force-heal a wedged channel. When a channel that
7085
+ // HAS connected drops and the client lib's own reconnect never brings it back
7086
+ // within the stale threshold (a half-open drop after a server restart/crash/
7087
+ // partition, or a reconnect that keeps failing), the client sits `disconnected`
7088
+ // forever and the worker vanishes from the Workers view until a supervisor
7089
+ // restart. This tears the wedged channel down (so `shouldContinue` re-arms) and
7090
+ // re-runs full discovery + reopen instead of trusting the client lib alone.
7091
+ const healStaleAgenticChannel = async () => {
7092
+ const stale = workChannel;
7093
+ if (!stale) return;
7094
+ workChannel = null; // re-arms armAgenticSelfHeal()'s shouldContinue gate
7095
+ agenticDisconnectedSince = null; // reset the clock; the fresh open restarts it
7096
+ try { bufferMonitor?.stop(); } catch { /* best effort */ }
7097
+ bufferMonitor = null;
7098
+ markAgentic('disconnected', 'stale channel — re-discovering hub');
7099
+ // Deregister + close the wedged client so it stops its own doomed reconnect
7100
+ // attempts and we don't leak two clients once the fresh one connects.
7101
+ try { await stale.stop('stale channel — re-discovering'); } catch { /* best effort */ }
7102
+ armAgenticSelfHeal();
7103
+ };
7104
+
7105
+ const startAgenticWatchdog = () => {
7106
+ if (agenticWatchdog) return;
7107
+ const staleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_STALE_MS, DEFAULT_AGENTIC_STALE_MS));
7108
+ const intervalMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_WATCHDOG_MS, DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS));
7109
+ agenticWatchdog = startAgenticChannelWatchdog({
7110
+ getChannel: () => workChannel,
7111
+ disconnectedSince: () => agenticDisconnectedSince,
7112
+ onStale: healStaleAgenticChannel,
7113
+ staleAfterMs,
7114
+ intervalMs,
7115
+ logger,
7116
+ });
7117
+ };
7118
+
7119
+ if (agenticCfg) {
7120
+ await openAgenticChannel(agenticCfg);
7121
+ // Guard the connected channel: if it later drops and the client lib can't
7122
+ // recover it, the watchdog forces a full re-discovery + reopen (#144).
7123
+ startAgenticWatchdog();
7124
+ } else if (agenticTarget.status === 'advisory') {
7125
+ // A cold-start discovery miss leaves the worker `advisory`; the self-heal
7126
+ // loop upgrades it to `connected` without a restart (#133), and once a
7127
+ // channel exists the watchdog keeps it alive across later drops (#144).
7128
+ armAgenticSelfHeal();
7129
+ startAgenticWatchdog();
6766
7130
  }
6767
7131
 
6768
7132
  // C3 (#42): the role's live-terminal mode — a full PTY (streamed on the relay
@@ -7052,7 +7416,7 @@ async function workAgent(req, flags) {
7052
7416
  liveRunDirs.add(resultDir);
7053
7417
  } catch { resultDir = null; resultFile = null; }
7054
7418
 
7055
- result = await runAgentJob(profile, job, {
7419
+ const runOpts = {
7056
7420
  timeoutMs: effectiveHardCapMs,
7057
7421
  idleTimeoutMs: effectiveIdleTimeoutMs,
7058
7422
  recoveryWindowMs: effectiveRecoveryWindowMs,
@@ -7083,7 +7447,42 @@ async function workAgent(req, flags) {
7083
7447
  // spying never corrupts a structured/JSON output mode.
7084
7448
  onStreamOut: stream ? (line) => logger.info(line) : undefined,
7085
7449
  onStreamErr: stream ? (line) => logger.warn(line) : undefined,
7086
- });
7450
+ };
7451
+ result = await runAgentJob(profile, job, runOpts);
7452
+
7453
+ // Gap 2 (#678): a clean run that emitted no machine-readable result gets
7454
+ // ONE bounded re-emit nudge in the same workspace, feeding back its own
7455
+ // output, before we accept an empty result. Runs before finalizeGit so
7456
+ // the workspace/result file are still live; the nudge changes no code.
7457
+ // Not gated on `resultFile`: when the temp dir/file could not be created
7458
+ // the result is recoverable only via the stdout `::nano:result::`
7459
+ // sentinel, which `resolveAgentResultWithNudge` handles directly.
7460
+ if (result.ok) {
7461
+ const { stdout, nudged, truncated } = await resolveAgentResultWithNudge({
7462
+ result,
7463
+ resultFile,
7464
+ logger,
7465
+ logPrefix: `[${jobType}] job ${job.jobKey}:`,
7466
+ rerun: (nudgeText) => runAgentJob(profile, job, {
7467
+ ...runOpts,
7468
+ nudgePayload: nudgeText,
7469
+ stream: false,
7470
+ idleTimeoutMs: Math.min(effectiveIdleTimeoutMs || NUDGE_IDLE_TIMEOUT_MS, NUDGE_IDLE_TIMEOUT_MS),
7471
+ // Cap the recovery/probe window to the same 120s idle bound: it is
7472
+ // used by createIdleLivenessMonitor as the probe window when >0, so
7473
+ // inheriting the (possibly minutes-long) `effectiveRecoveryWindowMs`
7474
+ // from runOpts would let a silent nudge run outlive the intended
7475
+ // 120s idle bound and delay convergence on a wedged second turn.
7476
+ recoveryWindowMs: Math.min(effectiveRecoveryWindowMs || NUDGE_IDLE_TIMEOUT_MS, NUDGE_IDLE_TIMEOUT_MS),
7477
+ timeoutMs: Math.min(effectiveHardCapMs || NUDGE_HARD_CAP_MS, NUDGE_HARD_CAP_MS) || NUDGE_HARD_CAP_MS,
7478
+ }),
7479
+ });
7480
+ result.stdout = stdout;
7481
+ if (nudged) {
7482
+ result.nudgedForResult = true;
7483
+ if (truncated) result.truncated = true;
7484
+ }
7485
+ }
7087
7486
 
7088
7487
  // Finalize git only when the harness succeeded — never push a
7089
7488
  // half-finished workspace.
@@ -7382,6 +7781,9 @@ async function workAgent(req, flags) {
7382
7781
  logger.info(`Received ${signal} — stopping ${list.length} worker(s)...`);
7383
7782
  if (reaperTimer) clearInterval(reaperTimer);
7384
7783
  if (runDirTimer) clearInterval(runDirTimer);
7784
+ // Stop the #144 liveness watchdog so it can't kick off a re-discovery
7785
+ // mid-teardown (which would resurrect the channel we're about to close).
7786
+ if (agenticWatchdog) { try { agenticWatchdog.stop(); } catch { /* best effort */ } agenticWatchdog = null; }
7385
7787
  const results = await Promise.all(list.map(drainWorker));
7386
7788
  const stopFailures = results.filter((ok) => !ok).length;
7387
7789
  if (stopFailures > 0) {
@@ -11751,6 +12153,8 @@ export {
11751
12153
  isLinkLocalAddress,
11752
12154
  rediscoverAgenticUntilConnected,
11753
12155
  defaultAgenticRediscoveryDelays,
12156
+ agenticChannelIsStale,
12157
+ startAgenticChannelWatchdog,
11754
12158
  };
11755
12159
  export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
11756
12160
  export {
@@ -11783,11 +12187,14 @@ export {
11783
12187
  makeSecretResolver,
11784
12188
  hostEnvSecretResolver,
11785
12189
  buildAgentPayload,
12190
+ buildAgentStdin,
11786
12191
  buildResultEnvelope,
11787
12192
  parseAgentResultObject,
11788
12193
  readAgentResultFile,
11789
12194
  parseResultFromStdout,
11790
12195
  sanitizeResultVars,
12196
+ buildResultNudgePrompt,
12197
+ resolveAgentResultWithNudge,
11791
12198
  parseEnvPairs,
11792
12199
  normalizeEnvMap,
11793
12200
  normalizeArgList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.44.8",
3
+ "version": "1.44.10",
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.8",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.8",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.8",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.8",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.8",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.8",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.8"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.10",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.10",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.10",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.10",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.10",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.10",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.10"
67
67
  }
68
68
  }
package/work-channel.mjs CHANGED
@@ -281,15 +281,25 @@ export async function createWorkChannel(opts) {
281
281
  everConnected: () => hasConnected,
282
282
  buffered: () => client.buffered,
283
283
  async stop(reason = 'worker stopped') {
284
+ // Deregister to drop presence cleanly, then ALWAYS close the socket so the
285
+ // client stops its own reconnect loop. Closing only on a deregister error
286
+ // (the old behaviour) left a successfully-deregistered client half-open and
287
+ // still reconnecting — which is exactly the wedged/duplicate-client case the
288
+ // #144 stale-channel heal path relies on stop() to end.
289
+ // deregister() is fire-and-forget but may be thenable (register() is
290
+ // treated as one above), so guard BOTH a synchronous throw and an async
291
+ // rejection — an unhandled rejection during shutdown must never escape.
284
292
  try {
285
- client.deregister(reason);
286
- } catch (err) {
287
- try {
293
+ Promise.resolve(client.deregister(reason)).catch((err) => {
288
294
  log.warn?.(`agentic deregister failed: ${err?.message || err}`);
289
- client.close();
290
- } catch {
291
- /* best effort never let shutdown hang on the channel */
292
- }
295
+ });
296
+ } catch (err) {
297
+ log.warn?.(`agentic deregister failed: ${err?.message || err}`);
298
+ }
299
+ try {
300
+ client.close();
301
+ } catch {
302
+ /* best effort — never let shutdown hang on the channel */
293
303
  }
294
304
  },
295
305
  };