修复重进会话被堵死:前端打开项目时接管仍在运行的 Direct 回合

打开项目读项目对话之后调用 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 两个命令返回类型
This commit is contained in:
2026-09-15 20:33:25 +08:00
parent d833ca9d32
commit e788bf0fea
2 changed files with 210 additions and 6 deletions
+184 -6
View File
@@ -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<string | null>(null);
/**
* Rust
* "这一轮其实已经没响应"
*/
const recoveredDirectCodexTurnRef = useRef<{
projectPath: string;
turnId: string;
} | null>(null);
const recoveredDirectCodexTurnTimerRef = useRef<number | null>(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<DirectActiveTurnView | null>(
'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<DirectTurnCancelView>(
'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}`);
@@ -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;