c8ctl-plugin-nano 1.44.11 → 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/c8ctl-plugin.js +115 -5
- package/package.json +8 -8
- package/work-relay.mjs +92 -2
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,
|
|
@@ -6655,6 +6670,86 @@ function enableEngineHappyEyeballs(opts = {}) {
|
|
|
6655
6670
|
return false;
|
|
6656
6671
|
}
|
|
6657
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
|
+
|
|
6658
6753
|
/**
|
|
6659
6754
|
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
6660
6755
|
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
@@ -6902,6 +6997,14 @@ async function workAgent(req, flags) {
|
|
|
6902
6997
|
// scaling the fleet to zero (jwulf/c8ctl-plugin-nano#139). Process-wide, so it
|
|
6903
6998
|
// covers both the SDK's activateJobs and the raw-fetch --auto engine reads.
|
|
6904
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();
|
|
6905
7008
|
const camunda = globalThis.c8ctl.createClient();
|
|
6906
7009
|
|
|
6907
7010
|
// Broker REST endpoint for live linked-resource prompts (issue #63) and the
|
|
@@ -7688,9 +7791,12 @@ async function workAgent(req, flags) {
|
|
|
7688
7791
|
if (isContainer) liveRunIds.delete(runId);
|
|
7689
7792
|
if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
7690
7793
|
if (runDir) liveRunDirs.delete(runDir);
|
|
7691
|
-
//
|
|
7692
|
-
//
|
|
7693
|
-
|
|
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 */ } }
|
|
7694
7800
|
}
|
|
7695
7801
|
|
|
7696
7802
|
// Read the agent's structured result: the file it wrote, else a stdout
|
|
@@ -12427,6 +12533,10 @@ export {
|
|
|
12427
12533
|
readDeployedAgentJobTypes,
|
|
12428
12534
|
resolveAutoJobTypes,
|
|
12429
12535
|
enableEngineHappyEyeballs,
|
|
12536
|
+
preferIpv4Resolution,
|
|
12537
|
+
ipv4FirstNodeOptions,
|
|
12538
|
+
withIpv4FirstNodeOptions,
|
|
12539
|
+
DNS_RESULT_ORDER_IPV4_FIRST,
|
|
12430
12540
|
workAgent,
|
|
12431
12541
|
derivePollTimeoutMs,
|
|
12432
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
|