botmux 3.12.1 → 3.13.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/bot-registry.d.ts +7 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +46 -13
- package/dist/bot-registry.js.map +1 -1
- package/dist/core/config-dir.d.ts +167 -0
- package/dist/core/config-dir.d.ts.map +1 -0
- package/dist/core/config-dir.js +152 -0
- package/dist/core/config-dir.js.map +1 -0
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +48 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/per-bot-env.d.ts.map +1 -1
- package/dist/core/per-bot-env.js +9 -0
- package/dist/core/per-bot-env.js.map +1 -1
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +19 -2
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +44 -3
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/trigger-session.d.ts.map +1 -1
- package/dist/core/trigger-session.js +499 -63
- package/dist/core/trigger-session.js.map +1 -1
- package/dist/core/types.d.ts +26 -4
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/core/worker-pool.d.ts +64 -0
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +301 -10
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +13 -6
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/auth.d.ts +8 -0
- package/dist/dashboard/auth.d.ts.map +1 -1
- package/dist/dashboard/auth.js +16 -11
- package/dist/dashboard/auth.js.map +1 -1
- package/dist/dashboard/web/bot-multi-select.d.ts +33 -0
- package/dist/dashboard/web/bot-multi-select.d.ts.map +1 -0
- package/dist/dashboard/web/bot-multi-select.js +34 -0
- package/dist/dashboard/web/bot-multi-select.js.map +1 -0
- package/dist/dashboard/web/groups-page.d.ts +13 -0
- package/dist/dashboard/web/groups-page.d.ts.map +1 -1
- package/dist/dashboard/web/groups-page.js +25 -7
- 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 -4
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
- package/dist/dashboard/web/sessions-page.js +15 -23
- package/dist/dashboard/web/sessions-page.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts +13 -0
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +25 -0
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/{bot-defaults-page-HL655CNR.js → bot-defaults-page-NQJFJ45Z.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-BFUTTYVK.js → chunk-5QJB24I2.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-ELL53D62.js → chunk-6JTZJRQJ.js} +2 -2
- package/dist/dashboard-web/chunks/{chunk-5ZL7NLR5.js → chunk-CU4SUKK3.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-5PAYQ3KT.js → chunk-KEEYQ3WF.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-WLIVBPJC.js +1 -0
- package/dist/dashboard-web/chunks/{connectors-page-3QQACF23.js → connectors-page-HADHZXXZ.js} +1 -1
- package/dist/dashboard-web/chunks/groups-page-5TYF46RS.js +8 -0
- package/dist/dashboard-web/chunks/{insights-page-ZZ3JEJ6L.js → insights-page-EVGGJI5L.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-ROJ5GZ4N.js → monitor-room-HRGR2ENP.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-IATV7KJF.js → monitoring-page-LBU7TTTW.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-TYO5OIIT.js → office-page-DF6RRBMN.js} +1 -1
- package/dist/dashboard-web/chunks/{overview-page-VJUULZF2.js → overview-page-ZB6QH2EX.js} +1 -1
- package/dist/dashboard-web/chunks/{roles-page-7RJJKZGK.js → roles-page-ZBZGHIIF.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-JTEJ5IXW.js → schedules-page-NQVQC3QY.js} +1 -1
- package/dist/dashboard-web/chunks/sessions-page-DXBCDWCS.js +8 -0
- package/dist/dashboard-web/chunks/{settings-page-7CTTFBRS.js → settings-page-E7NN4NVQ.js} +1 -1
- package/dist/dashboard-web/chunks/{skills-page-57NZECJ3.js → skills-page-2QAVB2SA.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-6YYIMJG3.js → team-federation-page-VGWL53AE.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-ZGKBSBK3.js → v3-page-AY35LKCX.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-AACJF6DB.js → whiteboards-page-BNWUU4KG.js} +1 -1
- package/dist/dashboard-web/style.css +61 -53
- package/dist/features/codex-notifier/internal-turn.d.ts +1 -2
- package/dist/features/codex-notifier/internal-turn.d.ts.map +1 -1
- package/dist/features/codex-notifier/internal-turn.js +1 -13
- package/dist/features/codex-notifier/internal-turn.js.map +1 -1
- package/dist/i18n/en.js +2 -2
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.js +2 -2
- package/dist/i18n/zh.js.map +1 -1
- package/dist/platform/secure-host-file.d.ts +63 -0
- package/dist/platform/secure-host-file.d.ts.map +1 -1
- package/dist/platform/secure-host-file.js +90 -25
- package/dist/platform/secure-host-file.js.map +1 -1
- package/dist/services/async-trigger-store.d.ts +24 -4
- package/dist/services/async-trigger-store.d.ts.map +1 -1
- package/dist/services/async-trigger-store.js +81 -19
- package/dist/services/async-trigger-store.js.map +1 -1
- package/dist/services/codex-session-meta.d.ts +4 -0
- package/dist/services/codex-session-meta.d.ts.map +1 -0
- package/dist/services/codex-session-meta.js +15 -0
- package/dist/services/codex-session-meta.js.map +1 -0
- package/dist/services/idempotency-store.d.ts +18 -3
- package/dist/services/idempotency-store.d.ts.map +1 -1
- package/dist/services/idempotency-store.js +30 -12
- package/dist/services/idempotency-store.js.map +1 -1
- package/dist/services/traex-transcript.d.ts +7 -5
- package/dist/services/traex-transcript.d.ts.map +1 -1
- package/dist/services/traex-transcript.js +139 -33
- package/dist/services/traex-transcript.js.map +1 -1
- package/dist/services/trigger-types.d.ts +14 -1
- package/dist/services/trigger-types.d.ts.map +1 -1
- package/dist/services/trigger-types.js +35 -0
- package/dist/services/trigger-types.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/child-env.d.ts +1 -1
- package/dist/utils/child-env.d.ts.map +1 -1
- package/dist/utils/child-env.js +18 -0
- package/dist/utils/child-env.js.map +1 -1
- package/dist/worker.js +35 -2
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/dist/dashboard-web/chunks/chunk-6ALR252E.js +0 -1
- package/dist/dashboard-web/chunks/groups-page-SDZMJ7UV.js +0 -8
- package/dist/dashboard-web/chunks/sessions-page-KD7HDT3U.js +0 -8
package/dist/core/worker-pool.js
CHANGED
|
@@ -46,7 +46,7 @@ import { sandboxEnabled } from '../adapters/backend/sandbox.js';
|
|
|
46
46
|
import { isStrongManagedHerdrAgentName, managedHerdrAgentName, } from '../adapters/backend/session-backend-selector.js';
|
|
47
47
|
import { isRiffBackendSession, isSuspendableBackendType, getSessionPersistentBackendType, persistentBackendTargetForSession, persistentSessionName, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, resolvePairedSpawnBackendType, resolvePersistentBackendTarget } from './persistent-backend.js';
|
|
48
48
|
import { withBotTurnMutation } from './bot-turn-mutation-gate.js';
|
|
49
|
-
import { getBot, getAllBots, resolveBrandLabel, getLoadedConfigPath, resolveUsageDisplay } from '../bot-registry.js';
|
|
49
|
+
import { getBot, getAllBots, resolveBrandLabel, getLoadedConfigPath, getLoadedConfigProvenance, resolveUsageDisplay } from '../bot-registry.js';
|
|
50
50
|
import { RestartCoordinator } from './restart-coordinator.js';
|
|
51
51
|
import { runtimeBuildIdentity } from '../utils/runtime-build-id.js';
|
|
52
52
|
import { scrubWorkflowWorkerEnv } from '../utils/child-env.js';
|
|
@@ -999,6 +999,108 @@ export function recallFrozenCards(ds) {
|
|
|
999
999
|
}
|
|
1000
1000
|
logger.info(`[${tag(ds)}] Recalled ${targets.length} previous streaming card(s)`);
|
|
1001
1001
|
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Post the current turn's starting card as soon as the daemon accepts the
|
|
1004
|
+
* inbound message. Terminal redraw is deliberately not part of this trigger:
|
|
1005
|
+
* some CLIs consume a turn without emitting another screen_update, which used
|
|
1006
|
+
* to leave streamCardPending stuck and suppress cards for every later turn.
|
|
1007
|
+
*
|
|
1008
|
+
* Only one POST may be in flight per session. If another turn arrives during
|
|
1009
|
+
* the request, the generation check preserves that newer pending state and
|
|
1010
|
+
* immediately follows with its card after the first POST settles.
|
|
1011
|
+
*/
|
|
1012
|
+
export async function postTurnStartingCard(ds, sessionReply, turnId) {
|
|
1013
|
+
if (!ds.streamCardPending || ds.streamCardPendingTurnId !== turnId)
|
|
1014
|
+
return false;
|
|
1015
|
+
if (riffRetirementAdmissionPhase(ds))
|
|
1016
|
+
return false;
|
|
1017
|
+
if (ds.streamCardId === CARD_POSTING_SENTINEL)
|
|
1018
|
+
return false;
|
|
1019
|
+
if (ds.session.vcMeetingReceiver || streamingCardDisabled(ds, turnId))
|
|
1020
|
+
return false;
|
|
1021
|
+
if (!workerHasInitialized(ds))
|
|
1022
|
+
return false;
|
|
1023
|
+
if (!larkTransportEnabled({ chatId: ds.chatId, apiOnly: getBot(ds.larkAppId).config.apiOnly }))
|
|
1024
|
+
return false;
|
|
1025
|
+
const generation = ds.streamCardTurnGeneration ?? 0;
|
|
1026
|
+
const sessionAtPost = ds.session;
|
|
1027
|
+
const larkAppIdAtPost = ds.larkAppId;
|
|
1028
|
+
const anchorAtPost = sessionAnchorId(ds);
|
|
1029
|
+
const registryKeyAtPost = sessionKey(anchorAtPost, larkAppIdAtPost);
|
|
1030
|
+
const botCfg = getBot(ds.larkAppId).config;
|
|
1031
|
+
const effectiveCliId = sessionCliId(ds, botCfg);
|
|
1032
|
+
const previousCardId = ds.streamCardId;
|
|
1033
|
+
const previousNonce = ds.streamCardNonce;
|
|
1034
|
+
// A newer turn may have arrived while the previous turn's POST was still in
|
|
1035
|
+
// flight, so beginNewTurn could not park that sentinel. Park the now-live
|
|
1036
|
+
// predecessor here before replacing its identity.
|
|
1037
|
+
parkStreamCard(ds);
|
|
1038
|
+
const nonce = randomBytes(4).toString('hex');
|
|
1039
|
+
const status = ds.usageLimit ? 'limited' : 'starting';
|
|
1040
|
+
const cardJson = buildStreamingCard(ds.session.sessionId, sessionAnchorId(ds), readableTerminalUrlFor(ds), ds.currentTurnTitle || ds.session.title || sessionCliDisplayName(ds, botCfg), '', status, effectiveCliId, ds.displayMode ?? 'hidden', nonce, undefined, !!ds.adoptedFrom, false, localeForBot(ds.larkAppId), ds.usageLimit, writableTerminalLinkFor(ds), isLocalCliOpenReady(ds, { cliId: effectiveCliId }), getDaemonStreamingCardUsageSnapshot(ds, effectiveCliId), sessionRuntimeDisplayName(ds, botCfg), codexServiceTierBadge(effectiveCliId, ds.codexServiceTier));
|
|
1041
|
+
ds.streamCardNonce = nonce;
|
|
1042
|
+
ds.streamCardId = CARD_POSTING_SENTINEL;
|
|
1043
|
+
const ownsPostIdentity = () => ds.session === sessionAtPost
|
|
1044
|
+
&& ds.session.status === 'active'
|
|
1045
|
+
&& ds.larkAppId === larkAppIdAtPost
|
|
1046
|
+
&& sessionAnchorId(ds) === anchorAtPost
|
|
1047
|
+
&& !isSessionTransferring(ds)
|
|
1048
|
+
&& ds.streamCardId === CARD_POSTING_SENTINEL
|
|
1049
|
+
&& ds.streamCardNonce === nonce
|
|
1050
|
+
&& (!activeSessionsRegistry || activeSessionsRegistry.get(registryKeyAtPost) === ds);
|
|
1051
|
+
const stillOwnsPost = () => ownsPostIdentity() && riffRetirementAdmissionPhase(ds) === null;
|
|
1052
|
+
const restorePrePostIdentityForRetirement = () => {
|
|
1053
|
+
if (riffRetirementAdmissionPhase(ds) === null || !ownsPostIdentity())
|
|
1054
|
+
return false;
|
|
1055
|
+
ds.streamCardId = previousCardId;
|
|
1056
|
+
ds.streamCardNonce = previousNonce;
|
|
1057
|
+
persistStreamCardState(ds);
|
|
1058
|
+
return true;
|
|
1059
|
+
};
|
|
1060
|
+
try {
|
|
1061
|
+
const messageId = await sessionReply(anchorAtPost, cardJson, 'interactive', larkAppIdAtPost, turnId);
|
|
1062
|
+
if (!stillOwnsPost()) {
|
|
1063
|
+
void deleteMessage(larkAppIdAtPost, messageId).catch(() => { });
|
|
1064
|
+
restorePrePostIdentityForRetirement();
|
|
1065
|
+
logger.info(`[${tag(ds)}] Discarded stale starting card for turn ${turnId.substring(0, 12)}`);
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1068
|
+
ds.streamCardId = messageId;
|
|
1069
|
+
ds.parkedStreamCardNonce = undefined;
|
|
1070
|
+
const superseded = (ds.streamCardTurnGeneration ?? 0) !== generation;
|
|
1071
|
+
if (!superseded) {
|
|
1072
|
+
ds.streamCardPending = false;
|
|
1073
|
+
ds.streamCardPendingTurnId = undefined;
|
|
1074
|
+
}
|
|
1075
|
+
persistStreamCardState(ds);
|
|
1076
|
+
recallFrozenCards(ds);
|
|
1077
|
+
flushPendingLocalCliOpenReadinessPatch(ds);
|
|
1078
|
+
flushPendingRiffUrlPatch(ds);
|
|
1079
|
+
flushPendingActiveRuntimePatch(ds);
|
|
1080
|
+
flushPendingCodexServiceTierPatch(ds);
|
|
1081
|
+
syncUsageRefreshTimer(ds);
|
|
1082
|
+
logger.info(`[${tag(ds)}] Posted starting card for turn ${turnId.substring(0, 12)}`);
|
|
1083
|
+
if (superseded && ds.streamCardPendingTurnId) {
|
|
1084
|
+
void postTurnStartingCard(ds, sessionReply, ds.streamCardPendingTurnId);
|
|
1085
|
+
}
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
if (!stillOwnsPost()) {
|
|
1090
|
+
restorePrePostIdentityForRetirement();
|
|
1091
|
+
logger.info(`[${tag(ds)}] Ignored stale starting-card failure for turn ${turnId.substring(0, 12)}`);
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
1094
|
+
ds.streamCardId = previousCardId;
|
|
1095
|
+
ds.streamCardNonce = previousNonce;
|
|
1096
|
+
persistStreamCardState(ds);
|
|
1097
|
+
logger.warn(`[${tag(ds)}] Failed to post starting card for turn ${turnId.substring(0, 12)}: ${err}`);
|
|
1098
|
+
if ((ds.streamCardTurnGeneration ?? 0) !== generation && ds.streamCardPendingTurnId) {
|
|
1099
|
+
void postTurnStartingCard(ds, sessionReply, ds.streamCardPendingTurnId);
|
|
1100
|
+
}
|
|
1101
|
+
return false;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1002
1104
|
/**
|
|
1003
1105
|
* Force-post a fresh streaming card for `ds`, bypassing the per-bot
|
|
1004
1106
|
* `disableStreamingCard` opt-out. Backs the `/card` command: a user can
|
|
@@ -4171,6 +4273,137 @@ function rollbackAcceptedCodexAppDispatch(ds, dispatchId, turnId, dispatchAttemp
|
|
|
4171
4273
|
ds.session.codexAppDispatchLedger = priorLedger;
|
|
4172
4274
|
}
|
|
4173
4275
|
}
|
|
4276
|
+
/**
|
|
4277
|
+
* Recovery-seam at-most-once fence for a keyed follow-up turn (turnIdempotencyKey,
|
|
4278
|
+
* PR #818). The turn-level idempotency lease terminalizes an interrupted keyed
|
|
4279
|
+
* turn to a durable `failed(dispatch_unknown)` (worker-exit convergence / boot
|
|
4280
|
+
* reconcile / same-key retry) and — unlike a fresh async-virtual session — LEAVES
|
|
4281
|
+
* the shared session open and un-quarantined (P1-3). So a later refork of that
|
|
4282
|
+
* session must NOT resurrect the interrupted turn: `noReplay` only lives on the
|
|
4283
|
+
* transient input queues (pendingMessages / inflight), NOT on the durable Codex
|
|
4284
|
+
* App dispatch ledger. Without this fence, the recovery path
|
|
4285
|
+
* (codexAppRecoveredDispatches → worker init → recoveredAcceptedInputs, keyed on
|
|
4286
|
+
* `state==='accepted'` alone) would re-issue `turn/start` for a turn the caller
|
|
4287
|
+
* was already told is `failed` at-most-once — the ledger is the third replay
|
|
4288
|
+
* channel the lease's noReplay does not reach (codex #818 recovery-seam finding).
|
|
4289
|
+
*
|
|
4290
|
+
* Fence: for each `accepted` (NEVER `prepared`) ledger entry whose OWNER-MATCHED
|
|
4291
|
+
* async terminal is already `failed(dispatch_unknown)`, durably retire the entry
|
|
4292
|
+
* via cancelCodexAppDispatch. The retirement is TRANSACTIONAL: if any candidate
|
|
4293
|
+
* cannot be exact-cancelled (a prepared successor pins the FIFO, or the entry
|
|
4294
|
+
* vanished), the whole batch is rolled back in-memory and the fence THROWS before
|
|
4295
|
+
* any persist — never a partial retire, never a fork past a still-live
|
|
4296
|
+
* terminalized `accepted`. Idempotent and re-run at EVERY recovery seam, so it
|
|
4297
|
+
* also covers the window where the durable failed was written but a crash hit
|
|
4298
|
+
* before the exit-time retirement (the durable async truth is authoritative,
|
|
4299
|
+
* re-checked here). A `prepared` entry is never cancelled without proof — the
|
|
4300
|
+
* runner may have crossed the write boundary; the fence only ever targets
|
|
4301
|
+
* `accepted`, and a prepared frame blocking an accepted retirement aborts the
|
|
4302
|
+
* fork (above). Owner-scoped: only THIS bot's failed evidence counts, so a
|
|
4303
|
+
* foreign/unstamped async record never retires our accepted entry.
|
|
4304
|
+
*
|
|
4305
|
+
* FAIL-CLOSED (codex #818 recovery-seam round-2). At-most-once forbids replaying
|
|
4306
|
+
* a turn the caller was already told is `failed`, so an ambiguous fence THROWS
|
|
4307
|
+
* rather than proceeding to fork:
|
|
4308
|
+
* 1. Read side uses `asyncTriggerStore.lookupStrict` — a present-but-unreadable
|
|
4309
|
+
* / corrupt terminal file must NOT fold into "no record" (soft `lookup`),
|
|
4310
|
+
* which would let the accepted entry re-enter the recovery snapshot and
|
|
4311
|
+
* replay. ENOENT / absent trigger is a genuine "no terminal" and is fine.
|
|
4312
|
+
* 2. Retire persist failure (updateSession EIO): the in-memory ledger is rolled
|
|
4313
|
+
* back to `priorLedger` and the error is RETHROWN, aborting this fork before
|
|
4314
|
+
* the recovery snapshot is taken. `staggeredRecoveryFork` (the boot eager
|
|
4315
|
+
* re-attach caller) already try/catches each fork, isolates the row, and
|
|
4316
|
+
* retains it for a later retry — so the durable ledger + async truth stay
|
|
4317
|
+
* intact and the next seam re-attempts the retirement. Degrading to "replay
|
|
4318
|
+
* once" (the pre-fix behavior) would itself be the P1 we are closing.
|
|
4319
|
+
* @throws when the authoritative async truth is unreadable, or the retirement
|
|
4320
|
+
* cannot be durably persisted — the caller (forkWorker) must abort this fork.
|
|
4321
|
+
*/
|
|
4322
|
+
function retireTerminalizedCodexAppLedgerEntriesForRecovery(ds) {
|
|
4323
|
+
const ledger = ds.session.codexAppDispatchLedger;
|
|
4324
|
+
if (!ledger || ledger.length === 0)
|
|
4325
|
+
return;
|
|
4326
|
+
const ownerLarkAppId = ds.larkAppId;
|
|
4327
|
+
const toRetire = ledger.filter(entry => entry.state === 'accepted'
|
|
4328
|
+
&& (() => {
|
|
4329
|
+
// STRICT read: a present-but-unreadable / corrupt terminal file THROWS
|
|
4330
|
+
// (fail-closed) instead of folding into "no record" and replaying. ONLY
|
|
4331
|
+
// our own durable dispatch_unknown failed counts (async-trigger-store is
|
|
4332
|
+
// keyed by sessionId, so a foreign/unstamped terminal on the same
|
|
4333
|
+
// sessionId/triggerId must not retire our accepted entry — mirrors the
|
|
4334
|
+
// owner-positive-proof gate used everywhere else in the idempotency path).
|
|
4335
|
+
const rec = asyncTriggerStore.lookupStrict(ds.session.sessionId, entry.turnId);
|
|
4336
|
+
return !!rec
|
|
4337
|
+
&& rec.ownerLarkAppId === ownerLarkAppId
|
|
4338
|
+
&& rec.result.status === 'failed'
|
|
4339
|
+
&& rec.result.reason === 'dispatch_unknown';
|
|
4340
|
+
})());
|
|
4341
|
+
if (toRetire.length === 0)
|
|
4342
|
+
return;
|
|
4343
|
+
// TRANSACTIONAL retirement (codex #818 exact-retirement fail-open). All work is
|
|
4344
|
+
// done against an in-memory working copy; nothing is persisted until EVERY
|
|
4345
|
+
// owner-matched terminal candidate has been exact-cancelled. If ANY candidate
|
|
4346
|
+
// cannot be cancelled — `prepared_successor_exists` (a later prepared frame
|
|
4347
|
+
// pins the FIFO) or `dispatch_not_found` (the ledger changed under us) — we
|
|
4348
|
+
// restore the in-memory ledger to `priorLedger` and THROW *before* any persist,
|
|
4349
|
+
// aborting this fork fail-closed. A `continue`-and-fork would leave that
|
|
4350
|
+
// terminalized `accepted` entry to be replayed as `recoveredAcceptedInputs`
|
|
4351
|
+
// (the generation fence only constrains `prepared`, never re-adds noReplay to a
|
|
4352
|
+
// surviving `accepted`), and a partial persist could retire some while forking
|
|
4353
|
+
// the rest. All-or-nothing + fail-closed is the only safe shape.
|
|
4354
|
+
const priorLedger = ds.session.codexAppDispatchLedger;
|
|
4355
|
+
let workingLedger = [...(ds.session.codexAppDispatchLedger ?? [])];
|
|
4356
|
+
const retiredTurnIds = [];
|
|
4357
|
+
for (const entry of toRetire) {
|
|
4358
|
+
const cancelled = cancelCodexAppDispatch(workingLedger, {
|
|
4359
|
+
dispatchId: entry.dispatchId,
|
|
4360
|
+
turnId: entry.turnId,
|
|
4361
|
+
...(entry.dispatchAttempt !== undefined ? { dispatchAttempt: entry.dispatchAttempt } : {}),
|
|
4362
|
+
});
|
|
4363
|
+
if (!cancelled.ok) {
|
|
4364
|
+
// Cannot exact-cancel a terminalized accepted entry (prepared successor /
|
|
4365
|
+
// vanished). Abort the whole retirement + fork fail-closed; a later seam
|
|
4366
|
+
// re-attempts once the blocking prepared frame settles or the ledger
|
|
4367
|
+
// stabilizes. NEVER fall through to fork with a live terminalized accepted.
|
|
4368
|
+
ds.session.codexAppDispatchLedger = priorLedger;
|
|
4369
|
+
throw new Error(`recovery fence could not exact-retire terminalized Codex App dispatch `
|
|
4370
|
+
+ `turn=${entry.turnId} (${cancelled.error}); aborting fork (fail-closed)`);
|
|
4371
|
+
}
|
|
4372
|
+
workingLedger = cancelled.ledger;
|
|
4373
|
+
retiredTurnIds.push(entry.turnId);
|
|
4374
|
+
}
|
|
4375
|
+
// Every candidate retired on the working copy — commit ONCE.
|
|
4376
|
+
ds.session.codexAppDispatchLedger = workingLedger;
|
|
4377
|
+
try {
|
|
4378
|
+
sessionStore.updateSession(ds.session);
|
|
4379
|
+
for (const turnId of retiredTurnIds) {
|
|
4380
|
+
logger.warn(`[${ds.session.sessionId.slice(0, 8)}] Recovery fence retired at-most-once terminalized `
|
|
4381
|
+
+ `Codex App dispatch turn=${turnId.slice(0, 12)} (durable failed:dispatch_unknown; not replayed)`);
|
|
4382
|
+
}
|
|
4383
|
+
if (!hasUnsettledCodexAppDispatch(ds.session.codexAppDispatchLedger)) {
|
|
4384
|
+
void Promise.resolve(callbacks?.onCodexAppLedgerDrained?.(ds)).catch(err => {
|
|
4385
|
+
logger.error(`[${ds.session.sessionId.slice(0, 8)}] post-recovery-fence ledger-drain cleanup failed: `
|
|
4386
|
+
+ `${err instanceof Error ? err.message : String(err)}`);
|
|
4387
|
+
});
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
catch (err) {
|
|
4391
|
+
// Persist failed (EIO/ENOSPC). Roll back the in-memory ledger so the durable
|
|
4392
|
+
// async truth + the on-disk ledger stay consistent, then RETHROW to ABORT
|
|
4393
|
+
// this fork BEFORE the recovery snapshot is taken (fail-closed). Degrading to
|
|
4394
|
+
// "let the accepted entry replay once" is exactly the at-most-once violation
|
|
4395
|
+
// this fence exists to close, so we must NOT proceed to fork. The boot eager
|
|
4396
|
+
// re-attach caller (staggeredRecoveryFork) try/catches each fork, isolates
|
|
4397
|
+
// this row, and retains it for a later retry — so the next seam re-attempts
|
|
4398
|
+
// the retirement against the intact durable state.
|
|
4399
|
+
ds.session.codexAppDispatchLedger = priorLedger;
|
|
4400
|
+
logger.error(`[${ds.session.sessionId.slice(0, 8)}] Recovery fence could not persist retired ledger; `
|
|
4401
|
+
+ `aborting fork (fail-closed), will retry next seam: ${err instanceof Error ? err.message : String(err)}`);
|
|
4402
|
+
throw err instanceof Error
|
|
4403
|
+
? err
|
|
4404
|
+
: new Error(`recovery-fence ledger retirement persist failed: ${String(err)}`);
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4174
4407
|
function snapshotQueuedActivationJournal(session) {
|
|
4175
4408
|
return {
|
|
4176
4409
|
queuedActivationPending: session.queuedActivationPending,
|
|
@@ -4429,6 +4662,7 @@ export function sendWorkerInput(ds, payload, turnId, opts = {}) {
|
|
|
4429
4662
|
...(opts.dispatchAttempt !== undefined ? { dispatchAttempt: opts.dispatchAttempt } : {}),
|
|
4430
4663
|
...(codexAppDispatchId ? { codexAppDispatchId } : {}),
|
|
4431
4664
|
...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
|
|
4665
|
+
...(opts.atMostOnce ? { atMostOnce: true } : {}),
|
|
4432
4666
|
...(vcMeetingImTurnOrigin
|
|
4433
4667
|
? { vcMeetingImTurnOrigin }
|
|
4434
4668
|
: {}),
|
|
@@ -4718,6 +4952,9 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
4718
4952
|
const gatedDispatchAttempt = typeof resumeOrTurnId === 'object' && resumeOrTurnId !== null
|
|
4719
4953
|
? resumeOrTurnId.dispatchAttempt
|
|
4720
4954
|
: undefined;
|
|
4955
|
+
const gatedAtMostOnce = typeof resumeOrTurnId === 'object' && resumeOrTurnId !== null
|
|
4956
|
+
? resumeOrTurnId.atMostOnce
|
|
4957
|
+
: undefined;
|
|
4721
4958
|
sendWorkerInput(ds, promptInput, gatedTurnId, {
|
|
4722
4959
|
...(gatedDispatchAttempt !== undefined
|
|
4723
4960
|
? { dispatchAttempt: gatedDispatchAttempt }
|
|
@@ -4727,6 +4964,12 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
4727
4964
|
// rerouted through the transfer gate must carry the flag here too, or it
|
|
4728
4965
|
// silently downgrades true → false like the direct-route branch did.
|
|
4729
4966
|
...(gatedPrompt.codexAppSteerable === true ? { codexAppSteerable: true } : {}),
|
|
4967
|
+
// At-most-once (codex #818 P1-5): a keyed follow-up fork rerouted through
|
|
4968
|
+
// the transfer gate to sendWorkerInput must preserve atMostOnce, else the
|
|
4969
|
+
// replacement CLI's input is replayable and a crash after the daemon
|
|
4970
|
+
// terminalized the turn re-runs it. forkWorker's own init path sets this
|
|
4971
|
+
// from resumeOrTurnId.atMostOnce; the reroute must forward it identically.
|
|
4972
|
+
...(gatedAtMostOnce ? { atMostOnce: true } : {}),
|
|
4730
4973
|
});
|
|
4731
4974
|
}
|
|
4732
4975
|
else {
|
|
@@ -5102,6 +5345,28 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
5102
5345
|
// Snapshot the prior durable FIFO before accepting this fork's new prompt.
|
|
5103
5346
|
// A pure reattach receives the full snapshot; a refork carrying N+1 restores
|
|
5104
5347
|
// old N first and reserves N+1 through the normal worker write path.
|
|
5348
|
+
// FIRST fence out any keyed turn already terminalized to a durable
|
|
5349
|
+
// failed(dispatch_unknown): the turn-level idempotency lease (PR #818) leaves
|
|
5350
|
+
// the shared session open, so without this the recovery path would replay an
|
|
5351
|
+
// at-most-once turn the caller was already told failed (the ledger is the third
|
|
5352
|
+
// replay channel `noReplay` does not reach). Idempotent + re-checked here.
|
|
5353
|
+
//
|
|
5354
|
+
// FAIL-CLOSED via THROW (codex #818 recovery-seam round-3): if the fence cannot
|
|
5355
|
+
// PROVE it is safe to build the recovery snapshot — unreadable/corrupt
|
|
5356
|
+
// authoritative async terminal, a terminalized accepted entry that cannot be
|
|
5357
|
+
// exact-cancelled, or a retirement persist failure — it THROWS. We deliberately
|
|
5358
|
+
// do NOT catch-and-`return false` here: `forkWorker`'s bool return is widely
|
|
5359
|
+
// IGNORED by callers (the keyed `/api/trigger` dormant path forks and then
|
|
5360
|
+
// unconditionally returns `queued`, so a swallowed false would strand the turn
|
|
5361
|
+
// `running`), and a `return false` would also bypass the outer
|
|
5362
|
+
// `rollbackWorkerForkPreInit` that restores the queued-activation journal
|
|
5363
|
+
// mutated above. Letting it throw routes through forkWorker's existing outer
|
|
5364
|
+
// catch (pre-init rollback + rethrow) and then the keyed trigger's post-barrier
|
|
5365
|
+
// convergence, which durably terminalizes the turn to `failed` (observable,
|
|
5366
|
+
// at-most-once) — the correct contract for every fork-failure path.
|
|
5367
|
+
if (agentCfg.cliId === 'codex-app') {
|
|
5368
|
+
retireTerminalizedCodexAppLedgerEntriesForRecovery(ds);
|
|
5369
|
+
}
|
|
5105
5370
|
const codexAppRecoveredDispatches = agentCfg.cliId === 'codex-app'
|
|
5106
5371
|
? (ds.session.codexAppDispatchLedger ?? []).map(entry => ({ ...entry }))
|
|
5107
5372
|
: [];
|
|
@@ -5351,6 +5616,12 @@ export function forkWorker(ds, promptInput, resumeOrTurnId = false) {
|
|
|
5351
5616
|
// rather than silently masking an arbitrary parent dir (codex P1). Omitted
|
|
5352
5617
|
// from forkAdoptWorker below — its observe branch returns before fs-policy.
|
|
5353
5618
|
loadedBotsConfigPath: getLoadedConfigPath(),
|
|
5619
|
+
// PROVENANCE of that path: 'loaded' = the daemon actually parsed this exact
|
|
5620
|
+
// file (a real registry authority, safe to pin onto CLI children);
|
|
5621
|
+
// 'synthetic' = core-only placeholder that was never parsed. The worker must
|
|
5622
|
+
// not guess this from the filesystem — existence and provenance are
|
|
5623
|
+
// independent facts (see core/config-dir.ts BotsConfigProvenance).
|
|
5624
|
+
loadedBotsConfigProvenance: getLoadedConfigProvenance(),
|
|
5354
5625
|
brand: normalizeBrand(botCfg.brand),
|
|
5355
5626
|
botName: bot.botName,
|
|
5356
5627
|
botOpenId: bot.botOpenId,
|
|
@@ -6043,6 +6314,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
6043
6314
|
// POST a second card; the in-flight POST becomes this turn's card.
|
|
6044
6315
|
if (ds.streamCardId === CARD_POSTING_SENTINEL)
|
|
6045
6316
|
break;
|
|
6317
|
+
const postingGeneration = ds.streamCardTurnGeneration ?? 0;
|
|
6046
6318
|
ds.streamCardId = CARD_POSTING_SENTINEL;
|
|
6047
6319
|
try {
|
|
6048
6320
|
ds.streamCardNonce = randomBytes(4).toString('hex');
|
|
@@ -6071,7 +6343,11 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
6071
6343
|
// this "starting" card is orphaned (never entered frozenCards, so
|
|
6072
6344
|
// recallFrozenCards can't withdraw it). Mirrors the screen_update POST
|
|
6073
6345
|
// branch which clears the flag after posting.
|
|
6074
|
-
ds.
|
|
6346
|
+
const superseded = (ds.streamCardTurnGeneration ?? 0) !== postingGeneration;
|
|
6347
|
+
if (!superseded) {
|
|
6348
|
+
ds.streamCardPending = false;
|
|
6349
|
+
ds.streamCardPendingTurnId = undefined;
|
|
6350
|
+
}
|
|
6075
6351
|
ds.parkedStreamCardNonce = undefined;
|
|
6076
6352
|
persistStreamCardState(ds);
|
|
6077
6353
|
// Re-sync worker's display mode (it starts fresh in 'hidden')
|
|
@@ -6088,6 +6364,9 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
6088
6364
|
// resume where the CLI kept running), arm here — same authorized arm
|
|
6089
6365
|
// point as the reuse branch, now that streamCardId is the real id.
|
|
6090
6366
|
syncUsageRefreshTimer(ds);
|
|
6367
|
+
if (superseded && ds.streamCardPendingTurnId) {
|
|
6368
|
+
void postTurnStartingCard(ds, cb.sessionReply, ds.streamCardPendingTurnId);
|
|
6369
|
+
}
|
|
6091
6370
|
}
|
|
6092
6371
|
catch (err) {
|
|
6093
6372
|
if (!ownsLifecycleMutation())
|
|
@@ -6404,6 +6683,7 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
6404
6683
|
// New turn — create a fresh card, old card freezes at its last state.
|
|
6405
6684
|
// Generate new nonce so old card buttons are distinguishable.
|
|
6406
6685
|
const isNewTurn = !!ds.streamCardPending;
|
|
6686
|
+
const postingGeneration = ds.streamCardTurnGeneration ?? 0;
|
|
6407
6687
|
ds.streamCardNonce = randomBytes(4).toString('hex');
|
|
6408
6688
|
// New turn → image_key from previous turn no longer valid
|
|
6409
6689
|
if (isNewTurn)
|
|
@@ -6420,6 +6700,9 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
6420
6700
|
return;
|
|
6421
6701
|
}
|
|
6422
6702
|
ds.streamCardId = msgId;
|
|
6703
|
+
const superseded = (ds.streamCardTurnGeneration ?? 0) !== postingGeneration;
|
|
6704
|
+
if (!superseded)
|
|
6705
|
+
ds.streamCardPendingTurnId = undefined;
|
|
6423
6706
|
ds.parkedStreamCardNonce = undefined;
|
|
6424
6707
|
persistStreamCardState(ds);
|
|
6425
6708
|
// New card live — recall any cards parked by previous turns
|
|
@@ -6437,6 +6720,9 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
6437
6720
|
// periodic usage refresh here (once the real card id exists, not
|
|
6438
6721
|
// the POSTING sentinel). syncUsageRefreshTimer re-checks state.
|
|
6439
6722
|
syncUsageRefreshTimer(ds);
|
|
6723
|
+
if (superseded && ds.streamCardPendingTurnId) {
|
|
6724
|
+
void postTurnStartingCard(ds, cb.sessionReply, ds.streamCardPendingTurnId);
|
|
6725
|
+
}
|
|
6440
6726
|
})
|
|
6441
6727
|
.catch(async (err) => {
|
|
6442
6728
|
if (!ownsLifecycleMutation())
|
|
@@ -6451,6 +6737,9 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
6451
6737
|
ds.pendingActiveRuntimeCardRefresh = undefined;
|
|
6452
6738
|
ds.pendingCodexTierCardRefresh = undefined;
|
|
6453
6739
|
persistStreamCardState(ds);
|
|
6740
|
+
if ((ds.streamCardTurnGeneration ?? 0) !== postingGeneration && ds.streamCardPendingTurnId) {
|
|
6741
|
+
void postTurnStartingCard(ds, cb.sessionReply, ds.streamCardPendingTurnId);
|
|
6742
|
+
}
|
|
6454
6743
|
});
|
|
6455
6744
|
}
|
|
6456
6745
|
else {
|
|
@@ -7194,9 +7483,9 @@ function setupWorkerHandlers(ds, worker, startupState = { ready: false, failureN
|
|
|
7194
7483
|
logger.error(`[${t}] Failed to persist async terminal-settle for ${msg.turnId.substring(0, 8)}: ${err.message}`);
|
|
7195
7484
|
}
|
|
7196
7485
|
// Cleared like the final_output path so worker-exit convergence does
|
|
7197
|
-
// not retro-fail this now-completed turn.
|
|
7198
|
-
|
|
7199
|
-
|
|
7486
|
+
// not retro-fail this now-completed turn. Per-triggerId delete so a
|
|
7487
|
+
// concurrent sibling keyed turn's convergence entry is untouched.
|
|
7488
|
+
ds.idempotentAsyncTurns?.delete(msg.turnId);
|
|
7200
7489
|
logger.info(`[${t}] Settled async HTTP turn ${msg.turnId.substring(0, 8)} completed (empty output; nothing-to-send)`);
|
|
7201
7490
|
}
|
|
7202
7491
|
}
|
|
@@ -7860,11 +8149,12 @@ function deliverFinalOutput(ds, msg, t, attempt, onComplete, isStillOwned = () =
|
|
|
7860
8149
|
// (with content + usage) after a daemon restart drops the in-memory Map.
|
|
7861
8150
|
// Stamp the owning bot for cross-bot isolation.
|
|
7862
8151
|
asyncTriggerStore.recordCompleted(ds.session.sessionId, msg.turnId, msg.content, completedAt, ds.larkAppId, msg.usage);
|
|
7863
|
-
// This idempotent async turn produced its terminal output —
|
|
7864
|
-
// worker-exit convergence
|
|
7865
|
-
// is not retro-failed (codex #776 round-6 finding #1).
|
|
7866
|
-
|
|
7867
|
-
|
|
8152
|
+
// This idempotent async turn produced its terminal output — drop its
|
|
8153
|
+
// worker-exit convergence entry so a later graceful exit of this generation
|
|
8154
|
+
// is not retro-failed (codex #776 round-6 finding #1). Per-triggerId delete so
|
|
8155
|
+
// a concurrent sibling keyed turn on the same shared session is untouched
|
|
8156
|
+
// (codex #818 P1-1).
|
|
8157
|
+
ds.idempotentAsyncTurns?.delete(msg.turnId);
|
|
7868
8158
|
ds.lastBridgeEmittedUuid = finalOutputDedupeKey(ds, msg);
|
|
7869
8159
|
logger.info(`[${t}] Captured final_output for Async HTTP request (turn ${msg.turnId.substring(0, 8)})`);
|
|
7870
8160
|
onComplete?.(true);
|
|
@@ -8227,6 +8517,7 @@ export const __testOnly_setupWorkerHandlers = setupWorkerHandlers;
|
|
|
8227
8517
|
export const __testOnly_reserveWorkerGeneration = reserveWorkerGeneration;
|
|
8228
8518
|
export const __testOnly_finishTurnReactions = finishTurnReactions;
|
|
8229
8519
|
export const __testOnly_finalOutputDedupeKey = finalOutputDedupeKey;
|
|
8520
|
+
export const __testOnly_retireTerminalizedCodexAppLedgerEntriesForRecovery = retireTerminalizedCodexAppLedgerEntriesForRecovery;
|
|
8230
8521
|
// ─── Fork adopt worker ──────────────────────────────────────────────────────
|
|
8231
8522
|
function reserveWorkerGeneration(ds) {
|
|
8232
8523
|
const previousDaemonGeneration = ds.workerGeneration;
|