c8ctl-plugin-nano 1.61.0 → 1.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -728,7 +728,7 @@ only `latest`, so the key is the reproducibility handle).
728
728
 
729
729
  On completion the plugin writes an **output envelope** back under
730
730
  `io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error, promptResourceKey?}`). When a repository was
731
- provisioned (below) it also carries `{repository, branch, baseSha, headSha, commits[], pushed, pushError?, gitError?, pr?}`.
731
+ provisioned (below) it also carries `{repository, branch, baseSha, headSha, commits[], pushed, pushError?, pushFailed?, strandedCommits?, branchMismatch?, scanError?, gitError?, pr?}`. `pushFailed` is the explicit "push failed" flag — set for a non-zero `git push` **whose result could not be confirmed as landed at the remote** (a non-fast-forward rejection, or an auth, hook, or network error), not only a server rejection. **`pushFailed` does not by itself prove a remote push was attempted or rejected:** `finalizeGit` also sets it (with `strandedCommits` and a `branchMismatch` `{expected, actual}`) when it *refuses to push at all* because the harness moved HEAD off the provisioned work branch — or left commits abandoned on another local branch — so pushing the work branch would publish stale work and strand those commits (no `git push` runs, so `pushError` is absent in that case). As a guard against false strands, a non-zero push is re-checked with `ls-remote`: if `origin/<branch>` already points at `headSha` — or the remote tip is a **descendant** of `headSha` (another actor pushed a further commit after ours landed) — the push is treated as a transport hiccup that landed after the ref was accepted (`pushed: true`, no `pushFailed`/`pushError`); otherwise `pushFailed` is set and `strandedCommits` lists the SHAs of the new commits left UNPUSHED in the throwaway workspace — together they are the recovery handle for a failed push, so consumers must not treat `pushed: false` alone as the only signal. On such a failure (a rejected push **or** a branch-mismatch refusal) the throwaway workspace is preserved **best-effort** (even under the default `--keep-runs=false`) so those SHAs stay recoverable, but this is a *recovery window, not a durable archive*: the run-directory reaper still ages it out by mtime and worker shutdown removes the namespace — copy the stranded commits out promptly (or run with `--keep-runs`). **`scanError`** flags a **best-effort/PARTIAL** `strandedCommits` list: a commit or remote-reachability scan did **not** complete, so a consumer should recover the whole preserved workspace rather than trust the list exactly. It arises two ways: (a) a **pre-push** commit-enumeration scan failed, so `finalizeGit` refused to push on an incomplete graph — **no push was attempted** (`pushError` is absent) and `strandedCommits` is a partial surfacing; or (b) a push was **ATTEMPTED and rejected** but the remote-reachability FILTER that trims already-published commits from the strand set itself failed — so `scanError` is present **alongside** `pushError`, marking the rejected-push strand list as best-effort (it may falsely include an already-landed commit). Either way `pushFailed` stays authoritative; `scanError` is how a consumer knows the strand list is inexact (recover the whole workspace) versus an exact list (a rejected push with no `scanError`).
732
732
 
733
733
  **Git provisioning (host).** When `--sandbox none` (the default) and the envelope
734
734
  carries a `repository.url`, the plugin provisions a workspace on the host around
@@ -764,7 +764,17 @@ the harness:
764
764
  per envelope (default 120s, or the `--clone-timeout` worker flag) as a backstop
765
765
  for repos big enough to approach the cap even when shallow; a timeout is now
766
766
  reported *as a timeout* rather than an opaque `exit 128`;
