宿主崩掉不再留下永远开着的回合:本地命令失败按身份兜底收口
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(),
|
||||
|
||||
Reference in New Issue
Block a user