c8ctl-plugin-nano 1.52.0 → 1.53.1
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/agentic-endpoint.mjs +27 -4
- package/c8ctl-plugin.js +168 -274
- package/package.json +10 -10
- package/supervisor-engine.mjs +150 -42
- package/supervisor.dist.js +2 -2
- package/work-relay.mjs +147 -0
package/agentic-endpoint.mjs
CHANGED
|
@@ -108,10 +108,18 @@ function negotiatedSupport(remoteAdvertisement) {
|
|
|
108
108
|
* producer fences its stale predecessor on the hub's incarnation ring)
|
|
109
109
|
* @param {{ claimRelease: boolean, steer: boolean }} params.support negotiated capabilities
|
|
110
110
|
* @param {{ warn?: Function, debug?: Function }} [params.logger]
|
|
111
|
+
* @param {(state: 'connected' | 'disconnected') => void} [params.onConnectionState]
|
|
112
|
+
* optional observer invoked on connection-state transitions: `'connected'`
|
|
113
|
+
* once the transport opens and `'disconnected'` when it drops (fired at most
|
|
114
|
+
* once per drop). A throwing observer is caught and logged, never propagated.
|
|
111
115
|
* @returns {import('./supervisor.dist.js').RawEmitClient}
|
|
112
116
|
*/
|
|
113
|
-
function openHostConnection({ url, transportFactory, incarnation, support, logger }) {
|
|
117
|
+
function openHostConnection({ url, transportFactory, incarnation, support, logger, onConnectionState }) {
|
|
114
118
|
const log = logger || {};
|
|
119
|
+
const notifyState = (state) => {
|
|
120
|
+
if (typeof onConnectionState !== 'function') return;
|
|
121
|
+
try { onConnectionState(state); } catch (err) { log.debug?.(`agentic onConnectionState threw — ${err?.message || err}`); }
|
|
122
|
+
};
|
|
115
123
|
const openCbs = [];
|
|
116
124
|
const closeCbs = [];
|
|
117
125
|
let steerRoute = null;
|
|
@@ -139,7 +147,13 @@ function openHostConnection({ url, transportFactory, incarnation, support, logge
|
|
|
139
147
|
closedFired = true;
|
|
140
148
|
open = false;
|
|
141
149
|
hasClosed = true;
|
|
142
|
-
|
|
150
|
+
// Guard each subscriber: a throwing onClose callback must not abort the loop
|
|
151
|
+
// or prevent notifyState('disconnected') from firing (which would strand the
|
|
152
|
+
// connection-state observer and could crash the worker/supervisor on a drop).
|
|
153
|
+
for (const cb of closeCbs) {
|
|
154
|
+
try { cb(); } catch (err) { log.debug?.(`agentic onClose subscriber threw — ${err?.message || err}`); }
|
|
155
|
+
}
|
|
156
|
+
notifyState('disconnected');
|
|
143
157
|
};
|
|
144
158
|
|
|
145
159
|
const send = (lane, family, payload) => {
|
|
@@ -186,7 +200,12 @@ function openHostConnection({ url, transportFactory, incarnation, support, logge
|
|
|
186
200
|
transport = transportFactory(url, {
|
|
187
201
|
onOpen() {
|
|
188
202
|
open = true;
|
|
189
|
-
|
|
203
|
+
// Guard each subscriber: a throwing onOpen callback must not abort the loop
|
|
204
|
+
// or prevent notifyState('connected') from firing.
|
|
205
|
+
for (const cb of openCbs) {
|
|
206
|
+
try { cb(); } catch (err) { log.debug?.(`agentic onOpen subscriber threw — ${err?.message || err}`); }
|
|
207
|
+
}
|
|
208
|
+
notifyState('connected');
|
|
190
209
|
},
|
|
191
210
|
onFrame(bytes) {
|
|
192
211
|
handleInbound(bytes);
|
|
@@ -293,11 +312,14 @@ function openHostConnection({ url, transportFactory, incarnation, support, logge
|
|
|
293
312
|
* the peer's advertised support; omit to assume full support (see {@link negotiatedSupport})
|
|
294
313
|
* @param {number} [opts.incarnationBase] first transcript incarnation (default `Date.now()`)
|
|
295
314
|
* @param {import('@nanobpm/urban-agent-client').TransportFactory} [opts.transportFactory] injectable transport (tests)
|
|
315
|
+
* @param {(state: 'connected'|'disconnected') => void} [opts.onConnectionState] observer fired
|
|
316
|
+
* when a connection opens/drops, so a caller can track the single host connection's
|
|
317
|
+
* liveness (e.g. the supervisor activity marker's agentic status)
|
|
296
318
|
* @param {{ warn?: Function, debug?: Function }} [opts.logger]
|
|
297
319
|
* @returns {Promise<() => import('./supervisor.dist.js').RawEmitClient>} a synchronous `connect` factory
|
|
298
320
|
*/
|
|
299
321
|
export async function createRawEmitConnect(opts) {
|
|
300
|
-
const { url, token, credential, remoteAdvertisement, incarnationBase, transportFactory, logger } = opts || {};
|
|
322
|
+
const { url, token, credential, remoteAdvertisement, incarnationBase, transportFactory, logger, onConnectionState } = opts || {};
|
|
301
323
|
if (typeof url !== 'string' || url.trim() === '') {
|
|
302
324
|
throw new Error('createRawEmitConnect requires an agentic channel base url');
|
|
303
325
|
}
|
|
@@ -318,6 +340,7 @@ export async function createRawEmitConnect(opts) {
|
|
|
318
340
|
incarnation: generation++,
|
|
319
341
|
support,
|
|
320
342
|
logger,
|
|
343
|
+
onConnectionState,
|
|
321
344
|
});
|
|
322
345
|
}
|
|
323
346
|
|
package/c8ctl-plugin.js
CHANGED
|
@@ -63,9 +63,9 @@ import { createInterface } from 'node:readline/promises';
|
|
|
63
63
|
import { StringDecoder } from 'node:string_decoder';
|
|
64
64
|
import { createInterface as createReadline, cursorTo as rlCursorTo, moveCursor as rlMoveCursor, clearScreenDown as rlClearScreenDown } from 'node:readline';
|
|
65
65
|
import { platformForHost } from './platforms.mjs';
|
|
66
|
-
import {
|
|
67
|
-
import {
|
|
68
|
-
import {
|
|
66
|
+
import { redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
|
|
67
|
+
import { createHostRelaySession, roleTerminalMode } from './work-relay.mjs';
|
|
68
|
+
import { resolveBufferCapacity } from './work-buffer.mjs';
|
|
69
69
|
// Canonical ACP → transcript wire bridge (nanobpm/nano-ide#534), consumed through
|
|
70
70
|
// the single agentic import surface. `acpUpdateToTranscriptChunk(update)` maps one
|
|
71
71
|
// raw ACP `session/update` to the exact transcript-chunk bytes the cockpit decodes,
|
|
@@ -3366,7 +3366,7 @@ async function createSupervisorDeps(opts = {}) {
|
|
|
3366
3366
|
};
|
|
3367
3367
|
}
|
|
3368
3368
|
|
|
3369
|
-
const rawEngine = createRawEngineClient({ baseUrl: rc.baseUrl, token: rc.token, authHeaders: resolvedAuthHeaders, worker, fetchImpl });
|
|
3369
|
+
const rawEngine = createRawEngineClient({ baseUrl: rc.baseUrl, token: rc.token, authHeaders: resolvedAuthHeaders, worker, fetchImpl, camunda });
|
|
3370
3370
|
const engine = rt.makeEngineClient(rawEngine);
|
|
3371
3371
|
// `reconcileReader` (a raw `{ searchProcessDefinitionKeys, getProcessDefinitionXml }`)
|
|
3372
3372
|
// may be injected to override the default keep-alive `httpC8RestReader` — the
|
|
@@ -7236,58 +7236,54 @@ async function workAgent(req, flags) {
|
|
|
7236
7236
|
/* best effort — activity is advisory, never fail a job over it */
|
|
7237
7237
|
}
|
|
7238
7238
|
};
|
|
7239
|
-
// The
|
|
7240
|
-
//
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
//
|
|
7246
|
-
//
|
|
7247
|
-
//
|
|
7248
|
-
|
|
7249
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
//
|
|
7254
|
-
//
|
|
7255
|
-
//
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
// Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
|
|
7267
|
-
// file (gated inside writeActivity) AND the agentic presence frame's live
|
|
7268
|
-
// jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
|
|
7269
|
-
// reports its current jobs on the visibility page.
|
|
7239
|
+
// The single-owner supervisor's agentic plane (issue #173): ONE multiplexed
|
|
7240
|
+
// host connection carries presence/steer/ownership/transcript for EVERY
|
|
7241
|
+
// supervised agent, retiring the per-`work`-process `createWorkChannel` fan-out
|
|
7242
|
+
// (where each process opened its own socket for a single identity). Built below
|
|
7243
|
+
// from the resolved connect config and injected into the runtime as
|
|
7244
|
+
// `deps.agenticEndpoint`; `agenticPlane` is late-bound to the running
|
|
7245
|
+
// supervisor's presence/steer/transcript seams once `makeSupervisor` returns, so
|
|
7246
|
+
// the per-job runner streams a job's terminal + accepts steer over that one
|
|
7247
|
+
// connection instead of a per-process channel.
|
|
7248
|
+
/** @type {import('./supervisor.dist.js').AgenticEndpoint | null} */
|
|
7249
|
+
let agenticEndpoint = null;
|
|
7250
|
+
/** @type {{ register: () => void, deregister: (reason?: string) => void, relaySessionFor: (jobKey: string|number) => (object|null) } | null} */
|
|
7251
|
+
let agenticPlane = null;
|
|
7252
|
+
// The presence attributes this worker announces on `register` (ENROLMENT
|
|
7253
|
+
// attributes, not routing tokens — jobKeys are carried by the explicit
|
|
7254
|
+
// claim/release ownership frames the dispatch lifecycle emits, never smuggled in
|
|
7255
|
+
// here). Reused for the initial seed and any resync.
|
|
7256
|
+
const agenticCapability = {
|
|
7257
|
+
cognition: profile.rank,
|
|
7258
|
+
family: profile.model || undefined,
|
|
7259
|
+
host: hostname(),
|
|
7260
|
+
};
|
|
7261
|
+
// Maintain `activeJobs` unconditionally: it feeds the supervisor activity file
|
|
7262
|
+
// (gated inside writeActivity) so a standalone worker still reports its current
|
|
7263
|
+
// jobs. Live presence/ownership jobKeys are now owned by the runtime — the
|
|
7264
|
+
// dispatch claim/release lifecycle keyed by this worker's instance drives the
|
|
7265
|
+
// cockpit's jobKeys — so the recorders no longer poke a per-process channel.
|
|
7270
7266
|
const recordJobStart = (job, jobType) => {
|
|
7271
7267
|
activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now() });
|
|
7272
7268
|
writeActivity();
|
|
7273
|
-
workChannel?.refreshPresence();
|
|
7274
7269
|
};
|
|
7275
7270
|
const recordJobEnd = (job) => {
|
|
7276
7271
|
activeJobs.delete(String(job.jobKey));
|
|
7277
7272
|
writeActivity();
|
|
7278
|
-
workChannel?.refreshPresence();
|
|
7279
7273
|
};
|
|
7280
7274
|
// Seed an initial idle marker so status reports 'idle' immediately after spawn.
|
|
7281
7275
|
writeActivity();
|
|
7282
7276
|
|
|
7283
|
-
// ---- Agentic visibility
|
|
7284
|
-
//
|
|
7285
|
-
//
|
|
7286
|
-
//
|
|
7287
|
-
//
|
|
7288
|
-
//
|
|
7289
|
-
//
|
|
7290
|
-
//
|
|
7277
|
+
// ---- Agentic visibility connection (ADR 0056; issue #173) -----------------
|
|
7278
|
+
// Resolve WHERE this worker's presence is announced, then hand the connect
|
|
7279
|
+
// config to the single-owner supervisor runtime as its ONE multiplexed host
|
|
7280
|
+
// connection (built below). The supervisor announces presence (identity, host),
|
|
7281
|
+
// heartbeats it, claims/releases jobs, and streams transcript + accepts steer
|
|
7282
|
+
// for EVERY supervised agent over that single connection — replacing the retired
|
|
7283
|
+
// per-`work`-process `createWorkChannel` fan-out where each process opened its
|
|
7284
|
+
// own socket for a single identity. The sibling data planes (C3 PTY relay #42,
|
|
7285
|
+
// steer #163) now ride this connection via the runtime's transcript/steer seams
|
|
7286
|
+
// rather than a per-process channel.
|
|
7291
7287
|
//
|
|
7292
7288
|
// Local-first (security opt-in): visibility is ON BY DEFAULT. In LOCAL mode the
|
|
7293
7289
|
// worker joins with the well-known LOCAL token and no credential; SECURE mode
|
|
@@ -7333,226 +7329,67 @@ async function workAgent(req, flags) {
|
|
|
7333
7329
|
// the socket opens (or without a channel at all).
|
|
7334
7330
|
writeActivity();
|
|
7335
7331
|
// Track the live connection state on the activity marker so the supervisor
|
|
7336
|
-
// shows connected↔disconnected transitions (#99).
|
|
7337
|
-
//
|
|
7338
|
-
//
|
|
7332
|
+
// shows connected↔disconnected transitions (#99). Diagnostics ride the contract
|
|
7333
|
+
// `agentic.message` field (not `reason`): the endpoint's connection observer
|
|
7334
|
+
// only reports a state (`connected`/`disconnected`) — the underlying close does
|
|
7335
|
+
// not surface a reason — so a hub drop records a generic 'connection dropped'
|
|
7336
|
+
// message, while an endpoint-construction failure records the normalized error
|
|
7337
|
+
// (see the catch below). A fresh (re)connect clears any stale message.
|
|
7339
7338
|
const markAgentic = (status, message = null) => { agenticState = { ...agenticState, status, message }; writeActivity(); };
|
|
7340
|
-
//
|
|
7341
|
-
//
|
|
7342
|
-
//
|
|
7343
|
-
//
|
|
7344
|
-
//
|
|
7345
|
-
|
|
7339
|
+
// Build the ONE multiplexed host-connection endpoint (issue #173) for the
|
|
7340
|
+
// resolved connect config and hand it to the single-owner runtime as
|
|
7341
|
+
// `deps.agenticEndpoint`. This RETIRES the per-`work`-process createWorkChannel
|
|
7342
|
+
// fan-out: the supervisor now owns exactly one connection carrying presence
|
|
7343
|
+
// (register/heartbeat/deregister projected from the registry), ownership
|
|
7344
|
+
// (claim/release per job), inbound steer, and transcript for EVERY supervised
|
|
7345
|
+
// agent — each frame carrying its `instance` explicitly. The endpoint composes
|
|
7346
|
+
// the raw-JS wire (`agentic-endpoint.mjs` → the single agentic import surface)
|
|
7347
|
+
// with the bundle's Effect adapter; additive negotiation still degrades
|
|
7348
|
+
// claim/release/steer to a no-op against an older hub. Reconnect + resync (and
|
|
7349
|
+
// teardown-on-interruption) are owned by the runtime's `superviseAgentic`, so the
|
|
7350
|
+
// retired #133 self-heal loop and #144/#147 liveness watchdog are gone — the
|
|
7351
|
+
// marker's agentic status is driven by the endpoint's connection observer below.
|
|
7352
|
+
// Constructing the endpoint opens NO socket (the runtime's supervision calls the
|
|
7353
|
+
// connect factory), so this stays cheap and side-effect-free until `run` forks.
|
|
7354
|
+
if (agenticCfg) {
|
|
7346
7355
|
try {
|
|
7347
|
-
|
|
7348
|
-
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
|
|
7352
|
-
|
|
7353
|
-
|
|
7356
|
+
agenticEndpoint = await createAgenticEndpoint({
|
|
7357
|
+
url: agenticCfg.url,
|
|
7358
|
+
token: agenticCfg.token,
|
|
7359
|
+
credential: agenticCfg.credential,
|
|
7360
|
+
// Keep the activity marker's agentic status honest across the single
|
|
7361
|
+
// connection's open/drop transitions (#99) — the direct analogue of the
|
|
7362
|
+
// retired createWorkChannel onConnect/onDisconnect marker wiring.
|
|
7363
|
+
onConnectionState: (state) => {
|
|
7364
|
+
if (state === 'connected') markAgentic('connected');
|
|
7365
|
+
else markAgentic('disconnected', 'connection dropped');
|
|
7354
7366
|
},
|
|
7355
|
-
listJobKeys: () => [...activeJobs.keys()],
|
|
7356
|
-
url: cfg.url,
|
|
7357
|
-
token: cfg.token,
|
|
7358
|
-
credential: cfg.credential,
|
|
7359
|
-
bufferCapacity: cfg.bufferCapacity,
|
|
7360
|
-
// #147: de-synchronise reconnect attempts with equal-jitter backoff so a
|
|
7361
|
-
// fleet dropped on the same lossy link does not reconnect in lockstep and
|
|
7362
|
-
// re-congest it. Wraps the client lib's own exponential policy (which has
|
|
7363
|
-
// no jitter of its own); the base delays/factor stay the lib's defaults.
|
|
7364
|
-
schedule: makeJitteredReconnectSchedule(),
|
|
7365
7367
|
logger,
|
|
7366
7368
|
});
|
|
7367
|
-
const shown = redactAgenticUrl(buildAgenticUrl(cfg.url, {}));
|
|
7368
|
-
const mode = cfg.secure ? 'secure' : 'local';
|
|
7369
|
-
if (cfg.discovered) {
|
|
7370
|
-
const d = cfg.discovered;
|
|
7371
|
-
logger.info(` agentic channel: auto-discovered ${d.project} on the app's /agentic port ${wsHostPart(d.host)}:${d.port} (bypassing the WS-incapable console proxy).`);
|
|
7372
|
-
}
|
|
7373
|
-
logger.info(` agentic channel (${mode}): announcing presence as ${workerName} on ${shown}`);
|
|
7374
|
-
// onConnect fires only for listeners present at first open, so also
|
|
7375
|
-
// reconcile the already-open case synchronously via connected(). If the
|
|
7376
|
-
// socket opened and then dropped inside the createWorkChannel() await window
|
|
7377
|
-
// (before these listeners existed), connected() is false but everConnected()
|
|
7378
|
-
// is true — record that as `disconnected` rather than leaving it stuck at
|
|
7379
|
-
// `connecting`.
|
|
7380
|
-
// #144: track the drop clock alongside presence — a (re)connect clears it,
|
|
7381
|
-
// a disconnect starts it (first drop wins, so the watchdog measures from the
|
|
7382
|
-
// ORIGINAL drop, not the latest of a reconnect storm). The watchdog reads
|
|
7383
|
-
// this to decide when the client lib has failed to self-heal.
|
|
7384
|
-
workChannel.onConnect(() => {
|
|
7385
|
-
markAgentic('connected');
|
|
7386
|
-
agenticDisconnectedSince = null;
|
|
7387
|
-
agenticConnectedSince = Date.now();
|
|
7388
|
-
// First open drains the buffered REGISTER → presence lands; seed the
|
|
7389
|
-
// presence-health clock so the #147 trigger measures from here (#147).
|
|
7390
|
-
agenticPresenceHealthyAt = Date.now();
|
|
7391
|
-
});
|
|
7392
|
-
workChannel.onReconnect(() => {
|
|
7393
|
-
markAgentic('connected');
|
|
7394
|
-
agenticDisconnectedSince = null;
|
|
7395
|
-
agenticConnectedSince = Date.now();
|
|
7396
|
-
// Deliberately do NOT advance agenticPresenceHealthyAt here: a reconnect
|
|
7397
|
-
// only CLAIMS presence (re-announces). On a lossy link the socket may
|
|
7398
|
-
// re-drop `1006` before presence actually lands, so the watchdog confirms
|
|
7399
|
-
// it only once a connection HOLDS for the grace window — a reconnect that
|
|
7400
|
-
// immediately re-drops must not mask an unrecovered presence (#147).
|
|
7401
|
-
});
|
|
7402
|
-
workChannel.onDisconnect((info) => {
|
|
7403
|
-
markAgentic('disconnected', normalizeAgenticMessage(info));
|
|
7404
|
-
if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
|
|
7405
|
-
agenticConnectedSince = null;
|
|
7406
|
-
});
|
|
7407
|
-
if (workChannel.connected()) {
|
|
7408
|
-
markAgentic('connected');
|
|
7409
|
-
agenticDisconnectedSince = null;
|
|
7410
|
-
agenticConnectedSince = Date.now();
|
|
7411
|
-
if (agenticPresenceHealthyAt == null) agenticPresenceHealthyAt = Date.now();
|
|
7412
|
-
} else if (workChannel.everConnected()) {
|
|
7413
|
-
markAgentic('disconnected');
|
|
7414
|
-
if (agenticDisconnectedSince == null) agenticDisconnectedSince = Date.now();
|
|
7415
|
-
agenticConnectedSince = null;
|
|
7416
|
-
}
|
|
7417
7369
|
} catch (err) {
|
|
7418
|
-
// Never let
|
|
7419
|
-
|
|
7420
|
-
//
|
|
7421
|
-
|
|
7422
|
-
// The contract diagnostic field is `agentic.message` (#99), matching the
|
|
7423
|
-
// live-disconnect path above — keep the key consistent, not `reason`.
|
|
7370
|
+
// Never let endpoint construction stop the worker from doing its job — the
|
|
7371
|
+
// agentic plane is best-effort visibility. Record the failure on the marker
|
|
7372
|
+
// and run the supervisor with no agenticEndpoint (presence/steer become no-ops).
|
|
7373
|
+
agenticEndpoint = null;
|
|
7424
7374
|
agenticState = { ...agenticState, status: 'disconnected', message: normalizeAgenticMessage(err) };
|
|
7425
7375
|
writeActivity();
|
|
7426
|
-
logger.warn(` agentic
|
|
7427
|
-
return;
|
|
7428
|
-
}
|
|
7429
|
-
// C4 (#43): observe the client's built-in outbound buffer across the
|
|
7430
|
-
// channel lifecycle — surface a high-water mark and warn when the bound
|
|
7431
|
-
// is hit so a hub outage that starts shedding frames is never silent. The
|
|
7432
|
-
// monitor is observability-only, so keep it OUTSIDE the channel try/catch:
|
|
7433
|
-
// a monitor failure must never null out a healthy channel and take down
|
|
7434
|
-
// presence/visibility.
|
|
7435
|
-
if (workChannel) {
|
|
7436
|
-
try {
|
|
7437
|
-
bufferMonitor = createBufferMonitor(workChannel, {
|
|
7438
|
-
capacity: cfg.bufferCapacity,
|
|
7439
|
-
logger,
|
|
7440
|
-
});
|
|
7441
|
-
} catch (err) {
|
|
7442
|
-
bufferMonitor = null;
|
|
7443
|
-
logger.warn(` agentic buffer monitor unavailable (${err?.message || err}); channel presence still active.`);
|
|
7444
|
-
}
|
|
7376
|
+
logger.warn(` agentic endpoint unavailable (${err?.message || err}); continuing without visibility.`);
|
|
7445
7377
|
}
|
|
7446
|
-
};
|
|
7447
|
-
|
|
7448
|
-
// (A) Background self-heal loop, shared by the cold-start advisory path (#133)
|
|
7449
|
-
// AND the #144 liveness watchdog. Re-run discovery on a jittered backoff and,
|
|
7450
|
-
// on the first `connect` target, (re)open the channel WITHOUT a restart. The
|
|
7451
|
-
// `agenticSelfHealing` guard makes it idempotent: the watchdog can call it
|
|
7452
|
-
// after tearing a stale channel down without racing a still-running cold-start
|
|
7453
|
-
// loop. A shared cache lets a brief blip reuse the last known-good hub (#133-C).
|
|
7454
|
-
const armAgenticSelfHeal = () => {
|
|
7455
|
-
if (agenticSelfHealing) return; // a re-discovery loop is already running
|
|
7456
|
-
if (workChannel !== null) return; // a channel already exists — nothing to heal
|
|
7457
|
-
agenticSelfHealing = true;
|
|
7458
|
-
const hubCache = new Map();
|
|
7459
|
-
rediscoverAgenticUntilConnected({
|
|
7460
|
-
resolveTarget: () => resolveAgenticTarget({ camunda, logger, cache: hubCache }),
|
|
7461
|
-
onConnect: async (target) => {
|
|
7462
|
-
agenticCfg = target.config;
|
|
7463
|
-
agenticState = agenticStateForTarget(target, safeAgenticDisplayUrl);
|
|
7464
|
-
writeActivity();
|
|
7465
|
-
logger.info(' agentic channel: background re-discovery succeeded — (re)opening channel.');
|
|
7466
|
-
await openAgenticChannel(agenticCfg);
|
|
7467
|
-
// openAgenticChannel swallows its own open failures (it nulls
|
|
7468
|
-
// workChannel and returns rather than throwing), so a failed open must
|
|
7469
|
-
// be re-thrown here — otherwise the self-heal loop treats this attempt
|
|
7470
|
-
// as success and stops retrying with workChannel still null (#133).
|
|
7471
|
-
if (workChannel === null) {
|
|
7472
|
-
throw new Error('agentic channel failed to open after background re-discovery');
|
|
7473
|
-
}
|
|
7474
|
-
},
|
|
7475
|
-
// Stop as soon as a channel exists (loop won this or a prior attempt did).
|
|
7476
|
-
shouldContinue: () => workChannel === null,
|
|
7477
|
-
logger,
|
|
7478
|
-
})
|
|
7479
|
-
.catch(() => { /* best-effort self-heal — never surfaces an error */ })
|
|
7480
|
-
.finally(() => { agenticSelfHealing = false; });
|
|
7481
|
-
};
|
|
7482
|
-
|
|
7483
|
-
// (B) #144 liveness watchdog: force-heal a wedged channel. When a channel that
|
|
7484
|
-
// HAS connected drops and the client lib's own reconnect never brings it back
|
|
7485
|
-
// within the stale threshold (a half-open drop after a server restart/crash/
|
|
7486
|
-
// partition, or a reconnect that keeps failing), the client sits `disconnected`
|
|
7487
|
-
// forever and the worker vanishes from the Workers view until a supervisor
|
|
7488
|
-
// restart. This tears the wedged channel down (so `shouldContinue` re-arms) and
|
|
7489
|
-
// re-runs full discovery + reopen instead of trusting the client lib alone.
|
|
7490
|
-
const healStaleAgenticChannel = async () => {
|
|
7491
|
-
const stale = workChannel;
|
|
7492
|
-
if (!stale) return;
|
|
7493
|
-
workChannel = null; // re-arms armAgenticSelfHeal()'s shouldContinue gate
|
|
7494
|
-
agenticDisconnectedSince = null; // reset the clock; the fresh open restarts it
|
|
7495
|
-
agenticConnectedSince = null; // #147: the fresh open re-seeds it
|
|
7496
|
-
agenticPresenceHealthyAt = null; // #147: the fresh open re-confirms presence
|
|
7497
|
-
try { bufferMonitor?.stop(); } catch { /* best effort */ }
|
|
7498
|
-
bufferMonitor = null;
|
|
7499
|
-
markAgentic('disconnected', 'stale channel — re-discovering hub');
|
|
7500
|
-
// Deregister + close the wedged client so it stops its own doomed reconnect
|
|
7501
|
-
// attempts and we don't leak two clients once the fresh one connects.
|
|
7502
|
-
try { await stale.stop('stale channel — re-discovering'); } catch { /* best effort */ }
|
|
7503
|
-
armAgenticSelfHeal();
|
|
7504
|
-
};
|
|
7505
|
-
|
|
7506
|
-
const startAgenticWatchdog = () => {
|
|
7507
|
-
if (agenticWatchdog) return;
|
|
7508
|
-
const staleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_STALE_MS, DEFAULT_AGENTIC_STALE_MS));
|
|
7509
|
-
const intervalMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_WATCHDOG_MS, DEFAULT_AGENTIC_WATCHDOG_INTERVAL_MS));
|
|
7510
|
-
const presenceStaleAfterMs = Math.max(5_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_STALE_MS, DEFAULT_AGENTIC_PRESENCE_STALE_MS));
|
|
7511
|
-
const presenceGraceMs = Math.max(1_000, intFlag(process.env.NANO_AGENTIC_PRESENCE_GRACE_MS, DEFAULT_AGENTIC_PRESENCE_GRACE_MS));
|
|
7512
|
-
agenticWatchdog = startAgenticChannelWatchdog({
|
|
7513
|
-
getChannel: () => workChannel,
|
|
7514
|
-
disconnectedSince: () => agenticDisconnectedSince,
|
|
7515
|
-
// #147: presence-keyed trigger — heal reconnect-churn that never re-lands
|
|
7516
|
-
// presence, not just a sustained socket drop.
|
|
7517
|
-
connectedSince: () => agenticConnectedSince,
|
|
7518
|
-
presenceHealthySince: () => agenticPresenceHealthyAt,
|
|
7519
|
-
onPresenceHealthy: () => { agenticPresenceHealthyAt = Date.now(); },
|
|
7520
|
-
presenceStaleAfterMs,
|
|
7521
|
-
presenceGraceMs,
|
|
7522
|
-
onStale: healStaleAgenticChannel,
|
|
7523
|
-
staleAfterMs,
|
|
7524
|
-
intervalMs,
|
|
7525
|
-
logger,
|
|
7526
|
-
});
|
|
7527
|
-
};
|
|
7528
|
-
|
|
7529
|
-
if (agenticCfg) {
|
|
7530
|
-
await openAgenticChannel(agenticCfg);
|
|
7531
|
-
// Guard the connected channel: if it later drops and the client lib can't
|
|
7532
|
-
// recover it, the watchdog forces a full re-discovery + reopen (#144).
|
|
7533
|
-
startAgenticWatchdog();
|
|
7534
|
-
} else if (agenticTarget.status === 'advisory') {
|
|
7535
|
-
// A cold-start discovery miss leaves the worker `advisory`; the self-heal
|
|
7536
|
-
// loop upgrades it to `connected` without a restart (#133), and once a
|
|
7537
|
-
// channel exists the watchdog keeps it alive across later drops (#144).
|
|
7538
|
-
armAgenticSelfHeal();
|
|
7539
|
-
startAgenticWatchdog();
|
|
7540
7378
|
}
|
|
7541
|
-
|
|
7542
7379
|
// C3 (#42): the role's live-terminal mode — a full PTY (streamed on the relay
|
|
7543
7380
|
// lane when a relay session exists, steerable) or a plain pipe. Honors the
|
|
7544
7381
|
// vocab's per-role opt-in read off the hire profile (`terminal: pty|pipe`),
|
|
7545
7382
|
// with an env override for a one-off worker (`NANO_AGENTIC_TERMINAL`). The PTY
|
|
7546
7383
|
// itself is allocated locally regardless of enrollment; relay streaming (and
|
|
7547
|
-
// steer-in) only engages when the worker is enrolled
|
|
7548
|
-
// without
|
|
7549
|
-
//
|
|
7384
|
+
// steer-in) only engages when the worker is enrolled (a live agentic endpoint),
|
|
7385
|
+
// so without it there's simply no relay tap — the harness still runs on the
|
|
7386
|
+
// chosen local transport.
|
|
7550
7387
|
const envTerminal = (process.env.NANO_AGENTIC_TERMINAL || '').trim().toLowerCase();
|
|
7551
7388
|
const roleTerminal = (envTerminal === 'pty' || envTerminal === 'pipe')
|
|
7552
7389
|
? envTerminal
|
|
7553
7390
|
: roleTerminalMode(profile);
|
|
7554
|
-
if (
|
|
7555
|
-
logger.info(` live terminal: ${roleTerminal === 'pty' ? 'PTY (streamed + steerable)' : 'pipe (streamed)'} on the
|
|
7391
|
+
if (agenticEndpoint) {
|
|
7392
|
+
logger.info(` live terminal: ${roleTerminal === 'pty' ? 'PTY (streamed + steerable)' : 'pipe (streamed)'} on the supervised connection.`);
|
|
7556
7393
|
}
|
|
7557
7394
|
|
|
7558
7395
|
// #110: the role's harness protocol (pipe|acp) and ACP permission policy
|
|
@@ -7768,18 +7605,15 @@ async function workAgent(req, flags) {
|
|
|
7768
7605
|
|
|
7769
7606
|
let result;
|
|
7770
7607
|
let gitResult = null;
|
|
7771
|
-
//
|
|
7772
|
-
// harness terminal
|
|
7773
|
-
//
|
|
7774
|
-
//
|
|
7608
|
+
// The per-job live-terminal relay session (issue #173): streams this job's
|
|
7609
|
+
// harness terminal over the single-owner supervisor's ONE multiplexed host
|
|
7610
|
+
// connection, keyed by this worker's instance + the jobKey, and accepts
|
|
7611
|
+
// cockpit steer-in fanned back to this job's PTY by the runtime's steer
|
|
7612
|
+
// router. Only when the worker is enrolled (a live agentic plane); closed
|
|
7613
|
+
// in the finally so its steer subscription never leaks across jobs.
|
|
7775
7614
|
let relaySession = null;
|
|
7776
|
-
if (
|
|
7777
|
-
|
|
7778
|
-
relaySession = createRelaySession({ channel: workChannel, jobKey: job.jobKey, logger });
|
|
7779
|
-
} catch (err) {
|
|
7780
|
-
relaySession = null;
|
|
7781
|
-
logger.warn(`[${jobType}] job ${job.jobKey}: relay session unavailable (${err?.message || err}); continuing without live terminal.`);
|
|
7782
|
-
}
|
|
7615
|
+
if (agenticPlane) {
|
|
7616
|
+
relaySession = agenticPlane.relaySessionFor(job.jobKey);
|
|
7783
7617
|
}
|
|
7784
7618
|
// Private structured-result channel: hand the agent a file (outside any
|
|
7785
7619
|
// repo clone so it can't be `git add`ed) to write its job-result vars to.
|
|
@@ -7958,6 +7792,11 @@ async function workAgent(req, flags) {
|
|
|
7958
7792
|
workers: [{ id: workerName, types: jobTypes, capacity: 1 }],
|
|
7959
7793
|
autoWorkerId: autoMode ? workerName : undefined,
|
|
7960
7794
|
scope: autoScope,
|
|
7795
|
+
// The ONE multiplexed host connection (issue #173): the runtime owns its
|
|
7796
|
+
// connect/reconnect/resync + teardown lifecycle. Omitted (undefined) when the
|
|
7797
|
+
// agentic target didn't resolve to a connect — the runtime then runs with no
|
|
7798
|
+
// agentic scope and presence/steer degrade to no-ops.
|
|
7799
|
+
agenticEndpoint: agenticEndpoint || undefined,
|
|
7961
7800
|
config: {
|
|
7962
7801
|
activation: { requestTimeoutMs: pollTimeoutMs },
|
|
7963
7802
|
dispatch: { recoveryWindowMs, extendIntervalMs: lockExtendIntervalMs },
|
|
@@ -7979,6 +7818,69 @@ async function workAgent(req, flags) {
|
|
|
7979
7818
|
const supervisor = await SupervisorEffect.runPromise(makeSupervisorRuntime(supervisorDeps));
|
|
7980
7819
|
const supervisorFiber = SupervisorEffect.runFork(supervisor.run);
|
|
7981
7820
|
|
|
7821
|
+
// Seed this worker's presence into the runtime's ownership registry (issue
|
|
7822
|
+
// #173) and late-bind the per-job relay seam to the running supervisor. The
|
|
7823
|
+
// presence-projection fiber announces (register) then heartbeats this instance
|
|
7824
|
+
// over the one multiplexed connection, and drops it (deregister) when it leaves
|
|
7825
|
+
// the registry; ownership jobKeys are driven by the dispatch claim/release
|
|
7826
|
+
// lifecycle keyed by this same instance. Every hop is best-effort — a
|
|
7827
|
+
// visibility seam must never fail the worker.
|
|
7828
|
+
if (agenticEndpoint) {
|
|
7829
|
+
const agenticEncoder = new TextEncoder();
|
|
7830
|
+
const seedPresence = () => {
|
|
7831
|
+
try {
|
|
7832
|
+
SupervisorEffect.runSync(supervisor.ownership.register(workerName, agenticCapability));
|
|
7833
|
+
} catch (err) {
|
|
7834
|
+
logger.warn(` agentic presence seed failed (${err?.message || err}); continuing.`);
|
|
7835
|
+
}
|
|
7836
|
+
};
|
|
7837
|
+
seedPresence();
|
|
7838
|
+
agenticPlane = {
|
|
7839
|
+
register: seedPresence,
|
|
7840
|
+
// Graceful teardown: emit an explicit deregister for this identity over the
|
|
7841
|
+
// live connection, then drop it from the registry so the projection stops
|
|
7842
|
+
// heartbeating it. Best-effort — teardown must never hang or throw.
|
|
7843
|
+
deregister: (reason) => {
|
|
7844
|
+
try { SupervisorEffect.runSync(supervisor.presence.deregister(workerName, reason)); } catch { /* best effort */ }
|
|
7845
|
+
try { SupervisorEffect.runSync(supervisor.ownership.deregister(workerName)); } catch { /* best effort */ }
|
|
7846
|
+
},
|
|
7847
|
+
// Build a per-job relay session over the supervisor's transcript + steer
|
|
7848
|
+
// seams (issue #173), shape-compatible with the retired createWorkChannel
|
|
7849
|
+
// relay session so `runAgentJob` consumes it unchanged. Transcript rides the
|
|
7850
|
+
// one connection keyed by this instance + jobKey; inbound steer is fanned
|
|
7851
|
+
// back to this job's PTY by the runtime's per-instance steer router.
|
|
7852
|
+
relaySessionFor: (jobKey) => {
|
|
7853
|
+
try {
|
|
7854
|
+
return createHostRelaySession({
|
|
7855
|
+
instance: workerName,
|
|
7856
|
+
jobKey,
|
|
7857
|
+
publish: (text) => {
|
|
7858
|
+
// Fire-and-forget over the live handle; a frame between a drop and
|
|
7859
|
+
// the next reconnect is a harmless no-op (best-effort semantics).
|
|
7860
|
+
try { SupervisorEffect.runSync(supervisor.transcript(workerName, String(jobKey), agenticEncoder.encode(text))); } catch { /* best effort */ }
|
|
7861
|
+
},
|
|
7862
|
+
subscribeSteer: (onChunk) => {
|
|
7863
|
+
// The router keys sinks by instance, not jobKey, so it hands every
|
|
7864
|
+
// steer frame for this instance to the active sink. Filter by the
|
|
7865
|
+
// session's own jobKey before delivering: even at capacity=1 a
|
|
7866
|
+
// late/queued steer frame for a PRIOR job could otherwise land in
|
|
7867
|
+
// the NEXT job's PTY. Drop any frame whose jobKey isn't this job's.
|
|
7868
|
+
const sink = (jk, chunk) => SupervisorEffect.sync(() => {
|
|
7869
|
+
if (String(jk) === String(jobKey)) onChunk(chunk);
|
|
7870
|
+
});
|
|
7871
|
+
try { SupervisorEffect.runSync(supervisor.steerRouter.register(workerName, sink)); } catch { /* best effort */ }
|
|
7872
|
+
return () => { try { SupervisorEffect.runSync(supervisor.steerRouter.unregister(workerName)); } catch { /* best effort */ } };
|
|
7873
|
+
},
|
|
7874
|
+
logger,
|
|
7875
|
+
});
|
|
7876
|
+
} catch (err) {
|
|
7877
|
+
logger.warn(` host relay session unavailable (${err?.message || err}); continuing without live terminal.`);
|
|
7878
|
+
return null;
|
|
7879
|
+
}
|
|
7880
|
+
},
|
|
7881
|
+
};
|
|
7882
|
+
}
|
|
7883
|
+
|
|
7982
7884
|
// Non-auto live retype: the runtime's reconcile loop only rewrites the --auto
|
|
7983
7885
|
// worker's types (from the engine read), so keep watching the profile file to
|
|
7984
7886
|
// honour `nano assign` — a profile edit rewrites THIS worker's serviceable types
|
|
@@ -8052,29 +7954,21 @@ async function workAgent(req, flags) {
|
|
|
8052
7954
|
logger.info(`Received ${signal} — stopping worker...`);
|
|
8053
7955
|
if (reaperTimer) clearInterval(reaperTimer);
|
|
8054
7956
|
if (runDirTimer) clearInterval(runDirTimer);
|
|
8055
|
-
//
|
|
8056
|
-
//
|
|
8057
|
-
|
|
7957
|
+
// Deregister this worker's presence BEFORE interrupting the runtime: emit
|
|
7958
|
+
// the explicit deregister while the multiplexed host connection is still
|
|
7959
|
+
// live, so the worker disappears from the cockpit cleanly rather than
|
|
7960
|
+
// lingering until its heartbeat lapses. Best-effort — teardown must never
|
|
7961
|
+
// hang. Interrupting the fiber then runs the runtime's bracketed teardown
|
|
7962
|
+
// (release slots, stop the heartbeat + tear down the agentic scope).
|
|
7963
|
+
if (agenticPlane) {
|
|
7964
|
+
try { agenticPlane.deregister(`worker stopped (${signal})`); } catch { /* best effort */ }
|
|
7965
|
+
}
|
|
8058
7966
|
try {
|
|
8059
7967
|
await SupervisorEffect.runPromise(SupervisorFiber.interrupt(supervisorFiber));
|
|
8060
7968
|
logger.info('Worker stopped.');
|
|
8061
7969
|
} catch (err) {
|
|
8062
7970
|
logger.warn(`supervisor shutdown error — runtime loop may not have shut down cleanly: ${err?.message || err}`);
|
|
8063
7971
|
}
|
|
8064
|
-
// Deregister from the visibility channel LAST, so the worker disappears from
|
|
8065
|
-
// the page only once its jobs have drained. Best-effort — a channel teardown
|
|
8066
|
-
// must never hang shutdown.
|
|
8067
|
-
if (workChannel) {
|
|
8068
|
-
try {
|
|
8069
|
-
bufferMonitor?.stop();
|
|
8070
|
-
} catch { /* best effort */ }
|
|
8071
|
-
try {
|
|
8072
|
-
await workChannel.stop(`worker stopped (${signal})`);
|
|
8073
|
-
logger.info('Deregistered from the agentic visibility channel.');
|
|
8074
|
-
} catch (err) {
|
|
8075
|
-
logger.warn(`agentic channel deregister failed: ${err?.message || err}`);
|
|
8076
|
-
}
|
|
8077
|
-
}
|
|
8078
7972
|
resolve();
|
|
8079
7973
|
};
|
|
8080
7974
|
process.once('SIGINT', () => { stop('SIGINT'); });
|