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.
Files changed (2) hide show
  1. package/c8ctl-plugin.js +203 -4
  2. 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
- const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
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
- result = await runAgentJob(profile, job, {
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.9",
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.9",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.9",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.9",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.9",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.9",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.9",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.9"
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
  }