c8ctl-plugin-nano 1.52.0 → 1.53.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.
@@ -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
- for (const cb of closeCbs) cb();
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
- for (const cb of openCbs) cb();
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 { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
67
- import { createRelaySession, roleTerminalMode } from './work-relay.mjs';
68
- import { createBufferMonitor, resolveBufferCapacity } from './work-buffer.mjs';
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,
@@ -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 agentic-visibility channel, wired below. Declared here so the job
7240
- // recorders can refresh presence with the live job set as jobs start/end.
7241
- /** @type {import('./work-channel.mjs').WorkChannel | null} */
7242
- let workChannel = null;
7243
- /** @type {import('./work-buffer.mjs').BufferMonitor | null} */
7244
- let bufferMonitor = null;
7245
- // #144 liveness watchdog state. `agenticDisconnectedSince` is the epoch-ms the
7246
- // channel last dropped (null whenever it is up or has never opened); the
7247
- // watchdog uses it to force a full re-discovery + reopen when the client lib's
7248
- // own reconnect fails to bring a previously-connected channel back within the
7249
- // stale threshold. `agenticWatchdog` is the running timer handle (stopped on
7250
- // shutdown); `agenticSelfHealing` guards against two concurrent re-discovery
7251
- // loops (the cold-start one and a watchdog-triggered one).
7252
- let agenticDisconnectedSince = null;
7253
- // #147 presence-keyed watchdog state. `agenticConnectedSince` is the epoch-ms
7254
- // the channel last (re)connected (null while down); the watchdog uses it to
7255
- // require a stable connection to have held for a grace window before counting
7256
- // presence as confirmed. `agenticPresenceHealthyAt` is the epoch-ms presence
7257
- // was last confirmed healthy — advanced on the first connect (buffered REGISTER
7258
- // drains) and by the watchdog whenever a stable connection is observed, and
7259
- // reset by a heal. When it ages past the presence-stale threshold — even while
7260
- // the socket flaps `1006` reconnects — the watchdog forces a re-discovery.
7261
- let agenticConnectedSince = null;
7262
- let agenticPresenceHealthyAt = null;
7263
- /** @type {{ stop: () => void } | null} */
7264
- let agenticWatchdog = null;
7265
- let agenticSelfHealing = false;
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 channel (ADR 0056 slice C2, #41) ----------------
7284
- // Connect this worker to the app's same-port `/agentic` channel and announce
7285
- // presence (identity, host, live jobs), heartbeat, and deregister on exit, so
7286
- // it appears live on the Workforce visibility page. This is the SINGLE place
7287
- // the connected+authenticated channel client is instantiated in `work`: the
7288
- // sibling slices C3 (PTY relay, #42) and C4 (buffer, #43) attach to the
7289
- // accessors on `workChannel` (relay-lane sink + connect/disconnect/reconnect
7290
- // lifecycle events) rather than opening their own connection.
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). A close carries a normalized
7337
- // diagnostic under the contract `agentic.message` field (not `reason`) so a hub
7338
- // drop explains WHY; a fresh (re)connect clears any stale message.
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
- // Open (or re-open) the visibility channel for a resolved connect config. This
7341
- // is the SINGLE place the connected+authenticated client is instantiated the
7342
- // initial connect path and the background self-heal loop (#133) both call it,
7343
- // (re)assigning the shared `workChannel`/`bufferMonitor` closures the job
7344
- // recorders and shutdown path already track.
7345
- const openAgenticChannel = async (cfg) => {
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
- workChannel = await createWorkChannel({
7348
- instance: workerName,
7349
- host: hostname(),
7350
- capability: {
7351
- cognition: profile.rank,
7352
- family: profile.model || undefined,
7353
- host: hostname(),
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 a channel failure stop the worker from doing its actual job.
7419
- workChannel = null;
7420
- // Retain the failure reason on the marker so the supervisor can show WHY
7421
- // presence dropped (bad URL, refused socket, …), not just `disconnected`.
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 channel unavailable (${err?.message || err}); continuing without visibility.`);
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 on the channel, so
7548
- // without the channel there's simply no relay tap — the harness still runs on
7549
- // the chosen local transport.
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 (workChannel) {
7555
- logger.info(` live terminal: ${roleTerminal === 'pty' ? 'PTY (streamed + steerable)' : 'pipe (streamed)'} on the relay lane.`);
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
- // C3 (#42): the per-job live-terminal relay session. Streams this job's
7772
- // harness terminal on the relay lane tagged with its jobKey, and accepts
7773
- // steer-in. Only when the worker is enrolled on the channel; closed in
7774
- // the finally so its inbound-frame subscription never leaks across jobs.
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 (workChannel) {
7777
- try {
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
- // Stop the #144 liveness watchdog so it can't kick off a re-discovery
8056
- // mid-teardown (which would resurrect the channel we're about to close).
8057
- if (agenticWatchdog) { try { agenticWatchdog.stop(); } catch { /* best effort */ } agenticWatchdog = null; }
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'); });