c8ctl-plugin-nano 1.44.5 → 1.44.7
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 +13 -0
- package/c8ctl-plugin.js +147 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -262,6 +262,19 @@ specialisation.
|
|
|
262
262
|
> (capability-declared) enrolment path. Use `--auto-scope <process-id | prefix>`
|
|
263
263
|
> to narrow the blast radius to one app/network.
|
|
264
264
|
|
|
265
|
+
> **Dual-stack / IPv6-first engine hosts.** The engine client races IPv4 and IPv6
|
|
266
|
+
> (Happy-Eyeballs, RFC 8305) at connect time, so a worker whose engine host
|
|
267
|
+
> resolves to an **unreachable IPv6 address first** — common with macOS mDNS
|
|
268
|
+
> (`merlin.local → fe80::… (dead) → 192.168.x.x`), a stray/misordered `AAAA`, or
|
|
269
|
+
> an IPv6-advertised host — transparently falls back to IPv4 instead of failing
|
|
270
|
+
> the activation / `--auto` engine read (`fetch failed` / `write EPIPE` /
|
|
271
|
+
> `UND_ERR_CONNECT_TIMEOUT`). Without this, the `--auto` autoscaler would read
|
|
272
|
+
> `0` job types and **silently scale the fleet to zero** (workers vanish from the
|
|
273
|
+
> Nano console) even though `curl`/`fetch` reach the same host fine
|
|
274
|
+
> (jwulf/c8ctl-plugin-nano#139). No configuration is needed; if you still want to
|
|
275
|
+
> pin a family you can set `NODE_OPTIONS=--dns-result-order=ipv4first` or point
|
|
276
|
+
> the engine URL at the IPv4 literal.
|
|
277
|
+
|
|
265
278
|
|
|
266
279
|
The optional `--name` sets **this worker's name** — the `workerName` it
|
|
267
280
|
registers under at the broker (`‹name›:‹jobType›`) and how it shows up in
|
package/c8ctl-plugin.js
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
unwatchFile,
|
|
52
52
|
} from 'node:fs';
|
|
53
53
|
import { createConnection, createServer } from 'node:net';
|
|
54
|
+
import * as nodeNet from 'node:net';
|
|
54
55
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
55
56
|
import { randomUUID, createHash, randomBytes } from 'node:crypto';
|
|
56
57
|
import { homedir, platform as osPlatform, devNull, tmpdir, hostname } from 'node:os';
|
|
@@ -4567,6 +4568,15 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4567
4568
|
// One-time warning latch for the reserved escalate/filter policies so the
|
|
4568
4569
|
// deferral is observable (not silent) but never spams a warning per request.
|
|
4569
4570
|
let interimWarned = false;
|
|
4571
|
+
// #137: count of canonical transcript chunks actually PUBLISHED to the relay's
|
|
4572
|
+
// transcript-chunk seam, plus a one-time latch for the fallback floor. Some ACP
|
|
4573
|
+
// agents (e.g. copilot 1.0.82 on some hosts) complete a turn without ever
|
|
4574
|
+
// emitting a mappable `session/update`, so `emitTranscript` never fires and the
|
|
4575
|
+
// cockpit drill-in would show an empty session. When the turn completes having
|
|
4576
|
+
// published zero chunks, we synthesise ONE structured floor chunk so the
|
|
4577
|
+
// drill-in shows the outcome instead of nothing (see `maybeEmitTranscriptFloor`).
|
|
4578
|
+
let transcriptChunksPublished = 0;
|
|
4579
|
+
let floorEmitted = false;
|
|
4570
4580
|
|
|
4571
4581
|
// Live "spy" tee (--stream), line-buffered, mirroring the other paths.
|
|
4572
4582
|
// Separate line buffers per lane (stdout-human vs stderr) so a partial line
|
|
@@ -4637,6 +4647,13 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4637
4647
|
if (idleMon) idleMon.stop();
|
|
4638
4648
|
if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
|
|
4639
4649
|
if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
|
|
4650
|
+
// #137: a turn that completed but produced no mappable session/update gets a
|
|
4651
|
+
// synthesised structured floor so the cockpit drill-in isn't empty. Only for
|
|
4652
|
+
// a resolved turn (`promptResolved`) — a handshake/spawn failure has nothing
|
|
4653
|
+
// to floor and its error is surfaced in the result envelope. Best-effort and
|
|
4654
|
+
// guarded, so it can never turn a settle into a throw. Runs before the tee is
|
|
4655
|
+
// flushed so a --stream watcher sees the floored line too.
|
|
4656
|
+
if (promptResolved) { try { maybeEmitTranscriptFloor(); } catch { /* floor best-effort */ } }
|
|
4640
4657
|
if (teeSink) { tee('', true); teeErr('', true); }
|
|
4641
4658
|
// Reap the child if it is still alive (turn resolved but agent lingering).
|
|
4642
4659
|
try { if (child && childClosed === null) killTree(child); } catch { /* best effort */ }
|
|
@@ -4774,6 +4791,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4774
4791
|
let published = false;
|
|
4775
4792
|
try { relayTap.relayTranscriptChunk(chunk); published = true; } catch { /* relay best-effort */ }
|
|
4776
4793
|
if (published) {
|
|
4794
|
+
transcriptChunksPublished++;
|
|
4777
4795
|
// Mirror the human text locally (spy tee + captured stdout) so the
|
|
4778
4796
|
// result envelope and --stream spy are unchanged — without re-emitting
|
|
4779
4797
|
// raw text onto the relay lane, which now carries the canonical chunk.
|
|
@@ -4786,6 +4804,76 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4786
4804
|
emitHuman(describeUpdate(update));
|
|
4787
4805
|
};
|
|
4788
4806
|
|
|
4807
|
+
// #137: build the human-readable text for a fallback transcript FLOOR — used
|
|
4808
|
+
// only when a turn completed having produced no mappable session/update, so
|
|
4809
|
+
// the cockpit drill-in shows the run's OUTCOME instead of an empty session.
|
|
4810
|
+
// Prefer the agent's own structured result (from $AGENT_RESULT_FILE: summary,
|
|
4811
|
+
// status, pr), then its stderr diagnostics, then a fixed explanatory note.
|
|
4812
|
+
const buildFloorText = () => {
|
|
4813
|
+
const resultPath = env && typeof env === 'object' ? env[AGENT_RESULT_FILE_ENV] : null;
|
|
4814
|
+
const res = readAgentResultFile(resultPath);
|
|
4815
|
+
if (res && typeof res === 'object') {
|
|
4816
|
+
const parts = [];
|
|
4817
|
+
if (typeof res.summary === 'string' && res.summary.trim()) parts.push(res.summary.trim());
|
|
4818
|
+
if (typeof res.status === 'string' && res.status.trim()) parts.push(`(status: ${res.status.trim()})`);
|
|
4819
|
+
if (typeof res.pr === 'string' && res.pr.trim()) parts.push(`PR: ${res.pr.trim()}`);
|
|
4820
|
+
else if (res.pr && typeof res.pr === 'object' && typeof res.pr.url === 'string' && res.pr.url.trim()) parts.push(`PR: ${res.pr.url.trim()}`);
|
|
4821
|
+
if (parts.length) return parts.join(' ');
|
|
4822
|
+
}
|
|
4823
|
+
const diag = joinCapped(stderrChunks).trim();
|
|
4824
|
+
if (diag) {
|
|
4825
|
+
// Surface the tail of the agent's stderr (its own diagnostics) as the
|
|
4826
|
+
// floor — capped so a chatty agent can't blow up a single transcript card.
|
|
4827
|
+
const FLOOR_DIAG_CAP = 2_000;
|
|
4828
|
+
const tail = diag.length > FLOOR_DIAG_CAP ? `…${diag.slice(diag.length - FLOOR_DIAG_CAP)}` : diag;
|
|
4829
|
+
return `Agent published no structured/canonical transcript content. Diagnostics:\n${tail}`;
|
|
4830
|
+
}
|
|
4831
|
+
return 'Agent completed the turn but published no structured/canonical transcript content, so no transcript messages were produced.';
|
|
4832
|
+
};
|
|
4833
|
+
|
|
4834
|
+
// #137: synthesise ONE structured transcript-chunk FLOOR when a completed turn
|
|
4835
|
+
// published zero canonical chunks. The floor rides the SAME canonical bridge
|
|
4836
|
+
// (an `agent_message_chunk` run through `acpUpdateToTranscriptChunk`) so the
|
|
4837
|
+
// cockpit renders a real assistant-message card — never a hand-rolled envelope
|
|
4838
|
+
// — and honours the acceptance's "structured/raw transcript floor" so the
|
|
4839
|
+
// drill-in shows something useful rather than an empty session. Idempotent
|
|
4840
|
+
// (one-time latch), inert unless the relay exposes the transcript-chunk seam,
|
|
4841
|
+
// and skipped entirely when any real chunk already reached the lane.
|
|
4842
|
+
const maybeEmitTranscriptFloor = () => {
|
|
4843
|
+
if (floorEmitted) return;
|
|
4844
|
+
if (transcriptChunksPublished > 0) return;
|
|
4845
|
+
if (!relayTap || typeof relayTap.relayTranscriptChunk !== 'function') return;
|
|
4846
|
+
floorEmitted = true;
|
|
4847
|
+
const text = buildFloorText();
|
|
4848
|
+
if (!text) return;
|
|
4849
|
+
const chunk = encodeTranscriptChunk({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } });
|
|
4850
|
+
if (!chunk) {
|
|
4851
|
+
// Canonical bridge couldn't produce a chunk (bridge throw or unmapped
|
|
4852
|
+
// classification) → emit the floor on the text lane rather than dropping
|
|
4853
|
+
// it, exactly like `emitTranscript`, so the cockpit transcript is never
|
|
4854
|
+
// left empty in the very failure mode this floor exists to mitigate.
|
|
4855
|
+
emitHuman(text);
|
|
4856
|
+
return;
|
|
4857
|
+
}
|
|
4858
|
+
// Only skip the text lane when the chunk publish ACTUALLY succeeded. If the
|
|
4859
|
+
// seam throws (a downstream tap implementation bug, not just the built-in
|
|
4860
|
+
// best-effort guard), the floor never reached the relay lane — so fall back
|
|
4861
|
+
// to the text lane, exactly like `emitTranscript`, or the cockpit transcript
|
|
4862
|
+
// could still be empty in that failure mode.
|
|
4863
|
+
let published = false;
|
|
4864
|
+
try { relayTap.relayTranscriptChunk(chunk); published = true; } catch { /* relay best-effort */ }
|
|
4865
|
+
if (published) {
|
|
4866
|
+
transcriptChunksPublished++;
|
|
4867
|
+
// Mirror locally (spy tee + captured stdout) so a --stream watcher and the
|
|
4868
|
+
// result envelope also reflect the floor, matching the mapped-update path.
|
|
4869
|
+
captureHuman(text);
|
|
4870
|
+
return;
|
|
4871
|
+
}
|
|
4872
|
+
// Chunk publish threw → emit the floor on the text lane (relay text + spy
|
|
4873
|
+
// tee + capture) so nothing is dropped.
|
|
4874
|
+
emitHuman(text);
|
|
4875
|
+
};
|
|
4876
|
+
|
|
4789
4877
|
const handleMessage = (msg) => {
|
|
4790
4878
|
if (!msg || typeof msg !== 'object') return;
|
|
4791
4879
|
// A response to one of OUR requests.
|
|
@@ -6045,6 +6133,58 @@ function buildActivityPayload({ pid, updatedAt, jobs, engine, agentic }) {
|
|
|
6045
6133
|
};
|
|
6046
6134
|
}
|
|
6047
6135
|
|
|
6136
|
+
/**
|
|
6137
|
+
* Enable Happy-Eyeballs (RFC 8305) dual-stack connect for the engine client so a
|
|
6138
|
+
* worker whose engine host resolves IPv6-first with an *unreachable* v6 address
|
|
6139
|
+
* (macOS mDNS `fe80::` link-local, a stray/misordered AAAA, an IPv6-advertised
|
|
6140
|
+
* host) transparently races and falls back to IPv4 — matching `curl`/`fetch` —
|
|
6141
|
+
* instead of connecting to the dead address and failing the activation / `--auto`
|
|
6142
|
+
* engine read with `fetch failed` / `write EPIPE` / `UND_ERR_CONNECT_TIMEOUT`
|
|
6143
|
+
* (jwulf/c8ctl-plugin-nano#139).
|
|
6144
|
+
*
|
|
6145
|
+
* We set Node's PROCESS-WIDE `net` default rather than an instance option so the
|
|
6146
|
+
* fix covers the *class*, not one call site: BOTH transports the worker harness
|
|
6147
|
+
* uses inherit it — the `@camunda8` SDK client's `activateJobs` (its undici
|
|
6148
|
+
* dispatcher connects via `net`) AND the raw-`fetch` `--auto` reconcile / initial
|
|
6149
|
+
* engine reads. Node ≥20 already defaults this on, but older runtimes — and any
|
|
6150
|
+
* build where the client's dispatcher inherits a `false` default — leave it off;
|
|
6151
|
+
* the reporter's repro shows the failing "SDK default path" is exactly
|
|
6152
|
+
* `setDefaultAutoSelectFamily(false)`, and asserting `true` here is what fixes it.
|
|
6153
|
+
* The attempt timeout bounds the per-address race so a dead `fe80::` yields to
|
|
6154
|
+
* IPv4 quickly (RFC 8305 default of 250ms) instead of stalling the connect.
|
|
6155
|
+
*
|
|
6156
|
+
* Fail-open and idempotent: a runtime without the API (or any throw) is swallowed
|
|
6157
|
+
* so Happy-Eyeballs — an optimisation — can never block a worker from starting.
|
|
6158
|
+
*
|
|
6159
|
+
* @param {{ net?: object, attemptTimeoutMs?: number }} [opts] injection seam for tests
|
|
6160
|
+
* @returns {boolean} true if the net default was (re)asserted to Happy-Eyeballs
|
|
6161
|
+
*/
|
|
6162
|
+
function enableEngineHappyEyeballs(opts = {}) {
|
|
6163
|
+
try {
|
|
6164
|
+
// Destructure INSIDE the try so a non-object arg (e.g. `null`) fails open
|
|
6165
|
+
// like any other throw rather than blowing up before the guard.
|
|
6166
|
+
const { net = nodeNet, attemptTimeoutMs = 250 } = opts || {};
|
|
6167
|
+
if (net && typeof net.setDefaultAutoSelectFamily === 'function') {
|
|
6168
|
+
net.setDefaultAutoSelectFamily(true);
|
|
6169
|
+
// The attempt timeout is an optional refinement: once the primary family
|
|
6170
|
+
// default is asserted the contract is satisfied, so a throw here must not
|
|
6171
|
+
// retract our `true`. Swallow it independently.
|
|
6172
|
+
try {
|
|
6173
|
+
if (typeof net.setDefaultAutoSelectFamilyAttemptTimeout === 'function'
|
|
6174
|
+
&& Number.isFinite(attemptTimeoutMs) && attemptTimeoutMs > 0) {
|
|
6175
|
+
net.setDefaultAutoSelectFamilyAttemptTimeout(attemptTimeoutMs);
|
|
6176
|
+
}
|
|
6177
|
+
} catch {
|
|
6178
|
+
// Fail-open on the optional timeout setter; the family default still holds.
|
|
6179
|
+
}
|
|
6180
|
+
return true;
|
|
6181
|
+
}
|
|
6182
|
+
} catch {
|
|
6183
|
+
// Fail-open: Happy-Eyeballs is a connectivity optimisation, never a start gate.
|
|
6184
|
+
}
|
|
6185
|
+
return false;
|
|
6186
|
+
}
|
|
6187
|
+
|
|
6048
6188
|
/**
|
|
6049
6189
|
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
6050
6190
|
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
@@ -6279,6 +6419,12 @@ async function workAgent(req, flags) {
|
|
|
6279
6419
|
logger.error('--auto-scope requires --auto (it narrows the engine-read agent job types).');
|
|
6280
6420
|
process.exit(1);
|
|
6281
6421
|
}
|
|
6422
|
+
// Enable Happy-Eyeballs (RFC 8305) at the socket layer BEFORE the SDK client
|
|
6423
|
+
// or any engine read is created, so a worker whose engine host resolves
|
|
6424
|
+
// IPv6-first with a dead v6 address falls back to IPv4 instead of silently
|
|
6425
|
+
// scaling the fleet to zero (jwulf/c8ctl-plugin-nano#139). Process-wide, so it
|
|
6426
|
+
// covers both the SDK's activateJobs and the raw-fetch --auto engine reads.
|
|
6427
|
+
enableEngineHappyEyeballs();
|
|
6282
6428
|
const camunda = globalThis.c8ctl.createClient();
|
|
6283
6429
|
|
|
6284
6430
|
// Broker REST endpoint for live linked-resource prompts (issue #63) and the
|
|
@@ -11612,6 +11758,7 @@ export {
|
|
|
11612
11758
|
serviceTaskHasAgentHeader,
|
|
11613
11759
|
readDeployedAgentJobTypes,
|
|
11614
11760
|
resolveAutoJobTypes,
|
|
11761
|
+
enableEngineHappyEyeballs,
|
|
11615
11762
|
workAgent,
|
|
11616
11763
|
derivePollTimeoutMs,
|
|
11617
11764
|
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.7",
|
|
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.7",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.7",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.7",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.7",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.7",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.7",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.7"
|
|
67
67
|
}
|
|
68
68
|
}
|