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 +17 -1
- package/c8ctl-plugin.js +428 -21
- package/package.json +8 -8
- package/work-channel.mjs +17 -7
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),
|
|
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
|
-
|
|
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
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
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
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
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 —
|
|
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
|
-
})
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
};
|