c8ctl-plugin-nano 1.44.7 → 1.44.9

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
@@ -135,6 +135,11 @@ const READINESS_TIMEOUT_MS = 60_000;
135
135
  const READINESS_POLL_MS = 500;
136
136
  const HEALTH_TIMEOUT_MS = 1_500;
137
137
  const STOP_GRACE_MS = 8_000;
138
+ // Backoff applied when a poller fails a lease fast because the worker is already
139
+ // running another job (issue #142 single-flight). Long enough that the deferred
140
+ // job doesn't tight-loop re-activating while the first runs, short enough that it
141
+ // is picked up promptly once the worker frees up.
142
+ const WORKER_BUSY_RETRY_BACKOFF_MS = 5_000;
138
143
  // Upper bound on one `--auto` engine-read reconcile (enumerate deployed
139
144
  // definitions + fetch each BPMN). A read that stalls past this is treated as a
140
145
  // transient failure so the running poller set is KEPT and, crucially, shutdown
@@ -5164,6 +5169,44 @@ function baseAgentEnv(profile, job) {
5164
5169
  };
5165
5170
  }
5166
5171
 
5172
+ /**
5173
+ * Process-wide single-flight guard (issue #142).
5174
+ *
5175
+ * `maxParallelJobs = 1` only caps concurrency WITHIN one job-type poller, but a
5176
+ * single `work` process runs one poller per job type (rank×capability matrix, or
5177
+ * every deployed agent type under `--auto`). Without a shared gate a worker
5178
+ * serving N job types could lease and run up to N jobs at once — each holding its
5179
+ * own PTY + git workspace + broker lock-extender — the exact failure the
5180
+ * "one job per worker" invariant exists to prevent.
5181
+ *
5182
+ * This is a capacity-1, non-blocking mutex shared by EVERY per-type poller: the
5183
+ * first poller to `tryAcquire()` runs its job to completion (releasing in a
5184
+ * `finally`); any other poller that finds the permit already held must NOT begin
5185
+ * a second job (the caller fails the lease fast so the broker re-queues it rather
5186
+ * than leaving it "claimed but idle"). `tryAcquire`/`release` are synchronous
5187
+ * check-and-set, so the single-threaded event loop makes them race-free across
5188
+ * the concurrently-invoked async job handlers.
5189
+ */
5190
+ function createSingleFlight() {
5191
+ let held = false;
5192
+ return {
5193
+ /** Take the permit if free; returns false when a job is already in flight. */
5194
+ tryAcquire() {
5195
+ if (held) return false;
5196
+ held = true;
5197
+ return true;
5198
+ },
5199
+ /** Release the permit. Idempotent: redundant calls are safe no-ops, though the normal path releases once per acquire (in a `finally`). */
5200
+ release() {
5201
+ held = false;
5202
+ },
5203
+ /** True while a job holds the permit. */
5204
+ get busy() {
5205
+ return held;
5206
+ },
5207
+ };
5208
+ }
5209
+
5167
5210
  /**
5168
5211
  * Keep a leased job's broker activation lock ahead of *now* while the harness is
5169
5212
  * running, so a long agent run never has its lock lapse and get re-activated (a
@@ -6046,6 +6089,138 @@ async function rediscoverAgenticUntilConnected({
6046
6089
  return null;
6047
6090
  }
6048
6091
 
6092
+ // Worker-side liveness watchdog defaults (jwulf/c8ctl-plugin-nano#144). A
6093
+ // previously-connected agentic channel that has been `disconnected` for longer
6094
+ // than the stale threshold — because the client lib's own reconnect never
6095
+ // brought it back (e.g. a half-open drop after a server restart/crash/partition,
6096
+ // or a reconnect that keeps failing) — is force-healed: the wedged channel is
6097
+ // torn down and full discovery + reopen is re-armed, instead of trusting the
6098
+ // client lib alone. Overridable via NANO_AGENTIC_STALE_MS / NANO_AGENTIC_WATCHDOG_MS.
6099
+ const DEFAULT_AGENTIC_STALE_MS = 60_000;
6100
+ const DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS = 15_000;
6101
+
6102
+ /**
6103
+ * Decide whether a worker's agentic channel is *stale* — i.e. it once connected,
6104
+ * is no longer connected, and has stayed down past `staleAfterMs`. Pure so the
6105
+ * watchdog's trigger condition is unit-testable without timers or sockets. A
6106
+ * channel that never opened (`everConnected() === false`) is NOT stale — it is
6107
+ * still doing its first connect, which the initial open / cold-start self-heal
6108
+ * owns. `disconnectedSince` is null whenever the channel is up (or never went
6109
+ * down), which also reads as not-stale.
6110
+ *
6111
+ * @param {{
6112
+ * connected: () => boolean,
6113
+ * everConnected: () => boolean,
6114
+ * disconnectedSince: () => (number|null),
6115
+ * now?: () => number,
6116
+ * staleAfterMs?: number,
6117
+ * }} opts
6118
+ * @returns {boolean}
6119
+ */
6120
+ function agenticChannelIsStale({
6121
+ connected,
6122
+ everConnected,
6123
+ disconnectedSince,
6124
+ now = () => Date.now(),
6125
+ staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
6126
+ }) {
6127
+ if (typeof connected !== 'function' || typeof everConnected !== 'function') return false;
6128
+ if (!everConnected()) return false; // never opened → the initial connect owns it
6129
+ if (connected()) return false; // healthy
6130
+ const since = typeof disconnectedSince === 'function' ? disconnectedSince() : null;
6131
+ if (since == null) return false; // no recorded drop → nothing to heal
6132
+ return now() - since >= staleAfterMs;
6133
+ }
6134
+
6135
+ /**
6136
+ * Start the worker-side agentic-channel liveness watchdog (#144). On a fixed
6137
+ * interval it asks {@link agenticChannelIsStale} whether the channel dropped and
6138
+ * never recovered within the threshold; when it has, it fires `onStale()` (which
6139
+ * tears the wedged channel down and re-runs discovery + reopen). It fires
6140
+ * `onStale` **exactly once per stale episode** — a per-episode latch is re-armed
6141
+ * only when the channel is next observed healthy (or gone), or when a heal
6142
+ * throws (a failed recovery retries on the next tick), so a persistent stale
6143
+ * condition with a successful heal does not retrigger the heal on every tick. Re-entrancy is
6144
+ * guarded so a slow heal never overlaps a later tick. Timers, clock, and the
6145
+ * channel accessors are injectable so this is unit-testable without real waits.
6146
+ * Returns a `{ stop, tick }` handle — `stop()` clears the timer AND latches the
6147
+ * watchdog stopped so any tick already scheduled or in flight around shutdown
6148
+ * becomes a no-op before it can reach `onStale` (shutdown relies on this to
6149
+ * prevent a stale-channel resurrection mid-teardown), and `tick()` runs a single
6150
+ * check (tests drive it directly).
6151
+ *
6152
+ * @param {{
6153
+ * getChannel: () => (import('./work-channel.mjs').WorkChannel | null),
6154
+ * disconnectedSince: () => (number|null),
6155
+ * onStale: () => (void|Promise<void>),
6156
+ * staleAfterMs?: number,
6157
+ * intervalMs?: number,
6158
+ * now?: () => number,
6159
+ * setIntervalFn?: typeof setInterval,
6160
+ * clearIntervalFn?: typeof clearInterval,
6161
+ * logger?: object|null,
6162
+ * }} opts
6163
+ * @returns {{ stop: () => void, tick: () => Promise<void> }}
6164
+ */
6165
+ function startAgenticChannelWatchdog({
6166
+ getChannel,
6167
+ disconnectedSince,
6168
+ onStale,
6169
+ staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
6170
+ intervalMs = DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS,
6171
+ now = () => Date.now(),
6172
+ setIntervalFn = setInterval,
6173
+ clearIntervalFn = clearInterval,
6174
+ logger = null,
6175
+ } = {}) {
6176
+ let healing = false;
6177
+ // Set once `stop()` runs so any tick already scheduled/in flight around
6178
+ // shutdown becomes a no-op and can never re-open the channel mid-teardown
6179
+ // (shutdown relies on `stop()` to prevent a stale-channel resurrection).
6180
+ let stopped = false;
6181
+ // Per-episode latch: fire `onStale` exactly once when a connected channel goes
6182
+ // stale, and don't fire again until it recovers (a fresh episode). Without this
6183
+ // the helper would re-heal on every tick whenever `onStale` does not itself
6184
+ // clear the staleness signal, causing repeated teardown/re-discovery attempts.
6185
+ let firedForEpisode = false;
6186
+ const tick = async () => {
6187
+ if (stopped || healing) return; // shutting down, or a heal is in flight — don't stack a second re-discovery
6188
+ const ch = typeof getChannel === 'function' ? getChannel() : null;
6189
+ // No channel object → the initial open or the cold-start self-heal loop owns
6190
+ // recovery; the watchdog only guards a channel that HAS connected and stalled.
6191
+ // A missing or non-stale (healthy / recovered / still-connecting) channel also
6192
+ // 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
+ })) {
6200
+ firedForEpisode = false;
6201
+ return;
6202
+ }
6203
+ if (firedForEpisode) return; // already fired once for this stale episode
6204
+ healing = true;
6205
+ firedForEpisode = true;
6206
+ try {
6207
+ 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).`);
6211
+ await onStale?.();
6212
+ } catch (err) {
6213
+ firedForEpisode = false; // heal failed → re-arm so a later tick retries this episode
6214
+ logger?.debug?.(`agentic watchdog heal failed: ${err?.message || err}`);
6215
+ } finally {
6216
+ healing = false;
6217
+ }
6218
+ };
6219
+ const timer = setIntervalFn(() => { tick().catch(() => {}); }, intervalMs);
6220
+ if (timer && typeof timer.unref === 'function') timer.unref();
6221
+ return { stop: () => { stopped = true; try { clearIntervalFn(timer); } catch { /* best effort */ } }, tick };
6222
+ }
6223
+
6049
6224
  /**
6050
6225
  * Collapse an agentic disconnect/failure detail into the single short string the
6051
6226
  * marker's `agentic.message` field carries (#99 contract). Accepts the close
@@ -6266,6 +6441,13 @@ async function workAgent(req, flags) {
6266
6441
  // SDK derives maxJobsToActivate = maxParallelJobs - activeJobs, so 1 means
6267
6442
  // "activate one job, then stop polling until it completes".
6268
6443
  const maxParallelJobs = 1;
6444
+ // Process-wide single-flight guard (issue #142). The SDK's maxParallelJobs=1
6445
+ // only serializes ONE job-type poller, but this process runs one poller per
6446
+ // job type, so nothing stops N pollers from each leasing + running a job
6447
+ // concurrently. This capacity-1 mutex, shared by every poller's jobHandler,
6448
+ // enforces the real "one job per worker" invariant: while any job is in flight
6449
+ // on any job type, no other poller starts a second one.
6450
+ const singleFlight = createSingleFlight();
6269
6451
  // The broker job-activation lock is NOT hardcoded up front. A fixed timeout is
6270
6452
  // impossible to size for an agent: too short reclaims a still-working job (a
6271
6453
  // second agent starts + the stale complete/fail is rejected 409), too long
@@ -6465,7 +6647,7 @@ async function workAgent(req, flags) {
6465
6647
  }
6466
6648
  const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
6467
6649
  logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
6468
- logger.info(` one job per worker; recovery window: ${recoveryWindowMs}ms; idle timeout: ${idleTimeoutMs}ms; hard cap: ${hardCapMs > 0 ? `${hardCapMs}ms` : 'off'}; poll timeout: ${pollTimeoutMs}ms`);
6650
+ logger.info(` one job per worker (single-flight across all ${jobTypes.length} job type(s)); recovery window: ${recoveryWindowMs}ms; idle timeout: ${idleTimeoutMs}ms; hard cap: ${hardCapMs > 0 ? `${hardCapMs}ms` : 'off'}; poll timeout: ${pollTimeoutMs}ms`);
6469
6651
  // Warm the gh-token cache now, off the job-handling path: githubCloneToken()
6470
6652
  // may consult `gh auth token` (a synchronous spawn, up to 10s) as its default
6471
6653
  // credential fallback, and doing that inside a job handler would block the
@@ -6537,6 +6719,17 @@ async function workAgent(req, flags) {
6537
6719
  let workChannel = null;
6538
6720
  /** @type {import('./work-buffer.mjs').BufferMonitor | null} */
6539
6721
  let bufferMonitor = null;
6722
+ // #144 liveness watchdog state. `agenticDisconnectedSince` is the epoch-ms the
6723
+ // channel last dropped (null whenever it is up or has never opened); the
6724
+ // watchdog uses it to force a full re-discovery + reopen when the client lib's
6725
+ // own reconnect fails to bring a previously-connected channel back within the
6726
+ // stale threshold. `agenticWatchdog` is the running timer handle (stopped on
6727
+ // shutdown); `agenticSelfHealing` guards against two concurrent re-discovery
6728
+ // loops (the cold-start one and a watchdog-triggered one).
6729
+ let agenticDisconnectedSince = null;
6730
+ /** @type {{ stop: () => void } | null} */
6731
+ let agenticWatchdog = null;
6732
+ let agenticSelfHealing = false;
6540
6733
  // Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
6541
6734
  // file (gated inside writeActivity) AND the agentic presence frame's live
6542
6735
  // jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
@@ -6646,11 +6839,21 @@ async function workAgent(req, flags) {
6646
6839
  // (before these listeners existed), connected() is false but everConnected()
6647
6840
  // is true — record that as `disconnected` rather than leaving it stuck at
6648
6841
  // `connecting`.
6649
- workChannel.onConnect(() => markAgentic('connected'));
6650
- workChannel.onReconnect(() => markAgentic('connected'));
6651
- workChannel.onDisconnect((info) => markAgentic('disconnected', normalizeAgenticMessage(info)));
6652
- if (workChannel.connected()) markAgentic('connected');
6653
- else if (workChannel.everConnected()) markAgentic('disconnected');
6842
+ // #144: track the drop clock alongside presence — a (re)connect clears it,
6843
+ // a disconnect starts it (first drop wins, so the watchdog measures from the
6844
+ // ORIGINAL drop, not the latest of a reconnect storm). The watchdog reads
6845
+ // 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; });
6848
+ workChannel.onDisconnect((info) => {
6849
+ markAgentic('disconnected', normalizeAgenticMessage(info));
6850
+ if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
6851
+ });
6852
+ if (workChannel.connected()) { markAgentic('connected'); agenticDisconnectedSince = null; }
6853
+ else if (workChannel.everConnected()) {
6854
+ markAgentic('disconnected');
6855
+ if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
6856
+ }
6654
6857
  } catch (err) {
6655
6858
  // Never let a channel failure stop the worker from doing its actual job.
6656
6859
  workChannel = null;
@@ -6682,16 +6885,16 @@ async function workAgent(req, flags) {
6682
6885
  }
6683
6886
  };
6684
6887
 
6685
- if (agenticCfg) {
6686
- await openAgenticChannel(agenticCfg);
6687
- } else if (agenticTarget.status === 'advisory') {
6688
- // (A) Self-heal a cold-start discovery miss (#133): discovery is one-shot at
6689
- // enrolment, so a worker that merely lost the cold-start race (e.g. a slow
6690
- // link-local candidate blew the budget) would otherwise run `advisory` for
6691
- // its whole lifetime the only recovery being a restart. Keep re-discovering
6692
- // in the background on a jittered backoff and, on a later success, upgrade
6693
- // advisory→connected WITHOUT a restart, flipping the AGENTIC status surface.
6694
- // A shared cache lets a brief blip reuse the last known-good hub (#133-C).
6888
+ // (A) Background self-heal loop, shared by the cold-start advisory path (#133)
6889
+ // AND the #144 liveness watchdog. Re-run discovery on a jittered backoff and,
6890
+ // on the first `connect` target, (re)open the channel WITHOUT a restart. The
6891
+ // `agenticSelfHealing` guard makes it idempotent: the watchdog can call it
6892
+ // after tearing a stale channel down without racing a still-running cold-start
6893
+ // loop. A shared cache lets a brief blip reuse the last known-good hub (#133-C).
6894
+ const armAgenticSelfHeal = () => {
6895
+ if (agenticSelfHealing) return; // a re-discovery loop is already running
6896
+ if (workChannel !== null) return; // a channel already exists nothing to heal
6897
+ agenticSelfHealing = true;
6695
6898
  const hubCache = new Map();
6696
6899
  rediscoverAgenticUntilConnected({
6697
6900
  resolveTarget: () => resolveAgenticTarget({ camunda, logger, cache: hubCache }),
@@ -6699,7 +6902,7 @@ async function workAgent(req, flags) {
6699
6902
  agenticCfg = target.config;
6700
6903
  agenticState = agenticStateForTarget(target, safeAgenticDisplayUrl);
6701
6904
  writeActivity();
6702
- logger.info(' agentic channel: background re-discovery succeeded — upgrading advisory → connecting.');
6905
+ logger.info(' agentic channel: background re-discovery succeeded — (re)opening channel.');
6703
6906
  await openAgenticChannel(agenticCfg);
6704
6907
  // openAgenticChannel swallows its own open failures (it nulls
6705
6908
  // workChannel and returns rather than throwing), so a failed open must
@@ -6712,7 +6915,57 @@ async function workAgent(req, flags) {
6712
6915
  // Stop as soon as a channel exists (loop won this or a prior attempt did).
6713
6916
  shouldContinue: () => workChannel === null,
6714
6917
  logger,
6715
- }).catch(() => { /* best-effort self-heal — never surfaces an error */ });
6918
+ })
6919
+ .catch(() => { /* best-effort self-heal — never surfaces an error */ })
6920
+ .finally(() => { agenticSelfHealing = false; });
6921
+ };
6922
+
6923
+ // (B) #144 liveness watchdog: force-heal a wedged channel. When a channel that
6924
+ // HAS connected drops and the client lib's own reconnect never brings it back
6925
+ // within the stale threshold (a half-open drop after a server restart/crash/
6926
+ // partition, or a reconnect that keeps failing), the client sits `disconnected`
6927
+ // forever and the worker vanishes from the Workers view until a supervisor
6928
+ // restart. This tears the wedged channel down (so `shouldContinue` re-arms) and
6929
+ // re-runs full discovery + reopen instead of trusting the client lib alone.
6930
+ const healStaleAgenticChannel = async () => {
6931
+ const stale = workChannel;
6932
+ if (!stale) return;
6933
+ workChannel = null; // re-arms armAgenticSelfHeal()'s shouldContinue gate
6934
+ agenticDisconnectedSince = null; // reset the clock; the fresh open restarts it
6935
+ try { bufferMonitor?.stop(); } catch { /* best effort */ }
6936
+ bufferMonitor = null;
6937
+ markAgentic('disconnected', 'stale channel — re-discovering hub');
6938
+ // Deregister + close the wedged client so it stops its own doomed reconnect
6939
+ // attempts and we don't leak two clients once the fresh one connects.
6940
+ try { await stale.stop('stale channel — re-discovering'); } catch { /* best effort */ }
6941
+ armAgenticSelfHeal();
6942
+ };
6943
+
6944
+ const startAgenticWatchdog = () => {
6945
+ if (agenticWatchdog) return;
6946
+ const staleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_STALE_MS, DEFAULT_AGENTIC_STALE_MS));
6947
+ const intervalMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_WATCHDOG_MS, DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS));
6948
+ agenticWatchdog = startAgenticChannelWatchdog({
6949
+ getChannel: () => workChannel,
6950
+ disconnectedSince: () => agenticDisconnectedSince,
6951
+ onStale: healStaleAgenticChannel,
6952
+ staleAfterMs,
6953
+ intervalMs,
6954
+ logger,
6955
+ });
6956
+ };
6957
+
6958
+ if (agenticCfg) {
6959
+ await openAgenticChannel(agenticCfg);
6960
+ // Guard the connected channel: if it later drops and the client lib can't
6961
+ // recover it, the watchdog forces a full re-discovery + reopen (#144).
6962
+ startAgenticWatchdog();
6963
+ } else if (agenticTarget.status === 'advisory') {
6964
+ // A cold-start discovery miss leaves the worker `advisory`; the self-heal
6965
+ // loop upgrades it to `connected` without a restart (#133), and once a
6966
+ // channel exists the watchdog keeps it alive across later drops (#144).
6967
+ armAgenticSelfHeal();
6968
+ startAgenticWatchdog();
6716
6969
  }
6717
6970
 
6718
6971
  // C3 (#42): the role's live-terminal mode — a full PTY (streamed on the relay
@@ -6753,6 +7006,27 @@ async function workAgent(req, flags) {
6753
7006
  jobTimeoutMs: recoveryWindowMs,
6754
7007
  pollTimeoutMs,
6755
7008
  jobHandler: async (job) => {
7009
+ // Process-wide single-flight (issue #142): if another job is already
7010
+ // running on ANY poller, do not start a second harness. Fail this lease
7011
+ // FAST — before recording it active, extending its lock, or provisioning
7012
+ // anything — so the broker re-queues it (retries preserved) instead of it
7013
+ // sitting "claimed but idle" while the first job runs. Gating here, at the
7014
+ // point activation surfaces as a handler call, is the cross-poller gate
7015
+ // the per-type maxParallelJobs cannot provide.
7016
+ if (!singleFlight.tryAcquire()) {
7017
+ // Not a failure — preserve the broker-provided retries verbatim so
7018
+ // re-dispatch doesn't decrement (or resurrect) the job. Keep a real 0
7019
+ // as 0 (an already-incidentable job must stay that way); only default
7020
+ // to 1 when the count is missing/invalid.
7021
+ const rawRetries = Number(job.retries);
7022
+ const retries = Number.isInteger(rawRetries) && rawRetries >= 0 ? rawRetries : 1;
7023
+ logger.info(`[${jobType}] job ${job.jobKey} deferred — worker already running another job; releasing lease for re-dispatch.`);
7024
+ return job.fail({
7025
+ errorMessage: 'worker busy: one job per worker (single-flight across all job types)',
7026
+ retries,
7027
+ retryBackOff: WORKER_BUSY_RETRY_BACKOFF_MS,
7028
+ });
7029
+ }
6756
7030
  recordJobStart(job, jobType);
6757
7031
  // Auto-extend the broker lock for the whole life of this job (harness run
6758
7032
  // + git finalize + complete/fail), stopped in the outer finally. The lock
@@ -7091,6 +7365,10 @@ async function workAgent(req, flags) {
7091
7365
  } finally {
7092
7366
  stopLockExtender();
7093
7367
  recordJobEnd(job);
7368
+ // Release the process-wide single-flight permit LAST, once this job's
7369
+ // lock-extender is stopped and its bookkeeping cleared, so another
7370
+ // poller can only begin after this job is fully settled.
7371
+ singleFlight.release();
7094
7372
  }
7095
7373
  },
7096
7374
  });
@@ -7307,6 +7585,9 @@ async function workAgent(req, flags) {
7307
7585
  logger.info(`Received ${signal} — stopping ${list.length} worker(s)...`);
7308
7586
  if (reaperTimer) clearInterval(reaperTimer);
7309
7587
  if (runDirTimer) clearInterval(runDirTimer);
7588
+ // Stop the #144 liveness watchdog so it can't kick off a re-discovery
7589
+ // mid-teardown (which would resurrect the channel we're about to close).
7590
+ if (agenticWatchdog) { try { agenticWatchdog.stop(); } catch { /* best effort */ } agenticWatchdog = null; }
7310
7591
  const results = await Promise.all(list.map(drainWorker));
7311
7592
  const stopFailures = results.filter((ok) => !ok).length;
7312
7593
  if (stopFailures > 0) {
@@ -11676,6 +11957,8 @@ export {
11676
11957
  isLinkLocalAddress,
11677
11958
  rediscoverAgenticUntilConnected,
11678
11959
  defaultAgenticRediscoveryDelays,
11960
+ agenticChannelIsStale,
11961
+ startAgenticChannelWatchdog,
11679
11962
  };
11680
11963
  export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
11681
11964
  export {
@@ -11731,6 +12014,7 @@ export {
11731
12014
  parsePsTime,
11732
12015
  ensureAcpFlag,
11733
12016
  startLockExtender,
12017
+ createSingleFlight,
11734
12018
  provisionRepo,
11735
12019
  finalizeGit,
11736
12020
  describeGitFailure,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.44.7",
3
+ "version": "1.44.9",
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.7",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.7",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.7",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.7",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.7",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.7",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.7"
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"
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
  };