From fa1ff4e3c9189457f495211f9f4324312b23295f Mon Sep 17 00:00:00 2001 From: zhouzhixia Date: Mon, 27 Jul 2026 16:47:46 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(codex):=20=E5=A2=9E=E5=8A=A0=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E7=BA=A7=20Fast=20Mode=20=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTEXT.md | 4 + src/adapters/cli/codex.ts | 14 ++- src/adapters/cli/types.ts | 3 + src/core/command-handler.ts | 57 ++++++++++ src/core/passthrough-commands.ts | 2 +- src/core/worker-pool.ts | 3 + src/i18n/en.ts | 5 + src/i18n/zh.ts | 5 + src/setup/cli-selection.ts | 4 +- src/types.ts | 5 +- src/worker.ts | 1 + test/cli-adapters.test.ts | 17 ++- test/cli-selection.test.ts | 8 ++ test/command-handler.test.ts | 149 ++++++++++++++++++++++++++- test/session-lifecycle-start.test.ts | 14 +++ test/write-input.test.ts | 12 +++ 16 files changed, 293 insertions(+), 10 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 8b15cfb1e..0b065cf1f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -19,6 +19,10 @@ _Avoid_: agent, app A continuing conversation between one chat anchor and one **Agent CLI**. _Avoid_: thread, task +**Fast Mode**: +A per-**Session** opt-in to a faster Agent CLI service tier. Every new Session starts with Fast Mode disabled, and changing it never changes another Session. +_Avoid_: global fast setting, Bot-level fast mode + **Token Usage**: Token counts reported by an **Agent CLI** or its persisted transcript for a **Session**. Token In is the Agent CLI's native input-side total, including diff --git a/src/adapters/cli/codex.ts b/src/adapters/cli/codex.ts index 2d815726a..cd20a5c5d 100644 --- a/src/adapters/cli/codex.ts +++ b/src/adapters/cli/codex.ts @@ -145,7 +145,7 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { authPaths: ['~/.codex'], get resolvedBin(): string { return (cachedBin ??= resolveCommand(rawBin)); }, - buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) { + buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, fastMode, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) { // Hybrid RPC input mode: attach this TUI to the botmux-owned app-server // thread. User input is delivered out-of-band via JSON-RPC (turn/start, // see codex-rpc-engine + worker), so the pane is a pure viewer — no paste @@ -156,7 +156,12 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { // enter to continue" dialog would block the resume forever and freeze the // Web terminal. Disable the check at the PROCESS level (never the user's // global config). The bounded startup-dialog watcher is only a fail-safe. - return ['--remote', remoteWsUrl, 'resume', '--no-alt-screen', '-c', 'check_for_update_on_startup=false', remoteThreadId]; + return [ + '--remote', remoteWsUrl, 'resume', '--no-alt-screen', + '-c', 'check_for_update_on_startup=false', + '-c', `service_tier=${JSON.stringify(fastMode === true ? 'fast' : 'default')}`, + remoteThreadId, + ]; } // Read isolation for Codex is enforced by the worker's Seatbelt wrapper, // NOT by codex's own profile (codex 0.137 can't express a read blocklist). @@ -173,6 +178,11 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { // not); the host-side daily monitor reports newer versions to the owner. '-c', 'check_for_update_on_startup=false', + // Codex persists its native /fast toggle in CODEX_HOME, which would + // otherwise leak one Session's choice into every later Session. Pin the + // effective tier at process launch; Botmux owns the per-Session state. + '-c', + `service_tier=${JSON.stringify(fastMode === true ? 'fast' : 'default')}`, ]; // Under read isolation the worker denies bots.json, so `botmux send` (a shell // subprocess) registers this bot from the worker-written cred FILE, keyed by diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index dd0a4e399..5c555f780 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -94,6 +94,9 @@ export interface CliAdapter { * `--model` flag (or equivalent) inject it here; adapters whose CLI has no * such concept simply ignore the field. Empty / undefined → CLI default. */ model?: string; + /** Session-scoped Fast Mode. Codex consumes this as a process-level service + * tier override; other adapters ignore it. Missing/false means standard. */ + fastMode?: boolean; /** When true, do not add adapter-default flags that bypass CLI approvals or disable sandboxing. */ disableCliBypass?: boolean; /** Optional session-scoped skill plugin/root prepared by botmux. */ diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index d188c1489..b22a857b4 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1892,6 +1892,62 @@ export async function handleCommand( break; } + case '/fast': { + if (!ds) { + await sessionReply(rootId, t('cmd.no_active_session', undefined, loc)); + break; + } + const botConfig = getBot(ds.larkAppId).config; + const sessionCliId = ds.session.cliId ?? botConfig.cliId; + const wrapperCli = ds.session.agentFrozen + ? ds.session.wrapperCli + : (ds.session.wrapperCli ?? botConfig.wrapperCli); + // Aiden rejects every Codex config override, so Botmux cannot pin the + // service tier on cold start and therefore cannot uphold Session-local + // semantics. Other known wrappers either rewrite or pass the override. + const isAidenWrapped = wrapperCli?.trim().split(/\s+/)[0] === 'aiden'; + if (sessionCliId !== 'codex' || ds.adoptedFrom || isAidenWrapped) { + await sessionReply(rootId, t('cmd.fast.unsupported', undefined, loc)); + break; + } + + const action = message.content.replace(/^\/fast(?:\s+)?/i, '').trim().toLowerCase(); + if (action && action !== 'on' && action !== 'off' && action !== 'status') { + await sessionReply(rootId, t('cmd.fast.usage', undefined, loc)); + break; + } + + const current = ds.session.fastMode === true; + if (action === 'status') { + await sessionReply(rootId, t(current ? 'cmd.fast.on' : 'cmd.fast.off', undefined, loc)); + break; + } + + const enabled = action === 'on' || (!action && !current); + if (enabled !== current) { + ds.session.fastMode = enabled; + sessionStore.updateSession(ds.session); + if (ds.worker && !ds.worker.killed) { + // Codex 0.145 exposes only a bare native /fast toggle. Botmux owns + // on/off/status semantics and sends the toggle only on a real state + // transition, so repeated `/fast on` is deterministic. + ds.worker.send({ + type: 'raw_input', + content: '/fast', + turnId: message.messageId, + } as DaemonToWorker); + } + } else if (ds.session.fastMode === undefined) { + // Materialize the default on first use so resumes no longer depend on + // Codex's process-global persisted service tier. + ds.session.fastMode = enabled; + sessionStore.updateSession(ds.session); + } + await sessionReply(rootId, t(enabled ? 'cmd.fast.on' : 'cmd.fast.off', undefined, loc)); + logger.info(`[${logTag}] Fast Mode ${enabled ? 'enabled' : 'disabled'} for Session`); + break; + } + case '/schedule': { const scheduleArgs = message.content.replace(/^\/schedule\s*/, ''); const chatId = ds?.chatId!; @@ -3281,6 +3337,7 @@ export async function handleCommand( t('help.repo_wt', undefined, loc), t('help.rename', undefined, loc), t('help.status', undefined, loc), + t('help.fast', undefined, loc), t('help.card', undefined, loc), t('help.term', undefined, loc), t('help.dashboard', undefined, loc), diff --git a/src/core/passthrough-commands.ts b/src/core/passthrough-commands.ts index e3c160ea9..64e3d0016 100644 --- a/src/core/passthrough-commands.ts +++ b/src/core/passthrough-commands.ts @@ -10,7 +10,7 @@ * chat) rather than relayed to the CLI. Used both for routing and to reject * `customPassthroughCommands` entries that would shadow a daemon command. */ -export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/vc-auth']); +export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/fast', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/vc-auth']); /** * Slash commands that are forwarded verbatim to the underlying CLI (e.g. diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index b23f5b005..1f51930fd 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -2250,6 +2250,9 @@ export function forkWorker( wrapperCli: agentCfg.wrapperCli, launchShell: botCfg.launchShell, model: agentCfg.model, + // Fast Mode is frozen on the Session rather than inherited from Codex's + // CODEX_HOME. Missing means the Session has never opted in → standard. + fastMode: agentCfg.cliId === 'codex' ? ds.session.fastMode === true : undefined, disableCliBypass: botCfg.disableCliBypass === true, codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault, // Startup commands run on every fresh spawn (incl. resume) so session-only diff --git a/src/i18n/en.ts b/src/i18n/en.ts index c955ad042..cdf0701f0 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -268,6 +268,10 @@ export const messages: Record = { 'cmd.status.running': 'running', 'cmd.status.waiting': 'idle', 'cmd.status.fallback_no_session': 'No active session in this topic.\nDaemon active sessions: {count}\n{cliName}: v{version}', + 'cmd.fast.on': '✅ Fast Mode is ON for this Session only.', + 'cmd.fast.off': '⚪ Fast Mode is OFF for this Session only.', + 'cmd.fast.usage': 'Usage: /fast (toggle) | /fast on | /fast off | /fast status', + 'cmd.fast.unsupported': '⚠️ /fast requires a Botmux-managed Codex Session whose service tier can be pinned (Aiden gateways are not supported).', 'cmd.login.no_credentials': '❌ Cannot read app credentials.', 'cmd.login.title': '🔐 Lark User OAuth', 'cmd.login.step1': '1. Click the link below to authorize:', @@ -490,6 +494,7 @@ export const messages: Record = { 'help.repo_wt': '/repo wt [branch] - Create a worktree off the remote default branch and open it (auto semantic name when branch is omitted)', 'help.rename': '/rename - Rename this Botmux session and sync the running Codex/Claude session name', 'help.status': '/status - Show current session status (incl. terminal URL)', + 'help.fast': '/fast [on|off|status] - Toggle or inspect Codex Fast Mode for this Session (off by default)', 'help.card': '/card - Manually post the streaming card for this session (summons it even when streaming is off, and resumes live updates; with private-card mode on, sends a static snapshot visible only to authorized users instead)', 'help.term': '/term - Get the operable (write-enabled) terminal link for this session, delivered privately to the owner (visible-to-you in-chat, falling back to DM in topic/p2p — never exposed in the group)', 'help.dashboard': '/dashboard [module] - Open Dashboard control cards in Feishu (sessions/schedules/groups/settings/help, etc.)', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index cc51e5e8b..6b9413e81 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -271,6 +271,10 @@ export const messages: Record<string, string> = { 'cmd.status.running': '运行中', 'cmd.status.waiting': '等待中', 'cmd.status.fallback_no_session': '当前话题没有活跃的会话。\nDaemon active sessions: {count}\n{cliName}: v{version}', + 'cmd.fast.on': '✅ Fast Mode 已开启(仅当前话题 Session)。', + 'cmd.fast.off': '⚪ Fast Mode 已关闭(仅当前话题 Session)。', + 'cmd.fast.usage': '用法:/fast(切换)| /fast on | /fast off | /fast status', + 'cmd.fast.unsupported': '⚠️ /fast 仅支持由 Botmux 管理且能固定服务等级的 Codex Session(Aiden 网关暂不支持)。', 'cmd.login.no_credentials': '❌ 无法获取应用凭证', 'cmd.login.title': '🔐 飞书用户授权', 'cmd.login.step1': '1. 点击下方链接完成授权:', @@ -493,6 +497,7 @@ export const messages: Record<string, string> = { 'help.repo_wt': '/repo wt <编号|项目名> [分支] - 基于远端默认分支新建 worktree 并打开(未指定分支时自动语义命名)', 'help.rename': '/rename <标题> - 重命名当前 Botmux 会话,并同步运行中的 Codex/Claude 原生会话名', 'help.status': '/status - 查看当前会话状态(含终端链接)', + 'help.fast': '/fast [on|off|status] - 切换或查看当前话题 Session 的 Codex Fast Mode(默认关闭)', 'help.card': '/card - 手动弹出当前会话的流式卡片(关流式时也能临时召唤,并恢复实时刷新;开了私密卡片则改发仅授权人可见的静态快照)', 'help.term': '/term - 获取当前会话的「可操作终端」(带写权限)链接,私密发给 owner(群内仅你可见,话题/单聊回退私信,不在群里暴露)', 'help.dashboard': '/dashboard [模块] - 在飞书里打开 Dashboard 控制卡片(sessions/schedules/groups/settings/help 等)', diff --git a/src/setup/cli-selection.ts b/src/setup/cli-selection.ts index ab6fb070f..8a3171988 100644 --- a/src/setup/cli-selection.ts +++ b/src/setup/cli-selection.ts @@ -255,6 +255,8 @@ function isBotmuxCodexConfigValue(value: string | undefined): boolean { return !!value && ( value.startsWith('shell_environment_policy.set.BOTMUX_') || value === 'check_for_update_on_startup=false' + || value === 'service_tier="default"' + || value === 'service_tier="fast"' ); } @@ -276,7 +278,7 @@ export function stripSettingsArgs(args: ReadonlyArray<string>): string[] { /** * 剥掉 aiden `aiden x <cli>` 网关拒收的、**botmux 注入的**底层 CLI config 覆盖参数: * - `--settings <v>` / `--settings=<v>`(claude 携带 hook/bypass,aiden x claude 历来就剥) - * - botmux 自己注入的 Codex `-c`(session 环境,以及关闭启动更新选择器); + * - botmux 自己注入的 Codex `-c`(session 环境、关闭启动更新选择器、Session Fast Mode); * aiden 1.8.38+ 会直接报错拒收 `aiden x codex` 透传的 `-c`/`--config`。 * 这些参数承载的 session 环境已在进程级 env(BOTMUX_SESSION_ID 等)注入、并被 wrapper * 子进程继承(见 worker.ts childEnv),故剥掉只是去掉一条冗余的 belt-and-suspenders diff --git a/src/types.ts b/src/types.ts index e5bb00f37..9be1675ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -334,6 +334,9 @@ export interface Session { wrapperCli?: string; /** Optional model frozen at creation so historical sessions resume with their original model. */ model?: string; + /** Session-scoped Codex Fast Mode. Missing/false means standard service tier; + * true opts only this Session into the faster tier. */ + fastMode?: boolean; /** * True once `cliId`/`cliPathOverride`/`wrapperCli`/`model` have been frozen for * this session (see `sessionAgentConfig`). Gates the one-time freeze so it runs @@ -581,7 +584,7 @@ export interface CliTurnPayload { /** Messages sent from Daemon to Worker */ export type DaemonToWorker = - | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record<string, string>; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } + | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; fastMode?: boolean; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record<string, string>; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } | { type: 'message'; content: string; codexAppInput?: CodexAppTurnInput; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin } /** Literal slash-command passthrough. `followUpContent` rides along so the * worker enqueues it strictly AFTER the slash command's Enter — two separate diff --git a/src/worker.ts b/src/worker.ts index 4c7472627..529ee7afe 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6947,6 +6947,7 @@ async function spawnCli( larkAppId: cfg.larkAppId, locale: cfg.locale, model: ttadkGateway ? undefined : cfg.model, + fastMode: cfg.fastMode === true, disableCliBypass: cfg.disableCliBypass === true, skillPluginDir: cfg.skillPluginDir, readIsolation: willRedirectCliData, diff --git a/test/cli-adapters.test.ts b/test/cli-adapters.test.ts index 00a2ef660..ea597970c 100644 --- a/test/cli-adapters.test.ts +++ b/test/cli-adapters.test.ts @@ -333,7 +333,9 @@ describe('codex buildArgs', () => { // pure --remote viewer: no paste-mode bypass flag, no stale resume path expect(args).toEqual([ '--remote', 'ws://127.0.0.1:9931', 'resume', '--no-alt-screen', - '-c', 'check_for_update_on_startup=false', 'thread-abc', + '-c', 'check_for_update_on_startup=false', + '-c', 'service_tier="default"', + 'thread-abc', ]); // the -c disable must land BEFORE the thread id (a resume-subcommand config) const cIdx = args.indexOf('-c'); @@ -394,6 +396,8 @@ describe('codex buildArgs', () => { 'shell_environment_policy.set.BOTMUX_SESSION_ID="sess-4"', '-c', 'check_for_update_on_startup=false', + '-c', + 'service_tier="default"', '-C', '/repo/root', ]); @@ -407,6 +411,8 @@ describe('codex buildArgs', () => { 'shell_environment_policy.set.BOTMUX_SESSION_ID="sess-4"', '-c', 'check_for_update_on_startup=false', + '-c', + 'service_tier="default"', '-C', '/repo/root', ]); @@ -419,6 +425,15 @@ describe('codex buildArgs', () => { expect(args[idx - 1]).toBe('-c'); }); + it('starts every Codex Session with Fast Mode off unless that Session opted in', () => { + const standard = adapter.buildArgs({ sessionId: 'sess-standard', resume: false }); + const fast = adapter.buildArgs({ sessionId: 'sess-fast', resume: false, fastMode: true }); + + expect(standard).toContain('service_tier="default"'); + expect(fast).toContain('service_tier="fast"'); + expect(fast).not.toContain('service_tier="default"'); + }); + it('keeps the startup update override on resume before the Codex session id', () => { const args = adapter.buildArgs({ sessionId: 'sess-4', diff --git a/test/cli-selection.test.ts b/test/cli-selection.test.ts index 647435b5d..7a0983efe 100644 --- a/test/cli-selection.test.ts +++ b/test/cli-selection.test.ts @@ -231,6 +231,14 @@ describe('stripWrapperUnsafeArgs', () => { .toEqual(['--model', 'm']); }); + it('strips the botmux-injected Session service tier override', () => { + expect(stripWrapperUnsafeArgs([ + '-c', 'service_tier="default"', + '-c', 'service_tier="fast"', + '--model', 'm', + ])).toEqual(['--model', 'm']); + }); + it('leaves a user-supplied -c (non-botmux config override) untouched', () => { expect(stripWrapperUnsafeArgs(['-c', 'model_reasoning_effort="high"', '--model', 'm'])) .toEqual(['-c', 'model_reasoning_effort="high"', '--model', 'm']); diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 4e03bc9a6..3b05dba74 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -450,7 +450,7 @@ vi.mock('../src/services/card-mode-store.js', () => ({ // ─── Imports (after mocks) ────────────────────────────────────────────────── -import { DAEMON_COMMANDS, SESSIONLESS_DAEMON_COMMANDS, PASSTHROUGH_COMMANDS, resolvePassthroughCommands, handleCommand, handleCardCommand, handleTermLinkCommand, parseSlashCommandInvocation, parseForceTopicInvocation } from '../src/core/command-handler.js'; +import { DAEMON_COMMANDS, SESSIONLESS_DAEMON_COMMANDS, PASSTHROUGH_COMMANDS, resolveAdapterDefaultPassthroughCommands, resolvePassthroughCommands, handleCommand, handleCardCommand, handleTermLinkCommand, parseSlashCommandInvocation, parseForceTopicInvocation } from '../src/core/command-handler.js'; import { setCardMode } from '../src/services/card-mode-store.js'; import { writeRoleFile, deleteRoleFile, writeTeamRoleFile, deleteTeamRoleFile, resolveRole, resolveRoleFile } from '../src/core/role-resolver.js'; import { setBotCapability, clearBotCapability } from '../src/services/bot-profile-store.js'; @@ -597,7 +597,7 @@ function mockCodexAppBot(): void { describe('DAEMON_COMMANDS set', () => { it('should contain all expected commands', () => { - const expected = ['/close', '/restart', '/status', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/vc-auth']; + const expected = ['/close', '/restart', '/status', '/fast', '/help', '/cd', '/repo', '/rename', '/schedule', '/role', '/botconfig', '/skills', '/pair', '/login', '/adopt', '/detach', '/disconnect', '/oncall', '/group', '/g', '/relay', '/card', '/term', '/list-slash-command', '/slash', '/subscribe-lark-doc', '/watch-comment', '/vc', '/insight', '/dashboard', '/vc-auth']; for (const cmd of expected) { expect(DAEMON_COMMANDS.has(cmd), `Expected DAEMON_COMMANDS to contain ${cmd}`).toBe(true); } @@ -630,10 +630,11 @@ describe('DAEMON_COMMANDS set', () => { }); it('should have the correct size', () => { - // 30 = current master command set without the removed /land command. + // 31 = current master command set without the removed /land command, plus + // the session-scoped /fast control command. // /subscribe-lark-doc remains // as its original per-file API subscription command rather than an alias. - expect(DAEMON_COMMANDS.size).toBe(30); + expect(DAEMON_COMMANDS.size).toBe(31); }); it('contains the /list-slash-command lister and its /slash alias', () => { @@ -975,6 +976,14 @@ describe('PASSTHROUGH_COMMANDS set', () => { expect(resolvePassthroughCommands('app-2').has('/goal')).toBe(true); }); + it('keeps /fast daemon-owned instead of exposing it as raw Codex passthrough', () => { + expect(DAEMON_COMMANDS.has('/fast')).toBe(true); + expect(PASSTHROUGH_COMMANDS.has('/fast')).toBe(false); + expect(resolveAdapterDefaultPassthroughCommands('app-1')).not.toContain('/fast'); + expect(resolveAdapterDefaultPassthroughCommands('app-2')).not.toContain('/fast'); + expect(resolvePassthroughCommands('app-2').has('/fast')).toBe(false); + }); + it('does not expose Codex interactive /title through the Lark channel', () => { expect(PASSTHROUGH_COMMANDS.has('/title')).toBe(false); expect(DAEMON_COMMANDS.has('/title')).toBe(false); @@ -1582,6 +1591,138 @@ describe('handleCommand', () => { }); }); + // ─── /fast ────────────────────────────────────────────────────────────── + + describe('/fast', () => { + function makeCodexFastSession(enabled: boolean | undefined, running = true): DaemonSession { + const session = makeSession({ cliId: 'codex' }); + if (enabled !== undefined) session.fastMode = enabled; + return makeDaemonSession({ + larkAppId: 'app-2', + session, + worker: running ? ({ killed: false, send: vi.fn() } as any) : null, + }); + } + + it('enables Fast Mode for only the current Session and replies in Lark', async () => { + const ds = makeCodexFastSession(false); + const deps = makeDeps(ds); + + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), deps, 'app-2'); + + expect(ds.worker?.send).toHaveBeenCalledWith({ + type: 'raw_input', + content: '/fast', + turnId: 'msg_001', + }); + expect(ds.session.fastMode).toBe(true); + expect(sessionStore.updateSession).toHaveBeenCalledWith(ds.session); + expect(deps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringMatching(/Fast Mode.*已开启.*当前话题/s), + undefined, + 'app-2', + 'msg_001', + ); + }); + + it('stores the setting without spawning a CLI when /fast starts a new topic', async () => { + const ds = makeCodexFastSession(false, false); + const deps = makeDeps(ds); + + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), deps, 'app-2'); + + expect(ds.session.fastMode).toBe(true); + expect(sessionStore.updateSession).toHaveBeenCalledWith(ds.session); + expect(deps.sessionReply).toHaveBeenCalled(); + }); + + it('reports Session state without toggling Codex', async () => { + const ds = makeCodexFastSession(true); + const deps = makeDeps(ds); + + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast status'), deps, 'app-2'); + + expect(ds.worker?.send).not.toHaveBeenCalled(); + expect(deps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringMatching(/Fast Mode.*已开启.*当前话题/s), + undefined, + 'app-2', + 'msg_001', + ); + }); + + it('keeps /fast off idempotent and does not toggle the native CLI', async () => { + const ds = makeCodexFastSession(false); + const deps = makeDeps(ds); + + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast off'), deps, 'app-2'); + + expect(ds.worker?.send).not.toHaveBeenCalled(); + expect(ds.session.fastMode).toBe(false); + expect(deps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringMatching(/Fast Mode.*已关闭.*当前话题/s), + undefined, + 'app-2', + 'msg_001', + ); + }); + + it('uses bare /fast as a toggle', async () => { + const ds = makeCodexFastSession(false); + const deps = makeDeps(ds); + + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast'), deps, 'app-2'); + + expect(ds.session.fastMode).toBe(true); + expect(ds.worker?.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'raw_input', + content: '/fast', + })); + }); + + it('rejects unsupported arguments and non-Codex Sessions explicitly', async () => { + const codex = makeCodexFastSession(false); + const codexDeps = makeDeps(codex); + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast turbo'), codexDeps, 'app-2'); + expect(codexDeps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringContaining('/fast on'), + undefined, + 'app-2', + 'msg_001', + ); + expect(codex.worker?.send).not.toHaveBeenCalled(); + + const claude = makeDaemonSession(); + const claudeDeps = makeDeps(claude); + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), claudeDeps, LARK_APP_ID); + expect(claudeDeps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringContaining('仅支持由 Botmux 管理'), + undefined, + LARK_APP_ID, + 'msg_001', + ); + + const aiden = makeCodexFastSession(false); + aiden.session.wrapperCli = 'aiden x codex'; + aiden.session.agentFrozen = true; + const aidenDeps = makeDeps(aiden); + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), aidenDeps, 'app-2'); + expect(aiden.worker?.send).not.toHaveBeenCalled(); + expect(aidenDeps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringContaining('Aiden'), + undefined, + 'app-2', + 'msg_001', + ); + }); + }); + // ─── /help ────────────────────────────────────────────────────────────── describe('/help', () => { diff --git a/test/session-lifecycle-start.test.ts b/test/session-lifecycle-start.test.ts index 68821457d..9e50408d3 100644 --- a/test/session-lifecycle-start.test.ts +++ b/test/session-lifecycle-start.test.ts @@ -1518,6 +1518,20 @@ describe('forkWorker session agent config freeze', () => { })); }); + it('passes the persisted Session Fast Mode into the Codex worker', () => { + const ds = makeDs(); + ds.session.fastMode = true; + + forkWorker(ds, 'hello', false); + + const worker = forkMock.mock.results.at(-1)!.value; + expect(worker.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'init', + cliId: 'codex', + fastMode: true, + })); + }); + it('fills wrapper and model on fresh sessions that already stamped cliId', () => { const ds = makeDs(); ds.session.cliId = 'codex' as any; diff --git a/test/write-input.test.ts b/test/write-input.test.ts index c077c8474..bec21dd8c 100644 --- a/test/write-input.test.ts +++ b/test/write-input.test.ts @@ -1089,6 +1089,8 @@ describe('codex writeInput submission confirmation', () => { 'shell_environment_policy.set.BOTMUX_SESSION_ID="botmux-session"', '-c', 'check_for_update_on_startup=false', + '-c', + 'service_tier="default"', '019dd3e2-f2da-7592-86b5-a43d4cd0772f', ]); }); @@ -1110,6 +1112,8 @@ describe('codex writeInput submission confirmation', () => { 'shell_environment_policy.set.BOTMUX_SESSION_ID="botmux-session"', '-c', 'check_for_update_on_startup=false', + '-c', + 'service_tier="default"', '019dd3e2-f2da-7592-86b5-a43d4cd0772f', ]); }); @@ -1129,6 +1133,8 @@ describe('codex writeInput submission confirmation', () => { 'shell_environment_policy.set.BOTMUX_SESSION_ID="botmux-session"', '-c', 'check_for_update_on_startup=false', + '-c', + 'service_tier="default"', 'new-codex-session', ]); }); @@ -1149,6 +1155,8 @@ describe('codex writeInput submission confirmation', () => { 'shell_environment_policy.set.BOTMUX_SESSION_ID="custom-botmux-session"', '-c', 'check_for_update_on_startup=false', + '-c', + 'service_tier="default"', 'custom-codex-session', ]); @@ -1174,6 +1182,8 @@ describe('codex writeInput submission confirmation', () => { 'shell_environment_policy.set.BOTMUX_SESSION_ID="botmux-session"', '-c', 'check_for_update_on_startup=false', + '-c', + 'service_tier="default"', ]); }); @@ -1192,6 +1202,8 @@ describe('codex writeInput submission confirmation', () => { 'shell_environment_policy.set.BOTMUX_SESSION_ID="botmux-session"', '-c', 'check_for_update_on_startup=false', + '-c', + 'service_tier="default"', '-C', '/repo/root', ]); From 432a9c08c05f881e95a920e192f7e593882f1b56 Mon Sep 17 00:00:00 2001 From: zhouzhixia <zhouzhixia@bytedance.com> Date: Mon, 27 Jul 2026 18:24:15 +0800 Subject: [PATCH 2/5] fix(codex): make Fast Mode executor-confirmed --- src/adapters/backend/reproduce-command.ts | 6 +- src/adapters/cli/codex.ts | 10 +- src/adapters/cli/types.ts | 2 + src/codex-rpc-engine.ts | 81 +++++++ src/core/command-handler.ts | 80 ++++--- src/core/fast-mode-control.ts | 95 +++++++++ src/core/fast-mode-handshake.ts | 60 ++++++ src/core/worker-pool.ts | 24 +++ src/daemon.ts | 194 ++++++++++++++++- src/i18n/en.ts | 2 + src/i18n/zh.ts | 2 + src/setup/cli-selection.ts | 34 ++- src/types.ts | 25 ++- src/worker.ts | 246 +++++++++++++++++++++- test/cli-adapters.test.ts | 17 +- test/cli-selection.test.ts | 30 ++- test/codex-fast-worker-wiring.test.ts | 50 +++++ test/codex-rpc-engine.test.ts | 88 +++++++- test/command-handler.test.ts | 72 ++++++- test/daemon-rename-route.test.ts | 95 +++++++++ test/dashboard-attention-signals.test.ts | 4 +- test/fast-mode-control.test.ts | 84 ++++++++ test/fast-mode-handshake.test.ts | 46 ++++ test/fixtures/fake-codex-rpc-server.mjs | 23 ++ 24 files changed, 1304 insertions(+), 66 deletions(-) create mode 100644 src/core/fast-mode-control.ts create mode 100644 src/core/fast-mode-handshake.ts create mode 100644 test/codex-fast-worker-wiring.test.ts create mode 100644 test/fast-mode-control.test.ts create mode 100644 test/fast-mode-handshake.test.ts diff --git a/src/adapters/backend/reproduce-command.ts b/src/adapters/backend/reproduce-command.ts index ab9b01bfd..48870424f 100644 --- a/src/adapters/backend/reproduce-command.ts +++ b/src/adapters/backend/reproduce-command.ts @@ -39,6 +39,7 @@ export function selectReproduceLaunch(input: { sandboxOn: boolean; binResolver?: (bin: string) => string; ttadkModel?: string; + codexServiceTier?: string; }): { bin: string; args: string[] } { const { baseBin, baseArgs, wrapperCli, sandboxOn } = input; if (wrapperCli && wrapperCli.trim() && !sandboxOn) { @@ -46,7 +47,10 @@ export function selectReproduceLaunch(input: { wrapperCli, baseArgs, input.binResolver ?? ((b) => b), - { ttadkModel: input.ttadkModel }, + { + ttadkModel: input.ttadkModel, + codexServiceTier: input.codexServiceTier, + }, ); if (launch.bin) return { bin: launch.bin, args: launch.args }; } diff --git a/src/adapters/cli/codex.ts b/src/adapters/cli/codex.ts index cd20a5c5d..40c471fae 100644 --- a/src/adapters/cli/codex.ts +++ b/src/adapters/cli/codex.ts @@ -145,7 +145,11 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { authPaths: ['~/.codex'], get resolvedBin(): string { return (cachedBin ??= resolveCommand(rawBin)); }, - buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, fastMode, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) { + buildArgs({ sessionId, resume, resumeSessionId, workingDir, model, fastMode, fastServiceTier, disableCliBypass, readIsolation, remoteWsUrl, remoteThreadId }) { + if (fastMode === true && !fastServiceTier) { + throw new Error('Fast service tier was not resolved from the Codex model catalog'); + } + const serviceTier = fastMode === true ? fastServiceTier! : 'default'; // Hybrid RPC input mode: attach this TUI to the botmux-owned app-server // thread. User input is delivered out-of-band via JSON-RPC (turn/start, // see codex-rpc-engine + worker), so the pane is a pure viewer — no paste @@ -159,7 +163,7 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { return [ '--remote', remoteWsUrl, 'resume', '--no-alt-screen', '-c', 'check_for_update_on_startup=false', - '-c', `service_tier=${JSON.stringify(fastMode === true ? 'fast' : 'default')}`, + '-c', `service_tier=${JSON.stringify(serviceTier)}`, remoteThreadId, ]; } @@ -182,7 +186,7 @@ export function createCodexAdapter(pathOverride?: string): CliAdapter { // otherwise leak one Session's choice into every later Session. Pin the // effective tier at process launch; Botmux owns the per-Session state. '-c', - `service_tier=${JSON.stringify(fastMode === true ? 'fast' : 'default')}`, + `service_tier=${JSON.stringify(serviceTier)}`, ]; // Under read isolation the worker denies bots.json, so `botmux send` (a shell // subprocess) registers this bot from the worker-written cred FILE, keyed by diff --git a/src/adapters/cli/types.ts b/src/adapters/cli/types.ts index 5c555f780..4c0e2b3eb 100644 --- a/src/adapters/cli/types.ts +++ b/src/adapters/cli/types.ts @@ -97,6 +97,8 @@ export interface CliAdapter { /** Session-scoped Fast Mode. Codex consumes this as a process-level service * tier override; other adapters ignore it. Missing/false means standard. */ fastMode?: boolean; + /** Concrete Fast tier id resolved from Codex's model catalog. */ + fastServiceTier?: string; /** When true, do not add adapter-default flags that bypass CLI approvals or disable sandboxing. */ disableCliBypass?: boolean; /** Optional session-scoped skill plugin/root prepared by botmux. */ diff --git a/src/codex-rpc-engine.ts b/src/codex-rpc-engine.ts index 3f8bec2e0..e8865c63d 100644 --- a/src/codex-rpc-engine.ts +++ b/src/codex-rpc-engine.ts @@ -68,6 +68,9 @@ export interface CodexRpcEngineOpts { /** Optional model + reasoning effort forwarded to thread config (P1). */ model?: string; reasoningEffort?: string; + /** Session-scoped Fast Mode. The concrete protocol tier id is resolved from + * `model/list`; "Fast" is a display name and is not itself a stable tier id. */ + fastMode?: boolean; /** Feature gates owned by the app-server process (the viewer TUI does not * execute model tools in RPC mode). */ appServerFeatures?: string[]; @@ -111,6 +114,7 @@ export class CodexRpcEngine { private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>(); private port = 0; private threadId?: string; + private serviceTier?: string; private closed = false; private deadNotified = false; private lastStderr = ''; @@ -122,6 +126,7 @@ export class CodexRpcEngine { get wsUrl(): string { return `ws://127.0.0.1:${this.port}`; } get activeThreadId(): string | undefined { return this.threadId; } + get activeServiceTier(): string | undefined { return this.serviceTier; } get appServerPid(): number | undefined { return this.child?.pid; } /** Spawn the app-server, connect, and complete the initialize handshake. */ @@ -159,6 +164,7 @@ export class CodexRpcEngine { /** Create a fresh session thread. Its id (== codex rollout session id) is what * the TUI resumes and what botmux persists for future resume. */ async startThread(): Promise<string> { + await this.prepareInitialServiceTier(); const r = await this.request('thread/start', this.threadParams()); this.threadId = String(r?.thread?.id ?? ''); if (!this.threadId) throw new Error('thread/start returned no thread id'); @@ -169,6 +175,7 @@ export class CodexRpcEngine { * so RPC mode stays engaged across daemon restarts instead of reverting to * the paste path. */ async resumeThread(threadId: string): Promise<string> { + await this.prepareInitialServiceTier(); const params: Json = { ...this.threadParams(), threadId, excludeTurns: true }; delete params.serviceName; // resume keeps the original thread's identity const r = await this.request('thread/resume', params); @@ -191,10 +198,82 @@ export class CodexRpcEngine { serviceName: 'botmux', ephemeral: false, persistExtendedHistory: true, + serviceTier: this.serviceTier ?? null, config, }; } + /** Resolve the current model's catalog entry whose user-facing name is Fast. + * Codex 0.145 deliberately selects by tier name and sends the catalog's id + * (currently `priority` for OpenAI models), so callers must never hardcode + * `fast` as the protocol value. */ + async resolveFastServiceTier(model = this.opts.model): Promise<{ model: string; serviceTier: string } | undefined> { + let cursor: string | null | undefined; + const models: Json[] = []; + do { + const result = await this.request('model/list', { + cursor: cursor ?? null, + limit: 100, + includeHidden: true, + }); + if (Array.isArray(result?.data)) models.push(...result.data); + cursor = typeof result?.nextCursor === 'string' && result.nextCursor + ? result.nextCursor + : null; + } while (cursor); + + const requested = model?.trim(); + const selected = requested + ? models.find(entry => entry?.id === requested || entry?.model === requested) + : models.find(entry => entry?.isDefault === true); + if (!selected) return undefined; + const fast = Array.isArray(selected.serviceTiers) + ? selected.serviceTiers.find((tier: Json) => + typeof tier?.name === 'string' && tier.name.toLowerCase() === 'fast') + : undefined; + if (!fast || typeof fast.id !== 'string' || !fast.id) return undefined; + return { + model: String(selected.model ?? selected.id), + serviceTier: fast.id, + }; + } + + private async prepareInitialServiceTier(): Promise<void> { + if (!this.opts.fastMode) { + this.serviceTier = undefined; + return; + } + if (this.serviceTier) return; + const resolved = await this.resolveFastServiceTier(); + if (!resolved) { + throw new Error(`Fast Mode is not supported by model ${this.opts.model ?? '(default)'}`); + } + this.serviceTier = resolved.serviceTier; + } + + /** Change the loaded thread's tier for subsequent turns. State is committed + * only after app-server acknowledges `thread/settings/update`, so the daemon + * can persist exactly what the executor accepted. */ + async setFastMode(enabled: boolean): Promise<{ enabled: boolean; serviceTier?: string }> { + if (!this.threadId) throw new Error('setFastMode before startThread/resumeThread'); + let nextTier: string | undefined; + if (enabled) { + const resolved = await this.resolveFastServiceTier(); + if (!resolved) { + throw new Error(`Fast Mode is not supported by model ${this.opts.model ?? '(default)'}`); + } + nextTier = resolved.serviceTier; + } + await this.request('thread/settings/update', { + threadId: this.threadId, + serviceTier: nextTier ?? null, + }); + this.serviceTier = nextTier; + return nextTier + ? { enabled: true, serviceTier: nextTier } + : { enabled: false }; + } + /** Inject one user message as a turn. Resolves when the app-server acks the * turn start (fast); the turn itself streams to the attached TUI. * `clientUserMessageId` (a stable botmux turn id) is forwarded so codex can @@ -212,6 +291,7 @@ export class CodexRpcEngine { cwd: this.opts.cwd, approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' }, + serviceTier: this.serviceTier ?? null, }; if (clientUserMessageId) params.clientUserMessageId = clientUserMessageId; await this.request('turn/start', params, opts); @@ -298,6 +378,7 @@ export class CodexRpcEngine { cwd: this.opts.cwd, approvalPolicy: 'never', sandboxPolicy: { type: 'dangerFullAccess' }, + serviceTier: this.serviceTier ?? null, }; if (clientUserMessageId) params.clientUserMessageId = clientUserMessageId; try { diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index b22a857b4..dead720be 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -84,10 +84,11 @@ import { readRoleProfileEntry, writeRoleProfileEntry, } from '../services/role-profile-store.js'; -import type { LarkMessage, DaemonToWorker } from '../types.js'; +import type { LarkMessage, DaemonToWorker, FastModeApplyResult } from '../types.js'; import { sessionKey, sessionAnchorId, markRepoCardConsumed, claimCurrentRepoCard } from './types.js'; import type { DaemonSession } from './types.js'; import { t, localeForBot, type Locale } from '../i18n/index.js'; +import { fastModeSessionSupported, parseFastModeAction } from './fast-mode-control.js'; import { runSkillsImCommand } from './skills/im-command.js'; import { fetchDaemonIpc } from './daemon-ipc-auth.js'; import { updateSessionTitle } from './session-title.js'; @@ -132,16 +133,13 @@ export function formatSlashGroupName(name: string, prefix = ''): string { } /** - * Daemon commands that operate on an ALREADY-EXISTING session and must never - * pre-create one. `/rename` renames the current session — with no session there - * is nothing to rename, so the daemon routes must skip their generic - * "createSession + activeSessions.set(worker:null)" pre-create block and let - * handleCommand's `!ds` branch reply no_active_session. Without this, `/rename` - * in a brand-new topic (or a thread with no session) would spawn a phantom - * worker:null session just to rename it, polluting the dashboard. (Same class - * of fix as the `/card` / `/term` special cases in daemon.ts.) + * Commands that normally operate on an ALREADY-EXISTING session and must not + * pass through the generic pre-create path. Without this guard, `/rename …` or + * `/fast status` in a new topic would leave a phantom worker:null session. + * daemon.ts has one deliberate exception: a valid Codex `/fast on` may create + * a session, but only after model-catalog preflight succeeds. */ -export const EXISTING_SESSION_ONLY_DAEMON_COMMANDS = new Set(['/rename']); +export const EXISTING_SESSION_ONLY_DAEMON_COMMANDS = new Set(['/rename', '/fast']); export function resolveAdapterDefaultPassthroughCommands(larkAppId?: string): string[] { if (!larkAppId) return []; @@ -389,6 +387,9 @@ export interface CommandHandlerDeps { lastRepoScan: Map<string, import('../services/project-scanner.js').ProjectInfo[]>; /** 会前预热文档评论会话:立即启动 CLI、读取文档并进入待命。 */ prewarmDocCommentSession?: (ds: DaemonSession, sub: DocSubscription) => Promise<void>; + /** Apply Fast Mode at the real executor boundary. The implementation waits + * for a worker/app-server ACK and returns the concrete model-catalog tier. */ + applyFastMode?: (ds: DaemonSession, enabled: boolean) => Promise<FastModeApplyResult>; } // ─── Schedule command ──────────────────────────────────────────────────────── @@ -1893,6 +1894,11 @@ export async function handleCommand( } case '/fast': { + const action = parseFastModeAction(message.content); + if (action === 'invalid') { + await sessionReply(rootId, t('cmd.fast.usage', undefined, loc)); + break; + } if (!ds) { await sessionReply(rootId, t('cmd.no_active_session', undefined, loc)); break; @@ -1905,42 +1911,56 @@ export async function handleCommand( // Aiden rejects every Codex config override, so Botmux cannot pin the // service tier on cold start and therefore cannot uphold Session-local // semantics. Other known wrappers either rewrite or pass the override. - const isAidenWrapped = wrapperCli?.trim().split(/\s+/)[0] === 'aiden'; - if (sessionCliId !== 'codex' || ds.adoptedFrom || isAidenWrapped) { + if (!fastModeSessionSupported({ + cliId: sessionCliId, + wrapperCli, + adopted: !!ds.adoptedFrom, + })) { await sessionReply(rootId, t('cmd.fast.unsupported', undefined, loc)); break; } - const action = message.content.replace(/^\/fast(?:\s+)?/i, '').trim().toLowerCase(); - if (action && action !== 'on' && action !== 'off' && action !== 'status') { - await sessionReply(rootId, t('cmd.fast.usage', undefined, loc)); - break; - } - const current = ds.session.fastMode === true; if (action === 'status') { await sessionReply(rootId, t(current ? 'cmd.fast.on' : 'cmd.fast.off', undefined, loc)); break; } - const enabled = action === 'on' || (!action && !current); - if (enabled !== current) { + const enabled = action === 'on' || (action === 'toggle' && !current); + // A legacy `fastMode:true` record without a catalog tier is not proof + // that the executor actually applied Fast Mode. Re-apply it through the + // acknowledged path so the persisted state self-heals. + const needsApply = enabled !== current || (enabled && !ds.session.fastServiceTier); + if (needsApply) { + const applied = deps.applyFastMode + ? await deps.applyFastMode(ds, enabled) + : ({ ok: false, reason: 'apply_failed' } as const); + if (!applied.ok || applied.enabled !== enabled) { + const key = !applied.ok && applied.reason === 'unsupported_model' + ? 'cmd.fast.unsupported_model' + : 'cmd.fast.apply_failed'; + await sessionReply(rootId, t(key, undefined, loc)); + break; + } + + // Commit only after the executor ACKs. Keep both persisted Session + // state and the daemon's in-memory restart snapshot coherent. ds.session.fastMode = enabled; - sessionStore.updateSession(ds.session); - if (ds.worker && !ds.worker.killed) { - // Codex 0.145 exposes only a bare native /fast toggle. Botmux owns - // on/off/status semantics and sends the toggle only on a real state - // transition, so repeated `/fast on` is deterministic. - ds.worker.send({ - type: 'raw_input', - content: '/fast', - turnId: message.messageId, - } as DaemonToWorker); + ds.session.fastServiceTier = enabled ? applied.serviceTier : undefined; + if (ds.initConfig) { + ds.initConfig.fastMode = enabled; + ds.initConfig.fastServiceTier = enabled ? applied.serviceTier : undefined; } + sessionStore.updateSession(ds.session); } else if (ds.session.fastMode === undefined) { // Materialize the default on first use so resumes no longer depend on // Codex's process-global persisted service tier. ds.session.fastMode = enabled; + ds.session.fastServiceTier = undefined; + if (ds.initConfig) { + ds.initConfig.fastMode = enabled; + ds.initConfig.fastServiceTier = undefined; + } sessionStore.updateSession(ds.session); } await sessionReply(rootId, t(enabled ? 'cmd.fast.on' : 'cmd.fast.off', undefined, loc)); diff --git a/src/core/fast-mode-control.ts b/src/core/fast-mode-control.ts new file mode 100644 index 000000000..a45960aca --- /dev/null +++ b/src/core/fast-mode-control.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'node:crypto'; +import type { ChildProcess } from 'node:child_process'; +import { CodexRpcEngine } from '../codex-rpc-engine.js'; +import type { CliId } from '../adapters/cli/types.js'; +import type { FastModeApplyResult } from '../types.js'; +import { + cancelFastModeResult, + waitForFastModeResult, +} from './fast-mode-handshake.js'; + +export type FastModeAction = 'toggle' | 'on' | 'off' | 'status' | 'invalid'; + +/** Parse the public `/fast` surface once so daemon routing and the command + * handler cannot disagree about which invocations may create a Session. */ +export function parseFastModeAction(content: string): FastModeAction { + const match = content.trim().match(/^\/fast(?:\s+(.*))?$/i); + if (!match) return 'invalid'; + const action = (match[1] ?? '').trim().toLowerCase(); + if (!action) return 'toggle'; + if (action === 'on' || action === 'off' || action === 'status') return action; + return 'invalid'; +} + +/** Static capability gate. Model-level support is deliberately separate and + * comes from app-server model/list. */ +export function fastModeSessionSupported(input: { + cliId: CliId; + wrapperCli?: string; + adopted?: boolean; +}): boolean { + if (input.cliId !== 'codex' || input.adopted) return false; + return input.wrapperCli?.trim().split(/\s+/)[0] !== 'aiden'; +} + +/** Query the same Codex app-server catalog used by the executor. This is used + * before a cold `/fast on` creates a Session and as a migration fallback for + * old Fast sessions that did not persist a concrete tier id. */ +export async function probeCodexFastServiceTier(input: { + cliBin: string; + cwd: string; + env: NodeJS.ProcessEnv; + model?: string; + log?: (message: string) => void; +}): Promise<FastModeApplyResult> { + const engine = new CodexRpcEngine({ + cliBin: input.cliBin, + cwd: input.cwd, + env: input.env, + model: input.model, + log: input.log, + }); + try { + await engine.start(); + const resolved = await engine.resolveFastServiceTier(input.model); + if (!resolved) return { ok: false, reason: 'unsupported_model' }; + return { + ok: true, + enabled: true, + serviceTier: resolved.serviceTier, + }; + } catch (error) { + return { + ok: false, + reason: 'apply_failed', + message: error instanceof Error ? error.message : String(error), + }; + } finally { + engine.stop(); + } +} + +/** Send a typed state-change request and wait for the executor ACK. Merely + * handing bytes to child_process.send is not success. */ +export async function requestWorkerFastModeChange( + worker: ChildProcess, + enabled: boolean, + timeoutMs = 120_000, +): Promise<FastModeApplyResult> { + if (worker.killed || worker.connected === false) { + return { ok: false, reason: 'not_ready' }; + } + const requestId = randomUUID(); + const result = waitForFastModeResult(requestId, timeoutMs); + try { + worker.send( + { type: 'set_fast_mode', requestId, enabled }, + error => { + if (error) cancelFastModeResult(requestId); + }, + ); + } catch { + cancelFastModeResult(requestId); + } + return result; +} diff --git a/src/core/fast-mode-handshake.ts b/src/core/fast-mode-handshake.ts new file mode 100644 index 000000000..cbdd6acd1 --- /dev/null +++ b/src/core/fast-mode-handshake.ts @@ -0,0 +1,60 @@ +import type { FastModeApplyResult, WorkerToDaemon } from '../types.js'; + +type PendingFastModeResult = { + resolve: (result: FastModeApplyResult) => void; + timer: ReturnType<typeof setTimeout>; +}; + +const pendingResults = new Map<string, PendingFastModeResult>(); + +/** Register before sending set_fast_mode. Timeout is deliberately fail-closed: + * the daemon must never persist or report a state the worker did not ACK. */ +export function waitForFastModeResult( + requestId: string, + timeoutMs: number, +): Promise<FastModeApplyResult> { + return new Promise(resolve => { + const previous = pendingResults.get(requestId); + if (previous) { + clearTimeout(previous.timer); + previous.resolve({ ok: false, reason: 'not_ready' }); + } + const timer = setTimeout(() => { + pendingResults.delete(requestId); + resolve({ ok: false, reason: 'not_ready' }); + }, timeoutMs); + pendingResults.set(requestId, { resolve, timer }); + }); +} + +/** Resolve an exact worker ACK. Unknown, duplicate, and stale ids are harmless. */ +export function acknowledgeFastModeResult( + message: Extract<WorkerToDaemon, { type: 'fast_mode_result' }>, +): boolean { + const pending = pendingResults.get(message.requestId); + if (!pending) return false; + pendingResults.delete(message.requestId); + clearTimeout(pending.timer); + pending.resolve(message.ok + ? { + ok: true, + enabled: message.enabled, + ...(message.serviceTier ? { serviceTier: message.serviceTier } : {}), + } + : { + ok: false, + reason: message.reason, + ...(message.message ? { message: message.message } : {}), + }); + return true; +} + +/** Fail immediately when IPC send itself failed. */ +export function cancelFastModeResult(requestId: string): boolean { + const pending = pendingResults.get(requestId); + if (!pending) return false; + pendingResults.delete(requestId); + clearTimeout(pending.timer); + pending.resolve({ ok: false, reason: 'not_ready' }); + return true; +} diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 1f51930fd..c980f42c4 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -108,6 +108,7 @@ import { extractBotmuxLarkNativeSessionTitlePrompt, } from './session-title.js'; import { acknowledgeSessionReady } from './session-ready-handshake.js'; +import { acknowledgeFastModeResult } from './fast-mode-handshake.js'; import { recordDispatchInputCommit } from './dispatch.js'; type WindowsForkOptions = ForkOptions & { windowsHide?: boolean }; @@ -2253,6 +2254,7 @@ export function forkWorker( // Fast Mode is frozen on the Session rather than inherited from Codex's // CODEX_HOME. Missing means the Session has never opted in → standard. fastMode: agentCfg.cliId === 'codex' ? ds.session.fastMode === true : undefined, + fastServiceTier: agentCfg.cliId === 'codex' ? ds.session.fastServiceTier : undefined, disableCliBypass: botCfg.disableCliBypass === true, codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault, // Startup commands run on every fresh spawn (incl. resume) so session-only @@ -2579,6 +2581,28 @@ function setupWorkerHandlers( acknowledgeSessionReady(msg.requestId); break; } + case 'fast_mode_result': { + if (ds.worker !== worker) { + logger.warn(`[${t}] Ignored fast_mode_result from stale worker generation`); + break; + } + acknowledgeFastModeResult(msg); + break; + } + case 'fast_mode_state': { + if (ds.worker !== worker) { + logger.warn(`[${t}] Ignored fast_mode_state from stale worker generation`); + break; + } + ds.session.fastMode = msg.enabled; + ds.session.fastServiceTier = msg.serviceTier; + if (ds.initConfig) { + ds.initConfig.fastMode = msg.enabled; + ds.initConfig.fastServiceTier = msg.serviceTier; + } + sessionStore.updateSession(ds.session); + break; + } case 'local_process_attestation': { // This message arrives over the private parent<->worker IPC channel; // unlike .botmux-cli-pids it cannot be forged or deleted by the CLI. diff --git a/src/daemon.ts b/src/daemon.ts index 4b3d9835f..88209b692 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { readFileSync, existsSync, mkdirSync, unlinkSync, watch, readdirSync } from 'node:fs'; import { atomicWriteFileSync } from './utils/atomic-write.js'; import { readAllowedUsersResolveCache, writeAllowedUsersResolveCache } from './utils/allowed-users-cache.js'; -import { join, dirname } from 'node:path'; +import { join, dirname, delimiter } from 'node:path'; import { homedir, loadavg, cpus, totalmem, freemem } from 'node:os'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { fileURLToPath } from 'node:url'; @@ -105,6 +105,15 @@ import { isLocalCliOpenReady } from './services/local-cli-opener.js'; import { RECEIVED_REACTION_EMOJI_TYPE, SUBSTITUTE_RECEIVED_REACTION_EMOJI_TYPE } from './core/pending-response.js'; import { t as tr, botLocale, localeForBot } from './i18n/index.js'; import { createCliAdapterSync } from './adapters/cli/registry.js'; +import { sanitizePerBotEnv } from './core/per-bot-env.js'; +import { redactChildEnv } from './utils/child-env.js'; +import { + fastModeSessionSupported, + parseFastModeAction, + probeCodexFastServiceTier, + requestWorkerFastModeChange, +} from './core/fast-mode-control.js'; +import type { FastModeApplyResult } from './types.js'; import { initWorkerPool, setActiveSessionsRegistry, @@ -3762,6 +3771,90 @@ async function prewarmDocCommentSession(ds: DaemonSession, sub: DocSubscription) logger.info(`[${tag(ds)}] doc-comment watch prewarm injected file=${sub.fileToken.slice(0, 12)}`); } +function fastModeTargetConfig(larkAppId: string, ds?: DaemonSession): { + cliId: CliId; + cliPathOverride?: string; + wrapperCli?: string; + model?: string; + env?: Record<string, string | number | boolean>; +} { + const botCfg = getBot(larkAppId).config; + const frozen = ds?.session.agentFrozen === true; + return { + cliId: ds?.session.cliId ?? botCfg.cliId, + cliPathOverride: frozen + ? ds?.session.cliPathOverride + : (ds?.session.cliPathOverride ?? botCfg.cliPathOverride), + wrapperCli: frozen + ? ds?.session.wrapperCli + : (ds?.session.wrapperCli ?? botCfg.wrapperCli), + model: frozen + ? ds?.session.model + : (ds?.session.model ?? botCfg.model), + env: botCfg.env, + }; +} + +async function probeFastModeForTarget( + larkAppId: string, + workingDir: string, + ds?: DaemonSession, +): Promise<FastModeApplyResult> { + const target = fastModeTargetConfig(larkAppId, ds); + if (!fastModeSessionSupported({ + cliId: target.cliId, + wrapperCli: target.wrapperCli, + adopted: !!ds?.adoptedFrom, + })) { + return { ok: false, reason: 'unsupported_session' }; + } + const env: NodeJS.ProcessEnv = { + ...redactChildEnv(process.env), + ...sanitizePerBotEnv(target.env), + }; + env.PATH = `${join(homedir(), '.botmux', 'bin')}${delimiter}${env.PATH ?? ''}`; + const cliBin = createCliAdapterSync(target.cliId, target.cliPathOverride).resolvedBin; + return probeCodexFastServiceTier({ + cliBin, + cwd: workingDir, + env, + model: target.model, + log: message => logger.info(`[fast-probe:${larkAppId}] ${message}`), + }); +} + +async function applyFastModeForSession( + ds: DaemonSession, + enabled: boolean, +): Promise<FastModeApplyResult> { + const target = fastModeTargetConfig(ds.larkAppId, ds); + if (!fastModeSessionSupported({ + cliId: target.cliId, + wrapperCli: target.wrapperCli, + adopted: !!ds.adoptedFrom, + })) { + return { ok: false, reason: 'unsupported_session' }; + } + if (ds.worker && !ds.worker.killed) { + return requestWorkerFastModeChange(ds.worker, enabled); + } + if (!enabled) return { ok: true, enabled: false }; + if (ds.session.fastServiceTier) { + return { + ok: true, + enabled: true, + serviceTier: ds.session.fastServiceTier, + }; + } + return probeFastModeForTarget(ds.larkAppId, getSessionWorkingDir(ds), ds); +} + +function fastModeFailureReplyKey(result: FastModeApplyResult): string { + if (!result.ok && result.reason === 'unsupported_session') return 'cmd.fast.unsupported'; + if (!result.ok && result.reason === 'unsupported_model') return 'cmd.fast.unsupported_model'; + return 'cmd.fast.apply_failed'; +} + // Dependencies passed to command-handler const commandDeps: CommandHandlerDeps = { activeSessions, @@ -3769,6 +3862,7 @@ const commandDeps: CommandHandlerDeps = { getActiveCount, lastRepoScan, prewarmDocCommentSession, + applyFastMode: applyFastModeForSession, }; /** @@ -15228,6 +15322,46 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { fireSessionlessCommandDetached(cmd, anchor, { ...parsed, content: commandContent, chatId }, larkAppId); return; } + let coldFastServiceTier: string | undefined; + let coldFastWorkingDir: string | undefined; + if (cmd === '/fast') { + const action = parseFastModeAction(commandContent); + // Only a valid enable request may open a Session. Status/off/invalid + // are routed session-less so they can report their result without + // polluting the dashboard. + if (action !== 'toggle' && action !== 'on') { + await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId); + return; + } + const target = fastModeTargetConfig(larkAppId); + if (!fastModeSessionSupported({ + cliId: target.cliId, + wrapperCli: target.wrapperCli, + })) { + await sessionReply(anchor, tr('cmd.fast.unsupported', undefined, localeForBot(larkAppId)), 'text', larkAppId); + return; + } + const { pinnedWorkingDir } = await resolvePinnedWorkingDir({ + scope, + anchor, + chatId, + chatType, + larkAppId, + }); + coldFastWorkingDir = pinnedWorkingDir + ?? expandHome(effectiveDefaultWorkingDir(botCfg) ?? botCfg.workingDir ?? config.daemon.workingDir); + const preflight = await probeFastModeForTarget(larkAppId, coldFastWorkingDir); + if (!preflight.ok || !preflight.serviceTier) { + await sessionReply( + anchor, + tr(fastModeFailureReplyKey(preflight), undefined, localeForBot(larkAppId)), + 'text', + larkAppId, + ); + return; + } + coldFastServiceTier = preflight.serviceTier; + } // `/rename` renames an EXISTING session; a brand-new topic has none. Route // straight to handleCommand (its `!ds` branch replies no_active_session) // so the pre-create block below doesn't spawn a worker:null phantom @@ -15235,7 +15369,7 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { // and /term special cases, but UNLIKE those (which carry their own // permission gates inside their handlers) this branch MUST stay after // the canOperate gate above — the /rename handler itself has no gate. - if (EXISTING_SESSION_ONLY_DAEMON_COMMANDS.has(cmd)) { + if (EXISTING_SESSION_ONLY_DAEMON_COMMANDS.has(cmd) && cmd !== '/fast') { await handleCommand(cmd, anchor, { ...parsed, content: commandContent }, commandDeps, larkAppId); return; } @@ -15259,6 +15393,10 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { session.lastCallerOpenId = senderOpenId; session.lastMessageAt = new Date(now).toISOString(); session.scope = scope; + if (coldFastServiceTier) { + session.fastServiceTier = coldFastServiceTier; + session.workingDir = coldFastWorkingDir; + } // First-message `/repo`: seed the same pending-repo state the card flow // uses, so the `/repo` handler launches the CLI straight away — @@ -15290,6 +15428,7 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { lastMessageAt: now, hasHistory: false, ownerOpenId: senderOpenId, + ...(coldFastWorkingDir ? { workingDir: coldFastWorkingDir } : {}), ...cmdPending, }); // Pass mention-stripped content so /command argument parsing works. @@ -16042,6 +16181,50 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> sessionReply(anchor, tr('daemon.cmd_allowed_users_only', { cmd }, localeForBot(larkAppId)), 'text', larkAppId); return; } + let coldFastServiceTier: string | undefined; + let coldFastWorkingDir: string | undefined; + if (!existingDs && cmd === '/fast') { + const action = parseFastModeAction(commandContent); + if (action !== 'toggle' && action !== 'on') { + await handleCommand( + cmd, + anchor, + { ...parsed, content: commandContent, chatId: threadChatId }, + commandDeps, + larkAppId, + ); + return; + } + const target = fastModeTargetConfig(larkAppId); + if (!fastModeSessionSupported({ + cliId: target.cliId, + wrapperCli: target.wrapperCli, + })) { + await sessionReply(anchor, tr('cmd.fast.unsupported', undefined, localeForBot(larkAppId)), 'text', larkAppId); + return; + } + const { pinnedWorkingDir } = await resolvePinnedWorkingDir({ + scope, + anchor, + chatId: threadChatId, + chatType: ctxChatType, + larkAppId, + }); + const fastBotCfg = getBot(larkAppId).config; + coldFastWorkingDir = pinnedWorkingDir + ?? expandHome(effectiveDefaultWorkingDir(fastBotCfg) ?? fastBotCfg.workingDir ?? config.daemon.workingDir); + const preflight = await probeFastModeForTarget(larkAppId, coldFastWorkingDir); + if (!preflight.ok || !preflight.serviceTier) { + await sessionReply( + anchor, + tr(fastModeFailureReplyKey(preflight), undefined, localeForBot(larkAppId)), + 'text', + larkAppId, + ); + return; + } + coldFastServiceTier = preflight.serviceTier; + } // First message of a fresh thread carrying a session-needing daemon command // — e.g. another bot dispatched `/repo <path>` into a brand-new thread. // Without a session, handleCommand gets ds=undefined and `/repo` (and other @@ -16052,7 +16235,7 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> // would be a phantom conversation that only exists to be renamed. Let // handleCommand's `!ds` branch reply no_active_session instead. if (!existingDs && threadChatId && !isSessionlessCommandInvocation(cmd, commandContent) - && !EXISTING_SESSION_ONLY_DAEMON_COMMANDS.has(cmd)) { + && (!EXISTING_SESSION_ONLY_DAEMON_COMMANDS.has(cmd) || cmd === '/fast')) { const session = sessionStore.createSession(threadChatId, anchor, cmdContent.substring(0, 50), ctxChatType); const now = Date.now(); if (ctxChatType === 'p2p') { @@ -16069,6 +16252,10 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> session.lastCallerOpenId = threadSenderOpenId; session.lastMessageAt = new Date(now).toISOString(); session.scope = scope; + if (coldFastServiceTier) { + session.fastServiceTier = coldFastServiceTier; + session.workingDir = coldFastWorkingDir; + } let cmdPending: Partial<DaemonSession> | undefined; if (cmd === '/repo') { const { pinnedWorkingDir } = await resolvePinnedWorkingDir({ scope, anchor, chatId: threadChatId, chatType: ctxChatType, larkAppId }); @@ -16090,6 +16277,7 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> lastMessageAt: now, hasHistory: false, ownerOpenId: threadSenderOpenId, + ...(coldFastWorkingDir ? { workingDir: coldFastWorkingDir } : {}), ...cmdPending, }); } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index cdf0701f0..eca0f3856 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -272,6 +272,8 @@ export const messages: Record<string, string> = { 'cmd.fast.off': '⚪ Fast Mode is OFF for this Session only.', 'cmd.fast.usage': 'Usage: /fast (toggle) | /fast on | /fast off | /fast status', 'cmd.fast.unsupported': '⚠️ /fast requires a Botmux-managed Codex Session whose service tier can be pinned (Aiden gateways are not supported).', + 'cmd.fast.unsupported_model': '⚠️ The current model does not support Fast Mode. State was not changed.', + 'cmd.fast.apply_failed': '⚠️ Codex did not confirm the Fast Mode change. State was not changed; please retry.', 'cmd.login.no_credentials': '❌ Cannot read app credentials.', 'cmd.login.title': '🔐 Lark User OAuth', 'cmd.login.step1': '1. Click the link below to authorize:', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 6b9413e81..0f43f4230 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -275,6 +275,8 @@ export const messages: Record<string, string> = { 'cmd.fast.off': '⚪ Fast Mode 已关闭(仅当前话题 Session)。', 'cmd.fast.usage': '用法:/fast(切换)| /fast on | /fast off | /fast status', 'cmd.fast.unsupported': '⚠️ /fast 仅支持由 Botmux 管理且能固定服务等级的 Codex Session(Aiden 网关暂不支持)。', + 'cmd.fast.unsupported_model': '⚠️ 当前模型不支持 Fast Mode,状态未修改。', + 'cmd.fast.apply_failed': '⚠️ Fast Mode 未能在 Codex 执行端确认生效,状态未修改;请稍后重试。', 'cmd.login.no_credentials': '❌ 无法获取应用凭证', 'cmd.login.title': '🔐 飞书用户授权', 'cmd.login.step1': '1. 点击下方链接完成授权:', diff --git a/src/setup/cli-selection.ts b/src/setup/cli-selection.ts index 8a3171988..ffaeac27e 100644 --- a/src/setup/cli-selection.ts +++ b/src/setup/cli-selection.ts @@ -250,13 +250,20 @@ function isCjadkWrapper(tokens: ReadonlyArray<string>): boolean { } /** 是否为 botmux 自己给 Codex 注入的 config 覆盖。精确白名单避免误伤用户自带的 - * `-c key=val`;wrapper 层据此决定剥离(aiden)或改成长参数(cjadk)。 */ -function isBotmuxCodexConfigValue(value: string | undefined): boolean { + * `-c key=val`;wrapper 层据此决定剥离(aiden)或改成长参数(cjadk)。 + * + * Fast tier id 来自 Codex model catalog,不能静态枚举;调用方必须把本次实际注入的 + * tier 显式传入,不能按 `service_tier=*` 泛匹配,否则会误伤用户自己的 config。 */ +function isBotmuxCodexConfigValue(value: string | undefined, codexServiceTier?: string): boolean { + const injectedServiceTier = codexServiceTier + ? `service_tier=${JSON.stringify(codexServiceTier)}` + : undefined; return !!value && ( value.startsWith('shell_environment_policy.set.BOTMUX_') || value === 'check_for_update_on_startup=false' || value === 'service_tier="default"' || value === 'service_tier="fast"' + || value === injectedServiceTier ); } @@ -296,13 +303,16 @@ export function stripSettingsArgs(args: ReadonlyArray<string>): string[] { * 注意范围仅限上述两种 botmux 注入形态:coco 的 `--config model.name=…`(承载 model、非 session * 管线,且无内置 `aiden x coco` 选项)等不同形态的 config 覆盖**有意不在此剥离**。 */ -export function stripWrapperUnsafeArgs(args: ReadonlyArray<string>): string[] { +export function stripWrapperUnsafeArgs( + args: ReadonlyArray<string>, + codexServiceTier?: string, +): string[] { // 先剥 claude 的 --settings(单一事实源 stripSettingsArgs),再剥 codex 注入的 -c override。 const afterSettings = stripSettingsArgs(args); const out: string[] = []; for (let i = 0; i < afterSettings.length; i++) { const a = afterSettings[i]!; - if (a === '-c' && isBotmuxCodexConfigValue(afterSettings[i + 1])) { i++; continue; } + if (a === '-c' && isBotmuxCodexConfigValue(afterSettings[i + 1], codexServiceTier)) { i++; continue; } out.push(a); } return out; @@ -326,11 +336,14 @@ export function stripWrapperUnsafeArgs(args: ReadonlyArray<string>): string[] { * 非 codex 的 cjadk 形态(cjadk claude 带 `--settings`、不带这些值)经此函数是 no-op, * `--settings` 照常保留透传。 */ -export function rewriteCjadkCodexConfigArgs(args: ReadonlyArray<string>): string[] { +export function rewriteCjadkCodexConfigArgs( + args: ReadonlyArray<string>, + codexServiceTier?: string, +): string[] { const out: string[] = []; for (let i = 0; i < args.length; i++) { const a = args[i]!; - if (a === '-c' && isBotmuxCodexConfigValue(args[i + 1])) { + if (a === '-c' && isBotmuxCodexConfigValue(args[i + 1], codexServiceTier)) { out.push('--config', args[i + 1]!); i++; continue; @@ -359,6 +372,11 @@ export interface WrappedLaunchOptions { * 用 {@link TTADK_DEFAULT_MODEL} 兜底;不接受 -m 的子命令(CoCo)忽略此项。 */ readonly ttadkModel?: string; + /** + * Botmux 本次给 Codex 注入的 service tier。只用于精确识别该一条 config, + * 以便 aiden 剥离或 cjadk 改写,不会匹配用户自带的其他 service_tier。 + */ + readonly codexServiceTier?: string; } /** @@ -424,8 +442,8 @@ export function buildWrappedLaunch( if (tokens.length === 0) return { bin: '', args: [...cliArgs] }; if (tokens[0] === 'ttadk') return buildTtadkLaunch(tokens, cliArgs, binResolver, opts.ttadkModel); let forwarded: string[]; - if (isAidenWrapper(tokens)) forwarded = stripWrapperUnsafeArgs(cliArgs); - else if (isCjadkWrapper(tokens)) forwarded = rewriteCjadkCodexConfigArgs(cliArgs); + if (isAidenWrapper(tokens)) forwarded = stripWrapperUnsafeArgs(cliArgs, opts.codexServiceTier); + else if (isCjadkWrapper(tokens)) forwarded = rewriteCjadkCodexConfigArgs(cliArgs, opts.codexServiceTier); else forwarded = [...cliArgs]; return { bin: binResolver(tokens[0]), args: [...tokens.slice(1), ...forwarded] }; } diff --git a/src/types.ts b/src/types.ts index 9be1675ae..1221522f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -337,6 +337,10 @@ export interface Session { /** Session-scoped Codex Fast Mode. Missing/false means standard service tier; * true opts only this Session into the faster tier. */ fastMode?: boolean; + /** Concrete app-server service-tier id resolved from the selected model's + * catalog (for example `priority`). Persisting the id keeps cold starts, + * restarts, and the native TUI on the same acknowledged setting. */ + fastServiceTier?: string; /** * True once `cliId`/`cliPathOverride`/`wrapperCli`/`model` have been frozen for * this session (see `sessionAgentConfig`). Gates the one-time freeze so it runs @@ -582,9 +586,19 @@ export interface CliTurnPayload { codexAppInput?: CodexAppTurnInput; } +export type FastModeFailureReason = + | 'unsupported_session' + | 'unsupported_model' + | 'not_ready' + | 'apply_failed'; + +export type FastModeApplyResult = + | { ok: true; enabled: boolean; serviceTier?: string } + | { ok: false; reason: FastModeFailureReason; message?: string }; + /** Messages sent from Daemon to Worker */ export type DaemonToWorker = - | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; fastMode?: boolean; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record<string, string>; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } + | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; fastMode?: boolean; fastServiceTier?: string; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record<string, string>; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } | { type: 'message'; content: string; codexAppInput?: CodexAppTurnInput; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin } /** Literal slash-command passthrough. `followUpContent` rides along so the * worker enqueues it strictly AFTER the slash command's Enter — two separate @@ -592,6 +606,10 @@ export type DaemonToWorker = * raw_input branch awaits 200ms between sendText and Enter, a window where * a separate `message` IPC could write into the PTY first. */ | { type: 'raw_input'; content: string; turnId?: string; followUpContent?: string; followUpTurnId?: string; followUpCodexAppInput?: CodexAppTurnInput } + /** Apply Session-scoped Codex Fast Mode and ACK only after the executor has + * accepted it. Unlike raw_input this is queued across startup/restart gates + * and updates the worker's restart config before success is reported. */ + | { type: 'set_fast_mode'; requestId: string; enabled: boolean } /** Rename the current CLI-native interactive session. The worker queues this * administrative slash command until the TUI is idle and does not treat it * as a model turn. Only adapters declaring buildSessionRenameCommand handle @@ -661,6 +679,11 @@ export type WorkerToDaemon = /** Worker 已处理 SessionStart 信号并建立 post-hook prompt evidence fence。 * daemon 收到后才结束 `botmux session-ready` HTTP 请求。 */ | { type: 'session_ready_ack'; requestId: string } + | { type: 'fast_mode_result'; requestId: string; ok: true; enabled: boolean; serviceTier?: string } + | { type: 'fast_mode_result'; requestId: string; ok: false; reason: FastModeFailureReason; message?: string } + /** Worker resolved a legacy/cold Fast Session's concrete tier before spawn. + * The daemon persists it so later worker generations need no migration probe. */ + | { type: 'fast_mode_state'; enabled: boolean; serviceTier?: string } | { type: 'screen_update'; content: string; status: ScreenStatus; usageLimit?: CliUsageLimitState; turnId?: string; dispatchAttempt?: number } | { type: 'error'; message: string; turnId?: string; dispatchAttempt?: number } | { type: 'bridge_source_session'; bridge: 'hermes'; sourceSessionId: string } diff --git a/src/worker.ts b/src/worker.ts index 529ee7afe..4f28e039b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -251,6 +251,10 @@ import { replaceManagedOriginCapabilityFile, } from './core/managed-origin-capability.js'; import { CodexRpcEngine } from './codex-rpc-engine.js'; +import { + fastModeSessionSupported, + probeCodexFastServiceTier, +} from './core/fast-mode-control.js'; // A worker must never trust an INHERITED session-level CLI home pointer // (CLAUDE_CONFIG_DIR / CODEX_HOME): a stale pm2 dump can resurrect the daemon @@ -385,6 +389,45 @@ function codexNativeTitleEnv(cfg: Extract<DaemonToWorker, { type: 'init' }>): No return env; } +async function resolveWorkerFastServiceTier( + cfg: Extract<DaemonToWorker, { type: 'init' }>, +): Promise<Awaited<ReturnType<typeof probeCodexFastServiceTier>>> { + if (!fastModeSessionSupported({ + cliId: cfg.cliId as CliId, + wrapperCli: cfg.wrapperCli, + adopted: cfg.adoptMode, + })) { + return { ok: false, reason: 'unsupported_session' }; + } + const cliBin = createCliAdapterSync(cfg.cliId as CliId, cfg.cliPathOverride).resolvedBin; + return probeCodexFastServiceTier({ + cliBin, + cwd: cfg.workingDir, + env: codexNativeTitleEnv(cfg), + model: cfg.model, + log: message => log(`[fast-probe] ${message}`), + }); +} + +/** Migrate pre-catalog Fast sessions before buildArgs. Failing closed here is + * preferable to launching a process that silently executes at default tier + * while the persisted Session says ON. */ +async function ensureConfiguredFastServiceTier( + cfg: Extract<DaemonToWorker, { type: 'init' }>, +): Promise<void> { + if (cfg.fastMode !== true || cfg.fastServiceTier) return; + const resolved = await resolveWorkerFastServiceTier(cfg); + if (!resolved.ok || !resolved.serviceTier) { + const detail = !resolved.ok && resolved.message ? `: ${resolved.message}` : ''; + throw new Error( + resolved.ok || resolved.reason === 'unsupported_model' + ? `Fast Mode is not supported by model ${cfg.model ?? '(default)'}` + : `Fast Mode capability check failed${detail}`, + ); + } + cfg.fastServiceTier = resolved.serviceTier; +} + function registerNativeTitleForceClose(forceClose: () => void): () => void { nativeSessionTitleSyncForceClosers.add(forceClose); return () => nativeSessionTitleSyncForceClosers.delete(forceClose); @@ -689,7 +732,7 @@ async function engageCodexRpc(cfg: Extract<DaemonToWorker, { type: 'init' }>): P Object.assign(engineEnv, sanitizePerBotEnv(cfg.env)); engine = new CodexRpcEngine({ cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId, - model: cfg.model, log: (m: string) => log(m), + model: cfg.model, fastMode: cfg.fastMode === true, log: (m: string) => log(m), appServerFeatures: cfg.cliId === 'traex' ? ['default_mode_request_user_input'] : undefined, onRequestUserInput: cfg.cliId === 'traex' ? (params: unknown) => bridgeTraexUserInput(cfg, params) @@ -716,6 +759,7 @@ async function engageCodexRpc(cfg: Extract<DaemonToWorker, { type: 'init' }>): P // per-turn identity instead of falling back to a stale/session-only env. registerRpcEnginePidMarker(engine.appServerPid); const threadId = wantResume ? await engine.resumeThread(cfg.cliSessionId!) : await engine.startThread(); + cfg.fastServiceTier = engine.activeServiceTier; let outcome: EngageOutcome = wantResume ? 'resumed' : 'accepted'; if (!wantResume && cfg.prompt) { // Three-state delivery (P1-1, exactly-once priority): 'accepted' (ack or @@ -1470,6 +1514,16 @@ const pendingMessages: PendingCliInput[] = []; * an owned CLI restart was fenced. Normal raw_input commands are still * delivered immediately (including while busy). */ const pendingRawInputs: Array<Extract<DaemonToWorker, { type: 'raw_input' }>> = []; +/** Fast Mode is a Session configuration barrier, not ordinary terminal input. + * Requests survive startup/restart gates and run before later user messages. */ +const pendingFastModeChanges: Array<Extract<DaemonToWorker, { type: 'set_fast_mode' }>> = []; +let fastModeChangeInFlight = false; +let pendingRestartFastModeAck: { + request: Extract<DaemonToWorker, { type: 'set_fast_mode' }>; + serviceTier?: string; + previousFastMode: boolean; + previousServiceTier?: string; +} | null = null; /** Latest requested canonical session title. Unlike a normal prompt this is an * administrative TUI command: never type-ahead while the agent is busy, never * open a model turn, and latest-wins if several renames arrive before idle. */ @@ -5038,6 +5092,141 @@ function markPromptReadyFromPty(): void { } } +function commitFastModeChange( + request: Extract<DaemonToWorker, { type: 'set_fast_mode' }>, + serviceTier?: string, +): void { + if (!lastInitConfig) return; + lastInitConfig.fastMode = request.enabled; + lastInitConfig.fastServiceTier = request.enabled ? serviceTier : undefined; + // Publish the accepted executor state before resolving the command waiter. + // IPC preserves ordering, so persistence happens before the success reply; + // a late ACK after the daemon-side wait deadline still cannot leave the + // worker and Session store split-brained. + send({ + type: 'fast_mode_state', + enabled: request.enabled, + ...(request.enabled && serviceTier ? { serviceTier } : {}), + }); + send({ + type: 'fast_mode_result', + requestId: request.requestId, + ok: true, + enabled: request.enabled, + ...(request.enabled && serviceTier ? { serviceTier } : {}), + }); +} + +function rejectFastModeChange( + request: Extract<DaemonToWorker, { type: 'set_fast_mode' }>, + reason: 'unsupported_session' | 'unsupported_model' | 'not_ready' | 'apply_failed', + message?: string, +): void { + send({ + type: 'fast_mode_result', + requestId: request.requestId, + ok: false, + reason, + ...(message ? { message } : {}), + }); +} + +function continueAfterFastModeChange(): void { + queueMicrotask(() => { + if (pendingFastModeChanges.length > 0) void flushPendingFastModeChanges(); + else void flushPending(); + }); +} + +/** Apply one Fast Mode request at an idle prompt. RPC mode uses the protocol's + * acknowledged settings update. Native mode restarts/resumes with an explicit + * catalog tier instead of injecting the toggle-only `/fast` command; this makes + * on/off deterministic and gives the replacement prompt a real ACK boundary. */ +async function flushPendingFastModeChanges(): Promise<void> { + if (fastModeChangeInFlight || isFlushing || cliRestartInProgress) return; + if (!backend || !cliAdapter || !isPromptReady) return; + if (commandLineWritesPending > 0 || injectionFlushing || sessionRenameInFlight) return; + const request = pendingFastModeChanges.shift(); + if (!request) return; + fastModeChangeInFlight = true; + + if (!lastInitConfig || !fastModeSessionSupported({ + cliId: lastInitConfig.cliId as CliId, + wrapperCli: lastInitConfig.wrapperCli, + adopted: lastInitConfig.adoptMode, + })) { + rejectFastModeChange(request, 'unsupported_session'); + fastModeChangeInFlight = false; + continueAfterFastModeChange(); + return; + } + + try { + if (codexRpcEngine) { + const applied = await codexRpcEngine.setFastMode(request.enabled); + commitFastModeChange(request, applied.serviceTier); + fastModeChangeInFlight = false; + continueAfterFastModeChange(); + return; + } + + let serviceTier: string | undefined; + if (request.enabled) { + const resolved = await resolveWorkerFastServiceTier(lastInitConfig); + if (!resolved.ok || !resolved.serviceTier) { + rejectFastModeChange( + request, + !resolved.ok ? resolved.reason : 'unsupported_model', + !resolved.ok ? resolved.message : undefined, + ); + fastModeChangeInFlight = false; + continueAfterFastModeChange(); + return; + } + serviceTier = resolved.serviceTier; + } + + // The launch snapshot is authoritative for this worker generation. An + // idempotent request needs no native toggle; the resolved tier still heals + // a legacy record before ACK. + if (lastInitConfig.fastMode === request.enabled) { + commitFastModeChange(request, serviceTier ?? lastInitConfig.fastServiceTier); + fastModeChangeInFlight = false; + continueAfterFastModeChange(); + return; + } + + pendingRestartFastModeAck = { + request, + serviceTier, + previousFastMode: lastInitConfig.fastMode === true, + previousServiceTier: lastInitConfig.fastServiceTier, + }; + // Stage the replacement config before teardown. The daemon does not persist + // it until the replacement prompt ACK below. + lastInitConfig.fastMode = request.enabled; + lastInitConfig.fastServiceTier = request.enabled ? serviceTier : undefined; + await restartCliProcess( + `Fast Mode ${request.enabled ? 'enable' : 'disable'} reconciliation`, + { immediate: true, preservePending: true, skipRestartBudget: true }, + ); + log(`Fast Mode replacement launched; waiting for prompt ACK (${request.enabled ? 'on' : 'off'})`); + } catch (error) { + if (pendingRestartFastModeAck && lastInitConfig) { + lastInitConfig.fastMode = pendingRestartFastModeAck.previousFastMode; + lastInitConfig.fastServiceTier = pendingRestartFastModeAck.previousServiceTier; + } + pendingRestartFastModeAck = null; + rejectFastModeChange( + request, + 'apply_failed', + error instanceof Error ? error.message : String(error), + ); + fastModeChangeInFlight = false; + continueAfterFastModeChange(); + } +} + function markPromptReady(): void { if (isPromptReady) return; // guard against duplicate calls stopBusyPatternIdleProbe(); @@ -5070,6 +5259,15 @@ function markPromptReady(): void { return; } isPromptReady = true; + // A non-RPC Fast change is committed only after its replacement CLI has + // resumed with the explicit tier and reached a real prompt. + if (pendingRestartFastModeAck) { + const pending = pendingRestartFastModeAck; + pendingRestartFastModeAck = null; + commitFastModeChange(pending.request, pending.serviceTier); + fastModeChangeInFlight = false; + log(`Fast Mode replacement confirmed at prompt (${pending.request.enabled ? 'on' : 'off'})`); + } clearSessionRenameInFlight(); // An old backend can still report idle while its async teardown is running. // Only a prompt observed after the general restart fence drops may release @@ -5103,7 +5301,9 @@ function markPromptReady(): void { // make the CLI busy, so the idle state is transient and shouldn't appear // in the card. This avoids a false "就绪" flash on daemon restart // (where the initial prompt is queued before the CLI becomes idle). - if (renderer && pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null && !isFlushing) { + if (renderer && pendingMessages.length === 0 && pendingRawInputs.length === 0 + && pendingFastModeChanges.length === 0 && !fastModeChangeInFlight + && pendingSessionRename === null && !isFlushing) { const { content } = renderer.snapshot(); send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle'), turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt }); } @@ -5112,7 +5312,9 @@ function markPromptReady(): void { // flushPending 不会饿死用户消息:flushPending 自身的 injectionFlushing 守卫 // 会挡住并发写入,flushPendingInjections 的 finally 会在注入完成、CLI 重新 // idle 后补踢一次 flushPending 排空 pendingMessages。 - if (shouldFlushInjectionsFirst(pendingInjections)) { + if (pendingFastModeChanges.length > 0) { + void flushPendingFastModeChanges(); + } else if (shouldFlushInjectionsFirst(pendingInjections)) { void flushPendingInjections(); } else { flushPending(); @@ -5418,6 +5620,12 @@ async function flushPending(): Promise<void> { // backend's idle/task-done callback) write across that restart boundary. if (cliRestartInProgress) return; if (isFlushing) return; // while loop in active flush will pick up new messages + // A Fast Mode request is a configuration barrier for every later user turn. + // Its own queue owns delivery until the executor ACKs it. + if (pendingFastModeChanges.length > 0 || fastModeChangeInFlight) { + void flushPendingFastModeChanges(); + return; + } if (!backend || !cliAdapter) return; if (pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null) return; // nothing to flush — keep isPromptReady if (sessionRenameInFlight) return; // wait for /rename to finish before any user input @@ -6948,9 +7156,12 @@ async function spawnCli( locale: cfg.locale, model: ttadkGateway ? undefined : cfg.model, fastMode: cfg.fastMode === true, + fastServiceTier: cfg.fastServiceTier, disableCliBypass: cfg.disableCliBypass === true, skillPluginDir: cfg.skillPluginDir, readIsolation: willRedirectCliData, + remoteWsUrl, + remoteThreadId, }); // Pi's deferred long-first-prompt command is implemented by a session-scoped // extension. Keep its launch args across owned process restarts while the @@ -7487,6 +7698,9 @@ async function spawnCli( } else { const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnPath(b) ?? b, { ttadkModel: cfg.model, + codexServiceTier: cfg.cliId === 'codex' + ? (cfg.fastMode === true ? cfg.fastServiceTier : 'default') + : undefined, }); if (launch.bin) { spawnBin = launch.bin; @@ -7677,6 +7891,9 @@ async function spawnCli( sandboxOn: sandboxRequested, binResolver: (b) => locateOnPath(b) ?? b, ttadkModel: cfg.model, + codexServiceTier: cfg.cliId === 'codex' + ? (cfg.fastMode === true ? cfg.fastServiceTier : 'default') + : undefined, }); capturedSpawnCommand = buildReproduceCommand({ backendType: effectiveBackendType, @@ -9732,6 +9949,17 @@ process.on('message', async (raw: unknown) => { // init so the daemon retries a clean incarnation instead of freezing. throw new Error('codex RPC resume: could not replace stale --remote pane; aborting init'); } + // RPC engage resolves the tier itself; native/migration paths resolve + // it here before buildArgs so a persisted Fast Session can never launch + // at default tier. + await ensureConfiguredFastServiceTier(msg); + if (msg.fastMode === true && msg.fastServiceTier) { + send({ + type: 'fast_mode_state', + enabled: true, + serviceTier: msg.fastServiceTier, + }); + } await spawnCli(msg, { pluginGenerationPrepared: rpcPluginGenerationPrepared }); await prepareCodexNativeTitleGeneration(msg, codexRpcEngine); if (codexRpcEngine) armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog @@ -9981,6 +10209,15 @@ process.on('message', async (raw: unknown) => { break; } + case 'set_fast_mode': { + // Queue even if init/backend is still starting. The prompt-gated flush + // applies this before any later user input and returns an explicit ACK. + pendingFastModeChanges.push(msg); + log(`Queued Fast Mode change (${msg.enabled ? 'on' : 'off'}, request=${msg.requestId.slice(0, 8)})`); + void flushPendingFastModeChanges(); + break; + } + case 'raw_input': { // Preserve legacy busy delivery (/btw and other steering commands). A // native /rename and an owned CLI restart are the exceptions: never splice @@ -10000,7 +10237,8 @@ process.on('message', async (raw: unknown) => { // 这些 pending 不会再被 flush(与 pendingMessages 同款处理),符合预期。 if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight || injectionFlushing || shouldDeferUserFlush(pendingInjections) - || bareShellCheckInProgress || bareShellLaunchBlocked) { + || bareShellCheckInProgress || bareShellLaunchBlocked + || pendingFastModeChanges.length > 0 || fastModeChangeInFlight) { pendingRawInputs.push(msg); log(`Deferred passthrough slash command until CLI input gate settles: ${msg.content}`); } else { diff --git a/test/cli-adapters.test.ts b/test/cli-adapters.test.ts index ea597970c..7cf2ead3c 100644 --- a/test/cli-adapters.test.ts +++ b/test/cli-adapters.test.ts @@ -427,13 +427,26 @@ describe('codex buildArgs', () => { it('starts every Codex Session with Fast Mode off unless that Session opted in', () => { const standard = adapter.buildArgs({ sessionId: 'sess-standard', resume: false }); - const fast = adapter.buildArgs({ sessionId: 'sess-fast', resume: false, fastMode: true }); + const fast = adapter.buildArgs({ + sessionId: 'sess-fast', + resume: false, + fastMode: true, + fastServiceTier: 'priority', + }); expect(standard).toContain('service_tier="default"'); - expect(fast).toContain('service_tier="fast"'); + expect(fast).toContain('service_tier="priority"'); expect(fast).not.toContain('service_tier="default"'); }); + it('refuses to guess a Fast protocol tier when the model catalog was not resolved', () => { + expect(() => adapter.buildArgs({ + sessionId: 'sess-fast-unresolved', + resume: false, + fastMode: true, + })).toThrow(/Fast service tier was not resolved/); + }); + it('keeps the startup update override on resume before the Codex session id', () => { const args = adapter.buildArgs({ sessionId: 'sess-4', diff --git a/test/cli-selection.test.ts b/test/cli-selection.test.ts index 7a0983efe..d753fad0a 100644 --- a/test/cli-selection.test.ts +++ b/test/cli-selection.test.ts @@ -235,8 +235,16 @@ describe('stripWrapperUnsafeArgs', () => { expect(stripWrapperUnsafeArgs([ '-c', 'service_tier="default"', '-c', 'service_tier="fast"', + '-c', 'service_tier="priority"', '--model', 'm', - ])).toEqual(['--model', 'm']); + ], 'priority')).toEqual(['--model', 'm']); + }); + + it('does not claim an arbitrary catalog tier unless the caller says Botmux injected it', () => { + expect(stripWrapperUnsafeArgs([ + '-c', 'service_tier="priority"', + '--model', 'm', + ])).toEqual(['-c', 'service_tier="priority"', '--model', 'm']); }); it('leaves a user-supplied -c (non-botmux config override) untouched', () => { @@ -312,6 +320,16 @@ describe('buildWrappedLaunch', () => { expect(out.args).toEqual(['x', 'codex', '--no-alt-screen']); }); + it('strips the exact dynamic Fast tier injected by Botmux', () => { + const out = buildWrappedLaunch( + 'aiden x codex', + ['-c', 'service_tier="priority"', '--no-alt-screen'], + (bin) => bin, + { codexServiceTier: 'priority' }, + ); + expect(out.args).toEqual(['x', 'codex', '--no-alt-screen']); + }); + it('does not strip a user-supplied -c that is not a botmux override (aiden x codex)', () => { const out = buildWrappedLaunch('aiden x codex', ['-c', 'model_reasoning_effort="high"', '--model', 'm']); expect(out.args).toEqual(['x', 'codex', '-c', 'model_reasoning_effort="high"', '--model', 'm']); @@ -350,6 +368,16 @@ describe('buildWrappedLaunch', () => { expect(out.args).toEqual(['codex', '--config', 'check_for_update_on_startup=false']); }); + it('rewrites the exact dynamic Fast tier injected by Botmux for cjadk', () => { + const out = buildWrappedLaunch( + 'cjadk codex', + ['-c', 'service_tier="priority"', '--no-alt-screen'], + (bin) => bin, + { codexServiceTier: 'priority' }, + ); + expect(out.args).toEqual(['codex', '--config', 'service_tier="priority"', '--no-alt-screen']); + }); + it('keeps the codex -c override for the bare-passthrough ttadk codex gateway', () => { const out = buildWrappedLaunch('ttadk codex', [ '-c', diff --git a/test/codex-fast-worker-wiring.test.ts b/test/codex-fast-worker-wiring.test.ts new file mode 100644 index 000000000..a71d68296 --- /dev/null +++ b/test/codex-fast-worker-wiring.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const workerSource = readFileSync(new URL('../src/worker.ts', import.meta.url), 'utf8'); +const workerPoolSource = readFileSync(new URL('../src/core/worker-pool.ts', import.meta.url), 'utf8'); + +describe('Codex Fast Mode worker wiring', () => { + it('passes Session Fast Mode into the app-server executor and captures its resolved tier', () => { + const start = workerSource.indexOf('async function engageCodexRpc('); + const end = workerSource.indexOf('/** RPC panes have NO terminal input path', start); + const region = workerSource.slice(start, end); + + expect(region).toContain('fastMode: cfg.fastMode === true'); + expect(region).toContain('cfg.fastServiceTier = engine.activeServiceTier'); + }); + + it('passes the live app-server endpoint and thread to the viewer adapter', () => { + const start = workerSource.indexOf('const args = cliAdapter.buildArgs({'); + const end = workerSource.indexOf('\\n });', start); + const region = workerSource.slice(start, end); + + expect(region).toContain('remoteWsUrl'); + expect(region).toContain('remoteThreadId'); + expect(region).toContain('fastServiceTier: cfg.fastServiceTier'); + }); + + it('queues typed runtime changes and ACKs only after restart config is updated', () => { + expect(workerSource).toContain("case 'set_fast_mode':"); + expect(workerSource).toContain('pendingFastModeChanges.push(msg)'); + + const applyStart = workerSource.indexOf('function commitFastModeChange('); + const applyEnd = workerSource.indexOf('\n}', applyStart); + const apply = workerSource.slice(applyStart, applyEnd); + expect(applyStart).toBeGreaterThan(-1); + expect(apply.indexOf('lastInitConfig.fastMode =')).toBeGreaterThan(-1); + expect(apply.indexOf("type: 'fast_mode_state'")).toBeGreaterThan( + apply.indexOf('lastInitConfig.fastMode ='), + ); + expect(apply.indexOf("type: 'fast_mode_result'")).toBeGreaterThan( + apply.indexOf("type: 'fast_mode_state'"), + ); + }); + + it('persists the concrete tier across daemon/worker restarts', () => { + expect(workerPoolSource).toContain("fastServiceTier: agentCfg.cliId === 'codex' ? ds.session.fastServiceTier : undefined"); + expect(workerPoolSource).toContain("case 'fast_mode_result':"); + expect(workerPoolSource).toContain("case 'fast_mode_state':"); + expect(workerPoolSource).toContain('ds.session.fastServiceTier = msg.serviceTier'); + }); +}); diff --git a/test/codex-rpc-engine.test.ts b/test/codex-rpc-engine.test.ts index ee02bd75a..66f631628 100644 --- a/test/codex-rpc-engine.test.ts +++ b/test/codex-rpc-engine.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { chmodSync, mkdirSync, writeFileSync, existsSync, rmSync } from 'node:fs'; +import { chmodSync, mkdirSync, writeFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { CodexRpcEngine } from '../src/codex-rpc-engine.js'; @@ -20,7 +20,91 @@ function makeEngine(over: Partial<ConstructorParameters<typeof CodexRpcEngine>[0 }); } +function readRpcLog(path: string): Array<{ method?: string; params?: Record<string, unknown> }> { + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)); +} + describe('CodexRpcEngine — happy-path lifecycle against a fake app-server', () => { + it('resolves the model catalog Fast tier and pins it on thread/start + turn/start', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-fast-rpc-')); + const rpcLog = join(dir, 'rpc.jsonl'); + const engine = makeEngine({ + model: 'gpt-fast', + fastMode: true, + env: { ...process.env, FAKE_RPC_LOG: rpcLog }, + }); + + try { + await engine.start(); + await engine.startThread(); + await engine.sendTurn('run fast'); + + const requests = readRpcLog(rpcLog); + expect(requests.find(entry => entry.method === 'model/list')).toBeDefined(); + expect(requests.find(entry => entry.method === 'thread/start')?.params).toMatchObject({ + serviceTier: 'priority', + }); + expect(requests.find(entry => entry.method === 'turn/start')?.params).toMatchObject({ + serviceTier: 'priority', + }); + } finally { + engine.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }, 20_000); + + it('pins Fast on thread/resume and applies runtime changes through acknowledged thread settings', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-fast-rpc-update-')); + const rpcLog = join(dir, 'rpc.jsonl'); + const engine = makeEngine({ + model: 'gpt-fast', + env: { ...process.env, FAKE_RPC_LOG: rpcLog }, + }); + + try { + await engine.start(); + await engine.resumeThread('thread-fast-resume'); + await engine.setFastMode(true); + await engine.sendTurn('fast turn'); + await engine.setFastMode(false); + await engine.sendTurn('default turn'); + + const requests = readRpcLog(rpcLog); + const resume = requests.find(entry => entry.method === 'thread/resume'); + expect(resume?.params).toMatchObject({ serviceTier: null }); + const updates = requests.filter(entry => entry.method === 'thread/settings/update'); + expect(updates.map(entry => entry.params?.serviceTier)).toEqual(['priority', null]); + const turns = requests.filter(entry => entry.method === 'turn/start'); + expect(turns.map(entry => entry.params?.serviceTier)).toEqual(['priority', null]); + } finally { + engine.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }, 20_000); + + it('rejects Fast before thread creation when the selected model has no Fast tier', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-fast-rpc-unsupported-')); + const rpcLog = join(dir, 'rpc.jsonl'); + const engine = makeEngine({ + model: 'gpt-standard', + fastMode: true, + env: { ...process.env, FAKE_RPC_LOG: rpcLog }, + }); + + try { + await engine.start(); + await expect(engine.startThread()).rejects.toThrow(/Fast Mode is not supported/); + expect(readRpcLog(rpcLog).some(entry => entry.method === 'thread/start')).toBe(false); + } finally { + engine.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }, 20_000); + it('start (spawn → /readyz → connect → initialize) then startThread → sendTurn → stop', async () => { const engine = makeEngine(); await engine.start(); diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 3b05dba74..f21b72e15 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -571,6 +571,11 @@ function makeDeps(ds?: DaemonSession): CommandHandlerDeps { getActiveCount: vi.fn(() => activeSessions.size), lastRepoScan: new Map(), prewarmDocCommentSession: vi.fn(async () => {}), + applyFastMode: vi.fn(async (_session, enabled) => ( + enabled + ? { ok: true as const, enabled: true, serviceTier: 'priority' } + : { ok: true as const, enabled: false } + )), }; } @@ -1610,12 +1615,10 @@ describe('handleCommand', () => { await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), deps, 'app-2'); - expect(ds.worker?.send).toHaveBeenCalledWith({ - type: 'raw_input', - content: '/fast', - turnId: 'msg_001', - }); + expect(deps.applyFastMode).toHaveBeenCalledWith(ds, true); + expect(ds.worker?.send).not.toHaveBeenCalled(); expect(ds.session.fastMode).toBe(true); + expect(ds.session.fastServiceTier).toBe('priority'); expect(sessionStore.updateSession).toHaveBeenCalledWith(ds.session); expect(deps.sessionReply).toHaveBeenCalledWith( ROOT_ID, @@ -1632,11 +1635,63 @@ describe('handleCommand', () => { await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), deps, 'app-2'); + expect(deps.applyFastMode).toHaveBeenCalledWith(ds, true); expect(ds.session.fastMode).toBe(true); + expect(ds.session.fastServiceTier).toBe('priority'); expect(sessionStore.updateSession).toHaveBeenCalledWith(ds.session); expect(deps.sessionReply).toHaveBeenCalled(); }); + it('persists and reports success only after the worker ACKs the change', async () => { + const ds = makeCodexFastSession(false); + const deps = makeDeps(ds); + let resolveApply!: (value: { ok: true; enabled: true; serviceTier: string }) => void; + deps.applyFastMode = vi.fn(() => new Promise(resolve => { resolveApply = resolve; })); + + const pending = handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), deps, 'app-2'); + await Promise.resolve(); + + expect(ds.session.fastMode).toBe(false); + expect(sessionStore.updateSession).not.toHaveBeenCalled(); + expect(deps.sessionReply).not.toHaveBeenCalled(); + + resolveApply({ ok: true, enabled: true, serviceTier: 'priority' }); + await pending; + + expect(ds.session.fastMode).toBe(true); + expect(ds.session.fastServiceTier).toBe('priority'); + expect(sessionStore.updateSession).toHaveBeenCalledWith(ds.session); + expect(deps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringMatching(/Fast Mode.*已开启/s), + undefined, + 'app-2', + 'msg_001', + ); + }); + + it('keeps persisted state unchanged when applying Fast Mode fails', async () => { + const ds = makeCodexFastSession(false); + const deps = makeDeps(ds); + deps.applyFastMode = vi.fn(async () => ({ + ok: false as const, + reason: 'unsupported_model' as const, + })); + + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), deps, 'app-2'); + + expect(ds.session.fastMode).toBe(false); + expect(ds.session.fastServiceTier).toBeUndefined(); + expect(sessionStore.updateSession).not.toHaveBeenCalled(); + expect(deps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringContaining('当前模型不支持'), + undefined, + 'app-2', + 'msg_001', + ); + }); + it('reports Session state without toggling Codex', async () => { const ds = makeCodexFastSession(true); const deps = makeDeps(ds); @@ -1659,6 +1714,7 @@ describe('handleCommand', () => { await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast off'), deps, 'app-2'); + expect(deps.applyFastMode).not.toHaveBeenCalled(); expect(ds.worker?.send).not.toHaveBeenCalled(); expect(ds.session.fastMode).toBe(false); expect(deps.sessionReply).toHaveBeenCalledWith( @@ -1677,10 +1733,8 @@ describe('handleCommand', () => { await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast'), deps, 'app-2'); expect(ds.session.fastMode).toBe(true); - expect(ds.worker?.send).toHaveBeenCalledWith(expect.objectContaining({ - type: 'raw_input', - content: '/fast', - })); + expect(deps.applyFastMode).toHaveBeenCalledWith(ds, true); + expect(ds.worker?.send).not.toHaveBeenCalled(); }); it('rejects unsupported arguments and non-Codex Sessions explicitly', async () => { diff --git a/test/daemon-rename-route.test.ts b/test/daemon-rename-route.test.ts index 7b1897d7c..12f3a8138 100644 --- a/test/daemon-rename-route.test.ts +++ b/test/daemon-rename-route.test.ts @@ -44,6 +44,12 @@ const mocks = vi.hoisted(() => { : undefined )), forkWorker: vi.fn(), + probeCodexFastServiceTier: vi.fn(async () => ({ + ok: true as const, + enabled: true, + serviceTier: 'priority', + })), + requestWorkerFastModeChange: vi.fn(), createSession: vi.fn((chatId: string, rootMessageId: string, title: string, chatType?: 'group' | 'p2p') => ({ sessionId: `sess-fake-${++seq}`, chatId, @@ -88,6 +94,15 @@ vi.mock('../src/core/worker-pool.js', async () => { return { ...actual, forkWorker: (...args: any[]) => mocks.forkWorker(...args) }; }); +vi.mock('../src/core/fast-mode-control.js', async () => { + const actual = await vi.importActual<any>('../src/core/fast-mode-control.js'); + return { + ...actual, + probeCodexFastServiceTier: (...args: any[]) => mocks.probeCodexFastServiceTier(...args), + requestWorkerFastModeChange: (...args: any[]) => mocks.requestWorkerFastModeChange(...args), + }; +}); + import { registerBot } from '../src/bot-registry.js'; import { sessionKey } from '../src/core/types.js'; import { @@ -231,6 +246,11 @@ describe('/rename production routing — must not pre-create a session (review P mocks.sendMessage.mockResolvedValue('om_top'); mocks.getChatMode.mockResolvedValue('group'); mocks.getChatNameAndMode.mockResolvedValue({ name: null, mode: 'group' }); + mocks.probeCodexFastServiceTier.mockResolvedValue({ + ok: true, + enabled: true, + serviceTier: 'priority', + }); activeSessions.clear(); const bot = registerBot({ larkAppId: APP, @@ -315,6 +335,81 @@ describe('/rename production routing — must not pre-create a session (review P expect(activeSessions.has(sessionKey('om_new_2', APP))).toBe(true); }); + it('/fast status and invalid input never create phantom sessions on either route', async () => { + await handleNewTopic( + makeEventData('om_fast_status', '/fast status'), + makeCtx('om_fast_status', 'om_fast_status'), + ); + await handleThreadReply( + makeEventData('om_fast_invalid', '/fast turbo', 'om_fast_root'), + makeCtx('om_fast_root', 'om_fast_invalid'), + ); + + expect(mocks.createSession).not.toHaveBeenCalled(); + expect(activeSessions.size).toBe(0); + expect(repliedText()).toContain('没有活跃的会话'); + expect(repliedText()).toContain('/fast on'); + }); + + it('non-Codex /fast on reports unsupported without creating a session', async () => { + await handleNewTopic( + makeEventData('om_fast_claude', '/fast on'), + makeCtx('om_fast_claude', 'om_fast_claude'), + ); + + expect(mocks.createSession).not.toHaveBeenCalled(); + expect(mocks.probeCodexFastServiceTier).not.toHaveBeenCalled(); + expect(activeSessions.size).toBe(0); + expect(repliedText()).toContain('仅支持由 Botmux 管理'); + }); + + it('valid Codex /fast on preflights the model, then creates exactly one Fast session', async () => { + const bot = registerBot({ + larkAppId: APP, + larkAppSecret: 's', + cliId: 'codex', + allowedUsers: [OWNER], + oncallChats: [{ chatId: CHAT, workingDir: '/tmp' }], + }); + bot.resolvedAllowedUsers = [OWNER]; + + await handleNewTopic( + makeEventData('om_fast_on', '/fast on'), + makeCtx('om_fast_on', 'om_fast_on'), + ); + + expect(mocks.probeCodexFastServiceTier).toHaveBeenCalledTimes(1); + expect(mocks.createSession).toHaveBeenCalledTimes(1); + const ds = activeSessions.get(sessionKey('om_fast_on', APP)); + expect(ds?.worker).toBeNull(); + expect(ds?.session.fastMode).toBe(true); + expect(ds?.session.fastServiceTier).toBe('priority'); + }); + + it('unsupported Codex model fails before session creation', async () => { + const bot = registerBot({ + larkAppId: APP, + larkAppSecret: 's', + cliId: 'codex', + allowedUsers: [OWNER], + oncallChats: [{ chatId: CHAT, workingDir: '/tmp' }], + }); + bot.resolvedAllowedUsers = [OWNER]; + mocks.probeCodexFastServiceTier.mockResolvedValue({ + ok: false, + reason: 'unsupported_model', + }); + + await handleThreadReply( + makeEventData('om_fast_unsupported', '/fast on', 'om_fast_unsupported_root'), + makeCtx('om_fast_unsupported_root', 'om_fast_unsupported'), + ); + + expect(mocks.createSession).not.toHaveBeenCalled(); + expect(activeSessions.size).toBe(0); + expect(repliedText()).toContain('当前模型不支持'); + }); + it('new topic: passes the accepted Lark message id into the first worker', async () => { await handleNewTopic( makeEventData('om_workflow_new', '/workflow new 修复首轮授权'), diff --git a/test/dashboard-attention-signals.test.ts b/test/dashboard-attention-signals.test.ts index 956179f31..3309085e5 100644 --- a/test/dashboard-attention-signals.test.ts +++ b/test/dashboard-attention-signals.test.ts @@ -218,10 +218,10 @@ describe('attention signals', () => { const src = readFileSync(new URL('../src/daemon.ts', import.meta.url), 'utf-8'); const start = src.indexOf('async function handleThreadReply('); expect(start).toBeGreaterThanOrEqual(0); - // 20000:窗口需罩住函数头到最后一个拦截点 (findPendingAskByAnchor) 的全部 + // 30000:窗口需罩住函数头到最后一个拦截点 (findPendingAskByAnchor) 的全部 // 源码——passthrough 冷启动等合法插入会把后续 marker 往后推,窗口太紧会误报。 // 语义断言不变:clear 在所有拦截点之前。 - const region = src.slice(start, start + 20000); + const region = src.slice(start, start + 30000); const clearIdx = region.indexOf('clearAgentAttentionForHumanInbound();'); expect(clearIdx).toBeGreaterThanOrEqual(0); for (const marker of [ diff --git a/test/fast-mode-control.test.ts b/test/fast-mode-control.test.ts new file mode 100644 index 000000000..21aec993d --- /dev/null +++ b/test/fast-mode-control.test.ts @@ -0,0 +1,84 @@ +import { chmodSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { + fastModeSessionSupported, + parseFastModeAction, + probeCodexFastServiceTier, + requestWorkerFastModeChange, +} from '../src/core/fast-mode-control.js'; +import { acknowledgeFastModeResult } from '../src/core/fast-mode-handshake.js'; + +const FIXTURE = fileURLToPath(new URL('./fixtures/fake-codex-rpc-server.mjs', import.meta.url)); +beforeAll(() => { chmodSync(FIXTURE, 0o755); }); + +describe('Fast Mode control', () => { + it('parses only the supported command actions', () => { + expect(parseFastModeAction('/fast')).toBe('toggle'); + expect(parseFastModeAction('/fast on')).toBe('on'); + expect(parseFastModeAction('/FAST OFF')).toBe('off'); + expect(parseFastModeAction('/fast status')).toBe('status'); + expect(parseFastModeAction('/fast turbo')).toBe('invalid'); + }); + + it('rejects non-Codex, adopted, and Aiden-wrapped sessions', () => { + expect(fastModeSessionSupported({ cliId: 'codex' })).toBe(true); + expect(fastModeSessionSupported({ cliId: 'claude-code' })).toBe(false); + expect(fastModeSessionSupported({ cliId: 'codex', adopted: true })).toBe(false); + expect(fastModeSessionSupported({ cliId: 'codex', wrapperCli: 'aiden x codex' })).toBe(false); + }); + + it('resolves the selected model Fast tier from app-server model/list', async () => { + await expect(probeCodexFastServiceTier({ + cliBin: FIXTURE, + cwd: '/tmp', + env: process.env, + model: 'gpt-fast', + })).resolves.toEqual({ + ok: true, + enabled: true, + serviceTier: 'priority', + }); + }, 20_000); + + it('fails closed when the selected model has no Fast tier', async () => { + await expect(probeCodexFastServiceTier({ + cliBin: FIXTURE, + cwd: '/tmp', + env: process.env, + model: 'gpt-standard', + })).resolves.toEqual({ + ok: false, + reason: 'unsupported_model', + }); + }, 20_000); + + it('waits for the worker ACK instead of treating IPC send as success', async () => { + let sent: any; + const worker = { + killed: false, + connected: true, + send(message: unknown, callback: (error: Error | null) => void) { + sent = message; + callback(null); + }, + } as any; + + const pending = requestWorkerFastModeChange(worker, true, 1_000); + await Promise.resolve(); + expect(sent).toMatchObject({ type: 'set_fast_mode', enabled: true }); + + acknowledgeFastModeResult({ + type: 'fast_mode_result', + requestId: sent.requestId, + ok: true, + enabled: true, + serviceTier: 'priority', + }); + await expect(pending).resolves.toEqual({ + ok: true, + enabled: true, + serviceTier: 'priority', + }); + }); +}); diff --git a/test/fast-mode-handshake.test.ts b/test/fast-mode-handshake.test.ts new file mode 100644 index 000000000..52d6e0bb9 --- /dev/null +++ b/test/fast-mode-handshake.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + acknowledgeFastModeResult, + cancelFastModeResult, + waitForFastModeResult, +} from '../src/core/fast-mode-handshake.js'; + +describe('Fast Mode IPC handshake', () => { + it('resolves only the exact request ACK', async () => { + const pending = waitForFastModeResult('req-1', 1_000); + + expect(acknowledgeFastModeResult({ + type: 'fast_mode_result', + requestId: 'other', + ok: true, + enabled: true, + serviceTier: 'priority', + })).toBe(false); + expect(acknowledgeFastModeResult({ + type: 'fast_mode_result', + requestId: 'req-1', + ok: true, + enabled: true, + serviceTier: 'priority', + })).toBe(true); + await expect(pending).resolves.toEqual({ + ok: true, + enabled: true, + serviceTier: 'priority', + }); + }); + + it('fails closed on timeout or send cancellation', async () => { + await expect(waitForFastModeResult('req-timeout', 5)).resolves.toEqual({ + ok: false, + reason: 'not_ready', + }); + + const pending = waitForFastModeResult('req-cancel', 1_000); + expect(cancelFastModeResult('req-cancel')).toBe(true); + await expect(pending).resolves.toEqual({ + ok: false, + reason: 'not_ready', + }); + }); +}); diff --git a/test/fixtures/fake-codex-rpc-server.mjs b/test/fixtures/fake-codex-rpc-server.mjs index 5a136f979..3a59ff6e3 100755 --- a/test/fixtures/fake-codex-rpc-server.mjs +++ b/test/fixtures/fake-codex-rpc-server.mjs @@ -6,6 +6,7 @@ // FAKE_HANG_TURN=1 → never answer turn/start (wedged app-server) // FAKE_DIE_AFTER_MS=N → exit(1) after N ms (crash → engine onDead) import { createServer } from 'node:http'; +import { appendFileSync } from 'node:fs'; import { WebSocketServer } from 'ws'; const listenArg = process.argv[process.argv.indexOf('--listen') + 1] || ''; @@ -17,6 +18,7 @@ const PREVIEW_DELAY_READS = Number(process.env.FAKE_PREVIEW_DELAY_READS ?? '0'); const UPDATED_DELAY_READS = Number(process.env.FAKE_UPDATED_DELAY_READS ?? '0'); const UPDATED_BEFORE = Number(process.env.FAKE_UPDATED_BEFORE ?? '100'); const UPDATED_AFTER = Number(process.env.FAKE_UPDATED_AFTER ?? '101'); +const RPC_LOG = process.env.FAKE_RPC_LOG; let threadReadAttempt = 0; let currentThreadName; const REQUEST_USER_INPUT = process.env.FAKE_REQUEST_USER_INPUT === '1'; @@ -30,6 +32,7 @@ wss.on('connection', (ws) => { let pendingTurnReply; ws.on('message', (data) => { let msg; try { msg = JSON.parse(data.toString()); } catch { return; } + if (RPC_LOG) appendFileSync(RPC_LOG, `${JSON.stringify(msg)}\n`); if (REQUEST_USER_INPUT && msg.id === 900 && (msg.result !== undefined || msg.error !== undefined)) { if (!pendingTurnReply) return; // Real traex 0.200.19 normalizes ANY reply to requestUserInput (empty @@ -51,8 +54,28 @@ wss.on('connection', (ws) => { const reply = (result) => ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result })); switch (msg.method) { case 'initialize': return reply({ ok: true }); + case 'model/list': return reply({ + data: [ + { + id: 'gpt-fast', + model: 'gpt-fast', + isDefault: true, + serviceTiers: [ + { id: 'priority', name: 'Fast', description: 'faster' }, + ], + }, + { + id: 'gpt-standard', + model: 'gpt-standard', + isDefault: false, + serviceTiers: [], + }, + ], + nextCursor: null, + }); case 'thread/start': return reply({ thread: { id: 'thread-fake-1' } }); case 'thread/resume': return reply({ thread: { id: msg.params?.threadId ?? 'thread-fake-1' } }); + case 'thread/settings/update': return reply({}); case 'thread/read': threadReadAttempt += 1; return reply({ thread: { From 06305768e36dbaaf535798d7650781fe24b9dcd5 Mon Sep 17 00:00:00 2001 From: zhouzhixia <zhouzhixia@bytedance.com> Date: Mon, 27 Jul 2026 18:35:34 +0800 Subject: [PATCH 3/5] fix(codex): reconcile legacy Fast sessions --- src/core/command-handler.ts | 10 ++- src/core/worker-pool.ts | 3 + src/types.ts | 10 ++- src/worker.ts | 105 ++++++++++++++++++++++---- test/codex-fast-worker-wiring.test.ts | 33 +++++++- test/command-handler.test.ts | 12 +++ 6 files changed, 151 insertions(+), 22 deletions(-) diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index dead720be..79a54a8f1 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1930,8 +1930,10 @@ export async function handleCommand( // A legacy `fastMode:true` record without a catalog tier is not proof // that the executor actually applied Fast Mode. Re-apply it through the // acknowledged path so the persisted state self-heals. - const needsApply = enabled !== current || (enabled && !ds.session.fastServiceTier); + const needsApply = enabled !== current + || (enabled && (!ds.session.fastServiceTier || ds.session.fastModeStateVersion !== 1)); if (needsApply) { + const executorWasLive = !!ds.worker && !ds.worker.killed; const applied = deps.applyFastMode ? await deps.applyFastMode(ds, enabled) : ({ ok: false, reason: 'apply_failed' } as const); @@ -1947,9 +1949,15 @@ export async function handleCommand( // state and the daemon's in-memory restart snapshot coherent. ds.session.fastMode = enabled; ds.session.fastServiceTier = enabled ? applied.serviceTier : undefined; + // A capability probe can stage a cold Session, but it is not an + // executor ACK. Leave the marker absent so init reconciles before + // trusting any persistent pane. A live worker publishes version 1 + // through fast_mode_state before its result ACK. + if (!executorWasLive) ds.session.fastModeStateVersion = undefined; if (ds.initConfig) { ds.initConfig.fastMode = enabled; ds.initConfig.fastServiceTier = enabled ? applied.serviceTier : undefined; + if (!executorWasLive) ds.initConfig.fastModeStateVersion = undefined; } sessionStore.updateSession(ds.session); } else if (ds.session.fastMode === undefined) { diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index c980f42c4..9daf77f0e 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -2255,6 +2255,7 @@ export function forkWorker( // CODEX_HOME. Missing means the Session has never opted in → standard. fastMode: agentCfg.cliId === 'codex' ? ds.session.fastMode === true : undefined, fastServiceTier: agentCfg.cliId === 'codex' ? ds.session.fastServiceTier : undefined, + fastModeStateVersion: agentCfg.cliId === 'codex' ? ds.session.fastModeStateVersion : undefined, disableCliBypass: botCfg.disableCliBypass === true, codexRpcInput: botCfg.codexRpcInput === true || config.codexRpcInputDefault, // Startup commands run on every fresh spawn (incl. resume) so session-only @@ -2596,9 +2597,11 @@ function setupWorkerHandlers( } ds.session.fastMode = msg.enabled; ds.session.fastServiceTier = msg.serviceTier; + ds.session.fastModeStateVersion = 1; if (ds.initConfig) { ds.initConfig.fastMode = msg.enabled; ds.initConfig.fastServiceTier = msg.serviceTier; + ds.initConfig.fastModeStateVersion = 1; } sessionStore.updateSession(ds.session); break; diff --git a/src/types.ts b/src/types.ts index 1221522f9..76e72bfdb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -341,6 +341,10 @@ export interface Session { * catalog (for example `priority`). Persisting the id keeps cold starts, * restarts, and the native TUI on the same acknowledged setting. */ fastServiceTier?: string; + /** Version of the executor-confirmed Fast Mode state. Missing on legacy or + * cold-staged records, which must be reconciled before a surviving native + * pane can be trusted. */ + fastModeStateVersion?: 1; /** * True once `cliId`/`cliPathOverride`/`wrapperCli`/`model` have been frozen for * this session (see `sessionAgentConfig`). Gates the one-time freeze so it runs @@ -598,7 +602,7 @@ export type FastModeApplyResult = /** Messages sent from Daemon to Worker */ export type DaemonToWorker = - | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; fastMode?: boolean; fastServiceTier?: string; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record<string, string>; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } + | { type: 'init'; sessionId: string; chatId: string; chatType?: 'group' | 'p2p'; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; fastMode?: boolean; fastServiceTier?: string; fastModeStateVersion?: 1; disableCliBypass?: boolean; codexRpcInput?: boolean; startupCommands?: string[]; env?: Record<string, string>; sandbox?: boolean; sandboxPaths?: { readWrite?: string[]; readOnly?: string[]; deny?: string[] }; sandboxHidePaths?: string[]; sandboxReadonlyPaths?: string[]; sandboxNetwork?: boolean; readIsolation?: boolean; readDenyExtraPaths?: string[]; daemonBootId?: string; backendType: BackendType; persistentBackendTarget?: PersistentBackendTarget; backendConfig?: RiffBackendConfig; riffParentTaskId?: string; riffRepoDirs?: string[]; deferredScheduleRun?: Session['deferredScheduleRun']; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; prompt: string; promptCodexAppInput?: CodexAppTurnInput; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; pluginBindings?: string[]; skillPolicy?: BotSkillPolicy; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } | { type: 'message'; content: string; codexAppInput?: CodexAppTurnInput; nativeSessionTitle?: string; nativeSessionTitlePrompt?: string; turnId?: string; dispatchAttempt?: number; vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin } /** Literal slash-command passthrough. `followUpContent` rides along so the * worker enqueues it strictly AFTER the slash command's Enter — two separate @@ -681,8 +685,8 @@ export type WorkerToDaemon = | { type: 'session_ready_ack'; requestId: string } | { type: 'fast_mode_result'; requestId: string; ok: true; enabled: boolean; serviceTier?: string } | { type: 'fast_mode_result'; requestId: string; ok: false; reason: FastModeFailureReason; message?: string } - /** Worker resolved a legacy/cold Fast Session's concrete tier before spawn. - * The daemon persists it so later worker generations need no migration probe. */ + /** Executor-confirmed Fast Mode state. The daemon persists it with the + * current state version so later worker generations can safely reattach. */ | { type: 'fast_mode_state'; enabled: boolean; serviceTier?: string } | { type: 'screen_update'; content: string; status: ScreenStatus; usageLimit?: CliUsageLimitState; turnId?: string; dispatchAttempt?: number } | { type: 'error'; message: string; turnId?: string; dispatchAttempt?: number } diff --git a/src/worker.ts b/src/worker.ts index 4f28e039b..81c2e7090 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -209,7 +209,7 @@ import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js'; -import type { BackendType, SessionBackend } from './adapters/backend/types.js'; +import type { BackendType, PersistentBackendTarget, SessionBackend } from './adapters/backend/types.js'; import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js'; import { tmuxRestartJitterMs } from './core/tmux-recovery.js'; import { IdleDetector } from './utils/idle-detector.js'; @@ -358,6 +358,16 @@ async function killPersistentSessionVerified(backendType: PersistentBackendType, }); } +/** Kill the exact persisted backend resource (including an agent-scoped Herdr + * target) and fail closed unless probing confirms it is gone. */ +async function killPersistentBackendTargetVerified(target: PersistentBackendTarget): Promise<boolean> { + return killAndVerifyPersistentPane(target.sessionName, { + kill: () => killPersistentBackendTarget(target), + isLive: () => probePersistentBackendTarget(target) !== 'missing', + wait: delay, + }); +} + /** Poll the codex/traex rollout for POSITIVE evidence that the fresh first turn's * user message was persisted (P1-1). Used only to resolve a dispatched-but- * unacked first turn: a hit means the app-server accepted the turn → 'accepted' @@ -1523,6 +1533,13 @@ let pendingRestartFastModeAck: { serviceTier?: string; previousFastMode: boolean; previousServiceTier?: string; + previousStateVersion?: 1; +} | null = null; +/** A cold or legacy native Fast setting is only executor-confirmed after the + * explicitly-tiered replacement process reaches a real prompt. */ +let pendingInitialFastModeConfirmation: { + enabled: true; + serviceTier: string; } | null = null; /** Latest requested canonical session title. Unlike a normal prompt this is an * administrative TUI command: never type-ahead while the agent is busy, never @@ -5092,22 +5109,30 @@ function markPromptReadyFromPty(): void { } } -function commitFastModeChange( - request: Extract<DaemonToWorker, { type: 'set_fast_mode' }>, +function publishFastModeExecutorState( + enabled: boolean, serviceTier?: string, ): void { if (!lastInitConfig) return; - lastInitConfig.fastMode = request.enabled; - lastInitConfig.fastServiceTier = request.enabled ? serviceTier : undefined; + lastInitConfig.fastMode = enabled; + lastInitConfig.fastServiceTier = enabled ? serviceTier : undefined; + lastInitConfig.fastModeStateVersion = 1; // Publish the accepted executor state before resolving the command waiter. // IPC preserves ordering, so persistence happens before the success reply; // a late ACK after the daemon-side wait deadline still cannot leave the // worker and Session store split-brained. send({ type: 'fast_mode_state', - enabled: request.enabled, - ...(request.enabled && serviceTier ? { serviceTier } : {}), + enabled, + ...(enabled && serviceTier ? { serviceTier } : {}), }); +} + +function commitFastModeChange( + request: Extract<DaemonToWorker, { type: 'set_fast_mode' }>, + serviceTier?: string, +): void { + publishFastModeExecutorState(request.enabled, serviceTier); send({ type: 'fast_mode_result', requestId: request.requestId, @@ -5189,7 +5214,8 @@ async function flushPendingFastModeChanges(): Promise<void> { // The launch snapshot is authoritative for this worker generation. An // idempotent request needs no native toggle; the resolved tier still heals // a legacy record before ACK. - if (lastInitConfig.fastMode === request.enabled) { + if (lastInitConfig.fastMode === request.enabled + && (!request.enabled || lastInitConfig.fastModeStateVersion === 1)) { commitFastModeChange(request, serviceTier ?? lastInitConfig.fastServiceTier); fastModeChangeInFlight = false; continueAfterFastModeChange(); @@ -5201,11 +5227,13 @@ async function flushPendingFastModeChanges(): Promise<void> { serviceTier, previousFastMode: lastInitConfig.fastMode === true, previousServiceTier: lastInitConfig.fastServiceTier, + previousStateVersion: lastInitConfig.fastModeStateVersion, }; // Stage the replacement config before teardown. The daemon does not persist // it until the replacement prompt ACK below. lastInitConfig.fastMode = request.enabled; lastInitConfig.fastServiceTier = request.enabled ? serviceTier : undefined; + lastInitConfig.fastModeStateVersion = undefined; await restartCliProcess( `Fast Mode ${request.enabled ? 'enable' : 'disable'} reconciliation`, { immediate: true, preservePending: true, skipRestartBudget: true }, @@ -5215,6 +5243,7 @@ async function flushPendingFastModeChanges(): Promise<void> { if (pendingRestartFastModeAck && lastInitConfig) { lastInitConfig.fastMode = pendingRestartFastModeAck.previousFastMode; lastInitConfig.fastServiceTier = pendingRestartFastModeAck.previousServiceTier; + lastInitConfig.fastModeStateVersion = pendingRestartFastModeAck.previousStateVersion; } pendingRestartFastModeAck = null; rejectFastModeChange( @@ -5268,6 +5297,12 @@ function markPromptReady(): void { fastModeChangeInFlight = false; log(`Fast Mode replacement confirmed at prompt (${pending.request.enabled ? 'on' : 'off'})`); } + if (pendingInitialFastModeConfirmation) { + const pending = pendingInitialFastModeConfirmation; + pendingInitialFastModeConfirmation = null; + publishFastModeExecutorState(pending.enabled, pending.serviceTier); + log('Initial Fast Mode state confirmed at prompt'); + } clearSessionRenameInFlight(); // An old backend can still report idle while its async teardown is running. // Only a prompt observed after the general restart fence drops may release @@ -6361,7 +6396,7 @@ function scheduleBusyPatternIdleProbe(source: string): void { async function spawnCli( cfg: Extract<DaemonToWorker, { type: 'init' }>, - opts: { pluginGenerationPrepared?: boolean } = {}, + opts: { pluginGenerationPrepared?: boolean; forceFreshPersistent?: boolean } = {}, ): Promise<void> { clearSessionRenameInFlight(); currentCliCredentialIsolated = false; @@ -6842,6 +6877,34 @@ async function spawnCli( // real host path the probe may not be able to stat). Computed here (not at // the spawn site below) so the pre-flight can short-circuit on it. let persistentSessionName = selectedBackend.persistentSessionName; + const persistentBackendType: PersistentBackendType | undefined = + effectiveBackendType === 'tmux' + || effectiveBackendType === 'herdr' + || effectiveBackendType === 'zellij' + ? effectiveBackendType + : undefined; + if (opts.forceFreshPersistent && persistentSessionName && persistentBackendType) { + const target = selectedBackend.persistentBackendTarget ?? { + backendType: persistentBackendType, + sessionName: persistentSessionName, + }; + if (probePersistentBackendTarget(target) !== 'missing') { + log(`[fast-mode] replacing unconfirmed legacy persistent pane for ${cfg.sessionId}`); + const killed = await killPersistentBackendTargetVerified(target); + if (!killed) { + throw new Error( + `[fast-mode] refusing to reattach unconfirmed persistent pane for ${cfg.sessionId}`, + ); + } + selectedBackend = selectBackend(); + isTmuxMode = selectedBackend.isTmuxMode; + isPipeMode = selectedBackend.isPipeMode; + isZellijMode = selectedBackend.isZellijMode; + backend = selectedBackend.backend; + cliLifetimeNonce++; + persistentSessionName = selectedBackend.persistentSessionName; + } + } // [read-isolation] Before we decide to reattach a persistent pane: a pane can // survive a daemon restart still running a CLI that may NOT be isolated (e.g. // spawned before isolation was enabled, or by an old build). Isolation is only @@ -9928,6 +9991,8 @@ process.on('message', async (raw: unknown) => { // then launched/respawned as `codex --remote resume` (codex.ts buildArgs) // against the CURRENT app-server (a fresh port each incarnation). const rpcBackendType = msg.backendType ?? config.daemon.backendType; + const fastModeNeedsReconciliation = msg.fastMode === true + && (msg.fastModeStateVersion !== 1 || !msg.fastServiceTier); let rpcPluginGenerationPrepared = false; const rpcDecision = await orchestrateCodexRpcInit(msg, { paneInfo: (sid) => persistentPaneInfo(rpcBackendType, sid), @@ -9954,13 +10019,23 @@ process.on('message', async (raw: unknown) => { // at default tier. await ensureConfiguredFastServiceTier(msg); if (msg.fastMode === true && msg.fastServiceTier) { - send({ - type: 'fast_mode_state', - enabled: true, - serviceTier: msg.fastServiceTier, - }); + if (codexRpcEngine) { + // thread/start|resume already accepted this concrete tier. + publishFastModeExecutorState(true, msg.fastServiceTier); + } else if (fastModeNeedsReconciliation) { + // Native launch args are not proof until the replacement reaches a + // real prompt. Arm this before spawn so an early ready event cannot + // race past the confirmation. + pendingInitialFastModeConfirmation = { + enabled: true, + serviceTier: msg.fastServiceTier, + }; + } } - await spawnCli(msg, { pluginGenerationPrepared: rpcPluginGenerationPrepared }); + await spawnCli(msg, { + pluginGenerationPrepared: rpcPluginGenerationPrepared, + forceFreshPersistent: fastModeNeedsReconciliation && !codexRpcEngine, + }); await prepareCodexNativeTitleGeneration(msg, codexRpcEngine); if (codexRpcEngine) armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog diff --git a/test/codex-fast-worker-wiring.test.ts b/test/codex-fast-worker-wiring.test.ts index a71d68296..0baa86db0 100644 --- a/test/codex-fast-worker-wiring.test.ts +++ b/test/codex-fast-worker-wiring.test.ts @@ -28,7 +28,7 @@ describe('Codex Fast Mode worker wiring', () => { expect(workerSource).toContain("case 'set_fast_mode':"); expect(workerSource).toContain('pendingFastModeChanges.push(msg)'); - const applyStart = workerSource.indexOf('function commitFastModeChange('); + const applyStart = workerSource.indexOf('function publishFastModeExecutorState('); const applyEnd = workerSource.indexOf('\n}', applyStart); const apply = workerSource.slice(applyStart, applyEnd); expect(applyStart).toBeGreaterThan(-1); @@ -36,15 +36,42 @@ describe('Codex Fast Mode worker wiring', () => { expect(apply.indexOf("type: 'fast_mode_state'")).toBeGreaterThan( apply.indexOf('lastInitConfig.fastMode ='), ); - expect(apply.indexOf("type: 'fast_mode_result'")).toBeGreaterThan( - apply.indexOf("type: 'fast_mode_state'"), + const commitStart = workerSource.indexOf('function commitFastModeChange('); + const commitEnd = workerSource.indexOf('\n}', commitStart); + const commit = workerSource.slice(commitStart, commitEnd); + expect(commit.indexOf("type: 'fast_mode_result'")).toBeGreaterThan( + commit.indexOf('publishFastModeExecutorState('), ); }); it('persists the concrete tier across daemon/worker restarts', () => { expect(workerPoolSource).toContain("fastServiceTier: agentCfg.cliId === 'codex' ? ds.session.fastServiceTier : undefined"); + expect(workerPoolSource).toContain("fastModeStateVersion: agentCfg.cliId === 'codex' ? ds.session.fastModeStateVersion : undefined"); expect(workerPoolSource).toContain("case 'fast_mode_result':"); expect(workerPoolSource).toContain("case 'fast_mode_state':"); expect(workerPoolSource).toContain('ds.session.fastServiceTier = msg.serviceTier'); + expect(workerPoolSource).toContain('ds.session.fastModeStateVersion = 1'); + }); + + it('reconciles an unconfirmed legacy Fast pane exactly once', () => { + const initStart = workerSource.indexOf("case 'init':"); + const initEnd = workerSource.indexOf('// Queue the initial prompt', initStart); + const init = workerSource.slice(initStart, initEnd); + expect(init).toContain('const fastModeNeedsReconciliation = msg.fastMode === true'); + expect(init).toContain('msg.fastModeStateVersion !== 1'); + expect(init).toContain('forceFreshPersistent: fastModeNeedsReconciliation && !codexRpcEngine'); + expect(init).toContain('pendingInitialFastModeConfirmation ='); + + const spawnStart = workerSource.indexOf('async function spawnCli('); + const spawnEnd = workerSource.indexOf('// The plugin set is stable', spawnStart); + const spawn = workerSource.slice(spawnStart, spawnEnd); + expect(spawn).toContain('opts.forceFreshPersistent'); + expect(spawn).toContain('killPersistentBackendTargetVerified'); + + const readyStart = workerSource.indexOf('function markPromptReady(): void'); + const readyEnd = workerSource.indexOf('clearSessionRenameInFlight();', readyStart); + const ready = workerSource.slice(readyStart, readyEnd); + expect(ready).toContain('pendingInitialFastModeConfirmation'); + expect(ready).toContain('publishFastModeExecutorState'); }); }); diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index f21b72e15..494109e73 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -1631,6 +1631,7 @@ describe('handleCommand', () => { it('stores the setting without spawning a CLI when /fast starts a new topic', async () => { const ds = makeCodexFastSession(false, false); + ds.session.fastModeStateVersion = 1; const deps = makeDeps(ds); await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), deps, 'app-2'); @@ -1638,10 +1639,21 @@ describe('handleCommand', () => { expect(deps.applyFastMode).toHaveBeenCalledWith(ds, true); expect(ds.session.fastMode).toBe(true); expect(ds.session.fastServiceTier).toBe('priority'); + expect(ds.session.fastModeStateVersion).toBeUndefined(); expect(sessionStore.updateSession).toHaveBeenCalledWith(ds.session); expect(deps.sessionReply).toHaveBeenCalled(); }); + it('re-applies an enabled legacy Session until an executor confirms it', async () => { + const ds = makeCodexFastSession(true); + ds.session.fastServiceTier = 'priority'; + const deps = makeDeps(ds); + + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), deps, 'app-2'); + + expect(deps.applyFastMode).toHaveBeenCalledWith(ds, true); + }); + it('persists and reports success only after the worker ACKs the change', async () => { const ds = makeCodexFastSession(false); const deps = makeDeps(ds); From 46e6aa3092e443e55504b37fc7a56d793ba9e956 Mon Sep 17 00:00:00 2001 From: zhouzhixia <zhouzhixia@bytedance.com> Date: Mon, 27 Jul 2026 19:44:46 +0800 Subject: [PATCH 4/5] fix(codex): close Fast Mode reconciliation gaps --- src/core/command-handler.ts | 20 +++- src/core/fast-mode-control.ts | 31 ++++++- src/core/fast-mode-handshake.ts | 2 + src/core/fast-mode-restart-watchdog.ts | 46 +++++++++ src/daemon.ts | 16 +++- src/types.ts | 3 + src/worker.ts | 118 ++++++++++++++++++++---- test/codex-fast-worker-wiring.test.ts | 44 ++++++++- test/command-handler.test.ts | 24 +++++ test/daemon-rename-route.test.ts | 22 +++++ test/fast-mode-control.test.ts | 47 +++++++++- test/fast-mode-handshake.test.ts | 8 +- test/fast-mode-restart-watchdog.test.ts | 49 ++++++++++ 13 files changed, 395 insertions(+), 35 deletions(-) create mode 100644 src/core/fast-mode-restart-watchdog.ts create mode 100644 test/fast-mode-restart-watchdog.test.ts diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 79a54a8f1..4a274b0d6 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -88,7 +88,11 @@ import type { LarkMessage, DaemonToWorker, FastModeApplyResult } from '../types. import { sessionKey, sessionAnchorId, markRepoCardConsumed, claimCurrentRepoCard } from './types.js'; import type { DaemonSession } from './types.js'; import { t, localeForBot, type Locale } from '../i18n/index.js'; -import { fastModeSessionSupported, parseFastModeAction } from './fast-mode-control.js'; +import { + fastModeSessionSupported, + fastModeStateNeedsReconciliation, + parseFastModeAction, +} from './fast-mode-control.js'; import { runSkillsImCommand } from './skills/im-command.js'; import { fetchDaemonIpc } from './daemon-ipc-auth.js'; import { updateSessionTitle } from './session-title.js'; @@ -1908,6 +1912,12 @@ export async function handleCommand( const wrapperCli = ds.session.agentFrozen ? ds.session.wrapperCli : (ds.session.wrapperCli ?? botConfig.wrapperCli); + const backendType = resolvePairedSpawnBackendType( + sessionCliId, + ds.session.backendType, + botConfig.backendType, + config.daemon.backendType, + ); // Aiden rejects every Codex config override, so Botmux cannot pin the // service tier on cold start and therefore cannot uphold Session-local // semantics. Other known wrappers either rewrite or pass the override. @@ -1915,6 +1925,7 @@ export async function handleCommand( cliId: sessionCliId, wrapperCli, adopted: !!ds.adoptedFrom, + backendType, })) { await sessionReply(rootId, t('cmd.fast.unsupported', undefined, loc)); break; @@ -1930,8 +1941,11 @@ export async function handleCommand( // A legacy `fastMode:true` record without a catalog tier is not proof // that the executor actually applied Fast Mode. Re-apply it through the // acknowledged path so the persisted state self-heals. - const needsApply = enabled !== current - || (enabled && (!ds.session.fastServiceTier || ds.session.fastModeStateVersion !== 1)); + const needsApply = enabled !== current || fastModeStateNeedsReconciliation({ + enabled, + serviceTier: ds.session.fastServiceTier, + stateVersion: ds.session.fastModeStateVersion, + }); if (needsApply) { const executorWasLive = !!ds.worker && !ds.worker.killed; const applied = deps.applyFastMode diff --git a/src/core/fast-mode-control.ts b/src/core/fast-mode-control.ts index a45960aca..f4dd5dfef 100644 --- a/src/core/fast-mode-control.ts +++ b/src/core/fast-mode-control.ts @@ -2,7 +2,8 @@ import { randomUUID } from 'node:crypto'; import type { ChildProcess } from 'node:child_process'; import { CodexRpcEngine } from '../codex-rpc-engine.js'; import type { CliId } from '../adapters/cli/types.js'; -import type { FastModeApplyResult } from '../types.js'; +import type { BackendType } from '../adapters/backend/types.js'; +import type { DaemonToWorker, FastModeApplyResult } from '../types.js'; import { cancelFastModeResult, waitForFastModeResult, @@ -10,6 +11,17 @@ import { export type FastModeAction = 'toggle' | 'on' | 'off' | 'status' | 'invalid'; +/** A persisted Session state is trustworthy only after the executor confirmed + * that exact ON/OFF target. Enabled state additionally requires the concrete + * model-catalog tier used to launch future native processes. */ +export function fastModeStateNeedsReconciliation(input: { + enabled: boolean; + serviceTier?: string; + stateVersion?: 1; +}): boolean { + return input.stateVersion !== 1 || (input.enabled && !input.serviceTier); +} + /** Parse the public `/fast` surface once so daemon routing and the command * handler cannot disagree about which invocations may create a Session. */ export function parseFastModeAction(content: string): FastModeAction { @@ -27,8 +39,9 @@ export function fastModeSessionSupported(input: { cliId: CliId; wrapperCli?: string; adopted?: boolean; + backendType?: BackendType; }): boolean { - if (input.cliId !== 'codex' || input.adopted) return false; + if (input.cliId !== 'codex' || input.adopted || input.backendType === 'riff') return false; return input.wrapperCli?.trim().split(/\s+/)[0] !== 'aiden'; } @@ -80,10 +93,20 @@ export async function requestWorkerFastModeChange( return { ok: false, reason: 'not_ready' }; } const requestId = randomUUID(); - const result = waitForFastModeResult(requestId, timeoutMs); + const result = waitForFastModeResult(requestId, timeoutMs, () => { + if (worker.killed || worker.connected === false) return; + try { + worker.send({ + type: 'cancel_fast_mode', + requestId, + } satisfies DaemonToWorker); + } catch { + // The daemon-side waiter is already resolving fail-closed. + } + }); try { worker.send( - { type: 'set_fast_mode', requestId, enabled }, + { type: 'set_fast_mode', requestId, enabled } satisfies DaemonToWorker, error => { if (error) cancelFastModeResult(requestId); }, diff --git a/src/core/fast-mode-handshake.ts b/src/core/fast-mode-handshake.ts index cbdd6acd1..1384b38be 100644 --- a/src/core/fast-mode-handshake.ts +++ b/src/core/fast-mode-handshake.ts @@ -12,6 +12,7 @@ const pendingResults = new Map<string, PendingFastModeResult>(); export function waitForFastModeResult( requestId: string, timeoutMs: number, + onTimeout?: () => void, ): Promise<FastModeApplyResult> { return new Promise(resolve => { const previous = pendingResults.get(requestId); @@ -21,6 +22,7 @@ export function waitForFastModeResult( } const timer = setTimeout(() => { pendingResults.delete(requestId); + try { onTimeout?.(); } catch { /* best-effort worker cancellation */ } resolve({ ok: false, reason: 'not_ready' }); }, timeoutMs); pendingResults.set(requestId, { resolve, timer }); diff --git a/src/core/fast-mode-restart-watchdog.ts b/src/core/fast-mode-restart-watchdog.ts new file mode 100644 index 000000000..c00ddd978 --- /dev/null +++ b/src/core/fast-mode-restart-watchdog.ts @@ -0,0 +1,46 @@ +export const FAST_MODE_RESTART_ACK_TIMEOUT_MS = 110_000; + +type ActiveWatchdog = { + requestId: string; + timer: ReturnType<typeof setTimeout>; +}; + +/** One native Fast replacement may wait for a prompt at a time. The request id + * fences stale clears/cancels so a late event from transaction N cannot release + * transaction N+1. */ +export class FastModeRestartWatchdog { + private active?: ActiveWatchdog; + + constructor( + private readonly timeoutMs = FAST_MODE_RESTART_ACK_TIMEOUT_MS, + ) {} + + arm( + requestId: string, + onTimeout: (requestId: string) => void | Promise<void>, + ): void { + if (this.active) { + throw new Error(`Fast Mode restart watchdog already armed for ${this.active.requestId}`); + } + const timer = setTimeout(() => { + if (this.active?.requestId !== requestId) return; + this.active = undefined; + void onTimeout(requestId); + }, this.timeoutMs); + timer.unref?.(); + this.active = { requestId, timer }; + } + + clear(requestId: string): boolean { + if (this.active?.requestId !== requestId) return false; + clearTimeout(this.active.timer); + this.active = undefined; + return true; + } + + dispose(): void { + if (!this.active) return; + clearTimeout(this.active.timer); + this.active = undefined; + } +} diff --git a/src/daemon.ts b/src/daemon.ts index 88209b692..e1effbfec 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -199,7 +199,7 @@ import { resolvePairedSpawnBackendType, type PersistentBackendType, } from './core/persistent-backend.js'; -import type { PersistentBackendTarget } from './adapters/backend/types.js'; +import type { BackendType, PersistentBackendTarget } from './adapters/backend/types.js'; import { handleCardAction, runAutoWorktreeCommit } from './im/lark/card-handler.js'; import type { CardActionData, CardHandlerDeps } from './im/lark/card-handler.js'; import { @@ -3776,12 +3776,14 @@ function fastModeTargetConfig(larkAppId: string, ds?: DaemonSession): { cliPathOverride?: string; wrapperCli?: string; model?: string; + backendType: BackendType; env?: Record<string, string | number | boolean>; } { const botCfg = getBot(larkAppId).config; const frozen = ds?.session.agentFrozen === true; + const cliId = ds?.session.cliId ?? botCfg.cliId; return { - cliId: ds?.session.cliId ?? botCfg.cliId, + cliId, cliPathOverride: frozen ? ds?.session.cliPathOverride : (ds?.session.cliPathOverride ?? botCfg.cliPathOverride), @@ -3791,6 +3793,12 @@ function fastModeTargetConfig(larkAppId: string, ds?: DaemonSession): { model: frozen ? ds?.session.model : (ds?.session.model ?? botCfg.model), + backendType: resolvePairedSpawnBackendType( + cliId, + ds?.session.backendType, + botCfg.backendType, + config.daemon.backendType, + ), env: botCfg.env, }; } @@ -3805,6 +3813,7 @@ async function probeFastModeForTarget( cliId: target.cliId, wrapperCli: target.wrapperCli, adopted: !!ds?.adoptedFrom, + backendType: target.backendType, })) { return { ok: false, reason: 'unsupported_session' }; } @@ -3832,6 +3841,7 @@ async function applyFastModeForSession( cliId: target.cliId, wrapperCli: target.wrapperCli, adopted: !!ds.adoptedFrom, + backendType: target.backendType, })) { return { ok: false, reason: 'unsupported_session' }; } @@ -15337,6 +15347,7 @@ async function handleNewTopic(data: any, ctx: RoutingContext): Promise<void> { if (!fastModeSessionSupported({ cliId: target.cliId, wrapperCli: target.wrapperCli, + backendType: target.backendType, })) { await sessionReply(anchor, tr('cmd.fast.unsupported', undefined, localeForBot(larkAppId)), 'text', larkAppId); return; @@ -16199,6 +16210,7 @@ async function handleThreadReply(data: any, ctx: RoutingContext): Promise<void> if (!fastModeSessionSupported({ cliId: target.cliId, wrapperCli: target.wrapperCli, + backendType: target.backendType, })) { await sessionReply(anchor, tr('cmd.fast.unsupported', undefined, localeForBot(larkAppId)), 'text', larkAppId); return; diff --git a/src/types.ts b/src/types.ts index 76e72bfdb..42a9f6619 100644 --- a/src/types.ts +++ b/src/types.ts @@ -614,6 +614,9 @@ export type DaemonToWorker = * accepted it. Unlike raw_input this is queued across startup/restart gates * and updates the worker's restart config before success is reported. */ | { type: 'set_fast_mode'; requestId: string; enabled: boolean } + /** Cancel the exact still-queued or native-restart Fast transaction after + * the daemon-side waiter expires. Stale request ids are ignored. */ + | { type: 'cancel_fast_mode'; requestId: string } /** Rename the current CLI-native interactive session. The worker queues this * administrative slash command until the TUI is idle and does not treat it * as a model turn. Only adapters declaring buildSessionRenameCommand handle diff --git a/src/worker.ts b/src/worker.ts index 81c2e7090..091399ac7 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -253,8 +253,10 @@ import { import { CodexRpcEngine } from './codex-rpc-engine.js'; import { fastModeSessionSupported, + fastModeStateNeedsReconciliation, probeCodexFastServiceTier, } from './core/fast-mode-control.js'; +import { FastModeRestartWatchdog } from './core/fast-mode-restart-watchdog.js'; // A worker must never trust an INHERITED session-level CLI home pointer // (CLAUDE_CONFIG_DIR / CODEX_HOME): a stale pm2 dump can resurrect the daemon @@ -406,6 +408,7 @@ async function resolveWorkerFastServiceTier( cliId: cfg.cliId as CliId, wrapperCli: cfg.wrapperCli, adopted: cfg.adoptMode, + backendType: cfg.backendType, })) { return { ok: false, reason: 'unsupported_session' }; } @@ -1528,18 +1531,20 @@ const pendingRawInputs: Array<Extract<DaemonToWorker, { type: 'raw_input' }>> = * Requests survive startup/restart gates and run before later user messages. */ const pendingFastModeChanges: Array<Extract<DaemonToWorker, { type: 'set_fast_mode' }>> = []; let fastModeChangeInFlight = false; -let pendingRestartFastModeAck: { +type PendingRestartFastModeAck = { request: Extract<DaemonToWorker, { type: 'set_fast_mode' }>; serviceTier?: string; - previousFastMode: boolean; + previousFastMode?: boolean; previousServiceTier?: string; previousStateVersion?: 1; -} | null = null; +}; +let pendingRestartFastModeAck: PendingRestartFastModeAck | null = null; +const fastModeRestartWatchdog = new FastModeRestartWatchdog(); /** A cold or legacy native Fast setting is only executor-confirmed after the * explicitly-tiered replacement process reaches a real prompt. */ let pendingInitialFastModeConfirmation: { - enabled: true; - serviceTier: string; + enabled: boolean; + serviceTier?: string; } | null = null; /** Latest requested canonical session title. Unlike a normal prompt this is an * administrative TUI command: never type-ahead while the agent is busy, never @@ -5163,6 +5168,46 @@ function continueAfterFastModeChange(): void { }); } +function restorePendingFastModeConfig(pending: PendingRestartFastModeAck): void { + if (!lastInitConfig) return; + lastInitConfig.fastMode = pending.previousFastMode; + lastInitConfig.fastServiceTier = pending.previousServiceTier; + lastInitConfig.fastModeStateVersion = pending.previousStateVersion; +} + +/** Cancel only the exact native replacement transaction. Detaching the pending + * ACK first fences any late prompt from committing it; the compensating restart + * then converges the actual executor back to the previously confirmed config. */ +async function abortPendingNativeFastModeChange( + requestId: string, + source: 'worker_watchdog' | 'daemon_timeout', +): Promise<boolean> { + const pending = pendingRestartFastModeAck; + if (!pending || pending.request.requestId !== requestId) return false; + + fastModeRestartWatchdog.clear(requestId); + pendingRestartFastModeAck = null; + restorePendingFastModeConfig(pending); + const message = source === 'worker_watchdog' + ? 'Fast Mode replacement did not reach a prompt before the worker deadline' + : 'Fast Mode replacement was cancelled after the daemon deadline'; + log(`WARN ${message}; restoring previous executor state (request=${requestId.slice(0, 8)})`); + rejectFastModeChange(pending.request, 'apply_failed', message); + + try { + await restartCliProcess( + 'Fast Mode timed out; restoring previous executor state', + { immediate: true, preservePending: true, skipRestartBudget: true }, + ); + } catch (error) { + log(`WARN Fast Mode rollback restart failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + fastModeChangeInFlight = false; + continueAfterFastModeChange(); + } + return true; +} + /** Apply one Fast Mode request at an idle prompt. RPC mode uses the protocol's * acknowledged settings update. Native mode restarts/resumes with an explicit * catalog tier instead of injecting the toggle-only `/fast` command; this makes @@ -5179,6 +5224,7 @@ async function flushPendingFastModeChanges(): Promise<void> { cliId: lastInitConfig.cliId as CliId, wrapperCli: lastInitConfig.wrapperCli, adopted: lastInitConfig.adoptMode, + backendType: lastInitConfig.backendType, })) { rejectFastModeChange(request, 'unsupported_session'); fastModeChangeInFlight = false; @@ -5225,7 +5271,7 @@ async function flushPendingFastModeChanges(): Promise<void> { pendingRestartFastModeAck = { request, serviceTier, - previousFastMode: lastInitConfig.fastMode === true, + previousFastMode: lastInitConfig.fastMode, previousServiceTier: lastInitConfig.fastServiceTier, previousStateVersion: lastInitConfig.fastModeStateVersion, }; @@ -5234,17 +5280,23 @@ async function flushPendingFastModeChanges(): Promise<void> { lastInitConfig.fastMode = request.enabled; lastInitConfig.fastServiceTier = request.enabled ? serviceTier : undefined; lastInitConfig.fastModeStateVersion = undefined; + fastModeRestartWatchdog.arm(request.requestId, async requestId => { + await abortPendingNativeFastModeChange(requestId, 'worker_watchdog'); + }); await restartCliProcess( `Fast Mode ${request.enabled ? 'enable' : 'disable'} reconciliation`, { immediate: true, preservePending: true, skipRestartBudget: true }, ); - log(`Fast Mode replacement launched; waiting for prompt ACK (${request.enabled ? 'on' : 'off'})`); - } catch (error) { - if (pendingRestartFastModeAck && lastInitConfig) { - lastInitConfig.fastMode = pendingRestartFastModeAck.previousFastMode; - lastInitConfig.fastServiceTier = pendingRestartFastModeAck.previousServiceTier; - lastInitConfig.fastModeStateVersion = pendingRestartFastModeAck.previousStateVersion; + if (pendingRestartFastModeAck?.request.requestId === request.requestId) { + log(`Fast Mode replacement launched; waiting for prompt ACK (${request.enabled ? 'on' : 'off'})`); } + } catch (error) { + const pending = pendingRestartFastModeAck; + // A watchdog/cancel may already own completion while an awaited restart + // unwinds. Never reject twice or release its rollback barrier early. + if (!pending || pending.request.requestId !== request.requestId) return; + fastModeRestartWatchdog.clear(request.requestId); + restorePendingFastModeConfig(pending); pendingRestartFastModeAck = null; rejectFastModeChange( request, @@ -5292,6 +5344,7 @@ function markPromptReady(): void { // resumed with the explicit tier and reached a real prompt. if (pendingRestartFastModeAck) { const pending = pendingRestartFastModeAck; + fastModeRestartWatchdog.clear(pending.request.requestId); pendingRestartFastModeAck = null; commitFastModeChange(pending.request, pending.serviceTier); fastModeChangeInFlight = false; @@ -9991,8 +10044,16 @@ process.on('message', async (raw: unknown) => { // then launched/respawned as `codex --remote resume` (codex.ts buildArgs) // against the CURRENT app-server (a fresh port each incarnation). const rpcBackendType = msg.backendType ?? config.daemon.backendType; - const fastModeNeedsReconciliation = msg.fastMode === true - && (msg.fastModeStateVersion !== 1 || !msg.fastServiceTier); + const fastModeNeedsReconciliation = fastModeSessionSupported({ + cliId: msg.cliId as CliId, + wrapperCli: msg.wrapperCli, + adopted: msg.adoptMode, + backendType: msg.backendType, + }) && fastModeStateNeedsReconciliation({ + enabled: msg.fastMode === true, + serviceTier: msg.fastServiceTier, + stateVersion: msg.fastModeStateVersion, + }); let rpcPluginGenerationPrepared = false; const rpcDecision = await orchestrateCodexRpcInit(msg, { paneInfo: (sid) => persistentPaneInfo(rpcBackendType, sid), @@ -10018,17 +10079,18 @@ process.on('message', async (raw: unknown) => { // it here before buildArgs so a persisted Fast Session can never launch // at default tier. await ensureConfiguredFastServiceTier(msg); - if (msg.fastMode === true && msg.fastServiceTier) { + if (fastModeNeedsReconciliation) { if (codexRpcEngine) { - // thread/start|resume already accepted this concrete tier. - publishFastModeExecutorState(true, msg.fastServiceTier); - } else if (fastModeNeedsReconciliation) { + // thread/start|resume already accepted either the concrete Fast + // tier or null for default. + publishFastModeExecutorState(msg.fastMode === true, msg.fastServiceTier); + } else { // Native launch args are not proof until the replacement reaches a // real prompt. Arm this before spawn so an early ready event cannot // race past the confirmation. pendingInitialFastModeConfirmation = { - enabled: true, - serviceTier: msg.fastServiceTier, + enabled: msg.fastMode === true, + ...(msg.fastServiceTier ? { serviceTier: msg.fastServiceTier } : {}), }; } } @@ -10293,6 +10355,21 @@ process.on('message', async (raw: unknown) => { break; } + case 'cancel_fast_mode': { + const queuedIndex = pendingFastModeChanges.findIndex( + request => request.requestId === msg.requestId, + ); + if (queuedIndex >= 0) { + const [cancelled] = pendingFastModeChanges.splice(queuedIndex, 1); + rejectFastModeChange(cancelled, 'not_ready', 'Fast Mode request expired before execution'); + log(`Cancelled queued Fast Mode change (request=${msg.requestId.slice(0, 8)})`); + continueAfterFastModeChange(); + } else if (!await abortPendingNativeFastModeChange(msg.requestId, 'daemon_timeout')) { + log(`Ignored stale Fast Mode cancellation (request=${msg.requestId.slice(0, 8)})`); + } + break; + } + case 'raw_input': { // Preserve legacy busy delivery (/btw and other steering commands). A // native /rename and an owned CLI restart are the exceptions: never splice @@ -10703,6 +10780,7 @@ process.on('message', async (raw: unknown) => { function cleanup(): void { stopNativeSessionTitleSync(); + fastModeRestartWatchdog.dispose(); cleanupPiInitialPromptFiles(); stopSessionMcpGatewayHost(); if (tmuxRestartTimer) { diff --git a/test/codex-fast-worker-wiring.test.ts b/test/codex-fast-worker-wiring.test.ts index 0baa86db0..3027e945a 100644 --- a/test/codex-fast-worker-wiring.test.ts +++ b/test/codex-fast-worker-wiring.test.ts @@ -16,9 +16,12 @@ describe('Codex Fast Mode worker wiring', () => { it('passes the live app-server endpoint and thread to the viewer adapter', () => { const start = workerSource.indexOf('const args = cliAdapter.buildArgs({'); - const end = workerSource.indexOf('\\n });', start); + const end = workerSource.indexOf('\n });', start); const region = workerSource.slice(start, end); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(region.length).toBeLessThan(2_000); expect(region).toContain('remoteWsUrl'); expect(region).toContain('remoteThreadId'); expect(region).toContain('fastServiceTier: cfg.fastServiceTier'); @@ -44,6 +47,16 @@ describe('Codex Fast Mode worker wiring', () => { ); }); + it('passes the resolved backend into every worker-side Fast capability gate', () => { + const resolveStart = workerSource.indexOf('async function resolveWorkerFastServiceTier('); + const resolveEnd = workerSource.indexOf('async function ensureConfiguredFastServiceTier(', resolveStart); + expect(workerSource.slice(resolveStart, resolveEnd)).toContain('backendType: cfg.backendType'); + + const runtimeStart = workerSource.indexOf('async function flushPendingFastModeChanges(): Promise<void>'); + const runtimeEnd = workerSource.indexOf('function markPromptReady(): void', runtimeStart); + expect(workerSource.slice(runtimeStart, runtimeEnd)).toContain('backendType: lastInitConfig.backendType'); + }); + it('persists the concrete tier across daemon/worker restarts', () => { expect(workerPoolSource).toContain("fastServiceTier: agentCfg.cliId === 'codex' ? ds.session.fastServiceTier : undefined"); expect(workerPoolSource).toContain("fastModeStateVersion: agentCfg.cliId === 'codex' ? ds.session.fastModeStateVersion : undefined"); @@ -53,12 +66,12 @@ describe('Codex Fast Mode worker wiring', () => { expect(workerPoolSource).toContain('ds.session.fastModeStateVersion = 1'); }); - it('reconciles an unconfirmed legacy Fast pane exactly once', () => { + it('reconciles an unconfirmed legacy Fast state in either direction exactly once', () => { const initStart = workerSource.indexOf("case 'init':"); const initEnd = workerSource.indexOf('// Queue the initial prompt', initStart); const init = workerSource.slice(initStart, initEnd); - expect(init).toContain('const fastModeNeedsReconciliation = msg.fastMode === true'); - expect(init).toContain('msg.fastModeStateVersion !== 1'); + expect(init).toContain('}) && fastModeStateNeedsReconciliation({'); + expect(init).toContain('enabled: msg.fastMode === true'); expect(init).toContain('forceFreshPersistent: fastModeNeedsReconciliation && !codexRpcEngine'); expect(init).toContain('pendingInitialFastModeConfirmation ='); @@ -74,4 +87,27 @@ describe('Codex Fast Mode worker wiring', () => { expect(ready).toContain('pendingInitialFastModeConfirmation'); expect(ready).toContain('publishFastModeExecutorState'); }); + + it('bounds and cancels a native replacement without allowing a late prompt to commit it', () => { + expect(workerSource).toContain("case 'cancel_fast_mode':"); + expect(workerSource).toContain('fastModeRestartWatchdog.arm(request.requestId'); + + const abortStart = workerSource.indexOf('async function abortPendingNativeFastModeChange('); + const abortEnd = workerSource.indexOf('\n}\n\n/** Apply one Fast Mode request', abortStart); + const abort = workerSource.slice(abortStart, abortEnd); + expect(abortStart).toBeGreaterThan(-1); + expect(abortEnd).toBeGreaterThan(abortStart); + expect(abort).toContain('pending.request.requestId !== requestId'); + expect(abort).toContain('pendingRestartFastModeAck = null'); + expect(abort).toContain('restorePendingFastModeConfig(pending)'); + expect(abort).toContain('await restartCliProcess('); + expect(abort).toContain('rejectFastModeChange('); + expect(abort).toContain('fastModeChangeInFlight = false'); + expect(abort).toContain('continueAfterFastModeChange()'); + + const readyStart = workerSource.indexOf('function markPromptReady(): void'); + const readyEnd = workerSource.indexOf('clearSessionRenameInFlight();', readyStart); + const ready = workerSource.slice(readyStart, readyEnd); + expect(ready).toContain('fastModeRestartWatchdog.clear(pending.request.requestId)'); + }); }); diff --git a/test/command-handler.test.ts b/test/command-handler.test.ts index 494109e73..c72bb8d04 100644 --- a/test/command-handler.test.ts +++ b/test/command-handler.test.ts @@ -1654,6 +1654,15 @@ describe('handleCommand', () => { expect(deps.applyFastMode).toHaveBeenCalledWith(ds, true); }); + it('re-applies a disabled legacy Session until an executor confirms default tier', async () => { + const ds = makeCodexFastSession(false); + const deps = makeDeps(ds); + + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast off'), deps, 'app-2'); + + expect(deps.applyFastMode).toHaveBeenCalledWith(ds, false); + }); + it('persists and reports success only after the worker ACKs the change', async () => { const ds = makeCodexFastSession(false); const deps = makeDeps(ds); @@ -1722,6 +1731,7 @@ describe('handleCommand', () => { it('keeps /fast off idempotent and does not toggle the native CLI', async () => { const ds = makeCodexFastSession(false); + ds.session.fastModeStateVersion = 1; const deps = makeDeps(ds); await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast off'), deps, 'app-2'); @@ -1786,6 +1796,20 @@ describe('handleCommand', () => { 'app-2', 'msg_001', ); + + const riff = makeCodexFastSession(false); + riff.session.cliId = 'riff'; + riff.session.backendType = 'riff'; + const riffDeps = makeDeps(riff); + await handleCommand('/fast', ROOT_ID, makeLarkMessage('/fast on'), riffDeps, 'app-2'); + expect(riffDeps.applyFastMode).not.toHaveBeenCalled(); + expect(riffDeps.sessionReply).toHaveBeenCalledWith( + ROOT_ID, + expect.stringContaining('仅支持由 Botmux 管理'), + undefined, + 'app-2', + 'msg_001', + ); }); }); diff --git a/test/daemon-rename-route.test.ts b/test/daemon-rename-route.test.ts index 12f3a8138..5edf18e36 100644 --- a/test/daemon-rename-route.test.ts +++ b/test/daemon-rename-route.test.ts @@ -363,6 +363,28 @@ describe('/rename production routing — must not pre-create a session (review P expect(repliedText()).toContain('仅支持由 Botmux 管理'); }); + it('Riff-backed /fast on reports unsupported without probing or creating a session', async () => { + const bot = registerBot({ + larkAppId: APP, + larkAppSecret: 's', + cliId: 'riff', + backendType: 'riff', + allowedUsers: [OWNER], + oncallChats: [{ chatId: CHAT, workingDir: '/tmp' }], + }); + bot.resolvedAllowedUsers = [OWNER]; + + await handleNewTopic( + makeEventData('om_fast_riff', '/fast on'), + makeCtx('om_fast_riff', 'om_fast_riff'), + ); + + expect(mocks.probeCodexFastServiceTier).not.toHaveBeenCalled(); + expect(mocks.createSession).not.toHaveBeenCalled(); + expect(activeSessions.size).toBe(0); + expect(repliedText()).toContain('仅支持由 Botmux 管理'); + }); + it('valid Codex /fast on preflights the model, then creates exactly one Fast session', async () => { const bot = registerBot({ larkAppId: APP, diff --git a/test/fast-mode-control.test.ts b/test/fast-mode-control.test.ts index 21aec993d..08ec1ebfd 100644 --- a/test/fast-mode-control.test.ts +++ b/test/fast-mode-control.test.ts @@ -2,6 +2,7 @@ import { chmodSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { beforeAll, describe, expect, it } from 'vitest'; import { + fastModeStateNeedsReconciliation, fastModeSessionSupported, parseFastModeAction, probeCodexFastServiceTier, @@ -21,11 +22,31 @@ describe('Fast Mode control', () => { expect(parseFastModeAction('/fast turbo')).toBe('invalid'); }); - it('rejects non-Codex, adopted, and Aiden-wrapped sessions', () => { + it('rejects non-Codex, adopted, Aiden-wrapped, and Riff-backed sessions', () => { expect(fastModeSessionSupported({ cliId: 'codex' })).toBe(true); expect(fastModeSessionSupported({ cliId: 'claude-code' })).toBe(false); expect(fastModeSessionSupported({ cliId: 'codex', adopted: true })).toBe(false); expect(fastModeSessionSupported({ cliId: 'codex', wrapperCli: 'aiden x codex' })).toBe(false); + expect(fastModeSessionSupported({ cliId: 'codex', backendType: 'riff' })).toBe(false); + }); + + it('requires executor confirmation for both enabled and disabled legacy states', () => { + expect(fastModeStateNeedsReconciliation({ + enabled: false, + })).toBe(true); + expect(fastModeStateNeedsReconciliation({ + enabled: false, + stateVersion: 1, + })).toBe(false); + expect(fastModeStateNeedsReconciliation({ + enabled: true, + serviceTier: 'priority', + stateVersion: 1, + })).toBe(false); + expect(fastModeStateNeedsReconciliation({ + enabled: true, + stateVersion: 1, + })).toBe(true); }); it('resolves the selected model Fast tier from app-server model/list', async () => { @@ -81,4 +102,28 @@ describe('Fast Mode control', () => { serviceTier: 'priority', }); }); + + it('cancels the exact worker transaction when the daemon wait expires', async () => { + const sent: any[] = []; + const worker = { + killed: false, + connected: true, + send(message: unknown, callback?: (error: Error | null) => void) { + sent.push(message); + callback?.(null); + }, + } as any; + + await expect(requestWorkerFastModeChange(worker, true, 5)).resolves.toEqual({ + ok: false, + reason: 'not_ready', + }); + + expect(sent).toHaveLength(2); + expect(sent[0]).toMatchObject({ type: 'set_fast_mode', enabled: true }); + expect(sent[1]).toEqual({ + type: 'cancel_fast_mode', + requestId: sent[0].requestId, + }); + }); }); diff --git a/test/fast-mode-handshake.test.ts b/test/fast-mode-handshake.test.ts index 52d6e0bb9..e16c5156b 100644 --- a/test/fast-mode-handshake.test.ts +++ b/test/fast-mode-handshake.test.ts @@ -31,10 +31,16 @@ describe('Fast Mode IPC handshake', () => { }); it('fails closed on timeout or send cancellation', async () => { - await expect(waitForFastModeResult('req-timeout', 5)).resolves.toEqual({ + const cancelled: string[] = []; + await expect(waitForFastModeResult( + 'req-timeout', + 5, + () => cancelled.push('req-timeout'), + )).resolves.toEqual({ ok: false, reason: 'not_ready', }); + expect(cancelled).toEqual(['req-timeout']); const pending = waitForFastModeResult('req-cancel', 1_000); expect(cancelFastModeResult('req-cancel')).toBe(true); diff --git a/test/fast-mode-restart-watchdog.test.ts b/test/fast-mode-restart-watchdog.test.ts new file mode 100644 index 000000000..987a219b1 --- /dev/null +++ b/test/fast-mode-restart-watchdog.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + FAST_MODE_RESTART_ACK_TIMEOUT_MS, + FastModeRestartWatchdog, +} from '../src/core/fast-mode-restart-watchdog.js'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('Fast Mode native restart watchdog', () => { + it('expires only the active request after the bounded worker deadline', async () => { + vi.useFakeTimers(); + const expired: string[] = []; + const watchdog = new FastModeRestartWatchdog(); + + watchdog.arm('req-active', requestId => expired.push(requestId)); + await vi.advanceTimersByTimeAsync(FAST_MODE_RESTART_ACK_TIMEOUT_MS - 1); + expect(expired).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + expect(expired).toEqual(['req-active']); + expect(watchdog.clear('req-active')).toBe(false); + }); + + it('cannot clear a newer transaction with a stale request id', async () => { + vi.useFakeTimers(); + const expired: string[] = []; + const watchdog = new FastModeRestartWatchdog(25); + + watchdog.arm('req-new', requestId => expired.push(requestId)); + expect(watchdog.clear('req-old')).toBe(false); + await vi.advanceTimersByTimeAsync(25); + + expect(expired).toEqual(['req-new']); + }); + + it('does not expire a transaction confirmed at the prompt', async () => { + vi.useFakeTimers(); + const expired: string[] = []; + const watchdog = new FastModeRestartWatchdog(25); + + watchdog.arm('req-confirmed', requestId => expired.push(requestId)); + expect(watchdog.clear('req-confirmed')).toBe(true); + await vi.advanceTimersByTimeAsync(25); + + expect(expired).toEqual([]); + }); +}); From 009c327807fb07ff06aa81dacca00947ab042a2a Mon Sep 17 00:00:00 2001 From: zhouzhixia <zhouzhixia@bytedance.com> Date: Wed, 29 Jul 2026 22:43:29 +0800 Subject: [PATCH 5/5] fix(codex): fence Fast Mode apply cancellation --- src/codex-rpc-engine.ts | 131 +++++++-- src/core/command-handler.ts | 2 +- src/core/fast-mode-apply-transaction.ts | 55 ++++ src/core/fast-mode-handshake.ts | 5 + src/core/fast-mode-restart-watchdog.ts | 6 +- src/core/worker-pool.ts | 37 ++- src/types.ts | 8 +- src/worker.ts | 337 ++++++++++++++++------- test/codex-fast-worker-wiring.test.ts | 65 +++-- test/codex-rpc-engine.test.ts | 50 +++- test/fast-mode-apply-transaction.test.ts | 26 ++ test/fast-mode-handshake.test.ts | 4 + test/fixtures/fake-codex-rpc-server.mjs | 7 +- 13 files changed, 580 insertions(+), 153 deletions(-) create mode 100644 src/core/fast-mode-apply-transaction.ts create mode 100644 test/fast-mode-apply-transaction.test.ts diff --git a/src/codex-rpc-engine.ts b/src/codex-rpc-engine.ts index e8865c63d..4d1e4e475 100644 --- a/src/codex-rpc-engine.ts +++ b/src/codex-rpc-engine.ts @@ -32,6 +32,20 @@ import { WebSocket } from 'ws'; type Json = Record<string, any>; type LogFn = (msg: string) => void; +class CodexRpcRequestAbortedError extends Error { + constructor(method: string) { + super(`codex app-server request '${method}' was cancelled`); + this.name = 'CodexRpcRequestAbortedError'; + } +} + +export class CodexRpcFastModeCancelledError extends Error { + constructor(message = 'Codex Fast Mode update was cancelled') { + super(message); + this.name = 'CodexRpcFastModeCancelledError'; + } +} + async function findFreePort(): Promise<number> { return new Promise<number>((resolve, reject) => { const srv = createServer(); @@ -207,7 +221,10 @@ export class CodexRpcEngine { * Codex 0.145 deliberately selects by tier name and sends the catalog's id * (currently `priority` for OpenAI models), so callers must never hardcode * `fast` as the protocol value. */ - async resolveFastServiceTier(model = this.opts.model): Promise<{ model: string; serviceTier: string } | undefined> { + async resolveFastServiceTier( + model = this.opts.model, + opts?: { signal?: AbortSignal }, + ): Promise<{ model: string; serviceTier: string } | undefined> { let cursor: string | null | undefined; const models: Json[] = []; do { @@ -215,7 +232,7 @@ export class CodexRpcEngine { cursor: cursor ?? null, limit: 100, includeHidden: true, - }); + }, { signal: opts?.signal }); if (Array.isArray(result?.data)) models.push(...result.data); cursor = typeof result?.nextCursor === 'string' && result.nextCursor ? result.nextCursor @@ -254,24 +271,73 @@ export class CodexRpcEngine { /** Change the loaded thread's tier for subsequent turns. State is committed * only after app-server acknowledges `thread/settings/update`, so the daemon * can persist exactly what the executor accepted. */ - async setFastMode(enabled: boolean): Promise<{ enabled: boolean; serviceTier?: string }> { + async setFastMode( + enabled: boolean, + opts?: { signal?: AbortSignal }, + ): Promise<{ enabled: boolean; serviceTier?: string }> { if (!this.threadId) throw new Error('setFastMode before startThread/resumeThread'); + const previousTier = this.serviceTier; let nextTier: string | undefined; - if (enabled) { - const resolved = await this.resolveFastServiceTier(); - if (!resolved) { - throw new Error(`Fast Mode is not supported by model ${this.opts.model ?? '(default)'}`); + let settingsUpdateDispatched = false; + try { + if (opts?.signal?.aborted) throw new CodexRpcRequestAbortedError('thread/settings/update'); + if (enabled) { + const resolved = await this.resolveFastServiceTier(this.opts.model, opts); + if (!resolved) { + throw new Error(`Fast Mode is not supported by model ${this.opts.model ?? '(default)'}`); + } + nextTier = resolved.serviceTier; } - nextTier = resolved.serviceTier; + if (opts?.signal?.aborted) throw new CodexRpcRequestAbortedError('thread/settings/update'); + await this.request('thread/settings/update', { + threadId: this.threadId, + serviceTier: nextTier ?? null, + }, { signal: opts?.signal }, () => { + settingsUpdateDispatched = true; + }); + this.serviceTier = nextTier; + if (opts?.signal?.aborted) throw new CodexRpcRequestAbortedError('thread/settings/update'); + return nextTier + ? { enabled: true, serviceTier: nextTier } + : { enabled: false }; + } catch (error) { + const cancelled = opts?.signal?.aborted || error instanceof CodexRpcRequestAbortedError; + if (!cancelled) throw error; + + // Once the settings frame is on the socket, cancellation is ambiguous: + // app-server may have accepted it even if its ACK has not arrived. Send + // the previous tier on the same ordered connection and wait for that ACK + // before reporting cancellation to the worker. + if (settingsUpdateDispatched) { + try { + await this.restoreFastModeServiceTier(previousTier); + } catch (rollbackError) { + const message = rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError); + throw new CodexRpcFastModeCancelledError( + `Codex Fast Mode update was cancelled, but restoring the previous tier failed: ${message}`, + ); + } + } + throw new CodexRpcFastModeCancelledError(); + } + } + + /** Restore an already-confirmed tier after a cancelled runtime update. */ + async restoreFastModeServiceTier(serviceTier?: string): Promise<void> { + if (!this.threadId) throw new Error('restoreFastModeServiceTier before startThread/resumeThread'); + try { + await this.request('thread/settings/update', { + threadId: this.threadId, + serviceTier: serviceTier ?? null, + }); + this.serviceTier = serviceTier; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.failAll(new Error(`Fast Mode rollback failed: ${message}`)); + throw error; } - await this.request('thread/settings/update', { - threadId: this.threadId, - serviceTier: nextTier ?? null, - }); - this.serviceTier = nextTier; - return nextTier - ? { enabled: true, serviceTier: nextTier } - : { enabled: false }; } /** Inject one user message as a turn. Resolves when the app-server acks the @@ -517,13 +583,33 @@ export class CodexRpcEngine { private request( method: string, params: unknown, - opts?: { timeoutMs?: number; fatalOnTimeout?: boolean }, + opts?: { timeoutMs?: number; fatalOnTimeout?: boolean; signal?: AbortSignal }, onDispatch?: () => void, ): Promise<any> { const timeoutMs = opts?.timeoutMs ?? this.opts.requestTimeoutMs ?? REQUEST_TIMEOUT_MS; const fatalOnTimeout = opts?.fatalOnTimeout !== false; // default fatal const id = this.nextId++; + if (opts?.signal?.aborted) { + return Promise.reject(new CodexRpcRequestAbortedError(method)); + } return new Promise((resolve, reject) => { + const cleanupAbort = (): void => { + opts?.signal?.removeEventListener('abort', onAbort); + }; + const resolveRequest = (value: unknown): void => { + cleanupAbort(); + resolve(value); + }; + const rejectRequest = (error: Error): void => { + cleanupAbort(); + reject(error); + }; + const onAbort = (): void => { + if (!this.pending.has(id)) return; + this.pending.delete(id); + clearTimeout(timer); + rejectRequest(new CodexRpcRequestAbortedError(method)); + }; const timer = setTimeout(() => { if (!this.pending.has(id)) return; const err = new Error(`codex app-server request '${method}' timed out after ${timeoutMs}ms`); @@ -538,16 +624,21 @@ export class CodexRpcEngine { // Non-fatal (the fresh first turn): reject only THIS request and keep // the engine alive, so its ambiguity can be resolved against rollout // persistence and the viewer can still resume if the turn landed (P1-1). - this.pending.delete(id); reject(err); + this.pending.delete(id); rejectRequest(err); } }, timeoutMs); timer.unref?.(); - this.pending.set(id, { resolve, reject, timer }); + this.pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer }); + opts?.signal?.addEventListener('abort', onAbort, { once: true }); // onDispatch fires ONLY after send() succeeds (ws was OPEN + no throw) — the // frame is then on the socket, so any later failure is "dispatched" and must // be treated as ambiguous, never not-sent (Codex P1-1 boundary). try { this.send({ jsonrpc: '2.0', id, method, params }); onDispatch?.(); } - catch (e) { this.pending.delete(id); clearTimeout(timer); reject(e as Error); } + catch (e) { + this.pending.delete(id); + clearTimeout(timer); + rejectRequest(e as Error); + } }); } diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index 2c50f43d3..9bfa8341a 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1970,7 +1970,7 @@ export async function handleCommand( // A capability probe can stage a cold Session, but it is not an // executor ACK. Leave the marker absent so init reconciles before // trusting any persistent pane. A live worker publishes version 1 - // through fast_mode_state before its result ACK. + // atomically through its exact fast_mode_result before the waiter resolves. if (!executorWasLive) ds.session.fastModeStateVersion = undefined; if (ds.initConfig) { ds.initConfig.fastMode = enabled; diff --git a/src/core/fast-mode-apply-transaction.ts b/src/core/fast-mode-apply-transaction.ts new file mode 100644 index 000000000..3d5157b99 --- /dev/null +++ b/src/core/fast-mode-apply-transaction.ts @@ -0,0 +1,55 @@ +export type FastModeCancellationSource = 'worker_deadline' | 'daemon_timeout'; + +export class FastModeApplyCancelledError extends Error { + constructor( + readonly requestId: string, + readonly source: FastModeCancellationSource, + ) { + super(`Fast Mode apply transaction ${requestId} was cancelled (${source})`); + this.name = 'FastModeApplyCancelledError'; + } +} + +/** + * Lifetime guard for one exact Fast Mode request. + * + * The worker creates this object immediately after dequeuing the request. Every + * asynchronous result must pass through waitFor() before it can mutate executor + * or persisted state, so cancellation covers the applying window as well as the + * later native prompt-ACK window. + */ +export class FastModeApplyTransaction { + private readonly abortController = new AbortController(); + private cancellationSource?: FastModeCancellationSource; + + constructor(readonly requestId: string) {} + + get signal(): AbortSignal { + return this.abortController.signal; + } + + get cancelledBy(): FastModeCancellationSource | undefined { + return this.cancellationSource; + } + + cancel(requestId: string, source: FastModeCancellationSource): boolean { + if (requestId !== this.requestId) return false; + if (!this.cancellationSource) { + this.cancellationSource = source; + this.abortController.abort(); + } + return true; + } + + assertActive(): void { + if (this.cancellationSource) { + throw new FastModeApplyCancelledError(this.requestId, this.cancellationSource); + } + } + + async waitFor<T>(operation: Promise<T>): Promise<T> { + const result = await operation; + this.assertActive(); + return result; + } +} diff --git a/src/core/fast-mode-handshake.ts b/src/core/fast-mode-handshake.ts index 1384b38be..819ab346f 100644 --- a/src/core/fast-mode-handshake.ts +++ b/src/core/fast-mode-handshake.ts @@ -7,6 +7,11 @@ type PendingFastModeResult = { const pendingResults = new Map<string, PendingFastModeResult>(); +/** Read-only exact-request fence for executor-state persistence. */ +export function isFastModeResultPending(requestId: string): boolean { + return pendingResults.has(requestId); +} + /** Register before sending set_fast_mode. Timeout is deliberately fail-closed: * the daemon must never persist or report a state the worker did not ACK. */ export function waitForFastModeResult( diff --git a/src/core/fast-mode-restart-watchdog.ts b/src/core/fast-mode-restart-watchdog.ts index c00ddd978..592f5b287 100644 --- a/src/core/fast-mode-restart-watchdog.ts +++ b/src/core/fast-mode-restart-watchdog.ts @@ -5,9 +5,9 @@ type ActiveWatchdog = { timer: ReturnType<typeof setTimeout>; }; -/** One native Fast replacement may wait for a prompt at a time. The request id - * fences stale clears/cancels so a late event from transaction N cannot release - * transaction N+1. */ +/** One Fast apply transaction may be active at a time. The worker arms this at + * dequeue, before native probing or RPC work; the request id fences stale + * clears/cancels so transaction N cannot release transaction N+1. */ export class FastModeRestartWatchdog { private active?: ActiveWatchdog; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 7473b0828..32aa69332 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -111,7 +111,10 @@ import { extractBotmuxLarkNativeSessionTitlePrompt, } from './session-title.js'; import { acknowledgeSessionReady } from './session-ready-handshake.js'; -import { acknowledgeFastModeResult } from './fast-mode-handshake.js'; +import { + acknowledgeFastModeResult, + isFastModeResultPending, +} from './fast-mode-handshake.js'; import { recordDispatchInputCommit } from './dispatch.js'; type WindowsForkOptions = ForkOptions & { windowsHide?: boolean }; @@ -2719,18 +2722,46 @@ function setupWorkerHandlers( break; } case 'fast_mode_result': { - if (ds.worker !== worker) { + if ( + ds.worker !== worker + || ds.workerGeneration !== workerGeneration + || ds.session.workerGeneration !== workerGeneration + ) { logger.warn(`[${t}] Ignored fast_mode_result from stale worker generation`); break; } + // Persistence and waiter resolution form one exact-request commit. A + // result arriving after the daemon deadline cannot mutate the Session, + // even when it came from the current worker generation. + if (!isFastModeResultPending(msg.requestId)) { + logger.warn(`[${t}] Ignored stale fast_mode_result request=${msg.requestId.slice(0, 8)}`); + break; + } + if (msg.ok) { + ds.session.fastMode = msg.enabled; + ds.session.fastServiceTier = msg.serviceTier; + ds.session.fastModeStateVersion = 1; + if (ds.initConfig) { + ds.initConfig.fastMode = msg.enabled; + ds.initConfig.fastServiceTier = msg.serviceTier; + ds.initConfig.fastModeStateVersion = 1; + } + sessionStore.updateSession(ds.session); + } acknowledgeFastModeResult(msg); break; } case 'fast_mode_state': { - if (ds.worker !== worker) { + if ( + ds.worker !== worker + || ds.workerGeneration !== workerGeneration + || ds.session.workerGeneration !== workerGeneration + ) { logger.warn(`[${t}] Ignored fast_mode_state from stale worker generation`); break; } + // Only cold/legacy launch reconciliation has no command requestId. + // Runtime changes commit through the exact fast_mode_result case above. ds.session.fastMode = msg.enabled; ds.session.fastServiceTier = msg.serviceTier; ds.session.fastModeStateVersion = 1; diff --git a/src/types.ts b/src/types.ts index cc4496c70..91cc22f3a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -616,8 +616,8 @@ export type DaemonToWorker = * accepted it. Unlike raw_input this is queued across startup/restart gates * and updates the worker's restart config before success is reported. */ | { type: 'set_fast_mode'; requestId: string; enabled: boolean } - /** Cancel the exact still-queued or native-restart Fast transaction after - * the daemon-side waiter expires. Stale request ids are ignored. */ + /** Cancel the exact queued or active Fast transaction after the daemon-side + * waiter expires. The active lifetime begins when the worker dequeues it. */ | { type: 'cancel_fast_mode'; requestId: string } /** Rename the current CLI-native interactive session. The worker queues this * administrative slash command until the TUI is idle and does not treat it @@ -697,8 +697,8 @@ export type WorkerToDaemon = | { type: 'session_ready_ack'; requestId: string } | { type: 'fast_mode_result'; requestId: string; ok: true; enabled: boolean; serviceTier?: string } | { type: 'fast_mode_result'; requestId: string; ok: false; reason: FastModeFailureReason; message?: string } - /** Executor-confirmed Fast Mode state. The daemon persists it with the - * current state version so later worker generations can safely reattach. */ + /** Executor-confirmed state discovered during cold/legacy reconciliation. + * Runtime command state is committed through exact-request fast_mode_result. */ | { type: 'fast_mode_state'; enabled: boolean; serviceTier?: string } | { type: 'screen_update'; content: string; status: ScreenStatus; usageLimit?: CliUsageLimitState; turnId?: string; dispatchAttempt?: number } | { type: 'error'; message: string; turnId?: string; dispatchAttempt?: number } diff --git a/src/worker.ts b/src/worker.ts index 441f9c0c1..b7ad01bf6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -267,6 +267,10 @@ import { fastModeStateNeedsReconciliation, probeCodexFastServiceTier, } from './core/fast-mode-control.js'; +import { + FastModeApplyTransaction, + type FastModeCancellationSource, +} from './core/fast-mode-apply-transaction.js'; import { FastModeRestartWatchdog } from './core/fast-mode-restart-watchdog.js'; // A worker must never trust an INHERITED session-level CLI home pointer @@ -1582,16 +1586,26 @@ const pendingRawInputs = freshnessInputQueue.raw; /** Fast Mode is a Session configuration barrier, not ordinary terminal input. * Requests survive startup/restart gates and run before later user messages. */ const pendingFastModeChanges: Array<Extract<DaemonToWorker, { type: 'set_fast_mode' }>> = []; -let fastModeChangeInFlight = false; -type PendingRestartFastModeAck = { +type ActiveFastModeChange = { + transaction: FastModeApplyTransaction; request: Extract<DaemonToWorker, { type: 'set_fast_mode' }>; + phase: + | 'applying' + | 'restarting_native' + | 'awaiting_native_prompt' + | 'rolling_back_wait' + | 'rolling_back_prompt'; serviceTier?: string; previousFastMode?: boolean; previousServiceTier?: string; previousStateVersion?: 1; + previousRpcServiceTier?: string; + resultSettled: boolean; + rollbackPromise?: Promise<void>; + resolveRollback?: () => void; }; -let pendingRestartFastModeAck: PendingRestartFastModeAck | null = null; -const fastModeRestartWatchdog = new FastModeRestartWatchdog(); +let activeFastModeChange: ActiveFastModeChange | null = null; +const fastModeApplyWatchdog = new FastModeRestartWatchdog(); /** A cold or legacy native Fast setting is only executor-confirmed after the * explicitly-tiered replacement process reaches a real prompt. */ let pendingInitialFastModeConfirmation: { @@ -5246,7 +5260,7 @@ function markPromptReadyFromPty(): void { } } -function publishFastModeExecutorState( +function updateWorkerFastModeExecutorState( enabled: boolean, serviceTier?: string, ): void { @@ -5254,10 +5268,15 @@ function publishFastModeExecutorState( lastInitConfig.fastMode = enabled; lastInitConfig.fastServiceTier = enabled ? serviceTier : undefined; lastInitConfig.fastModeStateVersion = 1; - // Publish the accepted executor state before resolving the command waiter. - // IPC preserves ordering, so persistence happens before the success reply; - // a late ACK after the daemon-side wait deadline still cannot leave the - // worker and Session store split-brained. +} + +/** Initial/legacy reconciliation has no daemon command waiter. Runtime changes + * are persisted atomically from their exact fast_mode_result requestId. */ +function publishInitialFastModeExecutorState( + enabled: boolean, + serviceTier?: string, +): void { + updateWorkerFastModeExecutorState(enabled, serviceTier); send({ type: 'fast_mode_state', enabled, @@ -5266,10 +5285,13 @@ function publishFastModeExecutorState( } function commitFastModeChange( - request: Extract<DaemonToWorker, { type: 'set_fast_mode' }>, + active: ActiveFastModeChange, serviceTier?: string, ): void { - publishFastModeExecutorState(request.enabled, serviceTier); + assertActiveFastModeChange(active); + const { request } = active; + updateWorkerFastModeExecutorState(request.enabled, serviceTier); + active.resultSettled = true; send({ type: 'fast_mode_result', requestId: request.requestId, @@ -5277,6 +5299,7 @@ function commitFastModeChange( enabled: request.enabled, ...(request.enabled && serviceTier ? { serviceTier } : {}), }); + finishActiveFastModeChange(active); } function rejectFastModeChange( @@ -5300,42 +5323,126 @@ function continueAfterFastModeChange(): void { }); } -function restorePendingFastModeConfig(pending: PendingRestartFastModeAck): void { +function beginActiveFastModeChange( + request: Extract<DaemonToWorker, { type: 'set_fast_mode' }>, +): ActiveFastModeChange { + const active: ActiveFastModeChange = { + transaction: new FastModeApplyTransaction(request.requestId), + request, + phase: 'applying', + previousFastMode: lastInitConfig?.fastMode, + previousServiceTier: lastInitConfig?.fastServiceTier, + previousStateVersion: lastInitConfig?.fastModeStateVersion, + resultSettled: false, + }; + activeFastModeChange = active; + // The worker deadline owns the entire transaction, beginning at dequeue — + // model probing and RPC ACK waits are not an untracked pre-watchdog window. + fastModeApplyWatchdog.arm(request.requestId, async requestId => { + await cancelActiveFastModeChange(requestId, 'worker_deadline'); + }); + return active; +} + +function isActiveFastModeChange(active: ActiveFastModeChange): boolean { + const current = activeFastModeChange; + return current === active + && current.request.requestId === active.request.requestId; +} + +function assertActiveFastModeChange(active: ActiveFastModeChange): void { + active.transaction.assertActive(); + if (!isActiveFastModeChange(active)) { + throw new Error(`Stale Fast Mode transaction ${active.request.requestId}`); + } +} + +async function waitForActiveFastModeChange<T>( + active: ActiveFastModeChange, + operation: Promise<T>, +): Promise<T> { + const result = await active.transaction.waitFor(operation); + assertActiveFastModeChange(active); + return result; +} + +function finishActiveFastModeChange(active: ActiveFastModeChange): boolean { + if (!isActiveFastModeChange(active)) return false; + fastModeApplyWatchdog.clear(active.request.requestId); + activeFastModeChange = null; + continueAfterFastModeChange(); + return true; +} + +function rejectActiveFastModeChange( + active: ActiveFastModeChange, + reason: 'unsupported_session' | 'unsupported_model' | 'not_ready' | 'apply_failed', + message?: string, +): void { + if (active.resultSettled) return; + active.resultSettled = true; + rejectFastModeChange(active.request, reason, message); +} + +function restoreActiveFastModeConfig(active: ActiveFastModeChange): void { if (!lastInitConfig) return; - lastInitConfig.fastMode = pending.previousFastMode; - lastInitConfig.fastServiceTier = pending.previousServiceTier; - lastInitConfig.fastModeStateVersion = pending.previousStateVersion; + lastInitConfig.fastMode = active.previousFastMode; + lastInitConfig.fastServiceTier = active.previousServiceTier; + lastInitConfig.fastModeStateVersion = active.previousStateVersion; +} + +/** Roll a staged native replacement back on the same serialized transaction. + * Multiple cancellation/error paths share one promise and cannot double-restart. */ +function rollbackActiveNativeFastModeChange( + active: ActiveFastModeChange, +): Promise<void> { + if (active.rollbackPromise) return active.rollbackPromise; + active.phase = 'rolling_back_wait'; + restoreActiveFastModeConfig(active); + active.rollbackPromise = new Promise<void>(resolve => { + active.resolveRollback = resolve; + }); + void (async () => { + try { + // restartCliProcess resolves after scheduling its replacement, while + // cliRestartInProgress remains true until that spawn settles. Never start + // the compensating restart on top of it: wait, ignore the possibly-new- + // tier prompt, then launch one clean old-tier replacement. + while (cliRestartInProgress && isActiveFastModeChange(active)) { + await delay(50); + } + if (!isActiveFastModeChange(active)) return; + active.phase = 'rolling_back_prompt'; + await restartCliProcess( + 'Fast Mode apply failed; restoring previous executor state', + { immediate: true, preservePending: true, skipRestartBudget: true }, + ); + } catch (error) { + log(`WARN Fast Mode rollback restart failed: ${error instanceof Error ? error.message : String(error)}`); + await sendFatalWorkerErrorAndExit(error); + } + })(); + return active.rollbackPromise; } -/** Cancel only the exact native replacement transaction. Detaching the pending - * ACK first fences any late prompt from committing it; the compensating restart - * then converges the actual executor back to the previously confirmed config. */ -async function abortPendingNativeFastModeChange( +/** Mark the exact active request even while an async probe/RPC call is applying. + * Its continuation owns cleanup; a transaction already waiting for a native + * prompt can start the compensating restart immediately. */ +async function cancelActiveFastModeChange( requestId: string, - source: 'worker_watchdog' | 'daemon_timeout', + source: FastModeCancellationSource, ): Promise<boolean> { - const pending = pendingRestartFastModeAck; - if (!pending || pending.request.requestId !== requestId) return false; + const active = activeFastModeChange; + if (!active || !active.transaction.cancel(requestId, source)) return false; - fastModeRestartWatchdog.clear(requestId); - pendingRestartFastModeAck = null; - restorePendingFastModeConfig(pending); - const message = source === 'worker_watchdog' + fastModeApplyWatchdog.clear(requestId); + const message = source === 'worker_deadline' ? 'Fast Mode replacement did not reach a prompt before the worker deadline' : 'Fast Mode replacement was cancelled after the daemon deadline'; log(`WARN ${message}; restoring previous executor state (request=${requestId.slice(0, 8)})`); - rejectFastModeChange(pending.request, 'apply_failed', message); - - try { - await restartCliProcess( - 'Fast Mode timed out; restoring previous executor state', - { immediate: true, preservePending: true, skipRestartBudget: true }, - ); - } catch (error) { - log(`WARN Fast Mode rollback restart failed: ${error instanceof Error ? error.message : String(error)}`); - } finally { - fastModeChangeInFlight = false; - continueAfterFastModeChange(); + rejectActiveFastModeChange(active, 'apply_failed', message); + if (active.phase === 'awaiting_native_prompt') { + await rollbackActiveNativeFastModeChange(active); } return true; } @@ -5345,12 +5452,12 @@ async function abortPendingNativeFastModeChange( * catalog tier instead of injecting the toggle-only `/fast` command; this makes * on/off deterministic and gives the replacement prompt a real ACK boundary. */ async function flushPendingFastModeChanges(): Promise<void> { - if (fastModeChangeInFlight || isFlushing || cliRestartInProgress) return; + if (activeFastModeChange || isFlushing || cliRestartInProgress) return; if (!backend || !cliAdapter || !isPromptReady) return; if (commandLineWritesPending > 0 || injectionFlushing || sessionRenameInFlight) return; const request = pendingFastModeChanges.shift(); if (!request) return; - fastModeChangeInFlight = true; + const active = beginActiveFastModeChange(request); if (!lastInitConfig || !fastModeSessionSupported({ cliId: lastInitConfig.cliId as CliId, @@ -5358,32 +5465,38 @@ async function flushPendingFastModeChanges(): Promise<void> { adopted: lastInitConfig.adoptMode, backendType: lastInitConfig.backendType, })) { - rejectFastModeChange(request, 'unsupported_session'); - fastModeChangeInFlight = false; - continueAfterFastModeChange(); + rejectActiveFastModeChange(active, 'unsupported_session'); + finishActiveFastModeChange(active); return; } + const rpcEngine = codexRpcEngine; try { - if (codexRpcEngine) { - const applied = await codexRpcEngine.setFastMode(request.enabled); - commitFastModeChange(request, applied.serviceTier); - fastModeChangeInFlight = false; - continueAfterFastModeChange(); + if (rpcEngine) { + active.previousRpcServiceTier = rpcEngine.activeServiceTier; + const applied = await waitForActiveFastModeChange( + active, + rpcEngine.setFastMode(request.enabled, { + signal: active.transaction.signal, + }), + ); + commitFastModeChange(active, applied.serviceTier); return; } let serviceTier: string | undefined; if (request.enabled) { - const resolved = await resolveWorkerFastServiceTier(lastInitConfig); + const resolved = await active.transaction.waitFor( + resolveWorkerFastServiceTier(lastInitConfig), + ); + assertActiveFastModeChange(active); if (!resolved.ok || !resolved.serviceTier) { - rejectFastModeChange( - request, + rejectActiveFastModeChange( + active, !resolved.ok ? resolved.reason : 'unsupported_model', !resolved.ok ? resolved.message : undefined, ); - fastModeChangeInFlight = false; - continueAfterFastModeChange(); + finishActiveFastModeChange(active); return; } serviceTier = resolved.serviceTier; @@ -5394,49 +5507,67 @@ async function flushPendingFastModeChanges(): Promise<void> { // a legacy record before ACK. if (lastInitConfig.fastMode === request.enabled && (!request.enabled || lastInitConfig.fastModeStateVersion === 1)) { - commitFastModeChange(request, serviceTier ?? lastInitConfig.fastServiceTier); - fastModeChangeInFlight = false; - continueAfterFastModeChange(); + commitFastModeChange(active, serviceTier ?? lastInitConfig.fastServiceTier); return; } - pendingRestartFastModeAck = { - request, - serviceTier, - previousFastMode: lastInitConfig.fastMode, - previousServiceTier: lastInitConfig.fastServiceTier, - previousStateVersion: lastInitConfig.fastModeStateVersion, - }; + active.serviceTier = serviceTier; + active.phase = 'restarting_native'; // Stage the replacement config before teardown. The daemon does not persist // it until the replacement prompt ACK below. lastInitConfig.fastMode = request.enabled; lastInitConfig.fastServiceTier = request.enabled ? serviceTier : undefined; lastInitConfig.fastModeStateVersion = undefined; - fastModeRestartWatchdog.arm(request.requestId, async requestId => { - await abortPendingNativeFastModeChange(requestId, 'worker_watchdog'); - }); - await restartCliProcess( - `Fast Mode ${request.enabled ? 'enable' : 'disable'} reconciliation`, - { immediate: true, preservePending: true, skipRestartBudget: true }, + await waitForActiveFastModeChange( + active, + restartCliProcess( + `Fast Mode ${request.enabled ? 'enable' : 'disable'} reconciliation`, + { immediate: true, preservePending: true, skipRestartBudget: true }, + ), ); - if (pendingRestartFastModeAck?.request.requestId === request.requestId) { - log(`Fast Mode replacement launched; waiting for prompt ACK (${request.enabled ? 'on' : 'off'})`); - } + active.phase = 'awaiting_native_prompt'; + log(`Fast Mode replacement launched; waiting for prompt ACK (${request.enabled ? 'on' : 'off'})`); } catch (error) { - const pending = pendingRestartFastModeAck; - // A watchdog/cancel may already own completion while an awaited restart - // unwinds. Never reject twice or release its rollback barrier early. - if (!pending || pending.request.requestId !== request.requestId) return; - fastModeRestartWatchdog.clear(request.requestId); - restorePendingFastModeConfig(pending); - pendingRestartFastModeAck = null; - rejectFastModeChange( - request, + // A prompt may have committed while restartCliProcess was unwinding. + if (!isActiveFastModeChange(active)) return; + if (active.transaction.cancelledBy) { + if (rpcEngine) { + // setFastMode compensates when its own AbortSignal wins. This second + // check closes the narrower race where it returned just before the + // worker observed cancellation. + if (rpcEngine.activeServiceTier !== active.previousRpcServiceTier) { + try { + await rpcEngine.restoreFastModeServiceTier(active.previousRpcServiceTier); + } catch (rollbackError) { + log(`WARN Fast Mode RPC rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`); + } + if (!isActiveFastModeChange(active)) return; + } + finishActiveFastModeChange(active); + } else if (active.phase === 'restarting_native' + || active.phase === 'awaiting_native_prompt' + || active.phase === 'rolling_back_wait' + || active.phase === 'rolling_back_prompt') { + await rollbackActiveNativeFastModeChange(active); + } else { + finishActiveFastModeChange(active); + } + return; + } + + rejectActiveFastModeChange( + active, 'apply_failed', error instanceof Error ? error.message : String(error), ); - fastModeChangeInFlight = false; - continueAfterFastModeChange(); + if (!rpcEngine && (active.phase === 'restarting_native' + || active.phase === 'awaiting_native_prompt' + || active.phase === 'rolling_back_wait' + || active.phase === 'rolling_back_prompt')) { + await rollbackActiveNativeFastModeChange(active); + } else { + finishActiveFastModeChange(active); + } } } @@ -5485,18 +5616,27 @@ function markPromptReady(): void { isPromptReady = true; // A non-RPC Fast change is committed only after its replacement CLI has // resumed with the explicit tier and reached a real prompt. - if (pendingRestartFastModeAck) { - const pending = pendingRestartFastModeAck; - fastModeRestartWatchdog.clear(pending.request.requestId); - pendingRestartFastModeAck = null; - commitFastModeChange(pending.request, pending.serviceTier); - fastModeChangeInFlight = false; - log(`Fast Mode replacement confirmed at prompt (${pending.request.enabled ? 'on' : 'off'})`); + if (activeFastModeChange?.phase === 'awaiting_native_prompt' + || activeFastModeChange?.phase === 'restarting_native') { + const active = activeFastModeChange; + if (active.transaction.cancelledBy) { + void rollbackActiveNativeFastModeChange(active); + } else { + commitFastModeChange(active, active.serviceTier); + log(`Fast Mode replacement confirmed at prompt (${active.request.enabled ? 'on' : 'off'})`); + } + } + if (activeFastModeChange?.phase === 'rolling_back_prompt') { + const active = activeFastModeChange; + const resolveRollback = active.resolveRollback; + finishActiveFastModeChange(active); + resolveRollback?.(); + log('Fast Mode rollback confirmed at prompt'); } if (pendingInitialFastModeConfirmation) { const pending = pendingInitialFastModeConfirmation; pendingInitialFastModeConfirmation = null; - publishFastModeExecutorState(pending.enabled, pending.serviceTier); + publishInitialFastModeExecutorState(pending.enabled, pending.serviceTier); log('Initial Fast Mode state confirmed at prompt'); } clearSessionRenameInFlight(); @@ -5561,7 +5701,7 @@ function markPromptReady(): void { // in the card. This avoids a false "就绪" flash on daemon restart // (where the initial prompt is queued before the CLI becomes idle). if (renderer && pendingMessages.length === 0 && pendingRawInputs.length === 0 - && pendingFastModeChanges.length === 0 && !fastModeChangeInFlight + && pendingFastModeChanges.length === 0 && !activeFastModeChange && pendingSessionRename === null && !isFlushing) { const { content } = renderer.snapshot(); send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle'), turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt }); @@ -5882,7 +6022,7 @@ async function flushPending(): Promise<void> { if (isFlushing) return; // while loop in active flush will pick up new messages // A Fast Mode request is a configuration barrier for every later user turn. // Its own queue owns delivery until the executor ACKs it. - if (pendingFastModeChanges.length > 0 || fastModeChangeInFlight) { + if (pendingFastModeChanges.length > 0 || activeFastModeChange) { void flushPendingFastModeChanges(); return; } @@ -10345,7 +10485,7 @@ process.on('message', async (raw: unknown) => { if (codexRpcEngine) { // thread/start|resume already accepted either the concrete Fast // tier or null for default. - publishFastModeExecutorState(msg.fastMode === true, msg.fastServiceTier); + publishInitialFastModeExecutorState(msg.fastMode === true, msg.fastServiceTier); } else { // Native launch args are not proof until the replacement reaches a // real prompt. Arm this before spawn so an early ready event cannot @@ -10618,6 +10758,9 @@ process.on('message', async (raw: unknown) => { } case 'cancel_fast_mode': { + if (await cancelActiveFastModeChange(msg.requestId, 'daemon_timeout')) { + break; + } const queuedIndex = pendingFastModeChanges.findIndex( request => request.requestId === msg.requestId, ); @@ -10626,7 +10769,7 @@ process.on('message', async (raw: unknown) => { rejectFastModeChange(cancelled, 'not_ready', 'Fast Mode request expired before execution'); log(`Cancelled queued Fast Mode change (request=${msg.requestId.slice(0, 8)})`); continueAfterFastModeChange(); - } else if (!await abortPendingNativeFastModeChange(msg.requestId, 'daemon_timeout')) { + } else { log(`Ignored stale Fast Mode cancellation (request=${msg.requestId.slice(0, 8)})`); } break; @@ -10653,7 +10796,7 @@ process.on('message', async (raw: unknown) => { || shouldHoldCodexRunnerInput(codexRunnerFreshness) || injectionFlushing || shouldDeferUserFlush(pendingInjections) || bareShellCheckInProgress || bareShellLaunchBlocked - || pendingFastModeChanges.length > 0 || fastModeChangeInFlight) { + || pendingFastModeChanges.length > 0 || activeFastModeChange) { freshnessInputQueue.enqueueRaw(msg); log(`Deferred passthrough slash command until CLI input gate settles: ${msg.content}`); } else { @@ -11045,7 +11188,7 @@ process.on('message', async (raw: unknown) => { function cleanup(): void { stopNativeSessionTitleSync(); - fastModeRestartWatchdog.dispose(); + fastModeApplyWatchdog.dispose(); cleanupPiInitialPromptFiles(); stopSessionMcpGatewayHost(); if (tmuxRestartTimer) { diff --git a/test/codex-fast-worker-wiring.test.ts b/test/codex-fast-worker-wiring.test.ts index 3027e945a..c4f8c86ee 100644 --- a/test/codex-fast-worker-wiring.test.ts +++ b/test/codex-fast-worker-wiring.test.ts @@ -27,23 +27,32 @@ describe('Codex Fast Mode worker wiring', () => { expect(region).toContain('fastServiceTier: cfg.fastServiceTier'); }); - it('queues typed runtime changes and ACKs only after restart config is updated', () => { + it('queues typed runtime changes and persists only an exact pending result', () => { expect(workerSource).toContain("case 'set_fast_mode':"); expect(workerSource).toContain('pendingFastModeChanges.push(msg)'); - const applyStart = workerSource.indexOf('function publishFastModeExecutorState('); + const applyStart = workerSource.indexOf('function updateWorkerFastModeExecutorState('); const applyEnd = workerSource.indexOf('\n}', applyStart); const apply = workerSource.slice(applyStart, applyEnd); expect(applyStart).toBeGreaterThan(-1); expect(apply.indexOf('lastInitConfig.fastMode =')).toBeGreaterThan(-1); - expect(apply.indexOf("type: 'fast_mode_state'")).toBeGreaterThan( - apply.indexOf('lastInitConfig.fastMode ='), - ); const commitStart = workerSource.indexOf('function commitFastModeChange('); const commitEnd = workerSource.indexOf('\n}', commitStart); const commit = workerSource.slice(commitStart, commitEnd); expect(commit.indexOf("type: 'fast_mode_result'")).toBeGreaterThan( - commit.indexOf('publishFastModeExecutorState('), + commit.indexOf('updateWorkerFastModeExecutorState('), + ); + expect(commit).not.toContain("type: 'fast_mode_state'"); + + const resultStart = workerPoolSource.indexOf("case 'fast_mode_result':"); + const resultEnd = workerPoolSource.indexOf("case 'fast_mode_state':", resultStart); + const result = workerPoolSource.slice(resultStart, resultEnd); + expect(result.indexOf('isFastModeResultPending(msg.requestId)')).toBeGreaterThan(-1); + expect(result.indexOf('ds.session.fastMode = msg.enabled')).toBeGreaterThan( + result.indexOf('isFastModeResultPending(msg.requestId)'), + ); + expect(result.indexOf('acknowledgeFastModeResult(msg)')).toBeGreaterThan( + result.indexOf('ds.session.fastMode = msg.enabled'), ); }); @@ -85,29 +94,39 @@ describe('Codex Fast Mode worker wiring', () => { const readyEnd = workerSource.indexOf('clearSessionRenameInFlight();', readyStart); const ready = workerSource.slice(readyStart, readyEnd); expect(ready).toContain('pendingInitialFastModeConfirmation'); - expect(ready).toContain('publishFastModeExecutorState'); + expect(ready).toContain('publishInitialFastModeExecutorState'); }); - it('bounds and cancels a native replacement without allowing a late prompt to commit it', () => { + it('owns the exact request from dequeue through async apply and native prompt ACK', () => { expect(workerSource).toContain("case 'cancel_fast_mode':"); - expect(workerSource).toContain('fastModeRestartWatchdog.arm(request.requestId'); - - const abortStart = workerSource.indexOf('async function abortPendingNativeFastModeChange('); - const abortEnd = workerSource.indexOf('\n}\n\n/** Apply one Fast Mode request', abortStart); - const abort = workerSource.slice(abortStart, abortEnd); - expect(abortStart).toBeGreaterThan(-1); - expect(abortEnd).toBeGreaterThan(abortStart); - expect(abort).toContain('pending.request.requestId !== requestId'); - expect(abort).toContain('pendingRestartFastModeAck = null'); - expect(abort).toContain('restorePendingFastModeConfig(pending)'); - expect(abort).toContain('await restartCliProcess('); - expect(abort).toContain('rejectFastModeChange('); - expect(abort).toContain('fastModeChangeInFlight = false'); - expect(abort).toContain('continueAfterFastModeChange()'); + expect(workerSource).toContain('await cancelActiveFastModeChange(msg.requestId'); + + const beginFnStart = workerSource.indexOf('function beginActiveFastModeChange('); + const beginFnEnd = workerSource.indexOf('function assertActiveFastModeChange(', beginFnStart); + const beginFn = workerSource.slice(beginFnStart, beginFnEnd); + expect(beginFn.indexOf('activeFastModeChange = active')).toBeGreaterThan(-1); + expect(beginFn.indexOf('fastModeApplyWatchdog.arm(request.requestId')).toBeGreaterThan( + beginFn.indexOf('activeFastModeChange = active'), + ); + + const runtimeStart = workerSource.indexOf('async function flushPendingFastModeChanges(): Promise<void>'); + const runtimeEnd = workerSource.indexOf('function markPromptReady(): void', runtimeStart); + const runtime = workerSource.slice(runtimeStart, runtimeEnd); + const dequeue = runtime.indexOf('pendingFastModeChanges.shift()'); + const begin = runtime.indexOf('beginActiveFastModeChange(request)'); + const rpcApply = runtime.indexOf('rpcEngine.setFastMode('); + const nativeProbe = runtime.indexOf('resolveWorkerFastServiceTier('); + expect(dequeue).toBeGreaterThan(-1); + expect(begin).toBeGreaterThan(dequeue); + expect(rpcApply).toBeGreaterThan(begin); + expect(nativeProbe).toBeGreaterThan(begin); + expect(runtime).toContain('active.transaction.waitFor('); + expect(runtime).toContain('signal: active.transaction.signal'); const readyStart = workerSource.indexOf('function markPromptReady(): void'); const readyEnd = workerSource.indexOf('clearSessionRenameInFlight();', readyStart); const ready = workerSource.slice(readyStart, readyEnd); - expect(ready).toContain('fastModeRestartWatchdog.clear(pending.request.requestId)'); + expect(ready).toContain("activeFastModeChange?.phase === 'awaiting_native_prompt'"); + expect(ready).toContain('commitFastModeChange(active'); }); }); diff --git a/test/codex-rpc-engine.test.ts b/test/codex-rpc-engine.test.ts index 66f631628..5743c83c2 100644 --- a/test/codex-rpc-engine.test.ts +++ b/test/codex-rpc-engine.test.ts @@ -4,7 +4,10 @@ import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; -import { CodexRpcEngine } from '../src/codex-rpc-engine.js'; +import { + CodexRpcEngine, + CodexRpcFastModeCancelledError, +} from '../src/codex-rpc-engine.js'; const isAlive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; @@ -28,6 +31,17 @@ function readRpcLog(path: string): Array<{ method?: string; params?: Record<stri .map(line => JSON.parse(line)); } +async function waitForRpcCount(path: string, method: string, count: number): Promise<void> { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if (existsSync(path) && readRpcLog(path).filter(entry => entry.method === method).length >= count) { + return; + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error(`Timed out waiting for ${count} ${method} request(s)`); +} + describe('CodexRpcEngine — happy-path lifecycle against a fake app-server', () => { it('resolves the model catalog Fast tier and pins it on thread/start + turn/start', async () => { const dir = mkdtempSync(join(tmpdir(), 'botmux-fast-rpc-')); @@ -86,6 +100,40 @@ describe('CodexRpcEngine — happy-path lifecycle against a fake app-server', () } }, 20_000); + it('compensates to the previous tier when a settings ACK arrives after cancellation', async () => { + const dir = mkdtempSync(join(tmpdir(), 'botmux-fast-rpc-cancel-')); + const rpcLog = join(dir, 'rpc.jsonl'); + const engine = makeEngine({ + model: 'gpt-fast', + env: { + ...process.env, + FAKE_RPC_LOG: rpcLog, + FAKE_SETTINGS_UPDATE_ACK_DELAY_MS: '100', + }, + }); + const controller = new AbortController(); + + try { + await engine.start(); + await engine.resumeThread('thread-fast-cancel'); + + const applying = engine.setFastMode(true, { signal: controller.signal }); + await waitForRpcCount(rpcLog, 'thread/settings/update', 1); + controller.abort(); + + await expect(applying).rejects.toBeInstanceOf(CodexRpcFastModeCancelledError); + await waitForRpcCount(rpcLog, 'thread/settings/update', 2); + + const updates = readRpcLog(rpcLog) + .filter(entry => entry.method === 'thread/settings/update'); + expect(updates.map(entry => entry.params?.serviceTier)).toEqual(['priority', null]); + expect(engine.activeServiceTier).toBeUndefined(); + } finally { + engine.stop(); + rmSync(dir, { recursive: true, force: true }); + } + }, 20_000); + it('rejects Fast before thread creation when the selected model has no Fast tier', async () => { const dir = mkdtempSync(join(tmpdir(), 'botmux-fast-rpc-unsupported-')); const rpcLog = join(dir, 'rpc.jsonl'); diff --git a/test/fast-mode-apply-transaction.test.ts b/test/fast-mode-apply-transaction.test.ts new file mode 100644 index 000000000..3f746b385 --- /dev/null +++ b/test/fast-mode-apply-transaction.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { + FastModeApplyCancelledError, + FastModeApplyTransaction, +} from '../src/core/fast-mode-apply-transaction.js'; + +describe('Fast Mode apply transaction', () => { + it('rejects a native probe result that arrives after the exact request was cancelled', async () => { + let resolveProbe!: (serviceTier: string) => void; + const probe = new Promise<string>(resolve => { + resolveProbe = resolve; + }); + const transaction = new FastModeApplyTransaction('req-native-late'); + + const guardedProbe = transaction.waitFor(probe); + + expect(transaction.cancel('req-other', 'daemon_timeout')).toBe(false); + expect(transaction.cancel('req-native-late', 'daemon_timeout')).toBe(true); + resolveProbe('priority'); + + await expect(guardedProbe).rejects.toEqual( + new FastModeApplyCancelledError('req-native-late', 'daemon_timeout'), + ); + expect(transaction.signal.aborted).toBe(true); + }); +}); diff --git a/test/fast-mode-handshake.test.ts b/test/fast-mode-handshake.test.ts index e16c5156b..da7e18937 100644 --- a/test/fast-mode-handshake.test.ts +++ b/test/fast-mode-handshake.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { acknowledgeFastModeResult, cancelFastModeResult, + isFastModeResultPending, waitForFastModeResult, } from '../src/core/fast-mode-handshake.js'; @@ -9,6 +10,8 @@ describe('Fast Mode IPC handshake', () => { it('resolves only the exact request ACK', async () => { const pending = waitForFastModeResult('req-1', 1_000); + expect(isFastModeResultPending('req-1')).toBe(true); + expect(isFastModeResultPending('other')).toBe(false); expect(acknowledgeFastModeResult({ type: 'fast_mode_result', requestId: 'other', @@ -23,6 +26,7 @@ describe('Fast Mode IPC handshake', () => { enabled: true, serviceTier: 'priority', })).toBe(true); + expect(isFastModeResultPending('req-1')).toBe(false); await expect(pending).resolves.toEqual({ ok: true, enabled: true, diff --git a/test/fixtures/fake-codex-rpc-server.mjs b/test/fixtures/fake-codex-rpc-server.mjs index 3a59ff6e3..95470c853 100755 --- a/test/fixtures/fake-codex-rpc-server.mjs +++ b/test/fixtures/fake-codex-rpc-server.mjs @@ -18,6 +18,7 @@ const PREVIEW_DELAY_READS = Number(process.env.FAKE_PREVIEW_DELAY_READS ?? '0'); const UPDATED_DELAY_READS = Number(process.env.FAKE_UPDATED_DELAY_READS ?? '0'); const UPDATED_BEFORE = Number(process.env.FAKE_UPDATED_BEFORE ?? '100'); const UPDATED_AFTER = Number(process.env.FAKE_UPDATED_AFTER ?? '101'); +const SETTINGS_UPDATE_ACK_DELAY_MS = Number(process.env.FAKE_SETTINGS_UPDATE_ACK_DELAY_MS ?? '0'); const RPC_LOG = process.env.FAKE_RPC_LOG; let threadReadAttempt = 0; let currentThreadName; @@ -75,7 +76,11 @@ wss.on('connection', (ws) => { }); case 'thread/start': return reply({ thread: { id: 'thread-fake-1' } }); case 'thread/resume': return reply({ thread: { id: msg.params?.threadId ?? 'thread-fake-1' } }); - case 'thread/settings/update': return reply({}); + case 'thread/settings/update': + if (SETTINGS_UPDATE_ACK_DELAY_MS > 0) { + return setTimeout(() => reply({}), SETTINGS_UPDATE_ACK_DELAY_MS); + } + return reply({}); case 'thread/read': threadReadAttempt += 1; return reply({ thread: {