From e788bf0feac86c5591b26fe953b064d8271badf9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 15 Sep 2026 20:33:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=87=8D=E8=BF=9B=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=A2=AB=E5=A0=B5=E6=AD=BB=EF=BC=9A=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=89=93=E5=BC=80=E9=A1=B9=E7=9B=AE=E6=97=B6=E6=8E=A5=E7=AE=A1?= =?UTF-8?q?=E4=BB=8D=E5=9C=A8=E8=BF=90=E8=A1=8C=E7=9A=84=20Direct=20?= =?UTF-8?q?=E5=9B=9E=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打开项目读项目对话之后调用 read_direct_codex_active_turn,返回回合时把它设回 activeDirectCodexTurnRef(字段与既有两处赋值同形),并置 directCodexStatus=running、chatAgentBusy=true,让过程卡、"任务执行中"与输入盒的终止按钮都出现 只读探测失败或没有回合等于没有回合,不影响打开项目;同一项目已接管时不重复接管,避免 /history 之类重读把 lastSequence 归零 恢复出来的回合挂 15 秒看门狗:窗口内一条本回合事件都没有,就在过程卡与输入盒提示"该回合已无响应,可在输入盒点「终止」结束它以继续";收到真实事件立刻撤掉提示与看门狗 终止按 cancel_direct_codex_turn 的返回值分流:outcome=released 说明守卫已被后端兜底释放、没有回合 promise 会回来复位,界面自己复位并展示后端可读原因;interrupted 仍等回合自身收尾 发消息若仍被"已有另一条 Direct 客户端回合正在运行"拒绝,延时接管那条回合并把出口写进提示(不与同 clientTurnId 的既有分类混用,新增独立判定) 新增 DirectActiveTurnView / DirectTurnCancelView 两个命令返回类型 --- apps/ai-game-creator-shell/src/App.tsx | 190 +++++++++++++++++++- apps/ai-game-creator-shell/src/app/types.ts | 26 +++ 2 files changed, 210 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index a65717054..d08172319 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -55,6 +55,8 @@ import type { DesignClarificationRequest, DesignEvent, DesignView, + DirectActiveTurnView, + DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, GameCreatorDirectToolCall, @@ -297,6 +299,18 @@ const DIRECT_CODEX_PRODUCT_RUNTIME = true; const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = 'direct-codex-turn-already-running:'; +/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ +const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = + '当前项目已有另一条 Direct 客户端回合正在运行'; +/** + * 恢复出来的回合多久没有任何事件就算"没响应"。Rust 守卫是进程内的:重进会话时它还在, + * 但 app-server 侧可能早就没了。这时界面必须给出明确动作,而不是让用户一直等。 + */ +const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000; +const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE = + '该回合已无响应,可在输入盒点「终止」结束它以继续'; +const DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL = + '已恢复正在运行的回合,正在等待陶泥儿的最新进度'; function isDirectCodexAuthenticationRequired(error: unknown) { const message = error instanceof Error ? error.message : String(error); @@ -492,6 +506,16 @@ export function isDirectCodexTurnAlreadyRunningError(error: unknown) { .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); } +/** + * 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同 + * 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的 + * 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。 + */ +export function isDirectCodexAnotherTurnRunningError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER); +} + /** * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 @@ -827,6 +851,15 @@ export function App({ receivedDirectUpdate: boolean; } | null>(null); const lastDirectCodexActivityRef = useRef(null); + /** + * 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件, + * 才判定"这一轮其实已经没响应",给出终止出口。收到任何一条本回合事件就撤掉。 + */ + const recoveredDirectCodexTurnRef = useRef<{ + projectPath: string; + turnId: string; + } | null>(null); + const recoveredDirectCodexTurnTimerRef = useRef(null); const directCodexConversationTurnSequenceRef = useRef(0); // 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。 // 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。 @@ -899,6 +932,7 @@ export function App({ } function resetDirectCodexTurn() { + clearRecoveredDirectCodexTurnWatch(); activeDirectCodexTurnRef.current = null; lastDirectCodexActivityRef.current = null; setDirectCodexProgress(''); @@ -910,6 +944,106 @@ export function App({ setDirectCodexTransientReplyUpdatedAt(null); } + /** 撤掉"恢复出来的回合没响应"的看门狗;回合正常结束、被终止、或收到事件时都要撤。 */ + function clearRecoveredDirectCodexTurnWatch() { + if (recoveredDirectCodexTurnTimerRef.current !== null) { + window.clearTimeout(recoveredDirectCodexTurnTimerRef.current); + recoveredDirectCodexTurnTimerRef.current = null; + } + recoveredDirectCodexTurnRef.current = null; + } + + /** + * 给恢复出来的回合挂一个看门狗:一个窗口内没有任何本回合事件,就说明 app-server 侧 + * 其实已经没了、Rust 守卫是残留。这时把可读动作放到过程卡与输入盒提示上,用户点 + * 「终止」会走 `cancel_direct_codex_turn` 的兜底释放(见 handleCancelDirectCodexTurn)。 + * 收到任何一条本回合事件就由调用方撤掉它,绝不会覆盖真实的进度文案。 + */ + function watchRecoveredDirectCodexTurn(projectPath: string, turnId: string) { + clearRecoveredDirectCodexTurnWatch(); + recoveredDirectCodexTurnRef.current = { projectPath, turnId }; + recoveredDirectCodexTurnTimerRef.current = window.setTimeout(() => { + recoveredDirectCodexTurnTimerRef.current = null; + const watch = recoveredDirectCodexTurnRef.current; + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !watch || + watch.projectPath !== projectPath || + watch.turnId !== turnId || + activeTurn?.projectPath !== projectPath || + activeTurn.turnId !== turnId || + activeTurn.receivedDirectUpdate + ) { + return; + } + setDirectCodexStatus('running'); + setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); + setDirectCodexProgressUpdatedAt(Date.now()); + setChatComposerNotice(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); + }, DIRECT_CODEX_RECOVERED_TURN_STALLED_MS); + } + + /** + * 重进会话时接管仍在运行的 Direct 回合。 + * + * 背景:活跃回合的守卫(`DirectTaonierActiveInvocationGuard`)是 Rust 进程内的,重开 + * 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示 + * 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。 + * + * 只读探测,不改后端回合本身;探测失败(老二进制没有这个命令、路径读不到)等于没有 + * 回合,不影响打开项目。 + */ + async function restoreRunningDirectCodexTurn(projectPath: string) { + if (!directCodexProductRuntime || !projectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + // 本组件已经接管这个项目:`/history` 之类的重复读取不能把 lastSequence 归零。 + if (activeDirectCodexTurnRef.current?.projectPath === projectPath) { + return; + } + let activeView: DirectActiveTurnView | null = null; + try { + activeView = await invoke( + 'read_direct_codex_active_turn', + { projectPath }, + ); + } catch { + return; + } + const clientTurnId = activeView?.clientTurnId?.trim(); + if (!clientTurnId) { + return; + } + if ( + localProjectPathRef.current !== projectPath || + activeDirectCodexTurnRef.current?.projectPath === projectPath + ) { + return; + } + activeDirectCodexTurnRef.current = { + projectPath, + turnId: clientTurnId, + // 与下面发起回合的两处赋值同形(`lastSequence: -1`):Rust 侧 emitter 的 sequence + // 从 1 开始,所以恢复后到达的第一批事件不会被 sequence 过滤丢掉。 + lastSequence: -1, + receivedDirectUpdate: false, + }; + setChatAgentBusy(true); + setDirectCodexStatus('running'); + setDirectCodexProcessKey(`${projectPath}\u0000${clientTurnId}`); + setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL); + setDirectCodexProgressUpdatedAt(Date.now()); + setDirectCodexTransientReply(''); + directCodexTransientReplyRef.current = ''; + setDirectCodexTransientReplyUpdatedAt(null); + setProjectSupervisorRuntimeError(''); + watchRecoveredDirectCodexTurn(projectPath, clientTurnId); + } + /** 工具调用卡片按项目维度作废:换项目 / 重开历史时整体替换,避免串项目。 */ function replaceDirectToolCalls(next: readonly GameCreatorDirectToolCall[]) { const normalized = next @@ -1959,6 +2093,19 @@ export function App({ } activeTurn.lastSequence = payload.sequence; activeTurn.receivedDirectUpdate = true; + // 恢复出来的回合只要回来一条真实事件,就不再是"没响应",撤掉看门狗与那句提示。 + if ( + recoveredDirectCodexTurnRef.current?.projectPath === + payload.projectPath && + recoveredDirectCodexTurnRef.current.turnId === payload.turnId + ) { + clearRecoveredDirectCodexTurnWatch(); + setChatComposerNotice((current) => + current === DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE + ? '' + : current, + ); + } // 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。 if (payload.toolCalls?.length) { applyDirectToolCalls( @@ -3631,6 +3778,9 @@ export function App({ { projectPath: nextProjectPath }, ).catch(() => []); replaceDirectToolCalls(persistedToolCalls); + // 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示 + // 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。 + await restoreRunningDirectCodexTurn(nextProjectPath); } let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; @@ -6658,12 +6808,21 @@ export function App({ await refreshDirectProjectManifest(directProjectPath); } } catch (error) { - if (isDirectCodexTurnAlreadyRunningError(error)) { + if ( + isDirectCodexTurnAlreadyRunningError(error) || + isDirectCodexAnotherTurnRunningError(error) + ) { if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( - '陶泥儿仍在处理这条消息,请稍候刷新对话。', + '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', ); + // 兜底:出现这条拒绝说明本项目确实有回合在跑,而本组件此前没接管它 + // (重进会话的漏网情况)。放到当前任务之后再接管,避开本回合 finally + // 里 setChatAgentBusy(false) 的复位竞态。 + window.setTimeout(() => { + void restoreRunningDirectCodexTurn(directProjectPath); + }, 0); } return; } @@ -12112,11 +12271,30 @@ export function App({ setDirectCodexTurnCancelling(true); setChatComposerNotice('正在终止当前回合'); try { - await invoke('cancel_direct_codex_turn', { - projectPath: directProjectPath, - clientTurnId: activeTurn.turnId, - }); + const result = await invoke( + 'cancel_direct_codex_turn', + { + projectPath: directProjectPath, + clientTurnId: activeTurn.turnId, + }, + ); + const message = result?.message?.trim(); + if (result?.outcome === 'released') { + // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧已强制释放 + // 守卫。没有会 return 的回合 promise 来复位界面,这里必须自己复位,否则过程卡 + // 与"任务执行中"会一直挂着,用户仍然发不出消息。 + resetDirectCodexTurn(); + setChatAgentBusy(false); + setProjectSupervisorRuntimeError(''); + setChatComposerNotice( + message ?? '已结束这一轮占用,可以直接重新发送消息', + ); + return; + } setDirectCodexProgress('正在终止当前回合'); + if (message) { + setChatComposerNotice(message); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); setProjectSupervisorRuntimeError(`终止失败:${message}`); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index cb5965504..6661b0649 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -1162,6 +1162,32 @@ export interface GameCreatorDirectTurnUpdateEvent { updatedAt: number; } +/** + * `read_direct_codex_active_turn` 的返回值:Rust 进程内当前登记的 Direct 活跃回合。 + * + * 重进会话时用它把前端的"当前活跃回合"接管回来,否则界面不知道有回合在跑, + * 既不显示过程卡也不给终止入口,用户再发消息只会被守卫拒绝。 + */ +export interface DirectActiveTurnView { + /** 与回合事件的 `turnId` 同一个身份。 */ + clientTurnId: string; + /** 这一轮登记的时刻(Unix 毫秒)。 */ + startedAt: number; +} + +/** `cancel_direct_codex_turn` 的返回值。 */ +export interface DirectTurnCancelView { + /** + * `interrupted` = 已向正在跑的回合发出中断,界面等这一轮自己的收尾复位; + * `released` = app-server 侧已无句柄,本轮守卫被兜底释放,界面必须自己复位。 + */ + outcome: string; + /** 给用户看的可读结果。 */ + message: string; + /** 被终止 / 被释放的 clientTurnId。 */ + clientTurnId: string; +} + export interface AgentRunControlResult { runId: string; status: string;