c8ctl-plugin-nano 1.61.1 → 1.61.2

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.
@@ -28,38 +28,193 @@ 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
+
31
32
  // #229: collapse CR/LF (and other line/para separators) to a single space so a
32
33
  // multiline engine error can't split one correlation record across several
33
34
  // 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
+ // crafted error body spoof extra log lines. Used by the one-line #229 renderers.
35
36
  const oneLine = (v) => String(v).replace(/[\r\n\t\f\v\u0085\u2028\u2029]+/g, ' ');
36
37
 
38
+ // Default create-retry backoff (issue #230). A transient createAgentInstance
39
+ // rejection at second 0 (e.g. a lease fence that has not yet settled) must NOT
40
+ // forfeit the whole run's durable transcript, so a failed/absent create is
41
+ // retried — idempotently, correlated on the elementInstanceKey — rather than
42
+ // disabling the producer. Retries are paced by exponential backoff (capped) and
43
+ // driven lazily by the ACP hot path, so a create that becomes possible mid-run
44
+ // is picked up without hammering the engine.
45
+ const DEFAULT_CREATE_RETRY_BASE_MS = 1000;
46
+ const DEFAULT_CREATE_RETRY_MAX_MS = 30000;
47
+
48
+ // Bound on the pre-mint replay buffer (issue #230). Until the create succeeds there
49
+ // is no agentInstanceKey to append against, so ACP updates that arrive while a create
50
+ // is still being retried are buffered and replayed once the instance mints — that is
51
+ // what keeps a transient create failure from silently losing the agent's actual work.
52
+ // The buffer is capped so a create that never succeeds cannot grow it without bound;
53
+ // once full, further updates are dropped (with a one-time warning) rather than
54
+ // evicting the earliest turns, so the replayed transcript stays a contiguous prefix.
55
+ // The cap is applied on BOTH a slot count and an approximate byte budget: a streamed
56
+ // response can emit many token-chunk notifications, so a raw-count cap alone would let
57
+ // a chunk storm exhaust the buffer and silently truncate the transcript — the byte
58
+ // budget bounds the actual payload, and dropped updates are COUNTED and reported on
59
+ // replay so any truncation is visible rather than silent (issue #230).
60
+ const DEFAULT_PRE_MINT_BUFFER_MAX = 1000;
61
+ const DEFAULT_PRE_MINT_BUFFER_MAX_BYTES = 8_000_000;
62
+
63
+ // A terminal COMPLETED update is driven directly (not via the best-effort append
64
+ // queue) and RETRIED a bounded number of times: on a successful job end the caller
65
+ // settles the job the instant complete() returns, so there is no reactivation to
66
+ // retry a rejected terminal transition — a single swallowed failure would strand the
67
+ // instance non-terminal forever (issue #230).
68
+ const DEFAULT_TERMINAL_RETRY_MAX = 3;
69
+
70
+ // Bound on complete()'s last-chance create attempt (issue #230). The harness awaits
71
+ // complete() BEFORE it settles the job (c8ctl-plugin.js), and the final un-throttled
72
+ // createAgentInstance attempt awaits the SDK promise, which has no timeout of its own.
73
+ // An AgentInstance outage that hangs that request would otherwise block complete()
74
+ // indefinitely and could expire the job lease — the opposite of the "best-effort /
75
+ // job completion unaffected" contract. So the final attempt is awaited only up to this
76
+ // bound; past it we stop awaiting (the idempotent request is left to settle in the
77
+ // background) and let the job settle. `0`/non-positive disables the bound.
78
+ const DEFAULT_FINALIZE_TIMEOUT_MS = 10_000;
79
+
80
+ // Circuit-breaker cap on the number of concurrent, still-in-flight createAgentInstance
81
+ // requests (issue #230). Retiring a hung create frees the `creating` slot for a fresh
82
+ // retry, but the underlying SDK POST is NOT cancellable — it stays outstanding until it
83
+ // finally settles (maybe never, during an engine outage). Because the retry backoff
84
+ // starts at ~1s while a hung request isn't retired until finalizeTimeoutMs (~10s),
85
+ // frequent ACP updates could otherwise launch a new POST every backoff window while the
86
+ // earlier retired-but-hung POSTs are all still in flight, accumulating overlapping
87
+ // requests and hammering the engine. This bounds the outstanding POSTs: once this many
88
+ // are in flight, maybeStartCreate() pauses new attempts until one settles.
89
+ const DEFAULT_MAX_INFLIGHT_CREATES = 3;
90
+
91
+ // Backpressure cap on the post-mint append backlog (issue #230). Appends are serialized
92
+ // on `queue`, and each is individually bounded at finalizeTimeoutMs — so during a
93
+ // prolonged AgentInstance outage every append can take the full bound to settle. Without
94
+ // a cap, ingest() (the ACP hot path is non-blocking) could enqueue an unbounded backlog
95
+ // of turn closures/payloads and keep draining it for hours after complete() returns,
96
+ // retaining all of it in memory. Once this many appends are pending, the NEWEST turn is
97
+ // dropped and counted (warn-once), mirroring the pre-mint buffer's explicit drop policy
98
+ // so the truncation is visible rather than a silent unbounded backlog.
99
+ const DEFAULT_MAX_PENDING_APPENDS = 1000;
100
+
101
+ // Companion BYTE cap on the post-mint append backlog (issue #230). The count cap above
102
+ // bounds only the NUMBER of pending appends, but each retained turn closure holds the
103
+ // full `turn` payload — and a single tool result/argument can be arbitrarily large — so
104
+ // a backlog well under `maxPendingAppends` can still consume unbounded memory during an
105
+ // AgentInstance outage. Mirror the pre-mint buffer's byte cap: once the pending appends'
106
+ // approximate serialized size would exceed this, DROP the newest turn (counted, warn-once)
107
+ // like the count cap, so the memory bound holds regardless of per-turn size.
108
+ const DEFAULT_MAX_PENDING_APPEND_BYTES = 8_000_000;
109
+
110
+ // Byte cap on the streaming coalescing buffer for a SINGLE in-flight assistant message
111
+ // (issue #230). A `message` update streams as many token-chunk notifications that are
112
+ // coalesced in `pendingMessage.texts` until a boundary (role/messageId change, tool
113
+ // call/result, drain, or completion) flushes them into ONE turn. Until that flush the
114
+ // chunks are held in memory and are NOT yet subject to the post-mint append/pre-mint
115
+ // buffer byte caps — so a single very long (or never-terminated) response could grow
116
+ // the coalescing buffer without bound. Once the buffered chunks' approximate size would
117
+ // exceed this, DROP further chunks (counted, warn-once) and mark the message truncated,
118
+ // so the flushed turn carries a visible truncation marker rather than the buffer growing
119
+ // unbounded — the same explicit "bound + surface the drop" policy as the other buffers.
120
+ const DEFAULT_MAX_PENDING_MESSAGE_BYTES = 8_000_000;
121
+
122
+ // Race a best-effort promise against `timeoutMs`, reporting WHICH won, without ever
123
+ // rejecting. Used to bound every create attempt so a hung createAgentInstance can
124
+ // neither block activate() (which gates whether the harness runs at all) nor
125
+ // complete() (which gates job settlement / lease expiry) — issue #230. Resolves `true`
126
+ // when the promise settles (success OR failure — both are best-effort) within the
127
+ // window, or `false` when the timeout wins. `timeoutMs<=0` waits unbounded (always
128
+ // resolves `true` once the promise settles). The underlying attempt is not cancelled
129
+ // (the SDK call is not cancellable) but is left to settle in the background; its late
130
+ // result is neutralised by the caller's identity guard, and createAgentInstance is
131
+ // idempotent per elementInstanceKey.
132
+ function settleWithin(promise, timeoutMs, setTimer = setTimeout) {
133
+ const settled = Promise.resolve(promise).then(
134
+ () => true,
135
+ () => true,
136
+ );
137
+ if (!(timeoutMs > 0)) return settled;
138
+ return new Promise((resolve) => {
139
+ // This is a REQUIRED deadline timer, not a hygiene timer: it is the guarantee
140
+ // that lets activate()/complete() return when the SDK promise is hung, so it must
141
+ // keep the event loop alive until it fires (or is cleared on settle). unref()'ing
142
+ // it would let a client-side hang with no other active handle exit the worker
143
+ // before the bound resolves, defeating the guarantee (issue #230). It is always
144
+ // cleared the instant the promise settles, so it never outlives its purpose.
145
+ const timer = setTimer(() => resolve(false), timeoutMs);
146
+ settled.then((won) => {
147
+ clearTimeout(timer);
148
+ resolve(won);
149
+ });
150
+ });
151
+ }
152
+
153
+ // Await an SDK call up to `timeoutMs`, resolving to its value on success, RE-THROWING
154
+ // its rejection, or throwing a synthetic timeout error (tagged `__nanoTimeout`) when
155
+ // the deadline wins — the underlying call is left to settle in the background. Unlike
156
+ // `settleWithin` (which only reports WHO won) this preserves the call's outcome, so a
157
+ // bounded terminal update can still observe success/failure while never blocking job
158
+ // settlement on a hung request past its lease (issue #230). `timeoutMs<=0` is unbounded.
159
+ async function callWithin(promise, timeoutMs, setTimer = setTimeout) {
160
+ if (!(timeoutMs > 0)) return promise;
161
+ let timer;
162
+ const deadline = new Promise((_resolve, reject) => {
163
+ // Required deadline timer (see settleWithin) — deliberately NOT unref()'d.
164
+ timer = setTimer(() => {
165
+ const err = new Error(`SDK call timed out after ${timeoutMs}ms`);
166
+ err.__nanoTimeout = true;
167
+ reject(err);
168
+ }, timeoutMs);
169
+ });
170
+ try {
171
+ return await Promise.race([Promise.resolve(promise), deadline]);
172
+ } finally {
173
+ clearTimeout(timer);
174
+ }
175
+ }
176
+
177
+ // Cap the normalized SDK error body before logging so an oversized/multiline engine
178
+ // response can't overwhelm the per-worker log (matches supervisor-engine.mjs).
179
+ const SDK_ERROR_BODY_MAX = 500;
180
+
181
+ // Fold line breaks into a visible inline marker and cap length so a diagnostic stays ONE
182
+ // correlatable, volume-bounded log line: a multiline SDK body/message would otherwise
183
+ // split the worker log across lines (breaking correlation), and an over-long message
184
+ // that embeds a large body would bypass the body cap and flood the log (issue #230).
185
+ // Cover the SAME line-separator set as `oneLine` (CR/LF plus U+0085/U+2028/U+2029) so a
186
+ // Unicode line separator in an engine body/message can't slip past the guard and split
187
+ // the correlation record.
188
+ function normalizeSdkText(value, max) {
189
+ let s = String(value).replace(/[\r\n\u0085\u2028\u2029]+/g, ' ⏎ ');
190
+ if (s.length > max) s = `${s.slice(0, max)}… (${s.length} chars)`;
191
+ return s;
192
+ }
193
+
37
194
  /**
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 }}
195
+ * Pull the diagnosable facts out of an SDK/transport rejection so a create/append
196
+ * failure is LOUD and root-causable (issue #230 ask 1 / #229): the HTTP status and
197
+ * the engine's response body distinguish a lease-fence rejection from a schema
198
+ * error an "SDK status 400" message alone is useless. Tolerant of the various
199
+ * error shapes the `@camunda8/orchestration-cluster-api` client and the underlying
200
+ * transport surface (statusCode / status / nested response / body / data).
48
201
  */
