@minhspark/codex-mcp-bridge 1.22.9 → 1.22.10
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/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/scripts/install-native-relay.mjs +1 -1
- package/src/claude-bridge.mjs +1 -1
- package/src/codex-native-response.mjs +56 -52
- package/src/codex-sender-context.mjs +16 -18
- package/src/index.mjs +1 -1
- package/src/native-relay-companion.mjs +1 -1
- package/src/rollout-segments.mjs +27 -0
- package/src/thread-delivery.mjs +24 -13
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [SemVer](ht
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.22.10] - 2026-10-20
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Re-read the whole rollout set when Codex appends between per-segment reads. The pre-send watermark, dispatch, turn and creation readers and Codex sender verification now repeat find, read and set check together, up to three times, instead of failing with `A rollout segment changed while reading`. Only read races qualify: identity, history, account, workspace and dispatch mismatches still fail at once.
|
|
12
|
+
- Keep observing an accepted Desktop send or creation while Codex is still writing its rollout. When a read keeps reporting that the rollout changed while reading, the wait polls again until its deadline and then returns the pending receipt with that reason, instead of reporting the delivery as failed. Seen live on 2026-10-20: an accepted send reported `failed` while the reply was later recovered from the same receipt. A rollout whose last record stays incomplete, for example after Codex stopped mid-write, is still reported at once with its turn status and reason.
|
|
13
|
+
|
|
14
|
+
### Verification
|
|
15
|
+
|
|
16
|
+
- 8 new regression tests reproduce the append during the set check for the watermark, the turn and dispatch reply readers, sender verification and both wait paths; all fail on v1.22.8. Two more pin that a permanently incomplete rollout is reported without waiting, and a unit test pins which messages are retried.
|
|
17
|
+
- Two rounds of independent review shaped the wait behavior. A deferred released turn stays the observed turn even if a newer turn starts. The deferral reason is cleared after a clean read, and a timeout reports it as an unavailable observation. Only the set level retries, so an actively written rollout is scanned at most three times per observation. Deferral is limited to `changed while reading`, because `incomplete final record` can be permanent. Each of these has a regression test that fails without its fix.
|
|
18
|
+
|
|
19
|
+
### Limitations
|
|
20
|
+
|
|
21
|
+
- The original race was seen once in a live Desktop send and cannot be triggered on demand; the regression tests reproduce it deterministically by appending to the rollout during the set check. A Codex Desktop or MCP restart still ends in-memory peer receipts, as before.
|
|
22
|
+
|
|
7
23
|
## [1.22.9] - 2026-10-20
|
|
8
24
|
|
|
9
25
|
### Fixed
|
package/package.json
CHANGED
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "codex-mcp-bridge",
|
|
4
|
-
"version": "1.22.
|
|
4
|
+
"version": "1.22.10",
|
|
5
5
|
"description": "Connect ChatGPT to your own Codex Desktop projects and conversations.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Bui Dang Minh",
|
|
@@ -24,7 +24,7 @@ import { stableInstallPath } from "../src/install-paths.mjs";
|
|
|
24
24
|
|
|
25
25
|
exitForVersionRequest(import.meta.url);
|
|
26
26
|
|
|
27
|
-
const VERSION = "1.22.
|
|
27
|
+
const VERSION = "1.22.10";
|
|
28
28
|
const root = stableInstallPath(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
|
|
29
29
|
const entry = path.join(root, "src", "mcp-supervisor.mjs");
|
|
30
30
|
const entryArgs = ["native-relay-companion.mjs"];
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { discoverClaudeProject } from "./claude-project-discovery.mjs";
|
|
|
30
30
|
|
|
31
31
|
exitForVersionRequest(import.meta.url);
|
|
32
32
|
|
|
33
|
-
const VERSION = "1.22.
|
|
33
|
+
const VERSION = "1.22.10";
|
|
34
34
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
35
35
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
36
36
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { scanRollout } from "./rollout-reader.mjs";
|
|
7
7
|
import { fileIdentity, sameFileIdentity } from "./file-identity.mjs";
|
|
8
|
-
import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
|
|
8
|
+
import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable, retryRolloutRead } from "./rollout-segments.mjs";
|
|
9
9
|
|
|
10
10
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
11
11
|
const MAX_ROLLOUT_BYTES = 16 * 1024 * 1024;
|
|
@@ -84,12 +84,15 @@ function samePathBoundary(expected, current) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function readStable(found, maxBytes, { threadId, turnId, afterOffset = 0, prefixSize, dispatchOutputs, dispatchAfterOffset = afterOffset } = {}) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
return readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readStableRolloutSet(threadId, env, readSegments) {
|
|
91
|
+
return retryRolloutRead(() => {
|
|
92
|
+
const snapshots = readSegments(findRollouts(threadId, env));
|
|
93
|
+
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
94
|
+
return snapshots;
|
|
95
|
+
});
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
function readStableOnce(found, maxBytes, { threadId, turnId, afterOffset, prefixSize, dispatchOutputs, dispatchAfterOffset }) {
|
|
@@ -346,8 +349,7 @@ function inspectTurnRecords(records, threadId, turnId, cwd, { requireDispatch, o
|
|
|
346
349
|
export function captureCodexRolloutWatermark({ threadId, expectedCwd, desktopEvidence }, { env = process.env, maxRolloutBytes = MAX_ROLLOUT_BYTES } = {}) {
|
|
347
350
|
try {
|
|
348
351
|
const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
|
|
349
|
-
const snapshots =
|
|
350
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
352
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => found.map((entry) => ({ ...entry, ...readStable(entry, maxRolloutBytes) })));
|
|
351
353
|
for (const snapshot of snapshots) validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
352
354
|
const matches = snapshots.length === 1 ? snapshots : snapshots.filter((snapshot) =>
|
|
353
355
|
desktopEvidence?.latestTurnId && snapshot.latestContext?.turn_id === desktopEvidence.latestTurnId);
|
|
@@ -369,15 +371,16 @@ export function readCodexNativeCreationResponse({ threadId, turnId, expectedCwd,
|
|
|
369
371
|
if (!UUID.test(threadId) || !UUID.test(turnId) || !UUID.test(executorThreadId)) throw new Error("The native creation response identity is invalid");
|
|
370
372
|
if (typeof prompt !== "string" || !prompt.length) throw new Error("The exact creation prompt is unavailable");
|
|
371
373
|
const cwd = canonicalDirectory(expectedCwd, "The created native task workspace");
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
374
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => {
|
|
375
|
+
let retainedBytes = 0;
|
|
376
|
+
return found.map((entry) => {
|
|
377
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId });
|
|
378
|
+
retainedBytes += snapshot.retainedBytes;
|
|
379
|
+
if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
|
|
380
|
+
validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
381
|
+
return snapshot;
|
|
382
|
+
});
|
|
379
383
|
});
|
|
380
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
381
384
|
const selected = uniqueTurn(snapshots);
|
|
382
385
|
validateSession(selected, threadId, cwd, desktopEvidence);
|
|
383
386
|
return inspectTurnRecords(selected.records, threadId, turnId, cwd, {
|
|
@@ -392,14 +395,15 @@ export function inspectCodexNativeTurn({ threadId, turnId, expectedCwd, desktopE
|
|
|
392
395
|
try {
|
|
393
396
|
if (!UUID.test(threadId) || !UUID.test(turnId)) throw new Error("The native turn identity is invalid");
|
|
394
397
|
const cwd = canonicalDirectory(expectedCwd, "The selected native task workspace");
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
398
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => {
|
|
399
|
+
let retainedBytes = 0;
|
|
400
|
+
return found.map((entry) => {
|
|
401
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId });
|
|
402
|
+
retainedBytes += snapshot.retainedBytes;
|
|
403
|
+
if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
|
|
404
|
+
return snapshot;
|
|
405
|
+
});
|
|
401
406
|
});
|
|
402
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
403
407
|
for (const snapshot of snapshots) validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
404
408
|
const selected = uniqueTurn(snapshots);
|
|
405
409
|
validateSession(selected, threadId, cwd, desktopEvidence);
|
|
@@ -421,20 +425,20 @@ export function resolveCodexNativeDispatchResponse(binding, { env = process.env,
|
|
|
421
425
|
if (!Array.isArray(marks) || !marks.length || marks.length > 64 || new Set(marks.map((mark) => mark.file)).size !== marks.length ||
|
|
422
426
|
!marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256) ||
|
|
423
427
|
marks.some((mark) => !Number.isSafeInteger(mark.size) || mark.size < 1)) throw new Error("The pre-send segment watermarks are invalid");
|
|
424
|
-
const found = findRollouts(threadId, env);
|
|
425
|
-
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
426
428
|
const outputs = delegationOutputs(executorThreadId, prompt);
|
|
427
|
-
const snapshots =
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
if (
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
429
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => {
|
|
430
|
+
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
431
|
+
return found.map((entry) => {
|
|
432
|
+
const mark = marks.find(({ file }) => file === entry.file);
|
|
433
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, afterOffset: mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs });
|
|
434
|
+
if (mark) {
|
|
435
|
+
if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
|
|
436
|
+
if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
|
|
437
|
+
}
|
|
438
|
+
validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
439
|
+
return snapshot;
|
|
440
|
+
});
|
|
436
441
|
});
|
|
437
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
438
442
|
const matches = snapshots.flatMap((snapshot) => snapshot.dispatches.map((dispatch) => ({ snapshot, dispatch })));
|
|
439
443
|
if (!matches.length) return { status: "pending", threadId, turnId: null, text: "", assistantItems: [], replySha256: null };
|
|
440
444
|
if (matches.length !== 1) throw new Error("Multiple post-send native dispatches match the reply receipt");
|
|
@@ -464,24 +468,24 @@ export function readCodexNativeTurnResponse({ threadId, turnId, previousTurnId,
|
|
|
464
468
|
|| new Set(marks.map((mark) => mark.file)).size !== marks.length
|
|
465
469
|
|| !marks.some((mark) => mark.file === watermark.file && mark.size === watermark.size && mark.prefixSha256 === watermark.prefixSha256)) throw new Error("The pre-send segment watermarks are invalid");
|
|
466
470
|
for (const mark of marks) if (!Number.isSafeInteger(mark.size) || mark.size < 1) throw new Error("The pre-send rollout watermark size is invalid");
|
|
467
|
-
const found = findRollouts(threadId, env);
|
|
468
|
-
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
469
471
|
const outputs = delegationOutputs(executorThreadId, prompt);
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (
|
|
478
|
-
if (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
472
|
+
const snapshots = readStableRolloutSet(threadId, env, (found) => {
|
|
473
|
+
if (marks.some((mark) => !found.some(({ file }) => file === mark.file))) throw new Error("A pre-send rollout segment disappeared after dispatch");
|
|
474
|
+
let retainedBytes = 0;
|
|
475
|
+
return found.map((entry) => {
|
|
476
|
+
const mark = marks.find(({ file }) => file === entry.file);
|
|
477
|
+
const snapshot = readStable(entry, maxRolloutBytes, { threadId, turnId, afterOffset: existingTurn ? 0 : mark?.size ?? 0, prefixSize: mark?.size, dispatchOutputs: outputs, dispatchAfterOffset: mark?.size ?? 0 });
|
|
478
|
+
retainedBytes += snapshot.retainedBytes;
|
|
479
|
+
if (retainedBytes > maxRolloutBytes) throw new Error("The native turn exceeds the retained-record limit across segments");
|
|
480
|
+
if (mark) {
|
|
481
|
+
if (!sameFileIdentity(snapshot.identity, mark.identity) || snapshot.size < mark.size) throw new Error("The selected native task rollout identity changed after dispatch");
|
|
482
|
+
if (snapshot.prefixSha256 !== mark.prefixSha256) throw new Error("The pre-send Codex rollout history changed after dispatch");
|
|
483
|
+
}
|
|
484
|
+
if (snapshot.turnBeforeWatermark) throw new Error("The observed turn was already present before dispatch");
|
|
485
|
+
validateIdentity(snapshot, threadId, cwd, desktopEvidence);
|
|
486
|
+
return snapshot;
|
|
487
|
+
});
|
|
483
488
|
});
|
|
484
|
-
assertRolloutSetStable(snapshots[0].sessions, threadId, snapshots);
|
|
485
489
|
const selected = uniqueTurn(snapshots);
|
|
486
490
|
const matchedDispatches = snapshots.flatMap((snapshot) => snapshot.dispatches);
|
|
487
491
|
if (matchedDispatches.length !== 1 || matchedDispatches[0].turnId !== turnId) throw new Error("The observed turn is not uniquely correlated to the exact native dispatch");
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { scanRollout } from "./rollout-reader.mjs";
|
|
5
|
-
import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable } from "./rollout-segments.mjs";
|
|
5
|
+
import { findRolloutSegments, confirmsDesktopTask, isLegacyCliSession, assertRolloutSetStable, retryRolloutRead } from "./rollout-segments.mjs";
|
|
6
6
|
|
|
7
7
|
const METADATA_KEY = "x-codex-turn-metadata";
|
|
8
8
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
@@ -43,14 +43,9 @@ export function findRollout(sessions, threadId) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export function readState(file, maxBytes) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// A live Desktop task can append its tool event during verification.
|
|
50
|
-
// Retry the entire scan, never reuse partially observed permissions.
|
|
51
|
-
if (attempt >= 2 || !/changed while (reading|opening)|incomplete final record/.test(error.message)) throw error;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
46
|
+
// A live Desktop task can append its tool event during verification.
|
|
47
|
+
// Retry the entire scan, never reuse partially observed permissions.
|
|
48
|
+
return retryRolloutRead(() => readStateOnce(file, maxBytes));
|
|
54
49
|
}
|
|
55
50
|
|
|
56
51
|
function readStateOnce(file, maxBytes) {
|
|
@@ -182,15 +177,18 @@ export function readCodexSenderContext(meta, { env = process.env, maxRolloutByte
|
|
|
182
177
|
const configuredHome = env.CODEX_HOME || path.join(env.HOME || env.USERPROFILE || os.homedir(), ".codex");
|
|
183
178
|
if (!path.isAbsolute(configuredHome)) throw new Error("The configured Codex home must be absolute");
|
|
184
179
|
const sessions = path.join(configuredHome, "sessions");
|
|
185
|
-
const matches =
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
180
|
+
const matches = retryRolloutRead(() => {
|
|
181
|
+
const found = [], snapshots = [];
|
|
182
|
+
for (const file of findRolloutSegments(sessions, identity.threadId)) {
|
|
183
|
+
const state = readStateOnce(file, Math.min(MAX_ROLLOUT_BYTES, maxRolloutBytes));
|
|
184
|
+
if (state.session?.id !== identity.threadId) throw new Error("A rollout segment contradicts the calling task identity");
|
|
185
|
+
snapshots.push({ file, version: state.version });
|
|
186
|
+
if (state.context?.turn_id === identity.turnId || state.lifecycle?.turn_id === identity.turnId) found.push({ file, ...state });
|
|
187
|
+
if (found.length > 1) throw new Error("Multiple rollout segments match the calling turn");
|
|
188
|
+
}
|
|
189
|
+
assertRolloutSetStable(sessions, identity.threadId, snapshots);
|
|
190
|
+
return found;
|
|
191
|
+
});
|
|
194
192
|
if (matches.length !== 1) throw new Error(matches.length ? "Multiple rollout segments match the calling turn" : "The calling turn is no longer the latest active Codex turn");
|
|
195
193
|
const { file, session, context, lifecycle } = matches[0];
|
|
196
194
|
if (!["Codex Desktop", "codex_vscode"].includes(originator)) throw new Error("Unsupported calling host");
|
package/src/index.mjs
CHANGED
|
@@ -40,7 +40,7 @@ import { PROJECT_SETUP_TOOLS, registerProjectOnboardingTools } from "./project-o
|
|
|
40
40
|
|
|
41
41
|
exitForVersionRequest(import.meta.url);
|
|
42
42
|
|
|
43
|
-
const VERSION = "1.22.
|
|
43
|
+
const VERSION = "1.22.10";
|
|
44
44
|
void import("./telemetry.mjs").then(({ startUsageReporting }) => startUsageReporting({ version: VERSION })).catch(() => {});
|
|
45
45
|
const log = (msg) => process.stderr.write(`[codex-mcp-bridge] ${msg}\n`);
|
|
46
46
|
|
|
@@ -38,7 +38,7 @@ import { NativeCreationReceipts, creationIdValid } from "./native-creation-recei
|
|
|
38
38
|
|
|
39
39
|
exitForVersionRequest(import.meta.url);
|
|
40
40
|
|
|
41
|
-
const VERSION = "1.22.
|
|
41
|
+
const VERSION = "1.22.10";
|
|
42
42
|
const log = (msg) => process.stderr.write(`[native-relay] ${msg}\n`);
|
|
43
43
|
|
|
44
44
|
function errorResponse(code, message, sent, details) {
|
package/src/rollout-segments.mjs
CHANGED
|
@@ -55,6 +55,33 @@ export function isLegacyCliSession(session) {
|
|
|
55
55
|
return session?.originator === "codex-tui" && session.source === "cli";
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
const TRANSIENT_ROLLOUT_READ = /changed while (reading|opening)|incomplete final record/;
|
|
59
|
+
const ROLLOUT_CHANGED_DURING_READ = /changed while (reading|opening)/;
|
|
60
|
+
const ROLLOUT_READ_ATTEMPTS = 3;
|
|
61
|
+
|
|
62
|
+
function reasonText(reason) {
|
|
63
|
+
return typeof reason === "string" ? reason : reason?.message;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isTransientRolloutRead(reason) {
|
|
67
|
+
const message = reasonText(reason);
|
|
68
|
+
return typeof message === "string" && TRANSIENT_ROLLOUT_READ.test(message);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isRolloutChangedDuringRead(reason) {
|
|
72
|
+
const message = reasonText(reason);
|
|
73
|
+
return typeof message === "string" && ROLLOUT_CHANGED_DURING_READ.test(message);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function retryRolloutRead(read, attempts = ROLLOUT_READ_ATTEMPTS) {
|
|
77
|
+
for (let attempt = 1; ; attempt++) {
|
|
78
|
+
try { return read(); }
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (attempt >= attempts || !isTransientRolloutRead(error)) throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
export function assertRolloutSetStable(sessions, threadId, snapshots) {
|
|
59
86
|
const files = findRolloutSegments(sessions, threadId);
|
|
60
87
|
if (files.length !== snapshots.length || files.some((file, i) => file !== snapshots[i].file)) throw new Error("The rollout segment set changed while reading");
|
package/src/thread-delivery.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { creationRequestHash } from "./native-creation-receipts.mjs";
|
|
|
7
7
|
import { DesktopTaskReceipts } from "./desktop-task-receipts.mjs";
|
|
8
8
|
import { captureCodexRolloutWatermark, inspectCodexNativeTurn, readCodexNativeTurnResponse, readCodexNativeCreationResponse, resolveCodexNativeDispatchResponse } from "./codex-native-response.mjs";
|
|
9
9
|
import { captureProjectScope, recheckProjectScope } from "./project-scope.mjs";
|
|
10
|
+
import { isRolloutChangedDuringRead } from "./rollout-segments.mjs";
|
|
10
11
|
import { requireClaudeSenderContext } from "./claude-sender-context.mjs";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -550,6 +551,8 @@ export class DesktopTaskDelivery {
|
|
|
550
551
|
let cursor;
|
|
551
552
|
let turnId = null;
|
|
552
553
|
let readSnapshots = false;
|
|
554
|
+
let deferredReason = null;
|
|
555
|
+
let deferredTurn = null;
|
|
553
556
|
const readSnapshot = async () => {
|
|
554
557
|
const result = await this.request("read_thread", { threadId, hostId: "local", turnLimit: 1, ...METADATA_READ_OPTIONS }, { deadline: startedAt + timeoutMs });
|
|
555
558
|
const thread = result?.thread;
|
|
@@ -560,19 +563,24 @@ export class DesktopTaskDelivery {
|
|
|
560
563
|
if (responseObservation?.expectedCwd && realpathSync.native(thread.cwd) !== responseObservation.expectedCwd) throw new Error("The selected native task workspace changed during observation");
|
|
561
564
|
return { polls: [{ thread, latestTurn: result.turns?.[0] ?? null }] };
|
|
562
565
|
};
|
|
563
|
-
const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
566
|
+
const expired = () => ({ threadId, turnId, status: "timeout", text: "", responseStatus: "unavailable", ...(deferredReason ? { observationStatus: "unavailable", observationReason: deferredReason } : {}), assistantItems: [], replySha256: null, activity: [], errors: [], durationMs: this.now() - startedAt });
|
|
564
567
|
const deadlineError = (error) => {
|
|
565
568
|
const cause = error.cause ?? error;
|
|
566
569
|
return this.now() - startedAt >= timeoutMs && (["DESKTOP_RESPONSE_DEADLINE", "RELAY_TIMEOUT"].includes(cause.code) ||
|
|
567
570
|
cause.code === "RELAY_UNREACHABLE" && cause.message === "The Codex Desktop relay send deadline elapsed before dispatch");
|
|
568
571
|
};
|
|
569
|
-
const
|
|
572
|
+
const observeLater = Symbol("observe-later");
|
|
570
573
|
const observe = async (selectedTurnId, resolveDispatch = false) => {
|
|
571
574
|
try {
|
|
572
|
-
|
|
575
|
+
const observed = await this.observeNativeResponse(threadId, selectedTurnId, responseObservation, { deadline: startedAt + timeoutMs, resolveDispatch });
|
|
576
|
+
deferredReason = observed?.status === "unavailable" && isRolloutChangedDuringRead(observed.reason) ? observed.reason : null;
|
|
577
|
+
return deferredReason ? observeLater : observed;
|
|
573
578
|
} catch (error) {
|
|
574
579
|
if (deadlineError(error)) return null;
|
|
575
|
-
if (error.code === WORKSPACE_UNASSIGNED)
|
|
580
|
+
if (error.code === WORKSPACE_UNASSIGNED) {
|
|
581
|
+
deferredReason = null;
|
|
582
|
+
return observeLater;
|
|
583
|
+
}
|
|
576
584
|
throw error;
|
|
577
585
|
}
|
|
578
586
|
};
|
|
@@ -611,7 +619,7 @@ export class DesktopTaskDelivery {
|
|
|
611
619
|
if (this.resolveResponse && responseObservation?.watermark?.status === "available" && responseObservation.operation !== "create_thread") {
|
|
612
620
|
const observed = await observe(null, true);
|
|
613
621
|
if (!observed) return expired();
|
|
614
|
-
if (observed ===
|
|
622
|
+
if (observed === observeLater) {
|
|
615
623
|
await pause();
|
|
616
624
|
continue;
|
|
617
625
|
}
|
|
@@ -634,17 +642,20 @@ export class DesktopTaskDelivery {
|
|
|
634
642
|
continue;
|
|
635
643
|
}
|
|
636
644
|
if (needsAttention) return attention();
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
645
|
+
const released = deferredTurn ?? turn;
|
|
646
|
+
const existingTurn = released?.id === previousTurnId;
|
|
647
|
+
if (released?.id && (!existingTurn || responseObservation?.previousTurnId === previousTurnId)) {
|
|
648
|
+
if (!existingTurn) turnId = released.id;
|
|
649
|
+
const status = released.status;
|
|
641
650
|
if (RELEASE_STATUSES.has(status)) {
|
|
642
|
-
const observed = await observe(
|
|
651
|
+
const observed = await observe(released.id);
|
|
643
652
|
if (!observed) return expired();
|
|
644
|
-
if (observed ===
|
|
653
|
+
if (observed === observeLater) {
|
|
654
|
+
deferredTurn = deferredReason ? released : null;
|
|
645
655
|
await pause();
|
|
646
656
|
continue;
|
|
647
657
|
}
|
|
658
|
+
deferredTurn = null;
|
|
648
659
|
const responseStatus = observed.status;
|
|
649
660
|
if (existingTurn && !observed.dispatchObserved && !["completed", "completed_no_reply"].includes(responseStatus)) {
|
|
650
661
|
turnId = null;
|
|
@@ -652,7 +663,7 @@ export class DesktopTaskDelivery {
|
|
|
652
663
|
await this.sleep(Math.min(1500, timeoutMs - (this.now() - startedAt)));
|
|
653
664
|
continue;
|
|
654
665
|
}
|
|
655
|
-
turnId =
|
|
666
|
+
turnId = released.id;
|
|
656
667
|
return {
|
|
657
668
|
threadId, turnId, status,
|
|
658
669
|
text: responseStatus === "completed" ? observed.text : "",
|
|
@@ -663,7 +674,7 @@ export class DesktopTaskDelivery {
|
|
|
663
674
|
replySha256: observed.replySha256 ?? null,
|
|
664
675
|
responseSource: observed.source ?? null,
|
|
665
676
|
...(observed.responseScope ? { responseScope: observed.responseScope } : {}),
|
|
666
|
-
activity: [], errors:
|
|
677
|
+
activity: [], errors: released.error ? [released.error] : [], durationMs: released.durationMs ?? this.now() - startedAt,
|
|
667
678
|
};
|
|
668
679
|
}
|
|
669
680
|
}
|