宿主崩掉不再留下永远开着的回合:本地命令失败按身份兜底收口
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

- directThreadChat 新增 commandClosedTurnUserItemId 与 stopDirectThreadTurn:本地命令返回即这一轮在宿主那边收场,只放掉「是否在跑」、不写终态时间
- 同身份迟到的 turn.started 不再把这一轮拉回运行态;迟到的 turn.completed 例外放行,仍要拿它补上真正的结束时间
- 身份不同的回合不动:「正在跑的是另一轮」这种拒绝不能顺手抹掉别人的回合
- useDirectThreadChatSubscription 暴露 stopCommandTurn;controller 在真失败分支按本轮身份调用,主动终止与 another-turn-running 不走这条
- 新增用例:reducer 两条(不复活 / 不碰别人的回合)、appSurface 一条(失败后不再显示「陶泥儿正在处理」、输入盒回到发送态);变异去掉兜底收口后该用例变红
- decision-log 记录本次决策,以及仍未做的宿主侧根因(Rust 侧 drop 守卫补终态事件)
This commit is contained in:
2026-09-22 15:34:48 +08:00
parent cab31613f2
commit a35956f3ec
6 changed files with 175 additions and 1 deletions
@@ -555,6 +555,13 @@ export function useDirectProjectChatController({
}); });
return; return;
} }
// 真失败:这条命令返回就说明这一轮在宿主那边已经收场,但终态事件可能永远不来
// app-server 崩了、任务被中止、panic 都只留下一条开着的 `turn.started`)。
// 按本轮身份放掉原生忙态,否则界面会一直显示「正在处理」、输入盒一直排队。
// 主动终止与「正在跑的是另一轮」不走这里:前者宿主必然补终态,后者不是这一轮。
directThread.stopCommandTurn(
directCodexConversationMessageId(input.clientTurnId, 'user'),
);
void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID);
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
let persistedDetail = ''; let persistedDetail = '';
@@ -19,6 +19,7 @@ import {
mergeDirectHistoryItems, mergeDirectHistoryItems,
resolveDirectThreadBootstrap, resolveDirectThreadBootstrap,
selectDirectChatEntries, selectDirectChatEntries,
stopDirectThreadTurn,
} from '../conversation/directThreadChat'; } from '../conversation/directThreadChat';
import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult'; import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult';
import type { DirectThreadItem } from '../generated/DirectThreadItem'; import type { DirectThreadItem } from '../generated/DirectThreadItem';
@@ -40,6 +41,11 @@ export type DirectThreadChatSubscription = {
mergeHistoryItems: (items: readonly DirectThreadItem[]) => void; mergeHistoryItems: (items: readonly DirectThreadItem[]) => void;
/** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */ /** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */
markTurnStopped: () => void; markTurnStopped: () => void;
/**
* 本地命令失败收场时按身份放掉这一轮:宿主的终态事件可能永远不会来(进程崩了 /
* 任务被中止),不能一直挂在 `turn.started` 上显示「正在处理」。
*/
stopCommandTurn: (userItemId: string) => void;
}; };
/** /**
@@ -185,6 +191,13 @@ export function useDirectThreadChatSubscription({
[], [],
); );
const stopCommandTurn = useMemo(
() => (userItemId: string) => {
setState((current) => stopDirectThreadTurn(current, userItemId));
},
[],
);
const entries = useMemo(() => selectDirectChatEntries(state), [state]); const entries = useMemo(() => selectDirectChatEntries(state), [state]);
return { return {
@@ -194,5 +207,6 @@ export function useDirectThreadChatSubscription({
anchorGateRef, anchorGateRef,
mergeHistoryItems, mergeHistoryItems,
markTurnStopped, markTurnStopped,
stopCommandTurn,
}; };
} }
@@ -65,6 +65,16 @@ export type DirectThreadChatState = {
* 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。 * 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。
*/ */
turnUserItemId: string; turnUserItemId: string;
/**
* 「本地命令已经返回、宿主却一直没给终态」的那一轮身份(见 `stopDirectThreadTurn`)。
*
* `turn.started` 与 `turn.completed` 是原生回合唯一的开闭配对,但**进程崩了、任务被
* 中止、panic** 这类收场不会补终态事件,只留一条永远开着的 `turn.started`:界面上就
* 一直显示「正在处理」,输入盒也一直忙。本地那一条命令(`chat_with_game_creator_direct_codex`
* 返回时说到底就是"这一轮在宿主那边已经收场",这条身份就是它的记录:同身份的
* `turn.started` 迟到 / 重放回来不再复活这一轮,避免收口之后又被拉回运行态。
*/
commandClosedTurnUserItemId: string;
/** 历史切片条目,保持文件顺序。 */ /** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[]; history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
@@ -77,11 +87,40 @@ export function emptyDirectThreadChatState(): DirectThreadChatState {
turnStartedAt: 0, turnStartedAt: 0,
turnEndedAt: 0, turnEndedAt: 0,
turnUserItemId: '', turnUserItemId: '',
commandClosedTurnUserItemId: '',
history: [], history: [],
live: [], live: [],
}; };
} }
/**
* 本地命令失败收场:这一轮命令已经返回,宿主却还在事件流里挂着 `turn.started`。
*
* 只放掉"是否在跑",**不写终态时间**——命令返回不等于我们知道这一轮真正的结束时刻,
* 编一个只会让耗时变成假数。收口后同身份的 `turn.started` 迟到 / 重放回来不再复活,
* 免得刚修好的"还在处理"又被拉起来。宿主随后真发来 `turn.completed` 时照旧正常收口。
*
* 身份不同的轮次不动:宿主同时只允许一条回合,但"正在跑的是另一轮"`another-turn-running`
* 这种拒绝也要能原样报给用户,不能顺手把别人那轮抹掉。空身份(宿主没能落上身份)时按
* 本轮处理,否则这条兜底永远盖不住协议早期失败。
*/
export function stopDirectThreadTurn(
state: DirectThreadChatState,
userItemId: string,
): DirectThreadChatState {
if (state.turnUserItemId !== '' && state.turnUserItemId !== userItemId) {
return state;
}
if (!state.turnRunning && state.commandClosedTurnUserItemId === userItemId) {
return state;
}
return {
...state,
turnRunning: false,
commandClosedTurnUserItemId: userItemId,
};
}
/** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */ /** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */
function validBoundaryAt(value: number | null | undefined): number { function validBoundaryAt(value: number | null | undefined): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 return typeof value === 'number' && Number.isFinite(value) && value > 0
@@ -302,6 +341,14 @@ export function reduceDirectThreadEvent(
// 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时 // 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时
// 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。 // 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。
const turnUserItemId = readDirectThreadEventUserItemId(event); const turnUserItemId = readDirectThreadEventUserItemId(event);
// 本地命令已经收过场的那一轮:迟到的 `turn.started` 不得把它拉回运行态(见
// `commandClosedTurnUserItemId`)。身份按 clientTurnId 唯一,只挡它自己那一轮。
if (
turnUserItemId !== '' &&
turnUserItemId === state.commandClosedTurnUserItemId
) {
return state;
}
return { return {
...state, ...state,
turnRunning: true, turnRunning: true,
@@ -323,7 +370,17 @@ export function reduceDirectThreadEvent(
return state; return state;
} }
// 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。 // 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
if (!state.turnRunning && state.live.length === 0) { // 例外是"本地命令兜底收口"的那一轮(`commandClosedTurnUserItemId` 命中且还没有终态
// 时间):那次收口本来就没写时间,宿主这份迟到的终态要拿来补上真正的结束时刻。
const lateTerminalForCommandClosedTurn =
eventUserItemId !== '' &&
eventUserItemId === state.commandClosedTurnUserItemId &&
state.turnEndedAt <= 0;
if (
!state.turnRunning &&
state.live.length === 0 &&
!lateTerminalForCommandClosedTurn
) {
return state; return state;
} }
return finishDirectThreadTurn(state, eventAt); return finishDirectThreadTurn(state, eventAt);
@@ -503,6 +503,44 @@ export function registerChatComposerControlTests() {
}); });
}); });
it('stops claiming the turn is running when a failed send left turn.started open', async () => {
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
null;
const { surface } = await openDirectCodexSurface(
{
chat_with_game_creator_direct_codex: (
args: Record<string, unknown> | undefined,
) => {
// 宿主先认领了这一轮(turn.started),随后崩掉:没有终态事件,命令以失败返回。
harness?.emitDirectThreadEvents({
type: 'turn.started',
at: 5_000,
userItemId: `direct-codex:${String(args?.clientTurnId ?? '')}:user`,
});
throw new Error('模拟宿主崩溃:turn.started 之后没有终态事件');
},
},
(directHarness) => {
harness = directHarness;
},
);
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '崩掉的那条');
await waitFor(() => {
expect(
within(surface).getAllByText('陶泥儿智能创作 执行失败,请稍后重试')
.length,
).toBeGreaterThan(0);
});
// 命令已经收场:卡片和输入区都不能再声称"还在处理"。
expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0);
expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull();
expect(
within(surface).getByRole('button', { name: '发送' }),
).not.toBeNull();
});
it('keeps the next queued turn busy when the write gate refuses the running one', async () => { it('keeps the next queued turn busy when the write gate refuses the running one', async () => {
const pending: Array<{ resolve: (value: string) => void }> = []; const pending: Array<{ resolve: (value: string) => void }> = [];
const deferredPolicies: Array<(value: unknown) => void> = []; const deferredPolicies: Array<(value: unknown) => void> = [];
@@ -11,6 +11,7 @@ import {
reduceDirectThreadEvents, reduceDirectThreadEvents,
resolveDirectThreadBootstrap, resolveDirectThreadBootstrap,
selectDirectChatEntries, selectDirectChatEntries,
stopDirectThreadTurn,
} from '../src/view/project-development/chat/conversation/directThreadChat'; } from '../src/view/project-development/chat/conversation/directThreadChat';
import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection'; import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection';
import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent'; import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent';
@@ -176,6 +177,54 @@ describe('DirectProject 聊天 reducer', () => {
expect(selectDirectChatEntries(done)).toHaveLength(2); expect(selectDirectChatEntries(done)).toHaveLength(2);
}); });
it('本地命令兜底收口后,迟到的同名 turn.started 不复活这一轮', () => {
const identity = 'direct-codex:client-turn-1:user';
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event(withUserItemId({ type: 'turn.started', at: 1_000 }, identity)),
event({ type: 'item.completed', item: messageItem() }),
]);
expect(running.turnRunning).toBe(true);
const stopped = stopDirectThreadTurn(running, identity);
expect(stopped.turnRunning).toBe(false);
// 兜底收口不写终态时间:命令返回不等于知道这一轮真正的结束时刻。
expect(stopped.turnEndedAt).toBe(0);
// 同一轮迟到的 turn.started 不再把它拉回运行态,运行态条目也没丢。
const revived = reduceDirectThreadEvents(stopped, [
event(withUserItemId({ type: 'turn.started', at: 2_000 }, identity)),
]);
expect(revived.turnRunning).toBe(false);
expect(selectDirectChatEntries(revived)).toHaveLength(1);
// 宿主随后补上的真终态照旧收口,并把真正的结束时间补上。
const late = reduceDirectThreadEvents(revived, [
event(
withUserItemId(
{ type: 'turn.completed', status: 'failed', at: 3_000 },
identity,
),
),
]);
expect(late.turnRunning).toBe(false);
expect(late.turnEndedAt).toBe(3_000);
});
it('本地命令兜底收口不碰身份不同的那轮', () => {
const other = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event(
withUserItemId(
{ type: 'turn.started', at: 1_000 },
'direct-codex:client-turn-9:user',
),
),
]);
expect(
stopDirectThreadTurn(other, 'direct-codex:client-turn-1:user')
.turnRunning,
).toBe(true);
});
it('历史切片搬运层不合并,合并发生在前端投影', () => { it('历史切片搬运层不合并,合并发生在前端投影', () => {
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
toolStarted(), toolStarted(),
@@ -9330,3 +9330,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策(卡片口径取保守):`DirectProjectConversation` 的「正在处理」卡片与 `activeTurnStartedAt` 仍只认 `turnStatus.nativeRunning`,窗口期不出现这张卡片。文案是「陶泥儿正在处理」,在 `turn.started` 之前无法断言宿主已经开始,这与空窗缺陷是同一个病根(把"本地已发出"当成"宿主已在跑");窗口期用户看到的是"消息已发出 + 输入框忙",语义诚实。若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,那时应把投影压成 `unfinished: boolean`,不要留一个没人读的状态成员。 - 决策(卡片口径取保守):`DirectProjectConversation` 的「正在处理」卡片与 `activeTurnStartedAt` 仍只认 `turnStatus.nativeRunning`,窗口期不出现这张卡片。文案是「陶泥儿正在处理」,在 `turn.started` 之前无法断言宿主已经开始,这与空窗缺陷是同一个病根(把"本地已发出"当成"宿主已在跑");窗口期用户看到的是"消息已发出 + 输入框忙",语义诚实。若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,那时应把投影压成 `unfinished: boolean`,不要留一个没人读的状态成员。
- 边界(A 仍未修):`DirectProjectTurnUsage``Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx``DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts``DirectChatTurnState` 注释),改完删掉那段注释。 - 边界(A 仍未修):`DirectProjectTurnUsage``Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx``DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts``DirectChatTurnState` 注释),改完删掉那段注释。
- 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start``running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding``check:doc-index``git diff --check` 通过。真实客户端观感未复核。 - 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start``running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding``check:doc-index``git diff --check` 通过。真实客户端观感未复核。
## 2026-09-22 宿主崩掉不再留下永远开着的回合:本地命令失败时按身份兜底收口
- 背景:`turn.started` / `turn.completed` 是原生回合唯一的开闭配对,界面上的「正在处理」卡片与输入盒忙态都读 reducer 的 `turnRunning`。但 app-server 崩了、回合任务被中止或 panic 时没人补终态事件,事件流里就留一条永远开着的 `turn.started`:界面一直显示「陶泥儿正在处理」、输入盒一直排队(用户现场反馈)。
- 决策(本地命令返回即这一轮在宿主那边收场):`chat_with_game_creator_direct_codex` 以真失败返回时,controller 按本轮身份调用 `stopDirectThreadTurn`,只放掉「是否在跑」,**不写终态时间**——命令返回不等于知道这一轮真正的结束时刻,编一个只会让耗时变成假数。用户主动终止与「正在跑的是另一轮」两条不适用:前者宿主必然补终态,后者不是这一轮(不能顺手抹掉别人的回合)。
- 决策(身份作用域 + 不复活):`stopDirectThreadTurn` 只在 reducer 里的运行身份相同或为空时生效;收口记进 `commandClosedTurnUserItemId`,同身份迟到的 `turn.started` 不再把这一轮拉回运行态(迟到的 `turn.completed` 例外放行,仍要拿它补上真正的结束时间)。身份按 clientTurnId 唯一,所以这条记忆只挡它自己那一轮。
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}``apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}`
- 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿。
- 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx``DirectChatTurnState` 注释里的 A 项)。