修复重进会话被堵死:前端打开项目时接管仍在运行的 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user