修复:统一 game-chat 单主阶段投影

新增精确绑定 root、main 与动态美术 child 的 game-chat 谱系投影。

统一单主进度、可玩 revision、终态归档与跨轮恢复界面语义。

收紧 final-reply、通用 retry 与异常根身份的失败关闭边界。

补齐普通 DAG 回归、谱系冲突及事件证据契约测试。

同步技术方案、架构裁决与已知陷阱文档。
This commit is contained in:
2026-08-11 16:23:58 +00:00
parent 133cd1fa3b
commit 8afac9c843
12 changed files with 1585 additions and 409 deletions
+136 -150
View File
@@ -99,6 +99,7 @@ import {
agentRuntimeStartStatus,
agentRuntimeStateFromResult,
agentRuntimeSteerStatus,
canArchiveGameChatStage,
conversationContainsProjectSupervisorResponseStream,
createAgentChatRunId,
createDefaultChatMessages,
@@ -108,12 +109,16 @@ import {
isAgentRuntimeTerminalState,
isMissingAgentRuntimeResumeCommandError,
isRuntimeConfigMissingError,
latestGameChatPlayableRevision,
matchingAgentRuntimeForSteer,
mergeAgentRuntimeStateIntoMap,
mergeGameChatRuntimeResponseMessagesIntoHistory,
mergeProjectSupervisorConversation,
mergeProjectSupervisorResponseStream,
normalizeAgentRuntimeState,
gameChatRuntimeBelongsToLineage,
gameChatRuntimeIdentity,
projectCurrentGameChatRuntimeLineage,
projectNameFromPath,
projectProfessionalAgentLabel,
projectSupervisorPendingRepairMatchesProfessional,
@@ -122,6 +127,7 @@ import {
sameAgentRuntimeRun,
submitProjectSupervisorRuntimeTask,
taskRowsFromManifest,
type GameChatPlayableRevision,
} from './features/agent-runtime';
import {
isDeveloperMode,
@@ -260,37 +266,45 @@ type GameChatAutoPreviewAuthorization = {
runId: string;
};
type GameChatPlayableRevision = {
runId: string;
revision: number;
validatedAt: number;
type PendingGameChatStageArchive = {
rootRuntime: AgentRuntimeState;
runtimeRecords: AgentRuntimeState[];
manifestSnapshot: GameCreationAppManifest | null;
};
type GameChatPreviewValidationCandidate = GameChatPlayableRevision & {
eventOrder: number;
playable: boolean;
};
function mergeGameChatRuntimeSnapshots(
previous: AgentRuntimeState[],
incoming: Iterable<AgentRuntimeState | null | undefined>,
) {
const merged = new Map(
previous.map((runtime) => [gameChatRuntimeIdentity(runtime), runtime]),
);
for (const runtime of incoming) {
if (!runtime) {
continue;
}
const key = gameChatRuntimeIdentity(runtime);
const existing = merged.get(key);
if (!existing || runtime.updatedAt >= existing.updatedAt) {
merged.set(key, runtime);
}
}
return Array.from(merged.values());
}
const GAME_CHAT_STAGE_TASK_IDS = [
'design-director',
'art-director',
'art-asset-plan',
'code-director',
'code-prototype',
'preview-readiness',
'preview-playtest',
] as const;
function gameChatManifestHasTerminalStageTasks(
function gameChatManifestHasTerminalPrimaryTask(
manifest: GameCreationAppManifest | null,
) {
if (!manifest) {
return false;
}
return GAME_CHAT_STAGE_TASK_IDS.every((taskId) => {
const status = manifest.tasks.find((task) => task.id === taskId)?.status;
return status === 'completed' || status === 'failed';
});
const tasks =
manifest?.tasks.filter((task) => task.id === 'code-prototype') ?? [];
return (
tasks.length === 1 &&
(tasks[0]?.status === 'completed' || tasks[0]?.status === 'failed')
);
}
function gameChatStageRecordMessageId(runId: string) {
return `game-chat-stage-record:${encodeURIComponent(runId)}`;
}
function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) {
@@ -335,99 +349,6 @@ function gameChatPlayableRevisionIsAfterAuthorization(
);
}
export function latestGameChatPlayableRevision(
runtime: AgentRuntimeState | null,
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>,
): GameChatPlayableRevision | null {
if (!runtime?.runId) {
return null;
}
const playtestChildren = new Map<string, AgentRuntimeState>();
for (const child of Object.values(runtimeByAgentId)) {
if (
child?.agentId !== 'preview-playtest' ||
child.taskId !== 'preview-playtest' ||
child.source !== 'agent-ready-task-scheduler' ||
child.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID ||
child.parentRunId !== runtime.runId
) {
continue;
}
playtestChildren.set(
`${child.agentId}\n${child.sessionId}\n${child.runId}`,
child,
);
}
let latest: GameChatPreviewValidationCandidate | null = null;
let eventOrder = 0;
for (const child of playtestChildren.values()) {
for (const event of child.recentEvents ?? []) {
eventOrder += 1;
if (
event.agentId !== child.agentId ||
event.taskId !== child.taskId ||
event.sessionId !== child.sessionId ||
event.runId !== child.runId ||
event.eventType !== 'observation' ||
!Number.isSafeInteger(event.updatedAt) ||
event.updatedAt < 0 ||
!event.summary.startsWith('preview.validate') ||
!event.detail?.trim().startsWith('{')
) {
continue;
}
try {
const detail = JSON.parse(event.detail) as {
passed?: unknown;
playtestPassed?: unknown;
revision?: unknown;
};
const playable =
event.summary.startsWith('preview.validateok') &&
detail.passed === true &&
detail.playtestPassed === true;
if (
typeof detail.passed !== 'boolean' ||
typeof detail.revision !== 'number' ||
!Number.isSafeInteger(detail.revision) ||
detail.revision <= 0
) {
continue;
}
const shouldReplace =
!latest ||
detail.revision > latest.revision ||
(detail.revision === latest.revision &&
event.updatedAt > latest.validatedAt) ||
(detail.revision === latest.revision &&
event.updatedAt === latest.validatedAt &&
((latest.playable && !playable) ||
(latest.playable === playable &&
eventOrder > latest.eventOrder)));
if (!shouldReplace) {
continue;
}
latest = {
eventOrder,
playable,
runId: runtime.runId,
revision: detail.revision,
validatedAt: event.updatedAt,
};
} catch {
// Ignore malformed or truncated public evidence and wait for a valid revision.
}
}
}
return latest?.playable
? {
runId: latest.runId,
revision: latest.revision,
validatedAt: latest.validatedAt,
}
: null;
}
function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthorization | null {
try {
window.localStorage.removeItem(
@@ -656,7 +577,7 @@ export function App({
const gameChatObservedRunKeysRef = useRef(new Set<string>());
const gameChatArchivedRunKeysRef = useRef(new Set<string>());
const gameChatPendingStageRuntimesRef = useRef(
new Map<string, AgentRuntimeState>(),
new Map<string, PendingGameChatStageArchive>(),
);
const gameChatCommittedResponseStreamKeysRef = useRef(new Set<string>());
const initialSupervisorMessageLatchRef = useRef({
@@ -1086,36 +1007,65 @@ export function App({
}
function flushPendingGameChatStageRecords() {
if (!gameChatOnly || !gameChatManifestHasTerminalStageTasks(manifest)) {
if (!gameChatOnly) {
return;
}
for (const [
archiveKey,
pendingRuntime,
pendingArchive,
] of gameChatPendingStageRuntimesRef.current) {
if (gameChatArchivedRunKeysRef.current.has(archiveKey)) {
gameChatPendingStageRuntimesRef.current.delete(archiveKey);
continue;
}
pendingArchive.runtimeRecords = mergeGameChatRuntimeSnapshots(
pendingArchive.runtimeRecords,
Object.values(agentRuntimeById),
);
if (
projectSupervisorRuntimeRef.current?.runId ===
pendingArchive.rootRuntime.runId &&
gameChatManifestHasTerminalPrimaryTask(manifest)
) {
pendingArchive.manifestSnapshot = manifest;
}
const lineage = projectCurrentGameChatRuntimeLineage(
pendingArchive.rootRuntime,
pendingArchive.runtimeRecords,
);
if (!canArchiveGameChatStage(lineage, pendingArchive.manifestSnapshot)) {
continue;
}
const runtimeSnapshot = Object.fromEntries(
pendingArchive.runtimeRecords.map((runtime) => [
gameChatRuntimeIdentity(runtime),
runtime,
]),
);
const progress = buildGameChatProgressEvidence(
pendingRuntime,
agentRuntimeById,
manifest,
pendingArchive.rootRuntime,
runtimeSnapshot,
pendingArchive.manifestSnapshot,
);
if (!progress) {
continue;
}
const text = formatGameChatStageRecord(
pendingRuntime,
pendingArchive.rootRuntime,
progress,
collectGameChatResultImages(manifest),
collectGameChatResultImages(pendingArchive.manifestSnapshot),
);
const messageId = gameChatStageRecordMessageId(
pendingArchive.rootRuntime.runId,
);
gameChatArchivedRunKeysRef.current.add(archiveKey);
gameChatPendingStageRuntimesRef.current.delete(archiveKey);
setMessages((current) => {
if (
current.some(
(message) => message.role === 'assistant' && message.text === text,
(message) =>
message.role === 'assistant' &&
(message.messageId === messageId || message.text === text),
)
) {
return current;
@@ -1130,7 +1080,15 @@ export function App({
current.length,
);
}
return [...current, { role: 'assistant', text }];
return [
...current,
{
role: 'assistant',
text,
messageId,
updatedAt: pendingArchive.rootRuntime.updatedAt,
},
];
});
}
}
@@ -1141,7 +1099,7 @@ export function App({
) {
const eventMessages = gameChatRuntimeEventMessages(
runtime,
agentRuntimeById,
agentRuntimeByIdRef.current,
);
if (eventMessages.length === 0) {
return;
@@ -1174,27 +1132,36 @@ export function App({
if (!gameChatOnly || runtimeResults.length === 0) {
return;
}
// A professional Runtime is only part of the active game-chat turn when
// it was delegated by the current Project Supervisor run. This prevents
// a stale child Runtime (or a different app mode) from leaking into the
// project transcript after a restart.
const supervisorRunId =
projectSupervisorRuntimeRef.current?.runId ??
runtimeResults.find(
(result) => result.state.agentId === PROJECT_SUPERVISOR_AGENT_ID,
)?.state.runId;
if (!supervisorRunId) {
const runtimeStates = runtimeResults.map((result) =>
agentRuntimeStateFromResult(result),
);
const root =
projectSupervisorRuntimeRef.current ??
runtimeStates.find(
(candidate) => candidate.agentId === PROJECT_SUPERVISOR_AGENT_ID,
) ??
null;
const lineage = projectCurrentGameChatRuntimeLineage(root, [
...Object.values(agentRuntimeByIdRef.current),
...runtimeStates,
]);
if (!lineage?.main) {
return;
}
const messages = runtimeResults.flatMap((result) => {
const runtime = agentRuntimeStateFromResult(result);
const messages = runtimeResults.flatMap((result, index) => {
const runtime = runtimeStates[index]!;
const stream = result.responseStream;
if (
runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID ||
runtime.parentRunId !== supervisorRunId
!gameChatRuntimeBelongsToLineage(lineage, runtime) ||
!stream ||
stream.agentId !== runtime.agentId ||
stream.taskId !== runtime.taskId ||
stream.sessionId !== runtime.sessionId ||
stream.runId !== runtime.runId
) {
return [];
}
return gameChatFinalReplyMessages([result.responseStream]);
return gameChatFinalReplyMessages([stream]);
});
if (messages.length === 0) {
return;
@@ -1243,7 +1210,22 @@ export function App({
if (gameChatArchivedRunKeysRef.current.has(archiveKey)) {
return;
}
gameChatPendingStageRuntimesRef.current.set(archiveKey, runtime);
const previous = gameChatPendingStageRuntimesRef.current.get(archiveKey);
gameChatPendingStageRuntimesRef.current.set(archiveKey, {
rootRuntime:
!previous || runtime.updatedAt >= previous.rootRuntime.updatedAt
? runtime
: previous.rootRuntime,
runtimeRecords: mergeGameChatRuntimeSnapshots(
previous?.runtimeRecords ?? [],
Object.values(agentRuntimeById),
),
manifestSnapshot:
gameChatManifestHasTerminalPrimaryTask(manifest) &&
projectSupervisorRuntimeRef.current?.runId === runtime.runId
? manifest
: (previous?.manifestSnapshot ?? null),
});
flushPendingGameChatStageRecords();
}
@@ -1802,8 +1784,10 @@ export function App({
}
const currentSupervisor = projectSupervisorRuntimeRef.current;
let playableRevision = latestGameChatPlayableRevision(
currentSupervisor,
agentRuntimeByIdRef.current,
projectCurrentGameChatRuntimeLineage(
currentSupervisor,
agentRuntimeByIdRef.current,
),
);
if (playableRevision) {
const currentRevision = await readCurrentProjectRevision();
@@ -5798,8 +5782,10 @@ export function App({
let autoPreviewAfterValidatedAt = 0;
if (steerRuntime) {
const playableAtSubmission = latestGameChatPlayableRevision(
runtimeAtSubmission,
agentRuntimeByIdRef.current,
projectCurrentGameChatRuntimeLineage(
runtimeAtSubmission,
agentRuntimeByIdRef.current,
),
);
autoPreviewAfterRevision = Math.max(
gameChatPreviewRevisionRef.current ?? 0,
@@ -0,0 +1,445 @@
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { PROJECT_SUPERVISOR_AGENT_ID } from '../../app/constants';
import type {
AgentRuntimeEventRecord,
AgentRuntimeState,
} from '../../app/types';
export const GAME_CHAT_SUPERVISOR_SOURCE =
'project-supervisor-game-chat' as const;
export const GAME_CHAT_PRIMARY_TASK_ID = 'code-prototype' as const;
export const GAME_CHAT_DYNAMIC_ART_AGENT_IDS: ReadonlySet<string> = new Set([
'art-director',
'art-asset-plan',
]);
const GAME_CHAT_MAIN_SOURCE = 'agent-ready-task-scheduler';
const GAME_CHAT_DYNAMIC_ART_SOURCES = new Set([
'agent-delegate',
'agent-delegate-retry',
]);
const GAME_CHAT_TERMINAL_STATES = new Set(['completed', 'failed', 'cancelled']);
const GAME_CHAT_RECONCILIATION_STATE = 'needs-reconciliation';
export type GameChatRuntimeLineage = {
root: AgentRuntimeState;
main: AgentRuntimeState | null;
dynamicArtChildren: AgentRuntimeState[];
hasConflict: boolean;
};
export type GameChatPrimaryProgress = {
completed: 0 | 1;
total: 1;
status:
| 'pending'
| 'running'
| 'completed'
| 'failed'
| 'needs-reconciliation';
};
export type GameChatPlayableRevision = {
runId: string;
mainRunId: string;
revision: number;
validatedAt: number;
};
type OrderedRuntimeEvent = {
event: AgentRuntimeEventRecord;
order: number;
};
type PreviewValidationCandidate = GameChatPlayableRevision & {
order: number;
playable: boolean;
};
function hasIdentity(value: string | null | undefined) {
return typeof value === 'string' && value.trim().length > 0;
}
export function gameChatRuntimeIdentity(runtime: AgentRuntimeState) {
return [runtime.agentId, runtime.sessionId, runtime.runId].join('\u001f');
}
export function sameGameChatRuntimeIdentity(
left: AgentRuntimeState,
right: AgentRuntimeState,
) {
return gameChatRuntimeIdentity(left) === gameChatRuntimeIdentity(right);
}
function distinctRuntimeRecords(
runtimeRecords:
| Iterable<AgentRuntimeState | null | undefined>
| Record<string, AgentRuntimeState | null | undefined>,
) {
const records =
Symbol.iterator in Object(runtimeRecords)
? Array.from(
runtimeRecords as Iterable<AgentRuntimeState | null | undefined>,
)
: Object.values(runtimeRecords);
const distinct = new Map<string, AgentRuntimeState>();
for (const runtime of records) {
if (!runtime) {
continue;
}
const key = gameChatRuntimeIdentity(runtime);
const previous = distinct.get(key);
if (!previous || runtime.updatedAt >= previous.updatedAt) {
distinct.set(key, runtime);
}
}
return Array.from(distinct.values());
}
export function isGameChatSupervisorRoot(
runtime: AgentRuntimeState | null | undefined,
): boolean {
return Boolean(
runtime &&
runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID &&
runtime.taskId === PROJECT_SUPERVISOR_AGENT_ID &&
runtime.source === GAME_CHAT_SUPERVISOR_SOURCE &&
hasIdentity(runtime.sessionId) &&
hasIdentity(runtime.runId) &&
!hasIdentity(runtime.parentAgentId) &&
!hasIdentity(runtime.parentRunId),
);
}
export function isCurrentGameChatMainRuntime(
root: AgentRuntimeState | null | undefined,
candidate: AgentRuntimeState | null | undefined,
): boolean {
if (!root || !isGameChatSupervisorRoot(root) || !candidate) {
return false;
}
return Boolean(
candidate.agentId === GAME_CHAT_PRIMARY_TASK_ID &&
candidate.taskId === GAME_CHAT_PRIMARY_TASK_ID &&
candidate.source === GAME_CHAT_MAIN_SOURCE &&
candidate.parentAgentId === PROJECT_SUPERVISOR_AGENT_ID &&
candidate.parentRunId === root.runId &&
hasIdentity(candidate.sessionId) &&
hasIdentity(candidate.runId),
);
}
export function isCurrentGameChatDynamicArtRuntime(
main: AgentRuntimeState | null | undefined,
candidate: AgentRuntimeState | null | undefined,
): boolean {
return Boolean(
main &&
main.agentId === GAME_CHAT_PRIMARY_TASK_ID &&
main.taskId === GAME_CHAT_PRIMARY_TASK_ID &&
main.source === GAME_CHAT_MAIN_SOURCE &&
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.sessionId) &&
hasIdentity(candidate.runId),
);
}
export function isGameChatRuntimeTerminalState(
runtime: AgentRuntimeState | null | undefined,
) {
return Boolean(
runtime &&
(GAME_CHAT_TERMINAL_STATES.has(runtime.status) ||
GAME_CHAT_TERMINAL_STATES.has(runtime.phase)),
);
}
export function isGameChatRuntimeInReconciliation(
runtime: AgentRuntimeState | null | undefined,
) {
return Boolean(
runtime &&
(runtime.status === GAME_CHAT_RECONCILIATION_STATE ||
runtime.phase === GAME_CHAT_RECONCILIATION_STATE),
);
}
export function projectCurrentGameChatRuntimeLineage(
root: AgentRuntimeState | null | undefined,
runtimeRecords:
| Iterable<AgentRuntimeState | null | undefined>
| Record<string, AgentRuntimeState | null | undefined>,
): GameChatRuntimeLineage | null {
if (!root || !isGameChatSupervisorRoot(root)) {
return null;
}
const records = distinctRuntimeRecords(runtimeRecords);
const mainCandidates = records.filter((candidate) =>
isCurrentGameChatMainRuntime(root, candidate),
);
const main = mainCandidates.length === 1 ? mainCandidates[0]! : null;
const dynamicArtChildren = main
? records
.filter((candidate) =>
isCurrentGameChatDynamicArtRuntime(main, candidate),
)
.sort(
(left, right) =>
left.updatedAt - right.updatedAt ||
left.agentId.localeCompare(right.agentId) ||
left.runId.localeCompare(right.runId),
)
: [];
const activeArtChildren = dynamicArtChildren.filter(
(candidate) => !isGameChatRuntimeTerminalState(candidate),
);
return {
root,
main,
dynamicArtChildren,
hasConflict: mainCandidates.length > 1 || activeArtChildren.length > 1,
};
}
export function gameChatLineageCollaboratingRuntimes(
lineage: GameChatRuntimeLineage | null,
) {
return lineage?.main ? [lineage.main, ...lineage.dynamicArtChildren] : [];
}
export function gameChatRuntimeBelongsToLineage(
lineage: GameChatRuntimeLineage | null,
candidate: AgentRuntimeState | null | undefined,
) {
return Boolean(
candidate &&
gameChatLineageCollaboratingRuntimes(lineage).some((runtime) =>
sameGameChatRuntimeIdentity(runtime, candidate),
),
);
}
function manifestPrimaryTaskStatus(manifest: GameCreationAppManifest | null) {
const tasks =
manifest?.tasks.filter((task) => task.id === GAME_CHAT_PRIMARY_TASK_ID) ??
[];
return tasks.length === 1 ? tasks[0]!.status : null;
}
export function projectGameChatPrimaryProgress(
manifest: GameCreationAppManifest | null,
lineage: GameChatRuntimeLineage | null,
): GameChatPrimaryProgress {
if (!lineage) {
return { completed: 0, total: 1, status: 'pending' };
}
if (lineage.hasConflict || isGameChatRuntimeInReconciliation(lineage.root)) {
return { completed: 0, total: 1, status: 'needs-reconciliation' };
}
if (!lineage.main) {
return { completed: 0, total: 1, status: 'pending' };
}
const relatedRuntimes = gameChatLineageCollaboratingRuntimes(lineage);
if (relatedRuntimes.some(isGameChatRuntimeInReconciliation)) {
return { completed: 0, total: 1, status: 'needs-reconciliation' };
}
const main = lineage.main;
if (
main.status === 'failed' ||
main.phase === 'failed' ||
main.status === 'cancelled' ||
main.phase === 'cancelled'
) {
return { completed: 0, total: 1, status: 'failed' };
}
if (!isGameChatRuntimeTerminalState(main)) {
return { completed: 0, total: 1, status: 'running' };
}
const manifestStatus = manifestPrimaryTaskStatus(manifest);
if (manifestStatus === 'completed') {
return { completed: 1, total: 1, status: 'completed' };
}
if (manifestStatus === 'failed') {
return { completed: 0, total: 1, status: 'failed' };
}
return { completed: 0, total: 1, status: 'pending' };
}
function runtimeOwnOrderedEvents(runtime: AgentRuntimeState) {
return (runtime.recentEvents ?? []).flatMap((event, order) =>
event.agentId === runtime.agentId &&
event.taskId === runtime.taskId &&
event.sessionId === runtime.sessionId &&
event.runId === runtime.runId &&
event.source === runtime.source &&
Number.isSafeInteger(event.updatedAt) &&
event.updatedAt >= 0
? [{ event, order }]
: [],
);
}
function staticSmokeObservationPassed(summary: string) {
if (
!summary.startsWith('command.run_limited') ||
!/(?:^|[\s·])game\.static_smoke(?:$|[\s])/u.test(summary)
) {
return null;
}
return summary.startsWith('command.run_limitedok') ? true : false;
}
function orderedEventAtOrBefore(
left: OrderedRuntimeEvent,
right: OrderedRuntimeEvent,
) {
return (
left.event.updatedAt < right.event.updatedAt ||
(left.event.updatedAt === right.event.updatedAt &&
left.order <= right.order)
);
}
function shouldReplacePreviewCandidate(
current: PreviewValidationCandidate | null,
candidate: PreviewValidationCandidate,
) {
if (!current) {
return true;
}
if (candidate.revision !== current.revision) {
return candidate.revision > current.revision;
}
if (candidate.validatedAt !== current.validatedAt) {
return candidate.validatedAt > current.validatedAt;
}
if (candidate.playable !== current.playable) {
return !candidate.playable;
}
return candidate.order > current.order;
}
export function latestGameChatPlayableRevision(
lineage: GameChatRuntimeLineage | null,
): GameChatPlayableRevision | null {
if (!lineage?.main || lineage.hasConflict) {
return null;
}
const orderedEvents = runtimeOwnOrderedEvents(lineage.main);
const staticSmokeEvents = orderedEvents.filter(
({ event }) =>
event.eventType === 'observation' &&
staticSmokeObservationPassed(event.summary) !== null,
);
const latestStaticSmoke =
staticSmokeEvents.reduce<OrderedRuntimeEvent | null>(
(latest, candidate) => {
if (
!latest ||
candidate.event.updatedAt > latest.event.updatedAt ||
(candidate.event.updatedAt === latest.event.updatedAt &&
candidate.order > latest.order)
) {
return candidate;
}
return latest;
},
null,
);
if (
!latestStaticSmoke ||
staticSmokeObservationPassed(latestStaticSmoke.event.summary) !== true
) {
return null;
}
let latestPreview: PreviewValidationCandidate | null = null;
for (const orderedEvent of orderedEvents) {
const { event, order } = orderedEvent;
if (
event.eventType !== 'observation' ||
!event.summary.startsWith('preview.validate') ||
!event.detail?.trim().startsWith('{')
) {
continue;
}
try {
const detail = JSON.parse(event.detail) as {
passed?: unknown;
playtestPassed?: unknown;
revision?: unknown;
};
if (
typeof detail.revision !== 'number' ||
!Number.isSafeInteger(detail.revision) ||
detail.revision <= 0
) {
continue;
}
const candidate: PreviewValidationCandidate = {
runId: lineage.root.runId,
mainRunId: lineage.main.runId,
revision: detail.revision,
validatedAt: event.updatedAt,
order,
playable:
event.summary.startsWith('preview.validateok') &&
detail.passed === true &&
detail.playtestPassed === true,
};
if (shouldReplacePreviewCandidate(latestPreview, candidate)) {
latestPreview = candidate;
}
} catch {
// Malformed public evidence cannot establish a playable revision.
}
}
if (!latestPreview?.playable) {
return null;
}
const previewEvent = orderedEvents.find(
({ event, order }) =>
event.updatedAt === latestPreview?.validatedAt &&
order === latestPreview.order,
);
if (
!previewEvent ||
!orderedEventAtOrBefore(latestStaticSmoke, previewEvent)
) {
return null;
}
return {
runId: latestPreview.runId,
mainRunId: latestPreview.mainRunId,
revision: latestPreview.revision,
validatedAt: latestPreview.validatedAt,
};
}
export function canArchiveGameChatStage(
lineage: GameChatRuntimeLineage | null,
manifest: GameCreationAppManifest | null,
) {
if (!lineage?.main || lineage.hasConflict) {
return false;
}
const relatedRuntimes = [
lineage.root,
lineage.main,
...lineage.dynamicArtChildren,
];
if (
relatedRuntimes.some(isGameChatRuntimeInReconciliation) ||
relatedRuntimes.some((runtime) => !isGameChatRuntimeTerminalState(runtime))
) {
return false;
}
return ['completed', 'failed'].includes(
manifestPrimaryTaskStatus(manifest) ?? '',
);
}
@@ -1,2 +1,3 @@
export * from './gameChatRuntimeProjection';
export * from './model';
export * from './panels';
@@ -20,6 +20,12 @@ import type {
LocalConversationMessageRecord,
TauriInvoke,
} from '../../app/types';
import {
GAME_CHAT_SUPERVISOR_SOURCE,
gameChatLineageCollaboratingRuntimes,
isGameChatSupervisorRoot,
projectCurrentGameChatRuntimeLineage,
} from './gameChatRuntimeProjection';
const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-';
const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-';
@@ -1412,14 +1418,22 @@ export function projectSupervisorCollaboratingAgentRuntimes(
if (!supervisorRuntime?.runId) {
return [];
}
if (supervisorRuntime.source === GAME_CHAT_SUPERVISOR_SOURCE) {
return isGameChatSupervisorRoot(supervisorRuntime)
? gameChatLineageCollaboratingRuntimes(
projectCurrentGameChatRuntimeLineage(
supervisorRuntime,
runtimeByAgentId,
),
)
: [];
}
const runtimesByAgentId = new Map<string, AgentRuntimeState>();
for (const runtime of Object.values(runtimeByAgentId)) {
const isVisibleChildSource =
['agent-delegate', 'agent-delegate-retry'].includes(
runtime?.source ?? '',
) ||
(supervisorRuntime.source === 'project-supervisor-game-chat' &&
runtime?.source === 'agent-ready-task-scheduler');
const isVisibleChildSource = [
'agent-delegate',
'agent-delegate-retry',
].includes(runtime?.source ?? '');
if (
!runtime ||
runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID ||
@@ -46,6 +46,10 @@ import {
projectSupervisorPendingActionPresentation,
projectSupervisorRuntimeStatusLabel,
} from './model';
import {
isCurrentGameChatDynamicArtRuntime,
projectCurrentGameChatRuntimeLineage,
} from './gameChatRuntimeProjection';
export function AgentGoalStatusPanel({
goal,
@@ -721,6 +725,10 @@ export function ProjectSupervisorRuntimePanel({
runtime,
runtimeByAgentId,
);
const gameChatLineage = projectCurrentGameChatRuntimeLineage(
runtime,
runtimeByAgentId,
);
const visibleSnapshotKey = [
runtime?.runId ?? '',
runtime?.status ?? '',
@@ -975,8 +983,14 @@ export function ProjectSupervisorRuntimePanel({
professionalRuntime.pendingToolAction ?? null;
const professionalResult =
professionalResultsByAgentId[professionalRuntime.agentId];
const dynamicArtRetryUnsupported =
isCurrentGameChatDynamicArtRuntime(
gameChatLineage?.main,
professionalRuntime,
);
const canRetryProfessional =
!supervisorIsTerminal &&
!dynamicArtRetryUnsupported &&
!professionalPendingAction &&
(professionalRuntime.status === 'failed' ||
professionalRuntime.phase === 'failed') &&
@@ -1169,7 +1183,9 @@ export function ProjectSupervisorRuntimePanel({
(professionalRuntime.status === 'failed' ||
professionalRuntime.phase === 'failed') ? (
<small className="project-runtime-retry-feedback">
{dynamicArtRetryUnsupported
? '请继续 game-chat 对话,由下一轮程序原型 Agent 重新审计缺口后委派'
: '请先重试项目总控,再由新总控继续安排此任务'}
</small>
) : null}
</article>
@@ -24,9 +24,13 @@ import type {
} from '../../app/types';
import {
formatAgentRuntimeEvent,
GAME_CHAT_DYNAMIC_ART_AGENT_IDS,
GAME_CHAT_PRIMARY_TASK_ID,
isAgentRuntimeTerminalState,
isMudPointInsufficientRuntimeError,
MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE,
projectCurrentGameChatRuntimeLineage,
projectGameChatPrimaryProgress,
projectNameFromPath,
projectProfessionalAgentLabel,
projectRuntimePlanProgress,
@@ -62,13 +66,8 @@ export type GameChatRuntimeEvent = {
const GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX = 'game-chat-runtime-event:';
const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:';
const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([
'design-director',
'art-director',
'art-asset-plan',
'code-director',
'code-prototype',
'preview-readiness',
'preview-playtest',
GAME_CHAT_PRIMARY_TASK_ID,
...GAME_CHAT_DYNAMIC_ART_AGENT_IDS,
]);
const GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES = new Set([
'tool.request',
@@ -329,18 +328,10 @@ export function gameChatMudPointInterruptionText(
runtime: AgentRuntimeState,
runtimeByAgentId: Record<string, AgentRuntimeState | undefined>,
) {
const childRuntimes = Object.values(runtimeByAgentId).filter(
(childRuntime): childRuntime is AgentRuntimeState =>
childRuntime !== undefined &&
childRuntime.parentAgentId === runtime.agentId &&
childRuntime.parentRunId === runtime.runId &&
[
'agent-delegate',
'agent-delegate-retry',
'agent-ready-task-scheduler',
].includes(childRuntime.source),
);
const relatedRuntimes = [runtime, ...childRuntimes];
const relatedRuntimes = [
runtime,
...projectSupervisorCollaboratingAgentRuntimes(runtime, runtimeByAgentId),
];
return relatedRuntimes.some(
(relatedRuntime) =>
relatedRuntime.error &&
@@ -442,9 +433,11 @@ function runtimeEvidenceEvents(
function latestEvidenceEvent(
events: ReturnType<typeof runtimeEvidenceEvents>,
predicate: (event: AgentRuntimeEventRecord) => boolean,
predicate: (
item: ReturnType<typeof runtimeEvidenceEvents>[number],
) => boolean,
) {
return events.find(({ event }) => predicate(event)) ?? null;
return events.find(predicate) ?? null;
}
export function formatGameChatRuntimeText(text: string) {
@@ -463,29 +456,20 @@ export function buildGameChatProgressEvidence(
if (!runtime?.runId) {
return null;
}
const fastPathTaskIds = new Set([
'design-director',
'art-director',
'art-asset-plan',
'code-director',
'code-prototype',
'preview-readiness',
'preview-playtest',
]);
const tasks =
manifest?.tasks.filter((task) => fastPathTaskIds.has(task.id)) ?? [];
const completedTasks = tasks.filter(
(task) => task.status === 'completed',
).length;
const runningTasks = tasks.filter((task) =>
['running', 'waiting-for-confirmation'].includes(task.status),
).length;
const failedTasks = tasks.filter((task) => task.status === 'failed').length;
const lineage = projectCurrentGameChatRuntimeLineage(
runtime,
runtimeByAgentId,
);
const primaryProgress = projectGameChatPrimaryProgress(manifest, lineage);
const plan = projectRuntimePlanProgress(runtime);
const taskParts = [
tasks.length > 0 ? `任务图 ${completedTasks}/${tasks.length}` : null,
runningTasks > 0 ? `进行中 ${runningTasks}` : null,
failedTasks > 0 ? `失败 ${failedTasks}` : null,
primaryProgress.status === 'completed'
? '主阶段 1/1'
: primaryProgress.status === 'failed'
? '主阶段失败'
: primaryProgress.status === 'needs-reconciliation'
? '主阶段待核对'
: '主阶段 0/1',
plan.total > 0 ? `计划 ${plan.completed}/${plan.total}` : null,
].filter(Boolean);
const activeAgents = projectSupervisorCollaboratingAgentRuntimes(
@@ -504,33 +488,39 @@ export function buildGameChatProgressEvidence(
return compactProgressText(parts.join(' · '), 140);
});
const events = runtimeEvidenceEvents(runtime, runtimeByAgentId);
const mainRuntime = lineage?.main ?? null;
const preview = latestEvidenceEvent(
events,
(event) =>
({ event, source }) =>
source === mainRuntime &&
event.eventType === 'observation' &&
event.summary.startsWith('preview.validate'),
);
const staticCheck = latestEvidenceEvent(
events,
(event) =>
({ event, source }) =>
source === mainRuntime &&
event.eventType === 'observation' &&
event.summary.startsWith('command.run_limited'),
event.summary.startsWith('command.run_limited') &&
/(?:^|[\s·])game\.static_smoke(?:$|[\s])/u.test(event.summary),
);
const delegate = latestEvidenceEvent(
events,
(event) =>
({ event, source }) =>
source === mainRuntime &&
event.eventType === 'action' &&
event.summary === '调用工具 agent.delegate',
);
const imageInspection = latestEvidenceEvent(
events,
(event) =>
({ event }) =>
event.eventType === 'observation' &&
event.summary.startsWith('image.inspect'),
);
const mutation = latestEvidenceEvent(
events,
(event) =>
({ event, source }) =>
source === mainRuntime &&
event.eventType === 'observation' &&
(event.summary.startsWith('file.patch') ||
event.summary.startsWith('file.write')),
@@ -1,6 +1,9 @@
import { describe, expect, test, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import type {
AgentRuntimeEventRecord,
AgentRuntimeResponseStream,
AgentRuntimeResult,
AgentRuntimeState,
@@ -15,10 +18,17 @@ import {
projectRuntimeVisibleCurrentWork,
projectRuntimeVisibleError,
projectSupervisorChatRuntimeStatus,
projectSupervisorCollaboratingAgentRuntimes,
projectSupervisorResponseStreamIdentity,
projectSupervisorVisibleConversationText,
submitProjectSupervisorRuntimeTask,
} from '../src/features/agent-runtime/model';
import {
canArchiveGameChatStage,
latestGameChatPlayableRevision,
projectCurrentGameChatRuntimeLineage,
projectGameChatPrimaryProgress,
} from '../src/features/agent-runtime/gameChatRuntimeProjection';
describe('Runtime-owned public statuses', () => {
test('keeps backend status messages visible without treating them as client-authored conversation', () => {
@@ -669,3 +679,407 @@ describe('Agent Runtime Provider 状态投影', () => {
expect(emptySummaryEvent).not.toContain(fingerprint);
});
});
function gameChatProjectionRuntime(
overrides: Partial<AgentRuntimeState> = {},
): AgentRuntimeState {
return {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'game-chat-root-session',
runId: 'game-chat-root-run',
source: 'project-supervisor-game-chat',
status: 'running',
phase: 'execution',
currentTask: '完成当前游戏创作轮次',
currentAction: '等待单主 Agent 完成',
plan: [],
observations: [],
allowedTools: [],
lastResponse: null,
error: null,
updatedAt: 100,
...overrides,
};
}
function gameChatProjectionMain(
root: AgentRuntimeState,
overrides: Partial<AgentRuntimeState> = {},
) {
return gameChatProjectionRuntime({
agentId: 'code-prototype',
taskId: 'code-prototype',
sessionId: 'game-chat-main-session',
runId: 'game-chat-main-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: root.runId,
currentTask: '审计、接入并验收当前原型',
updatedAt: 110,
...overrides,
});
}
function gameChatProjectionEvent(
runtime: AgentRuntimeState,
overrides: Partial<AgentRuntimeEventRecord>,
): AgentRuntimeEventRecord {
return {
schemaVersion: 'game-creator-runtime-event.v1',
agentId: runtime.agentId,
taskId: runtime.taskId,
sessionId: runtime.sessionId,
runId: runtime.runId,
source: runtime.source,
eventType: 'observation',
status: runtime.status,
phase: 'observation',
summary: 'observation',
detail: null,
updatedAt: runtime.updatedAt,
...overrides,
};
}
describe('Game-chat source-aware runtime projection', () => {
test('projects only the current scheduler main and its nested art children', () => {
const root = gameChatProjectionRuntime();
const main = gameChatProjectionMain(root);
const artDirector = gameChatProjectionRuntime({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'art-director-session',
runId: 'art-director-run',
source: 'agent-delegate',
parentAgentId: 'code-prototype',
parentRunId: main.runId,
status: 'completed',
phase: 'completed',
updatedAt: 120,
});
const legacyRetry = gameChatProjectionRuntime({
agentId: 'art-asset-plan',
taskId: 'art-asset-plan',
sessionId: 'art-asset-plan-retry-session',
runId: 'art-asset-plan-retry-run',
source: 'agent-delegate-retry',
parentAgentId: 'code-prototype',
parentRunId: main.runId,
status: 'failed',
phase: 'failed',
updatedAt: 130,
});
const oldMain = gameChatProjectionMain(root, {
sessionId: 'old-main-session',
runId: 'old-main-run',
parentRunId: 'old-root-run',
updatedAt: 999,
});
const oldRootDirectArt = gameChatProjectionRuntime({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'old-root-art-session',
runId: 'old-root-art-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: root.runId,
updatedAt: 998,
});
const fullDagPlaytest = gameChatProjectionRuntime({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: 'full-dag-playtest-session',
runId: 'full-dag-playtest-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: root.runId,
updatedAt: 997,
});
const runtimeMap = {
main,
'main-task-alias': main,
artDirector,
legacyRetry,
oldMain,
oldRootDirectArt,
fullDagPlaytest,
};
const lineage = projectCurrentGameChatRuntimeLineage(root, runtimeMap);
expect(lineage?.main).toBe(main);
expect(lineage?.dynamicArtChildren).toEqual([artDirector, legacyRetry]);
expect(lineage?.hasConflict).toBe(false);
expect(
projectSupervisorCollaboratingAgentRuntimes(root, runtimeMap),
).toEqual([main, artDirector, legacyRetry]);
const malformedGameChatRoot = {
...root,
parentAgentId: 'project-supervisor',
parentRunId: 'ancestor-run',
};
expect(
projectSupervisorCollaboratingAgentRuntimes(malformedGameChatRoot, {
directDelegate: {
...artDirector,
parentAgentId: 'project-supervisor',
parentRunId: malformedGameChatRoot.runId,
},
}),
).toEqual([]);
});
test('uses only the current main for the 0/1 progress denominator', () => {
const root = gameChatProjectionRuntime();
const activeMain = gameChatProjectionMain(root);
const manifest = createGameCreationAppManifest('project-1', '单主进度');
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
expect(
projectGameChatPrimaryProgress(
manifest,
projectCurrentGameChatRuntimeLineage(root, {}),
),
).toEqual({ completed: 0, total: 1, status: 'pending' });
expect(
projectGameChatPrimaryProgress(
manifest,
projectCurrentGameChatRuntimeLineage(root, [activeMain]),
),
).toEqual({ completed: 0, total: 1, status: 'running' });
const completedMain = {
...activeMain,
status: 'completed',
phase: 'completed',
};
expect(
projectGameChatPrimaryProgress(
manifest,
projectCurrentGameChatRuntimeLineage(root, [completedMain]),
),
).toEqual({ completed: 1, total: 1, status: 'completed' });
const failedMain = {
...activeMain,
status: 'failed',
phase: 'failed',
};
expect(
projectGameChatPrimaryProgress(
manifest,
projectCurrentGameChatRuntimeLineage(root, [failedMain]),
),
).toEqual({ completed: 0, total: 1, status: 'failed' });
const reconciliationRoot = {
...root,
status: 'needs-reconciliation',
phase: 'needs-reconciliation',
};
expect(
projectGameChatPrimaryProgress(
manifest,
projectCurrentGameChatRuntimeLineage(reconciliationRoot, []),
),
).toEqual({
completed: 0,
total: 1,
status: 'needs-reconciliation',
});
const conflictingMain = gameChatProjectionMain(root, {
sessionId: 'conflicting-main-session',
runId: 'conflicting-main-run',
});
expect(
projectGameChatPrimaryProgress(
manifest,
projectCurrentGameChatRuntimeLineage(root, [
activeMain,
conflictingMain,
]),
),
).toEqual({
completed: 0,
total: 1,
status: 'needs-reconciliation',
});
});
test('derives a playable revision only from current-main smoke then structured preview evidence', () => {
const root = gameChatProjectionRuntime();
const main = gameChatProjectionMain(root, { recentEvents: [] });
const smoke = gameChatProjectionEvent(main, {
summary: 'command.run_limitedok · game.static_smoke 已完成',
updatedAt: 200,
});
const previewPassed = gameChatProjectionEvent(main, {
summary: 'preview.validateok · 浏览器验证已通过',
detail: JSON.stringify({
passed: true,
playtestPassed: true,
revision: 7,
}),
updatedAt: 201,
});
main.recentEvents = [smoke, previewPassed];
const lineage = projectCurrentGameChatRuntimeLineage(root, [main]);
expect(latestGameChatPlayableRevision(lineage)).toEqual({
runId: root.runId,
mainRunId: main.runId,
revision: 7,
validatedAt: 201,
});
const laterFailure = gameChatProjectionEvent(main, {
summary: 'preview.validatefailed · 后续回归',
detail: JSON.stringify({
passed: false,
playtestPassed: false,
revision: 7,
}),
updatedAt: 202,
});
main.recentEvents = [smoke, previewPassed, laterFailure];
expect(
latestGameChatPlayableRevision(
projectCurrentGameChatRuntimeLineage(root, [main]),
),
).toBeNull();
main.recentEvents = [previewPassed];
expect(
latestGameChatPlayableRevision(
projectCurrentGameChatRuntimeLineage(root, [main]),
),
).toBeNull();
const oldPlaytest = gameChatProjectionRuntime({
agentId: 'preview-playtest',
taskId: 'preview-playtest',
sessionId: 'old-playtest-session',
runId: 'old-playtest-run',
source: 'agent-ready-task-scheduler',
parentAgentId: 'project-supervisor',
parentRunId: root.runId,
recentEvents: [previewPassed],
});
expect(
latestGameChatPlayableRevision(
projectCurrentGameChatRuntimeLineage(root, [oldPlaytest]),
),
).toBeNull();
main.recentEvents = [previewPassed, { ...smoke, updatedAt: 202 }];
expect(
latestGameChatPlayableRevision(
projectCurrentGameChatRuntimeLineage(root, [main]),
),
).toBeNull();
main.recentEvents = [smoke, { ...previewPassed, updatedAt: -1 }];
expect(
latestGameChatPlayableRevision(
projectCurrentGameChatRuntimeLineage(root, [main]),
),
).toBeNull();
});
test('archives only after root, main, dynamic children and manifest have converged', () => {
const root = gameChatProjectionRuntime({
status: 'completed',
phase: 'completed',
});
const main = gameChatProjectionMain(root, {
status: 'completed',
phase: 'completed',
});
const art = gameChatProjectionRuntime({
agentId: 'art-director',
taskId: 'art-director',
sessionId: 'archive-art-session',
runId: 'archive-art-run',
source: 'agent-delegate',
parentAgentId: 'code-prototype',
parentRunId: main.runId,
status: 'completed',
phase: 'completed',
});
const manifest = createGameCreationAppManifest('project-2', '归档门');
manifest.tasks = manifest.tasks.map((task) =>
task.id === 'code-prototype'
? { ...task, status: 'completed' as const }
: task,
);
expect(
canArchiveGameChatStage(
projectCurrentGameChatRuntimeLineage(root, [main, art]),
manifest,
),
).toBe(true);
const activeArt = { ...art, status: 'running', phase: 'execution' };
expect(
canArchiveGameChatStage(
projectCurrentGameChatRuntimeLineage(root, [main, activeArt]),
manifest,
),
).toBe(false);
const waitingMain = {
...main,
status: 'running',
phase: 'waiting-for-delegate-receipts',
};
expect(
canArchiveGameChatStage(
projectCurrentGameChatRuntimeLineage(root, [waitingMain, art]),
manifest,
),
).toBe(false);
const activeRoot = { ...root, status: 'running', phase: 'execution' };
expect(
canArchiveGameChatStage(
projectCurrentGameChatRuntimeLineage(activeRoot, [
{ ...main, parentRunId: activeRoot.runId },
art,
]),
manifest,
),
).toBe(false);
const reconciliationArt = {
...art,
status: 'needs-reconciliation',
phase: 'needs-reconciliation',
};
expect(
canArchiveGameChatStage(
projectCurrentGameChatRuntimeLineage(root, [main, reconciliationArt]),
manifest,
),
).toBe(false);
const pendingManifest = createGameCreationAppManifest(
'project-2',
'归档门未收敛',
);
expect(
canArchiveGameChatStage(
projectCurrentGameChatRuntimeLineage(root, [main, art]),
pendingManifest,
),
).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,13 @@
# 决策记录
## 2026-08-11 game-chat 单主前端投影统一使用 source-aware lineage
- 投影身份:game-chat 前端只认 `project-supervisor` 且 source=`project-supervisor-game-chat` 的无父根 Run;其唯一 main 必须是 parent 指向该根、source=`agent-ready-task-scheduler``code-prototype`;动态美术 child 只允许 parent 指向该 main 的 `art-director | art-asset-plan`source 为 `agent-delegate | agent-delegate-retry`。旧根直属美术、固定 DAG 节点、错误 source/parent/run 和并发冲突一律不进入当前轮投影。
- 消费者边界:game-chat 主进度固定为 `0/1``1/1`、失败或待核对;专业 final-reply 同时要求角色 allowlist 与精确 run 谱系;可玩 revision 只由当前 main 的成功 `game.static_smoke` 之后、`passed=true + playtestPassed=true` 的结构化 `preview.validate` 建立,同 revision 后续失败优先。完整 16 任务 DAG 的投影与试玩 owner 不变。
- retry 投影:动态美术 child,包括遗留 `agent-delegate-retry`,只作诊断展示且不提供通用 retry 控件;根终态时恢复提示固定引导用户继续 game-chat,由下一轮 main 重新审计缺口后首次委派。普通 DAG child 和非美术委派 retry 不受影响。
- 归档边界:`【Supervisor 阶段记录】` 只有在 root、唯一 main、当前动态美术 children 与 manifest `code-prototype` 全部终态且无冲突/reconciliation 时才可写入。等待期间按完整 `agent/session/run` 身份保存 root-scoped Runtime 快照,避免 current-by-agent map 被新 root 覆盖后把新旧证据串线;消息 ID 固定绑定 root run,重载与 hydration 幂等。
- 范围:M0B-2 只改前端纯投影、接线、文案与回归,不修改后端 DTO/schema/delivery/route,不迁移历史 manifest,也不提前实现 M1M3。
## 2026-08-11 game-chat 动态美术 child 通用 retry 失败关闭
- 裁决:game-chat 动态美术 child`art-director` / `art-asset-plan`parent 为单主 `code-prototype`、root 绑定 `project-supervisor-game-chat` source)不允许走通用 Agent Runtime retry。retry 入口识别该类 child 后,在创建 successor run、delegationId、task journal 或其它 durable 副作用前直接返回类型化错误,引导用户继续 game-chat 对话:下一轮 main `code-prototype` 重新完成 `asset.list` 审计后,按仍存在的缺口建立新的 durable 美术委派。委派去重以 parent run 为键,跨轮自然放行;新委派产生新的 delegationId 与 durable delivery,沿用现行严格 lineage 判定,不新增 delivery 换绑、续发或可变机制。
@@ -1,5 +1,12 @@
# 踩坑与排障记录
## 2026-08-11 game-chat 阶段归档不能只保存 current-by-agent map
- 现象:前端 Runtime map 以 Agent ID 保存当前记录。新一轮仍会复用 `code-prototype``art-director``art-asset-plan` 这些 Agent ID;若旧 root 已终态但 main/美术 child 或 manifest 仍在收口,直接从 live map 归档会在新 root 接管后丢失旧后代,或把新轮证据误接到旧阶段记录。
- 原因:Agent ID 只是当前视图索引,不是跨 root 的运行身份。game-chat 的正式投影身份至少需要 `agentId + sessionId + runId + source + parentAgentId + parentRunId`,而阶段记录还必须绑定 source-bound root。
- 修复:根进入终态时按完整 `agent/session/run` 身份保存 root-scoped Runtime 快照,后续只合并同一稳定身份的更新;manifest 快照只在该 root 仍为当前 root 时捕获。归档前重新执行严格 lineage、全终态、单 main/单 active art 与 reconciliation 门禁,并用 root run 派生稳定 message ID。
- 防回归:测试必须覆盖 root 先终态、main 或 child 仍 active、main 仍等待 delegate receipts、manifest hydration 滞后、initial hydration 和历史阶段记录去重。完整 DAG 继续使用自己的投影,不能把 game-chat selector 反向推广为全局任务图真相。
> 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。
## 记录格式
@@ -34,7 +34,7 @@
- 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。game-chat 的自主链路中,Supervisor 持久化意图后只有 `code-prototype` 是主 Agent;它可能临时委派一个受限美术 child,后者只写 `assets/**`,回执返回同一主 Run 后由主 Agent 接入与验收。界面聚合当前 Supervisor 父 run、单主 Agent 及其直接美术 child 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。
- 公开消息硬门:模型仍负责 Supervisor / 专业 Agent 回复的业务语义,Runtime 不根据 tool 或 Provider 事件自行补写业务结论;但用户直接投递的 Project Supervisor 根后台任务必须先落为不可执行的 `preparing / public-status-pending`,再以 `runtime-public-status-*` 稳定 message ID 把“任务已接收,正在启动处理”写入项目 conversation,成功后才转为 `pending / queued`;恢复预检只读,只能在验证到同 run accepted 消息后把该任务临时分类为可恢复,真实 resume 持有 Agent 锁后才可持久提升为 `pending / queued`;写入失败则落为 `failed / public-status-write-failed`,不得继续执行。这些 Runtime 公开状态只供 UI 展示,prompt 构建器必须按稳定前缀排除。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,必须在 task、event、state 等其它终态投影之前先幂等写入一条脱敏、用户可理解的失败消息;专业 Agent 命中该全局硬期限时,也必须通过权威 Run Profile 和根 task 将同一根终态写入项目 conversation,同时保留 child 私有 Session 状态;状态文件本身写坏也不能导致零公开结果。当前 Runtime 自称根 agent/run 时,其 session 和两个 parent 字段必须与权威根 task 一致;任一身份冲突必须失败关闭,不得以另一 session 派生第二条项目终态。前端把该前缀识别为 Runtime-owned,同秒时排在触发它的 Supervisor 用户消息之后,不二次持久化;仅根 Supervisor 的 `turn.started / turn.failed / turn.budget_exhausted` 只保留在 Runtime 详情和进度投影中,不能再生成第二条聊天消息,专业 Agent 的公开启动事件仍可见。该硬门不改变 final-reply 的唯一性;非 Supervisor 专业 Agent 的失败消息继续留在对应 Agent Session,不把私有诊断写进项目 conversation。
- 启动恢复和续跑边界:本条取代上一条中“只有 accepted 才可恢复”的窄口径。若进程在 Supervisor 用户消息已持久、accepted 未持久之间崩溃,只读 preflight 可以把该 `preparing` 识别为可恢复,但不改写 task/conversation;真实 resume 持有 Agent 锁后必须先幂等补写 accepted,再提升为 `pending / queued`。用户消息或 accepted conversation 已落盘而辅助审计失败时,以 conversation 为公开真相继续入队,不留下“已接收但永不执行”的任务;根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。receipt / isolated-join 等带 parent 的 Supervisor continuation 不再另写 Session 终态,只保留单一后端公开事件;`runtime-task-*``runtime-public-status-*` 共享同 run 的不透明关联摘要,秒级时间戳下多个连续任务必须按实际 run 对应的 `user -> accepted -> terminal` 顺序交错展示。
- Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。运行详情弹窗在项目或 run 身份切换的同次提交中同步关闭,不能由延迟 effect 关闭用户在新 run 状态可见后刚打开的弹窗。
- Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡。game-chat 客户端只从当前 source-bound 根、唯一 `code-prototype` scheduler main、该 main 的动态美术 child、Supervisor 结构化计划和对应持久事件确定性整理,主阶段分母固定为 `0/1``1/1`;不再读取完整 DAG 的固定七节点,也不把旧根直属美术、错误 parent/source 或 `preview-playtest` child 混入当前轮。播报显示任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。运行详情弹窗在项目或 run 身份切换的同次提交中同步关闭,不能由延迟 effect 关闭用户在新 run 状态可见后刚打开的弹窗。
- ready-task 启动活性:`background_task.queued``autonomous_ready_task.scheduled`、Runner heartbeat 或执行锁已移交都不等于 child 已启动。实际持有执行权的 Runner 必须在释放项目写锁后同步写入 child 的 running task、`turn.started` 与 started journal,再把已启动 state 和 per-Agent 执行锁交给已确认开始轮询的独立 execution worker;同步启动或 worker 接管失败时,要在仍持有执行锁期间依次把 child 和 manifest Graph 节点明确落为 failed,再释放锁并让 parent 收到调度错误。`autonomous_ready_task.scheduled` 只作诊断审计,其写入失败不能阻断 durable child 启动;external client 只 wake Runner,不在客户端抢占执行。Supervisor 进度卡通过 durable `startedAt`(旧 Run 从完整 task journal 恢复,最新 task-record fallback 保持 0)显示真实持续时间,并以父 Run 与当前关联专业 Agent 的最大事件时间计算运行态活跃度:运行超过 5 分钟无新事件时显示“运行中 · 疑似停滞”和静默时长;等待用户、等待确认、Provider retry、视觉资产、进程会话、pausing 与 paused 不误报。父 Run terminal 后,持续时间冻结在父 Run 自身最后活动,不随 child 晚到收口事件增长。消息时间统一校验为 JavaScript 可表示的 Date;越界值显示“时间未知”且不写无效 `datetime`。实时回复只显示 response stream 自己的 `updatedAt`,缺失时同样显示“时间未知”,不能借用其它 Runtime 活动时间或随前端时钟漂移。该提示只提供可观测性,不改变 Runtime/manifest 正式状态。
- ready-task manifest 漂移:父 Supervisor 必须分别判断“能否调度新节点”和“是否存在必须等待的工作”。派生视觉需要父规划修复时不再调度新 child,但当前最新且活跃的根 Run 下,只要存在确定性 runId、scheduler source、正确父绑定且 durable journal 为 queued/running 的 ready child,父 Run 就保持 `waiting-for-manifest-tasks`,不能因旧 hydration 快照把 manifest running 覆盖成 pending 而提前 fixed-graph-stalled。game-chat child 可在相同严格身份下容忍 pending 漂移;正式产物、Canvas、revision、`game.static_smoke``preview.validate` 门禁不放宽。GUI/CLI、旧父 Run、终态、确认/用户输入/reconciliation、伪造绑定或非确定性 runId 全部失败关闭;更新根 Run 后旧 child 不得继续维持新 DAG 或投影完成。
- Supervisor 持久决策与单主条件美术:game-chat 的关键词、用户是否报告“美术未接入”、占位状态和当前资产探测只形成 `advisoryOnly=true` 的补充上下文,不得直接重置 Graph、预完成美术节点、选择复用/生成分支或继承历史试玩类型。当前根 Run 没有持久化 Supervisor 决策时,scheduler 不启动任何 childSupervisor Provider 只通过 auto-safe 的 `agent.route_manifest` 提交 `game-chat-workflow-decision.v2``intentSummary` 是 Supervisor 自行理解并持久化的用户意图,`strategy=audit-existing-first` 只是固定安全执行策略,两者不得混用。此动作不能审计、生成、委派或替代后续判断,也不能把整体视觉重做解释成整套美术的强制重生成;成功后 Runtime 只启动唯一 `code-prototype` 主 Agent。升级恢复时严格校验 v1 sidecar 的旧 fingerprint,并从完成合同绑定的有效任务恢复 `intentSummary`;旧 `code-director` coverage/route 只作为迁移输入,不作为当前完成证据,必须由同一根 Run 的 `code-prototype` 重新 `asset.list` 后原位替换为单主合同。确定性 `code-prototype` Run 仅兼容已知 canonical task 文本版本,其余 task/binding/root 身份继续失败关闭;升级前已运行的 fixed-graph 美术 child 不再具备任何 mutation 或生图权限。主 Agent 必须以当前正式资产、Canvas 登记、私有图集合同、四张语义切片和 art manifest 判断真实缺口;完整覆盖时直接接入,不得生成或扣费。只有可证实缺失 `art-spec` 或核心 spritesheet 时,主 Agent 才可对相应 `art-director``art-asset-plan` 建立一条 durable 委派;每次最多一个活跃美术 child,child 仅可写 `assets/**`,不得修改 `game/**` 或接入/验收游戏。若两个槽位都缺失,必须先完成 `art-director`,由同一主 Run 认领其 `EvidenceReady` delivery 后,才能委派依赖规范图的 `art-asset-plan`;失败或未就绪 delivery 不得消耗不可重试的图集委派槽位。主 Agent 认领必要回执后继续同一 Run 完成素材接入、原玩法语义校验、`game.static_smoke` 与桌面/移动 `preview.validate`。绝对硬截止对嵌套美术 child 继续核验 `root -> code-prototype -> agent-delegate` 完整身份并保留未知外部生成的 reconciliation 证据。Runtime 只负责校验根/父子身份、当前 revision、路径、Canvas 登记、缺口/路由 fingerprint、写入范围及完成证据;纯“继续”仍走既有正式 continuation 合同,普通美术措辞不得借用更老项目的具体试玩场景。不得以增加 loop 预算、伪造 revision、机械改写 manifest 或重放历史图片 action 代替 Supervisor 决策和程序侧审计。
@@ -42,12 +42,12 @@
- ready-task 对账取消续跑:未知工具结果仍停在 `needs-reconciliation` 且禁止自动重放;人工核对后显式取消原 child,保留 cancel tombstone,旧 child 和旧父 Run 按真实终态收口。若随后创建同 Session、同 Supervisor source、同有效任务语义的 continuation,新完成合同只对同时具有历史 `failed / needs-reconciliation`、最终 `cancelled` 和 durable tombstone 的 ready-task,把当前 manifest 对应 failed 节点恢复为 pending,并由 scheduler 创建全新 child Run。manifest 的读取、failed 筛选、每任务一次的 child journal 索引、证据重验和写回必须位于同一项目写锁域;较新的无 child 根 Run 只有在 durable journal 精确表明为旧 failed Graph 在进入调度前即失败时才能跨过,scheduler 自身失败必须阻断借用更老 tombstone。普通失败、无 tombstone、不同 source/Session/任务语义或证据冲突均保持失败关闭;不得复活旧 pending action、补造 observation 或把取消任务标成 completed。
- 完成门静态分析预算:Canvas 视觉门必须先做只会提前拒绝的词法预检。经典或模块脚本同时不含大小写精确的 `import``export` 字节序列时,不运行模块依赖语义分析;纯 `export ... from` / `export * from` 仍须进入正式模块图分析。当前脚本不含目标文件名或任一已绑定 DOM 图片元素 ID 时,先低成本解码 `\\xNN``\\uNNNN``\\u{...}`、简单转义和续行;解码后仍无候选才不运行完整 Canvas alias / 函数可达性分析,解码不确定则保守进入 Oxc。存在任一候选时仍执行原 parser、semantic binding、解码后的 computed 属性/StringLiteral 路径、可达 `drawImage`、可见 Canvas、路径大小写和动态 namespace 写入门禁;HTML 中存在某个绑定元素不得使所有无关 JavaScript 单元进入重分析,禁止把词法命中当作通过条件。
- Provider 故障展示:Provider retry 的“是否可重试”继续使用 `upstream-5xx` 等稳定类别判断,但 durable retry record 保留安全的精确 `upstream-<HTTP status>` 身份。等待态必须从真实 record 显示 HTTP 状态、`nextAttempt/maxRetries` 与当前持久退避剩余秒数,例如“Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 8 秒后重试”;不得以动画或前端自增计时伪造 attempt。重试耗尽的 Runtime 私有错误只保存 `kind/httpStatus/fingerprint/chars/retryAttempt/maxRetries/retryState`,前端和持久 conversation 仅在字段顺序、范围、状态一致且无尾随正文时派生“上游服务返回 HTTP 503;自动重试已耗尽(3/3)”;其它错误使用固定安全摘要。Provider 响应正文、URL/query、凭据、本地绝对路径、fingerprint、字符数和 `[redacted ...]` 占位符均不得进入用户可见消息。
- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待唯一 `code-prototype` 主 Run 及其所有必要美术委派都已形成真实终态,再把本轮、主 Agent 进度、是否复用/补齐素材、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。父 run 先终态而 child 或 manifest 仍在 hydration 时不得以陈旧快照提前归档,要暂存终态 Runtime 并在状态刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。
- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待唯一 `code-prototype` 主 Run、该 main 的全部动态美术 child 与 manifest 中 `code-prototype` 都形成终态,且全链没有冲突或 `needs-reconciliation`,再把本轮、主 Agent 进度、是否复用/补齐素材、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。父 run 先终态而 child 或 manifest 仍在 hydration 时不得以陈旧快照提前归档;客户端以完整 `agent/session/run` 身份保留 root-scoped Runtime 快照并在状态刷新后重试,不能继续依赖会被新 run 覆盖的 current-by-agent map。页面初始 hydration 若直接读到缺少阶段记录的真实终态全链,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”使用稳定 message ID 最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。
- 图片成果:当前 manifest 新增或恢复已登记的 PNG / JPEG / WebP 资源时,聊天消息流同步显示 Runtime-owned “Supervisor 成果图片”卡,最多展示最新 4 张并随 manifest 原位更新。图片必须通过现有 `read_local_project_image_preview` 读取,只允许当前授权项目中 `assets/` 下的已登记资源,继续执行 `file.read` auto 权限、真实格式、大小、尺寸、普通文件、祖先目录和项目根边界校验;前端只接受返回路径、媒体类型和 `data:` 前缀与请求完全一致的结果。缩略图点击后使用独立模态查看器,支持按钮与滚轮缩放、指针拖拽、双击 / 按钮复位、Esc / 按钮 / 遮罩关闭,移动端占满视口;不得在聊天卡下方追加展开区。图片卡不写入 conversation,不解析 assistant 文本中的任意 Markdown / 绝对路径,也不开放 `.agent` 验收截图读取。
- Run 接管:External Runner 模式下首次提交可能返回“旧 canonical state + 新 `acceptedRunId`”;页面必须以 `acceptedRunId` 作为本轮权威身份,在 state 尚未切换时显示“已投递,正在同步 Agent Runner”,并允许该 run 的 Tauri event 或轮询结果接管。不得把旧 idle state 当作本轮结果、过滤新 run 事件,自动预览授权也必须绑定 `acceptedRunId`
- 运行容器:当前项目没有由 Tauri 客户端 `PreviewRegistry` 返回的有效 `running` 预览时,页面只渲染聊天,不显示游戏区域或占位文案,顶部运行状态必须明确显示“预览未启动”,不得再使用含义不明的“未启动”;预览运行后自动显示 iframe,桌面端按“游戏 2 / 聊天 1”分栏,移动端改为上下布局。预览停止、失败或切换项目后立即移除 iframe。运行容器继续只接受当前授权项目的 `http://127.0.0.1:*`,复用现有 CSP、iframe sandbox、autoplay、fullscreen 和 gamepad 约束;远程 URL、`file://`、手填地址或陈旧 manifest 状态均不得显示。
- 预览进程归属:External Runner 与 Tauri 客户端位于不同进程,双方 `PreviewRegistry`、server 子进程句柄和 running 状态严格进程内隔离;Runner 为 `preview.validate` 持有或回收的预览进程不能作为用户可见预览,也不能据此伪造 Tauri registry 的 running 状态。game-chat 用户可见 preview 必须由 Tauri 客户端启动、持有和停止,iframe 只使用同一 Tauri registry 返回的 loopback URL。
- 自动启动门禁:客户端只接受当前 accepted Supervisor 父 run 下真实 `preview-playtest` scheduler child 的结构化 `preview.validate` 事件,同 revision 采用最新事件且同时间失败优先;成功证据的 revision 必须精确等于当前项目原子 sidecar revision。客户端向 Tauri `preview.start` 传入 `expectedRevision`,后端在取得项目写锁后再次比对再启动,以闭合检查 / 启动 TOCTOU。same-run steer 的一次性授权使用带 revision / validation cursor 和唯一 generation ID 的 v2 记录,旧事件和旧异步 attempt 不得消费后续授权。旧 attempt 返回后的补偿清理按完整 preview identity 原子停止 registry server,不能让项目 stop policy 或写锁竞争造成后台 server 泄漏;若同项目新 server 已接管,则不能把其持久状态覆盖为 stopped。
- 自动启动门禁:客户端只接受当前 accepted、source-bound Supervisor 父 run 下唯一 `code-prototype` scheduler main 本人的证据序列:该 main 先成功执行 `game.static_smoke`,随后产生结构化 `preview.validate`,且 detail 同时满足 `passed=true``playtestPassed=true` 和正整数 revision。同 revision 采用最新事件且失败优先;错误 main/source/parent、旧固定 `preview-playtest` child、根 Supervisor、动态美术 child 和 smoke 之前的 preview 都不能建立可玩 revision。成功证据的 revision 必须精确等于当前项目原子 sidecar revision。客户端向 Tauri `preview.start` 传入 `expectedRevision`,后端在取得项目写锁后再次比对再启动,以闭合检查 / 启动 TOCTOU。same-run steer 的一次性授权使用带 revision / validation cursor 和唯一 generation ID 的 v2 记录,旧事件和旧异步 attempt 不得消费后续授权。旧 attempt 返回后的补偿清理按完整 preview identity 原子停止 registry server,不能让项目 stop policy 或写锁竞争造成后台 server 泄漏;若同项目新 server 已接管,则不能把其持久状态覆盖为 stopped。
- 固定试玩契约:`generic-v1` 初始状态必须为 `ready``level > 0`;点击 start 后 sequence 必须推进、phase 必须进入 `playing`,并先持续观察 2 秒、取得至少 8 个实际样本,期间保持 `playing`,以确认玩家获得正常操作机会。随后必须点击唯一可见、启用且真实可交互的 `data-playtest-id="primary-action"` 控件;该控件必须映射游戏的真实主要玩法操作,并以 sequence 相对点击前严格推进证明操作已被接受。玩家获得这次正常操作机会之前进入 `won | lost` 属于过早结束并失败;操作被接受后的单次 `lost` 是合法游戏结局,但不能成为所有受控尝试的唯一结果;若主要操作后仍为 `playing`,则继续观察 3 秒并取得至少 12 个实际样本,`won` 可提前证明非失败推进。点击 restart 后 sequence 必须再次推进并恢复到 `ready | playing`,随后持续观察 3 秒且取得至少 12 个实际样本。若首轮结果为 `lost`,重开稳定后必须再执行一次必要的 start、2 秒 / 8 样本操作机会和真实 primary-action;第二次必须进入或保持 `playing`(再观察 3 秒 / 12 样本且不得转为 `lost`)或进入 `won`,两次都固定 `lost` 代表无法正常推进的恶性 bug,必须失败。各观察窗口内 sequence 不得回退,restart 窗口只能保持 `ready | playing`;样本数门槛不能替代时长门槛,窗口末端必须强制再读取一次有效状态,不能只在前段快速取得足够样本后提前通过。控件 selector、观察时长、最少样本数、终态边界、非失败推进、末端覆盖、sequence 单调 / 严格推进规则及完整 required assertions 都进入 scenario fingerprint。读取旧 fingerprint 回执和检查 plan liveness 时,把合同升级造成的 fingerprint 不匹配视为 stale missing,允许同一 run 重新执行 `preview.validate` 自愈;身份、路径、digest 或内容完整性篡改仍失败关闭。最终完成门每次按当前合同重算 fingerprint,并严格拒绝旧 fingerprint、旧 assertion 集或仅保存历史 `passed=true` 的证据。
- 试玩证据展示:game-chat 的进度卡、可玩 revision 和自动预览只接受结构化 `preview.validate` detail 同时满足 `passed=true``playtestPassed=true`;工具 summary 中的 `ok` 不能作为兜底。`image.inspect``status=ok` 只代表工具执行成功,不代表视觉验收通过;结构化 `passed=null` 或缺少布尔结论时,UI 必须以中性“截图分析完成”展示,只有显式 `passed=true` 才能显示“截图检查通过”。
- 一次性自动预览授权:用户在该入口成功提交本轮自主生成需求,即视为对“当前项目 + 当前 Supervisor 父 run”的一次 `preview.start` 授权。授权以仅含项目路径与 accepted parent runId 的客户端本地记录持久化,App / WebView 重启后仍可恢复,但项目或 run 身份不匹配时不得使用。只有当前 accepted parent run 成功完成 `preview.validate` 且给出有效 revision 后,客户端才可消费授权,由 Tauri 首次启动并自动展示该 revision 的用户可见预览;一次授权最多成功启动一个 Tauri preview server,并必须继续走现有权限、项目写锁、审计和客户端 `PreviewRegistry` 链路。项目或 Agent 策略的显式 deny 始终优先,不得被此授权绕过。启动成功、显式 deny、非瞬时失败、父 run 在首版验证前终止或切换项目后授权失效;`preview.start` 恰逢项目写锁竞争属于瞬时失败,不消费授权,释放写锁后由同一轮询链路重试。
@@ -1428,6 +1428,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析
| owner 产物验证 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs` | M0-3 / M0A-2 让四个 pre-code fixed owner 复用 canonical 路径映射,由 Runtime 内部验固定产物;Provider 不见验证工具,code / preview 节点继续真实 smoke |
| approvedGddRef | 当前仓库无匹配实现 | M2 从 command 到 task/run/completion/context 全链新增 |
| game-chat retry 边界 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs:7-39,120-134``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs:740-772,885-905` | M0-4 / 工作包 M0B-1 统一 delegate/retry assets-only lineage |
| game-chat 前端投影 | `apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts``src/features/agent-runtime/model.ts``src/features/project-workspace/SupervisorChatOnlyView.tsx``src/App.tsx` | M0-4 / 工作包 M0B-2 只认 root → 单主 `code-prototype` → 动态美术 child 的 source-aware lineage;进度、final-reply、可玩 revision、retry 控件与阶段归档共用该身份边界 |
## 23. 分期门禁与完成定义
@@ -1450,6 +1451,8 @@ M0A-1 是 M1 详细设计与技术 spike 的输入,不是功能交付。M1 可
`M0B-1` 先封闭 game-chat 动态美术 delegate/retry 的安全边界:首次 `agent-delegate` 继续按严格 delivery/route/Canvas lineage 只写 `assets/**`;该类 child 的通用 retry 在入队前返回 `kind=game-chat-dynamic-art-retry-unsupported`,遗留或伪造的 `agent-delegate-retry` 只读且全部 mutation 失败关闭。恢复只走跨轮:用户继续 game-chat 对话,由下一轮 main `code-prototype` 重新审计并按仍存在的缺口创建新的首次委派;同一 main run 对同一 target 的第二次委派继续拒绝,新一轮以新 parent run、新 delegationId、targetRunId 与 delivery 全链恢复。`M0B-2` 再以 source-aware lineage 修复单主进度、最终回复、可玩 revision 与归档投影。M0-4 不阻塞 M1 策划闭环开工,但阻塞 M3 game-chat 接入和“M0 全部完成”。M0-1~M0-4 全部合入并通过各自门禁后,才能标记“M0 全部完成”。
2026-08-11 的 M0B-2 落地固定为 presentation-only:新增纯投影模块,结构身份严格限定为 `project-supervisor-game-chat` root → `agent-ready-task-scheduler` 的唯一 `code-prototype` main → 该 main 下 `agent-delegate | agent-delegate-retry``art-director | art-asset-plan`retry source 只作遗留诊断展示,不恢复通用 retry 控件。game-chat 进度只显示主阶段 `0/1``1/1`、失败或待核对;final-reply 除角色 allowlist 外还必须匹配精确 run 谱系;可玩 revision 只由当前 main 成功 smoke 后的结构化双视口试玩通过建立,同 revision 后续失败优先;阶段记录须等待 root、main、动态 child 和 manifest 主任务终态且无 reconciliation,并使用 root-scoped Runtime 快照与稳定 root-run message ID 幂等归档。该工作包不修改后端 DTO/schema/delivery/route,不迁移历史 manifest,也不实现 M1M3。
## 24. 最终不变量摘要
- 策划与构建是两个不同 profile/source 的 run,由用户动作连接,不由 Agent 自行升级。