c8ctl-plugin-nano 1.44.10 → 1.44.12
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 +22 -0
- package/c8ctl-plugin.js +314 -19
- package/package.json +8 -8
- package/work-relay.mjs +92 -2
package/README.md
CHANGED
|
@@ -377,6 +377,28 @@ export NANO_AGENTIC_STALE_MS=60000 # force re-discovery if a drop hasn't rec
|
|
|
377
377
|
export NANO_AGENTIC_WATCHDOG_MS=15000 # how often the watchdog checks channel liveness (default)
|
|
378
378
|
```
|
|
379
379
|
|
|
380
|
+
**Lossy-link hardening (reconnect churn that never re-lands presence).** A
|
|
381
|
+
distinct failure mode shows up on a **lossy/roaming WiFi (or NAT) link**: the drop
|
|
382
|
+
*is* detected (an abnormal closure, code `1006`), the client *does* reconnect and
|
|
383
|
+
re-announce — yet presence never re-lands on the hub, so the worker stays absent
|
|
384
|
+
even though its log shows it re-announcing. The sustained-drop watchdog above does
|
|
385
|
+
not catch this, because each brief reconnect keeps resetting its drop clock. Two
|
|
386
|
+
extra safeguards close the gap:
|
|
387
|
+
|
|
388
|
+
- **Presence-keyed watchdog trigger.** A reconnect only counts as recovered once
|
|
389
|
+
the socket *holds* for a short grace window (so a `1006` blip that immediately
|
|
390
|
+
re-drops does not mask an unrecovered presence). When presence has not been
|
|
391
|
+
confirmed within a threshold — regardless of the reconnect flapping — the
|
|
392
|
+
watchdog forces the same full re-discovery + reopen.
|
|
393
|
+
- **Jittered reconnect backoff.** Reconnect attempts are spread with equal-jitter
|
|
394
|
+
backoff so a fleet dropped on the same link does not reconnect in lockstep and
|
|
395
|
+
re-congest it.
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
export NANO_AGENTIC_PRESENCE_STALE_MS=60000 # force re-discovery if presence isn't re-confirmed within 60s (default)
|
|
399
|
+
export NANO_AGENTIC_PRESENCE_GRACE_MS=5000 # how long a reconnect must hold before presence counts as landed (default)
|
|
400
|
+
```
|
|
401
|
+
|
|
380
402
|
**Secure mode (opt-in).** For a deployment where you want the visibility channel
|
|
381
403
|
authenticated (rather than open on the LAN), start the server **and** every worker
|
|
382
404
|
box with the **same** `NANO_AGENTIC_SECRET` — same env-var name, same value on both
|
package/c8ctl-plugin.js
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
import { createConnection, createServer } from 'node:net';
|
|
54
54
|
import * as nodeNet from 'node:net';
|
|
55
55
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
56
|
+
import * as nodeDns from 'node:dns';
|
|
56
57
|
import { randomUUID, createHash, randomBytes } from 'node:crypto';
|
|
57
58
|
import { homedir, platform as osPlatform, devNull, tmpdir, hostname } from 'node:os';
|
|
58
59
|
import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from 'node:path';
|
|
@@ -5463,7 +5464,11 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5463
5464
|
return Promise.resolve({ ok: false, exitCode: null, stdout: '', stderr: '', error: 'command-line args (--arg) are not supported for host execution on Windows; use a container sandbox or bake switches into the command', truncated: false, stderrTruncated: false });
|
|
5464
5465
|
}
|
|
5465
5466
|
const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
|
|
5466
|
-
|
|
5467
|
+
// Propagate IPv4-first DNS ordering into the forked agent via NODE_OPTIONS
|
|
5468
|
+
// (jwulf/c8ctl-plugin-nano#151): the parent's process-wide setDefaultResultOrder
|
|
5469
|
+
// can't reach the child, so its own Node runtime must read the flag at startup.
|
|
5470
|
+
// Merges (never clobbers) any inherited/operator NODE_OPTIONS.
|
|
5471
|
+
const harnessEnv = withIpv4FirstNodeOptions({ ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv });
|
|
5467
5472
|
|
|
5468
5473
|
// A role opted into ACP (`protocol: acp`) drives its harness over the Agent
|
|
5469
5474
|
// Client Protocol (JSON-RPC 2.0 over stdio) instead of the stdin/scrape pipe
|
|
@@ -5566,6 +5571,16 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5566
5571
|
for (const n of passThroughSecretNames) envArgs.push('-e', n);
|
|
5567
5572
|
for (const k of Object.keys(staticEnv)) envArgs.push('-e', k);
|
|
5568
5573
|
|
|
5574
|
+
// Propagate IPv4-first DNS ordering into the containerised agent's Node runtime
|
|
5575
|
+
// via NODE_OPTIONS (jwulf/c8ctl-plugin-nano#151), merged (never clobbered) with
|
|
5576
|
+
// any inherited/operator value. Forwarded by NAME like every other env so the
|
|
5577
|
+
// value stays out of argv/`docker inspect`; `-e NODE_OPTIONS` is added only when
|
|
5578
|
+
// it wasn't already forwarded via the static env above (avoid a duplicate flag).
|
|
5579
|
+
const containerEnv = withIpv4FirstNodeOptions({ ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv });
|
|
5580
|
+
if (!Object.prototype.hasOwnProperty.call(staticEnv, 'NODE_OPTIONS')) {
|
|
5581
|
+
envArgs.push('-e', 'NODE_OPTIONS');
|
|
5582
|
+
}
|
|
5583
|
+
|
|
5569
5584
|
const args = [
|
|
5570
5585
|
'run', '--rm', '-i',
|
|
5571
5586
|
'--name', containerName,
|
|
@@ -5588,7 +5603,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
5588
5603
|
// Reserved harness env (AGENT_* + the result-file path) is layered AFTER
|
|
5589
5604
|
// resolved secrets so a task-supplied secret NAME can never shadow it. In
|
|
5590
5605
|
// container mode docker reads these values from our child env by NAME.
|
|
5591
|
-
env:
|
|
5606
|
+
env: containerEnv,
|
|
5592
5607
|
stdinData: payload,
|
|
5593
5608
|
timeoutMs,
|
|
5594
5609
|
idleTimeoutMs,
|
|
@@ -6259,6 +6274,20 @@ async function rediscoverAgenticUntilConnected({
|
|
|
6259
6274
|
// client lib alone. Overridable via NANO_AGENTIC_STALE_MS / NANO_AGENTIC_WATCHDOG_MS.
|
|
6260
6275
|
const DEFAULT_AGENTIC_STALE_MS = 60_000;
|
|
6261
6276
|
const DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS = 15_000;
|
|
6277
|
+
// #147 presence-keyed staleness. #144's stale trigger keys on a SUSTAINED socket
|
|
6278
|
+
// drop (`disconnectedSince` ages past the threshold). On a lossy WiFi/NAT link
|
|
6279
|
+
// that drops `1006` repeatedly, the client lib reconnects and re-announces, but
|
|
6280
|
+
// each brief reconnect clears the drop clock — so the sustained-drop trigger
|
|
6281
|
+
// never fires even though presence never actually re-lands on the hub (the
|
|
6282
|
+
// worker stays absent from the Workers view). The presence-keyed trigger closes
|
|
6283
|
+
// that gap: a connection only counts as "presence confirmed" once it HOLDS for
|
|
6284
|
+
// the grace window, so a reconnect that immediately re-drops cannot mask an
|
|
6285
|
+
// unrecovered presence. When presence has not been confirmed within the stale
|
|
6286
|
+
// threshold — regardless of transient reconnect flapping — the watchdog forces a
|
|
6287
|
+
// full re-discovery + reopen. Overridable via NANO_AGENTIC_PRESENCE_STALE_MS /
|
|
6288
|
+
// NANO_AGENTIC_PRESENCE_GRACE_MS.
|
|
6289
|
+
const DEFAULT_AGENTIC_PRESENCE_STALE_MS = DEFAULT_AGENTIC_STALE_MS;
|
|
6290
|
+
const DEFAULT_AGENTIC_PRESENCE_GRACE_MS = 5_000;
|
|
6262
6291
|
|
|
6263
6292
|
/**
|
|
6264
6293
|
* Decide whether a worker's agentic channel is *stale* — i.e. it once connected,
|
|
@@ -6293,6 +6322,78 @@ function agenticChannelIsStale({
|
|
|
6293
6322
|
return now() - since >= staleAfterMs;
|
|
6294
6323
|
}
|
|
6295
6324
|
|
|
6325
|
+
/**
|
|
6326
|
+
* Decide whether a worker's agentic channel is *presence-stale* (#147) — i.e. it
|
|
6327
|
+
* once connected but its presence has NOT been re-confirmed on the hub within
|
|
6328
|
+
* `staleAfterMs`, even though the socket may be intermittently reconnecting. This
|
|
6329
|
+
* is the churn counterpart to {@link agenticChannelIsStale}: on a lossy link the
|
|
6330
|
+
* client lib reconnects and re-announces after every `1006`, which keeps clearing
|
|
6331
|
+
* the sustained-drop clock, so the socket-keyed trigger never fires — yet
|
|
6332
|
+
* presence never actually lands. `presenceHealthySince` is the epoch-ms of the
|
|
6333
|
+
* last time the channel was observed *stably* connected long enough for presence
|
|
6334
|
+
* to be considered landed. The first open seeds it immediately (that open drains
|
|
6335
|
+
* the buffered REGISTER, so presence lands with it); thereafter it advances only
|
|
6336
|
+
* once a connection has *held* past a grace window (via `onPresenceHealthy`), so a
|
|
6337
|
+
* reconnect that immediately re-drops does not count. Pure so the trigger is
|
|
6338
|
+
* unit-testable
|
|
6339
|
+
* without timers or sockets. A channel that never opened is not presence-stale
|
|
6340
|
+
* (the initial connect owns it); a null `presenceHealthySince` (never confirmed,
|
|
6341
|
+
* or reset by a heal) also reads as not-stale so a fresh open is given its grace.
|
|
6342
|
+
*
|
|
6343
|
+
* @param {{
|
|
6344
|
+
* everConnected: () => boolean,
|
|
6345
|
+
* presenceHealthySince?: () => (number|null),
|
|
6346
|
+
* now?: () => number,
|
|
6347
|
+
* staleAfterMs?: number,
|
|
6348
|
+
* }} opts
|
|
6349
|
+
* @returns {boolean}
|
|
6350
|
+
*/
|
|
6351
|
+
function agenticPresenceIsStale({
|
|
6352
|
+
everConnected,
|
|
6353
|
+
presenceHealthySince,
|
|
6354
|
+
now = () => Date.now(),
|
|
6355
|
+
staleAfterMs = DEFAULT_AGENTIC_PRESENCE_STALE_MS,
|
|
6356
|
+
}) {
|
|
6357
|
+
if (typeof everConnected !== 'function') return false;
|
|
6358
|
+
if (!everConnected()) return false; // never opened → the initial connect owns it
|
|
6359
|
+
const since = typeof presenceHealthySince === 'function' ? presenceHealthySince() : null;
|
|
6360
|
+
if (since == null) return false; // never confirmed / freshly reset → give the open its grace
|
|
6361
|
+
return now() - since >= staleAfterMs;
|
|
6362
|
+
}
|
|
6363
|
+
|
|
6364
|
+
/**
|
|
6365
|
+
* Equal-jitter backoff (#147). Given a base backoff `baseMs`, keep half of it and
|
|
6366
|
+
* randomise the other half: `baseMs/2 + rand()*baseMs/2`. A fleet of workers that
|
|
6367
|
+
* all dropped on the same lossy link would otherwise reconnect in lockstep (the
|
|
6368
|
+
* client lib's exponential backoff is deterministic), re-congesting the link and
|
|
6369
|
+
* re-triggering the `1006` drops that stranded them. Spreading the reconnect
|
|
6370
|
+
* attempts bounds that thundering-herd churn while preserving the exponential
|
|
6371
|
+
* growth of the underlying policy. Pure (`rand` injectable) so it is testable.
|
|
6372
|
+
*
|
|
6373
|
+
* @param {number} baseMs the deterministic backoff the client lib computed
|
|
6374
|
+
* @param {{ rand?: () => number }} [opts]
|
|
6375
|
+
* @returns {number} the jittered delay in ms (0 when baseMs is non-positive)
|
|
6376
|
+
*/
|
|
6377
|
+
function jitteredDelay(baseMs, { rand = Math.random } = {}) {
|
|
6378
|
+
const b = Number.isFinite(baseMs) && baseMs > 0 ? baseMs : 0;
|
|
6379
|
+
if (b === 0) return 0;
|
|
6380
|
+
const half = b / 2;
|
|
6381
|
+
return Math.round(half + rand() * half);
|
|
6382
|
+
}
|
|
6383
|
+
|
|
6384
|
+
/**
|
|
6385
|
+
* Build a reconnect `schedule` function that applies {@link jitteredDelay} to the
|
|
6386
|
+
* backoff the client lib passes, so the worker's reconnect attempts on a lossy
|
|
6387
|
+
* link are de-synchronised (#147). Drops straight into `createWorkChannel`'s
|
|
6388
|
+
* injectable `schedule` seam; the timer is injectable for tests.
|
|
6389
|
+
*
|
|
6390
|
+
* @param {{ rand?: () => number, timer?: (fn: () => void, ms: number) => any }} [opts]
|
|
6391
|
+
* @returns {(fn: () => void, ms: number) => void}
|
|
6392
|
+
*/
|
|
6393
|
+
function makeJitteredReconnectSchedule({ rand = Math.random, timer = setTimeout } = {}) {
|
|
6394
|
+
return (fn, ms) => { timer(fn, jitteredDelay(ms, { rand })); };
|
|
6395
|
+
}
|
|
6396
|
+
|
|
6296
6397
|
/**
|
|
6297
6398
|
* Start the worker-side agentic-channel liveness watchdog (#144). On a fixed
|
|
6298
6399
|
* interval it asks {@link agenticChannelIsStale} whether the channel dropped and
|
|
@@ -6310,9 +6411,23 @@ function agenticChannelIsStale({
|
|
|
6310
6411
|
* prevent a stale-channel resurrection mid-teardown), and `tick()` runs a single
|
|
6311
6412
|
* check (tests drive it directly).
|
|
6312
6413
|
*
|
|
6414
|
+
* #147 adds a second, presence-keyed trigger alongside the #144 sustained-drop
|
|
6415
|
+
* one: each tick, a channel observed *stably* connected (connected for at least
|
|
6416
|
+
* `presenceGraceMs` since `connectedSince()`) advances the presence-health clock
|
|
6417
|
+
* via `onPresenceHealthy()`; when presence has not been confirmed within
|
|
6418
|
+
* `presenceStaleAfterMs` — even while the socket flaps `1006` reconnects — the
|
|
6419
|
+
* watchdog heals just as it does for a sustained drop. The presence accessors are
|
|
6420
|
+
* optional: omitting them leaves the #147 trigger inert, so the #144 behaviour is
|
|
6421
|
+
* unchanged.
|
|
6422
|
+
*
|
|
6313
6423
|
* @param {{
|
|
6314
6424
|
* getChannel: () => (import('./work-channel.mjs').WorkChannel | null),
|
|
6315
6425
|
* disconnectedSince: () => (number|null),
|
|
6426
|
+
* connectedSince?: () => (number|null),
|
|
6427
|
+
* presenceHealthySince?: () => (number|null),
|
|
6428
|
+
* onPresenceHealthy?: () => void,
|
|
6429
|
+
* presenceGraceMs?: number,
|
|
6430
|
+
* presenceStaleAfterMs?: number,
|
|
6316
6431
|
* onStale: () => (void|Promise<void>),
|
|
6317
6432
|
* staleAfterMs?: number,
|
|
6318
6433
|
* intervalMs?: number,
|
|
@@ -6327,6 +6442,11 @@ function startAgenticChannelWatchdog({
|
|
|
6327
6442
|
getChannel,
|
|
6328
6443
|
disconnectedSince,
|
|
6329
6444
|
onStale,
|
|
6445
|
+
connectedSince = null,
|
|
6446
|
+
presenceHealthySince = null,
|
|
6447
|
+
onPresenceHealthy = null,
|
|
6448
|
+
presenceGraceMs = DEFAULT_AGENTIC_PRESENCE_GRACE_MS,
|
|
6449
|
+
presenceStaleAfterMs = DEFAULT_AGENTIC_PRESENCE_STALE_MS,
|
|
6330
6450
|
staleAfterMs = DEFAULT_AGENTIC_STALE_MS,
|
|
6331
6451
|
intervalMs = DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS,
|
|
6332
6452
|
now = () => Date.now(),
|
|
@@ -6347,17 +6467,39 @@ function startAgenticChannelWatchdog({
|
|
|
6347
6467
|
const tick = async () => {
|
|
6348
6468
|
if (stopped || healing) return; // shutting down, or a heal is in flight — don't stack a second re-discovery
|
|
6349
6469
|
const ch = typeof getChannel === 'function' ? getChannel() : null;
|
|
6470
|
+
// #147: advance the presence-health clock when the channel is observed
|
|
6471
|
+
// STABLY connected (held for the grace window). A churny link that reconnects
|
|
6472
|
+
// but immediately re-drops never accrues the grace, so its health clock ages
|
|
6473
|
+
// out and the presence-keyed trigger below fires — unlike the sustained-drop
|
|
6474
|
+
// trigger, which the churn keeps resetting. Inert unless the accessors are wired.
|
|
6475
|
+
if (ch && typeof onPresenceHealthy === 'function' && typeof connectedSince === 'function') {
|
|
6476
|
+
try {
|
|
6477
|
+
const cs = connectedSince();
|
|
6478
|
+
if (ch.connected() && cs != null && now() - cs >= presenceGraceMs) onPresenceHealthy();
|
|
6479
|
+
} catch { /* best effort — never let a health probe break the tick */ }
|
|
6480
|
+
}
|
|
6481
|
+
// The channel is stale when EITHER trigger fires: #144's sustained socket
|
|
6482
|
+
// drop, or #147's presence-not-confirmed-despite-reconnect-churn.
|
|
6483
|
+
const stale = !!ch && (
|
|
6484
|
+
agenticChannelIsStale({
|
|
6485
|
+
connected: () => ch.connected(),
|
|
6486
|
+
everConnected: () => ch.everConnected(),
|
|
6487
|
+
disconnectedSince,
|
|
6488
|
+
now,
|
|
6489
|
+
staleAfterMs,
|
|
6490
|
+
})
|
|
6491
|
+
|| agenticPresenceIsStale({
|
|
6492
|
+
everConnected: () => ch.everConnected(),
|
|
6493
|
+
presenceHealthySince,
|
|
6494
|
+
now,
|
|
6495
|
+
staleAfterMs: presenceStaleAfterMs,
|
|
6496
|
+
})
|
|
6497
|
+
);
|
|
6350
6498
|
// No channel object → the initial open or the cold-start self-heal loop owns
|
|
6351
6499
|
// recovery; the watchdog only guards a channel that HAS connected and stalled.
|
|
6352
6500
|
// A missing or non-stale (healthy / recovered / still-connecting) channel also
|
|
6353
6501
|
// ends any current stale episode, so re-arm the latch for the next one.
|
|
6354
|
-
if (!
|
|
6355
|
-
connected: () => ch.connected(),
|
|
6356
|
-
everConnected: () => ch.everConnected(),
|
|
6357
|
-
disconnectedSince,
|
|
6358
|
-
now,
|
|
6359
|
-
staleAfterMs,
|
|
6360
|
-
})) {
|
|
6502
|
+
if (!stale) {
|
|
6361
6503
|
firedForEpisode = false;
|
|
6362
6504
|
return;
|
|
6363
6505
|
}
|
|
@@ -6366,9 +6508,16 @@ function startAgenticChannelWatchdog({
|
|
|
6366
6508
|
firedForEpisode = true;
|
|
6367
6509
|
try {
|
|
6368
6510
|
if (stopped) return; // shutdown raced us between the checks — do not heal
|
|
6369
|
-
const since = disconnectedSince();
|
|
6370
|
-
|
|
6371
|
-
|
|
6511
|
+
const since = typeof disconnectedSince === 'function' ? disconnectedSince() : null;
|
|
6512
|
+
// During reconnect churn `disconnectedSince` can be null (no sustained drop)
|
|
6513
|
+
// or reset by the latest blip, so it under-reports the real staleness. Fall
|
|
6514
|
+
// back to the presence-health clock — the signal we actually acted on — so
|
|
6515
|
+
// the logged age reflects how long presence has genuinely been unconfirmed.
|
|
6516
|
+
const staleSince = since != null
|
|
6517
|
+
? since
|
|
6518
|
+
: (typeof presenceHealthySince === 'function' ? presenceHealthySince() : null);
|
|
6519
|
+
const downFor = staleSince != null ? Math.round((now() - staleSince) / 1000) : '?';
|
|
6520
|
+
logger?.warn?.(` agentic channel: presence not confirmed within threshold (down ${downFor}s / reconnect churn) — forcing re-discovery (the client lib did not self-heal; likely a lossy-link 1006 churn or half-open drop).`);
|
|
6372
6521
|
await onStale?.();
|
|
6373
6522
|
} catch (err) {
|
|
6374
6523
|
firedForEpisode = false; // heal failed → re-arm so a later tick retries this episode
|
|
@@ -6521,6 +6670,86 @@ function enableEngineHappyEyeballs(opts = {}) {
|
|
|
6521
6670
|
return false;
|
|
6522
6671
|
}
|
|
6523
6672
|
|
|
6673
|
+
// The Node DNS result-order token that ranks any A (IPv4) record ahead of an
|
|
6674
|
+
// AAAA (IPv6) one, so a host advertised over mDNS (`*.local`) that answers with
|
|
6675
|
+
// an unreachable IPv6 link-local `fe80::…` FIRST is still connected over its
|
|
6676
|
+
// reachable IPv4 A record. Used both process-wide (setDefaultResultOrder) and as
|
|
6677
|
+
// the `--dns-result-order` value propagated into the forked agent's NODE_OPTIONS.
|
|
6678
|
+
const DNS_RESULT_ORDER_IPV4_FIRST = 'ipv4first';
|
|
6679
|
+
|
|
6680
|
+
/**
|
|
6681
|
+
* Prefer IPv4 whenever an A record exists, PROCESS-WIDE, by flipping Node's
|
|
6682
|
+
* default DNS result order to `ipv4first` (jwulf/c8ctl-plugin-nano#151). This is
|
|
6683
|
+
* the harness-wide *class* fix that complements the engine-client Happy-Eyeballs
|
|
6684
|
+
* enabler (#139): Happy-Eyeballs races families but can't help when the *only*
|
|
6685
|
+
* answer picked is a single dead address, and it doesn't govern ordering; forcing
|
|
6686
|
+
* `ipv4first` makes every `dns.lookup` (and therefore every outbound the worker
|
|
6687
|
+
* and any in-process client make — the SDK's `activateJobs`, the raw-`fetch`
|
|
6688
|
+
* `--auto` engine reads, agentic discovery) rank the reachable A record ahead of
|
|
6689
|
+
* a dead AAAA. Because `dns.lookup` re-resolves per connection (Node keeps no
|
|
6690
|
+
* process-wide lookup cache), a *running* worker that started against a bad
|
|
6691
|
+
* resolution also heals the moment DNS is corrected — no supervisor restart — as
|
|
6692
|
+
* the next long-poll / reconcile re-resolves under the new ordering.
|
|
6693
|
+
*
|
|
6694
|
+
* Fail-open and idempotent: a runtime without `setDefaultResultOrder` (or any
|
|
6695
|
+
* throw) is swallowed so an ordering hint — an optimisation — can never block a
|
|
6696
|
+
* worker from starting.
|
|
6697
|
+
*
|
|
6698
|
+
* @param {{ dns?: object, order?: string }} [opts] injection seam for tests
|
|
6699
|
+
* @returns {boolean} true if the default DNS result order was (re)asserted
|
|
6700
|
+
*/
|
|
6701
|
+
function preferIpv4Resolution(opts = {}) {
|
|
6702
|
+
try {
|
|
6703
|
+
// Destructure INSIDE the try so a non-object arg (e.g. `null`) fails open
|
|
6704
|
+
// like any other throw rather than blowing up before the guard.
|
|
6705
|
+
const { dns = nodeDns, order = DNS_RESULT_ORDER_IPV4_FIRST } = opts || {};
|
|
6706
|
+
if (dns && typeof dns.setDefaultResultOrder === 'function') {
|
|
6707
|
+
dns.setDefaultResultOrder(order);
|
|
6708
|
+
return true;
|
|
6709
|
+
}
|
|
6710
|
+
} catch {
|
|
6711
|
+
// Fail-open: DNS ordering is a connectivity optimisation, never a start gate.
|
|
6712
|
+
}
|
|
6713
|
+
return false;
|
|
6714
|
+
}
|
|
6715
|
+
|
|
6716
|
+
/**
|
|
6717
|
+
* Merge `--dns-result-order=ipv4first` into an existing `NODE_OPTIONS` string so
|
|
6718
|
+
* a forked agent child (and any node-based tool it spawns) inherits the same
|
|
6719
|
+
* IPv4-first ordering as its parent worker (jwulf/c8ctl-plugin-nano#151) — a
|
|
6720
|
+
* `dns.setDefaultResultOrder` call in THIS process can't reach the child, but the
|
|
6721
|
+
* child's own Node runtime reads the flag from `NODE_OPTIONS` at startup.
|
|
6722
|
+
*
|
|
6723
|
+
* Preserves any other options already in `NODE_OPTIONS` (we append, never
|
|
6724
|
+
* clobber) and, crucially, RESPECTS an operator who has *explicitly* pinned a
|
|
6725
|
+
* `--dns-result-order` (e.g. `verbatim`): we only add ours when none is present,
|
|
6726
|
+
* so we harden the default without overriding a deliberate choice.
|
|
6727
|
+
*
|
|
6728
|
+
* @param {string} [existing] the incoming NODE_OPTIONS value (may be empty)
|
|
6729
|
+
* @returns {string} the NODE_OPTIONS value with an ordering flag guaranteed
|
|
6730
|
+
*/
|
|
6731
|
+
function ipv4FirstNodeOptions(existing = '') {
|
|
6732
|
+
const flag = `--dns-result-order=${DNS_RESULT_ORDER_IPV4_FIRST}`;
|
|
6733
|
+
const cur = typeof existing === 'string' ? existing.trim() : '';
|
|
6734
|
+
// An explicit operator ordering (any `--dns-result-order=…`) wins untouched.
|
|
6735
|
+
if (/--dns-result-order[=\s]/.test(cur)) return cur;
|
|
6736
|
+
return cur ? `${cur} ${flag}` : flag;
|
|
6737
|
+
}
|
|
6738
|
+
|
|
6739
|
+
/**
|
|
6740
|
+
* Return a shallow copy of an env map whose `NODE_OPTIONS` carries the
|
|
6741
|
+
* IPv4-first ordering flag, so the forked harness inherits it (#151). Applied at
|
|
6742
|
+
* every harness spawn site (host pipe/PTY/ACP and container) so the ordering
|
|
6743
|
+
* follows the agent regardless of transport. Never mutates the input.
|
|
6744
|
+
*
|
|
6745
|
+
* @param {Record<string,string>} [env] the env map about to be handed to a child
|
|
6746
|
+
* @returns {Record<string,string>} a new env map with NODE_OPTIONS hardened
|
|
6747
|
+
*/
|
|
6748
|
+
function withIpv4FirstNodeOptions(env) {
|
|
6749
|
+
const base = env && typeof env === 'object' ? env : {};
|
|
6750
|
+
return { ...base, NODE_OPTIONS: ipv4FirstNodeOptions(base.NODE_OPTIONS) };
|
|
6751
|
+
}
|
|
6752
|
+
|
|
6524
6753
|
/**
|
|
6525
6754
|
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
6526
6755
|
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
@@ -6768,6 +6997,14 @@ async function workAgent(req, flags) {
|
|
|
6768
6997
|
// scaling the fleet to zero (jwulf/c8ctl-plugin-nano#139). Process-wide, so it
|
|
6769
6998
|
// covers both the SDK's activateJobs and the raw-fetch --auto engine reads.
|
|
6770
6999
|
enableEngineHappyEyeballs();
|
|
7000
|
+
// Prefer IPv4 whenever an A record exists (jwulf/c8ctl-plugin-nano#151): the
|
|
7001
|
+
// process-wide, class-level complement to Happy-Eyeballs. `ipv4first` ranks the
|
|
7002
|
+
// reachable A record ahead of a dead AAAA (mDNS `fe80::` link-local), so BOTH
|
|
7003
|
+
// the SDK activateJobs and the raw-fetch --auto reads resolve to it — and,
|
|
7004
|
+
// since Node re-resolves per connection, a running worker heals the moment a
|
|
7005
|
+
// bad DNS answer is corrected, without a supervisor restart. Set before the
|
|
7006
|
+
// client is created so every outbound inherits it.
|
|
7007
|
+
preferIpv4Resolution();
|
|
6771
7008
|
const camunda = globalThis.c8ctl.createClient();
|
|
6772
7009
|
|
|
6773
7010
|
// Broker REST endpoint for live linked-resource prompts (issue #63) and the
|
|
@@ -6888,6 +7125,16 @@ async function workAgent(req, flags) {
|
|
|
6888
7125
|
// shutdown); `agenticSelfHealing` guards against two concurrent re-discovery
|
|
6889
7126
|
// loops (the cold-start one and a watchdog-triggered one).
|
|
6890
7127
|
let agenticDisconnectedSince = null;
|
|
7128
|
+
// #147 presence-keyed watchdog state. `agenticConnectedSince` is the epoch-ms
|
|
7129
|
+
// the channel last (re)connected (null while down); the watchdog uses it to
|
|
7130
|
+
// require a stable connection to have held for a grace window before counting
|
|
7131
|
+
// presence as confirmed. `agenticPresenceHealthyAt` is the epoch-ms presence
|
|
7132
|
+
// was last confirmed healthy — advanced on the first connect (buffered REGISTER
|
|
7133
|
+
// drains) and by the watchdog whenever a stable connection is observed, and
|
|
7134
|
+
// reset by a heal. When it ages past the presence-stale threshold — even while
|
|
7135
|
+
// the socket flaps `1006` reconnects — the watchdog forces a re-discovery.
|
|
7136
|
+
let agenticConnectedSince = null;
|
|
7137
|
+
let agenticPresenceHealthyAt = null;
|
|
6891
7138
|
/** @type {{ stop: () => void } | null} */
|
|
6892
7139
|
let agenticWatchdog = null;
|
|
6893
7140
|
let agenticSelfHealing = false;
|
|
@@ -6985,6 +7232,11 @@ async function workAgent(req, flags) {
|
|
|
6985
7232
|
token: cfg.token,
|
|
6986
7233
|
credential: cfg.credential,
|
|
6987
7234
|
bufferCapacity: cfg.bufferCapacity,
|
|
7235
|
+
// #147: de-synchronise reconnect attempts with equal-jitter backoff so a
|
|
7236
|
+
// fleet dropped on the same lossy link does not reconnect in lockstep and
|
|
7237
|
+
// re-congest it. Wraps the client lib's own exponential policy (which has
|
|
7238
|
+
// no jitter of its own); the base delays/factor stay the lib's defaults.
|
|
7239
|
+
schedule: makeJitteredReconnectSchedule(),
|
|
6988
7240
|
logger,
|
|
6989
7241
|
});
|
|
6990
7242
|
const shown = redactAgenticUrl(buildAgenticUrl(cfg.url, {}));
|
|
@@ -7004,16 +7256,38 @@ async function workAgent(req, flags) {
|
|
|
7004
7256
|
// a disconnect starts it (first drop wins, so the watchdog measures from the
|
|
7005
7257
|
// ORIGINAL drop, not the latest of a reconnect storm). The watchdog reads
|
|
7006
7258
|
// this to decide when the client lib has failed to self-heal.
|
|
7007
|
-
workChannel.onConnect(() => {
|
|
7008
|
-
|
|
7259
|
+
workChannel.onConnect(() => {
|
|
7260
|
+
markAgentic('connected');
|
|
7261
|
+
agenticDisconnectedSince = null;
|
|
7262
|
+
agenticConnectedSince = Date.now();
|
|
7263
|
+
// First open drains the buffered REGISTER → presence lands; seed the
|
|
7264
|
+
// presence-health clock so the #147 trigger measures from here (#147).
|
|
7265
|
+
agenticPresenceHealthyAt = Date.now();
|
|
7266
|
+
});
|
|
7267
|
+
workChannel.onReconnect(() => {
|
|
7268
|
+
markAgentic('connected');
|
|
7269
|
+
agenticDisconnectedSince = null;
|
|
7270
|
+
agenticConnectedSince = Date.now();
|
|
7271
|
+
// Deliberately do NOT advance agenticPresenceHealthyAt here: a reconnect
|
|
7272
|
+
// only CLAIMS presence (re-announces). On a lossy link the socket may
|
|
7273
|
+
// re-drop `1006` before presence actually lands, so the watchdog confirms
|
|
7274
|
+
// it only once a connection HOLDS for the grace window — a reconnect that
|
|
7275
|
+
// immediately re-drops must not mask an unrecovered presence (#147).
|
|
7276
|
+
});
|
|
7009
7277
|
workChannel.onDisconnect((info) => {
|
|
7010
7278
|
markAgentic('disconnected', normalizeAgenticMessage(info));
|
|
7011
7279
|
if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
|
|
7280
|
+
agenticConnectedSince = null;
|
|
7012
7281
|
});
|
|
7013
|
-
if (workChannel.connected()) {
|
|
7014
|
-
|
|
7282
|
+
if (workChannel.connected()) {
|
|
7283
|
+
markAgentic('connected');
|
|
7284
|
+
agenticDisconnectedSince = null;
|
|
7285
|
+
agenticConnectedSince = Date.now();
|
|
7286
|
+
if (agenticPresenceHealthyAt == null) agenticPresenceHealthyAt = Date.now();
|
|
7287
|
+
} else if (workChannel.everConnected()) {
|
|
7015
7288
|
markAgentic('disconnected');
|
|
7016
7289
|
if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
|
|
7290
|
+
agenticConnectedSince = null;
|
|
7017
7291
|
}
|
|
7018
7292
|
} catch (err) {
|
|
7019
7293
|
// Never let a channel failure stop the worker from doing its actual job.
|
|
@@ -7093,6 +7367,8 @@ async function workAgent(req, flags) {
|
|
|
7093
7367
|
if (!stale) return;
|
|
7094
7368
|
workChannel = null; // re-arms armAgenticSelfHeal()'s shouldContinue gate
|
|
7095
7369
|
agenticDisconnectedSince = null; // reset the clock; the fresh open restarts it
|
|
7370
|
+
agenticConnectedSince = null; // #147: the fresh open re-seeds it
|
|
7371
|
+
agenticPresenceHealthyAt = null; // #147: the fresh open re-confirms presence
|
|
7096
7372
|
try { bufferMonitor?.stop(); } catch { /* best effort */ }
|
|
7097
7373
|
bufferMonitor = null;
|
|
7098
7374
|
markAgentic('disconnected', 'stale channel — re-discovering hub');
|
|
@@ -7106,9 +7382,18 @@ async function workAgent(req, flags) {
|
|
|
7106
7382
|
if (agenticWatchdog) return;
|
|
7107
7383
|
const staleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_STALE_MS, DEFAULT_AGENTIC_STALE_MS));
|
|
7108
7384
|
const intervalMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_WATCHDOG_MS, DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS));
|
|
7385
|
+
const presenceStaleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_STALE_MS, DEFAULT_AGENTIC_PRESENCE_STALE_MS));
|
|
7386
|
+
const presenceGraceMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_GRACE_MS, DEFAULT_AGENTIC_PRESENCE_GRACE_MS));
|
|
7109
7387
|
agenticWatchdog = startAgenticChannelWatchdog({
|
|
7110
7388
|
getChannel: () => workChannel,
|
|
7111
7389
|
disconnectedSince: () => agenticDisconnectedSince,
|
|
7390
|
+
// #147: presence-keyed trigger — heal reconnect-churn that never re-lands
|
|
7391
|
+
// presence, not just a sustained socket drop.
|
|
7392
|
+
connectedSince: () => agenticConnectedSince,
|
|
7393
|
+
presenceHealthySince: () => agenticPresenceHealthyAt,
|
|
7394
|
+
onPresenceHealthy: () => { agenticPresenceHealthyAt = Date.now(); },
|
|
7395
|
+
presenceStaleAfterMs,
|
|
7396
|
+
presenceGraceMs,
|
|
7112
7397
|
onStale: healStaleAgenticChannel,
|
|
7113
7398
|
staleAfterMs,
|
|
7114
7399
|
intervalMs,
|
|
@@ -7506,9 +7791,12 @@ async function workAgent(req, flags) {
|
|
|
7506
7791
|
if (isContainer) liveRunIds.delete(runId);
|
|
7507
7792
|
if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
7508
7793
|
if (runDir) liveRunDirs.delete(runDir);
|
|
7509
|
-
//
|
|
7510
|
-
//
|
|
7511
|
-
|
|
7794
|
+
// Emit the relay session's `phase:close` lifecycle event and drain its
|
|
7795
|
+
// outbound buffer before the job settles (so the live-terminal tail is
|
|
7796
|
+
// flushed, nanobpm/nano-workforce#710), then detach its inbound-frame
|
|
7797
|
+
// subscription so it never outlives the job or leaks a steer listener
|
|
7798
|
+
// across jobs. Bounded internally — a hub outage never wedges completion.
|
|
7799
|
+
if (relaySession) { try { await relaySession.close(); } catch { /* best effort */ } }
|
|
7512
7800
|
}
|
|
7513
7801
|
|
|
7514
7802
|
// Read the agent's structured result: the file it wrote, else a stdout
|
|
@@ -12154,6 +12442,9 @@ export {
|
|
|
12154
12442
|
rediscoverAgenticUntilConnected,
|
|
12155
12443
|
defaultAgenticRediscoveryDelays,
|
|
12156
12444
|
agenticChannelIsStale,
|
|
12445
|
+
agenticPresenceIsStale,
|
|
12446
|
+
jitteredDelay,
|
|
12447
|
+
makeJitteredReconnectSchedule,
|
|
12157
12448
|
startAgenticChannelWatchdog,
|
|
12158
12449
|
};
|
|
12159
12450
|
export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
|
|
@@ -12242,6 +12533,10 @@ export {
|
|
|
12242
12533
|
readDeployedAgentJobTypes,
|
|
12243
12534
|
resolveAutoJobTypes,
|
|
12244
12535
|
enableEngineHappyEyeballs,
|
|
12536
|
+
preferIpv4Resolution,
|
|
12537
|
+
ipv4FirstNodeOptions,
|
|
12538
|
+
withIpv4FirstNodeOptions,
|
|
12539
|
+
DNS_RESULT_ORDER_IPV4_FIRST,
|
|
12245
12540
|
workAgent,
|
|
12246
12541
|
derivePollTimeoutMs,
|
|
12247
12542
|
AGENT_TASK_NS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.44.
|
|
3
|
+
"version": "1.44.12",
|
|
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.12",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.12",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.12",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.12",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.12",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.12",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.12"
|
|
67
67
|
}
|
|
68
68
|
}
|
package/work-relay.mjs
CHANGED
|
@@ -62,6 +62,36 @@ export const RELAY_OPEN_CHUNK = `${agenticTranscript.encodeTranscriptEvent({
|
|
|
62
62
|
phase: 'open',
|
|
63
63
|
})}\n`;
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The final produce frame emitted on a relay stream the instant a session is
|
|
67
|
+
* closed: the closing twin of {@link RELAY_OPEN_CHUNK} — a canonical
|
|
68
|
+
* `@nanobpm/agentic` lifecycle "close" transcript event.
|
|
69
|
+
*
|
|
70
|
+
* It carries no agent output — its sole job is to CLOSE the `job:<jobKey>`
|
|
71
|
+
* stream so the app can flush the durable transcript deterministically at job
|
|
72
|
+
* completion (nanobpm/nano-workforce#710), instead of relying only on the
|
|
73
|
+
* supersede/disconnect fallback (which can abandon the tail frames). Without it,
|
|
74
|
+
* a completed job's live-terminal transcript is truncated at the tail. Emitted
|
|
75
|
+
* from {@link RelaySession.close} before the outbound buffer is drained, so the
|
|
76
|
+
* close marker itself rides the same buffered, QoS-ordered relay lane as the
|
|
77
|
+
* agent's output (and survives a brief hub outage via C4's ring where possible).
|
|
78
|
+
* Newline-framed to match `RELAY_OPEN_CHUNK`, and derived through
|
|
79
|
+
* `encodeTranscriptEvent` (never hand-rolled) so the wire marker stays
|
|
80
|
+
* single-sourced in `@nanobpm/agentic`.
|
|
81
|
+
*/
|
|
82
|
+
export const RELAY_CLOSE_CHUNK = `${agenticTranscript.encodeTranscriptEvent({
|
|
83
|
+
kind: 'lifecycle',
|
|
84
|
+
phase: 'close',
|
|
85
|
+
})}\n`;
|
|
86
|
+
|
|
87
|
+
/** Default bound on the outbound-buffer drain at session close (ms). A hub
|
|
88
|
+
* outage must never wedge job completion, so the drain is always bounded: on
|
|
89
|
+
* timeout the caller completes anyway and the app's supersede/disconnect
|
|
90
|
+
* fallback still eventually flushes whatever arrived. */
|
|
91
|
+
export const DEFAULT_DRAIN_TIMEOUT_MS = 2_000;
|
|
92
|
+
/** Default poll cadence while awaiting `channel.buffered() → 0` (ms). */
|
|
93
|
+
export const DEFAULT_DRAIN_POLL_MS = 25;
|
|
94
|
+
|
|
65
95
|
/**
|
|
66
96
|
* Resolve a role's terminal mode — whether the agent harness for this role gets
|
|
67
97
|
* a full PTY or a plain pipe. Honors the vocab's per-role opt-in: a role may set
|
|
@@ -113,7 +143,7 @@ export function parseInboundRelayChunk(frame, stream) {
|
|
|
113
143
|
* @property {string} stream the relay stream name (derived from the jobKey)
|
|
114
144
|
* @property {(chunk: string|Uint8Array) => void} relay publish one framed, jobKey-tagged output chunk on the relay lane
|
|
115
145
|
* @property {(write: (chunk: string) => void) => (() => void)} attachSteer wire inbound steer bytes for this stream to `write`; returns a detach fn
|
|
116
|
-
* @property {() =>
|
|
146
|
+
* @property {() => Promise<{ closeEmitted: boolean, drained: boolean, timedOut: boolean }>} close emit the `phase:close` lifecycle event, detach any steer subscription, then drain the outbound buffer (bounded)
|
|
117
147
|
*/
|
|
118
148
|
|
|
119
149
|
/**
|
|
@@ -132,9 +162,21 @@ export function parseInboundRelayChunk(frame, stream) {
|
|
|
132
162
|
* @param {import('./work-channel.mjs').WorkChannel} opts.channel the C2 channel holder (NOT re-instantiated)
|
|
133
163
|
* @param {string|number} opts.jobKey the activated job's key; tags every frame and names the stream
|
|
134
164
|
* @param {{ warn?: Function, debug?: Function }} [opts.logger]
|
|
165
|
+
* @param {number} [opts.drainTimeoutMs] bound on the close-time outbound-buffer drain (ms); a hub outage must never wedge completion
|
|
166
|
+
* @param {number} [opts.drainPollMs] poll cadence while awaiting `channel.buffered() → 0` (ms)
|
|
167
|
+
* @param {(ms: number) => Promise<void>} [opts.sleep] injectable delay (tests); defaults to a `setTimeout` promise
|
|
168
|
+
* @param {() => number} [opts.now] injectable clock (tests); defaults to `Date.now`
|
|
135
169
|
* @returns {RelaySession}
|
|
136
170
|
*/
|
|
137
|
-
export function createRelaySession({
|
|
171
|
+
export function createRelaySession({
|
|
172
|
+
channel,
|
|
173
|
+
jobKey,
|
|
174
|
+
logger,
|
|
175
|
+
drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
|
|
176
|
+
drainPollMs = DEFAULT_DRAIN_POLL_MS,
|
|
177
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
178
|
+
now = () => Date.now(),
|
|
179
|
+
} = {}) {
|
|
138
180
|
if (!channel || typeof channel.relayLane !== 'function') {
|
|
139
181
|
throw new Error('createRelaySession requires a WorkChannel with a relayLane() accessor');
|
|
140
182
|
}
|
|
@@ -207,8 +249,56 @@ export function createRelaySession({ channel, jobKey, logger } = {}) {
|
|
|
207
249
|
return detach;
|
|
208
250
|
};
|
|
209
251
|
|
|
252
|
+
// The outbound-buffer drain: poll `channel.buffered()` down to zero, bounded
|
|
253
|
+
// by drainTimeoutMs. A channel without a buffered() accessor (or one that
|
|
254
|
+
// throws) is treated as already drained — the drain must never block or crash
|
|
255
|
+
// completion. Returns whether the buffer emptied and whether we hit the bound.
|
|
256
|
+
const drain = async () => {
|
|
257
|
+
if (typeof channel.buffered !== 'function') return { drained: true, timedOut: false };
|
|
258
|
+
const deadline = now() + Math.max(0, Number(drainTimeoutMs) || 0);
|
|
259
|
+
for (;;) {
|
|
260
|
+
let pending;
|
|
261
|
+
try {
|
|
262
|
+
pending = channel.buffered();
|
|
263
|
+
} catch {
|
|
264
|
+
return { drained: true, timedOut: false };
|
|
265
|
+
}
|
|
266
|
+
if (!(Number(pending) > 0)) return { drained: true, timedOut: false };
|
|
267
|
+
if (now() >= deadline) {
|
|
268
|
+
try {
|
|
269
|
+
log.warn?.(`relay drain timed out for ${stream}: ${pending} frame(s) still buffered; completing anyway`);
|
|
270
|
+
} catch {
|
|
271
|
+
/* never let a logging failure escape the drain path */
|
|
272
|
+
}
|
|
273
|
+
return { drained: false, timedOut: true };
|
|
274
|
+
}
|
|
275
|
+
await sleep(Math.max(1, Number(drainPollMs) || 1));
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// close() is idempotent: a second call returns the same settled promise
|
|
280
|
+
// without re-emitting the close marker or re-draining.
|
|
281
|
+
let closed = false;
|
|
282
|
+
let closedPromise = Promise.resolve({ closeEmitted: false, drained: true, timedOut: false });
|
|
210
283
|
const close = () => {
|
|
284
|
+
if (closed) return closedPromise;
|
|
285
|
+
closed = true;
|
|
286
|
+
// Emit the closing lifecycle twin of RELAY_OPEN_CHUNK FIRST, so the app can
|
|
287
|
+
// flush the durable transcript deterministically at completion
|
|
288
|
+
// (nanobpm/nano-workforce#710). Routed through the internal relay(), which
|
|
289
|
+
// swallows sink errors, so emitting the close marker never fails the job. It
|
|
290
|
+
// rides the same buffered relay lane as the agent's output, and is included
|
|
291
|
+
// in the drain below.
|
|
292
|
+
relay(RELAY_CLOSE_CHUNK);
|
|
293
|
+
// Detach every steer subscription so none outlives the job.
|
|
211
294
|
for (const detach of [...activeDetaches]) detach();
|
|
295
|
+
// Then drain the outbound buffer — a bounded await until the agent's tail
|
|
296
|
+
// bytes (and the close marker) are actually transmitted before the job
|
|
297
|
+
// settles. The bound is essential: a hub outage must not wedge completion,
|
|
298
|
+
// so on timeout we resolve anyway (the app's supersede/disconnect fallback
|
|
299
|
+
// still eventually flushes what arrived).
|
|
300
|
+
closedPromise = drain().then((res) => ({ closeEmitted: true, ...res }));
|
|
301
|
+
return closedPromise;
|
|
212
302
|
};
|
|
213
303
|
|
|
214
304
|
// Open the stream the instant the session exists, so the app correlates
|