c8ctl-plugin-nano 1.44.2 → 1.44.4
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 +47 -4
- package/agentic.mjs +9 -0
- package/c8ctl-plugin.js +888 -174
- package/package.json +10 -10
package/c8ctl-plugin.js
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
unwatchFile,
|
|
52
52
|
} from 'node:fs';
|
|
53
53
|
import { createConnection, createServer } from 'node:net';
|
|
54
|
+
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
54
55
|
import { randomUUID, createHash, randomBytes } from 'node:crypto';
|
|
55
56
|
import { homedir, platform as osPlatform, devNull, tmpdir, hostname } from 'node:os';
|
|
56
57
|
import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from 'node:path';
|
|
@@ -63,6 +64,11 @@ import { platformForHost } from './platforms.mjs';
|
|
|
63
64
|
import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
|
|
64
65
|
import { createRelaySession, roleTerminalMode } from './work-relay.mjs';
|
|
65
66
|
import { createBufferMonitor, resolveBufferCapacity } from './work-buffer.mjs';
|
|
67
|
+
// Canonical ACP → transcript wire bridge (nanobpm/nano-ide#534), consumed through
|
|
68
|
+
// the single agentic import surface. `acpUpdateToTranscriptChunk(update)` maps one
|
|
69
|
+
// raw ACP `session/update` to the exact transcript-chunk bytes the cockpit decodes,
|
|
70
|
+
// replacing the plugin's former hand-rolled `nwfTranscriptEvent` envelope grammar.
|
|
71
|
+
import { sessionAcp as agenticSessionAcp } from './agentic.mjs';
|
|
66
72
|
|
|
67
73
|
const requireFromHere = createRequire(import.meta.url);
|
|
68
74
|
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -2203,6 +2209,254 @@ function killTree(child) {
|
|
|
2203
2209
|
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
2204
2210
|
}
|
|
2205
2211
|
|
|
2212
|
+
// ---- Progress-aware idle liveness (#130) ---------------------------------
|
|
2213
|
+
// The idle-liveness kill used to treat terminal silence as "wedged": a harness
|
|
2214
|
+
// that emitted no stdout/stderr for `idleTimeoutMs` was killed, which stops the
|
|
2215
|
+
// broker lock extension and lets the job be reclaimed. But an agent running a
|
|
2216
|
+
// long, QUIET subprocess (e.g. a cold `./mvnw -q … | tail` reactor build) can
|
|
2217
|
+
// produce zero output for 10+ minutes while actively progressing — a silence
|
|
2218
|
+
// that is NOT a hang. The fix: before the idle-kill fires, probe the spawned
|
|
2219
|
+
// process subtree; if a descendant is doing work (aggregate CPU time across the
|
|
2220
|
+
// tree is advancing) treat the agent as live and defer the kill instead of
|
|
2221
|
+
// reclaiming a job that is genuinely making forward progress. Only a quiescent
|
|
2222
|
+
// tree — no live descendant, or descendants burning no CPU across the window —
|
|
2223
|
+
// ages out and is killed, so a truly wedged agent is still reclaimed.
|
|
2224
|
+
|
|
2225
|
+
/**
|
|
2226
|
+
* Parse a `ps`-style CPU time field (`[[dd-]hh:]mm:ss[.frac]`) into whole
|
|
2227
|
+
* seconds. Best-effort: an unrecognised token yields 0 so a single odd line
|
|
2228
|
+
* never poisons the aggregate.
|
|
2229
|
+
*/
|
|
2230
|
+
function parsePsTime(token) {
|
|
2231
|
+
if (typeof token !== 'string') return 0;
|
|
2232
|
+
let s = token.trim();
|
|
2233
|
+
let days = 0;
|
|
2234
|
+
const dash = s.indexOf('-');
|
|
2235
|
+
if (dash !== -1) { days = Number(s.slice(0, dash)) || 0; s = s.slice(dash + 1); }
|
|
2236
|
+
const parts = s.split(':').map((p) => Number.parseFloat(p));
|
|
2237
|
+
if (parts.length === 0 || parts.some((n) => !Number.isFinite(n))) return 0;
|
|
2238
|
+
let secs = 0;
|
|
2239
|
+
for (const p of parts) secs = secs * 60 + p;
|
|
2240
|
+
return Math.floor(days * 86_400 + secs);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/**
|
|
2244
|
+
* Sample the process subtree rooted at `rootPid` from Linux `/proc`.
|
|
2245
|
+
* Returns `{ descendants, cpu }` — the count of live descendant processes
|
|
2246
|
+
* (excluding the root) and the aggregate CPU time (utime+stime, in clock ticks)
|
|
2247
|
+
* summed across the WHOLE subtree including the root — or `null` when the tree
|
|
2248
|
+
* cannot be read (e.g. `/proc` unavailable). The descendant count is the
|
|
2249
|
+
* presence gate (a childless silent agent still ages out on silence); the CPU
|
|
2250
|
+
* aggregate is the progress signal (a present-but-hung `java` burning no CPU
|
|
2251
|
+
* still ages out).
|
|
2252
|
+
*/
|
|
2253
|
+
function sampleSubtreeCpuProc(rootPid) {
|
|
2254
|
+
let entries;
|
|
2255
|
+
try { entries = readdirSync('/proc'); } catch { return null; }
|
|
2256
|
+
const children = new Map(); // ppid -> [pid]
|
|
2257
|
+
const cpu = new Map(); // pid -> aggregate ticks (utime+stime)
|
|
2258
|
+
for (const name of entries) {
|
|
2259
|
+
if (name.charCodeAt(0) < 48 || name.charCodeAt(0) > 57) continue; // fast /^\d/ gate
|
|
2260
|
+
if (!/^\d+$/.test(name)) continue;
|
|
2261
|
+
const pid = Number(name);
|
|
2262
|
+
let stat;
|
|
2263
|
+
try { stat = readFileSync(`/proc/${name}/stat`, 'utf8'); } catch { continue; }
|
|
2264
|
+
// `comm` (field #2) is parenthesised and may itself contain spaces or
|
|
2265
|
+
// parentheses, so split on the LAST ')' — everything after it is the
|
|
2266
|
+
// space-separated tail starting at field #3 (state).
|
|
2267
|
+
const rparen = stat.lastIndexOf(')');
|
|
2268
|
+
if (rparen < 0) continue;
|
|
2269
|
+
// Split the post-`comm` tail on RUNS of whitespace (not a single space): a
|
|
2270
|
+
// stray double space would otherwise yield empty tokens and shift the
|
|
2271
|
+
// ppid/utime/stime field indexes, mis-sampling the subtree.
|
|
2272
|
+
const tail = stat.slice(rparen + 1).trim().split(/\s+/);
|
|
2273
|
+
// tail[0]=state(#3), tail[1]=ppid(#4) … utime(#14)=tail[11], stime(#15)=tail[12].
|
|
2274
|
+
const ppid = Number(tail[1]);
|
|
2275
|
+
if (!Number.isFinite(ppid)) continue;
|
|
2276
|
+
const utime = Number(tail[11]);
|
|
2277
|
+
const stime = Number(tail[12]);
|
|
2278
|
+
if (!children.has(ppid)) children.set(ppid, []);
|
|
2279
|
+
children.get(ppid).push(pid);
|
|
2280
|
+
cpu.set(pid, (Number.isFinite(utime) ? utime : 0) + (Number.isFinite(stime) ? stime : 0));
|
|
2281
|
+
}
|
|
2282
|
+
if (!cpu.has(rootPid) && !children.has(rootPid)) return null; // root already gone
|
|
2283
|
+
let descendants = 0;
|
|
2284
|
+
let totalCpu = cpu.get(rootPid) || 0;
|
|
2285
|
+
const seen = new Set([rootPid]);
|
|
2286
|
+
const stack = [...(children.get(rootPid) || [])];
|
|
2287
|
+
while (stack.length) {
|
|
2288
|
+
const pid = stack.pop();
|
|
2289
|
+
if (seen.has(pid)) continue;
|
|
2290
|
+
seen.add(pid);
|
|
2291
|
+
descendants += 1;
|
|
2292
|
+
totalCpu += cpu.get(pid) || 0;
|
|
2293
|
+
const kids = children.get(pid);
|
|
2294
|
+
if (kids) for (const k of kids) if (!seen.has(k)) stack.push(k);
|
|
2295
|
+
}
|
|
2296
|
+
return { descendants, cpu: totalCpu };
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
/**
|
|
2300
|
+
* Non-Linux fallback: sample the subtree via a one-shot `ps`. Heavier than the
|
|
2301
|
+
* `/proc` read (it spawns a process) but only runs during an idle window, which
|
|
2302
|
+
* is rare by construction. Returns the same `{ descendants, cpu }` shape (cpu in
|
|
2303
|
+
* whole seconds here) plus `resolutionMs: 1000` to flag the coarse whole-second
|
|
2304
|
+
* CPU granularity — the monitor uses that so a probe window shorter than a
|
|
2305
|
+
* whole-second tick can't read flat CPU and false-kill a still-progressing but
|
|
2306
|
+
* low-churn subtree. Returns `null` when `ps` is unavailable/unparsable.
|
|
2307
|
+
*/
|
|
2308
|
+
function sampleSubtreeCpuPs(rootPid) {
|
|
2309
|
+
let out;
|
|
2310
|
+
try {
|
|
2311
|
+
const r = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,time='], { encoding: 'utf8', timeout: 5_000 });
|
|
2312
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
2313
|
+
out = r.stdout;
|
|
2314
|
+
} catch { return null; }
|
|
2315
|
+
const children = new Map();
|
|
2316
|
+
const cpu = new Map();
|
|
2317
|
+
for (const line of out.split('\n')) {
|
|
2318
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)$/);
|
|
2319
|
+
if (!m) continue;
|
|
2320
|
+
const pid = Number(m[1]);
|
|
2321
|
+
const ppid = Number(m[2]);
|
|
2322
|
+
if (!children.has(ppid)) children.set(ppid, []);
|
|
2323
|
+
children.get(ppid).push(pid);
|
|
2324
|
+
cpu.set(pid, parsePsTime(m[3]));
|
|
2325
|
+
}
|
|
2326
|
+
if (!cpu.has(rootPid) && !children.has(rootPid)) return null;
|
|
2327
|
+
let descendants = 0;
|
|
2328
|
+
let totalCpu = cpu.get(rootPid) || 0;
|
|
2329
|
+
const seen = new Set([rootPid]);
|
|
2330
|
+
const stack = [...(children.get(rootPid) || [])];
|
|
2331
|
+
while (stack.length) {
|
|
2332
|
+
const pid = stack.pop();
|
|
2333
|
+
if (seen.has(pid)) continue;
|
|
2334
|
+
seen.add(pid);
|
|
2335
|
+
descendants += 1;
|
|
2336
|
+
totalCpu += cpu.get(pid) || 0;
|
|
2337
|
+
const kids = children.get(pid);
|
|
2338
|
+
if (kids) for (const k of kids) if (!seen.has(k)) stack.push(k);
|
|
2339
|
+
}
|
|
2340
|
+
return { descendants, cpu: totalCpu, resolutionMs: 1000 };
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* Sample the aggregate CPU + descendant count of the process subtree rooted at
|
|
2345
|
+
* `rootPid`. Linux reads `/proc`; other POSIX platforms fall back to `ps`.
|
|
2346
|
+
* Returns `null` when sampling is unsupported/failed, so callers degrade to the
|
|
2347
|
+
* legacy silence-only liveness rather than mis-killing.
|
|
2348
|
+
*/
|
|
2349
|
+
function sampleSubtreeCpu(rootPid) {
|
|
2350
|
+
if (typeof rootPid !== 'number' || !(rootPid > 0)) return null;
|
|
2351
|
+
if (process.platform === 'win32') return null;
|
|
2352
|
+
if (process.platform === 'linux') return sampleSubtreeCpuProc(rootPid);
|
|
2353
|
+
return sampleSubtreeCpuPs(rootPid);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
/**
|
|
2357
|
+
* Build a progress-aware idle-liveness monitor shared by the pipe/PTY/ACP
|
|
2358
|
+
* capture paths. `arm()` is called on every output chunk (and once at start) to
|
|
2359
|
+
* (re)start the silence window; `stop()` is called when the run settles.
|
|
2360
|
+
*
|
|
2361
|
+
* When the silence window elapses the monitor probes the subtree via `sampleCpu`
|
|
2362
|
+
* instead of killing outright:
|
|
2363
|
+
* - no usable sample OR no live descendant → the tree is childless/quiescent →
|
|
2364
|
+
* `onIdleKill()` (preserves the legacy "silence == wedged" behaviour, so a
|
|
2365
|
+
* genuinely wedged agent with no children still ages out).
|
|
2366
|
+
* - a live descendant whose aggregate CPU advanced since the last probe → the
|
|
2367
|
+
* tree is doing work → defer another probe window (`recoveryWindowMs`, or the
|
|
2368
|
+
* idle window when unset) instead of killing.
|
|
2369
|
+
* - a live descendant whose aggregate CPU regressed → a busy descendant exited
|
|
2370
|
+
* (its ticks no longer count) → that is progress, not a stall → re-baseline
|
|
2371
|
+
* and defer instead of killing.
|
|
2372
|
+
* - a live descendant whose CPU was exactly unchanged across a probe window →
|
|
2373
|
+
* a candidate stall, but only a *confirmed* stall once the cumulative flat
|
|
2374
|
+
* time reaches the sampler's CPU-time resolution (`sample.resolutionMs`, e.g.
|
|
2375
|
+
* the whole-second `ps` fallback reports 1000ms). A probe window shorter than
|
|
2376
|
+
* that resolution can read flat CPU while a low-churn subtree is still
|
|
2377
|
+
* progressing but hasn't crossed the next whole-second tick, so a single flat
|
|
2378
|
+
* read under a coarse sampler must NOT kill; the fine-grained `/proc` path
|
|
2379
|
+
* carries no resolution and so ages out on the first flat window as before →
|
|
2380
|
+
* `onIdleKill()`.
|
|
2381
|
+
* The first elapse with live descendants takes a baseline (a single sample can't
|
|
2382
|
+
* show movement) and re-probes after the recovery window. The absolute
|
|
2383
|
+
* `--job-timeout` hard cap remains the ultimate backstop, untouched by this.
|
|
2384
|
+
*/
|
|
2385
|
+
function createIdleLivenessMonitor({ getPid, idleTimeoutMs, recoveryWindowMs, onIdleKill, isSettled, sampleCpu = sampleSubtreeCpu }) {
|
|
2386
|
+
const enabled = idleTimeoutMs && idleTimeoutMs > 0;
|
|
2387
|
+
const probeWindow = recoveryWindowMs && recoveryWindowMs > 0 ? recoveryWindowMs : idleTimeoutMs;
|
|
2388
|
+
let timer = null;
|
|
2389
|
+
let prev = null; // last subtree CPU probe taken during the current silent stretch
|
|
2390
|
+
let flatMs = 0; // cumulative time observed with flat CPU since the last movement/baseline
|
|
2391
|
+
let lastWindow = idleTimeoutMs; // duration of the currently pending probe window
|
|
2392
|
+
const settled = () => (typeof isSettled === 'function' ? isSettled() : false);
|
|
2393
|
+
const clear = () => { if (timer) { clearTimeout(timer); timer = null; } };
|
|
2394
|
+
const schedule = (ms) => { lastWindow = ms; timer = setTimeout(onElapse, ms); };
|
|
2395
|
+
function onElapse() {
|
|
2396
|
+
timer = null;
|
|
2397
|
+
if (settled()) return;
|
|
2398
|
+
const elapsed = lastWindow;
|
|
2399
|
+
const pid = typeof getPid === 'function' ? getPid() : undefined;
|
|
2400
|
+
const sample = pid != null ? sampleCpu(pid) : null;
|
|
2401
|
+
// Childless / unsampleable / dead tree → silence really is a hang: kill.
|
|
2402
|
+
if (!sample || sample.descendants <= 0) { onIdleKill(); return; }
|
|
2403
|
+
if (prev != null) {
|
|
2404
|
+
// Aggregate CPU advanced across the window → the tree is doing work; defer.
|
|
2405
|
+
if (sample.cpu > prev.cpu) { prev = sample; flatMs = 0; schedule(probeWindow); return; }
|
|
2406
|
+
// Aggregate CPU REGRESSED → `sampleSubtreeCpu*` only sums CPU for live
|
|
2407
|
+
// PIDs, so the total can legitimately drop when a busy descendant exits
|
|
2408
|
+
// (its ticks stop counting) even though the subtree just made progress.
|
|
2409
|
+
// A regression is activity, not a stall: re-baseline and defer rather
|
|
2410
|
+
// than mis-killing a silent-but-busy job.
|
|
2411
|
+
if (sample.cpu < prev.cpu) { prev = sample; flatMs = 0; schedule(probeWindow); return; }
|
|
2412
|
+
// Live descendants but CPU exactly unchanged across a full window → a
|
|
2413
|
+
// candidate stall. Only confirm it once we have observed flat CPU for at
|
|
2414
|
+
// least the sampler's CPU-time resolution: a probe window shorter than the
|
|
2415
|
+
// granularity (the whole-second `ps` fallback) can read flat while a
|
|
2416
|
+
// low-churn subtree is still progressing but hasn't crossed the next tick.
|
|
2417
|
+
flatMs += elapsed;
|
|
2418
|
+
const resolutionMs = Number.isFinite(sample.resolutionMs) ? sample.resolutionMs : 0;
|
|
2419
|
+
if (flatMs >= resolutionMs) { onIdleKill(); return; }
|
|
2420
|
+
schedule(probeWindow);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
// First elapse with live descendants: baseline, then re-probe (can't judge
|
|
2424
|
+
// progress from one sample).
|
|
2425
|
+
prev = sample;
|
|
2426
|
+
flatMs = 0;
|
|
2427
|
+
schedule(probeWindow);
|
|
2428
|
+
}
|
|
2429
|
+
return {
|
|
2430
|
+
enabled: !!enabled,
|
|
2431
|
+
arm() {
|
|
2432
|
+
if (!enabled || settled()) return;
|
|
2433
|
+
prev = null; // output resumed → a fresh silent stretch starts
|
|
2434
|
+
flatMs = 0;
|
|
2435
|
+
clear();
|
|
2436
|
+
schedule(idleTimeoutMs);
|
|
2437
|
+
},
|
|
2438
|
+
stop() { clear(); },
|
|
2439
|
+
};
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
/**
|
|
2443
|
+
* Resolve the effective per-job liveness values (#130) from the task envelope
|
|
2444
|
+
* with precedence: envelope override → worker-flag default → built-in default.
|
|
2445
|
+
* Each override is clamped to a sane max so a task envelope can't request an
|
|
2446
|
+
* unbounded window. Absent/invalid envelope fields fall through to the
|
|
2447
|
+
* worker-flag value unchanged. `hardCapMs` (0 = no absolute cap) is preserved as
|
|
2448
|
+
* the ultimate backstop; an envelope `timeoutMs` can raise it (clamped).
|
|
2449
|
+
*/
|
|
2450
|
+
function resolveLivenessOverrides(envelopeTask, { idleTimeoutMs, recoveryWindowMs, hardCapMs } = {}) {
|
|
2451
|
+
const task = isPlainObject(envelopeTask) ? envelopeTask : {};
|
|
2452
|
+
const clampPos = (v, max) => (Number.isFinite(v) && v > 0 ? Math.min(v, max) : undefined);
|
|
2453
|
+
return {
|
|
2454
|
+
idleTimeoutMs: clampPos(task.idleTimeoutMs, MAX_TASK_IDLE_TIMEOUT_MS) ?? idleTimeoutMs,
|
|
2455
|
+
recoveryWindowMs: clampPos(task.recoveryWindowMs, MAX_TASK_RECOVERY_WINDOW_MS) ?? recoveryWindowMs,
|
|
2456
|
+
hardCapMs: clampPos(task.timeoutMs, MAX_TASK_HARD_CAP_MS) ?? hardCapMs,
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2206
2460
|
// ===========================================================================
|
|
2207
2461
|
// Agent task envelope + sandboxed execution (issue #8, increment 1)
|
|
2208
2462
|
// ===========================================================================
|
|
@@ -2223,6 +2477,14 @@ const AGENT_RESULT_KEY = 'io.nanobpm.agentResult';
|
|
|
2223
2477
|
const LINKED_RESOURCES_HEADER = 'linkedResources';
|
|
2224
2478
|
const DEFAULT_PROMPT_LINK_NAME = 'prompt';
|
|
2225
2479
|
const TASK_ENVELOPE_SCHEMA_VERSION = 1;
|
|
2480
|
+
// Upper bounds for per-task liveness overrides (#130). A task envelope may widen
|
|
2481
|
+
// the idle/recovery window (and the absolute hard cap) for a long-idle task
|
|
2482
|
+
// class, but never unboundedly — a runaway or malicious envelope can't pin a
|
|
2483
|
+
// worker forever. Generous but finite: 1h of silence-with-progress, 1h recovery
|
|
2484
|
+
// cadence, 24h absolute runtime cap.
|
|
2485
|
+
const MAX_TASK_IDLE_TIMEOUT_MS = 60 * 60_000;
|
|
2486
|
+
const MAX_TASK_RECOVERY_WINDOW_MS = 60 * 60_000;
|
|
2487
|
+
const MAX_TASK_HARD_CAP_MS = 24 * 60 * 60_000;
|
|
2226
2488
|
// The result-envelope version is intentionally independent of the task-envelope
|
|
2227
2489
|
// version so the two contracts can evolve separately without silently coupling.
|
|
2228
2490
|
const RESULT_ENVELOPE_SCHEMA_VERSION = 1;
|
|
@@ -2467,6 +2729,16 @@ function normalizeTaskEnvelope(customHeaders, variables, opts = {}) {
|
|
|
2467
2729
|
promptFile: str(task.promptFile),
|
|
2468
2730
|
maxIterations: coerceInt(task.maxIterations, undefined),
|
|
2469
2731
|
timeoutMs: coerceInt(task.timeoutMs, undefined),
|
|
2732
|
+
// #130: per-task liveness overrides. `idleTimeoutMs` is the max silence
|
|
2733
|
+
// (with no subtree CPU progress) before the harness is treated as wedged;
|
|
2734
|
+
// `recoveryWindowMs` is the cadence at which a silent-but-working tree is
|
|
2735
|
+
// re-probed and also the broker lock recovery window. Both let the
|
|
2736
|
+
// orchestrator widen the window for a specific long-idle task class (e.g. a
|
|
2737
|
+
// JVM-heavy monorepo build) without a global `--idle-timeout` widen. Absent
|
|
2738
|
+
// → the worker-flag defaults apply, unchanged. Clamped to a sane max at the
|
|
2739
|
+
// point of use so a task can't request an unbounded window.
|
|
2740
|
+
idleTimeoutMs: coerceInt(task.idleTimeoutMs, undefined),
|
|
2741
|
+
recoveryWindowMs: coerceInt(task.recoveryWindowMs, undefined),
|
|
2470
2742
|
allowPr: coerceBool(task.allowPr, false),
|
|
2471
2743
|
prBase: str(task.prBase),
|
|
2472
2744
|
};
|
|
@@ -2474,6 +2746,71 @@ function normalizeTaskEnvelope(customHeaders, variables, opts = {}) {
|
|
|
2474
2746
|
return env;
|
|
2475
2747
|
}
|
|
2476
2748
|
|
|
2749
|
+
// Does a string look like a usable git clone URL? Used to fail-closed on a
|
|
2750
|
+
// half-specified repository envelope (issue #129) rather than silently degrading
|
|
2751
|
+
// to a launch/temp cwd. Deliberately permissive — it only rejects values git
|
|
2752
|
+
// clone could never resolve to a remote:
|
|
2753
|
+
// - `scheme://host/…` (https/http/ssh/git), host required;
|
|
2754
|
+
// - `file://…` with a path;
|
|
2755
|
+
// - scp-like `user@host:path` (e.g. `git@github.com:o/r.git`);
|
|
2756
|
+
// - a POSIX local filesystem path (`/…`, `./…`, `../…`);
|
|
2757
|
+
// - a Windows local path — drive-absolute (`C:\repo`, `C:/repo`), UNC
|
|
2758
|
+
// (`\\server\share\repo`), or backslash-relative (`.\repo`, `..\repo`) —
|
|
2759
|
+
// since the plugin also runs on win32 hosts.
|
|
2760
|
+
// Anything else (a bare word, `github.com/o/r` with no scheme) is treated as
|
|
2761
|
+
// malformed.
|
|
2762
|
+
function isPlausibleRepoUrl(url) {
|
|
2763
|
+
const s = String(url == null ? '' : url).trim();
|
|
2764
|
+
if (!s) return false;
|
|
2765
|
+
// scp-like syntax has no scheme: user@host:path (the colon is not a port).
|
|
2766
|
+
if (/^[^\s/@]+@[^\s/:]+:.+/.test(s)) return true;
|
|
2767
|
+
// Windows local paths — checked BEFORE new URL() because a drive letter like
|
|
2768
|
+
// `C:` parses as a (hostname-less) URL scheme and would otherwise be rejected.
|
|
2769
|
+
if (/^[A-Za-z]:[\\/]/.test(s) || /^\\\\[^\\]/.test(s) || s.startsWith('.\\') || s.startsWith('..\\')) return true;
|
|
2770
|
+
try {
|
|
2771
|
+
const u = new URL(s);
|
|
2772
|
+
if (u.protocol === 'file:') return !!u.pathname && u.pathname !== '/';
|
|
2773
|
+
return !!u.hostname;
|
|
2774
|
+
} catch { /* not a URL — fall through to the local-path check */ }
|
|
2775
|
+
return s.startsWith('/') || s.startsWith('./') || s.startsWith('../');
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
// Classify the RAW `repository` block of a task envelope BEFORE normalization
|
|
2779
|
+
// drops it (issue #129). `normalizeTaskEnvelope` only emits `env.repository`
|
|
2780
|
+
// when a truthy `url` is present, so a half-specified repo block (other
|
|
2781
|
+
// `repository.*` fields, but `url` absent — or a `url` that isn't a usable clone
|
|
2782
|
+
// target) is invisible downstream and silently degrades to a repo-less run in
|
|
2783
|
+
// the launch/temp cwd. This surfaces that intent so the job handler can
|
|
2784
|
+
// fail-closed on it.
|
|
2785
|
+
//
|
|
2786
|
+
// Returns { present, url, malformed }:
|
|
2787
|
+
// - present — repository intent was supplied: a `repository` block that
|
|
2788
|
+
// either carries at least one non-empty field OR explicitly
|
|
2789
|
+
// includes a `url` key at all (even blank/whitespace — spelling
|
|
2790
|
+
// out `repository.url` IS intent to clone, so it must fail-closed
|
|
2791
|
+
// rather than silently degrade). A task with NO repository block,
|
|
2792
|
+
// or one with only blank non-url fields, is legitimately
|
|
2793
|
+
// repo-less and is `present: false`.
|
|
2794
|
+
// - url — the trimmed url string when present & non-empty, else null.
|
|
2795
|
+
// - malformed — repository intent is expected but unusable: the block is
|
|
2796
|
+
// present with fields but the `url` is absent/blank, or the `url`
|
|
2797
|
+
// is a non-empty string that isn't a plausible clone target.
|
|
2798
|
+
function classifyRepoEnvelope(customHeaders, variables) {
|
|
2799
|
+
const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
|
|
2800
|
+
const repo = raw.repository;
|
|
2801
|
+
if (!isPlainObject(repo)) return { present: false, url: null, malformed: false };
|
|
2802
|
+
// A `url` key present at all — even blank/whitespace/null — is repository
|
|
2803
|
+
// intent, so an envelope carrying only a blank `repository.url` must
|
|
2804
|
+
// fail-closed instead of silently running repo-less in the temp cwd.
|
|
2805
|
+
const hasUrlKey = Object.prototype.hasOwnProperty.call(repo, 'url');
|
|
2806
|
+
const hasAnyField = Object.keys(repo).some((k) => repo[k] != null && String(repo[k]).trim() !== '');
|
|
2807
|
+
if (!hasUrlKey && !hasAnyField) return { present: false, url: null, malformed: false };
|
|
2808
|
+
const url = repo.url == null ? '' : String(repo.url).trim();
|
|
2809
|
+
if (!url) return { present: true, url: null, malformed: true };
|
|
2810
|
+
if (!isPlausibleRepoUrl(url)) return { present: true, url, malformed: true };
|
|
2811
|
+
return { present: true, url, malformed: false };
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2477
2814
|
// ---- Linked resources → live agent prompt (issue #63) ----------------------
|
|
2478
2815
|
// A job's `linkedResources` activation header carries KEYS (not content); the
|
|
2479
2816
|
// worker resolves each to the LATEST deployed bytes over the broker REST API, so
|
|
@@ -3784,7 +4121,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
|
3784
4121
|
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
3785
4122
|
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
3786
4123
|
// uniform result. Used by both the host and container executors.
|
|
3787
|
-
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr, relayTap = null }) {
|
|
4124
|
+
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr, relayTap = null }) {
|
|
3788
4125
|
return new Promise((resolve) => {
|
|
3789
4126
|
let child;
|
|
3790
4127
|
const stdoutChunks = [];
|
|
@@ -3795,7 +4132,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3795
4132
|
let stderrTruncated = false;
|
|
3796
4133
|
let settled = false;
|
|
3797
4134
|
let timer = null;
|
|
3798
|
-
let
|
|
4135
|
+
let idleMon = null;
|
|
3799
4136
|
|
|
3800
4137
|
// Live "spy" tee (--stream): mirror the child's output line-by-line to a
|
|
3801
4138
|
// caller-supplied emitter (the worker routes these through c8ctl's
|
|
@@ -3832,7 +4169,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3832
4169
|
if (settled) return;
|
|
3833
4170
|
settled = true;
|
|
3834
4171
|
if (timer) clearTimeout(timer);
|
|
3835
|
-
if (
|
|
4172
|
+
if (idleMon) idleMon.stop();
|
|
3836
4173
|
if (teeOut) teeOut('', true);
|
|
3837
4174
|
if (teeErr) teeErr('', true);
|
|
3838
4175
|
resolve(result);
|
|
@@ -3852,21 +4189,27 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3852
4189
|
}, timeoutMs)
|
|
3853
4190
|
: null;
|
|
3854
4191
|
|
|
3855
|
-
// Idle-liveness kill: if the child emits no stdout/stderr for `idleTimeoutMs
|
|
3856
|
-
//
|
|
3857
|
-
//
|
|
3858
|
-
//
|
|
3859
|
-
//
|
|
3860
|
-
//
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
4192
|
+
// Idle-liveness kill: if the child emits no stdout/stderr for `idleTimeoutMs`
|
|
4193
|
+
// AND its process subtree is quiescent (no live descendant, or descendants
|
|
4194
|
+
// burning no CPU across the window), treat it as wedged and kill the tree.
|
|
4195
|
+
// This is the liveness signal the worker's lock-extender relies on — a silent
|
|
4196
|
+
// hang stops producing output, we kill it here, `runAgentJob` resolves, and
|
|
4197
|
+
// the worker fails the job (retryable) so the broker reclaims it. Distinct
|
|
4198
|
+
// from the absolute `timeoutMs` hard cap: this fires on *silence without
|
|
4199
|
+
// progress*, not total runtime. A long, QUIET subprocess that keeps a
|
|
4200
|
+
// descendant doing work (advancing subtree CPU) is deferred, not killed
|
|
4201
|
+
// (#130). Re-armed on every chunk.
|
|
4202
|
+
idleMon = createIdleLivenessMonitor({
|
|
4203
|
+
getPid: () => child?.pid,
|
|
4204
|
+
idleTimeoutMs,
|
|
4205
|
+
recoveryWindowMs,
|
|
4206
|
+
isSettled: () => settled,
|
|
4207
|
+
onIdleKill: () => {
|
|
3866
4208
|
try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
|
|
3867
|
-
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `no output for ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated: stdoutTruncated, stderrTruncated });
|
|
3868
|
-
},
|
|
3869
|
-
};
|
|
4209
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `no output and no subtree progress for >= ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated: stdoutTruncated, stderrTruncated });
|
|
4210
|
+
},
|
|
4211
|
+
});
|
|
4212
|
+
const armIdle = () => idleMon.arm();
|
|
3870
4213
|
armIdle();
|
|
3871
4214
|
|
|
3872
4215
|
child.stdout.on('data', (d) => {
|
|
@@ -3948,7 +4291,7 @@ function ptyAvailable(ptyFactory) {
|
|
|
3948
4291
|
// spawnCaptureOneShot. A PTY merges stdout+stderr into one stream, so stderr is
|
|
3949
4292
|
// always '' here; that is expected for a live terminal. `ptyFactory` is
|
|
3950
4293
|
// injectable for tests (defaults to node-pty).
|
|
3951
|
-
function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, cols = 120, rows = 30, ptyFactory, relayTap = null, stream = false, streamPrefix = '', onStreamOut }) {
|
|
4294
|
+
function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, cols = 120, rows = 30, ptyFactory, relayTap = null, stream = false, streamPrefix = '', onStreamOut }) {
|
|
3952
4295
|
return new Promise((resolve) => {
|
|
3953
4296
|
const factory = ptyFactory || loadPtyModule();
|
|
3954
4297
|
if (!factory || typeof factory.spawn !== 'function') {
|
|
@@ -3961,7 +4304,7 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
3961
4304
|
let truncated = false;
|
|
3962
4305
|
let settled = false;
|
|
3963
4306
|
let timer = null;
|
|
3964
|
-
let
|
|
4307
|
+
let idleMon = null;
|
|
3965
4308
|
let detachSteer = null;
|
|
3966
4309
|
let term;
|
|
3967
4310
|
|
|
@@ -3992,7 +4335,7 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
3992
4335
|
if (settled) return;
|
|
3993
4336
|
settled = true;
|
|
3994
4337
|
if (timer) clearTimeout(timer);
|
|
3995
|
-
if (
|
|
4338
|
+
if (idleMon) idleMon.stop();
|
|
3996
4339
|
if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
|
|
3997
4340
|
if (teeSink) tee('', true);
|
|
3998
4341
|
resolve(result);
|
|
@@ -4012,15 +4355,17 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4012
4355
|
}, timeoutMs)
|
|
4013
4356
|
: null;
|
|
4014
4357
|
|
|
4015
|
-
const armIdle = () =>
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4358
|
+
const armIdle = () => idleMon.arm();
|
|
4359
|
+
idleMon = createIdleLivenessMonitor({
|
|
4360
|
+
getPid: () => term?.pid,
|
|
4361
|
+
idleTimeoutMs,
|
|
4362
|
+
recoveryWindowMs,
|
|
4363
|
+
isSettled: () => settled,
|
|
4364
|
+
onIdleKill: () => {
|
|
4020
4365
|
killTerm();
|
|
4021
|
-
finish({ ok: false, exitCode: null, stdout: joinCapped(chunks), stderr: '', error: `no output for ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated, stderrTruncated: false });
|
|
4022
|
-
},
|
|
4023
|
-
};
|
|
4366
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(chunks), stderr: '', error: `no output and no subtree progress for >= ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated, stderrTruncated: false });
|
|
4367
|
+
},
|
|
4368
|
+
});
|
|
4024
4369
|
armIdle();
|
|
4025
4370
|
|
|
4026
4371
|
term.onData((d) => {
|
|
@@ -4165,15 +4510,17 @@ const ACP_MAX_LINE_BYTES = 8 * 1024 * 1024; // 8 MiB
|
|
|
4165
4510
|
|
|
4166
4511
|
// Drive an ACP (Agent Client Protocol) agent over JSON-RPC 2.0 on stdio.
|
|
4167
4512
|
//
|
|
4168
|
-
// This executor drives ACP end-to-end. Each `session/update` is mapped to
|
|
4169
|
-
//
|
|
4170
|
-
//
|
|
4171
|
-
//
|
|
4172
|
-
// `
|
|
4173
|
-
//
|
|
4174
|
-
//
|
|
4175
|
-
//
|
|
4176
|
-
//
|
|
4513
|
+
// This executor drives ACP end-to-end. Each `session/update` is mapped to the
|
|
4514
|
+
// CANONICAL transcript-chunk wire form via the shared `@nanobpm/agentic` bridge
|
|
4515
|
+
// (`acpUpdateToTranscriptChunk` — nanobpm/nano-ide#534) and published on the relay
|
|
4516
|
+
// session's transcript-chunk seam (`relayTap.relayTranscriptChunk`) — the exact
|
|
4517
|
+
// `{ nwfTranscriptEvent: 1, kind, … }` bytes the cockpit's `parseTranscriptEvent` +
|
|
4518
|
+
// `deriveView` decode into messages / tool cards. The raw relay TRANSPORT is
|
|
4519
|
+
// untouched (still `relaySession.relay(text)`); only the payload shape on the lane
|
|
4520
|
+
// changes. When an update has no canonical mapping (an `ignored` classification), or
|
|
4521
|
+
// the relay exposes no transcript seam (minimal mode / a plain tap), it falls back
|
|
4522
|
+
// to the human-TEXT chunk on the same lane (`relayTap.onData`) so nothing is
|
|
4523
|
+
// dropped. The raw JSON-RPC is never tee'd either way.
|
|
4177
4524
|
//
|
|
4178
4525
|
// Framing: ACP frames are newline-delimited JSON-RPC 2.0 messages on stdio (one
|
|
4179
4526
|
// compact JSON object per line, `\n`-terminated). We implement a tiny inline
|
|
@@ -4197,7 +4544,7 @@ const ACP_MAX_LINE_BYTES = 8 * 1024 * 1024; // 8 MiB
|
|
|
4197
4544
|
// and every caller work unchanged. Because the raw stream is JSON-RPC (not human
|
|
4198
4545
|
// output), `stdout` here is the accumulated human-readable transcript text (what
|
|
4199
4546
|
// we relay), and `stderr` is the child's real stderr (agent diagnostics).
|
|
4200
|
-
function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false }) {
|
|
4547
|
+
function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, recoveryWindowMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false }) {
|
|
4201
4548
|
return new Promise((resolve) => {
|
|
4202
4549
|
const logger = getLogger();
|
|
4203
4550
|
const humanChunks = [];
|
|
@@ -4208,7 +4555,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4208
4555
|
let stderrTruncated = false;
|
|
4209
4556
|
let settled = false;
|
|
4210
4557
|
let timer = null;
|
|
4211
|
-
let
|
|
4558
|
+
let idleMon = null;
|
|
4212
4559
|
let detachSteer = null;
|
|
4213
4560
|
let child;
|
|
4214
4561
|
let sessionId = null;
|
|
@@ -4256,9 +4603,9 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4256
4603
|
|
|
4257
4604
|
// Local mirrors of a human text chunk: the --stream spy tee and the byte-
|
|
4258
4605
|
// capped stdout capture (what the result envelope carries). Deliberately does
|
|
4259
|
-
// NOT touch the relay lane, so a
|
|
4260
|
-
// text locally (for the result + spy) WITHOUT also re-emitting raw text
|
|
4261
|
-
// the relay lane — which
|
|
4606
|
+
// NOT touch the relay lane, so a canonical-transcript update can mirror its
|
|
4607
|
+
// human text locally (for the result + spy) WITHOUT also re-emitting raw text
|
|
4608
|
+
// onto the relay lane — which carries the canonical transcript chunk instead.
|
|
4262
4609
|
const captureHuman = (text) => {
|
|
4263
4610
|
if (!text) return;
|
|
4264
4611
|
const buf = Buffer.from(text, 'utf8');
|
|
@@ -4273,8 +4620,8 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4273
4620
|
// use: the relay tap (framed + jobKey-tagged by the caller) and the local
|
|
4274
4621
|
// --stream spy tee. Byte-capped like the raw captures. This is the minimal-
|
|
4275
4622
|
// mode text path — used for stderr, and as the fallback for any session/update
|
|
4276
|
-
// that has no
|
|
4277
|
-
//
|
|
4623
|
+
// that has no canonical transcript mapping (or when the relay exposes no
|
|
4624
|
+
// transcript-chunk seam).
|
|
4278
4625
|
const emitHuman = (text) => {
|
|
4279
4626
|
if (!text) return;
|
|
4280
4627
|
if (relayTap && typeof relayTap.onData === 'function') {
|
|
@@ -4287,7 +4634,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4287
4634
|
if (settled) return;
|
|
4288
4635
|
settled = true;
|
|
4289
4636
|
if (timer) clearTimeout(timer);
|
|
4290
|
-
if (
|
|
4637
|
+
if (idleMon) idleMon.stop();
|
|
4291
4638
|
if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
|
|
4292
4639
|
if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
|
|
4293
4640
|
if (teeSink) { tee('', true); teeErr('', true); }
|
|
@@ -4395,75 +4742,45 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4395
4742
|
}
|
|
4396
4743
|
};
|
|
4397
4744
|
|
|
4398
|
-
// #110
|
|
4399
|
-
//
|
|
4400
|
-
//
|
|
4401
|
-
//
|
|
4402
|
-
//
|
|
4403
|
-
//
|
|
4404
|
-
|
|
4405
|
-
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
// consumers see omitted/optional strings, not coerced nulls. `status`
|
|
4414
|
-
// falls through to the kind's default only when genuinely absent.
|
|
4415
|
-
const toolOf = (u, defaultStatus) => ({
|
|
4416
|
-
id: u.toolCallId ?? undefined,
|
|
4417
|
-
title: u.title ?? undefined,
|
|
4418
|
-
status: u.status ?? defaultStatus ?? undefined,
|
|
4419
|
-
kind: u.kind ?? undefined,
|
|
4420
|
-
});
|
|
4421
|
-
switch (kind) {
|
|
4422
|
-
case 'agent_message_chunk':
|
|
4423
|
-
return { ...base, kind: 'message', role: 'agent', text: acpTextOf(update.content) };
|
|
4424
|
-
case 'agent_thought_chunk':
|
|
4425
|
-
return { ...base, kind: 'thought', role: 'agent', text: acpTextOf(update.content) };
|
|
4426
|
-
case 'user_message_chunk':
|
|
4427
|
-
return { ...base, kind: 'message', role: 'user', text: acpTextOf(update.content) };
|
|
4428
|
-
case 'tool_call':
|
|
4429
|
-
// `text` mirrors the fallback's plain text so the typed envelope stays
|
|
4430
|
-
// self-contained for lightweight renderers (matches the stated contract).
|
|
4431
|
-
return { ...base, kind: 'tool_call', text: describeUpdate(update), tool: toolOf(update, 'pending') };
|
|
4432
|
-
case 'tool_call_update':
|
|
4433
|
-
return { ...base, kind: 'tool_call_update', text: describeUpdate(update), tool: toolOf(update, undefined) };
|
|
4434
|
-
case 'plan':
|
|
4435
|
-
// Carry the actual plan entries (rich cockpit renders them), not a
|
|
4436
|
-
// count — an absent/malformed payload stays `undefined` (omitted).
|
|
4437
|
-
return { ...base, kind: 'plan', entries: Array.isArray(update.entries) ? update.entries : undefined };
|
|
4438
|
-
default:
|
|
4439
|
-
// Unmodelled kind → no typed envelope; caller uses the text fallback.
|
|
4440
|
-
return null;
|
|
4441
|
-
}
|
|
4745
|
+
// #110 / nanobpm/nano-ide#534: map an ACP session/update to the CANONICAL
|
|
4746
|
+
// transcript-chunk wire form via the shared `@nanobpm/agentic` bridge —
|
|
4747
|
+
// `classifyUpdate` composed with `encodeTranscriptEvent` behind the single
|
|
4748
|
+
// `acpUpdateToTranscriptChunk` helper. It returns the exact
|
|
4749
|
+
// `{ nwfTranscriptEvent: 1, kind, … }` bytes the cockpit's `parseTranscriptEvent`
|
|
4750
|
+
// decodes and `deriveView` folds into messages / tool cards, or `null` for an
|
|
4751
|
+
// update with no canonical meaning (an `ignored` classification: a plan, an
|
|
4752
|
+
// intermediate tool_call_update, a non-text chunk, or a malformed update). No
|
|
4753
|
+
// envelope grammar or vocab is hand-rolled here anymore — the marker, version,
|
|
4754
|
+
// kinds and fields all come from the package, so a producer and a consumer can
|
|
4755
|
+
// never diverge on the wire again. `null` (and any bridge throw) falls through
|
|
4756
|
+
// to the minimal human-text path below, so nothing is ever dropped.
|
|
4757
|
+
const encodeTranscriptChunk = (update) => {
|
|
4758
|
+
try { return agenticSessionAcp.acpUpdateToTranscriptChunk(update); }
|
|
4759
|
+
catch { return null; }
|
|
4442
4760
|
};
|
|
4443
4761
|
|
|
4444
|
-
// Publish a session/update: prefer the
|
|
4445
|
-
//
|
|
4446
|
-
//
|
|
4447
|
-
// nothing is ever dropped (no regression vs minimal mode).
|
|
4762
|
+
// Publish a session/update: prefer the canonical transcript chunk on the
|
|
4763
|
+
// relay's transcript-chunk seam; fall back to the minimal human-text path when
|
|
4764
|
+
// the update has no canonical mapping OR the relay exposes no transcript seam,
|
|
4765
|
+
// so nothing is ever dropped (no regression vs minimal mode).
|
|
4448
4766
|
const emitTranscript = (update) => {
|
|
4449
|
-
const
|
|
4450
|
-
if (
|
|
4451
|
-
// Only skip the text fallback when the
|
|
4767
|
+
const chunk = encodeTranscriptChunk(update);
|
|
4768
|
+
if (chunk && relayTap && typeof relayTap.relayTranscriptChunk === 'function') {
|
|
4769
|
+
// Only skip the text fallback when the chunk publish ACTUALLY succeeded.
|
|
4452
4770
|
// If the seam throws (a downstream tap implementation, not just the
|
|
4453
|
-
// built-in best-effort
|
|
4454
|
-
//
|
|
4455
|
-
//
|
|
4456
|
-
// guarantee.
|
|
4771
|
+
// built-in best-effort guard), the chunk never reached the relay lane — so
|
|
4772
|
+
// we must fall through to the text path or that update would be silently
|
|
4773
|
+
// dropped, breaking the "nothing is ever dropped" guarantee.
|
|
4457
4774
|
let published = false;
|
|
4458
|
-
try { relayTap.
|
|
4775
|
+
try { relayTap.relayTranscriptChunk(chunk); published = true; } catch { /* relay best-effort */ }
|
|
4459
4776
|
if (published) {
|
|
4460
4777
|
// Mirror the human text locally (spy tee + captured stdout) so the
|
|
4461
4778
|
// result envelope and --stream spy are unchanged — without re-emitting
|
|
4462
|
-
// raw text onto the relay lane, which now carries the
|
|
4779
|
+
// raw text onto the relay lane, which now carries the canonical chunk.
|
|
4463
4780
|
captureHuman(describeUpdate(update));
|
|
4464
4781
|
return;
|
|
4465
4782
|
}
|
|
4466
|
-
//
|
|
4783
|
+
// Chunk publish threw → fall through to the text lane below.
|
|
4467
4784
|
}
|
|
4468
4785
|
// Fallback: minimal text-chunk path (relay text + spy tee + capture).
|
|
4469
4786
|
emitHuman(describeUpdate(update));
|
|
@@ -4508,15 +4825,17 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
|
|
|
4508
4825
|
}, timeoutMs)
|
|
4509
4826
|
: null;
|
|
4510
4827
|
|
|
4511
|
-
const armIdle = () =>
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4828
|
+
const armIdle = () => idleMon.arm();
|
|
4829
|
+
idleMon = createIdleLivenessMonitor({
|
|
4830
|
+
getPid: () => child?.pid,
|
|
4831
|
+
idleTimeoutMs,
|
|
4832
|
+
recoveryWindowMs,
|
|
4833
|
+
isSettled: () => settled,
|
|
4834
|
+
onIdleKill: () => {
|
|
4516
4835
|
try { killTree(child); } catch { /* best effort */ }
|
|
4517
|
-
finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `no output for ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated: humanTruncated, stderrTruncated });
|
|
4518
|
-
},
|
|
4519
|
-
};
|
|
4836
|
+
finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `no output and no subtree progress for >= ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated: humanTruncated, stderrTruncated });
|
|
4837
|
+
},
|
|
4838
|
+
});
|
|
4520
4839
|
armIdle();
|
|
4521
4840
|
|
|
4522
4841
|
// Newline-delimited JSON-RPC parser over stdout. Progress on stdout re-arms
|
|
@@ -4804,7 +5123,7 @@ function startLockExtender(job, windowMs, intervalMs, tag, logger) {
|
|
|
4804
5123
|
* Both paths resolve to the same result contract.
|
|
4805
5124
|
*/
|
|
4806
5125
|
function runAgentJob(profile, job, opts = {}) {
|
|
4807
|
-
const { timeoutMs, idleTimeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory } = opts;
|
|
5126
|
+
const { timeoutMs, idleTimeoutMs, recoveryWindowMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory } = opts;
|
|
4808
5127
|
// #110: `protocol`/`permission` drive the ACP executor branch below. The
|
|
4809
5128
|
// pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
|
|
4810
5129
|
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
@@ -4825,17 +5144,18 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
4825
5144
|
const relayTap = relaySession
|
|
4826
5145
|
? {
|
|
4827
5146
|
onData: (buf) => relaySession.relay(buf),
|
|
4828
|
-
// #110
|
|
4829
|
-
// session/update to
|
|
4830
|
-
//
|
|
4831
|
-
//
|
|
4832
|
-
//
|
|
4833
|
-
//
|
|
4834
|
-
//
|
|
4835
|
-
//
|
|
4836
|
-
//
|
|
4837
|
-
|
|
4838
|
-
|
|
5147
|
+
// #110 / nanobpm/nano-ide#534: canonical transcript-chunk publish seam. The
|
|
5148
|
+
// ACP producer maps each session/update to the exact `{ nwfTranscriptEvent:
|
|
5149
|
+
// 1, kind, … }` chunk bytes via `acpUpdateToTranscriptChunk` and hands them
|
|
5150
|
+
// here PRE-ENCODED; we relay them verbatim (newline-framed) onto the SAME
|
|
5151
|
+
// relay lane — the raw relay TRANSPORT (ring/QoS/offsets/jobKey routing) is
|
|
5152
|
+
// unchanged, still `relaySession.relay(text)`. Consumers (cockpit
|
|
5153
|
+
// parseTranscriptEvent + deriveView) decode the chunk; unmapped updates fall
|
|
5154
|
+
// back to the `onData` text path so nothing is dropped (no regression vs the
|
|
5155
|
+
// minimal-mode floor). Best-effort: a relay-transport failure must never
|
|
5156
|
+
// crash the worker, so swallow here.
|
|
5157
|
+
relayTranscriptChunk: (chunk) => {
|
|
5158
|
+
try { relaySession.relay(`${chunk}\n`); } catch { /* relay best-effort */ }
|
|
4839
5159
|
},
|
|
4840
5160
|
attachSteer: (write) => relaySession.attachSteer(write),
|
|
4841
5161
|
}
|
|
@@ -4873,6 +5193,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
4873
5193
|
stdinData: payload,
|
|
4874
5194
|
timeoutMs,
|
|
4875
5195
|
idleTimeoutMs,
|
|
5196
|
+
recoveryWindowMs,
|
|
4876
5197
|
relayTap,
|
|
4877
5198
|
stream,
|
|
4878
5199
|
streamPrefix,
|
|
@@ -4895,6 +5216,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
4895
5216
|
stdinData: payload,
|
|
4896
5217
|
timeoutMs,
|
|
4897
5218
|
idleTimeoutMs,
|
|
5219
|
+
recoveryWindowMs,
|
|
4898
5220
|
ptyFactory,
|
|
4899
5221
|
relayTap,
|
|
4900
5222
|
stream,
|
|
@@ -4916,6 +5238,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
4916
5238
|
stdinData: payload,
|
|
4917
5239
|
timeoutMs,
|
|
4918
5240
|
idleTimeoutMs,
|
|
5241
|
+
recoveryWindowMs,
|
|
4919
5242
|
onTimeout: (child) => killTree(child),
|
|
4920
5243
|
stream,
|
|
4921
5244
|
streamPrefix,
|
|
@@ -4977,6 +5300,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
4977
5300
|
stdinData: payload,
|
|
4978
5301
|
timeoutMs,
|
|
4979
5302
|
idleTimeoutMs,
|
|
5303
|
+
recoveryWindowMs,
|
|
4980
5304
|
stream,
|
|
4981
5305
|
streamPrefix,
|
|
4982
5306
|
onStreamOut,
|
|
@@ -5150,6 +5474,23 @@ function wsHostPart(host) {
|
|
|
5150
5474
|
return h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
|
|
5151
5475
|
}
|
|
5152
5476
|
|
|
5477
|
+
/**
|
|
5478
|
+
* URL-encode the zone id of an IPv6 scoped address so it is safe to embed in a
|
|
5479
|
+
* URL host (#133). A resolver may return a link-local address carrying an
|
|
5480
|
+
* interface zone id (e.g. `fe80::1%en0`); the raw `%` is not URL-safe and yields
|
|
5481
|
+
* an invalid `ws://[fe80::1%en0]:…` that fails to parse even when the address is
|
|
5482
|
+
* reachable. Percent-encode the delimiter (`%` → `%25`) so the bracketed host is
|
|
5483
|
+
* valid. Idempotent — an already-encoded zone id (`%25`) is left untouched — and
|
|
5484
|
+
* a no-op for any host without a zone id.
|
|
5485
|
+
*
|
|
5486
|
+
* @param {string} host a raw address, possibly `<ipv6>%<zone>`
|
|
5487
|
+
* @returns {string}
|
|
5488
|
+
*/
|
|
5489
|
+
function encodeIpZoneId(host) {
|
|
5490
|
+
const h = String(host || '');
|
|
5491
|
+
return h.replace(/%(25)?/g, '%25');
|
|
5492
|
+
}
|
|
5493
|
+
|
|
5153
5494
|
/**
|
|
5154
5495
|
* Normalise the engine's `GET /console/api/projects` payload into the running
|
|
5155
5496
|
* embedded apps that advertise an agentic UI port. Accepts the shapes the
|
|
@@ -5229,6 +5570,136 @@ function probeAgenticChannel(port, {
|
|
|
5229
5570
|
});
|
|
5230
5571
|
}
|
|
5231
5572
|
|
|
5573
|
+
/**
|
|
5574
|
+
* Is `address` a link-local address? Node's default `dns.lookup` can return an
|
|
5575
|
+
* IPv6 link-local `fe80::…` (interface-scoped) address FIRST for a `.local`/mDNS
|
|
5576
|
+
* host, and on some hosts (notably macOS) opening a TCP/WS connection to such a
|
|
5577
|
+
* peer stalls for hundreds of ms — long enough to blow the discovery budget and
|
|
5578
|
+
* strand the worker in `advisory` (#133). Covers IPv6 `fe80::/10` and IPv4
|
|
5579
|
+
* link-local `169.254.0.0/16`; tolerates URL brackets and a zone-id suffix.
|
|
5580
|
+
* @param {string} address
|
|
5581
|
+
* @returns {boolean}
|
|
5582
|
+
*/
|
|
5583
|
+
function isLinkLocalAddress(address) {
|
|
5584
|
+
const a = String(address || '')
|
|
5585
|
+
.trim().toLowerCase()
|
|
5586
|
+
.replace(/^\[|\]$/g, '')
|
|
5587
|
+
.replace(/%.*$/, '');
|
|
5588
|
+
// IPv6 link-local is the full fe80::/10 block, whose first hextet spans
|
|
5589
|
+
// fe80–febf (the low byte's top two bits are `10`), not just the fe80:
|
|
5590
|
+
// prefix — match the whole range so e.g. fe90::/fea0::/febf:: are covered too.
|
|
5591
|
+
return /^fe[89ab][0-9a-f]:/.test(a) || /^169\.254\./.test(a);
|
|
5592
|
+
}
|
|
5593
|
+
|
|
5594
|
+
/**
|
|
5595
|
+
* Is `host` an IP literal (v4 or bracketed/bare v6) that needs no DNS
|
|
5596
|
+
* resolution? Used to skip the resolver for an address that is already concrete.
|
|
5597
|
+
* @param {string} host
|
|
5598
|
+
* @returns {boolean}
|
|
5599
|
+
*/
|
|
5600
|
+
function isIpLiteral(host) {
|
|
5601
|
+
const h = String(host || '').trim().replace(/^\[|\]$/g, '');
|
|
5602
|
+
if (!h) return false;
|
|
5603
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h)) return true; // IPv4 dotted-quad
|
|
5604
|
+
return h.includes(':'); // any colon → IPv6 literal
|
|
5605
|
+
}
|
|
5606
|
+
|
|
5607
|
+
/**
|
|
5608
|
+
* Order resolved connect candidates routable-first (#133, fix B). Ranks a
|
|
5609
|
+
* link-local `fe80::`/`169.254.` candidate LAST (stable within a rank) so a fast
|
|
5610
|
+
* routable IPv4/global address wins the Happy-Eyeballs race instead of a slow
|
|
5611
|
+
* interface-scoped one dominating the connect budget.
|
|
5612
|
+
* @param {Array<{address:string, family?:number}>} addresses
|
|
5613
|
+
* @returns {Array<{address:string, family?:number}>} a new, rank-sorted array
|
|
5614
|
+
*/
|
|
5615
|
+
function orderProbeAddresses(addresses) {
|
|
5616
|
+
const list = Array.isArray(addresses) ? addresses.filter((a) => a && a.address) : [];
|
|
5617
|
+
return list
|
|
5618
|
+
.map((a, i) => ({ a, i, rank: isLinkLocalAddress(a.address) ? 1 : 0 }))
|
|
5619
|
+
.sort((x, y) => (x.rank - y.rank) || (x.i - y.i))
|
|
5620
|
+
.map((x) => x.a);
|
|
5621
|
+
}
|
|
5622
|
+
|
|
5623
|
+
/**
|
|
5624
|
+
* Resolve a probe host to an ordered list of candidate host strings to try,
|
|
5625
|
+
* routable-first (#133, fix B). A loopback host, an IP literal, or an
|
|
5626
|
+
* unavailable resolver yields the single host verbatim (unchanged legacy
|
|
5627
|
+
* behaviour); a real hostname is resolved via `lookupImpl(host, { all:true })`
|
|
5628
|
+
* and its addresses are ranked so a link-local `fe80::` never dominates the
|
|
5629
|
+
* connect budget. Fail-open: any resolver error or empty result falls back to
|
|
5630
|
+
* `[host]`, so a host that can't be pre-resolved still gets its one legacy probe.
|
|
5631
|
+
* @param {string} host
|
|
5632
|
+
* @param {{ lookupImpl?: Function }} [opts]
|
|
5633
|
+
* @returns {Promise<string[]>}
|
|
5634
|
+
*/
|
|
5635
|
+
async function resolveProbeCandidates(host, { lookupImpl = dnsLookup } = {}) {
|
|
5636
|
+
const h = String(host || '').trim();
|
|
5637
|
+
if (!h) return [];
|
|
5638
|
+
if (isLoopbackHost(h) || isIpLiteral(h) || typeof lookupImpl !== 'function') return [h];
|
|
5639
|
+
try {
|
|
5640
|
+
const all = await lookupImpl(h, { all: true });
|
|
5641
|
+
const hosts = orderProbeAddresses(Array.isArray(all) ? all : [])
|
|
5642
|
+
.map((a) => wsHostPart(encodeIpZoneId(String(a.address))));
|
|
5643
|
+
return hosts.length ? hosts : [h];
|
|
5644
|
+
} catch {
|
|
5645
|
+
return [h];
|
|
5646
|
+
}
|
|
5647
|
+
}
|
|
5648
|
+
|
|
5649
|
+
/**
|
|
5650
|
+
* Race a WS `/agentic` probe across ordered candidate hosts (Happy-Eyeballs,
|
|
5651
|
+
* #133 fix B) and resolve with the FIRST host whose socket opens — or `null` if
|
|
5652
|
+
* every candidate fails/times out. Candidates are tried in order (routable
|
|
5653
|
+
* first) with a small stagger so the preferred address gets a head start but a
|
|
5654
|
+
* slow-to-open one can't stall the whole probe: a fast later candidate still
|
|
5655
|
+
* wins. Each per-host probe is bounded by `timeoutMs`. A single candidate skips
|
|
5656
|
+
* the racing machinery entirely (unchanged legacy path).
|
|
5657
|
+
* @param {number} port
|
|
5658
|
+
* @param {{ hosts?: string[], token?: string, timeoutMs?: number, wsProbe?: Function, staggerMs?: number }} [opts]
|
|
5659
|
+
* @returns {Promise<string|null>} the winning host, or null
|
|
5660
|
+
*/
|
|
5661
|
+
async function raceProbeCandidates(port, {
|
|
5662
|
+
hosts = [],
|
|
5663
|
+
token = LOCAL_AGENTIC_TOKEN,
|
|
5664
|
+
timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
|
|
5665
|
+
wsProbe = probeAgenticChannel,
|
|
5666
|
+
staggerMs = 250,
|
|
5667
|
+
} = {}) {
|
|
5668
|
+
const list = Array.isArray(hosts) ? hosts.filter(Boolean) : [];
|
|
5669
|
+
if (list.length === 0) return null;
|
|
5670
|
+
if (list.length === 1) {
|
|
5671
|
+
try {
|
|
5672
|
+
return (await wsProbe(port, { host: list[0], token, timeoutMs })) ? list[0] : null;
|
|
5673
|
+
} catch {
|
|
5674
|
+
return null;
|
|
5675
|
+
}
|
|
5676
|
+
}
|
|
5677
|
+
return new Promise((resolve) => {
|
|
5678
|
+
let pending = list.length;
|
|
5679
|
+
let settled = false;
|
|
5680
|
+
const timers = [];
|
|
5681
|
+
const done = (host) => {
|
|
5682
|
+
if (settled) return;
|
|
5683
|
+
settled = true;
|
|
5684
|
+
timers.forEach(clearTimeout);
|
|
5685
|
+
resolve(host);
|
|
5686
|
+
};
|
|
5687
|
+
const start = (host) => {
|
|
5688
|
+
Promise.resolve()
|
|
5689
|
+
.then(() => wsProbe(port, { host, token, timeoutMs }))
|
|
5690
|
+
.catch(() => false)
|
|
5691
|
+
.then((ok) => {
|
|
5692
|
+
if (ok) done(host);
|
|
5693
|
+
else if (--pending === 0) done(null);
|
|
5694
|
+
});
|
|
5695
|
+
};
|
|
5696
|
+
list.forEach((host, idx) => {
|
|
5697
|
+
if (idx === 0) start(host);
|
|
5698
|
+
else timers.push(setTimeout(() => start(host), staggerMs * idx));
|
|
5699
|
+
});
|
|
5700
|
+
});
|
|
5701
|
+
}
|
|
5702
|
+
|
|
5232
5703
|
/**
|
|
5233
5704
|
* Auto-discover the embedded nwf agentic hub(s) reachable from an engine base
|
|
5234
5705
|
* URL (#75, #96). Reads `GET <engine>/console/api/projects`, keeps the apps that
|
|
@@ -5238,20 +5709,25 @@ function probeAgenticChannel(port, {
|
|
|
5238
5709
|
* loopback engine probes `127.0.0.1`, a remote engine (e.g. `merlin.local`)
|
|
5239
5710
|
* probes that same host — the port is taken from the projects API but the host is
|
|
5240
5711
|
* always the engine's, so a rogue projects API can never steer a probe at the
|
|
5241
|
-
* worker's own loopback (#76).
|
|
5242
|
-
*
|
|
5243
|
-
*
|
|
5244
|
-
*
|
|
5712
|
+
* worker's own loopback (#76). Gives the projects fetch and each WS probe
|
|
5713
|
+
* INDEPENDENT deadlines (#133) so a slow fetch can't starve the probe, prefers a
|
|
5714
|
+
* routable address over a link-local `fe80::` one (Happy-Eyeballs), and is
|
|
5715
|
+
* fail-open: any error — not a nano engine (Camunda), network failure, malformed
|
|
5716
|
+
* body, or a timeout — degrades to `[]` so the worker's real job is never
|
|
5717
|
+
* blocked.
|
|
5245
5718
|
*
|
|
5246
5719
|
* @param {string} engineBaseUrl the engine base URL (e.g. `http://merlin.local:8080`)
|
|
5247
|
-
* @param {{ token?: string, fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
|
|
5720
|
+
* @param {{ token?: string, fetchImpl?: Function, wsProbe?: Function, lookupImpl?: Function, timeoutMs?: number, fetchTimeoutMs?: number, probeTimeoutMs?: number }} [opts]
|
|
5248
5721
|
* @returns {Promise<Array<{ project: string, port: number, label?: string, host: string }>>}
|
|
5249
5722
|
*/
|
|
5250
5723
|
async function discoverAgenticHubs(engineBaseUrl, {
|
|
5251
5724
|
token = LOCAL_AGENTIC_TOKEN,
|
|
5252
5725
|
fetchImpl = globalThis.fetch,
|
|
5253
5726
|
wsProbe = probeAgenticChannel,
|
|
5727
|
+
lookupImpl = dnsLookup,
|
|
5254
5728
|
timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
|
|
5729
|
+
fetchTimeoutMs = timeoutMs,
|
|
5730
|
+
probeTimeoutMs = timeoutMs,
|
|
5255
5731
|
} = {}) {
|
|
5256
5732
|
if (typeof fetchImpl !== 'function' || typeof engineBaseUrl !== 'string' || !engineBaseUrl.trim()) {
|
|
5257
5733
|
return [];
|
|
@@ -5272,14 +5748,15 @@ async function discoverAgenticHubs(engineBaseUrl, {
|
|
|
5272
5748
|
return [];
|
|
5273
5749
|
}
|
|
5274
5750
|
const probeHost = isLoopbackHost(host) ? '127.0.0.1' : host;
|
|
5275
|
-
//
|
|
5276
|
-
// deadline
|
|
5277
|
-
//
|
|
5278
|
-
//
|
|
5279
|
-
|
|
5751
|
+
// (C) Decoupled budgets (#133): the projects fetch and each WS probe get their
|
|
5752
|
+
// OWN independent deadline. Previously they shared one 2s budget, so a fetch
|
|
5753
|
+
// that consumed most of the window starved the probe to ~0ms remaining and
|
|
5754
|
+
// stranded an otherwise-reachable worker in `advisory`. They no longer subtract
|
|
5755
|
+
// from each other — the fetch is bounded by `fetchTimeoutMs`, each probe by
|
|
5756
|
+
// `probeTimeoutMs`.
|
|
5280
5757
|
let projects;
|
|
5281
5758
|
const controller = new AbortController();
|
|
5282
|
-
const timer = setTimeout(() => controller.abort(),
|
|
5759
|
+
const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
|
5283
5760
|
try {
|
|
5284
5761
|
const res = await fetchImpl(`${base}/console/api/projects`, { signal: controller.signal });
|
|
5285
5762
|
if (!res || !res.ok) return [];
|
|
@@ -5291,15 +5768,23 @@ async function discoverAgenticHubs(engineBaseUrl, {
|
|
|
5291
5768
|
}
|
|
5292
5769
|
const apps = normalizeProjectApps(projects);
|
|
5293
5770
|
if (apps.length === 0) return [];
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
//
|
|
5297
|
-
//
|
|
5771
|
+
// (B) Prefer a routable address over a link-local one and race families
|
|
5772
|
+
// (Happy-Eyeballs, #133): resolve the engine host to ordered candidate
|
|
5773
|
+
// addresses (routable first) so a fast IPv4/global address wins over a slow
|
|
5774
|
+
// `fe80::` link-local one that Node's default dns.lookup may return first. Each
|
|
5775
|
+
// surviving hub carries the WINNING host so the caller (and the real channel
|
|
5776
|
+
// connect) reuse that fast, routable address rather than re-resolving to the
|
|
5777
|
+
// slow link-local path.
|
|
5778
|
+
const candidates = await resolveProbeCandidates(probeHost, { lookupImpl });
|
|
5298
5779
|
const settled = await Promise.all(apps.map(async (app) => {
|
|
5299
5780
|
try {
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5781
|
+
const winner = await raceProbeCandidates(app.port, {
|
|
5782
|
+
hosts: candidates,
|
|
5783
|
+
token,
|
|
5784
|
+
timeoutMs: probeTimeoutMs,
|
|
5785
|
+
wsProbe,
|
|
5786
|
+
});
|
|
5787
|
+
return winner ? { ...app, host: winner } : null;
|
|
5303
5788
|
} catch {
|
|
5304
5789
|
return null;
|
|
5305
5790
|
}
|
|
@@ -5324,10 +5809,16 @@ async function discoverAgenticHubs(engineBaseUrl, {
|
|
|
5324
5809
|
* no projects API / not a nano engine, or discovery error/timeout). The
|
|
5325
5810
|
* worker continues doing real work with no channel.
|
|
5326
5811
|
*
|
|
5327
|
-
*
|
|
5812
|
+
* (C) When a `cache` Map is supplied, a successfully-resolved hub URL is
|
|
5813
|
+
* remembered against the engine base URL, and a later discovery that comes up
|
|
5814
|
+
* empty for that same engine reuses the cached known-good hub instead of dropping
|
|
5815
|
+
* to `advisory` — so a brief blip doesn't strip visibility from an
|
|
5816
|
+
* already-connected worker (#133).
|
|
5817
|
+
*
|
|
5818
|
+
* @param {{ camunda?: object, fetchImpl?: Function, wsProbe?: Function, lookupImpl?: Function, cache?: Map, timeoutMs?: number }} [opts]
|
|
5328
5819
|
* @returns {Promise<{ status: string, config?: object, message?: string, candidates?: Array }>}
|
|
5329
5820
|
*/
|
|
5330
|
-
async function resolveAgenticTarget({ camunda, ...opts } = {}) {
|
|
5821
|
+
async function resolveAgenticTarget({ camunda, cache, ...opts } = {}) {
|
|
5331
5822
|
const base = resolveAgenticConfig(camunda);
|
|
5332
5823
|
if (!base) return { status: 'off' };
|
|
5333
5824
|
// Explicit target wins verbatim and skips discovery entirely.
|
|
@@ -5346,14 +5837,14 @@ async function resolveAgenticTarget({ camunda, ...opts } = {}) {
|
|
|
5346
5837
|
|
|
5347
5838
|
if (hubs.length === 1) {
|
|
5348
5839
|
const { project, port, host } = hubs[0];
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
url: `http://${wsHostPart(host)}:${port}`,
|
|
5354
|
-
discovered: { project, port, host },
|
|
5355
|
-
},
|
|
5840
|
+
const config = {
|
|
5841
|
+
...base,
|
|
5842
|
+
url: `http://${wsHostPart(host)}:${port}`,
|
|
5843
|
+
discovered: { project, port, host },
|
|
5356
5844
|
};
|
|
5845
|
+
// Cache the known-good hub so a later blip self-heals from cache (#133-C).
|
|
5846
|
+
if (cache && typeof cache.set === 'function') cache.set(base.url, config);
|
|
5847
|
+
return { status: 'connect', config };
|
|
5357
5848
|
}
|
|
5358
5849
|
if (hubs.length > 1) {
|
|
5359
5850
|
const list = hubs.map((h) => `${h.project} → :${h.port}`).join(', ');
|
|
@@ -5364,6 +5855,13 @@ async function resolveAgenticTarget({ camunda, ...opts } = {}) {
|
|
|
5364
5855
|
+ `Disambiguate by setting NANO_AGENTIC_URL=http://${suggestHost}:<port> (or persisted agenticUrl) to the one you want.`,
|
|
5365
5856
|
};
|
|
5366
5857
|
}
|
|
5858
|
+
// Zero matches: reuse a cached known-good hub for this engine if we have one, so
|
|
5859
|
+
// a transient discovery miss doesn't drop an already-known-good worker to
|
|
5860
|
+
// advisory (#133-C).
|
|
5861
|
+
if (cache && typeof cache.get === 'function') {
|
|
5862
|
+
const cached = cache.get(base.url);
|
|
5863
|
+
if (cached) return { status: 'connect', config: { ...cached, fromCache: true } };
|
|
5864
|
+
}
|
|
5367
5865
|
return {
|
|
5368
5866
|
status: 'advisory',
|
|
5369
5867
|
message: `agentic visibility was not discoverable at ${base.url} — the embedded app port could `
|
|
@@ -5372,6 +5870,94 @@ async function resolveAgenticTarget({ camunda, ...opts } = {}) {
|
|
|
5372
5870
|
};
|
|
5373
5871
|
}
|
|
5374
5872
|
|
|
5873
|
+
/**
|
|
5874
|
+
* A jittered backoff schedule (ms) for background agentic re-discovery (#133-A):
|
|
5875
|
+
* ~2s → 4s → 8s → 16s → 30s (capped), each waited value randomised ±20%, spanning
|
|
5876
|
+
* roughly `ceilingMs` (default ~5 minutes) of total elapsed retry time before the
|
|
5877
|
+
* loop gives up. A worker that lost the cold-start discovery race walks this
|
|
5878
|
+
* schedule to upgrade `advisory → connected` without a restart.
|
|
5879
|
+
* @param {{ base?: number, cap?: number, ceilingMs?: number, rng?: () => number }} [opts]
|
|
5880
|
+
* @returns {number[]} the ordered per-attempt wait durations
|
|
5881
|
+
*/
|
|
5882
|
+
function defaultAgenticRediscoveryDelays({
|
|
5883
|
+
base = 2_000,
|
|
5884
|
+
cap = 30_000,
|
|
5885
|
+
ceilingMs = 5 * 60 * 1_000,
|
|
5886
|
+
rng = Math.random,
|
|
5887
|
+
} = {}) {
|
|
5888
|
+
const delays = [];
|
|
5889
|
+
let d = base;
|
|
5890
|
+
let total = 0;
|
|
5891
|
+
while (total < ceilingMs) {
|
|
5892
|
+
const jitter = Math.round((rng() - 0.5) * 0.4 * d); // ±20%
|
|
5893
|
+
const wait = Math.max(500, d + jitter);
|
|
5894
|
+
delays.push(wait);
|
|
5895
|
+
total += wait;
|
|
5896
|
+
d = Math.min(cap, d * 2);
|
|
5897
|
+
}
|
|
5898
|
+
return delays;
|
|
5899
|
+
}
|
|
5900
|
+
|
|
5901
|
+
/**
|
|
5902
|
+
* Background self-heal loop for agentic discovery (#133-A). After an initial
|
|
5903
|
+
* cold-start miss leaves a worker in `advisory`, re-run `resolveTarget` on a
|
|
5904
|
+
* jittered backoff schedule and, on the FIRST attempt that yields a
|
|
5905
|
+
* `status:'connect'` target, invoke `onConnect(target)` and stop — so the worker
|
|
5906
|
+
* upgrades to `connected` without a restart. Fail-open: an attempt whose
|
|
5907
|
+
* `resolveTarget` throws is swallowed and the loop continues; likewise, if
|
|
5908
|
+
* `onConnect` itself throws (a transient channel-open failure), the loop keeps
|
|
5909
|
+
* re-discovering rather than stopping, so retries proceed until a connect
|
|
5910
|
+
* callback actually succeeds. The loop also stops early whenever
|
|
5911
|
+
* `shouldContinue()` returns false (e.g. a channel already came up, or the worker
|
|
5912
|
+
* is shutting down). Returns the connecting target, or `null` if the schedule was
|
|
5913
|
+
* exhausted / cancelled without a hit. Timers and the resolver are injectable so
|
|
5914
|
+
* this is unit-testable without real waits or sockets.
|
|
5915
|
+
*
|
|
5916
|
+
* @param {{
|
|
5917
|
+
* resolveTarget: () => Promise<{status:string, config?:object}>,
|
|
5918
|
+
* onConnect?: (target: {status:string, config?:object}) => (void|Promise<void>),
|
|
5919
|
+
* delaysMs?: number[],
|
|
5920
|
+
* sleep?: (ms:number) => Promise<void>,
|
|
5921
|
+
* shouldContinue?: () => boolean,
|
|
5922
|
+
* logger?: object,
|
|
5923
|
+
* }} opts
|
|
5924
|
+
* @returns {Promise<object|null>}
|
|
5925
|
+
*/
|
|
5926
|
+
async function rediscoverAgenticUntilConnected({
|
|
5927
|
+
resolveTarget,
|
|
5928
|
+
onConnect,
|
|
5929
|
+
delaysMs = defaultAgenticRediscoveryDelays(),
|
|
5930
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
5931
|
+
shouldContinue = () => true,
|
|
5932
|
+
logger = null,
|
|
5933
|
+
} = {}) {
|
|
5934
|
+
if (typeof resolveTarget !== 'function') return null;
|
|
5935
|
+
for (const delay of delaysMs) {
|
|
5936
|
+
if (!shouldContinue()) return null;
|
|
5937
|
+
try { await sleep(delay); } catch { return null; }
|
|
5938
|
+
if (!shouldContinue()) return null;
|
|
5939
|
+
let target;
|
|
5940
|
+
try {
|
|
5941
|
+
target = await resolveTarget();
|
|
5942
|
+
} catch (err) {
|
|
5943
|
+
logger?.debug?.(`agentic re-discovery attempt failed: ${err?.message || err}`);
|
|
5944
|
+
continue;
|
|
5945
|
+
}
|
|
5946
|
+
if (target && target.status === 'connect') {
|
|
5947
|
+
try {
|
|
5948
|
+
await onConnect?.(target);
|
|
5949
|
+
} catch (err) {
|
|
5950
|
+
// A transient channel-open failure must not prematurely stop self-heal:
|
|
5951
|
+
// keep re-discovering until an onConnect callback actually succeeds.
|
|
5952
|
+
logger?.debug?.(`agentic re-discovery onConnect failed, will retry: ${err?.message || err}`);
|
|
5953
|
+
continue;
|
|
5954
|
+
}
|
|
5955
|
+
return target;
|
|
5956
|
+
}
|
|
5957
|
+
}
|
|
5958
|
+
return null;
|
|
5959
|
+
}
|
|
5960
|
+
|
|
5375
5961
|
/**
|
|
5376
5962
|
* Collapse an agentic disconnect/failure detail into the single short string the
|
|
5377
5963
|
* marker's `agentic.message` field carries (#99 contract). Accepts the close
|
|
@@ -5874,7 +6460,17 @@ async function workAgent(req, flags) {
|
|
|
5874
6460
|
// `supervisor status` reflects connecting/advisory/off immediately, before
|
|
5875
6461
|
// the socket opens (or without a channel at all).
|
|
5876
6462
|
writeActivity();
|
|
5877
|
-
|
|
6463
|
+
// Track the live connection state on the activity marker so the supervisor
|
|
6464
|
+
// shows connected↔disconnected transitions (#99). A close carries a normalized
|
|
6465
|
+
// diagnostic under the contract `agentic.message` field (not `reason`) so a hub
|
|
6466
|
+
// drop explains WHY; a fresh (re)connect clears any stale message.
|
|
6467
|
+
const markAgentic = (status, message = null) => { agenticState = { ...agenticState, status, message }; writeActivity(); };
|
|
6468
|
+
// Open (or re-open) the visibility channel for a resolved connect config. This
|
|
6469
|
+
// is the SINGLE place the connected+authenticated client is instantiated — the
|
|
6470
|
+
// initial connect path and the background self-heal loop (#133) both call it,
|
|
6471
|
+
// (re)assigning the shared `workChannel`/`bufferMonitor` closures the job
|
|
6472
|
+
// recorders and shutdown path already track.
|
|
6473
|
+
const openAgenticChannel = async (cfg) => {
|
|
5878
6474
|
try {
|
|
5879
6475
|
workChannel = await createWorkChannel({
|
|
5880
6476
|
instance: workerName,
|
|
@@ -5885,30 +6481,25 @@ async function workAgent(req, flags) {
|
|
|
5885
6481
|
host: hostname(),
|
|
5886
6482
|
},
|
|
5887
6483
|
listJobKeys: () => [...activeJobs.keys()],
|
|
5888
|
-
url:
|
|
5889
|
-
token:
|
|
5890
|
-
credential:
|
|
5891
|
-
bufferCapacity:
|
|
6484
|
+
url: cfg.url,
|
|
6485
|
+
token: cfg.token,
|
|
6486
|
+
credential: cfg.credential,
|
|
6487
|
+
bufferCapacity: cfg.bufferCapacity,
|
|
5892
6488
|
logger,
|
|
5893
6489
|
});
|
|
5894
|
-
const shown = redactAgenticUrl(buildAgenticUrl(
|
|
5895
|
-
const mode =
|
|
5896
|
-
if (
|
|
5897
|
-
const d =
|
|
6490
|
+
const shown = redactAgenticUrl(buildAgenticUrl(cfg.url, {}));
|
|
6491
|
+
const mode = cfg.secure ? 'secure' : 'local';
|
|
6492
|
+
if (cfg.discovered) {
|
|
6493
|
+
const d = cfg.discovered;
|
|
5898
6494
|
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).`);
|
|
5899
6495
|
}
|
|
5900
6496
|
logger.info(` agentic channel (${mode}): announcing presence as ${workerName} on ${shown}`);
|
|
5901
|
-
//
|
|
5902
|
-
//
|
|
5903
|
-
//
|
|
5904
|
-
//
|
|
5905
|
-
//
|
|
5906
|
-
//
|
|
5907
|
-
// true — record that as `disconnected` rather than leaving it stuck at
|
|
5908
|
-
// `connecting`. A close carries a normalized diagnostic under the contract
|
|
5909
|
-
// `agentic.message` field (not `reason`) so a hub drop explains WHY; a
|
|
5910
|
-
// fresh (re)connect clears any stale message.
|
|
5911
|
-
const markAgentic = (status, message = null) => { agenticState = { ...agenticState, status, message }; writeActivity(); };
|
|
6497
|
+
// onConnect fires only for listeners present at first open, so also
|
|
6498
|
+
// reconcile the already-open case synchronously via connected(). If the
|
|
6499
|
+
// socket opened and then dropped inside the createWorkChannel() await window
|
|
6500
|
+
// (before these listeners existed), connected() is false but everConnected()
|
|
6501
|
+
// is true — record that as `disconnected` rather than leaving it stuck at
|
|
6502
|
+
// `connecting`.
|
|
5912
6503
|
workChannel.onConnect(() => markAgentic('connected'));
|
|
5913
6504
|
workChannel.onReconnect(() => markAgentic('connected'));
|
|
5914
6505
|
workChannel.onDisconnect((info) => markAgentic('disconnected', normalizeAgenticMessage(info)));
|
|
@@ -5924,6 +6515,7 @@ async function workAgent(req, flags) {
|
|
|
5924
6515
|
agenticState = { ...agenticState, status: 'disconnected', message: normalizeAgenticMessage(err) };
|
|
5925
6516
|
writeActivity();
|
|
5926
6517
|
logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
|
|
6518
|
+
return;
|
|
5927
6519
|
}
|
|
5928
6520
|
// C4 (#43): observe the client's built-in outbound buffer across the
|
|
5929
6521
|
// channel lifecycle — surface a high-water mark and warn when the bound
|
|
@@ -5934,7 +6526,7 @@ async function workAgent(req, flags) {
|
|
|
5934
6526
|
if (workChannel) {
|
|
5935
6527
|
try {
|
|
5936
6528
|
bufferMonitor = createBufferMonitor(workChannel, {
|
|
5937
|
-
capacity:
|
|
6529
|
+
capacity: cfg.bufferCapacity,
|
|
5938
6530
|
logger,
|
|
5939
6531
|
});
|
|
5940
6532
|
} catch (err) {
|
|
@@ -5942,6 +6534,39 @@ async function workAgent(req, flags) {
|
|
|
5942
6534
|
logger.warn(` agentic buffer monitor unavailable (${err?.message || err}); channel presence still active.`);
|
|
5943
6535
|
}
|
|
5944
6536
|
}
|
|
6537
|
+
};
|
|
6538
|
+
|
|
6539
|
+
if (agenticCfg) {
|
|
6540
|
+
await openAgenticChannel(agenticCfg);
|
|
6541
|
+
} else if (agenticTarget.status === 'advisory') {
|
|
6542
|
+
// (A) Self-heal a cold-start discovery miss (#133): discovery is one-shot at
|
|
6543
|
+
// enrolment, so a worker that merely lost the cold-start race (e.g. a slow
|
|
6544
|
+
// link-local candidate blew the budget) would otherwise run `advisory` for
|
|
6545
|
+
// its whole lifetime — the only recovery being a restart. Keep re-discovering
|
|
6546
|
+
// in the background on a jittered backoff and, on a later success, upgrade
|
|
6547
|
+
// advisory→connected WITHOUT a restart, flipping the AGENTIC status surface.
|
|
6548
|
+
// A shared cache lets a brief blip reuse the last known-good hub (#133-C).
|
|
6549
|
+
const hubCache = new Map();
|
|
6550
|
+
rediscoverAgenticUntilConnected({
|
|
6551
|
+
resolveTarget: () => resolveAgenticTarget({ camunda, logger, cache: hubCache }),
|
|
6552
|
+
onConnect: async (target) => {
|
|
6553
|
+
agenticCfg = target.config;
|
|
6554
|
+
agenticState = agenticStateForTarget(target, safeAgenticDisplayUrl);
|
|
6555
|
+
writeActivity();
|
|
6556
|
+
logger.info(' agentic channel: background re-discovery succeeded — upgrading advisory → connecting.');
|
|
6557
|
+
await openAgenticChannel(agenticCfg);
|
|
6558
|
+
// openAgenticChannel swallows its own open failures (it nulls
|
|
6559
|
+
// workChannel and returns rather than throwing), so a failed open must
|
|
6560
|
+
// be re-thrown here — otherwise the self-heal loop treats this attempt
|
|
6561
|
+
// as success and stops retrying with workChannel still null (#133).
|
|
6562
|
+
if (workChannel === null) {
|
|
6563
|
+
throw new Error('agentic channel failed to open after background re-discovery');
|
|
6564
|
+
}
|
|
6565
|
+
},
|
|
6566
|
+
// Stop as soon as a channel exists (loop won this or a prior attempt did).
|
|
6567
|
+
shouldContinue: () => workChannel === null,
|
|
6568
|
+
logger,
|
|
6569
|
+
}).catch(() => { /* best-effort self-heal — never surfaces an error */ });
|
|
5945
6570
|
}
|
|
5946
6571
|
|
|
5947
6572
|
// C3 (#42): the role's live-terminal mode — a full PTY (streamed on the relay
|
|
@@ -6047,9 +6672,57 @@ async function workAgent(req, flags) {
|
|
|
6047
6672
|
return job.fail({ errorMessage: msg, retries });
|
|
6048
6673
|
}
|
|
6049
6674
|
|
|
6675
|
+
// #130: per-task liveness overrides. Precedence: envelope override →
|
|
6676
|
+
// worker-flag default → built-in default, each clamped to a sane max so
|
|
6677
|
+
// a task can't request an unbounded window. Absent envelope fields leave
|
|
6678
|
+
// the worker-flag behaviour unchanged. These drive BOTH the harness idle
|
|
6679
|
+
// liveness (idle/recovery) AND the broker lock recovery window, so a
|
|
6680
|
+
// JVM-heavy task can widen its own window without a global flag change.
|
|
6681
|
+
const {
|
|
6682
|
+
idleTimeoutMs: effectiveIdleTimeoutMs,
|
|
6683
|
+
recoveryWindowMs: effectiveRecoveryWindowMs,
|
|
6684
|
+
hardCapMs: effectiveHardCapMs,
|
|
6685
|
+
} = resolveLivenessOverrides(envelope.task, { idleTimeoutMs, recoveryWindowMs, hardCapMs });
|
|
6686
|
+
// Recompute the lock-extend cadence from the effective recovery window
|
|
6687
|
+
// (same ~1/3-of-window rule as at startup) so a widened window still
|
|
6688
|
+
// renews comfortably before it lapses.
|
|
6689
|
+
const effectiveLockExtendIntervalMs = Math.min(
|
|
6690
|
+
Math.max(5_000, Math.floor(effectiveRecoveryWindowMs / 3)),
|
|
6691
|
+
Math.max(1, Math.floor(effectiveRecoveryWindowMs * 0.75)),
|
|
6692
|
+
);
|
|
6693
|
+
if (
|
|
6694
|
+
effectiveIdleTimeoutMs !== idleTimeoutMs ||
|
|
6695
|
+
effectiveRecoveryWindowMs !== recoveryWindowMs ||
|
|
6696
|
+
effectiveHardCapMs !== hardCapMs
|
|
6697
|
+
) {
|
|
6698
|
+
logger.info(`[${jobType}] job ${job.jobKey} liveness (envelope override) → idle timeout: ${effectiveIdleTimeoutMs}ms; recovery window: ${effectiveRecoveryWindowMs}ms; hard cap: ${effectiveHardCapMs > 0 ? `${effectiveHardCapMs}ms` : 'off'}`);
|
|
6699
|
+
}
|
|
6700
|
+
|
|
6050
6701
|
const runId = randomUUID();
|
|
6051
6702
|
if (isContainer) liveRunIds.add(runId);
|
|
6052
6703
|
|
|
6704
|
+
// Fail-closed on a half-specified repository envelope (issue #129,
|
|
6705
|
+
// hardening 2): a `repository` block that declares intent (any field set)
|
|
6706
|
+
// but whose `url` is absent or not a usable clone target almost always
|
|
6707
|
+
// means an orchestrator bug. Refuse the job LOUDLY (retryable) instead of
|
|
6708
|
+
// silently degrading to a launch/temp cwd and running an agent with no
|
|
6709
|
+
// repo where one was expected. A task with NO `repository` block at all
|
|
6710
|
+
// is legitimately repo-less and takes the temp-cwd default below.
|
|
6711
|
+
// Container runs don't provision a host repo (cloning is a later
|
|
6712
|
+
// increment), so this gate is host-only, mirroring `hasRepo`.
|
|
6713
|
+
if (!isContainer) {
|
|
6714
|
+
const repoIntent = classifyRepoEnvelope(job.customHeaders ?? {}, job.variables ?? {});
|
|
6715
|
+
if (repoIntent.malformed) {
|
|
6716
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
6717
|
+
const why = repoIntent.url
|
|
6718
|
+
? `repository.url ${JSON.stringify(repoIntent.url)} is not a usable clone target`
|
|
6719
|
+
: 'repository.url is missing';
|
|
6720
|
+
const msg = `incomplete repository envelope — ${why}; refusing to run in the launch/temp cwd (likely an orchestrator bug emitting a half-specified repository block)`;
|
|
6721
|
+
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
6722
|
+
return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
6723
|
+
}
|
|
6724
|
+
}
|
|
6725
|
+
|
|
6053
6726
|
// Host git provisioning (increment 2a): sandbox=none + a repository →
|
|
6054
6727
|
// clone into a throwaway workspace, run the harness there, then push +
|
|
6055
6728
|
// reconcile the agent PR. Container-side cloning is a later increment.
|
|
@@ -6061,7 +6734,7 @@ async function workAgent(req, flags) {
|
|
|
6061
6734
|
// here — ahead of provisionRepo — guarantees the first renewal is queued
|
|
6062
6735
|
// before the clone, so the lock can't lapse mid-provision and trigger the
|
|
6063
6736
|
// duplicate-activation / stale-409 race. The `finally` below stops it.
|
|
6064
|
-
stopLockExtender = startLockExtender(job,
|
|
6737
|
+
stopLockExtender = startLockExtender(job, effectiveRecoveryWindowMs, effectiveLockExtendIntervalMs, `[${jobType}] job ${job.jobKey}`, logger);
|
|
6065
6738
|
let cwd;
|
|
6066
6739
|
let extraEnv;
|
|
6067
6740
|
let repoToken = null;
|
|
@@ -6105,6 +6778,32 @@ async function workAgent(req, flags) {
|
|
|
6105
6778
|
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
6106
6779
|
return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
6107
6780
|
}
|
|
6781
|
+
} else if (!isContainer) {
|
|
6782
|
+
// Repo-less host job (issue #129, hardening 1): nothing is provisioned,
|
|
6783
|
+
// so default the agent's cwd to a fresh, empty `run-*` dir under the
|
|
6784
|
+
// runs root instead of leaving it undefined (which inherits the
|
|
6785
|
+
// worker's launch dir and lets stray scratch files / a `git init`
|
|
6786
|
+
// pollute it — or worse, an agent `cd` into a shared checkout). Track
|
|
6787
|
+
// it in `liveRunDirs` and let the SAME `finally` below reap it, reusing
|
|
6788
|
+
// the existing run-dir lifecycle — the only difference from the
|
|
6789
|
+
// provisioned path is an EMPTY workspace.
|
|
6790
|
+
//
|
|
6791
|
+
// Hygiene, not a sandbox: the agent runs as the user with full
|
|
6792
|
+
// filesystem access, so an empty cwd does not CONFINE it (it can still
|
|
6793
|
+
// `cd` to a known absolute path). True confinement is the container
|
|
6794
|
+
// increment; a provisioned repository envelope stays the preferred path.
|
|
6795
|
+
try {
|
|
6796
|
+
mkdirSync(agentRunsRoot(), { recursive: true });
|
|
6797
|
+
runDir = mkdtempSync(join(agentRunsRoot(), 'run-'));
|
|
6798
|
+
liveRunDirs.add(runDir);
|
|
6799
|
+
cwd = runDir;
|
|
6800
|
+
} catch (err) {
|
|
6801
|
+
if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); runDir = null; }
|
|
6802
|
+
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
6803
|
+
const msg = `could not create a temp workspace under the runs root: ${err.message}`;
|
|
6804
|
+
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
6805
|
+
return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
6806
|
+
}
|
|
6108
6807
|
}
|
|
6109
6808
|
|
|
6110
6809
|
let result;
|
|
@@ -6137,8 +6836,9 @@ async function workAgent(req, flags) {
|
|
|
6137
6836
|
} catch { resultDir = null; resultFile = null; }
|
|
6138
6837
|
|
|
6139
6838
|
result = await runAgentJob(profile, job, {
|
|
6140
|
-
timeoutMs:
|
|
6141
|
-
idleTimeoutMs,
|
|
6839
|
+
timeoutMs: effectiveHardCapMs,
|
|
6840
|
+
idleTimeoutMs: effectiveIdleTimeoutMs,
|
|
6841
|
+
recoveryWindowMs: effectiveRecoveryWindowMs,
|
|
6142
6842
|
envelope,
|
|
6143
6843
|
sandbox,
|
|
6144
6844
|
image,
|
|
@@ -10823,6 +11523,14 @@ export { buildNpmInvocation };
|
|
|
10823
11523
|
export { resolveAgenticConfig, LOCAL_AGENTIC_TOKEN };
|
|
10824
11524
|
export { resolveAgenticSetting, PROTOCOLS, PERMISSION_MODES };
|
|
10825
11525
|
export { resolveAgenticTarget, discoverAgenticHubs, probeAgenticChannel, normalizeProjectApps, isLoopbackHost };
|
|
11526
|
+
export {
|
|
11527
|
+
orderProbeAddresses,
|
|
11528
|
+
resolveProbeCandidates,
|
|
11529
|
+
raceProbeCandidates,
|
|
11530
|
+
isLinkLocalAddress,
|
|
11531
|
+
rediscoverAgenticUntilConnected,
|
|
11532
|
+
defaultAgenticRediscoveryDelays,
|
|
11533
|
+
};
|
|
10826
11534
|
export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
|
|
10827
11535
|
export {
|
|
10828
11536
|
webConsoleUrl,
|
|
@@ -10833,6 +11541,8 @@ export {
|
|
|
10833
11541
|
export {
|
|
10834
11542
|
normalizeTaskEnvelope,
|
|
10835
11543
|
collectEnvelopeFrom,
|
|
11544
|
+
classifyRepoEnvelope,
|
|
11545
|
+
isPlausibleRepoUrl,
|
|
10836
11546
|
parseLinkedResources,
|
|
10837
11547
|
pickLinkedResource,
|
|
10838
11548
|
resolveBrokerRestConfig,
|
|
@@ -10869,6 +11579,10 @@ export {
|
|
|
10869
11579
|
runAgentJob,
|
|
10870
11580
|
spawnCapturePty,
|
|
10871
11581
|
spawnCaptureAcp,
|
|
11582
|
+
sampleSubtreeCpu,
|
|
11583
|
+
createIdleLivenessMonitor,
|
|
11584
|
+
resolveLivenessOverrides,
|
|
11585
|
+
parsePsTime,
|
|
10872
11586
|
ensureAcpFlag,
|
|
10873
11587
|
startLockExtender,
|
|
10874
11588
|
provisionRepo,
|