767
- 3. create `branch.create` (if set) off that target;
767
+ 3. create `branch.create` (if set) off that target — or, when `branch.create` is
768
+ **absent** (or names the effective base itself) **and push is enabled**, cut a
769
+ generated fallback work branch `nano/agent-work/<base>-<runId>` so commits are
770
+ never made directly on the base branch; the branch actually used (configured or
771
+ generated) rides back in the result envelope (`branch`) and is exported to the
772
+ harness as `AGENT_REPO_BRANCH`. **Exception — a detached tag/SHA checkout with
773
+ no `branch.create`:** with no symbolic branch to name a fallback from, it stays
774
+ `branch: null` and its commits are **not** pushed even when push is enabled —
775
+ this holds for *any* detached no-`branch.create` checkout, **including** when a
776
+ `branch.base` is configured (the base name is not used to synthesize a fallback
777
+ here). Supply `branch.create` to publish work committed off a tag/SHA base;
768
778
  4. set a **committer identity** on the workspace, preferring the operator's own
769
779
  (`GIT_AUTHOR_*` env → global `git config user.name/email` → the
770
780
  `gh`-authenticated GitHub user), and only falling back to `nano-agent` when
@@ -793,10 +803,36 @@ config is neutralized (`GIT_CONFIG_GLOBAL` → the platform null device,
793
803
  or `url.*.insteadOf` can't silently inject operator credentials. (An **SSH**
794
804
  remote — `git@…`/`ssh://…` — can still authenticate via the host's SSH
795
805
  agent/config; use HTTPS URLs if you need a guaranteed-anonymous clone.)
796
- Token-backed jobs keep global config (e.g. `http.proxy`). A push failure is
797
- reported as `pushError` (the job still completes) so a later BPMN step can drive
798
- the merge; a clone/checkout failure sheds the job (retryable). Workspaces are
799
- deleted after each job (keep them with `--keep-runs`).
806
+ Token-backed jobs keep global config (e.g. `http.proxy`). **`pushFailed` is the
807
+ authoritative "work is stranded, workspace preserved" flag** (the job still
808
+ completes, so a later BPMN step can drive the merge); a clone/checkout failure
809
+ sheds the job (retryable). `pushError` is present **only** for a push that was
810
+ ATTEMPTED and rejected/unconfirmed — when `finalizeGit` refuses to push at all (a
811
+ branch mismatch, or a partial/incomplete commit scan), it sets `pushFailed`
812
+ (with `branchMismatch` or `scanError`) but **no** `pushError`, so consumers must
813
+ key recovery off `pushFailed`, not `pushError`. **`scanError` flags a
814
+ best-effort/PARTIAL `strandedCommits` list** — a commit/reachability scan did not
815
+ complete, so the list may omit or (after a rejected push) falsely include commits
816
+ and a consumer should recover the whole preserved workspace rather than trust the
817
+ list exactly. It arises two ways: (a) a pre-push commit-enumeration scan failed, so
818
+ `finalizeGit` refused to push on an incomplete graph (**no** `git push` ran, so
819
+ `pushError` is absent); or (b) a push was ATTEMPTED and rejected but the
820
+ remote-reachability FILTER that trims already-published commits from the strand set
821
+ itself failed — so `scanError` is present **alongside** `pushError`, marking the
822
+ otherwise-complete rejected-push strand list as best-effort. Either way `pushFailed`
823
+ stays authoritative. Workspaces are
824
+ deleted after each job (keep them with `--keep-runs`) — **except** a job whose
825
+ `git push` **could not be confirmed as landed** (a non-fast-forward rejection, or
826
+ an auth/hook/network error whose `ls-remote` re-check did not find `origin/<branch>`
827
+ at or ahead of `headSha` — `pushFailed` + `pushError`), **or** one where
828
+ `finalizeGit` refused to push (no `git push` ran, so `pushError` is absent)
829
+ because HEAD moved off the provisioned work branch — or left commits on another
830
+ local branch or an abandoned detached HEAD reachable only via the reflog (a
831
+ `branchMismatch` strand) — **or** because a critical local commit scan did not
832
+ complete (a `scanError` strand, with a best-effort PARTIAL `strandedCommits`),
833
+ whose workspace is preserved best-effort so its
834
+ `strandedCommits` stay recoverable; that preservation is still age-gated by the
835
+ reaper and cleared on worker shutdown, so recover the SHAs promptly.
800
836
 