49
202
  export function describeSdkError(err) {
50
- if (err == null) return { status: undefined, body: undefined, message: String(err) };
203
+ if (err == null) return { status: null, body: null, message: String(err) };
51
204
  const status =
52
- err.status ??
53
205
  err.statusCode ??
54
- err?.response?.status ??
55
- err?.response?.statusCode ??
56
- (typeof err.code === 'number' ? err.code : undefined);
206
+ err.status ??
207
+ err.response?.status ??
208
+ err.response?.statusCode ??
209
+ (typeof err.code === 'number' ? err.code : null) ??
210
+ null;
57
211
  let body =
58
212
  err.body ??
59
213
  err.responseBody ??
60
- err?.response?.data ??
61
- err?.response?.body ??
62
- undefined;
214
+ err.response?.body ??
215
+ err.response?.data ??
216
+ err.data ??
217
+ null;
63
218
  if (body != null && typeof body !== 'string') {
64
219
  try {
65
220
  body = JSON.stringify(body);
@@ -67,17 +222,28 @@ export function describeSdkError(err) {
67
222
  body = String(body);
68
223
  }
69
224
  }
70
- const message = err.message ? String(err.message) : String(err);
71
- return { status, body, message };
225
+ // Normalize line breaks and cap BOTH the response body and the error message so a
226
+ // multiline or oversized engine response/error can neither split the single
227
+ // correlatable log line nor bypass the length cap (a message that embeds a large
228
+ // body must not sneak past the body cap) — issue #230. Matches the raw engine
229
+ // adapter's 500-char cap (supervisor-engine.mjs readErrorBody).
230
+ const normalizedBody = body != null ? normalizeSdkText(body, SDK_ERROR_BODY_MAX) : null;
231
+ const rawMessage = isNonBlank(err.message) ? String(err.message) : String(err);
232
+ const message = normalizeSdkText(rawMessage, SDK_ERROR_BODY_MAX);
233
+ return { status: status ?? null, body: normalizedBody, message };
72
234
  }
73
235
 
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('; ');
236
+ // Redact a lease token down to a presence + short tail so it can be logged for
237
+ // correlation without leaking the opaque fence value. For a short token a last-4
238
+ // tail would reveal most (or all) of the value, so emit a fixed redacted marker
239
+ // instead `isExternalAgentJob` accepts any non-blank token, so a short/custom
240
+ // value must never be logged verbatim. The tail is only kept once the token is
241
+ // longer than 8 characters, matching the producer's `leaseNote()` threshold so both
242
+ // diagnostics disclose the same amount. The presence signal is preserved either way.
243
+ export function leaseTokenLabel(leaseToken) {
244
+ if (!isNonBlank(leaseToken)) return 'ABSENT';
245
+ const s = String(leaseToken);
246
+ return s.length > 8 ? `present(…${oneLine(s.slice(-4))})` : 'present(short)';
81
247
  }
82
248
 
83
249
  /**
@@ -213,6 +379,21 @@ export function createAgentInstanceProducer(opts = {}) {
213
379
  logger = console,
214
380
  now = () => Date.now(),
215
381
  sessionAcp = defaultSessionAcp,
382
+ createRetryBaseMs = DEFAULT_CREATE_RETRY_BASE_MS,
383
+ createRetryMaxMs = DEFAULT_CREATE_RETRY_MAX_MS,
384
+ preMintBufferMax = DEFAULT_PRE_MINT_BUFFER_MAX,
385
+ preMintBufferMaxBytes = DEFAULT_PRE_MINT_BUFFER_MAX_BYTES,
386
+ terminalRetryMax = DEFAULT_TERMINAL_RETRY_MAX,
387
+ finalizeTimeoutMs = DEFAULT_FINALIZE_TIMEOUT_MS,
388
+ maxInFlightCreates = DEFAULT_MAX_INFLIGHT_CREATES,
389
+ maxPendingAppends = DEFAULT_MAX_PENDING_APPENDS,
390
+ maxPendingAppendBytes = DEFAULT_MAX_PENDING_APPEND_BYTES,
391
+ maxPendingMessageBytes = DEFAULT_MAX_PENDING_MESSAGE_BYTES,
392
+ // Injected deadline-timer factory (defaults to setTimeout) — the seam that lets a
393
+ // test drive the create-retirement / bounded-call timers deterministically instead
394
+ // of sleeping on wall-clock time (issue #230). Only the timer is injected; the
395
+ // clock is `now`.
396
+ setTimer = setTimeout,
216
397
  } = opts;
217
398
 
218
399
  const classify = typeof sessionAcp?.classifyUpdate === 'function' ? sessionAcp.classifyUpdate : null;
@@ -226,18 +407,17 @@ export function createAgentInstanceProducer(opts = {}) {
226
407
  const leaseToken = job?.leaseToken != null ? String(job.leaseToken) : '';
227
408
  const elementInstanceKey = job?.elementInstanceKey != null ? String(job.elementInstanceKey) : '';
228
409
  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
410
  const processInstanceKey = job?.processInstanceKey != null ? String(job.processInstanceKey) : '';
411
+ // #229 cross-channel correlation, compact form. Stamps job/eik/pik so the
412
+ // AgentInstance channel can be joined to the job / relay / git channels. This is the
413
+ // terse rendering used by the observability lines; `correlation()` below is the
414
+ // richer key=value rendering used by the #230 retry diagnostics.
234
415
  const corr = () =>
235
416
  `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.
417
+ // The lease token is a secret-ish fence token — never log it whole. Only surface a
418
+ // tail when the token is long enough that the tail still hides most of it; otherwise
419
+ // emit a safe digest (presence + length). Enough either way to tell "present" from
420
+ // "absent" and to correlate the activation without exposing the token (#229).
241
421
  const leaseNote = () =>
242
422
  !leaseToken
243
423
  ? 'lease absent'
@@ -255,55 +435,104 @@ export function createAgentInstanceProducer(opts = {}) {
255
435
  !!classify &&
256
436
  isExternalAgentJob(job);
257
437
 
438
+ // Permanent kill-switch: the producer is inert (no behaviour change for the
439
+ // harness path) unless every precondition holds. This is distinct from a
440
+ // transient create failure — the latter is RETRIED, never permanently disabled
441
+ // (issue #230).
258
442
  let disabled = !usable;
259
443
  let agentInstanceKey = null;
260
- let activated = false;
444
+ // Create-retry bookkeeping. `creating` dedups a concurrent in-flight attempt;
445
+ // `createAttempts` / `lastCreateAttemptAt` pace the exponential backoff so a
446
+ // failing create is re-attempted (idempotently, on the ACP hot path) without
447
+ // hammering the engine. Every attempt carries a monotonic identity (`createGen`);
448
+ // `creatingGen` is the identity of the attempt currently owning the `creating`
449
+ // slot. A bounded await that times out RETIRES its attempt by bumping `createGen`
450
+ // past `creatingGen`, so the hung request's eventual result is dropped by the guard
451
+ // in `doCreate` (it cannot latch a key / replay the buffer late) and the slot is
452
+ // freed for a fresh retry. `finalized` latches once complete() has run its terminal
453
+ // path, hard-stopping any late create from minting an orphaned instance (issue #230).
454
+ let creating = null;
455
+ let creatingGen = 0;
456
+ // True while the attempt owning the `creating` slot is complete()'s one last-chance
457
+ // FINAL attempt. Tracked so retireCreate can emit the correct diagnostic: after the
458
+ // final attempt complete() latches `finalized`, so NO hot-path retry can follow and
459
+ // the timeout message must not promise one (issue #230).
460
+ let creatingFinal = false;
461
+ let createGen = 0;
462
+ // Count of createAgentInstance POSTs currently in flight (started but not yet
463
+ // settled), including RETIRED attempts whose hung request is still outstanding. Caps
464
+ // concurrent uncancellable creates (see `maxInFlightCreates`) so retirement-driven
465
+ // retries can't accumulate against a hung engine (issue #230).
466
+ let createInFlight = 0;
467
+ // Elevate the FIRST create-retirement (bounded-out hung request) to `warn` so a
468
+ // timed-out AgentInstance create is visible even when a later retry succeeds;
469
+ // subsequent retirements stay `debug` to avoid a warn storm during an outage.
470
+ let createTimeoutLogged = false;
471
+ let finalized = false;
472
+ // Latches true while complete() is running its terminal last-chance path. A create
473
+ // retired during that path (via awaitCreateBounded) is NOT followed by a hot-path
474
+ // retry even when it is non-final — complete() latches `finalized` immediately after —
475
+ // so retireCreate must not promise one (issue #230).
476
+ let finalizing = false;
477
+ let createAttempts = 0;
478
+ let lastCreateAttemptAt = 0;
261
479
  let loopIteration = 0;
262
480
  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;
273
481
  // Coalesce streamed message chunks (same messageId + role) into one turn, flushed
274
482
  // on a role/message boundary, a tool event, or completion — the engine dedups on
275
483
  // historyItemId (it does NOT merge), so a turn must be appended exactly once, whole.
276
484
  let pendingMessage = null;
277
485
  // callId → toolName, so a TOOL_RESULT turn can reference the originating call name.
278
486
  const toolNames = new Map();
487
+ // Count of AgentHistory turns the ENGINE actually created (via `res.createdHistory`,
488
+ // not the attempt), for the completion diagnostic — separates "create failed" from
489
+ // "created but nothing ingested over a long run", and a deduplicated no-op from a
490
+ // real append (issue #230 / #229 / #232).
491
+ let appendedTurns = 0;
492
+ // Elevate the FIRST per-turn append failure to `warn` (repeats stay `debug`) so a
493
+ // 400/404 append storm is visible without flooding the log (issue #230 / #229).
494
+ let appendFailureLogged = false;
495
+ // Backpressure bookkeeping for the post-mint append backlog (issue #230):
496
+ // `pendingAppends` counts appends enqueued but not yet settled; `appendsDropped`
497
+ // counts turns dropped once the backlog hits `maxPendingAppends`; the drop is
498
+ // elevated to `warn` exactly once so a bounded-out backlog is visible without a storm.
499
+ let pendingAppends = 0;
500
+ let appendsDropped = 0;
501
+ // Streaming coalescing-buffer bookkeeping (issue #230): `messageChunksDropped` counts
502
+ // token chunks dropped once an in-flight assistant message's coalescing buffer hits
503
+ // `maxPendingMessageBytes`; the drop is elevated to `warn` exactly once so a truncated
504
+ // streaming response is visible without a per-chunk storm.
505
+ let messageChunksDropped = 0;
506
+ let messageBufferLogged = false;
507
+ // Approximate serialized size of the currently-pending appends, for the companion
508
+ // byte cap (issue #230): a small number of huge turns can breach the memory bound the
509
+ // count cap alone can't — so track bytes alongside count and drop on either.
510
+ let pendingAppendBytes = 0;
511
+ let appendBacklogLogged = false;
512
+ // #229 first-failure elevation for ingest faults (classifier OR handler): the FIRST
513
+ // per-instance ingest failure logs at `warn`, repeats stay at `debug`.
514
+ let ingestFailureLogged = false;
515
+ // #229 turn accounting: when the instance was minted, so `complete()` can log
516
+ // "N turns over Xm" and separate the 0-turns husk from a healthy run.
517
+ let activatedAt = 0;
518
+ // Pre-mint replay buffer (issue #230): ACP updates that arrive after an activation
519
+ // attempt but before the instance has minted are held here (bounded) and replayed
520
+ // in arrival order once `agentInstanceKey` becomes available, so a create that
521
+ // succeeds on a retry does not lose the turns emitted while it was still failing.
522
+ const preMintBuffer = [];
523
+ let preMintOverflowLogged = false;
524
+ // Approximate byte weight of the buffered updates + a count of updates dropped on
525
+ // overflow, so a truncated pre-mint replay is reported (not silent) at replay time.
526
+ let preMintBufferBytes = 0;
527
+ let preMintDropped = 0;
279
528
 
280
529
  const iso = () => new Date(now()).toISOString();
281
530
 
282
531
  // Serialize an SDK call onto the queue so appends preserve order and `complete`
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') => {
532
+ // can drain them. A rejection is swallowed (best-effort) but never breaks the chain.
533
+ const enqueue = (fn) => {
287
534
  queue = queue.then(fn).catch((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
- }
535
+ logger?.debug?.(`AgentInstance producer: SDK call failed ${err?.message || err}`);
307
536
  });
308
537
  return queue;
309
538
  };
@@ -312,7 +541,35 @@ export function createAgentInstanceProducer(opts = {}) {
312
541
  // dedup boundary crisp). Only ever runs once the instance is minted.
313
542
  const appendTurn = (turn, status) => {
314
543
  if (disabled || !agentInstanceKey) return;
315
- enqueue(async () => {
544
+ // Backpressure (issue #230): during an AgentInstance outage each serialized append
545
+ // can take up to finalizeTimeoutMs to settle, so an unbounded enqueue would let the
546
+ // backlog (and its retained turn payloads) grow without limit and drain for hours
547
+ // past complete(). Once the backlog hits EITHER the count cap OR the byte cap, DROP
548
+ // the newest turn — counting it and warning once — rather than retain it, mirroring
549
+ // the pre-mint buffer's explicit drop policy so the truncation is visible, not a
550
+ // silent runaway queue. The byte cap applies even to the first pending append: a
551
+ // single arbitrarily large turn (e.g. a huge tool result) would otherwise be
552
+ // retained in full during a prolonged outage, defeating the memory bound.
553
+ const size = sizeOfUpdate(turn);
554
+ if (
555
+ (maxPendingAppends > 0 && pendingAppends >= maxPendingAppends) ||
556
+ (maxPendingAppendBytes > 0 && pendingAppendBytes + size > maxPendingAppendBytes)
557
+ ) {
558
+ appendsDropped += 1;
559
+ if (!appendBacklogLogged) {
560
+ appendBacklogLogged = true;
561
+ logger?.warn?.(
562
+ `AgentInstance producer: append backlog full (${pendingAppends} pending, ` +
563
+ `~${pendingAppendBytes} bytes; caps ${maxPendingAppends} turns / ` +
564
+ `${maxPendingAppendBytes} bytes); dropping further turns until it drains ` +
565
+ `${correlation()}.`,
566
+ );
567
+ }
568
+ return;
569
+ }
570
+ pendingAppends += 1;
571
+ pendingAppendBytes += size;
572
+ const appended = enqueue(async () => {
316
573
  const req = {
317
574
  agentInstanceKey,
318
575
  elementInstanceKey,
@@ -321,25 +578,62 @@ export function createAgentInstanceProducer(opts = {}) {
321
578
  history: [turn],
322
579
  };
323
580
  if (status) req.status = status;
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)');
581
+ try {
582
+ // BOUND the append (finalizeTimeoutMs). Appends are serialized on `queue`, and
583
+ // complete() awaits that queue before settling the job; an append that HANGS
584
+ // (rather than rejects) would otherwise stall the drain and hold the lease
585
+ // until it expires during an AgentInstance outage. A timeout is tagged
586
+ // `__nanoTimeout` and swallowed by the catch below like any other append
587
+ // failure best-effort, never breaks the chain (issue #230).
588
+ const res = await callWithin(camunda[SDK_UPDATE](req), finalizeTimeoutMs, setTimer);
589
+ // #229/#232: the engine dedups appends by historyItemId, so a retry or a
590
+ // reactivation can return 200 while creating ZERO new history entries. Count
591
+ // what the engine actually CREATED (`res.createdHistory`) — not the attempt —
592
+ // so the completion counter separates a real append from a deduplicated no-op
593
+ // and keeps the 0-turns husk diagnosis honest. Fall back to +1 only when the
594
+ // response omits the field (older engine), so a genuine append is never
595
+ // under-counted.
596
+ appendedTurns += Array.isArray(res?.createdHistory) ? res.createdHistory.length : 1;
597
+ } catch (err) {
598
+ const d = describeSdkError(err);
599
+ // ONE canonical append-failure diagnostic (issue #230 / #229): the shaped
600
+ // key=value line (status/message/body) plus the correlation keys, so a 400/404
601
+ // append storm is both root-causable and joinable to the job/relay channels.
602
+ // The FIRST per-turn failure elevates to `warn` (with the "repeats stay debug"
603
+ // hint); the rest stay `debug` so the log isn't flooded.
604
+ const line =
605
+ `AgentInstance producer: ${SDK_UPDATE} append failed — ` +
606
+ `status=${d.status ?? 'n/a'} message=${d.message} body=${d.body ?? 'n/a'} ${correlation()}.`;
607
+ if (!appendFailureLogged) {
608
+ appendFailureLogged = true;
609
+ logger?.warn?.(`${line} Further append failures for this instance stay at debug.`);
610
+ } else {
611
+ logger?.debug?.(line);
612
+ }
613
+ }
614
+ });
615
+ // Decrement the backlog when THIS append settles (enqueue's chain never rejects),
616
+ // freeing a slot (and its bytes) for a later turn without affecting the serialized
617
+ // `queue`.
618
+ appended.finally(() => {
619
+ pendingAppends -= 1;
620
+ pendingAppendBytes -= size;
621
+ });
334
622
  };
335
623
 
336
624
  const flushMessage = () => {
337
625
  if (!pendingMessage) return;
338
- const text = pendingMessage.texts.join('');
339
- const hasText = text.trim() !== '';
340
- const hasMetrics = pendingMessage.metrics !== undefined;
341
626
  const msg = pendingMessage;
342
627
  pendingMessage = null;
628
+ let text = msg.texts.join('');
629
+ if (msg.truncated) {
630
+ // Surface the streaming-buffer truncation in the persisted turn so the dropped
631
+ // content is visible rather than silently lost (issue #230 review).
632
+ text += `${text ? '\n' : ''}[nano: assistant response truncated — exceeded the ` +
633
+ `${maxPendingMessageBytes}-byte streaming buffer cap; later chunk(s) dropped]`;
634
+ }
635
+ const hasText = text.trim() !== '';
636
+ const hasMetrics = msg.metrics !== undefined;
343
637
  if (!hasText && !hasMetrics) return;
344
638
  const idBasis = isNonBlank(msg.messageId) ? String(msg.messageId) : `h:${shortHash(text)}`;
345
639
  const turn = {
@@ -396,22 +690,513 @@ export function createAgentInstanceProducer(opts = {}) {
396
690
  appendTurn(turn);
397
691
  };
398
692
 
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
693
+ const correlation = () =>
694
+ `[jobKey=${jobKey || 'n/a'} elementInstanceKey=${elementInstanceKey || 'n/a'} ` +
695
+ `processInstanceKey=${processInstanceKey || 'n/a'}]`;
696
+
697
+ // #229: first-failure elevation for ingest faults (classifier OR handler). The FIRST
698
+ // ingest failure per instance logs at `warn`; repeats stay at `debug` so a
401
699
  // persistently-faulting instance doesn't flood the log.
402
700
  const noteIngestFailure = (err) => {
403
701
  if (!ingestFailureLogged) {
404
702
  ingestFailureLogged = true;
405
- logger?.warn?.(`AgentInstance producer: ingest failed (${corr()}) — ${oneLine(err?.message || err)}; further ingest failures for this instance stay at debug.`);
703
+ logger?.warn?.(
704
+ `AgentInstance producer: ingest failed (${corr()}) — ${oneLine(err?.message || err)}; further ingest failures for this instance stay at debug.`,
705
+ );
406
706
  } else {
407
707
  logger?.debug?.(`AgentInstance producer: ingest failed (${corr()}) — ${oneLine(err?.message || err)}`);
408
708
  }
409
709
  };
410
710
 
711
+ // Build the opening CONFIGURATION turn from the concrete runtime definition.
712
+ const buildConfigTurn = () => {
713
+ const def = deriveAgentDefinition({ profile, envelope });
714
+ const configTurn = {
715
+ historyItemId: `configuration:${elementInstanceKey}`,
716
+ loopIteration: 1,
717
+ role: 'CONFIGURATION',
718
+ content: [],
719
+ producedAt: iso(),
720
+ model: def.model,
721
+ provider: def.provider,
722
+ };
723
+ if (isNonBlank(def.systemPrompt)) {
724
+ configTurn.systemPrompt = [{ contentType: 'TEXT', text: def.systemPrompt }];
725
+ }
726
+ const limits = deriveLimits(envelope);
727
+ if (limits) configTurn.limits = limits;
728
+ return { def, configTurn };
729
+ };
730
+
731
+ // Exponential backoff (capped) for the Nth create attempt (1-based).
732
+ const backoffForAttempt = (attempt) =>
733
+ Math.min(createRetryMaxMs, createRetryBaseMs * Math.pow(2, Math.max(0, attempt - 1)));
734
+
735
+ // Perform ONE createAgentInstance attempt, tagged with the caller-supplied identity
736
+ // `gen`. On success the instance key is latched (unless the attempt was retired or
737
+ // the producer finalized — see the guard below); on failure it is logged LOUDLY
738
+ // (status + body + lease presence + correlation) and the producer is left retryable
739
+ // — NOT disabled (issue #230). `final` marks complete()'s one last-chance attempt,
740
+ // after which NO further retry happens, so the diagnostic must not promise one.
741
+ const doCreate = async ({ gen = 0, final = false } = {}) => {
742
+ // Capture THIS attempt's number locally. `createAttempts` is a shared mutable
743
+ // counter and timed-out attempts overlap later retries, so reading the global at
744
+ // log time could report a late result from attempt 1 as attempt 2 (with the wrong
745
+ // retry context). All of this attempt's diagnostics use the local `attempt`.
746
+ const attempt = (createAttempts += 1);
747
+ // A failure on the FINAL attempt won't be retried (the producer is about to be
748
+ // discarded), so don't tell operators to wait for a recovery that can't happen.
749
+ const retryClause = final
750
+ ? `no further create will be attempted (final attempt; ` +
751
+ `job completion unaffected).`
752
+ : `will retry (durable transcript resumes once the create succeeds; ` +
753
+ `job completion unaffected).`;
754
+ const { def, configTurn } = buildConfigTurn();
755
+ try {
756
+ const res = await camunda[SDK_CREATE]({
757
+ elementInstanceKey,
758
+ jobKey,
759
+ jobLease: leaseToken,
760
+ history: [configTurn],
761
+ });
762
+ // Treat a blank/whitespace key as "no key". Updates need a usable
763
+ // agentInstanceKey, so an empty ('' or whitespace-only) value must take the
764
+ // keyless retry path rather than latch state, log a mint, and replay the pre-mint
765
+ // buffer against an invalid key (issue #230).
766
+ const rawKey = res && (res.agentInstanceKey ?? res.key);
767
+ const key = isNonBlank(rawKey) ? String(rawKey).trim() : null;
768
+ if (!key) {
769
+ // A RETIRED or post-finalization attempt that resolves late without a usable
770
+ // key must NOT emit the ordinary "will retry" warning: maybeStartCreate() won't
771
+ // retry once a key/finalization is latched, so promising a retry (and reading
772
+ // the mutable global attempt counter) would be a misleading diagnostic after
773
+ // complete() already settled the job. Mirror the throw path's late guard and
774
+ // report it as dropped instead (issue #230).
775
+ if (finalized || gen !== createGen) {
776
+ logger?.debug?.(
777
+ `AgentInstance producer: createAgentInstance resolved without a usable key ` +
778
+ `late for a retired attempt (attempt ${attempt}) ${correlation()}; result ` +
779
+ `dropped (no retry — key/finalization already latched).`,
780
+ );
781
+ return false;
782
+ }
783
+ // createAgentInstance resolved but carried no key — updates need the
784
+ // agentInstanceKey, so we cannot append yet. Idempotent per
785
+ // elementInstanceKey, so keep retrying rather than forfeiting the run. Log the
786
+ // SAME request-context fields the throw path does (with explicit status/body
787
+ // n/a, since the call resolved) so an unexpected success shape is diagnosable
788
+ // against the request that produced it (issue #230).
789
+ logger?.warn?.(
790
+ `AgentInstance producer: createAgentInstance returned no agentInstanceKey ` +
791
+ `(attempt ${attempt}) — status=n/a body=n/a ` +
792
+ `jobLease=${leaseTokenLabel(leaseToken)} model=${oneLine(def.model)} provider=${oneLine(def.provider)} ` +
793
+ `${correlation()}; ${retryClause}`,
794
+ );
795
+ return false;
796
+ }
797
+ // Identity + finalization guard: a RETIRED attempt (its bounded await timed out,
798
+ // so `createGen` was bumped past our `gen`) or a producer that has already
799
+ // finalized (complete() returned) must NOT latch state. A late success would
800
+ // otherwise set agentInstanceKey and replay the pre-mint buffer into an orphaned,
801
+ // non-terminal AgentInstance AFTER the job was settled without a COMPLETED update
802
+ // (issue #230). The mint is idempotent per elementInstanceKey, so dropping the
803
+ // late result is safe — a live retry (or none) owns the state instead.
804
+ if (finalized || gen !== createGen) {
805
+ logger?.debug?.(
806
+ `AgentInstance producer: createAgentInstance succeeded late for a retired ` +
807
+ `attempt (attempt ${attempt}) ${correlation()}; result dropped ` +
808
+ `(idempotent per element instance).`,
809
+ );
810
+ return false;
811
+ }
812
+ agentInstanceKey = key;
813
+ loopIteration = 1;
814
+ activatedAt = now();
815
+ logger?.info?.(
816
+ `AgentInstance ${agentInstanceKey} minted for element instance ${elementInstanceKey} ` +
817
+ `(job ${jobKey}, attempt ${attempt}) ${correlation()} ` +
818
+ `(${leaseNote()}; model ${oneLine(def.model)}/${oneLine(def.provider)}).`,
819
+ );
820
+ replayPreMintBuffer();
821
+ return true;
822
+ } catch (err) {
823
+ // A RETIRED or post-finalization attempt that rejects late must not emit the
824
+ // ordinary "will retry" warning: maybeStartCreate() will not retry once a key or
825
+ // finalization is latched, so promising a retry (and reading the mutable global
826
+ // attempt counter) would be misleading. Report it quietly instead (issue #230).
827
+ if (finalized || gen !== createGen) {
828
+ const d = describeSdkError(err);
829
+ logger?.debug?.(
830
+ `AgentInstance producer: createAgentInstance failed late for a retired ` +
831
+ `attempt (attempt ${attempt}) — status=${d.status ?? 'n/a'} message=${d.message} ` +
832
+ `body=${d.body ?? 'n/a'} jobLease=${leaseTokenLabel(leaseToken)} ` +
833
+ `model=${oneLine(def.model)} provider=${oneLine(def.provider)} ` +
834
+ `${correlation()}; result dropped (no retry — key/finalization already latched).`,
835
+ );
836
+ return false;
837
+ }
838
+ const d = describeSdkError(err);
839
+ // ONE canonical create-failure diagnostic (issue #230 / #229): the shaped
840
+ // key=value line carrying the HTTP status + engine body (a lease fence vs a
841
+ // schema error), the masked lease, model/provider, the correlation keys, and
842
+ // the honest retry clause. First-per-instance elevation is inherent to the
843
+ // retry loop (a distinct attempt number each time).
844
+ logger?.warn?.(
845
+ `AgentInstance producer: createAgentInstance failed (attempt ${attempt}) — ` +
846
+ `status=${d.status ?? 'n/a'} message=${d.message} body=${d.body ?? 'n/a'} ` +
847
+ `jobLease=${leaseTokenLabel(leaseToken)} model=${oneLine(def.model)} provider=${oneLine(def.provider)} ` +
848
+ `${correlation()}; ${retryClause}`,
849
+ );
850
+ return false;
851
+ } finally {
852
+ // Anchor the backoff on when the attempt SETTLES, not when it started: a slow
853
+ // failure (network/timeout) must pace the NEXT attempt from the moment it failed.
854
+ // Recording at request start would let a failure that outlasts the current
855
+ // backoff window trigger another request immediately, defeating the exponential
856
+ // pacing and risking a retry storm during an outage (issue #230). Only record
857
+ // while THIS generation still owns the slot: a retired attempt already had its
858
+ // retirement time recorded (retireCreate), and a stale late settle overwriting it
859
+ // could push the anchor forward and postpone a live attempt's next retry based on
860
+ // the old request's completion rather than the current one (issue #230).
861
+ if (gen === createGen) lastCreateAttemptAt = now();
862
+ }
863
+ };
864
+
865
+ // Retire the create attempt that owns the slot at identity `gen`: bump `createGen`
866
+ // past it (so its eventual late result is dropped by doCreate's guard), free the
867
+ // `creating` slot for a fresh retry, and RECORD the retirement as the latest attempt
868
+ // time. Recording the timestamp is essential: a hung attempt's own `finally` won't
869
+ // run until the SDK promise finally settles (maybe never), so without this the next
870
+ // ACP update would see the stale `lastCreateAttemptAt` and fire immediately, letting
871
+ // repeated hung attempts pile up concurrently and defeat the backoff (issue #230).
872
+ // No-op unless `gen` still owns the slot (it may have already settled/been retired).
873
+ const retireCreate = (gen) => {
874
+ if (creatingGen !== gen || creating == null) return false;
875
+ const wasFinal = creatingFinal;
876
+ createGen += 1; // > creatingGen ⇒ the hung attempt's late result is dropped
877
+ creating = null;
878
+ creatingGen = 0;
879
+ creatingFinal = false;
880
+ lastCreateAttemptAt = now();
881
+ // A hung create that is bounded out and retired here would otherwise vanish
882
+ // silently: retireCreate only updates state, and if a later retry succeeds the
883
+ // original timeout leaves no trace — making a stuck AgentInstance request
884
+ // indistinguishable from a run with no ACP traffic. Emit a bounded timeout
885
+ // diagnostic (attempt + correlation) so the timeout is visible. This attempt owns
886
+ // the slot, so `createAttempts` is its number (no newer attempt has started). First
887
+ // retirement is `warn`; the rest are `debug` to avoid a warn storm (issue #230).
888
+ // The FINAL last-chance attempt is followed by `finalized` in complete(), so NO
889
+ // hot-path retry can mint the instance afterwards — its diagnostic must not promise
890
+ // one (matching doCreate's `final` retry clause), or operators would wait for a
891
+ // recovery that can't happen (issue #230). A NON-final attempt retired while
892
+ // complete() is finalizing (`finalizing`) is different: no HOT-PATH retry follows
893
+ // (maybeStartCreate gates on `finalizing`), but complete() itself may still make one
894
+ // explicit final attempt right after (when the in-flight cap has room), so the
895
+ // diagnostic must describe that as a possible final attempt — not promise "no
896
+ // further create" (which would be false on that path) nor a hot-path retry.
897
+ const retryClause = wasFinal
898
+ ? `no further create will be attempted (final attempt; job completion unaffected).`
899
+ : finalizing
900
+ ? `completion is in progress and may make one final attempt to mint the instance (job completion unaffected).`
901
+ : `a later hot-path retry will attempt to mint the instance (job completion unaffected).`;
902
+ const line =
903
+ `AgentInstance producer: createAgentInstance did not settle within ` +
904
+ `${finalizeTimeoutMs}ms (attempt ${createAttempts}) — request RETIRED and left to ` +
905
+ `settle in the background ${correlation()}; ${retryClause}`;
906
+ if (!createTimeoutLogged) {
907
+ createTimeoutLogged = true;
908
+ logger?.warn?.(line);
909
+ } else {
910
+ logger?.debug?.(line);
911
+ }
912
+ return true;
913
+ };
914
+
915
+ // Start ONE create attempt, own the `creating` slot, and track its identity so a
916
+ // retired (timed-out) attempt's late settle can neither clear a newer attempt's slot
917
+ // nor latch orphaned state. EVERY attempt is self-supervised: if it hasn't settled
918
+ // within `finalizeTimeoutMs` it is retired, so a hung createAgentInstance started off
919
+ // the non-awaited ACP hot path (ingest → maybeStartCreate) can't leave `creating`
920
+ // non-null forever — which would buffer every later ingest and refuse all retries
921
+ // until complete() (issue #230). Returns the (wrapped, never-rejecting) promise.
922
+ const startCreate = (opts = {}) => {
923
+ const gen = (createGen += 1);
924
+ creatingGen = gen;
925
+ creatingFinal = opts.final === true;
926
+ createInFlight += 1;
927
+ const p = doCreate({ ...opts, gen })
928
+ .catch(() => false)
929
+ .finally(() => {
930
+ // The POST has finally settled — free its in-flight slot in the concurrency
931
+ // cap regardless of whether this attempt still owns the `creating` slot (a
932
+ // retired attempt no longer owns it but was still counted while hung).
933
+ createInFlight -= 1;
934
+ // Only free the shared slot if THIS attempt still owns it — a retired attempt
935
+ // that settles late must not null a newer attempt's `creating` promise.
936
+ if (creatingGen === gen) {
937
+ creating = null;
938
+ creatingGen = 0;
939
+ creatingFinal = false;
940
+ }
941
+ });
942
+ creating = p;
943
+ // Fire-and-forget retirement supervision (bounds even non-awaited hot-path
944
+ // attempts). Never rejects; a no-op if the attempt settled or was already retired.
945
+ void settleWithin(p, finalizeTimeoutMs, setTimer).then((settled) => {
946
+ if (!settled) retireCreate(gen);
947
+ });
948
+ return p;
949
+ };
950
+
951
+ // Await the in-flight create up to `finalizeTimeoutMs`, reporting nothing but
952
+ // RETIRING the attempt on timeout: a hung createAgentInstance must not block
953
+ // activate() (which gates whether the harness runs) or complete() (which gates job
954
+ // settlement / lease expiry). Retiring bumps the identity past the hung attempt (so
955
+ // its late result is dropped in doCreate) and frees the slot so a later retry isn't
956
+ // wedged behind the stuck request forever (issue #230). Callers that must observe the
957
+ // retirement synchronously on return use this; startCreate's supervision is the
958
+ // backstop for attempts nobody awaits.
959
+ const awaitCreateBounded = async () => {
960
+ const pending = creating;
961
+ const gen = creatingGen;
962
+ if (!pending) return;
963
+ const settled = await settleWithin(pending, finalizeTimeoutMs, setTimer);
964
+ // Only retire if it genuinely timed out AND still owns the slot (it may have
965
+ // settled in the same tick the timer fired, in which case its finally already
966
+ // freed the slot / started nothing new).
967
+ if (!settled && creating === pending) retireCreate(gen);
968
+ };
969
+
970
+ // Kick off a create attempt if one is warranted and the backoff window has
971
+ // elapsed. Non-blocking: dedups a concurrent attempt and never throws. Called
972
+ // from the ACP hot path (`ingest`) so a create that becomes possible mid-run is
973
+ // retried without a dedicated timer.
974
+ const maybeStartCreate = () => {
975
+ // `finalizing` is included so no hot-path retry can start once complete() has begun
976
+ // its terminal last-chance path (issue #230): complete() awaits an existing create
977
+ // (bounded) and, if still un-minted, makes ONE explicit final attempt. If a late ACP
978
+ // frame started a fresh non-final create while complete() was suspended on that
979
+ // await, complete() would see `creating` truthy, skip its explicit final attempt,
980
+ // then latch `finalized` — and doCreate would drop the late result, losing the
981
+ // terminal record during a transient create failure. Gating on `finalizing` keeps
982
+ // complete() the sole author of the final attempt.
983
+ if (disabled || agentInstanceKey || creating || finalized || finalizing) return;
984
+ // Circuit-breaker: don't launch a fresh retry while `maxInFlightCreates` create
985
+ // POSTs are still outstanding. Retiring a hung attempt frees the `creating` slot,
986
+ // but its uncancellable SDK call stays in flight until it settles; without this
987
+ // cap, frequent ACP updates would keep starting new retries (backoff base ~1s)
988
+ // while earlier retired-but-hung POSTs (bounded at finalizeTimeoutMs) remain in
989
+ // flight, accumulating overlapping requests and hammering the engine during an
990
+ // outage. Pausing here until an in-flight create settles bounds the overlap; a
991
+ // recovered engine settles those requests and re-opens the retry path (issue #230).
992
+ if (maxInFlightCreates > 0 && createInFlight >= maxInFlightCreates) return;
993
+ if (createAttempts > 0 && now() - lastCreateAttemptAt < backoffForAttempt(createAttempts)) return;
994
+ startCreate();
995
+ };
996
+
997
+ // Classify one raw ACP `session/update` and append the resulting turn(s). Assumes
998
+ // the instance is already minted (an append needs the agentInstanceKey). Malformed
999
+ // or ignored updates are dropped. Never throws. Shared by the hot path (`ingest`)
1000
+ // and the pre-mint replay so both translate a turn identically.
1001
+ const ingestClassified = (rawUpdate) => {
1002
+ let classified;
1003
+ try {
1004
+ classified = classify(rawUpdate);
1005
+ } catch (err) {
1006
+ // A classifier/translation fault is an ingest failure too — route it through the
1007
+ // same first-failure elevation (#229) instead of returning silently, or the
1008
+ // ingest path can still drop every turn with no warning.
1009
+ noteIngestFailure(err);
1010
+ return;
1011
+ }
1012
+ if (!classified || typeof classified !== 'object') return;
1013
+ try {
1014
+ switch (classified.kind) {
1015
+ case 'message': {
1016
+ const role = historyRole(classified.role);
1017
+ if (
1018
+ pendingMessage &&
1019
+ (pendingMessage.messageId !== classified.messageId || pendingMessage.role !== role)
1020
+ ) {
1021
+ flushMessage();
1022
+ }
1023
+ if (!pendingMessage) {
1024
+ pendingMessage = {
1025
+ role,
1026
+ messageId: classified.messageId ?? null,
1027
+ texts: [],
1028
+ bytes: 0,
1029
+ truncated: false,
1030
+ metrics: undefined,
1031
+ loopIteration,
1032
+ producedAt: iso(),
1033
+ };
1034
+ }
1035
+ if (isNonBlank(classified.text)) {
1036
+ const chunk = String(classified.text);
1037
+ // Bound the streaming coalescing buffer (issue #230): a long/never-terminated
1038
+ // response could otherwise grow `texts` without limit before a boundary flush,
1039
+ // bypassing the post-mint append/pre-mint byte caps. Once the buffered chunks'
1040
+ // approximate size would exceed the cap, DROP further chunks (counted, warn-once)
1041
+ // and mark the message truncated so the flushed turn carries a visible marker.
1042
+ if (
1043
+ maxPendingMessageBytes > 0 &&
1044
+ pendingMessage.bytes + chunk.length > maxPendingMessageBytes
1045
+ ) {
1046
+ pendingMessage.truncated = true;
1047
+ messageChunksDropped += 1;
1048
+ if (!messageBufferLogged) {
1049
+ messageBufferLogged = true;
1050
+ logger?.warn?.(
1051
+ `AgentInstance producer: streaming message buffer full ` +
1052
+ `(~${pendingMessage.bytes} bytes; cap ${maxPendingMessageBytes} bytes); ` +
1053
+ `dropping further chunks of this response until it flushes ${correlation()}.`,
1054
+ );
1055
+ }
1056
+ } else {
1057
+ pendingMessage.texts.push(chunk);
1058
+ pendingMessage.bytes += chunk.length;
1059
+ }
1060
+ }
1061
+ const m = extractMetrics(rawUpdate);
1062
+ if (m) pendingMessage.metrics = { ...(pendingMessage.metrics || {}), ...m };
1063
+ break;
1064
+ }
1065
+ case 'tool-call':
1066
+ onToolCall(classified);
1067
+ break;
1068
+ case 'tool-result':
1069
+ onToolResult(classified);
1070
+ break;
1071
+ default:
1072
+ break;
1073
+ }
1074
+ } catch (err) {
1075
+ // #229: elevate the FIRST ingest failure per instance to `warn` (repeats stay
1076
+ // `debug`) so a translation/append fault that silently drops every turn is
1077
+ // visible at normal verbosity.
1078
+ noteIngestFailure(err);
1079
+ }
1080
+ };
1081
+
1082
+ // Hold a pre-mint ACP update for later replay, bounded so a create that never
1083
+ // succeeds cannot grow the buffer without bound. Bounded by BOTH a slot count and an
1084
+ // approximate byte budget — a streamed response can emit many token-chunk
1085
+ // notifications, so a raw-count cap alone would let a chunk storm exhaust the buffer.
1086
+ // Once either cap is hit, drop the newest update (keeping the buffered prefix
1087
+ // contiguous) and COUNT the drop so the truncation is reported (not silent) on
1088
+ // replay. The byte cap applies even to the FIRST buffered update: a single oversized
1089
+ // update (e.g. a large tool-result) is dropped and counted rather than retained in
1090
+ // full, so the buffer stays bounded even in the worst case — an outage that only ever
1091
+ // sees oversized updates keeps NO turns but never grows the buffer.
1092
+ const sizeOfUpdate = (u) => {
1093
+ try {
1094
+ return JSON.stringify(u)?.length ?? 0;
1095
+ } catch {
1096
+ return 0;
1097
+ }
1098
+ };
1099
+ // Would this raw update classify to a persisted history turn (message/tool-call/
1100
+ // tool-result)? The replay path (ingestClassified) IGNORES everything else — e.g.
1101
+ // `plan`/status notifications — so buffering them would let a plan/status burst
1102
+ // consume the count/byte caps during a create outage and starve later message/tool
1103
+ // updates. Filter them out before buffering; arrival order is preserved because an
1104
+ // ignored update contributes no turn on replay anyway (issue #230).
1105
+ const PERSISTED_KINDS = new Set(['message', 'tool-call', 'tool-result']);
1106
+ const classifiesToPersistedTurn = (rawUpdate) => {
1107
+ if (!classify) return false;
1108
+ let classified;
1109
+ try {
1110
+ classified = classify(rawUpdate);
1111
+ } catch (err) {
1112
+ // A classifier fault on the pre-mint path would otherwise be swallowed here, so
1113
+ // the FIRST per-instance ingest failure during a create outage would never reach
1114
+ // noteIngestFailure() and the warn-once diagnostic would be absent exactly when
1115
+ // it matters (issue #230). Report it before dropping the update.
1116
+ noteIngestFailure(err);
1117
+ return false;
1118
+ }
1119
+ if (!classified || typeof classified !== 'object' || !PERSISTED_KINDS.has(classified.kind)) {
1120
+ return false;
1121
+ }
1122
+ // A `message` update only persists a turn if it carries non-blank text OR metrics —
1123
+ // mirror flushMessage()'s own predicate so a metadata-only message chunk (no text,
1124
+ // no metrics) is not buffered. Otherwise such chunks would consume the count/byte
1125
+ // caps during a create outage and starve later real message/tool turns, even though
1126
+ // they contribute NOTHING on replay (flushMessage drops them). tool-call/tool-result
1127
+ // always persist a turn, so they are buffered unconditionally (issue #230).
1128
+ if (classified.kind === 'message') {
1129
+ return isNonBlank(classified.text) || !!extractMetrics(rawUpdate);
1130
+ }
1131
+ return true;
1132
+ };
1133
+ const bufferPreMint = (rawUpdate) => {
1134
+ // Skip updates that will not persist a turn on replay so they cannot exhaust the
1135
+ // caps (issue #230). Not counted as a drop — dropping an ignored update loses no
1136
+ // transcript content.
1137
+ if (!classifiesToPersistedTurn(rawUpdate)) return;
1138
+ const size = sizeOfUpdate(rawUpdate);
1139
+ // The byte cap applies even to the FIRST buffered update: a single persisted ACP
1140
+ // update (e.g. an arbitrarily large tool-result) whose JSON alone exceeds
1141
+ // preMintBufferMaxBytes would otherwise be retained in full during a prolonged
1142
+ // create outage, defeating the memory bound. Drop (and count) it instead of
1143
+ // special-casing an empty buffer (issue #230).
1144
+ if (
1145
+ preMintBuffer.length >= preMintBufferMax ||
1146
+ preMintBufferBytes + size > preMintBufferMaxBytes
1147
+ ) {
1148
+ preMintDropped += 1;
1149
+ if (!preMintOverflowLogged) {
1150
+ preMintOverflowLogged = true;
1151
+ logger?.warn?.(
1152
+ `AgentInstance producer: pre-mint replay buffer full ` +
1153
+ `(${preMintBuffer.length} update(s), ~${preMintBufferBytes} bytes; caps ` +
1154
+ `${preMintBufferMax} updates / ${preMintBufferMaxBytes} bytes); dropping further ` +
1155
+ `updates until the create succeeds ${correlation()}.`,
1156
+ );
1157
+ }
1158
+ return;
1159
+ }
1160
+ preMintBuffer.push(rawUpdate);
1161
+ preMintBufferBytes += size;
1162
+ };
1163
+
1164
+ // Replay every buffered pre-mint update against the freshly minted instance, in
1165
+ // arrival order, then clear the buffer. Called from doCreate on a successful mint.
1166
+ // If any updates were dropped on overflow, report the count so a truncated replay is
1167
+ // visible rather than silent (issue #230).
1168
+ const replayPreMintBuffer = () => {
1169
+ const dropped = preMintDropped;
1170
+ preMintDropped = 0;
1171
+ preMintBufferBytes = 0;
1172
+ if (dropped > 0) {
1173
+ logger?.warn?.(
1174
+ `AgentInstance producer: ${dropped} pre-mint update(s) were dropped before the instance ` +
1175
+ `minted (replay buffer overflow) — the replayed transcript is truncated by that many ` +
1176
+ `updates ${correlation()}.`,
1177
+ );
1178
+ }
1179
+ if (preMintBuffer.length === 0) return;
1180
+ const buffered = preMintBuffer.splice(0, preMintBuffer.length);
1181
+ for (const raw of buffered) ingestClassified(raw);
1182
+ };
1183
+
411
1184
  return {
412
- /** True once the AgentInstance has been minted (or is being minted). */
1185
+ /** True once the AgentInstance has been minted. */
413
1186
  get active() {
414
- return activated && !disabled;
1187
+ return !!agentInstanceKey && !disabled;
1188
+ },
1189
+ /**
1190
+ * True when the producer is NOT yet active but its create is still armed to retry
1191
+ * on the ACP hot path (issue #230): a create was attempted (createAttempts > 0)
1192
+ * and neither disabled nor minted. This distinguishes a TRANSIENT `active === false`
1193
+ * (a rejected/timed-out create that may still recover a durable transcript on a
1194
+ * later ingest) from the PERMANENT `disabled` state (the SDK/classifier is missing,
1195
+ * so no transcript will ever be recorded). `finalized` is treated as no longer
1196
+ * pending — complete() has run its terminal path and no further retry can follow.
1197
+ */
1198
+ get retryPending() {
1199
+ return !disabled && !agentInstanceKey && createAttempts > 0 && !finalized;
415
1200
  },
416
1201
  get agentInstanceKey() {
417
1202
  return agentInstanceKey;
@@ -422,60 +1207,26 @@ export function createAgentInstanceProducer(opts = {}) {
422
1207
  * establishes model/provider/systemPrompt/limits. Idempotent per element
423
1208
  * instance — safe to call once per activation; a reactivation reconciles onto
424
1209
  * the same instance rather than creating a second one. Best-effort: a rejected
425
- * create (e.g. a stale lease) disables the producer and returns false.
1210
+ * create (e.g. a stale lease fence at second 0) does NOT disable the producer
1211
+ * it is retried on the ACP hot path (issue #230), so a transient failure never
1212
+ * forfeits the whole run's durable transcript.
426
1213
  */
427
1214
  async activate() {
428
- if (disabled || activated) return this.active;
429
- activated = true;
430
- const def = deriveAgentDefinition({ profile, envelope });
431
- const configTurn = {
432
- historyItemId: `configuration:${elementInstanceKey}`,
433
- loopIteration: 1,
434
- role: 'CONFIGURATION',
435
- content: [],
436
- producedAt: iso(),
437
- model: def.model,
438
- provider: def.provider,
439
- };
440
- if (isNonBlank(def.systemPrompt)) {
441
- configTurn.systemPrompt = [{ contentType: 'TEXT', text: def.systemPrompt }];
442
- }
443
- const limits = deriveLimits(envelope);
444
- if (limits) configTurn.limits = limits;
445
- try {
446
- const res = await camunda[SDK_CREATE]({
447
- elementInstanceKey,
448
- jobKey,
449
- jobLease: leaseToken,
450
- history: [configTurn],
451
- });
452
- agentInstanceKey =
453
- (res && (res.agentInstanceKey ?? res.key)) != null
454
- ? String(res.agentInstanceKey ?? res.key)
455
- : null;
456
- if (!agentInstanceKey) {
457
- // A reactivation reconciles the auto-minted/existing record; if the create
458
- // result carries no key, fall back to the elementInstanceKey correlation is
459
- // not possible for updates (they need the agentInstanceKey), so disable.
460
- disabled = true;
461
- logger?.warn?.(`AgentInstance producer: create returned no agentInstanceKey (${corr()}); disabling durable transcript for this job.`);
462
- return false;
463
- }
464
- loopIteration = 1;
465
- activatedAt = now();
466
- logger?.info?.(`AgentInstance ${agentInstanceKey} minted (${corr()}; ${leaseNote()}; model ${oneLine(def.model)}/${oneLine(def.provider)}).`);
467
- return true;
468
- } catch (err) {
469
- disabled = true;
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).`);
477
- return false;
478
- }
1215
+ if (disabled || agentInstanceKey) return this.active;
1216
+ // Route through maybeStartCreate so a repeated activation (a reactivation loop)
1217
+ // respects the SAME exponential backoff the hot path does, rather than firing a
1218
+ // fresh doCreate on every call and hammering the engine (issue #230). The first
1219
+ // activation (createAttempts === 0) always attempts immediately; a reactivation
1220
+ // still inside the backoff window is throttled and simply returns the current
1221
+ // (not-yet-active) state without a new attempt.
1222
+ maybeStartCreate();
1223
+ // BOUND the wait: c8ctl-plugin.js awaits activate() before it starts
1224
+ // runAgentJob, so a hung createAgentInstance must not be able to block the
1225
+ // harness from ever running. On timeout the attempt is RETIRED (identity-guarded,
1226
+ // so a late success cannot corrupt state) and the create simply resumes on the
1227
+ // ACP hot path; activate() returns the current (not-yet-active) state (issue #230).
1228
+ await awaitCreateBounded();
1229
+ return this.active;
479
1230
  },
480
1231
 
481
1232
  /**
@@ -484,58 +1235,26 @@ export function createAgentInstanceProducer(opts = {}) {
484
1235
  * dropped. Never throws.
485
1236
  */
486
1237
  ingest(rawUpdate) {
487
- if (disabled || !agentInstanceKey) return;
488
- let classified;
489
- try {
490
- classified = classify(rawUpdate);
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);
496
- return;
497
- }
498
- if (!classified || typeof classified !== 'object') return;
499
- try {
500
- switch (classified.kind) {
501
- case 'message': {
502
- const role = historyRole(classified.role);
503
- if (
504
- pendingMessage &&
505
- (pendingMessage.messageId !== classified.messageId || pendingMessage.role !== role)
506
- ) {
507
- flushMessage();
508
- }
509
- if (!pendingMessage) {
510
- pendingMessage = {
511
- role,
512
- messageId: classified.messageId ?? null,
513
- texts: [],
514
- metrics: undefined,
515
- loopIteration,
516
- producedAt: iso(),
517
- };
518
- }
519
- if (isNonBlank(classified.text)) pendingMessage.texts.push(String(classified.text));
520
- const m = extractMetrics(rawUpdate);
521
- if (m) pendingMessage.metrics = { ...(pendingMessage.metrics || {}), ...m };
522
- break;
523
- }
524
- case 'tool-call':
525
- onToolCall(classified);
526
- break;
527
- case 'tool-result':
528
- onToolResult(classified);
529
- break;
530
- default:
531
- break;
1238
+ // Treat a FINALIZED producer as inert: once complete() has drained the queue and
1239
+ // driven (or bounded out) the terminal COMPLETED update, a late ACP frame — e.g.
1240
+ // spawnCaptureAcp's timeout/abort cleanup firing onAcpUpdate after finish()
1241
+ // resolved — must not enqueue history after the terminal update or buffer updates
1242
+ // after finalization, resurrecting an orphaned instance (issue #230).
1243
+ if (disabled || finalized) return;
1244
+ // Not minted yet? A create may have failed at second 0 — re-attempt it on the
1245
+ // hot path (throttled) so the transcript resumes as soon as the create takes,
1246
+ // and BUFFER this update so it is replayed against the instance once the create
1247
+ // succeeds (issue #230 — a pre-mint update must not be silently lost). We only
1248
+ // do this AFTER an activation attempt (createAttempts > 0): an ingest before
1249
+ // activate() neither mints nor buffers, preserving the lifecycle contract.
1250
+ if (!agentInstanceKey) {
1251
+ if (createAttempts > 0) {
1252
+ bufferPreMint(rawUpdate);
1253
+ maybeStartCreate();
532
1254
  }
533
- } catch (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);
1255
+ return;
538
1256
  }
1257
+ ingestClassified(rawUpdate);
539
1258
  },
540
1259
 
541
1260
  /** Drain any queued appends without transitioning status. */
@@ -552,46 +1271,252 @@ export function createAgentInstanceProducer(opts = {}) {
552
1271
  * the same instance. Best-effort; never throws; `job.complete` is unaffected.
553
1272
  */
554
1273
  async complete(ok = true) {
1274
+ // Last chance: if the instance never minted (create kept failing) make one
1275
+ // final, un-throttled attempt so at least the CONFIGURATION turn + terminal
1276
+ // status survive when the create finally becomes possible (issue #230).
1277
+ // Only after an activation attempt (createAttempts > 0): if activate() was
1278
+ // never called this stays a no-op rather than minting — and potentially
1279
+ // completing — an AgentInstance for a run that never activated, preserving the
1280
+ // same lifecycle contract as ingest().
1281
+ if (!disabled && !agentInstanceKey && createAttempts > 0) {
1282
+ // From here we are on the terminal last-chance path: any create retired while
1283
+ // we finalize is not followed by a hot-path retry (retireCreate reads this to
1284
+ // emit an accurate diagnostic; issue #230).
1285
+ finalizing = true;
1286
+ // A throttled, ingest-triggered attempt may already be in flight — await it
1287
+ // first (bounded) so we don't start a duplicate. If it (or the lack of one)
1288
+ // leaves us un-minted, make one explicit, un-throttled FINAL attempt regardless
1289
+ // of the backoff window, so a transient failure right before completion doesn't
1290
+ // lose the record. Both awaits are BOUNDED (finalizeTimeoutMs): the harness
1291
+ // awaits complete() before it settles the job, so a hung createAgentInstance
1292
+ // must not block settlement / expire the lease. A timed-out attempt is RETIRED
1293
+ // (identity-guarded) so its late success can't mint+replay after we return
1294
+ // (issue #230).
1295
+ if (creating) {
1296
+ await awaitCreateBounded();
1297
+ }
1298
+ // The `maxInFlightCreates` circuit breaker (issue #230) deliberately gates
1299
+ // HOT-PATH retries so a storm of overlapping create POSTs can't pile up during
1300
+ // an engine outage. But this is complete()'s ONE terminal last-chance attempt —
1301
+ // the durability backstop that lets at least the configuration turn + terminal
1302
+ // status survive when the create finally becomes possible. It runs at most ONCE
1303
+ // per job, so it is NOT a retry storm. Skipping it because retired-but-hung
1304
+ // POSTs still saturate the cap would strand a run with NO durable record exactly
1305
+ // when the engine recovers: the cap count is stale (those POSTs are uncancellable
1306
+ // and may never settle), so "saturated" does not prove the engine is still hung —
1307
+ // a final POST against a recovered engine would SUCCEED, not hang. So this single
1308
+ // attempt intentionally BYPASSES the cap: a bounded, one-time +1 overshoot (itself
1309
+ // bounded by awaitCreateBounded and retired if it hangs), never a storm. This is
1310
+ // the fix for the "cap permanently stops recovery / complete() skips its final
1311
+ // attempt" case (issue #230): the terminal durability attempt is always made once.
1312
+ if (!agentInstanceKey && !creating) {
1313
+ startCreate({ final: true });
1314
+ await awaitCreateBounded();
1315
+ }
1316
+ }
1317
+ // Point of no return: from here the producer is finalized. Any create still in
1318
+ // flight (a retired last-chance attempt, or one that raced the bound) must NOT
1319
+ // latch a key or replay the pre-mint buffer later — doing so would resurrect an
1320
+ // orphaned, non-terminal AgentInstance after the job is settled without a
1321
+ // COMPLETED update (issue #230). doCreate's guard drops any such late success.
1322
+ finalized = true;
555
1323
  if (disabled || !agentInstanceKey) {
556
- // Still drain any queued appends so a caller awaiting completion settles.
557
- try { await this.drain(); } catch { /* best effort */ }
1324
+ // Still drain any queued appends so a caller awaiting completion settles — but
1325
+ // bound the TOTAL drain at finalizeTimeoutMs (settleWithin never rejects). Each
1326
+ // append is individually bounded, yet the queue is serialized, so during an
1327
+ // AgentInstance outage N queued appends could take up to N×finalizeTimeoutMs and
1328
+ // hold the lease for that whole span; the remaining appends drain in the
1329
+ // background past the deadline (best-effort) so job settlement isn't blocked.
1330
+ await settleWithin(this.drain(), finalizeTimeoutMs, setTimer);
1331
+ // The instance never minted, and `finalized` now guarantees the pre-mint buffer
1332
+ // can NEVER be replayed. Release it (and its byte/drop counters) so an
1333
+ // uncancellable create promise still hung during a prolonged outage cannot
1334
+ // retain the full buffer (up to preMintBufferMaxBytes) for the rest of the
1335
+ // process's life — a per-job memory leak with no possible payoff (issue #230).
1336
+ preMintBuffer.length = 0;
1337
+ preMintBufferBytes = 0;
1338
+ preMintDropped = 0;
1339
+ // Only warn when we actually attempted to mint (createAttempts > 0). A
1340
+ // producer that was never activated is a clean no-op — there is no missing
1341
+ // transcript to report.
1342
+ if (createAttempts > 0) {
1343
+ logger?.warn?.(
1344
+ `AgentInstance producer: no durable AgentInstance for this job after ` +
1345
+ `${createAttempts} create attempt(s) ${correlation()}; ` +
1346
+ `no engine transcript was recorded (job completion unaffected).`,
1347
+ );
1348
+ }
558
1349
  return;
559
1350
  }
560
1351
  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;
567
- if (ok) {
1352
+ // Drain any queued appends first so ordering is preserved, THEN drive the
1353
+ // terminal COMPLETED update directly (not via the best-effort append queue) so
1354
+ // we can observe its outcome and RETRY it. On a successful job end the caller
1355
+ // settles the job the instant complete() returns, so there is no reactivation to
1356
+ // retry a rejected terminal transition a single swallowed failure would strand
1357
+ // the instance non-terminal forever (issue #230). BOUND the aggregate drain at
1358
+ // finalizeTimeoutMs: each append is individually bounded, but the queue is
1359
+ // serialized, so during an outage N queued appends could take up to
1360
+ // N×finalizeTimeoutMs and hold the lease that whole span before the terminal
1361
+ // update even begins (issue #230).
1362
+ const drained = await settleWithin(queue, finalizeTimeoutMs, setTimer);
1363
+ if (ok && !drained) {
1364
+ // The aggregate drain timed out: appends are STILL in flight on `queue`.
1365
+ // Driving the terminal COMPLETED update directly here would race those pending
1366
+ // appends — a concurrent request that could terminalize the instance BEFORE a
1367
+ // delayed append lands, reordering or losing that turn (issue #230). Instead,
1368
+ // SERIALIZE the terminal transition behind the queue: enqueue it so it runs
1369
+ // only after every pending append settles (each is individually bounded, so the
1370
+ // queue keeps making progress even under an outage). We CANNOT observe or retry
1371
+ // its outcome from here, and the caller settles the job the instant complete()
1372
+ // returns (c8ctl-plugin.js) — so by the time the drain catches up this background
1373
+ // write carries a now-VOID jobLease and is expected to be FENCE-REJECTED. We
1374
+ // therefore do NOT rely on it landing: terminalization on this path is effectively
1375
+ // ABANDONED and MANUAL RECONCILIATION is REQUIRED (surfaced below). The enqueue is
1376
+ // a best-effort last try only — it wins solely in the rare case settlement is
1377
+ // delayed long enough for the drain to catch up first. Return without blocking
1378
+ // job settlement.
568
1379
  enqueue(async () => {
569
- await camunda[SDK_UPDATE]({
570
- agentInstanceKey,
571
- elementInstanceKey,
572
- jobKey,
573
- jobLease: leaseToken,
574
- status: 'COMPLETED',
575
- });
576
- statusResolved = true;
577
- }, 'updateAgentInstance(status→COMPLETED)');
1380
+ try {
1381
+ await callWithin(
1382
+ camunda[SDK_UPDATE]({
1383
+ agentInstanceKey,
1384
+ elementInstanceKey,
1385
+ jobKey,
1386
+ jobLease: leaseToken,
1387
+ status: 'COMPLETED',
1388
+ }),
1389
+ finalizeTimeoutMs,
1390
+ setTimer,
1391
+ );
1392
+ } catch (err) {
1393
+ // This serialized enqueue IS the terminal transition on the drain-timeout
1394
+ // path — the only actor that can still terminalize the instance here. The
1395
+ // generic enqueue() catch would swallow it at debug with just err.message,
1396
+ // losing the HTTP status/body/correlation on the very path that already
1397
+ // warned reconciliation may be needed (issue #230). Emit the SAME shaped
1398
+ // status/body/correlation warning the direct terminal-retry path uses
1399
+ // (with its manual-reconciliation outcome) before it is swallowed.
1400
+ const d = describeSdkError(err);
1401
+ logger?.warn?.(
1402
+ `AgentInstance ${agentInstanceKey}: serialized terminal COMPLETED update ` +
1403
+ `${err?.__nanoTimeout ? `timed out (bounded at ${finalizeTimeoutMs}ms)` : 'failed'} — ` +
1404
+ `status=${d.status ?? 'n/a'} body=${d.body ?? 'n/a'} message=${d.message ?? 'n/a'}; ` +
1405
+ `MANUAL RECONCILIATION REQUIRED — the drain did not catch up and the job has ` +
1406
+ `settled with no reactivation to retry this transition ${correlation()}.`,
1407
+ );
1408
+ }
1409
+ });
1410
+ logger?.warn?.(
1411
+ `AgentInstance ${agentInstanceKey}: history drain did not settle within ` +
1412
+ `${finalizeTimeoutMs}ms — terminal COMPLETED update SERIALIZED behind the ` +
1413
+ `pending appends (best-effort, ordered) rather than racing them; its outcome ` +
1414
+ `is not observed here and the job settles on return, so this background write ` +
1415
+ `is expected to be fence-rejected once its lease is void — MANUAL ` +
1416
+ `RECONCILIATION REQUIRED (terminal status NOT confirmed) ${correlation()}.`,
1417
+ );
1418
+ // Emit the turn counter on this path too (issue #229/#232): the aggregate
1419
+ // append timeout is the hardest transcript failure to diagnose, so record how
1420
+ // many turns were appended SO FAR — the count is "so far / drain still pending"
1421
+ // here because the serialized appends have not been observed to settle.
1422
+ {
1423
+ const elapsedMs = activatedAt ? Math.max(0, now() - activatedAt) : 0;
1424
+ const mins = (elapsedMs / 60000).toFixed(1);
1425
+ logger?.info?.(
1426
+ `AgentInstance ${agentInstanceKey} (${corr()}): ${appendedTurns} turn(s) appended ` +
1427
+ `so far over ${mins}m (drain still pending — count unobserved)` +
1428
+ `${appendsDropped > 0 ? `, ${appendsDropped} turn(s) DROPPED (append backlog cap — transcript truncated)` : ''}, ` +
1429
+ `${messageChunksDropped > 0 ? `${messageChunksDropped} streaming chunk(s) DROPPED (message buffer cap), ` : ''}` +
1430
+ `terminal COMPLETED update serialized behind pending appends.`,
1431
+ );
1432
+ }
1433
+ return;
1434
+ }
1435
+ let completedOk = false;
1436
+ let terminalErr = null;
1437
+ let terminalTimedOut = false;
1438
+ const terminalAttempts = Math.max(1, terminalRetryMax);
1439
+ if (ok) {
1440
+ for (let attempt = 1; attempt <= terminalAttempts; attempt += 1) {
1441
+ try {
1442
+ // BOUND each terminal update (finalizeTimeoutMs). Like the create calls,
1443
+ // the harness awaits complete() before it settles the job, so a hung
1444
+ // updateAgentInstance would otherwise hold the lease until it expires and
1445
+ // stall settlement during an engine outage (issue #230).
1446
+ await callWithin(
1447
+ camunda[SDK_UPDATE]({
1448
+ agentInstanceKey,
1449
+ elementInstanceKey,
1450
+ jobKey,
1451
+ jobLease: leaseToken,
1452
+ status: 'COMPLETED',
1453
+ }),
1454
+ finalizeTimeoutMs,
1455
+ setTimer,
1456
+ );
1457
+ completedOk = true;
1458
+ terminalErr = null;
1459
+ terminalTimedOut = false;
1460
+ break;
1461
+ } catch (err) {
1462
+ terminalErr = describeSdkError(err);
1463
+ // A hung endpoint won't recover within the next attempt and each retry
1464
+ // burns another finalizeTimeoutMs against the lease, so STOP retrying on a
1465
+ // timeout (the underlying call is left to settle in the background). A
1466
+ // plain rejection is potentially transient, so those still retry.
1467
+ if (err && err.__nanoTimeout) {
1468
+ terminalTimedOut = true;
1469
+ break;
1470
+ }
1471
+ }
1472
+ }
1473
+ }
1474
+ const terminalOk = ok && completedOk;
1475
+ if (ok && !completedOk) {
1476
+ // The terminal transition never confirmed (every retry rejected, or a hung
1477
+ // request was bounded out). The job settles now with no reactivation to try
1478
+ // again, so this is NOT self-healing — surface the SDK error details and flag
1479
+ // that manual reconciliation is required (issue #230).
1480
+ logger?.warn?.(
1481
+ `AgentInstance ${agentInstanceKey}: terminal status update to COMPLETED ` +
1482
+ `${terminalTimedOut ? `timed out (bounded at ${finalizeTimeoutMs}ms)` : `failed after ${terminalAttempts} attempt(s)`} — ` +
1483
+ `status=${terminalErr?.status ?? 'n/a'} ` +
1484
+ `body=${terminalErr?.body ?? 'n/a'} message=${terminalErr?.message ?? 'n/a'}; ` +
1485
+ `MANUAL RECONCILIATION REQUIRED — the job settles now with no reactivation to retry ` +
1486
+ `this transition ${correlation()}.`,
1487
+ );
578
1488
  }
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.
1489
+ // #229/#232 turn counter "N turn(s) appended over Xm, <transition>" — so the
1490
+ // 0-turns husk ("created but nothing ingested") is distinguishable from a healthy
1491
+ // run at a glance. Render the HONEST terminal transition: only claim
1492
+ // `status→COMPLETED` when the terminal update actually confirmed (terminalOk).
1493
+ // The `!ok` (failed job end) path IS reactivatable, so it says so; but the
1494
+ // terminalOk-false path is a SUCCESSFUL job end whose COMPLETED update failed —
1495
+ // the caller settles the job the instant complete() returns, so there is NO
1496
+ // reactivation to continue it (that would contradict the manual-reconciliation
1497
+ // warning). State only that it was left non-terminal and needs reconciliation.
583
1498
  const elapsedMs = activatedAt ? Math.max(0, now() - activatedAt) : 0;
584
1499
  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}.`);
1500
+ const transition = !ok
1501
+ ? 'left non-terminal (retry/reactivation continues it)'
1502
+ : terminalOk
1503
+ ? 'status→COMPLETED'
1504
+ : 'COMPLETED update FAILED — left non-terminal; manual reconciliation required (no reactivation on a settled job)';
1505
+ // Surface the aggregate append backlog DROP count (issue #230): the warn-once
1506
+ // backlog log only says drops STARTED — the operator can't quantify the loss or
1507
+ // tell it apart from an engine append failure without the final tally. Fold it
1508
+ // into the completion counter so a truncated transcript is quantified, not silent.
1509
+ const droppedNote =
1510
+ appendsDropped > 0
1511
+ ? ` (${appendsDropped} turn(s) DROPPED — append backlog cap; transcript truncated)`
1512
+ : '';
1513
+ const chunkNote =
1514
+ messageChunksDropped > 0
1515
+ ? ` (${messageChunksDropped} streaming chunk(s) DROPPED — message buffer cap; response truncated)`
1516
+ : '';
1517
+ logger?.info?.(
1518
+ `AgentInstance ${agentInstanceKey} (${corr()}): ${appendedTurns} turn(s) appended over ${mins}m${droppedNote}${chunkNote}, ${transition}.`,
1519
+ );
595
1520
  },
596
1521
  };
597
1522
  }