c8ctl-plugin-nano 1.63.0 → 1.63.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.
Files changed (2) hide show
  1. package/agent-resume.mjs +162 -6
  2. package/package.json +8 -8
package/agent-resume.mjs CHANGED
@@ -100,6 +100,29 @@ function readConsistency(signal) {
100
100
  // if a stale/mismatched `baseRef` names something else (issue #241 round 6).
101
101
  const CONVENTIONAL_BASE_BRANCHES = new Set(['main', 'master']);
102
102
 
103
+ // A single element-scoped activation history is bounded, but the SDK's `search*`
104
+ // surface returns it in CURSOR-PAGINATED pages (issue #245): each response carries
105
+ // one page of `items` plus a `page.endCursor` bookmark, and the next page is fetched
106
+ // by echoing that cursor back as the request's `page.after`. Following the cursor to
107
+ // exhaustion is what prevents seeding a resume from a truncated-NEWEST transcript
108
+ // (consuming only the first/oldest page would drop the newest turns → re-drive
109
+ // already-completed steps). This caps the follow to a sane number of pages so a
110
+ // runaway or looping server cursor can never spin the probe forever (the whole read
111
+ // is also fenced by RESUME_READ_TIMEOUT_MS and the caller's abort signal).
112
+ export const RESUME_MAX_HISTORY_PAGES = 50;
113
+
114
+ // Aggregate cap on the RAW page bytes retained while draining the cursor, independent
115
+ // of the page COUNT above (issue #245). RESUME_MAX_HISTORY_PAGES bounds how many
116
+ // requests we make, but each page can itself be large (a single huge tool result), so
117
+ // a history well within the page cap could still buffer many megabytes of raw turns in
118
+ // `all` before `renderHistoryTurns` trims it down to RESUME_CONTEXT_CAP_CHARS. This
119
+ // bounds the peak memory a reactivation can consume: once the accumulated raw size
120
+ // crosses the budget the drain REJECTS (like the page-cap reject) and the caller
121
+ // cold-runs, rather than materializing an unbounded transcript just to discard all but
122
+ // its tail. The budget is a generous multiple of the rendered cap so a normal resume
123
+ // (whose rendered tail must fit RESUME_CONTEXT_CAP_CHARS anyway) never trips it.
124
+ export const RESUME_MAX_HISTORY_BYTES = RESUME_CONTEXT_CAP_CHARS * 8;
125
+
103
126
  // Race a promise against a deadline; rejects with a tagged timeout error so the
104
127
  // best-effort caller degrades to a cold rerun rather than blocking forever. The
105
128
  // deadline timer is cleared as soon as the read settles (win or lose), so it never
@@ -294,7 +317,9 @@ export function renderHistoryTurns(turns, { capChars = RESUME_CONTEXT_CAP_CHARS
294
317
  // a bare element key and MUST follow an instance resolution. We DO pass the current
295
318
  // `elementInstanceKey` in the history `filter` so a shared AgentInstance that spans
296
319
  // SIBLING element instances never bleeds another element's turns into this resume
297
- // (wrong continuation / cross-job exposure).
320
+ // (wrong continuation / cross-job exposure). The history read is CURSOR-PAGINATED
321
+ // (see readHistoryAllPages) — every page is followed to exhaustion so the newest
322
+ // turns of a multi-page activation are never dropped (issue #245).
298
323
  //
299
324
  // Every one of these reads is eventually consistent and gets the mandatory
300
325
  // `READ_CONSISTENCY` trailing argument (see its definition) — omitting it makes the real
@@ -329,6 +354,108 @@ function normalizeHistory(res) {
329
354
  return [];
330
355
  }
331
356
 
357
+ // Approximate the retained byte size of one raw history turn, for the aggregate-size
358
+ // budget that bounds peak resume memory (RESUME_MAX_HISTORY_BYTES). A cheap, robust
359
+ // serialized-length estimate: JSON.stringify covers content, tool calls, and their
360
+ // arguments; a turn that can't be serialized (cycles / exotic values) falls back to a
361
+ // fixed nominal cost so a pathological turn still advances the budget rather than
362
+ // counting as zero.
363
+ function approxTurnBytes(turn) {
364
+ try {
365
+ const s = JSON.stringify(turn);
366
+ return typeof s === 'string' ? s.length : 1_024;
367
+ } catch {
368
+ return 1_024;
369
+ }
370
+ }
371
+
372
+ // The forward pagination cursor a history response carries, per the real
373
+ // @camunda8/orchestration-cluster-api contract: `page.endCursor` (a null/absent
374
+ // value means this was the LAST page; `page.after` of the ensuing request advances
375
+ // off it). We also tolerate a top-level `endCursor`/`nextCursor` for leaner
376
+ // in-memory fakes / older shapes. Returns null when there is no further page.
377
+ function historyEndCursor(res) {
378
+ if (!isPlainObject(res)) return null;
379
+ // `page.endCursor` is AUTHORITATIVE whenever the `page` envelope carries that key —
380
+ // a PRESENT `endCursor` wins even when its value is `null`/blank, the documented
381
+ // terminal marker. Only when the `page` envelope does NOT carry an `endCursor` key at
382
+ // all (leaner in-memory fakes / older top-level shapes) do we fall through to a
383
+ // top-level `endCursor`/`nextCursor`. A plain `??` chain would instead let a stale
384
+ // top-level cursor OVERRIDE an explicit `page.endCursor: null`, following a mixed/newer
385
+ // response past its end-of-stream and seeding extra or misordered turns.
386
+ const cursor = (isPlainObject(res.page) && 'endCursor' in res.page)
387
+ ? res.page.endCursor
388
+ : (res.endCursor ?? res.nextCursor ?? null);
389
+ return isNonBlank(cursor) ? String(cursor) : null;
390
+ }
391
+
392
+ // Drain the FULL element-scoped AgentInstance history, following the SDK's
393
+ // cursor-forward pagination (`page.endCursor` → next request `page: { after }`)
394
+ // until the server reports no further page (issue #245). The `search*` history
395
+ // surface returns ONE bounded page at a time ({ items, page: { endCursor, … } }),
396
+ // so consuming only the first response would silently drop the NEWEST turns of a
397
+ // multi-page activation and seed the resume from a truncated-newest transcript —
398
+ // re-driving already-completed steps, the exact hazard the resume feature exists to
399
+ // prevent. We assemble every page IN ORDER before returning.
400
+ //
401
+ // Bounds:
402
+ // - RESUME_MAX_HISTORY_PAGES caps the follow so a runaway/looping cursor can't spin
403
+ // forever. Hitting the cap while a cursor STILL ADVANCES is NOT a clean end — it is
404
+ // a truncated-newest read (the exact hazard this pagination prevents), so the loop
405
+ // REJECTS (throws) rather than returning the partial `all` as if complete; the
406
+ // caller then discards it and cold-runs.
407
+ // - RESUME_MAX_HISTORY_BYTES caps the aggregate RAW size retained in `all` while
408
+ // draining, independent of the page COUNT: a history within the page cap can still
409
+ // carry huge per-page tool results, so crossing the byte budget REJECTS (throws)
410
+ // exactly like the page-cap reject — the caller discards the partial and cold-runs
411
+ // rather than buffering an unbounded transcript just to trim it to the render cap.
412
+ // - A server that echoes an unchanged cursor is treated as end-of-stream (no-progress
413
+ // guard). Because a paginated SDK commonly re-returns the SAME page in that case, we
414
+ // detect the non-advancing cursor BEFORE appending, so the echoed page's turns are
415
+ // never inserted twice into the assembled transcript.
416
+ // - The caller's abort `signal` stops the loop BEFORE issuing the next page request
417
+ // once the deadline fires (the outer callWithin has by then already degraded the
418
+ // whole probe to a cold rerun, so a partial return here is never seeded).
419
+ // - Any page rejection PROPAGATES to the caller's try/catch, which discards the
420
+ // partial (possibly truncated-newest) transcript and cold-runs — seeding from a
421
+ // partial read is exactly what this loop avoids.
422
+ async function readHistoryAllPages(fn, baseReq, signal) {
423
+ const all = [];
424
+ let bytes = 0;
425
+ let after;
426
+ for (let page = 0; page < RESUME_MAX_HISTORY_PAGES; page++) {
427
+ if (signal?.aborted) return all;
428
+ const req = after === undefined
429
+ ? baseReq
430
+ : { ...baseReq, page: { ...(isPlainObject(baseReq.page) ? baseReq.page : {}), after } };
431
+ const res = await fn(req, readConsistency(signal));
432
+ const next = historyEndCursor(res);
433
+ // No-progress guard, checked BEFORE appending: a server echoing the previous
434
+ // cursor is repeating the SAME page, so its turns are already in `all`. Discard
435
+ // the duplicate page and treat the echoed cursor as end-of-stream — appending it
436
+ // would double-insert completed turns into the seeded transcript.
437
+ if (after !== undefined && next === after) return all;
438
+ for (const turn of normalizeHistory(res)) {
439
+ all.push(turn);
440
+ bytes += approxTurnBytes(turn);
441
+ }
442
+ // Aggregate-size guard: bound peak memory independently of the page COUNT. A
443
+ // history within RESUME_MAX_HISTORY_PAGES can still buffer megabytes of raw turns
444
+ // here before rendering trims them to the tail, so reject once the accumulated raw
445
+ // size crosses the budget → the caller discards the partial and cold-runs.
446
+ if (bytes > RESUME_MAX_HISTORY_BYTES) {
447
+ throw new Error(`resume: AgentHistory exceeded ${RESUME_MAX_HISTORY_BYTES}-byte budget before terminating; treating as incomplete`);
448
+ }
449
+ // End-of-stream: the terminal page carries no forward cursor.
450
+ if (next === null) return all;
451
+ after = next;
452
+ }
453
+ // Page cap reached with the cursor still advancing → a TRUNCATED-NEWEST read. Reject
454
+ // so the caller discards the partial transcript and takes the cold-run fallback,
455
+ // rather than seeding an incomplete history that re-drives completed steps.
456
+ throw new Error(`resume: AgentHistory exceeded ${RESUME_MAX_HISTORY_PAGES} pages without terminating; treating as incomplete`);
457
+ }
458
+
332
459
  // Extract the element-instance keys an instance record is associated with, tolerating
333
460
  // BOTH the real SDK's plural `elementInstanceKeys` array (an AgentInstance can span
334
461
  // several element instances) and a singular `elementInstanceKey` scalar (in-memory
@@ -370,8 +497,17 @@ function scopeEmbeddedHistoryToElement(match, eik) {
370
497
 
371
498
  // The default engine read seam: probe the candidate SDK methods for a prior
372
499
  // AgentInstance correlated on `elementInstanceKey` and return its history turns.
373
- // Entirely best-effort ANY rejection/throw resolves to an empty list, never
374
- // propagates. Injected as `read` so tests drive it deterministically.
500
+ // Injected as `read` so tests drive it deterministically.
501
+ //
502
+ // Error handling is SPLIT by phase, deliberately:
503
+ // - Instance CORRELATION (the search + get-by-element probes below) is entirely
504
+ // best-effort: any rejection/throw resolves to an empty list and never propagates,
505
+ // because a failed correlation just means "nothing to resume from" (cold-run).
506
+ // - History PAGINATION (readHistoryAllPages) does NOT swallow: a page error
507
+ // propagates to readPriorTranscript's try/catch so a partial/truncated read is
508
+ // discarded (cold-run) rather than silently falling through to another alias's
509
+ // first-page-only result and being seeded as a complete transcript. See the alias
510
+ // loop below for the full rationale.
375
511
  //
376
512
  // `signal` (optional AbortSignal) bounds the request FAN-OUT: once the caller's
377
513
  // deadline aborts it, this stops BEFORE issuing the next SDK request (the get
@@ -436,9 +572,29 @@ async function defaultRead({ camunda, elementInstanceKey, signal }) {
436
572
  for (const m of HISTORY_METHODS) {
437
573
  if (signal?.aborted) return turns;
438
574
  if (typeof camunda[m] !== 'function') continue;
439
- try {
440
- turns = normalizeHistory(await camunda[m]({ agentInstanceKey: String(aik), filter: { elementInstanceKey: eik } }, readConsistency(signal)));
441
- } catch { turns = []; }
575
+ // Request the history OLDEST-first (`producedAt` ascending) explicitly:
576
+ // `renderHistoryTurns` assembles a bounded TAIL from the END of the array, so
577
+ // it requires chronological order. Cursor pagination preserves whatever order
578
+ // the API returns; without an explicit sort a newest-first (or changed) default
579
+ // would make us retain the OLDEST turns and let the resumed agent repeat
580
+ // already-completed side effects (issue #245).
581
+ const baseReq = {
582
+ agentInstanceKey: String(aik),
583
+ filter: { elementInstanceKey: eik },
584
+ sort: [{ field: 'producedAt', order: 'ASC' }],
585
+ };
586
+ // Do NOT swallow a pagination error here and fall through to the NEXT alias
587
+ // method: a partial/truncated read from one method (e.g. searchAgentInstanceHistory
588
+ // rejecting mid-pagination, or the page-cap truncated-newest reject) must never be
589
+ // silently replaced by another alias's first-page-only result (e.g.
590
+ // getAgentInstanceHistory returning just the oldest page), which readHistoryAllPages
591
+ // would then accept as complete and seed as a truncated-newest transcript — the exact
592
+ // hazard the pagination guard exists to prevent for a client exposing BOTH methods.
593
+ // Let the error ESCAPE to readPriorTranscript's best-effort try/catch, marking the
594
+ // whole read incomplete so the caller cold-runs instead of seeding partial history.
595
+ // (A method that is simply absent is skipped by the typeof guard above; one that
596
+ // returns EMPTY — no history via that name — still advances to the next alias.)
597
+ turns = await readHistoryAllPages(camunda[m].bind(camunda), baseReq, signal);
442
598
  if (turns.length) break;
443
599
  }
444
600
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.63.0",
3
+ "version": "1.63.1",
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",
@@ -75,12 +75,12 @@
75
75
  },
76
76
  "optionalDependencies": {
77
77
  "node-pty": "^1.0.0",
78
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.63.0",
79
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.63.0",
80
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.63.0",
81
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.63.0",
82
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.63.0",
83
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.63.0",
84
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.63.0"
78
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.63.1",
79
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.63.1",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.63.1",
81
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.63.1",
82
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.63.1",
83
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.63.1",
84
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.63.1"
85
85
  }
86
86
  }