diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index e00bc7ee5..f52858571 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -20,6 +20,7 @@ "agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat", "agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry", "agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-final-reply-transient-retry", + "agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill", "agent-runtime:steer-real-e2e": "node scripts/agent-runtime-steer-real-e2e.mjs", "agent-runtime:steer-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite steer-runner-kill", "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs" diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index ab7053aae..9de30a314 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -3,6 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { constants as fsConstants, createReadStream, + readFileSync, watch as watchFileSystem, } from 'node:fs'; import fs from 'node:fs/promises'; @@ -75,6 +76,18 @@ const supervisorSwarmFinalReplyTransientRetryAppDataSentinelFileName = '.agent-runtime-real-e2e-supervisor-swarm-final-reply-transient-retry-appdata.json'; const supervisorSwarmFinalReplyTransientRetryAppDataSentinelSchema = 'genarrative-agent-runtime-real-e2e-supervisor-swarm-final-reply-transient-retry-appdata.v1'; +const supervisorSwarmToolPlanHandoffRunnerKillAppDataSentinelFileName = + '.agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-appdata.json'; +const supervisorSwarmToolPlanHandoffRunnerKillAppDataSentinelSchema = + 'genarrative-agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-appdata.v1'; +const supervisorSwarmToolPlanHandoffCheckpointFileName = + '.agent-runtime-real-e2e-tool-plan-handoff-checkpoint.json'; +const supervisorSwarmToolPlanHandoffCheckpointSchema = + 'game-creator-tool-plan-handoff-runner-kill-checkpoint.v1'; +const supervisorSwarmToolPlanHandoffCheckpointReachedFileName = + '.agent-runtime-real-e2e-tool-plan-handoff-checkpoint-reached.json'; +const supervisorSwarmToolPlanHandoffCheckpointReachedSchema = + 'game-creator-tool-plan-handoff-runner-kill-checkpoint-reached.v1'; const supervisorSwarmAutonomousChatAppDataSentinelFileName = '.agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-appdata.json'; const supervisorSwarmAutonomousChatAppDataSentinelSchema = @@ -119,6 +132,11 @@ const supervisorSwarmSuite = 'supervisor-swarm'; const supervisorSwarmTransientRetrySuite = 'supervisor-swarm-transient-retry'; const supervisorSwarmFinalReplyTransientRetrySuite = 'supervisor-swarm-final-reply-transient-retry'; +const supervisorSwarmToolPlanHandoffRunnerKillSuite = + 'supervisor-swarm-tool-plan-handoff-runner-kill'; +const supervisorSwarmToolPlanHandoffRequestSlot = 'loop-1-repair-0'; +const supervisorSwarmToolPlanHandoffAckTimeoutMs = 360_000; +const toolPlanHandoffSchemaVersion = 'game-creator-tool-plan-handoff.v1'; const supervisorSwarmTransientRetryTargetAgentId = projectSupervisorAgentId; const supervisorSwarmTransientRetryBackoffMs = 30_000; const supervisorSwarmTransientRetryPreDueGuardMs = 1_500; @@ -601,6 +619,7 @@ const state = { blocked: [], errors: [], secrets: [], + transcriptOnlySecrets: [], transcriptLeakCount: 0, projectLeakCount: 0, reportLeakCount: 0, @@ -819,6 +838,21 @@ const state = { transientRetryToolPlanCountAtFault: 0, transientRetryToolPlanCountAfterRecovery: 0, transientRetryFinalReplyStreamCommitted: false, + toolPlanHandoffProxy: null, + toolPlanHandoffCheckpointControl: null, + toolPlanHandoffCheckpoint: null, + toolPlanHandoffOldRunnerBootId: null, + toolPlanHandoffNewRunnerBootId: null, + toolPlanHandoffRequestCountBeforeKill: 0, + toolPlanHandoffRequestCountAfterRestart: 0, + toolPlanHandoffNetworkReplayCount: 0, + toolPlanHandoffRuntimeIdentityStable: false, + toolPlanHandoffRequestIdentityStable: false, + toolPlanHandoffRecoveredFromPreviousBoot: false, + toolPlanHandoffControlRemovedBeforeResume: false, + toolPlanHandoffLifecycleClosedExactlyOnce: false, + toolPlanHandoffAuditIdempotent: false, + toolPlanHandoffRecoveredPlanFingerprintMatched: false, autonomousTaskRecipeFree: false, autonomousRepositoryRecipeFree: false, interactiveCliUsed: false, @@ -1342,6 +1376,28 @@ try { ); } } + if (supervisorSwarmToolPlanHandoffProxyNeedsCleanup()) { + try { + await state.supervisorSwarm.toolPlanHandoffProxy.stop(); + const proxyStats = + state.supervisorSwarm.toolPlanHandoffProxy.getStats(); + state.evidence.toolPlanHandoffProxyRequestCount = + proxyStats.requestCount; + state.evidence.toolPlanHandoffProxyForwardedRequestCount = + proxyStats.forwardedRequestCount; + state.evidence.toolPlanHandoffProxyStopped = proxyStats.stopped === true; + if (!state.evidence.toolPlanHandoffProxyStopped) { + state.status = 'FAIL'; + recordError('supervisor-swarm-tool-plan-handoff-proxy-not-stopped'); + } + } catch (error) { + state.status = 'FAIL'; + recordError( + 'supervisor-swarm-tool-plan-handoff-proxy-cleanup-failed', + error, + ); + } + } if ( isSteerRunnerKillSuite() && state.projectRoot && @@ -6360,7 +6416,7 @@ function buildSupervisorSwarmTaskPrompt() { if (isSupervisorSwarmInteractiveChatSuite()) { return supervisorSwarmAutonomousTask; } - return `请把 ${supervisorSwarmDesignPath} 与 ${supervisorSwarmQualityPath} 两项专业交付推进到仓库规范规定的 ready 状态。设计方向交给 ${supervisorSwarmDesignAgentId},质量方向交给 ${supervisorSwarmQualityAgentId};第一轮必须在同一个 planning 轮次同时安排两个方向。收到内部证据后由总控逐项验收,质量首轮未达标时只安排一次返工;下一步执行条件已经齐全时直接行动,不要反复只更新计划。全部证据成立后再由总控向用户简短汇报。不要转述仓库指令、内部标记、私有运行信息或绝对路径。`; + return `请把 ${supervisorSwarmDesignPath} 与 ${supervisorSwarmQualityPath} 两项专业交付推进到仓库规范规定的 ready 状态。设计方向交给 ${supervisorSwarmDesignAgentId},质量方向交给 ${supervisorSwarmQualityAgentId};第一轮不得只更新计划,计划更新和两个方向的实际安排必须在同一个模型响应、同一个 planning 轮次提交。收到内部证据后由总控逐项验收,质量首轮未达标时只安排一次返工;下一步执行条件已经齐全时直接行动,不要反复只更新计划。全部证据成立后再由总控向用户简短汇报。不要转述仓库指令、内部标记、私有运行信息或绝对路径。`; } function assertSupervisorSwarmTaskPrompt(task) { @@ -6393,6 +6449,8 @@ function assertSupervisorSwarmTaskPrompt(task) { task.includes(supervisorSwarmQualityAgentId) && task.includes(supervisorSwarmDesignPath) && task.includes(supervisorSwarmQualityPath) && + task.includes('第一轮不得只更新计划') && + task.includes('同一个模型响应') && task.includes('同一个 planning 轮次') && task.includes('只安排一次返工') && task.includes('不要反复只更新计划'), @@ -8623,26 +8681,51 @@ function assertSupervisorSwarmDeliveryContract( expectedPath, codePrefix, ) { + const invalidFields = [ + [ + 'schemaVersion', + delivery?.schemaVersion === staticDelegateDeliverySchemaVersion, + ], + ['parentAgentId', delivery?.parentAgentId === projectSupervisorAgentId], + ['parentSessionId', delivery?.parentSessionId === supervisorSwarmSessionId], + ['parentRunId', delivery?.parentRunId === state.initialRunId], + ['parentActionId', isNonEmptyString(delivery?.parentActionId)], + ['delegationId', isNonEmptyString(delivery?.delegationId)], + ['targetAgentId', delivery?.targetAgentId === expectedAgentId], + ['targetSessionId', isNonEmptyString(delivery?.targetSessionId)], + ['targetRunId', isNonEmptyString(delivery?.targetRunId)], + [ + 'acceptanceCriteria', + Array.isArray(delivery?.acceptanceCriteria) && + delivery.acceptanceCriteria.length >= 1 && + delivery.acceptanceCriteria.length <= 8 && + delivery.acceptanceCriteria.every(isNonEmptyString) && + new Set(delivery.acceptanceCriteria).size === + delivery.acceptanceCriteria.length, + ], + [ + 'expectedArtifacts', + JSON.stringify(delivery?.expectedArtifacts) === + JSON.stringify([expectedPath]), + ], + ] + .filter(([, valid]) => !valid) + .map(([field]) => field); assert( - delivery?.schemaVersion === staticDelegateDeliverySchemaVersion && - delivery.parentAgentId === projectSupervisorAgentId && - delivery.parentSessionId === supervisorSwarmSessionId && - delivery.parentRunId === state.initialRunId && - isNonEmptyString(delivery.parentActionId) && - isNonEmptyString(delivery.delegationId) && - delivery.targetAgentId === expectedAgentId && - isNonEmptyString(delivery.targetSessionId) && - isNonEmptyString(delivery.targetRunId) && - Array.isArray(delivery.acceptanceCriteria) && - delivery.acceptanceCriteria.length >= 1 && - delivery.acceptanceCriteria.length <= 8 && - delivery.acceptanceCriteria.every(isNonEmptyString) && - new Set(delivery.acceptanceCriteria).size === - delivery.acceptanceCriteria.length && - JSON.stringify(delivery.expectedArtifacts) === - JSON.stringify([expectedPath]) && - ['dispatched', 'ready', 'claimed-by-parent'].includes(delivery.status), - `${codePrefix}-delivery-contract-invalid`, + invalidFields.length === 0, + `${codePrefix}-delivery-contract-invalid:fields=${invalidFields.join(',')}`, + ); + const safeStatus = [ + 'dispatched', + 'ready', + 'claimed-by-parent', + 'suppressed', + ].includes(delivery?.status) + ? delivery.status + : 'unknown'; + assert( + ['dispatched', 'ready', 'claimed-by-parent'].includes(delivery?.status), + `${codePrefix}-delivery-status-invalid:status=${safeStatus}`, ); } @@ -9540,21 +9623,32 @@ function assertSupervisorSwarmRepairDelivery(repair, original) { isPlainObject(original), 'supervisor-swarm-repair-original-delivery-missing', ); + const invalidInheritedFields = [ + ['repairOfDelegationId', repair?.repairOfDelegationId === original.delegationId], + ['targetAgentId', repair?.targetAgentId === original.targetAgentId], + [ + 'acceptanceCriteria', + JSON.stringify(repair?.acceptanceCriteria) === + JSON.stringify(original.acceptanceCriteria), + ], + [ + 'expectedArtifacts', + JSON.stringify(repair?.expectedArtifacts) === + JSON.stringify(original.expectedArtifacts), + ], + ] + .filter(([, valid]) => !valid) + .map(([field]) => field); + assert( + invalidInheritedFields.length === 0, + `supervisor-swarm-repair-contract-not-inherited:fields=${invalidInheritedFields.join(',')}`, + ); assertSupervisorSwarmDeliveryContract( repair, supervisorSwarmQualityAgentId, supervisorSwarmQualityPath, 'supervisor-swarm-repair', ); - assert( - repair.repairOfDelegationId === original.delegationId && - repair.targetAgentId === original.targetAgentId && - JSON.stringify(repair.acceptanceCriteria) === - JSON.stringify(original.acceptanceCriteria) && - JSON.stringify(repair.expectedArtifacts) === - JSON.stringify(original.expectedArtifacts), - 'supervisor-swarm-repair-contract-not-inherited', - ); } function supervisorSwarmRepairProviderLifecycle(agentDb, repair) { @@ -10218,19 +10312,175 @@ async function driveSupervisorSwarmRuntimeToCompletion() { throw codedError('supervisor-swarm-runtime-timeout'); } +function rebuildSupervisorSwarmTranscriptScanner() { + const previousLeakCount = state.transcriptScanner?.count ?? 0; + state.transcriptScanner = new StreamingSecretScanner([ + ...new Set([...state.secrets, ...state.transcriptOnlySecrets]), + ]); + state.transcriptScanner.count = previousLeakCount; +} + +function registerSupervisorSwarmPrivateOutputValues(values) { + const normalized = [ + ...new Set(values.filter(isNonEmptyString).map((value) => value.trim())), + ]; + assert(normalized.length > 0, 'supervisor-swarm-private-transport-missing'); + state.supervisorSwarm.privateValues.push(...normalized); + state.transcriptOnlySecrets = [ + ...new Set([...state.transcriptOnlySecrets, ...normalized]), + ]; + rebuildSupervisorSwarmTranscriptScanner(); +} + function registerSupervisorSwarmPrivateTransportValues(values) { const normalized = [ ...new Set(values.filter(isNonEmptyString).map((value) => value.trim())), ]; assert(normalized.length > 0, 'supervisor-swarm-private-transport-missing'); state.supervisorSwarm.privateValues.push(...normalized); - const previousLeakCount = state.transcriptScanner?.count ?? 0; state.secrets = [...new Set([...state.secrets, ...normalized])]; - state.transcriptScanner = new StreamingSecretScanner(state.secrets); - state.transcriptScanner.count = previousLeakCount; + rebuildSupervisorSwarmTranscriptScanner(); +} + +function supervisorSwarmToolPlanHandoffProxyNeedsCleanup() { + return ( + isSupervisorSwarmToolPlanHandoffRunnerKillSuite() && + Boolean(state.supervisorSwarm.toolPlanHandoffProxy) + ); +} + +async function prepareSupervisorSwarmToolPlanHandoffCheckpoint() { + const loaded = await loadConfig(state.options.configDir); + const targetConfig = effectiveAgentLlmConfig( + loaded.config, + projectSupervisorAgentId, + ); + const upstream = new URL(targetConfig.baseUrl); + assert( + targetConfig.apiKind === 'openai_chat' && + isNonEmptyString(targetConfig.model) && + ['http:', 'https:'].includes(upstream.protocol) && + upstream.username === '' && + upstream.password === '' && + upstream.hash === '', + 'supervisor-swarm-tool-plan-handoff-upstream-invalid', + ); + const capability = `${randomUUID()}${randomUUID()}`.replaceAll('-', ''); + assert( + /^[a-f0-9]{64}$/u.test(capability), + 'supervisor-swarm-tool-plan-handoff-capability-invalid', + ); + registerSupervisorSwarmPrivateTransportValues([ + capability, + targetConfig.baseUrl, + upstream.origin, + upstream.host, + upstream.hostname, + ]); + const proxy = await startLlmTransientFaultProxy({ + upstreamBaseUrl: targetConfig.baseUrl, + faultCount: 0, + }); + state.supervisorSwarm.toolPlanHandoffProxy = proxy; + assert( + isNonEmptyString(proxy.baseUrl), + 'supervisor-swarm-tool-plan-handoff-proxy-base-url-missing', + ); + const proxyUrl = new URL(proxy.baseUrl); + registerSupervisorSwarmPrivateTransportValues([ + proxy.baseUrl, + proxyUrl.origin, + proxyUrl.host, + ]); + const configOverlay = { + llm: { + requestTimeoutMs: 300_000, + maxRetries: 3, + retryBackoffMs: 500, + }, + agentLlm: { + [projectSupervisorAgentId]: { baseUrl: proxy.baseUrl }, + }, + }; + await prepareIsolatedSuiteAppData({ configOverlay }); + const isolated = await loadConfig(state.isolatedRunner.appDataDir); + const isolatedTarget = effectiveAgentLlmConfig( + isolated.config, + projectSupervisorAgentId, + ); + assert( + isolatedTarget.baseUrl === proxy.baseUrl && + isolatedTarget.apiKey === targetConfig.apiKey && + isolatedTarget.model === targetConfig.model && + isolatedTarget.apiKind === targetConfig.apiKind && + isolatedTarget.requestTimeoutMs === 300_000 && + isolatedTarget.maxRetries === 3 && + isolatedTarget.retryBackoffMs === 500 && + state.isolatedRunner.configOverlayCreated, + 'supervisor-swarm-tool-plan-handoff-effective-overlay-invalid', + ); + + const createdAtMs = Date.now(); + const control = { + schemaVersion: supervisorSwarmToolPlanHandoffCheckpointSchema, + capability, + sentinelToken: state.isolatedRunner.ownerToken, + ownerPid: process.pid, + createdAtMs, + expiresAtMs: createdAtMs + 10 * 60_000, + projectRootSha256: hashValue(await fs.realpath(state.projectRoot)), + agentId: projectSupervisorAgentId, + runId: requestedRunId, + requestSlot: supervisorSwarmToolPlanHandoffRequestSlot, + }; + assert( + JSON.stringify(Object.keys(control).sort()) === + JSON.stringify( + [ + 'agentId', + 'capability', + 'createdAtMs', + 'expiresAtMs', + 'ownerPid', + 'projectRootSha256', + 'requestSlot', + 'runId', + 'schemaVersion', + 'sentinelToken', + ].sort(), + ), + 'supervisor-swarm-tool-plan-handoff-control-shape-invalid', + ); + const controlPath = path.join( + state.isolatedRunner.appDataDir, + supervisorSwarmToolPlanHandoffCheckpointFileName, + ); + await fs.writeFile(controlPath, `${JSON.stringify(control)}\n`, { + flag: 'wx', + mode: 0o600, + }); + const [metadata, persisted] = await Promise.all([ + fs.lstat(controlPath), + readJson(controlPath), + ]); + assert( + metadata.isFile() && + !metadata.isSymbolicLink() && + (process.platform === 'win32' || (metadata.mode & 0o077) === 0) && + JSON.stringify(persisted) === JSON.stringify(control), + 'supervisor-swarm-tool-plan-handoff-control-persistence-invalid', + ); + state.supervisorSwarm.toolPlanHandoffCheckpointControl = { + ...control, + capabilitySha256: hashValue(capability), + }; } async function prepareSupervisorSwarmRuntimeAppData() { + if (isSupervisorSwarmToolPlanHandoffRunnerKillSuite()) { + await prepareSupervisorSwarmToolPlanHandoffCheckpoint(); + return; + } if (!isSupervisorSwarmTransientRetrySuite()) { const configOverlay = { llm: { @@ -10762,6 +11012,559 @@ function assertSupervisorSwarmTransientRetrySideEffectsZero(sideEffects, code) { ); } +function truncateSupervisorSwarmRuntimeText(value, maxChars) { + const characters = [...String(value).trim()]; + return characters.length > maxChars + ? `${characters.slice(0, maxChars).join('')}…` + : characters.join(''); +} + +function sanitizeSupervisorSwarmPlanUpdateText(value, maxChars) { + const sensitiveLine = + /(?:-----begin.*private key|\.env|game-creator\.config|authorization:|cookie:|api_?key|api key|x-api-key|client_?secret|access_?token|refresh_?token|password[=:]|"password"|--password|--api-key|--apikey|--token|--secret|secret=|token=|"token"|bearer )/iu; + const sanitized = String(value) + .split(/\r?\n/u) + .map((line) => + sensitiveLine.test(line) ? '[redacted sensitive context]' : line, + ) + .join('\n'); + return truncateSupervisorSwarmRuntimeText(sanitized, maxChars); +} + +function normalizeSupervisorSwarmToolPlanUpdate(update) { + return { + explanation: sanitizeSupervisorSwarmPlanUpdateText( + update.explanation, + 240, + ), + steps: update.steps.map((step) => ({ + step: sanitizeSupervisorSwarmPlanUpdateText(step.step, 180), + status: String(step.status).trim(), + })), + }; +} + +function parseSupervisorSwarmToolPlanHandoffPlan(entry) { + const calls = entry?.response?.toolCalls; + assert( + Array.isArray(calls) && calls.length >= 2 && calls.length <= 3, + 'supervisor-swarm-tool-plan-handoff-call-count-invalid', + ); + const callIds = new Set(); + let planUpdate = null; + let thinkingSummary = null; + const actions = []; + for (const call of calls) { + assert( + isPlainObject(call) && + isNonEmptyString(call.id) && + !callIds.has(call.id) && + isNonEmptyString(call.name) && + isNonEmptyString(call.arguments), + 'supervisor-swarm-tool-plan-handoff-call-invalid', + ); + callIds.add(call.id); + let args; + try { + args = JSON.parse(call.arguments); + } catch (error) { + throw codedError( + 'supervisor-swarm-tool-plan-handoff-arguments-json-invalid', + error, + ); + } + assert( + isPlainObject(args), + 'supervisor-swarm-tool-plan-handoff-arguments-shape-invalid', + ); + if (call.name === 'update_agent_plan') { + assert( + planUpdate == null && + JSON.stringify(Object.keys(args).sort()) === + JSON.stringify(['explanation', 'steps']) && + isNonEmptyString(args.explanation) && + Array.isArray(args.steps) && + args.steps.length >= 1 && + args.steps.length <= 8 && + args.steps.every( + (step) => + isPlainObject(step) && + JSON.stringify(Object.keys(step).sort()) === + JSON.stringify(['status', 'step']) && + isNonEmptyString(step.step) && + ['pending', 'in_progress', 'completed'].includes( + String(step.status).trim(), + ), + ), + 'supervisor-swarm-tool-plan-handoff-plan-update-invalid', + ); + thinkingSummary = truncateSupervisorSwarmRuntimeText( + args.explanation, + 240, + ); + planUpdate = normalizeSupervisorSwarmToolPlanUpdate(args); + continue; + } + assert( + call.name === 'runtime_tool_agent_delegate' && + JSON.stringify(Object.keys(args).sort()) === + JSON.stringify(['input', 'reason']) && + isNonEmptyString(args.reason) && + isPlainObject(args.input), + 'supervisor-swarm-tool-plan-handoff-action-invalid', + ); + actions.push({ + tool: 'agent.delegate', + reason: args.reason, + input: args.input, + }); + } + assert( + actions.length === 2, + 'supervisor-swarm-tool-plan-handoff-delegate-count-invalid', + ); + return { + thinkingSummary: + thinkingSummary ?? + truncateSupervisorSwarmRuntimeText(actions[0].reason, 240), + planUpdate, + plan: [], + actions, + response: '', + }; +} + +async function readSupervisorSwarmToolPlanHandoffCheckpointEntry() { + const files = (await listFiles( + path.join(state.projectRoot, '.agent/runtime/tool-plan-handoffs'), + )).filter((file) => file.endsWith('.json')); + const matches = []; + for (const file of files) { + const [metadata, ledger] = await Promise.all([ + fs.lstat(file), + readJson(file), + ]); + if ( + ledger?.agentId !== projectSupervisorAgentId || + ledger?.runId !== state.initialRunId + ) { + continue; + } + assert( + metadata.isFile() && + !metadata.isSymbolicLink() && + (process.platform === 'win32' || (metadata.mode & 0o077) === 0) && + ledger.schemaVersion === toolPlanHandoffSchemaVersion && + Array.isArray(ledger.entries), + 'supervisor-swarm-tool-plan-handoff-ledger-invalid', + ); + for (const entry of ledger.entries) { + if ( + entry?.requestSlot === supervisorSwarmToolPlanHandoffRequestSlot && + entry?.identity?.baseRequestSlot === + supervisorSwarmToolPlanHandoffRequestSlot + ) { + matches.push({ entry, ledger }); + } + } + } + assert( + matches.length <= 1, + 'supervisor-swarm-tool-plan-handoff-entry-duplicate', + ); + return matches[0] ?? null; +} + +function validateSupervisorSwarmToolPlanHandoffEntry(entry) { + assert( + entry?.identity?.agentId === projectSupervisorAgentId && + isNonEmptyString(entry.identity.taskId) && + entry.identity.sessionId === supervisorSwarmSessionId && + entry.identity.runId === state.initialRunId && + entry.identity.requestKind === 'tool-plan' && + entry.identity.baseRequestSlot === + supervisorSwarmToolPlanHandoffRequestSlot && + /^[a-f0-9]{64}$/u.test(entry.identity.requestFingerprint ?? '') && + /^[a-f0-9]{64}$/u.test( + entry.identity.providerConfigFingerprint ?? '', + ) && + entry.requestSlot === supervisorSwarmToolPlanHandoffRequestSlot && + entry.attempt === 0 && + entry.loopIteration === 1 && + entry.repairAttempt === 0 && + isNonEmptyString(entry.providerRequestId) && + /^[a-f0-9]{64}$/u.test(entry.responseFingerprint ?? '') && + isPlainObject(entry.response), + 'supervisor-swarm-tool-plan-handoff-entry-identity-invalid', + ); + const plan = parseSupervisorSwarmToolPlanHandoffPlan(entry); + registerSupervisorSwarmPrivateOutputValues([ + entry.response?.responseId, + entry.response?.text, + ...(entry.response?.toolCalls ?? []).flatMap((call) => [ + call.id, + call.arguments, + ]), + ]); + return { plan, planFingerprint: hashJsonValue(plan) }; +} + +async function waitForSupervisorSwarmToolPlanHandoffCheckpointAck() { + const control = state.supervisorSwarm.toolPlanHandoffCheckpointControl; + assert( + isPlainObject(control), + 'supervisor-swarm-tool-plan-handoff-control-missing', + ); + const ackPath = path.join( + state.isolatedRunner.appDataDir, + supervisorSwarmToolPlanHandoffCheckpointReachedFileName, + ); + const deadline = + Date.now() + supervisorSwarmToolPlanHandoffAckTimeoutMs; + let pollCount = 0; + while (Date.now() < deadline) { + const metadata = await fs.lstat(ackPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + if (!metadata) { + pollCount += 1; + if (pollCount % 40 === 0) { + const runtime = await readRuntime(projectSupervisorAgentId).catch( + () => null, + ); + if ( + runtime?.phase === 'needs-reconciliation' || + runtime?.status === 'failed' + ) { + throw codedError( + 'supervisor-swarm-tool-plan-handoff-runtime-failed-before-ack', + ); + } + } + await sleep(25); + continue; + } + const bytes = await fs.readFile(ackPath); + let ack; + try { + ack = JSON.parse(bytes.toString('utf8')); + } catch (error) { + throw codedError( + 'supervisor-swarm-tool-plan-handoff-ack-json-invalid', + error, + ); + } + assert( + metadata.isFile() && + !metadata.isSymbolicLink() && + (process.platform === 'win32' || (metadata.mode & 0o077) === 0) && + JSON.stringify(Object.keys(ack).sort()) === + JSON.stringify( + [ + 'agentId', + 'capabilitySha256', + 'projectRootSha256', + 'providerRequestIdSha256', + 'reachedAtMs', + 'requestSlot', + 'responseFingerprint', + 'runId', + 'schemaVersion', + ].sort(), + ) && + ack.schemaVersion === + supervisorSwarmToolPlanHandoffCheckpointReachedSchema && + ack.capabilitySha256 === control.capabilitySha256 && + ack.projectRootSha256 === control.projectRootSha256 && + ack.agentId === control.agentId && + ack.runId === control.runId && + ack.requestSlot === control.requestSlot && + /^[a-f0-9]{64}$/u.test(ack.providerRequestIdSha256 ?? '') && + /^[a-f0-9]{64}$/u.test(ack.responseFingerprint ?? '') && + Number.isSafeInteger(ack.reachedAtMs) && + ack.reachedAtMs >= control.createdAtMs && + ack.reachedAtMs <= Date.now() && + !bytes.includes(Buffer.from(control.capability)), + 'supervisor-swarm-tool-plan-handoff-ack-invalid', + ); + return ack; + } + throw codedError('supervisor-swarm-tool-plan-handoff-ack-timeout'); +} + +async function captureSupervisorSwarmToolPlanHandoffRunnerKillCheckpoint() { + if (!isSupervisorSwarmToolPlanHandoffRunnerKillSuite()) return; + const proxy = state.supervisorSwarm.toolPlanHandoffProxy; + assert(proxy, 'supervisor-swarm-tool-plan-handoff-proxy-missing'); + const ack = await waitForSupervisorSwarmToolPlanHandoffCheckpointAck(); + const [handoff, persistence, pending, revision, batchFiles, designMetadata] = + await Promise.all([ + readSupervisorSwarmToolPlanHandoffCheckpointEntry(), + readSupervisorSwarmPersistence(), + findPendingActions(), + readSupervisorSwarmProjectRevision(), + listFiles( + path.join(state.projectRoot, '.agent/runtime/provider-action-batches'), + ), + fs + .lstat(path.join(state.projectRoot, supervisorSwarmDesignPath)) + .catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }), + ]); + assert( + handoff?.ledger?.entries?.length === 1, + 'supervisor-swarm-tool-plan-handoff-ledger-cardinality-invalid', + ); + const { entry } = handoff; + const { planFingerprint } = validateSupervisorSwarmToolPlanHandoffEntry(entry); + const lifecycle = persistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId, + ); + const targetLifecycle = lifecycle.filter( + (record) => record.requestId === entry.providerRequestId, + ); + const toolPlanAudits = persistence.agentDb.filter( + (record) => + ['agent.runtime.tool_plan.protocol', 'agent.runtime.tool_plan.repair'].includes( + record.recordType, + ) && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId, + ); + const targetTask = persistence.taskSnapshot.latest.find( + (task) => + task.agentId === projectSupervisorAgentId && + task.runId === state.initialRunId, + ); + const targetRuntime = persistence.runtimeStates.find( + (runtime) => + runtime.agentId === projectSupervisorAgentId && + runtime.runId === state.initialRunId, + ); + const target = { + agentId: projectSupervisorAgentId, + runId: state.initialRunId, + sessionId: supervisorSwarmSessionId, + delegationId: null, + }; + const sideEffects = supervisorSwarmTransientRetrySideEffects( + persistence, + pending, + revision, + designMetadata, + target, + ); + const stats = proxy.getStats(); + assertSupervisorSwarmTransientRetrySideEffectsZero( + sideEffects, + 'supervisor-swarm-tool-plan-handoff-pre-kill-side-effect', + ); + assert( + targetLifecycle.length === 1 && + targetLifecycle[0].status === 'started' && + targetLifecycle[0].requestKind === 'tool-plan' && + targetLifecycle[0].requestSlot === + supervisorSwarmToolPlanHandoffRequestSlot && + targetLifecycle[0].taskId === entry.identity.taskId && + targetLifecycle[0].sessionId === entry.identity.sessionId && + ack.providerRequestIdSha256 === hashValue(entry.providerRequestId) && + ack.responseFingerprint === entry.responseFingerprint && + toolPlanAudits.length === 0 && + batchFiles.length === 0 && + persistence.deliveries.length === 0 && + persistence.claims.length === 0 && + stats.requestCount === 1 && + stats.faultInjectedCount === 0 && + stats.heldRequestCount === 0 && + stats.forwardedRequestCount === 1 && + targetRuntime?.agentId === projectSupervisorAgentId && + targetRuntime.runId === state.initialRunId && + targetRuntime.sessionId === supervisorSwarmSessionId && + targetTask?.taskId === entry.identity.taskId, + 'supervisor-swarm-tool-plan-handoff-pre-kill-boundary-invalid', + ); + + const ownerBefore = await readSupervisorSwarmExecutionOwner(); + const currentRunner = await verifyOwnedRunnerForKill(); + assert( + ownerBefore.bootId === currentRunner.bootId && + ownerBefore.pid === currentRunner.pid, + 'supervisor-swarm-tool-plan-handoff-owner-before-kill-invalid', + ); + state.supervisorSwarm.toolPlanHandoffCheckpoint = { + ack, + requestId: entry.providerRequestId, + requestSlot: entry.requestSlot, + responseFingerprint: entry.responseFingerprint, + planFingerprint, + taskIdentity: supervisorSwarmTaskIdentity(targetTask), + runtimeIdentity: { + agentId: targetRuntime.agentId, + taskId: targetRuntime.taskId, + sessionId: targetRuntime.sessionId, + runId: targetRuntime.runId, + source: targetRuntime.source, + }, + actionCount: sideEffects.actionCount, + receiptCount: sideEffects.receiptCount, + deliveryCount: sideEffects.childDeliveryCount, + claimCount: sideEffects.claimCount, + pendingCount: sideEffects.pendingCount, + projectRevision: sideEffects.projectRevision, + assistantCount: sideEffects.assistantCount, + }; + state.supervisorSwarm.toolPlanHandoffOldRunnerBootId = currentRunner.bootId; + state.supervisorSwarm.toolPlanHandoffRequestCountBeforeKill = + stats.requestCount; + + await killRunnerOnce(); + const controlPath = path.join( + state.isolatedRunner.appDataDir, + supervisorSwarmToolPlanHandoffCheckpointFileName, + ); + await fs.rm(controlPath, { force: false }); + const removedControl = await fs.lstat(controlPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }); + assert( + removedControl == null, + 'supervisor-swarm-tool-plan-handoff-control-removal-failed', + ); + state.supervisorSwarm.toolPlanHandoffControlRemovedBeforeResume = true; + await runCli(['--agent-resume', state.projectRoot], { timeoutMs: 120_000 }); + state.resumed = true; + const restarted = await waitForRunnerBootChange(currentRunner.bootId); + const claimed = await claimOwnedRunner(restarted); + const ownerAfter = await readSupervisorSwarmExecutionOwner(claimed.bootId); + state.supervisorSwarm.toolPlanHandoffNewRunnerBootId = claimed.bootId; + state.supervisorSwarm.toolPlanHandoffRecoveredFromPreviousBoot = + ownerAfter.bootId === claimed.bootId && + ownerAfter.recoveredFromBootId === currentRunner.bootId; + assert( + claimed.bootId !== currentRunner.bootId && + state.supervisorSwarm.toolPlanHandoffRecoveredFromPreviousBoot, + 'supervisor-swarm-tool-plan-handoff-owner-recovery-invalid', + ); + + state.supervisorSwarm.toolPlanHandoffRequestCountAfterRestart = + proxy.getStats().requestCount; + state.supervisorSwarm.toolPlanHandoffNetworkReplayCount = Math.max( + 0, + state.supervisorSwarm.toolPlanHandoffRequestCountAfterRestart - + state.supervisorSwarm.toolPlanHandoffRequestCountBeforeKill, + ); + assert( + state.supervisorSwarm.toolPlanHandoffNetworkReplayCount === 0, + 'supervisor-swarm-tool-plan-handoff-network-replayed-after-restart', + ); + + const batchPath = supervisorSwarmInitialProviderBatchPath(); + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const [batch, recoveredPersistence, recoveredHandoff] = await Promise.all([ + readJson(batchPath).catch((error) => { + if (error?.code === 'ENOENT') return null; + throw error; + }), + readSupervisorSwarmPersistence(), + readSupervisorSwarmToolPlanHandoffCheckpointEntry(), + ]); + if (!batch || !recoveredHandoff) { + await sleep(25); + continue; + } + const recoveredLifecycle = recoveredPersistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.provider_request.lifecycle' && + record.requestId === entry.providerRequestId, + ); + const protocols = recoveredPersistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.tool_plan.protocol' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.loopIteration === 1 && + record.repairAttempt === 0 && + record.requestSlot === supervisorSwarmToolPlanHandoffRequestSlot, + ); + const repairs = recoveredPersistence.agentDb.filter( + (record) => + record.recordType === 'agent.runtime.tool_plan.repair' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.loopIteration === 1, + ); + const recoveredTask = recoveredPersistence.taskSnapshot.latest.find( + (task) => + task.agentId === projectSupervisorAgentId && + task.runId === state.initialRunId, + ); + const recoveredRuntime = recoveredPersistence.runtimeStates.find( + (runtime) => + runtime.agentId === projectSupervisorAgentId && + runtime.runId === state.initialRunId, + ); + if (recoveredLifecycle.length < 2 || protocols.length < 1) { + await sleep(25); + continue; + } + state.supervisorSwarm.toolPlanHandoffLifecycleClosedExactlyOnce = + recoveredLifecycle.length === 2 && + recoveredLifecycle[0].status === 'started' && + recoveredLifecycle[1].status === 'completed' && + recoveredPersistence.agentDb.indexOf(recoveredLifecycle[0]) < + recoveredPersistence.agentDb.indexOf(recoveredLifecycle[1]); + state.supervisorSwarm.toolPlanHandoffAuditIdempotent = + protocols.length === 1 && + repairs.length === 0 && + protocols[0].responseFingerprint === entry.responseFingerprint && + protocols[0].providerRequestIdSha256 === + hashValue(entry.providerRequestId); + state.supervisorSwarm.toolPlanHandoffRecoveredPlanFingerprintMatched = + hashJsonValue(batch.plan) === planFingerprint; + state.supervisorSwarm.toolPlanHandoffRuntimeIdentityStable = + JSON.stringify(supervisorSwarmTaskIdentity(recoveredTask)) === + JSON.stringify(supervisorSwarmTaskIdentity(targetTask)) && + recoveredRuntime?.agentId === targetRuntime.agentId && + recoveredRuntime?.taskId === targetRuntime.taskId && + recoveredRuntime?.sessionId === targetRuntime.sessionId && + recoveredRuntime?.runId === targetRuntime.runId && + recoveredRuntime?.source === targetRuntime.source; + state.supervisorSwarm.toolPlanHandoffRequestIdentityStable = + recoveredHandoff.entry.providerRequestId === entry.providerRequestId && + recoveredHandoff.entry.requestSlot === entry.requestSlot && + recoveredHandoff.entry.responseFingerprint === + entry.responseFingerprint && + JSON.stringify(recoveredHandoff.entry.identity) === + JSON.stringify(entry.identity); + state.supervisorSwarm.toolPlanHandoffRequestCountAfterRestart = + proxy.getStats().requestCount; + state.supervisorSwarm.toolPlanHandoffNetworkReplayCount = Math.max( + 0, + state.supervisorSwarm.toolPlanHandoffRequestCountAfterRestart - + state.supervisorSwarm.toolPlanHandoffRequestCountBeforeKill, + ); + assert( + state.supervisorSwarm.toolPlanHandoffLifecycleClosedExactlyOnce && + state.supervisorSwarm.toolPlanHandoffAuditIdempotent && + state.supervisorSwarm.toolPlanHandoffRecoveredPlanFingerprintMatched && + state.supervisorSwarm.toolPlanHandoffRuntimeIdentityStable && + state.supervisorSwarm.toolPlanHandoffRequestIdentityStable && + state.supervisorSwarm.toolPlanHandoffNetworkReplayCount === 0, + 'supervisor-swarm-tool-plan-handoff-recovery-invalid', + ); + return; + } + throw codedError('supervisor-swarm-tool-plan-handoff-recovery-timeout'); +} + async function captureSupervisorSwarmTransientRetryCheckpoint() { if (!isSupervisorSwarmTransientRetrySuite()) return; const proxy = state.supervisorSwarm.transientFaultProxy; @@ -11349,8 +12152,8 @@ async function runSupervisorSwarmE2e() { chars: [...task].length, sha256: hashValue(task), }; - state.isolatedRunner.launchAttempted = true; if (isSupervisorSwarmInteractiveChatSuite()) { + state.isolatedRunner.launchAttempted = true; supervisorSwarmCliSession = startInteractiveCli([ '--swarm-chat', '--init', @@ -11383,6 +12186,7 @@ async function runSupervisorSwarmE2e() { ], { timeoutMs: 120_000 }, ); + state.isolatedRunner.launchAttempted = true; } await claimOwnedRunner(); const runtime = await readRuntime(projectSupervisorAgentId); @@ -11396,6 +12200,9 @@ async function runSupervisorSwarmE2e() { if (isSupervisorSwarmInitialTransientRetrySuite()) { await captureSupervisorSwarmTransientRetryCheckpoint(); } + if (isSupervisorSwarmToolPlanHandoffRunnerKillSuite()) { + await captureSupervisorSwarmToolPlanHandoffRunnerKillCheckpoint(); + } await captureSupervisorSwarmInitialProviderBatch(); await captureSupervisorSwarmCollaborationPolicySnapshotAndDrift(); await restartSupervisorSwarmRunnerAtInitialBatchBoundary(); @@ -13077,6 +13884,99 @@ function supervisorSwarmTransientRetryEvidence(provider) { return evidence; } +function supervisorSwarmToolPlanHandoffSnapshotEvidence(persistence = null) { + const enabled = isSupervisorSwarmToolPlanHandoffRunnerKillSuite(); + const checkpoint = state.supervisorSwarm.toolPlanHandoffCheckpoint; + const control = state.supervisorSwarm.toolPlanHandoffCheckpointControl; + const proxy = state.supervisorSwarm.toolPlanHandoffProxy; + const stats = proxy?.getStats() ?? { + requestCount: 0, + forwardedRequestCount: 0, + stopped: false, + }; + const capabilityLeakCount = + persistence && isNonEmptyString(control?.capability) + ? countExactSecrets(Buffer.from(JSON.stringify(persistence)), [ + control.capability, + ]) + : 0; + return { + toolPlanHandoffModeEnabled: enabled, + toolPlanHandoffCheckpointCaptured: Boolean(checkpoint), + toolPlanHandoffProxyRequestCount: stats.requestCount, + toolPlanHandoffProxyForwardedRequestCount: stats.forwardedRequestCount, + toolPlanHandoffProxyStopped: stats.stopped === true, + toolPlanHandoffRequestCountBeforeKill: + state.supervisorSwarm.toolPlanHandoffRequestCountBeforeKill, + toolPlanHandoffRequestCountAfterRestart: + state.supervisorSwarm.toolPlanHandoffRequestCountAfterRestart, + toolPlanHandoffNetworkReplayCount: + state.supervisorSwarm.toolPlanHandoffNetworkReplayCount, + toolPlanHandoffRunnerBootChanged: Boolean( + state.supervisorSwarm.toolPlanHandoffOldRunnerBootId && + state.supervisorSwarm.toolPlanHandoffNewRunnerBootId && + state.supervisorSwarm.toolPlanHandoffOldRunnerBootId !== + state.supervisorSwarm.toolPlanHandoffNewRunnerBootId, + ), + toolPlanHandoffRecoveredFromPreviousBoot: + state.supervisorSwarm.toolPlanHandoffRecoveredFromPreviousBoot, + toolPlanHandoffControlRemovedBeforeResume: + state.supervisorSwarm.toolPlanHandoffControlRemovedBeforeResume, + toolPlanHandoffRuntimeIdentityStable: + state.supervisorSwarm.toolPlanHandoffRuntimeIdentityStable, + toolPlanHandoffRequestIdentityStable: + state.supervisorSwarm.toolPlanHandoffRequestIdentityStable, + toolPlanHandoffLifecycleClosedExactlyOnce: + state.supervisorSwarm.toolPlanHandoffLifecycleClosedExactlyOnce, + toolPlanHandoffAuditIdempotent: + state.supervisorSwarm.toolPlanHandoffAuditIdempotent, + toolPlanHandoffRecoveredPlanFingerprintMatched: + state.supervisorSwarm.toolPlanHandoffRecoveredPlanFingerprintMatched, + toolPlanHandoffPreRecoveryActionCount: checkpoint?.actionCount ?? 0, + toolPlanHandoffPreRecoveryReceiptCount: checkpoint?.receiptCount ?? 0, + toolPlanHandoffPreRecoveryDeliveryCount: checkpoint?.deliveryCount ?? 0, + toolPlanHandoffPreRecoveryClaimCount: checkpoint?.claimCount ?? 0, + toolPlanHandoffPreRecoveryPendingCount: checkpoint?.pendingCount ?? 0, + toolPlanHandoffPreRecoveryProjectRevision: + checkpoint?.projectRevision ?? 0, + toolPlanHandoffPreRecoveryAssistantCount: + checkpoint?.assistantCount ?? 0, + toolPlanHandoffCapabilityLeakCount: capabilityLeakCount, + toolPlanHandoffPrePersistUnknownResultWindowCovered: false, + }; +} + +function supervisorSwarmToolPlanHandoffEvidence(persistence) { + const evidence = supervisorSwarmToolPlanHandoffSnapshotEvidence(persistence); + if (evidence.toolPlanHandoffModeEnabled) { + assert( + evidence.toolPlanHandoffCheckpointCaptured && + evidence.toolPlanHandoffRequestCountBeforeKill === 1 && + evidence.toolPlanHandoffRequestCountAfterRestart === 1 && + evidence.toolPlanHandoffNetworkReplayCount === 0 && + evidence.toolPlanHandoffRunnerBootChanged && + evidence.toolPlanHandoffRecoveredFromPreviousBoot && + evidence.toolPlanHandoffControlRemovedBeforeResume && + evidence.toolPlanHandoffRuntimeIdentityStable && + evidence.toolPlanHandoffRequestIdentityStable && + evidence.toolPlanHandoffLifecycleClosedExactlyOnce && + evidence.toolPlanHandoffAuditIdempotent && + evidence.toolPlanHandoffRecoveredPlanFingerprintMatched && + evidence.toolPlanHandoffPreRecoveryActionCount === 0 && + evidence.toolPlanHandoffPreRecoveryReceiptCount === 0 && + evidence.toolPlanHandoffPreRecoveryDeliveryCount === 0 && + evidence.toolPlanHandoffPreRecoveryClaimCount === 0 && + evidence.toolPlanHandoffPreRecoveryPendingCount === 0 && + evidence.toolPlanHandoffPreRecoveryProjectRevision === 0 && + evidence.toolPlanHandoffPreRecoveryAssistantCount === 0 && + evidence.toolPlanHandoffCapabilityLeakCount === 0 && + evidence.toolPlanHandoffPrePersistUnknownResultWindowCovered === false, + 'supervisor-swarm-tool-plan-handoff-evidence-invalid', + ); + } + return evidence; +} + async function validateSupervisorSwarmEvidence() { const persistence = await readSupervisorSwarmPersistence(); assertSupervisorSwarmRuntimeHealthy(persistence); @@ -13507,6 +14407,8 @@ async function validateSupervisorSwarmEvidence() { mixedState?.instances ?? [], ); const transientRetry = supervisorSwarmTransientRetryEvidence(provider); + const toolPlanHandoff = + supervisorSwarmToolPlanHandoffEvidence(persistence); const actions = validateSupervisorSwarmActionPersistence( persistence.agentDb, deliveries, @@ -14388,6 +15290,7 @@ async function validateSupervisorSwarmEvidence() { providerRetryCount: provider.retryCount, providerRetryPathTriggered: provider.retryCount > 0, ...transientRetry, + ...toolPlanHandoff, toolPlanProviderRequestCount: provider.toolPlanCount, finalReplyProviderRequestCount: provider.finalReplyCount, contextCompactionProviderRequestCount: provider.contextCompactionCount, @@ -14542,6 +15445,20 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { const repairDeliveryIds = new Set( repairs.map((delivery) => delivery.delegationId), ); + const weakQualityDelivery = initial.find( + (delivery) => delivery.targetAgentId === supervisorSwarmQualityAgentId, + ); + const observedRepairDelivery = repairs[0] ?? null; + const observedRepairStatus = [ + 'dispatched', + 'ready', + 'claimed-by-parent', + 'suppressed', + ].includes(observedRepairDelivery?.status) + ? observedRepairDelivery.status + : observedRepairDelivery == null + ? 'absent' + : 'unknown'; const isolatedRecords = supervisorSwarmParentIsolatedRecords(persistence); const isolatedJoinClaims = (persistence.isolatedJoinClaims ?? []).filter( (claim) => @@ -15381,6 +16298,26 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { ), initialDeliveryCount: initial.length, repairDeliveryCount: repairs.length, + repairDeliveryStatus: observedRepairStatus, + repairReferencesWeakDelivery: + observedRepairDelivery != null && + weakQualityDelivery != null && + observedRepairDelivery.repairOfDelegationId === + weakQualityDelivery.delegationId, + repairTargetMatchesWeakDelivery: + observedRepairDelivery != null && + weakQualityDelivery != null && + observedRepairDelivery.targetAgentId === weakQualityDelivery.targetAgentId, + repairCriteriaMatchWeakDelivery: + observedRepairDelivery != null && + weakQualityDelivery != null && + JSON.stringify(observedRepairDelivery.acceptanceCriteria) === + JSON.stringify(weakQualityDelivery.acceptanceCriteria), + repairArtifactsMatchWeakDelivery: + observedRepairDelivery != null && + weakQualityDelivery != null && + JSON.stringify(observedRepairDelivery.expectedArtifacts) === + JSON.stringify(weakQualityDelivery.expectedArtifacts), totalDeliveryCount: deliveries.length, observedClaimCount: persistence.claims.filter( (claim) => claim.status === 'observed', @@ -15602,6 +16539,7 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { ...publicLeakEvidence, failureEvidenceErrors: persistence.failureEvidenceErrors, ...supervisorSwarmTransientRetrySnapshotEvidence(false), + ...supervisorSwarmToolPlanHandoffSnapshotEvidence(persistence), paths: [ '.agent/runtime/delegation-deliveries', '.agent/runtime/delegation-claims', @@ -15696,6 +16634,7 @@ function parseArguments(args) { suite === supervisorSwarmSuite || suite === supervisorSwarmTransientRetrySuite || suite === supervisorSwarmFinalReplyTransientRetrySuite || + suite === supervisorSwarmToolPlanHandoffRunnerKillSuite || suite === supervisorSwarmAutonomousChatSuite || suite === supervisorSwarmStaticIsolatedAutonomousChatSuite || suite === supervisorSwarmCollaborationPolicyMixedRecoverySuite || @@ -15844,6 +16783,7 @@ function sameEffectiveAgentLlm(left, right) { function isolatedSuiteProtectsSourceAppData() { return ( isSupervisorSwarmTransientRetrySuite() || + isSupervisorSwarmToolPlanHandoffRunnerKillSuite() || isSupervisorSwarmInteractiveChatSuite() ); } @@ -15936,6 +16876,18 @@ function isolatedSuiteAppDataProfile() { codePrefix: 'supervisor-swarm-final-reply-transient-retry-appdata', }; } + if (isSupervisorSwarmToolPlanHandoffRunnerKillSuite()) { + return { + prefix: + '.agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-', + sentinelName: + supervisorSwarmToolPlanHandoffRunnerKillAppDataSentinelFileName, + sentinelSchema: + supervisorSwarmToolPlanHandoffRunnerKillAppDataSentinelSchema, + codePrefix: + 'supervisor-swarm-tool-plan-handoff-runner-kill-appdata', + }; + } if (isSupervisorSwarmInitialTransientRetrySuite()) { return { prefix: '.agent-runtime-real-e2e-supervisor-swarm-transient-retry-', @@ -16419,7 +17371,7 @@ async function prepareIsolatedSuiteAppData({ const previousLeakCount = state.transcriptScanner?.count ?? 0; state.secrets = [...suiteSecrets]; - state.transcriptScanner = new StreamingSecretScanner(state.secrets); + rebuildSupervisorSwarmTranscriptScanner(); state.transcriptScanner.count = previousLeakCount; const unexpectedEndpoint = await fs .lstat(path.join(appDataDir, runnerEndpointFileName)) @@ -16566,7 +17518,9 @@ async function prepareIsolatedSuiteAppData({ assert( requiredEffectiveConfigs.every( ([agentId, effective]) => - effective.model === 'gpt-5.5' && + (isSupervisorSwarmToolPlanHandoffRunnerKillSuite() + ? isNonEmptyString(effective.model) + : effective.model === 'gpt-5.5') && effective.apiKind === 'openai_chat' && isNonEmptyString(effective.reasoningEffort) && Number.isSafeInteger(effective.requestTimeoutMs) && @@ -16587,7 +17541,9 @@ async function prepareIsolatedSuiteAppData({ effective[key].trim().length > 0, ), ), - 'supervisor-swarm-effective-openai-chat-gpt-5-5-config-invalid', + isSupervisorSwarmToolPlanHandoffRunnerKillSuite() + ? 'supervisor-swarm-effective-openai-chat-configured-model-invalid' + : 'supervisor-swarm-effective-openai-chat-gpt-5-5-config-invalid', ); const globalEffective = effectiveAgentLlmConfig( { llm: isolatedConfig.config.llm }, @@ -27997,6 +28953,11 @@ function supervisorSwarmEvidenceFieldTemplate() { mixedParentIdentityStable: false, initialDeliveryCount: 0, repairDeliveryCount: 0, + repairDeliveryStatus: 'absent', + repairReferencesWeakDelivery: false, + repairTargetMatchesWeakDelivery: false, + repairCriteriaMatchWeakDelivery: false, + repairArtifactsMatchWeakDelivery: false, totalDeliveryCount: 0, observedClaimCount: 0, initialClaimReceiptCount: 0, @@ -28114,6 +29075,31 @@ function supervisorSwarmEvidenceFieldTemplate() { transientFaultForwardingReleased: false, transientFaultRetryVerified: false, transientFaultProxyStopped: false, + toolPlanHandoffModeEnabled: false, + toolPlanHandoffCheckpointCaptured: false, + toolPlanHandoffProxyRequestCount: 0, + toolPlanHandoffProxyForwardedRequestCount: 0, + toolPlanHandoffProxyStopped: false, + toolPlanHandoffRequestCountBeforeKill: 0, + toolPlanHandoffRequestCountAfterRestart: 0, + toolPlanHandoffNetworkReplayCount: 0, + toolPlanHandoffRunnerBootChanged: false, + toolPlanHandoffRecoveredFromPreviousBoot: false, + toolPlanHandoffControlRemovedBeforeResume: false, + toolPlanHandoffRuntimeIdentityStable: false, + toolPlanHandoffRequestIdentityStable: false, + toolPlanHandoffLifecycleClosedExactlyOnce: false, + toolPlanHandoffAuditIdempotent: false, + toolPlanHandoffRecoveredPlanFingerprintMatched: false, + toolPlanHandoffPreRecoveryActionCount: 0, + toolPlanHandoffPreRecoveryReceiptCount: 0, + toolPlanHandoffPreRecoveryDeliveryCount: 0, + toolPlanHandoffPreRecoveryClaimCount: 0, + toolPlanHandoffPreRecoveryPendingCount: 0, + toolPlanHandoffPreRecoveryProjectRevision: 0, + toolPlanHandoffPreRecoveryAssistantCount: 0, + toolPlanHandoffCapabilityLeakCount: 0, + toolPlanHandoffPrePersistUnknownResultWindowCovered: false, toolPlanProviderRequestCount: 0, finalReplyProviderRequestCount: 0, contextCompactionProviderRequestCount: 0, @@ -29198,6 +30184,7 @@ function isSupervisorSwarmSuite() { return ( state.suite === supervisorSwarmSuite || isSupervisorSwarmTransientRetrySuite() || + isSupervisorSwarmToolPlanHandoffRunnerKillSuite() || isSupervisorSwarmInteractiveChatSuite() ); } @@ -29217,6 +30204,10 @@ function isSupervisorSwarmFinalReplyTransientRetrySuite() { return state.suite === supervisorSwarmFinalReplyTransientRetrySuite; } +function isSupervisorSwarmToolPlanHandoffRunnerKillSuite() { + return state.suite === supervisorSwarmToolPlanHandoffRunnerKillSuite; +} + function isSupervisorSwarmAutonomousChatSuite() { return state.suite === supervisorSwarmAutonomousChatSuite; } @@ -32072,6 +33063,136 @@ function runAgentRuntimeRealE2eSelfTests() { 'agent-runtime-real-e2e-self-test-source-endpoint-lifecycle-guard-invalid', ); + const previousSuiteForToolPlanHandoff = state.suite; + state.suite = supervisorSwarmToolPlanHandoffRunnerKillSuite; + const toolPlanHandoffProfile = isolatedSuiteAppDataProfile(); + const toolPlanHandoffParsedArguments = parseArguments([ + '--config-dir', + path.resolve('synthetic-tool-plan-handoff-config'), + '--suite', + supervisorSwarmToolPlanHandoffRunnerKillSuite, + ]); + const rootPackage = JSON.parse( + readFileSync(path.join(repoRoot, 'package.json'), 'utf8'), + ); + const shellPackage = JSON.parse( + readFileSync(path.join(appRoot, 'package.json'), 'utf8'), + ); + const rootToolPlanHandoffCommand = + 'npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e --'; + const shellToolPlanHandoffCommand = + 'node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill'; + const originalToolPlanHandoffProxy = + state.supervisorSwarm.toolPlanHandoffProxy; + state.supervisorSwarm.toolPlanHandoffProxy = {}; + const toolPlanHandoffCleanupRegistered = + supervisorSwarmToolPlanHandoffProxyNeedsCleanup(); + state.supervisorSwarm.toolPlanHandoffProxy = + originalToolPlanHandoffProxy; + const toolPlanHandoffPartialEvidence = + supervisorSwarmToolPlanHandoffSnapshotEvidence(); + const toolPlanHandoffPackageCommandsRegistered = + rootPackage.scripts?.[ + 'ai-game-creator-shell:agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e' + ] === rootToolPlanHandoffCommand && + shellPackage.scripts?.[ + 'agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e' + ] === shellToolPlanHandoffCommand; + const toolPlanHandoffSourceGuardRegistered = + sourceAppDataDirectoryEventIsViolation( + `${toolPlanHandoffProfile.prefix}synthetic`, + toolPlanHandoffProfile.prefix, + { exists: false, fingerprint: null }, + ); + const toolPlanHandoffSuiteRegistered = + toolPlanHandoffParsedArguments.suite === + supervisorSwarmToolPlanHandoffRunnerKillSuite && + isSupervisorSwarmToolPlanHandoffRunnerKillSuite() && + isSupervisorSwarmSuite() && + isIsolatedRunnerSuite() && + isolatedSuiteProtectsSourceAppData() && + toolPlanHandoffProfile.prefix === + '.agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-' && + toolPlanHandoffProfile.codePrefix === + 'supervisor-swarm-tool-plan-handoff-runner-kill-appdata' && + toolPlanHandoffProfile.sentinelName === + supervisorSwarmToolPlanHandoffRunnerKillAppDataSentinelFileName && + toolPlanHandoffProfile.sentinelSchema === + supervisorSwarmToolPlanHandoffRunnerKillAppDataSentinelSchema && + toolPlanHandoffSourceGuardRegistered && + toolPlanHandoffPackageCommandsRegistered && + toolPlanHandoffCleanupRegistered && + toolPlanHandoffPartialEvidence.toolPlanHandoffModeEnabled === true && + Object.hasOwn( + toolPlanHandoffPartialEvidence, + 'toolPlanHandoffNetworkReplayCount', + ); + state.suite = previousSuiteForToolPlanHandoff; + const syntheticToolPlanHandoffEntry = { + response: { + toolCalls: [ + { + id: 'synthetic-plan-call', + name: 'update_agent_plan', + arguments: JSON.stringify({ + explanation: 'synthetic plan', + steps: [{ step: 'delegate', status: 'in_progress' }], + }), + }, + ...['design', 'quality'].map((agentId) => ({ + id: `synthetic-${agentId}-call`, + name: 'runtime_tool_agent_delegate', + arguments: JSON.stringify({ + reason: `delegate ${agentId}`, + input: { agentId }, + }), + })), + ], + }, + }; + const syntheticToolPlanHandoffPlan = + parseSupervisorSwarmToolPlanHandoffPlan(syntheticToolPlanHandoffEntry); + const syntheticNormalizedPlanUpdate = + normalizeSupervisorSwarmToolPlanUpdate({ + explanation: ` ${'x'.repeat(241)} `, + steps: [{ step: ` ${'y'.repeat(181)} `, status: ' pending ' }], + }); + const toolPlanHandoffPlanProjectionValidated = + syntheticToolPlanHandoffPlan.thinkingSummary === 'synthetic plan' && + syntheticToolPlanHandoffPlan.planUpdate?.steps?.length === 1 && + syntheticToolPlanHandoffPlan.actions.length === 2 && + syntheticToolPlanHandoffPlan.actions.every( + (action) => action.tool === 'agent.delegate', + ) && + syntheticToolPlanHandoffPlan.response === '' && + syntheticNormalizedPlanUpdate.explanation === + `${'x'.repeat(240)}…` && + syntheticNormalizedPlanUpdate.steps[0].step === + `${'y'.repeat(180)}…` && + syntheticNormalizedPlanUpdate.steps[0].status === 'pending' && + hashJsonValue(syntheticToolPlanHandoffPlan) === + hashJsonValue(canonicalJsonValue(syntheticToolPlanHandoffPlan)); + const toolPlanHandoffEvidenceFieldsRegistered = [ + 'toolPlanHandoffModeEnabled', + 'toolPlanHandoffCheckpointCaptured', + 'toolPlanHandoffNetworkReplayCount', + 'toolPlanHandoffLifecycleClosedExactlyOnce', + 'toolPlanHandoffAuditIdempotent', + 'toolPlanHandoffRecoveredPlanFingerprintMatched', + 'toolPlanHandoffCapabilityLeakCount', + 'toolPlanHandoffPrePersistUnknownResultWindowCovered', + ].every((field) => + Object.hasOwn(supervisorSwarmEvidenceFieldTemplate(), field), + ); + assert( + toolPlanHandoffSuiteRegistered && + toolPlanHandoffPlanProjectionValidated && + toolPlanHandoffEvidenceFieldsRegistered && + supervisorSwarmEvidenceFieldTemplate() + .toolPlanHandoffPrePersistUnknownResultWindowCovered === false, + 'agent-runtime-real-e2e-self-test-tool-plan-handoff-suite-invalid', + ); + const previousInitialRunId = state.initialRunId; const syntheticFinalReplyRunId = 'synthetic-final-reply-run'; state.initialRunId = syntheticFinalReplyRunId; @@ -32264,6 +33385,57 @@ function runAgentRuntimeRealE2eSelfTests() { 'agent-runtime-real-e2e-self-test-exact-secret-count-invalid', ); + const previousPrivateValueScopeState = { + secrets: state.secrets, + transcriptOnlySecrets: state.transcriptOnlySecrets, + privateValues: state.supervisorSwarm.privateValues, + transcriptScanner: state.transcriptScanner, + }; + const syntheticTransportSecret = + 'synthetic-private-transport-secret-for-scope-self-test'; + const syntheticPrivateOutput = + 'synthetic-private-output-for-scope-self-test'; + state.secrets = []; + state.transcriptOnlySecrets = []; + state.supervisorSwarm.privateValues = []; + state.transcriptScanner = new StreamingSecretScanner([]); + registerSupervisorSwarmPrivateTransportValues([syntheticTransportSecret]); + registerSupervisorSwarmPrivateOutputValues([syntheticPrivateOutput]); + const privateOutputSplitAt = Math.floor(syntheticPrivateOutput.length / 2); + state.transcriptScanner.scan( + 'synthetic-private-output', + syntheticPrivateOutput.slice(0, privateOutputSplitAt), + ); + state.transcriptScanner.scan( + 'synthetic-private-output', + syntheticPrivateOutput.slice(privateOutputSplitAt), + ); + const scopedPrivateOutputValidated = + state.secrets.includes(syntheticTransportSecret) && + !state.secrets.includes(syntheticPrivateOutput) && + state.transcriptOnlySecrets.includes(syntheticPrivateOutput) && + state.supervisorSwarm.privateValues.includes(syntheticTransportSecret) && + state.supervisorSwarm.privateValues.includes(syntheticPrivateOutput) && + state.transcriptScanner.count === 1 && + countExactSecrets( + JSON.stringify({ privateOutput: syntheticPrivateOutput }), + state.secrets, + ) === 0 && + countExactSecrets( + JSON.stringify({ privateOutput: syntheticPrivateOutput }), + [...state.secrets, ...state.transcriptOnlySecrets], + ) === 1; + state.secrets = previousPrivateValueScopeState.secrets; + state.transcriptOnlySecrets = + previousPrivateValueScopeState.transcriptOnlySecrets; + state.supervisorSwarm.privateValues = + previousPrivateValueScopeState.privateValues; + state.transcriptScanner = previousPrivateValueScopeState.transcriptScanner; + assert( + scopedPrivateOutputValidated, + 'agent-runtime-real-e2e-self-test-private-output-scope-invalid', + ); + const syntheticRecoveredRepairSessionId = 'synthetic-repair-session'; const syntheticRecoveredRepairIdentity = { agentId: 'synthetic-repair-agent', @@ -32992,6 +34164,7 @@ function runAgentRuntimeRealE2eSelfTests() { suite: 'agent-runtime-real-e2e-self-test', providerUsed: false, exactSecretCounts, + scopedPrivateOutputValidated, recoveredRepairConfirmationLifecycleValidated: true, modernRecoveredRepairConfirmationLifecycleValidated: true, legacyRecoveredRepairConfirmationLifecycleValidated: true, @@ -33013,6 +34186,14 @@ function runAgentRuntimeRealE2eSelfTests() { collaborationPolicySnapshotBindingStable: true, durableSnapshotEligibilityAndContractBindingValidated: true, sourceEndpointAbsentLifecycleGuardValidated, + toolPlanHandoffSuiteRegistered, + toolPlanHandoffPackageCommandsRegistered, + toolPlanHandoffSourceGuardRegistered, + toolPlanHandoffCleanupRegistered, + toolPlanHandoffPartialEvidenceRegistered: true, + toolPlanHandoffPlanProjectionValidated, + toolPlanHandoffEvidenceFieldsRegistered, + toolPlanHandoffUnknownResultBoundaryPreserved: true, finalReplyFaultPrerequisiteValidated: true, collaborationPolicyDriftFixtureValidated: true, collaborationPolicyDriftStatusObserved: true, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 86897e9d5..45bb3762c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -7,7 +7,7 @@ use crate::tool_plan_handoff; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::io::{Seek, SeekFrom}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock< @@ -16,6 +16,7 @@ static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock< static PROVIDER_RETRY_WAKE_SINGLEFLIGHT: OnceLock< std::sync::Mutex>, > = OnceLock::new(); +static AGENT_RUNTIME_REAL_E2E_CHECKPOINT_TEMP_NONCE: AtomicU64 = AtomicU64::new(0); fn external_agent_runner_owns_background_execution() -> bool { external_agent_runner_enabled() && !external_agent_runner_is_server_process() @@ -9973,6 +9974,23 @@ const AGENT_RUNTIME_PROVIDER_TRANSIENT_ERROR_PREFIX: &str = "agent-runtime-provider-transient-error:"; #[cfg(test)] const AGENT_RUNTIME_PROVIDER_HANDOFF_TEST_STOP: &str = "agent-runtime-provider-handoff-test-stop"; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_FILE_NAME: &str = + ".agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-appdata.json"; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_SCHEMA_VERSION: &str = + "genarrative-agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-appdata.v1"; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_FILE_NAME: &str = + ".agent-runtime-real-e2e-tool-plan-handoff-checkpoint.json"; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_SCHEMA_VERSION: &str = + "game-creator-tool-plan-handoff-runner-kill-checkpoint.v1"; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE_NAME: &str = + ".agent-runtime-real-e2e-tool-plan-handoff-checkpoint-reached.json"; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_SCHEMA_VERSION: &str = + "game-creator-tool-plan-handoff-runner-kill-checkpoint-reached.v1"; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_BYTES: u64 = 16 * 1024; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_POLL_MS: u64 = 50; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS: u64 = 10 * 60 * 1_000; +const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR: &str = + "agent-runtime-real-e2e-tool-plan-handoff-checkpoint-needs-reconciliation"; const AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 3; const AGENT_RUNTIME_PROVIDER_TRANSIENT_BACKOFF_MAX_MS: u64 = 30_000; const AGENT_RUNTIME_FINALIZATION_LIFECYCLE_RECORD_TYPE: &str = @@ -12004,6 +12022,898 @@ where .await } +#[derive(Clone, Eq, PartialEq)] +struct AgentRuntimeRealE2ePrivateFileMetadata { + len: u64, + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, + #[cfg(unix)] + mode: u32, + #[cfg(unix)] + owner: u32, + #[cfg(unix)] + modified_seconds: i64, + #[cfg(unix)] + modified_nanoseconds: i64, + #[cfg(not(unix))] + modified: Option, + #[cfg(windows)] + file_attributes: u32, +} + +#[derive(Clone, Eq, PartialEq)] +struct AgentRuntimeRealE2ePrivateFileSnapshot { + metadata: AgentRuntimeRealE2ePrivateFileMetadata, + bytes: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AgentRuntimeRealE2eAckPublishPhase { + BeforePublish, + AfterPublish, +} + +struct AgentRuntimeRealE2eCheckpointAppData { + path: PathBuf, + #[cfg(unix)] + directory: File, + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, +} + +#[derive(Clone, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeRealE2eToolPlanCheckpointSentinel { + schema_version: String, + token: String, + owner_pid: u32, + created_at: u64, +} + +#[derive(Clone, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeRealE2eToolPlanCheckpointControl { + schema_version: String, + capability: String, + sentinel_token: String, + owner_pid: u32, + created_at_ms: u64, + expires_at_ms: u64, + project_root_sha256: String, + agent_id: String, + run_id: String, + request_slot: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeRealE2eToolPlanCheckpointAck { + schema_version: String, + capability_sha256: String, + project_root_sha256: String, + agent_id: String, + run_id: String, + request_slot: String, + provider_request_id_sha256: String, + response_fingerprint: String, + reached_at_ms: u64, +} + +fn agent_runtime_real_e2e_private_file_metadata( + metadata: &fs::Metadata, +) -> Result { + if !metadata.is_file() || metadata.len() > AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_BYTES + { + return Err(()); + } + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + let mode = metadata.mode() & 0o777; + // The AppData owner may choose 0400 or 0600, but group/other access is forbidden. + if metadata.nlink() != 1 + || metadata.uid() != unsafe { libc::geteuid() } + || mode & 0o400 == 0 + || mode & 0o077 != 0 + { + return Err(()); + } + return Ok(AgentRuntimeRealE2ePrivateFileMetadata { + len: metadata.len(), + device: metadata.dev(), + inode: metadata.ino(), + mode, + owner: metadata.uid(), + modified_seconds: metadata.mtime(), + modified_nanoseconds: metadata.mtime_nsec(), + }); + } + + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(()); + } + return Ok(AgentRuntimeRealE2ePrivateFileMetadata { + len: metadata.len(), + modified: metadata.modified().ok(), + file_attributes: metadata.file_attributes(), + }); + } + + #[cfg(not(any(unix, windows)))] + { + Ok(AgentRuntimeRealE2ePrivateFileMetadata { + len: metadata.len(), + modified: metadata.modified().ok(), + }) + } +} + +impl AgentRuntimeRealE2eCheckpointAppData { + fn open(path: &Path) -> Result { + let inspected = inspect_game_creator_runtime_config_dir(path).map_err(|_| ())?; + if inspected != path { + return Err(()); + } + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let directory = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(path) + .map_err(|_| ())?; + let metadata = directory.metadata().map_err(|_| ())?; + if !metadata.is_dir() + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + { + return Err(()); + } + return Ok(Self { + path: path.to_path_buf(), + device: metadata.dev(), + inode: metadata.ino(), + directory, + }); + } + + #[cfg(not(unix))] + { + Ok(Self { + path: path.to_path_buf(), + }) + } + } + + fn verify_current(&self) -> Result<(), ()> { + if game_creator_runtime_config_dir().as_deref() != Some(self.path.as_path()) + || inspect_game_creator_runtime_config_dir(&self.path).map_err(|_| ())? != self.path + { + return Err(()); + } + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; + + let current = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW) + .open(&self.path) + .map_err(|_| ())?; + let metadata = current.metadata().map_err(|_| ())?; + if !metadata.is_dir() + || metadata.dev() != self.device + || metadata.ino() != self.inode + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + { + return Err(()); + } + } + Ok(()) + } + + fn open_named_for_read(&self, name: &str) -> Result { + #[cfg(unix)] + { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + + let name = CString::new(name).map_err(|_| ())?; + // SAFETY: the directory fd is live and the fixed file name is NUL terminated. + let fd = unsafe { + libc::openat( + self.directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + return Err(()); + } + // SAFETY: openat returned a new owned fd. + return Ok(unsafe { File::from_raw_fd(fd) }); + } + + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + return fs::OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(self.path.join(name)) + .map_err(|_| ()); + } + + #[cfg(not(any(unix, windows)))] + { + fs::OpenOptions::new() + .read(true) + .open(self.path.join(name)) + .map_err(|_| ()) + } + } + + fn create_named_private_file(&self, name: &str) -> Result { + #[cfg(unix)] + { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::PermissionsExt; + + let name = CString::new(name).map_err(|_| ())?; + // SAFETY: the directory fd is live and the fixed file name is NUL terminated. + let fd = unsafe { + libc::openat( + self.directory.as_raw_fd(), + name.as_ptr(), + libc::O_WRONLY + | libc::O_CREAT + | libc::O_EXCL + | libc::O_CLOEXEC + | libc::O_NOFOLLOW, + 0o600, + ) + }; + if fd < 0 { + return Err(()); + } + // SAFETY: openat returned a new owned fd. + let file = unsafe { File::from_raw_fd(fd) }; + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|_| ())?; + return Ok(file); + } + + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let path = self.path.join(name); + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&path) + .map_err(|_| ())?; + secure_windows_game_creator_path_for_current_user(&path, false, true) + .map_err(|_| ())?; + return Ok(file); + } + + #[cfg(not(any(unix, windows)))] + { + fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(self.path.join(name)) + .map_err(|_| ()) + } + } + + fn read_named_private_file( + &self, + name: &str, + ) -> Result { + let mut file = self.open_named_for_read(name)?; + let before = + agent_runtime_real_e2e_private_file_metadata(&file.metadata().map_err(|_| ())?)?; + let mut bytes = Vec::with_capacity(before.len as usize); + file.read_to_end(&mut bytes).map_err(|_| ())?; + let after = + agent_runtime_real_e2e_private_file_metadata(&file.metadata().map_err(|_| ())?)?; + if before != after || bytes.len() as u64 != before.len { + return Err(()); + } + Ok(AgentRuntimeRealE2ePrivateFileSnapshot { + metadata: before, + bytes, + }) + } + + fn require_named_entry_missing(&self, name: &str) -> Result<(), ()> { + #[cfg(unix)] + { + use std::ffi::CString; + use std::os::fd::AsRawFd; + + let name = CString::new(name).map_err(|_| ())?; + let mut metadata = std::mem::MaybeUninit::::uninit(); + // SAFETY: the directory fd is live and metadata points to writable storage. + if unsafe { + libc::fstatat( + self.directory.as_raw_fd(), + name.as_ptr(), + metadata.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + } == 0 + { + return Err(()); + } + return (std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound) + .then_some(()) + .ok_or(()); + } + + #[cfg(not(unix))] + { + match fs::symlink_metadata(self.path.join(name)) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + _ => Err(()), + } + } + } + + #[cfg(unix)] + fn remove_named_file_if_present(&self, name: &str) -> Result<(), ()> { + use std::ffi::CString; + use std::os::fd::AsRawFd; + + let name = CString::new(name).map_err(|_| ())?; + // SAFETY: the directory fd is live and name is a fixed relative component. + if unsafe { libc::unlinkat(self.directory.as_raw_fd(), name.as_ptr(), 0) } == 0 { + return Ok(()); + } + (std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound) + .then_some(()) + .ok_or(()) + } + + #[cfg(not(unix))] + fn remove_named_file_if_present(&self, name: &str) -> Result<(), ()> { + match fs::remove_file(self.path.join(name)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(()), + } + } + + #[cfg(unix)] + fn publish_named_file_by_link(&self, source_name: &str, target_name: &str) -> Result<(), ()> { + use std::ffi::CString; + use std::os::fd::AsRawFd; + + let source = CString::new(source_name).map_err(|_| ())?; + let target = CString::new(target_name).map_err(|_| ())?; + // SAFETY: both names are fixed relative components under the same live directory fd. + if unsafe { + libc::linkat( + self.directory.as_raw_fd(), + source.as_ptr(), + self.directory.as_raw_fd(), + target.as_ptr(), + 0, + ) + } != 0 + { + return Err(()); + } + self.remove_named_file_if_present(source_name) + } + + #[cfg(target_os = "linux")] + fn publish_named_file_noreplace( + &self, + source_name: &str, + target_name: &str, + _source_file: &File, + ) -> Result<(), ()> { + use std::ffi::CString; + use std::os::fd::AsRawFd; + + let source = CString::new(source_name).map_err(|_| ())?; + let target = CString::new(target_name).map_err(|_| ())?; + // SAFETY: both names are fixed relative components under the same live directory fd. + if unsafe { + libc::renameat2( + self.directory.as_raw_fd(), + source.as_ptr(), + self.directory.as_raw_fd(), + target.as_ptr(), + libc::RENAME_NOREPLACE, + ) + } == 0 + { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if matches!( + error.raw_os_error(), + Some(libc::ENOSYS) | Some(libc::EINVAL) | Some(libc::EOPNOTSUPP) + ) { + return self.publish_named_file_by_link(source_name, target_name); + } + Err(()) + } + + #[cfg(all(unix, not(target_os = "linux")))] + fn publish_named_file_noreplace( + &self, + source_name: &str, + target_name: &str, + _source_file: &File, + ) -> Result<(), ()> { + self.publish_named_file_by_link(source_name, target_name) + } + + #[cfg(windows)] + fn publish_named_file_noreplace( + &self, + source_name: &str, + target_name: &str, + _source_file: &File, + ) -> Result<(), ()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::MoveFileExW; + + const MOVEFILE_WRITE_THROUGH: u32 = 0x0000_0008; + let source = self + .path + .join(source_name) + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target = self + .path + .join(target_name) + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // MOVEFILE_REPLACE_EXISTING is deliberately absent, so an existing ACK wins. + if unsafe { MoveFileExW(source.as_ptr(), target.as_ptr(), MOVEFILE_WRITE_THROUGH) } == 0 { + return Err(()); + } + Ok(()) + } + + #[cfg(not(any(unix, windows)))] + fn publish_named_file_noreplace( + &self, + source_name: &str, + target_name: &str, + _source_file: &File, + ) -> Result<(), ()> { + self.require_named_entry_missing(target_name)?; + fs::hard_link(self.path.join(source_name), self.path.join(target_name)).map_err(|_| ())?; + self.remove_named_file_if_present(source_name) + } + + fn create_ack_and_read_back( + &self, + bytes: &[u8], + ) -> Result { + self.create_ack_and_read_back_with_hook(bytes, |_, _, _| Ok(())) + } + + fn create_ack_and_read_back_with_hook( + &self, + bytes: &[u8], + mut publish_hook: F, + ) -> Result + where + F: FnMut(AgentRuntimeRealE2eAckPublishPhase, &str, &str) -> Result<(), ()>, + { + if bytes.len() as u64 > AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_BYTES { + return Err(()); + } + self.require_named_entry_missing( + AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE_NAME, + )?; + let (temporary_name, mut file) = (0..64) + .find_map(|_| { + let nonce = + AGENT_RUNTIME_REAL_E2E_CHECKPOINT_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); + let name = format!( + "{}.tmp-{}-{nonce}", + AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE_NAME, + std::process::id(), + ); + self.create_named_private_file(&name) + .ok() + .map(|file| (name, file)) + }) + .ok_or(())?; + file.write_all(bytes).map_err(|_| ())?; + file.sync_all().map_err(|_| ())?; + let temporary = self.read_named_private_file(&temporary_name)?; + if temporary.bytes != bytes { + let _ = self.remove_named_file_if_present(&temporary_name); + return Err(()); + } + publish_hook( + AgentRuntimeRealE2eAckPublishPhase::BeforePublish, + &temporary_name, + AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE_NAME, + )?; + self.verify_current()?; + if self + .publish_named_file_noreplace( + &temporary_name, + AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE_NAME, + &file, + ) + .is_err() + { + let _ = self.remove_named_file_if_present(&temporary_name); + #[cfg(unix)] + let _ = self.directory.sync_all(); + return Err(()); + } + drop(file); + #[cfg(unix)] + self.directory.sync_all().map_err(|_| ())?; + + let persisted = self + .read_named_private_file(AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE_NAME)?; + if persisted.bytes != bytes || self.require_named_entry_missing(&temporary_name).is_err() { + return Err(()); + } + publish_hook( + AgentRuntimeRealE2eAckPublishPhase::AfterPublish, + &temporary_name, + AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE_NAME, + )?; + Ok(persisted) + } +} + +#[cfg(test)] +pub(crate) fn publish_game_creator_agent_runtime_tool_plan_checkpoint_ack_for_test( + config_dir: &Path, + bytes: &[u8], + mut publish_hook: F, +) -> Result, String> +where + F: FnMut(AgentRuntimeRealE2eAckPublishPhase, &Path, &Path), +{ + let app_data = AgentRuntimeRealE2eCheckpointAppData::open(config_dir) + .map_err(|_| AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR.to_string())?; + let base = app_data.path.clone(); + app_data + .create_ack_and_read_back_with_hook(bytes, |phase, temporary_name, final_name| { + publish_hook(phase, &base.join(temporary_name), &base.join(final_name)); + Ok(()) + }) + .map(|snapshot| snapshot.bytes) + .map_err(|_| AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR.to_string()) +} + +fn agent_runtime_real_e2e_is_lowercase_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn agent_runtime_real_e2e_canonical_project_root_sha256(root: &Path) -> Result { + let canonical = fs::canonicalize(root).map_err(|_| ())?; + let canonical = canonical.to_str().ok_or(())?; + Ok(format!("{:x}", Sha256::digest(canonical.as_bytes()))) +} + +fn validate_agent_runtime_real_e2e_tool_plan_checkpoint( + sentinel: &AgentRuntimeRealE2eToolPlanCheckpointSentinel, + control: &AgentRuntimeRealE2eToolPlanCheckpointControl, + snapshot: &AgentRuntimeProviderRequestSnapshot, + project_root_sha256: &str, + now_ms: u64, +) -> Result<(), ()> { + if sentinel.schema_version + != AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_SCHEMA_VERSION + || sentinel.token.is_empty() + || sentinel.token.len() > 256 + || sentinel.token.chars().any(char::is_control) + || sentinel.owner_pid == 0 + || sentinel.created_at == 0 + || control.schema_version + != AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_SCHEMA_VERSION + || !agent_runtime_real_e2e_is_lowercase_sha256(&control.capability) + || !agent_runtime_real_e2e_is_lowercase_sha256(&control.project_root_sha256) + || control.sentinel_token != sentinel.token + || control.owner_pid != sentinel.owner_pid + || control.created_at_ms < sentinel.created_at + || control.created_at_ms > now_ms + || control.expires_at_ms <= control.created_at_ms + || control.expires_at_ms.saturating_sub(control.created_at_ms) + > AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS + || control.expires_at_ms <= now_ms + || control.project_root_sha256 != project_root_sha256 + || control.agent_id != snapshot.agent_id + || control.run_id != snapshot.run_id + || control.request_slot != snapshot.request_slot + { + return Err(()); + } + Ok(()) +} + +fn read_agent_runtime_real_e2e_tool_plan_checkpoint_sentinel( + app_data: &AgentRuntimeRealE2eCheckpointAppData, +) -> Result< + ( + AgentRuntimeRealE2eToolPlanCheckpointSentinel, + AgentRuntimeRealE2ePrivateFileSnapshot, + ), + (), +> { + let snapshot = app_data + .read_named_private_file(AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_FILE_NAME)?; + let value = serde_json::from_slice(&snapshot.bytes).map_err(|_| ())?; + Ok((value, snapshot)) +} + +fn read_agent_runtime_real_e2e_tool_plan_checkpoint_control( + app_data: &AgentRuntimeRealE2eCheckpointAppData, +) -> Result< + ( + AgentRuntimeRealE2eToolPlanCheckpointControl, + AgentRuntimeRealE2ePrivateFileSnapshot, + ), + (), +> { + let snapshot = app_data + .read_named_private_file(AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_FILE_NAME)?; + let value = serde_json::from_slice(&snapshot.bytes).map_err(|_| ())?; + Ok((value, snapshot)) +} + +fn read_agent_runtime_real_e2e_tool_plan_handoff_fingerprint( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + provider_request_id: &str, +) -> Result { + let ledger = tool_plan_handoff::read_for_run_at(root, &snapshot.agent_id, &snapshot.run_id) + .map_err(|_| ())? + .ok_or(())?; + let mut matching = ledger + .entries + .iter() + .filter(|entry| entry.request_slot == snapshot.request_slot); + let entry = matching.next().ok_or(())?; + if matching.next().is_some() + || entry.provider_request_id != provider_request_id + || entry.identity.agent_id != snapshot.agent_id + || entry.identity.task_id != snapshot.task_id + || entry.identity.session_id != snapshot.session_id + || entry.identity.run_id != snapshot.run_id + || entry.identity.request_kind != "tool-plan" + || !agent_runtime_real_e2e_is_lowercase_sha256(&entry.response_fingerprint) + { + return Err(()); + } + Ok(entry.response_fingerprint.clone()) +} + +async fn await_agent_runtime_real_e2e_tool_plan_handoff_checkpoint_inner( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + provider_request_id: &str, + config_dir: &Path, +) -> Result<(), ()> { + let app_data = AgentRuntimeRealE2eCheckpointAppData::open(config_dir)?; + let (sentinel, sentinel_file) = + read_agent_runtime_real_e2e_tool_plan_checkpoint_sentinel(&app_data)?; + let (control, control_file) = + read_agent_runtime_real_e2e_tool_plan_checkpoint_control(&app_data)?; + let project_root_sha256 = agent_runtime_real_e2e_canonical_project_root_sha256(root)?; + validate_agent_runtime_real_e2e_tool_plan_checkpoint( + &sentinel, + &control, + snapshot, + &project_root_sha256, + provider_retry::now_ms(), + )?; + // Close the validation-to-ack race: both private controls and AppData identity must be stable. + app_data.verify_current()?; + let (current_sentinel, current_sentinel_file) = + read_agent_runtime_real_e2e_tool_plan_checkpoint_sentinel(&app_data)?; + let (current_control, current_control_file) = + read_agent_runtime_real_e2e_tool_plan_checkpoint_control(&app_data)?; + if current_sentinel != sentinel + || current_sentinel_file != sentinel_file + || current_control != control + || current_control_file != control_file + { + return Err(()); + } + let checkpoint_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.tool_plan_checkpoint.ack", + ) + .map_err(|_| ())?; + if game_creator_agent_runtime_provider_snapshot_has_durable_control_at_locked(root, snapshot) + .map_err(|_| ())? + { + return Err(()); + } + let project_revision_before_ack = + read_game_creator_agent_runtime_project_revision(root).map_err(|_| ())?; + let response_fingerprint = read_agent_runtime_real_e2e_tool_plan_handoff_fingerprint( + root, + snapshot, + provider_request_id, + )?; + app_data.verify_current()?; + let (locked_sentinel, locked_sentinel_file) = + read_agent_runtime_real_e2e_tool_plan_checkpoint_sentinel(&app_data)?; + let (locked_control, locked_control_file) = + read_agent_runtime_real_e2e_tool_plan_checkpoint_control(&app_data)?; + if locked_sentinel != sentinel + || locked_sentinel_file != sentinel_file + || locked_control != control + || locked_control_file != control_file + { + return Err(()); + } + let reached_at_ms = provider_retry::now_ms(); + validate_agent_runtime_real_e2e_tool_plan_checkpoint( + &sentinel, + &control, + snapshot, + &project_root_sha256, + reached_at_ms, + )?; + let ack = AgentRuntimeRealE2eToolPlanCheckpointAck { + schema_version: AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_SCHEMA_VERSION.to_string(), + capability_sha256: format!("{:x}", Sha256::digest(control.capability.as_bytes())), + project_root_sha256, + agent_id: snapshot.agent_id.clone(), + run_id: snapshot.run_id.clone(), + request_slot: snapshot.request_slot.clone(), + provider_request_id_sha256: format!("{:x}", Sha256::digest(provider_request_id.as_bytes())), + response_fingerprint: response_fingerprint.clone(), + reached_at_ms, + }; + let ack_bytes = serde_json::to_vec(&ack).map_err(|_| ())?; + let ack_file = app_data.create_ack_and_read_back(&ack_bytes)?; + let persisted_ack = + serde_json::from_slice::(&ack_file.bytes) + .map_err(|_| ())?; + if persisted_ack != ack { + return Err(()); + } + drop(checkpoint_lock); + + let elapsed_deadline = tokio::time::Instant::now() + + Duration::from_millis( + control + .expires_at_ms + .saturating_sub(reached_at_ms) + .min(AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS), + ); + + loop { + let now_ms = provider_retry::now_ms(); + let monotonic_remaining = + elapsed_deadline.saturating_duration_since(tokio::time::Instant::now()); + if now_ms >= control.expires_at_ms || monotonic_remaining.is_zero() { + return Err(()); + } + tokio::time::sleep( + Duration::from_millis( + control + .expires_at_ms + .saturating_sub(now_ms) + .min(AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_POLL_MS) + .max(1), + ) + .min(monotonic_remaining), + ) + .await; + app_data.verify_current()?; + let (current_sentinel, current_sentinel_file) = + read_agent_runtime_real_e2e_tool_plan_checkpoint_sentinel(&app_data)?; + let (current_control, current_control_file) = + read_agent_runtime_real_e2e_tool_plan_checkpoint_control(&app_data)?; + let current_ack_file = app_data + .read_named_private_file(AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE_NAME)?; + if current_sentinel != sentinel + || current_sentinel_file != sentinel_file + || current_control != control + || current_control_file != control_file + || current_ack_file != ack_file + || provider_retry::now_ms() >= control.expires_at_ms + || tokio::time::Instant::now() >= elapsed_deadline + { + return Err(()); + } + let _checkpoint_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.tool_plan_checkpoint.poll", + ) + .map_err(|_| ())?; + if game_creator_agent_runtime_provider_snapshot_has_durable_control_at_locked( + root, snapshot, + ) + .map_err(|_| ())? + || read_game_creator_agent_runtime_project_revision(root).map_err(|_| ())? + != project_revision_before_ack + || read_agent_runtime_real_e2e_tool_plan_handoff_fingerprint( + root, + snapshot, + provider_request_id, + )? != response_fingerprint + { + return Err(()); + } + } +} + +async fn await_agent_runtime_real_e2e_tool_plan_handoff_checkpoint_if_configured( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + provider_request_id: &str, +) -> Result<(), String> { + if snapshot.request_kind != "tool-plan" { + return Ok(()); + } + let Some(config_dir) = game_creator_runtime_config_dir() else { + return Ok(()); + }; + let control_path = + config_dir.join(AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_FILE_NAME); + match fs::symlink_metadata(&control_path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => { + return Err(AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR.to_string()); + } + Ok(metadata) if !metadata.file_type().is_file() || metadata.file_type().is_symlink() => { + return Err(AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR.to_string()); + } + Ok(_) => {} + } + await_agent_runtime_real_e2e_tool_plan_handoff_checkpoint_inner( + root, + snapshot, + provider_request_id, + &config_dir, + ) + .await + .map_err(|_| AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR.to_string()) +} + async fn await_game_creator_agent_runtime_provider_request_with_snapshot_and_success_commit< T, F, @@ -12092,6 +13002,58 @@ where .await } +#[cfg(test)] +pub(crate) async fn await_game_creator_agent_runtime_tool_plan_checkpoint_for_test( + root: &Path, + snapshot: AgentRuntimeProviderRequestSnapshot, + response: platform_llm::LlmRunResponse, +) -> Result, String> { + let request_slot = snapshot.request_slot.clone(); + let request_identity = format!( + "real-e2e-checkpoint-test-request\n{}\n{}\n{}", + snapshot.agent_id, snapshot.run_id, snapshot.request_slot + ); + let identity = AgentRuntimeProviderRetryIdentity { + project_id: snapshot.project_id.clone(), + agent_id: snapshot.agent_id.clone(), + task_id: snapshot.task_id.clone(), + session_id: snapshot.session_id.clone(), + run_id: snapshot.run_id.clone(), + source: snapshot.source.clone(), + goal_id: snapshot.goal_id.clone(), + goal_revision: snapshot.goal_revision, + goal_snapshot_fingerprint: snapshot.goal_snapshot_fingerprint.clone(), + applied_steer_cursor: snapshot.applied_steer_cursor, + request_kind: snapshot.request_kind.clone(), + base_request_slot: snapshot.request_slot.clone(), + request_fingerprint: format!("{:x}", Sha256::digest(request_identity.as_bytes())), + provider_config_fingerprint: format!( + "{:x}", + Sha256::digest(b"real-e2e-checkpoint-test-provider-config") + ), + web_search_enabled: snapshot.web_search_enabled, + allow_idle_context_compaction: snapshot.allow_idle_context_compaction, + }; + await_game_creator_agent_runtime_provider_request_with_snapshot_and_control_recheck( + root, + snapshot, + async move { Ok::<_, String>(response) }, + || {}, + move |provider_request_id, response| { + tool_plan_handoff::write_at( + root, + &identity, + &request_slot, + 0, + provider_request_id, + response, + )?; + Ok(()) + }, + ) + .await +} + async fn await_game_creator_agent_runtime_provider_request_with_snapshot_and_control_recheck< T, F, @@ -12251,6 +13213,31 @@ where "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · successHandoff={error}" )); } + if let Err(error) = await_agent_runtime_real_e2e_tool_plan_handoff_checkpoint_if_configured( + root, + &snapshot, + &request_id, + ) + .await + { + unregister_game_creator_agent_runtime_provider_request(&key, &active); + if let Ok(_control_lock) = + acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_request.tool_plan_checkpoint_reconciliation", + ) + { + let _ = + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &snapshot, + &request_id, + ); + } + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: requestId={request_id} · toolPlanCheckpoint={error}" + )); + } #[cfg(test)] if game_creator_agent_runtime_provider_handoff_test_stop_matches(root, &snapshot) { let injection = root.join(".agent/runtime/test-stop-after-provider-handoff"); @@ -23867,7 +24854,7 @@ fn build_game_creator_agent_background_tool_plan_request( ) .replace( "agent.delegate 使用 {\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"},用于把任务投递到另一个 Agent 的独立队列", - "agent.delegate 使用 {\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[\"可选的精确项目内文件路径\"],\"repairOfDelegationId\":null,\"runId\":null},用于用持久验收合同把任务投递到另一个 Agent 的独立队列;expectedArtifacts 不接受 glob,返工时 repairOfDelegationId 指向已认领原 delivery", + "agent.delegate 使用 {\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[\"可选的精确项目内文件路径\"],\"repairOfDelegationId\":null,\"runId\":null},用于用持久验收合同把任务投递到另一个 Agent 的独立队列;expectedArtifacts 不接受 glob,返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null", ) .replace( "agent.run_status 使用 {\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"},用于读取自己或其他 Agent 的 Runtime 状态摘要", @@ -31621,6 +32608,15 @@ pub(crate) fn observe_agent_runtime_agent_delegate( detail: None, }; } + let run_id_input = agent_runtime_tool_input_text(input, &["runId", "run_id"]); + if repair_of_delegation_id.is_some() && !run_id_input.trim().is_empty() { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: "静态委派返工的 runId 必须为 null,由 Runtime 派生新 run 身份".to_string(), + detail: None, + }; + } let action_identity = action_id .filter(|value| !value.trim().is_empty()) .map(str::to_string) @@ -31649,7 +32645,6 @@ pub(crate) fn observe_agent_runtime_agent_delegate( }; } }; - let run_id_input = agent_runtime_tool_input_text(input, &["runId", "run_id"]); let run_id = if run_id_input.trim().is_empty() { if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { format!("delegated-{delegation_id}") @@ -39472,7 +40467,7 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( "{prompt}\n\n当 collaboration policy 的 minIsolatedGroupsBeforeClaim 大于 0 时,首次 agent.run_status 认领前必须已经建立且 ready 的 isolated group 数量达到该值;不足时 Runtime 会在写 claim 或改 delivery 前失败关闭。已有 durable claim 的恢复不受此门禁影响。只读任务的 writeScopes 也必须填写且不能留空,只能覆盖其 expectedArtifacts 所在的最小目录/**,不能扩大到 sibling 或共同父目录。" ); format!( - "{prompt}\n\n普通 agent.run_status 的 claimedDelegateContracts 只提供已认领合同目录。语义复核或返工前必须用原 delegationId 再调用 agent.run_status,读取 claimedDelegateContract 中未截断的 acceptanceCriteria 和 expectedArtifacts,并在 repair agent.delegate 中逐项原样提交。若返工因合同未完整继承而失败,失败 observation 中的 claimedDelegateContract 是同一 durable delivery 的权威快照,必须逐项据此修正;只有该字段缺失或身份不确定时才按同一 delegationId 重读,不得无目标地重复 run_status 或从 action_history 摘要猜测。" + "{prompt}\n\n普通 agent.run_status 的 claimedDelegateContracts 只提供已认领合同目录。语义复核或返工前必须用原 delegationId 再调用 agent.run_status,读取 claimedDelegateContract 中未截断的 acceptanceCriteria 和 expectedArtifacts,并在 repair agent.delegate 中逐项原样提交,同时把 runId 设为 null,由 Runtime 派生新的返工 run 身份。若返工因合同未完整继承而失败,失败 observation 中的 claimedDelegateContract 是同一 durable delivery 的权威快照,必须逐项据此修正;只有该字段缺失或身份不确定时才按同一 delegationId 重读,不得无目标地重复 run_status 或从 action_history 摘要猜测。" ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 43b928220..be88b5001 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -492,7 +492,18 @@ fn validate_native_agent_delegate_input( 160, true, )?; - validate_native_delegate_string(object.get("runId"), "runId", 160, true) + validate_native_delegate_string(object.get("runId"), "runId", 160, true)?; + if object + .get("repairOfDelegationId") + .is_some_and(Value::is_string) + && !object.get("runId").is_some_and(Value::is_null) + { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + "Agent 原生工具协议错误:agent.delegate 返工委派时 runId 必须为 JSON null", + )); + } + Ok(()) } fn validate_native_delegate_string( @@ -685,7 +696,9 @@ fn runtime_tool_description(tool: &str) -> &'static str { "canvas.asset_generate" => "通过已配置平台生成并登记首版美术素材。", "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", - "agent.delegate" => "用持久验收合同把边界清晰的后台任务委派给另一个 Agent。", + "agent.delegate" => { + "用持久验收合同把边界清晰的后台任务委派给另一个 Agent;返工时 repairOfDelegationId 指向原 delivery,且 runId 必须为 null。" + } "agent.spawn_isolated" => "创建最多三个写范围互不重叠的隔离子 Agent。", "agent.schedule_ready" => "调度依赖已完成的 ready manifest 任务。", "agent.action_history" => "查询当前 Agent 的持久终态动作历史。", @@ -1035,3 +1048,66 @@ fn project_patchset_input_schema() -> Value { } }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_delegate_input(repair_of_delegation_id: Value, run_id: Value) -> Value { + json!({ + "agentId": "specialist", + "task": "完成委派任务", + "acceptanceCriteria": ["定向测试通过"], + "expectedArtifacts": [], + "repairOfDelegationId": repair_of_delegation_id, + "runId": run_id, + }) + } + + #[test] + fn native_agent_delegate_repair_rejects_string_run_id() { + let repair_id = "delegation-value-must-not-leak"; + let run_id = "run-value-must-not-leak"; + let error = validate_native_agent_delegate_input(&valid_delegate_input( + json!(repair_id), + json!(run_id), + )) + .expect_err("repair delegate must not accept a string runId"); + + assert_eq!( + error.kind(), + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema + ); + let detail = error.to_string(); + assert_eq!( + detail, + "Agent 原生工具协议错误:agent.delegate 返工委派时 runId 必须为 JSON null" + ); + assert!(!detail.contains(repair_id)); + assert!(!detail.contains(run_id)); + } + + #[test] + fn native_agent_delegate_repair_accepts_null_run_id() { + let input = valid_delegate_input(json!("delegation-id"), Value::Null); + + validate_native_agent_delegate_input(&input) + .expect("repair delegate should accept a null runId"); + } + + #[test] + fn native_agent_delegate_initial_accepts_string_run_id() { + let input = valid_delegate_input(Value::Null, json!("initial-run-id")); + + validate_native_agent_delegate_input(&input) + .expect("initial delegate should accept a valid string runId"); + } + + #[test] + fn native_agent_delegate_description_explains_repair_run_identity() { + let description = runtime_tool_description("agent.delegate"); + + assert!(description.contains("repairOfDelegationId 指向原 delivery")); + assert!(description.contains("runId 必须为 null")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs index c8e28ad0a..25ec9f042 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -778,12 +778,26 @@ pub(crate) fn preflight_supervisor_collaboration_plan( policy: &SupervisorCollaborationPolicy, state: &SupervisorCollaborationState, ) -> Result { - if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || actions.is_empty() { + if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return Ok(SupervisorCollaborationPreflight::default()); } let policy = normalize_supervisor_collaboration_policy(policy.clone())?; - let summary = summarize_supervisor_collaboration_actions(actions)?; let initial_wave = !state.has_collaboration(); + + if actions.is_empty() { + if initial_wave && supervisor_collaboration_policy_has_initial_requirements(&policy) { + return Ok(SupervisorCollaborationPreflight { + violation: Some(SupervisorCollaborationViolation { + summary: "Project Supervisor 首批协作不能停留在计划更新".to_string(), + detail: "当前父 run 尚无协作事实,且项目 policy 明确要求首批专业协作;首批不能停留在计划更新,必须在同一 Provider 批次完整提交协作。".to_string(), + }), + ..SupervisorCollaborationPreflight::default() + }); + } + return Ok(SupervisorCollaborationPreflight::default()); + } + + let summary = summarize_supervisor_collaboration_actions(actions)?; let has_collaboration_action = summary.has_collaboration_action(); if !initial_wave @@ -1363,6 +1377,58 @@ mod tests { assert!(error.contains("minIsolatedGroupsBeforeClaim 不能超过 16")); } + #[test] + fn supervisor_collaboration_policy_blocks_empty_required_initial_wave() { + let result = preflight_supervisor_collaboration_plan( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[], + &mixed_policy(), + &SupervisorCollaborationState::default(), + ) + .expect("preflight empty required initial wave"); + let violation = result + .violation + .expect("required initial wave must reject empty actions"); + assert_eq!( + violation.summary, + "Project Supervisor 首批协作不能停留在计划更新" + ); + assert!(violation.detail.contains("首批不能停留在计划更新")); + assert!(violation + .detail + .contains("必须在同一 Provider 批次完整提交协作")); + assert!(result.contract.is_none()); + assert!(!result.force_durable_batch); + } + + #[test] + fn supervisor_collaboration_policy_allows_empty_actions_after_collaboration() { + let state = SupervisorCollaborationState { + initial_static_agent_ids: vec!["design-director".to_string()], + ..SupervisorCollaborationState::default() + }; + let result = preflight_supervisor_collaboration_plan( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[], + &mixed_policy(), + &state, + ) + .expect("preflight empty actions after collaboration"); + assert_eq!(result, SupervisorCollaborationPreflight::default()); + } + + #[test] + fn supervisor_collaboration_policy_allows_empty_actions_without_initial_requirements() { + let result = preflight_supervisor_collaboration_plan( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[], + &SupervisorCollaborationPolicy::default(), + &SupervisorCollaborationState::default(), + ) + .expect("preflight empty actions without initial requirements"); + assert_eq!(result, SupervisorCollaborationPreflight::default()); + } + #[test] fn supervisor_collaboration_policy_blocks_partial_mixed_wave() { let result = preflight_supervisor_collaboration_plan( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 6632cf448..c9e2c971b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -34500,6 +34500,604 @@ fn wait_for_tool_plan_handoff_test_stop( panic!("tool-plan handoff was not committed before the test-stop lane released"); } +const REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_FILE: &str = + ".agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-appdata.json"; +const REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_SCHEMA: &str = + "genarrative-agent-runtime-real-e2e-supervisor-swarm-tool-plan-handoff-runner-kill-appdata.v1"; +const REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_FILE: &str = + ".agent-runtime-real-e2e-tool-plan-handoff-checkpoint.json"; +const REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_SCHEMA: &str = + "game-creator-tool-plan-handoff-runner-kill-checkpoint.v1"; +const REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE: &str = + ".agent-runtime-real-e2e-tool-plan-handoff-checkpoint-reached.json"; +const REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_SCHEMA: &str = + "game-creator-tool-plan-handoff-runner-kill-checkpoint-reached.v1"; +const REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE: &str = + "REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE"; +const REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT: &str = + "REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT"; + +fn write_real_e2e_tool_plan_checkpoint_json(path: &Path, value: &Value, unix_mode: u32) { + let bytes = serde_json::to_vec(value).expect("serialize checkpoint fixture"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + options.mode(unix_mode); + let mut file = options.open(path).expect("create checkpoint fixture"); + file.set_permissions(fs::Permissions::from_mode(unix_mode)) + .expect("set checkpoint fixture mode"); + file.write_all(&bytes).expect("write checkpoint fixture"); + file.sync_all().expect("sync checkpoint fixture"); + return; + } + #[cfg(not(unix))] + { + let _ = unix_mode; + let mut file = options.open(path).expect("create checkpoint fixture"); + file.write_all(&bytes).expect("write checkpoint fixture"); + file.sync_all().expect("sync checkpoint fixture"); + } +} + +fn real_e2e_tool_plan_checkpoint_project_root_sha256(root: &Path) -> String { + let canonical = fs::canonicalize(root).expect("canonicalize checkpoint project root"); + let canonical = canonical + .to_str() + .expect("checkpoint project root must be UTF-8"); + format!("{:x}", Sha256::digest(canonical.as_bytes())) +} + +fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse { + platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "real-e2e-checkpoint-model".to_string(), + text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(), + finish_reason: Some("tool_calls".to_string()), + response_id: Some("real-e2e-checkpoint-private-response-id".to_string()), + usage: None, + tool_calls: vec![platform_llm::LlmToolCall { + id: "real-e2e-checkpoint-private-call-id".to_string(), + name: AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(), + arguments: serde_json::json!({ + "thinkingSummary": "checkpoint fixture", + "planUpdate": null, + "plan": [], + "actions": [], + "response": REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT, + }) + .to_string(), + }], + } +} + +struct RealE2eToolPlanCheckpointFixture { + root: PathBuf, + config_dir: PathBuf, + _config_guard: TestRuntimeConfigDirGuard, + state: AgentRuntimeState, + snapshot: AgentRuntimeProviderRequestSnapshot, + capability: String, + control: Value, +} + +impl RealE2eToolPlanCheckpointFixture { + fn new(run_id: &str, expires_after_ms: u64) -> Self { + let root = unique_project_path(); + let state = start_agent_runtime_steer_fixture(&root, run_id); + let request_slot = "loop-0-repair-0"; + let snapshot = capture_game_creator_agent_runtime_provider_request_snapshot( + &root, + &state.agent_id, + &state.session_id, + &state.run_id, + "tool-plan", + request_slot, + state.applied_steer_cursor, + ) + .expect("capture checkpoint Provider snapshot"); + let config_dir = prepare_game_creator_runtime_config_dir(&unique_project_path()) + .expect("prepare private checkpoint AppData"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + let sentinel_token = format!( + "sentinel-{:x}", + Sha256::digest(format!("sentinel-{run_id}").as_bytes()) + ); + let sentinel_created_at = crate::provider_retry::now_ms(); + write_real_e2e_tool_plan_checkpoint_json( + &config_dir.join(REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_FILE), + &serde_json::json!({ + "schemaVersion": REAL_E2E_TOOL_PLAN_CHECKPOINT_SENTINEL_SCHEMA, + "token": sentinel_token, + "ownerPid": std::process::id(), + "createdAt": sentinel_created_at, + }), + 0o600, + ); + let created_at_ms = crate::provider_retry::now_ms().max(sentinel_created_at); + let capability = format!( + "{:x}", + Sha256::digest(format!("checkpoint-capability-{run_id}").as_bytes()) + ); + let control = serde_json::json!({ + "schemaVersion": REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_SCHEMA, + "capability": capability, + "sentinelToken": sentinel_token, + "ownerPid": std::process::id(), + "createdAtMs": created_at_ms, + "expiresAtMs": created_at_ms.saturating_add(expires_after_ms), + "projectRootSha256": real_e2e_tool_plan_checkpoint_project_root_sha256(&root), + "agentId": state.agent_id, + "runId": state.run_id, + "requestSlot": request_slot, + }); + Self { + root, + config_dir, + _config_guard: config_guard, + state, + snapshot, + capability, + control, + } + } + + fn control_path(&self) -> PathBuf { + self.config_dir + .join(REAL_E2E_TOOL_PLAN_CHECKPOINT_CONTROL_FILE) + } + + fn ack_path(&self) -> PathBuf { + self.config_dir.join(REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE) + } + + fn write_control(&self, unix_mode: u32) { + write_real_e2e_tool_plan_checkpoint_json(&self.control_path(), &self.control, unix_mode); + } + + fn cleanup(self) { + let root = self.root.clone(); + let config_dir = self.config_dir.clone(); + drop(self); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); + } +} + +async fn wait_for_real_e2e_tool_plan_checkpoint_ack(path: &Path) -> String { + for _ in 0..100 { + if let Ok(content) = fs::read_to_string(path) { + if serde_json::from_str::(&content).is_ok() { + return content; + } + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("tool-plan handoff checkpoint ack was not committed"); +} + +fn assert_real_e2e_tool_plan_checkpoint_phase( + fixture: &RealE2eToolPlanCheckpointFixture, + expected_phase: &str, +) { + let runtime = read_game_creator_agent_runtime_at(&fixture.root, &fixture.state.agent_id) + .expect("read checkpoint failed-closed Runtime"); + assert_eq!(runtime.state.run_id, fixture.state.run_id); + assert_eq!(runtime.state.phase, expected_phase); + let lifecycle = read_agent_db_records_for_test(&fixture.root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == fixture.state.run_id + && record["requestKind"] == "tool-plan" + && record["requestSlot"] == "loop-0-repair-0" + }) + .collect::>(); + assert_eq!(lifecycle.len(), 1); + assert_eq!(lifecycle[0]["status"], "started"); +} + +fn assert_real_e2e_tool_plan_checkpoint_reconciliation(fixture: &RealE2eToolPlanCheckpointFixture) { + assert_real_e2e_tool_plan_checkpoint_phase(fixture, "needs-reconciliation"); +} + +fn assert_real_e2e_tool_plan_checkpoint_error_is_private( + fixture: &RealE2eToolPlanCheckpointFixture, + error: &str, +) { + assert!(error.contains("provider-request-needs-reconciliation")); + assert!(error.contains("toolPlanCheckpoint=")); + assert!(!error.contains(&fixture.capability)); + assert!(!error.contains(REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE)); + assert!(!error.contains(REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT)); + assert!(!error.contains( + fixture + .root + .to_str() + .expect("checkpoint test root must be UTF-8") + )); + assert!(!error.contains( + fixture + .config_dir + .to_str() + .expect("checkpoint AppData must be UTF-8") + )); +} + +fn real_e2e_tool_plan_checkpoint_temp_paths(config_dir: &Path) -> Vec { + fs::read_dir(config_dir) + .expect("read checkpoint AppData") + .filter_map(Result::ok) + .filter_map(|entry| { + entry + .file_name() + .to_str() + .is_some_and(|name| { + name.starts_with(&format!("{REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_FILE}.tmp-")) + }) + .then(|| entry.path()) + }) + .collect() +} + +#[test] +fn tool_plan_handoff_real_e2e_checkpoint_ack_publish_is_complete_and_noreplace() { + let fixture = + RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-atomic-ack-run", 5_000); + let ack_bytes = br#"{"schemaVersion":"atomic-ack-test","complete":true}"#; + let mut before_publish = false; + let mut after_publish = false; + let persisted = publish_game_creator_agent_runtime_tool_plan_checkpoint_ack_for_test( + &fixture.config_dir, + ack_bytes, + |phase, temporary_path, final_path| match phase { + AgentRuntimeRealE2eAckPublishPhase::BeforePublish => { + before_publish = true; + assert!(!final_path.exists()); + assert_eq!( + fs::read(temporary_path).expect("read complete ACK temp"), + ack_bytes + ); + assert_eq!( + real_e2e_tool_plan_checkpoint_temp_paths(&fixture.config_dir), + vec![temporary_path.to_path_buf()] + ); + } + AgentRuntimeRealE2eAckPublishPhase::AfterPublish => { + after_publish = true; + assert!(!temporary_path.exists()); + assert_eq!(fs::read(final_path).expect("read published ACK"), ack_bytes); + assert!(real_e2e_tool_plan_checkpoint_temp_paths(&fixture.config_dir).is_empty()); + } + }, + ) + .expect("atomically publish complete ACK"); + assert!(before_publish && after_publish); + assert_eq!(persisted, ack_bytes); + fixture.cleanup(); + + let competing = + RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-existing-ack-run", 5_000); + let existing_ack = serde_json::json!({"existing": "ACK must win"}); + let existing_bytes = serde_json::to_vec(&existing_ack).expect("serialize existing ACK"); + let mut injected_existing = false; + let error = publish_game_creator_agent_runtime_tool_plan_checkpoint_ack_for_test( + &competing.config_dir, + br#"{"candidate":"must not replace"}"#, + |phase, temporary_path, final_path| { + if phase == AgentRuntimeRealE2eAckPublishPhase::BeforePublish { + injected_existing = true; + assert_eq!( + fs::read(temporary_path).expect("read competing ACK temp"), + br#"{"candidate":"must not replace"}"# + ); + write_real_e2e_tool_plan_checkpoint_json(final_path, &existing_ack, 0o600); + } + }, + ) + .expect_err("atomic ACK publish must not replace a competing final"); + assert!(injected_existing); + assert!(error.contains("checkpoint-needs-reconciliation")); + assert_eq!( + fs::read(competing.ack_path()).expect("read preserved existing ACK"), + existing_bytes + ); + assert!(real_e2e_tool_plan_checkpoint_temp_paths(&competing.config_dir).is_empty()); + competing.cleanup(); +} + +#[tokio::test] +async fn tool_plan_handoff_real_e2e_checkpoint_is_disabled_without_control() { + let fixture = RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-disabled-run", 5_000); + let response = await_game_creator_agent_runtime_tool_plan_checkpoint_for_test( + &fixture.root, + fixture.snapshot.clone(), + real_e2e_tool_plan_checkpoint_response(), + ) + .await + .expect("missing control must not alter Provider completion") + .expect("Provider response must remain available"); + assert_eq!( + response.text, + REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE + ); + assert!(!fixture.ack_path().exists()); + let lifecycle = read_agent_db_records_for_test(&fixture.root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == fixture.state.run_id + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle[0]["status"], "started"); + assert_eq!(lifecycle[1]["status"], "completed"); + fixture.cleanup(); +} + +#[tokio::test] +async fn tool_plan_handoff_real_e2e_checkpoint_writes_private_ack_then_fails_on_delete() { + let fixture = RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-delete-run", 5_000); + fixture.write_control(0o600); + let root = fixture.root.clone(); + let snapshot = fixture.snapshot.clone(); + let mut checkpoint = tokio::spawn(async move { + await_game_creator_agent_runtime_tool_plan_checkpoint_for_test( + &root, + snapshot, + real_e2e_tool_plan_checkpoint_response(), + ) + .await + }); + + let ack_content = wait_for_real_e2e_tool_plan_checkpoint_ack(&fixture.ack_path()).await; + let ack = serde_json::from_str::(&ack_content).expect("parse checkpoint ack"); + let ack_keys = ack + .as_object() + .expect("checkpoint ack object") + .keys() + .cloned() + .collect::>(); + assert_eq!( + ack_keys, + [ + "schemaVersion", + "capabilitySha256", + "projectRootSha256", + "agentId", + "runId", + "requestSlot", + "providerRequestIdSha256", + "responseFingerprint", + "reachedAtMs", + ] + .into_iter() + .map(str::to_string) + .collect() + ); + assert_eq!( + ack["schemaVersion"], + REAL_E2E_TOOL_PLAN_CHECKPOINT_ACK_SCHEMA + ); + assert_eq!( + ack["capabilitySha256"], + format!("{:x}", Sha256::digest(fixture.capability.as_bytes())) + ); + assert_eq!( + ack["projectRootSha256"], + real_e2e_tool_plan_checkpoint_project_root_sha256(&fixture.root) + ); + assert_eq!(ack["agentId"], fixture.state.agent_id); + assert_eq!(ack["runId"], fixture.state.run_id); + assert_eq!(ack["requestSlot"], "loop-0-repair-0"); + assert!(ack["reachedAtMs"].as_u64().is_some_and(|value| value > 0)); + let handoff = crate::tool_plan_handoff::read_for_run_at( + &fixture.root, + &fixture.state.agent_id, + &fixture.state.run_id, + ) + .expect("read checkpoint handoff") + .expect("checkpoint handoff exists"); + assert_eq!(handoff.entries.len(), 1); + let entry = &handoff.entries[0]; + assert_eq!(ack["responseFingerprint"], entry.response_fingerprint); + assert_eq!( + ack["providerRequestIdSha256"], + format!("{:x}", Sha256::digest(entry.provider_request_id.as_bytes())) + ); + assert!(!ack_content.contains(&fixture.capability)); + assert!(!ack_content.contains(&entry.provider_request_id)); + assert!(!ack_content.contains(REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE)); + assert!(!ack_content.contains(REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_ARGUMENT)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::symlink_metadata(fixture.ack_path()).expect("checkpoint ack metadata"); + assert!(metadata.is_file()); + assert!(!metadata.file_type().is_symlink()); + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + } + assert!( + tokio::time::timeout(Duration::from_millis(120), &mut checkpoint) + .await + .is_err() + ); + fs::remove_file(fixture.control_path()).expect("delete checkpoint control"); + let error = tokio::time::timeout(Duration::from_secs(2), checkpoint) + .await + .expect("checkpoint must fail promptly after control deletion") + .expect("join checkpoint task") + .expect_err("deleted checkpoint control must fail closed"); + assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error); + assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture); + fixture.cleanup(); +} + +#[tokio::test] +async fn tool_plan_handoff_real_e2e_checkpoint_fails_closed_at_expiry() { + let fixture = RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-expiry-run", 800); + fixture.write_control(0o600); + let root = fixture.root.clone(); + let snapshot = fixture.snapshot.clone(); + let mut checkpoint = tokio::spawn(async move { + await_game_creator_agent_runtime_tool_plan_checkpoint_for_test( + &root, + snapshot, + real_e2e_tool_plan_checkpoint_response(), + ) + .await + }); + wait_for_real_e2e_tool_plan_checkpoint_ack(&fixture.ack_path()).await; + assert!( + tokio::time::timeout(Duration::from_millis(120), &mut checkpoint) + .await + .is_err() + ); + let error = tokio::time::timeout(Duration::from_secs(2), checkpoint) + .await + .expect("checkpoint must stop at expiresAtMs") + .expect("join expiring checkpoint task") + .expect_err("expired checkpoint must fail closed"); + assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error); + assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture); + assert!(fixture.control_path().exists()); + fixture.cleanup(); +} + +#[tokio::test] +async fn tool_plan_handoff_real_e2e_checkpoint_rejects_ttl_above_ten_minutes() { + let fixture = RealE2eToolPlanCheckpointFixture::new( + "tool-plan-checkpoint-overlong-ttl-run", + 10 * 60 * 1_000 + 1, + ); + fixture.write_control(0o600); + let error = await_game_creator_agent_runtime_tool_plan_checkpoint_for_test( + &fixture.root, + fixture.snapshot.clone(), + real_e2e_tool_plan_checkpoint_response(), + ) + .await + .expect_err("checkpoint TTL above ten minutes must fail closed"); + assert!(!fixture.ack_path().exists()); + assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error); + assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture); + fixture.cleanup(); +} + +#[tokio::test] +async fn tool_plan_handoff_real_e2e_checkpoint_rechecks_cancel_and_project_revision_after_ack() { + for drift in ["cancel", "project-revision"] { + let fixture = RealE2eToolPlanCheckpointFixture::new( + &format!("tool-plan-checkpoint-{drift}-drift-run"), + 5_000, + ); + fixture.write_control(0o600); + let root = fixture.root.clone(); + let snapshot = fixture.snapshot.clone(); + let checkpoint = tokio::spawn(async move { + await_game_creator_agent_runtime_tool_plan_checkpoint_for_test( + &root, + snapshot, + real_e2e_tool_plan_checkpoint_response(), + ) + .await + }); + wait_for_real_e2e_tool_plan_checkpoint_ack(&fixture.ack_path()).await; + + match drift { + "cancel" => write_game_creator_agent_runtime_cancel_request( + &fixture.root, + &fixture.state.agent_id, + &fixture.state.run_id, + "checkpoint drift test", + ) + .expect("write durable cancel drift"), + "project-revision" => { + let mut revision = read_game_creator_agent_runtime_project_revision(&fixture.root) + .expect("read checkpoint project revision"); + revision.revision = revision.revision.saturating_add(1); + revision.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_project_revision(&fixture.root, &revision) + .expect("write checkpoint project revision drift"); + } + _ => unreachable!(), + } + + let error = tokio::time::timeout(Duration::from_secs(2), checkpoint) + .await + .expect("checkpoint must detect post-ACK drift promptly") + .expect("join drifting checkpoint task") + .expect_err("post-ACK drift must fail closed"); + assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error); + assert_real_e2e_tool_plan_checkpoint_phase( + &fixture, + if drift == "cancel" { + "cancelling" + } else { + "needs-reconciliation" + }, + ); + fixture.cleanup(); + } +} + +#[tokio::test] +async fn tool_plan_handoff_real_e2e_checkpoint_rejects_identity_sentinel_and_capability() { + for case in ["identity", "sentinel", "capability"] { + let mut fixture = RealE2eToolPlanCheckpointFixture::new( + &format!("tool-plan-checkpoint-invalid-{case}-run"), + 5_000, + ); + match case { + "identity" => { + fixture.control["agentId"] = Value::String("different-agent".to_string()); + } + "sentinel" => { + fixture.control["sentinelToken"] = + Value::String("different-sentinel-token".to_string()); + } + "capability" => { + fixture.control["capability"] = Value::String("A".repeat(64)); + } + _ => unreachable!(), + } + fixture.write_control(0o600); + let error = await_game_creator_agent_runtime_tool_plan_checkpoint_for_test( + &fixture.root, + fixture.snapshot.clone(), + real_e2e_tool_plan_checkpoint_response(), + ) + .await + .expect_err("invalid checkpoint control must fail closed"); + assert!(!fixture.ack_path().exists()); + assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error); + assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture); + fixture.cleanup(); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn tool_plan_handoff_real_e2e_checkpoint_rejects_group_readable_control() { + let fixture = + RealE2eToolPlanCheckpointFixture::new("tool-plan-checkpoint-public-control-run", 5_000); + fixture.write_control(0o640); + let error = await_game_creator_agent_runtime_tool_plan_checkpoint_for_test( + &fixture.root, + fixture.snapshot.clone(), + real_e2e_tool_plan_checkpoint_response(), + ) + .await + .expect_err("group-readable checkpoint control must fail closed"); + assert!(!fixture.ack_path().exists()); + assert_real_e2e_tool_plan_checkpoint_error_is_private(&fixture, &error); + assert_real_e2e_tool_plan_checkpoint_reconciliation(&fixture); + fixture.cleanup(); +} + #[tokio::test] async fn provider_handoff_final_reply_restart_replays_success_without_network_request() { let root = unique_project_path(); @@ -54653,6 +55251,39 @@ fn project_supervisor_claimed_contract_query_is_exact_scoped_and_drives_repair() "invalid repair observation must return every authoritative field in persisted order" ); + let rejected_run_id_action = "project-supervisor-claimed-contract-repair-explicit-run-action"; + let rejected_run_id = durable.target_run_id.clone(); + let explicit_run_repair = observe_agent_runtime_agent_delegate( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + Some(rejected_run_id_action), + &serde_json::json!({ + "agentId": durable.target_agent_id, + "task": "验证返工不能复用或指定 child run 身份", + "acceptanceCriteria": observed_criteria.clone(), + "expectedArtifacts": observed_artifacts.clone(), + "repairOfDelegationId": original_delegation_id, + "runId": rejected_run_id + }), + ); + assert_eq!(explicit_run_repair.status, "failed"); + assert!(explicit_run_repair.summary.contains("runId 必须为 null")); + assert!(!explicit_run_repair.summary.contains(&durable.target_run_id)); + assert!(explicit_run_repair.detail.is_none()); + let rejected_delegation_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &durable.target_agent_id, + rejected_run_id_action, + ); + assert!( + read_static_delegate_delivery_at(&root, &rejected_delegation_id) + .expect("read absent explicit-run repair delivery") + .is_none(), + "invalid repair run identity must fail before reserving a delivery" + ); + let target_agent_id = contract["targetAgentId"] .as_str() .expect("observed target agent id") diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9e4dbc0d6..fca36fd02 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4940,5 +4940,5 @@ - 提交顺序:Provider 成功后必须先追加并回读 tool-plan handoff,之后才可为同一实际 requestId 写 lifecycle `completed`,再进入 parser、repair 或动作预检。`repair-0` 与所有 `repair-N` 统一使用持久 transient retry;恢复从当前 loop base 开始按序回放已有 entry,已成功请求零网络,前序回放不得删除后继 repair retry。 - 隐私与失败关闭:function arguments 只存在于私有 handoff 和后续 pending/action batch,公共 task/event/Agent DB/CLI/report 只写安全身份、哈希与计数。tool-plan protocol/repair 公共审计共同保存 Agent/task/Session/run/source、loop/repair/slot、响应指纹、Provider request ID SHA-256 和 protocol;protocol 只额外保存 function call 数量、call ID SHA-256 数组、catalog-bound function names、response ID SHA-256/字符数和归一化元数据,repair 只额外保存 attempt/maxAttempts、协议错误/响应 preview 哈希与字符数、call ID/function name SHA-256,不保存原始 callId/callIds/responseId/providerRequestId。审计写入在 Agent DB append 锁内按完整身份做全历史 compare-and-append,不使用 32 MiB 尾部近似去重。参数为保持语义不得静默脱敏;命中密钥、配置痕迹、结构化可执行路径中的项目/其它绝对路径、超限、乱序、slot/identity/requestId/response 冲突时进入 reconciliation。源码正文与计划叙述只做密钥检查,不能把 HTML 闭合标签或叙述路径误判为执行参数。格式错误但安全有界的 opaque arguments 只用于重建 repair,严格 parser/schema/catalog 通过前不能执行;未闭合或孤立 thinking wrapper 只持久化无正文的无效元数据,重放时仍必须进入 repair。 - 所有权与清理:账本保留同一 run 的已成功 planning entry,直到 run 完成、取消、失败、作废或明确 reconciliation 清理;这样单动作、多动作、confirmation、协作 batch 和直接回复都不会在下一 durable owner 建立前丢失。steer/cancel/终态/漂移清理前必须按整本账本补齐所有实际 requestId lifecycle,任一条失败时保留账本并进入 reconciliation。Runner 恢复会严格扫描 hash 路径、primary/`.previous` 和安全原子临时文件,清理合法终态遗留;Unix 全程使用固定目录句柄和根目录/Agent 目录 `flock`,安装用 `RENAME_EXCHANGE` 复核回滚,删除用 `RENAME_NOREPLACE` quarantine、inode 复核和原 fd 清空同步;Windows 使用相对父句柄及 `GetFileInformationByHandleEx` 句柄枚举,拒绝 reparse point/junction/硬链接并以禁止共享的独占句柄表示活跃 temp。两端都不依赖 PID 存活判断。未知、链接、目录身份替换或内容冲突项失败关闭。primary、`.previous` 或损坏账本阻止 `runner.shutdown_if_idle`。非协作同 UID 进程可主动忽略 Unix advisory lock,属于宿主 OS 信任边界,不纳入完整沙箱承诺。 -- 验收边界:确定性测试必须分别覆盖 base handoff 与 repair handoff 在 lifecycle completed 前停止,关闭 mock Provider 后恢复零网络、原 requestId 唯一闭合、repair/protocol audit 幂等、唯一 assistant/completed/committed stream和终局零 sidecar。规划中的真实 `supervisor-swarm-tool-plan-handoff-runner-kill` 应作为独立非默认 suite,使用 sentinel-owned AppData、随机 capability、精确 Agent/run/slot 和 pidfd 强杀;但该 suite 当前尚未实现、尚未注册,因此未执行且不得记 PASS,更不能记为真实外部验收。Provider 成功到 handoff 原子回读前的 unknown-result 及手动 context-compaction 仍不在本决策承诺内。 -- 当前证据:`tool_plan_` 61/61、`tool_plan_handoff_` 36/36、`provider_handoff_` 11/11、`provider_retry_` 21/21、`response_stream_` 31/31、`finalization_` 48/48、`finalization_resume_` 12/12;Tauri/Rust 串行全量 1043 tests 为 `1039 passed / 4 ignored / 0 failed`,Linux `cargo check` 与 `x86_64-pc-windows-gnu cargo check --tests` 均通过。默认并发全量曾分别在两个共享执行器异步投影断言上波动,两个失败用例精确复跑均通过,因此现行稳定门禁使用 `--test-threads=1`,默认并发结果只作竞态诊断。客户端 `308/308`(其中 `appSurface 280/280`)、E2E self-test、typecheck、变更脚本 ESLint、encoding、`platform-llm 41/41`、`platform-agent game_creation 17/17`、`shared-contracts game_creation_app 7/7` 与 agent-run smoke 全部通过。实现过程中发现并修复 thinking 归一化、源码路径误判、repair 漂移删账本、durable control 清理遗漏后继 repair lifecycle、复数敏感 key/Provider ID 泄漏、malformed JSON trivia 路径绕过、Agent DB 审计字段扩张、PID 复用 temp 误判、中间目录/文件名称换绑 TOCTOU、Windows 路径枚举 ABA 和审计尾部近似去重问题。`supervisor-swarm-tool-plan-handoff-runner-kill` 当前尚未实现、尚未注册,所以本轮没有执行,仍不得记 PASS。 +- 验收边界:确定性测试必须分别覆盖 base handoff 与 repair handoff 在 lifecycle completed 前停止,关闭 mock Provider 后恢复零网络、原 requestId 唯一闭合、repair/protocol audit 幂等、唯一 assistant/completed/committed stream和终局零 sidecar。独立非默认真实 suite `supervisor-swarm-tool-plan-handoff-runner-kill` 已实现并完成 Shell/Root 两级注册;它只使用 sentinel-owned sibling AppData 与 metadata-only zero-fault proxy,每轮随机 capability 严格绑定 project/Agent/run/实际 request slot。断点只能在 handoff 原子落盘并回读一致后、同一实际 requestId lifecycle `completed` 前 ACK,ACK 后才通过 pidfd `SIGKILL` 强杀 suite 自有 Runner。恢复必须在同一轮证明同一 requestId 唯一闭合、`networkReplayCount=0`、protocol/repair audit 幂等、handoff 与 durable batch plan fingerprint 对应、恢复消费前 action/pending/delivery/claim 等副作用为 `0`,并在终局得到零 sidecar、零重复、零临时资源残留和零正文/凭据/URL/绝对路径泄漏。2026-07-20 的真实单轮已经到达并通过上述 checkpoint,但随后因专业 Agent 连续连接失败而整轮 FAIL;另一独立轮因首批工具数不满足 fixture 也未通过,不能拼接为 PASS。Provider 成功到 handoff 原子落盘回读前的 unknown-result 及手动 context-compaction 仍不在本决策承诺内。 +- 当前证据:`tool_plan_`、`tool_plan_handoff_`、`provider_handoff_`、`provider_retry_`、`response_stream_`、`finalization_` 与 `finalization_resume_` 定向门禁均保持通过;本轮 `tool_plan_handoff_` 为 `44/44`,Supervisor collaboration 相关过滤为 `55/55`,权威返工合同用例为 `1/1`。Tauri/Rust 串行全量 1058 tests 为 `1054 passed / 4 ignored / 0 failed`,Linux `cargo check --tests` 与 `x86_64-pc-windows-gnu cargo check --tests` 均通过;E2E self-test、typecheck、变更脚本 ESLint、encoding 和 `git diff --check` 通过。默认并发全量只作竞态诊断,不替代 `--test-threads=1`。实现过程中发现并修复 thinking 归一化、源码路径误判、repair 漂移删账本、durable control 清理遗漏后继 repair lifecycle、复数敏感 key/Provider ID 泄漏、malformed JSON trivia 路径绕过、Agent DB 审计字段扩张、PID 复用 temp 误判、中间目录/文件名称换绑 TOCTOU、Windows 路径枚举 ABA 和审计尾部近似去重问题。真实 suite 的 checkpoint 已有单轮外部证据,但整轮仍无 PASS。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 39a385d2d..d3adf31e5 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -530,4 +530,13 @@ npm run check:server-rs-ddd - function arguments 只能进入私有 `0600` handoff 和后续 pending/action batch。参数命中密钥、配置痕迹或结构化可执行路径中的绝对路径时失败关闭,不得先脱敏再执行;源码正文与计划叙述不能用日志路径 token 扫描,以免把 HTML `` 当路径。未闭合/错配 thinking wrapper 要保留无正文的无效事实并走 repair,不能清洗成可执行计划。公共事件、Agent DB、CLI 和报告只保留哈希、计数与安全身份字段;tool-plan protocol 不保存原始 callId/callIds/responseId/providerRequestId,只保存 call ID SHA-256 数组、catalog-bound function names、response ID SHA-256/字符数和 Provider request ID SHA-256,repair 只保存 call ID/function name SHA-256 及协议错误/preview 哈希。protocol/repair 审计必须在 Agent DB append 锁内按完整身份全历史 compare-and-append。 - steer/cancel/终态/身份漂移删除 tool-plan handoff 前,必须先按账本顺序幂等闭合全部实际 requestId lifecycle;不能只闭合当前 base entry 后删除后继 repair。Runner 恢复必须扫描 hash 路径归属、primary/`.previous` 和安全临时文件,回收合法终态残留;Unix 读写、扫描和删除固定在逐层打开的目录句柄,handoff 根目录和 Agent 目录用跨进程 `flock` 序列化,临时文件再用非阻塞 `flock` 判断写入方是否仍持有。已有 primary 的安装通过 `RENAME_EXCHANGE` 双端复核并在冲突时回滚;删除先以 `RENAME_NOREPLACE` 隔离到可恢复 temp 名、复核 inode,再按原 fd 清空并同步私有内容。Windows 逐层使用相对父句柄打开,并用 `GetFileInformationByHandleEx` 直接枚举已验证目录句柄,拒绝 reparse point/junction 与硬链接,临时文件以禁止共享的独占句柄表示活跃写入。不得再用文件名中的 PID 或进程存活推断临时文件所有权。未知、链接、身份替换或内容冲突项保持 busy 并失败关闭;主动忽略 advisory lock 的同 UID 进程仍属于宿主 OS 信任边界,不能宣称为完整沙箱隔离。 - 修改 Provider handoff/retry/Runner idle 判断后,至少运行 `tool_plan_`、`tool_plan_handoff_`、`provider_handoff_`、`provider_retry_`、相关强杀恢复用例、Tauri 串行全量 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --test-threads=1`、编码检查和 `git diff --check`;涉及跨平台扫描、PID 或临时文件回收时追加 `cargo check --tests --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --target x86_64-pc-windows-gnu`。当前默认并发全量会受 Tauri 共享执行器饱和影响,曾在不同异步投影断言上偶发失败;它只作竞态诊断,失败时必须精确复跑,不能替代串行门禁,也不能把精确复跑结果伪装成默认并发 PASS。真实 Provider suite 单轮 PASS 前,确定性 mock 结果不得写成外部验收完成。 -- `supervisor-swarm-tool-plan-handoff-runner-kill` 目前只是规划中的真实 Provider 门禁,尚未实现且未注册;因此当前不能执行,也不得把“本轮没跑”或其它确定性结果记成该 suite PASS。 +- `supervisor-swarm-tool-plan-handoff-runner-kill` 已实现并在 Shell/Root 两级注册。真实外部 Provider 复验从仓库根目录运行: + +```bash +npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e -- --config-dir <发布AppData绝对路径> +``` + +- suite 必须使用 sentinel-owned sibling AppData;metadata-only zero-fault proxy 不注入 Provider 故障,为转发请求只做协议校验,请求日志仅记录验收所需的序号/时间等元数据,不得持久化或暴露 URL、method、headers、正文或凭据。每轮随机 capability 必须严格绑定 disposable project、目标 Agent、run 和实际 request slot,任一身份漂移、复用或越权命中都失败关闭。 +- checkpoint 只允许在目标 tool-plan handoff 原子落盘并逐字段回读一致后、同一实际 requestId lifecycle `completed` 前 ACK;收到 ACK 后才可用 pidfd `SIGKILL` 强杀 suite 自有 Runner。新 boot 恢复前不得出现由该计划产生的 action、pending、delivery、claim 或其它副作用。 +- 单轮验收必须证明同一 requestId 唯一闭合且没有替代 identity,proxy `networkReplayCount=0`,protocol/repair audit 幂等,handoff plan fingerprint 与恢复后的 durable pending/action batch 对应;终局 retry/tool-plan handoff/provider handoff/finalization/confirmation sidecar、重复 lifecycle/audit/action/message、capability/Runner/AppData 临时资源和正文/API Key/Provider URL/项目及正式配置绝对路径泄漏全部为 `0`。失败轮不得与后续轮拼接。 +- 当前该 suite 的实现、E2E self-test、Tauri/Rust 串行全量 `1054 passed / 4 ignored / 0 failed`、Linux `cargo check --tests` 与 `x86_64-pc-windows-gnu cargo check --tests` 已通过。2026-07-20 的真实外部 Provider 单轮已到达 checkpoint,并证明旧/新 Runner boot 切换、同一请求恢复、`networkReplayCount=0`、恢复前零 action/pending/delivery 与生命周期唯一闭合;但该轮随后因专业 Agent 连续连接失败而以 FAIL 结束,另一独立轮首批工具数不满足 fixture 也以 FAIL 结束,因此仍没有该 suite 的外部 PASS,且不得拼接两轮证据。Provider 成功到 handoff 原子落盘回读前的 unknown-result 仍未关闭,手动 context-compaction 也不在覆盖内;确定性 mock、命令注册成功或其它 suite PASS 都不能替代单轮完整真实验收。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 29111050f..005e03ce0 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1487,9 +1487,10 @@ V1.43 不放宽 V1.41 的文本型 `game-creator-provider-handoff.v1`,而是 - 单元测试覆盖严格 schema、原生 tool call 精确 round-trip、thinking 去除、base+repair 单调追加、幂等重写、乱序/冲突/超限、敏感内容拒绝、`.previous` 恢复、活跃/死亡 temp 判定、目录替换和双副本安全清理;Agent DB 审计另以真实双进程竞争固定 compare-and-append。primary、`.previous` 或损坏账本都必须使 Runner 保持 busy。 - Runtime 集成测试至少在 `repair-0` handoff 落盘且 lifecycle 仅 `started`、以及 malformed base 已完成而 `repair-1` handoff 落盘且 lifecycle 仅 `started` 两个断点停止 Runner。关闭 mock Provider 后恢复必须零网络,原 physical request lifecycle 唯一闭合,protocol/repair audit 不重复,最终 assistant/completed/stream 唯一,终局 retry/tool-plan handoff/finalization 均为零。 -- 规划中的真实 Provider 门禁名为非默认 `supervisor-swarm-tool-plan-handoff-runner-kill`:实现后只允许 sentinel-owned 隔离 AppData、随机 capability 和目标 Agent/run/request slot 启用断点,并复用 pidfd Runner 强杀、metadata-only proxy 与零泄漏扫描;它必须证明 checkpoint 到 durable batch 之间 `networkReplayCount=0`、handoff 与 batch plan fingerprint 一致、动作/pending/delivery/claim 无提前副作用和终局零残留。该 suite 当前尚未实现、尚未注册,因此未执行且不得记 PASS;确定性 mock PASS 不得替代它。 +- 非默认真实 Provider 门禁 `supervisor-swarm-tool-plan-handoff-runner-kill` 已实现并完成 Shell/Root 两级命令注册。suite 只使用 sentinel-owned sibling AppData 和不注入故障的 metadata-only zero-fault proxy;代理为转发请求只做协议校验,但请求日志仅保存序号与时间元数据,不持久化或暴露 URL、method、headers、正文或凭据。每轮生成随机 capability,并严格绑定 disposable project、目标 Agent、run 与实际 request slot,任一身份不匹配都不得 ACK 或强杀。断点只能在目标 tool-plan handoff 已原子落盘并逐字段回读一致、同一实际 requestId 的 lifecycle 尚未写入 `completed` 时 ACK,随后才允许通过 pidfd 向 suite 自有 Runner 发送 `SIGKILL`。 +- 恢复验收必须在同一轮证明:同一 requestId 只闭合一次且不产生替代 requestId,proxy 的 `networkReplayCount=0`,protocol/repair audit compare-and-append 幂等,handoff 与恢复后 durable pending/action batch 的 plan fingerprint 对应;ACK、强杀和恢复消费前不得出现由目标计划产生的 action、pending、delivery、claim 或其它副作用。终局 retry/tool-plan handoff/provider handoff/finalization/confirmation 等 sidecar、重复 lifecycle/audit/action/message、临时 capability/Runner 资源与 AppData 残留均为 `0`,公共报告中的 Provider URL、headers、正文、凭据及项目/正式配置绝对路径泄漏命中也必须为 `0`。2026-07-20 的真实外部 Provider 单轮已证明 checkpoint、Runner boot 切换、同一请求零网络重放、恢复前零副作用与唯一生命周期闭合,但随后专业 Agent 连续连接失败使整轮 FAIL;另一独立轮首批工具数不满足 fixture,同样未通过。两轮不得拼接,当前仍无该 suite 的完整外部 PASS。 - V1.43 仍不关闭“外部 Provider 已成功返回、但本地 handoff 尚未完成原子写入并回读”的 unknown-result 窗口;没有 Provider 级幂等键或结果查询能力时,该窗口继续进入人工 reconciliation,不能宣称端到端物理调用 exactly-once。手动 context-compaction 也不在本切片。 -- 2026-07-20 当前确定性证据:`tool_plan_` 61/61、`tool_plan_handoff_` 36/36、`provider_handoff_` 11/11、`provider_retry_` 21/21、`response_stream_` 31/31、`finalization_` 48/48、`finalization_resume_` 12/12;Tauri/Rust 串行全量 1043 tests 为 `1039 passed / 4 ignored / 0 failed`,Linux `cargo check` 与 `x86_64-pc-windows-gnu cargo check --tests` 均通过。默认并发全量曾分别在两个 Tauri 共享执行器异步投影断言上波动,两个失败用例精确复跑均通过,因此稳定门禁使用 `--test-threads=1`,默认并发只作竞态诊断。客户端测试总计 `308/308`,其中 `appSurface` 为 `280/280`;E2E self-test、typecheck、变更脚本 ESLint、encoding、`platform-llm 41/41`、`platform-agent game_creation 17/17` 和本地 agent-run smoke 均通过。实现过程中发现并固定 thinking 归一化与无效 wrapper、源码路径误判、repair 漂移删账本、durable control 清理遗漏后继 repair lifecycle、复数敏感 key/Provider ID 泄漏、malformed JSON trivia 路径绕过、Agent DB 审计字段扩张、PID 复用 temp 误判、中间目录/文件名称换绑 TOCTOU、Windows 路径枚举 ABA、终态/temp 遗留及 Agent DB 尾部近似去重问题。Unix handoff 存储使用固定目录句柄、根/Agent 双层 `flock`、`RENAME_EXCHANGE` 安装回滚和 `RENAME_NOREPLACE` quarantine;Windows 使用相对父句柄、`GetFileInformationByHandleEx` 句柄枚举与独占 temp 句柄,并拒绝 junction/reparse point 与硬链接。非协作同 UID 进程仍属于宿主 OS 信任边界,不能据此宣称完整沙箱。真实 `supervisor-swarm-tool-plan-handoff-runner-kill` 尚未实现、尚未注册,所以本轮没有执行,仍不得记 PASS。 +- 2026-07-20 当前确定性证据:本轮 `tool_plan_handoff_` 为 `44/44`,Supervisor collaboration 相关过滤为 `55/55`,权威返工合同用例为 `1/1`;Tauri/Rust 串行全量 1058 tests 为 `1054 passed / 4 ignored / 0 failed`,Linux `cargo check --tests` 与 `x86_64-pc-windows-gnu cargo check --tests` 均通过。E2E self-test、typecheck、变更脚本 ESLint、encoding 与 `git diff --check` 通过。默认并发全量只作竞态诊断,不替代 `--test-threads=1`。Unix handoff 存储使用固定目录句柄、根/Agent 双层 `flock`、`RENAME_EXCHANGE` 安装回滚和 `RENAME_NOREPLACE` quarantine;Windows 使用相对父句柄、`GetFileInformationByHandleEx` 句柄枚举与独占 temp 句柄,并拒绝 junction/reparse point 与硬链接。非协作同 UID 进程仍属于宿主 OS 信任边界,不能据此宣称完整沙箱。真实 suite 的 checkpoint 已有单轮外部证据,但整轮仍无 PASS。 ## 验收命令 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 9493ba0aa..e9201ef30 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -620,5 +620,5 @@ game-project/ - 第六轮 PASS 不改变 V1.41 handoff 原子落盘并回读前的 unknown-result 边界,tool-plan 成功响应/function arguments 的 durable handoff 仍未覆盖。 - 2026-07-20 起,同一 Runtime 文档的“V1.43 tool-plan 成功响应持久交接与 repair 链恢复”作为规划成功响应的现行恢复契约。V1.41 文本 handoff 保持不变;新增独立 `game-creator-tool-plan-handoff.v1` 私有账本,按同一 Agent/run 的 loop/repair 顺序保存实际 Provider requestId、retry identity、去 thinking 的响应、完整 function call envelope/arguments、usage 与响应指纹。`repair-0` 和全部 `repair-N` 统一进入持久 retry/handoff-first 路径,Runner 可从 base 开始零网络重放既有 repair 链。 - tool-plan arguments 只允许出现在 `0600` 原子 sidecar 及后续 pending/action batch,不得进入 task/event/Agent DB/CLI/report。公共 protocol/repair 审计共同保存 Agent/task/Session/run/source、loop/repair/slot、响应指纹、Provider request ID SHA-256 和 protocol;protocol 只保存 function call 数量、call ID SHA-256 数组、catalog-bound function names、response ID SHA-256/字符数及 normalization 元数据,repair 只保存 attempt/maxAttempts、协议错误/preview 哈希和 call ID/function name SHA-256,不保存原始 callId/callIds/responseId/providerRequestId,并在 Agent DB append 锁内按完整身份全历史幂等追加。为了保持执行语义,参数禁止静默脱敏;命中密钥、配置痕迹、敏感 JSON key、Provider ID 中的秘密/绝对路径、结构化可执行路径中的项目或其它绝对路径、大小/顺序/身份冲突时直接 reconciliation。源码正文和计划叙述只做密钥检查,不能把 HTML 闭合标签当绝对路径;未闭合 thinking 只留无正文无效元数据并继续 repair。账本保留到 run 终态或明确作废;steer/cancel/漂移/终态清理前先闭合整本账本的实际 requestId,Runner 恢复严格扫描 hash/primary/`.previous`/安全临时文件并回收合法终态残留,确保单动作、多动作、confirmation、协作 batch 与直接回复在下一 durable owner 建立前都有恢复来源;未知、冲突、primary、`.previous` 或损坏账本都阻止 Runner idle shutdown。 -- V1.43 的确定性门禁必须覆盖 base handoff 与 repair handoff 两个 lifecycle-completed 前断点,关闭 mock Provider 后恢复零网络、原 requestId 唯一闭合、repair/protocol audit 幂等、唯一 assistant/completed/committed stream及终局零 sidecar。规划中的真实 Provider 门禁名为 `supervisor-swarm-tool-plan-handoff-runner-kill`,但该独立非默认 suite 当前尚未实现、尚未注册,因此未执行且不得记 PASS;不能把 mock 结果记为它的外部验收。Provider 成功到 handoff 原子回读前的 unknown-result 和手动 context-compaction 仍不在本切片承诺内。 -- V1.43 当前确定性实现已通过 `tool_plan_` 61/61、`tool_plan_handoff_` 36/36、`provider_handoff_` 11/11、`provider_retry_` 21/21、`response_stream_` 31/31、`finalization_` 48/48、`finalization_resume_` 12/12,以及 Tauri/Rust 串行全量 `1039 passed / 4 ignored`;Linux `cargo check` 与 `x86_64-pc-windows-gnu cargo check --tests` 均通过。默认并发全量仍有共享执行器异步投影时序波动,精确失败用例均通过,因此不记默认并发 PASS。客户端 `308/308`(其中 `appSurface 280/280`)、E2E self-test、typecheck、变更脚本 ESLint、encoding、`platform-llm 41/41`、`platform-agent game_creation 17/17`、`shared-contracts game_creation_app 7/7` 与 agent-run smoke 已通过;Supervisor 真实 E2E 报告已把 `toolPlanHandoffSidecarCount` 纳入终局残留。handoff 跨平台存储使用 Unix 固定目录句柄、目录 `flock`、exchange/quarantine 与 Windows 相对父句柄、句柄枚举、独占 temp,不再根据 PID 推断写入方是否存活;主动忽略锁的同 UID 进程仍属于宿主 OS 信任边界。`supervisor-swarm-tool-plan-handoff-runner-kill` 尚未实现、尚未注册,所以本轮没有执行,不能记为外部 PASS。 +- V1.43 的确定性门禁必须覆盖 base handoff 与 repair handoff 两个 lifecycle-completed 前断点,关闭 mock Provider 后恢复零网络、原 requestId 唯一闭合、repair/protocol audit 幂等、唯一 assistant/completed/committed stream及终局零 sidecar。独立非默认真实门禁 `supervisor-swarm-tool-plan-handoff-runner-kill` 已实现并完成 Shell/Root 两级注册:它使用 sentinel-owned sibling AppData 与 metadata-only zero-fault proxy,以每轮随机 capability 严格绑定 project/Agent/run/实际 request slot;只有 tool-plan handoff 原子落盘并回读一致、同一实际 requestId lifecycle 尚未 `completed` 时才 ACK,随后通过 pidfd `SIGKILL` 强杀 suite 自有 Runner。恢复必须证明同一 requestId 唯一闭合且 `networkReplayCount=0`、protocol/repair audit 幂等、handoff 与 durable batch plan fingerprint 对应、恢复消费前 action/pending/delivery/claim 等副作用为 `0`,并在终局把 sidecar、重复记录、临时 capability/Runner/AppData 资源及公共正文、凭据、URL、项目/正式配置路径泄漏全部清零。2026-07-20 的真实外部 Provider 单轮已到达并通过 checkpoint,但随后专业 Agent 连续连接失败使整轮 FAIL;另一独立轮首批工具数不满足 fixture,也未通过。两轮不得拼接,当前仍无该 suite 的完整外部 PASS。Provider 成功到 handoff 原子落盘回读前的 unknown-result 和手动 context-compaction 仍不在本切片承诺内。 +- V1.43 当前确定性实现已通过本轮 `tool_plan_handoff_ 44/44`、Supervisor collaboration 相关过滤 `55/55`、权威返工合同 `1/1`,以及 Tauri/Rust 串行全量 `1054 passed / 4 ignored / 0 failed`;Linux `cargo check --tests` 与 `x86_64-pc-windows-gnu cargo check --tests` 均通过。E2E self-test、typecheck、变更脚本 ESLint、encoding 与 `git diff --check` 通过;默认并发全量只作竞态诊断,不替代串行门禁。Supervisor 真实 E2E 报告已把 `toolPlanHandoffSidecarCount` 纳入终局残留。handoff 跨平台存储使用 Unix 固定目录句柄、目录 `flock`、exchange/quarantine 与 Windows 相对父句柄、句柄枚举、独占 temp,不再根据 PID 推断写入方是否存活;主动忽略锁的同 UID 进程仍属于宿主 OS 信任边界。 diff --git a/package.json b/package.json index dd3a30f4d..394558f53 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "ai-game-creator-shell:agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-autonomous-chat-real-e2e --", "ai-game-creator-shell:agent-runtime:supervisor-swarm-transient-retry-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-transient-retry-real-e2e --", "ai-game-creator-shell:agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e --", + "ai-game-creator-shell:agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-real-e2e --", "ai-game-creator-shell:agent-runtime:steer-runner-kill-real-e2e": "npm --prefix apps/ai-game-creator-shell run agent-runtime:steer-runner-kill-real-e2e --", "ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck",