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.
- package/agent-resume.mjs +162 -6
- 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
|
-
//
|
|
374
|
-
//
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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.
|
|
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.
|
|
79
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.63.
|
|
80
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.63.
|
|
81
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.63.
|
|
82
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.63.
|
|
83
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.63.
|
|
84
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.63.
|
|
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
|
}
|