c8ctl-plugin-nano 1.29.0 → 1.31.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/README.md +85 -1
- package/c8ctl-plugin.js +227 -10
- package/package.json +9 -8
- package/work-buffer.mjs +331 -0
- package/work-channel.mjs +1 -0
package/README.md
CHANGED
|
@@ -219,6 +219,66 @@ name is generated, so two `work reviewer` processes never collide at the
|
|
|
219
219
|
broker. (`--name` names the worker; the profile to run is always the
|
|
220
220
|
positional argument.)
|
|
221
221
|
|
|
222
|
+
### Live agent visibility: the `/agentic` channel + live terminals
|
|
223
|
+
|
|
224
|
+
A running worker can appear **live on the Workforce visibility page** and stream
|
|
225
|
+
its agents' terminals to an operator's cockpit. This rides the app's agentic
|
|
226
|
+
channel (ADR 0056), served **same-port** on the app's own HTTP base URL at path
|
|
227
|
+
**`/agentic`** — not a sidecar, so there's no extra port to open.
|
|
228
|
+
|
|
229
|
+
**Connecting.** Enrolment is opt-in: a worker only connects when it has both an
|
|
230
|
+
ADR 0028 **identity token** and a **capability credential** (the same `?token=…`
|
|
231
|
+
pattern the blackboard uses). Point it at the app and hand it the two secrets via
|
|
232
|
+
env (or persisted config); the channel URL defaults to the configured nano URL:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
# Enrol this worker on the app's same-port /agentic channel
|
|
236
|
+
export NANO_AGENTIC_URL=http://localhost:8080 # app base URL; channel is served at /agentic
|
|
237
|
+
export NANO_AGENTIC_TOKEN=<identity-token> # ADR 0028 identity
|
|
238
|
+
export NANO_AGENTIC_CREDENTIAL=<capability-cred> # capability credential
|
|
239
|
+
c8ctl nano work reviewer
|
|
240
|
+
# agentic channel: announcing presence as ‹worker› on ws://localhost:8080/agentic
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Without both secrets the worker runs **exactly as before, off the channel** — no
|
|
244
|
+
visibility, no relay, nothing else changes. A valid identity + capability
|
|
245
|
+
connects; an invalid identity is rejected (unauthorized) and a missing capability
|
|
246
|
+
is rejected (forbidden).
|
|
247
|
+
|
|
248
|
+
**How presence appears.** On connect the worker **announces** its identity, its
|
|
249
|
+
`host`, and the set of `jobKeys` it is currently running, then **heartbeats** to
|
|
250
|
+
stay live and **re-announces** after a reconnect so its row survives a hub
|
|
251
|
+
restart. When `work` stops it **deregisters cleanly**, so the worker disappears
|
|
252
|
+
from the visibility page on exit.
|
|
253
|
+
|
|
254
|
+
**Opting a role into a live terminal (PTY vs pipe).** Each role chooses whether
|
|
255
|
+
its agent harness runs on a full **PTY** (a real terminal — streamed on the relay
|
|
256
|
+
lane and **steerable**: an operator's keystrokes reach the running agent) or a
|
|
257
|
+
plain **pipe** (streamed, not interactive). It's a **per-role opt-in**, set on
|
|
258
|
+
the hire profile and defaulting to `pipe`:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
# Hire a role whose harness runs on a full, steerable PTY
|
|
262
|
+
c8ctl nano hire --name coder --rank senior --command copilot --terminal pty
|
|
263
|
+
|
|
264
|
+
# Override the mode for a one-off worker without re-hiring
|
|
265
|
+
NANO_AGENTIC_TERMINAL=pipe c8ctl nano work coder
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
A PTY needs the optional native `node-pty` dependency; when it is
|
|
269
|
+
**unavailable** (not installed, or Windows) a `pty` role **gracefully falls
|
|
270
|
+
back to a pipe** that still relays. (If `node-pty` is present but a PTY can't be
|
|
271
|
+
spawned at runtime, that job fails rather than falling back.) Each job's terminal
|
|
272
|
+
streams on its own relay stream
|
|
273
|
+
named `job:‹jobKey›`, so output and steer-in are routed from the `jobKey` alone.
|
|
274
|
+
|
|
275
|
+
**Surviving a hub outage.** A worker that starts **before** the app, or survives
|
|
276
|
+
a **hub restart**, buffers its outbound frames in a **bounded** local ring and
|
|
277
|
+
**drains them in order** on reconnect (no loss or reorder within the bound). The
|
|
278
|
+
bound is operator-tunable for a long expected outage; raise it with
|
|
279
|
+
`NANO_AGENTIC_BUFFER_CAPACITY` (frames). When the bound is hit the worker warns
|
|
280
|
+
rather than silently shedding.
|
|
281
|
+
|
|
222
282
|
### Live profile reload (no restart on `assign`)
|
|
223
283
|
|
|
224
284
|
A running `c8ctl nano work <name>` **watches** the profile it is servicing. When
|
|
@@ -332,8 +392,32 @@ composed in FEEL, a task may supply per-instance context via **`task.appendPromp
|
|
|
332
392
|
separator/preamble), so a null/empty append leaves the base untouched. This lets the
|
|
333
393
|
static prompt live in a model header/side-car while the dynamic tail (e.g. plan-revision
|
|
334
394
|
feedback, a per-task brief) is built per instance.
|
|
395
|
+
|
|
396
|
+
**Live prompts via linked resources.** A service task can declare a Zeebe/Camunda-parity
|
|
397
|
+
**linked resource** for its prompt instead of baking it into a model header:
|
|
398
|
+
|
|
399
|
+
```xml
|
|
400
|
+
<zeebe:linkedResources>
|
|
401
|
+
<zeebe:linkedResource resourceId="plan.md" bindingType="latest" linkName="prompt"/>
|
|
402
|
+
</zeebe:linkedResources>
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
At job activation the engine resolves the `resourceId` to the **latest** deployed key and
|
|
406
|
+
delivers a `linkedResources` custom header (`[{resourceKey, resourceType, linkName}]`). The
|
|
407
|
+
header carries the **key, not the content** — the worker fetches the bytes over the broker
|
|
408
|
+
REST API (`GET /v2/resources/{resourceKey}/content/binary`, reusing the same nano endpoint
|
|
409
|
+
the worker already talks to; override with `NANO_REST_URL`/`NANO_REST_TOKEN`) and uses the
|
|
410
|
+
UTF-8 content as the **base prompt**. The entry whose `linkName` is `prompt` wins over the
|
|
411
|
+
header-baked `task.prompt` chain; `appendPrompt` still composes onto it. Redeploying just
|
|
412
|
+
the resource updates the prompt for the **next activation** — no process redeploy, no
|
|
413
|
+
worker restart. Jobs without `linkedResources` behave exactly as before (fallback chain).
|
|
414
|
+
A declared prompt resource that can't be fetched **fails the job** (retryable provisioning
|
|
415
|
+
error) rather than running an agent with an empty prompt, and the resolved `resourceKey` is
|
|
416
|
+
logged and echoed on the output envelope as `promptResourceKey` for audit (the engine keeps
|
|
417
|
+
only `latest`, so the key is the reproducibility handle).
|
|
418
|
+
|
|
335
419
|
On completion the plugin writes an **output envelope** back under
|
|
336
|
-
`io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error}`). When a repository was
|
|
420
|
+
`io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error, promptResourceKey?}`). When a repository was
|
|
337
421
|
provisioned (below) it also carries `{repository, branch, baseSha, headSha, commits[], pushed, pushError?, gitError?, pr?}`.
|
|
338
422
|
|
|
339
423
|
**Git provisioning (host).** When `--sandbox none` (the default) and the envelope
|
package/c8ctl-plugin.js
CHANGED
|
@@ -59,6 +59,7 @@ import { createInterface as createReadline } from 'node:readline';
|
|
|
59
59
|
import { platformForHost } from './platforms.mjs';
|
|
60
60
|
import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
|
|
61
61
|
import { createRelaySession, roleTerminalMode } from './work-relay.mjs';
|
|
62
|
+
import { createBufferMonitor, resolveBufferCapacity } from './work-buffer.mjs';
|
|
62
63
|
|
|
63
64
|
const requireFromHere = createRequire(import.meta.url);
|
|
64
65
|
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -2071,6 +2072,15 @@ function killTree(child) {
|
|
|
2071
2072
|
// envelope is written back on the job's completion variables.
|
|
2072
2073
|
const AGENT_TASK_NS = 'io.nanobpm.agentTask';
|
|
2073
2074
|
const AGENT_RESULT_KEY = 'io.nanobpm.agentResult';
|
|
2075
|
+
// Zeebe/Camunda-parity linked resources (issue #63). At job activation the
|
|
2076
|
+
// engine resolves each declared `<zeebe:linkedResource resourceId … linkName>`
|
|
2077
|
+
// to the LATEST deployed key and delivers this custom header: a JSON array of
|
|
2078
|
+
// `{ resourceKey, resourceType, linkName }`. The header carries the key, not the
|
|
2079
|
+
// content — the worker fetches the bytes over the broker REST API. The entry
|
|
2080
|
+
// whose `linkName` matches DEFAULT_PROMPT_LINK_NAME supplies the agent's base
|
|
2081
|
+
// prompt (live-updatable by redeploying just the resource).
|
|
2082
|
+
const LINKED_RESOURCES_HEADER = 'linkedResources';
|
|
2083
|
+
const DEFAULT_PROMPT_LINK_NAME = 'prompt';
|
|
2074
2084
|
const TASK_ENVELOPE_SCHEMA_VERSION = 1;
|
|
2075
2085
|
// The result-envelope version is intentionally independent of the task-envelope
|
|
2076
2086
|
// version so the two contracts can evolve separately without silently coupling.
|
|
@@ -2235,7 +2245,13 @@ function collectEnvelopeFrom(source) {
|
|
|
2235
2245
|
|
|
2236
2246
|
// Normalize the assembled envelope to schema v1, coercing header string values
|
|
2237
2247
|
// (element templates write everything as strings) into bool/int as needed.
|
|
2238
|
-
|
|
2248
|
+
//
|
|
2249
|
+
// `opts.basePromptOverride` (issue #63): when a `linkName: prompt` linked
|
|
2250
|
+
// resource resolves, its fetched content is passed here and WINS over the
|
|
2251
|
+
// header-baked `task.prompt` / `variables.prompt` / `variables.task` chain. The
|
|
2252
|
+
// FEEL-composed `appendPrompt` concatenation is unchanged — it still appends to
|
|
2253
|
+
// whatever base was resolved.
|
|
2254
|
+
function normalizeTaskEnvelope(customHeaders, variables, opts = {}) {
|
|
2239
2255
|
const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
|
|
2240
2256
|
const str = (v) => (v == null ? undefined : String(v));
|
|
2241
2257
|
const env = {
|
|
@@ -2271,9 +2287,12 @@ function normalizeTaskEnvelope(customHeaders, variables) {
|
|
|
2271
2287
|
};
|
|
2272
2288
|
|
|
2273
2289
|
const task = isPlainObject(raw.task) ? raw.task : {};
|
|
2274
|
-
// Base prompt:
|
|
2275
|
-
// else
|
|
2276
|
-
|
|
2290
|
+
// Base prompt: an override supplied by a resolved `linkName: prompt` linked
|
|
2291
|
+
// resource (issue #63) wins; else the reserved `task.prompt` (typically a model
|
|
2292
|
+
// header filled at deploy time), else a plain `prompt`/`task` job variable (the
|
|
2293
|
+
// pre-header delivery path).
|
|
2294
|
+
const overrideBase = opts && opts.basePromptOverride != null ? String(opts.basePromptOverride) : undefined;
|
|
2295
|
+
const basePrompt = overrideBase ?? str(task.prompt) ?? str(variables?.prompt) ?? str(variables?.task);
|
|
2277
2296
|
// Verbatim dynamic append: a header-delivered base prompt can't be composed in FEEL, so a task
|
|
2278
2297
|
// may supply per-instance context (e.g. plan-revision feedback, a per-task brief) via the
|
|
2279
2298
|
// reserved `task.appendPrompt`, or a plain `appendPrompt` variable. It is concatenated onto the
|
|
@@ -2296,7 +2315,133 @@ function normalizeTaskEnvelope(customHeaders, variables) {
|
|
|
2296
2315
|
return env;
|
|
2297
2316
|
}
|
|
2298
2317
|
|
|
2299
|
-
// ----
|
|
2318
|
+
// ---- Linked resources → live agent prompt (issue #63) ----------------------
|
|
2319
|
+
// A job's `linkedResources` activation header carries KEYS (not content); the
|
|
2320
|
+
// worker resolves each to the LATEST deployed bytes over the broker REST API, so
|
|
2321
|
+
// an agent prompt can be updated by redeploying one Markdown resource — no
|
|
2322
|
+
// process redeploy, no harness restart.
|
|
2323
|
+
|
|
2324
|
+
// Parse the `linkedResources` custom header off an activated job. The engine
|
|
2325
|
+
// delivers a JSON array; element-template/header transport stringifies it. Also
|
|
2326
|
+
// tolerate an already-parsed array. Anything malformed/absent → [] (fallback
|
|
2327
|
+
// path). Never throws.
|
|
2328
|
+
function parseLinkedResources(customHeaders) {
|
|
2329
|
+
if (!isPlainObject(customHeaders)) return [];
|
|
2330
|
+
const raw = customHeaders[LINKED_RESOURCES_HEADER];
|
|
2331
|
+
if (raw == null) return [];
|
|
2332
|
+
let val = raw;
|
|
2333
|
+
if (typeof raw === 'string') {
|
|
2334
|
+
if (!raw.trim()) return [];
|
|
2335
|
+
try { val = JSON.parse(raw); } catch { return []; }
|
|
2336
|
+
}
|
|
2337
|
+
if (!Array.isArray(val)) return [];
|
|
2338
|
+
return val.filter((e) => isPlainObject(e) && e.resourceKey != null);
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// Select the linked resource that supplies the base prompt: the first entry
|
|
2342
|
+
// whose `linkName` matches (default `prompt`). Returns the entry or null.
|
|
2343
|
+
function pickLinkedResource(linkedResources, linkName = DEFAULT_PROMPT_LINK_NAME) {
|
|
2344
|
+
if (!Array.isArray(linkedResources)) return null;
|
|
2345
|
+
return linkedResources.find((e) => isPlainObject(e) && String(e.linkName) === String(linkName)) || null;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// The broker REST base URL + optional bearer the harness uses to fetch resource
|
|
2349
|
+
// content — the SAME nano endpoint the worker already talks to (env wins over
|
|
2350
|
+
// persisted config, falling back to the default localhost port). A local nano
|
|
2351
|
+
// cluster is unauthenticated, so the token is optional; when set (e.g. against a
|
|
2352
|
+
// secured gateway) it is sent as a Bearer credential.
|
|
2353
|
+
// Same-origin test for two URLs (protocol + host + port). Returns false if
|
|
2354
|
+
// either string is missing or unparseable, so a token is never forwarded to an
|
|
2355
|
+
// endpoint we can't positively confirm matches.
|
|
2356
|
+
function sameOrigin(a, b) {
|
|
2357
|
+
if (!a || !b) return false;
|
|
2358
|
+
try {
|
|
2359
|
+
return new URL(a).origin === new URL(b).origin;
|
|
2360
|
+
} catch {
|
|
2361
|
+
return false;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
function resolveBrokerRestConfig(env = process.env) {
|
|
2366
|
+
// readConfig() swallows parse/IO errors and never throws (returns {}), so no
|
|
2367
|
+
// local try/catch is needed here.
|
|
2368
|
+
const cfg = readConfig() || {};
|
|
2369
|
+
const baseUrl =
|
|
2370
|
+
env.NANO_REST_URL ||
|
|
2371
|
+
env.NANO_BASE_URL ||
|
|
2372
|
+
cfg.nanoUrl ||
|
|
2373
|
+
DEFAULT_NANO_URL;
|
|
2374
|
+
// An explicit REST token always wins. The agentic identity token is only a
|
|
2375
|
+
// fallback for single-token deployments where the broker REST endpoint IS the
|
|
2376
|
+
// agentic endpoint — so only forward it when the REST base URL is same-origin
|
|
2377
|
+
// as the agentic URL. This prevents leaking the identity token to a different
|
|
2378
|
+
// NANO_REST_URL host when no REST token is set (see resolveAgenticConfig).
|
|
2379
|
+
let token = env.NANO_REST_TOKEN || '';
|
|
2380
|
+
if (!token) {
|
|
2381
|
+
const agenticToken = env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
|
|
2382
|
+
const agenticUrl =
|
|
2383
|
+
env.NANO_AGENTIC_URL ||
|
|
2384
|
+
cfg.agenticUrl ||
|
|
2385
|
+
cfg.nanoUrl ||
|
|
2386
|
+
env.NANO_BASE_URL ||
|
|
2387
|
+
DEFAULT_NANO_URL;
|
|
2388
|
+
if (agenticToken && sameOrigin(baseUrl, agenticUrl)) token = agenticToken;
|
|
2389
|
+
}
|
|
2390
|
+
return { baseUrl, token };
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// Build the content endpoint. Per issue #63 / nano-bpm #759 the non-binary
|
|
2394
|
+
// `/content` variant is deprecated for non-RPA types (Markdown → 406), so the
|
|
2395
|
+
// worker always fetches `/content/binary`.
|
|
2396
|
+
function resourceContentUrl(baseUrl, resourceKey) {
|
|
2397
|
+
const base = String(baseUrl || '').replace(/\/+$/, '');
|
|
2398
|
+
return `${base}/v2/resources/${encodeURIComponent(String(resourceKey))}/content/binary`;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// Fetch a linked resource's bytes and decode UTF-8. A non-2xx or network error
|
|
2402
|
+
// is surfaced as a ProvisionError so the caller fails the job with a clear
|
|
2403
|
+
// provisioning message (never runs an agent with a silently-empty prompt).
|
|
2404
|
+
async function fetchLinkedResourceContent(resourceKey, opts = {}) {
|
|
2405
|
+
const { baseUrl, token, fetchImpl = fetch, timeoutMs = 15_000 } = opts;
|
|
2406
|
+
const url = resourceContentUrl(baseUrl, resourceKey);
|
|
2407
|
+
const controller = new AbortController();
|
|
2408
|
+
const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
2409
|
+
let res;
|
|
2410
|
+
try {
|
|
2411
|
+
const headers = { Accept: 'application/octet-stream' };
|
|
2412
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
2413
|
+
res = await fetchImpl(url, { method: 'GET', headers, signal: controller.signal });
|
|
2414
|
+
} catch (err) {
|
|
2415
|
+
throw new ProvisionError(`prompt resource ${resourceKey} fetch failed: ${err && err.message ? err.message : String(err)}`);
|
|
2416
|
+
} finally {
|
|
2417
|
+
if (timer) clearTimeout(timer);
|
|
2418
|
+
}
|
|
2419
|
+
if (!res || !res.ok) {
|
|
2420
|
+
const status = res ? res.status : '?';
|
|
2421
|
+
throw new ProvisionError(`prompt resource ${resourceKey} fetch failed: HTTP ${status} from ${url}`);
|
|
2422
|
+
}
|
|
2423
|
+
try {
|
|
2424
|
+
return await res.text();
|
|
2425
|
+
} catch (err) {
|
|
2426
|
+
throw new ProvisionError(`prompt resource ${resourceKey} decode failed: ${err && err.message ? err.message : String(err)}`);
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// Resolve the base prompt from a `linkName: prompt` linked resource, if the job
|
|
2431
|
+
// declares one. Returns `{ basePrompt, resourceKey }` when a prompt resource is
|
|
2432
|
+
// present and fetched, or null when no such entry exists (→ fall back to the
|
|
2433
|
+
// header-baked task.prompt chain). Throws a ProvisionError when a declared
|
|
2434
|
+
// prompt resource cannot be fetched — a provisioning failure, not a silent
|
|
2435
|
+
// empty-prompt run.
|
|
2436
|
+
async function resolveLinkedPrompt(customHeaders, opts = {}) {
|
|
2437
|
+
const { linkName = DEFAULT_PROMPT_LINK_NAME, baseUrl, token, fetchImpl, timeoutMs } = opts;
|
|
2438
|
+
const entry = pickLinkedResource(parseLinkedResources(customHeaders), linkName);
|
|
2439
|
+
if (!entry) return null;
|
|
2440
|
+
const resourceKey = entry.resourceKey;
|
|
2441
|
+
const basePrompt = await fetchLinkedResourceContent(resourceKey, { baseUrl, token, fetchImpl, timeoutMs });
|
|
2442
|
+
return { basePrompt, resourceKey, resourceType: entry.resourceType ?? null, linkName: String(linkName) };
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2300
2445
|
// Secrets are referenced by NAME, never by value, in the model. A resolver maps
|
|
2301
2446
|
// a name → value at run time. The wrapper injects them into the child ENV, so
|
|
2302
2447
|
// values never appear in argv or `docker inspect`.
|
|
@@ -3415,7 +3560,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3415
3560
|
|
|
3416
3561
|
// Shape the io.nanobpm.agentResult output envelope. When a repository was
|
|
3417
3562
|
// provisioned (increment 2a), the `git` block adds branch/commits/push/PR facts.
|
|
3418
|
-
function buildResultEnvelope(result, { sandbox, image, git, result: agentResult } = {}) {
|
|
3563
|
+
function buildResultEnvelope(result, { sandbox, image, git, result: agentResult, promptResourceKey } = {}) {
|
|
3419
3564
|
const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
|
|
3420
3565
|
const env = {
|
|
3421
3566
|
schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
|
|
@@ -3429,6 +3574,10 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
|
|
|
3429
3574
|
signal: result.signal ?? null,
|
|
3430
3575
|
error: result.error ?? null,
|
|
3431
3576
|
};
|
|
3577
|
+
// Audit (issue #63): record which linked-resource key supplied the base prompt.
|
|
3578
|
+
// The engine only keeps `latest` per resourceId (no pinning), so recording the
|
|
3579
|
+
// resolved key is the only reproducibility handle for which prompt version ran.
|
|
3580
|
+
if (promptResourceKey != null) env.promptResourceKey = String(promptResourceKey);
|
|
3432
3581
|
// The agent's structured result (as returned via $AGENT_RESULT_FILE / sentinel),
|
|
3433
3582
|
// preserved verbatim for auditability even when merged into the completion vars.
|
|
3434
3583
|
if (isPlainObject(agentResult)) env.result = agentResult;
|
|
@@ -3457,7 +3606,7 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
|
|
|
3457
3606
|
* and a capability credential are present (enrolment) — absent either, it runs
|
|
3458
3607
|
* exactly as before, off the visibility page. Returns `null` when not enrolled.
|
|
3459
3608
|
*
|
|
3460
|
-
* @returns {{ url: string, token: string, credential: string } | null}
|
|
3609
|
+
* @returns {{ url: string, token: string, credential: string, bufferCapacity: number } | null}
|
|
3461
3610
|
*/
|
|
3462
3611
|
function resolveAgenticConfig() {
|
|
3463
3612
|
const cfg = readConfig();
|
|
@@ -3469,7 +3618,13 @@ function resolveAgenticConfig() {
|
|
|
3469
3618
|
const token = process.env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
|
|
3470
3619
|
const credential = process.env.NANO_AGENTIC_CREDENTIAL || cfg.agenticCredential || '';
|
|
3471
3620
|
if (!url || !token || !credential) return null;
|
|
3472
|
-
|
|
3621
|
+
// Outbound hub-down buffer bound (frames). Operator-tunable (C4, #43) so a
|
|
3622
|
+
// long expected outage can be given more headroom; resolveBufferCapacity
|
|
3623
|
+
// validates it to a positive integer and falls back to the client default.
|
|
3624
|
+
const bufferCapacity = resolveBufferCapacity(
|
|
3625
|
+
process.env.NANO_AGENTIC_BUFFER_CAPACITY ?? cfg.agenticBufferCapacity,
|
|
3626
|
+
);
|
|
3627
|
+
return { url, token, credential, bufferCapacity };
|
|
3473
3628
|
}
|
|
3474
3629
|
|
|
3475
3630
|
/**
|
|
@@ -3678,6 +3833,10 @@ async function workAgent(req, flags) {
|
|
|
3678
3833
|
const jobTypes = [...new Set([...matrix, ...extraJobTypes])];
|
|
3679
3834
|
const camunda = globalThis.c8ctl.createClient();
|
|
3680
3835
|
|
|
3836
|
+
// Broker REST endpoint for live linked-resource prompts (issue #63) — the same
|
|
3837
|
+
// nano endpoint this worker already talks to. Resolved once at startup.
|
|
3838
|
+
const restConfig = resolveBrokerRestConfig();
|
|
3839
|
+
|
|
3681
3840
|
logger.info(`Putting "${name}" [${profile.rank}] to work → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
|
|
3682
3841
|
logger.info(` worker: ${workerName}`);
|
|
3683
3842
|
logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
@@ -3720,6 +3879,8 @@ async function workAgent(req, flags) {
|
|
|
3720
3879
|
// recorders can refresh presence with the live job set as jobs start/end.
|
|
3721
3880
|
/** @type {import('./work-channel.mjs').WorkChannel | null} */
|
|
3722
3881
|
let workChannel = null;
|
|
3882
|
+
/** @type {import('./work-buffer.mjs').BufferMonitor | null} */
|
|
3883
|
+
let bufferMonitor = null;
|
|
3723
3884
|
// Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
|
|
3724
3885
|
// file (gated inside writeActivity) AND the agentic presence frame's live
|
|
3725
3886
|
// jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
|
|
@@ -3763,6 +3924,7 @@ async function workAgent(req, flags) {
|
|
|
3763
3924
|
url: agenticCfg.url,
|
|
3764
3925
|
token: agenticCfg.token,
|
|
3765
3926
|
credential: agenticCfg.credential,
|
|
3927
|
+
bufferCapacity: agenticCfg.bufferCapacity,
|
|
3766
3928
|
logger,
|
|
3767
3929
|
});
|
|
3768
3930
|
const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
|
|
@@ -3772,6 +3934,23 @@ async function workAgent(req, flags) {
|
|
|
3772
3934
|
workChannel = null;
|
|
3773
3935
|
logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
|
|
3774
3936
|
}
|
|
3937
|
+
// C4 (#43): observe the client's built-in outbound buffer across the
|
|
3938
|
+
// channel lifecycle — surface a high-water mark and warn when the bound
|
|
3939
|
+
// is hit so a hub outage that starts shedding frames is never silent. The
|
|
3940
|
+
// monitor is observability-only, so keep it OUTSIDE the channel try/catch:
|
|
3941
|
+
// a monitor failure must never null out a healthy channel and take down
|
|
3942
|
+
// presence/visibility.
|
|
3943
|
+
if (workChannel) {
|
|
3944
|
+
try {
|
|
3945
|
+
bufferMonitor = createBufferMonitor(workChannel, {
|
|
3946
|
+
capacity: agenticCfg.bufferCapacity,
|
|
3947
|
+
logger,
|
|
3948
|
+
});
|
|
3949
|
+
} catch (err) {
|
|
3950
|
+
bufferMonitor = null;
|
|
3951
|
+
logger.warn(` agentic buffer monitor unavailable (${err?.message || err}); channel presence still active.`);
|
|
3952
|
+
}
|
|
3953
|
+
}
|
|
3775
3954
|
} else {
|
|
3776
3955
|
logger.info(' agentic channel: not enrolled (set NANO_AGENTIC_URL + NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL to appear on the visibility page).');
|
|
3777
3956
|
}
|
|
@@ -3826,9 +4005,33 @@ async function workAgent(req, flags) {
|
|
|
3826
4005
|
}
|
|
3827
4006
|
}
|
|
3828
4007
|
|
|
4008
|
+
// Live agent prompt (issue #63): if the job declares a `linkName: prompt`
|
|
4009
|
+
// linked resource, fetch its LATEST deployed content and use it as the
|
|
4010
|
+
// base prompt (it wins over the header-baked task.prompt). A declared
|
|
4011
|
+
// prompt resource that can't be fetched is a provisioning failure — fail
|
|
4012
|
+
// (retryable) rather than run an agent with an empty prompt.
|
|
4013
|
+
let promptResourceKey = null;
|
|
4014
|
+
let basePromptOverride;
|
|
4015
|
+
try {
|
|
4016
|
+
const linked = await resolveLinkedPrompt(job.customHeaders ?? {}, {
|
|
4017
|
+
baseUrl: restConfig.baseUrl,
|
|
4018
|
+
token: restConfig.token,
|
|
4019
|
+
});
|
|
4020
|
+
if (linked) {
|
|
4021
|
+
basePromptOverride = linked.basePrompt;
|
|
4022
|
+
promptResourceKey = linked.resourceKey;
|
|
4023
|
+
logger.info(`[${jobType}] job ${job.jobKey} base prompt from linked resource key ${promptResourceKey} (linkName=${linked.linkName}, ${Buffer.byteLength(String(basePromptOverride), 'utf8')} bytes)`);
|
|
4024
|
+
}
|
|
4025
|
+
} catch (err) {
|
|
4026
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
4027
|
+
const msg = err instanceof ProvisionError ? err.message : `prompt resource fetch failed: ${err.message}`;
|
|
4028
|
+
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
4029
|
+
return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
4030
|
+
}
|
|
4031
|
+
|
|
3829
4032
|
// Assemble + normalize the task envelope from headers (defaults) and
|
|
3830
4033
|
// variables (overrides), then resolve any secrets it references.
|
|
3831
|
-
const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {});
|
|
4034
|
+
const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {}, { basePromptOverride });
|
|
3832
4035
|
const { resolved, missing, names } = resolveJobSecrets(secretResolver, envelope);
|
|
3833
4036
|
if (missing.length > 0) {
|
|
3834
4037
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
@@ -3973,7 +4176,7 @@ async function workAgent(req, flags) {
|
|
|
3973
4176
|
if (resultDir) { try { rmSync(resultDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(resultDir); }
|
|
3974
4177
|
const resultVars = sanitizeResultVars(rawResult);
|
|
3975
4178
|
|
|
3976
|
-
const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult });
|
|
4179
|
+
const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult, promptResourceKey });
|
|
3977
4180
|
if (result.ok) {
|
|
3978
4181
|
const gitNote = gitResult
|
|
3979
4182
|
? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.pushError ? 'push FAILED' : 'not pushed')) : 'no branch to push'}${gitResult.pr?.found ? `, PR #${gitResult.pr.number}` : ''}]`
|
|
@@ -4189,6 +4392,10 @@ async function workAgent(req, flags) {
|
|
|
4189
4392
|
// from the page only once its jobs have drained. Best-effort — a channel
|
|
4190
4393
|
// teardown must never hang shutdown.
|
|
4191
4394
|
if (workChannel) {
|
|
4395
|
+
// Stop the buffer monitor first so its sampler can't fire mid-teardown.
|
|
4396
|
+
try {
|
|
4397
|
+
bufferMonitor?.stop();
|
|
4398
|
+
} catch { /* best effort */ }
|
|
4192
4399
|
try {
|
|
4193
4400
|
await workChannel.stop(`worker stopped (${signal})`);
|
|
4194
4401
|
logger.info('Deregistered from the agentic visibility channel.');
|
|
@@ -6863,6 +7070,12 @@ export {
|
|
|
6863
7070
|
export {
|
|
6864
7071
|
normalizeTaskEnvelope,
|
|
6865
7072
|
collectEnvelopeFrom,
|
|
7073
|
+
parseLinkedResources,
|
|
7074
|
+
pickLinkedResource,
|
|
7075
|
+
resolveBrokerRestConfig,
|
|
7076
|
+
resourceContentUrl,
|
|
7077
|
+
fetchLinkedResourceContent,
|
|
7078
|
+
resolveLinkedPrompt,
|
|
6866
7079
|
coerceBool,
|
|
6867
7080
|
coerceInt,
|
|
6868
7081
|
deepMerge,
|
|
@@ -6909,6 +7122,8 @@ export {
|
|
|
6909
7122
|
derivePollTimeoutMs,
|
|
6910
7123
|
AGENT_TASK_NS,
|
|
6911
7124
|
AGENT_RESULT_KEY,
|
|
7125
|
+
LINKED_RESOURCES_HEADER,
|
|
7126
|
+
DEFAULT_PROMPT_LINK_NAME,
|
|
6912
7127
|
RESULT_SENTINEL,
|
|
6913
7128
|
RESERVED_RESULT_KEYS,
|
|
6914
7129
|
SANDBOXES,
|
|
@@ -6982,9 +7197,11 @@ export const metadata = {
|
|
|
6982
7197
|
{ command: 'c8ctl nano hire --name coder --rank senior --command copilot --env COPILOT_ENABLE_ALL_TOOLS=1', description: 'Persist a harness startup env var (e.g. permissions) on the profile' },
|
|
6983
7198
|
{ command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
|
|
6984
7199
|
{ command: 'c8ctl nano hire --name coder --rank senior --command "agent-harness" --sandbox docker --image ghcr.io/acme/agent:1', description: 'Create a profile that runs each job in a throwaway Docker container' },
|
|
7200
|
+
{ command: 'c8ctl nano hire --name coder --rank senior --command copilot --terminal pty', description: 'Opt this role into a full, steerable live terminal (PTY) streamed on the agentic relay lane (default: pipe)' },
|
|
6985
7201
|
{ command: 'c8ctl nano assign reviewer code-review,testing', description: 'Grant more capabilities (comma-separated, like hire) to an existing hire — additive; running workers hot-reload it' },
|
|
6986
7202
|
{ command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
|
|
6987
7203
|
{ command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
|
|
7204
|
+
{ command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_TOKEN=<identity-token> NANO_AGENTIC_CREDENTIAL=<capability-cred> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel so it appears live (presence + relay terminals) on the Workforce visibility page' },
|
|
6988
7205
|
{ command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
|
|
6989
7206
|
{ command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
|
|
6990
7207
|
{ command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, serviced job / idle, restarts, uptime) without the console' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.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",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"agentic-loader-hook.mjs",
|
|
27
27
|
"work-channel.mjs",
|
|
28
28
|
"work-relay.mjs",
|
|
29
|
+
"work-buffer.mjs",
|
|
29
30
|
"nanobpmn-binary.json",
|
|
30
31
|
"README.md"
|
|
31
32
|
],
|
|
@@ -56,12 +57,12 @@
|
|
|
56
57
|
},
|
|
57
58
|
"optionalDependencies": {
|
|
58
59
|
"node-pty": "^1.0.0",
|
|
59
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.31.0",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.31.0",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.31.0",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.31.0",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.31.0",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.31.0",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.31.0"
|
|
66
67
|
}
|
|
67
68
|
}
|
package/work-buffer.mjs
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// The `work` command's hub-down buffer observability + policy layer
|
|
2
|
+
// (ADR 0056 — slice C4, jwulf/c8ctl-plugin-nano#43).
|
|
3
|
+
//
|
|
4
|
+
// C4 makes a running worker survive hub disconnects: while the app hub is
|
|
5
|
+
// unreachable (the worker started before the app, or the hub restarted) the
|
|
6
|
+
// worker keeps producing frames, and they drain — bounded and in order — when
|
|
7
|
+
// the channel comes back.
|
|
8
|
+
//
|
|
9
|
+
// DERIVATION OVER DUPLICATION. The bounded local buffer this slice is about
|
|
10
|
+
// ALREADY exists as the connected client's built-in `OutboundRing` (in
|
|
11
|
+
// `@nanobpm/urban-agent-client`): a QoS-aware, capacity-bounded ring that holds
|
|
12
|
+
// every outbound frame while the socket is down and drains in strict lane
|
|
13
|
+
// priority (control → interactive → bulk, FIFO within a lane) on reconnect,
|
|
14
|
+
// shedding the single least-important frame on overflow. It sits at the
|
|
15
|
+
// TRANSPORT seam — below the lanes — so it captures any lane's frames and is
|
|
16
|
+
// therefore independent of C3's relay producer. We do NOT re-declare a second
|
|
17
|
+
// ring here (that would be a parallel, drift-prone buffer over the same
|
|
18
|
+
// frames); we CONSUME the canonical one through C2's `WorkChannel` seam.
|
|
19
|
+
//
|
|
20
|
+
// What this slice actually adds over C2's client is the two things the built-in
|
|
21
|
+
// ring leaves implicit:
|
|
22
|
+
//
|
|
23
|
+
// 1. The bound is OPERATOR-CONFIGURABLE, not a buried literal — see
|
|
24
|
+
// {@link resolveBufferCapacity} (wired to `NANO_AGENTIC_BUFFER_CAPACITY`
|
|
25
|
+
// in `resolveAgenticConfig`), so a long expected outage can be given more
|
|
26
|
+
// headroom without a code change.
|
|
27
|
+
// 2. The drop/backpressure policy is OBSERVABLE. The client sheds overflow
|
|
28
|
+
// frames silently (`relay()` returns void; the evicted frame is dropped
|
|
29
|
+
// inside the ring). {@link createBufferMonitor} turns that silent bound
|
|
30
|
+
// into a visible signal: it watches the buffer depth across C2's
|
|
31
|
+
// connect / disconnect / reconnect lifecycle, records a high-water mark
|
|
32
|
+
// and each outage→flush, and warns when the bound is hit so a hit bound is
|
|
33
|
+
// never silent data loss.
|
|
34
|
+
//
|
|
35
|
+
// The monitor is driven ENTIRELY by C2's lifecycle events + the client's
|
|
36
|
+
// buffer-drained event; it never opens, authenticates, or re-instantiates the
|
|
37
|
+
// channel, and it never produces frames of its own. It is pure observation over
|
|
38
|
+
// the one connected client.
|
|
39
|
+
|
|
40
|
+
// The outbound-ring bound (frames) the client buffers while the hub is
|
|
41
|
+
// unreachable. Single-sourced from the transport seam (`work-channel.mjs`),
|
|
42
|
+
// which applies it to the client, so the "falls back to the client default"
|
|
43
|
+
// contract stays accurate from one edit — no drift-prone second literal here.
|
|
44
|
+
import { DEFAULT_BUFFER_CAPACITY } from './work-channel.mjs';
|
|
45
|
+
|
|
46
|
+
const DEFAULT_SAMPLE_INTERVAL_MS = 1_000;
|
|
47
|
+
|
|
48
|
+
export { DEFAULT_BUFFER_CAPACITY };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the outbound-buffer bound (in frames) from an operator-supplied value
|
|
52
|
+
* with a sane fallback. The bound MUST be a positive integer — the client's
|
|
53
|
+
* `OutboundRing` throws on a non-positive capacity, so we validate here and fall
|
|
54
|
+
* back rather than let a typo wedge enrolment. Accepts a number or a numeric
|
|
55
|
+
* string (env vars arrive as strings).
|
|
56
|
+
*
|
|
57
|
+
* @param {unknown} raw the operator value (e.g. `process.env.NANO_AGENTIC_BUFFER_CAPACITY`)
|
|
58
|
+
* @param {number} [fallback] the default when `raw` is absent/invalid
|
|
59
|
+
* @returns {number} a positive-integer frame bound
|
|
60
|
+
*/
|
|
61
|
+
export function resolveBufferCapacity(raw, fallback = DEFAULT_BUFFER_CAPACITY) {
|
|
62
|
+
const base = Number.isInteger(fallback) && fallback > 0 ? fallback : DEFAULT_BUFFER_CAPACITY;
|
|
63
|
+
if (raw === undefined || raw === null || raw === '') return base;
|
|
64
|
+
const n = typeof raw === 'number' ? raw : Number(String(raw).trim());
|
|
65
|
+
if (!Number.isInteger(n) || n < 1) return base;
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {object} BufferHealth
|
|
71
|
+
* @property {number} capacity the configured frame bound
|
|
72
|
+
* @property {number} buffered frames currently held awaiting a live channel
|
|
73
|
+
* @property {boolean} connected whether the channel is currently open
|
|
74
|
+
* @property {number} highWaterMark the deepest buffer depth observed
|
|
75
|
+
* @property {number} outages number of times the channel went from up→down (buffering began)
|
|
76
|
+
* @property {number} reconnects number of times the channel recovered (up again after a drop)
|
|
77
|
+
* @property {number} flushes number of outage backlogs that fully drained on (re)connect
|
|
78
|
+
* @property {number} lastFlushFrames backlog size captured at the (re)connect that drove the last flush
|
|
79
|
+
* @property {number|null} lastFlushAt timestamp (ms) the last flush completed, or null
|
|
80
|
+
* @property {number} atCapacityEvents times a sample found the buffer at/over its bound (overflow shedding)
|
|
81
|
+
* @property {boolean} atCapacity whether the last sample was at/over the bound
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @typedef {object} BufferMonitor
|
|
86
|
+
* @property {() => BufferHealth} health snapshot of the buffer's current health/metrics
|
|
87
|
+
* @property {() => number} sample take a depth sample now (updates high-water / at-capacity); returns the depth
|
|
88
|
+
* @property {() => void} stop detach all listeners and stop sampling (idempotent)
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Observe the connected client's built-in outbound buffer across C2's channel
|
|
93
|
+
* lifecycle and surface its health + the (otherwise silent) drop policy.
|
|
94
|
+
*
|
|
95
|
+
* The monitor:
|
|
96
|
+
* - reads live depth via `channel.buffered()` (the client's `OutboundRing`
|
|
97
|
+
* size) — it does not hold its own buffer;
|
|
98
|
+
* - on the FIRST connect and every RECONNECT, captures the backlog about to
|
|
99
|
+
* flush (the client fires `onOpen`/our lifecycle listeners BEFORE it pumps
|
|
100
|
+
* the ring, so the depth read here is the pre-drain backlog) and, when the
|
|
101
|
+
* client's `onDrain` then fires (ring emptied after sending), records the
|
|
102
|
+
* completed flush;
|
|
103
|
+
* - on DISCONNECT (or immediately, if the worker starts before the app and is
|
|
104
|
+
* not connected yet), enters an "outage" and samples depth periodically so a
|
|
105
|
+
* growing backlog that hits the bound is noticed and warned about;
|
|
106
|
+
* - keeps a high-water mark and an at-capacity counter, warning (once per
|
|
107
|
+
* transition into the at-capacity state, to avoid log spam) so operators see
|
|
108
|
+
* that the bound is shedding frames.
|
|
109
|
+
*
|
|
110
|
+
* @param {import('./work-channel.mjs').WorkChannel} channel the C2 seam holder
|
|
111
|
+
* @param {object} [opts]
|
|
112
|
+
* @param {number} [opts.capacity] the configured bound (for health/at-capacity); defaults to DEFAULT_BUFFER_CAPACITY
|
|
113
|
+
* @param {number} [opts.sampleIntervalMs] periodic depth-sample cadence while in an outage; <=0 disables the timer
|
|
114
|
+
* @param {{ warn?: Function, info?: Function, debug?: Function }} [opts.logger] optional logger
|
|
115
|
+
* @param {() => number} [opts.now] injectable clock (tests); defaults to Date.now
|
|
116
|
+
* @param {{ setInterval: Function, clearInterval: Function }} [opts.timers] injectable timers (tests)
|
|
117
|
+
* @returns {BufferMonitor}
|
|
118
|
+
*/
|
|
119
|
+
export function createBufferMonitor(channel, opts = {}) {
|
|
120
|
+
if (!channel || typeof channel.buffered !== 'function') {
|
|
121
|
+
throw new Error('createBufferMonitor requires a WorkChannel with a buffered() accessor');
|
|
122
|
+
}
|
|
123
|
+
const capacity = resolveBufferCapacity(opts.capacity, DEFAULT_BUFFER_CAPACITY);
|
|
124
|
+
const sampleIntervalMs = Number.isFinite(opts.sampleIntervalMs)
|
|
125
|
+
? opts.sampleIntervalMs
|
|
126
|
+
: DEFAULT_SAMPLE_INTERVAL_MS;
|
|
127
|
+
const log = opts.logger || {};
|
|
128
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
129
|
+
const timers = opts.timers || { setInterval, clearInterval };
|
|
130
|
+
|
|
131
|
+
const state = {
|
|
132
|
+
highWaterMark: 0,
|
|
133
|
+
outages: 0,
|
|
134
|
+
reconnects: 0,
|
|
135
|
+
flushes: 0,
|
|
136
|
+
lastFlushFrames: 0,
|
|
137
|
+
lastFlushAt: /** @type {number|null} */ (null),
|
|
138
|
+
atCapacityEvents: 0,
|
|
139
|
+
atCapacity: false,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// `buffering` is true while we believe frames are queued for a hub that is
|
|
143
|
+
// down — set at disconnect (and at creation if we start disconnected), and
|
|
144
|
+
// cleared when the outage's backlog finishes flushing (the client's next
|
|
145
|
+
// onDrain) or when a (re)connect finds nothing was buffered. The client fires
|
|
146
|
+
// our lifecycle connect/reconnect listeners BEFORE it pumps the ring (and thus
|
|
147
|
+
// before its buffer-drained event), so settleOnOpen captures the pre-drain
|
|
148
|
+
// backlog into `outageBacklogPeak` and the subsequent onDrain records it.
|
|
149
|
+
// `outageBacklogPeak` is the deepest the buffer got during the current
|
|
150
|
+
// outage — what we report as the flushed frame count.
|
|
151
|
+
let buffering = false;
|
|
152
|
+
let outageBacklogPeak = 0;
|
|
153
|
+
let sampleTimer = null;
|
|
154
|
+
let stopped = false;
|
|
155
|
+
|
|
156
|
+
const depth = () => {
|
|
157
|
+
try {
|
|
158
|
+
return Number(channel.buffered()) || 0;
|
|
159
|
+
} catch {
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Take a depth sample: update the high-water mark, the per-outage peak, and
|
|
165
|
+
* the at-capacity signal. */
|
|
166
|
+
const sample = () => {
|
|
167
|
+
const d = depth();
|
|
168
|
+
if (d > state.highWaterMark) state.highWaterMark = d;
|
|
169
|
+
if (buffering && d > outageBacklogPeak) outageBacklogPeak = d;
|
|
170
|
+
const atCap = d >= capacity;
|
|
171
|
+
if (atCap) {
|
|
172
|
+
state.atCapacityEvents += 1;
|
|
173
|
+
if (!state.atCapacity) {
|
|
174
|
+
// Transition into the at-capacity state — warn ONCE so the operator sees
|
|
175
|
+
// the bound is full and further low-priority frames may be dropped
|
|
176
|
+
// (bulk relay before interactive before control), but we don't spam
|
|
177
|
+
// every sample. We only observe depth, so we don't assert a drop has
|
|
178
|
+
// already happened: depth can reach capacity before any overflow.
|
|
179
|
+
try {
|
|
180
|
+
log.warn?.(
|
|
181
|
+
`agentic outbound buffer full (${d}/${capacity} frames): the hub is unreachable and further low-priority frames may be dropped until it reconnects. Raise NANO_AGENTIC_BUFFER_CAPACITY for a longer expected outage.`,
|
|
182
|
+
);
|
|
183
|
+
} catch {
|
|
184
|
+
/* a logger failure must never break sampling */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
state.atCapacity = atCap;
|
|
189
|
+
return d;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const startSampler = () => {
|
|
193
|
+
if (stopped || sampleTimer !== null || sampleIntervalMs <= 0) return;
|
|
194
|
+
sampleTimer = timers.setInterval(() => sample(), sampleIntervalMs);
|
|
195
|
+
// Don't keep the event loop alive just to sample a buffer.
|
|
196
|
+
if (sampleTimer && typeof sampleTimer.unref === 'function') sampleTimer.unref();
|
|
197
|
+
};
|
|
198
|
+
const stopSampler = () => {
|
|
199
|
+
if (sampleTimer !== null) {
|
|
200
|
+
timers.clearInterval(sampleTimer);
|
|
201
|
+
sampleTimer = null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Enter an outage: begin (or continue) buffering and start watching depth.
|
|
206
|
+
const beginOutage = () => {
|
|
207
|
+
buffering = true;
|
|
208
|
+
outageBacklogPeak = 0;
|
|
209
|
+
state.atCapacity = false;
|
|
210
|
+
startSampler();
|
|
211
|
+
sample();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// A (re)connect happened. The client fires this listener BEFORE it pumps the
|
|
215
|
+
// ring, so depth() here is the pre-drain backlog (captured below); the
|
|
216
|
+
// client's onDrain then fires and records the completed flush. If we are still
|
|
217
|
+
// buffering and the buffer is already empty, the outage carried nothing to
|
|
218
|
+
// flush — just clear it.
|
|
219
|
+
const settleOnOpen = () => {
|
|
220
|
+
stopSampler();
|
|
221
|
+
// Capture the pre-drain backlog. The client fires our connect/reconnect
|
|
222
|
+
// listeners BEFORE it pumps the ring (see the module header), so depth()
|
|
223
|
+
// here is the backlog about to flush. Recording it into the outage peak
|
|
224
|
+
// makes the flush count (onDrain) reflect the real drained depth even when
|
|
225
|
+
// no periodic sample happened to catch the peak — under production
|
|
226
|
+
// timer-based sampling a short outage would otherwise leave the peak at 0
|
|
227
|
+
// and fall back to 1.
|
|
228
|
+
const d = depth();
|
|
229
|
+
if (d > state.highWaterMark) state.highWaterMark = d;
|
|
230
|
+
if (buffering && d > outageBacklogPeak) outageBacklogPeak = d;
|
|
231
|
+
if (buffering && d === 0) {
|
|
232
|
+
buffering = false;
|
|
233
|
+
outageBacklogPeak = 0;
|
|
234
|
+
}
|
|
235
|
+
state.atCapacity = false;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const unsub = [];
|
|
239
|
+
|
|
240
|
+
// First connect (worker-before-app: the pre-app backlog flushes here too).
|
|
241
|
+
unsub.push(
|
|
242
|
+
channel.onConnect(() => {
|
|
243
|
+
settleOnOpen();
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
// Every recovery after a drop (hub restart / transient outage).
|
|
247
|
+
unsub.push(
|
|
248
|
+
channel.onReconnect(() => {
|
|
249
|
+
state.reconnects += 1;
|
|
250
|
+
settleOnOpen();
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
// The channel went down: begin (or continue) buffering; sample the backlog as
|
|
254
|
+
// it grows so a bound hit is noticed even during a long outage.
|
|
255
|
+
unsub.push(
|
|
256
|
+
channel.onDisconnect(() => {
|
|
257
|
+
state.outages += 1;
|
|
258
|
+
beginOutage();
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
// The client's outbound ring emptied after sending: if we were flushing an
|
|
262
|
+
// outage backlog, the drain is now complete.
|
|
263
|
+
if (channel.client && typeof channel.client.onDrain === 'function') {
|
|
264
|
+
unsub.push(
|
|
265
|
+
channel.client.onDrain(() => {
|
|
266
|
+
if (buffering) {
|
|
267
|
+
state.flushes += 1;
|
|
268
|
+
state.lastFlushFrames = Math.max(outageBacklogPeak, 1);
|
|
269
|
+
state.lastFlushAt = now();
|
|
270
|
+
buffering = false;
|
|
271
|
+
outageBacklogPeak = 0;
|
|
272
|
+
stopSampler();
|
|
273
|
+
}
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Worker-before-app: if we're created while the channel is still down, we are
|
|
279
|
+
// already buffering — start sampling immediately so a pre-connect bound hit is
|
|
280
|
+
// observed and the first-connect drain is recorded as a flush.
|
|
281
|
+
let connectedNow = false;
|
|
282
|
+
try {
|
|
283
|
+
connectedNow = typeof channel.connected === 'function' ? Boolean(channel.connected()) : false;
|
|
284
|
+
} catch {
|
|
285
|
+
connectedNow = false;
|
|
286
|
+
}
|
|
287
|
+
if (!connectedNow) {
|
|
288
|
+
buffering = true;
|
|
289
|
+
outageBacklogPeak = 0;
|
|
290
|
+
startSampler();
|
|
291
|
+
sample();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
health() {
|
|
296
|
+
return {
|
|
297
|
+
capacity,
|
|
298
|
+
buffered: depth(),
|
|
299
|
+
connected: (() => {
|
|
300
|
+
try {
|
|
301
|
+
return typeof channel.connected === 'function' ? Boolean(channel.connected()) : false;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
})(),
|
|
306
|
+
highWaterMark: state.highWaterMark,
|
|
307
|
+
outages: state.outages,
|
|
308
|
+
reconnects: state.reconnects,
|
|
309
|
+
flushes: state.flushes,
|
|
310
|
+
lastFlushFrames: state.lastFlushFrames,
|
|
311
|
+
lastFlushAt: state.lastFlushAt,
|
|
312
|
+
atCapacityEvents: state.atCapacityEvents,
|
|
313
|
+
atCapacity: state.atCapacity,
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
sample,
|
|
317
|
+
stop() {
|
|
318
|
+
if (stopped) return;
|
|
319
|
+
stopped = true;
|
|
320
|
+
stopSampler();
|
|
321
|
+
for (const off of unsub) {
|
|
322
|
+
try {
|
|
323
|
+
off?.();
|
|
324
|
+
} catch {
|
|
325
|
+
/* best effort */
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
unsub.length = 0;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
package/work-channel.mjs
CHANGED
|
@@ -36,6 +36,7 @@ const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
|
36
36
|
// before the app from losing its early presence/relay frames.
|
|
37
37
|
const DEFAULT_BUFFER_CAPACITY = 1024;
|
|
38
38
|
|
|
39
|
+
export { DEFAULT_BUFFER_CAPACITY };
|
|
39
40
|
/**
|
|
40
41
|
* Build the worker's agentic-channel WebSocket URL from the app's HTTP base URL
|
|
41
42
|
* plus the ADR 0028 identity token and capability credential, carried as query
|