botmux 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.runtime-build-id +1 -1
- package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
- package/dist/adapters/backend/riff-backend.js +11 -2
- package/dist/adapters/backend/riff-backend.js.map +1 -1
- package/dist/adapters/cli/coco.js +3 -3
- package/dist/adapters/cli/coco.js.map +1 -1
- package/dist/adapters/cli/codex.js +2 -2
- package/dist/adapters/cli/codex.js.map +1 -1
- package/dist/cli/dashboard-command.d.ts +23 -0
- package/dist/cli/dashboard-command.d.ts.map +1 -0
- package/dist/cli/dashboard-command.js +61 -0
- package/dist/cli/dashboard-command.js.map +1 -0
- package/dist/cli/dashboard-endpoint.d.ts +3 -3
- package/dist/cli/dashboard-endpoint.d.ts.map +1 -1
- package/dist/cli/dashboard-endpoint.js +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +36 -22
- package/dist/cli.js.map +1 -1
- package/dist/codex-rpc-engine.d.ts +33 -3
- package/dist/codex-rpc-engine.d.ts.map +1 -1
- package/dist/codex-rpc-engine.js +182 -20
- package/dist/codex-rpc-engine.js.map +1 -1
- package/dist/codex-rpc-lifecycle.d.ts +42 -7
- package/dist/codex-rpc-lifecycle.d.ts.map +1 -1
- package/dist/codex-rpc-lifecycle.js +73 -7
- package/dist/codex-rpc-lifecycle.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/core/command-handler.d.ts +3 -2
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +3 -2
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +15 -10
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/dispatch.d.ts +9 -10
- package/dist/core/dispatch.d.ts.map +1 -1
- package/dist/core/dispatch.js +17 -10
- package/dist/core/dispatch.js.map +1 -1
- package/dist/core/role-resolver.d.ts +5 -2
- package/dist/core/role-resolver.d.ts.map +1 -1
- package/dist/core/role-resolver.js +55 -23
- package/dist/core/role-resolver.js.map +1 -1
- package/dist/core/session-manager.d.ts +2 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +20 -18
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/terminal-write-auth.d.ts +4 -4
- package/dist/core/terminal-write-auth.d.ts.map +1 -1
- package/dist/core/terminal-write-auth.js +18 -6
- package/dist/core/terminal-write-auth.js.map +1 -1
- package/dist/daemon.d.ts +1 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +101 -12
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/auth.d.ts +21 -11
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +39 -25
- package/dist/dashboard/auth.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +0 -1
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +0 -1
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/dashboard-summary-endpoint.d.ts +39 -0
- package/dist/dashboard/dashboard-summary-endpoint.d.ts.map +1 -0
- package/dist/dashboard/dashboard-summary-endpoint.js +50 -0
- package/dist/dashboard/dashboard-summary-endpoint.js.map +1 -0
- package/dist/dashboard/dashboard-summary.d.ts +62 -0
- package/dist/dashboard/dashboard-summary.d.ts.map +1 -0
- package/dist/dashboard/dashboard-summary.js +106 -0
- package/dist/dashboard/dashboard-summary.js.map +1 -0
- package/dist/dashboard/web/feed-group-picker.d.ts +13 -0
- package/dist/dashboard/web/feed-group-picker.d.ts.map +1 -0
- package/dist/dashboard/web/feed-group-picker.js +45 -0
- package/dist/dashboard/web/feed-group-picker.js.map +1 -0
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +2 -5
- package/dist/dashboard/web/groups-page.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +8 -14
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/roles-page.d.ts.map +1 -1
- package/dist/dashboard/web/roles-page.js +36 -4
- package/dist/dashboard/web/roles-page.js.map +1 -1
- package/dist/dashboard/web/roles.d.ts +3 -1
- package/dist/dashboard/web/roles.d.ts.map +1 -1
- package/dist/dashboard/web/roles.js +10 -2
- package/dist/dashboard/web/roles.js.map +1 -1
- package/dist/dashboard/web/schedules-page.d.ts.map +1 -1
- package/dist/dashboard/web/schedules-page.js +8 -2
- package/dist/dashboard/web/schedules-page.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +2 -2
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/{bot-defaults-page-GED4FEMS.js → bot-defaults-page-USC6EZXP.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-6ALR252E.js +1 -0
- package/dist/dashboard-web/chunks/{chunk-VBXJOJFH.js → chunk-A7VC4BXR.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-HK7NIBJG.js → chunk-KOPISPHN.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-6J5LS5AU.js → chunk-M6BHWQFO.js} +6 -6
- package/dist/dashboard-web/chunks/{chunk-KRHWZEXU.js → chunk-RXRJB5SO.js} +1 -1
- package/dist/dashboard-web/chunks/{connectors-page-EELM66G3.js → connectors-page-4Z76HWRH.js} +1 -1
- package/dist/dashboard-web/chunks/groups-page-C6NSDTIN.js +8 -0
- package/dist/dashboard-web/chunks/{insights-page-54UBPPOC.js → insights-page-UTOVYLLY.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-PORHVKRH.js → monitor-room-UKTJYTA2.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-XGA7AXNL.js → monitoring-page-Q3CEZIVX.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-AO3AFG45.js → office-page-GYCZOOVJ.js} +1 -1
- package/dist/dashboard-web/chunks/{overview-page-F5BUTYR6.js → overview-page-W2VBJ6OH.js} +1 -1
- package/dist/dashboard-web/chunks/roles-page-U4SHER4K.js +1 -0
- package/dist/dashboard-web/chunks/schedules-page-TNPW3N3Q.js +1 -0
- package/dist/dashboard-web/chunks/{sessions-page-YFRPSTIL.js → sessions-page-YWWDDV57.js} +3 -3
- package/dist/dashboard-web/chunks/{settings-page-OJAZLZQT.js → settings-page-VKW5ZK5K.js} +1 -1
- package/dist/dashboard-web/chunks/{skills-page-EFZQPWV4.js → skills-page-ZCEDFGZZ.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-ZJVAP7HW.js → team-federation-page-OMFGGBT2.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-GYWTRGO2.js → v3-page-PLXWK4KO.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-MSDAG6P7.js → whiteboards-page-NJQOEO2P.js} +1 -1
- package/dist/dashboard-web/style.css +112 -0
- package/dist/dashboard.js +116 -31
- package/dist/dashboard.js.map +1 -1
- package/dist/desktop/main/ipc.js +1 -1
- package/dist/desktop/main/runtime-service.js +2 -2
- package/dist/desktop/main/runtime-service.js.map +1 -1
- package/dist/desktop/shared/types.d.ts +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +3 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +3 -1
- package/dist/i18n/zh.js.map +1 -1
- package/dist/services/adopt-input-sequence.d.ts +52 -0
- package/dist/services/adopt-input-sequence.d.ts.map +1 -0
- package/dist/services/adopt-input-sequence.js +50 -0
- package/dist/services/adopt-input-sequence.js.map +1 -0
- package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
- package/dist/services/bridge-fallback-gate.js +34 -11
- package/dist/services/bridge-fallback-gate.js.map +1 -1
- package/dist/services/codex-bridge-queue.d.ts +103 -7
- package/dist/services/codex-bridge-queue.d.ts.map +1 -1
- package/dist/services/codex-bridge-queue.js +291 -17
- package/dist/services/codex-bridge-queue.js.map +1 -1
- package/dist/services/codex-transcript.d.ts +4 -3
- package/dist/services/codex-transcript.d.ts.map +1 -1
- package/dist/services/codex-transcript.js.map +1 -1
- package/dist/services/structured-bridge-clis.d.ts +8 -1
- package/dist/services/structured-bridge-clis.d.ts.map +1 -1
- package/dist/services/structured-bridge-clis.js +13 -0
- package/dist/services/structured-bridge-clis.js.map +1 -1
- package/dist/services/submit-confirmation.d.ts +50 -0
- package/dist/services/submit-confirmation.d.ts.map +1 -1
- package/dist/services/submit-confirmation.js +82 -0
- package/dist/services/submit-confirmation.js.map +1 -1
- package/dist/utils/async-serial-queue.d.ts +9 -0
- package/dist/utils/async-serial-queue.d.ts.map +1 -0
- package/dist/utils/async-serial-queue.js +13 -0
- package/dist/utils/async-serial-queue.js.map +1 -0
- package/dist/utils/runtime-screen-status.d.ts +52 -0
- package/dist/utils/runtime-screen-status.d.ts.map +1 -0
- package/dist/utils/runtime-screen-status.js +60 -0
- package/dist/utils/runtime-screen-status.js.map +1 -0
- package/dist/worker.js +1642 -452
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +0 -8
- package/dist/dashboard-web/chunks/roles-page-M5GIASJU.js +0 -1
- package/dist/dashboard-web/chunks/schedules-page-O232WQRR.js +0 -1
package/dist/worker.js
CHANGED
|
@@ -36,7 +36,7 @@ import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirs
|
|
|
36
36
|
import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
|
|
37
37
|
import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
|
|
38
38
|
import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
|
|
39
|
-
import { EffortConfirmDialogGuard
|
|
39
|
+
import { EffortConfirmDialogGuard } from './utils/effort-confirm-dialog.js';
|
|
40
40
|
import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
|
|
41
41
|
import { handoffQueuedDurableInputsOnBackendExit, mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldArmSpawnArgvInitialPromptBusy, shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
|
|
42
42
|
import { riffWorkerShutdownInputBlocker } from './core/riff-worker-shutdown-readiness.js';
|
|
@@ -58,7 +58,7 @@ import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
|
|
|
58
58
|
import { InflightInputTracker } from './core/inflight-input-tracker.js';
|
|
59
59
|
import { InputTurnDeduper } from './core/input-turn-dedupe.js';
|
|
60
60
|
import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, shouldHealAbsentBaseline, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
|
|
61
|
-
import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
|
|
61
|
+
import { CodexBridgeQueue, pruneExpiredPreStartHeadsAndEmit } from './services/codex-bridge-queue.js';
|
|
62
62
|
import { detectCodexComposerState } from './services/codex-composer-state.js';
|
|
63
63
|
import { generateCodexAppThreadTitle, readCodexAppThreadMetadata, setCodexAppThreadName, } from './services/codex-app-threads.js';
|
|
64
64
|
import { buildBotmuxLarkNativeSessionTitle } from './core/session-title.js';
|
|
@@ -74,7 +74,7 @@ import { currentMtrSessionOffset, drainMtrSession, findLatestMtrSessionByDirecto
|
|
|
74
74
|
import { drainPiTranscript } from './services/pi-transcript.js';
|
|
75
75
|
import { drainGrokUpdates, findGrokSessionByPid, grokSessionIdFromPath, } from './services/grok-transcript.js';
|
|
76
76
|
import { resolveFileBridgePath } from './services/file-bridge-path.js';
|
|
77
|
-
import { isStructuredBridgeAdoptIdleCli, isStructuredBridgeAdoptInputCli, isStructuredBridgeFallbackActive, } from './services/structured-bridge-clis.js';
|
|
77
|
+
import { isStructuredBridgeAdoptIdleCli, isStructuredBridgeAdoptInputCli, isStructuredBridgeFallbackActive, isStructuredBridgeLifecycleBlockingCli, } from './services/structured-bridge-clis.js';
|
|
78
78
|
import { drainCursorTranscript, findCursorChatIdByPid, findCursorTranscriptByChatId, findCursorTranscriptByPid } from './services/cursor-transcript.js';
|
|
79
79
|
import { shouldObserveCursorChatId, shouldPersistObservedCursorChatId } from './services/cursor-resume-policy.js';
|
|
80
80
|
import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
|
|
@@ -93,7 +93,7 @@ import { createCliAdapterSync, locateOnPath } from './adapters/cli/registry.js';
|
|
|
93
93
|
import { buildWrappedLaunch, parseWrapperCli, isTtadkWrapper } from './setup/cli-selection.js';
|
|
94
94
|
import { cliUnavailableMessage } from './setup/cli-availability.js';
|
|
95
95
|
import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind, settleLaunchComm, } from './core/session-discovery.js';
|
|
96
|
-
import { codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane } from './codex-rpc-lifecycle.js';
|
|
96
|
+
import { CODEX_RPC_TERMINAL_HYDRATION_DELAYS_MS, RpcEngagementFence, codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane, rpcTranscriptIngestBlockedByAwaitingActivation } from './codex-rpc-lifecycle.js';
|
|
97
97
|
import { delay } from './utils/timing.js';
|
|
98
98
|
import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
|
|
99
99
|
import { sessionReadyHookCommand } from './adapters/hook-command.js';
|
|
@@ -129,7 +129,8 @@ import { parseWorkerRequestUrl } from './utils/worker-http.js';
|
|
|
129
129
|
import { detectCliUsageLimit, usageLimitStateKey, structuredRateLimitState, isStructuredRateLimitAuthoritative } from './utils/cli-usage-limit.js';
|
|
130
130
|
import { uploadImageBuffer } from './utils/lark-upload.js';
|
|
131
131
|
import { redactChildEnv, scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js';
|
|
132
|
-
import { decideSubmitConfirmationAction } from './services/submit-confirmation.js';
|
|
132
|
+
import { decideSubmitConfirmationAction, settleDeferredSubmitConfirmation, settleStaleWriteContinuation, } from './services/submit-confirmation.js';
|
|
133
|
+
import { runAdoptQueuedWriteSequence, runAdoptRawInputSequence, runAdoptSessionRenameSequence, } from './services/adopt-input-sequence.js';
|
|
133
134
|
import { config, resolveChatBotDiscoveryConfig } from './config.js';
|
|
134
135
|
import * as sessionStore from './services/session-store.js';
|
|
135
136
|
import * as pty from 'node-pty';
|
|
@@ -142,7 +143,9 @@ import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.
|
|
|
142
143
|
import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
|
|
143
144
|
import { normalizeCodexAppLifecycleEvent, normalizeFinalUsage, } from './services/codex-app-runner-protocol.js';
|
|
144
145
|
import { hasMatchingManagedOriginCapability, ensureManagedOriginAttestationDirectory, ensureManagedOriginCapabilityLeafSafe, ensureManagedOriginDataRootProbe, ensureManagedOriginIsolationSentinel, ensureManagedOriginRootLocator, managedOriginLegacyIsolationProbeAccess, managedOriginDataRootProbeAccess, managedOriginIsolationSentinelAccess, managedOriginAttestationDirectory, managedOriginCapabilityPath, managedOriginRootLocatorPath, readManagedOriginAuthorityFile, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, sweepManagedOriginAttestationProofs, } from './core/managed-origin-capability.js';
|
|
145
|
-
import { CodexRpcEngine } from './codex-rpc-engine.js';
|
|
146
|
+
import { CodexRpcEngine, } from './codex-rpc-engine.js';
|
|
147
|
+
import { beginRuntimeWriteCycle, isCliBackendGenerationCurrent, PtyOutputGeneration, projectRuntimeScreenStatus, } from './utils/runtime-screen-status.js';
|
|
148
|
+
import { AsyncSerialQueue } from './utils/async-serial-queue.js';
|
|
146
149
|
// A worker must never trust an INHERITED session-level CLI home pointer
|
|
147
150
|
// (CLAUDE_CONFIG_DIR / CODEX_HOME): a stale pm2 dump can resurrect the daemon
|
|
148
151
|
// with a sibling bot's home baked in (see the index-daemon.ts boot scrub —
|
|
@@ -216,16 +219,184 @@ function cleanupPiInitialPromptFiles() {
|
|
|
216
219
|
piInitialPromptAdditionalArgs = [];
|
|
217
220
|
piInitialPromptEnv = {};
|
|
218
221
|
}
|
|
222
|
+
const rpcEngagementFence = new RpcEngagementFence();
|
|
223
|
+
const pendingRpcTurnTerminals = new Map();
|
|
224
|
+
const rpcTurnsAwaitingActivation = new Map();
|
|
225
|
+
const rpcTurnsAwaitingActivationIdentities = new Map();
|
|
226
|
+
/** Local send-start anchor for each exact awaiting owner. A predecessor's
|
|
227
|
+
* terminal hydration may ingest this successor's transcript before turn/start
|
|
228
|
+
* ACK returns. Reusing the send-start time when the bridge mark is installed
|
|
229
|
+
* keeps that already-buffered user/final pair inside its original bounded
|
|
230
|
+
* attribution window without widening replay for unrelated turns. */
|
|
231
|
+
const rpcTurnsAwaitingActivationReplayAnchors = new Map();
|
|
232
|
+
/** A bridge mark/activation failure after an accepted RPC submit must never
|
|
233
|
+
* degrade to ready. Keep a separate fail-closed gate until that exact native
|
|
234
|
+
* turn reaches terminal or the engine is torn down. */
|
|
235
|
+
const rpcLifecycleFailClosedOwners = new Map();
|
|
236
|
+
const rpcLifecycleFailClosedIdentities = new Map();
|
|
237
|
+
const rpcActiveOwners = new Map();
|
|
238
|
+
const rpcTerminalHydrationOwners = new Map();
|
|
239
|
+
const rpcTerminalHydrationTimers = new Map();
|
|
240
|
+
const rpcTerminalHydrationTerminals = new Map();
|
|
241
|
+
const settlingRpcTerminalOwners = new Map();
|
|
242
|
+
let deferredFreshRpcTurn;
|
|
243
|
+
function rpcTurnOwnerKey(identity) {
|
|
244
|
+
return `${identity.turnId}\0${identity.dispatchAttempt ?? ''}`;
|
|
245
|
+
}
|
|
246
|
+
function sameRpcGeneration(left, right) {
|
|
247
|
+
return left?.engine === right.engine
|
|
248
|
+
&& left.cliGeneration === right.cliGeneration;
|
|
249
|
+
}
|
|
250
|
+
function installRpcLifecycleFailClosedOwner(identity, generation) {
|
|
251
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
252
|
+
rpcLifecycleFailClosedOwners.set(ownerKey, generation);
|
|
253
|
+
rpcLifecycleFailClosedIdentities.set(ownerKey, identity);
|
|
254
|
+
}
|
|
255
|
+
function installAwaitingRpcActivation(identity, generation) {
|
|
256
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
257
|
+
rpcTurnsAwaitingActivation.set(ownerKey, generation);
|
|
258
|
+
rpcTurnsAwaitingActivationIdentities.set(ownerKey, identity);
|
|
259
|
+
rpcTurnsAwaitingActivationReplayAnchors.set(ownerKey, Date.now());
|
|
260
|
+
}
|
|
261
|
+
function awaitingRpcActivationReplayAnchorMs(identity, generation) {
|
|
262
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
263
|
+
if (!sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
return rpcTurnsAwaitingActivationReplayAnchors.get(ownerKey);
|
|
267
|
+
}
|
|
268
|
+
function clearRpcLifecycleFailClosedOwner(identity, generation) {
|
|
269
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
270
|
+
if (!sameRpcGeneration(rpcLifecycleFailClosedOwners.get(ownerKey), generation)) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
rpcLifecycleFailClosedOwners.delete(ownerKey);
|
|
274
|
+
rpcLifecycleFailClosedIdentities.delete(ownerKey);
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
function clearAwaitingRpcActivation(identity, generation) {
|
|
278
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
279
|
+
if (sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
|
|
280
|
+
rpcTurnsAwaitingActivation.delete(ownerKey);
|
|
281
|
+
rpcTurnsAwaitingActivationIdentities.delete(ownerKey);
|
|
282
|
+
rpcTurnsAwaitingActivationReplayAnchors.delete(ownerKey);
|
|
283
|
+
}
|
|
284
|
+
const pending = pendingRpcTurnTerminals.get(ownerKey);
|
|
285
|
+
if (pending && sameRpcGeneration(pending.generation, generation)) {
|
|
286
|
+
pendingRpcTurnTerminals.delete(ownerKey);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function notifyRpcTeardownBeforeActivation(identity, terminalStatus) {
|
|
290
|
+
const completedBeforeActivation = terminalStatus === 'completed';
|
|
291
|
+
send({
|
|
292
|
+
type: 'user_notify',
|
|
293
|
+
message: completedBeforeActivation
|
|
294
|
+
? 'Codex RPC 消息已执行完成,但会话在本地完成生命周期登记前重启,兜底输出可能未被捕获;原消息未自动重发,如未看到回复请先查看终端结果再人工跟进。'
|
|
295
|
+
: 'Codex RPC 消息已写出,但会话在取得 turn/start 归属前重启;为避免重复执行未自动重发,请按需人工确认结果。',
|
|
296
|
+
turnId: identity.turnId,
|
|
297
|
+
...(identity.dispatchAttempt !== undefined
|
|
298
|
+
? { dispatchAttempt: identity.dispatchAttempt }
|
|
299
|
+
: {}),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
219
302
|
function stopCodexRpcEngine() {
|
|
303
|
+
// Invalidate even when the engine has not yet been published: engageCodexRpc
|
|
304
|
+
// may be awaiting a local engine/thread/probe while another IPC handler starts
|
|
305
|
+
// a restart. That stale continuation must never republish the stopped engine.
|
|
306
|
+
rpcEngagementFence.invalidate();
|
|
220
307
|
const engine = codexRpcEngine;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
308
|
+
const ownedRpcTurns = new Set([
|
|
309
|
+
...rpcTurnsAwaitingActivation.keys(),
|
|
310
|
+
...rpcLifecycleFailClosedOwners.keys(),
|
|
311
|
+
...rpcActiveOwners.keys(),
|
|
312
|
+
...settlingRpcTerminalOwners.keys(),
|
|
313
|
+
]);
|
|
314
|
+
// stop() emits exact 'stopped' terminals for every engine-owned native turn.
|
|
315
|
+
// Keep the engine identity installed until those synchronous callbacks have
|
|
316
|
+
// retired their matching rpcActive entries.
|
|
225
317
|
try {
|
|
226
318
|
engine?.stop();
|
|
227
319
|
}
|
|
228
320
|
catch { /* best effort */ }
|
|
321
|
+
// A native terminal can arrive synchronously from engine.stop() while its
|
|
322
|
+
// turn/start continuation is still awaiting activation. handleRpcTurnTerminal
|
|
323
|
+
// buffers that exact terminal; settle it now, before teardown clears the
|
|
324
|
+
// awaiting/pending maps, so an accepted RPC delivery never loses its only
|
|
325
|
+
// terminal at an operator or in-worker restart boundary.
|
|
326
|
+
for (const [ownerKey, pending] of [...pendingRpcTurnTerminals]) {
|
|
327
|
+
const awaiting = rpcTurnsAwaitingActivation.get(ownerKey);
|
|
328
|
+
if (!awaiting || !sameRpcGeneration(awaiting, pending.generation))
|
|
329
|
+
continue;
|
|
330
|
+
const identity = rpcTurnsAwaitingActivationIdentities.get(ownerKey);
|
|
331
|
+
const shouldNotify = !rpcLifecycleFailClosedOwners.has(ownerKey);
|
|
332
|
+
settleRpcTurnTerminal(pending.terminal, pending.generation);
|
|
333
|
+
if (identity && shouldNotify) {
|
|
334
|
+
notifyRpcTeardownBeforeActivation(identity, pending.terminal.status);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// A follow-up can be accepted by the socket but lose its turn/start response
|
|
338
|
+
// while an operator or liveness restart tears down the engine. RPC submissions
|
|
339
|
+
// intentionally bypass InflightInputTracker (replay would risk duplicate
|
|
340
|
+
// execution), so close every still-unbound logical owner explicitly instead
|
|
341
|
+
// of letting its stale continuation assume an ordinary carryover exists.
|
|
342
|
+
for (const [ownerKey, identity] of [...rpcTurnsAwaitingActivationIdentities]) {
|
|
343
|
+
const awaiting = rpcTurnsAwaitingActivation.get(ownerKey);
|
|
344
|
+
if (!awaiting)
|
|
345
|
+
continue;
|
|
346
|
+
clearAwaitingRpcActivation(identity, awaiting);
|
|
347
|
+
emitTurnTerminal(identity.turnId, 'ambiguous', 'rpc_engine_teardown_before_turn_start_ack', identity.dispatchAttempt);
|
|
348
|
+
if (!rpcLifecycleFailClosedOwners.has(ownerKey)) {
|
|
349
|
+
notifyRpcTeardownBeforeActivation(identity);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const pending of [...rpcTerminalHydrationTerminals.values()]) {
|
|
353
|
+
finalizeRpcTurnTerminal(pending.terminal, pending.generation, true);
|
|
354
|
+
}
|
|
355
|
+
// A dispatched delivery for which the app-server never yielded a native turn
|
|
356
|
+
// id cannot receive engine.stop()'s native terminal callback. Publish one
|
|
357
|
+
// exact logical terminal before clearing the owner so the daemon's durable
|
|
358
|
+
// ledger never waits forever on a teardown-only attempt.
|
|
359
|
+
for (const [ownerKey, identity] of rpcLifecycleFailClosedIdentities) {
|
|
360
|
+
if (!rpcLifecycleFailClosedOwners.has(ownerKey))
|
|
361
|
+
continue;
|
|
362
|
+
emitTurnTerminal(identity.turnId, 'ambiguous', 'rpc_engine_teardown_without_native_terminal', identity.dispatchAttempt);
|
|
363
|
+
}
|
|
364
|
+
codexRpcEngine = undefined;
|
|
365
|
+
remoteWsUrl = undefined;
|
|
366
|
+
remoteThreadId = undefined;
|
|
367
|
+
clearRpcEnginePidMarker();
|
|
368
|
+
// Defensive cleanup for a protocol/transport failure that never yielded a
|
|
369
|
+
// native id. No RPC turn may survive an engine teardown holding the lifecycle
|
|
370
|
+
// gate open.
|
|
371
|
+
for (const turn of codexBridgeQueue.peek()) {
|
|
372
|
+
if (turn.finalText === undefined
|
|
373
|
+
&& (turn.rpcActive
|
|
374
|
+
|| ownedRpcTurns.has(rpcTurnOwnerKey({
|
|
375
|
+
turnId: turn.turnId,
|
|
376
|
+
...(turn.dispatchAttempt !== undefined
|
|
377
|
+
? { dispatchAttempt: turn.dispatchAttempt }
|
|
378
|
+
: {}),
|
|
379
|
+
})))) {
|
|
380
|
+
codexBridgeQueue.stopRpcActive(turn.turnId, turn.dispatchAttempt);
|
|
381
|
+
codexBridgeQueue.dropPendingTurn(turn.turnId, turn.dispatchAttempt, true);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
pendingRpcTurnTerminals.clear();
|
|
385
|
+
rpcTurnsAwaitingActivation.clear();
|
|
386
|
+
rpcTurnsAwaitingActivationIdentities.clear();
|
|
387
|
+
rpcTurnsAwaitingActivationReplayAnchors.clear();
|
|
388
|
+
rpcLifecycleFailClosedOwners.clear();
|
|
389
|
+
rpcLifecycleFailClosedIdentities.clear();
|
|
390
|
+
rpcActiveOwners.clear();
|
|
391
|
+
for (const timer of rpcTerminalHydrationTimers.values())
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
rpcTerminalHydrationTimers.clear();
|
|
394
|
+
rpcTerminalHydrationTerminals.clear();
|
|
395
|
+
rpcTerminalHydrationOwners.clear();
|
|
396
|
+
settlingRpcTerminalOwners.clear();
|
|
397
|
+
deferredFreshRpcTurn = undefined;
|
|
398
|
+
stopStructuredStartGraceRecheck();
|
|
399
|
+
structuredRejectedReadyEvidenceGeneration = undefined;
|
|
229
400
|
}
|
|
230
401
|
/** Resolve the persistent pane name + liveness for a backend type, mirroring
|
|
231
402
|
* spawnCli's willReattachPersistent probe (worker.ts) but callable from the init
|
|
@@ -566,8 +737,24 @@ async function engageCodexRpc(cfg) {
|
|
|
566
737
|
return 'not-engaged';
|
|
567
738
|
const wantResume = cfg.resume === true && !!cfg.cliSessionId;
|
|
568
739
|
stopCodexRpcEngine();
|
|
740
|
+
const engagementLease = rpcEngagementFence.begin();
|
|
569
741
|
let engine;
|
|
742
|
+
let enginePidMarker = null;
|
|
743
|
+
let freshDeliveryOwned = false;
|
|
744
|
+
const assertRpcEngagementCurrent = () => {
|
|
745
|
+
if (!rpcEngagementFence.isCurrent(engagementLease)) {
|
|
746
|
+
throw new CliSpawnSupersededError();
|
|
747
|
+
}
|
|
748
|
+
if (!rpcEngagementFence.isLive(engagementLease)) {
|
|
749
|
+
throw new CodexRpcEngineDiedDuringEngagementError();
|
|
750
|
+
}
|
|
751
|
+
};
|
|
570
752
|
try {
|
|
753
|
+
// engage runs immediately before spawnCli, which advances the viewer
|
|
754
|
+
// generation once. Bind every callback from this app-server to that target
|
|
755
|
+
// generation so a late terminal from an old server can never retire a
|
|
756
|
+
// same-(turnId,attempt) entry installed by its replacement.
|
|
757
|
+
const engineCliGeneration = cliSpawnGeneration + 1;
|
|
571
758
|
const cliBin = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride).resolvedBin;
|
|
572
759
|
const engineEnv = { ...redactChildEnv(process.env) };
|
|
573
760
|
engineEnv.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), engineEnv.PATH);
|
|
@@ -609,7 +796,21 @@ async function engageCodexRpc(cfg) {
|
|
|
609
796
|
onRequestUserInput: cfg.cliId === 'traex'
|
|
610
797
|
? (params) => bridgeTraexUserInput(cfg, params)
|
|
611
798
|
: undefined,
|
|
799
|
+
onTurnTerminal: (terminal) => {
|
|
800
|
+
if (!engine)
|
|
801
|
+
return;
|
|
802
|
+
handleRpcTurnTerminal(terminal, {
|
|
803
|
+
engine,
|
|
804
|
+
cliGeneration: engineCliGeneration,
|
|
805
|
+
});
|
|
806
|
+
},
|
|
612
807
|
onDead: () => {
|
|
808
|
+
// Death can race after the final awaited response but before the engine
|
|
809
|
+
// is published below. Record it against this exact engagement even when
|
|
810
|
+
// codexRpcEngine is still undefined; otherwise the continuation can
|
|
811
|
+
// publish a permanently dead/deadNotified engine whose later failures
|
|
812
|
+
// can no longer trigger onDead recovery.
|
|
813
|
+
rpcEngagementFence.markDead(engagementLease);
|
|
613
814
|
if (codexRpcEngine === engine) {
|
|
614
815
|
log('Codex RPC app-server died; replacing the tmux session and re-engaging the thread');
|
|
615
816
|
// failAll() rejects the active sendTurn promise immediately after this
|
|
@@ -627,18 +828,37 @@ async function engageCodexRpc(cfg) {
|
|
|
627
828
|
},
|
|
628
829
|
});
|
|
629
830
|
await engine.start();
|
|
831
|
+
assertRpcEngagementCurrent();
|
|
630
832
|
// Shell tools run under the app-server process tree, not the viewer TUI.
|
|
631
833
|
// Mark its pid before the first turn so `botmux send` resolves the current
|
|
632
834
|
// per-turn identity instead of falling back to a stale/session-only env.
|
|
633
|
-
registerRpcEnginePidMarker(engine.appServerPid);
|
|
835
|
+
enginePidMarker = registerRpcEnginePidMarker(engine.appServerPid);
|
|
634
836
|
const threadId = wantResume ? await engine.resumeThread(cfg.cliSessionId) : await engine.startThread();
|
|
837
|
+
assertRpcEngagementCurrent();
|
|
635
838
|
let outcome = wantResume ? 'resumed' : 'accepted';
|
|
636
839
|
if (!wantResume && cfg.prompt) {
|
|
840
|
+
const firstIdentity = {
|
|
841
|
+
turnId: cfg.turnId ?? `codex-rpc-${randomBytes(8).toString('hex')}`,
|
|
842
|
+
...(cfg.dispatchAttempt !== undefined
|
|
843
|
+
? { dispatchAttempt: cfg.dispatchAttempt }
|
|
844
|
+
: {}),
|
|
845
|
+
};
|
|
846
|
+
const firstGeneration = {
|
|
847
|
+
engine,
|
|
848
|
+
cliGeneration: engineCliGeneration,
|
|
849
|
+
};
|
|
850
|
+
installAwaitingRpcActivation(firstIdentity, firstGeneration);
|
|
637
851
|
// Three-state delivery (P1-1, exactly-once priority): 'accepted' (ack or
|
|
638
852
|
// rollout evidence), 'not-sent' (frame never dispatched → safe paste), or
|
|
639
853
|
// 'ambiguous' (dispatched, unconfirmed → engaged but NEVER resend).
|
|
640
|
-
const first = await engine.sendFirstTurn(cfg.prompt,
|
|
641
|
-
|
|
854
|
+
const first = await engine.sendFirstTurn(cfg.prompt, firstIdentity, (tid) => codexRolloutProbe(cfg.cliId, tid, cfg.prompt, 12_000));
|
|
855
|
+
freshDeliveryOwned = first.outcome !== 'not-sent';
|
|
856
|
+
// restart/close may have settled this exact attempt as ambiguous while the
|
|
857
|
+
// rollout probe was pending. Fence before ANY durable/bridge/global engine
|
|
858
|
+
// mutation; a superseded delivery is never pasted or re-queued.
|
|
859
|
+
assertRpcEngagementCurrent();
|
|
860
|
+
if (first.outcome === 'not-sent') {
|
|
861
|
+
clearAwaitingRpcActivation(firstIdentity, firstGeneration);
|
|
642
862
|
// The turn/start frame never left → the turn cannot have run → tear the
|
|
643
863
|
// engine down and fall back to paste. flushPending marks the bridge once
|
|
644
864
|
// on the paste path — we must NOT pre-mark here or that would double-mark
|
|
@@ -651,16 +871,69 @@ async function engageCodexRpc(cfg) {
|
|
|
651
871
|
catch { /* best effort */ }
|
|
652
872
|
return 'not-engaged';
|
|
653
873
|
}
|
|
874
|
+
// Fresh RPC delivery bypasses flushPending(), which is the normal owner
|
|
875
|
+
// of this durable head-of-line gate. Claim it here only after the frame is
|
|
876
|
+
// known dispatched (accepted or ambiguous); the not-sent branch above
|
|
877
|
+
// safely falls back to paste and lets flushPending claim it once.
|
|
878
|
+
if (firstIdentity.dispatchAttempt !== undefined) {
|
|
879
|
+
durableTurnInFlight = true;
|
|
880
|
+
}
|
|
654
881
|
// Bridge mark ONLY for a confirmed-accepted turn — so the structured
|
|
655
882
|
// fallback can attribute the reply even if the model skips `botmux send`.
|
|
656
883
|
// Marked here (after the outcome, before persistCliSessionId/attach — late
|
|
657
|
-
// attach from offset 0 still matches, timing-safe).
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
if (shouldPreMarkFirstTurn(first))
|
|
662
|
-
|
|
663
|
-
|
|
884
|
+
// attach from offset 0 still matches, timing-safe). An ambiguous outcome
|
|
885
|
+
// gets an attribution-only mark plus a separate fail-closed owner below:
|
|
886
|
+
// it cannot publish false idle or head-of-line a successor because no
|
|
887
|
+
// successor is admitted until terminal/teardown retires that owner.
|
|
888
|
+
if (shouldPreMarkFirstTurn(first.outcome)) {
|
|
889
|
+
if (!first.nativeTurnId) {
|
|
890
|
+
// Positive rollout evidence proves delivery, but without a native id
|
|
891
|
+
// no terminal can be associated precisely. Preserve exactly-once
|
|
892
|
+
// (never paste/requeue) and hold the explicit fail-closed lifecycle
|
|
893
|
+
// gate until a structured terminal or engine teardown instead of
|
|
894
|
+
// guessing a latest turn.
|
|
895
|
+
codexBridgeMarkPendingTurn(cfg.prompt, firstIdentity.turnId, firstIdentity.dispatchAttempt, awaitingRpcActivationReplayAnchorMs(firstIdentity, firstGeneration));
|
|
896
|
+
installRpcLifecycleFailClosedOwner(firstIdentity, firstGeneration);
|
|
897
|
+
deferredFreshRpcTurn = {
|
|
898
|
+
identity: firstIdentity,
|
|
899
|
+
generation: firstGeneration,
|
|
900
|
+
};
|
|
901
|
+
log(`Codex RPC fresh accepted turn has no native id; lifecycle failed closed for ${firstIdentity.turnId}`);
|
|
902
|
+
send({
|
|
903
|
+
type: 'user_notify',
|
|
904
|
+
message: 'Codex RPC 首条消息已确认落盘,但无法取得原生 turn id;为避免重复执行未重发,并保持忙碌直到检测到终态或会话重启。',
|
|
905
|
+
turnId: firstIdentity.turnId,
|
|
906
|
+
...(firstIdentity.dispatchAttempt !== undefined
|
|
907
|
+
? { dispatchAttempt: firstIdentity.dispatchAttempt }
|
|
908
|
+
: {}),
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
// Keep terminal delivery deferred until spawnCli has attached and
|
|
913
|
+
// baselined the rollout; otherwise an ultra-fast first completion can
|
|
914
|
+
// retire the bridge mark before fallback output is harvested.
|
|
915
|
+
activateRpcTurnLifecycle(firstIdentity, cfg.prompt, false, firstGeneration, true);
|
|
916
|
+
deferredFreshRpcTurn = {
|
|
917
|
+
identity: firstIdentity,
|
|
918
|
+
generation: firstGeneration,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
// A dispatched-but-unconfirmed first turn is never re-sent. Keep an
|
|
924
|
+
// exact attribution mark plus an explicit fail-closed gate even without
|
|
925
|
+
// a native id. A structured transcript terminal can retire it; a later
|
|
926
|
+
// native terminal without an owner is intentionally ignored instead of
|
|
927
|
+
// guessed. If no structured terminal becomes visible, exact engine
|
|
928
|
+
// teardown is the expected fallback and drops this attempt's mark.
|
|
929
|
+
codexBridgeMarkPendingTurn(cfg.prompt, firstIdentity.turnId, firstIdentity.dispatchAttempt, awaitingRpcActivationReplayAnchorMs(firstIdentity, firstGeneration));
|
|
930
|
+
installRpcLifecycleFailClosedOwner(firstIdentity, firstGeneration);
|
|
931
|
+
deferredFreshRpcTurn = {
|
|
932
|
+
identity: firstIdentity,
|
|
933
|
+
generation: firstGeneration,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
if (first.outcome === 'accepted' && cfg.queuedActivationToken) {
|
|
664
937
|
// Fresh RPC input bypasses pendingMessages/flushPending entirely. The
|
|
665
938
|
// app-server's accepted turn/start (or positive rollout proof) is the
|
|
666
939
|
// durable submission boundary for the daemon's queued-opening journal.
|
|
@@ -670,7 +943,7 @@ async function engageCodexRpc(cfg) {
|
|
|
670
943
|
activationToken: cfg.queuedActivationToken,
|
|
671
944
|
});
|
|
672
945
|
}
|
|
673
|
-
outcome = first; //
|
|
946
|
+
outcome = first.outcome; // accepted | ambiguous — both stay engaged, prompt never re-queued
|
|
674
947
|
}
|
|
675
948
|
codexRpcEngine = engine;
|
|
676
949
|
remoteWsUrl = engine.wsUrl;
|
|
@@ -683,15 +956,43 @@ async function engageCodexRpc(cfg) {
|
|
|
683
956
|
return outcome;
|
|
684
957
|
}
|
|
685
958
|
catch (err) {
|
|
959
|
+
if (err instanceof CliSpawnSupersededError
|
|
960
|
+
|| !rpcEngagementFence.isCurrent(engagementLease)) {
|
|
961
|
+
log('Codex RPC engagement was superseded; stopping only its local app-server and preserving the replacement generation');
|
|
962
|
+
try {
|
|
963
|
+
engine?.stop();
|
|
964
|
+
}
|
|
965
|
+
catch { /* best effort */ }
|
|
966
|
+
if (enginePidMarker)
|
|
967
|
+
clearRpcEnginePidMarker(enginePidMarker);
|
|
968
|
+
throw err instanceof CliSpawnSupersededError
|
|
969
|
+
? err
|
|
970
|
+
: new CliSpawnSupersededError();
|
|
971
|
+
}
|
|
972
|
+
if (err instanceof CodexRpcEngineDiedDuringEngagementError && freshDeliveryOwned) {
|
|
973
|
+
// The first frame may already have executed. Never turn this into
|
|
974
|
+
// `not-engaged`, because the init path would paste the same prompt into a
|
|
975
|
+
// native viewer and duplicate side effects. Tear down exact local state
|
|
976
|
+
// and fail the worker generation so durable recovery owns the next step.
|
|
977
|
+
log('Codex RPC app-server died after the fresh delivery boundary; aborting init without paste fallback');
|
|
978
|
+
clearRpcEnginePidMarker();
|
|
979
|
+
try {
|
|
980
|
+
engine?.stop();
|
|
981
|
+
}
|
|
982
|
+
catch { /* best effort */ }
|
|
983
|
+
stopCodexRpcEngine();
|
|
984
|
+
throw err;
|
|
985
|
+
}
|
|
686
986
|
log(`Codex RPC input failed to start (${err?.message ?? err}); falling back to paste mode`);
|
|
687
987
|
clearRpcEnginePidMarker();
|
|
688
988
|
try {
|
|
689
989
|
engine?.stop();
|
|
690
990
|
}
|
|
691
991
|
catch { /* best effort */ } // P1-3a: stop the LOCAL ref (codexRpcEngine may be unassigned)
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
992
|
+
// The local engine may not yet have been published into codexRpcEngine.
|
|
993
|
+
// Still clear any exact lifecycle state its callbacks installed before the
|
|
994
|
+
// failure; otherwise paste fallback could inherit a stale fail-closed mark.
|
|
995
|
+
stopCodexRpcEngine();
|
|
695
996
|
return 'not-engaged';
|
|
696
997
|
}
|
|
697
998
|
}
|
|
@@ -754,6 +1055,9 @@ function armRpcStartupDialogDismiss() {
|
|
|
754
1055
|
};
|
|
755
1056
|
rpcDialogDismissTimer = setTimeout(tick, 2500);
|
|
756
1057
|
}
|
|
1058
|
+
/** Monotonic identity for the owned/adopted CLI backend generation. Deferred
|
|
1059
|
+
* callbacks capture it and must re-check after every await before touching
|
|
1060
|
+
* transcript lifecycle or user-visible state. */
|
|
757
1061
|
let cliSpawnGeneration = 0;
|
|
758
1062
|
class CliSpawnSupersededError extends Error {
|
|
759
1063
|
constructor() {
|
|
@@ -761,6 +1065,12 @@ class CliSpawnSupersededError extends Error {
|
|
|
761
1065
|
this.name = 'CliSpawnSupersededError';
|
|
762
1066
|
}
|
|
763
1067
|
}
|
|
1068
|
+
class CodexRpcEngineDiedDuringEngagementError extends Error {
|
|
1069
|
+
constructor() {
|
|
1070
|
+
super('Codex RPC app-server died before engagement publication');
|
|
1071
|
+
this.name = 'CodexRpcEngineDiedDuringEngagementError';
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
764
1074
|
let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
|
|
765
1075
|
let seatbeltProfilePath = null; // per-session Seatbelt .sb profile to rm at exit (external-wrapper read isolation)
|
|
766
1076
|
let sandboxStopWatcher = null; // stop fn for the sandbox outbox watcher
|
|
@@ -1045,6 +1355,10 @@ let activeRestartAttemptId;
|
|
|
1045
1355
|
/** Distinguishes a replacement's synchronous ready signal (Riff) from a late
|
|
1046
1356
|
* idle callback emitted by the backend being torn down. */
|
|
1047
1357
|
let replacementSpawnInProgress = false;
|
|
1358
|
+
let structuredStartGraceRecheckTimer = null;
|
|
1359
|
+
let structuredRejectedReadyEvidenceGeneration;
|
|
1360
|
+
const ptyOutputGeneration = new PtyOutputGeneration();
|
|
1361
|
+
const adoptWriteQueue = new AsyncSerialQueue();
|
|
1048
1362
|
/** The effectiveResume flag used by the most recent spawnCli call. Written
|
|
1049
1363
|
* immediately after the two-tier fallback check so late-attach timers
|
|
1050
1364
|
* (hermes, cursor, etc.) can read THE SAME semantics the spawn used,
|
|
@@ -1527,6 +1841,25 @@ function shortCorrelationId(value) {
|
|
|
1527
1841
|
* an owned CLI restart was fenced. Normal raw_input commands are still
|
|
1528
1842
|
* delivered immediately (including while busy). */
|
|
1529
1843
|
const pendingRawInputs = freshnessInputQueue.raw;
|
|
1844
|
+
/** Adopt messages accepted while native /rename owns the TUI. They cannot use
|
|
1845
|
+
* pendingMessages because adopt writes need writeAdoptMessage's transcript
|
|
1846
|
+
* baseline + complete adapter lifecycle. flushPending drains them only after
|
|
1847
|
+
* deferred raw commands and the latest native rename have settled. */
|
|
1848
|
+
const pendingAdoptMessages = [];
|
|
1849
|
+
function captureAdoptWriteFence() {
|
|
1850
|
+
if (!backend || cliRestartInProgress || rawInputRestartGate)
|
|
1851
|
+
return undefined;
|
|
1852
|
+
return { generation: cliSpawnGeneration, backend };
|
|
1853
|
+
}
|
|
1854
|
+
/** A queued adopt task may outlive its CLI. Never let process-lifetime queue
|
|
1855
|
+
* work run against backend=null or a replacement backend generation. Tasks
|
|
1856
|
+
* that fail this check have not written anything and are safe to requeue. */
|
|
1857
|
+
function adoptWriteFenceIsCurrent(fence) {
|
|
1858
|
+
return cliSpawnGeneration === fence.generation
|
|
1859
|
+
&& backend === fence.backend
|
|
1860
|
+
&& !cliRestartInProgress
|
|
1861
|
+
&& !rawInputRestartGate;
|
|
1862
|
+
}
|
|
1530
1863
|
/** Latest requested canonical session title. Unlike a normal prompt this is an
|
|
1531
1864
|
* administrative TUI command: never type-ahead while the agent is busy, never
|
|
1532
1865
|
* open a model turn, and latest-wins if several renames arrive before idle. */
|
|
@@ -1546,32 +1879,45 @@ function stopNativeSessionTitleSync() {
|
|
|
1546
1879
|
forceClose();
|
|
1547
1880
|
nativeSessionTitleSyncForceClosers.clear();
|
|
1548
1881
|
}
|
|
1549
|
-
|
|
1550
|
-
* prompt. Blocks type-ahead user messages from racing into the command UI. */
|
|
1551
|
-
let sessionRenameInFlight = false;
|
|
1882
|
+
let sessionRenamePhase = 'idle';
|
|
1552
1883
|
const SESSION_RENAME_IDLE_TIMEOUT_MS = 5_000;
|
|
1553
1884
|
let sessionRenameIdleTimer = null;
|
|
1554
|
-
function
|
|
1885
|
+
function sessionRenameInFlight() {
|
|
1886
|
+
return sessionRenamePhase !== 'idle';
|
|
1887
|
+
}
|
|
1888
|
+
function forceClearSessionRenameInFlight() {
|
|
1555
1889
|
if (sessionRenameIdleTimer) {
|
|
1556
1890
|
clearTimeout(sessionRenameIdleTimer);
|
|
1557
1891
|
sessionRenameIdleTimer = null;
|
|
1558
1892
|
}
|
|
1559
|
-
|
|
1893
|
+
sessionRenamePhase = 'idle';
|
|
1894
|
+
}
|
|
1895
|
+
/** A prompt can release rename only after the command Enter was actually sent.
|
|
1896
|
+
* A fast final from an older adopt write may arrive while rename is merely
|
|
1897
|
+
* reserved behind the queue; keep that reservation and let the queued helper
|
|
1898
|
+
* consume the newly-ready prompt. */
|
|
1899
|
+
function settleSessionRenameOnPrompt() {
|
|
1900
|
+
if (sessionRenamePhase === 'sent')
|
|
1901
|
+
forceClearSessionRenameInFlight();
|
|
1902
|
+
}
|
|
1903
|
+
function settleFailedSessionRenameWrite() {
|
|
1904
|
+
if (sessionRenamePhase === 'writing')
|
|
1905
|
+
sessionRenamePhase = 'sent';
|
|
1560
1906
|
}
|
|
1561
1907
|
/** Fail open if a CLI executes /rename without emitting enough redraw output
|
|
1562
1908
|
* for IdleDetector to rediscover the prompt. The verified Codex/Claude paths
|
|
1563
1909
|
* normally settle immediately; this cap prevents one administrative command
|
|
1564
1910
|
* from wedging all later Lark messages forever. */
|
|
1565
1911
|
function armSessionRenameIdleTimeout() {
|
|
1566
|
-
if (
|
|
1912
|
+
if (sessionRenamePhase !== 'sent')
|
|
1567
1913
|
return;
|
|
1568
1914
|
if (sessionRenameIdleTimer)
|
|
1569
1915
|
clearTimeout(sessionRenameIdleTimer);
|
|
1570
1916
|
sessionRenameIdleTimer = setTimeout(() => {
|
|
1571
1917
|
sessionRenameIdleTimer = null;
|
|
1572
|
-
if (
|
|
1918
|
+
if (sessionRenamePhase !== 'sent')
|
|
1573
1919
|
return;
|
|
1574
|
-
|
|
1920
|
+
sessionRenamePhase = 'idle';
|
|
1575
1921
|
// The timeout specifically means prompt redraw detection failed. Fail open
|
|
1576
1922
|
// by restoring the gate explicitly; otherwise a deferred raw_input is the
|
|
1577
1923
|
// only queued work and flushPending would keep waiting for the very signal
|
|
@@ -1587,96 +1933,147 @@ function armSessionRenameIdleTimeout() {
|
|
|
1587
1933
|
* Commands received while /rename owns the TUI are deferred by the IPC handler
|
|
1588
1934
|
* and come through this same function after the prompt returns. */
|
|
1589
1935
|
async function deliverRawInput(msg) {
|
|
1590
|
-
const
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1936
|
+
const writeRawInput = async (targetBackend, fence, onFollowUp) => {
|
|
1937
|
+
let sent = false;
|
|
1938
|
+
let recoveryFailureReason;
|
|
1939
|
+
try {
|
|
1940
|
+
await sendRawCommandLineWithRecoveryFence(targetBackend, msg.content, () => {
|
|
1941
|
+
renderer?.markNewTurn();
|
|
1942
|
+
usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
1943
|
+
if (tmuxScrolledHalfPages > 0)
|
|
1944
|
+
exitTmuxScrollMode();
|
|
1945
|
+
currentBotmuxTurnId = msg.turnId;
|
|
1946
|
+
currentBotmuxDispatchAttempt = undefined;
|
|
1947
|
+
currentVcMeetingImTurnOrigin = undefined;
|
|
1948
|
+
writeCliPidMarker();
|
|
1949
|
+
publishSandboxRelayCapability();
|
|
1950
|
+
});
|
|
1951
|
+
sent = true;
|
|
1952
|
+
if (fence && !adoptWriteFenceIsCurrent(fence)) {
|
|
1953
|
+
send({
|
|
1954
|
+
type: 'user_notify',
|
|
1955
|
+
message: `Passthrough command ${msg.content} became ambiguous because the adopted CLI backend changed while it was being written. Its dependent follow-up was withheld; please verify the terminal and retry the bundle explicitly.`,
|
|
1956
|
+
});
|
|
1957
|
+
sent = false;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
catch (err) {
|
|
1961
|
+
recoveryFailureReason = err instanceof SubmissionWriteError
|
|
1962
|
+
? err.recoveryFailureReason
|
|
1963
|
+
: undefined;
|
|
1964
|
+
// Do not send a bundled follow-up or another queued command against a
|
|
1965
|
+
// backend whose literal command write failed.
|
|
1966
|
+
log(`Passthrough slash command failed (${msg.content}): ${err?.message ?? err}`);
|
|
1967
|
+
const failedTurnId = msg.followUpTurnId ?? msg.turnId;
|
|
1968
|
+
if (failedTurnId && !recoveryFailureReason) {
|
|
1969
|
+
emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed');
|
|
1970
|
+
}
|
|
1971
|
+
send({
|
|
1972
|
+
type: 'user_notify',
|
|
1973
|
+
...(failedTurnId ? { turnId: failedTurnId } : {}),
|
|
1974
|
+
message: t(msg.followUpContent
|
|
1975
|
+
? (recoveryFailureReason ? 'worker.raw_input_failed_recovery' : 'worker.raw_input_failed')
|
|
1976
|
+
: (recoveryFailureReason ? 'worker.raw_input_failed_command_only_recovery' : 'worker.raw_input_failed_command_only'), {
|
|
1977
|
+
cliName: cliName(),
|
|
1978
|
+
reason: recoveryFailureReason ?? '',
|
|
1979
|
+
}),
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
const accepted = finalizeRawCommandDelivery({
|
|
1983
|
+
accepted: sent,
|
|
1984
|
+
durableActivation: !!msg.queuedActivationToken,
|
|
1985
|
+
acknowledgeActivation: !!msg.queuedActivationToken,
|
|
1986
|
+
hasFollowUp: !!onFollowUp,
|
|
1987
|
+
onAccepted: () => {
|
|
1988
|
+
isPromptReady = false;
|
|
1989
|
+
idleDetector?.reset();
|
|
1990
|
+
log(`Passthrough slash command: ${msg.content}`);
|
|
1991
|
+
},
|
|
1992
|
+
// Non-adopt follows the normal busy queue. Adopt keeps the complete
|
|
1993
|
+
// adapter lifecycle in runAdoptRawInputSequence below.
|
|
1994
|
+
onFollowUp: () => onFollowUp?.(),
|
|
1995
|
+
// Pending-repo raw openings are durable too. ACK only after text + Enter.
|
|
1996
|
+
onActivationAck: () => send({
|
|
1997
|
+
type: 'queued_activation_submitted',
|
|
1998
|
+
sessionId,
|
|
1999
|
+
activationToken: msg.queuedActivationToken,
|
|
1644
2000
|
}),
|
|
2001
|
+
onDurableFailure: () => {
|
|
2002
|
+
isPromptReady = false;
|
|
2003
|
+
log('Durable raw activation write rejected; retiring worker generation');
|
|
2004
|
+
void sendFatalWorkerErrorAndExit(new Error('durable raw activation was not accepted by the backend'), msg.turnId);
|
|
2005
|
+
},
|
|
2006
|
+
});
|
|
2007
|
+
if (!accepted && !msg.queuedActivationToken) {
|
|
2008
|
+
log(`Passthrough slash command was not accepted by the backend: ${msg.content}`);
|
|
2009
|
+
}
|
|
2010
|
+
return accepted;
|
|
2011
|
+
};
|
|
2012
|
+
if (lastInitConfig?.adoptMode) {
|
|
2013
|
+
const fence = captureAdoptWriteFence();
|
|
2014
|
+
if (!fence) {
|
|
2015
|
+
pendingRawInputs.push(msg);
|
|
2016
|
+
log(`Deferred adopt passthrough until a stable CLI generation is ready: ${msg.content}`);
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
const followUpContent = msg.followUpContent;
|
|
2020
|
+
// Adopt raw commands, their bundled follow-up and ordinary adopt messages
|
|
2021
|
+
// all share one queue. Keep the queue until writeAdoptMessage has completed
|
|
2022
|
+
// transcript marking, adapter/history verification and lifecycle settling.
|
|
2023
|
+
await runAdoptRawInputSequence({
|
|
2024
|
+
queue: adoptWriteQueue,
|
|
2025
|
+
isCurrent: () => adoptWriteFenceIsCurrent(fence),
|
|
2026
|
+
onStaleBeforeWrite: () => {
|
|
2027
|
+
pendingRawInputs.push(msg);
|
|
2028
|
+
log(`Re-queued stale adopt passthrough for the replacement CLI generation: ${msg.content}`);
|
|
2029
|
+
},
|
|
2030
|
+
onStaleBeforeFollowUp: () => {
|
|
2031
|
+
if (!followUpContent)
|
|
2032
|
+
return;
|
|
2033
|
+
// The raw command already landed on the previous backend. Replaying
|
|
2034
|
+
// only its dependent prompt into the replacement could run in the
|
|
2035
|
+
// wrong repo/session (for example after `/cd ...`). Hold the bundle
|
|
2036
|
+
// for an explicit user retry instead of splitting its atomic meaning.
|
|
2037
|
+
send({
|
|
2038
|
+
type: 'user_notify',
|
|
2039
|
+
message: `Passthrough command ${msg.content} completed on the previous CLI, but the backend changed before its dependent follow-up. The follow-up was withheld; verify the terminal and retry the bundle explicitly.`,
|
|
2040
|
+
});
|
|
2041
|
+
log(`Withheld bundled adopt follow-up after CLI generation changed (${followUpContent.length} chars)`);
|
|
2042
|
+
},
|
|
2043
|
+
writeRawInput: () => writeRawInput(fence.backend, fence),
|
|
2044
|
+
...(followUpContent
|
|
2045
|
+
? {
|
|
2046
|
+
writeFollowUp: async () => {
|
|
2047
|
+
const result = await writeAdoptMessage(followUpContent, msg.followUpTurnId, undefined, undefined, fence);
|
|
2048
|
+
if (result === 'stale-before-write') {
|
|
2049
|
+
send({
|
|
2050
|
+
type: 'user_notify',
|
|
2051
|
+
message: `The CLI backend changed after passthrough command ${msg.content} but before its dependent follow-up. The follow-up was withheld; verify the terminal and retry the bundle explicitly.`,
|
|
2052
|
+
});
|
|
2053
|
+
log(`Withheld stale bundled adopt follow-up after raw command (${followUpContent.length} chars)`);
|
|
2054
|
+
}
|
|
2055
|
+
else if (result === 'completed') {
|
|
2056
|
+
log(`Completed adopt follow-up after raw input (${followUpContent.length} chars)`);
|
|
2057
|
+
}
|
|
2058
|
+
},
|
|
2059
|
+
}
|
|
2060
|
+
: {}),
|
|
1645
2061
|
});
|
|
1646
2062
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
sendToPty(msg.followUpContent, msg.followUpTurnId, {
|
|
1662
|
-
codexAppInput: msg.followUpCodexAppInput,
|
|
1663
|
-
});
|
|
1664
|
-
log(`Enqueued follow-up after raw input (${msg.followUpContent.length} chars)`);
|
|
1665
|
-
},
|
|
1666
|
-
// Pending-repo raw openings are durable too. ACK only after text + Enter.
|
|
1667
|
-
onActivationAck: () => send({
|
|
1668
|
-
type: 'queued_activation_submitted',
|
|
1669
|
-
sessionId,
|
|
1670
|
-
activationToken: msg.queuedActivationToken,
|
|
1671
|
-
}),
|
|
1672
|
-
onDurableFailure: () => {
|
|
1673
|
-
isPromptReady = false;
|
|
1674
|
-
log(`Durable raw activation write rejected; retiring worker generation`);
|
|
1675
|
-
void sendFatalWorkerErrorAndExit(new Error('durable raw activation was not accepted by the backend'), msg.turnId);
|
|
1676
|
-
},
|
|
1677
|
-
});
|
|
1678
|
-
if (!sent && !msg.queuedActivationToken) {
|
|
1679
|
-
log(`Passthrough slash command was not accepted by the backend: ${msg.content}`);
|
|
2063
|
+
else {
|
|
2064
|
+
const targetBackend = backend;
|
|
2065
|
+
if (!targetBackend) {
|
|
2066
|
+
pendingRawInputs.push(msg);
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
await writeRawInput(targetBackend, undefined, msg.followUpContent
|
|
2070
|
+
? () => {
|
|
2071
|
+
sendToPty(msg.followUpContent, msg.followUpTurnId, {
|
|
2072
|
+
codexAppInput: msg.followUpCodexAppInput,
|
|
2073
|
+
});
|
|
2074
|
+
log(`Enqueued follow-up after raw input (${msg.followUpContent.length} chars)`);
|
|
2075
|
+
}
|
|
2076
|
+
: undefined);
|
|
1680
2077
|
}
|
|
1681
2078
|
// A pending /rename may have been held by the command-write mutex. It still
|
|
1682
2079
|
// waits for a genuine prompt because isPromptReady was cleared above.
|
|
@@ -1860,7 +2257,9 @@ function authorizeManagedSend(claim) {
|
|
|
1860
2257
|
}
|
|
1861
2258
|
: { ok: false, error: `${decision.errorCode}: ${decision.error}` };
|
|
1862
2259
|
}
|
|
1863
|
-
function clearRpcEnginePidMarker() {
|
|
2260
|
+
function clearRpcEnginePidMarker(expectedMarker) {
|
|
2261
|
+
if (expectedMarker !== undefined && rpcEnginePidMarker !== expectedMarker)
|
|
2262
|
+
return;
|
|
1864
2263
|
if (!rpcEnginePidMarker)
|
|
1865
2264
|
return;
|
|
1866
2265
|
try {
|
|
@@ -1872,17 +2271,19 @@ function clearRpcEnginePidMarker() {
|
|
|
1872
2271
|
function registerRpcEnginePidMarker(pid) {
|
|
1873
2272
|
clearRpcEnginePidMarker();
|
|
1874
2273
|
if (!pid || !process.env.SESSION_DATA_DIR)
|
|
1875
|
-
return;
|
|
2274
|
+
return null;
|
|
1876
2275
|
try {
|
|
1877
2276
|
const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
|
|
1878
2277
|
mkdirSync(markersDir, { recursive: true });
|
|
1879
2278
|
rpcEnginePidMarker = join(markersDir, String(pid));
|
|
1880
2279
|
writeCliPidMarker();
|
|
1881
2280
|
log(`Codex RPC app-server PID marker written: ${pid}`);
|
|
2281
|
+
return rpcEnginePidMarker;
|
|
1882
2282
|
}
|
|
1883
2283
|
catch (err) {
|
|
1884
2284
|
rpcEnginePidMarker = null;
|
|
1885
2285
|
log(`Failed to write Codex RPC app-server PID marker: ${err?.message ?? err}`);
|
|
2286
|
+
return null;
|
|
1886
2287
|
}
|
|
1887
2288
|
}
|
|
1888
2289
|
function writeCliPidMarker() {
|
|
@@ -4346,6 +4747,23 @@ function codexBridgeFallbackActive() {
|
|
|
4346
4747
|
// services/structured-bridge-clis.ts (single source of the allowlist).
|
|
4347
4748
|
return isStructuredBridgeFallbackActive(lastInitConfig?.cliId, lastInitConfig?.adoptMode === true);
|
|
4348
4749
|
}
|
|
4750
|
+
/** Only drivers with a complete normal-final + interrupted-terminal contract
|
|
4751
|
+
* may let a transcript-started turn override the screen-ready heuristic. */
|
|
4752
|
+
function hasStructuredLifecycleBlock() {
|
|
4753
|
+
if (rpcTurnsAwaitingActivation.size > 0)
|
|
4754
|
+
return true;
|
|
4755
|
+
if (rpcLifecycleFailClosedOwners.size > 0)
|
|
4756
|
+
return true;
|
|
4757
|
+
if (rpcTerminalHydrationOwners.size > 0)
|
|
4758
|
+
return true;
|
|
4759
|
+
// rpcActive is an explicit native app-server ownership proof and is valid for
|
|
4760
|
+
// every RPC-capable Codex-family adapter (currently codex + traex), including
|
|
4761
|
+
// drivers intentionally excluded from transcript-only lifecycle blocking.
|
|
4762
|
+
if (codexBridgeQueue.peek().some(turn => turn.rpcActive === true && turn.finalText === undefined))
|
|
4763
|
+
return true;
|
|
4764
|
+
return isStructuredBridgeLifecycleBlockingCli(lastInitConfig?.cliId)
|
|
4765
|
+
&& codexBridgeQueue.hasBlockingTurn();
|
|
4766
|
+
}
|
|
4349
4767
|
function structuredBridgeIsCodex() {
|
|
4350
4768
|
return lastInitConfig?.cliId === 'codex';
|
|
4351
4769
|
}
|
|
@@ -4400,7 +4818,10 @@ function codexBridgeStartTimer() {
|
|
|
4400
4818
|
// up. Emitting here when isPromptReady=true closes that window.
|
|
4401
4819
|
// Codex's queue only releases turns on `assistant_final` (the model's
|
|
4402
4820
|
// declared end-of-turn), so a tick-driven emit can't accidentally
|
|
4403
|
-
// publish a half-streamed response.
|
|
4821
|
+
// publish a half-streamed response. The finally-path also expires stale
|
|
4822
|
+
// attribution heads after every tick even when no transcript exists yet or
|
|
4823
|
+
// its offset produced no events; otherwise an adapter returning undefined
|
|
4824
|
+
// before late-attach could leave a bare fingerprint at the head forever.
|
|
4404
4825
|
codexBridgeTimer = setInterval(() => {
|
|
4405
4826
|
try {
|
|
4406
4827
|
if (structuredBridgeIsHermes()) {
|
|
@@ -4512,6 +4933,18 @@ function codexBridgeStartTimer() {
|
|
|
4512
4933
|
catch (err) {
|
|
4513
4934
|
log(`Codex bridge tick error: ${err.message}`);
|
|
4514
4935
|
}
|
|
4936
|
+
finally {
|
|
4937
|
+
// All branch returns above still pass through here. Ingest always gets
|
|
4938
|
+
// first chance to claim a boundary-time user event; only afterwards do
|
|
4939
|
+
// we expire an unstarted head, replay buffered successors, and emit any
|
|
4940
|
+
// completion created by that replay in the same call stack.
|
|
4941
|
+
try {
|
|
4942
|
+
pruneExpiredStructuredHeadsAndEmit('structured bridge tick');
|
|
4943
|
+
}
|
|
4944
|
+
catch (err) {
|
|
4945
|
+
log(`Codex bridge tick expiry error: ${err.message}`);
|
|
4946
|
+
}
|
|
4947
|
+
}
|
|
4515
4948
|
}, 1000);
|
|
4516
4949
|
}
|
|
4517
4950
|
function hermesBridgeAttach(mode) {
|
|
@@ -4547,7 +4980,8 @@ function hermesBridgeIngest() {
|
|
|
4547
4980
|
if (filtered.events.length > 0)
|
|
4548
4981
|
lastStructuredBridgeActivityAtMs = Date.now();
|
|
4549
4982
|
codexBridgeQueue.ingest(filtered.events);
|
|
4550
|
-
|
|
4983
|
+
pruneExpiredStructuredHeadsAndEmit('Hermes ingest');
|
|
4984
|
+
if (filtered.events.some(event => event.kind === 'assistant_final')) {
|
|
4551
4985
|
idleDetector?.fireIdle();
|
|
4552
4986
|
}
|
|
4553
4987
|
}
|
|
@@ -4559,6 +4993,7 @@ function mtrBridgeAttach(source, mode) {
|
|
|
4559
4993
|
const { history, live } = splitCodexEventsByCutoff(result.events, cutoff);
|
|
4560
4994
|
codexBridgeQueue.absorb(history);
|
|
4561
4995
|
codexBridgeQueue.ingest(live);
|
|
4996
|
+
pruneExpiredStructuredHeadsAndEmit('MTR split-live attach');
|
|
4562
4997
|
mtrBridgeOffset = result.newOffset;
|
|
4563
4998
|
mtrBridgeBaselineDone = true;
|
|
4564
4999
|
log(`MTR bridge split-live: ${source.dbPath}#${source.sessionId} (history=${history.length}, live=${live.length}, cutoff=${cutoff}, offset=${mtrBridgeOffset})`);
|
|
@@ -4587,7 +5022,8 @@ function mtrBridgeIngest() {
|
|
|
4587
5022
|
if (result.events.length > 0)
|
|
4588
5023
|
lastStructuredBridgeActivityAtMs = Date.now();
|
|
4589
5024
|
codexBridgeQueue.ingest(result.events);
|
|
4590
|
-
|
|
5025
|
+
pruneExpiredStructuredHeadsAndEmit('MTR ingest');
|
|
5026
|
+
if (result.events.some(event => event.kind === 'assistant_final')) {
|
|
4591
5027
|
idleDetector?.fireIdle();
|
|
4592
5028
|
}
|
|
4593
5029
|
}
|
|
@@ -4619,6 +5055,14 @@ function codexBridgeAttach(rolloutPath, mode) {
|
|
|
4619
5055
|
const { history, live } = splitCodexEventsByCutoff(result.events, cutoff);
|
|
4620
5056
|
codexBridgeQueue.absorb(history);
|
|
4621
5057
|
codexBridgeQueue.ingest(live);
|
|
5058
|
+
pruneExpiredStructuredHeadsAndEmit('structured split-live attach');
|
|
5059
|
+
// Late attach can discover an already-completed live turn in the same
|
|
5060
|
+
// drain. Re-drive prompt readiness from that terminal event immediately;
|
|
5061
|
+
// otherwise a ready edge rejected by the lifecycle gate waits for the
|
|
5062
|
+
// 20-30s lease timer even though the transcript has already ended.
|
|
5063
|
+
if (live.some(event => event.kind === 'assistant_final')) {
|
|
5064
|
+
idleDetector?.fireIdle();
|
|
5065
|
+
}
|
|
4622
5066
|
codexBridgeOffset = result.newOffset;
|
|
4623
5067
|
codexBridgePendingTail = result.pendingTail;
|
|
4624
5068
|
codexBridgeBaselineDone = true;
|
|
@@ -5107,6 +5551,13 @@ function maybeFollowTraexSessionRotationViaPid() {
|
|
|
5107
5551
|
codexBridgeNotifyCliSessionId(observed.cliSessionId);
|
|
5108
5552
|
}
|
|
5109
5553
|
function codexBridgeIngest(opts = {}) {
|
|
5554
|
+
// Follow-up RPC turns install their exact bridge mark only after the
|
|
5555
|
+
// turn/start ACK passes the generation fence. Ordinary ingest must not
|
|
5556
|
+
// advance the rollout cursor in that window. Terminal hydration for an
|
|
5557
|
+
// older owner may continue: successor events reached by the same drain stay
|
|
5558
|
+
// buffered until the exact successor mark is installed.
|
|
5559
|
+
if (rpcTranscriptIngestBlockedByAwaitingActivation(rpcTurnsAwaitingActivation.keys(), opts.hydrationOwnerKey))
|
|
5560
|
+
return;
|
|
5110
5561
|
if (structuredBridgeIsHermes()) {
|
|
5111
5562
|
hermesBridgeIngest();
|
|
5112
5563
|
return;
|
|
@@ -5139,13 +5590,14 @@ function codexBridgeIngest(opts = {}) {
|
|
|
5139
5590
|
if (result.events.length > 0)
|
|
5140
5591
|
lastStructuredBridgeActivityAtMs = Date.now();
|
|
5141
5592
|
codexBridgeQueue.ingest(result.events);
|
|
5142
|
-
|
|
5143
|
-
//
|
|
5593
|
+
pruneExpiredStructuredHeadsAndEmit('structured ingest');
|
|
5594
|
+
// Transcript-driven idle: a normal `assistant_final` or no-output
|
|
5595
|
+
// `turn_aborted` is Codex declaring end-of-turn, far more reliable than the screen-pattern heuristic
|
|
5144
5596
|
// (CoCo's status bar varies by --yolo flag, version, theme; codex has
|
|
5145
5597
|
// its own moving targets). Pushing idle here lets the bridge emit
|
|
5146
5598
|
// immediately instead of waiting for readyPattern + quiescence to
|
|
5147
5599
|
// converge. Idempotent — IdleDetector.fireIdle no-ops while already idle.
|
|
5148
|
-
if (opts.signalIdle !== false && result.events.some(
|
|
5600
|
+
if (opts.signalIdle !== false && result.events.some(event => event.kind === 'assistant_final')) {
|
|
5149
5601
|
idleDetector?.fireIdle();
|
|
5150
5602
|
}
|
|
5151
5603
|
}
|
|
@@ -5172,11 +5624,309 @@ function maybeEmitCodexStructuredRateLimit(events) {
|
|
|
5172
5624
|
/** Mark a pending Lark turn for Codex. Crucially this works even before a
|
|
5173
5625
|
* rollout path is known — the queue is path-agnostic, and ingest after
|
|
5174
5626
|
* late-attach picks up the user_message and matches the fingerprint. */
|
|
5175
|
-
function codexBridgeMarkPendingTurn(messageText, preferredTurnId, dispatchAttempt) {
|
|
5627
|
+
function codexBridgeMarkPendingTurn(messageText, preferredTurnId, dispatchAttempt, markTimeMs = Date.now()) {
|
|
5176
5628
|
if (!codexBridgeFallbackActive())
|
|
5177
|
-
return
|
|
5629
|
+
return undefined;
|
|
5178
5630
|
const turnId = preferredTurnId ?? `codex-${randomBytes(8).toString('hex')}`;
|
|
5179
|
-
codexBridgeQueue.mark(turnId, messageText,
|
|
5631
|
+
codexBridgeQueue.mark(turnId, messageText, markTimeMs, dispatchAttempt);
|
|
5632
|
+
return turnId;
|
|
5633
|
+
}
|
|
5634
|
+
function finalizeRpcTurnTerminal(terminal, generation, dropPending) {
|
|
5635
|
+
const { identity } = terminal;
|
|
5636
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
5637
|
+
if (!sameRpcGeneration(settlingRpcTerminalOwners.get(ownerKey), generation))
|
|
5638
|
+
return;
|
|
5639
|
+
const timer = rpcTerminalHydrationTimers.get(ownerKey);
|
|
5640
|
+
if (timer)
|
|
5641
|
+
clearTimeout(timer);
|
|
5642
|
+
rpcTerminalHydrationTimers.delete(ownerKey);
|
|
5643
|
+
if (sameRpcGeneration(rpcTerminalHydrationOwners.get(ownerKey), generation)) {
|
|
5644
|
+
rpcTerminalHydrationOwners.delete(ownerKey);
|
|
5645
|
+
rpcTerminalHydrationTerminals.delete(ownerKey);
|
|
5646
|
+
}
|
|
5647
|
+
settlingRpcTerminalOwners.delete(ownerKey);
|
|
5648
|
+
clearAwaitingRpcActivation(identity, generation);
|
|
5649
|
+
clearRpcLifecycleFailClosedOwner(identity, generation);
|
|
5650
|
+
if (sameRpcGeneration(rpcActiveOwners.get(ownerKey), generation)) {
|
|
5651
|
+
rpcActiveOwners.delete(ownerKey);
|
|
5652
|
+
}
|
|
5653
|
+
codexBridgeQueue.stopRpcActive(identity.turnId, identity.dispatchAttempt);
|
|
5654
|
+
if (dropPending) {
|
|
5655
|
+
codexBridgeQueue.dropPendingTurn(identity.turnId, identity.dispatchAttempt, true);
|
|
5656
|
+
}
|
|
5657
|
+
// RPC `aborted` means the turn had already started executing before it was
|
|
5658
|
+
// interrupted, so its side effects may have already run. Map it to
|
|
5659
|
+
// `ambiguous` (fail-closed, no auto-retry) to match the transcript path
|
|
5660
|
+
// (codex-transcript.ts turn_aborted -> ambiguous). Only a genuine app-server
|
|
5661
|
+
// `failed` is retryable. Otherwise durable delivery would auto-redispatch and
|
|
5662
|
+
// re-run already-executed side effects — exactly what this PR guards against.
|
|
5663
|
+
const status = terminal.status === 'completed' ? 'completed'
|
|
5664
|
+
: terminal.status === 'failed' ? 'failed'
|
|
5665
|
+
: 'ambiguous';
|
|
5666
|
+
const errorCode = terminal.errorCode
|
|
5667
|
+
?? (terminal.status === 'engine-dead' ? 'rpc_engine_dead'
|
|
5668
|
+
: terminal.status === 'stopped' ? 'rpc_engine_stopped'
|
|
5669
|
+
: terminal.status === 'aborted' ? 'rpc_turn_aborted'
|
|
5670
|
+
: terminal.status === 'failed' ? 'rpc_turn_failed'
|
|
5671
|
+
: undefined);
|
|
5672
|
+
emitTurnTerminal(identity.turnId, status, errorCode, identity.dispatchAttempt);
|
|
5673
|
+
log(`Codex RPC terminal ${terminal.status}: native=${terminal.nativeTurnId.slice(0, 12)} `
|
|
5674
|
+
+ `turn=${identity.turnId.slice(0, 12)} attempt=${identity.dispatchAttempt ?? '-'}`);
|
|
5675
|
+
redriveRejectedStructuredReady();
|
|
5676
|
+
idleDetector?.fireIdle();
|
|
5677
|
+
}
|
|
5678
|
+
/** turn/completed can race ahead of rollout fs visibility even though it is an
|
|
5679
|
+
* authoritative execution terminal. Release rpcActive immediately, but retain
|
|
5680
|
+
* a short non-ready hydration gate while polling the structured transcript.
|
|
5681
|
+
* This preserves fallback final output without allowing the stale fingerprint
|
|
5682
|
+
* to head-of-line block a new RPC turn indefinitely. */
|
|
5683
|
+
function hydrateCompletedRpcTurn(terminal, generation, attempt = 0) {
|
|
5684
|
+
const { identity } = terminal;
|
|
5685
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
5686
|
+
if (!sameRpcGeneration(settlingRpcTerminalOwners.get(ownerKey), generation))
|
|
5687
|
+
return;
|
|
5688
|
+
// restartCliProcess advances the generation before its asynchronous teardown.
|
|
5689
|
+
// Do not let an old hydration timer touch a bridge queue that may already be
|
|
5690
|
+
// owned by the replacement. stopCodexRpcEngine will synchronously retire the
|
|
5691
|
+
// old generation when teardown reaches the engine.
|
|
5692
|
+
if (cliSpawnGeneration !== generation.cliGeneration
|
|
5693
|
+
|| codexRpcEngine !== generation.engine)
|
|
5694
|
+
return;
|
|
5695
|
+
try {
|
|
5696
|
+
codexBridgeDrainAndMaybeEmit({
|
|
5697
|
+
signalIdle: false,
|
|
5698
|
+
hydrationOwnerKey: ownerKey,
|
|
5699
|
+
});
|
|
5700
|
+
}
|
|
5701
|
+
catch { /* retry */ }
|
|
5702
|
+
if (!codexBridgeQueue.hasPendingTurn(identity.turnId, identity.dispatchAttempt)) {
|
|
5703
|
+
// Structured ingest already emitted final_output + terminal in the canonical
|
|
5704
|
+
// order. The explicit native terminal below is idempotently suppressed.
|
|
5705
|
+
finalizeRpcTurnTerminal(terminal, generation, false);
|
|
5706
|
+
return;
|
|
5707
|
+
}
|
|
5708
|
+
// The native terminal can beat rollout persistence by seconds on a cold or
|
|
5709
|
+
// busy filesystem. Keep this bounded (~11.6s), aligned with the fresh-turn
|
|
5710
|
+
// 12s positive-evidence probe, rather than dropping fallback output after the
|
|
5711
|
+
// previous 1.55s window.
|
|
5712
|
+
const delays = CODEX_RPC_TERMINAL_HYDRATION_DELAYS_MS;
|
|
5713
|
+
if (attempt >= delays.length) {
|
|
5714
|
+
finalizeRpcTurnTerminal(terminal, generation, true);
|
|
5715
|
+
return;
|
|
5716
|
+
}
|
|
5717
|
+
const timer = setTimeout(() => {
|
|
5718
|
+
rpcTerminalHydrationTimers.delete(ownerKey);
|
|
5719
|
+
hydrateCompletedRpcTurn(terminal, generation, attempt + 1);
|
|
5720
|
+
}, delays[attempt]);
|
|
5721
|
+
timer.unref?.();
|
|
5722
|
+
rpcTerminalHydrationTimers.set(ownerKey, timer);
|
|
5723
|
+
}
|
|
5724
|
+
function settleRpcTurnTerminal(terminal, generation) {
|
|
5725
|
+
const ownerKey = rpcTurnOwnerKey(terminal.identity);
|
|
5726
|
+
const existingSettlement = settlingRpcTerminalOwners.get(ownerKey);
|
|
5727
|
+
if (existingSettlement) {
|
|
5728
|
+
if (!sameRpcGeneration(existingSettlement, generation)) {
|
|
5729
|
+
log(`Ignored stale Codex RPC terminal settlement for ${terminal.identity.turnId}`);
|
|
5730
|
+
}
|
|
5731
|
+
return;
|
|
5732
|
+
}
|
|
5733
|
+
settlingRpcTerminalOwners.set(ownerKey, generation);
|
|
5734
|
+
clearAwaitingRpcActivation(terminal.identity, generation);
|
|
5735
|
+
clearRpcLifecycleFailClosedOwner(terminal.identity, generation);
|
|
5736
|
+
if (sameRpcGeneration(rpcActiveOwners.get(ownerKey), generation)) {
|
|
5737
|
+
rpcActiveOwners.delete(ownerKey);
|
|
5738
|
+
}
|
|
5739
|
+
codexBridgeQueue.stopRpcActive(terminal.identity.turnId, terminal.identity.dispatchAttempt);
|
|
5740
|
+
if (terminal.status === 'completed'
|
|
5741
|
+
&& codexBridgeQueue.hasPendingTurn(terminal.identity.turnId, terminal.identity.dispatchAttempt)) {
|
|
5742
|
+
rpcTerminalHydrationOwners.set(ownerKey, generation);
|
|
5743
|
+
rpcTerminalHydrationTerminals.set(ownerKey, { terminal, generation });
|
|
5744
|
+
hydrateCompletedRpcTurn(terminal, generation);
|
|
5745
|
+
return;
|
|
5746
|
+
}
|
|
5747
|
+
finalizeRpcTurnTerminal(terminal, generation, true);
|
|
5748
|
+
}
|
|
5749
|
+
function handleRpcTurnTerminal(terminal, generation) {
|
|
5750
|
+
const ownerKey = rpcTurnOwnerKey(terminal.identity);
|
|
5751
|
+
const awaiting = rpcTurnsAwaitingActivation.get(ownerKey);
|
|
5752
|
+
if (awaiting && sameRpcGeneration(awaiting, generation)) {
|
|
5753
|
+
// Response + turn/completed can be decoded before the await continuation
|
|
5754
|
+
// installs rpcActive. The exact terminal is replayed immediately after that
|
|
5755
|
+
// activation, never against a guessed/latest queue entry.
|
|
5756
|
+
pendingRpcTurnTerminals.set(ownerKey, { terminal, generation });
|
|
5757
|
+
return;
|
|
5758
|
+
}
|
|
5759
|
+
if (awaiting && !sameRpcGeneration(awaiting, generation)) {
|
|
5760
|
+
log(`Ignored stale Codex RPC terminal from replaced engine for ${terminal.identity.turnId}`);
|
|
5761
|
+
return;
|
|
5762
|
+
}
|
|
5763
|
+
const activeGeneration = rpcActiveOwners.get(ownerKey);
|
|
5764
|
+
const failedClosedGeneration = rpcLifecycleFailClosedOwners.get(ownerKey);
|
|
5765
|
+
if (!sameRpcGeneration(activeGeneration, generation)
|
|
5766
|
+
&& !sameRpcGeneration(failedClosedGeneration, generation)) {
|
|
5767
|
+
// The structured bridge may already have consumed the same turn and emitted
|
|
5768
|
+
// its terminal, or a replacement generation may own the same logical
|
|
5769
|
+
// delivery key. In either case this callback has no queue state to mutate.
|
|
5770
|
+
log(`Codex RPC terminal already retired: native=${terminal.nativeTurnId.slice(0, 12)} `
|
|
5771
|
+
+ `turn=${terminal.identity.turnId.slice(0, 12)}`);
|
|
5772
|
+
return;
|
|
5773
|
+
}
|
|
5774
|
+
settleRpcTurnTerminal(terminal, generation);
|
|
5775
|
+
}
|
|
5776
|
+
/** Install the exact bridge mark + rpcActive hand-off after turn/start is known
|
|
5777
|
+
* accepted. Failure is fail-closed: a separate lifecycle gate remains asserted
|
|
5778
|
+
* for this owner until its native terminal/engine teardown, so a bookkeeping
|
|
5779
|
+
* bug cannot publish false idle. */
|
|
5780
|
+
function activateRpcTurnLifecycle(identity, messageText, alreadyMarked, generation, deferTerminal = false) {
|
|
5781
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
5782
|
+
if (!sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
|
|
5783
|
+
log(`Refused stale Codex RPC lifecycle activation for ${identity.turnId}`);
|
|
5784
|
+
return false;
|
|
5785
|
+
}
|
|
5786
|
+
const replayAnchorMs = awaitingRpcActivationReplayAnchorMs(identity, generation);
|
|
5787
|
+
let marked = alreadyMarked
|
|
5788
|
+
&& (codexBridgeQueue.hasPendingTurn(identity.turnId, identity.dispatchAttempt)
|
|
5789
|
+
|| codexBridgeQueue.hasTerminalTurn(identity.turnId, identity.dispatchAttempt));
|
|
5790
|
+
if (!alreadyMarked) {
|
|
5791
|
+
const bridgeTurnId = codexBridgeMarkPendingTurn(messageText, identity.turnId, identity.dispatchAttempt, replayAnchorMs);
|
|
5792
|
+
marked = bridgeTurnId === identity.turnId;
|
|
5793
|
+
}
|
|
5794
|
+
// An older turn's terminal hydration may have drained and buffered this
|
|
5795
|
+
// successor's complete user/final pair while turn/start ACK was still
|
|
5796
|
+
// pending. mark() replays that pair synchronously. Treat the exact transcript
|
|
5797
|
+
// terminal as an already-retired activation instead of failing closed merely
|
|
5798
|
+
// because markRpcActive correctly refuses a completed queue entry.
|
|
5799
|
+
const transcriptTerminalObserved = marked
|
|
5800
|
+
&& codexBridgeQueue.hasTerminalTurn(identity.turnId, identity.dispatchAttempt);
|
|
5801
|
+
const activated = transcriptTerminalObserved
|
|
5802
|
+
|| (marked && codexBridgeQueue.markRpcActive(identity.turnId, identity.dispatchAttempt));
|
|
5803
|
+
if (activated && !transcriptTerminalObserved) {
|
|
5804
|
+
rpcActiveOwners.set(ownerKey, generation);
|
|
5805
|
+
}
|
|
5806
|
+
if (transcriptTerminalObserved)
|
|
5807
|
+
emitReadyCodexTurns();
|
|
5808
|
+
if (!deferTerminal
|
|
5809
|
+
&& sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
|
|
5810
|
+
rpcTurnsAwaitingActivation.delete(ownerKey);
|
|
5811
|
+
rpcTurnsAwaitingActivationIdentities.delete(ownerKey);
|
|
5812
|
+
rpcTurnsAwaitingActivationReplayAnchors.delete(ownerKey);
|
|
5813
|
+
}
|
|
5814
|
+
if (!activated) {
|
|
5815
|
+
if (marked)
|
|
5816
|
+
codexBridgeQueue.dropPendingTurn(identity.turnId, identity.dispatchAttempt, true);
|
|
5817
|
+
installRpcLifecycleFailClosedOwner(identity, generation);
|
|
5818
|
+
log(`Codex RPC lifecycle failed closed: mark=${marked} active=${activated} `
|
|
5819
|
+
+ `turn=${identity.turnId.slice(0, 12)} attempt=${identity.dispatchAttempt ?? '-'}`);
|
|
5820
|
+
send({
|
|
5821
|
+
type: 'user_notify',
|
|
5822
|
+
message: 'Codex RPC 已接收消息,但本地生命周期登记失败;在原生终态到达前保持忙碌,未自动重发。',
|
|
5823
|
+
turnId: identity.turnId,
|
|
5824
|
+
...(identity.dispatchAttempt !== undefined
|
|
5825
|
+
? { dispatchAttempt: identity.dispatchAttempt }
|
|
5826
|
+
: {}),
|
|
5827
|
+
});
|
|
5828
|
+
}
|
|
5829
|
+
const pendingTerminal = pendingRpcTurnTerminals.get(ownerKey);
|
|
5830
|
+
if (pendingTerminal
|
|
5831
|
+
&& sameRpcGeneration(pendingTerminal.generation, generation)
|
|
5832
|
+
&& !deferTerminal) {
|
|
5833
|
+
settleRpcTurnTerminal(pendingTerminal.terminal, generation);
|
|
5834
|
+
}
|
|
5835
|
+
return activated;
|
|
5836
|
+
}
|
|
5837
|
+
function releaseRpcTurnTerminalDeferral(identity, generation) {
|
|
5838
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
5839
|
+
if (sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
|
|
5840
|
+
rpcTurnsAwaitingActivation.delete(ownerKey);
|
|
5841
|
+
rpcTurnsAwaitingActivationIdentities.delete(ownerKey);
|
|
5842
|
+
rpcTurnsAwaitingActivationReplayAnchors.delete(ownerKey);
|
|
5843
|
+
}
|
|
5844
|
+
const pendingTerminal = pendingRpcTurnTerminals.get(ownerKey);
|
|
5845
|
+
if (pendingTerminal && sameRpcGeneration(pendingTerminal.generation, generation)) {
|
|
5846
|
+
settleRpcTurnTerminal(pendingTerminal.terminal, generation);
|
|
5847
|
+
}
|
|
5848
|
+
}
|
|
5849
|
+
function retireRpcLifecycleFromStructuredTerminal(identity) {
|
|
5850
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
5851
|
+
// Native completion hydration owns its own finalization after this canonical
|
|
5852
|
+
// drain returns. Do not tear down that generation's settlement underneath it.
|
|
5853
|
+
if (settlingRpcTerminalOwners.has(ownerKey))
|
|
5854
|
+
return;
|
|
5855
|
+
const generation = rpcActiveOwners.get(ownerKey)
|
|
5856
|
+
?? rpcLifecycleFailClosedOwners.get(ownerKey)
|
|
5857
|
+
?? rpcTurnsAwaitingActivation.get(ownerKey);
|
|
5858
|
+
if (!generation)
|
|
5859
|
+
return;
|
|
5860
|
+
clearAwaitingRpcActivation(identity, generation);
|
|
5861
|
+
if (sameRpcGeneration(rpcActiveOwners.get(ownerKey), generation)) {
|
|
5862
|
+
rpcActiveOwners.delete(ownerKey);
|
|
5863
|
+
}
|
|
5864
|
+
clearRpcLifecycleFailClosedOwner(identity, generation);
|
|
5865
|
+
codexBridgeQueue.stopRpcActive(identity.turnId, identity.dispatchAttempt);
|
|
5866
|
+
}
|
|
5867
|
+
/** Expire confirmed or attribution-only unstarted queue heads at an explicit worker
|
|
5868
|
+
* lifecycle boundary. Pruning can replay a buffered successor user+final and
|
|
5869
|
+
* thereby produce an immediately emittable completion, so query/projection
|
|
5870
|
+
* methods must never do this mutation invisibly: every removal funnels
|
|
5871
|
+
* through this helper and drains ready output in the same call stack. */
|
|
5872
|
+
function pruneExpiredStructuredHeadsAndEmit(source) {
|
|
5873
|
+
let fenceCurrentRpcEngine = false;
|
|
5874
|
+
const dropped = pruneExpiredPreStartHeadsAndEmit(codexBridgeQueue, emitReadyCodexTurns, undefined, turns => {
|
|
5875
|
+
// A bounded pre-start lease is also the terminal boundary for a durable
|
|
5876
|
+
// delivery attempt that the CLI accepted but never wrote to transcript.
|
|
5877
|
+
// Settle N before the prune replay drains successor N+1; ordinary IM
|
|
5878
|
+
// turns have no dispatchAttempt and remain log-only as before.
|
|
5879
|
+
for (const turn of turns) {
|
|
5880
|
+
const identity = {
|
|
5881
|
+
turnId: turn.turnId,
|
|
5882
|
+
...(turn.dispatchAttempt !== undefined
|
|
5883
|
+
? { dispatchAttempt: turn.dispatchAttempt }
|
|
5884
|
+
: {}),
|
|
5885
|
+
};
|
|
5886
|
+
const ownerKey = rpcTurnOwnerKey(identity);
|
|
5887
|
+
const failedClosedGeneration = rpcLifecycleFailClosedOwners.get(ownerKey);
|
|
5888
|
+
const retiredRpcOwner = failedClosedGeneration
|
|
5889
|
+
? clearRpcLifecycleFailClosedOwner(identity, failedClosedGeneration)
|
|
5890
|
+
: false;
|
|
5891
|
+
let shouldFenceRpcEngine = false;
|
|
5892
|
+
if (retiredRpcOwner && failedClosedGeneration) {
|
|
5893
|
+
clearAwaitingRpcActivation(identity, failedClosedGeneration);
|
|
5894
|
+
if (deferredFreshRpcTurn
|
|
5895
|
+
&& rpcTurnOwnerKey(deferredFreshRpcTurn.identity) === ownerKey
|
|
5896
|
+
&& sameRpcGeneration(deferredFreshRpcTurn.generation, failedClosedGeneration)) {
|
|
5897
|
+
deferredFreshRpcTurn = undefined;
|
|
5898
|
+
}
|
|
5899
|
+
if (codexRpcEngine === failedClosedGeneration.engine
|
|
5900
|
+
&& cliSpawnGeneration === failedClosedGeneration.cliGeneration
|
|
5901
|
+
&& !cliRestartInProgress) {
|
|
5902
|
+
fenceCurrentRpcEngine = true;
|
|
5903
|
+
shouldFenceRpcEngine = true;
|
|
5904
|
+
}
|
|
5905
|
+
}
|
|
5906
|
+
if (retiredRpcOwner || turn.dispatchAttempt !== undefined) {
|
|
5907
|
+
emitTurnTerminal(turn.turnId, 'ambiguous', retiredRpcOwner
|
|
5908
|
+
? 'rpc_delivery_ambiguous_timeout'
|
|
5909
|
+
: 'structured_start_timeout', turn.dispatchAttempt);
|
|
5910
|
+
}
|
|
5911
|
+
// Publish/retire N before a synchronous immediate restart can clear the
|
|
5912
|
+
// current exact identity in killCli(). The callback still installs the
|
|
5913
|
+
// restart fence before it returns, so the helper cannot emit buffered
|
|
5914
|
+
// successor N+1 against this ambiguous engine generation.
|
|
5915
|
+
if (shouldFenceRpcEngine) {
|
|
5916
|
+
void restartCliProcess('Codex RPC ambiguous delivery exceeded its bounded attribution lease', { immediate: true, preservePending: true });
|
|
5917
|
+
}
|
|
5918
|
+
}
|
|
5919
|
+
});
|
|
5920
|
+
if (dropped.length === 0)
|
|
5921
|
+
return false;
|
|
5922
|
+
log(`${source}: expired ${dropped.length} structured head(s) without transcript start (${dropped.map(turn => turn.turnId).join(', ')})`);
|
|
5923
|
+
if (fenceCurrentRpcEngine)
|
|
5924
|
+
log('Fenced ambiguous Codex RPC generation before structured successor replay');
|
|
5925
|
+
// A rejected prompt-ready signal may be waiting on this exact pre-start
|
|
5926
|
+
// lease. Re-evaluate it after both the queue mark and its separate RPC
|
|
5927
|
+
// fail-closed owner have retired; otherwise the projector can stay busy
|
|
5928
|
+
// forever even though there is no remaining terminal source.
|
|
5929
|
+
redriveRejectedStructuredReady();
|
|
5180
5930
|
return true;
|
|
5181
5931
|
}
|
|
5182
5932
|
function codexBridgeDrainAndMaybeEmit(opts = {}) {
|
|
@@ -5309,6 +6059,12 @@ function emitReadyCodexTurns() {
|
|
|
5309
6059
|
});
|
|
5310
6060
|
}
|
|
5311
6061
|
for (const turn of ready) {
|
|
6062
|
+
retireRpcLifecycleFromStructuredTerminal({
|
|
6063
|
+
turnId: turn.turnId,
|
|
6064
|
+
...(turn.dispatchAttempt !== undefined
|
|
6065
|
+
? { dispatchAttempt: turn.dispatchAttempt }
|
|
6066
|
+
: {}),
|
|
6067
|
+
});
|
|
5312
6068
|
emitTurnTerminal(turn.turnId, turn.terminalStatus ?? 'completed', turn.terminalErrorCode, turn.dispatchAttempt);
|
|
5313
6069
|
}
|
|
5314
6070
|
}
|
|
@@ -5674,6 +6430,264 @@ function parkCrashDiagnosticTerminal(code, signal) {
|
|
|
5674
6430
|
* the ask-hook / CoCo picker paths (driveCocoPicker, handleTuiKeys); cleared
|
|
5675
6431
|
* when the prompt resolves or a Lark/terminal input overrides it. */
|
|
5676
6432
|
let tuiPromptBlocking = false;
|
|
6433
|
+
/** One composed status projection shared by the immediate screen path,
|
|
6434
|
+
* periodic text updates, and screenshot uploads. */
|
|
6435
|
+
function projectedRuntimeScreenStatus() {
|
|
6436
|
+
return codexAppLivenessStatus(projectRuntimeScreenStatus({
|
|
6437
|
+
promptReady: isPromptReady,
|
|
6438
|
+
analyzing: false,
|
|
6439
|
+
structuredTurnBlocking: hasStructuredLifecycleBlock(),
|
|
6440
|
+
}));
|
|
6441
|
+
}
|
|
6442
|
+
/** Worker-local widening of the status snapshot helper. Codex App composes a
|
|
6443
|
+
* `stalled` state on top of the base screen/structured projection, while the
|
|
6444
|
+
* shared utility intentionally exposes only the base three-state union. */
|
|
6445
|
+
async function snapshotWithLatestRuntimeStatus(capture, projectStatus) {
|
|
6446
|
+
const snapshot = await capture();
|
|
6447
|
+
return { snapshot, status: projectStatus() };
|
|
6448
|
+
}
|
|
6449
|
+
/** Re-arm readiness before every individual CLI write. A whole flush can span
|
|
6450
|
+
* several type-ahead items, and adopt writeInput can await history polling;
|
|
6451
|
+
* either path may observe an assistant_final before the await returns. Reset
|
|
6452
|
+
* here (never after the await) so that final remains a usable ready edge. */
|
|
6453
|
+
function beginCliWriteCycle() {
|
|
6454
|
+
beginRuntimeWriteCycle({
|
|
6455
|
+
setPromptReady: ready => { isPromptReady = ready; },
|
|
6456
|
+
resetIdleDetector: () => { idleDetector?.reset(); },
|
|
6457
|
+
});
|
|
6458
|
+
}
|
|
6459
|
+
async function writeAdoptMessage(content, turnId, dispatchAttempt, vcMeetingImTurnOrigin, fence) {
|
|
6460
|
+
const executionFence = fence ?? captureAdoptWriteFence();
|
|
6461
|
+
if (!executionFence || !adoptWriteFenceIsCurrent(executionFence)) {
|
|
6462
|
+
return 'stale-before-write';
|
|
6463
|
+
}
|
|
6464
|
+
const adoptBackend = executionFence.backend;
|
|
6465
|
+
renderer?.markNewTurn();
|
|
6466
|
+
const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
6467
|
+
currentBotmuxTurnId = turnId;
|
|
6468
|
+
currentBotmuxDispatchAttempt = dispatchAttempt;
|
|
6469
|
+
currentVcMeetingImTurnOrigin = vcMeetingImTurnOrigin;
|
|
6470
|
+
if (dispatchAttempt !== undefined)
|
|
6471
|
+
durableTurnInFlight = true;
|
|
6472
|
+
writeCliPidMarker();
|
|
6473
|
+
publishSandboxRelayCapability();
|
|
6474
|
+
let adoptStructuredBridgeTurnId;
|
|
6475
|
+
// Capture the transcript baseline immediately before the literal write, inside
|
|
6476
|
+
// the submission transaction (as its beforeWrite hook), so a ZMX composer-
|
|
6477
|
+
// recovery hold that refuses the write never leaves a bare, unwritten bridge
|
|
6478
|
+
// mark. prepareAdoptWrite is idempotent and also re-arms readiness before the
|
|
6479
|
+
// adapter can yield (an assistant_final may arrive while writeInput polls
|
|
6480
|
+
// history), so the re-arm stays BEFORE the write, never after.
|
|
6481
|
+
let adoptWritePrepared = false;
|
|
6482
|
+
const prepareAdoptWrite = () => {
|
|
6483
|
+
if (adoptWritePrepared)
|
|
6484
|
+
return;
|
|
6485
|
+
adoptWritePrepared = true;
|
|
6486
|
+
beginCliWriteCycle();
|
|
6487
|
+
if (bridgeJsonlPath) {
|
|
6488
|
+
try {
|
|
6489
|
+
bridgeIngest();
|
|
6490
|
+
}
|
|
6491
|
+
catch { /* best effort */ }
|
|
6492
|
+
bridgeMarkPendingTurn(content, turnId, dispatchAttempt);
|
|
6493
|
+
}
|
|
6494
|
+
else if (codexBridgeFallbackActive()) {
|
|
6495
|
+
if (codexBridgeIsCursor()) {
|
|
6496
|
+
// Cursor may append the current line before IPC handling; mark first so
|
|
6497
|
+
// the pre-existing line can fingerprint-match instead of becoming seen.
|
|
6498
|
+
adoptStructuredBridgeTurnId = codexBridgeMarkPendingTurn(content, turnId, dispatchAttempt);
|
|
6499
|
+
try {
|
|
6500
|
+
codexBridgeIngest();
|
|
6501
|
+
}
|
|
6502
|
+
catch { /* best effort */ }
|
|
6503
|
+
}
|
|
6504
|
+
else {
|
|
6505
|
+
try {
|
|
6506
|
+
codexBridgeIngest();
|
|
6507
|
+
}
|
|
6508
|
+
catch { /* best effort */ }
|
|
6509
|
+
adoptStructuredBridgeTurnId = codexBridgeMarkPendingTurn(content, turnId, dispatchAttempt);
|
|
6510
|
+
}
|
|
6511
|
+
if (adoptStructuredBridgeTurnId) {
|
|
6512
|
+
codexBridgeQueue.beginSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
|
|
6513
|
+
}
|
|
6514
|
+
}
|
|
6515
|
+
};
|
|
6516
|
+
const settleStaleAfterWrite = (errorCode) => {
|
|
6517
|
+
if (adoptStructuredBridgeTurnId) {
|
|
6518
|
+
codexBridgeQueue.finishSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
|
|
6519
|
+
}
|
|
6520
|
+
dropFailedBridgeMark(adoptStructuredBridgeTurnId, dispatchAttempt);
|
|
6521
|
+
if (turnId && dispatchAttempt !== undefined) {
|
|
6522
|
+
emitTurnTerminal(turnId, 'ambiguous', errorCode, dispatchAttempt);
|
|
6523
|
+
}
|
|
6524
|
+
else {
|
|
6525
|
+
send({
|
|
6526
|
+
type: 'user_notify',
|
|
6527
|
+
turnId,
|
|
6528
|
+
message: 'Adopt input could not be reconciled because the CLI backend changed while it was being written. Please verify the terminal before retrying.',
|
|
6529
|
+
});
|
|
6530
|
+
}
|
|
6531
|
+
return 'stale-after-write';
|
|
6532
|
+
};
|
|
6533
|
+
// Adopt mode write:
|
|
6534
|
+
// - Structured-bridge adopt-input CLIs (codex/traex/pi/grok/mtr) route
|
|
6535
|
+
// through cliAdapter.writeInput. The composer-conflict guard refuses input
|
|
6536
|
+
// when the adopted pane already holds an unsubmitted human draft, and the
|
|
6537
|
+
// shared submission transaction gives ZMX its capture→write→confirm/cancel
|
|
6538
|
+
// journal (a transparent pass-through on every non-ZMX backend).
|
|
6539
|
+
// - raw sendText+Enter runs inside the same transaction for identical
|
|
6540
|
+
// commit-journal atomicity.
|
|
6541
|
+
if (isStructuredBridgeAdoptInputCli(lastInitConfig?.cliId) && cliAdapter) {
|
|
6542
|
+
const submissionBackend = adoptBackend;
|
|
6543
|
+
let recoveryFailureReason;
|
|
6544
|
+
// Refuse adopt input while the local composer still holds an unsubmitted
|
|
6545
|
+
// human draft, BEFORE any bridge attribution or terminal write — otherwise
|
|
6546
|
+
// the Lark message would be appended onto the human's half-typed line.
|
|
6547
|
+
const composerConflict = codexAdoptComposerConflict(submissionBackend);
|
|
6548
|
+
if (composerConflict) {
|
|
6549
|
+
log('Refused Codex adopt input because the local composer contains an unsubmitted draft');
|
|
6550
|
+
scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, composerConflict, turnSeq, { turnId, dispatchAttempt }, 'failed');
|
|
6551
|
+
return 'completed';
|
|
6552
|
+
}
|
|
6553
|
+
try {
|
|
6554
|
+
const transaction = await runAmbiguousSubmissionTransaction(submissionBackend,
|
|
6555
|
+
// adapterInputHandle wraps a ZmxBackend in strictInputHandle, which turns
|
|
6556
|
+
// a `sendText/sendSpecialKeys === false` (ZMX write refusal) into a throw
|
|
6557
|
+
// so the transaction can cancel/poison the WAL. Passing the bare backend
|
|
6558
|
+
// would let a refused write look like success and clear the journal —
|
|
6559
|
+
// silent input loss (the exact failure the transaction guards against).
|
|
6560
|
+
() => cliAdapter.writeInput(adapterInputHandle(submissionBackend), content), settleVerifiableSubmissionForJournal, prepareAdoptWrite);
|
|
6561
|
+
const result = transaction.result;
|
|
6562
|
+
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
6563
|
+
if (!adoptWriteFenceIsCurrent(executionFence)) {
|
|
6564
|
+
return settleStaleAfterWrite('adopt_generation_changed');
|
|
6565
|
+
}
|
|
6566
|
+
if (result?.cliSessionId) {
|
|
6567
|
+
persistCliSessionId(result.cliSessionId);
|
|
6568
|
+
codexBridgeNotifyCliSessionId(result.cliSessionId);
|
|
6569
|
+
}
|
|
6570
|
+
if (result?.submitted === true && adoptStructuredBridgeTurnId) {
|
|
6571
|
+
codexBridgeQueue.confirmPendingTurn(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
|
|
6572
|
+
}
|
|
6573
|
+
else if (adoptStructuredBridgeTurnId && !(result?.submitted === false && result.recheck && !result.failureReason)) {
|
|
6574
|
+
codexBridgeQueue.finishSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
|
|
6575
|
+
}
|
|
6576
|
+
redriveRejectedStructuredReady();
|
|
6577
|
+
if (result?.submitted === false || recoveryFailureReason) {
|
|
6578
|
+
if (recoveryFailureReason) {
|
|
6579
|
+
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, { turnId, dispatchAttempt });
|
|
6580
|
+
}
|
|
6581
|
+
else {
|
|
6582
|
+
scheduleSubmitFailureNotify(content, result?.recheck, 'submit history', adoptStructuredBridgeTurnId, result?.failureReason, turnSeq, { turnId, dispatchAttempt }, 'failed', true);
|
|
6583
|
+
}
|
|
6584
|
+
}
|
|
6585
|
+
}
|
|
6586
|
+
catch (err) {
|
|
6587
|
+
recoveryFailureReason = err instanceof SubmissionWriteError
|
|
6588
|
+
? err.recoveryFailureReason
|
|
6589
|
+
: recoveryFailureReason;
|
|
6590
|
+
const blockedBeforeWrite = err instanceof SubmissionWriteError
|
|
6591
|
+
&& !!err.recoveryFailureReason
|
|
6592
|
+
&& !err.submissionStarted;
|
|
6593
|
+
log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
|
|
6594
|
+
if (!adoptWriteFenceIsCurrent(executionFence)) {
|
|
6595
|
+
return settleStaleAfterWrite('adopt_generation_changed');
|
|
6596
|
+
}
|
|
6597
|
+
if (adoptStructuredBridgeTurnId) {
|
|
6598
|
+
codexBridgeQueue.finishSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
|
|
6599
|
+
}
|
|
6600
|
+
dropFailedBridgeMark(adoptStructuredBridgeTurnId, dispatchAttempt);
|
|
6601
|
+
if (turnId && dispatchAttempt !== undefined && blockedBeforeWrite) {
|
|
6602
|
+
// The ZMX recovery hold refused the write, so the input definitely did
|
|
6603
|
+
// NOT execute — a genuine retryable failure, not an ambiguous one.
|
|
6604
|
+
emitTurnTerminal(turnId, 'failed', 'zmx_recovery_blocked_before_write', dispatchAttempt);
|
|
6605
|
+
}
|
|
6606
|
+
else if (turnId && dispatchAttempt !== undefined && !recoveryFailureReason) {
|
|
6607
|
+
emitTurnTerminal(turnId, 'ambiguous', 'adopt_write_input_threw', dispatchAttempt);
|
|
6608
|
+
}
|
|
6609
|
+
if (recoveryFailureReason) {
|
|
6610
|
+
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, { turnId, dispatchAttempt });
|
|
6611
|
+
}
|
|
6612
|
+
redriveRejectedStructuredReady();
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
6615
|
+
else if ('sendText' in adoptBackend && 'sendSpecialKeys' in adoptBackend) {
|
|
6616
|
+
const submissionBackend = adoptBackend;
|
|
6617
|
+
let recoveryFailureReason;
|
|
6618
|
+
try {
|
|
6619
|
+
const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, async () => {
|
|
6620
|
+
// Use adapterInputHandle so a ZMX write refusal (sendText/sendSpecialKeys
|
|
6621
|
+
// returning false) throws instead of silently succeeding — the raw path
|
|
6622
|
+
// needs the same strict-handle guard as the structured writeInput path.
|
|
6623
|
+
const input = adapterInputHandle(submissionBackend);
|
|
6624
|
+
input.sendText(content);
|
|
6625
|
+
// Beat between text and Enter so Ink-based TUIs register pasted text
|
|
6626
|
+
// before submit. The serial queue holds across this await.
|
|
6627
|
+
await new Promise(r => setTimeout(r, 200));
|
|
6628
|
+
if (!adoptWriteFenceIsCurrent(executionFence)) {
|
|
6629
|
+
throw new Error('backend changed before adopt raw Enter');
|
|
6630
|
+
}
|
|
6631
|
+
input.sendSpecialKeys('Enter');
|
|
6632
|
+
}, undefined, prepareAdoptWrite);
|
|
6633
|
+
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
6634
|
+
if (!adoptWriteFenceIsCurrent(executionFence)) {
|
|
6635
|
+
return settleStaleAfterWrite('adopt_generation_changed_before_enter');
|
|
6636
|
+
}
|
|
6637
|
+
if (recoveryFailureReason) {
|
|
6638
|
+
throw new SubmissionWriteError(`backend could not commit the adopt submission journal; ${recoveryFailureReason}`, recoveryFailureReason);
|
|
6639
|
+
}
|
|
6640
|
+
}
|
|
6641
|
+
catch (err) {
|
|
6642
|
+
recoveryFailureReason = err instanceof SubmissionWriteError
|
|
6643
|
+
? err.recoveryFailureReason
|
|
6644
|
+
: recoveryFailureReason;
|
|
6645
|
+
const blockedBeforeWrite = err instanceof SubmissionWriteError
|
|
6646
|
+
&& !!err.recoveryFailureReason
|
|
6647
|
+
&& !err.submissionStarted;
|
|
6648
|
+
log(`Adopt raw input error (${lastInitConfig?.cliId}): ${err.message}`);
|
|
6649
|
+
if (turnId && dispatchAttempt !== undefined && blockedBeforeWrite) {
|
|
6650
|
+
emitTurnTerminal(turnId, 'failed', 'zmx_recovery_blocked_before_write', dispatchAttempt);
|
|
6651
|
+
}
|
|
6652
|
+
else if (turnId && dispatchAttempt !== undefined && !recoveryFailureReason) {
|
|
6653
|
+
emitTurnTerminal(turnId, 'ambiguous', 'write_input_threw', dispatchAttempt);
|
|
6654
|
+
}
|
|
6655
|
+
if (recoveryFailureReason) {
|
|
6656
|
+
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, { turnId, dispatchAttempt });
|
|
6657
|
+
}
|
|
6658
|
+
}
|
|
6659
|
+
}
|
|
6660
|
+
else {
|
|
6661
|
+
prepareAdoptWrite();
|
|
6662
|
+
adoptBackend.write(content + '\r');
|
|
6663
|
+
}
|
|
6664
|
+
return 'completed';
|
|
6665
|
+
}
|
|
6666
|
+
async function runAdoptMessageForCapturedGeneration(item, requeue) {
|
|
6667
|
+
const fence = captureAdoptWriteFence();
|
|
6668
|
+
if (!fence) {
|
|
6669
|
+
requeue();
|
|
6670
|
+
return 'stale-before-write';
|
|
6671
|
+
}
|
|
6672
|
+
let requeued = false;
|
|
6673
|
+
const requeueOnce = () => {
|
|
6674
|
+
if (requeued)
|
|
6675
|
+
return;
|
|
6676
|
+
requeued = true;
|
|
6677
|
+
requeue();
|
|
6678
|
+
};
|
|
6679
|
+
const queued = await runAdoptQueuedWriteSequence({
|
|
6680
|
+
queue: adoptWriteQueue,
|
|
6681
|
+
isCurrent: () => adoptWriteFenceIsCurrent(fence),
|
|
6682
|
+
onStale: requeueOnce,
|
|
6683
|
+
write: () => writeAdoptMessage(item.content, item.turnId, item.dispatchAttempt, item.vcMeetingImTurnOrigin, fence),
|
|
6684
|
+
});
|
|
6685
|
+
if (queued.status === 'stale-before-write')
|
|
6686
|
+
return 'stale-before-write';
|
|
6687
|
+
if (queued.value === 'stale-before-write')
|
|
6688
|
+
requeueOnce();
|
|
6689
|
+
return queued.value;
|
|
6690
|
+
}
|
|
5677
6691
|
function isWorkflowWorker() {
|
|
5678
6692
|
return process.env.BOTMUX_WORKFLOW === '1';
|
|
5679
6693
|
}
|
|
@@ -5939,10 +6953,7 @@ async function captureAndUpload() {
|
|
|
5939
6953
|
logError(`Screenshot upload failed: ${err?.message ?? err}`);
|
|
5940
6954
|
return;
|
|
5941
6955
|
}
|
|
5942
|
-
|
|
5943
|
-
// master removed the AI ScreenAnalyzer, so the `analyzing` projection is gone;
|
|
5944
|
-
// PR #597's Codex App no-progress liveness projection still layers on top.
|
|
5945
|
-
status = codexAppLivenessStatus(status);
|
|
6956
|
+
const status = projectedRuntimeScreenStatus();
|
|
5946
6957
|
send({
|
|
5947
6958
|
type: 'screenshot_uploaded',
|
|
5948
6959
|
imageKey,
|
|
@@ -6168,7 +7179,7 @@ async function flushPendingInjections() {
|
|
|
6168
7179
|
if (!canStartInjectionFlush({
|
|
6169
7180
|
injectionFlushing,
|
|
6170
7181
|
userFlushing: isFlushing,
|
|
6171
|
-
sessionRenameInFlight,
|
|
7182
|
+
sessionRenameInFlight: sessionRenameInFlight(),
|
|
6172
7183
|
commandLineWritesPending,
|
|
6173
7184
|
bareShellLaunchBlocked,
|
|
6174
7185
|
}))
|
|
@@ -6176,7 +7187,7 @@ async function flushPendingInjections() {
|
|
|
6176
7187
|
injectionFlushing = true;
|
|
6177
7188
|
try {
|
|
6178
7189
|
while (pendingInjections.length > 0 && backend && isPromptReady && !bareShellLaunchBlocked
|
|
6179
|
-
&& !sessionRenameInFlight) {
|
|
7190
|
+
&& !sessionRenameInFlight()) {
|
|
6180
7191
|
// Mirror flushPending's one-shot launch-failure guard: when an injection is
|
|
6181
7192
|
// the FIRST writer after a (re)spawn, flushPending may never have run
|
|
6182
7193
|
// (pendingMessages empty ⇒ early return) so the bare-shell check must also
|
|
@@ -6881,7 +7892,15 @@ async function handleTrustedCodexAppMarker(kind, payload, control) {
|
|
|
6881
7892
|
}
|
|
6882
7893
|
if (deliverableContent && startedAtMs !== undefined) {
|
|
6883
7894
|
const suppressMarkers = readSendMarkers();
|
|
6884
|
-
|
|
7895
|
+
// Pass the RAW finalContent (not the pre-stripped deliverableContent) as
|
|
7896
|
+
// finalText: shouldSuppressBridgeEmit needs to SEE the trailing sentinel to
|
|
7897
|
+
// apply the "already sent this turn + sentinel terminator → suppress
|
|
7898
|
+
// narration" branch. It strips internally for the length comparison, so a
|
|
7899
|
+
// prose+sentinel final is still length-matched on the stripped prose. If we
|
|
7900
|
+
// handed it the already-stripped text, the trailing-sentinel signal would
|
|
7901
|
+
// be invisible and a longer-than-send narration would leak (same class as
|
|
7902
|
+
// the transcript-path bug this fixes).
|
|
7903
|
+
const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: finalContent };
|
|
6885
7904
|
suppressDelivery = suppressDelivery || shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, suppressMarkers, false);
|
|
6886
7905
|
if (suppressDelivery) {
|
|
6887
7906
|
log(`${cliName()} final_output suppressed (model already called botmux send)`);
|
|
@@ -7339,8 +8358,11 @@ function onPtyData(data) {
|
|
|
7339
8358
|
return;
|
|
7340
8359
|
}
|
|
7341
8360
|
// Track last PTY output time for the ready-gate quiescence settle (see
|
|
7342
|
-
// settleThenFlush) and
|
|
8361
|
+
// settleThenFlush) and a monotonic generation for rejected-ready evidence.
|
|
8362
|
+
// Generation (not timestamp equality) distinguishes two redraw chunks that
|
|
8363
|
+
// happen inside the same millisecond.
|
|
7343
8364
|
lastPtyOutputAtMs = Date.now();
|
|
8365
|
+
ptyOutputGeneration.observe();
|
|
7344
8366
|
idleDetector?.feed(data);
|
|
7345
8367
|
}
|
|
7346
8368
|
/**
|
|
@@ -7449,23 +8471,12 @@ function markPromptReadyFromPty(observedBackend) {
|
|
|
7449
8471
|
postSessionStartPromptEvidenceInFlight = false;
|
|
7450
8472
|
}
|
|
7451
8473
|
}
|
|
7452
|
-
/**
|
|
7453
|
-
* Push a coarse screen status to the daemon without waiting for the 2s sampler.
|
|
7454
|
-
* Used so card-off reactions see working→idle even on short turns.
|
|
7455
|
-
*
|
|
7456
|
-
* @param force - When true, send `status` as-is and skip classifyScreenUsageLimit.
|
|
7457
|
-
* Synthetic "working" seeds for card-off reactions MUST force: if the first
|
|
7458
|
-
* screen already shows a rate-limit banner, classify would rewrite working→
|
|
7459
|
-
* limited and the seed collapses to limited→limited (no working edge →
|
|
7460
|
-
* finishTurnReactions never runs). Review: PR #633 third round.
|
|
7461
|
-
*/
|
|
8474
|
+
/** Push a coarse screen status without waiting for the periodic sampler. */
|
|
7462
8475
|
function publishScreenStatus(status, opts) {
|
|
7463
8476
|
if (!renderer)
|
|
7464
8477
|
return;
|
|
7465
8478
|
const { content } = renderer.snapshot();
|
|
7466
|
-
const statusPayload = opts?.force
|
|
7467
|
-
? { status }
|
|
7468
|
-
: classifyScreenUsageLimit(content, status);
|
|
8479
|
+
const statusPayload = opts?.force ? { status } : classifyScreenUsageLimit(content, status);
|
|
7469
8480
|
send({
|
|
7470
8481
|
type: 'screen_update',
|
|
7471
8482
|
content,
|
|
@@ -7474,17 +8485,73 @@ function publishScreenStatus(status, opts) {
|
|
|
7474
8485
|
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
7475
8486
|
});
|
|
7476
8487
|
}
|
|
8488
|
+
function stopStructuredStartGraceRecheck() {
|
|
8489
|
+
if (!structuredStartGraceRecheckTimer)
|
|
8490
|
+
return;
|
|
8491
|
+
clearTimeout(structuredStartGraceRecheckTimer);
|
|
8492
|
+
structuredStartGraceRecheckTimer = null;
|
|
8493
|
+
}
|
|
8494
|
+
function redriveRejectedStructuredReady() {
|
|
8495
|
+
const readyEvidenceGeneration = structuredRejectedReadyEvidenceGeneration;
|
|
8496
|
+
if (readyEvidenceGeneration === undefined)
|
|
8497
|
+
return;
|
|
8498
|
+
stopStructuredStartGraceRecheck();
|
|
8499
|
+
pruneExpiredStructuredHeadsAndEmit('structured pre-start gate');
|
|
8500
|
+
if (!backend || isPromptReady) {
|
|
8501
|
+
structuredRejectedReadyEvidenceGeneration = undefined;
|
|
8502
|
+
return;
|
|
8503
|
+
}
|
|
8504
|
+
if (hasStructuredLifecycleBlock()) {
|
|
8505
|
+
const nextRemainingMs = codexBridgeQueue.preStartLeaseRemainingMs();
|
|
8506
|
+
if (nextRemainingMs !== undefined)
|
|
8507
|
+
scheduleStructuredStartGraceRecheck(nextRemainingMs);
|
|
8508
|
+
return;
|
|
8509
|
+
}
|
|
8510
|
+
structuredRejectedReadyEvidenceGeneration = undefined;
|
|
8511
|
+
if (ptyOutputGeneration.isCurrent(readyEvidenceGeneration)) {
|
|
8512
|
+
log('Structured pre-start gate settled with no newer PTY output — re-driving prior prompt-ready signal');
|
|
8513
|
+
idleDetector?.fireIdle();
|
|
8514
|
+
return;
|
|
8515
|
+
}
|
|
8516
|
+
if (cliAdapter?.busyPattern && (backend.captureCurrentScreen || backend.captureViewport)) {
|
|
8517
|
+
probeBusyPatternIdle('structured pre-start gate', backend);
|
|
8518
|
+
return;
|
|
8519
|
+
}
|
|
8520
|
+
try {
|
|
8521
|
+
const currentScreen = captureBackendScreen(backend);
|
|
8522
|
+
if (currentScreen)
|
|
8523
|
+
idleDetector?.feed(currentScreen);
|
|
8524
|
+
}
|
|
8525
|
+
catch (err) {
|
|
8526
|
+
log(`Structured pre-start screen recheck failed: ${err.message}`);
|
|
8527
|
+
}
|
|
8528
|
+
}
|
|
8529
|
+
/** Re-drive a prompt signal rejected only because an explicitly confirmed
|
|
8530
|
+
* submit or its in-flight verification had not reached the structured
|
|
8531
|
+
* transcript yet. The bounded lease prevents permanent false-busy. */
|
|
8532
|
+
function scheduleStructuredStartGraceRecheck(remainingMs) {
|
|
8533
|
+
stopStructuredStartGraceRecheck();
|
|
8534
|
+
const backendAtSchedule = backend;
|
|
8535
|
+
const cliGenerationAtSchedule = cliSpawnGeneration;
|
|
8536
|
+
structuredStartGraceRecheckTimer = setTimeout(() => {
|
|
8537
|
+
structuredStartGraceRecheckTimer = null;
|
|
8538
|
+
if (!isCliBackendGenerationCurrent({ generation: cliGenerationAtSchedule, backend: backendAtSchedule }, { generation: cliSpawnGeneration, backend, restartInProgress: cliRestartInProgress })) {
|
|
8539
|
+
return;
|
|
8540
|
+
}
|
|
8541
|
+
redriveRejectedStructuredReady();
|
|
8542
|
+
}, Math.max(1, remainingMs + 1));
|
|
8543
|
+
structuredStartGraceRecheckTimer.unref?.();
|
|
8544
|
+
}
|
|
7477
8545
|
function markPromptReady() {
|
|
7478
|
-
// Only screen readiness plus a non-shell pane leaf may release a launch that
|
|
7479
|
-
// was previously blocked. Transcript/task callbacks can arrive while the
|
|
7480
|
-
// wrapper is still a shell; accepting one here would set isPromptReady and
|
|
7481
|
-
// make the later real PTY callback return early, stranding queued input.
|
|
7482
8546
|
if (bareShellLaunchBlocked) {
|
|
7483
8547
|
log('Ignoring non-PTY prompt-ready while bare-shell launch block is active');
|
|
7484
8548
|
return;
|
|
7485
8549
|
}
|
|
7486
|
-
if (isPromptReady)
|
|
8550
|
+
if (isPromptReady) {
|
|
8551
|
+
stopStructuredStartGraceRecheck();
|
|
7487
8552
|
return; // guard against duplicate calls
|
|
8553
|
+
}
|
|
8554
|
+
stopStructuredStartGraceRecheck();
|
|
7488
8555
|
if (lastInitConfig?.cliId === 'codex-app' && !codexAppControlProven) {
|
|
7489
8556
|
if (!codexAppUnprovenPromptDeferred) {
|
|
7490
8557
|
log('Ignoring Codex App prompt until this worker verifies a fresh Ed25519 challenge response');
|
|
@@ -7550,8 +8617,27 @@ function markPromptReady() {
|
|
|
7550
8617
|
}
|
|
7551
8618
|
if (freshnessAction === 'ignore')
|
|
7552
8619
|
return;
|
|
8620
|
+
// Screen prompt/quiescence is only a UI heuristic. Structured transcript
|
|
8621
|
+
// bridges have the stronger lifecycle signal: a transcript-started turn
|
|
8622
|
+
// without assistant_final is still running even if the TUI redraw exposes a
|
|
8623
|
+
// prompt. An adapter-confirmed type-ahead submit also blocks during a bounded
|
|
8624
|
+
// hand-off lease while the CLI waits to write its transcript user event. A
|
|
8625
|
+
// bare mark is never authoritative, and the lease is explicitly re-driven,
|
|
8626
|
+
// so a dropped Enter cannot create permanent false-busy.
|
|
8627
|
+
// Reject the heuristic and re-arm IdleDetector so the later transcript-final
|
|
8628
|
+
// fireIdle() can drive the real ready edge.
|
|
8629
|
+
if (hasStructuredLifecycleBlock()) {
|
|
8630
|
+
const remainingMs = codexBridgeQueue.preStartLeaseRemainingMs();
|
|
8631
|
+
structuredRejectedReadyEvidenceGeneration = ptyOutputGeneration.snapshot();
|
|
8632
|
+
log('Ignoring prompt-ready heuristic while a structured turn is unfinished or submit verification/start is pending');
|
|
8633
|
+
idleDetector?.reset();
|
|
8634
|
+
if (remainingMs !== undefined)
|
|
8635
|
+
scheduleStructuredStartGraceRecheck(remainingMs);
|
|
8636
|
+
return;
|
|
8637
|
+
}
|
|
8638
|
+
structuredRejectedReadyEvidenceGeneration = undefined;
|
|
7553
8639
|
isPromptReady = true;
|
|
7554
|
-
|
|
8640
|
+
settleSessionRenameOnPrompt();
|
|
7555
8641
|
// An old backend can still report idle while its async teardown is running.
|
|
7556
8642
|
// Only a prompt observed after the general restart fence drops may release
|
|
7557
8643
|
// slash commands to the replacement generation.
|
|
@@ -7588,6 +8674,21 @@ function markPromptReady() {
|
|
|
7588
8674
|
send({ type: 'runner_build_ready', runnerBuildId: lastInitConfig.runnerBuildId });
|
|
7589
8675
|
persistCodexRunnerBuildOnReady = false;
|
|
7590
8676
|
}
|
|
8677
|
+
// Send immediate idle snapshot so Lark card reflects idle status.
|
|
8678
|
+
// BUT: skip when messages are pending — flushPending() will immediately
|
|
8679
|
+
// make the CLI busy, so the idle state is transient and shouldn't appear
|
|
8680
|
+
// in the card. This avoids a false "就绪" flash on daemon restart
|
|
8681
|
+
// (where the initial prompt is queued before the CLI becomes idle).
|
|
8682
|
+
if (renderer && pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null && !isFlushing) {
|
|
8683
|
+
const { content } = renderer.snapshot();
|
|
8684
|
+
send({
|
|
8685
|
+
type: 'screen_update',
|
|
8686
|
+
content,
|
|
8687
|
+
...usageLimitTracker.classify(content, projectedRuntimeScreenStatus()),
|
|
8688
|
+
turnId: currentBotmuxTurnId,
|
|
8689
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
8690
|
+
});
|
|
8691
|
+
}
|
|
7591
8692
|
if (activeRestartAttemptId) {
|
|
7592
8693
|
// Defense in depth: only report a successful restart when a replacement
|
|
7593
8694
|
// backend is actually installed. Every legitimate ready path assigns
|
|
@@ -7746,42 +8847,56 @@ function notifyAmbiguousSubmissionRecovery(recoveryReason, turnIdentity) {
|
|
|
7746
8847
|
message: recoveryReason,
|
|
7747
8848
|
});
|
|
7748
8849
|
}
|
|
8850
|
+
/** Remove a transcript mark after the submit path has conclusively failed.
|
|
8851
|
+
* Both bridge implementations mark before writing so they cannot miss a
|
|
8852
|
+
* fast transcript append; a failed write must undo only an UNSTARTED mark.
|
|
8853
|
+
* Cleanup itself does not synthesize ready: an unverified mark never blocks,
|
|
8854
|
+
* and any verified pre-start lease is bounded and owns its expiry re-drive. */
|
|
8855
|
+
function dropFailedBridgeMark(bridgeTurnId, dispatchAttempt) {
|
|
8856
|
+
if (!bridgeTurnId)
|
|
8857
|
+
return;
|
|
8858
|
+
const dropped = bridgeQueue.dropPendingTurn(bridgeTurnId, dispatchAttempt);
|
|
8859
|
+
const droppedStructured = codexBridgeQueue.dropPendingTurn(bridgeTurnId, dispatchAttempt);
|
|
8860
|
+
if (dropped) {
|
|
8861
|
+
if (dropped.contentFingerprint)
|
|
8862
|
+
bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
|
|
8863
|
+
log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — rotation-fallback scan will stop spinning on this fingerprint.`);
|
|
8864
|
+
}
|
|
8865
|
+
if (droppedStructured) {
|
|
8866
|
+
log(`Structured bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — later buffered turns were rechecked.`);
|
|
8867
|
+
// dropPendingTurn can replay a buffered successor user+final pair. Drain
|
|
8868
|
+
// it in the same explicit mutation path; leaving completion production to
|
|
8869
|
+
// a later transcript event can strand fallback output indefinitely.
|
|
8870
|
+
emitReadyCodexTurns();
|
|
8871
|
+
}
|
|
8872
|
+
}
|
|
7749
8873
|
/** Worker-side handler for `submitted: false`. Defers the user-facing
|
|
7750
8874
|
* warning and runs the adapter-supplied `recheck` closure first; if the
|
|
7751
8875
|
* message has shown up in the transcript by then (slow path, hook delay),
|
|
7752
8876
|
* suppresses the warning entirely. Adapters without a recheck still fall
|
|
7753
8877
|
* through to the warning after the same delay so the UX is uniform.
|
|
7754
8878
|
*
|
|
7755
|
-
* `bridgeTurnId` is the
|
|
8879
|
+
* `bridgeTurnId` is the transcript queue mark created right before the
|
|
7756
8880
|
* failing writeInput. When the deferred recheck conclusively fails (= no
|
|
7757
|
-
*
|
|
7758
|
-
* attempt's mark
|
|
7759
|
-
*
|
|
7760
|
-
*
|
|
7761
|
-
function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed') {
|
|
8881
|
+
* transcript event will ever match this fingerprint), we drop that exact
|
|
8882
|
+
* dispatch attempt's mark. Leaving a Claude mark would keep rotation-fallback
|
|
8883
|
+
* scans spinning; leaving a structured mark would block later fingerprint
|
|
8884
|
+
* attribution even after its bounded status lease expires. */
|
|
8885
|
+
function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed', structuredTarget = false) {
|
|
7762
8886
|
const preview = buildSubmitMessagePreview(msg);
|
|
7763
8887
|
const emitDurableTerminal = (errorCode) => {
|
|
7764
8888
|
if (turnIdentity?.turnId && turnIdentity.dispatchAttempt !== undefined) {
|
|
7765
8889
|
emitTurnTerminal(turnIdentity.turnId, durableTerminalStatus, errorCode, turnIdentity.dispatchAttempt);
|
|
7766
8890
|
}
|
|
7767
8891
|
};
|
|
7768
|
-
const dropBridgeMark = () => {
|
|
7769
|
-
if (!bridgeTurnId)
|
|
7770
|
-
return;
|
|
7771
|
-
const dropped = bridgeQueue.dropPendingTurn(bridgeTurnId, turnIdentity?.dispatchAttempt);
|
|
7772
|
-
if (dropped) {
|
|
7773
|
-
if (dropped.contentFingerprint)
|
|
7774
|
-
bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
|
|
7775
|
-
log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}, attempt=${turnIdentity?.dispatchAttempt ?? '-'}) — rotation-fallback scan will stop spinning on this fingerprint.`);
|
|
7776
|
-
}
|
|
7777
|
-
};
|
|
7778
8892
|
if (failureReason) {
|
|
7779
8893
|
const action = decideSubmitConfirmationAction({
|
|
7780
8894
|
failureReason,
|
|
7781
8895
|
recheckSubmitted: false,
|
|
7782
8896
|
usageLimitDetected: false,
|
|
7783
8897
|
});
|
|
7784
|
-
|
|
8898
|
+
dropFailedBridgeMark(bridgeTurnId, turnIdentity?.dispatchAttempt);
|
|
8899
|
+
redriveRejectedStructuredReady();
|
|
7785
8900
|
const reason = action.kind === 'notify-hard-failure' ? action.reason : failureReason;
|
|
7786
8901
|
log(`writeInput: submit impossible — notifying user immediately. reason="${reason}" preview="${preview}"`);
|
|
7787
8902
|
emitDurableTerminal(`submit_impossible:${reason}`);
|
|
@@ -7795,29 +8910,36 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
7795
8910
|
return;
|
|
7796
8911
|
}
|
|
7797
8912
|
const activityBaselineMs = Date.now();
|
|
8913
|
+
const cliGenerationAtSchedule = cliSpawnGeneration;
|
|
8914
|
+
const backendAtSchedule = backend;
|
|
8915
|
+
const deferredAttemptIsCurrent = () => (cliSpawnGeneration === cliGenerationAtSchedule
|
|
8916
|
+
&& backendAtSchedule !== null
|
|
8917
|
+
&& backend === backendAtSchedule
|
|
8918
|
+
&& !cliRestartInProgress);
|
|
7798
8919
|
log(`writeInput: submit not confirmed after retries — deferred ${SUBMIT_DEFERRED_RECHECK_MS}ms recheck queued. preview="${preview}"`);
|
|
7799
8920
|
setTimeout(async () => {
|
|
7800
|
-
|
|
7801
|
-
|
|
7802
|
-
|
|
7803
|
-
|
|
7804
|
-
|
|
7805
|
-
|
|
7806
|
-
|
|
7807
|
-
|
|
7808
|
-
cliSessionId = typeof recheckResult === 'object' && recheckResult && typeof recheckResult.cliSessionId === 'string'
|
|
7809
|
-
? recheckResult.cliSessionId
|
|
7810
|
-
: undefined;
|
|
7811
|
-
}
|
|
7812
|
-
catch (err) {
|
|
7813
|
-
log(`Deferred recheck threw (${err?.message ?? err}); falling through to warning.`);
|
|
7814
|
-
}
|
|
7815
|
-
}
|
|
7816
|
-
const action = decideSubmitConfirmationAction({
|
|
7817
|
-
recheckSubmitted,
|
|
7818
|
-
usageLimitDetected: usageLimitTracker.detectedThisTurn(turnSeq),
|
|
7819
|
-
activityEvidence: submitActivityEvidenceSince(activityBaselineMs),
|
|
8921
|
+
const settlement = await settleDeferredSubmitConfirmation(codexBridgeQueue, {
|
|
8922
|
+
turnId: bridgeTurnId,
|
|
8923
|
+
dispatchAttempt: turnIdentity?.dispatchAttempt,
|
|
8924
|
+
structuredTarget,
|
|
8925
|
+
recheck,
|
|
8926
|
+
usageLimitDetected: () => usageLimitTracker.detectedThisTurn(turnSeq),
|
|
8927
|
+
activityEvidence: () => submitActivityEvidenceSince(activityBaselineMs),
|
|
8928
|
+
isCurrent: deferredAttemptIsCurrent,
|
|
7820
8929
|
});
|
|
8930
|
+
// Restart/exit or exact-attempt expiry can happen during either the 20s
|
|
8931
|
+
// delay or the awaited adapter recheck. A stale callback must perform no
|
|
8932
|
+
// side effects at all: no old cliSessionId persistence, ready redrive,
|
|
8933
|
+
// recursive timer, terminal, warning, or mutation of replay attempt N+1.
|
|
8934
|
+
if (settlement.stale) {
|
|
8935
|
+
log(`Discarded stale deferred submit recheck (${settlement.staleReason}) turn=${bridgeTurnId ?? '-'} attempt=${turnIdentity?.dispatchAttempt ?? '-'}`);
|
|
8936
|
+
return;
|
|
8937
|
+
}
|
|
8938
|
+
if (settlement.recheckError !== undefined) {
|
|
8939
|
+
const err = settlement.recheckError;
|
|
8940
|
+
log(`Deferred recheck threw (${err?.message ?? err}); falling through to warning.`);
|
|
8941
|
+
}
|
|
8942
|
+
const { action, cliSessionId } = settlement;
|
|
7821
8943
|
switch (action.kind) {
|
|
7822
8944
|
case 'suppress-confirmed':
|
|
7823
8945
|
if (cliSessionId) {
|
|
@@ -7827,13 +8949,16 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
7827
8949
|
void syncFreshCodexNativeSessionTitle(cliSessionId, codexRpcEngine);
|
|
7828
8950
|
}
|
|
7829
8951
|
log(`Deferred recheck found submit in ${transcriptLabel} — suppressing warning. preview="${preview}"`);
|
|
8952
|
+
redriveRejectedStructuredReady();
|
|
7830
8953
|
return;
|
|
7831
8954
|
case 'suppress-usage-limit':
|
|
7832
|
-
|
|
8955
|
+
dropFailedBridgeMark(bridgeTurnId, turnIdentity?.dispatchAttempt);
|
|
8956
|
+
redriveRejectedStructuredReady();
|
|
7833
8957
|
log(`Deferred recheck missing but usage limit was detected for this turn — suppressing submit warning. preview="${preview}"`);
|
|
7834
8958
|
emitDurableTerminal('submit_usage_limit');
|
|
7835
8959
|
return;
|
|
7836
8960
|
case 'suppress-active':
|
|
8961
|
+
redriveRejectedStructuredReady();
|
|
7837
8962
|
log(`Deferred recheck missing but later ${action.evidence} shows ${cliName()} is active — suppressing submit warning. preview="${preview}"`);
|
|
7838
8963
|
// For a durable turn, activity is evidence of possible submission, not
|
|
7839
8964
|
// a terminal result. Keep the arbiter closed and re-check until either
|
|
@@ -7848,7 +8973,8 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
|
|
|
7848
8973
|
case 'notify-stuck':
|
|
7849
8974
|
break;
|
|
7850
8975
|
}
|
|
7851
|
-
|
|
8976
|
+
dropFailedBridgeMark(bridgeTurnId, turnIdentity?.dispatchAttempt);
|
|
8977
|
+
redriveRejectedStructuredReady();
|
|
7852
8978
|
log(`Deferred recheck still missing — notifying user. preview="${preview}"`);
|
|
7853
8979
|
emitDurableTerminal('submit_unconfirmed');
|
|
7854
8980
|
if (turnIdentity?.dispatchAttempt === undefined) {
|
|
@@ -8009,9 +9135,9 @@ async function flushPending() {
|
|
|
8009
9135
|
}
|
|
8010
9136
|
if (ambiguousSubmissionRecoveryHold?.backend === backend)
|
|
8011
9137
|
return;
|
|
8012
|
-
if (pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null)
|
|
9138
|
+
if (pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null)
|
|
8013
9139
|
return; // nothing to flush — keep isPromptReady
|
|
8014
|
-
if (sessionRenameInFlight)
|
|
9140
|
+
if (sessionRenameInFlight())
|
|
8015
9141
|
return; // wait for /rename to finish before any user input
|
|
8016
9142
|
if (commandLineWritesPending > 0)
|
|
8017
9143
|
return; // do not splice into text -> Enter
|
|
@@ -8034,6 +9160,16 @@ async function flushPending() {
|
|
|
8034
9160
|
// the active attempt.
|
|
8035
9161
|
if (!pendingInputMayFlush(durableTurnInFlight))
|
|
8036
9162
|
return;
|
|
9163
|
+
// A no-native or locally-unregistered RPC delivery is deliberately
|
|
9164
|
+
// fail-closed: its exact execution state is unknown. Codex normally permits
|
|
9165
|
+
// type-ahead/steer, but admitting a successor here could merge it into the
|
|
9166
|
+
// unknown turn or overlap a durable replay. Keep every input class queued
|
|
9167
|
+
// until structured evidence, exact terminal, bounded generation restart, or
|
|
9168
|
+
// teardown retires the owner.
|
|
9169
|
+
if (rpcLifecycleFailClosedOwners.size > 0) {
|
|
9170
|
+
log(`Holding pending input behind ${rpcLifecycleFailClosedOwners.size} unresolved Codex RPC delivery owner(s)`);
|
|
9171
|
+
return;
|
|
9172
|
+
}
|
|
8037
9173
|
// Ready-gate: hold the FIRST prompt until the SessionStart hook fires a true-
|
|
8038
9174
|
// ready signal. A cjadk-style startup selector's ❯ falsely matches readyPattern
|
|
8039
9175
|
// and would otherwise eat this message. releaseReadyGate() re-invokes us once
|
|
@@ -8084,12 +9220,13 @@ async function flushPending() {
|
|
|
8084
9220
|
// pending messages can still drain while busy; the rename stays queued.
|
|
8085
9221
|
const sessionRenameReady = isPromptReady && pendingSessionRename !== null;
|
|
8086
9222
|
const rawInputReady = isPromptReady && pendingRawInputs.length > 0;
|
|
9223
|
+
const adoptInputReady = isPromptReady && lastInitConfig?.adoptMode === true && pendingAdoptMessages.length > 0;
|
|
8087
9224
|
let supportedSessionRenameReady = sessionRenameReady;
|
|
8088
9225
|
if (sessionRenameReady && (!cliAdapter.buildSessionRenameCommand || effectiveBackendType === 'riff')) {
|
|
8089
9226
|
pendingSessionRename = null;
|
|
8090
9227
|
supportedSessionRenameReady = false;
|
|
8091
9228
|
log(`Ignoring native session rename — unsupported by ${cliName()}${effectiveBackendType === 'riff' ? ' on riff backend' : ''}`);
|
|
8092
|
-
if (pendingMessages.length === 0 && pendingRawInputs.length === 0)
|
|
9229
|
+
if (pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0)
|
|
8093
9230
|
return;
|
|
8094
9231
|
}
|
|
8095
9232
|
if (!isPromptReady && pendingMessages.length === 0)
|
|
@@ -8098,14 +9235,12 @@ async function flushPending() {
|
|
|
8098
9235
|
return;
|
|
8099
9236
|
isFlushing = true;
|
|
8100
9237
|
const codexAppPromptReplay = new CodexAppFlushPromptReplay();
|
|
8101
|
-
|
|
8102
|
-
|
|
8103
|
-
|
|
8104
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
// force: same rate-limit rewrite trap as argv seed (review third round).
|
|
8108
|
-
publishScreenStatus('working', { force: true });
|
|
9238
|
+
// Raw input and native rename own their explicit command-line/session gates;
|
|
9239
|
+
// pending adopt writes re-arm inside writeAdoptMessage. Clearing readiness
|
|
9240
|
+
// here would make an adopt rename's in-queue readiness recheck always see a
|
|
9241
|
+
// synthetic false. Normal prompt/startup flushes keep the original re-arm.
|
|
9242
|
+
if (!rawInputReady && !supportedSessionRenameReady && !adoptInputReady) {
|
|
9243
|
+
beginCliWriteCycle();
|
|
8109
9244
|
}
|
|
8110
9245
|
try {
|
|
8111
9246
|
// Launch-failure guard, run ONCE per spawn on the first flush, BEFORE startup
|
|
@@ -8155,10 +9290,49 @@ async function flushPending() {
|
|
|
8155
9290
|
if (supportedSessionRenameReady && pendingSessionRename !== null && backend && cliAdapter) {
|
|
8156
9291
|
const title = pendingSessionRename;
|
|
8157
9292
|
const buildRename = cliAdapter.buildSessionRenameCommand;
|
|
9293
|
+
const renameBackend = backend;
|
|
9294
|
+
const renameGeneration = cliSpawnGeneration;
|
|
8158
9295
|
pendingSessionRename = null;
|
|
8159
|
-
|
|
9296
|
+
sessionRenamePhase = 'reserved';
|
|
8160
9297
|
try {
|
|
8161
|
-
|
|
9298
|
+
const writeRename = async () => {
|
|
9299
|
+
// Transition to busy inside the adopt queue, after the readiness
|
|
9300
|
+
// recheck. Keep `writing` through text→beat→Enter: a terminal from
|
|
9301
|
+
// the preceding turn in that window must not release the rename gate.
|
|
9302
|
+
beginCliWriteCycle();
|
|
9303
|
+
sessionRenamePhase = 'writing';
|
|
9304
|
+
await sendRawCommandLineWithRecoveryFence(renameBackend, buildRename(title));
|
|
9305
|
+
if (cliSpawnGeneration !== renameGeneration || backend !== renameBackend || cliRestartInProgress) {
|
|
9306
|
+
throw new Error('rename backend generation changed before Enter settlement');
|
|
9307
|
+
}
|
|
9308
|
+
sessionRenamePhase = 'sent';
|
|
9309
|
+
// A previous turn's terminal may have raised ready while the rename
|
|
9310
|
+
// was still writing. Re-arm after Enter so only rename's own prompt
|
|
9311
|
+
// can settle `sent` (with the bounded timeout as fail-open fallback).
|
|
9312
|
+
beginCliWriteCycle();
|
|
9313
|
+
};
|
|
9314
|
+
const sent = lastInitConfig?.adoptMode
|
|
9315
|
+
? await runAdoptSessionRenameSequence({
|
|
9316
|
+
queue: adoptWriteQueue,
|
|
9317
|
+
// An adopt write queued immediately before rename can make the
|
|
9318
|
+
// outer readiness decision stale while this task waits.
|
|
9319
|
+
isPromptReady: () => isPromptReady
|
|
9320
|
+
&& backend === renameBackend
|
|
9321
|
+
&& cliSpawnGeneration === renameGeneration
|
|
9322
|
+
&& !cliRestartInProgress
|
|
9323
|
+
&& !rawInputRestartGate,
|
|
9324
|
+
writeRename,
|
|
9325
|
+
})
|
|
9326
|
+
: (await writeRename(), true);
|
|
9327
|
+
if (!sent) {
|
|
9328
|
+
// Keep a newer title if one arrived while this rename waited. Retry
|
|
9329
|
+
// this title only when it is still the latest requested canonical title.
|
|
9330
|
+
if (pendingSessionRename === null)
|
|
9331
|
+
pendingSessionRename = title;
|
|
9332
|
+
forceClearSessionRenameInFlight();
|
|
9333
|
+
log(`Deferred native session rename until the next prompt (${cliName()}): ${title}`);
|
|
9334
|
+
return;
|
|
9335
|
+
}
|
|
8162
9336
|
armSessionRenameIdleTimeout();
|
|
8163
9337
|
idleDetector?.reset();
|
|
8164
9338
|
log(`Native session rename command sent (${cliName()}): ${title}`);
|
|
@@ -8169,17 +9343,43 @@ async function flushPending() {
|
|
|
8169
9343
|
// Keep the rename gate for the same bounded fail-open window: the write
|
|
8170
9344
|
// may have stopped after typing only part of the command, so immediately
|
|
8171
9345
|
// appending a deferred raw_input could corrupt both commands.
|
|
8172
|
-
|
|
9346
|
+
if (cliSpawnGeneration === renameGeneration && backend === renameBackend && !cliRestartInProgress) {
|
|
9347
|
+
settleFailedSessionRenameWrite();
|
|
9348
|
+
armSessionRenameIdleTimeout();
|
|
9349
|
+
}
|
|
9350
|
+
else {
|
|
9351
|
+
forceClearSessionRenameInFlight();
|
|
9352
|
+
}
|
|
8173
9353
|
log(`Native session rename command failed (${cliName()}): ${err?.message ?? err}; waiting for prompt or fail-open timeout`);
|
|
8174
9354
|
}
|
|
8175
9355
|
// Wait for the command to finish and the TUI to become idle again before
|
|
8176
9356
|
// sending queued user prompts; otherwise they can land in its picker.
|
|
8177
9357
|
return;
|
|
8178
9358
|
}
|
|
9359
|
+
if (adoptInputReady && pendingAdoptMessages.length > 0) {
|
|
9360
|
+
const item = pendingAdoptMessages.shift();
|
|
9361
|
+
await runAdoptMessageForCapturedGeneration(item, () => {
|
|
9362
|
+
pendingAdoptMessages.unshift(item);
|
|
9363
|
+
log(`Re-queued adopt message at queue head for the replacement CLI generation (${item.content.length} chars)`);
|
|
9364
|
+
});
|
|
9365
|
+
return;
|
|
9366
|
+
}
|
|
8179
9367
|
while (pendingMessages.length > 0 && backend && cliAdapter) {
|
|
8180
9368
|
const item = freshnessInputQueue.takeNormal();
|
|
8181
9369
|
if (!item)
|
|
8182
9370
|
break;
|
|
9371
|
+
// Type-ahead can drain several items in one flush. Each logical submit
|
|
9372
|
+
// starts its own readiness generation before transcript marking/writing.
|
|
9373
|
+
beginCliWriteCycle();
|
|
9374
|
+
const writeGeneration = cliSpawnGeneration;
|
|
9375
|
+
const writeBackend = backend;
|
|
9376
|
+
const writeAdapter = cliAdapter;
|
|
9377
|
+
const writeRpcEngine = codexRpcEngine;
|
|
9378
|
+
const writeContinuationIsCurrent = () => (cliSpawnGeneration === writeGeneration
|
|
9379
|
+
&& backend === writeBackend
|
|
9380
|
+
&& cliAdapter === writeAdapter
|
|
9381
|
+
&& codexRpcEngine === writeRpcEngine
|
|
9382
|
+
&& !cliRestartInProgress);
|
|
8183
9383
|
const durableWrite = item.dispatchAttempt !== undefined;
|
|
8184
9384
|
const msg = item.content;
|
|
8185
9385
|
const logicalMsg = item.logicalContent ?? msg;
|
|
@@ -8206,7 +9406,7 @@ async function flushPending() {
|
|
|
8206
9406
|
// in-flight and must not participate in restart replay. RPC mode is
|
|
8207
9407
|
// excluded: the app-server owns an accepted turn independently of the
|
|
8208
9408
|
// viewer pane, so replaying it after a pane restart could run it twice.
|
|
8209
|
-
if (!
|
|
9409
|
+
if (!writeRpcEngine) {
|
|
8210
9410
|
inflightInputs.onWrite(item);
|
|
8211
9411
|
stuckDetector?.arm();
|
|
8212
9412
|
}
|
|
@@ -8222,8 +9422,11 @@ async function flushPending() {
|
|
|
8222
9422
|
catch { /* best-effort */ }
|
|
8223
9423
|
bridgeTurnId = bridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
|
|
8224
9424
|
}
|
|
8225
|
-
else if (codexBridgeActive) {
|
|
8226
|
-
codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
|
|
9425
|
+
else if (codexBridgeActive && !writeRpcEngine) {
|
|
9426
|
+
bridgeTurnId = codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
|
|
9427
|
+
if (bridgeTurnId) {
|
|
9428
|
+
codexBridgeQueue.beginSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
|
|
9429
|
+
}
|
|
8227
9430
|
}
|
|
8228
9431
|
if (durableWrite
|
|
8229
9432
|
&& cliAdapter.reliableTurnTerminal === true
|
|
@@ -8290,42 +9493,89 @@ async function flushPending() {
|
|
|
8290
9493
|
// backend regression). flushPending is invoked fire-and-forget, so an
|
|
8291
9494
|
// escaping rejection would become an unhandledRejection and crash the
|
|
8292
9495
|
// worker — exactly the failure mode this change is closing. Contain it.
|
|
8293
|
-
let result;
|
|
8294
9496
|
let submissionBackend = null;
|
|
8295
9497
|
let recoveryFailureReason;
|
|
9498
|
+
const handleStaleWriteContinuation = (errorCode) => {
|
|
9499
|
+
const disposition = settleStaleWriteContinuation(item, errorCode, (turnId, code, dispatchAttempt) => {
|
|
9500
|
+
emitTurnTerminal(turnId, 'ambiguous', code, dispatchAttempt);
|
|
9501
|
+
});
|
|
9502
|
+
// Generation change has already handed ordinary input to
|
|
9503
|
+
// InflightInputTracker carryover. Do not touch global bridge queues:
|
|
9504
|
+
// replacement may already have marked the same turnId/undefined key.
|
|
9505
|
+
log(`Discarded stale writeInput continuation turn=${item.turnId ?? '-'} attempt=${item.dispatchAttempt ?? '-'} generation=${writeGeneration} disposition=${disposition}`);
|
|
9506
|
+
};
|
|
9507
|
+
let result;
|
|
9508
|
+
let rpcTurnIdentity;
|
|
9509
|
+
let rpcTurnGeneration;
|
|
8296
9510
|
try {
|
|
8297
|
-
if (
|
|
9511
|
+
if (writeRpcEngine) {
|
|
9512
|
+
rpcTurnIdentity = {
|
|
9513
|
+
turnId: item.turnId ?? `codex-rpc-${randomBytes(8).toString('hex')}`,
|
|
9514
|
+
...(item.dispatchAttempt !== undefined
|
|
9515
|
+
? { dispatchAttempt: item.dispatchAttempt }
|
|
9516
|
+
: {}),
|
|
9517
|
+
};
|
|
9518
|
+
rpcTurnGeneration = {
|
|
9519
|
+
engine: writeRpcEngine,
|
|
9520
|
+
cliGeneration: writeGeneration,
|
|
9521
|
+
};
|
|
9522
|
+
installAwaitingRpcActivation(rpcTurnIdentity, rpcTurnGeneration);
|
|
8298
9523
|
// RPC input mode: deliver via JSON-RPC turn/start (its ack IS the
|
|
8299
9524
|
// submit confirmation), which the attached `codex --remote` TUI
|
|
8300
9525
|
// renders. No tmux paste → the history.jsonl verify/retry/recover
|
|
8301
9526
|
// machinery is bypassed. A throw here falls into the catch below and
|
|
8302
9527
|
// surfaces as a normal submit-failure notice.
|
|
8303
|
-
|
|
8304
|
-
await runAfterAmbiguousSubmissionWrites(targetBackend, async () => {
|
|
9528
|
+
await runAfterAmbiguousSubmissionWrites(writeBackend, async () => {
|
|
8305
9529
|
prepareNormalWrite();
|
|
8306
|
-
await
|
|
9530
|
+
await writeRpcEngine.sendTurn(msg, rpcTurnIdentity);
|
|
8307
9531
|
});
|
|
9532
|
+
// The await may overlap an engine/pane replacement. Fence the captured
|
|
9533
|
+
// generation BEFORE touching the global bridge queue; a stale ack must
|
|
9534
|
+
// never activate a mark owned by the replacement generation.
|
|
9535
|
+
if (!writeContinuationIsCurrent()) {
|
|
9536
|
+
// RPC writes bypass InflightInputTracker because replay could
|
|
9537
|
+
// duplicate an already-accepted server turn. Keep this exact owner
|
|
9538
|
+
// registered until stopCodexRpcEngine settles a native terminal or
|
|
9539
|
+
// publishes one ambiguous teardown terminal + notice.
|
|
9540
|
+
log(`Deferred stale Codex RPC continuation to engine teardown `
|
|
9541
|
+
+ `turn=${rpcTurnIdentity.turnId} generation=${writeGeneration}`);
|
|
9542
|
+
break;
|
|
9543
|
+
}
|
|
8308
9544
|
result = { submitted: true };
|
|
9545
|
+
// Only the ACKed, still-current generation may create bridge state.
|
|
9546
|
+
// While turn/start was pending, codexBridgeIngest left its cursor
|
|
9547
|
+
// untouched; marking now therefore still precedes replay of any
|
|
9548
|
+
// already-persisted user/final events.
|
|
9549
|
+
bridgeTurnId = rpcTurnIdentity.turnId;
|
|
9550
|
+
// The app-server ack confirms execution has begun, but no local
|
|
9551
|
+
// transcript event will follow to flip started. Mark the turn active
|
|
9552
|
+
// so the lifecycle gate stays asserted for the full server-side run
|
|
9553
|
+
// instead of relying on the bounded 20s confirmation lease (which
|
|
9554
|
+
// would expire mid-turn on a long-running or approval-pending turn).
|
|
9555
|
+
const activated = activateRpcTurnLifecycle(rpcTurnIdentity, msg, false, rpcTurnGeneration);
|
|
9556
|
+
if (activated) {
|
|
9557
|
+
codexBridgeDrainAndMaybeEmit({ signalIdle: false });
|
|
9558
|
+
}
|
|
8309
9559
|
}
|
|
8310
|
-
else if (item.codexAppInput &&
|
|
8311
|
-
|
|
8312
|
-
const
|
|
8313
|
-
submissionBackend = targetBackend;
|
|
8314
|
-
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeStructuredInput(adapterInputHandle(targetBackend), msg, item.codexAppInput, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
9560
|
+
else if (item.codexAppInput && writeAdapter.writeStructuredInput) {
|
|
9561
|
+
submissionBackend = writeBackend;
|
|
9562
|
+
const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeStructuredInput(adapterInputHandle(writeBackend), msg, item.codexAppInput, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
8315
9563
|
result = transaction.result;
|
|
8316
9564
|
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
8317
9565
|
}
|
|
8318
9566
|
else {
|
|
8319
|
-
|
|
8320
|
-
const
|
|
8321
|
-
submissionBackend = targetBackend;
|
|
8322
|
-
const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeInput(adapterInputHandle(targetBackend), msg, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
9567
|
+
submissionBackend = writeBackend;
|
|
9568
|
+
const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeInput(adapterInputHandle(writeBackend), msg, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
|
|
8323
9569
|
result = transaction.result;
|
|
8324
9570
|
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
8325
9571
|
}
|
|
8326
9572
|
if (recoveryFailureReason) {
|
|
8327
9573
|
result = { ...result, submitted: false };
|
|
8328
9574
|
}
|
|
9575
|
+
if (!writeContinuationIsCurrent()) {
|
|
9576
|
+
handleStaleWriteContinuation('write_generation_changed');
|
|
9577
|
+
break;
|
|
9578
|
+
}
|
|
8329
9579
|
// Transcript-backed CLIs (Grok/Codex/… reliableTurnTerminal) own idle via
|
|
8330
9580
|
// assistant_final → fireIdle. Their busyPattern is often missing for
|
|
8331
9581
|
// several seconds after submit (or never matches the current TUI chrome),
|
|
@@ -8346,6 +9596,42 @@ async function flushPending() {
|
|
|
8346
9596
|
&& !err.submissionStarted;
|
|
8347
9597
|
// Roll back this turn's Codex App liveness slot regardless of the failure
|
|
8348
9598
|
// shape; the slot was opened before the fenced write.
|
|
9599
|
+
// A replacement generation may already have installed an equal
|
|
9600
|
+
// turn/attempt key. Fence before touching any process-global queue or
|
|
9601
|
+
// liveness state owned by that successor.
|
|
9602
|
+
if (!writeContinuationIsCurrent()) {
|
|
9603
|
+
if (rpcTurnIdentity && rpcTurnGeneration) {
|
|
9604
|
+
// The frame may have crossed the socket before the error. Never hand
|
|
9605
|
+
// RPC input to ordinary carryover/replay; exact teardown owns the
|
|
9606
|
+
// terminal and user notice for this still-awaiting identity.
|
|
9607
|
+
log(`Deferred stale failed Codex RPC continuation to engine teardown `
|
|
9608
|
+
+ `turn=${rpcTurnIdentity.turnId} generation=${writeGeneration}`);
|
|
9609
|
+
}
|
|
9610
|
+
else {
|
|
9611
|
+
handleStaleWriteContinuation('write_generation_changed_after_error');
|
|
9612
|
+
}
|
|
9613
|
+
break;
|
|
9614
|
+
}
|
|
9615
|
+
if (rpcTurnIdentity && rpcTurnGeneration && writeRpcEngine) {
|
|
9616
|
+
const replayAnchorMs = awaitingRpcActivationReplayAnchorMs(rpcTurnIdentity, rpcTurnGeneration);
|
|
9617
|
+
clearAwaitingRpcActivation(rpcTurnIdentity, rpcTurnGeneration);
|
|
9618
|
+
bridgeTurnId = codexBridgeMarkPendingTurn(msg, rpcTurnIdentity.turnId, rpcTurnIdentity.dispatchAttempt, replayAnchorMs);
|
|
9619
|
+
installRpcLifecycleFailClosedOwner(rpcTurnIdentity, rpcTurnGeneration);
|
|
9620
|
+
log(`Codex RPC turn/start became ambiguous (${err?.message ?? err}); `
|
|
9621
|
+
+ `held exact owner ${rpcTurnIdentity.turnId} and blocked successors`);
|
|
9622
|
+
send({
|
|
9623
|
+
type: 'user_notify',
|
|
9624
|
+
message: 'Codex RPC 消息已发出但未取得可验证的 turn/start 响应;为避免重复执行,未自动重发,并暂时阻塞后续消息直到结构化终态、边界重启或会话重启。',
|
|
9625
|
+
turnId: rpcTurnIdentity.turnId,
|
|
9626
|
+
...(rpcTurnIdentity.dispatchAttempt !== undefined
|
|
9627
|
+
? { dispatchAttempt: rpcTurnIdentity.dispatchAttempt }
|
|
9628
|
+
: {}),
|
|
9629
|
+
});
|
|
9630
|
+
if (bridgeTurnId !== rpcTurnIdentity.turnId && !cliRestartInProgress) {
|
|
9631
|
+
void restartCliProcess('Codex RPC ambiguous delivery could not establish an attribution lease', { immediate: true, preservePending: true });
|
|
9632
|
+
}
|
|
9633
|
+
break;
|
|
9634
|
+
}
|
|
8349
9635
|
codexAppPromptReplay.cancelSubmission(codexAppTurnLiveness, codexAppReadyAuthority, codexAppLivenessHandle);
|
|
8350
9636
|
if (submissionPreparationFailed) {
|
|
8351
9637
|
if (item.turnId) {
|
|
@@ -8389,6 +9675,10 @@ async function flushPending() {
|
|
|
8389
9675
|
dispatchStillPending = codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle);
|
|
8390
9676
|
}
|
|
8391
9677
|
}
|
|
9678
|
+
if (bridgeTurnId) {
|
|
9679
|
+
codexBridgeQueue.stopRpcActive(bridgeTurnId, item.dispatchAttempt);
|
|
9680
|
+
codexBridgeQueue.finishSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
|
|
9681
|
+
}
|
|
8392
9682
|
log(`writeInput threw: ${err?.message ?? err}`);
|
|
8393
9683
|
if (blockedBeforeWrite && submissionBackend) {
|
|
8394
9684
|
// This exact item is known not to have touched the PTY. Keep its
|
|
@@ -8451,6 +9741,15 @@ async function flushPending() {
|
|
|
8451
9741
|
if (threadId)
|
|
8452
9742
|
void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
|
|
8453
9743
|
}
|
|
9744
|
+
if (result?.submitted === true && bridgeTurnId) {
|
|
9745
|
+
codexBridgeQueue.confirmPendingTurn(bridgeTurnId, undefined, item.dispatchAttempt);
|
|
9746
|
+
}
|
|
9747
|
+
else if (bridgeTurnId && !(result?.submitted === false && result.recheck && !result.failureReason)) {
|
|
9748
|
+
// Keep verification pending only while an adapter-supplied deferred
|
|
9749
|
+
// recheck can still produce authoritative submit evidence.
|
|
9750
|
+
codexBridgeQueue.finishSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
|
|
9751
|
+
}
|
|
9752
|
+
redriveRejectedStructuredReady();
|
|
8454
9753
|
// `&& backend`: if the CLI exited during this write (pane gone → onExit
|
|
8455
9754
|
// nulled backend) the user already got a "CLI exited" notice; don't also
|
|
8456
9755
|
// nag that the submit wasn't confirmed.
|
|
@@ -8525,6 +9824,8 @@ async function flushPending() {
|
|
|
8525
9824
|
// that correctly. Durable receiver attempts are the exception: they and
|
|
8526
9825
|
// adjacent IM turns wait for separate idle edges so neither can be
|
|
8527
9826
|
// HOL-dropped or steered into the other.
|
|
9827
|
+
if (rpcLifecycleFailClosedOwners.size > 0)
|
|
9828
|
+
break;
|
|
8528
9829
|
if (shouldStopPendingBatch(item, pendingMessages[0]))
|
|
8529
9830
|
break;
|
|
8530
9831
|
}
|
|
@@ -8614,7 +9915,7 @@ function sendToPty(content, turnId, opts = {}) {
|
|
|
8614
9915
|
// type-ahead write is dropped (no input box yet) — markPromptReady()'s flush
|
|
8615
9916
|
// delivers queued messages instead. See input-gate.ts; this fixes dispatch's
|
|
8616
9917
|
// brief reaching Codex before its first idle and never landing.
|
|
8617
|
-
if (!sessionRenameInFlight && commandLineWritesPending === 0 && shouldWriteNow({
|
|
9918
|
+
if (!sessionRenameInFlight() && commandLineWritesPending === 0 && shouldWriteNow({
|
|
8618
9919
|
isPromptReady,
|
|
8619
9920
|
isFlushing,
|
|
8620
9921
|
supportsTypeAhead,
|
|
@@ -8658,59 +9959,65 @@ function startScreenUpdates() {
|
|
|
8658
9959
|
screenUpdateTimer = setInterval(() => {
|
|
8659
9960
|
if (awaitingFirstPrompt)
|
|
8660
9961
|
return;
|
|
8661
|
-
let status = isPromptReady ? 'idle' : 'working';
|
|
8662
|
-
// master removed the AI ScreenAnalyzer; PR #597's Codex App no-progress
|
|
8663
|
-
// liveness projection still layers on top of the base idle/working verdict.
|
|
8664
|
-
status = codexAppLivenessStatus(status);
|
|
8665
9962
|
void (async () => {
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
|
|
8670
|
-
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
8681
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
8687
|
-
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
|
|
8694
|
-
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
9963
|
+
const { snapshot, status } = await snapshotWithLatestRuntimeStatus(async () => {
|
|
9964
|
+
let content = lastContent;
|
|
9965
|
+
let changed = false;
|
|
9966
|
+
// Capture only when the pane has emitted output since our last snapshot.
|
|
9967
|
+
// During idle (the steady state for a parked session) this skips a tmux
|
|
9968
|
+
// capture-pane + a throwaway xterm-headless instantiation every tick —
|
|
9969
|
+
// the dominant per-session background cost — while the status-transition
|
|
9970
|
+
// send below still fires off the cached content. The exception is a
|
|
9971
|
+
// self-driven screen (observe backend with a live web-attach): the
|
|
9972
|
+
// watermark can't be trusted there, so capture every tick.
|
|
9973
|
+
const ptyActivity = lastPtyActivityAtMs;
|
|
9974
|
+
if (shouldCaptureScreen({
|
|
9975
|
+
ptyActivity,
|
|
9976
|
+
lastCapturedPtyActivity: lastSnapshotPtyActivity,
|
|
9977
|
+
screenSelfDriven: isScreenSelfDriven(backend),
|
|
9978
|
+
})) {
|
|
9979
|
+
lastSnapshotPtyActivity = ptyActivity;
|
|
9980
|
+
// Preferred path: pipe-pane backends pull a fresh viewport snapshot
|
|
9981
|
+
// from tmux every tick. This eliminates the accumulated-buffer drift
|
|
9982
|
+
// that produced duplicated/staircase text in 'text' display mode.
|
|
9983
|
+
const pipeText = await snapshotToText(backend, renderCols, renderRows, { filter: true });
|
|
9984
|
+
if (pipeText) {
|
|
9985
|
+
content = pipeText.content;
|
|
9986
|
+
const hash = pipeText.ansi;
|
|
9987
|
+
changed = hash !== lastTextSnapshotHash;
|
|
9988
|
+
lastTextSnapshotHash = hash;
|
|
9989
|
+
// Refresh the unfiltered cache that ScreenAnalyzer reads from. Same
|
|
9990
|
+
// tmux call would otherwise need to fire twice per tick.
|
|
9991
|
+
if (changed) {
|
|
9992
|
+
const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
|
|
9993
|
+
if (rawSnap)
|
|
9994
|
+
lastAnalyzerSnapshot = rawSnap.content;
|
|
9995
|
+
}
|
|
8698
9996
|
}
|
|
9997
|
+
else if (renderer) {
|
|
9998
|
+
const snap = renderer.snapshot();
|
|
9999
|
+
content = snap.content;
|
|
10000
|
+
changed = snap.changed;
|
|
10001
|
+
}
|
|
10002
|
+
else {
|
|
10003
|
+
return null;
|
|
10004
|
+
}
|
|
10005
|
+
lastContent = content;
|
|
8699
10006
|
}
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
|
|
8705
|
-
|
|
8706
|
-
return;
|
|
8707
|
-
}
|
|
8708
|
-
lastContent = content;
|
|
8709
|
-
}
|
|
8710
|
-
const usageAware = classifyScreenUsageLimit(content, status);
|
|
8711
|
-
if (changed || usageAware.status !== lastSentStatus) {
|
|
10007
|
+
return { content, changed };
|
|
10008
|
+
}, projectedRuntimeScreenStatus);
|
|
10009
|
+
if (!snapshot)
|
|
10010
|
+
return;
|
|
10011
|
+
const usageAware = usageLimitTracker.classify(snapshot.content, status);
|
|
10012
|
+
if (snapshot.changed || usageAware.status !== lastSentStatus) {
|
|
8712
10013
|
lastSentStatus = usageAware.status;
|
|
8713
|
-
send({
|
|
10014
|
+
send({
|
|
10015
|
+
type: 'screen_update',
|
|
10016
|
+
content: snapshot.content,
|
|
10017
|
+
...usageAware,
|
|
10018
|
+
turnId: currentBotmuxTurnId,
|
|
10019
|
+
dispatchAttempt: currentBotmuxDispatchAttempt,
|
|
10020
|
+
});
|
|
8714
10021
|
}
|
|
8715
10022
|
})();
|
|
8716
10023
|
}, SCREEN_UPDATE_INTERVAL_MS);
|
|
@@ -9082,7 +10389,8 @@ function scheduleBusyPatternIdleProbe(source) {
|
|
|
9082
10389
|
}
|
|
9083
10390
|
async function spawnCli(cfg, opts = {}) {
|
|
9084
10391
|
const spawnGeneration = ++cliSpawnGeneration;
|
|
9085
|
-
|
|
10392
|
+
// Prefer force-clear so a half-finished rename cannot block the new generation.
|
|
10393
|
+
forceClearSessionRenameInFlight();
|
|
9086
10394
|
currentCliCredentialIsolated = false;
|
|
9087
10395
|
// Enrollment writes the fixed marker before any device credential appears.
|
|
9088
10396
|
// From that instant onward every NEW local CLI must carry a credential
|
|
@@ -11420,7 +12728,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11420
12728
|
if (effectiveCliSessionId) {
|
|
11421
12729
|
const rolloutPath = findCodexRolloutBySessionId(effectiveCliSessionId);
|
|
11422
12730
|
if (rolloutPath) {
|
|
11423
|
-
codexBridgeAttach(rolloutPath, 'baseline-existing');
|
|
12731
|
+
codexBridgeAttach(rolloutPath, effectiveResume ? 'baseline-existing' : 'fresh-empty');
|
|
11424
12732
|
}
|
|
11425
12733
|
else {
|
|
11426
12734
|
codexBridgePendingSessionId = effectiveCliSessionId;
|
|
@@ -11439,7 +12747,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11439
12747
|
if (effectiveCliSessionId) {
|
|
11440
12748
|
const rolloutPath = findTraexRolloutBySessionId(effectiveCliSessionId);
|
|
11441
12749
|
if (rolloutPath) {
|
|
11442
|
-
codexBridgeAttach(rolloutPath, 'baseline-existing');
|
|
12750
|
+
codexBridgeAttach(rolloutPath, effectiveResume ? 'baseline-existing' : 'fresh-empty');
|
|
11443
12751
|
}
|
|
11444
12752
|
else {
|
|
11445
12753
|
codexBridgePendingSessionId = effectiveCliSessionId;
|
|
@@ -11542,6 +12850,7 @@ async function spawnCli(cfg, opts = {}) {
|
|
|
11542
12850
|
];
|
|
11543
12851
|
log(`Ready gate skipped — preflight failed: ${reasons.join('; ')}`);
|
|
11544
12852
|
}
|
|
12853
|
+
ptyOutputGeneration.reset();
|
|
11545
12854
|
if (shouldArmReadyGate({
|
|
11546
12855
|
injectsReadyHook: cliAdapter.injectsReadyHook === true,
|
|
11547
12856
|
readySignalAvailable,
|
|
@@ -11938,6 +13247,9 @@ function killCli(opts = {}) {
|
|
|
11938
13247
|
idleDetector = null;
|
|
11939
13248
|
stopReattachIdleProbe();
|
|
11940
13249
|
stopBusyPatternIdleProbe();
|
|
13250
|
+
stopStructuredStartGraceRecheck();
|
|
13251
|
+
structuredRejectedReadyEvidenceGeneration = undefined;
|
|
13252
|
+
ptyOutputGeneration.reset();
|
|
11941
13253
|
// Cancel any pending ready-gate fallback / settle timers; spawnCli re-arms on respawn.
|
|
11942
13254
|
if (readySignalTimer) {
|
|
11943
13255
|
clearTimeout(readySignalTimer);
|
|
@@ -12007,9 +13319,11 @@ function killCli(opts = {}) {
|
|
|
12007
13319
|
sandboxCleanup = null;
|
|
12008
13320
|
}
|
|
12009
13321
|
isPromptReady = false;
|
|
12010
|
-
|
|
12011
|
-
if (!opts.preservePending)
|
|
13322
|
+
forceClearSessionRenameInFlight();
|
|
13323
|
+
if (!opts.preservePending) {
|
|
12012
13324
|
pendingMessages.length = 0;
|
|
13325
|
+
pendingAdoptMessages.length = 0;
|
|
13326
|
+
}
|
|
12013
13327
|
// pendingRawInputs contains only commands that were accepted but never typed
|
|
12014
13328
|
// because /rename owned the TUI or an owned CLI restart was already fenced.
|
|
12015
13329
|
// Preserve them across restart; unlike an in-flight raw command, replaying
|
|
@@ -12034,6 +13348,10 @@ async function restartCliProcess(reason, opts = {}) {
|
|
|
12034
13348
|
log(`Restart ignored in adopt mode (${reason})`);
|
|
12035
13349
|
return;
|
|
12036
13350
|
}
|
|
13351
|
+
// Invalidate queued/deferred callbacks before async teardown begins. Waiting
|
|
13352
|
+
// until killCli/spawnCli would leave a window where an old timer can mutate
|
|
13353
|
+
// the next durable attempt while destroySession is still awaiting.
|
|
13354
|
+
cliSpawnGeneration += 1;
|
|
12037
13355
|
// Set before touching destroySession(): remote teardown can await for many
|
|
12038
13356
|
// seconds while the old backend object is still non-null and still capable
|
|
12039
13357
|
// of firing idle/task-done callbacks. Inputs accepted in that interval must
|
|
@@ -13397,7 +14715,7 @@ function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt) {
|
|
|
13397
14715
|
if (!emittedTurnTerminals.claim(sessionId, turnId, dispatchAttempt))
|
|
13398
14716
|
return;
|
|
13399
14717
|
if (status !== 'completed') {
|
|
13400
|
-
const dropped = codexBridgeQueue.dropPendingTurn(turnId, dispatchAttempt);
|
|
14718
|
+
const dropped = codexBridgeQueue.dropPendingTurn(turnId, dispatchAttempt, true);
|
|
13401
14719
|
if (dropped) {
|
|
13402
14720
|
log(`Structured bridge retired terminal attempt turn=${turnId.slice(0, 12)} attempt=${dispatchAttempt ?? '-'} status=${status}`);
|
|
13403
14721
|
}
|
|
@@ -13758,6 +15076,11 @@ process.on('message', async (raw) => {
|
|
|
13758
15076
|
await prepareCodexNativeTitleGeneration(msg, codexRpcEngine);
|
|
13759
15077
|
if (codexRpcEngine)
|
|
13760
15078
|
armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog
|
|
15079
|
+
if (deferredFreshRpcTurn) {
|
|
15080
|
+
const deferred = deferredFreshRpcTurn;
|
|
15081
|
+
deferredFreshRpcTurn = undefined;
|
|
15082
|
+
releaseRpcTurnTerminalDeferral(deferred.identity, deferred.generation);
|
|
15083
|
+
}
|
|
13761
15084
|
// Queue the initial prompt — flushed when CLI shows idle.
|
|
13762
15085
|
// Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
|
|
13763
15086
|
// prompt into CLI args, so we normally skip queuing to avoid double-send.
|
|
@@ -13778,7 +15101,9 @@ process.on('message', async (raw) => {
|
|
|
13778
15101
|
// Mark it here before the CLI starts processing; late-attach is fine
|
|
13779
15102
|
// because CodexBridgeQueue is path-agnostic until ingest discovers the
|
|
13780
15103
|
// transcript file.
|
|
13781
|
-
codexBridgeMarkPendingTurn(msg.prompt, msg.turnId, msg.dispatchAttempt);
|
|
15104
|
+
const bridgeTurnId = codexBridgeMarkPendingTurn(msg.prompt, msg.turnId, msg.dispatchAttempt);
|
|
15105
|
+
if (bridgeTurnId)
|
|
15106
|
+
codexBridgeQueue.confirmPendingTurn(bridgeTurnId, undefined, msg.dispatchAttempt);
|
|
13782
15107
|
}
|
|
13783
15108
|
// Queue the initial prompt for flushPending (pure decision, unit-tested):
|
|
13784
15109
|
// - paste (no engine): normal queue.
|
|
@@ -13930,6 +15255,13 @@ process.on('message', async (raw) => {
|
|
|
13930
15255
|
// submission mutex is held so a queued message cannot steal attribution
|
|
13931
15256
|
// from the write/verification already in flight.
|
|
13932
15257
|
let turnSeq = usageLimitTracker.currentTurn();
|
|
15258
|
+
// Adopt handlers are async and serialized below, so their renderer/usage
|
|
15259
|
+
// turn begins inside writeAdoptMessage. Non-adopt keeps the immediate
|
|
15260
|
+
// baseline while the message waits for normal flush scheduling.
|
|
15261
|
+
if (!lastInitConfig?.adoptMode) {
|
|
15262
|
+
renderer?.markNewTurn();
|
|
15263
|
+
usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
|
|
15264
|
+
}
|
|
13933
15265
|
if (msg.nativeSessionTitlePrompt
|
|
13934
15266
|
&& msg.nativeSessionTitle
|
|
13935
15267
|
&& lastInitConfig?.cliId === 'codex'
|
|
@@ -13985,173 +15317,28 @@ process.on('message', async (raw) => {
|
|
|
13985
15317
|
}
|
|
13986
15318
|
}
|
|
13987
15319
|
if (lastInitConfig?.adoptMode) {
|
|
13988
|
-
|
|
13989
|
-
|
|
13990
|
-
|
|
13991
|
-
|
|
13992
|
-
|
|
13993
|
-
|
|
13994
|
-
|
|
13995
|
-
|
|
13996
|
-
|
|
13997
|
-
currentBotmuxTurnId = msg.turnId;
|
|
13998
|
-
currentBotmuxDispatchAttempt = msg.dispatchAttempt;
|
|
13999
|
-
currentVcMeetingImTurnOrigin = msg.vcMeetingImTurnOrigin;
|
|
14000
|
-
writeCliPidMarker();
|
|
14001
|
-
publishSandboxRelayCapability();
|
|
14002
|
-
// Capture the transcript baseline only after this turn owns the
|
|
14003
|
-
// submission lock and immediately before its literal write.
|
|
14004
|
-
if (bridgeJsonlPath) {
|
|
14005
|
-
try {
|
|
14006
|
-
bridgeIngest();
|
|
14007
|
-
}
|
|
14008
|
-
catch { /* best effort */ }
|
|
14009
|
-
bridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
|
|
14010
|
-
}
|
|
14011
|
-
else if (codexBridgeFallbackActive()) {
|
|
14012
|
-
if (codexBridgeIsCursor()) {
|
|
14013
|
-
codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
|
|
14014
|
-
try {
|
|
14015
|
-
codexBridgeIngest();
|
|
14016
|
-
}
|
|
14017
|
-
catch { /* best effort */ }
|
|
14018
|
-
}
|
|
14019
|
-
else {
|
|
14020
|
-
try {
|
|
14021
|
-
codexBridgeIngest();
|
|
14022
|
-
}
|
|
14023
|
-
catch { /* best effort */ }
|
|
14024
|
-
codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
|
|
14025
|
-
}
|
|
14026
|
-
}
|
|
15320
|
+
const item = {
|
|
15321
|
+
content,
|
|
15322
|
+
turnId: msg.turnId,
|
|
15323
|
+
replyTurnId: msg.replyTurnId,
|
|
15324
|
+
dispatchAttempt: msg.dispatchAttempt,
|
|
15325
|
+
codexAppDispatchId: msg.codexAppDispatchId,
|
|
15326
|
+
queuedActivationToken: msg.queuedActivationToken,
|
|
15327
|
+
vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
|
|
15328
|
+
codexAppInput,
|
|
14027
15329
|
};
|
|
14028
|
-
//
|
|
14029
|
-
//
|
|
14030
|
-
//
|
|
14031
|
-
|
|
14032
|
-
|
|
14033
|
-
|
|
14034
|
-
|
|
14035
|
-
|
|
14036
|
-
|
|
14037
|
-
|
|
14038
|
-
|
|
14039
|
-
|
|
14040
|
-
if (isStructuredBridgeAdoptInputCli(lastInitConfig?.cliId) && cliAdapter) {
|
|
14041
|
-
// writeInput is async but we're already inside an async
|
|
14042
|
-
// message handler. Errors are best-effort logged; attribution and
|
|
14043
|
-
// bridge baselines are prepared only after the submission lock is
|
|
14044
|
-
// held, immediately before the adapter write.
|
|
14045
|
-
const submissionBackend = backend;
|
|
14046
|
-
const submissionAdapter = cliAdapter;
|
|
14047
|
-
let recoveryFailureReason;
|
|
14048
|
-
const composerConflict = codexAdoptComposerConflict(submissionBackend);
|
|
14049
|
-
if (composerConflict) {
|
|
14050
|
-
log('Refused Codex adopt input because the local composer contains an unsubmitted draft');
|
|
14051
|
-
scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, composerConflict, turnSeq, msg, 'failed');
|
|
14052
|
-
return;
|
|
14053
|
-
}
|
|
14054
|
-
try {
|
|
14055
|
-
const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, () => submissionAdapter.writeInput(adapterInputHandle(submissionBackend), content), settleVerifiableSubmissionForJournal, prepareAdoptWrite);
|
|
14056
|
-
const result = transaction.result;
|
|
14057
|
-
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
14058
|
-
if (result?.cliSessionId) {
|
|
14059
|
-
persistCliSessionId(result.cliSessionId);
|
|
14060
|
-
codexBridgeNotifyCliSessionId(result.cliSessionId);
|
|
14061
|
-
}
|
|
14062
|
-
if (result?.submitted === false || recoveryFailureReason) {
|
|
14063
|
-
if (recoveryFailureReason) {
|
|
14064
|
-
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
|
|
14065
|
-
}
|
|
14066
|
-
else {
|
|
14067
|
-
scheduleSubmitFailureNotify(content, result?.recheck, 'submit history', undefined, result?.failureReason, turnSeq, msg, 'failed');
|
|
14068
|
-
}
|
|
14069
|
-
}
|
|
14070
|
-
else {
|
|
14071
|
-
acknowledgeTurnInputCommitted(msg.turnId);
|
|
14072
|
-
}
|
|
14073
|
-
}
|
|
14074
|
-
catch (err) {
|
|
14075
|
-
recoveryFailureReason = err instanceof SubmissionWriteError
|
|
14076
|
-
? err.recoveryFailureReason
|
|
14077
|
-
: recoveryFailureReason;
|
|
14078
|
-
const blockedBeforeWrite = err instanceof SubmissionWriteError
|
|
14079
|
-
&& !!err.recoveryFailureReason
|
|
14080
|
-
&& !err.submissionStarted;
|
|
14081
|
-
log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
|
|
14082
|
-
if (msg.turnId
|
|
14083
|
-
&& msg.dispatchAttempt !== undefined
|
|
14084
|
-
&& blockedBeforeWrite) {
|
|
14085
|
-
emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
|
|
14086
|
-
}
|
|
14087
|
-
else if (msg.turnId
|
|
14088
|
-
&& msg.dispatchAttempt !== undefined
|
|
14089
|
-
&& !recoveryFailureReason) {
|
|
14090
|
-
emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
|
|
14091
|
-
}
|
|
14092
|
-
if (backend) {
|
|
14093
|
-
if (recoveryFailureReason) {
|
|
14094
|
-
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
|
|
14095
|
-
}
|
|
14096
|
-
else {
|
|
14097
|
-
scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
|
|
14098
|
-
}
|
|
14099
|
-
}
|
|
14100
|
-
}
|
|
14101
|
-
}
|
|
14102
|
-
else if ('sendText' in backend && 'sendSpecialKeys' in backend) {
|
|
14103
|
-
const submissionBackend = backend;
|
|
14104
|
-
let recoveryFailureReason;
|
|
14105
|
-
try {
|
|
14106
|
-
const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, async () => {
|
|
14107
|
-
const input = adapterInputHandle(submissionBackend);
|
|
14108
|
-
input.sendText(content);
|
|
14109
|
-
// Beat between text and Enter so the adopted CLI's input
|
|
14110
|
-
// layer registers the typed chars before submit.
|
|
14111
|
-
await new Promise(r => setTimeout(r, 200));
|
|
14112
|
-
input.sendSpecialKeys('Enter');
|
|
14113
|
-
}, undefined, prepareAdoptWrite);
|
|
14114
|
-
recoveryFailureReason = transaction.recoveryFailureReason;
|
|
14115
|
-
if (recoveryFailureReason) {
|
|
14116
|
-
throw new SubmissionWriteError(`backend could not commit the adopt submission journal; ${recoveryFailureReason}`, recoveryFailureReason);
|
|
14117
|
-
}
|
|
14118
|
-
acknowledgeTurnInputCommitted(msg.turnId);
|
|
14119
|
-
}
|
|
14120
|
-
catch (err) {
|
|
14121
|
-
recoveryFailureReason = err instanceof SubmissionWriteError
|
|
14122
|
-
? err.recoveryFailureReason
|
|
14123
|
-
: recoveryFailureReason;
|
|
14124
|
-
const blockedBeforeWrite = err instanceof SubmissionWriteError
|
|
14125
|
-
&& !!err.recoveryFailureReason
|
|
14126
|
-
&& !err.submissionStarted;
|
|
14127
|
-
log(`Adopt raw input error (${lastInitConfig?.cliId}): ${err.message}`);
|
|
14128
|
-
if (msg.turnId
|
|
14129
|
-
&& msg.dispatchAttempt !== undefined
|
|
14130
|
-
&& blockedBeforeWrite) {
|
|
14131
|
-
emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
|
|
14132
|
-
}
|
|
14133
|
-
else if (msg.turnId
|
|
14134
|
-
&& msg.dispatchAttempt !== undefined
|
|
14135
|
-
&& !recoveryFailureReason) {
|
|
14136
|
-
emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
|
|
14137
|
-
}
|
|
14138
|
-
if (backend) {
|
|
14139
|
-
if (recoveryFailureReason) {
|
|
14140
|
-
notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
|
|
14141
|
-
}
|
|
14142
|
-
else {
|
|
14143
|
-
scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
|
|
14144
|
-
}
|
|
14145
|
-
}
|
|
14146
|
-
}
|
|
14147
|
-
}
|
|
14148
|
-
else {
|
|
14149
|
-
prepareAdoptWrite();
|
|
14150
|
-
backend.write(content + '\r');
|
|
14151
|
-
acknowledgeTurnInputCommitted(msg.turnId);
|
|
14152
|
-
}
|
|
14153
|
-
isPromptReady = false;
|
|
14154
|
-
idleDetector?.reset();
|
|
15330
|
+
// process.on('message') does not serialize async listeners. Hold the
|
|
15331
|
+
// per-worker queue across transcript mark + complete adapter write so
|
|
15332
|
+
// two CoCo/Codex paste→wait→Enter/history cycles cannot overlap.
|
|
15333
|
+
if (cliRestartInProgress || rawInputRestartGate || !backend || sessionRenameInFlight()) {
|
|
15334
|
+
pendingAdoptMessages.push(item);
|
|
15335
|
+
log(`Deferred adopt message until the CLI generation/input gate settles (${content.length} chars)`);
|
|
15336
|
+
}
|
|
15337
|
+
else {
|
|
15338
|
+
await runAdoptMessageForCapturedGeneration(item, () => {
|
|
15339
|
+
pendingAdoptMessages.push(item);
|
|
15340
|
+
log(`Re-queued stale adopt message for the replacement CLI generation (${content.length} chars)`);
|
|
15341
|
+
});
|
|
14155
15342
|
}
|
|
14156
15343
|
}
|
|
14157
15344
|
else {
|
|
@@ -14193,7 +15380,7 @@ process.on('message', async (raw) => {
|
|
|
14193
15380
|
// shell。bareShellCheckInProgress 覆盖“检查进行中”、bareShellLaunchBlocked
|
|
14194
15381
|
// 覆盖“仍停在裸 shell 的安全 hold”两种状态,一并入队;若该进程随后出现
|
|
14195
15382
|
// 真实 PTY prompt 且 leaf 已变为非 shell,markPromptReady 会恢复排空。
|
|
14196
|
-
if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight
|
|
15383
|
+
if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight()
|
|
14197
15384
|
|| shouldHoldCodexRunnerInput(codexRunnerFreshness)
|
|
14198
15385
|
|| injectionFlushing || shouldDeferUserFlush(pendingInjections)
|
|
14199
15386
|
|| bareShellCheckInProgress || bareShellLaunchBlocked) {
|
|
@@ -14737,7 +15924,10 @@ process.on('message', async (raw) => {
|
|
|
14737
15924
|
pendingMessages: pendingMessages.length,
|
|
14738
15925
|
pendingRawInputs: pendingRawInputs.length,
|
|
14739
15926
|
pendingSessionRename: pendingSessionRename !== null,
|
|
14740
|
-
sessionRenameInFlight
|
|
15927
|
+
// sessionRenameInFlight became a function in this branch's rename
|
|
15928
|
+
// lifecycle rework; riffWorkerShutdownInputBlocker (added on master by
|
|
15929
|
+
// the RIFF two-phase shutdown) still consumes it as a boolean field.
|
|
15930
|
+
sessionRenameInFlight: sessionRenameInFlight(),
|
|
14741
15931
|
commandLineWritesPending,
|
|
14742
15932
|
});
|
|
14743
15933
|
if (preparedCloseRequestId || closeRequestInFlightId) {
|