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.
- package/agent-instance.mjs +1149 -224
- package/c8ctl-plugin.js +3 -1
- package/package.json +8 -8
package/agent-instance.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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:
|
|
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
|
|
55
|
-
err
|
|
56
|
-
|
|
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
|
|
61
|
-
err
|
|
62
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
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
|
-
|
|
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
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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?.(
|
|
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
|
|
1185
|
+
/** True once the AgentInstance has been minted. */
|
|
413
1186
|
get active() {
|
|
414
|
-
return
|
|
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)
|
|
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 ||
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
580
|
-
//
|
|
581
|
-
//
|
|
582
|
-
//
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
}
|