修复:收紧 game-chat 阶段投影与归档竞态
Project CI / Repository checks (pull_request) Failing after 47s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

严格校验 game-chat root、单主和嵌套美术 lineage,并同步 Launcher 摘要与终态后代状态。

禁用声称 game-chat 动态美术谱系的通用重试控件,保留完整 DAG 直属美术重试。

冻结终态轮次的 Runtime 与 manifest 快照,覆盖延迟刷新期间快速开始下一轮的持久归档路径。

补齐纯函数、AppSurface 回归与 M0 状态文档。
This commit is contained in:
2026-08-11 17:42:49 +00:00
parent 8afac9c843
commit 851db37bdc
8 changed files with 618 additions and 118 deletions
+144 -24
View File
@@ -107,6 +107,7 @@ import {
ensureProjectSupervisorActiveSessionId,
isAgentFinalizationMessageId,
isAgentRuntimeTerminalState,
isGameChatSupervisorRoot,
isMissingAgentRuntimeResumeCommandError,
isRuntimeConfigMissingError,
latestGameChatPlayableRevision,
@@ -270,6 +271,7 @@ type PendingGameChatStageArchive = {
rootRuntime: AgentRuntimeState;
runtimeRecords: AgentRuntimeState[];
manifestSnapshot: GameCreationAppManifest | null;
awaitingConversationSync: boolean;
};
function mergeGameChatRuntimeSnapshots(
@@ -898,6 +900,9 @@ export function App({
incoming,
runtime,
);
if (gameChatOnly && !isGameChatSupervisorRoot(runtime)) {
nextStream = null;
}
const candidateStream = nextStream;
if (candidateStream?.status === 'ready' && gameChatOnly) {
const text = candidateStream.accumulatedText.trim();
@@ -978,7 +983,20 @@ export function App({
return;
}
const syncKey = `${projectPath}\n${runtime.sessionId}\n${runtime.runId}`;
if (projectSupervisorRuntimeSyncingRef.current.has(syncKey)) {
const syncAlreadyStarted =
projectSupervisorRuntimeSyncingRef.current.has(syncKey);
if (gameChatOnly && isGameChatSupervisorRoot(runtime)) {
// Freeze the old root and its currently known descendants before the
// asynchronous conversation refresh. A fast next turn may replace the
// current-by-agent map while that refresh is still in flight.
appendGameChatStageRecord(
projectPath,
runtime,
false,
!syncAlreadyStarted,
);
}
if (syncAlreadyStarted) {
return;
}
const refreshConversation = projectSupervisorRefreshConversationRef.current;
@@ -986,24 +1004,54 @@ export function App({
return;
}
projectSupervisorRuntimeSyncingRef.current.add(syncKey);
if (gameChatOnly && isGameChatSupervisorRoot(runtime)) {
const archiveKey = `${projectPath}\n${runtime.runId}`;
void invoke<GameCreationAppManifest>('get_local_game_manifest', {
projectPath,
})
.then((capturedManifest) => {
const pendingArchive =
gameChatPendingStageRuntimesRef.current.get(archiveKey);
if (
pendingArchive &&
gameChatManifestHasTerminalPrimaryTask(capturedManifest)
) {
pendingArchive.manifestSnapshot = capturedManifest;
flushPendingGameChatStageRecords();
}
})
.catch(() => {
// The normal manifest refresh path can still supply the snapshot.
});
}
void refreshConversation(
invoke,
projectPath,
runtime.sessionId,
runtime.runId,
).catch((error) => {
projectSupervisorRuntimeSyncingRef.current.delete(syncKey);
if (
localProjectPathRef.current === projectPath &&
projectSupervisorSessionIdRef.current === runtime.sessionId
) {
setProjectSupervisorRuntimeError(
`项目总控 Agent 对话刷新失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
});
)
.then(() => {
const archiveKey = `${projectPath}\n${runtime.runId}`;
const pendingArchive =
gameChatPendingStageRuntimesRef.current.get(archiveKey);
if (pendingArchive) {
pendingArchive.awaitingConversationSync = false;
}
flushPendingGameChatStageRecords();
})
.catch((error) => {
projectSupervisorRuntimeSyncingRef.current.delete(syncKey);
if (
localProjectPathRef.current === projectPath &&
projectSupervisorSessionIdRef.current === runtime.sessionId
) {
setProjectSupervisorRuntimeError(
`项目总控 Agent 对话刷新失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
});
}
function flushPendingGameChatStageRecords() {
@@ -1018,9 +1066,12 @@ export function App({
gameChatPendingStageRuntimesRef.current.delete(archiveKey);
continue;
}
if (pendingArchive.awaitingConversationSync) {
continue;
}
pendingArchive.runtimeRecords = mergeGameChatRuntimeSnapshots(
pendingArchive.runtimeRecords,
Object.values(agentRuntimeById),
Object.values(agentRuntimeByIdRef.current),
);
if (
projectSupervisorRuntimeRef.current?.runId ===
@@ -1080,7 +1131,7 @@ export function App({
current.length,
);
}
return [
const nextMessages: ChatMessage[] = [
...current,
{
role: 'assistant',
@@ -1089,6 +1140,8 @@ export function App({
updatedAt: pendingArchive.rootRuntime.updatedAt,
},
];
latestMessagesRef.current = nextMessages;
return nextMessages;
});
}
}
@@ -1097,6 +1150,9 @@ export function App({
nextProjectPath: string,
runtime: AgentRuntimeState,
) {
if (!isGameChatSupervisorRoot(runtime)) {
return;
}
const eventMessages = gameChatRuntimeEventMessages(
runtime,
agentRuntimeByIdRef.current,
@@ -1199,8 +1255,14 @@ export function App({
function appendGameChatStageRecord(
nextProjectPath: string,
runtime: AgentRuntimeState,
flush = true,
awaitingConversationSync = false,
) {
if (!gameChatOnly || !gameChatRuntimeHasTerminalOutcome(runtime)) {
if (
!gameChatOnly ||
!isGameChatSupervisorRoot(runtime) ||
!gameChatRuntimeHasTerminalOutcome(runtime)
) {
return;
}
const archiveKey = `${nextProjectPath}\n${runtime.runId}`;
@@ -1218,15 +1280,19 @@ export function App({
: previous.rootRuntime,
runtimeRecords: mergeGameChatRuntimeSnapshots(
previous?.runtimeRecords ?? [],
Object.values(agentRuntimeById),
Object.values(agentRuntimeByIdRef.current),
),
manifestSnapshot:
gameChatManifestHasTerminalPrimaryTask(manifest) &&
projectSupervisorRuntimeRef.current?.runId === runtime.runId
? manifest
: (previous?.manifestSnapshot ?? null),
awaitingConversationSync:
awaitingConversationSync || previous?.awaitingConversationSync === true,
});
flushPendingGameChatStageRecords();
if (flush) {
flushPendingGameChatStageRecords();
}
}
useEffect(() => {
@@ -5735,6 +5801,38 @@ export function App({
return sessionId;
}
async function capturePendingGameChatStageManifestBeforeNextRun(
invoke: TauriInvoke,
projectPath: string,
) {
const archivePrefix = `${projectPath}\n`;
const pendingArchives = Array.from(
gameChatPendingStageRuntimesRef.current.entries(),
).filter(
([archiveKey, pendingArchive]) =>
archiveKey.startsWith(archivePrefix) &&
!pendingArchive.manifestSnapshot,
);
for (const [, pendingArchive] of pendingArchives) {
const currentRoot = projectSupervisorRuntimeRef.current;
if (
!isGameChatSupervisorRoot(currentRoot) ||
currentRoot.runId !== pendingArchive.rootRuntime.runId
) {
throw new Error('上一轮阶段清单尚未冻结,请稍后重试');
}
const capturedManifest = await invoke<GameCreationAppManifest>(
'get_local_game_manifest',
{ projectPath },
);
if (!gameChatManifestHasTerminalPrimaryTask(capturedManifest)) {
throw new Error('上一轮主阶段仍在收束,请稍后重试');
}
pendingArchive.manifestSnapshot = capturedManifest;
}
flushPendingGameChatStageRecords();
}
async function executeChatAgentReply(prompt: string) {
if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) {
setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题');
@@ -5778,6 +5876,15 @@ export function App({
'project-supervisor-game-chat',
)
: null;
if (gameChatOnly && !steerRuntime) {
await capturePendingGameChatStageManifestBeforeNextRun(
invoke,
nextProjectPath,
);
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
}
let autoPreviewAfterRevision = 0;
let autoPreviewAfterValidatedAt = 0;
if (steerRuntime) {
@@ -10304,9 +10411,13 @@ export function App({
if (!runtime) {
return;
}
setAgentRuntimeById((current) =>
mergeAgentRuntimeStateIntoMap(current, runtime, false),
const next = mergeAgentRuntimeStateIntoMap(
agentRuntimeByIdRef.current,
runtime,
false,
);
agentRuntimeByIdRef.current = next;
setAgentRuntimeById(next);
}
async function refreshAgentRuntimes(
@@ -10921,6 +11032,9 @@ export function App({
if (projectSupervisorOnly && gameChatOnly) {
const gameChatProjectPath = localProject?.projectPath ?? '';
const hasCurrentGameChatRoot = isGameChatSupervisorRoot(
projectSupervisorRuntime,
);
return (
<SupervisorChatOnlyView
chatAgentBusy={chatAgentBusy}
@@ -10956,11 +11070,17 @@ export function App({
manifest={manifest}
runtimeConfigOpen={runtimeConfigOpen}
runtimeError={projectSupervisorRuntimeError}
transientReply={projectSupervisorTransientReply}
transientReply={
hasCurrentGameChatRoot ? projectSupervisorTransientReply : ''
}
transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt}
hasConversationControls={projectSupervisorHasConversationControls}
hasConversationControls={
hasCurrentGameChatRoot && projectSupervisorHasConversationControls
}
hiddenConversationCount={hiddenConversationCount}
needsUserInput={projectSupervisorNeedsUserInput}
needsUserInput={
hasCurrentGameChatRoot && projectSupervisorNeedsUserInput
}
visibleMessages={visibleMessages}
workspaceStatus={workspaceStatus}
expectedRunId={projectSupervisorExpectedRunId}
@@ -98,7 +98,7 @@ function distinctRuntimeRecords(
export function isGameChatSupervisorRoot(
runtime: AgentRuntimeState | null | undefined,
): boolean {
): runtime is AgentRuntimeState {
return Boolean(
runtime &&
runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID &&
@@ -139,11 +139,26 @@ export function isCurrentGameChatDynamicArtRuntime(
main.taskId === GAME_CHAT_PRIMARY_TASK_ID &&
main.source === GAME_CHAT_MAIN_SOURCE &&
candidate &&
gameChatRuntimeClaimsDynamicArtLineage(candidate) &&
candidate.parentRunId === main.runId,
);
}
/**
* Fail-closed UI classification for views that only loaded the selected child
* Runtime and therefore cannot reconstruct its root binding. Exact lineage
* consumers must still use `isCurrentGameChatDynamicArtRuntime`.
*/
export function gameChatRuntimeClaimsDynamicArtLineage(
candidate: AgentRuntimeState | null | undefined,
): boolean {
return Boolean(
candidate &&
GAME_CHAT_DYNAMIC_ART_AGENT_IDS.has(candidate.agentId) &&
candidate.taskId === candidate.agentId &&
GAME_CHAT_DYNAMIC_ART_SOURCES.has(candidate.source) &&
candidate.parentAgentId === GAME_CHAT_PRIMARY_TASK_ID &&
candidate.parentRunId === main.runId &&
hasIdentity(candidate.parentRunId) &&
hasIdentity(candidate.sessionId) &&
hasIdentity(candidate.runId),
);
@@ -47,6 +47,7 @@ import {
projectSupervisorRuntimeStatusLabel,
} from './model';
import {
gameChatRuntimeClaimsDynamicArtLineage,
isCurrentGameChatDynamicArtRuntime,
projectCurrentGameChatRuntimeLineage,
} from './gameChatRuntimeProjection';
@@ -391,6 +392,7 @@ export function AgentRuntimeStatusPanel({
const canRetry =
Boolean(runtime.runId) &&
agentRuntimeCanRetry(runtime.status) &&
!gameChatRuntimeClaimsDynamicArtLineage(runtime) &&
!agentGoalStatusIsPaused(runtime.goalStatus) &&
!agentGoalStatusIsPaused(runtime.status) &&
!agentGoalStatusIsPaused(runtime.phase) &&
@@ -36,7 +36,11 @@ import {
formatAgentRecentRuntimeTask,
formatAgentRuntimePlanStep,
formatAgentRuntimeTaskQueue,
GAME_CHAT_SUPERVISOR_SOURCE,
gameChatLineageCollaboratingRuntimes,
isAgentRuntimeTerminalState,
isGameChatSupervisorRoot,
projectCurrentGameChatRuntimeLineage,
projectRuntimeVisibleCurrentWork,
taskRowsFromManifest,
} from '../agent-runtime';
@@ -228,6 +232,18 @@ export function projectAgentRuntimeSummaries(
if (!supervisorRuntime?.runId) {
return [];
}
const manifestTasks = taskRowsFromManifest(nextManifest);
const gameChatRuntimes =
supervisorRuntime.source === GAME_CHAT_SUPERVISOR_SOURCE
? isGameChatSupervisorRoot(supervisorRuntime)
? gameChatLineageCollaboratingRuntimes(
projectCurrentGameChatRuntimeLineage(
supervisorRuntime,
runtimeByAgentId,
),
)
: []
: null;
const groupConfigs = [
{ group: 'design' as const, label: '策划 Agent' },
{ group: 'art' as const, label: '美术 Agent' },
@@ -260,27 +276,34 @@ export function projectAgentRuntimeSummaries(
};
return groupConfigs.flatMap(({ group, label }) => {
const runtimes = taskRowsFromManifest(nextManifest)
.filter((task) => task.group === group)
.map((task) => {
const agentId = agentConversationId(task);
return runtimeByAgentId[agentId] ?? runtimeByAgentId[task.id];
})
.filter((runtime): runtime is AgentRuntimeState =>
Boolean(
runtime &&
['agent-delegate', 'agent-delegate-retry'].includes(
runtime.source,
) &&
runtime.parentAgentId === PROJECT_SUPERVISOR_AGENT_ID &&
runtime.parentRunId === supervisorRuntime.runId,
),
)
.sort(
(left, right) =>
statusRank(right) - statusRank(left) ||
right.updatedAt - left.updatedAt,
);
const runtimes = (
gameChatRuntimes
? gameChatRuntimes.filter((runtime) =>
manifestTasks.some(
(task) => task.id === runtime.taskId && task.group === group,
),
)
: manifestTasks
.filter((task) => task.group === group)
.map((task) => {
const agentId = agentConversationId(task);
return runtimeByAgentId[agentId] ?? runtimeByAgentId[task.id];
})
.filter((runtime): runtime is AgentRuntimeState =>
Boolean(
runtime &&
['agent-delegate', 'agent-delegate-retry'].includes(
runtime.source,
) &&
runtime.parentAgentId === PROJECT_SUPERVISOR_AGENT_ID &&
runtime.parentRunId === supervisorRuntime.runId,
),
)
).sort(
(left, right) =>
statusRank(right) - statusRank(left) ||
right.updatedAt - left.updatedAt,
);
const runtime = runtimes[0];
if (!runtime) {
return [];
@@ -27,6 +27,7 @@ import {
GAME_CHAT_DYNAMIC_ART_AGENT_IDS,
GAME_CHAT_PRIMARY_TASK_ID,
isAgentRuntimeTerminalState,
isGameChatSupervisorRoot,
isMudPointInsufficientRuntimeError,
MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE,
projectCurrentGameChatRuntimeLineage,
@@ -328,6 +329,9 @@ export function gameChatMudPointInterruptionText(
runtime: AgentRuntimeState,
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>,
) {
if (!isGameChatSupervisorRoot(runtime)) {
return null;
}
const relatedRuntimes = [
runtime,
...projectSupervisorCollaboratingAgentRuntimes(runtime, runtimeByAgentId),
@@ -453,7 +457,7 @@ export function buildGameChatProgressEvidence(
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>,
manifest: GameCreationAppManifest | null,
): GameChatSupervisorProgress | null {
if (!runtime?.runId) {
if (!isGameChatSupervisorRoot(runtime)) {
return null;
}
const lineage = projectCurrentGameChatRuntimeLineage(
@@ -472,21 +476,20 @@ export function buildGameChatProgressEvidence(
: '主阶段 0/1',
plan.total > 0 ? `计划 ${plan.completed}/${plan.total}` : null,
].filter(Boolean);
const activeAgents = projectSupervisorCollaboratingAgentRuntimes(
const collaboratingRuntimes = projectSupervisorCollaboratingAgentRuntimes(
runtime,
runtimeByAgentId,
)
.filter(
(professionalRuntime) =>
!isAgentRuntimeTerminalState(professionalRuntime),
)
.map((professionalRuntime) => {
const parts = [
projectProfessionalAgentLabel(professionalRuntime.agentId),
projectRuntimeVisibleCurrentWork(professionalRuntime),
].filter(Boolean);
return compactProgressText(parts.join(' · '), 140);
});
);
const activeProfessionalRuntimes = collaboratingRuntimes.filter(
(professionalRuntime) => !isAgentRuntimeTerminalState(professionalRuntime),
);
const activeAgents = activeProfessionalRuntimes.map((professionalRuntime) => {
const parts = [
projectProfessionalAgentLabel(professionalRuntime.agentId),
projectRuntimeVisibleCurrentWork(professionalRuntime),
].filter(Boolean);
return compactProgressText(parts.join(' · '), 140);
});
const events = runtimeEvidenceEvents(runtime, runtimeByAgentId);
const mainRuntime = lineage?.main ?? null;
const preview = latestEvidenceEvent(
@@ -627,7 +630,14 @@ export function buildGameChatProgressEvidence(
taskProgress:
taskParts.join(' · ') || projectSupervisorChatRuntimeStatus(runtime),
currentWork: formatGameChatRuntimeText(
compactProgressText(projectRuntimeVisibleCurrentWork(runtime)),
compactProgressText(
projectRuntimeVisibleCurrentWork(
isAgentRuntimeTerminalState(runtime) &&
activeProfessionalRuntimes.length > 0
? activeProfessionalRuntimes[0]!
: runtime,
),
),
),
activeAgents,
evidence,
@@ -643,7 +653,7 @@ function collectGameChatRuntimeEventsInternal(
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>,
limit: number,
) {
const sources = runtime
const sources = isGameChatSupervisorRoot(runtime)
? [
{ label: '项目总控 Agent', runtime },
...projectSupervisorCollaboratingAgentRuntimes(
@@ -949,17 +959,36 @@ export function SupervisorChatOnlyView({
>([]);
const [selectedResultImage, setSelectedResultImage] =
useState<LoadedGameChatResultImagePreview | null>(null);
const projectedRuntime =
gameChatMode && !isGameChatSupervisorRoot(runtime) ? null : runtime;
const synchronizingAcceptedRun = Boolean(
expectedRunId && runtime?.runId !== expectedRunId,
expectedRunId && projectedRuntime?.runId !== expectedRunId,
);
const collaboratingRuntimes = useMemo(
() =>
projectSupervisorCollaboratingAgentRuntimes(
projectedRuntime,
runtimeByAgentId,
),
[projectedRuntime, runtimeByAgentId],
);
const activeCollaboratingRuntimes = collaboratingRuntimes.filter(
(candidate) => !isAgentRuntimeTerminalState(candidate),
);
const runtimeTerminal = Boolean(
projectedRuntime && isAgentRuntimeTerminalState(projectedRuntime),
);
const descendantsStillActive = Boolean(
gameChatMode && runtimeTerminal && activeCollaboratingRuntimes.length > 0,
);
const running = Boolean(
chatAgentBusy ||
synchronizingAcceptedRun ||
(runtime && !isAgentRuntimeTerminalState(runtime)),
(projectedRuntime && (!runtimeTerminal || descendantsStillActive)),
);
const gameChatInterruptionText =
gameChatMode && runtime
? gameChatMudPointInterruptionText(runtime, runtimeByAgentId)
gameChatMode && projectedRuntime
? gameChatMudPointInterruptionText(projectedRuntime, runtimeByAgentId)
: null;
const status =
gameChatInterruptionText ||
@@ -967,23 +996,37 @@ export function SupervisorChatOnlyView({
? projectRuntimeVisibleError(runtimeError, '项目总控 Agent', true)
: synchronizingAcceptedRun
? '已投递,正在同步 Agent Runner'
: runtime
? projectSupervisorChatRuntimeStatus(runtime)
: workspaceStatus);
: descendantsStillActive
? '专业 Agent 正在收束'
: projectedRuntime
? projectSupervisorChatRuntimeStatus(projectedRuntime)
: workspaceStatus);
const headerStatus = gameChatMode ? status : previewStatus || status;
const runtimeEvents = useMemo(
() => collectGameChatRuntimeEvents(runtime, runtimeByAgentId),
[runtime, runtimeByAgentId],
() => collectGameChatRuntimeEvents(projectedRuntime, runtimeByAgentId),
[projectedRuntime, runtimeByAgentId],
);
const supervisorProgress = useMemo(
() =>
gameChatMode &&
projectReady &&
runtime &&
!isAgentRuntimeTerminalState(runtime)
? buildGameChatProgressEvidence(runtime, runtimeByAgentId, manifest)
projectedRuntime &&
(!runtimeTerminal || descendantsStillActive)
? buildGameChatProgressEvidence(
projectedRuntime,
runtimeByAgentId,
manifest,
)
: null,
[gameChatMode, manifest, projectReady, runtime, runtimeByAgentId],
[
descendantsStillActive,
gameChatMode,
manifest,
projectReady,
projectedRuntime,
runtimeByAgentId,
runtimeTerminal,
],
);
const resultImages = useMemo(
() => (gameChatMode ? collectGameChatResultImages(manifest) : []),
@@ -992,38 +1035,27 @@ export function SupervisorChatOnlyView({
const resultImageKey = resultImages
.map((image) => `${image.key}:${image.mediaType}`)
.join('\n');
const collaboratingRuntimes = useMemo(
() =>
projectSupervisorCollaboratingAgentRuntimes(runtime, runtimeByAgentId),
[runtime, runtimeByAgentId],
);
const attentionAgentCount = collaboratingRuntimes.filter(
(candidate) =>
['failed', 'needs-reconciliation'].includes(candidate.status) ||
['failed', 'needs-reconciliation'].includes(candidate.phase),
).length;
const runtimeTerminal = Boolean(
runtime && isAgentRuntimeTerminalState(runtime),
);
const runtimeRunId = runtime?.runId ?? null;
const latestParentActivityAt = runtime
? gameChatRuntimeActivityTimes(runtime).reduce(
const runtimeRunId = projectedRuntime?.runId ?? null;
const latestParentActivityAt = projectedRuntime
? gameChatRuntimeActivityTimes(projectedRuntime).reduce(
(latest, candidate) => Math.max(latest, candidate),
0,
)
: 0;
const latestActivityAt = runtime
? [runtime, ...collaboratingRuntimes]
const latestActivityAt = projectedRuntime
? [projectedRuntime, ...collaboratingRuntimes]
.flatMap(gameChatRuntimeActivityTimes)
.reduce((latest, candidate) => Math.max(latest, candidate), 0)
: 0;
const activeCollaboratingRuntimes = collaboratingRuntimes.filter(
(candidate) => !isAgentRuntimeTerminalState(candidate),
);
const activeRuntimeLanes = runtime
const activeRuntimeLanes = projectedRuntime
? activeCollaboratingRuntimes.length > 0
? activeCollaboratingRuntimes
: [runtime]
: [projectedRuntime]
: [];
const nonWaitingRuntimeLaneActivity = activeRuntimeLanes
.filter((candidate) => !gameChatRuntimeIsExpectedWait(candidate))
@@ -1035,9 +1067,9 @@ export function SupervisorChatOnlyView({
)
.filter((activityAt) => activityAt > 0);
const runStartedAt =
runtime && runtimeRunId
? (gameChatTimestampMilliseconds(runtime.startedAt) ??
gameChatRuntimeActivityTimes(runtime).reduce(
projectedRuntime && runtimeRunId
? (gameChatTimestampMilliseconds(projectedRuntime.startedAt) ??
gameChatRuntimeActivityTimes(projectedRuntime).reduce(
(earliest, candidate) => Math.min(earliest, candidate),
Number.POSITIVE_INFINITY,
))
@@ -1045,7 +1077,9 @@ export function SupervisorChatOnlyView({
const elapsedRuntimeMs = Number.isFinite(runStartedAt)
? Math.max(
0,
(runtimeTerminal && latestParentActivityAt > 0
(runtimeTerminal &&
!descendantsStillActive &&
latestParentActivityAt > 0
? latestParentActivityAt
: runtimeClockNow) - runStartedAt,
)
@@ -1067,8 +1101,8 @@ export function SupervisorChatOnlyView({
);
const runtimeAppearsStalled = Boolean(
running &&
runtime &&
!runtimeTerminal &&
projectedRuntime &&
(!runtimeTerminal || descendantsStillActive) &&
!expectedRuntimeWait &&
inactiveRuntimeMs !== null &&
inactiveRuntimeMs > GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS,
@@ -1092,13 +1126,22 @@ export function SupervisorChatOnlyView({
}
return attentionAgentCount > 0 ? '运行中 · 有异常' : '运行中';
}
if (runtime?.status === 'completed' || runtime?.phase === 'completed') {
if (
projectedRuntime?.status === 'completed' ||
projectedRuntime?.phase === 'completed'
) {
return '本轮已完成';
}
if (runtime?.status === 'failed' || runtime?.phase === 'failed') {
if (
projectedRuntime?.status === 'failed' ||
projectedRuntime?.phase === 'failed'
) {
return '本轮失败';
}
if (runtime?.status === 'cancelled' || runtime?.phase === 'cancelled') {
if (
projectedRuntime?.status === 'cancelled' ||
projectedRuntime?.phase === 'cancelled'
) {
return '本轮已取消';
}
return '未运行';
@@ -1114,7 +1157,8 @@ export function SupervisorChatOnlyView({
? 'warning'
: running || synchronizingAcceptedRun
? 'active'
: runtime?.status === 'completed' || runtime?.phase === 'completed'
: projectedRuntime?.status === 'completed' ||
projectedRuntime?.phase === 'completed'
? 'complete'
: 'idle';
const embeddedPreviewUrl = preview
@@ -1134,17 +1178,21 @@ export function SupervisorChatOnlyView({
})();
useLayoutEffect(() => {
setShowRuntimeDetails(false);
}, [projectPath, runtime?.runId]);
}, [projectPath, projectedRuntime?.runId]);
useEffect(() => {
setRuntimeClockNow(Date.now());
if (!gameChatMode || !runtimeRunId || runtimeTerminal) {
if (
!gameChatMode ||
!runtimeRunId ||
(runtimeTerminal && !descendantsStillActive)
) {
return undefined;
}
const interval = window.setInterval(() => {
setRuntimeClockNow(Date.now());
}, GAME_CHAT_RUNTIME_CLOCK_INTERVAL_MS);
return () => window.clearInterval(interval);
}, [gameChatMode, runtimeRunId, runtimeTerminal]);
}, [descendantsStillActive, gameChatMode, runtimeRunId, runtimeTerminal]);
useEffect(() => {
if (!showRuntimeDetails) {
return undefined;
@@ -1387,7 +1435,7 @@ export function SupervisorChatOnlyView({
{hasConversationControls ? (
<div className="supervisor-chat-only-runtime-controls">
<ProjectSupervisorRuntimeControls
runtime={runtime}
runtime={projectedRuntime}
controlBusy={chatAgentBusy}
onToolAction={onToolAction}
onUserInput={onUserInput}
@@ -25,10 +25,12 @@ import {
} from '../src/features/agent-runtime/model';
import {
canArchiveGameChatStage,
gameChatRuntimeClaimsDynamicArtLineage,
latestGameChatPlayableRevision,
projectCurrentGameChatRuntimeLineage,
projectGameChatPrimaryProgress,
} from '../src/features/agent-runtime/gameChatRuntimeProjection';
import { projectAgentRuntimeSummaries } from '../src/features/project-summary/agentPresentation';
describe('Runtime-owned public statuses', () => {
test('keeps backend status messages visible without treating them as client-authored conversation', () => {
@@ -812,6 +814,11 @@ describe('Game-chat source-aware runtime projection', () => {
expect(lineage?.main).toBe(main);
expect(lineage?.dynamicArtChildren).toEqual([artDirector, legacyRetry]);
expect(lineage?.hasConflict).toBe(false);
expect(gameChatRuntimeClaimsDynamicArtLineage(artDirector)).toBe(true);
expect(gameChatRuntimeClaimsDynamicArtLineage(legacyRetry)).toBe(true);
expect(gameChatRuntimeClaimsDynamicArtLineage(oldRootDirectArt)).toBe(
false,
);
expect(
projectSupervisorCollaboratingAgentRuntimes(root, runtimeMap),
).toEqual([main, artDirector, legacyRetry]);
@@ -832,6 +839,49 @@ describe('Game-chat source-aware runtime projection', () => {
).toEqual([]);
});
test('projects current game-chat main and nested art into launcher summaries', () => {
const root = gameChatProjectionRuntime();
const main = gameChatProjectionMain(root);
const art = gameChatProjectionRuntime({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'launcher-art-session',
runId: 'launcher-art-run',
source: 'agent-delegate',
parentAgentId: 'code-prototype',
parentRunId: main.runId,
status: 'completed',
phase: 'completed',
});
const manifest = createGameCreationAppManifest(
'launcher-game-chat',
'Launcher 单主摘要',
);
expect(projectAgentRuntimeSummaries(manifest, root, { main, art })).toEqual(
[
expect.objectContaining({
group: 'art',
label: '美术 Agent',
status: 'completed',
}),
expect.objectContaining({
group: 'code',
label: '程序 Agent',
status: 'running',
}),
],
);
expect(
projectAgentRuntimeSummaries(
manifest,
{ ...root, parentAgentId: 'project-supervisor' },
{ main, art },
),
).toEqual([]);
});
test('uses only the current main for the 0/1 progress denominator', () => {
const root = gameChatProjectionRuntime();
const activeMain = gameChatProjectionMain(root);
@@ -10,6 +10,7 @@ import type {
import {
latestGameChatPlayableRevision as projectLatestGameChatPlayableRevision,
MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE,
AgentRuntimeStatusPanel,
ProjectSupervisorRuntimePanel,
projectCurrentGameChatRuntimeLineage,
} from '../../src/features/agent-runtime';
@@ -3997,14 +3998,7 @@ export function registerProjectSupervisorSurfaceTests() {
gameChatRuntimeEventMessages(supervisorContinuation, {}).map(
(message) => message.text,
),
).toEqual([
expect.stringContaining(
`Supervisor ${continuation.kind} continuation started`,
),
expect.stringContaining(
`Supervisor ${continuation.kind} continuation failed`,
),
]);
).toEqual([]);
}
});
@@ -4157,6 +4151,52 @@ export function registerProjectSupervisorSurfaceTests() {
).toHaveLength(2);
});
it('disables generic retry when a selected runtime claims nested game-chat art lineage', () => {
const onRetryRuntimeTask = vi.fn();
const nestedArt = gameChatRuntimeState({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'selected-game-chat-art-session',
runId: 'selected-game-chat-art-run',
source: 'agent-delegate',
parentAgentId: 'code-prototype',
parentRunId: 'selected-game-chat-main-run',
status: 'failed',
phase: 'failed',
});
const rendered = render(
React.createElement(AgentRuntimeStatusPanel, {
runtime: nestedArt,
onRetryRuntimeTask,
}),
);
const retryButton = within(
screen.getByLabelText('Agent Runtime 操作'),
).getByRole('button', { name: '重试' }) as HTMLButtonElement;
expect(retryButton.disabled).toBe(true);
fireEvent.click(retryButton);
expect(onRetryRuntimeTask).not.toHaveBeenCalled();
rendered.rerender(
React.createElement(AgentRuntimeStatusPanel, {
runtime: {
...nestedArt,
parentAgentId: 'project-supervisor',
parentRunId: 'full-dag-root-run',
},
onRetryRuntimeTask,
}),
);
expect(
(
within(screen.getByLabelText('Agent Runtime 操作')).getByRole(
'button',
{ name: '重试' },
) as HTMLButtonElement
).disabled,
).toBe(false);
});
it('persists professional final-reply streams with stable ids and does not duplicate them after hydration', async () => {
const projectPath = '/tmp/game-chat-final-reply-hydration';
const runId = 'game-chat-final-reply-hydration-run';
@@ -4327,6 +4367,7 @@ export function registerProjectSupervisorSurfaceTests() {
initialRuntime: {
sessionId: 'supervisor-session-active',
runId,
source: 'project-supervisor-game-chat',
status: 'running',
phase: 'execution',
recentEvents: events,
@@ -5307,6 +5348,58 @@ export function registerProjectSupervisorSurfaceTests() {
expect(document.body.textContent).not.toMatch(/\s*\d+\s*/u);
});
it('fails closed when the game-chat surface hydrates a non-game-chat Supervisor root', () => {
const guiRuntime = gameChatRuntimeState({
source: 'project-supervisor-gui',
recentEvents: [
gameChatRuntimeEvent({
summary: '不应进入 game-chat 当前投影',
updatedAt: 100,
}),
],
});
renderGameChatStatus({ runtime: guiRuntime });
expect(screen.getByLabelText('最新状态').textContent).toContain('未运行');
expect(screen.queryByText('不应进入 game-chat 当前投影')).toBeNull();
expect(buildGameChatProgressEvidence(guiRuntime, {}, null)).toBeNull();
expect(collectGameChatRuntimeEvents(guiRuntime, {})).toEqual([]);
});
it('keeps game-chat running while an exact descendant is still active after root terminal projection', () => {
const root = gameChatRuntimeState({
runId: 'terminal-root-active-main',
status: 'completed',
phase: 'completed',
updatedAt: 200,
});
const main = gameChatRuntimeState({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'terminal-root-active-main-session',
runId: 'terminal-root-active-main-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: root.runId,
status: 'running',
phase: 'waiting-for-delegate-receipts',
currentAction: '等待动态美术回执',
updatedAt: 201,
});
renderGameChatStatus({
runtime: root,
runtimeByAgentId: { 'code-prototype': main },
});
const status = screen.getByLabelText('最新状态').textContent ?? '';
expect(status).toContain('运行中');
expect(status).toContain('主阶段 0/1');
expect(status).toContain('正在等待专业 Agent 回执');
expect(status).not.toContain('本轮已完成');
});
it('shows and archives the explicit mud point interruption from a failed art child runtime', () => {
const rootRunId = 'game-chat-mud-point-root-run';
const runtime = gameChatRuntimeState({
@@ -5899,6 +5992,155 @@ export function registerProjectSupervisorSurfaceTests() {
).toHaveLength(0);
});
it('freezes a terminal game-chat stage before a delayed conversation refresh and fast next turn', async () => {
const projectPath = '/tmp/game-chat-stage-record-fast-next-turn';
let professionalRuntimes: AgentRuntimeState[] = [];
const harness = createProjectSupervisorRuntimeHarness({
projectPath,
runtimeMapLoader: async () => professionalRuntimes,
});
const manifest = createGameCreationAppManifest(
'game-chat-stage-record-fast-next-turn',
'game-chat-stage-record-fast-next-turn',
);
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
const conversationGate = createDeferred<void>();
const manifestGate = createDeferred<void>();
let delayConversationRefresh = false;
let delayManifestCapture = false;
let delayedConversationReads = 0;
let delayedManifestReads = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'game-chat-stage-record-fast-next-turn',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'init_local_game_project') {
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
if (command === 'get_local_game_manifest') {
if (delayManifestCapture) {
delayedManifestReads += 1;
await manifestGate.promise;
}
return manifest;
}
if (command === 'read_local_conversation' && delayConversationRefresh) {
delayedConversationReads += 1;
await conversationGate.promise;
}
return harness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: harness.listen },
};
render(
React.createElement(App, {
initialProjectPath: projectPath,
projectSupervisorOnly: true,
gameChatOnly: true,
}),
);
const surface = await screen.findByLabelText('游戏创作聊天');
const composer = within(surface).getByLabelText(
'项目总控对话内容',
) as HTMLTextAreaElement;
await waitFor(() => expect(composer.disabled).toBe(false));
fireEvent.change(composer, { target: { value: '完成旧轮' } });
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(1);
});
const firstStart = invoke.mock.calls.find(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
);
const firstRunId = String(firstStart?.[1]?.runId ?? '');
const main = gameChatPlayableMainRuntime({
parentRunId: firstRunId,
revision: 17,
updatedAt: 1700,
});
professionalRuntimes = [main];
act(() => harness.emitAgentRuntime(main));
delayConversationRefresh = true;
delayManifestCapture = true;
act(() => {
harness.emitRuntime(
harness.runtimeState({
runId: firstRunId,
source: 'project-supervisor-game-chat',
status: 'completed',
phase: 'completed',
updatedAt: 4000,
}),
);
});
await waitFor(() => {
expect(delayedConversationReads).toBeGreaterThan(0);
expect(delayedManifestReads).toBeGreaterThan(0);
});
fireEvent.change(composer, { target: { value: '立即开始下一轮' } });
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
await waitFor(() => expect(delayedManifestReads).toBeGreaterThan(1));
expect(
invoke.mock.calls.filter(
([command]) => command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(1);
manifestGate.resolve(undefined);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'start_game_creator_supervisor_runtime_task',
),
).toHaveLength(2);
});
conversationGate.resolve(undefined);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command, args]) =>
command === 'append_local_conversation_message' &&
String(
(args?.message as { content?: string } | undefined)?.content ??
'',
).startsWith('【Supervisor 阶段记录】'),
),
).toHaveLength(1);
});
});
it('persists one terminal game-chat stage record and keeps it in the next round', async () => {
const projectPath = '/tmp/game-chat-stage-record';
let professionalRuntimes: AgentRuntimeState[] = [];
@@ -1,9 +1,9 @@
# 立项策划 AgentFast GDD)技术方案
- 日期:2026-08-10
- 状态:M0A-1 非交付阶段设计检查通过;M0A-2 / M0-3 合同对齐实施中M1M3 功能尚未实现
- 状态:M0A-1、M0A-2、M0B-1、M0B-2 均已形成独立提交;合入状态以 Git / PR 为准M1M3 功能尚未实现
- 适用范围:AI 游戏创作独立 App、Project Supervisor、Agent Runtime、本地项目策划 sidecar 与后续完整构建准入
- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;合入本文只代表 M0A-1 设计检查点,不代表立项策划入口、审批 UI、Runtime 持久化或构建绑定已经可用
- 当前实现边界:本文件是后续详细设计与实现的仓库内阶段基线;M0 工作包只冻结 Fast GDD 合同并修复现有 owner 验证、game-chat retry 与前端投影边界,不代表立项策划入口、审批 UI、Runtime 持久化或构建绑定已经可用
## 1. 背景与目标