@deksden-com/dd-flow-cli 0.9.0-beta.142 → 0.9.0-beta.143
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/CHANGELOG.md +10 -0
- package/dist/build-info.json +7 -7
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +8 -3
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +29 -8
- package/dist/harness-runtime/lib/dd-codex.mjs +184 -51
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +23 -6
- package/dist/schemas/code-review-decision.schema.json +9 -0
- package/dist/schemas/flow-run.schema.json +1 -0
- package/dist/schemas/plan-review-decision.schema.json +9 -0
- package/dist/services/accepted-intent.js +127 -0
- package/dist/services/check-runner-evidence.js +2 -0
- package/dist/services/code-checks.js +213 -47
- package/dist/services/repair-intents.js +1 -1
- package/dist/services/run-control.js +36 -1
- package/dist/services/stage-pause.js +30 -0
- package/dist/services/verification-matrix.js +20 -2
- package/dist/services/vnext-code-review.js +14 -2
- package/dist/services/vnext-plan-review.js +6 -1
- package/dist/services/vnext-protocolize.js +2 -0
- package/dist/services/vnext-specify.js +4 -0
- package/dist/services/work-registry.js +4 -2
- package/dist/storage/database.js +4 -0
- package/package.json +1 -1
- package/tools/probe-codex-history.mjs +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.143
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 60daf29: Keep an early AGY provider rejection as the readiness failure rather than a stream-drain cleanup failure. Preserve strict journal and physical cleanup errors.
|
|
8
|
+
- d86445c: Observe accepted Codex Turns while persisted history is unavailable, preserving exact native outcomes, cancellation identity and conservative tree settlement. Keep RPC errors intact in compact journals.
|
|
9
|
+
|
|
10
|
+
Treat successful metadata-only history responses as incomplete in cancellation and final result admission; require exact current Turn item evidence before bypassing retained-output/profile safeguards. Reject incomplete cancellation records and terminal settlement without a valid Turn identity. Prefer native completed output over stale snapshots of the same Turn.
|
|
11
|
+
|
|
3
12
|
## 0.9.0-beta.142
|
|
4
13
|
|
|
5
14
|
### Patch Changes
|
|
6
15
|
|
|
7
16
|
- Wait for the owned OpenCode v2 listener through typed SDK transport startup failures and expose bounded read-only scope inventory batches for validated retirement-proof reuse.
|
|
8
17
|
- Retry no-effect physical confirmation on the same held check, bootstrap or service process before dispatch, without replaying commands or extending exhausted authority.
|
|
18
|
+
- Backfilled from published artifact commit `19ca01d6`: Keep native Session observation stable, use bounded shared Codex read phases, scope check evidence to declared inputs, retain accepted intake/HITL review sources, and reject stale review coverage without productive replay. These changes were already shipped; beta.143 does not count them as new work.
|
|
9
19
|
|
|
10
20
|
## 0.9.0-beta.141
|
|
11
21
|
|
package/dist/build-info.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-10-
|
|
3
|
+
"cli_version": "0.9.0-beta.143",
|
|
4
|
+
"cli_commit": "2294710b9c9b678193798301d5a81048506d672d",
|
|
5
|
+
"built_at": "2026-10-20T02:52:47.825Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.2",
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "a1cfe8b06329c691c314064b6ea6a3da6628cceb",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
|
-
"repo_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-
|
|
11
|
-
"memorybank_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-
|
|
12
|
-
"flow_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-
|
|
10
|
+
"repo_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-plan092",
|
|
11
|
+
"memorybank_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-plan092/.memory-bank",
|
|
12
|
+
"flow_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-plan092/.memory-bank/dd-flow",
|
|
13
13
|
"layout": "dot_memory_bank"
|
|
14
14
|
}
|
|
15
15
|
}
|
|
@@ -294,7 +294,7 @@ export class Runtime {
|
|
|
294
294
|
let allowProviderFinalization;
|
|
295
295
|
const providerRegistered = new Promise(resolve => { allowProviderFinalization = resolve; });
|
|
296
296
|
const observationFailure = error => { this.drainingError ??= error; readyReject(error); this.active?.reject(error); };
|
|
297
|
-
this.nativeDrain = readNativeLines(this.child.stdout, line => { this.buffer = `${line}\n`; return this.draining = this.drain(readyResolve); }, observationFailure);
|
|
297
|
+
this.nativeDrain = readNativeLines(this.child.stdout, line => { this.buffer = `${line}\n`; return this.draining = this.drain(readyResolve, readyReject); }, observationFailure);
|
|
298
298
|
this.diagnostics = streamNativeDiagnostics(this.child.stderr, chunk => this.journal("stderr", { text: chunk }), observationFailure);
|
|
299
299
|
this.child.on("error", error => { readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } });
|
|
300
300
|
// A dead leader is not yet a finalized tree: stop must also join the
|
|
@@ -329,7 +329,7 @@ export class Runtime {
|
|
|
329
329
|
try { return await Promise.race([this.readyPromise, new Promise((_, reject) => { readyTimer = observedTimeout(() => reject(new DaemonError("agy_init_timeout", "Antigravity init timed out", true)), SETUP_STARTUP_MS, { progress: () => this.lastActivityAt }); })]); }
|
|
330
330
|
finally { clearTimeout(readyTimer); }
|
|
331
331
|
}
|
|
332
|
-
async drain(readyResolve) { let index; while ((index = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, index).trim(); this.buffer = this.buffer.slice(index + 1); if (!line) continue; let event; try { event = JSON.parse(line); } catch { await this.journal("invalid_json", { line: line.slice(0, 1000) }); continue; }
|
|
332
|
+
async drain(readyResolve, readyReject) { let index; while ((index = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, index).trim(); this.buffer = this.buffer.slice(index + 1); if (!line) continue; let event; try { event = JSON.parse(line); } catch { await this.journal("invalid_json", { line: line.slice(0, 1000) }); continue; }
|
|
333
333
|
// Record an early provider rejection before journalling: a short-lived
|
|
334
334
|
// child can close while the asynchronous write is still pending.
|
|
335
335
|
if (event.event === "result" && ["ERROR", "CANCELLED"].includes(event.result?.status)) {
|
|
@@ -344,7 +344,12 @@ export class Runtime {
|
|
|
344
344
|
}
|
|
345
345
|
this.observeProviderActivity(event);
|
|
346
346
|
if (event.event === "result" && this.init && !this.startupError) { await this.finishTurn(event.result ?? {}, event.terminal_metadata, event); continue; }
|
|
347
|
-
await this.journal("provider_event", { event }); if (event.event === "init") { this.init = { conversation_id: event.conversation_id, ...(event.init ?? {}) }; if (!this.init.conversation_id) throw new DaemonError("agy_init_missing", "Antigravity init has no conversation ID"); const requested = { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode, permission_mode: "always-proceed" }; this.profile = assertProfile(requested, observedProfile(this.init, requested)); await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: this.init.conversation_id, requested, observed: this.profile.observed, source: "native.init" }); await this.persist({ shutdown_state: "running", active_tree: false }); readyResolve(this.init); } else if (event.event === "step_update") this.observeStep(event.step_update ?? {}); else if (event.event === "result") {
|
|
347
|
+
await this.journal("provider_event", { event }); if (event.event === "init") { this.init = { conversation_id: event.conversation_id, ...(event.init ?? {}) }; if (!this.init.conversation_id) throw new DaemonError("agy_init_missing", "Antigravity init has no conversation ID"); const requested = { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode, permission_mode: "always-proceed" }; this.profile = assertProfile(requested, observedProfile(this.init, requested)); await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: this.init.conversation_id, requested, observed: this.profile.observed, source: "native.init" }); await this.persist({ shutdown_state: "running", active_tree: false }); readyResolve(this.init); } else if (event.event === "step_update") this.observeStep(event.step_update ?? {}); else if (event.event === "result") {
|
|
348
|
+
// A successfully read/journalled provider refusal rejects readiness, not
|
|
349
|
+
// the stream drain that physical cleanup must still join.
|
|
350
|
+
if (this.startupError) { readyReject(this.startupError); continue; }
|
|
351
|
+
await this.finishTurn(event.result ?? {});
|
|
352
|
+
} } }
|
|
348
353
|
observeStep(step) {
|
|
349
354
|
const sessionId = step.conversation_id ?? this.init?.conversation_id, observation = this.sessionObservations.get(sessionId) ?? {};
|
|
350
355
|
const fresh = Number.isInteger(step.step_index) && step.step_index > (observation.last_step_index ?? -1) && step.step_index > (observation.step_floor ?? -1);
|
|
@@ -14,7 +14,7 @@ import path from "node:path";
|
|
|
14
14
|
import { spawn } from "node:child_process";
|
|
15
15
|
import { waitForSettlement } from "./session-settlement.mjs";
|
|
16
16
|
import { errorRecord } from "./operation-errors.mjs";
|
|
17
|
-
import { CODEX_REQUEST_TIMEOUT_MS, LATE_RESPONSE_GRACE_MS, CodexBridge, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
|
|
17
|
+
import { CODEX_REQUEST_TIMEOUT_MS, LATE_RESPONSE_GRACE_MS, CodexBridge, codexReadPhase, readCodexHistory, retainedCodexTurn, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
|
|
18
18
|
import { assertDaemonOwnership, assertStartupChild, prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
|
|
19
19
|
|
|
20
20
|
export function codexBridgeEnv(config, daemonId) { return { ...config.env, ...managedFlowEnv(config, daemonId) }; }
|
|
@@ -28,10 +28,11 @@ async function readState(file) { try { return JSON.parse(await readFile(file, "u
|
|
|
28
28
|
async function writeState(file, value) { const temp = `${file}.${process.pid}.tmp`; await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temp, file); }
|
|
29
29
|
async function removeSocket(socket) { try { const info = await lstat(socket); if (!info.isSocket()) throw Object.assign(new Error("refusing to remove non-socket daemon path"), { code: "unsafe_socket" }); await unlink(socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
30
30
|
const errorPayload = errorRecord;
|
|
31
|
-
const terminalTurn = turn => ["completed", "interrupted", "failed", "cancelled"].includes(turn
|
|
32
|
-
export function settledCodexSession(status, latestTurn, active) {
|
|
31
|
+
const terminalTurn = turn => typeof turn?.id === "string" && turn.id.trim().length > 0 && ["completed", "interrupted", "failed", "cancelled"].includes(turn.status);
|
|
32
|
+
export function settledCodexSession(status, latestTurn, active, historyAvailable = true) {
|
|
33
|
+
if (latestTurn && !terminalTurn(latestTurn)) return false;
|
|
33
34
|
if (status === "idle") return true;
|
|
34
|
-
if (status === "notLoaded") return
|
|
35
|
+
if (status === "notLoaded") return terminalTurn(latestTurn) || historyAvailable && !latestTurn;
|
|
35
36
|
// `systemError` alone is not evidence that the provider stopped. A current
|
|
36
37
|
// terminal Turn plus no daemon operation is the narrow settled case.
|
|
37
38
|
return status === "systemError" && !active && terminalTurn(latestTurn);
|
|
@@ -49,26 +50,46 @@ async function observeCodexSessions(bridge, options, sessions, active, remember
|
|
|
49
50
|
const observed = await inspectSessionWithBridge(bridge, { ...options, sessionId, turnId: sessionId === options.sessionId ? options.turnId : undefined });
|
|
50
51
|
for (const child of observed.descendants ?? []) { sessions.add(child.provider_session_id); remember(child.provider_session_id); }
|
|
51
52
|
const status = observed.status?.type ?? observed.status;
|
|
52
|
-
const
|
|
53
|
-
const
|
|
53
|
+
const currentId = bridge.currentTurns?.get(sessionId) ?? (sessionId === options.sessionId ? options.turnId : undefined);
|
|
54
|
+
const retained = retainedCodexTurn(bridge, sessionId, currentId);
|
|
55
|
+
const compactTurn = observed.thread?.turns?.findLast(turn => !currentId || turn?.id === currentId);
|
|
56
|
+
const stored = ["notLoaded", "systemError"].includes(status) || currentId && !terminalTurn(retained)
|
|
57
|
+
? await readCodexHistory(bridge, "thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, options.readPhase) : null;
|
|
58
|
+
const hydrated = stored && !stored.available
|
|
59
|
+
? await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns: true }, options.readPhase) : null;
|
|
60
|
+
const hydratedAvailable = hydrated?.available && Array.isArray(hydrated.result.thread.turns);
|
|
61
|
+
const hydratedTurn = hydratedAvailable ? hydrated.result.thread.turns.findLast(turn => !currentId || turn?.id === currentId) : null;
|
|
62
|
+
const malformedHistory = [stored?.result?.data, hydrated?.result?.thread.turns, observed.thread?.turns].some(turns =>
|
|
63
|
+
turns?.some(turn => typeof turn?.id !== "string" || !turn.id.trim() || !["inProgress", "completed", "interrupted", "failed", "cancelled"].includes(turn.status)));
|
|
64
|
+
const latest = retained && terminalTurn(retained) ? retained
|
|
65
|
+
: malformedHistory ? { status: "unknown" }
|
|
66
|
+
: stored?.result?.data.find(turn => !currentId || turn?.id === currentId) ?? hydratedTurn ?? compactTurn ?? retained
|
|
67
|
+
?? (currentId ? { id: currentId, status: "unknown" } : null);
|
|
68
|
+
const observedStatus = hydrated?.result?.thread.status ?? observed.status;
|
|
69
|
+
const settled = settledCodexSession(observedStatus?.type ?? observedStatus, latest, active(), (stored?.available ?? true) || hydratedAvailable);
|
|
54
70
|
if (!settled) unsettled.push(sessionId);
|
|
55
|
-
observations.push({ ...observed, settled });
|
|
71
|
+
observations.push({ ...observed, status: observedStatus, settled });
|
|
56
72
|
}
|
|
57
73
|
return { sessions: unsettled.reverse(), active: Boolean(active()), observations };
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
export async function inspectCodexTree(bridge, options, { remember = () => {}, active = () => null } = {}) {
|
|
77
|
+
options = { ...options, readPhase: options.readPhase ?? codexReadPhase(bridge, options) };
|
|
61
78
|
const sessions = new Set([options.sessionId]);
|
|
62
79
|
const observed = await observeCodexSessions(bridge, options, sessions, active, remember);
|
|
63
80
|
return { ...observed.observations[0], settled: !observed.active && !observed.sessions.length, unsettled_sessions: observed.sessions, tree_session_ids: [...sessions], tree_observations: observed.observations.map(item => ({ provider_session_id: item.provider_session_id, status: item.status, settled: item.settled })) };
|
|
64
81
|
}
|
|
65
82
|
|
|
66
83
|
export async function cancelCodexTree(bridge, options, { remember = () => {}, active = () => null, timeoutMs = 5_000 } = {}) {
|
|
84
|
+
// Zero requests one best-effort cancellation/observation pass, not an
|
|
85
|
+
// invalid zero-length RPC window or permission to skip owned children.
|
|
86
|
+
const readBudget = timeoutMs === 0 ? CODEX_REQUEST_TIMEOUT_MS : timeoutMs;
|
|
87
|
+
options = { ...options, readPhase: options.readPhase ?? codexReadPhase(bridge, { ...options, timeoutMs: readBudget, livenessTimeoutMs: readBudget }) };
|
|
67
88
|
const sessions = new Set([options.sessionId]);
|
|
68
89
|
const settlement = await waitForSettlement({
|
|
69
90
|
timeoutMs, knownSessions: () => [...sessions],
|
|
70
91
|
cancel: async sessionId => {
|
|
71
|
-
try { await cancelSessionWithBridge(bridge, { ...options, sessionId, turnId:
|
|
92
|
+
try { await cancelSessionWithBridge(bridge, { ...options, sessionId, turnId: sessionId === options.sessionId ? options.turnId : undefined }); }
|
|
72
93
|
catch (error) { if (error.code !== "turn_not_active") throw error; }
|
|
73
94
|
},
|
|
74
95
|
observe: () => observeCodexSessions(bridge, options, sessions, active, remember)
|
|
@@ -4,7 +4,7 @@ import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import readline from "node:readline";
|
|
6
6
|
import { closeBridgeProcess } from "./managed-daemon.mjs";
|
|
7
|
-
import { errorRecord, isProvenShellCorrection } from "./operation-errors.mjs";
|
|
7
|
+
import { errorRecord, isObservationLoss, isProvenShellCorrection } from "./operation-errors.mjs";
|
|
8
8
|
import { reportedFailure, commandText } from "./process-json.mjs";
|
|
9
9
|
import { ObservationClock, observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
|
|
10
10
|
import { observeModel } from "./model-observations.mjs";
|
|
@@ -12,9 +12,105 @@ import { classifyCodexItem } from "./tool-observations.mjs";
|
|
|
12
12
|
import { readNativeLines, streamNativeDiagnostics, nativeObservationError } from "./native-streams.mjs";
|
|
13
13
|
import { operationProgressReporter } from "./operation-progress.mjs";
|
|
14
14
|
import { publishSetup } from './setup-progress.mjs';
|
|
15
|
+
import { capacityBackoff } from './provider-overload-policy.mjs';
|
|
15
16
|
|
|
16
17
|
export const CODEX_REQUEST_TIMEOUT_MS = 120_000;
|
|
17
18
|
export const LATE_RESPONSE_GRACE_MS = 5_000;
|
|
19
|
+
export function codexReadPhase(bridge, options = {}) {
|
|
20
|
+
const timeoutMs = Math.min(CODEX_REQUEST_TIMEOUT_MS, options.livenessTimeoutMs ?? options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS);
|
|
21
|
+
return { clock: new ObservationClock({ timeoutMs, ...options.observationClock,
|
|
22
|
+
onGap: gap => bridge.journal?.write("observation_gap", { ...gap, phase: "metadata_read" }) }),
|
|
23
|
+
random: options.readRandom ?? Math.random,
|
|
24
|
+
sleep: options.readSleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function dispatchBudget(phase, method, limitMs) {
|
|
28
|
+
const clock = phase.outerClock ?? phase.clock;
|
|
29
|
+
if (clock.sample(phase.progress?.())) throw Object.assign(new Error("Codex owner observation window expired before dispatch"), {
|
|
30
|
+
code: clock.observationLost ? "operation_observation_lost" : "rpc_timeout",
|
|
31
|
+
details: { method, phase: "before_dispatch", effect: "no_effect", observation: clock.state() }
|
|
32
|
+
});
|
|
33
|
+
const remaining = Math.max(1, Math.ceil(clock.timeoutMs - clock.elapsed));
|
|
34
|
+
const timeoutMs = Math.min(limitMs, remaining);
|
|
35
|
+
return [timeoutMs, { graceMs: Math.min(LATE_RESPONSE_GRACE_MS, remaining - timeoutMs) }];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readCodexRpc(bridge, method, params, phase = codexReadPhase(bridge)) {
|
|
39
|
+
if (!["thread/read", "thread/turns/list"].includes(method)) throw new Error("Codex read policy only accepts read-only RPC");
|
|
40
|
+
const sample = () => phase.clock.sample(phase.progress?.());
|
|
41
|
+
let primary;
|
|
42
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
43
|
+
if (sample()) throw primary ?? Object.assign(new Error("Codex metadata observation window expired"), { code: phase.clock.observationLost ? "operation_observation_lost" : "rpc_timeout", details: { method, phase: "metadata_read", observation: phase.clock.state() } });
|
|
44
|
+
const remaining = Math.min(CODEX_REQUEST_TIMEOUT_MS, Math.max(1, Math.ceil(phase.clock.timeoutMs - phase.clock.elapsed)));
|
|
45
|
+
try {
|
|
46
|
+
const result = await bridge.request(method, params, remaining, { graceMs: 0, observe: () => { sample(); phase.outerClock?.sample(); } });
|
|
47
|
+
if (!result || typeof result !== "object" || Array.isArray(result)
|
|
48
|
+
|| method === "thread/read" && (!result.thread || typeof result.thread !== "object" || Array.isArray(result.thread) || typeof result.thread.id !== "string" || !result.thread.id)
|
|
49
|
+
|| method === "thread/turns/list" && !Array.isArray(result.data))
|
|
50
|
+
throw Object.assign(new Error("Codex read returned invalid data"), { code: "rpc_schema_invalid", details: { method } });
|
|
51
|
+
if (method === "thread/read" && result.thread.id !== params.threadId)
|
|
52
|
+
throw Object.assign(new Error("Codex read returned a foreign Thread"), { code: "thread_identity_mismatch" });
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
primary ??= error; bridge.assertHookHealthy?.(params.threadId);
|
|
57
|
+
// A broken process cannot be replaced under the guise of a read retry.
|
|
58
|
+
const current = bridge.currentTurns?.get(params.threadId);
|
|
59
|
+
const materializing = method === "thread/read" && current && !bridge.turns?.has(current)
|
|
60
|
+
&& bridge.nativeHookTurns?.get(params.threadId) !== current && error?.code === "rpc_error" && error.details?.code === -32603;
|
|
61
|
+
const emptyMetadata = materializing && error.details.message?.match(/^failed to read thread: thread-store internal error: failed to read session metadata (.+): rollout at (.+) is empty$/);
|
|
62
|
+
const transient = error?.code === "rpc_timeout" || error?.code === "EAGAIN" || error?.code === "EINTR"
|
|
63
|
+
|| Boolean(emptyMetadata && emptyMetadata[1] === emptyMetadata[2] && emptyMetadata[1].endsWith(`-${params.threadId}.jsonl`));
|
|
64
|
+
if (!transient) { if (error !== primary) error.previous_read_error = errorRecord(primary); throw error; }
|
|
65
|
+
if (attempt === 2 || sample()) { if (error !== primary) primary.secondary_error = errorRecord(error); throw primary; }
|
|
66
|
+
const delay = Math.min(capacityBackoff(error, attempt + 1, phase.clock.wall(), phase.random), Math.max(0, phase.clock.timeoutMs - phase.clock.elapsed));
|
|
67
|
+
bridge.journal?.write("thread_read_retry", { method, session_id: params.threadId, attempt: attempt + 1, delay_ms: delay, error: errorRecord(error) });
|
|
68
|
+
await phase.sleep(delay);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Availability belongs to this read of this Thread, not to the whole binary.
|
|
74
|
+
export async function readCodexHistory(bridge, method, params, phase = codexReadPhase(bridge)) {
|
|
75
|
+
if (method !== "thread/turns/list" && !(method === "thread/read" && params.includeTurns === true))
|
|
76
|
+
throw new Error("Codex history policy only accepts history reads");
|
|
77
|
+
let result, error;
|
|
78
|
+
try {
|
|
79
|
+
result = await readCodexRpc(bridge, method, params, phase);
|
|
80
|
+
if (method === "thread/read" && result.thread.turns !== undefined && !Array.isArray(result.thread.turns))
|
|
81
|
+
fail("Codex history read returned invalid turns", "rpc_schema_invalid", { method });
|
|
82
|
+
}
|
|
83
|
+
catch (cause) {
|
|
84
|
+
bridge.assertHookHealthy?.(params.threadId);
|
|
85
|
+
const native = cause?.details;
|
|
86
|
+
const operation = method === "thread/read" ? "includeTurns" : "thread/turns/list";
|
|
87
|
+
const unsupported = native?.code === -32601 && native.message === "list_turns is not supported yet";
|
|
88
|
+
const unmaterialized = native?.code === -32600 && native.message ===
|
|
89
|
+
`thread ${params.threadId} is not materialized yet; ${operation} is unavailable before first user message`;
|
|
90
|
+
if (cause?.code !== "rpc_error" || !unsupported && !unmaterialized) throw cause;
|
|
91
|
+
error = errorRecord(cause);
|
|
92
|
+
}
|
|
93
|
+
const available = !error;
|
|
94
|
+
phase.history ??= new Map();
|
|
95
|
+
const key = `${params.threadId}:${method}`;
|
|
96
|
+
if (phase.history.get(key)?.available !== available) bridge.journal?.write("history_availability", {
|
|
97
|
+
method, session_id: params.threadId, available, ...(error ? { error } : {})
|
|
98
|
+
});
|
|
99
|
+
phase.history.set(key, { available, error });
|
|
100
|
+
return { available, result, error };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function retainedCodexTurn(bridge, sessionId, turnId = bridge.currentTurns?.get(sessionId)) {
|
|
104
|
+
if (!turnId) return null;
|
|
105
|
+
const retained = bridge.turns?.get(turnId);
|
|
106
|
+
if (!retained) return null;
|
|
107
|
+
if (retained.threadId && retained.threadId !== sessionId || retained.value?.threadId && retained.value.threadId !== sessionId)
|
|
108
|
+
fail("Codex retained Thread identity differs", "turn_identity_mismatch");
|
|
109
|
+
if (!retained.threadId && !retained.value?.threadId) return null;
|
|
110
|
+
const turn = retained.value?.turn;
|
|
111
|
+
if (turn?.id !== turnId) fail("Codex retained Turn identity differs", "turn_identity_mismatch");
|
|
112
|
+
return turn;
|
|
113
|
+
}
|
|
18
114
|
const CODEX_SESSION_CONFIG = Object.freeze({
|
|
19
115
|
bypass_hook_trust: true,
|
|
20
116
|
"features.plugins": false,
|
|
@@ -158,7 +254,7 @@ export class CodexBridge {
|
|
|
158
254
|
// retain a compact read receipt instead of writing the same transcript on
|
|
159
255
|
// every status poll.
|
|
160
256
|
const request = message.id !== undefined ? this.pending.get(message.id) : null;
|
|
161
|
-
if (request?.method === "thread/read") {
|
|
257
|
+
if (request?.method === "thread/read" && !message.error) {
|
|
162
258
|
const thread = message.result?.thread ?? message.result ?? {};
|
|
163
259
|
this.journal.write("inbound", { jsonrpc: message.jsonrpc, id: message.id, result: { thread: {
|
|
164
260
|
id: thread.id ?? null, status: thread.status ?? null, model: thread.model ?? null, modelProvider: thread.modelProvider ?? null, reasoningEffort: thread.reasoningEffort ?? null, updatedAt: thread.updatedAt ?? null,
|
|
@@ -410,7 +506,7 @@ export class CodexBridge {
|
|
|
410
506
|
this.send({ jsonrpc: "2.0", id: message.id, error });
|
|
411
507
|
}
|
|
412
508
|
|
|
413
|
-
request(method, params = {}, timeoutMs = this.options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS) {
|
|
509
|
+
request(method, params = {}, timeoutMs = this.options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS, policy = {}) {
|
|
414
510
|
const turnFloor = method === "turn/start" ? new Set([this.currentTurns?.get(params.threadId), ...[...this.turns].filter(([, value]) => value.threadId === params.threadId).map(([id]) => id)].filter(Boolean)) : null;
|
|
415
511
|
if (method === "turn/start" && params.threadId) {
|
|
416
512
|
// Only one productive root Turn is admitted by the daemon fence. Drop
|
|
@@ -422,16 +518,18 @@ export class CodexBridge {
|
|
|
422
518
|
}
|
|
423
519
|
const id = this.nextId++;
|
|
424
520
|
return new Promise((resolve, reject) => {
|
|
425
|
-
const timer = observedTimeout(
|
|
521
|
+
const timer = observedTimeout(clock => {
|
|
426
522
|
const pending = this.pending.get(id); if (!pending || pending.timedOut) return;
|
|
427
523
|
pending.timedOut = true;
|
|
428
|
-
const
|
|
524
|
+
const graceMs = policy.graceMs ?? LATE_RESPONSE_GRACE_MS;
|
|
525
|
+
const timeoutError = Object.assign(new Error(`${method} timed out`), { code: clock.observationLost ? "operation_observation_lost" : "rpc_timeout", details: { method, request_id: id, timeout_ms: timeoutMs, grace_ms: graceMs, observation: clock.state() } });
|
|
429
526
|
this.journal.write("request_deadline", timeoutError.details);
|
|
527
|
+
if (!graceMs) { this.pending.delete(id); reject(timeoutError); return; }
|
|
430
528
|
pending.timer = observedTimeout(() => {
|
|
431
529
|
if (this.pending.get(id) !== pending) return;
|
|
432
530
|
this.pending.delete(id); reject(timeoutError);
|
|
433
|
-
},
|
|
434
|
-
}, timeoutMs, { progress: () => this.pending.get(id)?.progress, onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
|
|
531
|
+
}, graceMs, { progress: () => pending.progress });
|
|
532
|
+
}, timeoutMs, { progress: () => { policy.observe?.(); return this.pending.get(id)?.progress; }, onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
|
|
435
533
|
const publish = operationProgressReporter();
|
|
436
534
|
this.pending.set(id, { id, resolve, reject, timer, method, threadId: params.threadId, turnFloor });
|
|
437
535
|
if (method === "turn/start" && params.threadId) {
|
|
@@ -467,12 +565,12 @@ async function withBridge(options, action) {
|
|
|
467
565
|
function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
|
|
468
566
|
function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
|
|
469
567
|
function directChildren(bridge, sessionId) { return typeof bridge?.directChildren === "function" ? bridge.directChildren(sessionId) : []; }
|
|
470
|
-
async function observedChildren(bridge, sessionId) {
|
|
568
|
+
async function observedChildren(bridge, sessionId, phase) {
|
|
471
569
|
const children = directChildren(bridge, sessionId);
|
|
472
570
|
let reads = 0;
|
|
473
571
|
return await Promise.all(children.map(async child => {
|
|
474
572
|
if (child.status !== "failed" || !child.terminal_turn_id || reads++ >= 16) return child;
|
|
475
|
-
const observed = await readTurn(bridge, child.provider_session_id, child.terminal_turn_id, true);
|
|
573
|
+
const observed = await readTurn(bridge, child.provider_session_id, child.terminal_turn_id, true, phase);
|
|
476
574
|
const turn = observed?.turn;
|
|
477
575
|
if (turn?.id !== child.terminal_turn_id || turn.status !== child.status) return child;
|
|
478
576
|
return { ...child, provider_error: nativeTurnError(bridge, child.provider_session_id, turn) ?? child.provider_error,
|
|
@@ -481,8 +579,8 @@ async function observedChildren(bridge, sessionId) {
|
|
|
481
579
|
}
|
|
482
580
|
function codexSessionConfig() { return { ...CODEX_SESSION_CONFIG }; }
|
|
483
581
|
|
|
484
|
-
async function requestConfiguredThread(bridge, method, params, timeoutMs) {
|
|
485
|
-
try { return await bridge.request(method, { ...params, config: codexSessionConfig() }, timeoutMs); }
|
|
582
|
+
async function requestConfiguredThread(bridge, method, params, timeoutMs, policy) {
|
|
583
|
+
try { return await bridge.request(method, { ...params, config: codexSessionConfig() }, timeoutMs, policy); }
|
|
486
584
|
catch (error) {
|
|
487
585
|
const data = error?.details?.data;
|
|
488
586
|
const key = data?.configKey ?? data?.config_key ?? null;
|
|
@@ -506,7 +604,7 @@ function threadReceipt(thread) {
|
|
|
506
604
|
|
|
507
605
|
export async function doctor(options = {}) {
|
|
508
606
|
const codex_cli = parseCodexCliVersion(await commandText(options.bin ?? process.env.DD_CODEX_BIN ?? "codex", ["--version"], { phase: 'native-version' }));
|
|
509
|
-
return await withBridge(options, async () => ({ harness: "codex-desktop", app_server: "available", observed_runtime: { codex_cli, dd_harness_contract: "dd-codex-harness@1" }, requested_profile: profile(options), observed_at: now() }));
|
|
607
|
+
return await withBridge(options, async () => ({ harness: "codex-desktop", app_server: "available", observation_scope: "version_and_initialize", observed_runtime: { codex_cli, dd_harness_contract: "dd-codex-harness@1" }, requested_profile: profile(options), observed_at: now() }));
|
|
510
608
|
}
|
|
511
609
|
|
|
512
610
|
async function observeCodexProfile(bridge, options, id, observed, source) {
|
|
@@ -548,6 +646,9 @@ export async function startSession(options) {
|
|
|
548
646
|
}
|
|
549
647
|
|
|
550
648
|
export async function startSessionWithBridge(bridge, options) {
|
|
649
|
+
options = { ...options, readPhase: codexReadPhase(bridge, options) };
|
|
650
|
+
options.readPhase.outerClock = new ObservationClock({ timeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS,
|
|
651
|
+
...options.observationClock, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, phase: "start", session_id: options.sessionId }) });
|
|
551
652
|
const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
|
|
552
653
|
await ensureThreadLoaded(bridge, sessionId, options, cwd);
|
|
553
654
|
bridge.requestedProfiles?.set(sessionId, profile(options));
|
|
@@ -555,13 +656,16 @@ export async function startSessionWithBridge(bridge, options) {
|
|
|
555
656
|
options.assertDispatch?.();
|
|
556
657
|
bridge.assertHookHealthy?.(sessionId);
|
|
557
658
|
bridge.dispatchOperationId = options.operationId;
|
|
558
|
-
const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, options.timeoutMs ?? 30_000);
|
|
659
|
+
const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, ...dispatchBudget(options.readPhase, "turn/start", options.timeoutMs ?? 30_000));
|
|
559
660
|
const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
|
|
560
661
|
if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
|
|
561
662
|
return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "started", requested_profile: profile(options), observed_profile: { model: null, reasoning: null } };
|
|
562
663
|
}
|
|
563
664
|
|
|
564
665
|
export async function promptSessionWithBridge(bridge, options) {
|
|
666
|
+
options = { ...options, readPhase: codexReadPhase(bridge, options) };
|
|
667
|
+
const clock = new ObservationClock({ timeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000, ...options.observationClock, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, phase: "prompt", session_id: options.sessionId }) });
|
|
668
|
+
options.readPhase.outerClock = clock;
|
|
565
669
|
const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
|
|
566
670
|
validateObservationDuration(options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000);
|
|
567
671
|
await ensureThreadLoaded(bridge, sessionId, options, cwd);
|
|
@@ -573,8 +677,7 @@ export async function promptSessionWithBridge(bridge, options) {
|
|
|
573
677
|
options.assertDispatch?.();
|
|
574
678
|
bridge.assertHookHealthy?.(sessionId);
|
|
575
679
|
bridge.dispatchOperationId = options.operationId;
|
|
576
|
-
const
|
|
577
|
-
const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, 30_000);
|
|
680
|
+
const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, ...dispatchBudget(options.readPhase, "turn/start", 30_000));
|
|
578
681
|
const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
|
|
579
682
|
let completed;
|
|
580
683
|
try { completed = isTerminalTurnStatus(turn?.status) ? terminalTurn({ turn }, sessionId, turnId, bridge)
|
|
@@ -599,14 +702,34 @@ export async function promptSessionWithBridge(bridge, options) {
|
|
|
599
702
|
details: { native_outcome: { provider_session_id: sessionId, turn_id: turnId, status: "failed", provider_error: error.details?.provider_error }, settled: false, observation_error: errorRecord(observationError) }, cause: error });
|
|
600
703
|
}
|
|
601
704
|
}
|
|
705
|
+
if (isObservationLoss(error)) throw Object.assign(new Error("Codex accepted Turn outcome could not be observed", { cause: error }), {
|
|
706
|
+
code: "operation_observation_lost", retryable: false, details: { provider_session_id: sessionId, turn_id: turnId,
|
|
707
|
+
operation_id: options.operationId ?? null, phase: "turn_observation", native_dispatch_started: true,
|
|
708
|
+
native_outcome: "unknown", observation_error: errorRecord(error), history_reads: Object.fromEntries(options.readPhase.history ?? []) }
|
|
709
|
+
});
|
|
602
710
|
throw error;
|
|
603
711
|
}
|
|
604
712
|
const nativeOutcome = { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "completed", turn: completed };
|
|
605
713
|
try {
|
|
606
|
-
const
|
|
714
|
+
const phase = codexReadPhase(bridge, options);
|
|
715
|
+
const history = await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns: true }, phase);
|
|
716
|
+
const thread = history.available ? history.result.thread
|
|
717
|
+
: (await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns: false }, phase)).thread;
|
|
607
718
|
bridge.assertHookHealthy?.(sessionId);
|
|
608
|
-
const
|
|
609
|
-
|
|
719
|
+
const final = bridge.finalMessages?.get(sessionId);
|
|
720
|
+
const historyTurn = thread?.turns?.find(item => item?.id === turnId);
|
|
721
|
+
const completeHistory = history.available && (turnItemEvidence(historyTurn, bridge, sessionId).observed
|
|
722
|
+
// Preserve the existing idle + readable-history final-message receipt.
|
|
723
|
+
|| completed?.turn?.synthetic === true && Array.isArray(thread.turns) && final?.turnId === turnId && thread.status?.type === "idle");
|
|
724
|
+
const assistantText = latestAssistantText(completed) ?? latestAssistantText(historyTurn)
|
|
725
|
+
?? (final?.turnId === turnId ? latestAssistantText({ items: [final.item] }) : null);
|
|
726
|
+
if (!completeHistory && (!assistantText || !turnItemEvidence(completed?.turn ?? completed, bridge, sessionId).observed))
|
|
727
|
+
fail("Codex completed but its current output/items remain unavailable", "operation_observation_lost", { history_error: history.error, provider_session_id: sessionId, turn_id: turnId });
|
|
728
|
+
if (!completeHistory && (options.model && !thread.model || options.reasoning && !thread.reasoningEffort))
|
|
729
|
+
fail("Codex completed but its applied profile remains unavailable", "operation_observation_lost", { history_error: history.error, provider_session_id: sessionId, turn_id: turnId });
|
|
730
|
+
const result = { ...nativeOutcome, ...(thread ? { thread: threadReceipt(thread) } : {}), descendants: await observedChildren(bridge, sessionId, phase), ...(assistantText ? { assistant_text: assistantText } : {}), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread?.model ?? null, reasoning: thread?.reasoningEffort ?? null, provider: thread?.modelProvider ?? null }, "thread/read.after_turn") };
|
|
731
|
+
await bridge.journal?.flush?.();
|
|
732
|
+
return result;
|
|
610
733
|
} catch (error) {
|
|
611
734
|
throw Object.assign(new Error("Codex completed natively but mandatory observation failed"), { code: "native_outcome_observation_failed", retryable: false,
|
|
612
735
|
details: { native_outcome: nativeOutcome, settled: false, observation_error: errorRecord(error) }, cause: error });
|
|
@@ -614,7 +737,7 @@ export async function promptSessionWithBridge(bridge, options) {
|
|
|
614
737
|
}
|
|
615
738
|
|
|
616
739
|
async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
|
|
617
|
-
const value = await bridge
|
|
740
|
+
const value = await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns: false }, options.readPhase);
|
|
618
741
|
const thread = value?.thread ?? value;
|
|
619
742
|
if (thread?.status?.type !== "notLoaded") return;
|
|
620
743
|
await requestConfiguredThread(bridge, "thread/resume", {
|
|
@@ -623,10 +746,14 @@ async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
|
|
|
623
746
|
model: options.model ?? null,
|
|
624
747
|
approvalPolicy: "never",
|
|
625
748
|
sandbox: "danger-full-access"
|
|
626
|
-
}, 30_000);
|
|
749
|
+
}, ...dispatchBudget(options.readPhase, "thread/resume", 30_000));
|
|
627
750
|
}
|
|
628
751
|
|
|
629
752
|
async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, clock = new ObservationClock({ timeoutMs, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId, turn_id: turnId }) }), startStatus) {
|
|
753
|
+
const readPhase = codexReadPhase(bridge, options);
|
|
754
|
+
readPhase.clock = clock;
|
|
755
|
+
readPhase.progress = () => bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId);
|
|
756
|
+
options = { ...options, readPhase };
|
|
630
757
|
// app-server can briefly expose the new Turn as `interrupted` between the
|
|
631
758
|
// turn/start response and its turn/started notification. Give that snapshot
|
|
632
759
|
// one event-loop grace window before treating failure as authoritative.
|
|
@@ -636,7 +763,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
|
|
|
636
763
|
if (status === "completed" || startStatus !== "inProgress" || bridge.turns.get(turnId)?.status === "completed") return true;
|
|
637
764
|
// A provisional failure can contradict the accepted start before its
|
|
638
765
|
// delayed notification arrives. Recheck this Turn, never start another.
|
|
639
|
-
const current = await readTurn(bridge, sessionId, turnId, true);
|
|
766
|
+
const current = await readTurn(bridge, sessionId, turnId, true, readPhase);
|
|
640
767
|
return bridge.turns.get(turnId)?.status === "completed" || current?.turn?.status === status
|
|
641
768
|
&& isAuthoritativeTerminal(bridge, turnId, status, current.threadStatus, failureNotBefore);
|
|
642
769
|
};
|
|
@@ -646,27 +773,29 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
|
|
|
646
773
|
const known = bridge.turns.get(turnId) ?? bridge.turns.get(sessionId); if (known?.status === "completed" && (!known.threadId || known.threadId === sessionId)) return terminalTurn(known.value, sessionId, turnId, bridge);
|
|
647
774
|
const expired = clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId));
|
|
648
775
|
if (expired || Date.now() >= nextStoredReadAt) {
|
|
649
|
-
const stored = await readTurn(bridge, sessionId, turnId, false);
|
|
776
|
+
const stored = await readTurn(bridge, sessionId, turnId, false, readPhase);
|
|
650
777
|
if (stored?.turn && await reconcileSnapshot(stored.turn.status, stored.threadStatus)) return terminalTurn({ turn: stored.turn }, sessionId, turnId, bridge);
|
|
651
778
|
// A loaded app-server can retain a stale `active` Thread state after a
|
|
652
779
|
// provider-side interruption. The persisted turn page is independent of
|
|
653
780
|
// that live subscription and is the compact authority for its terminal
|
|
654
|
-
// status
|
|
655
|
-
const persisted = await readPersistedTurn(bridge, sessionId, turnId);
|
|
656
|
-
if (persisted && await reconcileSnapshot(persisted.status, stored?.threadStatus)) return terminalTurn({ turn: persisted }, sessionId, turnId, bridge);
|
|
781
|
+
// status. Hydrate only when that read surface is unavailable.
|
|
782
|
+
const persisted = await readPersistedTurn(bridge, sessionId, turnId, readPhase);
|
|
783
|
+
if (persisted.turn && await reconcileSnapshot(persisted.turn.status, persisted.threadStatus ?? stored?.threadStatus)) return terminalTurn({ turn: persisted.turn }, sessionId, turnId, bridge);
|
|
784
|
+
const native = bridge.turns.get(turnId);
|
|
785
|
+
if (native?.status === "completed" && (!native.threadId || native.threadId === sessionId)) return terminalTurn(native.value, sessionId, turnId, bridge);
|
|
657
786
|
// Desktop may unload an idle Thread immediately after an interrupted
|
|
658
787
|
// Turn. Reload it once before polling again: otherwise the compact
|
|
659
788
|
// status remains `notLoaded` forever and hides the terminal Turn.
|
|
660
789
|
if (stored?.threadStatus === "notLoaded") {
|
|
661
790
|
await ensureThreadLoaded(bridge, sessionId, options, cwd);
|
|
662
|
-
const hydrated = await readTurn(bridge, sessionId, turnId, true);
|
|
791
|
+
const hydrated = await readTurn(bridge, sessionId, turnId, true, readPhase);
|
|
663
792
|
if (hydrated?.turn && await reconcileSnapshot(hydrated.turn.status, hydrated.threadStatus)) return terminalTurn({ turn: hydrated.turn }, sessionId, turnId, bridge);
|
|
664
793
|
}
|
|
665
794
|
// Some app-server versions correctly omit turns for a compact read.
|
|
666
795
|
// Once the thread is idle, hydrate exactly once to recover the terminal
|
|
667
796
|
// turn instead of waiting for the global timeout.
|
|
668
797
|
if (stored?.threadStatus === "idle") {
|
|
669
|
-
const hydrated = await readTurn(bridge, sessionId, turnId, true);
|
|
798
|
+
const hydrated = persisted.hydrated ? persisted : await readTurn(bridge, sessionId, turnId, true, readPhase);
|
|
670
799
|
// "idle" only describes the Thread. Some app-server builds retain
|
|
671
800
|
// an inProgress Turn after emitting the final agent message; treating
|
|
672
801
|
// that stale Turn as terminal incorrectly aborts an otherwise usable
|
|
@@ -681,7 +810,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
|
|
|
681
810
|
// reasoning and calling tools. They are terminal evidence only after
|
|
682
811
|
// app-server reports the Thread idle.
|
|
683
812
|
if (final?.turnId === turnId && stored?.threadStatus === "idle" && Date.now() - final.completedAt >= 1_000) {
|
|
684
|
-
const hydrated = await readTurn(bridge, sessionId, turnId, true);
|
|
813
|
+
const hydrated = await readTurn(bridge, sessionId, turnId, true, readPhase);
|
|
685
814
|
if (hydrated?.turn && await reconcileSnapshot(hydrated.turn.status, hydrated.threadStatus)) return terminalTurn({ turn: hydrated.turn }, sessionId, turnId, bridge);
|
|
686
815
|
if (hydrated?.threadStatus === "idle" && !isTerminalTurnStatus(hydrated.turn?.status))
|
|
687
816
|
return terminalTurn({ turn: { id: turnId, status: "completed", synthetic: true, final_message: final.item } }, sessionId, turnId, bridge);
|
|
@@ -689,7 +818,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
|
|
|
689
818
|
nextStoredReadAt = Date.now() < failureNotBefore ? failureNotBefore : Date.now() + 15_000;
|
|
690
819
|
}
|
|
691
820
|
const terminal = bridge.turns.get(turnId); if (terminal?.status === "completed" && (!terminal.threadId || terminal.threadId === sessionId)) return terminalTurn(terminal.value, sessionId, turnId, bridge);
|
|
692
|
-
if (expired && clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId))) fail("Codex native outcome remains unknown after its silence window", "operation_observation_lost", { provider_session_id: sessionId, turn_id: turnId, native_outcome: "unknown" });
|
|
821
|
+
if (expired && clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId))) fail("Codex native outcome remains unknown after its silence window", "operation_observation_lost", { provider_session_id: sessionId, turn_id: turnId, operation_id: options.operationId ?? null, native_dispatch_started: true, phase: "turn_observation", native_outcome: "unknown", history_reads: Object.fromEntries(readPhase.history ?? []) });
|
|
693
822
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
694
823
|
}
|
|
695
824
|
}
|
|
@@ -744,29 +873,23 @@ function isAuthoritativeTerminal(bridge, turnId, status, threadStatus, failureNo
|
|
|
744
873
|
return !(bridge.turns.get(turnId)?.status === "running" && threadStatus === "active");
|
|
745
874
|
}
|
|
746
875
|
|
|
747
|
-
async function readTurn(bridge, sessionId, turnId, includeTurns) {
|
|
876
|
+
async function readTurn(bridge, sessionId, turnId, includeTurns, phase) {
|
|
748
877
|
if (!turnId) return null;
|
|
749
878
|
try {
|
|
750
|
-
const
|
|
879
|
+
const history = includeTurns ? await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns }, phase) : null;
|
|
880
|
+
if (history && !history.available) return { turn: null, threadStatus: null, historyAvailable: false };
|
|
881
|
+
const result = history?.result ?? await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns }, phase);
|
|
751
882
|
const thread = result?.thread ?? result;
|
|
752
883
|
return { turn: Array.isArray(thread?.turns) ? thread.turns.find((turn) => turn?.id === turnId) ?? null : null, threadStatus: thread?.status?.type ?? null };
|
|
753
|
-
} catch { bridge.assertHookHealthy?.(sessionId);
|
|
884
|
+
} catch (error) { bridge.assertHookHealthy?.(sessionId); throw error; }
|
|
754
885
|
}
|
|
755
886
|
|
|
756
|
-
async function readPersistedTurn(bridge, sessionId, turnId) {
|
|
887
|
+
async function readPersistedTurn(bridge, sessionId, turnId, phase) {
|
|
757
888
|
try {
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
async function readCompletedThread(bridge, sessionId) {
|
|
764
|
-
let lastError;
|
|
765
|
-
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
766
|
-
try { const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000); return result?.thread ?? result; }
|
|
767
|
-
catch (error) { lastError = error; bridge.assertHookHealthy?.(sessionId); bridge.journal.write("thread_read_retry", { session_id: sessionId, attempt, error: errorRecord(error) }); await new Promise((resolve) => setTimeout(resolve, 150)); }
|
|
768
|
-
}
|
|
769
|
-
throw lastError;
|
|
889
|
+
const history = await readCodexHistory(bridge, "thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, phase);
|
|
890
|
+
if (!history.available) return { ...await readTurn(bridge, sessionId, turnId, true, phase), hydrated: true };
|
|
891
|
+
return { available: history.available, turn: history.result?.data.find((turn) => turn?.id === turnId) ?? null };
|
|
892
|
+
} catch (error) { bridge.assertHookHealthy?.(sessionId); throw error; }
|
|
770
893
|
}
|
|
771
894
|
|
|
772
895
|
function latestAssistantText(value) {
|
|
@@ -789,10 +912,11 @@ export async function inspectSession(options) {
|
|
|
789
912
|
}
|
|
790
913
|
|
|
791
914
|
export async function inspectSessionWithBridge(bridge, options) {
|
|
792
|
-
const
|
|
793
|
-
const
|
|
915
|
+
const phase = options.readPhase ?? codexReadPhase(bridge, options);
|
|
916
|
+
const sessionId = required(options.sessionId, "--session-id"); const value = await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns: false }, phase); const thread = value?.thread ?? value;
|
|
917
|
+
const terminal = options.turnId ? retainedCodexTurn(bridge, sessionId, options.turnId) ?? (await readTurn(bridge, sessionId, options.turnId, true, phase))?.turn : null;
|
|
794
918
|
const exact = terminal?.id === options.turnId && terminal?.status === "failed" ? { terminal_turn_id: terminal.id, terminal_status: terminal.status, provider_error: nativeTurnError(bridge, sessionId, terminal), native_turn_items: turnItemEvidence(terminal, bridge, sessionId) } : {};
|
|
795
|
-
return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, status: thread.status ?? null, thread: threadReceipt(thread), ...exact, descendants: await observedChildren(bridge, sessionId), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread.model ?? null, reasoning: thread.reasoningEffort ?? null, provider: thread.modelProvider ?? null }, "thread/read") };
|
|
919
|
+
return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, status: thread.status ?? null, thread: threadReceipt(thread), ...exact, descendants: await observedChildren(bridge, sessionId, phase), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread.model ?? null, reasoning: thread.reasoningEffort ?? null, provider: thread.modelProvider ?? null }, "thread/read") };
|
|
796
920
|
}
|
|
797
921
|
|
|
798
922
|
export async function cancelSession(options) {
|
|
@@ -800,12 +924,21 @@ export async function cancelSession(options) {
|
|
|
800
924
|
}
|
|
801
925
|
|
|
802
926
|
export async function cancelSessionWithBridge(bridge, options) {
|
|
927
|
+
const phase = options.readPhase ?? codexReadPhase(bridge, options);
|
|
803
928
|
const sessionId = required(options.sessionId, "--session-id");
|
|
804
929
|
let turnId = options.turnId ?? null;
|
|
805
930
|
if (!turnId) {
|
|
806
|
-
const
|
|
807
|
-
const
|
|
808
|
-
turnId =
|
|
931
|
+
const currentId = bridge.currentTurns?.get(sessionId);
|
|
932
|
+
const retained = retainedCodexTurn(bridge, sessionId, currentId);
|
|
933
|
+
turnId = currentId && !isTerminalTurnStatus(retained?.status) ? currentId : null;
|
|
934
|
+
if (!turnId) {
|
|
935
|
+
const history = await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns: true }, phase);
|
|
936
|
+
if (!history.available || !Array.isArray(history.result.thread.turns)
|
|
937
|
+
|| history.result.thread.turns.some(turn => typeof turn?.id !== "string" || !turn.id.trim()
|
|
938
|
+
|| turn.status !== "inProgress" && !isTerminalTurnStatus(turn.status)))
|
|
939
|
+
fail("Codex active Turn cannot be established from incomplete history", "operation_observation_lost", { provider_session_id: sessionId, native_outcome: "unknown", history_error: history.error });
|
|
940
|
+
turnId = [...history.result.thread.turns].reverse().find((turn) => turn.status === "inProgress")?.id ?? null;
|
|
941
|
+
}
|
|
809
942
|
}
|
|
810
943
|
if (!turnId) fail("Codex Session has no active Turn to interrupt", "turn_not_active");
|
|
811
944
|
await bridge.request("turn/interrupt", { threadId: sessionId, turnId });
|