c8ctl-plugin-nano 1.57.1 → 1.59.0
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/acp-transcript-producer.mjs +131 -0
- package/agentic-endpoint.mjs +80 -9
- package/c8ctl-plugin.js +79 -114
- package/package.json +11 -10
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Producer-side ACP → transcript-chunk mapping that PRESERVES message boundaries
|
|
2
|
+
// (jwulf/c8ctl-plugin-nano#206), built on top of the published shared contract
|
|
3
|
+
// from nanobpm/nano-ide#566 (@nanobpm/agentic >= 0.14.0).
|
|
4
|
+
//
|
|
5
|
+
// The problem: streaming ACP output arrives as arbitrary `agent_message_chunk`
|
|
6
|
+
// deltas whose transport boundaries are NOT message boundaries. The canonical
|
|
7
|
+
// bridge `acpUpdateToTranscriptChunk` (nanobpm/nano-ide#534) folds an ACP
|
|
8
|
+
// `session/update` into the exact transcript-chunk bytes the cockpit decodes, but
|
|
9
|
+
// it drops the ACP `messageId` that the shared classifier already extracts — so a
|
|
10
|
+
// consumer folding those chunks through the shared ordered-display derivation
|
|
11
|
+
// (`deriveDisplay`, #566) cannot tell a continuing delta of ONE message from the
|
|
12
|
+
// first delta of a NEW same-speaker message. Two distinct assistant messages
|
|
13
|
+
// emitted back-to-back would wrongly coalesce into one block; a single message
|
|
14
|
+
// split across chunks reconstructs correctly either way.
|
|
15
|
+
//
|
|
16
|
+
// This module carries the AVAILABLE producer semantics — message identity
|
|
17
|
+
// (`messageId`), role/channel and delta/snapshot mode — into the canonical
|
|
18
|
+
// additive `MessageEvent` fields the shared contract added in #566, using the
|
|
19
|
+
// SHARED classifier (`classifyUpdate`) and the SHARED canonical encoder
|
|
20
|
+
// (`encodeTranscriptEvent`). It does NOT hand-roll a parallel wire grammar,
|
|
21
|
+
// grouping implementation or heuristic sentence splitter: the marker, version,
|
|
22
|
+
// kinds and additive fields all come from the package.
|
|
23
|
+
//
|
|
24
|
+
// Fidelity contract (the documented legacy fallback):
|
|
25
|
+
// - ACP `agent_message_chunk` / `agent_thought_chunk` / `user_message_chunk`
|
|
26
|
+
// text is an incremental DELTA (never a cumulative snapshot for the supported
|
|
27
|
+
// ACP providers), so a message event is tagged `mode: "delta"` — a cumulative
|
|
28
|
+
// snapshot is NEVER emitted as an additive delta (the #566 "never append a
|
|
29
|
+
// snapshot as a delta" rule).
|
|
30
|
+
// - Where the provider exposes a `messageId`, it is carried so the display fold
|
|
31
|
+
// groups a message's fragments and separates two distinct same-speaker
|
|
32
|
+
// messages even when their transport chunks are adjacent.
|
|
33
|
+
// - Where the provider omits `messageId` (a documented ACP fidelity gap), NO
|
|
34
|
+
// identity is fabricated and NO boundary is inferred from delays/punctuation:
|
|
35
|
+
// the chunk is emitted through the canonical bridge UNCHANGED (byte-identical
|
|
36
|
+
// to the pre-#206 wire), and the display fold's adjacent-same-speaker
|
|
37
|
+
// coalescing is the legacy fallback.
|
|
38
|
+
// - Tool-call / tool-result / permission / ignored updates are delegated to the
|
|
39
|
+
// canonical bridge untouched, so tool and permission events stay correctly
|
|
40
|
+
// ordered and paired and raw replay is unchanged.
|
|
41
|
+
|
|
42
|
+
import { sessionAcp as defaultSessionAcp, transcript as defaultTranscript } from './agentic.mjs';
|
|
43
|
+
|
|
44
|
+
// Map the shared classifier's message role to a canonical `TranscriptRole`. ACP's
|
|
45
|
+
// `reasoning` (an `agent_thought_chunk`) has no distinct transcript role, so — like
|
|
46
|
+
// the canonical bridge `acpUpdateToTranscriptChunk` — it folds to `assistant`,
|
|
47
|
+
// which `deriveDisplay` renders as a message block rather than dropping to raw
|
|
48
|
+
// bytes. This mirrors the package bridge exactly so the producer never diverges
|
|
49
|
+
// from the shared role mapping.
|
|
50
|
+
function transcriptRole(acpRole) {
|
|
51
|
+
return acpRole === 'user' ? 'user' : 'assistant';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A non-empty string, else null. `messageId` is optional on the ACP update and the
|
|
55
|
+
// shared classifier already normalises it to `string | null`.
|
|
56
|
+
function nonBlankId(value) {
|
|
57
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Map one raw ACP `session/update` `update` object to the canonical transcript-chunk
|
|
62
|
+
* bytes a producer appends, carrying the available message identity / role / delta
|
|
63
|
+
* semantics into the shared additive `MessageEvent` contract (#566). Returns `null`
|
|
64
|
+
* for an update with no canonical meaning (an `ignored` classification), exactly like
|
|
65
|
+
* the underlying bridge, so a caller skips it.
|
|
66
|
+
*
|
|
67
|
+
* Pure and total: any classifier or encoder throw degrades to the canonical bridge,
|
|
68
|
+
* and a bridge throw is itself caught (yielding `null`), so the producer hot path
|
|
69
|
+
* never crashes on one malformed update.
|
|
70
|
+
*
|
|
71
|
+
* @param {unknown} update The raw ACP `session/update` `params.update` object.
|
|
72
|
+
* @param {object} [deps]
|
|
73
|
+
* @param {object} [deps.sessionAcp] The shared ACP surface (`classifyUpdate` +
|
|
74
|
+
* `acpUpdateToTranscriptChunk`); defaults to the package bridge.
|
|
75
|
+
* @param {object} [deps.transcript] The shared transcript surface
|
|
76
|
+
* (`encodeTranscriptEvent`); defaults to the package transcript module.
|
|
77
|
+
* @returns {string | null} The canonical transcript-chunk bytes, or `null`.
|
|
78
|
+
*/
|
|
79
|
+
export function acpUpdateToDisplayChunk(update, deps = {}) {
|
|
80
|
+
const sessionAcp = deps.sessionAcp || defaultSessionAcp;
|
|
81
|
+
const transcript = deps.transcript || defaultTranscript;
|
|
82
|
+
|
|
83
|
+
const classify = typeof sessionAcp?.classifyUpdate === 'function' ? sessionAcp.classifyUpdate : null;
|
|
84
|
+
const encode = typeof transcript?.encodeTranscriptEvent === 'function' ? transcript.encodeTranscriptEvent : null;
|
|
85
|
+
const bridge = typeof sessionAcp?.acpUpdateToTranscriptChunk === 'function' ? sessionAcp.acpUpdateToTranscriptChunk : null;
|
|
86
|
+
|
|
87
|
+
// Fallback to the canonical bridge output for this update. Never throws.
|
|
88
|
+
const viaBridge = () => {
|
|
89
|
+
if (!bridge) return null;
|
|
90
|
+
try { return bridge(update); }
|
|
91
|
+
catch { return null; }
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Without the shared classifier + encoder we cannot enrich the message event, so
|
|
95
|
+
// the byte-identical canonical bridge output is the only correct behaviour.
|
|
96
|
+
if (!classify || !encode) return viaBridge();
|
|
97
|
+
|
|
98
|
+
let classified;
|
|
99
|
+
try { classified = classify(update); }
|
|
100
|
+
catch { return viaBridge(); }
|
|
101
|
+
|
|
102
|
+
// Only message chunks carry identity/boundary semantics worth enriching. Every
|
|
103
|
+
// other classification (tool-call, tool-result, ignored) is delegated to the
|
|
104
|
+
// canonical bridge UNCHANGED — tool/permission ordering and raw replay untouched.
|
|
105
|
+
if (!classified || classified.kind !== 'message') return viaBridge();
|
|
106
|
+
|
|
107
|
+
const messageId = nonBlankId(classified.messageId);
|
|
108
|
+
|
|
109
|
+
// No provider-supplied identity → do NOT fabricate one or infer a boundary.
|
|
110
|
+
// Emit through the canonical bridge unchanged (byte-identical to the pre-#206
|
|
111
|
+
// wire) and let the display fold's adjacent-same-speaker coalescing be the
|
|
112
|
+
// documented legacy fallback.
|
|
113
|
+
if (messageId === null) return viaBridge();
|
|
114
|
+
|
|
115
|
+
// Carry the available semantics into the additive `MessageEvent` fields: the
|
|
116
|
+
// producer identity (`messageId`) so the fold groups this message's fragments and
|
|
117
|
+
// separates distinct same-speaker messages, and `mode: "delta"` because ACP
|
|
118
|
+
// message chunks are incremental deltas — never a cumulative snapshot. No `offset`
|
|
119
|
+
// is supplied here; the real store offset is assigned on append (matching every
|
|
120
|
+
// other `encodeTranscriptEvent` call site).
|
|
121
|
+
const event = {
|
|
122
|
+
kind: 'message',
|
|
123
|
+
role: transcriptRole(classified.role),
|
|
124
|
+
text: classified.text,
|
|
125
|
+
messageId,
|
|
126
|
+
mode: 'delta',
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
try { return encode(event); }
|
|
130
|
+
catch { return viaBridge(); }
|
|
131
|
+
}
|
package/agentic-endpoint.mjs
CHANGED
|
@@ -305,7 +305,10 @@ function buildRawEmitClient({ channelUrl, transportFactory, peerAdvertisement, l
|
|
|
305
305
|
* `transportFactory` and never touches the real client.
|
|
306
306
|
*
|
|
307
307
|
* @param {object} opts
|
|
308
|
-
* @param {string} opts.url the app's HTTP(S) base URL (channel served same-port at
|
|
308
|
+
* @param {string} [opts.url] the app's HTTP(S) base URL (channel served same-port at
|
|
309
|
+
* `/agentic`). OPTIONAL in self-heal mode: when `resolveConfig` is supplied the base
|
|
310
|
+
* URL is not fixed, but a static `url` may still be passed to SEED the initial memo
|
|
311
|
+
* before the first re-discovery lands. Required only when `resolveConfig` is absent.
|
|
309
312
|
* @param {string} [opts.token] ADR 0028 identity token (carried as `?token=`)
|
|
310
313
|
* @param {string} [opts.credential] capability credential (carried as `?capability=`)
|
|
311
314
|
* @param {import('@nanobpm/agentic/protocol').ProtocolAdvertisement} [opts.remoteAdvertisement]
|
|
@@ -314,23 +317,91 @@ function buildRawEmitClient({ channelUrl, transportFactory, peerAdvertisement, l
|
|
|
314
317
|
* @param {(state: 'connected'|'disconnected') => void} [opts.onConnectionState] observer fired
|
|
315
318
|
* when the single host connection opens/drops, so a caller can track its liveness
|
|
316
319
|
* @param {{ warn?: Function, debug?: Function }} [opts.logger]
|
|
317
|
-
* @
|
|
320
|
+
* @param {() => Promise<{url:string, token?:string, credential?:string}|null>} [opts.resolveConfig]
|
|
321
|
+
* OPTIONAL self-heal resolver (jwulf/c8ctl-plugin-nano#133). When present the
|
|
322
|
+
* base URL is NOT fixed: each (re)connect consults a background-refreshed,
|
|
323
|
+
* memoised config, and while none is known the synchronous factory THROWS —
|
|
324
|
+
* which `superviseAgentic` turns into its jittered, ≤30s-capped reconnect
|
|
325
|
+
* retry — and kicks a guarded async re-discovery, so a later retry finds the
|
|
326
|
+
* hub and connects (`advisory → connected` without a restart). A moved hub
|
|
327
|
+
* self-heals the same way. Fail-open: a throwing resolver is swallowed.
|
|
328
|
+
* @returns {Promise<() => import('./supervisor.dist.js').RawEmitClient>} a synchronous
|
|
329
|
+
* `connect` factory. In self-heal (`resolveConfig`) mode it may THROW when the
|
|
330
|
+
* hub is not yet discoverable — by contract that drives `superviseAgentic`'s retry.
|
|
318
331
|
*/
|
|
319
332
|
export async function createRawEmitConnect(opts) {
|
|
320
|
-
const { url, token, credential, remoteAdvertisement, transportFactory, logger, onConnectionState } =
|
|
321
|
-
|
|
322
|
-
|
|
333
|
+
const { url, token, credential, remoteAdvertisement, transportFactory, logger, onConnectionState, resolveConfig } =
|
|
334
|
+
opts || {};
|
|
335
|
+
const hasResolver = typeof resolveConfig === 'function';
|
|
336
|
+
const hasStaticUrl = typeof url === 'string' && url.trim() !== '';
|
|
337
|
+
if (!hasResolver && !hasStaticUrl) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
'createRawEmitConnect requires an agentic channel base `url`, or a `resolveConfig` self-heal resolver to discover one',
|
|
340
|
+
);
|
|
323
341
|
}
|
|
324
342
|
|
|
325
|
-
const channelUrl = buildAgenticUrl(url, { token, credential });
|
|
326
343
|
const factory = transportFactory ?? (await loadAgenticClient()).websocketTransport;
|
|
327
344
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
345
|
+
// Static mode: a fixed, known URL (explicit NANO_AGENTIC_URL or an
|
|
346
|
+
// already-discovered hub). The channel URL is built once; the emit client owns
|
|
347
|
+
// reconnect to it and `superviseAgentic` re-`connect`s to the SAME URL on a
|
|
348
|
+
// permanent close. Unchanged behaviour.
|
|
349
|
+
if (!hasResolver) {
|
|
350
|
+
const channelUrl = buildAgenticUrl(url, { token, credential });
|
|
351
|
+
return () =>
|
|
352
|
+
buildRawEmitClient({
|
|
353
|
+
channelUrl,
|
|
354
|
+
transportFactory: factory,
|
|
355
|
+
peerAdvertisement: remoteAdvertisement,
|
|
356
|
+
logger,
|
|
357
|
+
onConnectionState,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Self-heal mode: re-resolve discovery on each (re)connect via a guarded,
|
|
362
|
+
// background-refreshed memoised config. While unresolved the factory throws so
|
|
363
|
+
// `superviseAgentic`'s ≤30s-capped retry doubles as periodic re-discovery.
|
|
364
|
+
let current = hasStaticUrl ? { url, token, credential } : null;
|
|
365
|
+
let refreshing = false;
|
|
366
|
+
const kickRefresh = () => {
|
|
367
|
+
if (refreshing) return;
|
|
368
|
+
refreshing = true;
|
|
369
|
+
Promise.resolve()
|
|
370
|
+
.then(() => resolveConfig())
|
|
371
|
+
.then((next) => {
|
|
372
|
+
if (next && typeof next.url === 'string' && next.url.trim() !== '') {
|
|
373
|
+
// Preserve the last memoised credentials when a later refresh omits
|
|
374
|
+
// them, falling back to the initial opts only when nothing was ever
|
|
375
|
+
// discovered — so a partial refresh never clears a known-good token.
|
|
376
|
+
current = {
|
|
377
|
+
url: next.url,
|
|
378
|
+
token: next.token ?? current?.token ?? token,
|
|
379
|
+
credential: next.credential ?? current?.credential ?? credential,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
})
|
|
383
|
+
.catch((err) => {
|
|
384
|
+
logger?.debug?.(`agentic re-discovery attempt failed: ${err?.message || err}`);
|
|
385
|
+
})
|
|
386
|
+
.finally(() => {
|
|
387
|
+
refreshing = false;
|
|
388
|
+
});
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
return () => {
|
|
392
|
+
// Kick a background refresh on every (re)connect so a moved hub self-heals on
|
|
393
|
+
// the next attempt; it's a no-op while one is in flight, and only runs on a
|
|
394
|
+
// (re)connect (a steady socket never re-enters this factory).
|
|
395
|
+
kickRefresh();
|
|
396
|
+
if (!current) {
|
|
397
|
+
throw new Error('agentic hub not yet discoverable — retrying via supervised reconnect');
|
|
398
|
+
}
|
|
399
|
+
return buildRawEmitClient({
|
|
400
|
+
channelUrl: buildAgenticUrl(current.url, { token: current.token, credential: current.credential }),
|
|
331
401
|
transportFactory: factory,
|
|
332
402
|
peerAdvertisement: remoteAdvertisement,
|
|
333
403
|
logger,
|
|
334
404
|
onConnectionState,
|
|
335
405
|
});
|
|
406
|
+
};
|
|
336
407
|
}
|
package/c8ctl-plugin.js
CHANGED
|
@@ -73,6 +73,13 @@ import { createLogRing, resolveLogMaxBytes } from './supervisor-log-ring.mjs';
|
|
|
73
73
|
// raw ACP `session/update` to the exact transcript-chunk bytes the cockpit decodes,
|
|
74
74
|
// replacing the plugin's former hand-rolled `nwfTranscriptEvent` envelope grammar.
|
|
75
75
|
import { sessionAcp as agenticSessionAcp } from './agentic.mjs';
|
|
76
|
+
// Producer-side message-boundary preservation (jwulf/c8ctl-plugin-nano#206). Wraps
|
|
77
|
+
// the canonical bridge to carry the ACP `messageId` / role / delta semantics into
|
|
78
|
+
// the shared additive `MessageEvent` contract (nanobpm/nano-ide#566), so a consumer
|
|
79
|
+
// folding these chunks through `deriveDisplay` reconstructs transport-fragmented
|
|
80
|
+
// deltas into coherent blocks and keeps distinct same-speaker messages apart —
|
|
81
|
+
// falling back to byte-identical bridge output when the provider omits identity.
|
|
82
|
+
import { acpUpdateToDisplayChunk } from './acp-transcript-producer.mjs';
|
|
76
83
|
// Engine-native AgentInstance / AgentHistory durable-transcript producer (issue
|
|
77
84
|
// #194): mints an AgentInstance for an `external` agent job and appends each ACP
|
|
78
85
|
// turn to the engine's append-only AgentHistory via the host SDK client.
|
|
@@ -5455,20 +5462,25 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
5455
5462
|
}
|
|
5456
5463
|
};
|
|
5457
5464
|
|
|
5458
|
-
// #110 / nanobpm/nano-ide#534: map an ACP
|
|
5459
|
-
// transcript-chunk wire form via the shared
|
|
5460
|
-
// `
|
|
5461
|
-
//
|
|
5462
|
-
// `
|
|
5463
|
-
//
|
|
5465
|
+
// #110 / nanobpm/nano-ide#534 / jwulf/c8ctl-plugin-nano#206: map an ACP
|
|
5466
|
+
// session/update to the CANONICAL transcript-chunk wire form via the shared
|
|
5467
|
+
// `@nanobpm/agentic` seams. `acpUpdateToDisplayChunk` (this plugin's producer)
|
|
5468
|
+
// wraps the canonical bridge (`classifyUpdate` composed with
|
|
5469
|
+
// `encodeTranscriptEvent`) and additionally carries the ACP `messageId` / role /
|
|
5470
|
+
// delta semantics into the shared additive `MessageEvent` contract (#566) so a
|
|
5471
|
+
// consumer folding these chunks through `deriveDisplay` reconstructs
|
|
5472
|
+
// transport-fragmented deltas into coherent blocks and keeps distinct
|
|
5473
|
+
// same-speaker messages apart — degrading to byte-identical bridge output when
|
|
5474
|
+
// the provider omits identity. It returns the exact `{ nwfTranscriptEvent: 1,
|
|
5475
|
+
// kind, … }` bytes the cockpit's `parseTranscriptEvent` decodes, or `null` for an
|
|
5464
5476
|
// update with no canonical meaning (an `ignored` classification: a plan, an
|
|
5465
5477
|
// intermediate tool_call_update, a non-text chunk, or a malformed update). No
|
|
5466
|
-
// envelope grammar or vocab is hand-rolled here
|
|
5467
|
-
//
|
|
5468
|
-
// never diverge on the wire again. `null` (and any
|
|
5469
|
-
//
|
|
5478
|
+
// envelope grammar or vocab is hand-rolled here — the marker, version, kinds and
|
|
5479
|
+
// additive fields all come from the package, so a producer and a consumer can
|
|
5480
|
+
// never diverge on the wire again. `null` (and any throw) falls through to the
|
|
5481
|
+
// minimal human-text path below, so nothing is ever dropped.
|
|
5470
5482
|
const encodeTranscriptChunk = (update) => {
|
|
5471
|
-
try { return
|
|
5483
|
+
try { return acpUpdateToDisplayChunk(update, { sessionAcp: agenticSessionAcp }); }
|
|
5472
5484
|
catch { return null; }
|
|
5473
5485
|
};
|
|
5474
5486
|
|
|
@@ -6703,94 +6715,6 @@ async function resolveAgenticTarget({ camunda, cache, ...opts } = {}) {
|
|
|
6703
6715
|
};
|
|
6704
6716
|
}
|
|
6705
6717
|
|
|
6706
|
-
/**
|
|
6707
|
-
* A jittered backoff schedule (ms) for background agentic re-discovery (#133-A):
|
|
6708
|
-
* ~2s → 4s → 8s → 16s → 30s (capped), each waited value randomised ±20%, spanning
|
|
6709
|
-
* roughly `ceilingMs` (default ~5 minutes) of total elapsed retry time before the
|
|
6710
|
-
* loop gives up. A worker that lost the cold-start discovery race walks this
|
|
6711
|
-
* schedule to upgrade `advisory → connected` without a restart.
|
|
6712
|
-
* @param {{ base?: number, cap?: number, ceilingMs?: number, rng?: () => number }} [opts]
|
|
6713
|
-
* @returns {number[]} the ordered per-attempt wait durations
|
|
6714
|
-
*/
|
|
6715
|
-
function defaultAgenticRediscoveryDelays({
|
|
6716
|
-
base = 2_000,
|
|
6717
|
-
cap = 30_000,
|
|
6718
|
-
ceilingMs = 5 * 60 * 1_000,
|
|
6719
|
-
rng = Math.random,
|
|
6720
|
-
} = {}) {
|
|
6721
|
-
const delays = [];
|
|
6722
|
-
let d = base;
|
|
6723
|
-
let total = 0;
|
|
6724
|
-
while (total < ceilingMs) {
|
|
6725
|
-
const jitter = Math.round((rng() - 0.5) * 0.4 * d); // ±20%
|
|
6726
|
-
const wait = Math.max(500, d + jitter);
|
|
6727
|
-
delays.push(wait);
|
|
6728
|
-
total += wait;
|
|
6729
|
-
d = Math.min(cap, d * 2);
|
|
6730
|
-
}
|
|
6731
|
-
return delays;
|
|
6732
|
-
}
|
|
6733
|
-
|
|
6734
|
-
/**
|
|
6735
|
-
* Background self-heal loop for agentic discovery (#133-A). After an initial
|
|
6736
|
-
* cold-start miss leaves a worker in `advisory`, re-run `resolveTarget` on a
|
|
6737
|
-
* jittered backoff schedule and, on the FIRST attempt that yields a
|
|
6738
|
-
* `status:'connect'` target, invoke `onConnect(target)` and stop — so the worker
|
|
6739
|
-
* upgrades to `connected` without a restart. Fail-open: an attempt whose
|
|
6740
|
-
* `resolveTarget` throws is swallowed and the loop continues; likewise, if
|
|
6741
|
-
* `onConnect` itself throws (a transient channel-open failure), the loop keeps
|
|
6742
|
-
* re-discovering rather than stopping, so retries proceed until a connect
|
|
6743
|
-
* callback actually succeeds. The loop also stops early whenever
|
|
6744
|
-
* `shouldContinue()` returns false (e.g. a channel already came up, or the worker
|
|
6745
|
-
* is shutting down). Returns the connecting target, or `null` if the schedule was
|
|
6746
|
-
* exhausted / cancelled without a hit. Timers and the resolver are injectable so
|
|
6747
|
-
* this is unit-testable without real waits or sockets.
|
|
6748
|
-
*
|
|
6749
|
-
* @param {{
|
|
6750
|
-
* resolveTarget: () => Promise<{status:string, config?:object}>,
|
|
6751
|
-
* onConnect?: (target: {status:string, config?:object}) => (void|Promise<void>),
|
|
6752
|
-
* delaysMs?: number[],
|
|
6753
|
-
* sleep?: (ms:number) => Promise<void>,
|
|
6754
|
-
* shouldContinue?: () => boolean,
|
|
6755
|
-
* logger?: object,
|
|
6756
|
-
* }} opts
|
|
6757
|
-
* @returns {Promise<object|null>}
|
|
6758
|
-
*/
|
|
6759
|
-
async function rediscoverAgenticUntilConnected({
|
|
6760
|
-
resolveTarget,
|
|
6761
|
-
onConnect,
|
|
6762
|
-
delaysMs = defaultAgenticRediscoveryDelays(),
|
|
6763
|
-
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
6764
|
-
shouldContinue = () => true,
|
|
6765
|
-
logger = null,
|
|
6766
|
-
} = {}) {
|
|
6767
|
-
if (typeof resolveTarget !== 'function') return null;
|
|
6768
|
-
for (const delay of delaysMs) {
|
|
6769
|
-
if (!shouldContinue()) return null;
|
|
6770
|
-
try { await sleep(delay); } catch { return null; }
|
|
6771
|
-
if (!shouldContinue()) return null;
|
|
6772
|
-
let target;
|
|
6773
|
-
try {
|
|
6774
|
-
target = await resolveTarget();
|
|
6775
|
-
} catch (err) {
|
|
6776
|
-
logger?.debug?.(`agentic re-discovery attempt failed: ${err?.message || err}`);
|
|
6777
|
-
continue;
|
|
6778
|
-
}
|
|
6779
|
-
if (target && target.status === 'connect') {
|
|
6780
|
-
try {
|
|
6781
|
-
await onConnect?.(target);
|
|
6782
|
-
} catch (err) {
|
|
6783
|
-
// A transient channel-open failure must not prematurely stop self-heal:
|
|
6784
|
-
// keep re-discovering until an onConnect callback actually succeeds.
|
|
6785
|
-
logger?.debug?.(`agentic re-discovery onConnect failed, will retry: ${err?.message || err}`);
|
|
6786
|
-
continue;
|
|
6787
|
-
}
|
|
6788
|
-
return target;
|
|
6789
|
-
}
|
|
6790
|
-
}
|
|
6791
|
-
return null;
|
|
6792
|
-
}
|
|
6793
|
-
|
|
6794
6718
|
// Worker-side liveness watchdog defaults (jwulf/c8ctl-plugin-nano#144). A
|
|
6795
6719
|
// previously-connected agentic channel that has been `disconnected` for longer
|
|
6796
6720
|
// than the stale threshold — because the client lib's own reconnect never
|
|
@@ -7816,8 +7740,24 @@ async function workAgent(req, flags) {
|
|
|
7816
7740
|
// worker joins with the well-known LOCAL token and no credential; SECURE mode
|
|
7817
7741
|
// (NANO_AGENTIC_SECRET) sends a real per-peer shared secret as the identity;
|
|
7818
7742
|
// NANO_AGENTIC=off disables it (see resolveAgenticConfig).
|
|
7819
|
-
const
|
|
7743
|
+
const agenticDiscoveryCache = new Map();
|
|
7744
|
+
const agenticTarget = await resolveAgenticTarget({ camunda, logger, cache: agenticDiscoveryCache });
|
|
7820
7745
|
let agenticCfg = null;
|
|
7746
|
+
let agenticSelfHeal = false;
|
|
7747
|
+
// Self-heal resolver (jwulf/c8ctl-plugin-nano#133): re-run discovery on demand
|
|
7748
|
+
// and hand back a fresh connect config, or null while still undiscoverable. The
|
|
7749
|
+
// endpoint's connect factory calls this in the background so `superviseAgentic`'s
|
|
7750
|
+
// ≤30s-capped reconnect doubles as periodic re-discovery — an `advisory`-at-
|
|
7751
|
+
// startup worker (a cold-start discovery miss during an engine hiccup) upgrades
|
|
7752
|
+
// to `connected` without a restart when the hub reappears. The shared cache lets
|
|
7753
|
+
// a later transient miss reuse the last known-good hub (#133-C).
|
|
7754
|
+
const resolveAgenticConnectConfig = async () => {
|
|
7755
|
+
const t = await resolveAgenticTarget({ camunda, logger, cache: agenticDiscoveryCache });
|
|
7756
|
+
if (t && t.status === 'connect') {
|
|
7757
|
+
return { url: t.config.url, token: t.config.token, credential: t.config.credential };
|
|
7758
|
+
}
|
|
7759
|
+
return null;
|
|
7760
|
+
};
|
|
7821
7761
|
// buildAgenticUrl can throw on a malformed/unsupported explicit NANO_AGENTIC_URL.
|
|
7822
7762
|
// This is only the display URL for the activity marker, so compute it
|
|
7823
7763
|
// defensively: a bad URL must be recorded as a channel failure (via the
|
|
@@ -7841,10 +7781,31 @@ async function workAgent(req, flags) {
|
|
|
7841
7781
|
logger.error(` agentic visibility is ambiguous: ${agenticTarget.message}`);
|
|
7842
7782
|
process.exit(1);
|
|
7843
7783
|
break;
|
|
7844
|
-
case 'advisory':
|
|
7845
|
-
|
|
7846
|
-
|
|
7784
|
+
case 'advisory': {
|
|
7785
|
+
// Don't give up: build a SELF-HEALING endpoint whose connect re-discovers
|
|
7786
|
+
// on each of `superviseAgentic`'s ≤30s-capped reconnects, so a cold-start
|
|
7787
|
+
// discovery miss (e.g. the engine hiccuped just as the worker started)
|
|
7788
|
+
// upgrades to `connected` without a restart (jwulf/c8ctl-plugin-nano#133).
|
|
7789
|
+
agenticSelfHeal = true;
|
|
7790
|
+
// Derive one retry-aware message so the marker's `agentic.message` and the
|
|
7791
|
+
// log line agree: consumers of `supervisor status`/markers must see that the
|
|
7792
|
+
// worker WILL self-heal, not the raw "Continuing without it." advisory text.
|
|
7793
|
+
const retryMessage = agenticTarget.message.replace(
|
|
7794
|
+
'Continuing without it.',
|
|
7795
|
+
'Retrying discovery on each ≤30s reconnect until it self-heals.',
|
|
7796
|
+
);
|
|
7797
|
+
// Derive the auth mode from the resolved agentic config so `supervisor
|
|
7798
|
+
// status`/markers report SECURE (NANO_AGENTIC_SECRET) correctly while the
|
|
7799
|
+
// worker is still reconnecting, instead of hard-coding 'local'.
|
|
7800
|
+
agenticState = {
|
|
7801
|
+
status: 'connecting',
|
|
7802
|
+
mode: resolveAgenticConfig(camunda)?.secure ? 'secure' : 'local',
|
|
7803
|
+
url: null,
|
|
7804
|
+
message: retryMessage,
|
|
7805
|
+
};
|
|
7806
|
+
logger.info(` agentic channel: ${retryMessage}`);
|
|
7847
7807
|
break;
|
|
7808
|
+
}
|
|
7848
7809
|
case 'off':
|
|
7849
7810
|
default:
|
|
7850
7811
|
agenticState = agenticStateForTarget(agenticTarget);
|
|
@@ -7873,17 +7834,23 @@ async function workAgent(req, flags) {
|
|
|
7873
7834
|
// the raw-JS wire (`agentic-endpoint.mjs` → the single agentic import surface)
|
|
7874
7835
|
// with the bundle's Effect adapter; additive negotiation still degrades
|
|
7875
7836
|
// claim/release/steer to a no-op against an older hub. Reconnect + resync (and
|
|
7876
|
-
// teardown-on-interruption) are owned by the runtime's `superviseAgentic
|
|
7877
|
-
//
|
|
7878
|
-
//
|
|
7879
|
-
//
|
|
7880
|
-
//
|
|
7881
|
-
|
|
7837
|
+
// teardown-on-interruption) are owned by the runtime's `superviseAgentic`; the
|
|
7838
|
+
// #133 self-heal is REINSTATED without a parallel loop by handing the `advisory`
|
|
7839
|
+
// case a re-discovering connect factory (`resolveConfig`), so `superviseAgentic`'s
|
|
7840
|
+
// own ≤30s-capped reconnect retry IS the periodic re-discovery (the #144/#147
|
|
7841
|
+
// liveness watchdog remains retired) — the marker's agentic status is driven by
|
|
7842
|
+
// the endpoint's connection observer below. Constructing the endpoint opens NO
|
|
7843
|
+
// socket (the runtime's supervision calls the connect factory), so this stays
|
|
7844
|
+
// cheap and side-effect-free until `run` forks.
|
|
7845
|
+
if (agenticCfg || agenticSelfHeal) {
|
|
7882
7846
|
try {
|
|
7883
7847
|
agenticEndpoint = await createAgenticEndpoint({
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7848
|
+
// Discovered/explicit connect: a fixed URL (static factory). Advisory
|
|
7849
|
+
// self-heal: no URL yet — the resolver supplies it (and heals a moved hub)
|
|
7850
|
+
// on each supervised reconnect.
|
|
7851
|
+
...(agenticCfg
|
|
7852
|
+
? { url: agenticCfg.url, token: agenticCfg.token, credential: agenticCfg.credential }
|
|
7853
|
+
: { resolveConfig: resolveAgenticConnectConfig }),
|
|
7887
7854
|
// Keep the activity marker's agentic status honest across the single
|
|
7888
7855
|
// connection's open/drop transitions (#99) — the direct analogue of the
|
|
7889
7856
|
// retired createWorkChannel onConnect/onDisconnect marker wiring.
|
|
@@ -13732,8 +13699,6 @@ export {
|
|
|
13732
13699
|
resolveProbeCandidates,
|
|
13733
13700
|
raceProbeCandidates,
|
|
13734
13701
|
isLinkLocalAddress,
|
|
13735
|
-
rediscoverAgenticUntilConnected,
|
|
13736
|
-
defaultAgenticRediscoveryDelays,
|
|
13737
13702
|
agenticChannelIsStale,
|
|
13738
13703
|
agenticPresenceIsStale,
|
|
13739
13704
|
jitteredDelay,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.59.0",
|
|
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",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"c8ctl-plugin.js",
|
|
24
24
|
"agent-instance.mjs",
|
|
25
|
+
"acp-transcript-producer.mjs",
|
|
25
26
|
"platforms.mjs",
|
|
26
27
|
"agentic.mjs",
|
|
27
28
|
"agentic-loader-hook.mjs",
|
|
@@ -67,17 +68,17 @@
|
|
|
67
68
|
"typescript": "^5.9.3"
|
|
68
69
|
},
|
|
69
70
|
"dependencies": {
|
|
70
|
-
"@nanobpm/agentic": "^0.
|
|
71
|
-
"@nanobpm/urban-agent-client": "^0.1.
|
|
71
|
+
"@nanobpm/agentic": "^0.14.0",
|
|
72
|
+
"@nanobpm/urban-agent-client": "^0.1.14"
|
|
72
73
|
},
|
|
73
74
|
"optionalDependencies": {
|
|
74
75
|
"node-pty": "^1.0.0",
|
|
75
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
76
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
77
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
78
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
79
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
80
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
81
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
76
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.59.0",
|
|
77
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.59.0",
|
|
78
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.59.0",
|
|
79
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.59.0",
|
|
80
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.59.0",
|
|
81
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.59.0",
|
|
82
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.59.0"
|
|
82
83
|
}
|
|
83
84
|
}
|