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 +17 -1
- package/c8ctl-plugin.js +302 -18
- 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
|
@@ -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
|
-
|
|
6650
|
-
|
|
6651
|
-
|
|
6652
|
-
|
|
6653
|
-
|
|
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
|
-
|
|
6686
|
-
|
|
6687
|
-
|
|
6688
|
-
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
|
|
6693
|
-
|
|
6694
|
-
|
|
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 —
|
|
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
|
-
})
|
|
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.
|
|
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.
|
|
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.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
|
-
|
|
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
|
};
|