@automatalabs/acp-agents 0.35.3 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/dist/acp-client.d.ts +219 -0
- package/dist/acp-client.d.ts.map +1 -1
- package/dist/acp-client.js +320 -1
- package/dist/backends/opencode.d.ts.map +1 -1
- package/dist/backends/opencode.js +42 -15
- package/dist/capabilities.d.ts +6 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +15 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/interactive.d.ts +333 -1
- package/dist/interactive.d.ts.map +1 -1
- package/dist/interactive.js +662 -5
- package/dist/protocol-coverage.d.ts +14 -4
- package/dist/protocol-coverage.d.ts.map +1 -1
- package/dist/protocol-coverage.js +30 -2
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +12 -1
- package/package.json +4 -4
package/dist/interactive.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { WorkflowError, WorkflowErrorCode } from "@automatalabs/shared-types";
|
|
2
|
+
import { CustomAcpBackend } from "./backends/custom.js";
|
|
1
3
|
import { isChildCleanupError, } from "./acp-client.js";
|
|
2
4
|
import { mapThrownError } from "./errors-map.js";
|
|
3
|
-
import { appendPromptImages, mergeTurnMeta, validatePromptImages, } from "./prompt.js";
|
|
5
|
+
import { appendPromptImages, buildRunPrompt, mergeTurnMeta, validatePromptImages, } from "./prompt.js";
|
|
4
6
|
/** A held-open multi-turn ACP session backed by a dedicated agent process. Only one prompt may
|
|
5
7
|
* be in flight at a time; hosts that want queued turns should serialize calls themselves so
|
|
6
8
|
* cancellation, permissions, and turn text stay attributable to a single active turn. If the
|
|
@@ -20,8 +22,12 @@ export class InteractiveSession {
|
|
|
20
22
|
signal;
|
|
21
23
|
label;
|
|
22
24
|
subscriptions = new Set();
|
|
25
|
+
/** The re-attach arm's release watchers (see `waitForRelease`). */
|
|
26
|
+
releaseWatchers = new Set();
|
|
23
27
|
cwd;
|
|
24
28
|
keepSession;
|
|
29
|
+
/** The session's structured-output contract (see `InteractiveSessionOptions.schema`). */
|
|
30
|
+
schema;
|
|
25
31
|
removeAbort;
|
|
26
32
|
promptInFlight = false;
|
|
27
33
|
releasePromise;
|
|
@@ -38,6 +44,7 @@ export class InteractiveSession {
|
|
|
38
44
|
this.label = deps.label;
|
|
39
45
|
this.cwd = deps.cwd;
|
|
40
46
|
this.keepSession = deps.keepSession;
|
|
47
|
+
this.schema = deps.schema;
|
|
41
48
|
this.sessionId = deps.session.sessionId;
|
|
42
49
|
this.backendId = deps.connection.backendId;
|
|
43
50
|
if (deps.signal) {
|
|
@@ -63,6 +70,32 @@ export class InteractiveSession {
|
|
|
63
70
|
get text() {
|
|
64
71
|
return this.session.text;
|
|
65
72
|
}
|
|
73
|
+
/** The latest turn's assistant text (turn-segmented, like `run()`'s
|
|
74
|
+
* no-schema result path). Added for the REPL broker's result shaping;
|
|
75
|
+
* additive passthrough to `SessionHandle`. */
|
|
76
|
+
currentTurnText() {
|
|
77
|
+
return this.session.currentTurnText();
|
|
78
|
+
}
|
|
79
|
+
/** The latest turn's FINAL assistant message (the schema-extraction
|
|
80
|
+
* source `run()` uses; prose extraction over the whole turn would
|
|
81
|
+
* resurrect the first-JSON-wins bug for schema-shaped progress
|
|
82
|
+
* messages). Added for the REPL broker's structured-output ladder;
|
|
83
|
+
* additive passthrough to `SessionHandle`. */
|
|
84
|
+
finalMessageText() {
|
|
85
|
+
return this.session.finalMessageText();
|
|
86
|
+
}
|
|
87
|
+
/** This session's structured-output contract (set at open via
|
|
88
|
+
* `InteractiveSessionOptions.schema`), or undefined for plain sessions. */
|
|
89
|
+
get outputSchema() {
|
|
90
|
+
return this.schema;
|
|
91
|
+
}
|
|
92
|
+
/** Claude's raw `structured_output` for the latest turn, if any (the
|
|
93
|
+
* native structured channel the runner's ladder tries first). Added
|
|
94
|
+
* for the REPL broker's structured-output ladder; additive passthrough
|
|
95
|
+
* to `SessionHandle`. */
|
|
96
|
+
rawStructuredOutput() {
|
|
97
|
+
return this.session.rawStructuredOutput();
|
|
98
|
+
}
|
|
66
99
|
/** Message/tool history accumulated in this session's retained log. */
|
|
67
100
|
get history() {
|
|
68
101
|
return this.session.history;
|
|
@@ -70,7 +103,27 @@ export class InteractiveSession {
|
|
|
70
103
|
/** Send one prompt turn. A concurrent prompt on the same InteractiveSession is rejected with a
|
|
71
104
|
* clear host-side error; queueing is deliberately left to the host so turn boundaries remain
|
|
72
105
|
* explicit. Per-turn images are appended only to this prompt, and SessionHandle.prompt()
|
|
73
|
-
* performs capability adaptation before sending.
|
|
106
|
+
* performs capability adaptation before sending.
|
|
107
|
+
*
|
|
108
|
+
* The `onHandoff` option is the host's explicit handoff acknowledgment: it fires exactly
|
|
109
|
+
* once the prompt has passed every preflight check (released session, aborted signal,
|
|
110
|
+
* prompt-in-flight, image validation) AND the underlying ACP session/prompt request has
|
|
111
|
+
* actually been invoked — the call below runs synchronously through request construction
|
|
112
|
+
* and the wire send, so by the time the acknowledgment fires the payload is on the wire:
|
|
113
|
+
* the point of no return. A host that records a "delivered" marker for the prompt (the
|
|
114
|
+
* REPL broker's queued-steer delivery marker) MUST record it here rather than when the
|
|
115
|
+
* returned promise is created: an async pre-handoff rejection (released session, aborted
|
|
116
|
+
* signal, or prompt-in-flight) never reaches this line, and a marker recorded before it
|
|
117
|
+
* would make a restore skip a turn that was never delivered. The acknowledgment firing
|
|
118
|
+
* AFTER the invocation is the crash-boundary contract (review regression: it used to
|
|
119
|
+
* fire BEFORE, so a crash in that interval left a durable "delivered" marker on a prompt
|
|
120
|
+
* the backend never received — and a restore then skipped a never-delivered turn): a
|
|
121
|
+
* crash before the acknowledgment leaves the prompt undelivered-in-the-store and a
|
|
122
|
+
* restore re-issues it (at-least-once); a crash after it would replay a turn that is
|
|
123
|
+
* already on the wire, which the marker's host prevents. A throwing callback aborts the
|
|
124
|
+
* turn — its error propagates through the normal mapping — but the backend prompt is
|
|
125
|
+
* ALREADY invoked at that point, so the turn is the host's delivery-failure path, never
|
|
126
|
+
* a not-sent turn. */
|
|
74
127
|
async prompt(content, opts = {}) {
|
|
75
128
|
if (this.releasePromise)
|
|
76
129
|
throw new Error("InteractiveSession has been released");
|
|
@@ -81,9 +134,34 @@ export class InteractiveSession {
|
|
|
81
134
|
validatePromptImages(opts.images, this.label);
|
|
82
135
|
this.promptInFlight = true;
|
|
83
136
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
137
|
+
// Same request shaping as run(): a schema-bearing generic backend whose agent may
|
|
138
|
+
// ignore the `_meta` forward gets the contract stated in-band; the backend-computed
|
|
139
|
+
// turn meta (e.g. Codex's outputSchema forward) merges UNDER the user meta so the
|
|
140
|
+
// schema channel is never clobbered.
|
|
141
|
+
const shaped = typeof content === "string" && this.schema !== undefined && this.backend.embedSchemaInPrompt
|
|
142
|
+
? buildRunPrompt(content, {}, this.schema, this.backend)
|
|
143
|
+
: content;
|
|
144
|
+
const turnContent = appendPromptImages(shaped, opts.images);
|
|
145
|
+
const promptMeta = mergeTurnMeta(opts.promptMeta, this.backend.promptMeta(this.schema));
|
|
146
|
+
// The handoff acknowledgment (see the method docs above): fires only after the
|
|
147
|
+
// underlying ACP session/prompt request has been invoked — the call below runs
|
|
148
|
+
// synchronously through request construction and the wire send, so the payload is
|
|
149
|
+
// on the wire before the acknowledgment (review crash-boundary regression: the
|
|
150
|
+
// acknowledgment used to fire BEFORE the invocation, so a crash in that interval
|
|
151
|
+
// left a durable host "delivered" marker on a prompt the backend never received).
|
|
152
|
+
const responsePromise = this.session.prompt(turnContent, promptMeta);
|
|
153
|
+
try {
|
|
154
|
+
opts.onHandoff?.();
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
// The prompt is already on the wire: the marker write failed (or the host's
|
|
158
|
+
// acknowledgment threw) AFTER the hand-off, so the turn is a delivery failure,
|
|
159
|
+
// not a not-sent turn. The abandoned response must not become an unhandled
|
|
160
|
+
// rejection in the host process.
|
|
161
|
+
responsePromise.catch(() => { });
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const response = await responsePromise;
|
|
87
165
|
return {
|
|
88
166
|
stopReason: response.stopReason,
|
|
89
167
|
text: this.session.currentTurnText(),
|
|
@@ -156,6 +234,400 @@ export class InteractiveSession {
|
|
|
156
234
|
return;
|
|
157
235
|
await this.session.cancel();
|
|
158
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* The loaded session's founding-turn completion — the re-attach arm's task
|
|
239
|
+
* source (phase D of the REPL orchestrator roadmap; the broker drives this
|
|
240
|
+
* on a session re-opened with `runner.loadSession()` after a daemon
|
|
241
|
+
* restart). Resolves with the turn that was in flight at the backend when
|
|
242
|
+
* the session was loaded, so a re-attached call's continuation fires
|
|
243
|
+
* exactly once, through the same record → settle → consume pump as a live
|
|
244
|
+
* call.
|
|
245
|
+
*
|
|
246
|
+
* **The authoritative-completion seam** (the spec-owed decision,
|
|
247
|
+
* documented here). Completion evidence comes from TWO channels, by
|
|
248
|
+
* backend class:
|
|
249
|
+
*
|
|
250
|
+
* 1. **The `_session/loaded_turn` vendor extension** (the steering-
|
|
251
|
+
* extension precedent; advertised at initialize
|
|
252
|
+
* (`_meta.loadedTurn.supported === true`), served by the in-repo
|
|
253
|
+
* `@automatalabs/pi-acp` and `@automatalabs/codex-acp`):
|
|
254
|
+
* `session/load` obliges the agent to replay the entire persisted
|
|
255
|
+
* conversation before resolving (the runner marks the LOAD BOUNDARY
|
|
256
|
+
* synchronously after the response), and the seam then asks the
|
|
257
|
+
* backend `_session/loaded_turn/query` whether the founding turn is
|
|
258
|
+
* still running RIGHT NOW. The backend answers with one of three
|
|
259
|
+
* terminal classifications:
|
|
260
|
+
*
|
|
261
|
+
* - **`running`** — the founding turn is still executing at the
|
|
262
|
+
* backend. The seam KEEPS THE LOADED SESSION ATTACHED and waits
|
|
263
|
+
* for the `_session/loaded_turn/ended` notification — the turn's
|
|
264
|
+
* authoritative terminal marker (a quiet gap is only a
|
|
265
|
+
* progress-stream gap, never terminal evidence; the notification
|
|
266
|
+
* fires when the turn ends, carrying the stop reason or the
|
|
267
|
+
* error). It absorbs the turn's live `session/update` stream
|
|
268
|
+
* meanwhile, so a completion settles with the turn's REAL
|
|
269
|
+
* accumulated text. The wait is bounded by
|
|
270
|
+
* `LOADED_TURN_MAX_WAIT_MS` (default 15 min;
|
|
271
|
+
* `AGENTPRISM_ACP_LOADED_TURN_MAX_WAIT_MS` — the "never hang
|
|
272
|
+
* unobserved" backstop); a bound expiry rejects with the
|
|
273
|
+
* `LoadedTurnStillRunningError` (the broker re-arms the wait on
|
|
274
|
+
* the still-attached session — the notification may still arrive
|
|
275
|
+
* later).
|
|
276
|
+
* - **`completed`** — no turn is running, and the founding turn
|
|
277
|
+
* observably completed while this host was down: the replay's
|
|
278
|
+
* trailing assistant message is its FINAL message, so the seam
|
|
279
|
+
* resolves with it IMMEDIATELY (`{ stopReason: "end_turn", text }`
|
|
280
|
+
* — the stop reason is synthesized because the protocol's replay
|
|
281
|
+
* carries none; the text is the turn's real accumulated outcome,
|
|
282
|
+
* and the broker's result-shaping ladder reads the same
|
|
283
|
+
* transcript). A backend that answers `completed` while the
|
|
284
|
+
* replay does NOT end with an assistant message contradicts
|
|
285
|
+
* itself — the final message is not in the replay, so the seam
|
|
286
|
+
* rejects with the safe-re-issue class.
|
|
287
|
+
* - **`interrupted`** — no turn is running, and the founding turn
|
|
288
|
+
* ended without a terminal assistant message (it was
|
|
289
|
+
* interrupted/failed/abandoned while the host was down). Its
|
|
290
|
+
* outcome is not observable, but nothing is running at the
|
|
291
|
+
* backend, so the seam rejects with the SAFE-RE-ISSUE class (the
|
|
292
|
+
* broker re-issues under the same call id — no duplication
|
|
293
|
+
* possible).
|
|
294
|
+
*
|
|
295
|
+
* A QUERY FAILURE (the capability gate or a wire error) is NOT the
|
|
296
|
+
* missing extension: the seam falls THROUGH to the observation path
|
|
297
|
+
* below instead of classifying unobservable (phase-F review round
|
|
298
|
+
* 2 — the loaded session may still be executing, and a
|
|
299
|
+
* possibly-running call is never released-and-re-issued).
|
|
300
|
+
*
|
|
301
|
+
* 2. **The observation path — backends WITHOUT the extension (the
|
|
302
|
+
* built-in claude and opencode backends today), and extension
|
|
303
|
+
* backends whose query failed.** The authoritative observation is
|
|
304
|
+
* the loaded session's OWN stream plus its replay, under the
|
|
305
|
+
* CONNECTION-DEATH CONTRACT (live-verified against the current
|
|
306
|
+
* built-in servers): every built-in ACP server terminates its
|
|
307
|
+
* sessions' in-flight turns when the client connection closes —
|
|
308
|
+
* claude-agent-acp and pi-acp exit on connection close and cancel
|
|
309
|
+
* their turns (`connection.closed.then(shutdown)` → teardown →
|
|
310
|
+
* cancel + kill), `opencode acp` exits on stdin EOF, and codex-acp
|
|
311
|
+
* ends/kills the codex process — and their persisted transcripts
|
|
312
|
+
* contain only COMPLETED messages. So after a daemon crash the
|
|
313
|
+
* founding turn is NEVER still running at the backend, and the
|
|
314
|
+
* replay's trailing content is authoritative: an assistant message
|
|
315
|
+
* is the turn's terminal message (completed while down); anything
|
|
316
|
+
* else means the turn died mid-way (interrupted — nothing running,
|
|
317
|
+
* safe to re-issue). The one caveat is the in-flight-wire race —
|
|
318
|
+
* content still streaming when the load response resolves — so the
|
|
319
|
+
* seam first runs a bounded POST-LOAD CONTINUATION WATCH
|
|
320
|
+
* (`LOADED_TURN_OBSERVE_MS`, default 1 s;
|
|
321
|
+
* `AGENTPRISM_ACP_LOADED_TURN_OBSERVE_MS`): any CONTENT update
|
|
322
|
+
* applied after the load boundary is LIVE CONTINUATION — the
|
|
323
|
+
* authoritative still-running signal — and flips the classification
|
|
324
|
+
* to the keep-attached wait (below). No content within the window
|
|
325
|
+
* → classify from the replay (completed / interrupted) — but ONLY
|
|
326
|
+
* on a VERIFIED BUILT-IN backend (`connectionDeathVerified`: the
|
|
327
|
+
* four built-in instances; a custom registry entry's
|
|
328
|
+
* connection-death behavior is not live-verified, so its quiet
|
|
329
|
+
* window is NOT terminal evidence — phase-F review round 3: the
|
|
330
|
+
* replay classification used to apply to every extension-less
|
|
331
|
+
* backend and every query failure, so a durable custom backend
|
|
332
|
+
* could have a still-running turn settled from stale/partial
|
|
333
|
+
* replay or re-issued, violating the no-duplicate invariant). The
|
|
334
|
+
* window
|
|
335
|
+
* is the spec-owed concrete decision replacing the rejected
|
|
336
|
+
* quiet-grace heuristic: the grace is bounded AND the classification
|
|
337
|
+
* rests on the connection-death contract, never on a quiet gap
|
|
338
|
+
* alone (phase-D review round 3 rejected the unbounded settle-from-
|
|
339
|
+
* trailing-chunk guess; a still-running turn's quiet parks are
|
|
340
|
+
* never settled here — a park produces no content, but for the
|
|
341
|
+
* built-ins no turn can be running at restore in the first place).
|
|
342
|
+
*
|
|
343
|
+
* **The keep-attached still-running wait** (both channels): the
|
|
344
|
+
* loaded session stays attached, the turn's live stream is absorbed,
|
|
345
|
+
* and the seam waits for the terminal state — the `_session/loaded_turn/ended`
|
|
346
|
+
* notification when the backend pushes one (an extension backend, or
|
|
347
|
+
* a seam-less backend that sends it anyway), the max-wait bound (the
|
|
348
|
+
* "never hang unobserved" backstop), or the session's release. A
|
|
349
|
+
* bound expiry rejects with the `LoadedTurnStillRunningError`, and
|
|
350
|
+
* the broker RE-ARMS the wait on the still-attached session (phase-F
|
|
351
|
+
* review round 2: a possibly-running call is never re-issued — the
|
|
352
|
+
* re-issue arm is reserved for observably-dead calls); a cancel or
|
|
353
|
+
* the broker's drain settles it.
|
|
354
|
+
*
|
|
355
|
+
* The unconditional arms stay: a handle that was never load-marked
|
|
356
|
+
* (not produced by the runner's `loadSession` path), and a transcript
|
|
357
|
+
* with no user message at all (the recorded session never received its
|
|
358
|
+
* prompt — nothing reached the backend), both reject immediately with
|
|
359
|
+
* the safe-re-issue class. A released/dead session mid-wait rejects
|
|
360
|
+
* through the same plain class (a dead process means the backend turn
|
|
361
|
+
* died with it — re-issue is safe; the broker's own teardown releases
|
|
362
|
+
* are handled by the broker's drain state).
|
|
363
|
+
*/
|
|
364
|
+
async awaitCurrentTurn() {
|
|
365
|
+
if (this.releasePromise) {
|
|
366
|
+
throw new Error("InteractiveSession has been released");
|
|
367
|
+
}
|
|
368
|
+
const boundary = this.session.loadBoundaryState();
|
|
369
|
+
if (!boundary.marked) {
|
|
370
|
+
throw new Error("the loaded session's founding-turn boundary was not recorded at load " +
|
|
371
|
+
"— its completion is not observable over the ACP protocol; re-issue is the honest fallback");
|
|
372
|
+
}
|
|
373
|
+
if (!boundary.hasUserMessage) {
|
|
374
|
+
throw new Error("the loaded session's transcript shows no user message — the founding turn never reached the backend " +
|
|
375
|
+
"(its outcome is unobservable; re-issue is the honest fallback)");
|
|
376
|
+
}
|
|
377
|
+
const capabilities = this.connection.capabilities;
|
|
378
|
+
if (capabilities?.supportsLoadedTurnTerminalState === true) {
|
|
379
|
+
// Subscribe to the ended channel BEFORE the query: a turn that ends
|
|
380
|
+
// between the query response and this wait must not be missed (the
|
|
381
|
+
// notification is recorded on the session state, first-wins).
|
|
382
|
+
let status;
|
|
383
|
+
try {
|
|
384
|
+
status = (await this.connection.queryLoadedTurn(this.sessionId, this.label)).status;
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
// The wire query failed (the capability gate or a wire error):
|
|
388
|
+
// the authoritative answer is unavailable, so the terminal state
|
|
389
|
+
// is classified by the OBSERVATION path instead (the same path a
|
|
390
|
+
// seam-less backend takes — the post-load continuation watch plus
|
|
391
|
+
// the replay probe under the connection-death contract). Phase-F
|
|
392
|
+
// review round 2: a query failure must NOT push the broker to
|
|
393
|
+
// release-and-re-issue — the loaded session may still be
|
|
394
|
+
// executing, and a possibly-running call is never re-issued.
|
|
395
|
+
return this.observeLoadedTurn(boundary, error);
|
|
396
|
+
}
|
|
397
|
+
if (status === "completed") {
|
|
398
|
+
if (boundary.trailingContentKind !== "assistant-message") {
|
|
399
|
+
// The backend claims the founding turn completed, but its final
|
|
400
|
+
// assistant message is not the replay's last content event: the
|
|
401
|
+
// outcome is not in the transcript. Nothing is running (the
|
|
402
|
+
// backend said so), so the safe-re-issue class applies.
|
|
403
|
+
throw new Error(`the loaded session's backend reported the founding turn completed, but the replayed transcript ` +
|
|
404
|
+
`does not end with an assistant message — the turn's final outcome is not in the replay; re-issue ` +
|
|
405
|
+
`is the honest fallback`);
|
|
406
|
+
}
|
|
407
|
+
// Completed-while-down: the replay's trailing assistant message IS
|
|
408
|
+
// the founding turn's final message (the backend's authoritative
|
|
409
|
+
// answer, not a quiet-gap guess). Settle from the transcript.
|
|
410
|
+
return { stopReason: "end_turn", text: this.session.loadedTurnText() };
|
|
411
|
+
}
|
|
412
|
+
if (status === "interrupted") {
|
|
413
|
+
throw new Error(`the loaded session's founding turn ended without a terminal assistant message (it was interrupted ` +
|
|
414
|
+
`or failed while this host was down) and no turn is running at the backend — its outcome is not ` +
|
|
415
|
+
`observable over the ACP protocol; re-issue is the honest fallback (nothing is running to duplicate)`);
|
|
416
|
+
}
|
|
417
|
+
// status === "running": the authoritative terminal wait. The turn is
|
|
418
|
+
// still executing at the backend; the loaded session stays attached
|
|
419
|
+
// and the seam waits for the `_session/loaded_turn/ended` notification
|
|
420
|
+
// (absorbing the turn's live update stream — the settle text is the
|
|
421
|
+
// accumulated transcript at the notification, the turn's REAL outcome).
|
|
422
|
+
return this.waitForRunningLoadedTurn(loadedTurnMaxWaitMs(), `the query classified the loaded session's founding turn running`);
|
|
423
|
+
}
|
|
424
|
+
// No extension (the built-in claude and opencode backends today) — or
|
|
425
|
+
// the query failed and the catch already fell through: the
|
|
426
|
+
// observation path classifies the founding turn's terminal state
|
|
427
|
+
// (see `observeLoadedTurn`).
|
|
428
|
+
return this.observeLoadedTurn(boundary, undefined);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The observation path — backends WITHOUT the `_session/loaded_turn`
|
|
432
|
+
* extension (the built-in claude and opencode backends today), and
|
|
433
|
+
* extension backends whose query failed (see `awaitCurrentTurn`'s
|
|
434
|
+
* doc for the full semantics — the connection-death contract, the
|
|
435
|
+
* post-load continuation watch, and the replay probe). Never settles
|
|
436
|
+
* a quiet gap, never re-issues a possibly-running turn: the
|
|
437
|
+
* classification is authoritative ONLY for the VERIFIED BUILT-INS
|
|
438
|
+
* (`connectionDeathVerified`) because their ACP servers terminate
|
|
439
|
+
* in-flight turns when the client connection closes (live-verified),
|
|
440
|
+
* and their replay holds only completed messages. A CUSTOM backend
|
|
441
|
+
* (a registered registry entry — its connection-death behavior is
|
|
442
|
+
* NOT live-verified) that stays quiet through the window is NOT
|
|
443
|
+
* classified from the replay: its turn may still be running at the
|
|
444
|
+
* backend, so the seam keeps the loaded session attached and waits
|
|
445
|
+
* for the authoritative terminal state instead (phase-F review round
|
|
446
|
+
* 3: the quiet-window-plus-replay classification used to apply to
|
|
447
|
+
* every extension-less backend and every query failure, so a durable
|
|
448
|
+
* custom backend could have a still-running turn settled from stale
|
|
449
|
+
* replay or re-issued — the no-duplicate invariant requires the
|
|
450
|
+
* verified assumption to be restricted to the verified backends).
|
|
451
|
+
*/
|
|
452
|
+
async observeLoadedTurn(boundary, queryFailure) {
|
|
453
|
+
// The post-load continuation watch: the load response resolves after
|
|
454
|
+
// the replay completes, so any CONTENT update applied after the
|
|
455
|
+
// boundary is live continuation — the authoritative still-running
|
|
456
|
+
// signal. A content update that ALREADY arrived (applied between the
|
|
457
|
+
// load response and this call) skips the window.
|
|
458
|
+
if (!boundary.sawPostLoadContentUpdate) {
|
|
459
|
+
const contentArrived = await this.waitForPostLoadContent(loadedTurnObserveMs());
|
|
460
|
+
if (this.releasePromise) {
|
|
461
|
+
throw new Error("InteractiveSession has been released");
|
|
462
|
+
}
|
|
463
|
+
if (!contentArrived && this.connectionDeathVerified) {
|
|
464
|
+
// The stream stayed quiet through the observation window: classify
|
|
465
|
+
// the founding turn's terminal state from the REPLAY (authoritative
|
|
466
|
+
// under the connection-death contract — the replay's trailing
|
|
467
|
+
// assistant message is a COMPLETED, persisted message, and no
|
|
468
|
+
// live continuation followed the load).
|
|
469
|
+
if (boundary.trailingContentKind === 'assistant-message') {
|
|
470
|
+
return { stopReason: "end_turn", text: this.session.loadedTurnText() };
|
|
471
|
+
}
|
|
472
|
+
throw new Error(`the loaded session's founding turn ended without a terminal assistant message (its replayed ` +
|
|
473
|
+
`transcript's trailing content is not an assistant message) and no live continuation followed the ` +
|
|
474
|
+
`load — the turn was interrupted, failed, or abandoned while this host was down, and nothing is ` +
|
|
475
|
+
`running at the backend; re-issue is the honest fallback (no duplication possible)`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// Live continuation was observed — or the window stayed quiet on an
|
|
479
|
+
// UNVERIFIED backend (a custom registry entry, whose connection-death
|
|
480
|
+
// behavior is not live-verified): either way the turn may still be
|
|
481
|
+
// running at the backend. Keep the loaded session attached and wait
|
|
482
|
+
// for the authoritative terminal state (the ended notification, the
|
|
483
|
+
// max-wait bound — the re-armable still-running rejection the broker
|
|
484
|
+
// re-arms on the attached session). Never settle a quiet gap and
|
|
485
|
+
// never re-issue a possibly-running turn.
|
|
486
|
+
return this.waitForRunningLoadedTurn(loadedTurnMaxWaitMs(), queryFailure === undefined
|
|
487
|
+
? this.connectionDeathVerified
|
|
488
|
+
? `live content followed the load response on ${this.backendId}`
|
|
489
|
+
: `the loaded session's stream stayed quiet after the load on ${this.backendId} (a custom backend's ` +
|
|
490
|
+
`connection-death behavior is not live-verified — the quiet window is not terminal evidence)`
|
|
491
|
+
: this.connectionDeathVerified
|
|
492
|
+
? `live content followed the load response on ${this.backendId} (_session/loaded_turn/query failed: ` +
|
|
493
|
+
`${thrownMessageOf(queryFailure)})`
|
|
494
|
+
: `the loaded session's stream stayed quiet after the load on ${this.backendId} (a custom backend's ` +
|
|
495
|
+
`connection-death behavior is not live-verified — the quiet window is not terminal evidence; ` +
|
|
496
|
+
`_session/loaded_turn/query failed: ${thrownMessageOf(queryFailure)})`);
|
|
497
|
+
}
|
|
498
|
+
/** Is this session's backend one of the four built-ins whose ACP
|
|
499
|
+
* servers' connection-death behavior is LIVE-VERIFIED (claude,
|
|
500
|
+
* codex, opencode, pi — every built-in server terminates its
|
|
501
|
+
* sessions' in-flight turns when the client connection closes, so a
|
|
502
|
+
* restored session's replay holds only completed messages)? The
|
|
503
|
+
* observation path's quiet-window-plus-replay classification is
|
|
504
|
+
* authoritative ONLY for these; a CUSTOM backend (a registered
|
|
505
|
+
* registry entry — even one that shadows a built-in name) can keep a
|
|
506
|
+
* turn running while quiet, so its quiet window degrades to the
|
|
507
|
+
* keep-attached still-running wait (phase-F review round 3: the
|
|
508
|
+
* verified assumption must be restricted to the verified built-ins).
|
|
509
|
+
* Instance-based: a custom backend registered under a built-in name
|
|
510
|
+
* is a `CustomAcpBackend` and is never counted as verified. */
|
|
511
|
+
get connectionDeathVerified() {
|
|
512
|
+
return !(this.backend instanceof CustomAcpBackend);
|
|
513
|
+
}
|
|
514
|
+
/** The post-load continuation watch: resolve true on the first CONTENT
|
|
515
|
+
* update applied after the load boundary (live-continuation evidence —
|
|
516
|
+
* the authoritative still-running signal), false when the observation
|
|
517
|
+
* window elapses without one (or the session is released — the caller
|
|
518
|
+
* re-checks `releasePromise` before classifying). Bookkeeping updates
|
|
519
|
+
* (usage, mode, available commands — claude emits an
|
|
520
|
+
* `available_commands_update` right after every load) never count:
|
|
521
|
+
* the flag flips only on content updates. */
|
|
522
|
+
waitForPostLoadContent(observeMs) {
|
|
523
|
+
return new Promise((resolve) => {
|
|
524
|
+
if (this.releasePromise) {
|
|
525
|
+
resolve(false);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (this.session.loadBoundaryState().sawPostLoadContentUpdate) {
|
|
529
|
+
resolve(true);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
let finished = false;
|
|
533
|
+
const finish = (result) => {
|
|
534
|
+
if (finished)
|
|
535
|
+
return;
|
|
536
|
+
finished = true;
|
|
537
|
+
clearTimeout(timer);
|
|
538
|
+
offUpdate();
|
|
539
|
+
releaseWatcher();
|
|
540
|
+
resolve(result);
|
|
541
|
+
};
|
|
542
|
+
const timer = setTimeout(() => finish(false), observeMs);
|
|
543
|
+
const offUpdate = this.session.subscribeUpdates(() => {
|
|
544
|
+
if (this.session.loadBoundaryState().sawPostLoadContentUpdate)
|
|
545
|
+
finish(true);
|
|
546
|
+
});
|
|
547
|
+
const releaseWatcher = () => {
|
|
548
|
+
this.releaseWatchers.delete(releaseWatcher);
|
|
549
|
+
finish(false);
|
|
550
|
+
};
|
|
551
|
+
this.releaseWatchers.add(releaseWatcher);
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* The keep-attached still-running wait (the extension's `running` arm
|
|
556
|
+
* AND the observation path's live-continuation arm): the loaded
|
|
557
|
+
* session stays attached, the turn's live stream is absorbed, and the
|
|
558
|
+
* seam waits for the terminal state — the `_session/loaded_turn/ended`
|
|
559
|
+
* notification when the backend pushes one, the max-wait bound (the
|
|
560
|
+
* "never hang unobserved" backstop), or the session's release —
|
|
561
|
+
* whichever comes first (no polling: a long still-running turn is
|
|
562
|
+
* observed with zero busy work). A bound expiry rejects with the
|
|
563
|
+
* re-armable `LoadedTurnStillRunningError`: the broker re-arms the
|
|
564
|
+
* wait on the still-attached session — a later ended notification — or
|
|
565
|
+
* a cancel — still settles the call (phase-F review round 2: a
|
|
566
|
+
* possibly-running call is never re-issued).
|
|
567
|
+
*/
|
|
568
|
+
async waitForRunningLoadedTurn(maxWaitMs, context) {
|
|
569
|
+
const start = Date.now();
|
|
570
|
+
for (;;) {
|
|
571
|
+
if (this.releasePromise) {
|
|
572
|
+
throw new Error("InteractiveSession has been released while awaiting the loaded session's founding turn");
|
|
573
|
+
}
|
|
574
|
+
const ended = this.session.loadedTurnEndedState();
|
|
575
|
+
if (ended !== null) {
|
|
576
|
+
return this.loadedTurnEndedResult(ended);
|
|
577
|
+
}
|
|
578
|
+
const elapsed = Date.now() - start;
|
|
579
|
+
if (elapsed >= maxWaitMs) {
|
|
580
|
+
// The "never hang unobserved" backstop: the turn is STILL running
|
|
581
|
+
// and its terminal state has not become observable. Never settle a
|
|
582
|
+
// quiet gap and never re-issue a possibly-running turn — reject
|
|
583
|
+
// with the still-running class (the broker keeps the loaded
|
|
584
|
+
// session attached, warns guest-visibly, and re-arms the seam so
|
|
585
|
+
// a later notification — or a cancel — still settles the call).
|
|
586
|
+
throw new LoadedTurnStillRunningError(`the loaded session's founding turn is still running at the backend (${context}), and its terminal ` +
|
|
587
|
+
`state has not become observable within ${maxWaitMs} ms of the load — settling a quiet gap could ` +
|
|
588
|
+
`durably record partial output, and re-issuing could duplicate the running turn; the broker keeps ` +
|
|
589
|
+
`the loaded session attached and re-arms the wait on it`, true);
|
|
590
|
+
}
|
|
591
|
+
// Wait for the ended notification, the max-wait expiry, or the
|
|
592
|
+
// session's release — whichever comes first (no polling: a long
|
|
593
|
+
// still-running turn is observed with zero busy work).
|
|
594
|
+
await Promise.race([
|
|
595
|
+
this.nextLoadedTurnEnded(),
|
|
596
|
+
sleep(maxWaitMs - elapsed),
|
|
597
|
+
this.waitForRelease(),
|
|
598
|
+
]);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** The `_session/loaded_turn/ended` resolution: the turn that was running
|
|
602
|
+
* at load ended. A turn that ended with an ERROR is a definite
|
|
603
|
+
* rejection (never settled as success — `LoadedTurnFailedError`, the
|
|
604
|
+
* settle-as-rejection class the broker records and delivers); a turn
|
|
605
|
+
* that ended with a response resolves with its stop reason (the
|
|
606
|
+
* notification's, restricted to the ACP vocabulary — a server-specific
|
|
607
|
+
* reason the seam does not speak synthesizes `end_turn`, exactly like
|
|
608
|
+
* the completed-while-down arm) and the accumulated text. */
|
|
609
|
+
loadedTurnEndedResult(ended) {
|
|
610
|
+
if (ended.error !== undefined) {
|
|
611
|
+
throw new LoadedTurnFailedError(`the loaded session's founding turn failed at the backend: ${ended.error.message}`);
|
|
612
|
+
}
|
|
613
|
+
const stopReason = ended.stopReason ?? "end_turn";
|
|
614
|
+
return {
|
|
615
|
+
stopReason: LOADED_TURN_STOP_REASONS.has(stopReason) ? stopReason : "end_turn",
|
|
616
|
+
text: this.session.loadedTurnText(),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
/** Resolve on the session's next `_session/loaded_turn/ended`
|
|
620
|
+
* notification (the re-attach arm's authoritative terminal wait —
|
|
621
|
+
* zero polling; the subscription is one-shot and removed the moment it
|
|
622
|
+
* fires, and a notification that already arrived fires immediately). */
|
|
623
|
+
nextLoadedTurnEnded() {
|
|
624
|
+
return new Promise((resolve) => {
|
|
625
|
+
const off = this.session.subscribeLoadedTurnEnded(() => {
|
|
626
|
+
off();
|
|
627
|
+
resolve();
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
}
|
|
159
631
|
/** Subscribe to runner events for THIS ACP session only. Events from other one-shot or
|
|
160
632
|
* interactive sessions on the same runner are filtered out by sessionId. The returned
|
|
161
633
|
* unsubscribe thunk and every still-live subscription are removed automatically on release. */
|
|
@@ -178,12 +650,66 @@ export class InteractiveSession {
|
|
|
178
650
|
this.subscriptions.add(off);
|
|
179
651
|
return off;
|
|
180
652
|
}
|
|
653
|
+
/** Resolve when the session is released (or immediately when it
|
|
654
|
+
* already is) — the re-attach arm's release watch, so a session that
|
|
655
|
+
* dies or is disposed while the seam waits unblocks the wait instead
|
|
656
|
+
* of parking it until the max-wait expiry. */
|
|
657
|
+
waitForRelease() {
|
|
658
|
+
return new Promise((resolve) => {
|
|
659
|
+
if (this.releasePromise) {
|
|
660
|
+
resolve();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const watcher = () => {
|
|
664
|
+
this.releaseWatchers.delete(watcher);
|
|
665
|
+
resolve();
|
|
666
|
+
};
|
|
667
|
+
this.releaseWatchers.add(watcher);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
181
670
|
/** Release the ACP session and close the dedicated process. Idempotent. Session close is
|
|
182
671
|
* best-effort and bounded by SessionHandle; process disposal mirrors pool teardown. */
|
|
183
672
|
release() {
|
|
184
673
|
this.releasePromise ??= this.doRelease();
|
|
185
674
|
return this.releasePromise;
|
|
186
675
|
}
|
|
676
|
+
/** The loaded session's recorded founding-turn terminal state (the
|
|
677
|
+
* `_session/loaded_turn/ended` notification, when the backend pushed
|
|
678
|
+
* one — a seam-less backend that sends it anyway), or null when the
|
|
679
|
+
* turn has not ended (yet). The broker's non-re-armable settlement
|
|
680
|
+
* wait reads this surface instead of re-invoking a seam that can
|
|
681
|
+
* never observe the terminal state. */
|
|
682
|
+
loadedTurnEndedState() {
|
|
683
|
+
return this.session.loadedTurnEndedState();
|
|
684
|
+
}
|
|
685
|
+
/** Watch the loaded-turn-ended channel: the listener fires when the
|
|
686
|
+
* `_session/loaded_turn/ended` notification arrives (and immediately
|
|
687
|
+
* for a notification that already arrived). Returns the unsubscribe
|
|
688
|
+
* thunk. The broker's non-re-armable settlement wait's observability
|
|
689
|
+
* surface (the same channel the seam's own wait subscribes to). */
|
|
690
|
+
subscribeLoadedTurnEnded(listener) {
|
|
691
|
+
return this.session.subscribeLoadedTurnEnded(listener);
|
|
692
|
+
}
|
|
693
|
+
/** Resolve when the session is released (its dedicated process died or
|
|
694
|
+
* was disposed); never resolves on a live session. The broker's
|
|
695
|
+
* non-re-armable settlement wait's release watch — a released
|
|
696
|
+
* session means the backend turn died with the process (the
|
|
697
|
+
* safe-re-issue class). */
|
|
698
|
+
released() {
|
|
699
|
+
if (this.releasePromise !== undefined)
|
|
700
|
+
return this.releasePromise;
|
|
701
|
+
return new Promise((resolve) => {
|
|
702
|
+
if (this.releasePromise !== undefined) {
|
|
703
|
+
resolve();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const watcher = () => {
|
|
707
|
+
this.releaseWatchers.delete(watcher);
|
|
708
|
+
resolve();
|
|
709
|
+
};
|
|
710
|
+
this.releaseWatchers.add(watcher);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
187
713
|
/** The re-attach handle for this session — persist it, then re-open later with
|
|
188
714
|
* `runner.loadSession()`/`resumeSession()` (`backendId` doubles as the `model` routing spec).
|
|
189
715
|
* Reopen flags mirror the connected agent's advertised persistence; an agent that persists
|
|
@@ -208,6 +734,13 @@ export class InteractiveSession {
|
|
|
208
734
|
async doRelease() {
|
|
209
735
|
this.removeAbort?.();
|
|
210
736
|
this.removeAbort = undefined;
|
|
737
|
+
// Wake the re-attach arm's release watchers FIRST: an awaiting
|
|
738
|
+
// `awaitCurrentTurn` must unblock the moment the session is released
|
|
739
|
+
// (its loop re-checks `releasePromise` and rejects).
|
|
740
|
+
for (const watcher of [...this.releaseWatchers]) {
|
|
741
|
+
this.releaseWatchers.delete(watcher);
|
|
742
|
+
watcher();
|
|
743
|
+
}
|
|
211
744
|
let childFailure;
|
|
212
745
|
try {
|
|
213
746
|
await this.session.release({ keepOpen: this.keepSession });
|
|
@@ -236,3 +769,127 @@ export class InteractiveSession {
|
|
|
236
769
|
this.subscriptions.clear();
|
|
237
770
|
}
|
|
238
771
|
}
|
|
772
|
+
/** The loaded-session founding-turn max wait: the re-attach arm's
|
|
773
|
+
* "never hang unobserved" backstop (see `awaitCurrentTurn`). A turn the
|
|
774
|
+
* backend classifies as `running` (or the observation path sees live
|
|
775
|
+
* content from) is waited out up to this bound for its terminal state —
|
|
776
|
+
* the `_session/loaded_turn/ended` notification, or the end of the
|
|
777
|
+
* live stream — then the seam rejects with the
|
|
778
|
+
* `LoadedTurnStillRunningError` (the broker keeps the loaded session
|
|
779
|
+
* attached and re-arms the wait on it, so a later notification still
|
|
780
|
+
* settles the call). Default 15 min;
|
|
781
|
+
* `AGENTPRISM_ACP_LOADED_TURN_MAX_WAIT_MS` overrides (clamped to
|
|
782
|
+
* >= 1 ms). */
|
|
783
|
+
function loadedTurnMaxWaitMs() {
|
|
784
|
+
const env = process.env.AGENTPRISM_ACP_LOADED_TURN_MAX_WAIT_MS;
|
|
785
|
+
if (env !== undefined) {
|
|
786
|
+
const parsed = Number.parseInt(env, 10);
|
|
787
|
+
if (Number.isFinite(parsed) && parsed >= 1)
|
|
788
|
+
return parsed;
|
|
789
|
+
}
|
|
790
|
+
return 15 * 60 * 1000;
|
|
791
|
+
}
|
|
792
|
+
/** The observation path's post-load continuation window (see
|
|
793
|
+
* `observeLoadedTurn`): how long the seam watches the loaded session's
|
|
794
|
+
* stream for live continuation before classifying the founding turn's
|
|
795
|
+
* terminal state from the replay. The window absorbs content still in
|
|
796
|
+
* flight when the load response resolved (the replay completes before
|
|
797
|
+
* the response, so this is only the wire race and the first live chunk
|
|
798
|
+
* of a turn that is running after all); under the connection-death
|
|
799
|
+
* contract no built-in turn can be running at restore, so the window is
|
|
800
|
+
* short. Default 1 s; `AGENTPRISM_ACP_LOADED_TURN_OBSERVE_MS`
|
|
801
|
+
* overrides (clamped to >= 1 ms). */
|
|
802
|
+
function loadedTurnObserveMs() {
|
|
803
|
+
const env = process.env.AGENTPRISM_ACP_LOADED_TURN_OBSERVE_MS;
|
|
804
|
+
if (env !== undefined) {
|
|
805
|
+
const parsed = Number.parseInt(env, 10);
|
|
806
|
+
if (Number.isFinite(parsed) && parsed >= 1)
|
|
807
|
+
return parsed;
|
|
808
|
+
}
|
|
809
|
+
return 1000;
|
|
810
|
+
}
|
|
811
|
+
/** The ACP stop-reason vocabulary the loaded-turn ended notification may
|
|
812
|
+
* carry (a server-specific reason the seam does not speak synthesizes
|
|
813
|
+
* `end_turn`, exactly like the completed-while-down arm). */
|
|
814
|
+
const LOADED_TURN_STOP_REASONS = new Set([
|
|
815
|
+
"end_turn",
|
|
816
|
+
"max_tokens",
|
|
817
|
+
"max_turn_requests",
|
|
818
|
+
"refusal",
|
|
819
|
+
"cancelled",
|
|
820
|
+
]);
|
|
821
|
+
/** The re-attach arm's duplicate-risk rejection: the loaded session's
|
|
822
|
+
* founding turn MAY STILL BE RUNNING at the backend and its terminal
|
|
823
|
+
* state is unobservable — a `running`-classified turn produced no
|
|
824
|
+
* terminal notification within the max-wait bound (the observation
|
|
825
|
+
* path's live-continuation arm included). The host must NEVER settle
|
|
826
|
+
* partial output (a quiet gap is only a progress-stream gap) and NEVER
|
|
827
|
+
* re-issue a possibly-running call: the broker KEEPS THE LOADED SESSION
|
|
828
|
+
* ATTACHED and re-arms the seam on it — the doc's second reconciliation
|
|
829
|
+
* arm, re-attach to a still-running task — for every form of this
|
|
830
|
+
* rejection (phase-F review round 2: the old non-re-armable form
|
|
831
|
+
* pushed the broker to release the loaded session and re-issue the
|
|
832
|
+
* call, which could duplicate a still-running backend turn; re-issue
|
|
833
|
+
* is now reserved for the observably-dead classes). A later terminal
|
|
834
|
+
* notification — or a cancel — still settles the call. The marker
|
|
835
|
+
* property is structural, so third-party adapter seams can throw the
|
|
836
|
+
* same class of rejection; `rearmable` is retained for compatibility
|
|
837
|
+
* with those seams (the broker re-arms both forms). */
|
|
838
|
+
export class LoadedTurnStillRunningError extends Error {
|
|
839
|
+
rearmable;
|
|
840
|
+
loadedTurnStillRunning = true;
|
|
841
|
+
constructor(message, rearmable) {
|
|
842
|
+
super(message);
|
|
843
|
+
this.rearmable = rearmable;
|
|
844
|
+
this.name = "LoadedTurnStillRunningError";
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/** The re-attach arm's settle-as-rejection class: the loaded session's
|
|
848
|
+
* founding turn RAN and FAILED at the backend (the `_session/loaded_turn/
|
|
849
|
+
* ended` notification carried its error). A definite outcome — the host
|
|
850
|
+
* records and settles it as a rejection, exactly like a live prompt that
|
|
851
|
+
* rejects; it is never re-issued (the task already ran to a terminal
|
|
852
|
+
* state) and never settled as success (partial text is not an outcome).
|
|
853
|
+
* Marker property is structural, like `LoadedTurnStillRunningError`. */
|
|
854
|
+
export class LoadedTurnFailedError extends WorkflowError {
|
|
855
|
+
loadedTurnFailed = true;
|
|
856
|
+
constructor(message) {
|
|
857
|
+
super(message, WorkflowErrorCode.AGENT_EXECUTION_ERROR, { recoverable: false });
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/** Is this a loaded-turn still-running rejection (the broker's
|
|
861
|
+
* never-settle-a-quiet-gap classification)? Structural marker, so
|
|
862
|
+
* third-party adapter seams can throw the same class. */
|
|
863
|
+
export function isLoadedTurnStillRunningError(error) {
|
|
864
|
+
return (error instanceof LoadedTurnStillRunningError ||
|
|
865
|
+
(typeof error === "object" &&
|
|
866
|
+
error !== null &&
|
|
867
|
+
error.loadedTurnStillRunning === true));
|
|
868
|
+
}
|
|
869
|
+
/** Is this a loaded-turn failed-at-backend rejection (the broker's
|
|
870
|
+
* settle-as-rejection classification)? Structural marker, so third-party
|
|
871
|
+
* adapter seams can throw the same class. */
|
|
872
|
+
export function isLoadedTurnFailedError(error) {
|
|
873
|
+
return (error instanceof LoadedTurnFailedError ||
|
|
874
|
+
(typeof error === "object" &&
|
|
875
|
+
error !== null &&
|
|
876
|
+
error.loadedTurnFailed === true));
|
|
877
|
+
}
|
|
878
|
+
/** Normalize any thrown value into a message (the query-failure arm of
|
|
879
|
+
* the seam's degradation message). */
|
|
880
|
+
function thrownMessageOf(error) {
|
|
881
|
+
if (error instanceof Error)
|
|
882
|
+
return error.message;
|
|
883
|
+
if (typeof error === "string")
|
|
884
|
+
return error;
|
|
885
|
+
try {
|
|
886
|
+
return JSON.stringify(error);
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
return String(error);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/** Timer sleep (the re-attach arm's wait primitive). */
|
|
893
|
+
function sleep(ms) {
|
|
894
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
895
|
+
}
|