宿主崩掉不再留下永远开着的回合:本地命令失败按身份兜底收口
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
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:
+7
@@ -555,6 +555,13 @@ export function useDirectProjectChatController({
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 真失败:这条命令返回就说明这一轮在宿主那边已经收场,但终态事件可能永远不来
|
||||
// (app-server 崩了、任务被中止、panic 都只留下一条开着的 `turn.started`)。
|
||||
// 按本轮身份放掉原生忙态,否则界面会一直显示「正在处理」、输入盒一直排队。
|
||||
// 主动终止与「正在跑的是另一轮」不走这里:前者宿主必然补终态,后者不是这一轮。
|
||||
directThread.stopCommandTurn(
|
||||
directCodexConversationMessageId(input.clientTurnId, 'user'),
|
||||
);
|
||||
void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
let persistedDetail = '';
|
||||
|
||||
+14
@@ -19,6 +19,7 @@ import {
|
||||
mergeDirectHistoryItems,
|
||||
resolveDirectThreadBootstrap,
|
||||
selectDirectChatEntries,
|
||||
stopDirectThreadTurn,
|
||||
} from '../conversation/directThreadChat';
|
||||
import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult';
|
||||
import type { DirectThreadItem } from '../generated/DirectThreadItem';
|
||||
@@ -40,6 +41,11 @@ export type DirectThreadChatSubscription = {
|
||||
mergeHistoryItems: (items: readonly DirectThreadItem[]) => void;
|
||||
/** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */
|
||||
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]);
|
||||
|
||||
return {
|
||||
@@ -194,5 +207,6 @@ export function useDirectThreadChatSubscription({
|
||||
anchorGateRef,
|
||||
mergeHistoryItems,
|
||||
markTurnStopped,
|
||||
stopCommandTurn,
|
||||
};
|
||||
}
|
||||
|
||||
+58
-1
@@ -65,6 +65,16 @@ export type DirectThreadChatState = {
|
||||
* 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。
|
||||
*/
|
||||
turnUserItemId: string;
|
||||
/**
|
||||
* 「本地命令已经返回、宿主却一直没给终态」的那一轮身份(见 `stopDirectThreadTurn`)。
|
||||
*
|
||||
* `turn.started` 与 `turn.completed` 是原生回合唯一的开闭配对,但**进程崩了、任务被
|
||||
* 中止、panic** 这类收场不会补终态事件,只留一条永远开着的 `turn.started`:界面上就
|
||||
* 一直显示「正在处理」,输入盒也一直忙。本地那一条命令(`chat_with_game_creator_direct_codex`)
|
||||
* 返回时说到底就是"这一轮在宿主那边已经收场",这条身份就是它的记录:同身份的
|
||||
* `turn.started` 迟到 / 重放回来不再复活这一轮,避免收口之后又被拉回运行态。
|
||||
*/
|
||||
commandClosedTurnUserItemId: string;
|
||||
/** 历史切片条目,保持文件顺序。 */
|
||||
history: DirectChatEntry[];
|
||||
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
|
||||
@@ -77,11 +87,40 @@ export function emptyDirectThreadChatState(): DirectThreadChatState {
|
||||
turnStartedAt: 0,
|
||||
turnEndedAt: 0,
|
||||
turnUserItemId: '',
|
||||
commandClosedTurnUserItemId: '',
|
||||
history: [],
|
||||
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 / 非有限都算没有这个边界,不用它计任何耗时。 */
|
||||
function validBoundaryAt(value: number | null | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
@@ -302,6 +341,14 @@ export function reduceDirectThreadEvent(
|
||||
// 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时
|
||||
// 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。
|
||||
const turnUserItemId = readDirectThreadEventUserItemId(event);
|
||||
// 本地命令已经收过场的那一轮:迟到的 `turn.started` 不得把它拉回运行态(见
|
||||
// `commandClosedTurnUserItemId`)。身份按 clientTurnId 唯一,只挡它自己那一轮。
|
||||
if (
|
||||
turnUserItemId !== '' &&
|
||||
turnUserItemId === state.commandClosedTurnUserItemId
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
turnRunning: true,
|
||||
@@ -323,7 +370,17 @@ export function reduceDirectThreadEvent(
|
||||
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 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 () => {
|
||||
const pending: Array<{ resolve: (value: string) => void }> = [];
|
||||
const deferredPolicies: Array<(value: unknown) => void> = [];
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
reduceDirectThreadEvents,
|
||||
resolveDirectThreadBootstrap,
|
||||
selectDirectChatEntries,
|
||||
stopDirectThreadTurn,
|
||||
} from '../src/view/project-development/chat/conversation/directThreadChat';
|
||||
import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection';
|
||||
import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent';
|
||||
@@ -176,6 +177,54 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
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('历史切片搬运层不合并,合并发生在前端投影', () => {
|
||||
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||||
toolStarted(),
|
||||
|
||||
@@ -9330,3 +9330,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 决策(卡片口径取保守):`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` 注释),改完删掉那段注释。
|
||||
- 验证:`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 项)。
|
||||
|
||||
Reference in New Issue
Block a user