From 860dcb1bf521731ec768567e99fb399e4d88d183 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 14:06:20 +0800 Subject: [PATCH 1/9] =?UTF-8?q?DirectProject=20=E8=81=8A=E5=A4=A9=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=98=BE=E5=BC=8F=E5=8C=96=EF=BC=9A=E5=9B=9E=E5=90=88?= =?UTF-8?q?=E4=B8=89=E6=80=81=E4=B8=8E=E3=80=8C=E5=9C=A8=E8=B7=91=E5=90=97?= =?UTF-8?q?=E3=80=8D=E5=94=AF=E4=B8=80=E6=B4=BE=E7=94=9F=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectChatTurn 的 active 布尔改为三态 state(running / awaiting-start / finished),三态判定收在投影里、用身份而不是时间戳 - buildDirectChatTurns 新增 pendingUserItemId 入参;controller 的 beginTurnCommand / endTurnCommand 与 turnBusy 同生共死地维护它 - 新增 useDirectProjectTurnStatus 作为「在跑吗」的唯一派生入口:nativeRunning / commandInFlight / displayBusy / latestTurnState - DirectProjectChatView 不再手拼 busy:header 与 composer 读 displayBusy,「正在处理」卡片读 nativeRunning - DirectProjectConversation 的 running 入参改名 nativeRunning,语义写在类型注释里 - DirectProjectTurn 改读 turn.state;渲染条件保持不变,awaiting-start 仍按已结束渲染(空窗缺陷另开改动) - directTurnPresentation 的起点优先级与三态判定由嵌套三元改为逐级 if 覆盖 - 新增 directProjectTurnStatus 用例 4 条;directTurnPresentation 补三态用例 4 条并改按 state 断言 - ADR 补「活动回合唯一判据约束的是原生回合」澄清与代码指针 - decision-log 记录本次重构决策、未修的窗口期缺陷与验证证据 --- .../chat/DirectProjectChatView.tsx | 37 +++++---- .../DirectProjectConversation.tsx | 10 ++- .../DirectProjectTurn.tsx | 17 ++-- .../useDirectProjectChatController.ts | 33 ++++++-- .../controller/useDirectProjectTurnStatus.ts | 65 ++++++++++++++++ .../conversation/directTurnPresentation.ts | 71 +++++++++++++---- .../tests/directProjectTurnStatus.test.ts | 77 +++++++++++++++++++ .../tests/directTurnPresentation.test.ts | 60 ++++++++++++++- ...irectProject对话历史单一事实源-2026-09-16.md | 2 + .../shared-memory/decision-log.md | 9 +++ 10 files changed, 333 insertions(+), 48 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts create mode 100644 apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index 839b37053..8e3515ace 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -18,6 +18,7 @@ import { useDirectProjectChatController, } from './controller/useDirectProjectChatController'; import { useDirectProjectManifest } from './controller/useDirectProjectManifest'; +import { useDirectProjectTurnStatus } from './controller/useDirectProjectTurnStatus'; import { createDirectProjectTurnId, directCodexConversationMessageId, @@ -122,6 +123,7 @@ export function DirectProjectChatView({ historyHasMore, loadEarlierHistory, localMessages, + pendingUserItemId, queuedTurns, removeAttachment, startInitialTurn, @@ -131,7 +133,20 @@ export function DirectProjectChatView({ turnCancelling, uploadFiles, } = chat; - const busy = turnBusy || directTurnRunning; + const directTurns = buildDirectChatTurns({ + entries: directEntries, + localMessages, + turnRunning: directTurnRunning, + pendingUserItemId, + }); + // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。 + const turnStatus = useDirectProjectTurnStatus({ + turnRunning: directTurnRunning, + turnBusy, + turns: directTurns, + }); + const activeTurnStartedAt = + directTurns.find((turn) => turn.state === 'running')?.startedAt ?? 0; const statusText = runtimeNotice || statusNotice || @@ -144,7 +159,7 @@ export function DirectProjectChatView({ return; if (projectPath !== initialTurn.projectPath) return; // projectId 来自项目清单:清单还没到位时不能先认领,否则首轮需求会被空项目吃掉。 - if (!projectId || busy) return; + if (!projectId || turnStatus.displayBusy) return; if ( !claimInitialTurnForPage(initialTurn.projectPath, initialTurn.claimScope) ) { @@ -165,9 +180,9 @@ export function DirectProjectChatView({ : {}), userItem, }); - // 首轮需求只由入口 payload、项目身份、清单就绪和回合态驱动。 + // 首轮需求只由入口 payload、项目身份、清单就绪和回合忙态驱动。 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [busy, initialTurn, projectId, projectPath]); + }, [turnStatus.displayBusy, initialTurn, projectId, projectPath]); useEffect(() => { if (!shouldFollowLatestRef.current) return; @@ -175,14 +190,6 @@ export function DirectProjectChatView({ if (list) list.scrollTop = list.scrollHeight; }, [directEntries, localMessages]); - const directTurns = buildDirectChatTurns({ - entries: directEntries, - localMessages, - turnRunning: directTurnRunning, - }); - const activeTurnStartedAt = - directTurns.find((turn) => turn.active)?.startedAt ?? 0; - useImperativeHandle(ref, () => ({ announce: (text: string) => { appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() }); @@ -206,7 +213,7 @@ export function DirectProjectChatView({ >
setSettingsOpen(true)} /> @@ -214,7 +221,7 @@ export function DirectProjectChatView({ turns={directTurns} messagesRef={messagesRef} historyHasMore={historyHasMore} - running={directTurnRunning} + nativeRunning={turnStatus.nativeRunning} activeTurnStartedAt={activeTurnStartedAt} onLoadEarlierHistory={() => void loadEarlierHistory()} onScroll={handleScroll} @@ -227,7 +234,7 @@ export function DirectProjectChatView({ attachmentNotice={attachmentNotice} queuedTurns={queuedTurns} composerNotice={composerNotice} - busy={busy} + busy={turnStatus.displayBusy} turnCancelling={turnCancelling} onCancelQueuedTurn={cancelQueuedTurn} onCancelTurn={() => void cancelTurn()} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx index b982c84ae..fec7a00e4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx @@ -19,7 +19,7 @@ export function DirectProjectConversation({ turns, messagesRef, historyHasMore, - running, + nativeRunning, activeTurnStartedAt, onLoadEarlierHistory, onScroll, @@ -27,7 +27,11 @@ export function DirectProjectConversation({ turns: DirectChatTurn[]; messagesRef: RefObject; historyHasMore: boolean; - running: boolean; + /** + * 原生回合是否在跑(reducer 的 `turnRunning`):只决定这张"正在处理"卡片。 + * 本地命令在飞但原生还没认领的窗口见 `DirectProjectTurnStatus`。 + */ + nativeRunning: boolean; activeTurnStartedAt: number; onLoadEarlierHistory: () => void; onScroll: UIEventHandler; @@ -53,7 +57,7 @@ export function DirectProjectConversation({ ))}
- {running ? ( + {nativeRunning ? ( block.kind === 'assistant') - ?.key ?? null) - : null; + const streamingKey = + turn.state === 'running' + ? ([...turn.process].reverse().find((block) => block.kind === 'assistant') + ?.key ?? null) + : null; return ( {turn.users.map((block) => @@ -48,7 +49,7 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { const blocks = turn.process.map((block) => renderBlock(turn, block, 'process', streamingKey), ); - if (turn.active) return blocks; + if (turn.state === 'running') return blocks; return (
执行过程 @@ -58,7 +59,9 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { } function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { - if (turn.active || !turn.startedAt) return null; + // 已知缺陷(另开改动):`awaiting-start` 目前与 `finished` 同渲染,所以本地已发出、 + // 原生还没认领的那个窗口里会显示「本轮结束于 … · 耗时 0.0秒」。 + if (turn.state === 'running' || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return (

); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index d368e8bb5..0ec2935c6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -113,6 +113,9 @@ export function useDirectProjectChatController({ const [statusNotice, setStatusNotice] = useState(''); const [turnCancelling, setTurnCancelling] = useState(false); const [turnBusy, setTurnBusy] = useState(false); + // 本地已发出、原生还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态, + // 生命周期与 `turnBusy` 完全一致(命令在飞期间有值,收尾即清)。 + const [pendingUserItemId, setPendingUserItemId] = useState(''); const [localMessages, setLocalMessages] = useState([]); // 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里: // controller 只读投影后的条目与回合忙态,不再直接持有线程状态。 @@ -143,6 +146,7 @@ export function useDirectProjectChatController({ setQueuedTurns([]); queuedTurnsRef.current = []; setLocalMessages([]); + setPendingUserItemId(''); setHistoryHasMore(false); historyOldestItemIdRef.current = null; }, [projectPath]); @@ -194,9 +198,21 @@ export function useDirectProjectChatController({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, projectPath]); - function markTurnBusy(busy: boolean) { - turnBusyRef.current = busy; - setTurnBusy(busy); + /** + * 「本地这一轮的命令在飞」的唯一起止点:按下发送时带上本轮用户条目身份,收尾时一起清掉。 + * + * 忙态与待认领身份必须同生共死,否则投影会拿一个过期的身份去判 `awaiting-start`。 + */ + function beginTurnCommand(userItemId: string) { + turnBusyRef.current = true; + setTurnBusy(true); + setPendingUserItemId(userItemId); + } + + function endTurnCommand() { + turnBusyRef.current = false; + setTurnBusy(false); + setPendingUserItemId(''); } function appendLocalMessage(message: ChatMessage) { @@ -388,7 +404,9 @@ export function useDirectProjectChatController({ updatedAt: Date.now(), }); } - markTurnBusy(true); + beginTurnCommand( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ); void (async () => { let invoked = false; try { @@ -406,7 +424,7 @@ export function useDirectProjectChatController({ // 写权限门返回 false 且没有调用 onConfirmed(被策略拒绝、或读策略失败), // 说明这一轮不会重跑;它已经被 dispatchNextQueuedTurn 出队,必须自己把 // 忙态放下并继续出队,否则后面的排队消息会永久卡住。 - markTurnBusy(false); + endTurnCommand(); dispatchNextQueuedTurn(); return; } @@ -429,7 +447,7 @@ export function useDirectProjectChatController({ if (invoked) { await refreshDirectManifest(nextProjectPath); } - markTurnBusy(false); + endTurnCommand(); if (invoked && projectPathRef.current === nextProjectPath) { dispatchNextQueuedTurn(); } @@ -541,7 +559,7 @@ export function useDirectProjectChatController({ const message = result?.message?.trim(); if (result?.outcome === 'released') { directThread.markTurnStopped(); - markTurnBusy(false); + endTurnCommand(); onRuntimeError(''); setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息'); } else if (message) { @@ -714,6 +732,7 @@ export function useDirectProjectChatController({ historyHasMore, loadEarlierHistory, localMessages, + pendingUserItemId, queuedTurns, reloadHistory, removeAttachment, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts new file mode 100644 index 000000000..356e7d48c --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; + +import type { + DirectChatTurn, + DirectChatTurnState, +} from '../conversation/directTurnPresentation'; + +/** + * DirectProject「这一轮在跑吗」的唯一派生入口。 + * + * 同一件事此前在四层里各叫一个名字(reducer 的 `turnRunning`、controller 的 `turnBusy`、 + * 视图里手拼的 `busy`、投影里的 `active`),读代码时无法判断谁该信谁。这里把它们的语义 + * 一次讲清楚,组件只读这一个对象: + * + * - `nativeRunning`:**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started` + * 已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。 + * - `commandInFlight`:**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke → + * 收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。 + * - `displayBusy`:header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再 + * 接受新的发送。 + * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。 + * + * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。 + */ +export type DirectProjectTurnStatus = { + nativeRunning: boolean; + commandInFlight: boolean; + displayBusy: boolean; + latestTurnState: DirectChatTurnState | null; +}; + +export function deriveDirectProjectTurnStatus({ + turnRunning, + turnBusy, + turns, +}: { + turnRunning: boolean; + turnBusy: boolean; + turns: readonly DirectChatTurn[]; +}): DirectProjectTurnStatus { + const nativeRunning = Boolean(turnRunning); + const commandInFlight = Boolean(turnBusy); + const latest = turns.length > 0 ? turns[turns.length - 1] : null; + return { + nativeRunning, + commandInFlight, + displayBusy: nativeRunning || commandInFlight, + latestTurnState: latest ? latest.state : null, + }; +} + +export function useDirectProjectTurnStatus({ + turnRunning, + turnBusy, + turns, +}: { + turnRunning: boolean; + turnBusy: boolean; + turns: readonly DirectChatTurn[]; +}): DirectProjectTurnStatus { + return useMemo( + () => deriveDirectProjectTurnStatus({ turnRunning, turnBusy, turns }), + [turnRunning, turnBusy, turns], + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index 1673032c9..230f07255 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -36,6 +36,20 @@ export type DirectChatBlock = | { kind: 'reasoning'; key: string; text: string } | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; +/** + * 界面上一轮的三态。 + * + * - `running`:原生回合在跑(订阅流里出现过 `turn.started`、还没出现 `turn.completed`)。 + * - `awaiting-start`:本地已发出、原生还没认领(乐观用户气泡已出现,`turn.started` 未到)。 + * - `finished`:拿到明确终态,或不再有理由认为它在跑。 + * + * 它是**展示态**,不是第二套回合生命周期:`running` 只由 reducer 的 `turnRunning` 决定, + * `awaiting-start` 只由「这条用户条目身份就是本地这次发送的身份、且本轮还没有终态」决定。 + * 数据流与变量归属见 `../controller/useDirectProjectChatController.ts` 的模块注释; + * 三态在渲染上的映射见 `DirectProjectTurn.tsx`。 + */ +export type DirectChatTurnState = 'running' | 'awaiting-start' | 'finished'; + export type DirectChatTurn = { key: string; /** 用户气泡:顺序即发出顺序。 */ @@ -44,7 +58,8 @@ export type DirectChatTurn = { process: DirectChatBlock[]; /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */ finals: DirectChatBlock[]; - active: boolean; + /** 这一轮在界面上的状态(三态,取代原来的 `active` 布尔)。 */ + state: DirectChatTurnState; /** * 本轮起点:该轮**实际用户消息的发送时间**优先(与气泡显示的时间同源), * 缺失时用原生 `turn.started.at`,都拿不到是 0(此时隐藏不能证明的总耗时)。 @@ -60,7 +75,8 @@ type DirectChatTurnEntries = { /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */ localUsers: DirectChatBlock[]; notices: ChatMessage[]; - active: boolean; + /** 原生回合是否在跑;只有一个来源——reducer 的 `turnRunning`。 */ + nativeRunning: boolean; }; function blockFromEntry( @@ -178,7 +194,7 @@ function newTurn(key: string): DirectChatTurnEntries { entries: [], localUsers: [], notices: [], - active: false, + nativeRunning: false, }; } @@ -193,6 +209,7 @@ export function buildDirectChatTurns({ localMessages = [], turnRunning = false, turnStartedAt = 0, + pendingUserItemId = '', }: { entries: readonly DirectChatEntry[]; localMessages?: readonly ChatMessage[]; @@ -202,6 +219,14 @@ export function buildDirectChatTurns({ * 不会覆盖用户实际发送时间,也不参与已完成回合。 */ turnStartedAt?: number; + /** + * 本地已发出、原生还没认领的那一轮用户条目身份(`direct-codex:{clientTurnId}:user`)。 + * + * 只服务 `awaiting-start` 这一个展示态:身份命中、且本轮还没有明确终态时,最新一轮按 + * 「待认领」而不是「已结束」呈现。原生 `turn.started` 一到,`turnRunning` 就把这一轮接 + * 过去,这个入参不再参与判定;空串 = 没有在途的本地回合。 + */ + pendingUserItemId?: string; }): DirectChatTurn[] { const turns: DirectChatTurnEntries[] = []; let current: DirectChatTurnEntries | null = null; @@ -247,10 +272,11 @@ export function buildDirectChatTurns({ } current.notices.push(message); }); - if (current) current.active = turnRunning; + if (current) current.nativeRunning = turnRunning; - return turns.map((turn) => { - const lastAssistant = turn.active + const newestTurnIndex = turns.length - 1; + return turns.map((turn, turnIndex) => { + const lastAssistant = turn.nativeRunning ? -1 : turn.entries.reduce( (found, entry, index) => @@ -301,21 +327,38 @@ export function buildDirectChatTurns({ (found, entry) => found || normalizeDirectTimestamp(entry.turnEndedAt), 0, ); - const startedAt = - userSentAt > 0 - ? userSentAt - : turn.active - ? normalizeDirectTimestamp(turnStartedAt) - : stampedStart; + // 起点优先级(逐级覆盖,不嵌套三元):条目上盖的起点 → 运行中改读原生 + // `turn.started.at`(拿不到就是 0,不退回去用条目兜底)→ 该轮用户气泡自己的发送 + // 时间最高优先。 + let startedAt = stampedStart; + if (turn.nativeRunning) { + startedAt = normalizeDirectTimestamp(turnStartedAt); + } + if (userSentAt > 0) { + startedAt = userSentAt; + } // 终态只读**本轮条目**上盖的边界:跨轮 fallback 会把最新回合的终点填进所有 // 拿不到时间的旧历史回合,等于给未知耗时编一个值。 - const endedAt = turn.active ? 0 : stampedEnd; + const endedAt = turn.nativeRunning ? 0 : stampedEnd; + // 三态只在这里产生:原生在跑 = running;最新一轮是本地在途身份且没有终态 = + // awaiting-start;其余都是 finished。判据是身份(`itemId`)而不是时间戳大小。 + let state: DirectChatTurnState = 'finished'; + if (turn.nativeRunning) { + state = 'running'; + } else if ( + turnIndex === newestTurnIndex && + pendingUserItemId !== '' && + turn.key === pendingUserItemId && + stampedEnd <= 0 + ) { + state = 'awaiting-start'; + } return { key: turn.key, users, process: mergeToolBlocks(process), finals, - active: turn.active, + state, startedAt, endedAt, } satisfies DirectChatTurn; diff --git a/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts new file mode 100644 index 000000000..7cab1828d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directProjectTurnStatus.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveDirectProjectTurnStatus } from '../src/view/project-development/chat/controller/useDirectProjectTurnStatus'; +import type { DirectChatTurn } from '../src/view/project-development/chat/conversation/directTurnPresentation'; + +const turn = (key: string, state: DirectChatTurn['state']): DirectChatTurn => ({ + key, + users: [], + process: [], + finals: [], + state, + startedAt: 1_800_000_000_000, + endedAt: 0, +}); + +describe('DirectProject 回合状态派生', () => { + it('displayBusy 是原生真相与本地命令在飞的并集', () => { + expect( + deriveDirectProjectTurnStatus({ + turnRunning: true, + turnBusy: false, + turns: [], + }).displayBusy, + ).toBe(true); + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: true, + turns: [], + }).displayBusy, + ).toBe(true); + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: false, + turns: [], + }).displayBusy, + ).toBe(false); + }); + + it('两个来源各自独立暴露,不被并集吃掉', () => { + const status = deriveDirectProjectTurnStatus({ + turnRunning: true, + turnBusy: true, + turns: [], + }); + expect(status.nativeRunning).toBe(true); + expect(status.commandInFlight).toBe(true); + }); + + it('latestTurnState 取最新一轮的三态;没有回合时为 null', () => { + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: false, + turns: [turn('u1', 'finished'), turn('u2', 'awaiting-start')], + }).latestTurnState, + ).toBe('awaiting-start'); + expect( + deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: false, + turns: [], + }).latestTurnState, + ).toBeNull(); + }); + + it('本地命令在飞不等于原生在跑', () => { + const status = deriveDirectProjectTurnStatus({ + turnRunning: false, + turnBusy: true, + turns: [turn('u1', 'awaiting-start')], + }); + expect(status.nativeRunning).toBe(false); + expect(status.latestTurnState).toBe('awaiting-start'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index 853c930cd..1b95a2c0a 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -184,7 +184,7 @@ describe('DirectProject 聊天分区', () => { ], turnRunning: true, }); - expect(turns[0]?.active).toBe(true); + expect(turns[0]?.state).toBe('running'); expect(turns[0]?.finals).toEqual([]); expect(turns[0]?.process.map((block) => block.kind)).toEqual([ 'assistant', @@ -293,7 +293,63 @@ describe('DirectProject 聊天分区', () => { }); expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']); expect(turns[1]?.users).toHaveLength(1); - expect(turns[1]?.active).toBe(true); + expect(turns[1]?.state).toBe('running'); + }); + + it('三态:本地已发出、原生还没认领的那一轮是 awaiting-start,不是已结束', () => { + const turns = buildDirectChatTurns({ + entries: [], + localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')], + pendingUserItemId: 'direct-codex:turn-1:user', + }); + expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']); + expect(turns[0]?.endedAt).toBe(0); + expect(turns[0]?.startedAt).toBe(1_800_000_002_000); + }); + + it('三态:原生用户条目先到、turn.started 还没到时仍是 awaiting-start', () => { + const turns = buildDirectChatTurns({ + // 同身份的原生条目已经到了(本地气泡被去重),但原生回合还没开始。 + entries: [userEntry('direct-codex:turn-1:user')], + localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')], + pendingUserItemId: 'direct-codex:turn-1:user', + }); + expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']); + }); + + it('三态:拿到明确终态后,在途身份不再把这一轮判成待认领', () => { + const turns = buildDirectChatTurns({ + entries: [ + { + ...userEntry('direct-codex:turn-1:user'), + turnEndedAt: 1_800_000_010_000, + }, + ], + pendingUserItemId: 'direct-codex:turn-1:user', + }); + expect(turns[0]?.state).toBe('finished'); + expect(turns[0]?.endedAt).toBe(1_800_000_010_000); + }); + + it('三态:只有最新一轮能是 awaiting-start,身份不匹配也不影响判定', () => { + const notNewest = buildDirectChatTurns({ + entries: [userEntry('direct-codex:turn-1:user')], + localMessages: [localUser('第二条', 'direct-codex:turn-2:user')], + pendingUserItemId: 'direct-codex:turn-1:user', + }); + expect(notNewest.map((turn) => turn.state)).toEqual([ + 'finished', + 'finished', + ]); + const mismatch = buildDirectChatTurns({ + entries: [userEntry('u1')], + localMessages: [localUser('第二条', 'direct-codex:turn-2:user')], + pendingUserItemId: 'direct-codex:turn-9:user', + }); + expect(mismatch.map((turn) => turn.state)).toEqual([ + 'finished', + 'finished', + ]); }); it('历史无用户条目时也保留一个回合承载正文', () => { diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 0642ca205..48a2e5933 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -51,5 +51,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 - 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 +- 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 +- 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。 - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 - id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5913f4cfb..8f330e4e5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9312,3 +9312,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(空白口径):`directCodexContentToPromptText` 逐字投影、不再 `trim`(前端只在整条 content 上判空);出站提示词的收边规范化收敛成一个共享函数 `resourceCanvasAssetGenerationPromptText`,面板校验与任务落账共用,图集走 Unicode White_Space、其余走 JS 口径。 - 影响面:`apps/ai-game-creator-shell/src/features/{project-workspace/resourceReferences.ts,project-workspace/ResourceReferenceInput.tsx,resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx,resource-canvas/resourceCanvasAssetGenerationTaskModel.ts,resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts}`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx` 与对应 6 个定向测试文件。 - 验证:定向 `resourceCanvasAssetGenerationReferences` / `resourceCanvasAssetGenerationBackgroundClose` / `resourceCanvasBottomToolbar` / `resourceCanvasGenerationFloatingPanel(Chrome)` / `resourceReferenceInput` / `resourceReferences` / `resourceCanvasAssetGenerationTasksPanel` 全绿;全量 `npm run test -- apps/ai-game-creator-shell/tests` 只剩 `clientHttp` / `clientApi` / `clientAuthStorage` / `projectCreationDirectory` / `recentProjectsHook` 五个 jsdom `localStorage` 环境用例红(与本次改动无调用关系);TS typecheck、`check:encoding`、`git diff --check` 通过。未复核真实客户端观感。 + +## 2026-09-22 DirectProject 聊天状态显式化:回合三态 + 「在跑吗」唯一派生入口 + 数据流地图 + +- 背景:DirectProject 聊天框只有三份真相源(`project.jsonl` 历史切片、Thread Manager 运行态事件、本地乐观消息),但「这一轮在跑吗」在四层里各叫一个名字——reducer 的 `turnRunning`、controller 的 `turnBusy`、视图里手拼的 `busy`、投影里的 `active`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。 +- 决策(三态取代布尔):`DirectChatTurn.active` 改为 `DirectChatTurn.state: 'running' | 'awaiting-start' | 'finished'`。`running` 只由 reducer 的 `turnRunning` 决定;`awaiting-start` 由「最新一轮的用户条目身份 = 本地在途的 `pendingUserItemId`(`direct-codex:{clientTurnId}:user`)、且本轮还没有明确终态」决定;其余是 `finished`。判据是身份不是时间戳,`pendingUserItemId` 由 controller 在 `beginTurnCommand()` / `endTurnCommand()` 里与 `turnBusy` 同生共死。 +- 决策(单一派生入口):新增 `useDirectProjectTurnStatus()`,返回 `{ nativeRunning, commandInFlight, displayBusy, latestTurnState }`。header / composer 只读 `displayBusy`(两者并集,语义与原来的 `turnBusy || directTurnRunning` 完全一致),「陶泥儿正在处理」卡片只读 `nativeRunning`。约定:新增「忙 / 在跑」类判据先落进这里,不在组件里另拼布尔。 +- 决策(地图落代码):三层数据流、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属写进 `apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表写进 `.../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。这套说明是给改这块代码的人看的,放代码里才不会与实现脱节;ADR 补一条「活动回合唯一判据约束的是**原生回合**」的澄清与代码指针。 +- 边界(本次不修):`awaiting-start` 暂时与 `finished` 同渲染,所以空窗期内仍会显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」;`Math.max(turn.endedAt, turn.startedAt)` 的兜底与 `DirectProjectTurnUsage` 的渲染条件都没动。同源的第二条缺陷也记录在案:`turnEndedAt` 只是会话内展示缓存,页面重进后所有已结束回合都会走同一条兜底显示 0.0 秒(临时渲染用例实测确认,用例未入库)。修法与证据要求见后续那次三态接入渲染的改动,以及 `DirectProjectTurn.tsx` 里标注未修范围的注释。 +- 验证:`npx vitest run` 定向 `directTurnPresentation`(17 条,新增 4 条三态用例)、`directProjectTurnStatus`(4 条)、`directHistoryPaging`(9 条)全绿;`appSurface.test.ts` 202 passed / 13 skipped;`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。真实客户端观感与窗口期表现未在客户端复核。 From cfcd3cd604782307993cc6d1dd7c115a082c0d52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 14:07:07 +0800 Subject: [PATCH 2/9] =?UTF-8?q?DirectProject=20=E7=A9=BA=E7=AA=97=E6=9C=9F?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=B0=8E=E6=8A=A5=E6=9C=AC=E8=BD=AE=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=EF=BC=9A=E4=B8=89=E6=80=81=E6=8E=A5=E5=85=A5=E6=B8=B2?= =?UTF-8?q?=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectProjectTurnUsage 渲染判据改为 state !== 'finished',awaiting-start 不再显示「本轮结束于 … 耗时 0.0秒」 - DirectProjectTurn 的过程折叠判据同样改为 state !== 'finished';流式正文与工具卡片仍按 state === 'running' - 组件注释写明「否定式读 !== 'finished'、肯定式读 === 'running'」这条分工,并标出仍由 A 负责的另一半兜底与覆盖范围 - 新增 tests/directProjectTurn.test.tsx:awaiting-start / running 不显示终态文案且不折叠、finished 有终态时显示结束时间与耗时 - appSurface chat-composer 新增窗口期用例:invoke 挂起且无任何原生事件时不出现「本轮结束于」 - decision-log 追加本条决策、保守卡片口径的理由、未修边界与变异验证结论 --- .../DirectProjectTurn.tsx | 22 +++++-- .../tests/appSurface/chat-composer.suite.ts | 24 ++++++++ .../tests/directProjectTurn.test.tsx | 61 +++++++++++++++++++ .../shared-memory/decision-log.md | 13 +++- 4 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx index 1345343b0..362ecb5b4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx @@ -20,8 +20,15 @@ import { /** * 一个完整回合的分区表现:用户发言、执行过程(工具/思考)与最终答复。 * - * 运行中的回合把执行过程平铺出来,已结束的回合折叠进「执行过程」;这一层只做投影到 - * 表现的渲染,不拥有任何回合状态。 + * 未结束的回合(`running` / `awaiting-start`)把执行过程平铺出来并隐藏终态文案, + * `finished` 才折叠进「执行过程」;这一层只做投影到表现的渲染,不拥有任何回合状态。 + * + * 三态的判据分两类,不要对调(三态定义与真值表见 + * `../../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`): + * - **否定式**(不要说它结束、不要折叠、不要显示终态文案)读 `state !== 'finished'`: + * `awaiting-start` 时轮次确实还没结束,只是宿主还没确认。 + * - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`: + * `awaiting-start` 只说明本地已发出,不能据此断言宿主已经在跑。 */ export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) { const streamingKey = @@ -49,7 +56,7 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { const blocks = turn.process.map((block) => renderBlock(turn, block, 'process', streamingKey), ); - if (turn.state === 'running') return blocks; + if (turn.state !== 'finished') return blocks; return (

执行过程 @@ -59,9 +66,12 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { } function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { - // 已知缺陷(另开改动):`awaiting-start` 目前与 `finished` 同渲染,所以本地已发出、 - // 原生还没认领的那个窗口里会显示「本轮结束于 … · 耗时 0.0秒」。 - if (turn.state === 'running' || !turn.startedAt) return null; + // 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、 + // 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。 + // 仍未修的另一半:`finished` 但没有可证明终态时间的回合(例如重进项目后读回来的历史回合, + // 以及发送后没有产生任何原生事件的本地回合)仍会被下面的 `Math.max` 兜底量化成 0.0 秒; + // 覆盖范围和产品口径见 decision-log 的 2026-09-22 条目。 + if (turn.state !== 'finished' || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return (

{ + const pending: Array<{ resolve: (value: string) => void }> = []; + const { surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '窗口期的消息'); + + // 本地乐观气泡立刻可见;此刻原生既没回 turn.started,也没回显用户条目, + // 这一轮属于「本地已发出、宿主未确认」,不得渲染成已结束。 + await waitFor(() => { + expect(within(surface).getByText('窗口期的消息')).not.toBeNull(); + }); + expect(within(surface).queryByText(/本轮结束于/)).toBeNull(); + expect(within(surface).queryByTestId('turn-usage')).toBeNull(); + + await act(async () => { + pending[0]?.resolve('回复'); + }); + }); + it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { const pending: Array<{ resolve: (value: string) => void }> = []; const { invoke, surface, harness } = await openDirectCodexSurface( diff --git a/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx new file mode 100644 index 000000000..1561a54e7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directProjectTurn.test.tsx @@ -0,0 +1,61 @@ +/** @vitest-environment jsdom */ +import { render } from '@testing-library/react'; +import React from 'react'; +import { expect, it } from 'vitest'; + +import { DirectProjectTurn } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn'; +import type { + DirectChatTurn, + DirectChatTurnState, +} from '../src/view/project-development/chat/conversation/directTurnPresentation'; + +const SENT_AT = 1_800_000_000_000; +const ENDED_AT = SENT_AT + 12_400; + +const turn = ( + state: DirectChatTurnState, + overrides: Partial = {}, +): DirectChatTurn => ({ + key: 'direct-codex:turn-1:user', + users: [{ kind: 'user', key: 'u', text: '帮我做一个跳跃动作', at: SENT_AT }], + process: [ + { kind: 'reasoning', key: 'r', text: '先看目录' }, + { kind: 'assistant', key: 'a', text: '正在写' }, + ], + finals: [{ kind: 'assistant', key: 'a-final', text: '改好了', at: ENDED_AT }], + state, + startedAt: SENT_AT, + endedAt: state === 'finished' ? ENDED_AT : 0, + ...overrides, +}); + +it('awaiting-start:本地已发出、原生还没认领时不显示终态文案,也不折叠过程', () => { + const view = render( + React.createElement(DirectProjectTurn, { + turn: turn('awaiting-start'), + }), + ); + expect(view.queryByTestId('turn-usage')).toBeNull(); + expect(view.queryByText(/本轮结束于/)).toBeNull(); + // 否定式判据:未结束的回合把过程平铺出来,不折进「执行过程」。 + expect(view.queryByTestId('turn-process')).toBeNull(); + expect(view.getByLabelText('思考过程')).not.toBeNull(); +}); + +it('running:原生回合在跑时同样不显示终态文案', () => { + const view = render( + React.createElement(DirectProjectTurn, { turn: turn('running') }), + ); + expect(view.queryByTestId('turn-usage')).toBeNull(); + expect(view.queryByTestId('turn-process')).toBeNull(); +}); + +it('finished 且有明确终态:显示结束时间与耗时,过程折叠', () => { + const view = render( + React.createElement(DirectProjectTurn, { turn: turn('finished') }), + ); + const usage = view.getByTestId('turn-usage'); + expect(usage.textContent).toContain('本轮结束于'); + expect(usage.textContent).toContain('耗时 12.4秒'); + expect(view.queryByTestId('turn-process')).not.toBeNull(); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8f330e4e5..fad14aa78 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9318,6 +9318,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:DirectProject 聊天框只有三份真相源(`project.jsonl` 历史切片、Thread Manager 运行态事件、本地乐观消息),但「这一轮在跑吗」在四层里各叫一个名字——reducer 的 `turnRunning`、controller 的 `turnBusy`、视图里手拼的 `busy`、投影里的 `active`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。 - 决策(三态取代布尔):`DirectChatTurn.active` 改为 `DirectChatTurn.state: 'running' | 'awaiting-start' | 'finished'`。`running` 只由 reducer 的 `turnRunning` 决定;`awaiting-start` 由「最新一轮的用户条目身份 = 本地在途的 `pendingUserItemId`(`direct-codex:{clientTurnId}:user`)、且本轮还没有明确终态」决定;其余是 `finished`。判据是身份不是时间戳,`pendingUserItemId` 由 controller 在 `beginTurnCommand()` / `endTurnCommand()` 里与 `turnBusy` 同生共死。 - 决策(单一派生入口):新增 `useDirectProjectTurnStatus()`,返回 `{ nativeRunning, commandInFlight, displayBusy, latestTurnState }`。header / composer 只读 `displayBusy`(两者并集,语义与原来的 `turnBusy || directTurnRunning` 完全一致),「陶泥儿正在处理」卡片只读 `nativeRunning`。约定:新增「忙 / 在跑」类判据先落进这里,不在组件里另拼布尔。 -- 决策(地图落代码):三层数据流、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属写进 `apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表写进 `.../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。这套说明是给改这块代码的人看的,放代码里才不会与实现脱节;ADR 补一条「活动回合唯一判据约束的是**原生回合**」的澄清与代码指针。 -- 边界(本次不修):`awaiting-start` 暂时与 `finished` 同渲染,所以空窗期内仍会显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」;`Math.max(turn.endedAt, turn.startedAt)` 的兜底与 `DirectProjectTurnUsage` 的渲染条件都没动。同源的第二条缺陷也记录在案:`turnEndedAt` 只是会话内展示缓存,页面重进后所有已结束回合都会走同一条兜底显示 0.0 秒(临时渲染用例实测确认,用例未入库)。修法与证据要求见后续那次三态接入渲染的改动,以及 `DirectProjectTurn.tsx` 里标注未修范围的注释。 +- 决策(地图落代码):三层数据流、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属写进 `apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表写进 `.../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。不另开技术方案文档——这套说明是给改这块代码的人看的,放代码里才不会与实现脱节。ADR 补一条「活动回合唯一判据约束的是**原生回合**」的澄清与代码指针。 +- 口径更正(2026-09-22,同日第二条):本条记录的「`awaiting-start` 暂时与 `finished` 同渲染」已由随后的三态接入渲染改动修掉,见下面那条。 +- 边界(本次不修):`awaiting-start` 暂时与 `finished` 同渲染,所以空窗期内仍会显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」;`Math.max(turn.endedAt, turn.startedAt)` 的兜底与 `DirectProjectTurnUsage` 的渲染条件都没动。同源的第二条缺陷也记录在案:`turnEndedAt` 只是会话内展示缓存,页面重进后所有已结束回合都会走同一条兜底显示 0.0 秒(临时渲染用例实测确认,用例未入库)。修法与证据要求见下面那条(三态接入渲染)以及 `DirectProjectTurn.tsx` 里标注未修范围的注释。 - 验证:`npx vitest run` 定向 `directTurnPresentation`(17 条,新增 4 条三态用例)、`directProjectTurnStatus`(4 条)、`directHistoryPaging`(9 条)全绿;`appSurface.test.ts` 202 passed / 13 skipped;`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` 通过。真实客户端观感与窗口期表现未在客户端复核。 + +## 2026-09-22 空窗期不再谎报「本轮结束」:DirectProject 三态接入渲染 + +- 背景:上一条只把回合三态显式化,渲染层仍按 `state === 'running'` 判断,于是「本地已发出、宿主还没回 `turn.started`」的窗口里 `awaiting-start` 被当成 `finished` 渲染,显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」(用户现场反馈的现象)。 +- 决策(判据分两类,不许对调):**否定式**判断(不要说它结束、不要折叠过程、不要显示终态文案)读 `state !== 'finished'`;**肯定式**判断(哪段正文在流式、「陶泥儿正在处理」卡片与滚动已耗时)读 `state === 'running'`。理由是 `awaiting-start` 能支持"还没结束",但不能支持"宿主已经在跑"——后者只有 `turn.started` 能证明。 +- 决策(卡片口径取保守):`DirectProjectConversation` 的「正在处理」卡片与 `activeTurnStartedAt` 仍只认 `turnStatus.nativeRunning`,窗口期不出现这张卡片。文案是「陶泥儿正在处理」,在 `turn.started` 之前无法断言宿主已经开始,这与空窗缺陷是同一个病根(把"本地已发出"当成"宿主已在跑");窗口期用户看到的是"消息已发出 + 输入框忙",语义诚实。若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,那时应把投影压成 `unfinished: boolean`,不要留一个没人读的状态成员。 +- 边界(A 仍未修):`DirectProjectTurnUsage` 的 `Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。修法是只在 `endedAt > 0` 时渲染终态文案并把耗时交给 `turnTotalDurationMs()`,属于产品口径变化(宁可隐藏也不编),需要单独确认。修法落地后要同步删除 `DirectProjectTurn.tsx` 里那段说明未修范围的注释。 +- 验证:`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` 通过。真实客户端观感未复核。 From 417137baf80dca41abdc9afab4f14883619a25e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 14:07:35 +0800 Subject: [PATCH 3/9] =?UTF-8?q?=E6=8A=8A=20DirectProject=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=8A=B6=E6=80=81=E4=B8=8E=E6=95=B0=E6=8D=AE=E6=B5=81?= =?UTF-8?q?=E7=9A=84=E8=AF=B4=E6=98=8E=E5=86=99=E8=BF=9B=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useDirectProjectChatController 模块注释补三层地图、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属 - DirectChatTurnState 注明三态各自能断言什么、判据的四个输入及来路、state 真值表、两个易读错的点与「finished 拿不到终态时间」的已知边界 - DirectThreadChatState.turnRunning 注明它只是原生真相,不等于界面上的「这一轮在跑吗」 - DirectProjectTurnUsage 注释写清两类「finished 却拿不到终态时间」的回合、覆盖范围与修法口径,说明不再甩给任何文档 - decision-log 的未修边界条目改为指向上述代码位置,避免同一套说明在代码与文档里各留一份 --- .../DirectProjectTurn.tsx | 8 +-- .../useDirectProjectChatController.ts | 48 +++++++++++++++++ .../controller/useDirectProjectTurnStatus.ts | 3 ++ .../chat/conversation/directThreadChat.ts | 9 +++- .../conversation/directTurnPresentation.ts | 53 ++++++++++++++++--- .../shared-memory/decision-log.md | 2 +- 6 files changed, 110 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx index 362ecb5b4..b3d0df6c6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn.tsx @@ -68,9 +68,11 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) { function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) { // 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、 // 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。 - // 仍未修的另一半:`finished` 但没有可证明终态时间的回合(例如重进项目后读回来的历史回合, - // 以及发送后没有产生任何原生事件的本地回合)仍会被下面的 `Math.max` 兜底量化成 0.0 秒; - // 覆盖范围和产品口径见 decision-log 的 2026-09-22 条目。 + // 仍未修的另一半(A):`finished` 但没有可证明终态时间的回合,会被下面的 `Math.max` 兜底 + // 量化成 0.0 秒,共两类——① 重进项目后读回来的历史回合(`turnEndedAt` 只活在本次会话里, + // 不会随 `project.jsonl` 持久化);② 本地已发出却一个原生事件都没产生的回合(发送失败)。 + // 修法是只在 `turn.endedAt > 0` 时渲染终态文案、耗时改由 `turnTotalDurationMs()` 出(边界缺失 + // 就隐藏),属于产品口径变化(宁可隐藏也不编),确认后单独改;改完把这半段注释删掉。 if (turn.state !== 'finished' || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 0ec2935c6..0a0250fb4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -91,6 +91,54 @@ export type DirectProjectChatControllerProps = { * * 订阅、首屏锚点与历史分页、发送与 FIFO 队列、附件、终止、草稿和本地消息都归这里; * 工作台壳只注入项目上下文与两条权限门,不再持有 Direct 专属 state/ref/effect。 + * + * ## 数据流(改判据前先读这一段) + * + * ``` + * Rust 宿主(事实的产生地) + * ├─ project.jsonl 持久化原始条目(AGC 写;app-server 回显的用户消息被过滤) + * └─ Thread Manager 事件队列(内存) per-thread 事件序列 + 每 subscriber 游标 + lifecycle_anchor + * │ append_direct_thread_event() → notify(只带 subscriptionId,纯唤醒) + * ▼ + * 传输层(三条通道,前端各拉各的) + * A 运行态:notify → invoke consume_direct_project_thread → events[](实时) + * B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描) + * C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明) + * ▼ + * 前端 + * useDirectThreadChatSubscription reducer:A + B 进同一份 state(turnRunning / history / live) + * ▼ + * useDirectProjectChatController 本地状态:localMessages / turnBusy / pendingUserItemId / 队列 + * ▼ + * DirectProjectChatView turns = buildDirectChatTurns(...);status = useDirectProjectTurnStatus(...) + * ▼ + * DirectProjectTurn 用户气泡 / 过程块 / 最终回复 / 本轮耗时 + * ``` + * + * 三份原始输入各自是什么、带什么、活多久: + * - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**, + * 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。 + * - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合 + * 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。 + * - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份 + * (`direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。 + * + * 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗): + * 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。 + * 2. `invoke('chat_with_game_creator_direct_codex')`:Rust 先落盘用户条目,再发 `turn/start`, + * **应答返回后**才 append `turn.started` 并 notify。 + * 3. notify → consume → `turn.started`:reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。 + * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 + * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 + * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上。 + * 7. 命令收尾(`finally`):`endTurnCommand()` 清掉忙态与在途身份,刷新清单,按边沿推进队列出队。 + * + * 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写; + * 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` / + * `endTurnCommand`)、`localMessages`、发送队列与分页 ref 只服务发送与展示;界面上的 + * 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在 + * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读 + * `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。 */ export function useDirectProjectChatController({ assets, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts index 356e7d48c..05c9f456e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectTurnStatus.ts @@ -21,6 +21,9 @@ import type { * - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。 * * 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。 + * `latestTurnState` 三态各自的含义、判据输入与真值表写在 + * `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。 + * 数据流、变量归属与一次发送的时序见 `useDirectProjectChatController.ts` 的模块注释。 */ export type DirectProjectTurnStatus = { nativeRunning: boolean; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 4ed45d4c6..8d9565951 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -45,7 +45,14 @@ export type DirectChatEntry = { }; export type DirectThreadChatState = { - /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ + /** + * 最新**原生**回合是否还在跑;只由生命周期事件(`turn.started` / `turn.completed`) + * 的先后决定。 + * + * 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段 + * 窗口里它为假,但那一轮在界面上是"待认领"而不是"已结束"。界面侧的三态与判据见 + * `directTurnPresentation.ts` 的 `DirectChatTurnState`。 + */ turnRunning: boolean; /** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */ turnStartedAt: number; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts index 230f07255..33711fa2d 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts @@ -37,16 +37,53 @@ export type DirectChatBlock = | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; /** - * 界面上一轮的三态。 + * 界面上一轮的三态。它是**展示态**,不是第二套回合生命周期。 * - * - `running`:原生回合在跑(订阅流里出现过 `turn.started`、还没出现 `turn.completed`)。 - * - `awaiting-start`:本地已发出、原生还没认领(乐观用户气泡已出现,`turn.started` 未到)。 - * - `finished`:拿到明确终态,或不再有理由认为它在跑。 + * 三态各自能断言什么(渲染时按这个分两类,不要对调): + * - `running`:**宿主已确认这一轮开始了**(订阅流里出现过 `turn.started`、还没出现 + * `turn.completed`)。它是唯一能做肯定式断言的态。 + * - `awaiting-start`:**本地已把这轮交出去、宿主还没确认**(乐观气泡已出现,`turn.started` + * 未到)。只支持否定式断言:"它还没结束",不能说"它正在跑"。 + * - `finished`:其余全部 —— 拿到终态的、身份不匹配的、不是最新一轮的,以及**拿不到边界的 + * 历史回合**(这类最容易被误判成"还在跑",必须落在这一态)。 * - * 它是**展示态**,不是第二套回合生命周期:`running` 只由 reducer 的 `turnRunning` 决定, - * `awaiting-start` 只由「这条用户条目身份就是本地这次发送的身份、且本轮还没有终态」决定。 - * 数据流与变量归属见 `../controller/useDirectProjectChatController.ts` 的模块注释; - * 三态在渲染上的映射见 `DirectProjectTurn.tsx`。 + * 判据用四个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现): + * - `turn.nativeRunning` ← 入参 `turnRunning` ← reducer 的 `state.turnRunning` + * (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合, + * 所以「非最新一轮 + nativeRunning」不可达)。 + * - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护, + * 生命周期与 `turnBusy` 一致;空串 = 没有在途的本地回合。 + * - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用 + * `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。 + * - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。 + * + * 真值表: + * + * | nativeRunning | 最新一轮 && pendingUserItemId 身份命中 | stampedEnd > 0 | → state | + * | true | — | — | running | + * | false | false | 任意 | finished | + * | false | true | true | finished | + * | false | true | false | awaiting-start | + * + * 判据一律用**身份与显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开 + * 两轮,回显条目的 `at` 还是宿主 ack 的观测时间(晚于用户真实发送)。这也是为什么 + * `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。 + * + * 两个容易读错的地方: + * - `pendingUserItemId` 有值 **≠** `awaiting-start`:`invoke` 直到整轮结束才返回,所以 + * `turn.started` 之后它仍在,但那时 `nativeRunning` 已经把它接成 `running`。 + * - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示 + * 缓存),它们必须落 `finished`。 + * + * 三态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与 + * 一次发送的时序见 `../controller/useDirectProjectChatController.ts` 的模块注释。 + * + * 已知边界(改 `finished` 判据时要连着一起看):`finished` 只断言"不再有理由认为它在跑", + * **不断言"拿得到终态时间"**。有两类回合没有可证明的边界时间,只被 + * `Math.max(turn.endedAt, turn.startedAt)` 兜底量化成 0.0 秒——① 重进项目后读回来的历史回合 + * (`turnEndedAt` 只是会话内展示缓存,不随 `project.jsonl` 持久化);② 本地已发出却一个原生事件 + * 都没产生的回合(发送失败、`turn.started` 没来)。要不要把这两类的终态文案藏掉是产品口径问题 + * (宁可隐藏也不编),需要单独确认后单独改,不要顺手塞进三态判据。 */ export type DirectChatTurnState = 'running' | 'awaiting-start' | 'finished'; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index fad14aa78..db5bf4521 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9328,5 +9328,5 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 背景:上一条只把回合三态显式化,渲染层仍按 `state === 'running'` 判断,于是「本地已发出、宿主还没回 `turn.started`」的窗口里 `awaiting-start` 被当成 `finished` 渲染,显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」(用户现场反馈的现象)。 - 决策(判据分两类,不许对调):**否定式**判断(不要说它结束、不要折叠过程、不要显示终态文案)读 `state !== 'finished'`;**肯定式**判断(哪段正文在流式、「陶泥儿正在处理」卡片与滚动已耗时)读 `state === 'running'`。理由是 `awaiting-start` 能支持"还没结束",但不能支持"宿主已经在跑"——后者只有 `turn.started` 能证明。 - 决策(卡片口径取保守):`DirectProjectConversation` 的「正在处理」卡片与 `activeTurnStartedAt` 仍只认 `turnStatus.nativeRunning`,窗口期不出现这张卡片。文案是「陶泥儿正在处理」,在 `turn.started` 之前无法断言宿主已经开始,这与空窗缺陷是同一个病根(把"本地已发出"当成"宿主已在跑");窗口期用户看到的是"消息已发出 + 输入框忙",语义诚实。若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,那时应把投影压成 `unfinished: boolean`,不要留一个没人读的状态成员。 -- 边界(A 仍未修):`DirectProjectTurnUsage` 的 `Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。修法是只在 `endedAt > 0` 时渲染终态文案并把耗时交给 `turnTotalDurationMs()`,属于产品口径变化(宁可隐藏也不编),需要单独确认。修法落地后要同步删除 `DirectProjectTurn.tsx` 里那段说明未修范围的注释。 +- 边界(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` 通过。真实客户端观感未复核。 From c8a8b0b0b8d6b3aedf29da554551da6773e614c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 14:56:50 +0800 Subject: [PATCH 4/9] =?UTF-8?q?DirectProject=20=E8=81=8A=E5=A4=A9=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=E8=A1=A5=20memo=EF=BC=8C=E8=AE=A9=E4=B8=8B=E6=B8=B8?= =?UTF-8?q?=20memo=20=E4=B8=8D=E5=86=8D=E7=A9=BA=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectProjectChatView 的 directTurns 改用 useMemo 缓存,判据为 directEntries / localMessages / directTurnRunning / pendingUserItemId - 注释写明不缓存时每次渲染都是新数组引用,useDirectProjectTurnStatus 里以 turns 为判据的 useMemo 永远命中不了,读者会误以为 turnStatus 引用稳定 --- .../chat/DirectProjectChatView.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index 8e3515ace..0cd590b95 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -1,6 +1,12 @@ import type { UIEventHandler } from 'react'; import type { Ref } from 'react'; -import { useEffect, useImperativeHandle, useRef, useState } from 'react'; +import { + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants'; import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; @@ -133,12 +139,19 @@ export function DirectProjectChatView({ turnCancelling, uploadFiles, } = chat; - const directTurns = buildDirectChatTurns({ - entries: directEntries, - localMessages, - turnRunning: directTurnRunning, - pendingUserItemId, - }); + // 这份投影要参与下游 memo 的判据,必须自己先缓存:不缓存的话每次渲染都是一份新数组, + // `useDirectProjectTurnStatus` 里以 `turns` 为判据的 `useMemo` 永远命中不了,那层 memo + // 就成了死代码,读代码的人还会误以为 `turnStatus` 是引用稳定的。 + const directTurns = useMemo( + () => + buildDirectChatTurns({ + entries: directEntries, + localMessages, + turnRunning: directTurnRunning, + pendingUserItemId, + }), + [directEntries, localMessages, directTurnRunning, pendingUserItemId], + ); // 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。 const turnStatus = useDirectProjectTurnStatus({ turnRunning: directTurnRunning, From 67d2f0842154ef87686cf332c80255e7aa798197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 14:59:13 +0800 Subject: [PATCH 5/9] =?UTF-8?q?=E7=AA=97=E5=8F=A3=E6=9C=9F=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E7=94=A8=E4=BE=8B=E5=85=88=E7=AD=89=20invoke=20?= =?UTF-8?q?=E5=8F=91=E5=87=BA=EF=BC=8C=E5=88=AB=E8=AE=A9=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E5=9C=A8=E5=91=BD=E4=BB=A4=E6=9C=AA=E5=8F=91=E5=87=BA=E6=97=B6?= =?UTF-8?q?=E5=B0=B1=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - chat-composer 的 does not report a finished turn 用例捕获 invoke 并先等它被调用,再断言窗口期不出现终态文案 - 写权限门是异步的:不等待时断言会在 invoke 之前通过、收尾的 pending[0]?.resolve 变成空操作,用例盖不住它声称覆盖的窗口 --- .../tests/appSurface/chat-composer.suite.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 3be5f7899..72914b130 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -471,7 +471,7 @@ export function registerChatComposerControlTests() { it('does not report a finished turn while the host has not acknowledged the send yet', async () => { const pending: Array<{ resolve: (value: string) => void }> = []; - const { surface } = await openDirectCodexSurface({ + const { invoke, surface } = await openDirectCodexSurface({ chat_with_game_creator_direct_codex: () => new Promise((resolve) => { pending.push({ resolve }); @@ -480,6 +480,15 @@ export function registerChatComposerControlTests() { const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '窗口期的消息'); + // 写权限门是异步的,先等命令真的发出去,否则下面的窗口期断言会在 invoke 还没发生时就 + // 通过、收尾的 `pending[0]?.resolve` 也变成空操作,用例根本没盖住它要盖的窗口。 + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expectDirectTurnWithText('窗口期的消息'), + ); + }); + // 本地乐观气泡立刻可见;此刻原生既没回 turn.started,也没回显用户条目, // 这一轮属于「本地已发出、宿主未确认」,不得渲染成已结束。 await waitFor(() => { From cab31613f24d6fd391efc540c4044fadf31195af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 15:20:38 +0800 Subject: [PATCH 6/9] =?UTF-8?q?=E6=9D=83=E9=99=90=E8=A2=AB=E6=8B=92?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=B9=E6=8E=89=E4=B8=8B=E4=B8=80=E8=BD=AE?= =?UTF-8?q?=E7=9A=84=E5=BF=99=E6=80=81=EF=BC=9A=E5=87=BA=E9=98=9F=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E4=BA=A4=E7=BB=99=20finally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - startTurn 的权限被拒分支不再自己 endTurnCommand + dispatchNextQueuedTurn,改为标记 queueAdvance 交给 finally 收口 - finally 固定顺序:刷新清单 → endTurnCommand() → 出队,保证清忙态早于出队,不再清掉下一轮刚设上的忙态与 pendingUserItemId - 模块注释把这条顺序写成契约,并说明权限被拒这种「本轮从未发出但要继续出队」的走法 - 新增 appSurface 用例:权限被拒 + 后面排着消息时,被出队那一轮在飞期间输入区仍是终止态;改前该用例红 --- .../useDirectProjectChatController.ts | 23 ++++++-- .../tests/appSurface/chat-composer.suite.ts | 58 +++++++++++++++++++ 2 files changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 0a0250fb4..d6aa7a7d0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -131,7 +131,9 @@ export type DirectProjectChatControllerProps = { * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上。 - * 7. 命令收尾(`finally`):`endTurnCommand()` 清掉忙态与在途身份,刷新清单,按边沿推进队列出队。 + * 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。 + * **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队; + * 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。 * * 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写; * 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` / @@ -457,6 +459,9 @@ export function useDirectProjectChatController({ ); void (async () => { let invoked = false; + // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后: + // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。 + let queueAdvance = false; try { onRuntimeError(''); if (!input.directPolicyChecked) { @@ -470,10 +475,9 @@ export function useDirectProjectChatController({ }); if (!allowed) { // 写权限门返回 false 且没有调用 onConfirmed(被策略拒绝、或读策略失败), - // 说明这一轮不会重跑;它已经被 dispatchNextQueuedTurn 出队,必须自己把 - // 忙态放下并继续出队,否则后面的排队消息会永久卡住。 - endTurnCommand(); - dispatchNextQueuedTurn(); + // 说明这一轮不会重跑;它已经被出队,必须继续出队,否则后面的排队消息会 + // 永久卡住。放忙态与出队都交给 finally 收口,这里只做标记。 + queueAdvance = true; return; } } @@ -495,8 +499,15 @@ export function useDirectProjectChatController({ if (invoked) { await refreshDirectManifest(nextProjectPath); } + // 忙态与在途身份每轮只在这里放一次,且必须早于出队:出队会同步开始下一轮并设上 + // 它自己的忙态,清在它后面就等于把下一轮的忙态抹掉(composer 会以为可以并发发送, + // 下一轮的三态也会因为身份被清空而掉回 finished)。 endTurnCommand(); - if (invoked && projectPathRef.current === nextProjectPath) { + // 出队条件保持原样:真发出过的一轮要求项目没被换掉;权限被拒的一轮从未发出, + // 不受项目切换影响,照旧出队。 + const invokedInSameProject = + invoked && projectPathRef.current === nextProjectPath; + if (queueAdvance || invokedInSameProject) { dispatchNextQueuedTurn(); } } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 72914b130..f093acb43 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -21,6 +21,7 @@ import { act, createGameCreationAppManifest, createProjectChatRuntimeHarness, + emptyProjectPolicy, expect, fireEvent, it, @@ -502,6 +503,63 @@ export function registerChatComposerControlTests() { }); }); + 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> = []; + let policyAllowsWrite = false; + const { invoke, surface } = await openDirectCodexSurface({ + read_project_permission_policy: () => { + if (policyAllowsWrite) return Promise.resolve(emptyProjectPolicy()); + return new Promise((resolve) => { + deferredPolicies.push(resolve); + }); + }, + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '被拒的那条'); + + // 权限门还没回,先把第二条排进队列:后面那条要等被拒的这一轮出队才会发出去。 + await setComposerText(composer, '后面那条'); + submitComposerForm(composer); + const queue = await within(surface).findByLabelText('待发送消息队列'); + expect(within(queue).getByText('后面那条')).not.toBeNull(); + + // 写权限门拒绝这一轮(策略要求确认,且此刻没有 onConfirmed):这一轮不会重跑, + // 队列必须继续走,而它出队后那一轮仍要算「命令在飞」。 + policyAllowsWrite = true; + await act(async () => { + for (const resolve of deferredPolicies.splice(0)) { + resolve({ + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.write'], + }, + }); + } + }); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expectDirectTurnWithText('后面那条'), + ); + }); + // 被出队的那一轮还在飞:上一轮的收尾不得把它刚设上的忙态清掉。 + expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); + expect( + within(surface).getByRole('button', { name: '终止' }), + ).not.toBeNull(); + + await act(async () => { + pending[0]?.resolve('回复'); + }); + }); + it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { const pending: Array<{ resolve: (value: string) => void }> = []; const { invoke, surface, harness } = await openDirectCodexSurface( From a35956f3ec052a10a907de86aee180d40e0f5bd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 15:34:48 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E5=B4=A9=E6=8E=89?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=95=99=E4=B8=8B=E6=B0=B8=E8=BF=9C=E5=BC=80?= =?UTF-8?q?=E7=9D=80=E7=9A=84=E5=9B=9E=E5=90=88=EF=BC=9A=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E5=A4=B1=E8=B4=A5=E6=8C=89=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - directThreadChat 新增 commandClosedTurnUserItemId 与 stopDirectThreadTurn:本地命令返回即这一轮在宿主那边收场,只放掉「是否在跑」、不写终态时间 - 同身份迟到的 turn.started 不再把这一轮拉回运行态;迟到的 turn.completed 例外放行,仍要拿它补上真正的结束时间 - 身份不同的回合不动:「正在跑的是另一轮」这种拒绝不能顺手抹掉别人的回合 - useDirectThreadChatSubscription 暴露 stopCommandTurn;controller 在真失败分支按本轮身份调用,主动终止与 another-turn-running 不走这条 - 新增用例:reducer 两条(不复活 / 不碰别人的回合)、appSurface 一条(失败后不再显示「陶泥儿正在处理」、输入盒回到发送态);变异去掉兜底收口后该用例变红 - decision-log 记录本次决策,以及仍未做的宿主侧根因(Rust 侧 drop 守卫补终态事件) --- .../useDirectProjectChatController.ts | 7 +++ .../useDirectThreadChatSubscription.ts | 14 +++++ .../chat/conversation/directThreadChat.ts | 59 ++++++++++++++++++- .../tests/appSurface/chat-composer.suite.ts | 38 ++++++++++++ .../tests/directThreadChat.test.ts | 49 +++++++++++++++ .../shared-memory/decision-log.md | 9 +++ 6 files changed, 175 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index d6aa7a7d0..58ff19116 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -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 = ''; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 90757bdce..463954657 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -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, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 8d9565951..7075634a6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -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); diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index f093acb43..4de59b000 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -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 | null = + null; + const { surface } = await openDirectCodexSurface( + { + chat_with_game_creator_direct_codex: ( + args: Record | 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> = []; diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 55a1b05d6..98c6d397e 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -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(), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index db5bf4521..ccdb61e08 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 项)。 From 3775e0597004480a57271644cd2e37e0b891abe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=94=E4=BB=A4=E5=BC=98?= Date: Tue, 22 Sep 2026 16:25:51 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=B8=83=E5=B1=80=E4=B8=8E=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=20(#460)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复固定消息区域和工作区状态条样式,修正窄屏单列布局 限制长阶段提示、澄清卡片和文件导入结果高度,保证输入区可达 补齐实时正文和思考的滚动跟随,保留用户上滚意图 移除读取策划历史时补造的发送时间 补充布局与显示回归测试,同步技术方案和共享排障约定 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/460 --- apps/ai-game-creator-shell/src/App.tsx | 13 +- apps/ai-game-creator-shell/src/styles.css | 57 ++++++- .../planning/PlanningChatView.tsx | 8 +- .../tests/appSurface/design-agent.suite.ts | 142 ++++++++++++++++++ .../tests/chatDialogFrameLayout.test.ts | 135 +++++++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 19 +++ 7 files changed, 365 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2403d3913..9a4989b99 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -513,6 +513,7 @@ export function App({ texts.push(entry.text); reasoningByMessageId.set(entry.messageId, texts); } + // 持久策划消息没有发送时间,不能把读取时刻显示成历史发送时间。 const messages: ChatMessage[] = view.messages .filter((message) => message.text.trim()) .map((message) => ({ @@ -521,7 +522,6 @@ export function App({ runtimeOwned: true, messageId: message.id, reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), - updatedAt: Date.now(), })); const initialPrompt = initialPlanningPromptLatchRef.current.prompt; if ( @@ -534,7 +534,6 @@ export function App({ role: 'user', text: initialPrompt, runtimeOwned: true, - updatedAt: Date.now(), }); } return messages; @@ -872,7 +871,15 @@ export function App({ if (messageList) { messageList.scrollTop = messageList.scrollHeight; } - }, [messages, projectChatError]); + }, [ + messages, + projectChatError, + designAgentTransientReply, + designAgentReasoning, + designAgentView, + pendingUiConfirmation, + chatFileImportNotice, + ]); useEffect(() => { latestMessagesRef.current = messages; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 4a3f31494..3b62e9118 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -10121,8 +10121,8 @@ button.design-workspace-tree__entry:hover, justify-self: start; } -/* 策划聊天区包含阶段控制卡、消息、Runtime 状态和输入框。GameAgent 资源工作台的 - 消息列表默认占满整个聊天区,策划模式需要单独恢复五行布局,避免输入框被推到视口外。 */ +/* 阶段、状态条和可选待办按内容排布,只有消息列表吸收剩余高度。 + 不按子节点序号分配 grid 行,避免状态条或待办占用消息的伸缩空间。 */ /* 策划工作台保留标题行,避免共用跨行规则将标题挤到底部。 */ .game-workbench-layout--design .game-workbench-chat { grid-template-rows: auto minmax(0, 1fr); @@ -10142,13 +10142,24 @@ button.design-workspace-tree__entry:hover, } .game-workbench-layout--design .project-chat-conversation { - display: grid; - grid-template-rows: auto minmax(0, 1fr) auto auto auto; + display: flex; + flex-direction: column; height: 100%; min-height: 0; overflow: visible; } +.game-workbench-layout--design .project-chat-conversation > * { + flex: 0 0 auto; + min-width: 0; +} + +.game-workbench-layout--design + .project-chat-conversation + > .project-chat-message-list { + flex: 1 1 0; +} + .game-workbench-layout--design .project-chat-message-list { height: auto; min-height: 0; @@ -10156,6 +10167,16 @@ button.design-workspace-tree__entry:hover, padding-bottom: 12px; } +.game-workbench-layout--design .design-agent-controls, +.game-workbench-layout--design .design-agent-pending-actions, +.game-workbench-layout--design .pending-command { + flex-shrink: 1; + min-height: 0; + max-height: 30%; + overflow: auto; + overflow-wrap: anywhere; +} + .game-workbench-layout--design .project-chat-composer { min-height: 0; grid-template-columns: minmax(0, 1fr); @@ -10176,13 +10197,24 @@ button.design-workspace-tree__entry:hover, } @media (max-width: 760px) { - .game-workbench-layout--design { - height: auto; + .game-project-workbench--design { + overflow-x: hidden; + overflow-y: auto; + } + + /* 两块面板在固定外壳内滚动;覆盖外壳的 height: 100%,避免第二行被裁切。 */ + .game-project-workbench--design .game-workbench-layout--design { + height: auto; + grid-template-columns: minmax(0, 1fr); + } + + .game-workbench-layout--design .game-workbench-stage { + height: 560px; } - .game-workbench-layout--design .game-workbench-stage, .game-workbench-layout--design .game-workbench-chat { min-height: 560px; + height: clamp(560px, calc(100dvh - 154px), 900px); } .game-workbench-layout--design .project-chat-surface, @@ -11019,6 +11051,12 @@ button.design-workspace-tree__entry:hover, font-variant-numeric: tabular-nums; } +.game-workbench-layout--design .project-chat-composer-notice { + max-height: 96px; + overflow: auto; + overflow-wrap: anywhere; +} + .game-workbench-layout--design .project-chat-composer-notice, .game-workbench-chat .project-chat-surface.is-direct-codex @@ -11253,6 +11291,7 @@ button.design-workspace-tree__entry:hover, /* 顶部极简条:44px 高、无独立背景块,只有一条极细分隔线。 左右内缩与消息内容 / 输入盒统一为 16px(面板自身 padding 已归 0)。 */ +.game-workbench-layout--design .project-chat-topbar, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-topbar { @@ -11267,6 +11306,7 @@ button.design-workspace-tree__entry:hover, background: transparent; } +.game-workbench-layout--design .project-chat-topbar-status, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-topbar-status { @@ -11279,6 +11319,7 @@ button.design-workspace-tree__entry:hover, line-height: 1.5; } +.game-workbench-layout--design .project-chat-topbar-status > span:last-child, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-topbar-status @@ -11287,6 +11328,7 @@ button.design-workspace-tree__entry:hover, overflow-wrap: anywhere; } +.game-workbench-layout--design .project-chat-topbar-dot, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-topbar-dot { @@ -11298,6 +11340,7 @@ button.design-workspace-tree__entry:hover, background: var(--platform-neutral-bg); } +.game-workbench-layout--design .project-chat-topbar-dot.is-busy, .game-workbench-chat .project-chat-surface.is-direct-codex .project-chat-topbar-dot.is-busy { diff --git a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx index 81f8fe86d..4c08ac303 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/planning/PlanningChatView.tsx @@ -199,9 +199,11 @@ export function PlanningChatView({ className={`project-chat-topbar-dot${controlBusy ? ' is-busy' : ''}`} aria-hidden="true" /> - {controlBusy - ? '策划 Agent 正在处理' - : workspaceStatusForDisplay(workspaceStatus)} + + {controlBusy + ? '策划 Agent 正在处理' + : workspaceStatusForDisplay(workspaceStatus)} +

{ + const harness = createProjectChatRuntimeHarness({ + designAgentView: designConversationView(), + designAgentContinueView: designConversationView(), + }); + renderDesignAgent(harness); + + const input = await screen.findByLabelText('项目需求'); + await setComposerText(input, '请继续完善方案'); + fireEvent.submit(input.closest('form') as HTMLFormElement); + await waitFor(() => + expect(harness.invoke).toHaveBeenCalledWith( + 'continue_design_agent_session', + expect.objectContaining({ + input: { type: 'message', text: '请继续完善方案' }, + }), + ), + ); + const continueCall = [...harness.invoke.mock.calls] + .reverse() + .find(([command]) => command === 'continue_design_agent_session'); + const clientTurnId = String( + (continueCall?.[1] as { clientTurnId?: string }).clientTurnId, + ); + const messageList = screen.getByLabelText('立项策划消息'); + Object.defineProperty(messageList, 'scrollHeight', { + configurable: true, + value: 1000, + }); + Object.defineProperty(messageList, 'clientHeight', { + configurable: true, + value: 200, + }); + + messageList.scrollTop = 800; + fireEvent.scroll(messageList); + act(() => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + kind: 'reasoning', + reasoningText: '先核对玩法循环', + }), + ); + await screen.findAllByText('先核对玩法循环'); + await waitFor(() => expect(messageList.scrollTop).toBe(1000)); + + messageList.scrollTop = 800; + fireEvent.scroll(messageList); + act(() => + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + kind: 'text', + text: '正在补充关卡节奏', + }), + ); + await screen.findByLabelText('策划 Agent 实时回复'); + await waitFor(() => expect(messageList.scrollTop).toBe(1000)); + + messageList.scrollTop = 120; + fireEvent.scroll(messageList); + act(() => { + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + kind: 'reasoning', + reasoningText: '继续检查多人规则', + }); + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + kind: 'text', + text: '正在补充关卡节奏与多人规则', + }); + }); + await screen.findAllByText('继续检查多人规则'); + await waitFor(() => + expect( + screen.getByLabelText('策划 Agent 实时回复').textContent, + ).toContain('多人规则'), + ); + expect(messageList.scrollTop).toBe(120); + }); + + it('shows only known optimistic send times and does not invent persisted times', async () => { + const hydratedView = { + ...designConversationView(), + messages: [{ id: 'user-history', role: 'user', text: '历史策划需求' }], + }; + const continuedView = { + ...designConversationView(), + messages: [ + ...hydratedView.messages, + { id: 'user-current', role: 'user', text: '当前补充需求' }, + ], + }; + const harness = createProjectChatRuntimeHarness({ + designAgentView: hydratedView, + designAgentContinueView: continuedView, + }); + const originalInvoke = harness.invoke.getMockImplementation()!; + let finishContinue!: () => void; + const continueGate = new Promise((resolve) => { + finishContinue = resolve; + }); + harness.invoke.mockImplementation(async (command, args) => { + if (command === 'continue_design_agent_session') { + await continueGate; + } + return originalInvoke(command, args); + }); + renderDesignAgent(harness); + + const historicalText = await screen.findByText('历史策划需求'); + expect( + historicalText.closest('.message')?.querySelector('time'), + ).toBeNull(); + + const input = screen.getByLabelText('项目需求'); + await setComposerText(input, '当前补充需求'); + fireEvent.submit(input.closest('form') as HTMLFormElement); + const optimisticText = await screen.findByText('当前补充需求'); + const optimisticTime = optimisticText + .closest('.message') + ?.querySelector('time'); + expect(optimisticTime).not.toBeNull(); + expect(optimisticTime?.getAttribute('dateTime')).toBeTruthy(); + + await act(async () => finishContinue()); + await waitFor(() => { + const currentText = screen.getByText('当前补充需求'); + expect(currentText.closest('.message')?.querySelector('time')).toBeNull(); + }); + expect( + screen + .getByText('历史策划需求') + .closest('.message') + ?.querySelector('time'), + ).toBeNull(); + }); + it('hydrates an existing design session and decides approval through design commands', async () => { const harness = createProjectChatRuntimeHarness({ designAgentView: designApprovalView(), diff --git a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts index c185710a3..20a1ff525 100644 --- a/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts @@ -416,3 +416,138 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文 expect(pixelValue(listInPanelDirectCodex, 'min-height')).toBe(0); }); }); + +describe('策划对话:消息伸缩与长内容边界', () => { + function planningDeclarations(className: string, width: number) { + const root = document.createElement('div'); + root.innerHTML = `
+
+
+
+
+
+
+
`; + const element = root.querySelector(className)!; + // 从 DOM 匹配完整选择器,防止遗漏基础规则或误把 Direct 专用样式算到策划入口。 + const selectors = rules.flatMap((rule) => + rule.selectors.filter((selector) => { + try { + return element.matches(selector); + } catch { + // jsdom 不支持的浏览器伪类与本面板无关。 + return false; + } + }), + ); + return resolveDeclarations(rules, selectors, width); + } + + it.each([1440, 390])( + '%ipx:消息吸收剩余高度,状态和输入区不参与拉伸', + (width) => { + const conversation = planningDeclarations( + '.project-chat-conversation', + width, + ); + const list = planningDeclarations('.project-chat-message-list', width); + const composer = planningDeclarations('.project-chat-composer', width); + const topbar = planningDeclarations('.project-chat-topbar', width); + expect(declaration(conversation, 'display')).toBe('flex'); + expect(declaration(conversation, 'flex-direction')).toBe('column'); + expect(declaration(conversation, 'height')).toBe('100%'); + expect(declaration(conversation, 'overflow')).toBe('visible'); + expect(declaration(list, 'flex')).toBe('1 1 0'); + expect(declaration(list, 'min-height')).toBe('0'); + expect(declaration(list, 'overflow-y')).toBe('auto'); + expect(declaration(composer, 'flex')).toBe('0 0 auto'); + expect(declaration(topbar, 'flex')).toBe('0 0 auto'); + expect(declaration(topbar, 'min-height')).toBe('44px'); + expect( + declaration( + planningDeclarations('.project-chat-topbar-status', width), + 'font-size', + ), + ).toBe('12px'); + expect( + declaration( + planningDeclarations( + '.project-chat-topbar-status > span:last-child', + width, + ), + 'overflow-wrap', + ), + ).toBe('anywhere'); + for (const selector of [ + '.design-agent-controls', + '.design-agent-pending-actions', + ]) { + const panel = planningDeclarations(selector, width); + expect(declaration(panel, 'max-height')).toBe('30%'); + expect(declaration(panel, 'min-height')).toBe('0'); + expect(declaration(panel, 'overflow')).toBe('auto'); + } + }, + ); + + it.each([390, 760])( + '%ipx:固定外壳内工作台可滚动到对话,两块面板保持明确高度', + (width) => { + const workbench = planningDeclarations('.game-project-workbench', width); + const layout = planningDeclarations('.game-workbench-layout', width); + const stage = planningDeclarations('.game-workbench-stage', width); + const chat = planningDeclarations('.game-workbench-chat', width); + expect(declaration(workbench, 'min-height')).toBe('0'); + expect(declaration(workbench, 'overflow-y')).toBe('auto'); + expect(declaration(workbench, 'overflow-x')).toBe('hidden'); + expect(declaration(layout, 'height')).toBe('auto'); + expect(declaration(layout, 'grid-template-columns')).toBe( + 'minmax(0, 1fr)', + ); + expect(declaration(stage, 'height')).toBe('560px'); + expect(declaration(chat, 'height')).toBe( + 'clamp(560px, calc(100dvh - 154px), 900px)', + ); + expect( + declaration( + planningDeclarations('.design-workspace-panel', width), + 'height', + ), + ).toBe('100%'); + expect( + declaration( + planningDeclarations('.design-workspace-tree', width), + 'overflow', + ), + ).toBe('auto'); + }, + ); + + it.each([761, 1280])('%ipx:双栏继续填满外壳剩余高度', (width) => { + const workbench = planningDeclarations('.game-project-workbench', width); + const layout = planningDeclarations('.game-workbench-layout', width); + expect(declaration(workbench, 'overflow')).toBe('hidden'); + expect(workbench.has('overflow-y')).toBe(false); + expect(declaration(layout, 'height')).toBe('100%'); + expect(declaration(layout, 'grid-template-columns')).toBe( + 'minmax(0, 1fr) minmax(380px, 0.42fr)', + ); + }); + + it('批量导入失败提示可滚动且可换行,不撑高输入区或撑宽面板', () => { + const notice = planningDeclarations('.project-chat-composer-notice', 390); + expect(declaration(notice, 'max-height')).toBe('96px'); + expect(declaration(notice, 'overflow')).toBe('auto'); + expect(declaration(notice, 'overflow-wrap')).toBe('anywhere'); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b229b2883..9a7496644 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5943,6 +5943,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` `append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。 +## 策划聊天不能按可选子节点序号分配消息高度 + +策划与开发 Agent 共用聊天类名,但布局合同不同。策划新增状态条后,按第二个子节点分配 `1fr` 会把空白给状态条、让消息框随回复增长;只给 `.is-direct-codex` 的状态样式也不会覆盖策划。策划使用纵向 Flex,仅消息列表伸缩,阶段/待处理区域限高滚动;改共用样式时同时核对两种入口。jsdom 交互通过不代表布局正确,须匹配完整 CSS 层叠并用真实浏览器核对空/短/长消息与长待办。窄屏上下堆叠必须在固定外壳内提供工作台滚动容器,两块面板明确限高;低优先级的 `height: auto` 不能覆盖外壳后代规则的 `height: 100%`。布局夹具必须包含窗口外壳与启动器层级,并实际滚动验证输入框可见可操作,不能只检查输入框在聊天面板内部。实时正文/思考不更新持久消息数组,滚动跟随必须覆盖这些独立状态并保留用户上滚门禁;历史消息缺失的时间不得用读取时刻填补。详见 AGC 实施计划的“策划 Agent 对话显示与滚动合同”。 + ## 2026-09-22 Rust 对象快照必须对齐 CI 编译目录和 Cargo 环境 - 现象:sccache 快照已包含数百 MiB 对象,但全新 target 的“热缓存”仍然全部 miss,甚至比直接 rustc 更慢。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8613a787b..724656800 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -241,6 +241,25 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation Rust 分片日志在失败时输出有界 stdout 尾部中的失败段,保留 panic 位置、断言详情和 Rust 汇总;selected 只表示该分片选中的用例数量。成功分片保留简短摘要。定向验证使用相关 Rust 用例与 `node --test apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs`。 +## 策划 Agent 对话显示与滚动合同 + +策划工作台的对话区保留阶段状态、工作区状态、消息、可选确认/澄清和输入区。面板高度由工作台和视口决定;空消息、短回复和流式长回复不能改变消息区域的上下边界。阶段、状态条、输入区按内容排列,消息区独占剩余空间并内部滚动;待处理卡片出现时只让出其实际所需且有上限的空间,不依赖可选节点的序号分配伸缩高度。 + +- 工作区状态条复用现有聊天状态条的字号、间距和状态点。长项目名允许换行,不能撑宽面板;长错误、澄清和批量文件导入结果在受限区域内完整可读,不能挤走输入框。输入编辑器保留原有高度上限,模型/推理菜单继续允许弹出面板。 +- 已在消息底部时,实时正文与思考增高后继续跟随;用户主动上滚阅读历史后不自动抢回底部,发送新消息后恢复跟随。阶段/待处理卡片改变消息可用高度时,仍遵守同一跟随意图。滚动仅为临时 UI 状态,不写入正式会话。 +- 历史消息只有收到真实发送时间才能显示时间。现有策划持久消息不含逐消息时间,读取或刷新时保持未知,不以当前时刻补造。当前会话刚提交的乐观消息可以显示已知发送时间;正式快照覆盖后不补造或猜配旧消息时间。 +- ≤760px 时资源区与对话区单列排列,策划工作台在固定外壳内纵向滚动,用户向下滚动可到达输入区。布局使用内容高度,并以同等或更高选择器优先级覆盖外壳的 `height: 100%`;资源区明确为 560px,对话区高度为 `clamp(560px, calc(100dvh - 154px), 900px)`。文件树与消息列表各自内部滚动,长内容不增加两块面板高度。>760px 继续共用外壳剩余高度,不启用工作台整体滚动。正式主窗最小宽度不变,窄屏验收覆盖浏览器响应式布局。 +- 审批、澄清、导入期间禁用、错误重试、项目归属和发送权限沿用现有行为。此次调整不改变 Runtime、API、持久协议或数据库,无数据迁移;不重做开发 Agent 的对话布局。 + +验收使用策划组件与现有界面测试覆盖流式/历史跟随、未知时间、审批澄清和导入禁用;使用真实浏览器与当前样式核对桌面/窄屏的空、短、长消息,长项目名、长错误与长澄清,以及输入框和菜单位置。布局验收必须包含 `window-chrome → window-chrome__content → launcher-shell → launcher-main → game-project-workbench` 的完整外壳层级,覆盖 390px、760px、761px 和桌面尺寸;窄屏实际滚动工作台后,输入框须进入可视范围且可操作,长文件树、长消息和待确认内容不能使面板失去高度边界。不能只断言输入框位于聊天面板内部。开发 Agent 的既有布局回归同批执行。真实 Provider 请求不作为纯展示修复的前置条件,验证记录须区分模拟事件与真实 Provider。 + +| 验收面 | 对应证据 | +| --- | --- | +| 策划与开发 Agent 布局隔离、伸缩与窄屏滚动可达 | `chatDialogFrameLayout.test.ts` 包含外壳的选择器层叠回归;Chrome 实际组件、完整外壳与完整样式的 390px/760px/761px/桌面几何、滚动和输入操作检查 | +| 实时正文/思考跟随、上滚停留、发送恢复 | `appSurface/design-agent.suite.ts` 的实时事件与发送恢复用例;还原旧滚动依赖时新增用例失败 | +| 历史未知时间与乐观发送时间 | 同套界面测试覆盖读取、乐观发送和正式快照覆盖;恢复 `Date.now()` 补造时新增用例失败 | +| 审批/澄清、文件导入禁用及共用控件 | `appSurface.test.ts` 现有交互回归与 Chrome 模型菜单位置检查;不改变原生调用和业务状态 | + ## 策划 Agent 批量局部修改 `patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。 From fdc14404b2090881c0b3de264fe264398f00c25e Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 22 Sep 2026 16:31:56 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20AGC=20release=20?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Genarrative-Scheduled-Release-Trigger,每天 04:00 发布 Windows/macOS release 渠道 release 调度复用统一发号源,并为 macOS 启用过期构建让位 现有小时 dev 调度保持同时触发 Windows 与 macOS 更新生产运维门禁、发号权限、技术方案与共享记忆 --- .../shared-memory/decision-log.md | 8 + .../shared-memory/development-workflow.md | 2 +- ...技术方案】AGC总版本号与发号-2026-09-20.md | 8 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 8 +- jenkins/Jenkinsfile.agc-global-version-issue | 13 +- jenkins/Jenkinsfile.scheduled-release-trigger | 181 ++++++++++++++++++ .../agc-global-version-issue-job-config.xml | 7 +- .../scheduled-release-trigger-job-config.xml | 55 ++++++ scripts/check-production-ops-guardrails.mjs | 82 ++++++++ 9 files changed, 347 insertions(+), 17 deletions(-) create mode 100644 jenkins/Jenkinsfile.scheduled-release-trigger create mode 100644 jenkins/scheduled-release-trigger-job-config.xml diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ccdb61e08..6a0af194d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-09-22 AGC release 增加每日调度,dev 调度保持双平台 + +- 背景:原有 `Genarrative-Scheduled-Revision-Trigger` 每小时跟随 revision 发布 dev 客户端,但没有对应的 release 渠道自动入口;Mac 节点此前已纳入小时 dev 调度,需要避免新增 release 调度时再退回 Windows-only。 +- 决策:新增 `Genarrative-Scheduled-Release-Trigger`,每天 04:00 检查 `SOURCE_BRANCH`,使用独立的 `.jenkins-last-release-revision` 与客户端相关路径白名单;上一轮 release 调度后有客户端变更时,先经 `Genarrative-Agc-Global-Version-Issue` 发统一总号,再以同一固定 `COMMIT_HASH` 触发 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 的 `AGC_UPDATE_CHANNEL=release` 分区,Mac 继续带 `SKIP_IF_SUPERSEDED=true`。小时 dev 调度保持同时触发 Windows 与 macOS dev。 +- 原因:release 与 dev 是不同渠道和发布节奏,不能靠同一个小时 Job 隐式切换;独立 Job 能分别去重、记录状态和审计发号。release 调度不触发 Full Build,避免把桌面客户端发布与线上全栈部署绑定。 +- 影响范围:`jenkins/Jenkinsfile.scheduled-release-trigger`、`jenkins/scheduled-release-trigger-job-config.xml`、`jenkins/Jenkinsfile.agc-global-version-issue`、`scripts/check-production-ops-guardrails.mjs`、开发运维文档、共享开发工作流与 AGC 总版本号技术方案。 +- 验证方式:`npm run check:production-ops` 校验 release Job 的 cron、发号、双平台 release 参数、Mac 让位和 Copy Artifact 授权;`npm run check:encoding` 与 `git diff --check` 校验文件与补丁。 + ## 2026-09-21 合并 origin/master:Supervisor 永久退役,策划 V1V2 退役落到当前两条产品路径 - 背景:`refactor/split-direct-project`(DirectProject 独立聊天容器)与 `origin/master`(#355 退役策划 Agent V1/V2)在 2026-09-18 之后各走一条线:本分支删掉 Supervisor 前端链路、把立项策划收敛到 `view/project-development/planning/`,master 删掉整套策划 V1/V2(前端会话 / 审批卡 / 适配器 / 类型与 Rust `planning_*_v2` 命令、`planning_gdd_model.rs`、`planning_policy_v2.rs`、`planning_session_v2.rs`)只保留 Design Agent。两边都在删 Supervisor,冲突集中在 `App.tsx`、聊天视图(`PlanningChatView`、`DirectProjectTurn`、`ToolCallGroup`)、Direct composer / 引用输入区、`styles.css`、Rust direct user item 与 appSurface 用例。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index fc9354c63..d37feedb2 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -92,7 +92,7 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Jenkins 定时版本调度 -定时与版本比较只保留在 `Genarrative-Scheduled-Revision-Trigger` 一处:每小时用 `git ls-remote` 解析 `SOURCE_BRANCH` 远端 HEAD,与上一次触发过的 revision 比较,变化时才把同一个 `COMMIT_HASH` 传给 `Genarrative-Full-Build-And-Deploy`,并先经 `Genarrative-Agc-Global-Version-Issue` 发号、再把同一个总版本号透传给 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`,保证两个客户端的平台分区发布同一个版本。这三个下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住这两类回退。macOS 节点是日常办公机,调度触发它时置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +定时与版本比较收口到两条调度器:`Genarrative-Scheduled-Revision-Trigger` 每小时处理 dev 渠道,变化时触发 Full Build、AGC Windows/macOS dev,并让两个客户端平台共用同一个总版本号;`Genarrative-Scheduled-Release-Trigger` 每天 04:00 处理 AGC release 渠道,只在上一轮 release 调度后出现客户端相关路径变化时,经 `Genarrative-Agc-Global-Version-Issue` 发同一个总号并触发 AGC Windows/macOS release。各下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住回退。macOS 节点是日常办公机,两条调度触发它时都置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 ## Gitea CI 依赖闭合 diff --git a/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md b/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md index fad9d5cef..fdef27adb 100644 --- a/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md +++ b/docs/technical/【技术方案】AGC总版本号与发号-2026-09-20.md @@ -1,6 +1,6 @@ # AGC 总版本号与发号 -更新时间:`2026-09-20` +更新时间:`2026-09-22` ## 背景 @@ -18,7 +18,7 @@ AGC 客户端此前按「渠道各自比高水位自增」发号:`dev-win` 与 - `next = 总号 + 1`;总号不存在时先一次性播种,见下节。 - 顺序固定为「先写总号 → 再构建 → 再发渠道清单」;任何一步失败都不回滚,只烧号。 -- 统一构建:发一次号,通过 `AGC_RELEASE_VERSION` 同时传给 `dev-win`、`dev-mac`(以及后续渠道),各渠道共用同一个号。 +- 统一构建:发一次号,通过 `AGC_RELEASE_VERSION` 同时传给同一渠道组的两个平台;dev 调度写 `dev-win` / `dev-mac`,每日 release 调度写 `release-win` / `release-mac`,组内共用同一个号。 - 单渠道热修:发一次号,只传给该渠道;其他渠道清单保持原值。 - 显式传入 `AGC_RELEASE_VERSION` 的构建不再自行发号,只做高水位断言。 @@ -39,8 +39,8 @@ AGC 客户端此前按「渠道各自比高水位自增」发号:`dev-win` 与 - 发号模块:`apps/ai-game-creator-shell/scripts/agc-global-version.mjs`(读总号 / 播种 / 发号 / 写后回读 / 渠道高水位断言 / dry-run 预览)。 - 发号入口:`apps/ai-game-creator-shell/scripts/issue-global-version.mjs`(CI 与本地共用,输出固定为 `AGC_GLOBAL_VERSION=`)。 - 构建侧:`build-release.mjs` 的 `prepareReleaseVersion()` 优先采用传入总号,未传入时现场发号;`resolveRemoteHighWaterVersion()` 降级为断言来源,只用于「请求号低于本渠道清单版本即失败关闭」。 -- CI:`jenkins/Jenkinsfile.agc-global-version-issue` + `jenkins/agc-global-version-issue-job-config.xml`;调度管线 `jenkins/Jenkinsfile.scheduled-revision-trigger` 与手动管线先调发号 Job,再把号透传给 AGC Build。 -- 发号 Job 的 Copy Artifact 采用生产权限模式,`options` 里的 `copyArtifactPermission(...)` 必须显式列出**全部**消费者:定时调度 `Genarrative-Scheduled-Revision-Trigger` 与手动发布 `Genarrative-Manual-Build-And-Deploy`。用户触发的构建按「认证用户」判权(SYSTEM 定时构建短路放行),漏列时手动发布会报 `Unable to find project for artifact copy: Genarrative-Agc-Global-Version-Issue`,且失败点在发号之后。改完该 `options` 后必须先单独跑一次发号 Job,让 Declarative Pipeline 把 Job property 写回 Jenkins。 +- CI:`jenkins/Jenkinsfile.agc-global-version-issue` + `jenkins/agc-global-version-issue-job-config.xml`;dev 调度管线 `jenkins/Jenkinsfile.scheduled-revision-trigger`、每日 release 调度管线 `jenkins/Jenkinsfile.scheduled-release-trigger` 与手动管线先调发号 Job,再把号透传给 AGC Build。 +- 发号 Job 的 Copy Artifact 采用生产权限模式,`options` 里的 `copyArtifactPermission(...)` 必须显式列出**全部**消费者:小时 dev 调度 `Genarrative-Scheduled-Revision-Trigger`、每日 release 调度 `Genarrative-Scheduled-Release-Trigger` 与手动发布 `Genarrative-Manual-Build-And-Deploy`。用户触发的构建按「认证用户」判权(SYSTEM 定时构建短路放行),漏列时手动发布会报 `Unable to find project for artifact copy: Genarrative-Agc-Global-Version-Issue`,且失败点在发号之后。改完该 `options` 后必须先单独跑一次发号 Job,让 Declarative Pipeline 把 Job property 写回 Jenkins。 ## 不改的东西 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 60f267a61..de0f4211c 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -141,9 +141,9 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database --server-url `,只能由已授权 migration operator 执行。先进入维护模式并发布包含 `clean_editor_image_asset_kind_and_return` 的 SpacetimeDB module,并保持旧版本 API / controller / worker 停止;随后运行默认全量 dry-run,核对 `asset → project-resource → showcase → canvas` 各 scope 的扫描数、命中行数、字段数和 blocker 均符合预期,再追加 `--apply`。脚本对每批重新 dry-run、绑定包含画布迁移摘要、结构化 layer 与 generation-dialog 权威 JSON 的 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME 或媒体类型;project-resource scope 在清行前验证同工程 migration 并将其状态纳入批次 hash,layout version 0 的 legacy 画布可以没有 migration,但 structured 画布缺 migration 必须立即形成 blocker,资源行不得先被清空;清行后能保持原 status 不变量时立即刷新摘要,否则只允许留给后续精确 canvas 字段清理收口。canvas scope 在任何布局写入前再次按 active / backfilled / rolled_back 状态验证原 migration 凭证和双份 legacy / structured 不变量,将 `editor_canvas_generation_dialog.dialog_json` 与 layer rows 一并扫描并在同一事务 patch;只允许本批资源清零及精确字段删除造成的差异,写入后从全部结构化权威行重建 layout、再次复核新状态才受控重签摘要,同时保持业务 revision、migration status 与全部时间戳不变。新版本 API、SpacetimeDB storage 创建入口、legacy 画布元数据提取和项目资源落表边界都会将 trim 后精确等于 `image` 的 `assetKind` 归一为 `NULL`,防止旧页面、滞留请求或 legacy 保存重新制造废弃值。完成零残留复核,并分别确认 cleaned backfilled 可激活、active 可继续保存、rolled_back 可重复复检后恢复应用版本,最后退出维护。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-clean-editor-image-asset-kind.mjs` 与 `scripts/spacetime-migration-common.mjs`。 -`Genarrative-Scheduled-Revision-Trigger` 是唯一的定时入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。它只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 +`Genarrative-Scheduled-Revision-Trigger` 是 dev 渠道的小时调度入口,每小时检查一次(`H * * * *`,分钟由 Jenkins 按 Job 名散列,不等同于整点)。`Genarrative-Scheduled-Release-Trigger` 是 AGC release 渠道的每日调度入口,固定在每天 04:00(`0 4 * * *`)检查同一个源码分支。两条调度器都只用 `git ls-remote` 解析 `SOURCE_BRANCH`(默认 `master`)的远端 HEAD,不 checkout 工作区;解析出的完整 commit 与各自上一次触发过的 revision 相同则标记 `NOT_BUILT` 并结束,不触发任何下游。 -revision 变化时,调度管线把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy`、`Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`,各条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。这些下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`(两个平台分区发布同一个发号 Job 下发的总版本号),纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。两条下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。AGC 客户端版本号不再由渠道各自递增:唯一发号源是 OSS 对象 `agc/global-version.json`,发号收口到 `Genarrative-Agc-Global-Version-Issue`(`disableConcurrentBuilds()` + 写后回读校验;集群未装 `lockable-resources` 插件)。调度管线与手动发布管线都先调用该 Job 发号,再用归档产物 `agc-global-version.txt` 读取总号并作为 `AGC_RELEASE_VERSION` 透传给客户端构建;统一构建只发一次号供各渠道共用,单渠道热修只把号传给该渠道。显式传入的号低于本渠道当前清单版本时构建失败关闭;`AGC_RELEASE_DRY_RUN` 只预览下一位,不写回、不烧号。一次性播种用 `SEED_ONLY`:基线取「仓库版本 / 各渠道清单 / 旧迁移指针」的最大值,播种本身不烧号。仓库里的 5 个版本文件仍由构建改写,只作构建输入参考,不是事实源。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 结束后都会触发 `Genarrative-Notify-Email`;只有正式 OSS 发布成功时,通知正文才附加本次生成的 `latest.json` 首装包 URL,演练、失败或跳过构建时留空,避免把未写入或不完整的对象地址发给收件人。 +dev 调度器发现 revision 变化时,把同一个完整 commit 通过 `COMMIT_HASH` 同时传给 `Genarrative-Full-Build-And-Deploy`、`Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`,各条管线都按这个 commit 检出(Full Job 继续把 `env.SOURCE_COMMIT` 透传给 Web / API / Stdb 的 Build、Publish、Deploy),因此两个产物必然来自同一个版本,不会各自解析分支 HEAD 造成漂移。这些下游管线自身不带任何定时触发器,也不在管线内部做版本比较。Windows 客户端发布额外按路径过滤:调度管线比较「上一轮已触发的 revision」与本次 revision 之间的变更路径,只有出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 时才触发 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`(两个平台分区发布同一个发号 Job 下发的总版本号),纯文档或流水线自身的提交只触发 Full Build、不推高客户端版本号;判定取消或失败一律按「需要发布」处理,勾选 `FORCE_TRIGGER` 可强制两条都触发。每日 release 调度器不触发 Full Build;它使用独立的 `.jenkins-last-release-revision` 与客户端路径白名单,只在上一轮 release 调度后出现 `apps/ai-game-creator-shell/`、`packages/`、`server-rs/crates/`、`plugins/agc-cocos-editor/`、`plugins/agc-unity-editor/`、`plugins/agc-godot-editor/`、`apps/desktop-shell/src-tauri/icons/`、`package.json` 或 `package-lock.json` 变更时,先经发号 Job 获取统一总号,再以同一个固定 commit 触发 AGC Windows 与 macOS 的 `release` 分区构建;macOS 仍带 `SKIP_IF_SUPERSEDED=true`,无客户端变化时只推进 release revision 状态、不烧号。dev 调度器的下游各自判定:AGC Windows Build 采用「客户端相关路径白名单」,Full Build 采用「与线上站点 / 后端无关的路径黑名单」(`docs/`、`.codex/`、`jenkins/`、`apps/ai-game-creator-shell/`、`apps/mobile-shell/`、`apps/desktop-shell/`、`apps/preview-deployer-web/`、`tools/`、根级 `*.md`),改动只要落在黑名单之外就会照常部署,避免漏发线上站点或后端;两条同时被判为跳过时调度管线只推进 revision 状态、不触发任何发布。AGC 客户端版本号不再由渠道各自递增:唯一发号源是 OSS 对象 `agc/global-version.json`,发号收口到 `Genarrative-Agc-Global-Version-Issue`(`disableConcurrentBuilds()` + 写后回读校验;集群未装 `lockable-resources` 插件)。dev / release 两条调度管线与手动发布管线都先调用该 Job 发号,再用归档产物 `agc-global-version.txt` 读取总号并作为 `AGC_RELEASE_VERSION` 透传给客户端构建;统一构建只发一次号供各渠道共用,单渠道热修只把号传给该渠道。显式传入的号低于本渠道当前清单版本时构建失败关闭;`AGC_RELEASE_DRY_RUN` 只预览下一位,不写回、不烧号。一次性播种用 `SEED_ONLY`:基线取「仓库版本 / 各渠道清单 / 旧迁移指针」的最大值,播种本身不烧号。仓库里的 5 个版本文件仍由构建改写,只作构建输入参考,不是事实源。客户端渠道清单的更新摘要同样自动生成:发布脚本读取上一份渠道清单的 `commit` 字段,把该提交到本次提交之间触及客户端相关路径的提交标题逐条写进 `notes`(旧协议清单写入 `releaseNotes`,并落盘归档文件 `release-notes.txt`);`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准,缺少上一份 `commit` 时不写摘要。Full Job 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate;三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build` 结束后都会触发 `Genarrative-Notify-Email`;只有正式 OSS 发布成功时,通知正文才附加本次生成的 `latest.json` 首装包 URL,演练、失败或跳过构建时留空,避免把未写入或不完整的对象地址发给收件人。 手工发布入口 `Genarrative-Manual-Build-And-Deploy` 的 `DEPLOY_TARGET` 与 AGC 更新渠道是两个独立维度。手工入口必须把 `release` 映射为 `AGC_UPDATE_CHANNEL=release`、把 `development` 映射为 `dev`,并把该参数同时透传给 `Genarrative-Agc-Windows-Build` 与 `Genarrative-Agc-MacOS-Build`;只传 `AGC_RELEASE_VERSION` 时下游会落回各自默认 `dev`。统一号 `0.1.95` 的 release 包应分别位于 `agc/release-win/0.1.95/` 与 `agc/release-mac/0.1.95/`,渠道清单为对应目录下的 `latest.json`,不存在 `agc/release/` 这一层。补发本轮已烧号的版本时,直接以相同 `AGC_RELEASE_VERSION` 重跑两条 AGC Job,不重新发号。 @@ -730,7 +730,7 @@ cat /var/lib/genarrative/health-patrol/status.json 如需接外部告警,可在 `/etc/genarrative/health-patrol.env` 配置 `GENARRATIVE_HEALTH_PATROL_WEBHOOK_URL`;脚本只会在 `WARNING` 或 `CRITICAL` 时向该 webhook 发送 JSON。未配置 webhook 时,告警来源是 systemd 失败状态、journal 和状态文件。 -Jenkins Copy Artifact 必须保持 `Production` 权限模式;产物生产者要在 Jenkinsfile 中用 `copyArtifactPermission` 精确授权消费者,不能依赖 Migration 模式或全局 `Job/Read`。固定映射为 `Genarrative-Stdb-Module-Build` → `Genarrative-Stdb-Module-Publish`、`Genarrative-Api-Build` → `Genarrative-Api-Deploy`、`Genarrative-Web-Build` → `Genarrative-Web-Deploy`、`Genarrative-Database-Export` → `Genarrative-Database-Import`、`Genarrative-Agc-Global-Version-Issue` → `Genarrative-Scheduled-Revision-Trigger`(发号 Job 必须授权调度器读 `agc-global-version.txt`;缺这条会让整轮调度在发号之后失败——号已烧、渠道构建没被触发,2026-09-20 #103 即为此)。如果 `copyArtifacts` 报 `Unable to find project for artifact copy`,但来源 Job、指定构建号和归档都实际存在,先检查来源 Job 的 `CopyArtifactPermissionProperty`;修复 Jenkinsfile 后必须先运行一次产物生产者,让 Declarative Pipeline 把 Job property 写回 Jenkins,再重跑 Deploy / Publish / Import。`npm run check:production-ops` 会防止四条白名单再次丢失。 +Jenkins Copy Artifact 必须保持 `Production` 权限模式;产物生产者要在 Jenkinsfile 中用 `copyArtifactPermission` 精确授权消费者,不能依赖 Migration 模式或全局 `Job/Read`。固定映射为 `Genarrative-Stdb-Module-Build` → `Genarrative-Stdb-Module-Publish`、`Genarrative-Api-Build` → `Genarrative-Api-Deploy`、`Genarrative-Web-Build` → `Genarrative-Web-Deploy`、`Genarrative-Database-Export` → `Genarrative-Database-Import`、`Genarrative-Agc-Global-Version-Issue` → `Genarrative-Scheduled-Revision-Trigger` / `Genarrative-Scheduled-Release-Trigger`(发号 Job 必须授权两条调度器读取 `agc-global-version.txt`;缺这条会让整轮调度在发号之后失败——号已烧、渠道构建没被触发,2026-09-20 #103 即为此)。如果 `copyArtifacts` 报 `Unable to find project for artifact copy`,但来源 Job、指定构建号和归档都实际存在,先检查来源 Job 的 `CopyArtifactPermissionProperty`;修复 Jenkinsfile 后必须先运行一次产物生产者,让 Declarative Pipeline 把 Job property 写回 Jenkins,再重跑 Deploy / Publish / Import。`npm run check:production-ops` 会防止这些 Copy Artifact 授权再次丢失。 `Genarrative-Web-Build` 的主站构建失败若出现 Rollup 报错 `"xxx" is not exported by "src/services/publicWorkCode.ts"`,优先按前端公开作品号工具缺失处理,而不是排查 Jenkins 节点环境。修复时要让 `publicWorkCode.ts` 的 `buildPublicWorkCode` 与 `isSamePublicWorkCode` 成对导出,并补 `src/services/publicWorkCode.test.ts` 覆盖对应玩法前缀;随后用 `npm run build:production-release -- --component web --name <临时名>` 复现 Jenkins web 构建路径。 @@ -760,7 +760,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. ### AGC macOS 手动构建节点 -Mac 单架构(arm64)构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`,只对专用 `genarrative-agc-macos` 标签运行。节点按 EXCLUSIVE、单 executor 配置,Job 禁止并发、自身不设 cron(定时只来自调度 Job);它已接入每小时版本调度:`Genarrative-Scheduled-Revision-Trigger` 先调发号 Job 拿同一个总号,再把 `SOURCE_BRANCH`、固定 `COMMIT_HASH`、`AGC_UPDATE_CHANNEL=dev`、`AGC_RELEASE_VERSION=<总号>` 与 `SKIP_IF_SUPERSEDED=true` 透传给本 Job,**因此正常发布不需要(也不应该)手工点构建**;手工触发只是排障手段。它不改 Windows 发布职责。它使它只在专用 Agent 目录下构建:默认按约定匹配 `$HOME/Library/Jenkins/agents//workspace/`(不写死节点名,节点改名后仍成立),也可用 `AGC_AGENT_ROOT` 显式覆盖;禁止指向开发 checkout 或共享其可写 target/node_modules。 +Mac 单架构(arm64)构建脚本位于 `jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`,只对专用 `genarrative-agc-macos` 标签运行。节点按 EXCLUSIVE、单 executor 配置,Job 禁止并发、自身不设 cron(定时只来自调度 Job);它同时接入每小时 dev 与每日 04:00 release 两条版本调度:`Genarrative-Scheduled-Revision-Trigger` / `Genarrative-Scheduled-Release-Trigger` 先调发号 Job 拿同一个总号,再把 `SOURCE_BRANCH`、固定 `COMMIT_HASH`、对应 `AGC_UPDATE_CHANNEL=dev|release`、`AGC_RELEASE_VERSION=<总号>` 与 `SKIP_IF_SUPERSEDED=true` 透传给本 Job,**因此正常发布不需要(也不应该)手工点构建**;手工触发只是排障手段。它不改 Windows 发布职责。它使它只在专用 Agent 目录下构建:默认按约定匹配 `$HOME/Library/Jenkins/agents//workspace/`(不写死节点名,节点改名后仍成立),也可用 `AGC_AGENT_ROOT` 显式覆盖;禁止指向开发 checkout 或共享其可写 target/node_modules。 Job 名为 `Genarrative-Agc-MacOS-Build`,SCM 直接读取仓库内上述 Jenkinsfile,参数为 `SOURCE_BRANCH`、`COMMIT_HASH`、`AGC_UPDATE_CHANNEL`、`AGC_RELEASE_VERSION`、`AGC_RELEASE_DRY_RUN`、`AGC_UPDATE_RELEASE_NOTES`、`OSSUTIL_BIN`、`CARGO_BUILD_JOBS`、`NOTIFICATION_EMAILS`。渠道参数是基础名(不含系统,默认 `dev`),脚本不接受 `dev-mac` 这类系统后缀,写入分区固定推导为 `-mac`:这与 Windows Job 的 `-win` 对称,也延续已发布客户端的端点。 diff --git a/jenkins/Jenkinsfile.agc-global-version-issue b/jenkins/Jenkinsfile.agc-global-version-issue index 824394f7c..83277e23c 100644 --- a/jenkins/Jenkinsfile.agc-global-version-issue +++ b/jenkins/Jenkinsfile.agc-global-version-issue @@ -16,14 +16,15 @@ pipeline { skipDefaultCheckout(true) buildDiscarder(logRotator(numToKeepStr: '100', artifactNumToKeepStr: '20')) // Copy Artifact 保持 Production 权限模式:生产者必须显式授权消费者。 - // 调度管线与手动发布管线都要 copy 本 Job 的 agc-global-version.txt 才能把总号 + // 所有调度与手动发布管线都要 copy 本 Job 的 agc-global-version.txt 才能把总号 // 透传给渠道构建;缺授权时 copyArtifacts 会报「Unable to find project for // artifact copy」(用户触发的构建按「认证用户」判权,SYSTEM 定时构建会短路放行, // 所以定时调度能过、手动发布必挂;2026-09-20 #103 与 2026-09-21 手动发布 - // #6/#7 均命中)。这里必须同时列出两条消费者:Genarrative-Scheduled-Revision-Trigger - // 与 Genarrative-Manual-Build-And-Deploy。改完本文件后必须先单独跑一次本 Job, - // 让 Declarative Pipeline 把 Job property 写回 Jenkins,再重跑调度或手动发布。 - copyArtifactPermission('Genarrative-Scheduled-Revision-Trigger,Genarrative-Manual-Build-And-Deploy') + // #6/#7 均命中)。这里必须列出全部消费者:Genarrative-Scheduled-Revision-Trigger、 + // Genarrative-Scheduled-Release-Trigger 与 Genarrative-Manual-Build-And-Deploy。 + // 改完本文件后必须先单独跑一次本 Job,让 Declarative Pipeline 把 Job property + // 写回 Jenkins,再重跑调度或手动发布。 + copyArtifactPermission('Genarrative-Scheduled-Revision-Trigger,Genarrative-Scheduled-Release-Trigger,Genarrative-Manual-Build-And-Deploy') } environment { @@ -34,7 +35,7 @@ pipeline { } parameters { - choice(name: 'AGC_CHANNEL', choices: ['unified', 'dev-win', 'dev-mac'], description: '发号用途:unified 为统一构建(一个号同时给 dev-win 与 dev-mac),其余为单渠道热修') + choice(name: 'AGC_CHANNEL', choices: ['unified', 'dev-win', 'dev-mac', 'release-unified', 'release-win', 'release-mac'], description: '发号用途标签:unified / release-unified 为一个号同时给两个平台,其余为单渠道热修') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '取发号脚本的源码分支;默认 master') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选:固定提交;留空则解析 SOURCE_BRANCH 当前 revision') booleanParam(name: 'SEED_ONLY', defaultValue: false, description: '仅做一次性播种:写入渠道与仓库版本的最大值作为基线,不递增、不烧号') diff --git a/jenkins/Jenkinsfile.scheduled-release-trigger b/jenkins/Jenkinsfile.scheduled-release-trigger new file mode 100644 index 000000000..044bd0883 --- /dev/null +++ b/jenkins/Jenkinsfile.scheduled-release-trigger @@ -0,0 +1,181 @@ +// AGC release 每日调度管线:每天 04:00 检查源码分支, +// 只有上一轮 release 调度之后出现客户端相关路径变更时,才用同一个固定 commit +// 触发 AGC Windows/macOS 两个 release 分区构建。 +pipeline { + agent { + label 'linux && genarrative-build' + } + + options { + disableConcurrentBuilds() + skipDefaultCheckout(true) + buildDiscarder(logRotator(numToKeepStr: '100', artifactNumToKeepStr: '10')) + } + + triggers { + cron('0 4 * * *') + } + + environment { + GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git' + GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' + AGC_WINDOWS_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build' + AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build' + AGC_GLOBAL_VERSION_JOB_NAME = 'Genarrative-Agc-Global-Version-Issue' + AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt' + REVISION_STATE_FILE = '.jenkins-last-release-revision' + AGC_SCOPE_CACHE_DIR = '.agc-release-scope-cache' + } + + parameters { + string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '被检查的源码分支;默认 master') + booleanParam(name: 'FORCE_TRIGGER', defaultValue: false, description: '勾选后忽略版本与路径比较,强制发布 AGC release 渠道') + } + + stages { + stage('Resolve Revision') { + steps { + withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { + sh '''#!/usr/bin/env bash + set -euo pipefail + export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + revision="$(git ls-remote "${GIT_REMOTE_URL:?缺少 GIT_REMOTE_URL}" "refs/heads/${SOURCE_BRANCH}" | awk 'NR == 1 { print $1 }')" + if [[ ! "${revision}" =~ ^[0-9a-f]{40}$ ]]; then + echo "无法解析远端分支版本: branch=${SOURCE_BRANCH} revision=${revision:-}" >&2 + exit 1 + fi + printf '%s' "${revision}" > .jenkins-remote-revision + echo "release 远端版本: branch=${SOURCE_BRANCH} revision=${revision}" + ''' + } + script { + env.REMOTE_REVISION = readFile('.jenkins-remote-revision').trim() + env.LAST_TRIGGERED_REVISION = fileExists(env.REVISION_STATE_FILE) ? readFile(env.REVISION_STATE_FILE).trim() : '' + env.REVISION_CHANGED = (params.FORCE_TRIGGER || env.LAST_TRIGGERED_REVISION != env.REMOTE_REVISION) ? 'true' : 'false' + if (env.REVISION_CHANGED != 'true') { + currentBuild.result = 'NOT_BUILT' + currentBuild.description = "release 版本未变化,跳过:${env.SOURCE_BRANCH} 仍是 ${env.REMOTE_REVISION.take(12)}" + echo currentBuild.description + } + } + } + } + + stage('Resolve AGC Release Scope') { + when { + expression { return env.REVISION_CHANGED == 'true' } + } + steps { + withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { + script { + // 判定失败一律按「需要发布」处理,避免路径过滤阻塞客户端发布。 + def output = 'agc=changed' + try { + output = sh(script: '''#!/usr/bin/env bash + set -uo pipefail + export GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY:?缺少 Git SSH 凭据} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" + previous="$(cat "${REVISION_STATE_FILE}" 2>/dev/null || true)" + if [[ -z "${previous}" ]]; then + printf 'agc=changed\n' + exit 0 + fi + mkdir -p "${AGC_SCOPE_CACHE_DIR}" + if [[ ! -d "${AGC_SCOPE_CACHE_DIR}/.git" ]]; then + git -C "${AGC_SCOPE_CACHE_DIR}" init --quiet + git -C "${AGC_SCOPE_CACHE_DIR}" remote add origin "${GIT_REMOTE_URL}" 2>/dev/null || true + fi + refspec="+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" + if ! git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags --filter=blob:none origin "${refspec}"; then + git -C "${AGC_SCOPE_CACHE_DIR}" fetch --quiet --depth=200 --no-tags origin "${refspec}" || { printf 'agc=changed\n'; exit 0; } + fi + if ! git -C "${AGC_SCOPE_CACHE_DIR}" cat-file -e "${previous}^{commit}" 2>/dev/null; then + echo "浅取窗口内没有 ${previous},按需要发布处理" >&2 + printf 'agc=changed\n' + exit 0 + fi + changed_paths="$(git -C "${AGC_SCOPE_CACHE_DIR}" diff --name-only "${previous}" "${REMOTE_REVISION}" 2>/dev/null || true)" + agc_scope=unchanged + while IFS= read -r changed_path; do + [[ -z "${changed_path}" ]] && continue + case "${changed_path}" in + apps/ai-game-creator-shell/*|packages/*|server-rs/crates/*|plugins/agc-cocos-editor/*|plugins/agc-unity-editor/*|plugins/agc-godot-editor/*|apps/desktop-shell/src-tauri/icons/*|package.json|package-lock.json) + agc_scope=changed + ;; + esac + done <<< "${changed_paths}" + printf 'agc=%s\n' "${agc_scope}" + ''', returnStdout: true).trim() + } catch (error) { + echo "release 发布范围判定失败,按需要发布处理:${error}" + output = 'agc=changed' + } + def values = output.split('\n').collect { it.trim() }.findAll { it } + def entry = values.find { it.startsWith('agc=') } + def value = entry == null ? '' : entry.split('=')[1] + env.AGC_RELEASE_SCOPE = (value == 'unchanged') ? 'unchanged' : 'changed' + echo "release 发布范围:AGC=${env.AGC_RELEASE_SCOPE}(上一轮已触发 revision=${env.LAST_TRIGGERED_REVISION ?: '无'})" + } + } + } + } + + stage('Trigger AGC Release Builds') { + when { + expression { return env.REVISION_CHANGED == 'true' } + } + steps { + script { + def pinnedRevision = env.REMOTE_REVISION + def agcTriggered = false + if (params.FORCE_TRIGGER || env.AGC_RELEASE_SCOPE != 'unchanged') { + // Windows 与 macOS 共用一个总号;发号 Job 是唯一发号源。 + def versionRun = build job: env.AGC_GLOBAL_VERSION_JOB_NAME, + wait: true, + propagate: true, + parameters: [ + string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: pinnedRevision), + string(name: 'AGC_CHANNEL', value: 'release-unified'), + ] + copyArtifacts( + projectName: env.AGC_GLOBAL_VERSION_JOB_NAME, + selector: specific(versionRun.number.toString()), + filter: env.AGC_GLOBAL_VERSION_ARTIFACT, + target: '.', + flatten: true, + ) + env.AGC_GLOBAL_VERSION = readFile(env.AGC_GLOBAL_VERSION_ARTIFACT).trim() + echo "本轮 AGC release 客户端总版本号: ${env.AGC_GLOBAL_VERSION}" + def agcParameters = [ + string(name: 'SOURCE_BRANCH', value: env.SOURCE_BRANCH), + string(name: 'COMMIT_HASH', value: pinnedRevision), + string(name: 'AGC_UPDATE_CHANNEL', value: 'release'), + string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION), + ] + build job: env.AGC_WINDOWS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcParameters + // macOS 节点是办公机:离线期间排队的旧构建在节点回来后必须自行让位。 + def agcMacosParameters = agcParameters + [ + booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true), + ] + build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters + agcTriggered = true + } else { + echo "上一轮 release 调度后没有 AGC 客户端相关变更,跳过 ${env.AGC_WINDOWS_BUILD_JOB_NAME} 与 ${env.AGC_MACOS_BUILD_JOB_NAME};需要强制发布时勾选 FORCE_TRIGGER" + } + writeFile file: env.REVISION_STATE_FILE, text: pinnedRevision + def target = "${env.SOURCE_BRANCH}@${pinnedRevision.take(12)}" + currentBuild.description = agcTriggered + ? "已触发 AGC release:${env.AGC_WINDOWS_BUILD_JOB_NAME} 与 ${env.AGC_MACOS_BUILD_JOB_NAME} ${target}" + : "本轮没有 AGC 客户端相关变更,未触发 release 发布:${target}" + echo currentBuild.description + } + } + } + } + + post { + always { + echo "release 调度结束: branch=${env.SOURCE_BRANCH} revision=${env.REMOTE_REVISION ?: ''} changed=${env.REVISION_CHANGED ?: ''} agcScope=${env.AGC_RELEASE_SCOPE ?: ''}" + } + } +} diff --git a/jenkins/agc-global-version-issue-job-config.xml b/jenkins/agc-global-version-issue-job-config.xml index 858f39d5e..b55dbcac7 100644 --- a/jenkins/agc-global-version-issue-job-config.xml +++ b/jenkins/agc-global-version-issue-job-config.xml @@ -1,19 +1,22 @@ - AGC 总版本号唯一发号入口:先写 OSS agc/global-version.json,再由各渠道构建接收号发布。统一构建发一次号给所有渠道;dry-run 只预览不烧号。 + AGC 总版本号唯一发号入口:先写 OSS agc/global-version.json,再由各渠道构建接收号发布。dev / release 的统一构建各自发一次号给两个平台;dry-run 只预览不烧号。 false AGC_CHANNEL - 发号用途:unified 为统一构建(一个号同时给 dev-win 与 dev-mac),其余为单渠道热修 + 发号用途标签:unified / release-unified 为一个号同时给两个平台,其余为单渠道热修 unified dev-win dev-mac + release-unified + release-win + release-mac diff --git a/jenkins/scheduled-release-trigger-job-config.xml b/jenkins/scheduled-release-trigger-job-config.xml new file mode 100644 index 000000000..6baaa851b --- /dev/null +++ b/jenkins/scheduled-release-trigger-job-config.xml @@ -0,0 +1,55 @@ + + + + 每天 04:00 检查源码分支版本(cron 定义在 Jenkinsfile 内):revision 变化且出现客户端相关路径变更时,先经 Genarrative-Agc-Global-Version-Issue 发号,再用同一个固定 commit 与总版本号触发 AGC Windows、macOS 两个 release 分区构建;macOS 额外带 SKIP_IF_SUPERSEDED。下游 Job 一律不自带触发器。 + false + + + + + SOURCE_BRANCH + 被检查的源码分支;默认 master + master + true + + + FORCE_TRIGGER + 勾选后忽略版本与路径比较,强制发布 AGC release 渠道 + false + + + + + + + 2 + + + ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git + genarrative-local-gitea-ssh + +refs/heads/master:refs/remotes/origin/master + + + + + */master + + + false + + + + true + true + + 1 + true + + + + jenkins/Jenkinsfile.scheduled-release-trigger + false + + + false + diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 83270f8f7..6bc31b5b6 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -54,6 +54,17 @@ const checks = [ reason: 'Copy Artifact Production 模式下,AGC 发号 Job 还必须授权手动发布管线读取总号,否则用户触发的手动发布会停在 copyArtifacts(SYSTEM 定时构建不受影响)。', }, + { + file: 'jenkins/Jenkinsfile.agc-global-version-issue', + includes: 'Genarrative-Scheduled-Release-Trigger', + reason: + 'Copy Artifact Production 模式下,AGC 发号 Job 必须授权每日 release 调度器读取总号。', + }, + { + file: 'jenkins/agc-global-version-issue-job-config.xml', + includes: 'release-unified', + reason: 'AGC 发号 Job 配置必须保留 release 调度的统一发号用途标签。', + }, { file: 'jenkins/Jenkinsfile.production-stdb-module-build', includes: 'npm run check:rustfmt', @@ -7733,6 +7744,14 @@ const scheduledRevisionTriggerJobConfig = readFileSync( 'jenkins/scheduled-revision-trigger-job-config.xml', 'utf8', ); +const scheduledReleaseTriggerContent = readFileSync( + 'jenkins/Jenkinsfile.scheduled-release-trigger', + 'utf8', +); +const scheduledReleaseTriggerJobConfig = readFileSync( + 'jenkins/scheduled-release-trigger-job-config.xml', + 'utf8', +); // 定时与版本比较统一收敛到调度管线,两个下游流水线不得再自带触发器。 for (const [file, content] of [ @@ -7883,6 +7902,69 @@ if ( '[check:production-ops] scheduled-revision-trigger Job 必须指向调度 Jenkinsfile、使用本机 Git 入口与既有 SSH 凭据,并把定时器留在 Jenkinsfile。', ); } + +for (const [snippet, reason] of [ + ["cron('0 4 * * *')", 'release 调度器必须在每天 04:00 检查远端版本'], + ['disableConcurrentBuilds()', '必须禁止 release 调度器并发触发'], + ['skipDefaultCheckout(true)', 'release 调度器不得依赖本地 SCM 工作区'], + ['git ls-remote', 'release 调度器必须直接从远端解析分支版本'], + [ + "AGC_WINDOWS_BUILD_JOB_NAME = 'Genarrative-Agc-Windows-Build'", + '必须声明 AGC Windows release 构建目标 Job', + ], + [ + "AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build'", + '必须声明 AGC macOS release 构建目标 Job', + ], + [ + "string(name: 'AGC_CHANNEL', value: 'release-unified')", + 'release 调度器必须先通过发号 Job 获取统一总版本号', + ], + [ + "string(name: 'AGC_UPDATE_CHANNEL', value: 'release')", + 'release 调度器必须把 Windows 与 macOS 都发到 release 分区', + ], + [ + 'build job: env.AGC_WINDOWS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcParameters', + 'release 调度器必须触发 AGC Windows release 构建', + ], + [ + 'build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters', + 'release 调度器必须触发 AGC macOS release 构建', + ], + [ + "booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true)", + 'release 调度器必须让 macOS 节点恢复后跳过过期排队构建', + ], +]) { + if (!scheduledReleaseTriggerContent.includes(snippet)) { + failed = true; + console.error(`[check:production-ops] release 调度管线${reason}。`); + } +} +if (scheduledReleaseTriggerContent.includes('FULL_BUILD_JOB_NAME')) { + failed = true; + console.error( + '[check:production-ops] release 调度管线只负责 AGC release 客户端构建,不得触发 Full Build。', + ); +} +if ( + !scheduledReleaseTriggerJobConfig.includes( + 'jenkins/Jenkinsfile.scheduled-release-trigger', + ) || + !scheduledReleaseTriggerJobConfig.includes( + 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git', + ) || + !scheduledReleaseTriggerJobConfig.includes( + 'genarrative-local-gitea-ssh', + ) || + !scheduledReleaseTriggerJobConfig.includes('') +) { + failed = true; + console.error( + '[check:production-ops] scheduled-release-trigger Job 必须指向 release 调度 Jenkinsfile、使用本机 Git 入口与既有 SSH 凭据,并把定时器留在 Jenkinsfile。', + ); +} if ( !fullPipelineContent.includes( "string(name: 'COMMIT_HASH', value: env.SOURCE_COMMIT)",