c8ctl-plugin-nano 1.44.9 → 1.44.10
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/c8ctl-plugin.js +203 -4
- package/package.json +8 -8
package/c8ctl-plugin.js
CHANGED
|
@@ -2587,6 +2587,135 @@ const SANDBOXES = ['none', 'docker', 'podman'];
|
|
|
2587
2587
|
// Only container-based sandboxes need an image / disk hygiene / a runtime bin.
|
|
2588
2588
|
const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
|
|
2589
2589
|
|
|
2590
|
+
// Result-nudge (#678). A weak / non-Claude agent can finish a result-contract
|
|
2591
|
+
// job (exit 0, real work done) yet never emit the machine-readable result — the
|
|
2592
|
+
// downstream gateway then falls through to its default (e.g. a completed review
|
|
2593
|
+
// silently re-waits instead of converging). Rather than accept the empty result,
|
|
2594
|
+
// give the agent exactly ONE bounded "emit your result now" turn, feeding back
|
|
2595
|
+
// its own prior output, in the SAME workspace. This never redoes work; it only
|
|
2596
|
+
// recovers a dropped result. Bounded independently of the main run so a second
|
|
2597
|
+
// hang can't double a long idle window.
|
|
2598
|
+
const NUDGE_IDLE_TIMEOUT_MS = 120_000;
|
|
2599
|
+
const NUDGE_HARD_CAP_MS = 300_000;
|
|
2600
|
+
// Cap the prior transcript we echo back so a huge run can't blow the nudge prompt.
|
|
2601
|
+
// This is a UTF-16 code-unit (character) cap, not a byte cap: `String.slice`
|
|
2602
|
+
// counts code units, so with multi-byte output the byte size may be larger.
|
|
2603
|
+
const NUDGE_CONTEXT_CAP_CHARS = 24_000;
|
|
2604
|
+
|
|
2605
|
+
// The bespoke prompt for the re-emit turn: derive the status from the work the
|
|
2606
|
+
// agent already did, write ONLY the result, change nothing else.
|
|
2607
|
+
function buildResultNudgePrompt(priorStdout, { hasResultFile = true } = {}) {
|
|
2608
|
+
const ctx = typeof priorStdout === 'string' ? priorStdout.slice(-NUDGE_CONTEXT_CAP_CHARS) : '';
|
|
2609
|
+
const intro = [
|
|
2610
|
+
'You already completed the task in your previous turn, but you did NOT emit a',
|
|
2611
|
+
'machine-readable result, so the orchestrator cannot read your status and the',
|
|
2612
|
+
'run cannot advance.',
|
|
2613
|
+
'',
|
|
2614
|
+
'Do NOT redo the work, re-run tools, edit files, push, or open/modify a PR. Just',
|
|
2615
|
+
'emit the result for the work you already did: a single flat JSON object of your',
|
|
2616
|
+
'result variables (at minimum {"status":"..."}).',
|
|
2617
|
+
'',
|
|
2618
|
+
];
|
|
2619
|
+
// When the harness could not create the result file, AGENT_RESULT_FILE is NOT
|
|
2620
|
+
// exported (runAgentJob only sets it for a truthy resultFile), so the usual
|
|
2621
|
+
// "write to $AGENT_RESULT_FILE" instruction is impossible. Lead with the stdout
|
|
2622
|
+
// sentinel in that case so weaker agents don't waste the turn chasing an unset
|
|
2623
|
+
// env var; otherwise keep the file the primary path with the sentinel fallback.
|
|
2624
|
+
const how = hasResultFile
|
|
2625
|
+
? [
|
|
2626
|
+
'Write it to the file named by the AGENT_RESULT_FILE environment variable, e.g.:',
|
|
2627
|
+
'',
|
|
2628
|
+
' printf \'%s\' \'{"status":"...","summary":"..."}\' > "$AGENT_RESULT_FILE"',
|
|
2629
|
+
'',
|
|
2630
|
+
'If you truly cannot write that file, print exactly one line: ::nano:result:: {json}',
|
|
2631
|
+
]
|
|
2632
|
+
: [
|
|
2633
|
+
'The AGENT_RESULT_FILE environment variable is unset/empty in this run, so you',
|
|
2634
|
+
'CANNOT write a result file. Instead, print exactly one line to stdout:',
|
|
2635
|
+
'',
|
|
2636
|
+
' ::nano:result:: {"status":"...","summary":"..."}',
|
|
2637
|
+
];
|
|
2638
|
+
return [
|
|
2639
|
+
...intro,
|
|
2640
|
+
...how,
|
|
2641
|
+
'',
|
|
2642
|
+
'Your previous output (reference — derive the status/summary from it):',
|
|
2643
|
+
'-----',
|
|
2644
|
+
ctx,
|
|
2645
|
+
].join('\n');
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
// Given a finished agent run, decide whether it dropped its result and, if so,
|
|
2649
|
+
// perform ONE re-emit nudge via the injected `rerun(nudgeText)` (which re-invokes
|
|
2650
|
+
// the agent in the same workspace, writing to the same AGENT_RESULT_FILE).
|
|
2651
|
+
// `rerun` is injected so this is unit-testable without a real model. Returns the
|
|
2652
|
+
// (possibly appended) stdout and whether a nudge was attempted; the caller reads
|
|
2653
|
+
// the structured result from the result file / stdout afterwards as usual.
|
|
2654
|
+
// Cap a string to MAX_CAPTURE_BYTES of UTF-8 keeping the TAIL (so a trailing
|
|
2655
|
+
// `::nano:result::` sentinel / fenced JSON block survives the trim) and skipping
|
|
2656
|
+
// any partial leading continuation byte so we start on a char boundary. Mirrors
|
|
2657
|
+
// the per-stream capture cap for the nudge's post-concatenation stdout. Returns
|
|
2658
|
+
// `{ text, truncated }`.
|
|
2659
|
+
function capStdoutTail(s) {
|
|
2660
|
+
const buf = Buffer.from(s, 'utf8');
|
|
2661
|
+
if (buf.length <= MAX_CAPTURE_BYTES) return { text: s, truncated: false };
|
|
2662
|
+
let start = buf.length - MAX_CAPTURE_BYTES;
|
|
2663
|
+
while (start < buf.length && (buf[start] & 0xc0) === 0x80) start += 1;
|
|
2664
|
+
return { text: buf.subarray(start).toString('utf8'), truncated: true };
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
async function resolveAgentResultWithNudge({ result, resultFile, rerun, logger, logPrefix = '' }) {
|
|
2668
|
+
const stdout0 = result && typeof result.stdout === 'string' ? result.stdout : '';
|
|
2669
|
+
// A parsed result only counts as usable if it still carries at least one
|
|
2670
|
+
// *effective* var after sanitizeResultVars strips the reserved / io.nanobpm.* /
|
|
2671
|
+
// proto keys. An empty `{}` or a reserved-keys-only object leaves downstream
|
|
2672
|
+
// gateways with no status/decision vars — exactly the dropped-result case the
|
|
2673
|
+
// nudge exists to recover — so gate on effective vars, not raw object presence.
|
|
2674
|
+
const hasUsableResult = (parsed) => Object.keys(sanitizeResultVars(parsed)).length > 0;
|
|
2675
|
+
const already = readAgentResultFile(resultFile) ?? parseResultFromStdout(stdout0);
|
|
2676
|
+
// Only nudge a clean run that produced NO usable result but DID produce output
|
|
2677
|
+
// (silence means a crash/hang the idle path already handles, not a dropped result).
|
|
2678
|
+
// A null `resultFile` (temp-dir creation failed) is NOT a reason to skip: the
|
|
2679
|
+
// read-back already falls through to `parseResultFromStdout`, and the nudge
|
|
2680
|
+
// prompt explicitly offers the `::nano:result::` stdout sentinel, so recovery
|
|
2681
|
+
// still works in stdout-sentinel-only mode.
|
|
2682
|
+
if (hasUsableResult(already) || !result?.ok || !stdout0.trim() || typeof rerun !== 'function') {
|
|
2683
|
+
// No nudge: `stdout0` is the incoming stdout verbatim, so keep the returned
|
|
2684
|
+
// `truncated` flag consistent with it — echo the incoming `result.truncated`
|
|
2685
|
+
// rather than hardcoding false, so callers that trust the return value don't
|
|
2686
|
+
// see an already-truncated stdout reported as untruncated.
|
|
2687
|
+
return { stdout: stdout0, nudged: false, truncated: result?.truncated === true };
|
|
2688
|
+
}
|
|
2689
|
+
let nudge = null;
|
|
2690
|
+
let nudgeError = null;
|
|
2691
|
+
try { nudge = await rerun(buildResultNudgePrompt(stdout0, { hasResultFile: resultFile != null })); } catch (err) { nudge = null; nudgeError = err; }
|
|
2692
|
+
const nudgeOut = nudge && typeof nudge.stdout === 'string' ? nudge.stdout : '';
|
|
2693
|
+
// Re-apply the per-stream capture cap after concatenation: `result.stdout` is
|
|
2694
|
+
// forwarded verbatim into the job vars (`output`) and the audit envelope, so
|
|
2695
|
+
// the nudge must not let the combined output bypass MAX_CAPTURE_BYTES. Keep the
|
|
2696
|
+
// tail so a `::nano:result::` sentinel emitted by the nudge survives the trim,
|
|
2697
|
+
// and surface truncation so `result.truncated` stays honest.
|
|
2698
|
+
const capped = capStdoutTail(nudgeOut ? `${stdout0}\n${nudgeOut}` : stdout0);
|
|
2699
|
+
const stdout = capped.text;
|
|
2700
|
+
// The returned `stdout` still starts with `stdout0`, so if the FIRST turn was
|
|
2701
|
+
// already truncated the returned output is truncated regardless of whether the
|
|
2702
|
+
// post-concatenation cap trimmed anything: an empty/short nudge leaves
|
|
2703
|
+
// `capped.truncated` false even though `stdout0` was clipped. OR in the incoming
|
|
2704
|
+
// flag so `truncated` stays consistent with the returned stdout (and with the
|
|
2705
|
+
// no-nudge early return above).
|
|
2706
|
+
const truncated = result?.truncated === true || capped.truncated;
|
|
2707
|
+
const recovered = hasUsableResult(readAgentResultFile(resultFile) ?? parseResultFromStdout(stdout));
|
|
2708
|
+
if (nudgeError && logger?.warn) {
|
|
2709
|
+
logger.warn(`${logPrefix} re-emit nudge rerun threw — ${nudgeError?.message ?? nudgeError}`);
|
|
2710
|
+
}
|
|
2711
|
+
if (logger?.info) {
|
|
2712
|
+
logger.info(recovered
|
|
2713
|
+
? `${logPrefix} no result on the first turn — recovered it via one re-emit nudge`
|
|
2714
|
+
: `${logPrefix} no result on the first turn — re-emit nudge did not recover one`);
|
|
2715
|
+
}
|
|
2716
|
+
return { stdout, nudged: true, truncated };
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2590
2719
|
function coerceBool(v, dflt = false) {
|
|
2591
2720
|
if (typeof v === 'boolean') return v;
|
|
2592
2721
|
if (v == null) return dflt;
|
|
@@ -5159,6 +5288,31 @@ function buildAgentPayload(profile, job, envelope) {
|
|
|
5159
5288
|
};
|
|
5160
5289
|
}
|
|
5161
5290
|
|
|
5291
|
+
// Build the exact bytes handed to the harness on stdin for a run (#678).
|
|
5292
|
+
//
|
|
5293
|
+
// Normal run: the JSON job envelope from `buildAgentPayload`. Every non-ACP
|
|
5294
|
+
// harness (pipe/PTY/container) reads that JSON off stdin, so the shape is a
|
|
5295
|
+
// contract — a re-emit nudge must NOT replace it with a bare string or the
|
|
5296
|
+
// harness fails to parse its job. So when a `nudgePayload` (the bounded "re-emit
|
|
5297
|
+
// your result" prompt) is present:
|
|
5298
|
+
// - ACP delivers stdin verbatim as the `session/prompt` text, so it takes the
|
|
5299
|
+
// raw nudge string.
|
|
5300
|
+
// - Non-ACP keeps the JSON envelope and overrides its prompt fields (top-level
|
|
5301
|
+
// `prompt` and the reserved `task.task.prompt`, i.e. `envelope.task.prompt`)
|
|
5302
|
+
// so a harness that dispatches on either sees the nudge. The shared envelope
|
|
5303
|
+
// is copied, never mutated.
|
|
5304
|
+
function buildAgentStdin(profile, job, envelope, { nudgePayload = null, acp = false } = {}) {
|
|
5305
|
+
if (nudgePayload == null) return JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
5306
|
+
const nudgeText = String(nudgePayload);
|
|
5307
|
+
if (acp) return nudgeText;
|
|
5308
|
+
const base = buildAgentPayload(profile, job, envelope);
|
|
5309
|
+
base.prompt = nudgeText;
|
|
5310
|
+
if (isPlainObject(base.task) && isPlainObject(base.task.task)) {
|
|
5311
|
+
base.task = { ...base.task, task: { ...base.task.task, prompt: nudgeText } };
|
|
5312
|
+
}
|
|
5313
|
+
return JSON.stringify(base);
|
|
5314
|
+
}
|
|
5315
|
+
|
|
5162
5316
|
function baseAgentEnv(profile, job) {
|
|
5163
5317
|
return {
|
|
5164
5318
|
AGENT_PROFILE: profile.name,
|
|
@@ -5254,10 +5408,17 @@ function startLockExtender(job, windowMs, intervalMs, tag, logger) {
|
|
|
5254
5408
|
* Both paths resolve to the same result contract.
|
|
5255
5409
|
*/
|
|
5256
5410
|
function runAgentJob(profile, job, opts = {}) {
|
|
5257
|
-
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;
|
|
5411
|
+
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, nudgePayload = null } = opts;
|
|
5258
5412
|
// #110: `protocol`/`permission` drive the ACP executor branch below. The
|
|
5259
5413
|
// pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
|
|
5260
|
-
|
|
5414
|
+
// A `nudgePayload` (#678) carries the bespoke "re-emit your result" prompt for a
|
|
5415
|
+
// bounded second turn in the SAME workspace; everything else (env, cwd, command,
|
|
5416
|
+
// result file) is identical to the main run. ACP delivers stdin as the prompt
|
|
5417
|
+
// text so it takes the raw nudge; every non-ACP harness reads the JSON envelope
|
|
5418
|
+
// off stdin, so there we keep the envelope and override its prompt fields
|
|
5419
|
+
// (buildAgentStdin). Container sandboxes ignore `protocol` (pipe-only today).
|
|
5420
|
+
const acpStdin = protocol === 'acp' && !CONTAINER_SANDBOXES.has(sandbox);
|
|
5421
|
+
const payload = buildAgentStdin(profile, job, envelope, { nudgePayload, acp: acpStdin });
|
|
5261
5422
|
const agentEnv = baseAgentEnv(profile, job);
|
|
5262
5423
|
// The harness command line: the profile command plus its structured switches
|
|
5263
5424
|
// (persisted `--arg`s, possibly extended at work time via opts.args), each
|
|
@@ -7255,7 +7416,7 @@ async function workAgent(req, flags) {
|
|
|
7255
7416
|
liveRunDirs.add(resultDir);
|
|
7256
7417
|
} catch { resultDir = null; resultFile = null; }
|
|
7257
7418
|
|
|
7258
|
-
|
|
7419
|
+
const runOpts = {
|
|
7259
7420
|
timeoutMs: effectiveHardCapMs,
|
|
7260
7421
|
idleTimeoutMs: effectiveIdleTimeoutMs,
|
|
7261
7422
|
recoveryWindowMs: effectiveRecoveryWindowMs,
|
|
@@ -7286,7 +7447,42 @@ async function workAgent(req, flags) {
|
|
|
7286
7447
|
// spying never corrupts a structured/JSON output mode.
|
|
7287
7448
|
onStreamOut: stream ? (line) => logger.info(line) : undefined,
|
|
7288
7449
|
onStreamErr: stream ? (line) => logger.warn(line) : undefined,
|
|
7289
|
-
}
|
|
7450
|
+
};
|
|
7451
|
+
result = await runAgentJob(profile, job, runOpts);
|
|
7452
|
+
|
|
7453
|
+
// Gap 2 (#678): a clean run that emitted no machine-readable result gets
|
|
7454
|
+
// ONE bounded re-emit nudge in the same workspace, feeding back its own
|
|
7455
|
+
// output, before we accept an empty result. Runs before finalizeGit so
|
|
7456
|
+
// the workspace/result file are still live; the nudge changes no code.
|
|
7457
|
+
// Not gated on `resultFile`: when the temp dir/file could not be created
|
|
7458
|
+
// the result is recoverable only via the stdout `::nano:result::`
|
|
7459
|
+
// sentinel, which `resolveAgentResultWithNudge` handles directly.
|
|
7460
|
+
if (result.ok) {
|
|
7461
|
+
const { stdout, nudged, truncated } = await resolveAgentResultWithNudge({
|
|
7462
|
+
result,
|
|
7463
|
+
resultFile,
|
|
7464
|
+
logger,
|
|
7465
|
+
logPrefix: `[${jobType}] job ${job.jobKey}:`,
|
|
7466
|
+
rerun: (nudgeText) => runAgentJob(profile, job, {
|
|
7467
|
+
...runOpts,
|
|
7468
|
+
nudgePayload: nudgeText,
|
|
7469
|
+
stream: false,
|
|
7470
|
+
idleTimeoutMs: Math.min(effectiveIdleTimeoutMs || NUDGE_IDLE_TIMEOUT_MS, NUDGE_IDLE_TIMEOUT_MS),
|
|
7471
|
+
// Cap the recovery/probe window to the same 120s idle bound: it is
|
|
7472
|
+
// used by createIdleLivenessMonitor as the probe window when >0, so
|
|
7473
|
+
// inheriting the (possibly minutes-long) `effectiveRecoveryWindowMs`
|
|
7474
|
+
// from runOpts would let a silent nudge run outlive the intended
|
|
7475
|
+
// 120s idle bound and delay convergence on a wedged second turn.
|
|
7476
|
+
recoveryWindowMs: Math.min(effectiveRecoveryWindowMs || NUDGE_IDLE_TIMEOUT_MS, NUDGE_IDLE_TIMEOUT_MS),
|
|
7477
|
+
timeoutMs: Math.min(effectiveHardCapMs || NUDGE_HARD_CAP_MS, NUDGE_HARD_CAP_MS) || NUDGE_HARD_CAP_MS,
|
|
7478
|
+
}),
|
|
7479
|
+
});
|
|
7480
|
+
result.stdout = stdout;
|
|
7481
|
+
if (nudged) {
|
|
7482
|
+
result.nudgedForResult = true;
|
|
7483
|
+
if (truncated) result.truncated = true;
|
|
7484
|
+
}
|
|
7485
|
+
}
|
|
7290
7486
|
|
|
7291
7487
|
// Finalize git only when the harness succeeded — never push a
|
|
7292
7488
|
// half-finished workspace.
|
|
@@ -11991,11 +12187,14 @@ export {
|
|
|
11991
12187
|
makeSecretResolver,
|
|
11992
12188
|
hostEnvSecretResolver,
|
|
11993
12189
|
buildAgentPayload,
|
|
12190
|
+
buildAgentStdin,
|
|
11994
12191
|
buildResultEnvelope,
|
|
11995
12192
|
parseAgentResultObject,
|
|
11996
12193
|
readAgentResultFile,
|
|
11997
12194
|
parseResultFromStdout,
|
|
11998
12195
|
sanitizeResultVars,
|
|
12196
|
+
buildResultNudgePrompt,
|
|
12197
|
+
resolveAgentResultWithNudge,
|
|
11999
12198
|
parseEnvPairs,
|
|
12000
12199
|
normalizeEnvMap,
|
|
12001
12200
|
normalizeArgList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.44.
|
|
3
|
+
"version": "1.44.10",
|
|
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",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
},
|
|
58
58
|
"optionalDependencies": {
|
|
59
59
|
"node-pty": "^1.0.0",
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.
|
|
66
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.10",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.10",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.10",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.10",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.10",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.10",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.10"
|
|
67
67
|
}
|
|
68
68
|
}
|