@rynx-ai/runtime 0.1.11-beta.5 → 0.1.11-beta.50
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/dist/claude/executor.d.ts +19 -5
- package/dist/claude/executor.js +56 -12
- package/dist/claude/models.d.ts +0 -5
- package/dist/claude/models.js +1 -7
- package/dist/claude/native-bridge.d.ts +103 -1
- package/dist/claude/native-bridge.js +460 -30
- package/dist/claude/native-hook-main.js +81 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +178 -26
- package/dist/claude/native-integration.js +1571 -170
- package/dist/claude/session-status.d.ts +39 -0
- package/dist/claude/session-status.js +163 -0
- package/dist/claude/transcript-clone.d.ts +18 -0
- package/dist/claude/transcript-clone.js +497 -0
- package/dist/claude/transcript.d.ts +27 -4
- package/dist/claude/transcript.js +183 -46
- package/dist/codex-app-server/client.d.ts +10 -6
- package/dist/codex-app-server/client.js +67 -15
- package/dist/codex-app-server/forwarder.d.ts +96 -3
- package/dist/codex-app-server/forwarder.js +542 -57
- package/dist/codex-app-server/mapping.d.ts +3 -6
- package/dist/codex-app-server/mapping.js +228 -36
- package/dist/codex-app-server/mcp-startup.d.ts +13 -0
- package/dist/codex-app-server/mcp-startup.js +63 -0
- package/dist/codex-app-server/process-registry.d.ts +36 -0
- package/dist/codex-app-server/process-registry.js +320 -0
- package/dist/codex-app-server/protocol.d.ts +68 -7
- package/dist/codex-app-server/transport.d.ts +2 -2
- package/dist/codex-app-server/transport.js +4 -0
- package/dist/codex-app-server/ws-channel.d.ts +8 -1
- package/dist/codex-app-server/ws-channel.js +113 -28
- package/dist/codex-child-env.js +3 -32
- package/dist/codex-home.d.ts +35 -3
- package/dist/codex-home.js +323 -18
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +106 -48
- package/dist/host.js +2024 -640
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +4 -0
- package/dist/input-resources.js +21 -5
- package/dist/models-catalog.js +12 -3
- package/dist/runner/child.d.ts +105 -28
- package/dist/runner/child.js +1625 -100
- package/dist/runner/manager.d.ts +114 -46
- package/dist/runner/manager.js +1523 -446
- package/dist/runner/protocol.d.ts +212 -24
- package/dist/runner/protocol.js +5 -0
- package/dist/runner/startup-policy.d.ts +7 -0
- package/dist/runner/startup-policy.js +10 -0
- package/dist/runner/transport.d.ts +18 -2
- package/dist/runner/transport.js +82 -3
- package/dist/runner-main.js +8 -3
- package/dist/terminal/codex-tui.d.ts +4 -0
- package/dist/terminal/codex-tui.js +5 -0
- package/dist/terminal/control-parser.d.ts +39 -0
- package/dist/terminal/control-parser.js +172 -0
- package/dist/terminal/registry.d.ts +18 -15
- package/dist/terminal/registry.js +44 -23
- package/dist/terminal/spool.d.ts +47 -0
- package/dist/terminal/spool.js +231 -0
- package/dist/terminal/tmux.d.ts +126 -74
- package/dist/terminal/tmux.js +807 -211
- package/package.json +4 -4
|
@@ -1,9 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mapCodexItem, mapCodexNotification } from "./mapping.js";
|
|
1
|
+
import { codexUserEchoContent } from "../input-resources.js";
|
|
2
|
+
import { codexResumeTerminalStatus, mapCodexItem, mapCodexNotification, } from "./mapping.js";
|
|
3
|
+
const MCP_STARTUP_STATUS_METHOD = "mcpServer/startupStatus/updated";
|
|
4
|
+
const MCP_TERMINAL_STATES = new Set(["ready", "failed", "cancelled"]);
|
|
3
5
|
function threadIdFrom(params) {
|
|
4
6
|
const p = params;
|
|
5
7
|
return p?.threadId ?? p?.thread?.id;
|
|
6
8
|
}
|
|
9
|
+
/** Agent-control children announce their own thread on the shared app-server.
|
|
10
|
+
* They do not replace the parent TUI thread: only a top-level thread switch is
|
|
11
|
+
* a Session rotation boundary. */
|
|
12
|
+
function isSubagentThreadStarted(params) {
|
|
13
|
+
const source = params?.thread?.source?.subAgent?.thread_spawn;
|
|
14
|
+
return source !== null && typeof source === "object" && !Array.isArray(source);
|
|
15
|
+
}
|
|
16
|
+
/** Internal system threads are non-persistable and never replace the parent
|
|
17
|
+
* TUI thread, even though the app-server broadcasts `thread/started`. */
|
|
18
|
+
function isEphemeralThreadStarted(params) {
|
|
19
|
+
return params?.thread?.ephemeral === true;
|
|
20
|
+
}
|
|
7
21
|
function turnIdFrom(params) {
|
|
8
22
|
const p = params;
|
|
9
23
|
return p?.turnId ?? p?.turn?.id;
|
|
@@ -11,14 +25,7 @@ function turnIdFrom(params) {
|
|
|
11
25
|
function userMessageContent(item) {
|
|
12
26
|
if (item.type !== "userMessage")
|
|
13
27
|
return undefined;
|
|
14
|
-
|
|
15
|
-
if (parts.length === 0)
|
|
16
|
-
return undefined;
|
|
17
|
-
if (parts.every((part) => part.type === "input_text")) {
|
|
18
|
-
const text = parts.map((part) => part.text).join("").trim();
|
|
19
|
-
return text || undefined;
|
|
20
|
-
}
|
|
21
|
-
return parts;
|
|
28
|
+
return codexUserEchoContent(item.content);
|
|
22
29
|
}
|
|
23
30
|
/** Whether a notification implies the thread is now active (its first turn has
|
|
24
31
|
* begun, so the rollout exists). Mirrors reference implementation's `_event_indicates_thread_active`. */
|
|
@@ -31,6 +38,46 @@ function indicatesActive(method, params) {
|
|
|
31
38
|
}
|
|
32
39
|
return false;
|
|
33
40
|
}
|
|
41
|
+
function isThreadIdle(method, params) {
|
|
42
|
+
if (method !== "thread/status/changed")
|
|
43
|
+
return false;
|
|
44
|
+
const status = params?.status;
|
|
45
|
+
return status === "idle" || (typeof status === "object" && status?.type === "idle");
|
|
46
|
+
}
|
|
47
|
+
function isModelOutput(method, params) {
|
|
48
|
+
if (method !== "item/started" && method !== "item/completed")
|
|
49
|
+
return false;
|
|
50
|
+
const item = params?.item;
|
|
51
|
+
return Boolean(item?.type && item.type !== "userMessage");
|
|
52
|
+
}
|
|
53
|
+
function isTerminalTurnMethod(method) {
|
|
54
|
+
return method === "turn/completed" || method === "turn/failed";
|
|
55
|
+
}
|
|
56
|
+
/** Turn-scoped notifications must never be reassigned to whichever newer turn
|
|
57
|
+
* happens to be current when an app-server frame arrives late. */
|
|
58
|
+
function isTurnScopedMethod(method) {
|
|
59
|
+
return method.startsWith("turn/") || method.startsWith("item/") ||
|
|
60
|
+
method === "queue/status" || method === "error";
|
|
61
|
+
}
|
|
62
|
+
/** Recover a missed turn/started only from visible, fully-scoped assistant/plan
|
|
63
|
+
* deltas. Other deltas require an already matching active turn. */
|
|
64
|
+
function canRecoverMissedTurnFrom(method) {
|
|
65
|
+
return method === "item/agentMessage/delta" || method === "item/plan/delta";
|
|
66
|
+
}
|
|
67
|
+
/** Live deltas require the bridge's exact active Codex turn. Assistant/plan
|
|
68
|
+
* deltas may recover a missed `turn/started` when
|
|
69
|
+
* fully scoped; command output never opens or recovers a Turn. */
|
|
70
|
+
function requiresMatchingActiveTurn(method) {
|
|
71
|
+
return canRecoverMissedTurnFrom(method) || method === "item/commandExecution/outputDelta";
|
|
72
|
+
}
|
|
73
|
+
/** Turn content that remains valid independently of lifecycle status. */
|
|
74
|
+
function isIndependentTurnContent(method) {
|
|
75
|
+
return method === "item/started" ||
|
|
76
|
+
method === "item/reasoning/textDelta" ||
|
|
77
|
+
method === "item/reasoning/summaryTextDelta" ||
|
|
78
|
+
method === "item/fileChange/patchUpdated" ||
|
|
79
|
+
method === "turn/plan/updated";
|
|
80
|
+
}
|
|
34
81
|
export class CodexSessionForwarder {
|
|
35
82
|
client;
|
|
36
83
|
sink;
|
|
@@ -41,9 +88,16 @@ export class CodexSessionForwarder {
|
|
|
41
88
|
currentThreadIdValue = null;
|
|
42
89
|
activeSignaled = false;
|
|
43
90
|
completionTimer = null;
|
|
91
|
+
/** Turn id retained only for late-item dedup while a terminal response waits
|
|
92
|
+
* for its bounded output-ordering grace. It is not an active provider turn. */
|
|
93
|
+
pendingCompletionTurnId = null;
|
|
44
94
|
assistantMessageTimer = null;
|
|
45
95
|
deferredAssistantMessage = null;
|
|
46
96
|
pendingCompletion = null;
|
|
97
|
+
pendingMcpServers = new Set();
|
|
98
|
+
failedMcpServers = new Map();
|
|
99
|
+
mcpStartupTimer = null;
|
|
100
|
+
lastMcpStatusNote = null;
|
|
47
101
|
/** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
|
|
48
102
|
* `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
|
|
49
103
|
* counter. Mirrors reference implementation `_completed_item_key` + `synced_item_keys`. */
|
|
@@ -51,6 +105,11 @@ export class CodexSessionForwarder {
|
|
|
51
105
|
/** Per-(thread,turn) position counter for items lacking a stable codex id
|
|
52
106
|
* (peek-then-advance; advanced only on a successful claim). reference implementation anon path. */
|
|
53
107
|
anonCounters = new Map();
|
|
108
|
+
pendingPlanImplementation = null;
|
|
109
|
+
/** Latest aggregate diff per Turn. Codex republishes the complete diff after
|
|
110
|
+
* every edit; only the terminal snapshot belongs in transcript history. */
|
|
111
|
+
turnDiffByTurn = new Map();
|
|
112
|
+
replayingBackfill = false;
|
|
54
113
|
constructor(client, sink, options = {}) {
|
|
55
114
|
this.client = client;
|
|
56
115
|
this.sink = sink;
|
|
@@ -61,31 +120,111 @@ export class CodexSessionForwarder {
|
|
|
61
120
|
if (this.unsubscribe)
|
|
62
121
|
return;
|
|
63
122
|
this.unsubscribe = this.client.onNotification((method, params) => {
|
|
64
|
-
|
|
123
|
+
try {
|
|
124
|
+
this.handle(method, params);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
// One malformed or unsupported notification must not detach the
|
|
128
|
+
// long-lived observer from every later event in this session.
|
|
129
|
+
console.error(`[codex-forwarder] notification handler failed for ${method}; continuing:`, error);
|
|
130
|
+
}
|
|
65
131
|
});
|
|
132
|
+
const startup = this.options.mcpStartup;
|
|
133
|
+
if (startup?.servers.length) {
|
|
134
|
+
for (const server of startup.servers)
|
|
135
|
+
this.pendingMcpServers.add(server);
|
|
136
|
+
this.mcpStartupTimer = setTimeout(() => this.settleMcpStartup(), startup.settleTimeoutMs);
|
|
137
|
+
this.mcpStartupTimer.unref?.();
|
|
138
|
+
}
|
|
66
139
|
}
|
|
67
140
|
stop() {
|
|
68
141
|
this.unsubscribe?.();
|
|
69
142
|
this.unsubscribe = null;
|
|
143
|
+
this.clearMcpStartupTimer();
|
|
70
144
|
this.flushPendingCompletion();
|
|
71
145
|
this.flushDeferredAssistantMessage();
|
|
72
146
|
if (this.turnOpen) {
|
|
73
147
|
this.turnOpen = false;
|
|
148
|
+
this.currentTurnIdValue = null;
|
|
74
149
|
this.sink.onTurnEnd();
|
|
75
150
|
}
|
|
151
|
+
else {
|
|
152
|
+
this.currentTurnIdValue = null;
|
|
153
|
+
}
|
|
154
|
+
this.turnDiffByTurn.clear();
|
|
76
155
|
}
|
|
77
|
-
/** True while
|
|
156
|
+
/** True while the provider owns an active turn, including the short interval
|
|
157
|
+
* between injection acceptance and observer confirmation. */
|
|
78
158
|
isTurnOpen() {
|
|
79
|
-
return this.turnOpen;
|
|
159
|
+
return this.turnOpen || this.currentTurnIdValue !== null;
|
|
80
160
|
}
|
|
81
161
|
/** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
|
|
82
162
|
currentTurnId() {
|
|
83
163
|
return this.currentTurnIdValue;
|
|
84
164
|
}
|
|
165
|
+
/** Reconcile a proven stale steer without clearing a newer observed turn. */
|
|
166
|
+
clearActiveTurnIfMatches(turnId) {
|
|
167
|
+
if (this.currentTurnIdValue !== turnId)
|
|
168
|
+
return;
|
|
169
|
+
this.flushPendingCompletion();
|
|
170
|
+
this.flushDeferredAssistantMessage();
|
|
171
|
+
this.currentTurnIdValue = null;
|
|
172
|
+
this.turnOpen = false;
|
|
173
|
+
this.sink.onTurnInactive?.(turnId);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Record a turn accepted by the injection connection before the independent
|
|
177
|
+
* observer receives `turn/started`. This closes the read-decide-RPC-write race:
|
|
178
|
+
* another web message arriving in that window must steer this turn, not start
|
|
179
|
+
* a second one.
|
|
180
|
+
*/
|
|
181
|
+
noteTurnAccepted(turnId) {
|
|
182
|
+
if (!turnId)
|
|
183
|
+
return;
|
|
184
|
+
if (this.pendingCompletion)
|
|
185
|
+
this.flushPendingCompletion();
|
|
186
|
+
this.currentTurnIdValue = turnId;
|
|
187
|
+
this.emitMcpStartupStatus();
|
|
188
|
+
}
|
|
189
|
+
hasPendingMcpStartup() {
|
|
190
|
+
return this.pendingMcpServers.size > 0;
|
|
191
|
+
}
|
|
192
|
+
/** Mark and return the startup servers cancelled by a web Stop. */
|
|
193
|
+
cancelMcpStartup() {
|
|
194
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
195
|
+
if (pending.length === 0)
|
|
196
|
+
return pending;
|
|
197
|
+
this.pendingMcpServers.clear();
|
|
198
|
+
this.clearMcpStartupTimer();
|
|
199
|
+
if (this.isTurnOpen()) {
|
|
200
|
+
this.emitMcpStatus(`MCP startup cancelled: ${pending.join(", ")}`);
|
|
201
|
+
}
|
|
202
|
+
return pending;
|
|
203
|
+
}
|
|
204
|
+
/** Diagnostic suffix for an injection failure during Provider startup. */
|
|
205
|
+
mcpStartupDetail() {
|
|
206
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
207
|
+
if (pending.length)
|
|
208
|
+
return `MCP startup still waiting on ${pending.join(", ")}`;
|
|
209
|
+
const failed = [...this.failedMcpServers.entries()]
|
|
210
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
211
|
+
.map(([name, error]) => error ? `${name}: ${error}` : name);
|
|
212
|
+
return failed.length ? `MCP startup failed for ${failed.join(", ")}` : null;
|
|
213
|
+
}
|
|
85
214
|
/** The bound codex thread id captured from `thread/started` (null until then). */
|
|
86
215
|
threadId() {
|
|
87
216
|
return this.currentThreadIdValue;
|
|
88
217
|
}
|
|
218
|
+
/** Seed an already-persisted/resumed thread binding. The bridge retains this
|
|
219
|
+
* state even when app-server does not rebroadcast `thread/started`, so
|
|
220
|
+
* terminal-boundary recovery must know it too. */
|
|
221
|
+
noteThreadBound(threadId) {
|
|
222
|
+
if (!threadId || this.currentThreadIdValue === threadId)
|
|
223
|
+
return;
|
|
224
|
+
if (this.currentThreadIdValue !== null || this.turnOpen)
|
|
225
|
+
return;
|
|
226
|
+
this.currentThreadIdValue = threadId;
|
|
227
|
+
}
|
|
89
228
|
/**
|
|
90
229
|
* Replay the backlog turns from a `thread/resume` response as if they were live
|
|
91
230
|
* `item/completed` notifications — the fresh-thread first-turn backfill. Each
|
|
@@ -94,25 +233,52 @@ export class CodexSessionForwarder {
|
|
|
94
233
|
* not doubled.
|
|
95
234
|
*/
|
|
96
235
|
replayBackfill(turns) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
|
|
111
|
-
|
|
236
|
+
this.replayingBackfill = true;
|
|
237
|
+
try {
|
|
238
|
+
for (const turn of turns) {
|
|
239
|
+
const turnId = turn.id ?? turn.turnId;
|
|
240
|
+
if (turnId)
|
|
241
|
+
this.currentTurnIdValue = turnId;
|
|
242
|
+
this.ensureTurn();
|
|
243
|
+
for (const item of turn.items ?? [])
|
|
244
|
+
this.processCompletedItem(item);
|
|
245
|
+
if (codexResumeTerminalStatus(turn) === undefined)
|
|
246
|
+
continue;
|
|
247
|
+
const mapped = mapCodexNotification("turn/completed", { turn });
|
|
248
|
+
this.turnOpen = false;
|
|
249
|
+
this.currentTurnIdValue = null;
|
|
250
|
+
if (mapped.fatalError)
|
|
251
|
+
this.sink.onTurnError(mapped.fatalError);
|
|
252
|
+
else
|
|
253
|
+
this.sink.onTurnEnd(mapped.usage);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
this.replayingBackfill = false;
|
|
112
258
|
}
|
|
113
259
|
}
|
|
260
|
+
/** Suppress our synthesized picker when a future app-server emits the native
|
|
261
|
+
* `plan_implementation` request itself. */
|
|
262
|
+
noteNativePlanImplementationPrompt(turnId) {
|
|
263
|
+
if (!this.pendingPlanImplementation)
|
|
264
|
+
return;
|
|
265
|
+
if (!turnId || this.pendingPlanImplementation.turnId === turnId) {
|
|
266
|
+
this.pendingPlanImplementation = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** Fail an open response exactly once when its observer or terminal exits. */
|
|
270
|
+
failOpenTurn(error) {
|
|
271
|
+
if (!this.isTurnOpen())
|
|
272
|
+
return false;
|
|
273
|
+
this.ensureTurn();
|
|
274
|
+
this.settle({ kind: "error", error });
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
114
277
|
handle(method, params) {
|
|
115
278
|
if (method === "thread/started" || method === "thread.started") {
|
|
279
|
+
if (method === "thread/started" &&
|
|
280
|
+
(isSubagentThreadStarted(params) || isEphemeralThreadStarted(params)))
|
|
281
|
+
return;
|
|
116
282
|
const tid = threadIdFrom(params);
|
|
117
283
|
if (tid && tid !== this.currentThreadIdValue) {
|
|
118
284
|
const forkedFromId = params?.thread?.forkedFromId;
|
|
@@ -128,6 +294,7 @@ export class CodexSessionForwarder {
|
|
|
128
294
|
}
|
|
129
295
|
this.currentTurnIdValue = null;
|
|
130
296
|
this.activeSignaled = false;
|
|
297
|
+
this.turnDiffByTurn.clear();
|
|
131
298
|
this.currentThreadIdValue = tid;
|
|
132
299
|
this.sink.onThreadStarted?.(tid, normalizedForkedFromId);
|
|
133
300
|
}
|
|
@@ -138,6 +305,33 @@ export class CodexSessionForwarder {
|
|
|
138
305
|
notificationThreadId &&
|
|
139
306
|
notificationThreadId !== this.currentThreadIdValue)
|
|
140
307
|
return;
|
|
308
|
+
if (method === MCP_STARTUP_STATUS_METHOD) {
|
|
309
|
+
this.handleMcpStartupStatus(params);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (method === "thread/settings/updated") {
|
|
313
|
+
const threadSettings = params?.threadSettings;
|
|
314
|
+
const model = typeof threadSettings?.model === "string" && threadSettings.model.trim()
|
|
315
|
+
? threadSettings.model.trim()
|
|
316
|
+
: undefined;
|
|
317
|
+
const effort = threadSettings?.effort;
|
|
318
|
+
if (model !== undefined || effort === null || typeof effort === "string") {
|
|
319
|
+
this.sink.onThreadSettingsChanged?.({
|
|
320
|
+
...(model === undefined ? {} : { model }),
|
|
321
|
+
...(effort === null || typeof effort === "string"
|
|
322
|
+
? { reasoningEffort: effort }
|
|
323
|
+
: {}),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const mode = (threadSettings?.collaborationMode ?? threadSettings?.collaboration_mode)?.mode;
|
|
327
|
+
if (mode === "plan" || mode === "default") {
|
|
328
|
+
this.sink.onCollaborationModeChanged?.(mode);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (isThreadIdle(method, params) || isModelOutput(method, params)) {
|
|
333
|
+
this.settleMcpStartup();
|
|
334
|
+
}
|
|
141
335
|
// Release a parked resume as soon as the thread shows activity (rollout now
|
|
142
336
|
// exists). Fire once.
|
|
143
337
|
if (!this.activeSignaled && indicatesActive(method, params)) {
|
|
@@ -148,33 +342,147 @@ export class CodexSessionForwarder {
|
|
|
148
342
|
// behavior unchanged unless the Traex host explicitly opts in.
|
|
149
343
|
if (method === "queue/status" && !this.options.surfaceQueueStatus)
|
|
150
344
|
return;
|
|
345
|
+
if (method === "queue/status" && this.currentTurnIdValue === null)
|
|
346
|
+
return;
|
|
151
347
|
const carriedTurnId = turnIdFrom(params);
|
|
348
|
+
if (method === "turn/diff/updated") {
|
|
349
|
+
if (!carriedTurnId)
|
|
350
|
+
return;
|
|
351
|
+
const update = params;
|
|
352
|
+
const diff = typeof update?.diff === "string"
|
|
353
|
+
? update.diff
|
|
354
|
+
: typeof update?.unifiedDiff === "string"
|
|
355
|
+
? update.unifiedDiff
|
|
356
|
+
: "";
|
|
357
|
+
if (diff)
|
|
358
|
+
this.turnDiffByTurn.set(carriedTurnId, diff);
|
|
359
|
+
else
|
|
360
|
+
this.turnDiffByTurn.delete(carriedTurnId);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
152
363
|
if (method === "turn/started") {
|
|
153
|
-
this.
|
|
154
|
-
this.
|
|
155
|
-
|
|
364
|
+
this.beginTurn(carriedTurnId);
|
|
365
|
+
this.sink.onTurnObserved?.(carriedTurnId);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (isTerminalTurnMethod(method)) {
|
|
369
|
+
if (!this.terminalBoundaryMatchesActiveTurn(carriedTurnId, notificationThreadId)) {
|
|
370
|
+
const staleDiff = this.consumeTurnDiff(carriedTurnId);
|
|
371
|
+
if (staleDiff && carriedTurnId) {
|
|
372
|
+
this.sink.onTurnContentEvent?.(carriedTurnId, staleDiff);
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const hadActiveTurn = this.currentTurnIdValue !== null;
|
|
377
|
+
const hadOpenResponse = this.turnOpen;
|
|
378
|
+
const mapped = mapCodexNotification(method, params);
|
|
379
|
+
const terminalTurnId = carriedTurnId ?? this.currentTurnIdValue;
|
|
380
|
+
const turnDiff = this.consumeTurnDiff(terminalTurnId);
|
|
381
|
+
if (!hadActiveTurn && !hadOpenResponse) {
|
|
382
|
+
if (turnDiff && terminalTurnId) {
|
|
383
|
+
this.sink.onTurnContentEvent?.(terminalTurnId, turnDiff);
|
|
384
|
+
}
|
|
385
|
+
const turn = params?.turn;
|
|
386
|
+
const recoveredStatus = mapped.fatalError ||
|
|
387
|
+
method === "turn/failed" ||
|
|
388
|
+
codexResumeTerminalStatus(turn) === "failed"
|
|
389
|
+
? "failed"
|
|
390
|
+
: "idle";
|
|
391
|
+
this.sink.onRecoveredTurnStatus?.(recoveredStatus, carriedTurnId, mapped.fatalError);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (carriedTurnId && this.currentTurnIdValue === null) {
|
|
156
395
|
this.currentTurnIdValue = carriedTurnId;
|
|
396
|
+
}
|
|
157
397
|
this.ensureTurn();
|
|
398
|
+
this.pendingCompletionTurnId = carriedTurnId ?? this.currentTurnIdValue;
|
|
399
|
+
// The provider lifecycle closes immediately. A configured grace keeps
|
|
400
|
+
// only the canonical response open long enough to absorb late items.
|
|
401
|
+
this.currentTurnIdValue = null;
|
|
402
|
+
for (const event of mapped.events.filter((event) => event.type !== "runtime_debug")) {
|
|
403
|
+
this.sink.onEvent(event);
|
|
404
|
+
}
|
|
405
|
+
this.scheduleCompletion(mapped.fatalError
|
|
406
|
+
? { kind: "error", error: mapped.fatalError, ...(turnDiff ? { turnDiff } : {}) }
|
|
407
|
+
: mapped.turnInterrupted
|
|
408
|
+
? {
|
|
409
|
+
kind: "interrupted",
|
|
410
|
+
...(mapped.usage ? { usage: mapped.usage } : {}),
|
|
411
|
+
...(turnDiff ? { turnDiff } : {}),
|
|
412
|
+
}
|
|
413
|
+
: {
|
|
414
|
+
kind: "end",
|
|
415
|
+
...(mapped.usage ? { usage: mapped.usage } : {}),
|
|
416
|
+
...(turnDiff ? { turnDiff } : {}),
|
|
417
|
+
});
|
|
158
418
|
return;
|
|
159
419
|
}
|
|
160
|
-
|
|
161
|
-
|
|
420
|
+
// A late event from an older turn must not replace the app-server's active
|
|
421
|
+
// turn id. That was the source of stale completions closing a newer turn.
|
|
422
|
+
if (carriedTurnId &&
|
|
423
|
+
this.currentTurnIdValue &&
|
|
424
|
+
carriedTurnId !== this.currentTurnIdValue &&
|
|
425
|
+
isTurnScopedMethod(method)) {
|
|
426
|
+
// Durable completed items and passive content belong to their carried
|
|
427
|
+
// response even if a newer Turn is active. Only active-turn deltas and
|
|
428
|
+
// lifecycle edges are stale with respect to the newer Turn.
|
|
429
|
+
if (method === "item/completed") {
|
|
430
|
+
// Continue into the completed-item dedup/order path below.
|
|
431
|
+
}
|
|
432
|
+
else if (isIndependentTurnContent(method) && this.sink.onTurnContentEvent) {
|
|
433
|
+
const mapped = mapCodexNotification(method, params);
|
|
434
|
+
for (const event of mapped.events) {
|
|
435
|
+
if (event.type !== "runtime_debug") {
|
|
436
|
+
this.sink.onTurnContentEvent(carriedTurnId, event);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (requiresMatchingActiveTurn(method)) {
|
|
446
|
+
if (this.currentTurnIdValue !== null) {
|
|
447
|
+
// An id-less delta is not attributable to the active Turn. Require
|
|
448
|
+
// the exact active Turn id before forwarding it.
|
|
449
|
+
if (carriedTurnId !== this.currentTurnIdValue)
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
else if (carriedTurnId &&
|
|
453
|
+
canRecoverMissedTurnFrom(method) &&
|
|
454
|
+
this.notificationMatchesCurrentThread(notificationThreadId)) {
|
|
455
|
+
this.currentTurnIdValue = carriedTurnId;
|
|
456
|
+
this.ensureTurn();
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
162
462
|
// Completed items (user echo, assistant, tool, reasoning) go through the
|
|
163
463
|
// deduped path so a resume backfill and the live stream never double them.
|
|
164
464
|
if (method === "item/completed") {
|
|
165
465
|
const item = params?.item;
|
|
166
466
|
if (item) {
|
|
467
|
+
if (carriedTurnId &&
|
|
468
|
+
this.currentTurnIdValue &&
|
|
469
|
+
carriedTurnId !== this.currentTurnIdValue) {
|
|
470
|
+
// This is durable content for an older response, not an ordering
|
|
471
|
+
// signal for the newer Turn's deferred assistant message.
|
|
472
|
+
this.processCompletedItem(item, carriedTurnId);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
167
475
|
if (this.shouldDeferAssistantMessage(item)) {
|
|
168
|
-
this.deferAssistantMessage(item);
|
|
476
|
+
this.deferAssistantMessage(item, carriedTurnId);
|
|
169
477
|
this.refreshCompletionGrace();
|
|
170
478
|
return;
|
|
171
479
|
}
|
|
172
|
-
//
|
|
173
|
-
// completed item establishes that the held message
|
|
174
|
-
// must retain its original position.
|
|
480
|
+
// Within one Turn, a late reasoning item belongs before the held final
|
|
481
|
+
// answer. Any other completed item establishes that the held message
|
|
482
|
+
// was not final and must retain its original position.
|
|
175
483
|
if (item.type !== "reasoning")
|
|
176
484
|
this.flushDeferredAssistantMessage();
|
|
177
|
-
this.processCompletedItem(item);
|
|
485
|
+
this.processCompletedItem(item, carriedTurnId);
|
|
178
486
|
this.refreshCompletionGrace();
|
|
179
487
|
return;
|
|
180
488
|
}
|
|
@@ -189,6 +497,19 @@ export class CodexSessionForwarder {
|
|
|
189
497
|
if (canonicalEvents.some((event) => event.type !== "reasoning_delta" && event.type !== "reasoning_completed")) {
|
|
190
498
|
this.flushDeferredAssistantMessage();
|
|
191
499
|
}
|
|
500
|
+
// Item/content delivery is independent from turn lifecycle status. A
|
|
501
|
+
// scoped item or reasoning delta that arrives after the terminal
|
|
502
|
+
// edge remains visible, but must not synthesize another running response.
|
|
503
|
+
if (!this.turnOpen &&
|
|
504
|
+
this.currentTurnIdValue === null &&
|
|
505
|
+
!mapped.fatalError &&
|
|
506
|
+
canonicalEvents.length > 0 &&
|
|
507
|
+
this.sink.onTurnContentEvent) {
|
|
508
|
+
for (const event of canonicalEvents) {
|
|
509
|
+
this.sink.onTurnContentEvent(carriedTurnId, event);
|
|
510
|
+
}
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
192
513
|
if (canonicalEvents.length || mapped.turnCompleted || mapped.fatalError) {
|
|
193
514
|
this.ensureTurn();
|
|
194
515
|
}
|
|
@@ -200,7 +521,9 @@ export class CodexSessionForwarder {
|
|
|
200
521
|
return;
|
|
201
522
|
}
|
|
202
523
|
if (mapped.turnCompleted) {
|
|
203
|
-
this.scheduleCompletion(
|
|
524
|
+
this.scheduleCompletion(mapped.turnInterrupted
|
|
525
|
+
? { kind: "interrupted", ...(mapped.usage ? { usage: mapped.usage } : {}) }
|
|
526
|
+
: { kind: "end", ...(mapped.usage ? { usage: mapped.usage } : {}) });
|
|
204
527
|
}
|
|
205
528
|
else {
|
|
206
529
|
this.refreshCompletionGrace();
|
|
@@ -237,39 +560,150 @@ export class CodexSessionForwarder {
|
|
|
237
560
|
}
|
|
238
561
|
settle(completion) {
|
|
239
562
|
this.flushDeferredAssistantMessage();
|
|
563
|
+
const completedTurnId = this.pendingCompletionTurnId ?? this.currentTurnIdValue;
|
|
564
|
+
const planPrompt = completion.kind === "end" &&
|
|
565
|
+
this.pendingPlanImplementation?.turnId === completedTurnId
|
|
566
|
+
? this.pendingPlanImplementation
|
|
567
|
+
: null;
|
|
568
|
+
if (this.pendingPlanImplementation?.turnId === completedTurnId) {
|
|
569
|
+
this.pendingPlanImplementation = null;
|
|
570
|
+
}
|
|
571
|
+
if (completion.turnDiff)
|
|
572
|
+
this.sink.onEvent(completion.turnDiff);
|
|
573
|
+
if (completedTurnId)
|
|
574
|
+
this.turnDiffByTurn.delete(completedTurnId);
|
|
240
575
|
this.turnOpen = false;
|
|
576
|
+
this.currentTurnIdValue = null;
|
|
577
|
+
this.pendingCompletionTurnId = null;
|
|
241
578
|
if (completion.kind === "error")
|
|
242
579
|
this.sink.onTurnError(completion.error);
|
|
243
|
-
else
|
|
580
|
+
else if (completion.kind === "interrupted") {
|
|
581
|
+
if (this.sink.onTurnInterrupted)
|
|
582
|
+
this.sink.onTurnInterrupted(completion.usage);
|
|
583
|
+
else
|
|
584
|
+
this.sink.onTurnEnd(completion.usage);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
244
587
|
this.sink.onTurnEnd(completion.usage);
|
|
588
|
+
if (planPrompt)
|
|
589
|
+
this.sink.onPlanImplementationPrompt?.(planPrompt);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
handleMcpStartupStatus(params) {
|
|
593
|
+
const update = params;
|
|
594
|
+
const name = typeof update?.name === "string" ? update.name : "";
|
|
595
|
+
const status = typeof update?.status === "string" ? update.status : "";
|
|
596
|
+
if (!name || (status !== "starting" && !MCP_TERMINAL_STATES.has(status)))
|
|
597
|
+
return;
|
|
598
|
+
if (status === "starting") {
|
|
599
|
+
this.pendingMcpServers.add(name);
|
|
600
|
+
this.failedMcpServers.delete(name);
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
this.pendingMcpServers.delete(name);
|
|
604
|
+
if (status === "failed") {
|
|
605
|
+
this.failedMcpServers.set(name, typeof update?.error === "string" && update.error ? update.error : undefined);
|
|
606
|
+
}
|
|
607
|
+
else {
|
|
608
|
+
this.failedMcpServers.delete(name);
|
|
609
|
+
}
|
|
610
|
+
if (this.pendingMcpServers.size === 0)
|
|
611
|
+
this.clearMcpStartupTimer();
|
|
612
|
+
}
|
|
613
|
+
this.emitMcpStartupStatus();
|
|
614
|
+
}
|
|
615
|
+
settleMcpStartup() {
|
|
616
|
+
if (this.pendingMcpServers.size === 0) {
|
|
617
|
+
this.clearMcpStartupTimer();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
this.pendingMcpServers.clear();
|
|
621
|
+
this.clearMcpStartupTimer();
|
|
622
|
+
this.emitMcpStartupStatus();
|
|
623
|
+
}
|
|
624
|
+
clearMcpStartupTimer() {
|
|
625
|
+
if (this.mcpStartupTimer)
|
|
626
|
+
clearTimeout(this.mcpStartupTimer);
|
|
627
|
+
this.mcpStartupTimer = null;
|
|
628
|
+
}
|
|
629
|
+
emitMcpStartupStatus() {
|
|
630
|
+
if (!this.isTurnOpen())
|
|
631
|
+
return;
|
|
632
|
+
const pending = [...this.pendingMcpServers].sort();
|
|
633
|
+
if (pending.length) {
|
|
634
|
+
this.emitMcpStatus(`Starting MCP servers: ${pending.join(", ")}`);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const failed = [...this.failedMcpServers.keys()].sort();
|
|
638
|
+
if (failed.length) {
|
|
639
|
+
this.emitMcpStatus(`MCP startup failed: ${failed.join(", ")}`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (this.lastMcpStatusNote !== null) {
|
|
643
|
+
this.lastMcpStatusNote = null;
|
|
644
|
+
if (this.sink.onStatus)
|
|
645
|
+
this.sink.onStatus(undefined);
|
|
646
|
+
else
|
|
647
|
+
this.sink.onEvent({ type: "status" });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
emitMcpStatus(note) {
|
|
651
|
+
if (this.lastMcpStatusNote === note)
|
|
652
|
+
return;
|
|
653
|
+
this.lastMcpStatusNote = note;
|
|
654
|
+
if (this.sink.onStatus)
|
|
655
|
+
this.sink.onStatus(note, "startup");
|
|
656
|
+
else
|
|
657
|
+
this.sink.onEvent({ type: "status", statusKind: "startup", note });
|
|
245
658
|
}
|
|
246
659
|
/** Map + emit one completed codex item, deduped by a TOTAL key and routing the
|
|
247
660
|
* user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
|
|
248
|
-
processCompletedItem(item) {
|
|
249
|
-
if (!this.claimCompletedItem(item))
|
|
661
|
+
processCompletedItem(item, completedTurnId) {
|
|
662
|
+
if (!this.claimCompletedItem(item, completedTurnId))
|
|
250
663
|
return;
|
|
251
|
-
this.emitCompletedItem(item);
|
|
664
|
+
this.emitCompletedItem(item, completedTurnId);
|
|
252
665
|
}
|
|
253
|
-
claimCompletedItem(item) {
|
|
254
|
-
const { key, isAnon } = this.completedItemKey(item);
|
|
666
|
+
claimCompletedItem(item, completedTurnId) {
|
|
667
|
+
const { key, isAnon } = this.completedItemKey(item, completedTurnId);
|
|
255
668
|
if (this.seenCompletedItems.has(key))
|
|
256
669
|
return false;
|
|
257
670
|
this.seenCompletedItems.add(key);
|
|
258
671
|
if (isAnon)
|
|
259
|
-
this.advanceAnonCounter();
|
|
672
|
+
this.advanceAnonCounter(completedTurnId);
|
|
260
673
|
return true;
|
|
261
674
|
}
|
|
262
|
-
emitCompletedItem(item) {
|
|
675
|
+
emitCompletedItem(item, completedTurnId) {
|
|
676
|
+
const outsideTurnLifecycle = !this.turnOpen || Boolean(completedTurnId &&
|
|
677
|
+
this.currentTurnIdValue &&
|
|
678
|
+
completedTurnId !== this.currentTurnIdValue);
|
|
263
679
|
const userContent = userMessageContent(item);
|
|
264
680
|
if (userContent !== undefined) {
|
|
681
|
+
if (outsideTurnLifecycle && this.sink.onTurnContentUserMessage) {
|
|
682
|
+
this.sink.onTurnContentUserMessage(completedTurnId, userContent);
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
265
685
|
this.ensureTurn();
|
|
266
686
|
this.sink.onUserMessage?.(userContent);
|
|
267
687
|
return;
|
|
268
688
|
}
|
|
689
|
+
if (item.type === "plan" && !this.replayingBackfill) {
|
|
690
|
+
const text = item.text?.trim();
|
|
691
|
+
const turnId = this.currentTurnIdValue ?? this.pendingCompletionTurnId;
|
|
692
|
+
const threadId = this.currentThreadIdValue;
|
|
693
|
+
if (text && turnId && threadId) {
|
|
694
|
+
this.pendingPlanImplementation = { threadId, turnId, text };
|
|
695
|
+
}
|
|
696
|
+
}
|
|
269
697
|
const mapped = mapCodexItem("item/completed", item);
|
|
270
698
|
const canonicalEvents = mapped.events.filter((event) => event.type !== "runtime_debug");
|
|
271
699
|
if (canonicalEvents.length === 0)
|
|
272
700
|
return;
|
|
701
|
+
if (outsideTurnLifecycle && this.sink.onTurnContentEvent) {
|
|
702
|
+
for (const event of canonicalEvents) {
|
|
703
|
+
this.sink.onTurnContentEvent(completedTurnId, event);
|
|
704
|
+
}
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
273
707
|
this.ensureTurn();
|
|
274
708
|
for (const event of canonicalEvents)
|
|
275
709
|
this.sink.onEvent(event);
|
|
@@ -280,11 +714,11 @@ export class CodexSessionForwarder {
|
|
|
280
714
|
}
|
|
281
715
|
return Boolean(item.text?.trim());
|
|
282
716
|
}
|
|
283
|
-
deferAssistantMessage(item) {
|
|
284
|
-
if (!this.claimCompletedItem(item))
|
|
717
|
+
deferAssistantMessage(item, turnId) {
|
|
718
|
+
if (!this.claimCompletedItem(item, turnId))
|
|
285
719
|
return;
|
|
286
720
|
this.flushDeferredAssistantMessage();
|
|
287
|
-
this.deferredAssistantMessage = item;
|
|
721
|
+
this.deferredAssistantMessage = { item, ...(turnId ? { turnId } : {}) };
|
|
288
722
|
const graceMs = this.options.assistantMessageGraceMs ?? 0;
|
|
289
723
|
this.assistantMessageTimer = setTimeout(() => this.flushDeferredAssistantMessage(), graceMs);
|
|
290
724
|
this.assistantMessageTimer.unref?.();
|
|
@@ -293,26 +727,27 @@ export class CodexSessionForwarder {
|
|
|
293
727
|
if (this.assistantMessageTimer)
|
|
294
728
|
clearTimeout(this.assistantMessageTimer);
|
|
295
729
|
this.assistantMessageTimer = null;
|
|
296
|
-
const
|
|
730
|
+
const deferred = this.deferredAssistantMessage;
|
|
297
731
|
this.deferredAssistantMessage = null;
|
|
298
|
-
if (
|
|
299
|
-
this.emitCompletedItem(item);
|
|
732
|
+
if (deferred)
|
|
733
|
+
this.emitCompletedItem(deferred.item, deferred.turnId);
|
|
300
734
|
}
|
|
301
735
|
/** Build a total dedup key for one completed item. Stable-id items use
|
|
302
736
|
* `threadId:turnId:item.id` — identical across replay + live, so the second
|
|
303
737
|
* delivery is dropped. Items without a codex id fall back to a per-(thread,turn)
|
|
304
738
|
* position counter (peeked here; advanced only on a successful claim, via
|
|
305
739
|
* {@link advanceAnonCounter}). Mirrors reference implementation `_completed_item_key`. */
|
|
306
|
-
completedItemKey(item) {
|
|
740
|
+
completedItemKey(item, completedTurnId) {
|
|
307
741
|
const threadId = this.currentThreadIdValue ?? "thread";
|
|
308
|
-
const turnId = this.currentTurnIdValue ?? "turn";
|
|
742
|
+
const turnId = completedTurnId ?? this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
|
|
309
743
|
if (item.id)
|
|
310
744
|
return { key: `${threadId}:${turnId}:${item.id}`, isAnon: false };
|
|
311
745
|
const n = this.anonCounters.get(`${threadId}:${turnId}`) ?? 0;
|
|
312
746
|
return { key: `${threadId}:${turnId}:anon:${n}`, isAnon: true };
|
|
313
747
|
}
|
|
314
|
-
advanceAnonCounter() {
|
|
315
|
-
const
|
|
748
|
+
advanceAnonCounter(completedTurnId) {
|
|
749
|
+
const turnId = completedTurnId ?? this.currentTurnIdValue ?? this.pendingCompletionTurnId ?? "turn";
|
|
750
|
+
const k = `${this.currentThreadIdValue ?? "thread"}:${turnId}`;
|
|
316
751
|
this.anonCounters.set(k, (this.anonCounters.get(k) ?? 0) + 1);
|
|
317
752
|
}
|
|
318
753
|
ensureTurn() {
|
|
@@ -321,4 +756,54 @@ export class CodexSessionForwarder {
|
|
|
321
756
|
this.sink.onTurnStart(this.currentTurnIdValue ?? undefined);
|
|
322
757
|
}
|
|
323
758
|
}
|
|
759
|
+
consumeTurnDiff(turnId) {
|
|
760
|
+
if (!turnId)
|
|
761
|
+
return undefined;
|
|
762
|
+
const diff = this.turnDiffByTurn.get(turnId);
|
|
763
|
+
this.turnDiffByTurn.delete(turnId);
|
|
764
|
+
return diff
|
|
765
|
+
? { type: "turn_diff", callId: `codex_turn_diff_${turnId}`, diff }
|
|
766
|
+
: undefined;
|
|
767
|
+
}
|
|
768
|
+
/** Start (or confirm) the app-server's authoritative active turn. A newer
|
|
769
|
+
* start supersedes an older response whose terminal edge arrived late; a
|
|
770
|
+
* pending Traex completion is flushed first so its final item grace remains
|
|
771
|
+
* intact. */
|
|
772
|
+
beginTurn(turnId) {
|
|
773
|
+
if (this.pendingCompletion)
|
|
774
|
+
this.flushPendingCompletion();
|
|
775
|
+
const nextTurnId = turnId ?? null;
|
|
776
|
+
const changesIdentifiedTurn = this.turnOpen &&
|
|
777
|
+
this.currentTurnIdValue !== null &&
|
|
778
|
+
nextTurnId !== null &&
|
|
779
|
+
this.currentTurnIdValue !== nextTurnId;
|
|
780
|
+
if (changesIdentifiedTurn) {
|
|
781
|
+
this.flushPendingCompletion();
|
|
782
|
+
this.flushDeferredAssistantMessage();
|
|
783
|
+
if (this.turnOpen) {
|
|
784
|
+
this.turnOpen = false;
|
|
785
|
+
this.sink.onTurnEnd(undefined, "superseded");
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
this.currentTurnIdValue = nextTurnId;
|
|
789
|
+
this.ensureTurn();
|
|
790
|
+
}
|
|
791
|
+
/** Active-turn clearing contract:
|
|
792
|
+
*
|
|
793
|
+
* - an identified active turn is closed only by the same id;
|
|
794
|
+
* - an id-less boundary cannot close an identified active turn;
|
|
795
|
+
* - with no observed active turn, an identified boundary may recover a
|
|
796
|
+
* missed start only when it carries the currently-bound thread id. */
|
|
797
|
+
terminalBoundaryMatchesActiveTurn(terminalTurnId, notificationThreadId) {
|
|
798
|
+
if (this.currentTurnIdValue !== null) {
|
|
799
|
+
return terminalTurnId === this.currentTurnIdValue;
|
|
800
|
+
}
|
|
801
|
+
if (terminalTurnId === undefined)
|
|
802
|
+
return true;
|
|
803
|
+
return this.notificationMatchesCurrentThread(notificationThreadId);
|
|
804
|
+
}
|
|
805
|
+
notificationMatchesCurrentThread(notificationThreadId) {
|
|
806
|
+
return this.currentThreadIdValue !== null &&
|
|
807
|
+
notificationThreadId === this.currentThreadIdValue;
|
|
808
|
+
}
|
|
324
809
|
}
|