801
837
  ```bash
802
838
  # The harness sees a cloned repo at $AGENT_WORKSPACE; branch/push/PR are handled for it.
@@ -28,6 +28,57 @@ const SDK_UPDATE = 'updateAgentInstance';
28
28
 
29
29
  const isNonBlank = (v) => v != null && String(v).trim() !== '';
30
30
  const isPlainObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
31
+ // #229: collapse CR/LF (and other line/para separators) to a single space so a
32
+ // multiline engine error can't split one correlation record across several
33
+ // worker-log lines — that would both dilute the status/body diagnostic and let a
34
+ // crafted error body spoof extra log lines. Used when rendering SDK errors.
35
+ const oneLine = (v) => String(v).replace(/[\r\n\t\f\v\u0085\u2028\u2029]+/g, ' ');
36
+
37
+ /**
38
+ * Extract the HTTP status + response body from an SDK rejection (#229).
39
+ *
40
+ * A create/append failure logged as an opaque `status 400` is useless — the body
41
+ * is what distinguishes a lease-fence rejection from a schema error from a 404.
42
+ * The host `@camunda8/orchestration-cluster-api` client surfaces these on a few
43
+ * shapes depending on the transport, so probe the common ones defensively and
44
+ * never throw (this runs on the best-effort logging path).
45
+ *
46
+ * @param {*} err
47
+ * @returns {{ status: (number|string|undefined), body: (string|undefined), message: string }}
48
+ */
49
+ export function describeSdkError(err) {
50
+ if (err == null) return { status: undefined, body: undefined, message: String(err) };
51
+ const status =
52
+ err.status ??
53
+ err.statusCode ??
54
+ err?.response?.status ??
55
+ err?.response?.statusCode ??
56
+ (typeof err.code === 'number' ? err.code : undefined);
57
+ let body =
58
+ err.body ??
59
+ err.responseBody ??
60
+ err?.response?.data ??
61
+ err?.response?.body ??
62
+ undefined;
63
+ if (body != null && typeof body !== 'string') {
64
+ try {
65
+ body = JSON.stringify(body);
66
+ } catch {
67
+ body = String(body);
68
+ }
69
+ }
70
+ const message = err.message ? String(err.message) : String(err);
71
+ return { status, body, message };
72
+ }
73
+
74
+ /** One-line rendering of {@link describeSdkError} for a log line (body capped). */
75
+ function formatSdkError(err) {
76
+ const { status, body, message } = describeSdkError(err);
77
+ const parts = [`status ${status ?? 'unknown'}`];
78
+ if (isNonBlank(body)) parts.push(`body ${oneLine(String(body).slice(0, 600))}`);
79
+ parts.push(`msg ${oneLine(message)}`);
80
+ return parts.join('; ');
81
+ }
31
82
 
32
83
  /**
33
84
  * Is this activated job an `external` (job-backed) agent job — i.e. one whose
@@ -175,6 +226,24 @@ export function createAgentInstanceProducer(opts = {}) {
175
226
  const leaseToken = job?.leaseToken != null ? String(job.leaseToken) : '';
176
227
  const elementInstanceKey = job?.elementInstanceKey != null ? String(job.elementInstanceKey) : '';
177
228
  const elementId = job?.elementId != null ? String(job.elementId) : null;
229
+ // #229: cross-channel correlation. Stamp jobKey + elementInstanceKey +
230
+ // processInstanceKey on every producer log so the AgentInstance channel can be
231
+ // joined to the job / relay / git channels (today AgentInstance logs carry no
232
+ // processInstanceKey, so the four channels can't be reconciled).
233
+ const processInstanceKey = job?.processInstanceKey != null ? String(job.processInstanceKey) : '';
234
+ const corr = () =>
235
+ `job ${jobKey || '?'} eik ${elementInstanceKey || '?'} pik ${processInstanceKey || '?'}`;
236
+ // The lease token is a secret-ish fence token — never log it whole. `slice(-6)`
237
+ // would print a short/malformed lease (≤6 chars) IN FULL, so only surface a tail
238
+ // when the token is long enough that the tail still hides most of it; otherwise
239
+ // emit a safe digest (presence + length). Enough either way to tell "present"
240
+ // from "absent" and to correlate the activation without exposing the token.
241
+ const leaseNote = () =>
242
+ !leaseToken
243
+ ? 'lease absent'
244
+ : leaseToken.length > 8
245
+ ? `lease …${leaseToken.slice(-4)}`
246
+ : `lease present (len ${leaseToken.length})`;
178
247
 
179
248
  // The producer is a no-op unless every precondition holds: a usable SDK client,
180
249
  // an external agent job, and the ACP classifier. Any missing piece leaves the
@@ -191,6 +260,16 @@ export function createAgentInstanceProducer(opts = {}) {
191
260
  let activated = false;
192
261
  let loopIteration = 0;
193
262
  let queue = Promise.resolve();
263
+ // #229 turn accounting: how many AgentHistory turns were actually appended, and
264
+ // when the instance was minted — so `complete()` can log "N turns over Xm" and
265
+ // separate "created but nothing ingested" (the 0-turns husk) from "create failed".
266
+ let turnsAppended = 0;
267
+ let activatedAt = 0;
268
+ // #229 first-failure elevation: the FIRST per-turn append failure (400/404) for
269
+ // this instance is logged at `warn` (with the SDK verb + status); repeats stay at
270
+ // `debug` so a persistently-rejecting instance doesn't flood the log.
271
+ let appendFailureLogged = false;
272
+ let ingestFailureLogged = false;
194
273
  // Coalesce streamed message chunks (same messageId + role) into one turn, flushed
195
274
  // on a role/message boundary, a tool event, or completion — the engine dedups on
196
275
  // historyItemId (it does NOT merge), so a turn must be appended exactly once, whole.
@@ -201,10 +280,30 @@ export function createAgentInstanceProducer(opts = {}) {
201
280
  const iso = () => new Date(now()).toISOString();
202
281
 
203
282
  // Serialize an SDK call onto the queue so appends preserve order and `complete`
204
- // can drain them. A rejection is swallowed (best-effort) but never breaks the chain.
205
- const enqueue = (fn) => {
283
+ // can drain them. A rejection is best-effort (never breaks the chain), but the
284
+ // first append failure per instance is elevated to `warn` with the SDK verb +
285
+ // HTTP status (#229) — per-turn append failures were invisible at `debug`.
286
+ const enqueue = (fn, label = 'updateAgentInstance') => {
206
287
  queue = queue.then(fn).catch((err) => {
207
- logger?.debug?.(`AgentInstance producer: SDK call failed ${err?.message || err}`);
288
+ const { status, message } = describeSdkError(err);
289
+ // The first-failure elevation is for per-turn APPEND failures ONLY. A
290
+ // completion status update (status→COMPLETED) rides this same queue, so if
291
+ // it were allowed to consume the one-shot flag it would suppress the FIRST
292
+ // real per-turn append failure down to `debug` — the exact regression the
293
+ // #229 warning exists to prevent. Gate the elevation on the append label;
294
+ // completion failures are diagnosed separately in `complete()` (they render
295
+ // a failed/unknown terminal transition), so here they only ever log at debug.
296
+ const isAppend = label.includes('append');
297
+ if (isAppend && !appendFailureLogged) {
298
+ appendFailureLogged = true;
299
+ logger?.warn?.(
300
+ `AgentInstance producer: ${label} failed (${corr()}) — status ${status ?? 'unknown'}: ${oneLine(message)}; further append failures for this instance stay at debug.`,
301
+ );
302
+ } else {
303
+ logger?.debug?.(
304
+ `AgentInstance producer: ${label} failed (${corr()}) — status ${status ?? 'unknown'}: ${oneLine(message)}`,
305
+ );
306
+ }
208
307
  });
209
308
  return queue;
210
309
  };
@@ -222,8 +321,16 @@ export function createAgentInstanceProducer(opts = {}) {
222
321
  history: [turn],
223
322
  };
224
323
  if (status) req.status = status;
225
- await camunda[SDK_UPDATE](req);
226
- });
324
+ const res = await camunda[SDK_UPDATE](req);
325
+ // #229/#232: the engine dedups appends by historyItemId, so a retry or a
326
+ // reactivation can return 200 while creating ZERO new history entries. Count
327
+ // what the engine actually CREATED (`res.createdHistory`) — not the attempt —
328
+ // so the completion counter separates a real append from a deduplicated no-op
329
+ // and keeps the 0-turns husk diagnosis honest. Fall back to +1 only when the
330
+ // response omits the field (older engine), so a genuine append is never
331
+ // under-counted.
332
+ turnsAppended += Array.isArray(res?.createdHistory) ? res.createdHistory.length : 1;
333
+ }, 'updateAgentInstance(append)');
227
334
  };
228
335
 
229
336
  const flushMessage = () => {
@@ -289,6 +396,18 @@ export function createAgentInstanceProducer(opts = {}) {
289
396
  appendTurn(turn);
290
397
  };
291
398
 
399
+ // #229: first-failure elevation for ingest faults (classifier OR handler). The
400
+ // FIRST ingest failure per instance logs at `warn`; repeats stay at `debug` so a
401
+ // persistently-faulting instance doesn't flood the log.
402
+ const noteIngestFailure = (err) => {
403
+ if (!ingestFailureLogged) {
404
+ ingestFailureLogged = true;
405
+ logger?.warn?.(`AgentInstance producer: ingest failed (${corr()}) — ${oneLine(err?.message || err)}; further ingest failures for this instance stay at debug.`);
406
+ } else {
407
+ logger?.debug?.(`AgentInstance producer: ingest failed (${corr()}) — ${oneLine(err?.message || err)}`);
408
+ }
409
+ };
410
+
292
411
  return {
293
412
  /** True once the AgentInstance has been minted (or is being minted). */
294
413
  get active() {
@@ -339,15 +458,22 @@ export function createAgentInstanceProducer(opts = {}) {
339
458
  // result carries no key, fall back to the elementInstanceKey correlation is
340
459
  // not possible for updates (they need the agentInstanceKey), so disable.
341
460
  disabled = true;
342
- logger?.warn?.('AgentInstance producer: create returned no agentInstanceKey; disabling durable transcript for this job.');
461
+ logger?.warn?.(`AgentInstance producer: create returned no agentInstanceKey (${corr()}); disabling durable transcript for this job.`);
343
462
  return false;
344
463
  }
345
464
  loopIteration = 1;
346
- logger?.info?.(`AgentInstance ${agentInstanceKey} minted for element instance ${elementInstanceKey} (job ${jobKey}).`);
465
+ activatedAt = now();
466
+ logger?.info?.(`AgentInstance ${agentInstanceKey} minted (${corr()}; ${leaseNote()}; model ${oneLine(def.model)}/${oneLine(def.provider)}).`);
347
467
  return true;
348
468
  } catch (err) {
349
469
  disabled = true;
350
- logger?.warn?.(`AgentInstance producer: createAgentInstance failed ${err?.message || err}; continuing without a durable transcript (job completion unaffected).`);
470
+ // #229: the single line that would have root-caused the 20974 work-loss.
471
+ // Log the engine's HTTP status + response body (a lease-fence 400 vs a
472
+ // schema 400 vs a 404) plus the request correlation actually sent —
473
+ // elementInstanceKey/jobKey/processInstanceKey, whether the lease was
474
+ // present + its tail, and the model/provider. An opaque "status 400" alone
475
+ // is useless.
476
+ logger?.warn?.(`AgentInstance producer: createAgentInstance REJECTED (${corr()}; ${leaseNote()}; model ${oneLine(def.model)}/${oneLine(def.provider)}) — ${formatSdkError(err)}; continuing without a durable transcript (job completion unaffected).`);
351
477
  return false;
352
478
  }
353
479
  },
@@ -362,7 +488,11 @@ export function createAgentInstanceProducer(opts = {}) {
362
488
  let classified;
363
489
  try {
364
490
  classified = classify(rawUpdate);
365
- } catch {
491
+ } catch (err) {
492
+ // A classifier/translation fault is an ingest failure too — route it
493
+ // through the same first-failure elevation (#229) instead of returning
494
+ // silently, or the ingest path can still drop every turn with no warning.
495
+ noteIngestFailure(err);
366
496
  return;
367
497
  }
368
498
  if (!classified || typeof classified !== 'object') return;
@@ -401,7 +531,10 @@ export function createAgentInstanceProducer(opts = {}) {
401
531
  break;
402
532
  }
403
533
  } catch (err) {
404
- logger?.debug?.(`AgentInstance producer: ingest failed ${err?.message || err}`);
534
+ // #229: elevate the FIRST ingest failure per instance to `warn` (repeats
535
+ // stay `debug`) so a translation/append fault that silently drops every
536
+ // turn is visible at normal verbosity.
537
+ noteIngestFailure(err);
405
538
  }
406
539
  },
407
540
 
@@ -425,6 +558,12 @@ export function createAgentInstanceProducer(opts = {}) {
425
558
  return;
426
559
  }
427
560
  flushMessage();
561
+ // Track whether the COMPLETED status request actually resolved. The update
562
+ // rides the best-effort queue (whose catch swallows SDK rejections), so we
563
+ // must NOT infer the terminal transition from `ok` alone — a 400/404 on the
564
+ // status update would otherwise be logged as a successful COMPLETED (#229),
565
+ // defeating the husk diagnosis. `null` ⇒ no status update attempted.
566
+ let statusResolved = ok ? false : null;
428
567
  if (ok) {
429
568
  enqueue(async () => {
430
569
  await camunda[SDK_UPDATE]({
@@ -434,9 +573,25 @@ export function createAgentInstanceProducer(opts = {}) {
434
573
  jobLease: leaseToken,
435
574
  status: 'COMPLETED',
436
575
  });
437
- });
576
+ statusResolved = true;
577
+ }, 'updateAgentInstance(status→COMPLETED)');
438
578
  }
439
579
  try { await queue; } catch { /* best effort */ }
580
+ // #229: log a turn counter — "N turns appended over Xm, status→…" — so the
581
+ // 0-turns husk ("created but nothing ingested") is distinguishable from a
582
+ // healthy run at a glance, separate from the "create failed" line above.
583
+ const elapsedMs = activatedAt ? Math.max(0, now() - activatedAt) : 0;
584
+ const mins = (elapsedMs / 60000).toFixed(1);
585
+ // Render the ACTUAL terminal transition: COMPLETED only when the status
586
+ // update resolved; on a rejected update say so (the instance stays
587
+ // non-terminal, so a retry/reactivation still continues it); on a failed run
588
+ // no update was attempted at all.
589
+ const transition = ok
590
+ ? (statusResolved
591
+ ? 'COMPLETED'
592
+ : 'COMPLETED update FAILED — left non-terminal (retry/reactivation continues it)')
593
+ : 'left non-terminal (retry/reactivation continues it)';
594
+ logger?.info?.(`AgentInstance ${agentInstanceKey} (${corr()}): ${turnsAppended} turn(s) appended over ${mins}m, status→${transition}.`);
440
595
  },
441
596
  };
442
597
  }