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/work-relay.mjs
CHANGED
|
@@ -309,3 +309,150 @@ export function createRelaySession({
|
|
|
309
309
|
|
|
310
310
|
return { stream, relay, attachSteer, close };
|
|
311
311
|
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The canonical host-connection transcript stream name for a supervised job
|
|
315
|
+
* (issue #173). Unlike {@link relayStreamName} (which keys purely on the
|
|
316
|
+
* jobKey, safe only when one process owns one identity), the single host
|
|
317
|
+
* connection multiplexes N workers, so the stream carries BOTH the owning
|
|
318
|
+
* `instance` and the `jobKey` explicitly — two workers' (or two jobs') streams
|
|
319
|
+
* over the one socket can never collide. Mirrors the endpoint's own
|
|
320
|
+
* `composeTranscriptStream`, exposed here only for observability/parity.
|
|
321
|
+
*
|
|
322
|
+
* @param {string} instance the owning worker instance
|
|
323
|
+
* @param {string|number} jobKey the activated job's key
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
export function hostRelayStreamName(instance, jobKey) {
|
|
327
|
+
return `t/${encodeURIComponent(String(instance))}/${encodeURIComponent(String(jobKey))}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Create a per-job relay session backed by the single-owner supervisor's ONE
|
|
332
|
+
* multiplexed host connection (issue #173) instead of a per-worker
|
|
333
|
+
* {@link createWorkChannel} socket.
|
|
334
|
+
*
|
|
335
|
+
* It is shape-compatible with {@link createRelaySession} — `runAgentJob`
|
|
336
|
+
* consumes the identical `{ relay, attachSteer, close }` contract — but every
|
|
337
|
+
* frame rides the supervisor's connection keyed by explicit `instance`/`jobKey`:
|
|
338
|
+
*
|
|
339
|
+
* - {@link RelaySession.relay} streams a terminal chunk over the connection's
|
|
340
|
+
* transcript lane via the injected `publish(text)` (the plugin wires it to
|
|
341
|
+
* `supervisor.transcript(instance, jobKey, bytes)`), so N agents' transcript
|
|
342
|
+
* streams multiplex over one socket without crossing.
|
|
343
|
+
* - {@link RelaySession.attachSteer} registers a per-instance inbound sink via
|
|
344
|
+
* the injected `subscribeSteer(onChunk) → unsubscribe` (the plugin wires it
|
|
345
|
+
* to `supervisor.steerRouter.register/unregister`), so cockpit → agent steer
|
|
346
|
+
* bytes fan back to exactly this job's PTY.
|
|
347
|
+
*
|
|
348
|
+
* Kept Effect-free and transport-agnostic (the plugin owns the Effect glue), so
|
|
349
|
+
* it is unit-testable with plain fakes. Every wire op is best-effort: a publish
|
|
350
|
+
* or steer failure is logged and swallowed, never crashing the worker — the
|
|
351
|
+
* ownership registry (claim/release), not the transcript, is the source of truth.
|
|
352
|
+
*
|
|
353
|
+
* @param {object} opts
|
|
354
|
+
* @param {string} opts.instance the owning worker instance (explicit on every frame)
|
|
355
|
+
* @param {string|number} opts.jobKey the activated job's key
|
|
356
|
+
* @param {(text: string) => void} opts.publish stream one transcript chunk over the host connection
|
|
357
|
+
* @param {(onChunk: (chunk: string|Uint8Array) => void) => (() => void)} [opts.subscribeSteer]
|
|
358
|
+
* register a steer-in sink for this instance; returns an unsubscribe fn
|
|
359
|
+
* @param {{ warn?: Function, debug?: Function }} [opts.logger]
|
|
360
|
+
* @returns {RelaySession}
|
|
361
|
+
*/
|
|
362
|
+
export function createHostRelaySession({ instance, jobKey, publish, subscribeSteer, logger } = {}) {
|
|
363
|
+
if (instance === undefined || instance === null || String(instance) === '') {
|
|
364
|
+
throw new Error('createHostRelaySession requires an instance');
|
|
365
|
+
}
|
|
366
|
+
if (jobKey === undefined || jobKey === null || String(jobKey) === '') {
|
|
367
|
+
throw new Error('createHostRelaySession requires a jobKey');
|
|
368
|
+
}
|
|
369
|
+
if (typeof publish !== 'function') {
|
|
370
|
+
throw new Error('createHostRelaySession requires a publish(text) sink');
|
|
371
|
+
}
|
|
372
|
+
const stream = hostRelayStreamName(instance, jobKey);
|
|
373
|
+
const log = logger || {};
|
|
374
|
+
|
|
375
|
+
const relay = (chunk) => {
|
|
376
|
+
if (chunk == null) return;
|
|
377
|
+
const text = typeof chunk === 'string'
|
|
378
|
+
? chunk
|
|
379
|
+
: Buffer.isBuffer(chunk)
|
|
380
|
+
? chunk.toString('utf8')
|
|
381
|
+
: Buffer.from(chunk).toString('utf8');
|
|
382
|
+
if (text === '') return;
|
|
383
|
+
try {
|
|
384
|
+
publish(text);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
try {
|
|
387
|
+
log.warn?.(`host relay publish failed for ${stream}: ${err?.message || err}`);
|
|
388
|
+
} catch {
|
|
389
|
+
/* never let a logging failure escape the relay path */
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// Each attachSteer call owns its own subscription + detach fn (mirrors
|
|
395
|
+
// createRelaySession), so a second attachSteer can't clobber an earlier one.
|
|
396
|
+
const activeDetaches = new Set();
|
|
397
|
+
const attachSteer = (write) => {
|
|
398
|
+
if (typeof write !== 'function' || typeof subscribeSteer !== 'function') return () => {};
|
|
399
|
+
let unsub;
|
|
400
|
+
try {
|
|
401
|
+
unsub = subscribeSteer((data) => {
|
|
402
|
+
const text = typeof data === 'string' ? data : Buffer.from(data).toString('utf8');
|
|
403
|
+
try {
|
|
404
|
+
write(text);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
try {
|
|
407
|
+
log.warn?.(`host steer-in write failed for ${stream}: ${err?.message || err}`);
|
|
408
|
+
} catch {
|
|
409
|
+
/* swallow */
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
} catch (err) {
|
|
414
|
+
try {
|
|
415
|
+
log.warn?.(`host steer-in subscribe failed for ${stream}: ${err?.message || err}`);
|
|
416
|
+
} catch {
|
|
417
|
+
/* swallow */
|
|
418
|
+
}
|
|
419
|
+
return () => {};
|
|
420
|
+
}
|
|
421
|
+
let detached = false;
|
|
422
|
+
const detach = () => {
|
|
423
|
+
if (detached) return;
|
|
424
|
+
detached = true;
|
|
425
|
+
activeDetaches.delete(detach);
|
|
426
|
+
try {
|
|
427
|
+
unsub?.();
|
|
428
|
+
} catch {
|
|
429
|
+
/* swallow */
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
activeDetaches.add(detach);
|
|
433
|
+
return detach;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// close() is idempotent: a second call returns the same settled promise
|
|
437
|
+
// without re-emitting the close marker or re-detaching. There is no outbound
|
|
438
|
+
// buffer to drain on the host connection (the supervisor owns reconnect +
|
|
439
|
+
// resync), so close resolves as soon as the close marker is emitted and every
|
|
440
|
+
// steer subscription is torn down.
|
|
441
|
+
let closed = false;
|
|
442
|
+
let closedPromise = Promise.resolve({ closeEmitted: false, drained: true, timedOut: false });
|
|
443
|
+
const close = () => {
|
|
444
|
+
if (closed) return closedPromise;
|
|
445
|
+
closed = true;
|
|
446
|
+
// Emit the closing lifecycle twin of RELAY_OPEN_CHUNK so the app can flush the
|
|
447
|
+
// durable transcript deterministically at completion (nano-workforce#710).
|
|
448
|
+
relay(RELAY_CLOSE_CHUNK);
|
|
449
|
+
for (const detach of [...activeDetaches]) detach();
|
|
450
|
+
closedPromise = Promise.resolve({ closeEmitted: true, drained: true, timedOut: false });
|
|
451
|
+
return closedPromise;
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// Open the stream the instant the session exists (parity with createRelaySession).
|
|
455
|
+
relay(RELAY_OPEN_CHUNK);
|
|
456
|
+
|
|
457
|
+
return { stream, relay, attachSteer, close };
|
|
458
|
+
}
|