From 791a50ffe8bbbe1bad0f8f8499850ee34a83395c Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Mon, 20 Jul 2026 11:05:11 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84Agent=20Runtime=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=AE=A1=E5=88=92=E6=8C=81=E4=B9=85=E4=BA=A4=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 tool-plan 成功响应私有账本并支持 base 与 repair 零网络恢复 补齐 Provider 生命周期闭合、终态清理和 Runner 空闲门禁 收紧 Agent DB 审计白名单并拒绝原始 Provider 与调用标识 加固 Unix exchange/quarantine 与 Windows 句柄枚举存储路径 扩展 E2E 自测、跨平台回归和 V1.43 工程文档 --- .../scripts/agent-runtime-real-e2e.mjs | 300 +- .../src-tauri/Cargo.toml | 2 +- .../src-tauri/src/agent.rs | 1410 ++++- .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project.rs | 883 +++ .../src-tauri/src/runner.rs | 49 +- .../src-tauri/src/tests.rs | 447 +- .../src-tauri/src/tool_plan_handoff.rs | 5614 +++++++++++++++++ .../shared-memory/decision-log.md | 9 + .../shared-memory/development-workflow.md | 9 + ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 22 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + 12 files changed, 8599 insertions(+), 151 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs 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 ba9ff87e2..ab7053aae 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 @@ -447,16 +447,22 @@ const toolPlanNormalizationKinds = new Set([ 'complete-think-block', 'planner-commentary', ]); -const toolPlanProtocolAuditSafeFields = new Set( - 'schemaVersion updatedAt recordType agentId sessionId runId loopIteration protocol callId functionName functionCallCount callIds functionNames normalizationKinds normalizationCount normalizedTextChars normalizedTextSha256 responseId'.split( +const toolPlanAuditCommonSafeFields = + 'schemaVersion updatedAt recordType agentId taskId sessionId runId source loopIteration repairAttempt requestSlot responseFingerprint providerRequestIdSha256 protocol'.split( + ' ', + ); +const toolPlanProtocolAuditSafeFields = new Set([ + ...toolPlanAuditCommonSafeFields, + ...'functionCallCount callIdSha256s functionNames responseIdSha256 responseIdChars normalizationKinds normalizationCount normalizedTextChars normalizedTextSha256'.split( ' ', ), -); -const toolPlanRepairAuditSafeFields = new Set( - 'schemaVersion updatedAt recordType agentId sessionId runId loopIteration attempt maxAttempts protocolErrorKind protocolErrorSha256 protocolErrorChars responsePreviewSha256 responsePreviewChars protocol callIdSha256 functionNameSha256'.split( +]); +const toolPlanRepairAuditSafeFields = new Set([ + ...toolPlanAuditCommonSafeFields, + ...'attempt maxAttempts protocolErrorKind protocolErrorSha256 protocolErrorChars responsePreviewSha256 responsePreviewChars callIdSha256 functionNameSha256'.split( ' ', ), -); +]); const processSessionSuites = new Set([ 'process-session', 'process-session-runner-kill', @@ -5172,11 +5178,7 @@ async function validateProjectSkillEvidence() { toolPlanRepairEvidenceHasNoFatalLocalRepair(toolPlanRepairEvidence) && projectSkillToolPlanProtocols.every( (record) => - Number.isSafeInteger(record.functionCallCount) && - record.functionCallCount > 0 && - Array.isArray(record.callIds) && - record.callIds.length === record.functionCallCount && - new Set(record.callIds).size === record.callIds.length && + hasValidToolPlanProtocolCallProjection(record) && Array.isArray(record.functionNames) && record.functionNames.length === record.functionCallCount && record.functionNames.every( @@ -10454,9 +10456,7 @@ function supervisorSwarmFinalReplyFaultPrerequisites( observedClaimReceipts: claimedReceiptIds.length === 3 && new Set(claimedReceiptIds).size === 3 && - claimedReceiptIds.every((delegationId) => - deliveryIds.has(delegationId), - ), + claimedReceiptIds.every((delegationId) => deliveryIds.has(delegationId)), finalReplyTaskIdentity: finalReplyStarted.taskId != null, assistantAbsent: assistantCount === 0, projectRevision: @@ -11060,9 +11060,7 @@ async function captureSupervisorSwarmTransientRetryCheckpoint() { postRestartPersistence.deliveries, ) .map(supervisorSwarmDeliveryIdentity) - .sort((left, right) => - left.delegationId.localeCompare(right.delegationId), - ); + .sort((left, right) => left.delegationId.localeCompare(right.delegationId)); const postRestartClaimIdentities = postRestartPersistence.claims .filter( (claim) => @@ -11308,11 +11306,10 @@ async function captureSupervisorSwarmTransientRetryCheckpoint() { finalReplyPrerequisitesValid: waitingBoundary.faultSelection?.preconditionsValid === true, preFaultHostVerificationPassed: - waitingBoundary.faultSelection?.preFaultHostVerificationPassed === - true, + waitingBoundary.faultSelection?.preFaultHostVerificationPassed === true, preFaultHostVerificationCompletedAtMs: - waitingBoundary.faultSelection - ?.preFaultHostVerificationCompletedAtMs ?? 0, + waitingBoundary.faultSelection?.preFaultHostVerificationCompletedAtMs ?? + 0, repairDeliveryCount: waitingBoundary.faultSelection?.repairDeliveryCount ?? 0, observedClaimReceiptCount: @@ -11891,11 +11888,11 @@ function validateSupervisorSwarmNativeProtocol( ) && record.functionNames.filter((name) => name === 'update_agent_plan') .length <= 1 && - Number.isSafeInteger(record.functionCallCount) && + hasValidToolPlanProtocolCallProjection(record) && record.functionCallCount === record.functionNames.length && - Array.isArray(record.callIds) && - record.callIds.length === record.functionCallCount && - new Set(record.callIds).size === record.callIds.length + Array.isArray(record.callIdSha256s) && + record.callIdSha256s.length === record.functionCallCount && + new Set(record.callIdSha256s).size === record.callIdSha256s.length ); }); assert( @@ -11923,11 +11920,11 @@ function validateSupervisorSwarmNativeProtocol( ) && record.functionNames.filter((name) => name === 'update_agent_plan') .length <= 1 && - Number.isSafeInteger(record.functionCallCount) && + hasValidToolPlanProtocolCallProjection(record) && record.functionCallCount === record.functionNames.length && - Array.isArray(record.callIds) && - record.callIds.length === record.functionCallCount && - new Set(record.callIds).size === record.callIds.length + Array.isArray(record.callIdSha256s) && + record.callIdSha256s.length === record.functionCallCount && + new Set(record.callIdSha256s).size === record.callIdSha256s.length ); }) : []; @@ -12737,6 +12734,7 @@ async function readSupervisorSwarmResidualSidecarCounts() { providerActionBatches: '.agent/runtime/provider-action-batches', providerHandoffs: '.agent/runtime/provider-handoffs', providerRetries: '.agent/runtime/provider-retries', + toolPlanHandoffs: '.agent/runtime/tool-plan-handoffs', userInput: '.agent/runtime/user-input', }; const counts = {}; @@ -12746,6 +12744,7 @@ async function readSupervisorSwarmResidualSidecarCounts() { 'finalizations', 'providerHandoffs', 'providerRetries', + 'toolPlanHandoffs', ].includes(name) ? files.length : files.filter((file) => file.endsWith('.json')).length; @@ -14482,6 +14481,7 @@ async function validateSupervisorSwarmEvidence() { providerActionBatchSidecarCount: residualSidecars.providerActionBatches, providerHandoffSidecarCount: residualSidecars.providerHandoffs, providerRetrySidecarCount: residualSidecars.providerRetries, + toolPlanHandoffSidecarCount: residualSidecars.toolPlanHandoffs, parallelReadBatchSidecarCount: residualSidecars.parallelReadBatches, finalizationJournalCount: residualSidecars.finalizations, confirmationSidecarCount: residualSidecars.confirmations, @@ -14511,6 +14511,7 @@ async function validateSupervisorSwarmEvidence() { '.agent/runtime/collaboration-policy-snapshot-bindings', '.agent/runtime/provider-action-batches', '.agent/runtime/provider-handoffs', + '.agent/runtime/tool-plan-handoffs', '.agent/runtime/response-streams', '.agent/runtime/tasks', '.agent/runtime/events', @@ -15590,6 +15591,7 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { providerActionBatchSidecarCount: residualSidecars.providerActionBatches, providerHandoffSidecarCount: residualSidecars.providerHandoffs, providerRetrySidecarCount: residualSidecars.providerRetries, + toolPlanHandoffSidecarCount: residualSidecars.toolPlanHandoffs, parallelReadBatchSidecarCount: residualSidecars.parallelReadBatches, finalizationJournalCount: residualSidecars.finalizations, confirmationSidecarCount: residualSidecars.confirmations, @@ -15613,6 +15615,7 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { '.agent/runtime/provider-action-batches', '.agent/runtime/provider-handoffs', '.agent/runtime/provider-retries', + '.agent/runtime/tool-plan-handoffs', '.agent/runtime/response-streams', '.agent/runtime/tasks', '.agent/runtime/events', @@ -28153,6 +28156,7 @@ function supervisorSwarmEvidenceFieldTemplate() { providerActionBatchSidecarCount: 0, providerHandoffSidecarCount: 0, providerRetrySidecarCount: 0, + toolPlanHandoffSidecarCount: 0, parallelReadBatchSidecarCount: 0, finalizationJournalCount: 0, confirmationSidecarCount: 0, @@ -29439,8 +29443,10 @@ function validateMainRunToolPlanProtocols(records) { ); assert(protocols.length > 0, 'main-tool-plan-protocol-missing'); assert( - protocols.every((record) => - supportedToolPlanProtocols.has(record.protocol), + protocols.every( + (record) => + supportedToolPlanProtocols.has(record.protocol) && + hasSafeToolPlanAuditPayload(record), ), 'main-tool-plan-protocol-invalid', ); @@ -29453,6 +29459,53 @@ function emptyToolPlanRepairCountsByProtocolErrorKind() { ); } +function isCatalogBoundToolPlanFunctionName(name, protocol) { + if (!/^[a-z][a-z0-9_]{0,127}$/u.test(name)) return false; + if (protocol === 'native_function') { + return name === 'submit_agent_tool_plan'; + } + if (protocol !== 'native_runtime_tools') return false; + return ( + ['update_agent_plan', 'respond_to_user'].includes(name) || + name.startsWith('runtime_tool_') || + name.startsWith('mcp_tool_') + ); +} + +function hasValidToolPlanProtocolCallProjection(record) { + const isHash = (value) => /^[0-9a-f]{64}$/u.test(value); + if ( + !Number.isSafeInteger(record.functionCallCount) || + record.functionCallCount < 0 || + !Array.isArray(record.callIdSha256s) || + record.callIdSha256s.length !== record.functionCallCount || + !record.callIdSha256s.every(isHash) || + new Set(record.callIdSha256s).size !== record.callIdSha256s.length || + !Array.isArray(record.functionNames) || + record.functionNames.length !== record.functionCallCount + ) { + return false; + } + if (record.protocol === 'text_json') { + return record.functionCallCount === 0; + } + if (record.protocol === 'native_function') { + return ( + record.functionCallCount === 1 && + record.functionNames.every((name) => + isCatalogBoundToolPlanFunctionName(name, record.protocol), + ) + ); + } + return ( + record.protocol === 'native_runtime_tools' && + record.functionCallCount > 0 && + record.functionNames.every((name) => + isCatalogBoundToolPlanFunctionName(name, record.protocol), + ) + ); +} + function hasSafeToolPlanAuditPayload(record) { const safeFields = record?.recordType === 'agent.runtime.tool_plan.protocol' @@ -29460,56 +29513,57 @@ function hasSafeToolPlanAuditPayload(record) { : record?.recordType === 'agent.runtime.tool_plan.repair' ? toolPlanRepairAuditSafeFields : null; + const isHash = (value) => /^[0-9a-f]{64}$/u.test(value); if ( !safeFields || + !isPlainObject(record) || + Object.keys(record).length !== safeFields.size || Object.keys(record).some((field) => !safeFields.has(field)) || record.schemaVersion !== 'game-creator-agent-db.v1' || !Number.isSafeInteger(record.updatedAt) || record.updatedAt <= 0 || + !['agentId', 'taskId', 'sessionId', 'runId', 'source'].every((field) => + isNonEmptyString(record[field]), + ) || !supportedToolPlanProtocols.has(record.protocol) || !Number.isSafeInteger(record.loopIteration) || - record.loopIteration < 0 + record.loopIteration < 0 || + !Number.isSafeInteger(record.repairAttempt) || + record.repairAttempt < 0 || + record.requestSlot !== + `loop-${record.loopIteration}-repair-${record.repairAttempt}` || + !isHash(record.responseFingerprint) || + !isHash(record.providerRequestIdSha256) ) { return false; } - const isHash = (value) => /^[0-9a-f]{64}$/u.test(value); if (record.recordType === 'agent.runtime.tool_plan.repair') { + const hasCallIdentity = + isHash(record.callIdSha256) && isHash(record.functionNameSha256); + const hasNoCallIdentity = + record.callIdSha256 == null && record.functionNameSha256 == null; return ( toolPlanProtocolErrorKindSet.has(record.protocolErrorKind) && - Number.isSafeInteger(record.loopIteration) && - record.loopIteration >= 0 && Number.isSafeInteger(record.attempt) && record.attempt > 0 && + record.attempt === record.repairAttempt + 1 && Number.isSafeInteger(record.maxAttempts) && record.maxAttempts >= record.attempt && - ['protocolErrorSha256', 'responsePreviewSha256'].every( - (field) => !Object.hasOwn(record, field) || isHash(record[field]), - ) && - ['callIdSha256', 'functionNameSha256'].every( - (field) => - !Object.hasOwn(record, field) || - record[field] == null || - isHash(record[field]), - ) && + isHash(record.protocolErrorSha256) && + isHash(record.responsePreviewSha256) && ['protocolErrorChars', 'responsePreviewChars'].every( - (field) => - !Object.hasOwn(record, field) || - (Number.isSafeInteger(record[field]) && record[field] >= 0), - ) + (field) => Number.isSafeInteger(record[field]) && record[field] >= 0, + ) && + (record.protocol === 'text_json' ? hasNoCallIdentity : hasCallIdentity) ); } - const normalizationFields = [ - 'normalizationKinds', - 'normalizationCount', - 'normalizedTextChars', - 'normalizedTextSha256', - ]; - const presentCount = normalizationFields.filter((field) => - Object.hasOwn(record, field), - ).length; - if (presentCount === 0) return true; if ( - presentCount !== normalizationFields.length || + !hasValidToolPlanProtocolCallProjection(record) || + !Number.isSafeInteger(record.responseIdChars) || + record.responseIdChars < 0 || + (record.responseIdSha256 == null + ? record.responseIdChars !== 0 + : !isHash(record.responseIdSha256) || record.responseIdChars === 0) || !Array.isArray(record.normalizationKinds) || !Number.isSafeInteger(record.normalizationCount) || record.normalizationCount < 0 || @@ -29543,6 +29597,7 @@ function collectToolPlanRepairAuditEvidence(audits) { let secondRepairCount = 0; for (const repair of repairs) { assert( + hasSafeToolPlanAuditPayload(repair) && toolPlanProtocolErrorKindSet.has(repair.protocolErrorKind) && isNonEmptyString(repair.agentId) && isNonEmptyString(repair.runId) && @@ -29633,11 +29688,7 @@ function validateNativeRuntimeToolPlanProtocolEvidence(records) { toolPlanRepairEvidenceHasNoFatalLocalRepair(evidence) && protocols.every( (record) => - Number.isSafeInteger(record.functionCallCount) && - record.functionCallCount > 0 && - Array.isArray(record.callIds) && - record.callIds.length === record.functionCallCount && - new Set(record.callIds).size === record.callIds.length && + hasValidToolPlanProtocolCallProjection(record) && Array.isArray(record.functionNames) && record.functionNames.length === record.functionCallCount && record.functionNames.every( @@ -32715,8 +32766,10 @@ function runAgentRuntimeRealE2eSelfTests() { ); const syntheticIdentity = { agentId: 'private-agent-id', + taskId: 'private-task-id', sessionId: 'private-session-id', runId: 'private-run-id', + source: 'private-source', }; const persistedAuditEnvelope = { schemaVersion: 'game-creator-agent-db.v1', @@ -32730,6 +32783,10 @@ function runAgentRuntimeRealE2eSelfTests() { recordType: 'agent.runtime.tool_plan.repair', ...syntheticIdentity, loopIteration: index, + repairAttempt: 0, + requestSlot: `loop-${index}-repair-0`, + responseFingerprint: hashValue(`private-response-${kind}`), + providerRequestIdSha256: hashValue(`private-provider-request-${kind}`), attempt: 1, maxAttempts: 2, protocolErrorKind: kind, @@ -32738,31 +32795,48 @@ function runAgentRuntimeRealE2eSelfTests() { responsePreviewSha256: hashValue(`private-preview-${kind}`), responsePreviewChars: 26, protocol: 'native_runtime_tools', - callIdSha256: null, - functionNameSha256: null, + callIdSha256: hashValue(`private-call-${kind}`), + functionNameSha256: hashValue(`runtime_tool_${kind}`), })); - repairRecords.push({ ...repairRecords[0], attempt: 2 }); + repairRecords.push({ + ...repairRecords[0], + repairAttempt: 1, + requestSlot: 'loop-0-repair-1', + responseFingerprint: hashValue('private-response-shape-second'), + providerRequestIdSha256: hashValue('private-provider-request-shape-second'), + attempt: 2, + }); const fatalCatalogBindingRepair = { ...repairRecords[0], loopIteration: retryableProtocolErrorKinds.length, + requestSlot: `loop-${retryableProtocolErrorKinds.length}-repair-0`, + responseFingerprint: hashValue('private-response-catalog-binding'), + providerRequestIdSha256: hashValue( + 'private-provider-request-catalog-binding', + ), protocolErrorKind: 'catalog-binding', }; + const syntheticProtocolCallId = 'private-call-id'; + const syntheticProtocolResponseId = 'private-response-id'; const normalizedProtocolRecord = { ...persistedAuditEnvelope, recordType: 'agent.runtime.tool_plan.protocol', ...syntheticIdentity, loopIteration: 8, + repairAttempt: 0, + requestSlot: 'loop-8-repair-0', + responseFingerprint: hashValue('private-protocol-response'), + providerRequestIdSha256: hashValue('private-protocol-provider-request'), protocol: 'native_runtime_tools', - callId: 'private-call-id', - functionName: 'runtime_tool_file_read', functionCallCount: 1, - callIds: ['private-call-id'], + callIdSha256s: [hashValue(syntheticProtocolCallId)], functionNames: ['runtime_tool_file_read'], normalizationKinds: ['complete-think-block', 'planner-commentary'], normalizationCount: 3, normalizedTextChars: 80, normalizedTextSha256: hashValue('private-normalized-text'), - responseId: 'private-response-id', + responseIdSha256: hashValue(syntheticProtocolResponseId), + responseIdChars: syntheticProtocolResponseId.length, }; const toolPlanAudits = [normalizedProtocolRecord, ...repairRecords]; const fullRepairEvidence = collectToolPlanRepairAuditEvidence(toolPlanAudits); @@ -32807,8 +32881,79 @@ function runAgentRuntimeRealE2eSelfTests() { [field]: `private-${field}`, }), ).length; + const recordsMissingRequiredFields = [ + ...[...toolPlanProtocolAuditSafeFields].map((field) => { + const record = { ...normalizedProtocolRecord }; + delete record[field]; + return record; + }), + ...[...toolPlanRepairAuditSafeFields].map((field) => { + const record = { ...repairRecords[0] }; + delete record[field]; + return record; + }), + ]; + const rejectedMissingAuditFieldCount = recordsMissingRequiredFields.filter( + (record) => !hasSafeToolPlanAuditPayload(record), + ).length; + const wrongSlotAuditRecords = [ + { ...normalizedProtocolRecord, requestSlot: 'loop-8-repair-1' }, + { ...repairRecords[0], requestSlot: 'loop-0-repair-1' }, + ]; + const rejectedWrongSlotCount = wrongSlotAuditRecords.filter( + (record) => !hasSafeToolPlanAuditPayload(record), + ).length; + const rawIdentifierAuditFields = { + callId: syntheticProtocolCallId, + callIds: [syntheticProtocolCallId], + functionName: 'runtime_tool_file_read', + providerRequestId: 'private-provider-request-id', + responseId: syntheticProtocolResponseId, + }; + const rejectedRawIdentifierFieldCount = Object.entries( + rawIdentifierAuditFields, + ).filter( + ([field, value]) => + !hasSafeToolPlanAuditPayload({ + ...normalizedProtocolRecord, + [field]: value, + }), + ).length; + const invalidHashOrCatalogAuditRecords = [ + { + ...normalizedProtocolRecord, + callIdSha256s: ['A'.repeat(64)], + }, + { + ...normalizedProtocolRecord, + responseIdSha256: null, + }, + { + ...normalizedProtocolRecord, + functionNames: ['private_uncatalogued_function'], + }, + { + ...repairRecords[0], + providerRequestIdSha256: '0'.repeat(63), + }, + { + ...repairRecords[0], + repairAttempt: 1, + requestSlot: 'loop-0-repair-1', + }, + ]; + const rejectedInvalidHashOrCatalogCount = + invalidHashOrCatalogAuditRecords.filter( + (record) => !hasSafeToolPlanAuditPayload(record), + ).length; assert( rejectedRawPayloadFieldCount === rawPayloadFields.length && + rejectedMissingAuditFieldCount === recordsMissingRequiredFields.length && + rejectedWrongSlotCount === wrongSlotAuditRecords.length && + rejectedRawIdentifierFieldCount === + Object.keys(rawIdentifierAuditFields).length && + rejectedInvalidHashOrCatalogCount === + invalidHashOrCatalogAuditRecords.length && hasSafeToolPlanAuditPayload(normalizedProtocolRecord) && !hasSafeToolPlanAuditPayload({ ...normalizedProtocolRecord, @@ -32826,14 +32971,20 @@ function runAgentRuntimeRealE2eSelfTests() { [ ...Object.values(syntheticIdentity), ...repairRecords.flatMap((record) => [ + record.responseFingerprint, + record.providerRequestIdSha256, record.protocolErrorSha256, record.responsePreviewSha256, + record.callIdSha256, + record.functionNameSha256, ]), ], ); assert( repairReportPrivateValueLeakCount === 0 && - !/(agentId|sessionId|runId|loopIteration|Sha256)/u.test(repairReport), + !/(agentId|taskId|sessionId|runId|source|requestSlot|loopIteration|Sha256)/u.test( + repairReport, + ), 'agent-runtime-real-e2e-self-test-tool-plan-report-private-data-leak', ); return { @@ -32884,6 +33035,11 @@ function runAgentRuntimeRealE2eSelfTests() { toolPlanFatalLocalRepairRejected: true, toolPlanPersistedAuditEnvelopeAccepted: true, toolPlanRawPayloadFieldRejectionCount: rejectedRawPayloadFieldCount, + toolPlanMissingAuditFieldRejectionCount: rejectedMissingAuditFieldCount, + toolPlanWrongSlotRejectionCount: rejectedWrongSlotCount, + toolPlanRawIdentifierFieldRejectionCount: rejectedRawIdentifierFieldCount, + toolPlanInvalidHashOrCatalogRejectionCount: + rejectedInvalidHashOrCatalogCount, toolPlanRepairReportPrivateValueLeakCount: repairReportPrivateValueLeakCount, }; diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 76848bd20..1999883bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -36,4 +36,4 @@ zip = { version = "2", default-features = false, features = ["deflate"] } libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_JobObjects"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_JobObjects"] } 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 dceefc4e4..86897e9d5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -3,6 +3,7 @@ use crate::provider_handoff; use crate::provider_retry::{ self, AgentRuntimeProviderRetryIdentity, AgentRuntimeProviderRetryRecord, }; +use crate::tool_plan_handoff; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::io::{Seek, SeekFrom}; @@ -784,12 +785,23 @@ fn remove_game_creator_agent_runtime_provider_recovery_at( agent_id: &str, run_id: &str, ) -> Result<(), String> { + repair_game_creator_agent_runtime_provider_recovery_lifecycles_before_removal_at( + root, agent_id, run_id, + )?; provider_handoff::remove_at(root, agent_id, run_id)?; + tool_plan_handoff::remove_at(root, agent_id, run_id)?; provider_retry::remove_at(root, agent_id, run_id) } pub(crate) fn resume_game_creator_agent_background_tasks_at( root: &Path, +) -> Result, String> { + resume_game_creator_agent_background_tasks_unredacted_at(root) + .map_err(|error| redact_agent_runtime_error(root, &error, 500)) +} + +fn resume_game_creator_agent_background_tasks_unredacted_at( + root: &Path, ) -> Result, String> { validate_project_root(root)?; if external_agent_runner_owns_background_execution() { @@ -797,6 +809,35 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( return read_game_creator_agent_runtimes_at(root); } let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?; + for ledger in tool_plan_handoff::list_at(root)? { + let agent_id = ledger.agent_id(); + let run_id = ledger.run_id(); + let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .ok_or_else(|| { + format!("tool-plan 成功响应交接账本缺少所属任务:agent={agent_id} runId={run_id}") + })?; + let identity_matches = ledger.entries.iter().all(|entry| { + entry.identity.agent_id == task.agent_id + && entry.identity.task_id == task.task_id + && entry.identity.session_id == task.session_id + && entry.identity.run_id == task.run_id + && entry.identity.source == task.source + }); + if !identity_matches { + return Err(format!( + "tool-plan 成功响应交接账本与所属任务身份冲突:agent={agent_id} runId={run_id}" + )); + } + let has_pending_action = + game_creator_agent_runtime_has_pending_action_ledger(root, agent_id, run_id); + if game_creator_agent_runtime_task_is_terminal_for_cancel(&task, has_pending_action) { + remove_game_creator_agent_runtime_provider_recovery_at(root, agent_id, run_id)?; + } else if !agent_ids.contains(agent_id) { + return Err(format!( + "tool-plan 成功响应交接账本指向未知活动 Agent:agent={agent_id} runId={run_id}" + )); + } + } let retry_records = provider_retry::list_at(root)?; if retry_records .iter() @@ -12211,13 +12252,11 @@ where )); } #[cfg(test)] - if provider_handoff::supports_request_kind(&snapshot.request_kind) { + if game_creator_agent_runtime_provider_handoff_test_stop_matches(root, &snapshot) { let injection = root.join(".agent/runtime/test-stop-after-provider-handoff"); - if injection.exists() { - let _ = fs::remove_file(injection); - unregister_game_creator_agent_runtime_provider_request(&key, &active); - return Err(AGENT_RUNTIME_PROVIDER_HANDOFF_TEST_STOP.to_string()); - } + let _ = fs::remove_file(injection); + unregister_game_creator_agent_runtime_provider_request(&key, &active); + return Err(AGENT_RUNTIME_PROVIDER_HANDOFF_TEST_STOP.to_string()); } } unregister_game_creator_agent_runtime_provider_request(&key, &active); @@ -12251,6 +12290,29 @@ where result } +#[cfg(test)] +fn game_creator_agent_runtime_provider_handoff_test_stop_matches( + root: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, +) -> bool { + let injection = root.join(".agent/runtime/test-stop-after-provider-handoff"); + let Ok(marker) = fs::read_to_string(&injection) else { + return false; + }; + match marker.trim() { + "stop-after-final-reply-provider-handoff" => snapshot.request_kind == "final-reply", + "stop-after-final-reply-context-compaction-handoff" => { + snapshot.request_kind == "final-reply-context-compaction" + } + "stop-after-tool-plan-provider-handoff" => snapshot.request_kind == "tool-plan", + marker => marker + .strip_prefix("stop-after-tool-plan-provider-handoff:") + .is_some_and(|request_slot| { + snapshot.request_kind == "tool-plan" && snapshot.request_slot == request_slot.trim() + }), + } +} + fn game_creator_agent_runtime_transient_provider_error_kind( error: &platform_llm::LlmError, ) -> Option<&'static str> { @@ -12509,8 +12571,92 @@ fn repair_game_creator_agent_runtime_provider_handoff_lifecycle_at_locked( provider_snapshot: &AgentRuntimeProviderRequestSnapshot, handoff: &provider_handoff::AgentRuntimeProviderHandoffRecord, ) -> Result<(), String> { - let attempt_snapshot = provider_snapshot.with_request_slot(handoff.request_slot.clone()); - let request_id = handoff.provider_request_id.as_str(); + repair_game_creator_agent_runtime_provider_success_lifecycle_at_locked( + root, + provider_snapshot, + &handoff.request_slot, + &handoff.provider_request_id, + ) +} + +fn repair_game_creator_agent_runtime_tool_plan_handoff_lifecycle_at_locked( + root: &Path, + provider_snapshot: &AgentRuntimeProviderRequestSnapshot, + handoff: &tool_plan_handoff::AgentRuntimeToolPlanHandoffEntry, +) -> Result<(), String> { + repair_game_creator_agent_runtime_provider_success_lifecycle_at_locked( + root, + provider_snapshot, + &handoff.request_slot, + &handoff.provider_request_id, + ) +} + +fn repair_game_creator_agent_runtime_tool_plan_handoff_ledger_lifecycles_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + let Some(ledger) = tool_plan_handoff::read_for_run_at(root, agent_id, run_id)? else { + return Ok(()); + }; + for handoff in &ledger.entries { + let snapshot = + game_creator_agent_runtime_provider_snapshot_from_retry_identity(&handoff.identity); + if let Err(error) = repair_game_creator_agent_runtime_tool_plan_handoff_lifecycle_at_locked( + root, &snapshot, handoff, + ) { + let attempt_snapshot = snapshot.with_request_slot(handoff.request_slot.clone()); + let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &attempt_snapshot, + &handoff.provider_request_id, + ); + return Err(format!( + "tool-plan 成功响应交接账本 lifecycle 恢复失败:slot={}:{}", + handoff.request_slot, + redact_agent_runtime_error(root, &error, 320) + )); + } + } + Ok(()) +} + +fn repair_game_creator_agent_runtime_provider_recovery_lifecycles_before_removal_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + if let Some(handoff) = provider_handoff::read_for_run_at(root, agent_id, run_id)? { + let snapshot = + game_creator_agent_runtime_provider_snapshot_from_retry_identity(&handoff.identity); + if let Err(error) = repair_game_creator_agent_runtime_provider_handoff_lifecycle_at_locked( + root, &snapshot, &handoff, + ) { + let attempt_snapshot = snapshot.with_request_slot(handoff.request_slot.clone()); + let _ = mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &attempt_snapshot, + &handoff.provider_request_id, + ); + return Err(format!( + "Provider 成功响应交接 lifecycle 清理前恢复失败:{}", + redact_agent_runtime_error(root, &error, 320) + )); + } + } + repair_game_creator_agent_runtime_tool_plan_handoff_ledger_lifecycles_at_locked( + root, agent_id, run_id, + ) +} + +fn repair_game_creator_agent_runtime_provider_success_lifecycle_at_locked( + root: &Path, + provider_snapshot: &AgentRuntimeProviderRequestSnapshot, + request_slot: &str, + request_id: &str, +) -> Result<(), String> { + let attempt_snapshot = provider_snapshot.with_request_slot(request_slot.to_string()); let base_request_id = game_creator_agent_runtime_provider_request_id(&attempt_snapshot); if !(0..=64).any(|attempt| { game_creator_agent_runtime_provider_request_attempt_id(&base_request_id, attempt) @@ -12518,6 +12664,18 @@ fn repair_game_creator_agent_runtime_provider_handoff_lifecycle_at_locked( }) { return Err("Provider 成功响应交接 requestId 不属于当前 requestSlot".to_string()); } + let transitions = read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + request_id, + )?; + if transitions == ["started", "completed"] { + return Ok(()); + } + if transitions != ["started"] { + return Err("Provider 成功响应交接缺少匹配的 started lifecycle".to_string()); + } let incomplete = read_agent_db_incomplete_provider_request_ids_at( root, &provider_snapshot.agent_id, @@ -12529,15 +12687,6 @@ fn repair_game_creator_agent_runtime_provider_handoff_lifecycle_at_locked( { return Err("Provider 成功响应交接命中其它未闭合 Provider 请求".to_string()); } - let transitions = read_agent_db_lifecycle_transitions_at( - root, - AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, - "requestId", - request_id, - )?; - if transitions != ["started"] && transitions != ["started", "completed"] { - return Err("Provider 成功响应交接缺少匹配的 started lifecycle".to_string()); - } append_game_creator_agent_runtime_provider_request_lifecycle( root, &attempt_snapshot, @@ -12567,6 +12716,196 @@ where let max_retries = llm .max_retries .min(AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT); + let tool_plan_handoff_missing = if identity.request_kind == "tool-plan" { + match tool_plan_handoff::lookup_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + &identity, + ) + .map_err(|error| { + game_creator_agent_runtime_provider_handoff_reconciliation_error( + root, + provider_snapshot, + &error, + ) + })? { + tool_plan_handoff::AgentRuntimeToolPlanHandoffLookup::Missing => true, + tool_plan_handoff::AgentRuntimeToolPlanHandoffLookup::IdentityConflict(handoff) => { + if !game_creator_agent_runtime_provider_retry_same_durable_run( + &handoff.identity, + &identity, + ) { + return Err("tool-plan 成功响应交接记录与当前持久 run 身份冲突".to_string()); + } + let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.tool_plan_handoff.superseded_lifecycle", + )?; + if let Err(error) = + repair_game_creator_agent_runtime_tool_plan_handoff_ledger_lifecycles_at_locked( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + ) + { + drop(control_lock); + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: tool-plan 成功响应交接账本作废前 lifecycle 恢复失败:{}", + redact_agent_runtime_error(root, &error, 320) + )); + } + drop(control_lock); + let drift_fields = game_creator_agent_runtime_provider_retry_drift_fields( + &handoff.identity, + &identity, + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.provider_request.tool_plan_handoff_superseded", + "agentId": provider_snapshot.agent_id, + "taskId": provider_snapshot.task_id, + "sessionId": provider_snapshot.session_id, + "runId": provider_snapshot.run_id, + "source": provider_snapshot.source, + "requestKind": handoff.identity.request_kind, + "driftFields": drift_fields, + }), + ); + tool_plan_handoff::remove_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + )?; + provider_retry::remove_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + )?; + return Ok(AgentRuntimePersistedProviderRequestOutcome::Superseded); + } + tool_plan_handoff::AgentRuntimeToolPlanHandoffLookup::Exact(handoff) => { + let matching_retry = if let Some(retry) = provider_retry::read_for_run_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + )? { + if retry.identity == handoff.identity + && retry.next_attempt == handoff.attempt + && retry.next_request_slot == handoff.request_slot + { + true + } else if tool_plan_handoff::is_later_repair_identity( + &handoff.identity, + &retry.identity, + ) { + false + } else { + let handoff_snapshot = + provider_snapshot.with_request_slot(handoff.request_slot.clone()); + if let Ok(_control_lock) = + acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.tool_plan_handoff.retry_conflict_reconciliation", + ) + { + let _ = + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &handoff_snapshot, + &handoff.provider_request_id, + ); + } + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: tool-plan 成功响应交接与 retry sidecar 冲突" + )); + } + } else { + false + }; + let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.tool_plan_handoff.replay", + )?; + if let Err(error) = + repair_game_creator_agent_runtime_tool_plan_handoff_lifecycle_at_locked( + root, + provider_snapshot, + &handoff, + ) + { + let handoff_snapshot = + provider_snapshot.with_request_slot(handoff.request_slot.clone()); + let _ = + mark_game_creator_agent_runtime_provider_request_needs_reconciliation_at_locked( + root, + &handoff_snapshot, + &handoff.provider_request_id, + ); + drop(control_lock); + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: tool-plan 成功响应交接 lifecycle 恢复失败:{}", + redact_agent_runtime_error(root, &error, 320) + )); + } + let has_durable_control = + game_creator_agent_runtime_provider_snapshot_has_durable_control_at_locked( + root, + provider_snapshot, + )?; + let pausing = has_durable_control + && game_creator_agent_runtime_provider_snapshot_is_pausing_at_locked( + root, + provider_snapshot, + )?; + if has_durable_control && !pausing { + if let Err(error) = + repair_game_creator_agent_runtime_tool_plan_handoff_ledger_lifecycles_at_locked( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + ) + { + drop(control_lock); + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: durable control 清理 tool-plan 成功响应交接账本前 lifecycle 恢复失败:{}", + redact_agent_runtime_error(root, &error, 320) + )); + } + tool_plan_handoff::remove_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + )?; + provider_retry::remove_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + )?; + drop(control_lock); + return Ok(AgentRuntimePersistedProviderRequestOutcome::Response(None)); + } + if pausing { + drop(control_lock); + return Ok(AgentRuntimePersistedProviderRequestOutcome::Response(None)); + } + if matching_retry { + provider_retry::remove_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + )?; + } + drop(control_lock); + return Ok(AgentRuntimePersistedProviderRequestOutcome::Response(Some( + handoff.to_llm_response(), + ))); + } + } + } else { + false + }; if provider_handoff::supports_request_kind(&identity.request_kind) { if let Some(handoff) = provider_handoff::read_for_run_at( root, @@ -12747,6 +13086,22 @@ where } let drift_fields = game_creator_agent_runtime_provider_retry_drift_fields(&record.identity, &identity); + let control_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.provider_retry.superseded_recovery_cleanup", + )?; + if let Err(error) = remove_game_creator_agent_runtime_provider_recovery_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + ) { + drop(control_lock); + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: Provider retry 作废前 recovery lifecycle 恢复失败:{}", + redact_agent_runtime_error(root, &error, 320) + )); + } + drop(control_lock); let _ = append_agent_db_record( root, serde_json::json!({ @@ -12760,16 +13115,6 @@ where "driftFields": drift_fields, }), ); - provider_retry::remove_at( - root, - &provider_snapshot.agent_id, - &provider_snapshot.run_id, - )?; - provider_handoff::remove_at( - root, - &provider_snapshot.agent_id, - &provider_snapshot.run_id, - )?; return Ok(AgentRuntimePersistedProviderRequestOutcome::Superseded); } if provider_retry::remaining_ms(record) > 0 { @@ -12785,6 +13130,15 @@ where } else { (0, provider_snapshot.request_slot.clone(), max_retries) }; + if tool_plan_handoff_missing { + tool_plan_handoff::ensure_capacity_for_request_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + &identity, + ) + .map_err(|error| redact_agent_runtime_error(root, &error, 500))?; + } let attempt_snapshot = provider_snapshot.with_request_slot(request_slot); let client = match build_game_creator_agent_runtime_llm_client(llm, config_path) { Ok(client) => client, @@ -12809,6 +13163,7 @@ where let handoff_identity = identity.clone(); let handoff_request_slot = attempt_snapshot.request_slot.clone(); let persist_handoff = provider_handoff::supports_request_kind(&identity.request_kind); + let persist_tool_plan_handoff = identity.request_kind == "tool-plan"; match await_game_creator_agent_runtime_provider_request_with_snapshot_and_success_commit( root, attempt_snapshot.clone(), @@ -12824,6 +13179,15 @@ where provider_request_id, &response, )?; + } else if persist_tool_plan_handoff { + tool_plan_handoff::write_at( + root, + &handoff_identity, + &handoff_request_slot, + attempt, + provider_request_id, + response, + )?; } Ok(()) }, @@ -12879,6 +13243,42 @@ where ); } Some(handoff.to_llm_response()) + } else if persist_tool_plan_handoff && response.is_some() { + match tool_plan_handoff::lookup_at( + root, + &provider_snapshot.agent_id, + &provider_snapshot.run_id, + &identity, + ) + .map_err(|error| { + game_creator_agent_runtime_provider_handoff_reconciliation_error( + root, + &attempt_snapshot, + &error, + ) + })? { + tool_plan_handoff::AgentRuntimeToolPlanHandoffLookup::Exact(handoff) => { + Some(handoff.to_llm_response()) + } + tool_plan_handoff::AgentRuntimeToolPlanHandoffLookup::Missing => { + return Err( + game_creator_agent_runtime_provider_handoff_reconciliation_error( + root, + &attempt_snapshot, + "Provider lifecycle completed 前缺少 tool-plan 成功响应交接记录", + ), + ); + } + tool_plan_handoff::AgentRuntimeToolPlanHandoffLookup::IdentityConflict(_) => { + return Err( + game_creator_agent_runtime_provider_handoff_reconciliation_error( + root, + &attempt_snapshot, + "Provider lifecycle completed 前 tool-plan 成功响应交接身份已变化", + ), + ); + } + } } else { response }; @@ -14683,7 +15083,18 @@ fn remove_game_creator_agent_runtime_finalization_recovery_sidecars( agent_id: &str, run_id: &str, ) -> Result<(), String> { + if let Err(error) = + repair_game_creator_agent_runtime_provider_recovery_lifecycles_before_removal_at( + root, agent_id, run_id, + ) + { + return Err(format!( + "{AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX}: finalization 清理 recovery sidecar 前 lifecycle 恢复失败:{}", + redact_agent_runtime_error(root, &error, 320) + )); + } provider_handoff::remove_at(root, agent_id, run_id)?; + tool_plan_handoff::remove_at(root, agent_id, run_id)?; remove_game_creator_agent_runtime_finalization_journal(root, agent_id, run_id) } @@ -20701,6 +21112,44 @@ pub(crate) async fn compact_game_creator_agent_runtime_session_at( Ok(result) } +fn append_game_creator_agent_tool_plan_audit_idempotent( + root: &Path, + record: serde_json::Value, +) -> Result<(), String> { + let record_type = record + .get("recordType") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "tool-plan 审计缺少 recordType".to_string())?; + if !matches!( + record_type, + "agent.runtime.tool_plan.protocol" | "agent.runtime.tool_plan.repair" + ) { + return Err("tool-plan 幂等审计 recordType 无效".to_string()); + } + let request_slot = record + .get("requestSlot") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "tool-plan 审计缺少 requestSlot".to_string())?; + let loop_iteration = record + .get("loopIteration") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "tool-plan 审计缺少 loopIteration".to_string())?; + let repair_attempt = record + .get("repairAttempt") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| "tool-plan 审计缺少 repairAttempt".to_string())?; + if request_slot != format!("loop-{loop_iteration}-repair-{repair_attempt}") { + return Err("tool-plan 审计 requestSlot 与 loop/repair 身份不匹配".to_string()); + } + record + .get("responseFingerprint") + .and_then(serde_json::Value::as_str) + .filter(|value| value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .ok_or_else(|| "tool-plan 审计缺少有效 responseFingerprint".to_string())?; + append_agent_db_tool_plan_audit_idempotent(root, record).map(|_| ()) +} + async fn request_game_creator_agent_background_tool_plan_at( root: &Path, agent_id: &str, @@ -20839,29 +21288,15 @@ async fn request_game_creator_agent_background_tool_plan_at( let request_snapshot = provider_snapshot .with_request_slot(&request_slot) .with_web_search_enabled(request.enable_web_search); - let response = if repair_attempt == 0 { - request_game_creator_agent_runtime_llm_with_persisted_transient_retry( - root, - &request_snapshot, - &llm, - &config_path, - &operation, - &request, - ) - .await? - } else { - AgentRuntimePersistedProviderRequestOutcome::Response( - request_game_creator_agent_runtime_llm_with_transient_retries( - root, - &request_snapshot, - &llm, - &config_path, - &operation, - &request, - ) - .await?, - ) - }; + let response = request_game_creator_agent_runtime_llm_with_persisted_transient_retry( + root, + &request_snapshot, + &llm, + &config_path, + &operation, + &request, + ) + .await?; let response = match response { AgentRuntimePersistedProviderRequestOutcome::Response(Some(response)) => response, AgentRuntimePersistedProviderRequestOutcome::Response(None) => { @@ -20877,43 +21312,126 @@ async fn request_game_creator_agent_background_tool_plan_at( return Ok(RequestedAgentRuntimeToolPlanOutcome::Superseded); } }; + let response_identity = + game_creator_agent_runtime_provider_retry_identity(&request_snapshot, &llm, &request)?; + let response_handoff = + match tool_plan_handoff::lookup_at(root, agent_id, run_id, &response_identity).map_err( + |error| { + game_creator_agent_runtime_provider_handoff_reconciliation_error( + root, + &request_snapshot, + &error, + ) + }, + )? { + tool_plan_handoff::AgentRuntimeToolPlanHandoffLookup::Exact(handoff) => handoff, + tool_plan_handoff::AgentRuntimeToolPlanHandoffLookup::Missing => { + return Err( + game_creator_agent_runtime_provider_handoff_reconciliation_error( + root, + &request_snapshot, + "tool-plan Provider 成功后缺少持久交接记录", + ), + ); + } + tool_plan_handoff::AgentRuntimeToolPlanHandoffLookup::IdentityConflict(_) => { + return Err( + game_creator_agent_runtime_provider_handoff_reconciliation_error( + root, + &request_snapshot, + "tool-plan Provider 成功交接身份冲突", + ), + ); + } + }; + if response_handoff.to_llm_response() != response { + return Err( + game_creator_agent_runtime_provider_handoff_reconciliation_error( + root, + &request_snapshot, + "tool-plan Provider 成功响应与持久交接内容冲突", + ), + ); + } + let response_fingerprint = response_handoff.response_fingerprint.clone(); + let provider_request_id_sha256 = format!( + "{:x}", + Sha256::digest(response_handoff.provider_request_id.as_bytes()) + ); match parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified( &response, &mcp_catalog, ) { Ok(parsed) => { + if provider_retry::read_for_run_at(root, agent_id, run_id)?.is_some() { + return Err( + game_creator_agent_runtime_provider_handoff_reconciliation_error( + root, + &request_snapshot, + "有效 tool-plan 响应命中不应存在的后继 repair retry", + ), + ); + } let ParsedAgentRuntimeToolPlan { mut plan, protocol, - call_id, - function_name, + call_id: _, + function_name: _, call_ids, function_names, - normalization_kinds, - normalization_count, - normalized_text_chars, - normalized_text_sha256, + mut normalization_kinds, + mut normalization_count, + mut normalized_text_chars, + mut normalized_text_sha256, } = parsed; + if let Some((count, source_chars, source_sha256)) = + response_handoff.thinking_normalization_metadata() + { + if !normalization_kinds.contains(&"complete-think-block") { + normalization_kinds.insert(0, "complete-think-block"); + } + normalization_count = normalization_count.saturating_add(count); + normalized_text_chars = source_chars; + normalized_text_sha256 = Some(source_sha256.to_string()); + } enrich_game_creator_mcp_actions(&mut plan, &mcp_catalog)?; - append_agent_db_record( + let call_id_sha256s = call_ids + .iter() + .map(|value| format!("{:x}", Sha256::digest(value.as_bytes()))) + .collect::>(); + let response_id_sha256 = response + .response_id + .as_deref() + .map(|value| format!("{:x}", Sha256::digest(value.as_bytes()))); + let response_id_chars = response + .response_id + .as_deref() + .map(|value| value.chars().count()) + .unwrap_or(0); + append_game_creator_agent_tool_plan_audit_idempotent( root, serde_json::json!({ "recordType": "agent.runtime.tool_plan.protocol", "agentId": agent_id, + "taskId": provider_snapshot.task_id, "sessionId": session_id, "runId": run_id, + "source": provider_snapshot.source, "loopIteration": loop_index, + "repairAttempt": repair_attempt, + "requestSlot": request_slot, + "responseFingerprint": response_fingerprint, + "providerRequestIdSha256": provider_request_id_sha256, "protocol": protocol, - "callId": call_id, - "functionName": function_name, "functionCallCount": call_ids.len(), - "callIds": call_ids, + "callIdSha256s": call_id_sha256s, "functionNames": function_names, "normalizationKinds": normalization_kinds, "normalizationCount": normalization_count, "normalizedTextChars": normalized_text_chars, "normalizedTextSha256": normalized_text_sha256, - "responseId": response.response_id, + "responseIdSha256": response_id_sha256, + "responseIdChars": response_id_chars, }), )?; return Ok(RequestedAgentRuntimeToolPlanOutcome::Ready(Some( @@ -20950,14 +21468,20 @@ async fn request_game_creator_agent_background_tool_plan_at( }; let call_id = response.tool_calls.first().map(|call| call.id.clone()); let function_name = response.tool_calls.first().map(|call| call.name.clone()); - append_agent_db_record( + append_game_creator_agent_tool_plan_audit_idempotent( root, serde_json::json!({ "recordType": "agent.runtime.tool_plan.repair", "agentId": agent_id, + "taskId": provider_snapshot.task_id, "sessionId": session_id, "runId": run_id, + "source": provider_snapshot.source, "loopIteration": loop_index, + "repairAttempt": repair_attempt, + "requestSlot": request_slot, + "responseFingerprint": response_fingerprint, + "providerRequestIdSha256": provider_request_id_sha256, "attempt": next_attempt, "maxAttempts": AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS, "protocolErrorKind": error.kind().as_str(), @@ -21342,6 +21866,78 @@ mod response_stream_tests { (project, state, response_revision, snapshot) } + fn persist_tool_plan_handoff_repair_chain( + root: &Path, + provider_snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + ) -> (String, String) { + let mut base_snapshot = provider_snapshot.clone(); + base_snapshot.request_kind = "tool-plan".to_string(); + base_snapshot.request_slot = "loop-0-repair-0".to_string(); + let repair_snapshot = base_snapshot.with_request_slot("loop-0-repair-1"); + let base_request = LlmRunRequest::new(vec![LlmMessage::user("旧 base 请求")]); + let repair_request = LlmRunRequest::new(vec![ + LlmMessage::user("旧 base 请求"), + LlmMessage::assistant("{"), + LlmMessage::user("修复格式"), + ]); + let base_identity = + game_creator_agent_runtime_provider_retry_identity(&base_snapshot, llm, &base_request) + .expect("build cleanup base identity"); + let repair_identity = game_creator_agent_runtime_provider_retry_identity( + &repair_snapshot, + llm, + &repair_request, + ) + .expect("build cleanup repair identity"); + let base_request_id = game_creator_agent_runtime_provider_request_id(&base_snapshot); + let repair_request_id = game_creator_agent_runtime_provider_request_id(&repair_snapshot); + for status in ["started", "completed"] { + append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &base_snapshot, + &base_request_id, + status, + ) + .expect("append cleanup base lifecycle"); + } + append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &repair_snapshot, + &repair_request_id, + "started", + ) + .expect("append cleanup repair started lifecycle"); + let response = |text: &str| platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: llm.model.clone(), + text: text.to_string(), + finish_reason: Some("stop".to_string()), + response_id: None, + usage: None, + tool_calls: Vec::new(), + }; + tool_plan_handoff::write_at( + root, + &base_identity, + &base_snapshot.request_slot, + 0, + &base_request_id, + &response("{"), + ) + .expect("write cleanup base handoff"); + tool_plan_handoff::write_at( + root, + &repair_identity, + &repair_snapshot.request_slot, + 0, + &repair_request_id, + &response("still malformed"), + ) + .expect("write cleanup repair handoff"); + (base_request_id, repair_request_id) + } + fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmStreamDelta { platform_llm::LlmStreamDelta { accumulated_text: accumulated_text.to_string(), @@ -21753,6 +22349,69 @@ mod response_stream_tests { assert_eq!(assistants, vec![response]); } + #[test] + fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() { + let (project, state, response_revision, snapshot) = + response_stream_fixture("finalization-tool-plan-repair-chain-run"); + let root = project.path(); + let llm = GameCreatorLlmConfig { + api_key: "finalization-tool-plan-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "finalization-tool-plan-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "medium".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 96_000, + tool_output_token_limit: 8_000, + request_timeout_ms: 1_000, + max_retries: 0, + retry_backoff_ms: 1, + }; + let (base_request_id, repair_request_id) = + persist_tool_plan_handoff_repair_chain(root, &snapshot, &llm); + let response = "项目总监已经完成 repair-chain 最终回复。"; + write_game_creator_agent_runtime_response_stream_ready_at( + root, + &snapshot, + response_revision, + response, + Some("stop"), + ) + .expect("write ready response stream before repair-chain finalization"); + + let outcome = finish_game_creator_agent_background_runtime_turn_at( + root, + state.clone(), + response, + response_revision, + &[], + ) + .expect("finalize with tool-plan repair chain"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Completed(_) + )); + assert!( + tool_plan_handoff::read_for_run_at(root, &state.agent_id, &state.run_id) + .expect("read removed finalization tool-plan handoff") + .is_none() + ); + for request_id in [&base_request_id, &repair_request_id] { + assert_eq!( + read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + request_id, + ) + .expect("read finalization repair-chain lifecycle"), + ["started", "completed"] + ); + } + } + fn resume_response_stream_finalization(root: &Path, agent_id: &str) -> AgentRuntimeState { let runtime_lock = acquire_game_creator_agent_runtime_task_lock_with_wait(root, agent_id) .expect("acquire finalization recovery lane"); @@ -22069,6 +22728,633 @@ mod response_stream_tests { assert!(!audit.contains(private_response)); } + #[tokio::test] + async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_removal() { + let (project, state, _response_revision, mut base_snapshot) = + response_stream_fixture("tool-plan-handoff-identity-drift-run"); + let root = project.path(); + base_snapshot.request_kind = "tool-plan".to_string(); + base_snapshot.request_slot = "loop-0-repair-0".to_string(); + let base_request = LlmRunRequest::new(vec![LlmMessage::user("旧 base 请求")]); + let repair_snapshot = base_snapshot.with_request_slot("loop-0-repair-1"); + let repair_request = LlmRunRequest::new(vec![ + LlmMessage::user("旧 base 请求"), + LlmMessage::assistant("{"), + LlmMessage::user("修复格式"), + ]); + let old_llm = GameCreatorLlmConfig { + api_key: "old-tool-plan-provider-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "old-tool-plan-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "medium".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 96_000, + tool_output_token_limit: 8_000, + request_timeout_ms: 1_000, + max_retries: 0, + retry_backoff_ms: 1, + }; + let mut new_llm = old_llm.clone(); + new_llm.model = "new-tool-plan-model".to_string(); + let base_identity = game_creator_agent_runtime_provider_retry_identity( + &base_snapshot, + &old_llm, + &base_request, + ) + .expect("build old base identity"); + let repair_identity = game_creator_agent_runtime_provider_retry_identity( + &repair_snapshot, + &old_llm, + &repair_request, + ) + .expect("build old repair identity"); + let base_request_id = game_creator_agent_runtime_provider_request_id(&base_snapshot); + let repair_request_id = game_creator_agent_runtime_provider_request_id(&repair_snapshot); + for status in ["started", "completed"] { + append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &base_snapshot, + &base_request_id, + status, + ) + .expect("append old base lifecycle"); + } + append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &repair_snapshot, + &repair_request_id, + "started", + ) + .expect("append old repair started lifecycle"); + let response = |text: &str| platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: old_llm.model.clone(), + text: text.to_string(), + finish_reason: Some("stop".to_string()), + response_id: None, + usage: None, + tool_calls: Vec::new(), + }; + tool_plan_handoff::write_at( + root, + &base_identity, + &base_snapshot.request_slot, + 0, + &base_request_id, + &response("{"), + ) + .expect("write old base handoff"); + tool_plan_handoff::write_at( + root, + &repair_identity, + &repair_snapshot.request_slot, + 0, + &repair_request_id, + &response("still malformed"), + ) + .expect("write old repair handoff"); + + let outcome = request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using( + root, + &base_snapshot, + &new_llm, + "agentLlm.project-supervisor", + "测试 tool-plan handoff 身份漂移", + &base_request, + |_client, _request| async { + Err(platform_llm::LlmError::Transport( + "identity drift must not call Provider".to_string(), + )) + }, + |response| response.clone(), + ) + .await + .expect("supersede drifted tool-plan handoff ledger"); + assert!(matches!( + outcome, + AgentRuntimePersistedProviderRequestOutcome::Superseded + )); + assert!( + tool_plan_handoff::read_for_run_at(root, &state.agent_id, &state.run_id) + .expect("read removed tool-plan handoff") + .is_none() + ); + for request_id in [&base_request_id, &repair_request_id] { + assert_eq!( + read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + request_id, + ) + .expect("read closed tool-plan lifecycle"), + ["started", "completed"] + ); + } + } + + #[tokio::test] + async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_removal() { + let (project, state, _response_revision, snapshot) = + response_stream_fixture("generic-retry-drift-tool-plan-chain-run"); + let root = project.path(); + let old_llm = GameCreatorLlmConfig { + api_key: "old-generic-retry-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "old-generic-retry-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "medium".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 96_000, + tool_output_token_limit: 8_000, + request_timeout_ms: 1_000, + max_retries: 1, + retry_backoff_ms: 1, + }; + let (base_request_id, repair_request_id) = + persist_tool_plan_handoff_repair_chain(root, &snapshot, &old_llm); + let request = LlmRunRequest::new(vec![LlmMessage::user("验证通用 retry 身份漂移")]); + let old_identity = + game_creator_agent_runtime_provider_retry_identity(&snapshot, &old_llm, &request) + .expect("build old generic retry identity"); + provider_retry::write_next_at( + root, + &old_identity, + &format!("{}-transient-1", snapshot.request_slot), + 1, + 1, + 1_000, + "transport", + &"b".repeat(64), + ) + .expect("write old generic retry sidecar"); + let mut new_llm = old_llm.clone(); + new_llm.model = "new-generic-retry-model".to_string(); + + let outcome = request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using( + root, + &snapshot, + &new_llm, + "agentLlm.project-supervisor", + "测试通用 retry 身份漂移", + &request, + |_client, _request| async { + Err(platform_llm::LlmError::Transport( + "generic retry drift must not call Provider".to_string(), + )) + }, + |response| response.clone(), + ) + .await + .expect("supersede drifted generic retry and repair ledger"); + assert!(matches!( + outcome, + AgentRuntimePersistedProviderRequestOutcome::Superseded + )); + assert!( + provider_retry::read_for_run_at(root, &state.agent_id, &state.run_id) + .expect("read removed generic retry") + .is_none() + ); + assert!( + tool_plan_handoff::read_for_run_at(root, &state.agent_id, &state.run_id) + .expect("read removed generic retry tool-plan ledger") + .is_none() + ); + for request_id in [&base_request_id, &repair_request_id] { + assert_eq!( + read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + request_id, + ) + .expect("read generic retry repair-chain lifecycle"), + ["started", "completed"] + ); + } + } + + #[tokio::test] + async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() { + let (project, state, _response_revision, snapshot) = + response_stream_fixture("tool-plan-capacity-preflight-run"); + let root = project.path(); + let llm = GameCreatorLlmConfig { + api_key: "tool-plan-capacity-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "tool-plan-capacity-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "medium".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 96_000, + tool_output_token_limit: 8_000, + request_timeout_ms: 1_000, + max_retries: 0, + retry_backoff_ms: 1, + }; + let mut next_loop = None; + for loop_iteration in 0..256_u64 { + let mut entry_snapshot = snapshot.clone(); + entry_snapshot.request_kind = "tool-plan".to_string(); + entry_snapshot.request_slot = format!("loop-{loop_iteration}-repair-0"); + let request = LlmRunRequest::new(vec![LlmMessage::user(format!( + "capacity request {loop_iteration}" + ))]); + let identity = + game_creator_agent_runtime_provider_retry_identity(&entry_snapshot, &llm, &request) + .expect("build capacity entry identity"); + let request_id = game_creator_agent_runtime_provider_request_id(&entry_snapshot); + let response = platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: llm.model.clone(), + text: format!("capacity response {loop_iteration}"), + finish_reason: Some("stop".to_string()), + response_id: None, + usage: None, + tool_calls: Vec::new(), + }; + match tool_plan_handoff::write_at( + root, + &identity, + &entry_snapshot.request_slot, + 0, + &request_id, + &response, + ) { + Ok(_) => {} + Err(error) if error.contains("条上限") => { + next_loop = Some(loop_iteration); + break; + } + Err(error) => panic!("fill tool-plan capacity ledger: {error}"), + } + } + let next_loop = next_loop.expect("tool-plan entry capacity must be finite"); + let mut request_snapshot = snapshot.clone(); + request_snapshot.request_kind = "tool-plan".to_string(); + request_snapshot.request_slot = format!("loop-{next_loop}-repair-0"); + let request = LlmRunRequest::new(vec![LlmMessage::user(format!( + "capacity request {next_loop}" + ))]); + let request_id = game_creator_agent_runtime_provider_request_id(&request_snapshot); + let provider_called = std::sync::Arc::new(AtomicBool::new(false)); + let provider_called_for_request = std::sync::Arc::clone(&provider_called); + + let result = request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using( + root, + &request_snapshot, + &llm, + "agentLlm.project-supervisor", + "测试 tool-plan 请求前容量门禁", + &request, + move |_client, _request| async move { + provider_called_for_request.store(true, Ordering::Release); + Err(platform_llm::LlmError::Transport( + "capacity gate must run before Provider".to_string(), + )) + }, + |response| response.clone(), + ) + .await; + let error = match result { + Err(error) => error, + Ok(_) => panic!("full tool-plan ledger must reject before Provider"), + }; + assert!(error.contains("请求前账本容量")); + assert!(!error.contains(AGENT_RUNTIME_PROVIDER_REQUEST_RECONCILIATION_PREFIX)); + assert!(!provider_called.load(Ordering::Acquire)); + assert_eq!( + read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read capacity-gated request lifecycle"), + Vec::::new() + ); + assert_eq!( + tool_plan_handoff::read_for_run_at(root, &state.agent_id, &state.run_id) + .expect("read capacity ledger after rejection") + .expect("capacity ledger remains") + .entries + .len(), + next_loop as usize + ); + } + + #[tokio::test] + async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_removal() { + let (project, mut state, _response_revision, mut base_snapshot) = + response_stream_fixture("tool-plan-handoff-durable-control-run"); + let root = project.path(); + base_snapshot.request_kind = "tool-plan".to_string(); + base_snapshot.request_slot = "loop-0-repair-0".to_string(); + let base_request = LlmRunRequest::new(vec![LlmMessage::user("旧 base 请求")]); + let repair_snapshot = base_snapshot.with_request_slot("loop-0-repair-1"); + let repair_request = LlmRunRequest::new(vec![ + LlmMessage::user("旧 base 请求"), + LlmMessage::assistant("{"), + LlmMessage::user("修复格式"), + ]); + let llm = GameCreatorLlmConfig { + api_key: "durable-control-tool-plan-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "durable-control-tool-plan-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "medium".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 96_000, + tool_output_token_limit: 8_000, + request_timeout_ms: 1_000, + max_retries: 0, + retry_backoff_ms: 1, + }; + let base_identity = + game_creator_agent_runtime_provider_retry_identity(&base_snapshot, &llm, &base_request) + .expect("build durable control base identity"); + let repair_identity = game_creator_agent_runtime_provider_retry_identity( + &repair_snapshot, + &llm, + &repair_request, + ) + .expect("build durable control repair identity"); + let base_request_id = game_creator_agent_runtime_provider_request_id(&base_snapshot); + let repair_request_id = game_creator_agent_runtime_provider_request_id(&repair_snapshot); + for status in ["started", "completed"] { + append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &base_snapshot, + &base_request_id, + status, + ) + .expect("append durable control base lifecycle"); + } + append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &repair_snapshot, + &repair_request_id, + "started", + ) + .expect("append durable control repair lifecycle"); + let response = |text: &str| platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: llm.model.clone(), + text: text.to_string(), + finish_reason: Some("stop".to_string()), + response_id: None, + usage: None, + tool_calls: Vec::new(), + }; + tool_plan_handoff::write_at( + root, + &base_identity, + &base_snapshot.request_slot, + 0, + &base_request_id, + &response("{"), + ) + .expect("write durable control base handoff"); + tool_plan_handoff::write_at( + root, + &repair_identity, + &repair_snapshot.request_slot, + 0, + &repair_request_id, + &response("still malformed"), + ) + .expect("write durable control repair handoff"); + state.status = "cancelled".to_string(); + state.phase = "cancelled".to_string(); + state.current_action = "测试 durable control 清理".to_string(); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state) + .expect("append durable control task projection"); + write_game_creator_agent_runtime_state(root, &state) + .expect("write durable control runtime state"); + + let outcome = request_game_creator_agent_runtime_llm_with_persisted_transient_retry_using( + root, + &base_snapshot, + &llm, + "agentLlm.project-supervisor", + "测试 durable control 清理 tool-plan handoff", + &base_request, + |_client, _request| async { + Err(platform_llm::LlmError::Transport( + "durable control must not call Provider".to_string(), + )) + }, + |response| response.clone(), + ) + .await + .expect("clean tool-plan handoff ledger for durable control"); + assert!(matches!( + outcome, + AgentRuntimePersistedProviderRequestOutcome::Response(None) + )); + assert!( + tool_plan_handoff::read_for_run_at(root, &state.agent_id, &state.run_id) + .expect("read removed durable control tool-plan handoff") + .is_none() + ); + for request_id in [&base_request_id, &repair_request_id] { + assert_eq!( + read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + request_id, + ) + .expect("read durable control tool-plan lifecycle"), + ["started", "completed"] + ); + } + } + + #[test] + fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff() { + let (project, state, _response_revision, mut snapshot) = + response_stream_fixture("tool-plan-handoff-control-cleanup-run"); + let root = project.path(); + snapshot.request_kind = "tool-plan".to_string(); + snapshot.request_slot = "loop-0-repair-0".to_string(); + let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]); + let llm = GameCreatorLlmConfig { + api_key: "tool-plan-cleanup-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "tool-plan-cleanup-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "medium".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 96_000, + tool_output_token_limit: 8_000, + request_timeout_ms: 1_000, + max_retries: 0, + retry_backoff_ms: 1, + }; + let identity = + game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request) + .expect("build cleanup handoff identity"); + let request_id = game_creator_agent_runtime_provider_request_id(&snapshot); + append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &snapshot, + &request_id, + "started", + ) + .expect("append cleanup started lifecycle"); + tool_plan_handoff::write_at( + root, + &identity, + &snapshot.request_slot, + 0, + &request_id, + &platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: llm.model.clone(), + text: "cleanup handoff".to_string(), + finish_reason: Some("stop".to_string()), + response_id: None, + usage: None, + tool_calls: Vec::new(), + }, + ) + .expect("write cleanup handoff"); + + remove_game_creator_agent_runtime_provider_recovery_at( + root, + &state.agent_id, + &state.run_id, + ) + .expect("close lifecycle before control cleanup"); + assert_eq!( + read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read cleanup lifecycle"), + ["started", "completed"] + ); + assert!( + tool_plan_handoff::read_for_run_at(root, &state.agent_id, &state.run_id) + .expect("read cleaned handoff") + .is_none() + ); + } + + #[test] + fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() { + let (project, mut state, _response_revision, mut snapshot) = + response_stream_fixture("terminal-tool-plan-handoff-resume-run"); + let root = project.path(); + snapshot.request_kind = "tool-plan".to_string(); + snapshot.request_slot = "loop-0-repair-0".to_string(); + let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]); + let llm = GameCreatorLlmConfig { + api_key: "terminal-handoff-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "terminal-handoff-model".to_string(), + api_kind: "openai_responses".to_string(), + reasoning_effort: "medium".to_string(), + stream: false, + web_search_enabled: false, + context_window_tokens: 128_000, + auto_compact_token_limit: 96_000, + tool_output_token_limit: 8_000, + request_timeout_ms: 1_000, + max_retries: 0, + retry_backoff_ms: 1, + }; + let identity = + game_creator_agent_runtime_provider_retry_identity(&snapshot, &llm, &request) + .expect("build terminal handoff identity"); + let request_id = game_creator_agent_runtime_provider_request_id(&snapshot); + append_game_creator_agent_runtime_provider_request_lifecycle( + root, + &snapshot, + &request_id, + "started", + ) + .expect("append terminal handoff started lifecycle"); + tool_plan_handoff::write_at( + root, + &identity, + &snapshot.request_slot, + 0, + &request_id, + &platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: llm.model.clone(), + text: "terminal handoff".to_string(), + finish_reason: Some("stop".to_string()), + response_id: None, + usage: None, + tool_calls: Vec::new(), + }, + ) + .expect("write terminal handoff"); + state.status = "idle".to_string(); + state.phase = "completed".to_string(); + state.current_action = "等待下一轮输入".to_string(); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &state) + .expect("append terminal task projection"); + write_game_creator_agent_runtime_state(root, &state).expect("write terminal runtime state"); + + let resumed = resume_game_creator_agent_background_tasks_at(root) + .expect("scan terminal tool-plan handoff"); + assert!(resumed.is_empty()); + assert!(tool_plan_handoff::list_at(root) + .expect("list cleaned terminal handoffs") + .is_empty()); + assert_eq!( + read_agent_db_lifecycle_transitions_at( + root, + AGENT_RUNTIME_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "requestId", + &request_id, + ) + .expect("read terminal cleanup lifecycle"), + ["started", "completed"] + ); + } + + #[test] + fn runtime_resume_redacts_tool_plan_scan_absolute_path_errors() { + let (project, _state, _response_revision, _snapshot) = + response_stream_fixture("tool-plan-scan-path-redaction-run"); + let root = project.path(); + let handoff_root = root.join(".agent/runtime/tool-plan-handoffs"); + if handoff_root.exists() { + std::fs::remove_dir_all(&handoff_root).expect("remove existing handoff directory"); + } + std::fs::write(&handoff_root, b"not-a-directory") + .expect("write invalid tool-plan handoff root"); + let raw_error = tool_plan_handoff::list_at(root) + .expect_err("raw tool-plan scan must reject invalid handoff root"); + assert!(!raw_error.contains(root.to_string_lossy().as_ref())); + + let public_error = resume_game_creator_agent_background_tasks_at(root) + .expect_err("runtime resume must reject invalid tool-plan handoff root"); + assert!(!public_error.contains(root.to_string_lossy().as_ref())); + assert!(public_error.contains("tool-plan 成功响应交接")); + } + #[tokio::test] async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliation() { let (project, state, _response_revision, snapshot) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 631c91493..2011937f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -71,6 +71,7 @@ mod provider_retry; mod repository_context; mod runner; mod swarm_cli; +mod tool_plan_handoff; mod user_input; mod windows; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 23e09f2a6..15e9dc58a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1670,6 +1670,184 @@ pub(crate) fn append_agent_db_record_if_missing_for_action_and_delegation_group( Ok(true) } +pub(crate) fn append_agent_db_tool_plan_audit_idempotent( + root: &Path, + record: serde_json::Value, +) -> Result { + const PROTOCOL_FIELDS: &[&str] = &[ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "loopIteration", + "repairAttempt", + "requestSlot", + "responseFingerprint", + "providerRequestIdSha256", + "protocol", + "functionCallCount", + "callIdSha256s", + "functionNames", + "normalizationKinds", + "normalizationCount", + "normalizedTextChars", + "normalizedTextSha256", + "responseIdSha256", + "responseIdChars", + ]; + const REPAIR_FIELDS: &[&str] = &[ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "loopIteration", + "repairAttempt", + "requestSlot", + "responseFingerprint", + "providerRequestIdSha256", + "protocol", + "attempt", + "maxAttempts", + "protocolErrorKind", + "protocolErrorSha256", + "protocolErrorChars", + "responsePreviewSha256", + "responsePreviewChars", + "callIdSha256", + "functionNameSha256", + ]; + + let object = record + .as_object() + .ok_or_else(|| "Agent DB tool-plan 幂等审计必须是对象".to_string())?; + let record_type = object + .get("recordType") + .and_then(serde_json::Value::as_str) + .filter(|value| { + matches!( + *value, + "agent.runtime.tool_plan.protocol" | "agent.runtime.tool_plan.repair" + ) + }) + .ok_or_else(|| "Agent DB tool-plan 幂等审计 recordType 无效".to_string())?; + if object.contains_key("schemaVersion") || object.contains_key("updatedAt") { + return Err("Agent DB tool-plan 幂等审计不能预填持久化 envelope".to_string()); + } + let expected_fields = match record_type { + "agent.runtime.tool_plan.protocol" => PROTOCOL_FIELDS, + "agent.runtime.tool_plan.repair" => REPAIR_FIELDS, + _ => unreachable!("tool-plan audit record type was validated above"), + }; + if object.len() != expected_fields.len() + || expected_fields + .iter() + .any(|field| !object.contains_key(*field)) + { + return Err(format!( + "Agent DB tool-plan 幂等审计字段白名单不匹配:{record_type}" + )); + } + for field in [ + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "requestSlot", + ] { + if record + .get(field) + .and_then(serde_json::Value::as_str) + .is_none_or(|value| !is_safe_agent_db_lifecycle_identity(value)) + { + return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); + } + } + for field in ["responseFingerprint", "providerRequestIdSha256"] { + if record + .get(field) + .and_then(serde_json::Value::as_str) + .is_none_or(|value| !is_valid_agent_db_sha256(value)) + { + return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); + } + } + let is_null_or_sha256 = |field: &str| match record.get(field) { + Some(serde_json::Value::Null) => true, + Some(serde_json::Value::String(value)) => is_valid_agent_db_sha256(value), + _ => false, + }; + match record_type { + "agent.runtime.tool_plan.protocol" => { + if record + .get("callIdSha256s") + .and_then(serde_json::Value::as_array) + .is_none_or(|values| { + values.iter().any(|value| { + value + .as_str() + .is_none_or(|value| !is_valid_agent_db_sha256(value)) + }) + }) + { + return Err("Agent DB tool-plan 幂等审计字段无效:callIdSha256s".to_string()); + } + for field in ["normalizedTextSha256", "responseIdSha256"] { + if !is_null_or_sha256(field) { + return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); + } + } + } + "agent.runtime.tool_plan.repair" => { + for field in ["protocolErrorSha256", "responsePreviewSha256"] { + if record + .get(field) + .and_then(serde_json::Value::as_str) + .is_none_or(|value| !is_valid_agent_db_sha256(value)) + { + return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); + } + } + for field in ["callIdSha256", "functionNameSha256"] { + if !is_null_or_sha256(field) { + return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); + } + } + } + _ => unreachable!("tool-plan audit record type was validated above"), + } + #[cfg(test)] + take_agent_db_record_failure_injection(root, Some(record_type))?; + + let append_class = agent_db_record_append_class(&record); + let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + if validate_agent_db_tool_plan_audit_records_unlocked( + &mut storage.file, + &storage.path, + &record, + )? { + return Ok(false); + } + let line = serialize_agent_db_record(record)?; + validate_agent_db_append_class_record_size(append_class, &line)?; + append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?; + Ok(true) +} + pub(crate) fn append_agent_db_agent_message_if_missing( root: &Path, agent_id: &str, @@ -2218,6 +2396,103 @@ fn validate_agent_db_action_delegation_group_records_unlocked( Ok(exact_matches == 1) } +fn validate_agent_db_tool_plan_audit_records_unlocked( + file: &mut File, + path: &Path, + expected: &serde_json::Value, +) -> Result { + let length = file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引元数据失败:{}: {error}", path.display()))? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + path.display() + )); + } + let identity = [ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "requestSlot", + ] + .map(|field| { + expected + .get(field) + .and_then(serde_json::Value::as_str) + .expect("validated tool-plan audit identity") + }); + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut record_count = 0_usize; + let mut exact_matches = 0_usize; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { + if !line.complete { + return Err(format!( + "Agent 本地索引 tool-plan 审计全量扫描发现不完整 JSONL 尾记录:{}", + path.display() + )); + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + record_count = record_count.saturating_add(1); + if record_count > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引超过 {} 条记录扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + let record = serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + let matches_key = [ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "source", + "requestSlot", + ] + .into_iter() + .zip(identity.iter()) + .all(|(field, value)| { + record.get(field).and_then(serde_json::Value::as_str) == Some(*value) + }); + if !matches_key { + continue; + } + if !agent_db_stored_record_matches_expected_payload(&record, expected) { + return Err(format!( + "Agent 本地索引 tool-plan 幂等审计内容冲突:{}/{}/{}/{}", + identity[0], identity[1], identity[4], identity[6] + )); + } + exact_matches = exact_matches.saturating_add(1); + if exact_matches > 1 { + return Err(format!( + "Agent 本地索引 tool-plan 幂等审计重复:{}/{}/{}/{}", + identity[0], identity[1], identity[4], identity[6] + )); + } + } + if exact_matches == 0 && record_count >= AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引已达到 {} 条记录扫描上限,无法追加 tool-plan 审计:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + Ok(exact_matches == 1) +} + fn validate_agent_db_action_records_unlocked( file: &mut File, path: &Path, @@ -8491,6 +8766,247 @@ mod agent_db_security_tests { }) } + fn tool_plan_protocol_audit_record( + agent_id: &str, + run_id: &str, + request_slot: &str, + ) -> serde_json::Value { + serde_json::json!({ + "recordType": "agent.runtime.tool_plan.protocol", + "agentId": agent_id, + "taskId": agent_id, + "sessionId": format!("session-{agent_id}"), + "runId": run_id, + "source": "agent-background-task", + "loopIteration": 0, + "repairAttempt": 0, + "requestSlot": request_slot, + "responseFingerprint": "1".repeat(64), + "providerRequestIdSha256": "2".repeat(64), + "protocol": "native_runtime_tools", + "functionCallCount": 0, + "callIdSha256s": [], + "functionNames": [], + "normalizationKinds": [], + "normalizationCount": 0, + "normalizedTextChars": 0, + "normalizedTextSha256": null, + "responseIdSha256": null, + "responseIdChars": 0, + }) + } + + fn tool_plan_repair_audit_record( + agent_id: &str, + run_id: &str, + request_slot: &str, + ) -> serde_json::Value { + serde_json::json!({ + "recordType": "agent.runtime.tool_plan.repair", + "agentId": agent_id, + "taskId": agent_id, + "sessionId": format!("session-{agent_id}"), + "runId": run_id, + "source": "agent-background-task", + "loopIteration": 0, + "repairAttempt": 0, + "requestSlot": request_slot, + "responseFingerprint": "1".repeat(64), + "providerRequestIdSha256": "2".repeat(64), + "protocol": "native_runtime_tools", + "attempt": 1, + "maxAttempts": 2, + "protocolErrorKind": "response-shape", + "protocolErrorSha256": "3".repeat(64), + "protocolErrorChars": 32, + "responsePreviewSha256": "4".repeat(64), + "responsePreviewChars": 64, + "callIdSha256": null, + "functionNameSha256": null, + }) + } + + const TOOL_PLAN_PROCESS_ROOT_ENV: &str = "GENARRATIVE_TOOL_PLAN_AGENT_DB_TEST_ROOT"; + const TOOL_PLAN_PROCESS_RECORD_TYPE_ENV: &str = + "GENARRATIVE_TOOL_PLAN_AGENT_DB_TEST_RECORD_TYPE"; + const TOOL_PLAN_PROCESS_PAYLOAD_ENV: &str = "GENARRATIVE_TOOL_PLAN_AGENT_DB_TEST_PAYLOAD"; + const TOOL_PLAN_PROCESS_STARTED_ENV: &str = "GENARRATIVE_TOOL_PLAN_AGENT_DB_TEST_STARTED"; + const TOOL_PLAN_PROCESS_GATE_ENV: &str = "GENARRATIVE_TOOL_PLAN_AGENT_DB_TEST_GATE"; + const TOOL_PLAN_PROCESS_RESULT_ENV: &str = "GENARRATIVE_TOOL_PLAN_AGENT_DB_TEST_RESULT"; + + fn wait_for_tool_plan_process_file(path: &Path, label: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !path.exists() { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {label}: {}", + path.display() + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + + fn spawn_tool_plan_agent_db_process_worker( + root: &Path, + record_type: &str, + payload: &str, + started_path: &Path, + gate_path: &Path, + result_path: &Path, + ) -> std::process::Child { + std::process::Command::new(std::env::current_exe().expect("resolve current test binary")) + .arg("tool_plan_agent_db_cross_process_worker") + .arg("--test-threads=1") + .arg("--nocapture") + .env(TOOL_PLAN_PROCESS_ROOT_ENV, root) + .env(TOOL_PLAN_PROCESS_RECORD_TYPE_ENV, record_type) + .env(TOOL_PLAN_PROCESS_PAYLOAD_ENV, payload) + .env(TOOL_PLAN_PROCESS_STARTED_ENV, started_path) + .env(TOOL_PLAN_PROCESS_GATE_ENV, gate_path) + .env(TOOL_PLAN_PROCESS_RESULT_ENV, result_path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn tool-plan Agent DB process worker") + } + + fn read_tool_plan_agent_db_process_result( + child: std::process::Child, + result_path: &Path, + ) -> String { + let output = child + .wait_with_output() + .expect("wait for tool-plan Agent DB process worker"); + assert!( + output.status.success(), + "tool-plan Agent DB process worker failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + fs::read_to_string(result_path) + .unwrap_or_else(|error| { + panic!( + "read tool-plan Agent DB process result {}: {error}", + result_path.display() + ) + }) + .trim() + .to_string() + } + + fn run_tool_plan_agent_db_process_case( + test_name: &str, + record_type: &str, + second_payload: &str, + ) -> (Vec, Vec) { + let root = unique_agent_db_test_root(test_name); + let sentinel = serde_json::json!({ + "recordType": "test.tool-plan-process-sentinel", + "testName": test_name, + }); + write_agent_db_records(&root, std::slice::from_ref(&sentinel)); + + let agent_db_path = root.join(".agent/agent.db"); + let append_lock = project_append_lock_for(&agent_db_path) + .expect("resolve tool-plan Agent DB cross-process append lock"); + let append_guard = append_lock + .lock("Agent 本地索引 tool-plan 双进程测试") + .expect("hold tool-plan Agent DB cross-process append lock"); + let gate_path = root.join("tool-plan-process.go"); + let first_started = root.join("tool-plan-process-first.started"); + let first_result = root.join("tool-plan-process-first.result"); + let second_started = root.join("tool-plan-process-second.started"); + let second_result = root.join("tool-plan-process-second.result"); + let first = spawn_tool_plan_agent_db_process_worker( + &root, + record_type, + "base", + &first_started, + &gate_path, + &first_result, + ); + let second = spawn_tool_plan_agent_db_process_worker( + &root, + record_type, + second_payload, + &second_started, + &gate_path, + &second_result, + ); + wait_for_tool_plan_process_file(&first_started, "first process start marker"); + wait_for_tool_plan_process_file(&second_started, "second process start marker"); + fs::write(&gate_path, b"go\n").expect("release tool-plan Agent DB process workers"); + std::thread::sleep(std::time::Duration::from_millis(250)); + let completed_while_locked = first_result.exists() || second_result.exists(); + drop(append_guard); + + let outcomes = vec![ + read_tool_plan_agent_db_process_result(first, &first_result), + read_tool_plan_agent_db_process_result(second, &second_result), + ]; + let (records, truncated) = + read_agent_db_records_bounded(&root, u64::MAX).expect("read process Agent DB records"); + fs::remove_dir_all(&root).ok(); + assert!( + !completed_while_locked, + "tool-plan compare-and-append bypassed the shared cross-process append lock" + ); + assert!(!truncated); + assert_eq!( + records.iter().filter(|record| **record == sentinel).count(), + 1, + "the existing Agent DB prefix must not be overwritten" + ); + (outcomes, records) + } + + #[test] + fn tool_plan_agent_db_cross_process_worker() { + let Some(root) = std::env::var_os(TOOL_PLAN_PROCESS_ROOT_ENV).map(PathBuf::from) else { + return; + }; + let record_type = std::env::var(TOOL_PLAN_PROCESS_RECORD_TYPE_ENV) + .expect("tool-plan process worker record type"); + let payload = + std::env::var(TOOL_PLAN_PROCESS_PAYLOAD_ENV).expect("tool-plan process worker payload"); + let started_path = PathBuf::from( + std::env::var_os(TOOL_PLAN_PROCESS_STARTED_ENV) + .expect("tool-plan process worker start marker"), + ); + let gate_path = PathBuf::from( + std::env::var_os(TOOL_PLAN_PROCESS_GATE_ENV).expect("tool-plan process worker gate"), + ); + let result_path = PathBuf::from( + std::env::var_os(TOOL_PLAN_PROCESS_RESULT_ENV) + .expect("tool-plan process worker result"), + ); + fs::write(&started_path, b"started\n") + .expect("write tool-plan process worker start marker"); + wait_for_tool_plan_process_file(&gate_path, "process worker gate"); + + let mut record = match record_type.as_str() { + "agent.runtime.tool_plan.protocol" => { + tool_plan_protocol_audit_record("design-director", "process-run", "loop-0-repair-0") + } + "agent.runtime.tool_plan.repair" => { + tool_plan_repair_audit_record("design-director", "process-run", "loop-0-repair-0") + } + _ => panic!("unsupported tool-plan process record type: {record_type}"), + }; + if payload == "conflict" { + record["responseFingerprint"] = serde_json::Value::String("3".repeat(64)); + } else { + assert_eq!(payload, "base", "unsupported tool-plan process payload"); + } + let outcome = match append_agent_db_tool_plan_audit_idempotent(&root, record) { + Ok(true) => "appended".to_string(), + Ok(false) => "existing".to_string(), + Err(error) => format!("error:{error}"), + }; + fs::write(&result_path, format!("{outcome}\n")) + .expect("write tool-plan process worker result"); + } + fn isolated_join_claim_audit_record( delegation_group_id: &str, join_run_id: &str, @@ -8790,6 +9306,373 @@ mod agent_db_security_tests { fs::remove_dir_all(&outside).ok(); } + #[test] + fn tool_plan_audit_input_contract_accepts_exact_protocol_and_repair_records() { + let root = unique_agent_db_test_root("tool-plan-audit-exact-contract"); + fs::create_dir_all(&root).expect("create exact tool-plan audit project"); + + assert!(append_agent_db_tool_plan_audit_idempotent( + &root, + tool_plan_protocol_audit_record( + "design-director", + "exact-contract-run", + "loop-0-repair-0", + ), + ) + .expect("append exact protocol audit")); + assert!(append_agent_db_tool_plan_audit_idempotent( + &root, + tool_plan_repair_audit_record( + "design-director", + "exact-contract-run", + "loop-0-repair-0", + ), + ) + .expect("append exact repair audit")); + + let (records, truncated) = + read_agent_db_records_bounded(&root, u64::MAX).expect("read exact tool-plan audits"); + assert!(!truncated); + assert_eq!(records.len(), 2); + assert!(records.iter().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.tool_plan.protocol") + })); + assert!(records.iter().any(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("agent.runtime.tool_plan.repair") + })); + + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn tool_plan_audit_input_contract_rejects_unknown_raw_identifier_and_envelope_fields() { + let root = unique_agent_db_test_root("tool-plan-audit-reject-input-fields"); + let assert_rejected = |record: serde_json::Value, expected_error: &str| { + let error = append_agent_db_tool_plan_audit_idempotent(&root, record) + .expect_err("unsafe tool-plan audit input must be rejected"); + assert!(error.contains(expected_error), "{error}"); + }; + + assert_rejected(serde_json::json!([]), "必须是对象"); + for field in [ + "unexpectedField", + "providerId", + "providerRequestId", + "provider_request_id", + "requestId", + "request_id", + "callId", + "callIds", + "call_id", + "call_ids", + "responseId", + "response_id", + ] { + let mut record = tool_plan_protocol_audit_record( + "design-director", + "rejected-field-run", + "loop-0-repair-0", + ); + record + .as_object_mut() + .expect("protocol audit object") + .insert(field.to_string(), serde_json::json!("raw-identifier")); + assert_rejected(record, "字段白名单不匹配"); + } + + let mut missing_protocol_field = tool_plan_protocol_audit_record( + "design-director", + "missing-protocol-field-run", + "loop-0-repair-0", + ); + missing_protocol_field + .as_object_mut() + .expect("protocol audit object") + .remove("functionNames"); + assert_rejected(missing_protocol_field, "字段白名单不匹配"); + + let mut missing_repair_field = tool_plan_repair_audit_record( + "design-director", + "missing-repair-field-run", + "loop-0-repair-0", + ); + missing_repair_field + .as_object_mut() + .expect("repair audit object") + .remove("protocolErrorKind"); + assert_rejected(missing_repair_field, "字段白名单不匹配"); + + for (field, value) in [ + ( + "schemaVersion", + serde_json::json!(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION), + ), + ("updatedAt", serde_json::json!(unix_timestamp())), + ] { + let mut record = tool_plan_repair_audit_record( + "design-director", + "prefilled-envelope-run", + "loop-0-repair-0", + ); + record + .as_object_mut() + .expect("repair audit object") + .insert(field.to_string(), value); + assert_rejected(record, "不能预填持久化 envelope"); + } + + assert!( + !root.join(".agent/agent.db").exists(), + "rejected tool-plan audit inputs must not create Agent DB storage" + ); + } + + #[test] + fn tool_plan_audit_input_contract_rejects_raw_ids_in_sha256_fields() { + let root = unique_agent_db_test_root("tool-plan-audit-reject-raw-id-values"); + let assert_rejected = |record: serde_json::Value, field: &str| { + let error = append_agent_db_tool_plan_audit_idempotent(&root, record) + .expect_err("raw tool-plan identifier must be rejected"); + assert!(error.contains(field), "{error}"); + }; + + let mut raw_provider_request_id = tool_plan_protocol_audit_record( + "design-director", + "raw-provider-request-id-run", + "loop-0-repair-0", + ); + raw_provider_request_id["providerRequestIdSha256"] = + serde_json::json!("provider-request-raw"); + assert_rejected(raw_provider_request_id, "providerRequestIdSha256"); + + let mut raw_call_ids = tool_plan_protocol_audit_record( + "design-director", + "raw-call-id-run", + "loop-0-repair-0", + ); + raw_call_ids["callIdSha256s"] = serde_json::json!(["call-raw"]); + assert_rejected(raw_call_ids, "callIdSha256s"); + + let mut raw_response_id = tool_plan_protocol_audit_record( + "design-director", + "raw-response-id-run", + "loop-0-repair-0", + ); + raw_response_id["responseIdSha256"] = serde_json::json!("response-raw"); + assert_rejected(raw_response_id, "responseIdSha256"); + + let mut raw_repair_call_id = tool_plan_repair_audit_record( + "design-director", + "raw-repair-call-id-run", + "loop-0-repair-0", + ); + raw_repair_call_id["callIdSha256"] = serde_json::json!("call-raw"); + assert_rejected(raw_repair_call_id, "callIdSha256"); + + assert!( + !root.join(".agent/agent.db").exists(), + "raw identifiers rejected at the input boundary must not reach Agent DB storage" + ); + } + + #[test] + fn tool_plan_audit_append_is_atomic_conflict_checked_and_agent_scoped() { + use std::sync::{Arc, Barrier}; + + let root = unique_agent_db_test_root("tool-plan-audit-idempotent"); + fs::create_dir_all(&root).expect("create tool-plan audit project"); + let record = + tool_plan_protocol_audit_record("design-director", "shared-run-id", "loop-0-repair-0"); + assert!( + append_agent_db_tool_plan_audit_idempotent(&root, record.clone()) + .expect("append first tool-plan audit") + ); + assert!( + !append_agent_db_tool_plan_audit_idempotent(&root, record.clone()) + .expect("repeat identical tool-plan audit") + ); + + let mut conflict = record.clone(); + conflict["responseFingerprint"] = serde_json::json!("3".repeat(64)); + let error = append_agent_db_tool_plan_audit_idempotent(&root, conflict) + .expect_err("same tool-plan audit identity with new payload must conflict"); + assert!(error.contains("内容冲突"), "{error}"); + + let same_run_other_agent = + tool_plan_protocol_audit_record("art-director", "shared-run-id", "loop-0-repair-0"); + assert!( + append_agent_db_tool_plan_audit_idempotent(&root, same_run_other_agent) + .expect("same run id under another agent remains distinct") + ); + + let concurrent = + tool_plan_protocol_audit_record("design-director", "shared-run-id", "loop-1-repair-0"); + let barrier = Arc::new(Barrier::new(8)); + let mut workers = Vec::new(); + for _ in 0..8 { + let root = root.clone(); + let barrier = Arc::clone(&barrier); + let record = concurrent.clone(); + workers.push(std::thread::spawn(move || { + barrier.wait(); + append_agent_db_tool_plan_audit_idempotent(&root, record) + })); + } + let appended = workers + .into_iter() + .map(|worker| worker.join().expect("join tool-plan audit writer")) + .collect::, _>>() + .expect("append concurrent tool-plan audits") + .into_iter() + .filter(|appended| *appended) + .count(); + assert_eq!(appended, 1); + let (records, truncated) = + read_agent_db_records_bounded(&root, u64::MAX).expect("read tool-plan audit records"); + assert!(!truncated); + assert_eq!( + records + .iter() + .filter(|candidate| { + candidate["recordType"] == concurrent["recordType"] + && candidate["agentId"] == concurrent["agentId"] + && candidate["runId"] == concurrent["runId"] + && candidate["requestSlot"] == concurrent["requestSlot"] + }) + .count(), + 1 + ); + + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn tool_plan_protocol_audit_is_idempotent_across_processes() { + let record_type = "agent.runtime.tool_plan.protocol"; + let (outcomes, records) = run_tool_plan_agent_db_process_case( + "tool-plan-protocol-two-processes", + record_type, + "base", + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| *outcome == "appended") + .count(), + 1, + "exactly one process must append the shared protocol audit: {outcomes:?}" + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| *outcome == "existing") + .count(), + 1, + "the other process must observe the identical protocol audit: {outcomes:?}" + ); + assert_eq!( + records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(record_type) + }) + .count(), + 1, + "the shared protocol audit key must have one persisted record" + ); + assert_eq!( + records.len(), + 2, + "sentinel plus one protocol audit expected" + ); + } + + #[test] + fn tool_plan_repair_audit_conflict_fails_closed_across_processes() { + let record_type = "agent.runtime.tool_plan.repair"; + let (outcomes, records) = run_tool_plan_agent_db_process_case( + "tool-plan-repair-two-processes", + record_type, + "conflict", + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| *outcome == "appended") + .count(), + 1, + "exactly one conflicting repair payload may be persisted: {outcomes:?}" + ); + assert_eq!( + outcomes + .iter() + .filter(|outcome| outcome.contains("tool-plan 幂等审计内容冲突")) + .count(), + 1, + "the losing repair payload must fail closed: {outcomes:?}" + ); + assert_eq!( + records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(record_type) + }) + .count(), + 1, + "the conflicting repair audit key must have one persisted record" + ); + assert_eq!(records.len(), 2, "sentinel plus one repair audit expected"); + } + + #[test] + fn tool_plan_audit_idempotency_scans_beyond_bounded_read_window() { + let root = unique_agent_db_test_root("tool-plan-audit-full-history"); + let agent_dir = root.join(".agent"); + fs::create_dir_all(&agent_dir).expect("create full-history Agent DB directory"); + let record = tool_plan_protocol_audit_record( + "design-director", + "full-history-run", + "loop-0-repair-0", + ); + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(agent_dir.join("agent.db")) + .expect("open full-history Agent DB"); + writeln!( + file, + "{}", + serde_json::to_string(&with_agent_db_envelope(record.clone())) + .expect("serialize existing tool-plan audit") + ) + .expect("write existing tool-plan audit"); + let filler = serde_json::to_string(&serde_json::json!({ + "payload": "x".repeat(AGENT_DB_MAX_RECORD_BYTES - 128), + })) + .expect("serialize full-history filler"); + assert!(filler.len() < AGENT_DB_MAX_RECORD_BYTES); + while file + .metadata() + .expect("read full-history Agent DB size") + .len() + <= AGENT_DB_MAX_BOUNDED_READ_BYTES + AGENT_DB_MAX_RECORD_BYTES as u64 + { + writeln!(file, "{filler}").expect("write full-history filler"); + } + file.flush().expect("flush full-history Agent DB"); + drop(file); + + assert!(!append_agent_db_tool_plan_audit_idempotent(&root, record) + .expect("find tool-plan audit outside bounded tail")); + + fs::remove_dir_all(&root).ok(); + } + #[test] fn action_append_rejects_a_conflicting_second_record_with_the_same_key() { let root = unique_agent_db_test_root("duplicate-action-conflict"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index 32ed1751c..6be0cd04b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -542,6 +542,11 @@ fn redact_runner_secret(message: &str, token: &str) -> String { redacted.chars().take(2_000).collect() } +fn redact_external_agent_runner_runtime_error(root: &Path, message: &str, token: &str) -> String { + let redacted = redact_runner_secret(message, token); + crate::redact_agent_runtime_error(root, &redacted, 500) +} + fn normalize_external_agent_runner_config_dir(config_dir: &Path) -> Result { crate::prepare_game_creator_runtime_config_dir(config_dir) } @@ -2488,7 +2493,7 @@ fn dispatch_external_agent_runner_runtime_request( Err(error) => ExternalAgentRunnerResponse::failure( &request.request_id, "runtime-error", - redact_runner_secret(&error, &token), + redact_external_agent_runner_runtime_error(&root, &error, &token), ), } } @@ -2747,6 +2752,7 @@ fn external_agent_runner_root_is_idle(root: &Path) -> Result { root.join(".agent/runtime/finalizations"), root.join(".agent/runtime/provider-handoffs"), root.join(".agent/runtime/provider-retries"), + root.join(".agent/runtime/tool-plan-handoffs"), ] { if external_agent_runner_directory_has_durable_files(&durable_dir)? { return Ok(false); @@ -3889,6 +3895,26 @@ mod tests { assert!(!serialized.contains("\"token\"")); } + #[test] + fn runtime_error_redaction_hides_project_and_absolute_paths_from_runner_clients() { + let directory = unique_test_directory(); + let token = "runner-private-token-runner-private-token"; + let failing_path = directory + .0 + .join(".agent/runtime/tool-plan-handoffs/broken-ledger.json"); + let error = format!( + "读取 tool-plan 成功响应交接失败:{};token={token};backup=/home/private/ledger.previous", + failing_path.display() + ); + + let redacted = redact_external_agent_runner_runtime_error(&directory.0, &error, token); + assert!(!redacted.contains(directory.0.to_string_lossy().as_ref())); + assert!(!redacted.contains(token)); + assert!(!redacted.contains("/home/private")); + assert!(redacted.contains("$PROJECT_ROOT")); + assert!(redacted.contains("")); + } + #[test] fn continuation_params_bind_agent_run_and_action_exactly() { let request = ExternalAgentRunnerRequest { @@ -4468,6 +4494,27 @@ mod tests { assert!(!state.draining.load(Ordering::Acquire)); } + #[test] + fn durable_tool_plan_handoff_prevents_shutdown_even_when_corrupt() { + let directory = unique_test_directory(); + let root = directory.0.join("project"); + let handoff_path = root + .join(".agent/runtime/tool-plan-handoffs") + .join("agent-key") + .join("run-key.json"); + fs::create_dir_all(handoff_path.parent().expect("tool-plan handoff parent")) + .expect("create tool-plan handoff directory"); + fs::write(&handoff_path, b"{").expect("write corrupt tool-plan handoff"); + + assert!(!external_agent_runner_root_is_idle(&root).expect("scan primary handoff")); + let previous_path = crate::agent::agent_runtime_json_sidecar_backup_path(&handoff_path); + fs::rename(&handoff_path, &previous_path).expect("move tool-plan handoff to previous"); + assert!(!external_agent_runner_root_is_idle(&root).expect("scan previous handoff")); + + fs::remove_file(previous_path).expect("remove tool-plan handoff previous"); + assert!(external_agent_runner_root_is_idle(&root).expect("scan idle root")); + } + #[test] fn durable_provider_retry_prevents_shutdown_and_reopens_writes() { let directory = unique_test_directory(); 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 563130913..6632cf448 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -13549,15 +13549,25 @@ async fn background_agent_runtime_executes_native_function_tool_plan() { assert!(protocol_records .iter() .all(|record| record["protocol"] == "native_runtime_tools")); - assert_eq!(protocol_records[0]["callId"], "call-native-plan"); - assert_eq!(protocol_records[1]["callId"], "call-native-index"); - assert_eq!(protocol_records[2]["callId"], "call-native-plan-completed"); - assert_eq!(protocol_records[0]["functionName"], "update_agent_plan"); + for (record, call_id) in protocol_records.iter().zip([ + "call-native-plan", + "call-native-index", + "call-native-plan-completed", + ]) { + assert_eq!( + record["callIdSha256s"][0], + format!("{:x}", Sha256::digest(call_id.as_bytes())) + ); + assert!(record.get("callId").is_none()); + assert!(record.get("callIds").is_none()); + assert!(record.get("responseId").is_none()); + } + assert_eq!(protocol_records[0]["functionNames"][0], "update_agent_plan"); assert_eq!( - protocol_records[1]["functionName"], + protocol_records[1]["functionNames"][0], "runtime_tool_project_index" ); - assert_eq!(protocol_records[2]["functionName"], "update_agent_plan"); + assert_eq!(protocol_records[2]["functionNames"][0], "update_agent_plan"); assert_eq!(protocol_records[0]["functionCallCount"], 1); assert_eq!(protocol_records[2]["functionCallCount"], 2); assert_eq!( @@ -14737,7 +14747,23 @@ async fn background_agent_runtime_bounds_duplicate_agent_message_livelock() { 1, "semantic retries must append the target message exactly once" ); - let agent_db = read_agent_db_records_for_test(&root); + let has_budget_failure_audit = |records: &[Value]| { + records.iter().any(|record| { + record.get("recordType").and_then(Value::as_str) + == Some("agent.runtime.background_task.failed") + && record.get("runId").and_then(Value::as_str) == Some(run_id) + && record.get("failureKind").and_then(Value::as_str) + == Some("loop-budget-exhausted") + }) + }; + let mut agent_db = read_agent_db_records_for_test(&root); + for _ in 0..50 { + if has_budget_failure_audit(&agent_db) { + break; + } + std::thread::sleep(Duration::from_millis(20)); + agent_db = read_agent_db_records_for_test(&root); + } assert_eq!( agent_db .iter() @@ -14817,12 +14843,7 @@ async fn background_agent_runtime_bounds_duplicate_agent_message_livelock() { && record.get("detailUnavailable").and_then(Value::as_bool) == Some(true) && record.get("safeDetail").is_some_and(Value::is_null) })); - assert!(agent_db.iter().any(|record| { - record.get("recordType").and_then(Value::as_str) - == Some("agent.runtime.background_task.failed") - && record.get("runId").and_then(Value::as_str) == Some(run_id) - && record.get("failureKind").and_then(Value::as_str) == Some("loop-budget-exhausted") - })); + assert!(has_budget_failure_audit(&agent_db)); assert!(!agent_db.iter().any(|record| { record.get("recordType").and_then(Value::as_str) == Some("agent.runtime.background_task.completed") @@ -34435,6 +34456,8 @@ fn wait_for_provider_handoff_terminal_cleanup(root: &Path, agent_id: &str, run_i for _ in 0..250 { let handoff = crate::provider_handoff::read_for_run_at(root, agent_id, run_id) .expect("read Provider handoff during terminal cleanup"); + let tool_plan_handoff = crate::tool_plan_handoff::read_for_run_at(root, agent_id, run_id) + .expect("read tool-plan handoff during terminal cleanup"); let retry = crate::provider_retry::read_for_run_at(root, agent_id, run_id) .expect("read Provider retry during terminal cleanup"); let finalization = @@ -34442,7 +34465,12 @@ fn wait_for_provider_handoff_terminal_cleanup(root: &Path, agent_id: &str, run_i .expect("read finalization during Provider handoff terminal cleanup"); let lane_available = game_creator_agent_runtime_task_lock_is_available(root, agent_id) .expect("probe Provider handoff terminal lane"); - if handoff.is_none() && retry.is_none() && finalization.is_none() && lane_available { + if handoff.is_none() + && tool_plan_handoff.is_none() + && retry.is_none() + && finalization.is_none() + && lane_available + { return; } std::thread::sleep(Duration::from_millis(20)); @@ -34450,6 +34478,28 @@ fn wait_for_provider_handoff_terminal_cleanup(root: &Path, agent_id: &str, run_i panic!("Provider handoff/retry/finalization did not reach a clean terminal state"); } +fn wait_for_tool_plan_handoff_test_stop( + root: &Path, + agent_id: &str, + run_id: &str, + expected_entries: usize, +) -> crate::tool_plan_handoff::AgentRuntimeToolPlanHandoffLedger { + for _ in 0..250 { + let handoff = crate::tool_plan_handoff::read_for_run_at(root, agent_id, run_id) + .expect("read tool-plan handoff after test stop"); + if let Some(handoff) = handoff { + if handoff.entries.len() == expected_entries + && game_creator_agent_runtime_task_lock_is_available(root, agent_id) + .expect("probe tool-plan handoff test-stop lane") + { + return handoff; + } + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("tool-plan handoff was not committed before the test-stop lane released"); +} + #[tokio::test] async fn provider_handoff_final_reply_restart_replays_success_without_network_request() { let root = unique_project_path(); @@ -34931,6 +34981,298 @@ async fn provider_handoff_final_reply_compaction_restart_only_requests_final_rep fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn tool_plan_handoff_base_restart_replays_success_without_network_request() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "tool-plan base 成功交接恢复测试") + .expect("project init"); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create tool-plan handoff config dir"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); + let injection = root.join(".agent/runtime/test-stop-after-provider-handoff"); + fs::create_dir_all(injection.parent().expect("tool-plan injection parent")) + .expect("create tool-plan injection parent"); + fs::write( + &injection, + b"stop-after-tool-plan-provider-handoff:loop-1-repair-0", + ) + .expect("write tool-plan base handoff injection"); + let agent_id = "design-director"; + let run_id = "design-tool-plan-handoff-base-run"; + let final_response = "tool-plan base 成功交接恢复后只提交一次。"; + let base_url = spawn_mock_llm_server(final_tool_plan_response(final_response)); + replace_test_local_config( + &config_path, + format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "tool-plan-handoff-base-key", + "baseUrl": {base_url:?}, + "model": "tool-plan-handoff-base-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + ), + ); + let started = start_game_creator_agent_background_task_at( + &root, + agent_id, + "验证 tool-plan base 成功 handoff 后恢复不重发 Provider", + run_id, + ) + .expect("start tool-plan base handoff task"); + + let handoff = wait_for_tool_plan_handoff_test_stop(&root, agent_id, run_id, 1); + assert!(!injection.exists()); + assert_eq!(handoff.entries[0].loop_iteration, 1); + assert_eq!(handoff.entries[0].repair_attempt, 0); + assert_eq!(handoff.entries[0].identity.request_kind, "tool-plan"); + assert_eq!(handoff.entries[0].request_slot, "loop-1-repair-0"); + let before_records = read_agent_db_records_for_test(&root); + let before_lifecycle = before_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["requestId"] == handoff.entries[0].provider_request_id + }) + .collect::>(); + assert_eq!(before_lifecycle.len(), 1); + assert_eq!(before_lifecycle[0]["status"], "started"); + let before_conversation = read_local_conversation_for_session_at( + &root, + Some(agent_id), + Some(&started.state.session_id), + ) + .expect("read tool-plan base conversation before replay"); + assert_eq!( + before_conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .count(), + 0 + ); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume tool-plan base handoff"); + assert_eq!(resumed.len(), 1); + let completed = wait_for_agent_runtime_idle(&root, agent_id); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.run_id, run_id); + assert_eq!(completed.last_response.as_deref(), Some(final_response)); + wait_for_provider_handoff_terminal_cleanup(&root, agent_id, run_id); + + let records = read_agent_db_records_for_test(&root); + let lifecycle = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestKind"] == "tool-plan" + }) + .collect::>(); + assert_eq!(lifecycle.len(), 2); + assert_eq!(lifecycle[0]["status"], "started"); + assert_eq!(lifecycle[1]["status"], "completed"); + assert_eq!(lifecycle[0]["requestId"], lifecycle[1]["requestId"]); + let protocol = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(protocol.len(), 1); + assert_eq!(protocol[0]["requestSlot"], "loop-1-repair-0"); + let conversation = read_local_conversation_for_session_at( + &root, + Some(agent_id), + Some(&started.state.session_id), + ) + .expect("read completed tool-plan base conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(), + vec![final_response] + ); + let committed_stream = wait_for_response_stream_status(&root, agent_id, run_id, "committed", 1); + assert_eq!(committed_stream.accumulated_text, final_response); + + fs::remove_dir_all(root).ok(); + drop(config_guard); + fs::remove_dir_all(config_dir).ok(); +} + +#[tokio::test] +async fn tool_plan_handoff_repair_restart_replays_chain_without_network_request() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "tool-plan repair 成功交接恢复测试") + .expect("project init"); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create tool-plan repair config dir"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); + let injection = root.join(".agent/runtime/test-stop-after-provider-handoff"); + fs::create_dir_all( + injection + .parent() + .expect("tool-plan repair injection parent"), + ) + .expect("create tool-plan repair injection parent"); + fs::write( + &injection, + b"stop-after-tool-plan-provider-handoff:loop-1-repair-1", + ) + .expect("write tool-plan repair handoff injection"); + let agent_id = "design-director"; + let run_id = "design-tool-plan-handoff-repair-run"; + let malformed_response = "TOOL_PLAN_REPAIR_PRIVATE_RESPONSE"; + let final_response = "tool-plan repair 链恢复后只提交一次。"; + let base_url = spawn_mock_llm_server_responses(vec![ + malformed_response.to_string(), + final_tool_plan_response(final_response), + ]); + replace_test_local_config( + &config_path, + format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "tool-plan-handoff-repair-key", + "baseUrl": {base_url:?}, + "model": "tool-plan-handoff-repair-model", + "apiKind": "openai_responses", + "stream": false, + "maxRetries": 0 + }} + }} +}}"# + ), + ); + let started = start_game_creator_agent_background_task_at( + &root, + agent_id, + "验证 tool-plan repair 成功 handoff 后恢复整条 repair 链", + run_id, + ) + .expect("start tool-plan repair handoff task"); + + let handoff = wait_for_tool_plan_handoff_test_stop(&root, agent_id, run_id, 2); + assert!(!injection.exists()); + assert_eq!( + handoff + .entries + .iter() + .map(|entry| (entry.loop_iteration, entry.repair_attempt)) + .collect::>(), + vec![(1, 0), (1, 1)] + ); + assert_eq!( + handoff.entries[0].to_llm_response().text, + malformed_response + ); + let before_records = read_agent_db_records_for_test(&root); + let transitions_for = |request_id: &str| { + before_records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["requestId"] == request_id + }) + .map(|record| record["status"].as_str().unwrap_or_default()) + .collect::>() + }; + assert_eq!( + transitions_for(&handoff.entries[0].provider_request_id), + vec!["started", "completed"] + ); + assert_eq!( + transitions_for(&handoff.entries[1].provider_request_id), + vec!["started"] + ); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume tool-plan repair handoff"); + assert_eq!(resumed.len(), 1); + let completed = wait_for_agent_runtime_idle(&root, agent_id); + assert_eq!(completed.phase, "completed"); + assert_eq!(completed.run_id, run_id); + assert_eq!(completed.last_response.as_deref(), Some(final_response)); + wait_for_provider_handoff_terminal_cleanup(&root, agent_id, run_id); + + let records = read_agent_db_records_for_test(&root); + let lifecycle = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.provider_request.lifecycle" + && record["runId"] == run_id + && record["requestKind"] == "tool-plan" + }) + .collect::>(); + assert_eq!(lifecycle.len(), 4); + assert_eq!( + lifecycle + .iter() + .filter_map(|record| record["requestId"].as_str()) + .collect::>() + .len(), + 2 + ); + let repair_audits = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(repair_audits.len(), 1); + assert_eq!(repair_audits[0]["requestSlot"], "loop-1-repair-0"); + let protocol_audits = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(protocol_audits.len(), 1); + assert_eq!(protocol_audits[0]["requestSlot"], "loop-1-repair-1"); + let conversation = read_local_conversation_for_session_at( + &root, + Some(agent_id), + Some(&started.state.session_id), + ) + .expect("read completed tool-plan repair conversation"); + assert_eq!( + conversation + .messages + .iter() + .filter(|message| message.role == "assistant") + .map(|message| message.content.as_str()) + .collect::>(), + vec![final_response] + ); + let public_surfaces = format!( + "{}\n{}\n{}", + fs::read_to_string(root.join(".agent/agent.db")).expect("read tool-plan repair Agent DB"), + fs::read_to_string(root.join(".agent/runtime/events/design-director.jsonl")) + .expect("read tool-plan repair events"), + serde_json::to_string(&conversation).expect("serialize tool-plan repair conversation") + ); + assert!(!public_surfaces.contains(malformed_response)); + let committed_stream = wait_for_response_stream_status(&root, agent_id, run_id, "committed", 1); + assert_eq!(committed_stream.accumulated_text, final_response); + + fs::remove_dir_all(root).ok(); + drop(config_guard); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn provider_transient_retry_backoff_is_exponential_and_capped_at_thirty_seconds() { let root = unique_project_path(); @@ -35295,7 +35637,8 @@ async fn background_agent_runtime_repairs_malformed_native_function_arguments() record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id && record["protocol"] == "native_runtime_tools" - && record["callId"] == "call-native-repaired" + && record["callIdSha256s"][0] + == format!("{:x}", Sha256::digest("call-native-repaired".as_bytes())) })); fs::remove_dir_all(root).ok(); @@ -35367,6 +35710,80 @@ async fn background_agent_runtime_audits_thinking_normalization_without_body() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_repairs_unclosed_thinking_without_executing_hidden_call() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "未闭合推理块修复").expect("project init"); + let private_thinking = "UNCLOSED_THINKING_PRIVATE_CANARY"; + let mut malformed = native_agent_tool_plan_chat_response( + "call-unclosed-thinking", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + serde_json::json!({"response": "UNBALANCED_CALL_MUST_NOT_EXECUTE"}).to_string(), + ); + malformed["choices"][0]["message"]["content"] = + serde_json::json!(format!("{private_thinking}")); + let repaired = native_agent_tool_plan_chat_response( + "call-repaired-thinking", + AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + serde_json::json!({"response": "未闭合推理块已进入格式修复。THINKING_REPAIR_OK"}) + .to_string(), + ); + let base_url = spawn_mock_llm_raw_responses_with_capture(vec![malformed, repaired], None); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat" + }} + }} +}}"# + )); + let run_id = "design-unclosed-thinking-repair-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证未闭合 thinking 不会绕过 repair", + run_id, + ) + .expect("start unclosed thinking task"); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime.last_response.as_deref(), + Some("未闭合推理块已进入格式修复。THINKING_REPAIR_OK") + ); + let records = read_agent_db_records_for_test(&root); + let repairs = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(repairs.len(), 1); + assert_eq!(repairs[0]["protocolErrorKind"], "response-shape"); + assert_eq!(repairs[0]["repairAttempt"], 0); + let protocols = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(protocols.len(), 1); + assert_eq!(protocols[0]["repairAttempt"], 1); + let public = + fs::read_to_string(root.join(".agent/agent.db")).expect("read unclosed thinking Agent DB"); + assert!(!public.contains(private_thinking)); + assert!(!public.contains("UNBALANCED_CALL_MUST_NOT_EXECUTE")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_audits_planner_commentary_without_body() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs new file mode 100644 index 000000000..866c761fa --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff.rs @@ -0,0 +1,5614 @@ +use std::collections::{BTreeMap, BTreeSet}; +#[cfg(unix)] +use std::fs; +#[cfg(any(unix, windows))] +use std::fs::File; +#[cfg(windows)] +use std::fs::OpenOptions; +use std::io::{Read, Write}; +use std::path::Path; +#[cfg(any(test, windows))] +use std::path::PathBuf; + +#[cfg(unix)] +use std::ffi::{CStr, CString}; +#[cfg(unix)] +use std::os::fd::{AsRawFd, FromRawFd}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::fs::{MetadataExt, PermissionsExt}; + +use platform_llm::{LlmProvider, LlmRunResponse, LlmTokenUsage, LlmToolCall}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[cfg(test)] +use crate::agent::agent_runtime_json_sidecar_backup_path; +use crate::agent::redact_secret_tokens; +use crate::provider_retry::{self, validate_identity, AgentRuntimeProviderRetryIdentity}; +use crate::repository_context::redact_absolute_path_tokens; + +pub(crate) const TOOL_PLAN_HANDOFF_SCHEMA_VERSION: &str = "game-creator-tool-plan-handoff.v1"; + +#[cfg(test)] +const TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY: &str = ".agent/runtime/tool-plan-handoffs"; +const TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES: usize = 4 * 1024 * 1024; +const TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES: usize = 512 * 1024; +const TOOL_PLAN_HANDOFF_MAX_ENTRIES: usize = 128; +const TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS: usize = 32; +const TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES: usize = 256 * 1024; +const TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES: usize = 256 * 1024; +const TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS: usize = 256; +const TOOL_PLAN_HANDOFF_FINISH_REASON_MAX_CHARS: usize = 80; +const TOOL_PLAN_HANDOFF_REQUEST_ID_MAX_CHARS: usize = 256; +const TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS: usize = 512; +const TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS: usize = 1024; +const TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS: usize = 1024; +const TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES: usize = 4096; +#[cfg(test)] +const TOOL_PLAN_HANDOFF_LABEL: &str = "Agent Runtime tool-plan 成功响应交接账本"; +const INVALID_THINKING_OPEN_MARKER: &str = ""; +const INVALID_THINKING_CLOSE_MARKER: &str = ""; +static TOOL_PLAN_HANDOFF_TEMP_NONCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeToolPlanHandoffUsage { + prompt_tokens: u64, + completion_tokens: u64, + total_tokens: u64, +} + +impl From<&LlmTokenUsage> for AgentRuntimeToolPlanHandoffUsage { + fn from(value: &LlmTokenUsage) -> Self { + Self { + prompt_tokens: value.prompt_tokens, + completion_tokens: value.completion_tokens, + total_tokens: value.total_tokens, + } + } +} + +impl From<&AgentRuntimeToolPlanHandoffUsage> for LlmTokenUsage { + fn from(value: &AgentRuntimeToolPlanHandoffUsage) -> Self { + Self { + prompt_tokens: value.prompt_tokens, + completion_tokens: value.completion_tokens, + total_tokens: value.total_tokens, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeToolPlanHandoffToolCall { + id: String, + name: String, + arguments: String, +} + +impl From<&LlmToolCall> for AgentRuntimeToolPlanHandoffToolCall { + fn from(value: &LlmToolCall) -> Self { + Self { + id: value.id.clone(), + name: value.name.clone(), + arguments: value.arguments.clone(), + } + } +} + +impl From<&AgentRuntimeToolPlanHandoffToolCall> for LlmToolCall { + fn from(value: &AgentRuntimeToolPlanHandoffToolCall) -> Self { + Self { + id: value.id.clone(), + name: value.name.clone(), + arguments: value.arguments.clone(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeToolPlanHandoffResponse { + provider: LlmProvider, + model: String, + text: String, + thinking_wrapper_valid: bool, + thinking_wrapper_balanced: bool, + thinking_normalization_count: u32, + thinking_source_text_chars: usize, + thinking_source_text_sha256: Option, + finish_reason: Option, + response_id: Option, + usage: Option, + tool_calls: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AgentRuntimeToolPlanHandoffEntry { + pub(crate) identity: AgentRuntimeProviderRetryIdentity, + pub(crate) provider_request_id: String, + pub(crate) request_slot: String, + pub(crate) attempt: u32, + pub(crate) loop_iteration: u64, + pub(crate) repair_attempt: u32, + response: AgentRuntimeToolPlanHandoffResponse, + pub(crate) response_fingerprint: String, + created_at_ms: u64, +} + +impl AgentRuntimeToolPlanHandoffEntry { + pub(crate) fn to_llm_response(&self) -> LlmRunResponse { + let text = if !self.response.thinking_wrapper_valid { + INVALID_THINKING_CLOSE_MARKER.to_string() + } else if !self.response.thinking_wrapper_balanced { + INVALID_THINKING_OPEN_MARKER.to_string() + } else { + self.response.text.clone() + }; + LlmRunResponse { + provider: self.response.provider, + model: self.response.model.clone(), + text, + finish_reason: self.response.finish_reason.clone(), + response_id: self.response.response_id.clone(), + usage: self.response.usage.as_ref().map(LlmTokenUsage::from), + tool_calls: self + .response + .tool_calls + .iter() + .map(LlmToolCall::from) + .collect(), + } + } + + pub(crate) fn thinking_normalization_metadata(&self) -> Option<(usize, usize, &str)> { + (self.response.thinking_wrapper_valid + && self.response.thinking_wrapper_balanced + && self.response.thinking_normalization_count > 0) + .then(|| { + ( + self.response.thinking_normalization_count as usize, + self.response.thinking_source_text_chars, + self.response + .thinking_source_text_sha256 + .as_deref() + .expect("validated thinking normalization fingerprint"), + ) + }) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AgentRuntimeToolPlanHandoffLedger { + schema_version: String, + agent_id: String, + run_id: String, + pub(crate) entries: Vec, +} + +impl AgentRuntimeToolPlanHandoffLedger { + pub(crate) fn agent_id(&self) -> &str { + &self.agent_id + } + + pub(crate) fn run_id(&self) -> &str { + &self.run_id + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AgentRuntimeToolPlanHandoffLookup { + Missing, + Exact(AgentRuntimeToolPlanHandoffEntry), + IdentityConflict(AgentRuntimeToolPlanHandoffEntry), +} + +pub(crate) fn read_for_run_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + validate_path_identity(agent_id, run_id)?; + #[cfg(unix)] + { + return read_for_run_at_unix(root, agent_id, run_id); + } + #[cfg(windows)] + { + return read_for_run_at_windows(root, agent_id, run_id); + } + #[cfg(not(any(unix, windows)))] + { + let _ = root; + Err("当前平台不支持安全 tool-plan 成功响应交接存储".to_string()) + } +} + +fn serialize_ledger_for_storage( + ledger: &AgentRuntimeToolPlanHandoffLedger, +) -> Result, String> { + let mut bytes = serde_json::to_vec_pretty(ledger) + .map_err(|error| format!("序列化 tool-plan 成功响应交接账本失败:{error}"))?; + bytes.push(b'\n'); + if bytes.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + Ok(bytes) +} + +fn write_ledger_at(root: &Path, ledger: &AgentRuntimeToolPlanHandoffLedger) -> Result<(), String> { + let bytes = serialize_ledger_for_storage(ledger)?; + #[cfg(unix)] + { + return write_ledger_at_unix(root, ledger, &bytes); + } + #[cfg(windows)] + { + return write_ledger_at_windows(root, ledger, &bytes); + } + #[cfg(not(any(unix, windows)))] + { + let _ = (root, bytes); + Err("当前平台不支持安全 tool-plan 成功响应交接写入".to_string()) + } +} + +#[cfg(unix)] +pub(crate) fn list_at(root: &Path) -> Result, String> { + list_at_unix_with_agent_open_hook(root, |_| {}) +} + +#[cfg(unix)] +fn list_at_unix_with_agent_open_hook( + root: &Path, + mut after_agent_open: F, +) -> Result, String> +where + F: FnMut(&str), +{ + let project_directory = open_unix_tool_plan_root(root)?; + let Some(agent_runtime_directory) = + open_unix_tool_plan_directory_at(&project_directory, ".agent", "项目 .agent 目录")? + else { + return Ok(Vec::new()); + }; + let Some(runtime_directory) = open_unix_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + )? + else { + return Ok(Vec::new()); + }; + let Some(handoff_directory) = open_unix_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + )? + else { + return Ok(Vec::new()); + }; + lock_unix_tool_plan_directory(&handoff_directory, "tool-plan 成功响应交接根目录")?; + + let agent_names = + read_unix_tool_plan_directory_names(&handoff_directory, "tool-plan 成功响应交接根目录")?; + if agent_names.len() > TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS} 个 Agent 上限" + )); + } + let mut discovered = BTreeMap::<(String, String), DiscoveredToolPlanHandoffLedgers>::new(); + let mut discovered_file_count = 0usize; + for agent_key in agent_names { + if !is_handoff_path_key(&agent_key) { + return Err(format!( + "tool-plan 成功响应交接 Agent 目录名不是规范 hash:{agent_key}" + )); + } + let agent_directory = open_unix_tool_plan_directory_at( + &handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + )? + .ok_or_else(|| "tool-plan 成功响应交接 Agent 目录在扫描期间消失".to_string())?; + lock_unix_tool_plan_directory(&agent_directory, "tool-plan 成功响应交接 Agent 目录")?; + after_agent_open(&agent_key); + let run_names = read_unix_tool_plan_directory_names( + &agent_directory, + "tool-plan 成功响应交接 Agent 目录", + )?; + if run_names.len() > TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES + || discovered_file_count.saturating_add(run_names.len()) + > TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES + { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES} 个文件上限" + )); + } + discovered_file_count = discovered_file_count.saturating_add(run_names.len()); + for file_name in run_names { + match classify_handoff_file_name(&file_name) { + Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)) => { + let ledger = read_unix_discovered_ledger_file( + root, + &agent_directory, + &file_name, + &agent_key, + run_key, + )?; + let files = + discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; + if files.primary.replace(ledger).is_some() { + return Err("tool-plan 成功响应交接 primary 项冲突".to_string()); + } + } + Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)) => { + let ledger = read_unix_discovered_ledger_file( + root, + &agent_directory, + &file_name, + &agent_key, + run_key, + )?; + let files = + discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; + if files.previous.replace(ledger).is_some() { + return Err("tool-plan 成功响应交接 .previous 项冲突".to_string()); + } + } + Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) => { + if !is_handoff_path_key(run_key) { + return Err(format!( + "tool-plan 成功响应交接临时文件 run hash 无效:{file_name}" + )); + } + remove_stale_unix_handoff_temp_file_at(&agent_directory, &file_name)?; + } + None => { + return Err(format!( + "tool-plan 成功响应交接目录包含未知文件:{file_name}" + )); + } + } + } + verify_unix_tool_plan_entry( + &handoff_directory, + &agent_key, + &agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + )?; + } + verify_unix_tool_plan_entry( + &runtime_directory, + "tool-plan-handoffs", + &handoff_directory, + true, + "tool-plan 成功响应交接根目录", + )?; + verify_unix_tool_plan_entry( + &agent_runtime_directory, + "runtime", + &runtime_directory, + true, + "Agent Runtime 目录", + )?; + verify_unix_tool_plan_entry( + &project_directory, + ".agent", + &agent_runtime_directory, + true, + "项目 .agent 目录", + )?; + verify_unix_tool_plan_root(root, &project_directory)?; + + let mut ledgers = Vec::with_capacity(discovered.len()); + for ((agent_key, run_key), files) in discovered { + let selected = match (files.primary, files.previous) { + (Some(primary), Some(previous)) => { + if !ledger_is_prefix(&previous, &primary) { + return Err(format!( + "tool-plan 成功响应交接 primary/.previous 内容冲突:{agent_key}/{run_key}" + )); + } + primary + } + (Some(primary), None) => primary, + (None, Some(previous)) => previous, + (None, None) => continue, + }; + ledgers.push(selected); + } + ledgers.sort_by(|left, right| { + left.agent_id + .cmp(&right.agent_id) + .then_with(|| left.run_id.cmp(&right.run_id)) + }); + Ok(ledgers) +} + +#[cfg(windows)] +pub(crate) fn list_at(root: &Path) -> Result, String> { + list_at_windows(root) +} + +pub(crate) fn lookup_at( + root: &Path, + agent_id: &str, + run_id: &str, + identity: &AgentRuntimeProviderRetryIdentity, +) -> Result { + validate_tool_plan_identity(identity)?; + validate_path_identity(agent_id, run_id)?; + if identity.agent_id != agent_id || identity.run_id != run_id { + return Err("tool-plan 成功响应交接查询身份与路径 Agent/run 冲突".to_string()); + } + let (loop_iteration, repair_attempt) = + parse_tool_plan_base_request_slot(&identity.base_request_slot)?; + let Some(ledger) = read_for_run_at(root, agent_id, run_id)? else { + return Ok(AgentRuntimeToolPlanHandoffLookup::Missing); + }; + let target = (loop_iteration, repair_attempt); + let Some(entry) = ledger.entries.iter().find(|entry| { + entry.loop_iteration == loop_iteration && entry.repair_attempt == repair_attempt + }) else { + if ledger + .entries + .iter() + .any(|entry| (entry.loop_iteration, entry.repair_attempt) > target) + { + return Err("tool-plan 成功响应交接账本包含当前请求之后的未来 entry".to_string()); + } + return Ok(AgentRuntimeToolPlanHandoffLookup::Missing); + }; + if entry.identity == *identity { + Ok(AgentRuntimeToolPlanHandoffLookup::Exact(entry.clone())) + } else { + Ok(AgentRuntimeToolPlanHandoffLookup::IdentityConflict( + entry.clone(), + )) + } +} + +pub(crate) fn ensure_capacity_for_request_at( + root: &Path, + agent_id: &str, + run_id: &str, + identity: &AgentRuntimeProviderRetryIdentity, +) -> Result<(), String> { + validate_tool_plan_identity(identity)?; + validate_path_identity(agent_id, run_id)?; + if identity.agent_id != agent_id || identity.run_id != run_id { + return Err("tool-plan 请求前容量门禁与路径 Agent/run 身份冲突".to_string()); + } + let target = parse_tool_plan_base_request_slot(&identity.base_request_slot)?; + let Some(ledger) = read_for_run_at(root, agent_id, run_id)? else { + return Ok(()); + }; + if ledger.entries.iter().any(|entry| { + (entry.loop_iteration, entry.repair_attempt) == target && entry.identity == *identity + }) { + return Ok(()); + } + if ledger.entries.len() >= TOOL_PLAN_HANDOFF_MAX_ENTRIES { + return Err(format!( + "tool-plan 请求前账本容量已耗尽:同一 run 最多 {TOOL_PLAN_HANDOFF_MAX_ENTRIES} 条成功响应" + )); + } + let current_bytes = serde_json::to_vec_pretty(&ledger) + .map_err(|error| format!("序列化 tool-plan 请求前容量快照失败:{error}"))? + .len() + .saturating_add(1); + if current_bytes.saturating_add(TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES) + > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + { + return Err(format!( + "tool-plan 请求前账本剩余空间不足:至少需要预留 {TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES} 字节" + )); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn write_at( + root: &Path, + identity: &AgentRuntimeProviderRetryIdentity, + request_slot: &str, + attempt: u32, + provider_request_id: &str, + response: &LlmRunResponse, +) -> Result { + validate_tool_plan_identity(identity)?; + validate_provider_request_id(provider_request_id)?; + let (loop_iteration, repair_attempt) = + parse_tool_plan_base_request_slot(&identity.base_request_slot)?; + let response = response_for_persistence(root, response)?; + let response_fingerprint = response_fingerprint(&response)?; + let entry = AgentRuntimeToolPlanHandoffEntry { + identity: identity.clone(), + provider_request_id: provider_request_id.to_string(), + request_slot: request_slot.to_string(), + attempt, + loop_iteration, + repair_attempt, + response, + response_fingerprint, + created_at_ms: provider_retry::now_ms(), + }; + validate_entry(root, &entry)?; + + let mut ledger = match read_for_run_at(root, &identity.agent_id, &identity.run_id)? { + Some(mut ledger) => { + if let Some(existing) = ledger.entries.iter().find(|existing| { + existing.loop_iteration == loop_iteration + && existing.repair_attempt == repair_attempt + }) { + if entry_payload_matches(existing, &entry) { + return Ok(existing.clone()); + } + return Err("tool-plan 成功响应交接同一 slot 内容冲突".to_string()); + } + if ledger.entries.len() >= TOOL_PLAN_HANDOFF_MAX_ENTRIES { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_MAX_ENTRIES} 条上限" + )); + } + let previous = ledger + .entries + .last() + .ok_or_else(|| "tool-plan 成功响应交接账本不能为空".to_string())?; + validate_next_entry(previous, &entry)?; + ledger.entries.push(entry.clone()); + ledger + } + None => { + if repair_attempt != 0 { + return Err("tool-plan 成功响应交接新 loop 首条必须为 repair-0".to_string()); + } + AgentRuntimeToolPlanHandoffLedger { + schema_version: TOOL_PLAN_HANDOFF_SCHEMA_VERSION.to_string(), + agent_id: identity.agent_id.clone(), + run_id: identity.run_id.clone(), + entries: vec![entry.clone()], + } + } + }; + validate_ledger(root, &ledger)?; + + write_ledger_at(root, &ledger)?; + let persisted = read_for_run_at(root, &identity.agent_id, &identity.run_id)? + .ok_or_else(|| "tool-plan 成功响应交接账本写入后不存在".to_string())?; + if persisted != ledger { + return Err("tool-plan 成功响应交接账本写入后内容冲突".to_string()); + } + ledger = persisted; + ledger + .entries + .into_iter() + .find(|persisted_entry| { + persisted_entry.loop_iteration == loop_iteration + && persisted_entry.repair_attempt == repair_attempt + }) + .ok_or_else(|| "tool-plan 成功响应交接 entry 写入后不存在".to_string()) +} + +pub(crate) fn remove_at(root: &Path, agent_id: &str, run_id: &str) -> Result<(), String> { + validate_path_identity(agent_id, run_id)?; + #[cfg(unix)] + { + return remove_at_unix_with_agent_open_hook(root, agent_id, run_id, |_| {}); + } + #[cfg(windows)] + { + return remove_at_windows(root, agent_id, run_id); + } + #[cfg(not(any(unix, windows)))] + { + let _ = root; + Err("当前平台不支持安全 tool-plan 成功响应交接删除".to_string()) + } +} + +pub(crate) fn is_later_repair_identity( + current: &AgentRuntimeProviderRetryIdentity, + candidate: &AgentRuntimeProviderRetryIdentity, +) -> bool { + let Ok((current_loop, current_repair)) = validate_tool_plan_identity(current) else { + return false; + }; + let Ok((candidate_loop, candidate_repair)) = validate_tool_plan_identity(candidate) else { + return false; + }; + current_loop == candidate_loop + && candidate_repair > current_repair + && same_tool_plan_repair_chain(current, candidate) +} + +fn response_for_persistence( + root: &Path, + response: &LlmRunResponse, +) -> Result { + let thinking = normalize_thinking_for_persistence(&response.text); + let persisted = AgentRuntimeToolPlanHandoffResponse { + provider: response.provider, + model: response.model.clone(), + text: thinking.persisted_text, + thinking_wrapper_valid: thinking.wrapper_valid, + thinking_wrapper_balanced: thinking.wrapper_balanced, + thinking_normalization_count: thinking.complete_block_count, + thinking_source_text_chars: thinking.source_text_chars, + thinking_source_text_sha256: thinking.source_text_sha256, + finish_reason: response.finish_reason.clone(), + response_id: response.response_id.clone(), + usage: response + .usage + .as_ref() + .map(AgentRuntimeToolPlanHandoffUsage::from), + tool_calls: response + .tool_calls + .iter() + .map(AgentRuntimeToolPlanHandoffToolCall::from) + .collect(), + }; + validate_response(root, &persisted)?; + Ok(persisted) +} + +fn validate_ledger(root: &Path, ledger: &AgentRuntimeToolPlanHandoffLedger) -> Result<(), String> { + if ledger.schema_version != TOOL_PLAN_HANDOFF_SCHEMA_VERSION { + return Err(format!( + "不支持的 tool-plan 成功响应交接版本:{}", + ledger.schema_version + )); + } + validate_path_identity(&ledger.agent_id, &ledger.run_id)?; + if ledger.entries.is_empty() || ledger.entries.len() > TOOL_PLAN_HANDOFF_MAX_ENTRIES { + return Err(format!( + "tool-plan 成功响应交接账本 entries 必须为 1..={TOOL_PLAN_HANDOFF_MAX_ENTRIES} 条" + )); + } + + let mut provider_request_ids = BTreeSet::new(); + let mut previous = None; + for entry in &ledger.entries { + validate_entry(root, entry)?; + if entry.identity.agent_id != ledger.agent_id || entry.identity.run_id != ledger.run_id { + return Err("tool-plan 成功响应交接 entry 与账本 Agent/run 身份冲突".to_string()); + } + if !provider_request_ids.insert(entry.provider_request_id.as_str()) { + return Err("tool-plan 成功响应交接 providerRequestId 重复".to_string()); + } + match previous { + Some(previous) => validate_next_entry(previous, entry)?, + None if entry.repair_attempt != 0 => { + return Err("tool-plan 成功响应交接新 loop 首条必须为 repair-0".to_string()); + } + None => {} + } + previous = Some(entry); + } + Ok(()) +} + +fn validate_entry(root: &Path, entry: &AgentRuntimeToolPlanHandoffEntry) -> Result<(), String> { + let (loop_iteration, repair_attempt) = validate_tool_plan_identity(&entry.identity)?; + if entry.loop_iteration != loop_iteration || entry.repair_attempt != repair_attempt { + return Err("tool-plan 成功响应交接 loop/repair 与 identity 不匹配".to_string()); + } + validate_provider_request_id(&entry.provider_request_id)?; + if entry.request_slot != request_slot_for_attempt(&entry.identity, entry.attempt) { + return Err("tool-plan 成功响应交接 requestSlot/attempt 无效".to_string()); + } + validate_response(root, &entry.response)?; + if entry.response_fingerprint != response_fingerprint(&entry.response)? { + return Err("tool-plan 成功响应交接 responseFingerprint 不匹配".to_string()); + } + if entry.created_at_ms == 0 { + return Err("tool-plan 成功响应交接 createdAtMs 无效".to_string()); + } + Ok(()) +} + +fn validate_response( + root: &Path, + response: &AgentRuntimeToolPlanHandoffResponse, +) -> Result<(), String> { + validate_short_metadata( + root, + "model", + &response.model, + TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, + false, + )?; + if response.text.len() > TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接 text 超过 {TOOL_PLAN_HANDOFF_TEXT_MAX_BYTES} 字节上限" + )); + } + let persisted_thinking = normalize_thinking_for_persistence(&response.text); + if persisted_thinking.saw_wrapper + || !persisted_thinking.wrapper_valid + || !persisted_thinking.wrapper_balanced + || persisted_thinking.persisted_text != response.text + { + return Err("tool-plan 成功响应交接 text 仍包含 thinking block".to_string()); + } + match ( + response.thinking_wrapper_valid, + response.thinking_wrapper_balanced, + response.thinking_normalization_count, + response.thinking_source_text_chars, + response.thinking_source_text_sha256.as_deref(), + ) { + (true, true, 0, 0, None) => {} + (true, true, count, chars, Some(fingerprint)) + if count > 0 + && chars > 0 + && fingerprint.len() == 64 + && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) => {} + (wrapper_valid, wrapper_balanced, _, chars, Some(fingerprint)) + if (!wrapper_valid || !wrapper_balanced) + && response.text.is_empty() + && chars > 0 + && fingerprint.len() == 64 + && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) => {} + _ => return Err("tool-plan 成功响应交接 thinking normalization 元数据无效".to_string()), + } + validate_private_content(root, "text", &response.text, true)?; + if let Some(finish_reason) = response.finish_reason.as_deref() { + validate_short_metadata( + root, + "finishReason", + finish_reason, + TOOL_PLAN_HANDOFF_FINISH_REASON_MAX_CHARS, + true, + )?; + } + if let Some(response_id) = response.response_id.as_deref() { + validate_short_metadata( + root, + "responseId", + response_id, + TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, + true, + )?; + } + if response.tool_calls.len() > TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS { + return Err(format!( + "tool-plan 成功响应交接 tool calls 超过 {TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS} 条上限" + )); + } + for call in &response.tool_calls { + validate_short_metadata( + root, + "tool call id", + &call.id, + TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, + false, + )?; + validate_short_metadata( + root, + "tool call name", + &call.name, + TOOL_PLAN_HANDOFF_SHORT_TEXT_MAX_CHARS, + false, + )?; + if call.arguments.len() > TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接 arguments 超过 {TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES} 字节上限" + )); + } + validate_private_content(root, "arguments", &call.arguments, true)?; + } + if response.thinking_wrapper_valid + && response.thinking_wrapper_balanced + && response.text.trim().is_empty() + && response.tool_calls.is_empty() + { + return Err("tool-plan 成功响应交接响应不能为空".to_string()); + } + Ok(()) +} + +fn validate_private_content( + root: &Path, + label: &str, + value: &str, + scan_json_escapes: bool, +) -> Result<(), String> { + validate_private_content_view(root, label, value)?; + validate_json_like_sensitive_keys(label, value)?; + let parsed = serde_json::from_str::(value); + if let Ok(json) = &parsed { + validate_json_sensitive_keys(label, json)?; + validate_json_absolute_path_inputs(label, json, None)?; + if let serde_json::Value::String(inner) = json { + validate_private_content_view(root, label, inner)?; + validate_json_like_sensitive_keys(label, inner)?; + if let Ok(inner_json) = serde_json::from_str::(inner) { + validate_json_sensitive_keys(label, &inner_json)?; + validate_json_absolute_path_inputs(label, &inner_json, None)?; + } + } + } else if scan_json_escapes { + let scan_view = decode_json_escaped_scan_view(value); + if scan_view != value { + validate_private_content_view(root, label, &scan_view)?; + validate_json_like_sensitive_keys(label, &scan_view)?; + if let Ok(json) = serde_json::from_str::(&scan_view) { + validate_json_sensitive_keys(label, &json)?; + validate_json_absolute_path_inputs(label, &json, None)?; + } + } + let json_like_payload = label == "arguments" + || value + .trim_start() + .as_bytes() + .first() + .is_some_and(|byte| matches!(byte, b'{' | b'[')); + if json_like_payload { + validate_json_like_absolute_path_inputs(label, value)?; + if scan_view != value { + validate_json_like_absolute_path_inputs(label, &scan_view)?; + } + } + } + Ok(()) +} + +fn validate_private_content_view(_root: &Path, label: &str, value: &str) -> Result<(), String> { + let lower = value.to_ascii_lowercase(); + let sensitive_rule = [ + ".env", + "game-creator.config", + "authorization:", + "cookie:", + "bearer ", + ] + .into_iter() + .position(|marker| lower.contains(marker)) + .or_else(|| (redact_secret_tokens(value) != value).then_some(5)); + if let Some(rule) = sensitive_rule { + return Err(format!( + "tool-plan 成功响应交接 {label} 命中敏感规则 #{rule}" + )); + } + if value + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err(format!("tool-plan 成功响应交接 {label} 包含不安全控制字符")); + } + Ok(()) +} + +fn validate_json_sensitive_keys(label: &str, value: &serde_json::Value) -> Result<(), String> { + match value { + serde_json::Value::Array(values) => { + for value in values { + validate_json_sensitive_keys(label, value)?; + } + } + serde_json::Value::Object(values) => { + for (key, value) in values { + if is_sensitive_json_key(key) { + return Err(format!("tool-plan 成功响应交接 {label} 包含敏感 JSON key")); + } + validate_json_sensitive_keys(label, value)?; + } + } + _ => {} + } + Ok(()) +} + +fn validate_json_like_sensitive_keys(label: &str, value: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'"' | b'\'' => { + let quote = bytes[index]; + let Some((key, next_index)) = decode_json_like_quoted_token(value, index, quote) + else { + index += 1; + continue; + }; + index = next_index; + let separator = skip_json_like_trivia(value, index); + if bytes.get(separator) == Some(&b':') && is_sensitive_json_key(&key) { + return Err(format!("tool-plan 成功响应交接 {label} 包含敏感 JSON key")); + } + } + byte if is_json_like_key_byte(byte) => { + let start = index; + while bytes.get(index).copied().is_some_and(is_json_like_key_byte) { + index += 1; + } + let separator = skip_json_like_trivia(value, index); + if bytes.get(separator) == Some(&b':') + && is_sensitive_json_key(&value[start..index]) + { + return Err(format!("tool-plan 成功响应交接 {label} 包含敏感 JSON key")); + } + } + _ => index += 1, + } + } + Ok(()) +} + +fn decode_json_like_quoted_token(value: &str, start: usize, quote: u8) -> Option<(String, usize)> { + let bytes = value.as_bytes(); + let mut output = String::new(); + let mut index = start.checked_add(1)?; + while index < bytes.len() { + match bytes[index] { + byte if byte == quote => return Some((output, index + 1)), + b'\\' => { + let escaped = *bytes.get(index + 1)?; + if escaped == b'u' { + let decoded = index + .checked_add(6) + .and_then(|end| value.get(index + 2..end)) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .and_then(char::from_u32); + if let Some(character) = decoded { + output.push(character); + index += 6; + } else { + output.push('\\'); + output.push('u'); + index += 2; + } + } else { + if let Some(character) = match escaped { + b'"' => Some('"'), + b'\'' => Some('\''), + b'\\' => Some('\\'), + b'/' => Some('/'), + b'b' => Some('\u{0008}'), + b'f' => Some('\u{000c}'), + b'n' => Some('\n'), + b'r' => Some('\r'), + b't' => Some('\t'), + _ => None, + } { + output.push(character); + } else { + output.push('\\'); + output.push(escaped as char); + } + index += 2; + } + } + _ => { + let character = value[index..].chars().next()?; + output.push(character); + index += character.len_utf8(); + } + } + } + None +} + +fn is_json_like_key_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') +} + +fn skip_json_like_trivia(value: &str, mut index: usize) -> usize { + let bytes = value.as_bytes(); + loop { + while bytes + .get(index) + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + index += 1; + } + if bytes.get(index..index.saturating_add(2)) == Some(b"//") { + index += 2; + while bytes + .get(index) + .is_some_and(|byte| !matches!(byte, b'\n' | b'\r')) + { + index += 1; + } + continue; + } + if bytes.get(index..index.saturating_add(2)) == Some(b"/*") { + index += 2; + while index < bytes.len() && bytes.get(index..index.saturating_add(2)) != Some(b"*/") { + index += 1; + } + if index >= bytes.len() { + return bytes.len(); + } + index += 2; + continue; + } + return index; + } +} + +fn is_sensitive_json_key(key: &str) -> bool { + let normalized = key + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect::(); + [ + "xapikey", + "apikey", + "accesstoken", + "refreshtoken", + "password", + "clientsecret", + "privatekey", + "credential", + "authorization", + "cookie", + "secret", + ] + .into_iter() + .any(|sensitive| normalized.contains(sensitive)) + || ["token", "tokens"] + .into_iter() + .any(|sensitive| normalized == sensitive || normalized.ends_with(sensitive)) +} + +fn validate_json_absolute_path_inputs( + label: &str, + value: &serde_json::Value, + parent_key: Option<&str>, +) -> Result<(), String> { + match value { + serde_json::Value::String(value) => { + if !parent_key.is_some_and(is_tool_plan_content_field) + && redact_absolute_path_tokens(value) != *value + { + return Err(format!( + "tool-plan 成功响应交接 {label} 的结构化输入包含绝对路径" + )); + } + } + serde_json::Value::Array(values) => { + for value in values { + validate_json_absolute_path_inputs(label, value, parent_key)?; + } + } + serde_json::Value::Object(values) => { + for (key, value) in values { + validate_json_absolute_path_inputs(label, value, Some(key))?; + } + } + _ => {} + } + Ok(()) +} + +fn validate_json_like_absolute_path_inputs(label: &str, value: &str) -> Result<(), String> { + let bytes = value.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + let (key, next_index) = match bytes[index] { + b'"' | b'\'' => { + let quote = bytes[index]; + let Some(decoded) = decode_json_like_quoted_token(value, index, quote) else { + index += 1; + continue; + }; + decoded + } + byte if is_json_like_key_byte(byte) => { + let start = index; + while bytes.get(index).copied().is_some_and(is_json_like_key_byte) { + index += 1; + } + (value[start..index].to_string(), index) + } + _ => { + index += 1; + continue; + } + }; + index = next_index; + let separator = skip_json_like_trivia(value, index); + if bytes.get(separator) != Some(&b':') { + continue; + } + let value_start = skip_json_like_trivia(value, separator + 1); + let content_field = is_tool_plan_content_field(&key); + match bytes.get(value_start).copied() { + Some(quote @ (b'"' | b'\'')) => { + if let Some((candidate, next_value)) = + decode_json_like_quoted_token(value, value_start, quote) + { + if !content_field && redact_absolute_path_tokens(&candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的 JSON-like 输入包含绝对路径" + )); + } + index = next_value; + } else if !content_field { + let candidate = decode_json_escaped_scan_view( + value.get(value_start + 1..).unwrap_or_default(), + ); + if redact_absolute_path_tokens(&candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的未闭合 JSON-like 字符串包含绝对路径" + )); + } + } + } + Some(b'[') if !content_field => { + let mut array_index = value_start + 1; + while let Some(byte) = bytes.get(array_index).copied() { + if byte == b']' { + break; + } + if matches!(byte, b'"' | b'\'') { + if let Some((candidate, next_value)) = + decode_json_like_quoted_token(value, array_index, byte) + { + if redact_absolute_path_tokens(&candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的 JSON-like 数组包含绝对路径" + )); + } + array_index = next_value; + continue; + } else { + let candidate = decode_json_escaped_scan_view( + value.get(array_index + 1..).unwrap_or_default(), + ); + if redact_absolute_path_tokens(&candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的未闭合 JSON-like 数组包含绝对路径" + )); + } + break; + } + } + array_index += 1; + } + } + Some(_) if !content_field => { + let end = bytes[value_start..] + .iter() + .position(|byte| { + byte.is_ascii_whitespace() || matches!(byte, b',' | b'}' | b']') + }) + .map(|offset| value_start + offset) + .unwrap_or(bytes.len()); + let candidate = &value[value_start..end]; + if redact_absolute_path_tokens(candidate) != candidate { + return Err(format!( + "tool-plan 成功响应交接 {label} 的 JSON-like 输入包含绝对路径" + )); + } + } + _ => {} + } + } + Ok(()) +} + +fn is_tool_plan_content_field(key: &str) -> bool { + let key = key.to_ascii_lowercase(); + [ + "body", + "code", + "content", + "css", + "detail", + "explanation", + "html", + "instruction", + "message", + "newtext", + "oldtext", + "patch", + "plan", + "prompt", + "query", + "reason", + "response", + "script", + "summary", + "task", + "text", + "thinkingsummary", + "step", + "title", + ] + .contains(&key.as_str()) +} + +struct PersistedThinkingNormalization { + persisted_text: String, + wrapper_valid: bool, + wrapper_balanced: bool, + saw_wrapper: bool, + complete_block_count: u32, + source_text_chars: usize, + source_text_sha256: Option, +} + +fn normalize_thinking_for_persistence(value: &str) -> PersistedThinkingNormalization { + const THINK_START: &str = ""; + const THINK_END: &str = ""; + let lower = value.to_ascii_lowercase(); + let mut output = String::new(); + let mut cursor = 0usize; + let mut scan = 0usize; + let mut depth = 0u32; + let mut count = 0u32; + let mut saw_wrapper = false; + let mut wrapper_valid = true; + loop { + let next_start = lower[scan..].find(THINK_START).map(|index| scan + index); + let next_end = lower[scan..].find(THINK_END).map(|index| scan + index); + match (next_start, next_end) { + (Some(start), Some(end)) if start < end => { + saw_wrapper = true; + if depth == 0 { + output.push_str(&value[cursor..start]); + } + depth = depth.saturating_add(1); + scan = start + THINK_START.len(); + } + (Some(start), None) => { + saw_wrapper = true; + if depth == 0 { + output.push_str(&value[cursor..start]); + } + depth = depth.saturating_add(1); + scan = start + THINK_START.len(); + } + (_, Some(end)) => { + saw_wrapper = true; + scan = end + THINK_END.len(); + if depth == 0 { + wrapper_valid = false; + continue; + } + depth -= 1; + if depth == 0 { + cursor = scan; + count = count.saturating_add(1); + } + } + (None, None) => break, + } + } + let wrapper_balanced = depth == 0; + let persisted_text = if wrapper_valid && wrapper_balanced { + output.push_str(&value[cursor..]); + output.trim().to_string() + } else { + String::new() + }; + let (source_text_chars, source_text_sha256) = if saw_wrapper { + ( + value.chars().count(), + Some(format!("{:x}", Sha256::digest(value.as_bytes()))), + ) + } else { + (0, None) + }; + PersistedThinkingNormalization { + persisted_text, + wrapper_valid, + wrapper_balanced, + saw_wrapper, + complete_block_count: count, + source_text_chars, + source_text_sha256, + } +} + +fn decode_json_escaped_scan_view(value: &str) -> String { + let bytes = value.as_bytes(); + let mut output = String::with_capacity(value.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'\\' { + let character = value[index..].chars().next().unwrap_or_default(); + output.push(character); + index += character.len_utf8(); + continue; + } + let Some(escaped) = bytes.get(index + 1).copied() else { + output.push('\\'); + break; + }; + match escaped { + b'"' => output.push('"'), + b'\\' => output.push('\\'), + b'/' => output.push('/'), + b'b' => output.push('\u{0008}'), + b'f' => output.push('\u{000c}'), + b'n' => output.push('\n'), + b'r' => output.push('\r'), + b't' => output.push('\t'), + b'u' => { + let Some(hex) = value.get(index + 2..index.saturating_add(6)) else { + output.push('\\'); + index += 1; + continue; + }; + let Ok(codepoint) = u32::from_str_radix(hex, 16) else { + output.push('\\'); + index += 1; + continue; + }; + let Some(character) = char::from_u32(codepoint) else { + output.push('\\'); + index += 1; + continue; + }; + output.push(character); + index += 6; + continue; + } + _ => { + output.push('\\'); + index += 1; + continue; + } + } + index += 2; + } + output +} + +fn validate_tool_plan_identity( + identity: &AgentRuntimeProviderRetryIdentity, +) -> Result<(u64, u32), String> { + validate_identity(identity)?; + if identity.request_kind != "tool-plan" || identity.allow_idle_context_compaction { + return Err("tool-plan 成功响应交接 identity requestKind/compaction 无效".to_string()); + } + for (label, value) in [ + ("projectId", identity.project_id.as_str()), + ("agentId", identity.agent_id.as_str()), + ("taskId", identity.task_id.as_str()), + ("sessionId", identity.session_id.as_str()), + ("runId", identity.run_id.as_str()), + ("source", identity.source.as_str()), + ] { + validate_short_text( + label, + value, + TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS, + false, + )?; + } + if let Some(goal_id) = identity.goal_id.as_deref() { + validate_short_text( + "goalId", + goal_id, + TOOL_PLAN_HANDOFF_IDENTITY_TEXT_MAX_CHARS, + false, + )?; + } + parse_tool_plan_base_request_slot(&identity.base_request_slot) +} + +fn parse_tool_plan_base_request_slot(value: &str) -> Result<(u64, u32), String> { + let Some(rest) = value.strip_prefix("loop-") else { + return Err("tool-plan 成功响应交接 baseRequestSlot 无效".to_string()); + }; + let Some((loop_text, repair_text)) = rest.split_once("-repair-") else { + return Err("tool-plan 成功响应交接 baseRequestSlot 无效".to_string()); + }; + if loop_text.is_empty() + || repair_text.is_empty() + || !loop_text.bytes().all(|byte| byte.is_ascii_digit()) + || !repair_text.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err("tool-plan 成功响应交接 baseRequestSlot 无效".to_string()); + } + let loop_iteration = loop_text + .parse::() + .map_err(|_| "tool-plan 成功响应交接 loopIteration 溢出".to_string())?; + let repair_attempt = repair_text + .parse::() + .map_err(|_| "tool-plan 成功响应交接 repairAttempt 溢出".to_string())?; + if value != format!("loop-{loop_iteration}-repair-{repair_attempt}") { + return Err("tool-plan 成功响应交接 baseRequestSlot 非规范格式".to_string()); + } + Ok((loop_iteration, repair_attempt)) +} + +fn validate_next_entry( + previous: &AgentRuntimeToolPlanHandoffEntry, + candidate: &AgentRuntimeToolPlanHandoffEntry, +) -> Result<(), String> { + if !same_durable_tool_plan_run(&previous.identity, &candidate.identity) { + return Err("tool-plan 成功响应交接 entry 的 durable run 身份冲突".to_string()); + } + if candidate.loop_iteration == previous.loop_iteration { + if !same_tool_plan_repair_chain(&previous.identity, &candidate.identity) { + return Err("tool-plan 成功响应交接同 loop repair 链身份冲突".to_string()); + } + let expected_repair = previous + .repair_attempt + .checked_add(1) + .ok_or_else(|| "tool-plan 成功响应交接 repairAttempt 溢出".to_string())?; + if candidate.repair_attempt != expected_repair { + return Err("tool-plan 成功响应交接同 loop 的 repair 必须连续追加".to_string()); + } + return Ok(()); + } + if candidate.loop_iteration > previous.loop_iteration && candidate.repair_attempt == 0 { + return Ok(()); + } + Err("tool-plan 成功响应交接 entry 顺序无效,新 loop 必须从 repair-0 开始".to_string()) +} + +fn same_durable_tool_plan_run( + current: &AgentRuntimeProviderRetryIdentity, + candidate: &AgentRuntimeProviderRetryIdentity, +) -> bool { + current.project_id == candidate.project_id + && current.agent_id == candidate.agent_id + && current.task_id == candidate.task_id + && current.session_id == candidate.session_id + && current.run_id == candidate.run_id + && current.source == candidate.source + && current.request_kind == candidate.request_kind +} + +fn entry_payload_matches( + left: &AgentRuntimeToolPlanHandoffEntry, + right: &AgentRuntimeToolPlanHandoffEntry, +) -> bool { + left.identity == right.identity + && left.provider_request_id == right.provider_request_id + && left.request_slot == right.request_slot + && left.attempt == right.attempt + && left.loop_iteration == right.loop_iteration + && left.repair_attempt == right.repair_attempt + && left.response == right.response + && left.response_fingerprint == right.response_fingerprint +} + +fn same_tool_plan_repair_chain( + current: &AgentRuntimeProviderRetryIdentity, + candidate: &AgentRuntimeProviderRetryIdentity, +) -> bool { + current.project_id == candidate.project_id + && current.agent_id == candidate.agent_id + && current.task_id == candidate.task_id + && current.session_id == candidate.session_id + && current.run_id == candidate.run_id + && current.source == candidate.source + && current.goal_id == candidate.goal_id + && current.goal_revision == candidate.goal_revision + && current.goal_snapshot_fingerprint == candidate.goal_snapshot_fingerprint + && current.applied_steer_cursor == candidate.applied_steer_cursor + && current.request_kind == candidate.request_kind + && current.provider_config_fingerprint == candidate.provider_config_fingerprint + && current.allow_idle_context_compaction == candidate.allow_idle_context_compaction +} + +fn request_slot_for_attempt(identity: &AgentRuntimeProviderRetryIdentity, attempt: u32) -> String { + if attempt == 0 { + identity.base_request_slot.clone() + } else { + format!("{}-transient-{attempt}", identity.base_request_slot) + } +} + +fn validate_provider_request_id(value: &str) -> Result<(), String> { + validate_short_text( + "providerRequestId", + value, + TOOL_PLAN_HANDOFF_REQUEST_ID_MAX_CHARS, + false, + )?; + let fingerprint = value + .strip_prefix("provider-request-") + .ok_or_else(|| "tool-plan 成功响应交接 providerRequestId 无效".to_string())?; + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("tool-plan 成功响应交接 providerRequestId 无效".to_string()); + } + Ok(()) +} + +fn validate_short_text( + label: &str, + value: &str, + max_chars: usize, + allow_empty: bool, +) -> Result<(), String> { + if (!allow_empty && value.trim().is_empty()) + || value.chars().count() > max_chars + || value.chars().any(char::is_control) + { + return Err(format!("tool-plan 成功响应交接 {label} 无效")); + } + Ok(()) +} + +fn validate_short_metadata( + root: &Path, + label: &str, + value: &str, + max_chars: usize, + allow_empty: bool, +) -> Result<(), String> { + validate_short_text(label, value, max_chars, allow_empty)?; + validate_private_content(root, label, value, true)?; + if is_sensitive_json_key(value) { + return Err(format!("tool-plan 成功响应交接 {label} 包含敏感元数据")); + } + if redact_absolute_path_tokens(value) != value { + return Err(format!("tool-plan 成功响应交接 {label} 包含绝对路径")); + } + Ok(()) +} + +#[derive(Default)] +struct DiscoveredToolPlanHandoffLedgers { + primary: Option, + previous: Option, +} + +fn discovered_handoff_ledgers_mut<'a>( + discovered: &'a mut BTreeMap<(String, String), DiscoveredToolPlanHandoffLedgers>, + agent_key: &str, + run_key: &str, +) -> Result<&'a mut DiscoveredToolPlanHandoffLedgers, String> { + if !is_handoff_path_key(run_key) { + return Err(format!( + "tool-plan 成功响应交接 run 文件名不是规范 hash:{run_key}" + )); + } + let key = (agent_key.to_string(), run_key.to_string()); + if !discovered.contains_key(&key) + && discovered.len() >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS + { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_LEDGERS} 条账本上限" + )); + } + Ok(discovered.entry(key).or_default()) +} + +#[cfg(unix)] +fn unix_tool_plan_component(value: &str, label: &str) -> Result { + if value.is_empty() || value == "." || value == ".." || value.contains('/') { + return Err(format!("{label} 名称无效")); + } + CString::new(value.as_bytes()).map_err(|_| format!("{label} 名称包含 NUL")) +} + +#[cfg(unix)] +fn validate_unix_tool_plan_directory_handle(file: &File, label: &str) -> Result<(), String> { + let metadata = file + .metadata() + .map_err(|error| format!("复核 {label} 句柄失败:{error}"))?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_dir() || metadata.uid() != effective_user_id { + return Err(format!("{label} 必须是当前用户持有的普通目录")); + } + Ok(()) +} + +#[cfg(unix)] +fn validate_unix_tool_plan_file_handle(file: &File, label: &str) -> Result<(), String> { + let metadata = file + .metadata() + .map_err(|error| format!("复核 {label} 句柄失败:{error}"))?; + // SAFETY: geteuid takes no arguments and has no memory safety preconditions. + let effective_user_id = unsafe { libc::geteuid() }; + if !metadata.is_file() + || metadata.uid() != effective_user_id + || metadata.nlink() != 1 + || metadata.permissions().mode() & 0o777 != 0o600 + { + return Err(format!("{label} 必须是当前用户持有的 0600 单链接普通文件")); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_unix_tool_plan_root(root: &Path, opened: &File) -> Result<(), String> { + let path_metadata = fs::symlink_metadata(root) + .map_err(|error| format!("复核 tool-plan 项目根目录失败:{error}"))?; + let opened_metadata = opened + .metadata() + .map_err(|error| format!("复核 tool-plan 项目根目录句柄失败:{error}"))?; + if path_metadata.file_type().is_symlink() + || !path_metadata.is_dir() + || path_metadata.dev() != opened_metadata.dev() + || path_metadata.ino() != opened_metadata.ino() + { + return Err("tool-plan 项目根目录在安全扫描期间发生替换".to_string()); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_unix_tool_plan_entry( + parent: &File, + name: &str, + opened: &File, + directory: bool, + label: &str, +) -> Result<(), String> { + let name = unix_tool_plan_component(name, label)?; + // SAFETY: stat is plain data and fstatat initializes it on success. + let mut stat = unsafe { std::mem::zeroed::() }; + // SAFETY: parent and name remain valid for the duration of fstatat. + if unsafe { + libc::fstatat( + parent.as_raw_fd(), + name.as_ptr(), + &mut stat, + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + return Err(format!( + "复核 {label} 目录项失败:{}", + std::io::Error::last_os_error() + )); + } + let opened_metadata = opened + .metadata() + .map_err(|error| format!("复核 {label} 句柄失败:{error}"))?; + let expected_type = if directory { + libc::S_IFDIR + } else { + libc::S_IFREG + }; + if stat.st_dev != opened_metadata.dev() + || stat.st_ino != opened_metadata.ino() + || stat.st_mode & libc::S_IFMT != expected_type + { + return Err(format!("{label} 在安全扫描期间发生替换")); + } + Ok(()) +} + +#[cfg(unix)] +fn lock_unix_tool_plan_directory(directory: &File, label: &str) -> Result<(), String> { + // SAFETY: flock operates on the live directory fd and is released when File is dropped. + if unsafe { libc::flock(directory.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(format!( + "锁定 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(unix)] +fn open_unix_tool_plan_root(root: &Path) -> Result { + let root_name = CString::new(root.as_os_str().as_bytes()) + .map_err(|_| "tool-plan 项目根目录包含 NUL".to_string())?; + // SAFETY: root_name is NUL terminated and a successful fd is transferred to File once. + let fd = unsafe { + libc::open( + root_name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err(format!( + "安全打开 tool-plan 项目根目录失败:{}", + std::io::Error::last_os_error() + )); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_directory_handle(&file, "tool-plan 项目根目录")?; + verify_unix_tool_plan_root(root, &file)?; + Ok(file) +} + +#[cfg(unix)] +fn open_unix_tool_plan_directory_at( + parent: &File, + name: &str, + label: &str, +) -> Result, String> { + let name_c = unix_tool_plan_component(name, label)?; + let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC; + // SAFETY: parent fd and component remain valid during openat. + let fd = unsafe { libc::openat(parent.as_raw_fd(), name_c.as_ptr(), flags) }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOENT) { + return Ok(None); + } + return Err(format!("安全打开 {label} 失败:{error}")); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_directory_handle(&file, label)?; + verify_unix_tool_plan_entry(parent, name, &file, true, label)?; + Ok(Some(file)) +} + +#[cfg(unix)] +fn open_or_create_unix_tool_plan_directory_at( + parent: &File, + name: &str, + label: &str, +) -> Result { + if let Some(directory) = open_unix_tool_plan_directory_at(parent, name, label)? { + return Ok(directory); + } + let name_c = unix_tool_plan_component(name, label)?; + // SAFETY: parent is a stable directory fd and name is a validated relative component. + if unsafe { libc::mkdirat(parent.as_raw_fd(), name_c.as_ptr(), 0o700) } != 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::EEXIST) { + return Err(format!("创建 {label} 失败:{error}")); + } + } else { + parent + .sync_all() + .map_err(|error| format!("同步 {label} 父目录失败:{error}"))?; + } + open_unix_tool_plan_directory_at(parent, name, label)? + .ok_or_else(|| format!("创建后重新打开 {label} 失败")) +} + +#[cfg(unix)] +fn try_open_unix_tool_plan_file_at( + parent: &File, + name: &str, + label: &str, +) -> Result, String> { + let name_c = unix_tool_plan_component(name, label)?; + // SAFETY: parent fd and component remain valid during openat. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name_c.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOENT) { + return Ok(None); + } + return Err(format!("安全打开 {label} 失败:{error}")); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_file_handle(&file, label)?; + verify_unix_tool_plan_entry(parent, name, &file, false, label)?; + Ok(Some(file)) +} + +#[cfg(unix)] +fn open_unix_tool_plan_file_for_removal_at( + parent: &File, + name: &str, + label: &str, +) -> Result, String> { + let name_c = unix_tool_plan_component(name, label)?; + // SAFETY: parent fd and component remain valid during openat. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name_c.as_ptr(), + libc::O_RDWR | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENOENT) { + return Ok(None); + } + return Err(format!("安全打开待隔离 {label} 失败:{error}")); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_file_handle(&file, label)?; + verify_unix_tool_plan_entry(parent, name, &file, false, label)?; + Ok(Some(file)) +} + +#[cfg(unix)] +struct UnixToolPlanDirectoryStream(*mut libc::DIR); + +#[cfg(unix)] +impl Drop for UnixToolPlanDirectoryStream { + fn drop(&mut self) { + // SAFETY: this guard owns the DIR pointer returned by fdopendir. + unsafe { + libc::closedir(self.0); + } + } +} + +#[cfg(unix)] +fn read_unix_tool_plan_directory_names( + directory: &File, + label: &str, +) -> Result, String> { + // SAFETY: fcntl duplicates the live directory fd and returns independent ownership. + let duplicated = unsafe { libc::fcntl(directory.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + if duplicated < 0 { + return Err(format!( + "复制 {label} 目录句柄失败:{}", + std::io::Error::last_os_error() + )); + } + // SAFETY: duplicated is an owned directory fd; fdopendir takes ownership on success. + let stream = unsafe { libc::fdopendir(duplicated) }; + if stream.is_null() { + let error = std::io::Error::last_os_error(); + // SAFETY: fdopendir failed, so duplicated remains owned here. + unsafe { + libc::close(duplicated); + } + return Err(format!("读取 {label} 目录失败:{error}")); + } + let stream = UnixToolPlanDirectoryStream(stream); + let mut names = Vec::new(); + loop { + // SAFETY: stream owns a valid DIR pointer for the duration of this loop. + let entry = unsafe { libc::readdir(stream.0) }; + if entry.is_null() { + break; + } + // SAFETY: d_name is NUL terminated for a successful readdir entry. + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + let name = std::str::from_utf8(name.to_bytes()) + .map_err(|_| format!("{label} 目录项名称必须是 UTF-8"))?; + if matches!(name, "." | "..") { + continue; + } + names.push(name.to_string()); + } + Ok(names) +} + +#[cfg(unix)] +fn read_unix_discovered_ledger_file( + root: &Path, + parent: &File, + file_name: &str, + agent_key: &str, + run_key: &str, +) -> Result { + try_read_unix_discovered_ledger_file(root, parent, file_name, agent_key, run_key)? + .ok_or_else(|| "tool-plan 成功响应交接账本文件在安全扫描期间消失".to_string()) +} + +#[cfg(unix)] +fn try_read_unix_discovered_ledger_file( + root: &Path, + parent: &File, + file_name: &str, + agent_key: &str, + run_key: &str, +) -> Result, String> { + let Some(mut file) = + try_open_unix_tool_plan_file_at(parent, file_name, "tool-plan 成功响应交接账本文件")? + else { + return Ok(None); + }; + let metadata = file + .metadata() + .map_err(|error| format!("读取 tool-plan 成功响应交接账本元数据失败:{error}"))?; + if metadata.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES as u64 { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::io::Read::by_ref(&mut file) + .take((TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取 tool-plan 成功响应交接账本失败:{error}"))?; + if bytes.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + verify_unix_tool_plan_entry( + parent, + file_name, + &file, + false, + "tool-plan 成功响应交接账本文件", + )?; + let ledger = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 tool-plan 成功响应交接账本失败:{error}"))?; + validate_ledger(root, &ledger)?; + if path_key(&ledger.agent_id) != agent_key || path_key(&ledger.run_id) != run_key { + return Err("tool-plan 成功响应交接 hash 路径与 Agent/run 身份冲突".to_string()); + } + Ok(Some(ledger)) +} + +#[cfg(unix)] +struct UnixToolPlanAgentStorage { + project_directory: File, + agent_runtime_directory: File, + runtime_directory: File, + handoff_directory: File, + agent_directory: File, + agent_key: String, +} + +#[cfg(unix)] +impl UnixToolPlanAgentStorage { + fn verify(&self, root: &Path) -> Result<(), String> { + verify_unix_tool_plan_entry( + &self.handoff_directory, + &self.agent_key, + &self.agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + )?; + verify_unix_tool_plan_entry( + &self.runtime_directory, + "tool-plan-handoffs", + &self.handoff_directory, + true, + "tool-plan 成功响应交接根目录", + )?; + verify_unix_tool_plan_entry( + &self.agent_runtime_directory, + "runtime", + &self.runtime_directory, + true, + "Agent Runtime 目录", + )?; + verify_unix_tool_plan_entry( + &self.project_directory, + ".agent", + &self.agent_runtime_directory, + true, + "项目 .agent 目录", + )?; + verify_unix_tool_plan_root(root, &self.project_directory) + } +} + +#[cfg(unix)] +fn open_unix_tool_plan_agent_storage( + root: &Path, + agent_id: &str, + create: bool, +) -> Result, String> { + let project_directory = open_unix_tool_plan_root(root)?; + let agent_runtime_directory = if create { + open_or_create_unix_tool_plan_directory_at( + &project_directory, + ".agent", + "项目 .agent 目录", + )? + } else { + let Some(directory) = + open_unix_tool_plan_directory_at(&project_directory, ".agent", "项目 .agent 目录")? + else { + return Ok(None); + }; + directory + }; + let runtime_directory = if create { + open_or_create_unix_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + )? + } else { + let Some(directory) = open_unix_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + )? + else { + return Ok(None); + }; + directory + }; + let handoff_directory = if create { + open_or_create_unix_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + )? + } else { + let Some(directory) = open_unix_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + )? + else { + return Ok(None); + }; + directory + }; + lock_unix_tool_plan_directory(&handoff_directory, "tool-plan 成功响应交接根目录")?; + let agent_key = path_key(agent_id); + let agent_directory = if create { + open_or_create_unix_tool_plan_directory_at( + &handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + )? + } else { + let Some(directory) = open_unix_tool_plan_directory_at( + &handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + )? + else { + return Ok(None); + }; + directory + }; + let storage = UnixToolPlanAgentStorage { + project_directory, + agent_runtime_directory, + runtime_directory, + handoff_directory, + agent_directory, + agent_key, + }; + storage.verify(root)?; + Ok(Some(storage)) +} + +#[cfg(unix)] +fn read_for_run_at_unix( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + read_for_run_at_unix_with_agent_open_hook(root, agent_id, run_id, |_| {}) +} + +#[cfg(unix)] +fn read_for_run_at_unix_with_agent_open_hook( + root: &Path, + agent_id: &str, + run_id: &str, + after_agent_open: F, +) -> Result, String> +where + F: FnOnce(&str), +{ + let Some(storage) = open_unix_tool_plan_agent_storage(root, agent_id, false)? else { + return Ok(None); + }; + lock_unix_tool_plan_directory( + &storage.agent_directory, + "tool-plan 成功响应交接 Agent 目录", + )?; + after_agent_open(&storage.agent_key); + let run_key = path_key(run_id); + let primary_name = format!("{run_key}.json"); + let previous_name = format!(".{run_key}.json.previous"); + let primary = try_read_unix_discovered_ledger_file( + root, + &storage.agent_directory, + &primary_name, + &storage.agent_key, + &run_key, + )?; + let previous = try_read_unix_discovered_ledger_file( + root, + &storage.agent_directory, + &previous_name, + &storage.agent_key, + &run_key, + )?; + storage.verify(root)?; + let selected = select_primary_and_previous(&storage.agent_key, &run_key, primary, previous)?; + if selected + .as_ref() + .is_some_and(|ledger| ledger.agent_id != agent_id || ledger.run_id != run_id) + { + return Err("tool-plan 成功响应交接账本与路径 Agent/run 身份冲突".to_string()); + } + Ok(selected) +} + +#[cfg(unix)] +fn create_unix_tool_plan_temp_file_at( + parent: &File, + run_key: &str, +) -> Result<(String, File), String> { + for _ in 0..32 { + let file_name = format!( + ".{run_key}.json.tmp.{}.{}", + std::process::id(), + next_tool_plan_temp_nonce() + ); + let name = unix_tool_plan_component(&file_name, "tool-plan 成功响应交接临时文件")?; + // SAFETY: parent is stable, name is relative, and a successful fd is transferred once. + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + name.as_ptr(), + libc::O_CREAT | libc::O_EXCL | libc::O_WRONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + 0o600, + ) + }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EEXIST) { + continue; + } + return Err(format!("创建 tool-plan 成功响应交接临时文件失败:{error}")); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + validate_unix_tool_plan_file_handle(&file, "tool-plan 成功响应交接临时文件")?; + // SAFETY: flock operates on the live temp fd and the lock follows the open file. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + return Err(format!( + "锁定 tool-plan 成功响应交接临时文件失败:{}", + std::io::Error::last_os_error() + )); + } + verify_unix_tool_plan_entry( + parent, + &file_name, + &file, + false, + "tool-plan 成功响应交接临时文件", + )?; + return Ok((file_name, file)); + } + Err("创建 tool-plan 成功响应交接临时文件失败:名称冲突".to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn exchange_unix_tool_plan_entries_at( + parent: &File, + left_name: &str, + right_name: &str, + label: &str, +) -> Result<(), String> { + let left = unix_tool_plan_component(left_name, label)?; + let right = unix_tool_plan_component(right_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameat2( + parent.as_raw_fd(), + left.as_ptr(), + parent.as_raw_fd(), + right.as_ptr(), + libc::RENAME_EXCHANGE, + ) + } != 0 + { + return Err(format!( + "原子交换 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(target_vendor = "apple")] +fn exchange_unix_tool_plan_entries_at( + parent: &File, + left_name: &str, + right_name: &str, + label: &str, +) -> Result<(), String> { + let left = unix_tool_plan_component(left_name, label)?; + let right = unix_tool_plan_component(right_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameatx_np( + parent.as_raw_fd(), + left.as_ptr(), + parent.as_raw_fd(), + right.as_ptr(), + libc::RENAME_SWAP, + ) + } != 0 + { + return Err(format!( + "原子交换 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(all( + unix, + not(any(target_os = "linux", target_os = "android")), + not(target_vendor = "apple") +))] +fn exchange_unix_tool_plan_entries_at( + _parent: &File, + _left_name: &str, + _right_name: &str, + _label: &str, +) -> Result<(), String> { + Err("当前 Unix 平台不支持安全原子交换 tool-plan 账本".to_string()) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn rename_unix_tool_plan_entry_noreplace_at( + parent: &File, + source_name: &str, + target_name: &str, + label: &str, +) -> Result<(), String> { + let source = unix_tool_plan_component(source_name, label)?; + let target = unix_tool_plan_component(target_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameat2( + parent.as_raw_fd(), + source.as_ptr(), + parent.as_raw_fd(), + target.as_ptr(), + libc::RENAME_NOREPLACE, + ) + } != 0 + { + return Err(format!( + "隔离 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(target_vendor = "apple")] +fn rename_unix_tool_plan_entry_noreplace_at( + parent: &File, + source_name: &str, + target_name: &str, + label: &str, +) -> Result<(), String> { + let source = unix_tool_plan_component(source_name, label)?; + let target = unix_tool_plan_component(target_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameatx_np( + parent.as_raw_fd(), + source.as_ptr(), + parent.as_raw_fd(), + target.as_ptr(), + libc::RENAME_EXCL, + ) + } != 0 + { + return Err(format!( + "隔离 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(all( + unix, + not(any(target_os = "linux", target_os = "android")), + not(target_vendor = "apple") +))] +fn rename_unix_tool_plan_entry_noreplace_at( + _parent: &File, + _source_name: &str, + _target_name: &str, + _label: &str, +) -> Result<(), String> { + Err("当前 Unix 平台不支持安全隔离 tool-plan 文件".to_string()) +} + +#[cfg(unix)] +fn rename_unix_tool_plan_entry_at( + parent: &File, + source_name: &str, + target_name: &str, + label: &str, +) -> Result<(), String> { + let source = unix_tool_plan_component(source_name, label)?; + let target = unix_tool_plan_component(target_name, label)?; + // SAFETY: both names are fixed relative components under the same held directory. + if unsafe { + libc::renameat( + parent.as_raw_fd(), + source.as_ptr(), + parent.as_raw_fd(), + target.as_ptr(), + ) + } != 0 + { + return Err(format!( + "重命名 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(unix)] +fn write_ledger_at_unix( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, + bytes: &[u8], +) -> Result<(), String> { + write_ledger_at_unix_with_hooks(root, ledger, bytes, |_| {}, |_| {}) +} + +#[cfg(all(unix, test))] +fn write_ledger_at_unix_with_agent_open_hook( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, + bytes: &[u8], + after_agent_open: F, +) -> Result<(), String> +where + F: FnOnce(&str), +{ + write_ledger_at_unix_with_hooks(root, ledger, bytes, after_agent_open, |_| {}) +} + +#[cfg(unix)] +fn write_ledger_at_unix_with_hooks( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, + bytes: &[u8], + after_agent_open: F, + before_install: G, +) -> Result<(), String> +where + F: FnOnce(&str), + G: FnOnce(&str), +{ + let storage = open_unix_tool_plan_agent_storage(root, &ledger.agent_id, true)? + .ok_or_else(|| "创建 tool-plan 成功响应交接存储目录失败".to_string())?; + lock_unix_tool_plan_directory( + &storage.agent_directory, + "tool-plan 成功响应交接 Agent 目录", + )?; + after_agent_open(&storage.agent_key); + let run_key = path_key(&ledger.run_id); + let primary_name = format!("{run_key}.json"); + let previous_name = format!(".{run_key}.json.previous"); + let (temporary_name, mut temporary_file) = + create_unix_tool_plan_temp_file_at(&storage.agent_directory, &run_key)?; + if let Err(error) = temporary_file + .write_all(bytes) + .and_then(|_| temporary_file.sync_data()) + { + let _ = remove_unix_tool_plan_file_at( + &storage.agent_directory, + &temporary_name, + "tool-plan 成功响应交接临时文件", + ); + return Err(format!("写入 tool-plan 成功响应交接临时文件失败:{error}")); + } + verify_unix_tool_plan_entry( + &storage.agent_directory, + &temporary_name, + &temporary_file, + false, + "tool-plan 成功响应交接临时文件", + )?; + let previous_primary = try_open_unix_tool_plan_file_at( + &storage.agent_directory, + &primary_name, + "tool-plan 成功响应交接原账本", + )?; + remove_unix_tool_plan_file_at( + &storage.agent_directory, + &previous_name, + "tool-plan 成功响应交接恢复副本", + )?; + before_install(&temporary_name); + if let Some(previous_primary) = previous_primary { + exchange_unix_tool_plan_entries_at( + &storage.agent_directory, + &temporary_name, + &primary_name, + "tool-plan 成功响应交接账本", + )?; + let installed = verify_unix_tool_plan_entry( + &storage.agent_directory, + &primary_name, + &temporary_file, + false, + "tool-plan 成功响应交接新账本", + ) + .and_then(|_| { + verify_unix_tool_plan_entry( + &storage.agent_directory, + &temporary_name, + &previous_primary, + false, + "tool-plan 成功响应交接原账本", + ) + }); + if let Err(install_error) = installed { + let rollback = exchange_unix_tool_plan_entries_at( + &storage.agent_directory, + &temporary_name, + &primary_name, + "tool-plan 成功响应交接账本回滚", + ) + .and_then(|_| { + verify_unix_tool_plan_entry( + &storage.agent_directory, + &primary_name, + &previous_primary, + false, + "tool-plan 成功响应交接原账本回滚", + ) + }); + return Err(match rollback { + Ok(()) => format!("安装 tool-plan 成功响应交接账本身份冲突:{install_error}"), + Err(rollback_error) => format!( + "安装 tool-plan 成功响应交接账本身份冲突且回滚失败:{install_error}; {rollback_error}" + ), + }); + } + rename_unix_tool_plan_entry_at( + &storage.agent_directory, + &temporary_name, + &previous_name, + "tool-plan 成功响应交接恢复副本", + )?; + verify_unix_tool_plan_entry( + &storage.agent_directory, + &previous_name, + &previous_primary, + false, + "tool-plan 成功响应交接恢复副本", + )?; + } else { + verify_unix_tool_plan_entry( + &storage.agent_directory, + &temporary_name, + &temporary_file, + false, + "tool-plan 成功响应交接临时文件", + )?; + rename_unix_tool_plan_entry_at( + &storage.agent_directory, + &temporary_name, + &primary_name, + "tool-plan 成功响应交接账本", + )?; + verify_unix_tool_plan_entry( + &storage.agent_directory, + &primary_name, + &temporary_file, + false, + "tool-plan 成功响应交接新账本", + )?; + } + storage + .agent_directory + .sync_all() + .map_err(|error| format!("同步 tool-plan 成功响应交接目录失败:{error}"))?; + storage.verify(root)?; + drop(temporary_file); + Ok(()) +} + +#[cfg(unix)] +fn try_lock_unix_tool_plan_temp(file: &File) -> Result { + // SAFETY: flock observes only the live temp file descriptor. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::WouldBlock { + return Ok(false); + } + Err(format!( + "确认 tool-plan 成功响应交接临时文件锁失败:{error}" + )) +} + +#[cfg(unix)] +fn remove_stale_unix_handoff_temp_file_at(parent: &File, file_name: &str) -> Result<(), String> { + remove_stale_unix_handoff_temp_file_at_with_hook(parent, file_name, |_| {}) +} + +#[cfg(unix)] +fn remove_stale_unix_handoff_temp_file_at_with_hook( + parent: &File, + file_name: &str, + after_lock: F, +) -> Result<(), String> +where + F: FnOnce(&str), +{ + let file = open_unix_tool_plan_file_for_removal_at( + parent, + file_name, + "tool-plan 成功响应交接原子临时文件", + )? + .ok_or_else(|| "tool-plan 成功响应交接原子临时文件在清理前消失".to_string())?; + if !try_lock_unix_tool_plan_temp(&file)? { + return Err("tool-plan 成功响应交接原子临时文件仍由活跃写入句柄持有".to_string()); + } + verify_unix_tool_plan_entry( + parent, + file_name, + &file, + false, + "tool-plan 成功响应交接原子临时文件", + )?; + after_lock(file_name); + quarantine_and_remove_unix_tool_plan_file_at( + parent, + file_name, + &file, + "tool-plan 成功响应交接原子临时文件", + )?; + parent + .sync_all() + .map_err(|error| format!("同步 tool-plan 成功响应交接临时文件目录失败:{error}"))?; + Ok(()) +} + +#[cfg(unix)] +fn remove_at_unix_with_agent_open_hook( + root: &Path, + agent_id: &str, + run_id: &str, + mut after_agent_open: F, +) -> Result<(), String> +where + F: FnMut(&str), +{ + let project_directory = open_unix_tool_plan_root(root)?; + let Some(agent_runtime_directory) = + open_unix_tool_plan_directory_at(&project_directory, ".agent", "项目 .agent 目录")? + else { + return Ok(()); + }; + let Some(runtime_directory) = open_unix_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + )? + else { + return Ok(()); + }; + let Some(handoff_directory) = open_unix_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + )? + else { + return Ok(()); + }; + lock_unix_tool_plan_directory(&handoff_directory, "tool-plan 成功响应交接根目录")?; + let agent_key = path_key(agent_id); + let Some(agent_directory) = open_unix_tool_plan_directory_at( + &handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + )? + else { + return Ok(()); + }; + lock_unix_tool_plan_directory(&agent_directory, "tool-plan 成功响应交接 Agent 目录")?; + after_agent_open(&agent_key); + let run_key = path_key(run_id); + let primary_name = format!("{run_key}.json"); + let previous_name = format!(".{run_key}.json.previous"); + let removed_previous = remove_unix_tool_plan_file_at( + &agent_directory, + &previous_name, + "tool-plan 成功响应交接恢复副本", + )?; + let removed_primary = remove_unix_tool_plan_file_at( + &agent_directory, + &primary_name, + "tool-plan 成功响应交接账本", + )?; + if removed_previous || removed_primary { + agent_directory + .sync_all() + .map_err(|error| format!("同步 tool-plan 成功响应交接删除目录失败:{error}"))?; + } + verify_unix_tool_plan_entry( + &handoff_directory, + &agent_key, + &agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + )?; + verify_unix_tool_plan_entry( + &runtime_directory, + "tool-plan-handoffs", + &handoff_directory, + true, + "tool-plan 成功响应交接根目录", + )?; + verify_unix_tool_plan_entry( + &agent_runtime_directory, + "runtime", + &runtime_directory, + true, + "Agent Runtime 目录", + )?; + verify_unix_tool_plan_entry( + &project_directory, + ".agent", + &agent_runtime_directory, + true, + "项目 .agent 目录", + )?; + verify_unix_tool_plan_root(root, &project_directory) +} + +#[cfg(unix)] +fn remove_unix_tool_plan_file_at( + parent: &File, + file_name: &str, + label: &str, +) -> Result { + remove_unix_tool_plan_file_at_with_hook(parent, file_name, label, |_| {}) +} + +#[cfg(unix)] +fn remove_unix_tool_plan_file_at_with_hook( + parent: &File, + file_name: &str, + label: &str, + after_open: F, +) -> Result +where + F: FnOnce(&str), +{ + let Some(file) = open_unix_tool_plan_file_for_removal_at(parent, file_name, label)? else { + return Ok(false); + }; + verify_unix_tool_plan_entry(parent, file_name, &file, false, label)?; + after_open(file_name); + quarantine_and_remove_unix_tool_plan_file_at(parent, file_name, &file, label)?; + Ok(true) +} + +#[cfg(unix)] +fn quarantine_and_remove_unix_tool_plan_file_at( + parent: &File, + file_name: &str, + file: &File, + label: &str, +) -> Result<(), String> { + let run_key = match classify_handoff_file_name(file_name) { + Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)) + | Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)) + | Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) => run_key, + None => return Err(format!("{label} 文件名无法生成安全隔离名称")), + }; + let quarantine_name = format!( + ".{run_key}.json.tmp.{}.{}", + std::process::id(), + next_tool_plan_temp_nonce() + ); + rename_unix_tool_plan_entry_noreplace_at(parent, file_name, &quarantine_name, label)?; + let quarantined = match open_unix_tool_plan_file_for_removal_at(parent, &quarantine_name, label) + { + Ok(Some(quarantined)) => quarantined, + Ok(None) => { + return Err(format!("{label} 隔离后消失,保留现场等待 reconciliation")); + } + Err(error) => { + let rollback = rename_unix_tool_plan_entry_noreplace_at( + parent, + &quarantine_name, + file_name, + &format!("{label} 隔离回滚"), + ); + return Err(match rollback { + Ok(()) => format!("{label} 隔离对象无效且已回滚:{error}"), + Err(rollback_error) => { + format!("{label} 隔离对象无效且回滚失败:{error}; {rollback_error}") + } + }); + } + }; + let matches_opened = { + let opened_metadata = file + .metadata() + .map_err(|error| format!("复核 {label} 原句柄失败:{error}"))?; + let quarantined_metadata = quarantined + .metadata() + .map_err(|error| format!("复核 {label} 隔离句柄失败:{error}"))?; + opened_metadata.dev() == quarantined_metadata.dev() + && opened_metadata.ino() == quarantined_metadata.ino() + }; + if !matches_opened { + let rollback = rename_unix_tool_plan_entry_noreplace_at( + parent, + &quarantine_name, + file_name, + &format!("{label} 名称换绑回滚"), + ) + .and_then(|_| { + verify_unix_tool_plan_entry( + parent, + file_name, + &quarantined, + false, + &format!("{label} 名称换绑回滚"), + ) + }); + return Err(match rollback { + Ok(()) => format!("{label} 删除前发生名称换绑,替换对象已回滚"), + Err(rollback_error) => { + format!("{label} 删除前发生名称换绑且回滚失败,已保留隔离对象:{rollback_error}") + } + }); + } + file.set_len(0) + .and_then(|_| file.sync_data()) + .map_err(|error| format!("清空并同步已隔离 {label} 失败:{error}"))?; + verify_unix_tool_plan_entry(parent, &quarantine_name, file, false, label)?; + let quarantine = unix_tool_plan_component(&quarantine_name, label)?; + // SAFETY: parent is stable and quarantine is the freshly verified relative component. + if unsafe { libc::unlinkat(parent.as_raw_fd(), quarantine.as_ptr(), 0) } != 0 { + return Err(format!( + "删除已隔离 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + verify_unix_tool_plan_file_unlinked(file, label) +} + +#[cfg(unix)] +fn verify_unix_tool_plan_file_unlinked(file: &File, label: &str) -> Result<(), String> { + let metadata = file + .metadata() + .map_err(|error| format!("复核已删除 {label} 句柄失败:{error}"))?; + if metadata.nlink() != 0 { + return Err(format!("{label} 删除期间发生名称换绑")); + } + Ok(()) +} + +enum DiscoveredToolPlanHandoffFileName<'a> { + Primary(&'a str), + Previous(&'a str), + Temporary { run_key: &'a str }, +} + +fn classify_handoff_file_name(file_name: &str) -> Option> { + if let Some(run_key) = file_name.strip_suffix(".json") { + return Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)); + } + if let Some(run_key) = file_name + .strip_prefix('.') + .and_then(|value| value.strip_suffix(".json.previous")) + { + return Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)); + } + let temporary = file_name.strip_prefix('.')?; + let (run_key, suffix) = temporary.split_once(".json.tmp.")?; + let (pid, nanos) = suffix.split_once('.')?; + if pid.is_empty() + || nanos.is_empty() + || !pid.bytes().all(|byte| byte.is_ascii_digit()) + || !nanos.bytes().all(|byte| byte.is_ascii_digit()) + { + return None; + } + if pid.parse::().ok()? == 0 { + return None; + } + Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) +} + +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct WindowsToolPlanFileIdentity { + volume_serial_number: u32, + file_index: u64, + number_of_links: u32, + file_attributes: u32, +} + +#[cfg(windows)] +#[derive(Clone, Copy)] +enum WindowsToolPlanOpenDisposition { + Existing, + OpenOrCreate, + CreateNew, +} + +#[cfg(windows)] +fn windows_tool_plan_file_identity(file: &File) -> Result { + use std::ffi::c_void; + use std::os::windows::io::AsRawHandle; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + #[repr(C)] + struct ByHandleFileInformation { + file_attributes: u32, + creation_time: FileTime, + last_access_time: FileTime, + last_write_time: FileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandle( + file: *mut c_void, + information: *mut ByHandleFileInformation, + ) -> i32; + } + + // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. + let mut information = unsafe { std::mem::zeroed::() }; + // SAFETY: file owns a live handle and information is a valid output pointer. + if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 { + return Err(format!( + "读取 Windows tool-plan 文件句柄身份失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(WindowsToolPlanFileIdentity { + volume_serial_number: information.volume_serial_number, + file_index: (u64::from(information.file_index_high) << 32) + | u64::from(information.file_index_low), + number_of_links: information.number_of_links, + file_attributes: information.file_attributes, + }) +} + +#[cfg(windows)] +fn validate_windows_tool_plan_directory_handle(file: &File, label: &str) -> Result<(), String> { + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + let metadata = file + .metadata() + .map_err(|error| format!("读取 {label} 句柄元数据失败:{error}"))?; + let identity = windows_tool_plan_file_identity(file)?; + if !metadata.is_dir() || identity.file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 必须是普通目录且不能是 Windows junction/reparse point" + )); + } + Ok(()) +} + +#[cfg(windows)] +fn validate_windows_tool_plan_file_handle(file: &File, label: &str) -> Result<(), String> { + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + let metadata = file + .metadata() + .map_err(|error| format!("读取 {label} 句柄元数据失败:{error}"))?; + let identity = windows_tool_plan_file_identity(file)?; + if !metadata.is_file() + || identity.file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || identity.number_of_links != 1 + { + return Err(format!("{label} 必须是无 reparse point 的单链接普通文件")); + } + Ok(()) +} + +#[cfg(windows)] +fn read_windows_tool_plan_directory_names( + directory: &File, + label: &str, + max_names: usize, +) -> Result, String> { + use std::ffi::c_void; + use std::mem::{offset_of, size_of}; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::{ + Foundation::ERROR_NO_MORE_FILES, + Storage::FileSystem::{ + FileIdBothDirectoryInfo, FileIdBothDirectoryRestartInfo, GetFileInformationByHandleEx, + FILE_ID_BOTH_DIR_INFO, + }, + }; + + const BUFFER_SIZE: usize = 64 * 1024; + const NAME_OFFSET: usize = offset_of!(FILE_ID_BOTH_DIR_INFO, FileName); + const DOT: u16 = b'.' as u16; + + #[repr(align(8))] + struct DirectoryBuffer([u8; BUFFER_SIZE]); + + let mut buffer = Box::new(DirectoryBuffer([0; BUFFER_SIZE])); + let base = buffer.0.as_mut_ptr(); + let malformed = || format!("{label} 返回了无效的 Windows 目录枚举缓冲区"); + let mut restart = true; + let mut names = Vec::new(); + loop { + let information_class = if restart { + restart = false; + FileIdBothDirectoryRestartInfo + } else { + FileIdBothDirectoryInfo + }; + // SAFETY: directory is a live directory handle and buffer is aligned and writable. + let succeeded = unsafe { + GetFileInformationByHandleEx( + directory.as_raw_handle().cast(), + information_class, + base.cast::(), + BUFFER_SIZE as u32, + ) + }; + if succeeded == 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_NO_MORE_FILES as i32) { + break; + } + return Err(format!("按句柄读取 {label} 失败:{error}")); + } + + let mut offset = 0usize; + loop { + let remaining = BUFFER_SIZE.checked_sub(offset).ok_or_else(&malformed)?; + if remaining < size_of::() { + return Err(malformed()); + } + // SAFETY: offset is bounds-checked and every entry is required to be 8-byte aligned. + let information = unsafe { &*base.add(offset).cast::() }; + let name_bytes = information.FileNameLength as usize; + let used = NAME_OFFSET.checked_add(name_bytes).ok_or_else(&malformed)?; + let next = information.NextEntryOffset as usize; + if name_bytes == 0 || name_bytes % size_of::() != 0 || used > remaining { + return Err(malformed()); + } + if next != 0 && (next % 8 != 0 || next < used || next > remaining) { + return Err(malformed()); + } + // SAFETY: FileNameLength was checked against the remaining buffer and is UTF-16 bytes. + let wide_name = unsafe { + std::slice::from_raw_parts( + base.add(offset + NAME_OFFSET).cast::(), + name_bytes / size_of::(), + ) + }; + if !matches!(wide_name, [DOT] | [DOT, DOT]) { + if names.len() >= max_names { + return Err(format!("{label} 超过 {max_names} 个目录项上限")); + } + names.push( + String::from_utf16(wide_name) + .map_err(|_| format!("{label} 目录项名称必须是有效 UTF-16"))?, + ); + } + if next == 0 { + break; + } + offset = offset.checked_add(next).ok_or_else(&malformed)?; + } + } + Ok(names) +} + +#[cfg(windows)] +fn open_windows_tool_plan_root(root: &Path, writable: bool) -> Result { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + + let file = OpenOptions::new() + .read(true) + .write(writable) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(root) + .map_err(|error| format!("安全打开 Windows tool-plan 项目根目录失败:{error}"))?; + validate_windows_tool_plan_directory_handle(&file, "tool-plan 项目根目录")?; + Ok(file) +} + +#[cfg(windows)] +fn nt_open_windows_tool_plan_relative( + parent: &File, + name: &str, + directory: bool, + disposition: WindowsToolPlanOpenDisposition, + writable: bool, + exclusive: bool, + delete_access: bool, +) -> std::io::Result { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; + + if name.is_empty() + || matches!(name, "." | "..") + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid relative component", + )); + } + + type Handle = *mut c_void; + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, + } + #[repr(C)] + struct ObjectAttributes { + length: u32, + root_directory: Handle, + object_name: *mut UnicodeString, + attributes: u32, + security_descriptor: *mut c_void, + security_quality_of_service: *mut c_void, + } + #[repr(C)] + struct IoStatusBlock { + status: isize, + information: usize, + } + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtCreateFile( + file_handle: *mut Handle, + desired_access: u32, + object_attributes: *mut ObjectAttributes, + io_status_block: *mut IoStatusBlock, + allocation_size: *mut i64, + file_attributes: u32, + share_access: u32, + create_disposition: u32, + create_options: u32, + ea_buffer: *mut c_void, + ea_length: u32, + ) -> i32; + fn RtlNtStatusToDosError(status: i32) -> u32; + } + + const OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + const FILE_OPEN: u32 = 0x0000_0001; + const FILE_CREATE: u32 = 0x0000_0002; + const FILE_OPEN_IF: u32 = 0x0000_0003; + const FILE_DIRECTORY_FILE: u32 = 0x0000_0001; + const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020; + const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; + const FILE_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080; + const FILE_LIST_DIRECTORY: u32 = 0x0000_0001; + const FILE_ADD_FILE: u32 = 0x0000_0002; + const FILE_ADD_SUBDIRECTORY: u32 = 0x0000_0004; + const FILE_TRAVERSE: u32 = 0x0000_0020; + const FILE_READ_ATTRIBUTES: u32 = 0x0000_0080; + const READ_CONTROL: u32 = 0x0002_0000; + const DELETE: u32 = 0x0001_0000; + const SYNCHRONIZE: u32 = 0x0010_0000; + const GENERIC_READ: u32 = 0x8000_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + + let mut wide_name = std::ffi::OsStr::new(name).encode_wide().collect::>(); + let byte_length = wide_name + .len() + .checked_mul(2) + .and_then(|length| u16::try_from(length).ok()) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "relative name too long") + })?; + let mut unicode_name = UnicodeString { + length: byte_length, + maximum_length: byte_length, + buffer: wide_name.as_mut_ptr(), + }; + let mut attributes = ObjectAttributes { + length: std::mem::size_of::() as u32, + root_directory: parent.as_raw_handle().cast(), + object_name: &mut unicode_name, + attributes: OBJ_CASE_INSENSITIVE, + security_descriptor: std::ptr::null_mut(), + security_quality_of_service: std::ptr::null_mut(), + }; + let mut io_status = IoStatusBlock { + status: 0, + information: 0, + }; + let mut handle = std::ptr::null_mut(); + let mut desired_access = if directory { + FILE_LIST_DIRECTORY | FILE_TRAVERSE | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE + } else { + GENERIC_READ | READ_CONTROL | SYNCHRONIZE + }; + if writable { + desired_access |= if directory { + FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY + } else { + GENERIC_WRITE + }; + } + if delete_access { + desired_access |= DELETE; + } + let create_options = if directory { + FILE_DIRECTORY_FILE + } else { + FILE_NON_DIRECTORY_FILE + } | FILE_SYNCHRONOUS_IO_NONALERT + | FILE_OPEN_REPARSE_POINT; + let create_disposition = match disposition { + WindowsToolPlanOpenDisposition::Existing => FILE_OPEN, + WindowsToolPlanOpenDisposition::OpenOrCreate => FILE_OPEN_IF, + WindowsToolPlanOpenDisposition::CreateNew => FILE_CREATE, + }; + // SAFETY: all NT structures and buffers remain live for this call; handle is an output. + let status = unsafe { + NtCreateFile( + &mut handle, + desired_access, + &mut attributes, + &mut io_status, + std::ptr::null_mut(), + FILE_ATTRIBUTE_NORMAL, + if exclusive { + 0 + } else { + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + }, + create_disposition, + create_options, + std::ptr::null_mut(), + 0, + ) + }; + if status < 0 || handle.is_null() { + // SAFETY: conversion accepts any NTSTATUS and returns a Win32 error code. + let code = unsafe { RtlNtStatusToDosError(status) }; + return Err(std::io::Error::from_raw_os_error(code as i32)); + } + // SAFETY: NtCreateFile returned an owned handle transferred exactly once to File. + Ok(unsafe { File::from_raw_handle(handle.cast()) }) +} + +#[cfg(windows)] +fn windows_tool_plan_error_is_not_found(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::NotFound + || matches!(error.raw_os_error(), Some(2) | Some(3)) +} + +#[cfg(windows)] +fn windows_tool_plan_error_is_sharing_violation(error: &std::io::Error) -> bool { + matches!(error.raw_os_error(), Some(32) | Some(33)) +} + +#[cfg(windows)] +fn open_windows_tool_plan_directory_at( + parent: &File, + name: &str, + label: &str, + create: bool, +) -> Result, String> { + let disposition = if create { + WindowsToolPlanOpenDisposition::OpenOrCreate + } else { + WindowsToolPlanOpenDisposition::Existing + }; + let file = match nt_open_windows_tool_plan_relative( + parent, + name, + true, + disposition, + create, + false, + false, + ) { + Ok(file) => file, + Err(error) if !create && windows_tool_plan_error_is_not_found(&error) => return Ok(None), + Err(error) => return Err(format!("安全相对打开 {label} 失败:{error}")), + }; + validate_windows_tool_plan_directory_handle(&file, label)?; + Ok(Some(file)) +} + +#[cfg(windows)] +fn try_open_windows_tool_plan_file_at( + parent: &File, + name: &str, + label: &str, + exclusive: bool, + delete_access: bool, +) -> Result, String> { + let file = match nt_open_windows_tool_plan_relative( + parent, + name, + false, + WindowsToolPlanOpenDisposition::Existing, + false, + exclusive, + delete_access, + ) { + Ok(file) => file, + Err(error) if windows_tool_plan_error_is_not_found(&error) => return Ok(None), + Err(error) if windows_tool_plan_error_is_sharing_violation(&error) => { + return Err(format!("{label} 仍由活跃写入句柄持有")); + } + Err(error) => return Err(format!("安全相对打开 {label} 失败:{error}")), + }; + validate_windows_tool_plan_file_handle(&file, label)?; + Ok(Some(file)) +} + +#[cfg(windows)] +fn verify_windows_tool_plan_entry( + parent: &File, + name: &str, + opened: &File, + directory: bool, + label: &str, +) -> Result<(), String> { + let current = nt_open_windows_tool_plan_relative( + parent, + name, + directory, + WindowsToolPlanOpenDisposition::Existing, + false, + false, + false, + ) + .map_err(|error| format!("复核 {label} 目录项失败:{error}"))?; + if directory { + validate_windows_tool_plan_directory_handle(¤t, label)?; + } else { + validate_windows_tool_plan_file_handle(¤t, label)?; + } + if windows_tool_plan_file_identity(¤t)? != windows_tool_plan_file_identity(opened)? { + return Err(format!("{label} 在安全操作期间发生替换")); + } + Ok(()) +} + +#[cfg(windows)] +struct WindowsToolPlanRootStorage { + root_path: PathBuf, + project_directory: File, + agent_runtime_directory: File, + runtime_directory: File, + handoff_directory: File, +} + +#[cfg(windows)] +impl WindowsToolPlanRootStorage { + fn verify(&self) -> Result<(), String> { + let current_root = open_windows_tool_plan_root(&self.root_path, false)?; + if windows_tool_plan_file_identity(¤t_root)? + != windows_tool_plan_file_identity(&self.project_directory)? + { + return Err("Windows tool-plan 项目根目录在安全操作期间发生替换".to_string()); + } + verify_windows_tool_plan_entry( + &self.project_directory, + ".agent", + &self.agent_runtime_directory, + true, + "项目 .agent 目录", + )?; + verify_windows_tool_plan_entry( + &self.agent_runtime_directory, + "runtime", + &self.runtime_directory, + true, + "Agent Runtime 目录", + )?; + verify_windows_tool_plan_entry( + &self.runtime_directory, + "tool-plan-handoffs", + &self.handoff_directory, + true, + "tool-plan 成功响应交接根目录", + ) + } +} + +#[cfg(windows)] +fn open_windows_tool_plan_root_storage( + root: &Path, + create: bool, +) -> Result, String> { + let project_directory = open_windows_tool_plan_root(root, create)?; + let Some(agent_runtime_directory) = open_windows_tool_plan_directory_at( + &project_directory, + ".agent", + "项目 .agent 目录", + create, + )? + else { + return Ok(None); + }; + let Some(runtime_directory) = open_windows_tool_plan_directory_at( + &agent_runtime_directory, + "runtime", + "Agent Runtime 目录", + create, + )? + else { + return Ok(None); + }; + let Some(handoff_directory) = open_windows_tool_plan_directory_at( + &runtime_directory, + "tool-plan-handoffs", + "tool-plan 成功响应交接根目录", + create, + )? + else { + return Ok(None); + }; + let storage = WindowsToolPlanRootStorage { + root_path: root.to_path_buf(), + project_directory, + agent_runtime_directory, + runtime_directory, + handoff_directory, + }; + storage.verify()?; + Ok(Some(storage)) +} + +#[cfg(windows)] +struct WindowsToolPlanAgentStorage { + root: WindowsToolPlanRootStorage, + agent_directory: File, + agent_key: String, +} + +#[cfg(windows)] +impl WindowsToolPlanAgentStorage { + fn verify(&self) -> Result<(), String> { + self.root.verify()?; + verify_windows_tool_plan_entry( + &self.root.handoff_directory, + &self.agent_key, + &self.agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + ) + } +} + +#[cfg(windows)] +fn open_windows_tool_plan_agent_storage( + root: &Path, + agent_id: &str, + create: bool, +) -> Result, String> { + let Some(root_storage) = open_windows_tool_plan_root_storage(root, create)? else { + return Ok(None); + }; + let agent_key = path_key(agent_id); + let Some(agent_directory) = open_windows_tool_plan_directory_at( + &root_storage.handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + create, + )? + else { + return Ok(None); + }; + let storage = WindowsToolPlanAgentStorage { + root: root_storage, + agent_directory, + agent_key, + }; + storage.verify()?; + Ok(Some(storage)) +} + +#[cfg(windows)] +fn try_read_windows_discovered_ledger_file( + root: &Path, + parent: &File, + file_name: &str, + agent_key: &str, + run_key: &str, +) -> Result, String> { + let Some(mut file) = try_open_windows_tool_plan_file_at( + parent, + file_name, + "tool-plan 成功响应交接账本文件", + false, + false, + )? + else { + return Ok(None); + }; + let metadata = file + .metadata() + .map_err(|error| format!("读取 Windows tool-plan 账本元数据失败:{error}"))?; + if metadata.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES as u64 { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + std::io::Read::by_ref(&mut file) + .take((TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("读取 Windows tool-plan 成功响应交接账本失败:{error}"))?; + if bytes.len() > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { + return Err(format!( + "tool-plan 成功响应交接账本超过 {TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES} 字节上限" + )); + } + verify_windows_tool_plan_entry( + parent, + file_name, + &file, + false, + "tool-plan 成功响应交接账本文件", + )?; + let ledger = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 Windows tool-plan 成功响应交接账本失败:{error}"))?; + validate_ledger(root, &ledger)?; + if path_key(&ledger.agent_id) != agent_key || path_key(&ledger.run_id) != run_key { + return Err("tool-plan 成功响应交接 hash 路径与 Agent/run 身份冲突".to_string()); + } + Ok(Some(ledger)) +} + +#[cfg(windows)] +fn read_for_run_at_windows( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + let Some(storage) = open_windows_tool_plan_agent_storage(root, agent_id, false)? else { + return Ok(None); + }; + let run_key = path_key(run_id); + let primary = try_read_windows_discovered_ledger_file( + root, + &storage.agent_directory, + &format!("{run_key}.json"), + &storage.agent_key, + &run_key, + )?; + let previous = try_read_windows_discovered_ledger_file( + root, + &storage.agent_directory, + &format!(".{run_key}.json.previous"), + &storage.agent_key, + &run_key, + )?; + storage.verify()?; + let selected = select_primary_and_previous(&storage.agent_key, &run_key, primary, previous)?; + if selected + .as_ref() + .is_some_and(|ledger| ledger.agent_id != agent_id || ledger.run_id != run_id) + { + return Err("tool-plan 成功响应交接账本与路径 Agent/run 身份冲突".to_string()); + } + Ok(selected) +} + +#[cfg(windows)] +fn set_windows_tool_plan_file_deleted(file: &File, label: &str) -> Result<(), String> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, + }; + + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: file owns a DELETE-capable handle and disposition is a valid fixed-size buffer. + if unsafe { + SetFileInformationByHandle( + file.as_raw_handle().cast(), + FileDispositionInfo, + (&disposition as *const FILE_DISPOSITION_INFO).cast(), + std::mem::size_of::() as u32, + ) + } == 0 + { + return Err(format!( + "按句柄删除 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(windows)] +fn rename_windows_tool_plan_file_at( + file: &File, + parent: &File, + new_name: &str, + replace: bool, + label: &str, +) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileRenameInfo, SetFileInformationByHandle, FILE_RENAME_INFO, + }; + + let wide_name = std::ffi::OsStr::new(new_name) + .encode_wide() + .collect::>(); + let name_bytes = wide_name + .len() + .checked_mul(2) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| format!("{label} 目标名称过长"))?; + let header_bytes = std::mem::offset_of!(FILE_RENAME_INFO, FileName); + let total_bytes = header_bytes + .checked_add(name_bytes as usize) + .ok_or_else(|| format!("{label} 重命名缓冲区过大"))?; + let word_bytes = std::mem::size_of::(); + let mut buffer = vec![0usize; total_bytes.div_ceil(word_bytes)]; + let information = buffer.as_mut_ptr().cast::(); + // SAFETY: buffer is aligned and sized for the fixed header plus the complete UTF-16 name. + unsafe { + (*information).Anonymous.ReplaceIfExists = replace; + (*information).RootDirectory = parent.as_raw_handle().cast(); + (*information).FileNameLength = name_bytes; + std::ptr::copy_nonoverlapping( + wide_name.as_ptr(), + (*information).FileName.as_mut_ptr(), + wide_name.len(), + ); + } + // SAFETY: file owns a DELETE-capable handle and information spans total_bytes bytes. + if unsafe { + SetFileInformationByHandle( + file.as_raw_handle().cast(), + FileRenameInfo, + information.cast(), + total_bytes as u32, + ) + } == 0 + { + return Err(format!( + "按句柄安装 {label} 失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(()) +} + +#[cfg(windows)] +fn remove_windows_tool_plan_file_at( + parent: &File, + file_name: &str, + label: &str, +) -> Result { + let Some(file) = try_open_windows_tool_plan_file_at(parent, file_name, label, true, true)? + else { + return Ok(false); + }; + set_windows_tool_plan_file_deleted(&file, label)?; + Ok(true) +} + +#[cfg(windows)] +fn create_windows_tool_plan_temp_file_at( + parent: &File, + run_key: &str, +) -> Result<(String, File), String> { + for _ in 0..32 { + let file_name = format!( + ".{run_key}.json.tmp.{}.{}", + std::process::id(), + next_tool_plan_temp_nonce() + ); + match nt_open_windows_tool_plan_relative( + parent, + &file_name, + false, + WindowsToolPlanOpenDisposition::CreateNew, + true, + true, + true, + ) { + Ok(file) => { + validate_windows_tool_plan_file_handle(&file, "tool-plan 成功响应交接临时文件")?; + return Ok((file_name, file)); + } + Err(error) + if error.kind() == std::io::ErrorKind::AlreadyExists + || matches!(error.raw_os_error(), Some(80) | Some(183)) => + { + continue; + } + Err(error) => { + return Err(format!( + "创建 Windows tool-plan 成功响应交接临时文件失败:{error}" + )); + } + } + } + Err("创建 Windows tool-plan 成功响应交接临时文件失败:名称冲突".to_string()) +} + +#[cfg(windows)] +fn write_ledger_at_windows( + root: &Path, + ledger: &AgentRuntimeToolPlanHandoffLedger, + bytes: &[u8], +) -> Result<(), String> { + let storage = open_windows_tool_plan_agent_storage(root, &ledger.agent_id, true)? + .ok_or_else(|| "创建 Windows tool-plan 成功响应交接存储目录失败".to_string())?; + let run_key = path_key(&ledger.run_id); + let primary_name = format!("{run_key}.json"); + let previous_name = format!(".{run_key}.json.previous"); + let (_temporary_name, mut temporary_file) = + create_windows_tool_plan_temp_file_at(&storage.agent_directory, &run_key)?; + if let Err(error) = temporary_file + .write_all(bytes) + .and_then(|_| temporary_file.sync_data()) + { + let _ = + set_windows_tool_plan_file_deleted(&temporary_file, "tool-plan 成功响应交接临时文件"); + return Err(format!( + "写入 Windows tool-plan 成功响应交接临时文件失败:{error}" + )); + } + remove_windows_tool_plan_file_at( + &storage.agent_directory, + &previous_name, + "tool-plan 成功响应交接恢复副本", + )?; + if let Err(error) = rename_windows_tool_plan_file_at( + &temporary_file, + &storage.agent_directory, + &primary_name, + true, + "tool-plan 成功响应交接账本", + ) { + let _ = + set_windows_tool_plan_file_deleted(&temporary_file, "tool-plan 成功响应交接临时文件"); + return Err(error); + } + temporary_file + .sync_all() + .map_err(|error| format!("同步 Windows tool-plan 成功响应交接账本失败:{error}"))?; + storage.verify()?; + Ok(()) +} + +#[cfg(windows)] +fn remove_at_windows(root: &Path, agent_id: &str, run_id: &str) -> Result<(), String> { + let Some(storage) = open_windows_tool_plan_agent_storage(root, agent_id, false)? else { + return Ok(()); + }; + let run_key = path_key(run_id); + remove_windows_tool_plan_file_at( + &storage.agent_directory, + &format!(".{run_key}.json.previous"), + "tool-plan 成功响应交接恢复副本", + )?; + remove_windows_tool_plan_file_at( + &storage.agent_directory, + &format!("{run_key}.json"), + "tool-plan 成功响应交接账本", + )?; + storage.verify() +} + +#[cfg(windows)] +fn list_at_windows(root: &Path) -> Result, String> { + let Some(root_storage) = open_windows_tool_plan_root_storage(root, false)? else { + return Ok(Vec::new()); + }; + let agent_names = read_windows_tool_plan_directory_names( + &root_storage.handoff_directory, + "Windows tool-plan Agent 根目录", + TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS, + )?; + let mut discovered = BTreeMap::<(String, String), DiscoveredToolPlanHandoffLedgers>::new(); + let mut discovered_file_count = 0usize; + for (agent_index, agent_key) in agent_names.into_iter().enumerate() { + if agent_index >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_AGENTS} 个 Agent 上限" + )); + } + if !is_handoff_path_key(&agent_key) { + return Err(format!( + "tool-plan 成功响应交接 Agent 目录名不是规范 hash:{agent_key}" + )); + } + let agent_directory = open_windows_tool_plan_directory_at( + &root_storage.handoff_directory, + &agent_key, + "tool-plan 成功响应交接 Agent 目录", + false, + )? + .ok_or_else(|| "tool-plan 成功响应交接 Agent 目录在扫描期间消失".to_string())?; + let run_names = read_windows_tool_plan_directory_names( + &agent_directory, + "Windows tool-plan run 目录", + TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES, + )?; + for (run_index, file_name) in run_names.into_iter().enumerate() { + if run_index >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES + || discovered_file_count >= TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES + { + return Err(format!( + "tool-plan 成功响应交接目录超过 {TOOL_PLAN_HANDOFF_MAX_DISCOVERED_FILES} 个文件上限" + )); + } + discovered_file_count += 1; + match classify_handoff_file_name(&file_name) { + Some(DiscoveredToolPlanHandoffFileName::Primary(run_key)) => { + let ledger = try_read_windows_discovered_ledger_file( + root, + &agent_directory, + &file_name, + &agent_key, + run_key, + )? + .ok_or_else(|| "tool-plan 成功响应交接 primary 在扫描期间消失".to_string())?; + let files = + discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; + if files.primary.replace(ledger).is_some() { + return Err("tool-plan 成功响应交接 primary 项冲突".to_string()); + } + } + Some(DiscoveredToolPlanHandoffFileName::Previous(run_key)) => { + let ledger = try_read_windows_discovered_ledger_file( + root, + &agent_directory, + &file_name, + &agent_key, + run_key, + )? + .ok_or_else(|| ".previous 在 Windows tool-plan 扫描期间消失".to_string())?; + let files = + discovered_handoff_ledgers_mut(&mut discovered, &agent_key, run_key)?; + if files.previous.replace(ledger).is_some() { + return Err("tool-plan 成功响应交接 .previous 项冲突".to_string()); + } + } + Some(DiscoveredToolPlanHandoffFileName::Temporary { run_key }) => { + if !is_handoff_path_key(run_key) { + return Err(format!( + "tool-plan 成功响应交接临时文件 run hash 无效:{file_name}" + )); + } + remove_windows_tool_plan_file_at( + &agent_directory, + &file_name, + "tool-plan 成功响应交接原子临时文件", + )?; + } + None => { + return Err(format!( + "tool-plan 成功响应交接目录包含未知文件:{file_name}" + )); + } + } + } + verify_windows_tool_plan_entry( + &root_storage.handoff_directory, + &agent_key, + &agent_directory, + true, + "tool-plan 成功响应交接 Agent 目录", + )?; + } + root_storage.verify()?; + let mut ledgers = Vec::with_capacity(discovered.len()); + for ((agent_key, run_key), files) in discovered { + if let Some(ledger) = + select_primary_and_previous(&agent_key, &run_key, files.primary, files.previous)? + { + ledgers.push(ledger); + } + } + ledgers.sort_by(|left, right| { + left.agent_id + .cmp(&right.agent_id) + .then_with(|| left.run_id.cmp(&right.run_id)) + }); + Ok(ledgers) +} + +fn ledger_is_prefix( + previous: &AgentRuntimeToolPlanHandoffLedger, + primary: &AgentRuntimeToolPlanHandoffLedger, +) -> bool { + previous.schema_version == primary.schema_version + && previous.agent_id == primary.agent_id + && previous.run_id == primary.run_id + && previous.entries.len() <= primary.entries.len() + && previous.entries == primary.entries[..previous.entries.len()] +} + +fn select_primary_and_previous( + agent_key: &str, + run_key: &str, + primary: Option, + previous: Option, +) -> Result, String> { + match (primary, previous) { + (Some(primary), Some(previous)) => { + if !ledger_is_prefix(&previous, &primary) { + return Err(format!( + "tool-plan 成功响应交接 primary/.previous 内容冲突:{agent_key}/{run_key}" + )); + } + Ok(Some(primary)) + } + (Some(primary), None) => Ok(Some(primary)), + (None, Some(previous)) => Ok(Some(previous)), + (None, None) => Ok(None), + } +} + +fn is_handoff_path_key(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn response_fingerprint(response: &AgentRuntimeToolPlanHandoffResponse) -> Result { + let bytes = serde_json::to_vec(response) + .map_err(|error| format!("序列化 tool-plan 成功响应指纹失败:{error}"))?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} + +fn validate_path_identity(agent_id: &str, run_id: &str) -> Result<(), String> { + if agent_id.trim().is_empty() || run_id.trim().is_empty() { + return Err("tool-plan 成功响应交接路径的 Agent/run 身份不能为空".to_string()); + } + Ok(()) +} + +#[cfg(test)] +fn tool_plan_handoff_relative_path(agent_id: &str, run_id: &str) -> String { + format!( + "{TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY}/{}/{}.json", + path_key(agent_id), + path_key(run_id) + ) +} + +#[cfg(any(test, not(unix)))] +#[cfg(test)] +fn tool_plan_handoff_path(root: &Path, agent_id: &str, run_id: &str) -> PathBuf { + root.join(tool_plan_handoff_relative_path(agent_id, run_id)) +} + +fn path_key(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} + +fn next_tool_plan_temp_nonce() -> u128 { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + timestamp.saturating_add(u128::from( + TOOL_PLAN_HANDOFF_TEMP_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + )) +} + +#[cfg(test)] +mod tests { + use std::fs; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use tempfile::tempdir; + + use super::*; + use crate::agent::write_agent_runtime_json_sidecar_with_max_bytes; + + fn identity(slot: &str) -> AgentRuntimeProviderRetryIdentity { + identity_for(slot, "project-supervisor", "run-tool-plan-handoff") + } + + fn identity_for(slot: &str, agent_id: &str, run_id: &str) -> AgentRuntimeProviderRetryIdentity { + let (_, repair_attempt) = parse_tool_plan_base_request_slot(slot).expect("valid test slot"); + AgentRuntimeProviderRetryIdentity { + project_id: "project-tool-plan-handoff".to_string(), + agent_id: agent_id.to_string(), + task_id: "task-tool-plan-handoff".to_string(), + session_id: "session-tool-plan-handoff".to_string(), + run_id: run_id.to_string(), + source: "agent-background-task".to_string(), + goal_id: Some("goal-tool-plan-handoff".to_string()), + goal_revision: 4, + goal_snapshot_fingerprint: "a".repeat(64), + applied_steer_cursor: 2, + request_kind: "tool-plan".to_string(), + base_request_slot: slot.to_string(), + request_fingerprint: format!("{:x}", Sha256::digest(slot.as_bytes())), + provider_config_fingerprint: "b".repeat(64), + web_search_enabled: repair_attempt == 0, + allow_idle_context_compaction: false, + } + } + + fn response(text: &str, tool_calls: Vec) -> LlmRunResponse { + LlmRunResponse { + provider: LlmProvider::OpenAiCompatible, + model: "tool-plan-handoff-model".to_string(), + text: text.to_string(), + finish_reason: Some("tool_calls".to_string()), + response_id: Some("tool-plan-handoff-response".to_string()), + usage: Some(LlmTokenUsage { + prompt_tokens: 21, + completion_tokens: 13, + total_tokens: 34, + }), + tool_calls, + } + } + + fn call(id: &str, name: &str, arguments: &str) -> LlmToolCall { + LlmToolCall { + id: id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + } + } + + fn provider_request_id(marker: &str) -> String { + format!("provider-request-{:x}", Sha256::digest(marker.as_bytes())) + } + + fn request_slot(identity: &AgentRuntimeProviderRetryIdentity, attempt: u32) -> String { + request_slot_for_attempt(identity, attempt) + } + + fn write( + root: &Path, + identity: &AgentRuntimeProviderRetryIdentity, + attempt: u32, + response: &LlmRunResponse, + ) -> AgentRuntimeToolPlanHandoffEntry { + write_at( + root, + identity, + &request_slot(identity, attempt), + attempt, + &provider_request_id(&format!("{}-{attempt}", identity.base_request_slot)), + response, + ) + .expect("write tool-plan handoff") + } + + #[test] + fn tool_plan_handoff_exact_tool_calls_round_trip_and_lookup() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let expected = response( + "执行精确工具计划。", + vec![ + call("call-1", "file.write", r#"{"path":"src/main.rs"}"#), + call("call-2", "project.verify", r#"{"unterminated":"#), + ], + ); + let entry = write(project.path(), &identity, 0, &expected); + + assert_eq!(entry.identity, identity); + assert_eq!(entry.loop_iteration, 0); + assert_eq!(entry.repair_attempt, 0); + assert_eq!(entry.to_llm_response(), expected); + assert_eq!( + lookup_at( + project.path(), + &identity.agent_id, + &identity.run_id, + &identity, + ) + .expect("lookup tool-plan handoff"), + AgentRuntimeToolPlanHandoffLookup::Exact(entry.clone()) + ); + let ledger = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("read tool-plan handoff") + .expect("tool-plan handoff ledger"); + assert_eq!(ledger.entries, vec![entry]); + + #[cfg(unix)] + assert_eq!( + fs::metadata(tool_plan_handoff_path( + project.path(), + &identity.agent_id, + &identity.run_id, + )) + .expect("tool-plan handoff metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + + #[test] + fn tool_plan_handoff_strips_thinking_before_persisting() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let private_thinking = "TOOL_PLAN_PRIVATE_THINKING_MUST_NOT_PERSIST"; + let entry = write( + project.path(), + &identity, + 0, + &response( + &format!("{private_thinking}\n公开计划"), + vec![call("call-1", "update_agent_plan", "{}")], + ), + ); + + assert_eq!(entry.to_llm_response().text, "公开计划"); + assert_eq!( + entry + .thinking_normalization_metadata() + .map(|(count, chars, fingerprint)| (count, chars, fingerprint.len())), + Some(( + 1, + format!("{private_thinking}\n公开计划") + .chars() + .count(), + 64, + )) + ); + let persisted = fs::read_to_string(tool_plan_handoff_path( + project.path(), + &identity.agent_id, + &identity.run_id, + )) + .expect("read persisted tool-plan handoff"); + assert!(!persisted.contains(private_thinking)); + assert!(!persisted.to_ascii_lowercase().contains("")); + } + + #[test] + fn tool_plan_handoff_invalid_thinking_wrappers_replay_bodyless_protocol_markers() { + let cases = [ + ( + "UNTERMINATED_PRIVATE_THINKING", + true, + false, + INVALID_THINKING_OPEN_MARKER, + ), + ( + "ORPHAN_PRIVATE_THINKING", + false, + true, + INVALID_THINKING_CLOSE_MARKER, + ), + ( + "MISMATCHED_PRIVATE_THINKING", + false, + false, + INVALID_THINKING_CLOSE_MARKER, + ), + ]; + + for (index, (source, wrapper_valid, wrapper_balanced, marker)) in + cases.into_iter().enumerate() + { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let entry = write( + project.path(), + &identity, + 0, + &response( + source, + vec![call("call-invalid-think", "project.verify", "{}")], + ), + ); + + assert_eq!(entry.response.text, "", "case {index}"); + assert_eq!( + entry.response.thinking_wrapper_valid, wrapper_valid, + "case {index}" + ); + assert_eq!( + entry.response.thinking_wrapper_balanced, wrapper_balanced, + "case {index}" + ); + assert!(entry.response.thinking_source_text_chars > 0); + assert_eq!( + entry + .response + .thinking_source_text_sha256 + .as_deref() + .map(str::len), + Some(64) + ); + assert!(entry.thinking_normalization_metadata().is_none()); + + let replayed = entry.to_llm_response(); + assert_eq!(replayed.text, marker, "case {index}"); + assert!( + crate::agent::parse_game_creator_agent_tool_plan_llm_response(&replayed).is_err() + ); + let persisted = fs::read_to_string(tool_plan_handoff_path( + project.path(), + &identity.agent_id, + &identity.run_id, + )) + .expect("read persisted invalid thinking handoff"); + assert!(!persisted.contains(source)); + assert!(!persisted.contains("PRIVATE_THINKING")); + assert!(persisted.contains("\"thinkingWrapperValid\"")); + assert!(persisted.contains("\"thinkingWrapperBalanced\"")); + } + } + + #[test] + fn tool_plan_handoff_strips_nested_balanced_thinking_without_leaking_body() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let entry = write( + project.path(), + &identity, + 0, + &response( + "OUTER_PRIVATEINNER_PRIVATEvisible", + vec![call("call-balanced-think", "project.verify", "{}")], + ), + ); + + assert_eq!(entry.to_llm_response().text, "visible"); + assert!(entry.response.thinking_wrapper_valid); + assert!(entry.response.thinking_wrapper_balanced); + assert_eq!(entry.response.thinking_normalization_count, 1); + let persisted = fs::read_to_string(tool_plan_handoff_path( + project.path(), + &identity.agent_id, + &identity.run_id, + )) + .expect("read nested thinking handoff"); + assert!(!persisted.contains("OUTER_PRIVATE")); + assert!(!persisted.contains("INNER_PRIVATE")); + } + + #[test] + fn tool_plan_handoff_appends_base_repair_and_next_loop_monotonically() { + let project = tempdir().expect("tool-plan handoff project"); + let base = identity("loop-0-repair-0"); + let repair = identity("loop-0-repair-1"); + let next_loop = identity("loop-1-repair-0"); + write(project.path(), &base, 0, &response("base", Vec::new())); + write( + project.path(), + &repair, + 2, + &response("repair", vec![call("call-r", "respond_to_user", "{}")]), + ); + write( + project.path(), + &next_loop, + 0, + &response("next loop", Vec::new()), + ); + + let ledger = read_for_run_at(project.path(), &base.agent_id, &base.run_id) + .expect("read tool-plan handoff") + .expect("tool-plan handoff ledger"); + assert_eq!( + ledger + .entries + .iter() + .map(|entry| (entry.loop_iteration, entry.repair_attempt, entry.attempt)) + .collect::>(), + vec![(0, 0, 0), (0, 1, 2), (1, 0, 0)] + ); + assert!(is_later_repair_identity(&base, &repair)); + assert!(!is_later_repair_identity(&repair, &base)); + assert!(!is_later_repair_identity(&base, &next_loop)); + } + + #[test] + fn tool_plan_handoff_same_slot_is_idempotent() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-3-repair-0"); + let expected = response( + "idempotent", + vec![call("call-idempotent", "project.verify", "{}")], + ); + let first = write(project.path(), &identity, 1, &expected); + let second = write(project.path(), &identity, 1, &expected); + assert_eq!(first, second); + assert_eq!( + read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("read tool-plan handoff") + .expect("tool-plan handoff ledger") + .entries + .len(), + 1 + ); + } + + #[test] + fn tool_plan_handoff_reports_identity_and_payload_conflicts() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let expected = response("original", Vec::new()); + let entry = write(project.path(), &identity, 0, &expected); + + let mut conflicting_identity = identity.clone(); + conflicting_identity.request_fingerprint = "c".repeat(64); + assert_eq!( + lookup_at( + project.path(), + &identity.agent_id, + &identity.run_id, + &conflicting_identity, + ) + .expect("lookup conflicting tool-plan handoff"), + AgentRuntimeToolPlanHandoffLookup::IdentityConflict(entry) + ); + let error = write_at( + project.path(), + &conflicting_identity, + &conflicting_identity.base_request_slot, + 0, + &provider_request_id("conflicting-identity"), + &expected, + ) + .expect_err("conflicting identity must fail"); + assert!(error.contains("同一 slot 内容冲突")); + + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("conflicting-request-id"), + &expected, + ) + .expect_err("conflicting requestId must fail"); + assert!(error.contains("同一 slot 内容冲突")); + let error = write_at( + project.path(), + &identity, + &request_slot(&identity, 1), + 1, + &provider_request_id("conflicting-attempt"), + &expected, + ) + .expect_err("conflicting attempt must fail"); + assert!(error.contains("同一 slot 内容冲突")); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("loop-0-repair-0-0"), + &response("different response", Vec::new()), + ) + .expect_err("conflicting response must fail"); + assert!(error.contains("同一 slot 内容冲突")); + } + + #[test] + fn tool_plan_handoff_rejects_out_of_order_entries() { + let project = tempdir().expect("tool-plan handoff project"); + let repair_without_base = identity("loop-0-repair-1"); + let error = write_at( + project.path(), + &repair_without_base, + &repair_without_base.base_request_slot, + 0, + &provider_request_id("repair-without-base"), + &response("repair", Vec::new()), + ) + .expect_err("repair without base must fail"); + assert!(error.contains("repair-0")); + + let base = identity("loop-0-repair-0"); + write(project.path(), &base, 0, &response("base", Vec::new())); + let repair_gap = identity("loop-0-repair-2"); + let error = write_at( + project.path(), + &repair_gap, + &repair_gap.base_request_slot, + 0, + &provider_request_id("repair-gap"), + &response("repair gap", Vec::new()), + ) + .expect_err("repair gap must fail"); + assert!(error.contains("连续追加")); + + let mut drifted_repair = identity("loop-0-repair-1"); + drifted_repair.goal_revision += 1; + let error = write_at( + project.path(), + &drifted_repair, + &drifted_repair.base_request_slot, + 0, + &provider_request_id("repair-identity-drift"), + &response("repair identity drift", Vec::new()), + ) + .expect_err("same loop repair identity drift must fail"); + assert!(error.contains("repair 链身份冲突")); + + let next_loop_repair = identity("loop-1-repair-1"); + let error = write_at( + project.path(), + &next_loop_repair, + &next_loop_repair.base_request_slot, + 0, + &provider_request_id("next-loop-repair"), + &response("next loop repair", Vec::new()), + ) + .expect_err("new loop repair must start at zero"); + assert!(error.contains("repair-0")); + + let future = identity("loop-2-repair-0"); + write( + project.path(), + &future, + 0, + &response("future loop", Vec::new()), + ); + let missing_middle = identity("loop-1-repair-0"); + let error = lookup_at( + project.path(), + &missing_middle.agent_id, + &missing_middle.run_id, + &missing_middle, + ) + .expect_err("future entry must prevent replaying an older missing request"); + assert!(error.contains("未来 entry")); + + let ledger = read_for_run_at(project.path(), &base.agent_id, &base.run_id) + .expect("read ordered handoff") + .expect("ordered handoff ledger"); + assert_eq!(ledger.entries.len(), 2); + } + + #[test] + fn tool_plan_handoff_rejects_dangerous_content_and_invalid_calls_without_writing() { + let cases = [ + response("load .env.local", Vec::new()), + response( + "safe text", + vec![call("call-path", "file.write", r#"{"path":"/etc/passwd"}"#)], + ), + response( + "safe text", + vec![call( + "call-escaped-path", + "file.write", + r#"{"path":"\u002fetc\u002fpasswd"}"#, + )], + ), + response( + "safe text", + vec![call("call-secret", "file.write", r#"{"api_key":"secret"}"#)], + ), + response("safe text", vec![call("", "file.write", "{}")]), + response( + "safe text", + vec![call("call-control", "file.\nwrite", "{}")], + ), + ]; + + for (index, response) in cases.into_iter().enumerate() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("dangerous-{index}")), + &response, + ) + .expect_err("dangerous tool-plan handoff must fail"); + assert!( + error.contains("敏感") || error.contains("绝对路径") || error.contains("无效"), + "unexpected error: {error}" + ); + assert!( + !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,) + .exists() + ); + } + + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let project_path_argument = format!(r#"{{"path":"{}"}}"#, project.path().display()); + write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("project-path"), + &response( + "safe text", + vec![call( + "call-project-path", + "file.write", + &project_path_argument, + )], + ), + ) + .expect_err("project path must fail"); + assert!( + !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,).exists() + ); + } + + #[test] + fn tool_plan_handoff_rejects_sensitive_keys_in_valid_and_malformed_json() { + let sensitive_keys = [ + "x-api-key", + "apiKey", + "token", + "access_token", + "refresh_token", + "password", + "client_secret", + "private_key", + "credential", + "openai_api_key", + "github_token", + "db_password", + "webhook_secret", + "secrets", + "clientSecrets", + "secretKey", + "apiKeys", + "privateKeys", + "credentials", + ]; + for (index, key) in sensitive_keys.into_iter().enumerate() { + for (shape, arguments) in [ + ("valid", format!(r#"{{"{key}":"placeholder"}}"#)), + ("malformed", format!(r#"{{"{key}":"placeholder""#)), + ( + "commented", + format!(r#"{{"{key}"/*untrusted*/:"placeholder"}}"#), + ), + ] { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("sensitive-key-{index}-{shape}")), + &response( + "safe text", + vec![call("call-sensitive-key", "project.verify", &arguments)], + ), + ) + .expect_err("sensitive JSON keys must fail closed"); + assert!(error.contains("敏感 JSON key"), "unexpected error: {error}"); + assert!(!tool_plan_handoff_path( + project.path(), + &identity.agent_id, + &identity.run_id, + ) + .exists()); + } + } + + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let unicode_key = r#"{"to\u006ben":"placeholder""#; + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("unicode-sensitive-key"), + &response( + "safe text", + vec![call("call-unicode-key", "project.verify", unicode_key)], + ), + ) + .expect_err("escaped sensitive JSON key must fail closed"); + assert!(error.contains("敏感 JSON key")); + } + + #[test] + fn tool_plan_handoff_rejects_nested_and_malformed_absolute_path_inputs() { + for (index, arguments) in [ + r#"{"content":{"path":"/home/user/private"}}"#, + r#"{"path":"/home/user/private""#, + r#"{"path":"/home/user/private"#, + r#"{paths:["/home/user/private"]"#, + r#"{"path":"C:\Users\alice"}"#, + r#"{"path":"C:\Users\alice"#, + r#"{"bad\uZZZZ":"x","path":"/etc/passwd"}"#, + r#"{"path"/*untrusted*/:"/etc/passwd"}"#, + r#"{"path":/*untrusted*/"C:\Users\alice"}"#, + ] + .into_iter() + .enumerate() + { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("absolute-path-shape-{index}")), + &response( + "safe text", + vec![call("call-absolute-path", "file.write", arguments)], + ), + ) + .expect_err("nested and malformed absolute paths must fail closed"); + assert!(error.contains("绝对路径"), "unexpected error: {error}"); + } + + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id("malformed-text-absolute-path"), + &response(r#"{"path":"/etc/passwd"#, Vec::new()), + ) + .expect_err("malformed text JSON absolute path must fail closed"); + assert!(error.contains("绝对路径"), "unexpected error: {error}"); + } + + #[test] + fn tool_plan_handoff_allows_ordinary_source_in_content_html_and_patch_fields() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let arguments = serde_json::json!({ + "path": "src/security-form.ts", + "content": "const token = props.token; const apiKey = options.apiKey; const samplePath = '/home/example';", + "html": "", + "patch": "const defaults = { client_secret: label, private_key: fieldName };" + }) + .to_string(); + let expected = response( + "safe source plan", + vec![call("call-source-fields", "file.write", &arguments)], + ); + + let entry = write(project.path(), &identity, 0, &expected); + assert_eq!(entry.to_llm_response(), expected); + } + + #[test] + fn tool_plan_handoff_rejects_secrets_and_absolute_paths_in_short_metadata() { + enum MetadataField { + Model, + FinishReason, + ResponseId, + ToolCallId, + ToolCallName, + } + + for (index, (field, value)) in [ + (MetadataField::Model, "sk-0123456789abcdef"), + (MetadataField::Model, "/tmp/private-model"), + (MetadataField::FinishReason, "sk-0123456789abcdef"), + (MetadataField::FinishReason, "/tmp/private-finish"), + (MetadataField::ResponseId, "sk-0123456789abcdef"), + (MetadataField::ResponseId, "/tmp/private-response"), + (MetadataField::ToolCallId, "sk-0123456789abcdef"), + (MetadataField::ToolCallId, "/tmp/private-call-id"), + (MetadataField::ToolCallName, "credential"), + (MetadataField::ToolCallName, "/tmp/private-call-name"), + ] + .into_iter() + .enumerate() + { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let mut candidate = + response("safe text", vec![call("call-safe", "project.verify", "{}")]); + match field { + MetadataField::Model => candidate.model = value.to_string(), + MetadataField::FinishReason => { + candidate.finish_reason = Some(value.to_string()); + } + MetadataField::ResponseId => candidate.response_id = Some(value.to_string()), + MetadataField::ToolCallId => candidate.tool_calls[0].id = value.to_string(), + MetadataField::ToolCallName => candidate.tool_calls[0].name = value.to_string(), + } + let error = write_at( + project.path(), + &identity, + &identity.base_request_slot, + 0, + &provider_request_id(&format!("unsafe-short-metadata-{index}")), + &candidate, + ) + .expect_err("unsafe short metadata must fail closed"); + assert!( + error.contains("敏感") || error.contains("绝对路径"), + "unexpected error: {error}" + ); + assert!( + !tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id,) + .exists() + ); + } + } + + #[test] + fn tool_plan_handoff_allows_source_content_without_treating_html_tags_as_paths() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let plan = serde_json::json!({ + "thinkingSummary": "写入可玩页面", + "planUpdate": null, + "plan": ["写入 game/index.html"], + "actions": [{ + "tool": "file.write", + "reason": "写入 HTML", + "input": { + "path": "game/index.html", + "content": "" + } + }], + "response": "" + }) + .to_string(); + let entry = write(project.path(), &identity, 0, &response(&plan, Vec::new())); + assert_eq!(entry.to_llm_response().text, plan); + } + + #[test] + fn tool_plan_handoff_rejects_call_argument_entry_and_total_limits() { + let project = tempdir().expect("tool-plan handoff project"); + let base_identity = identity("loop-0-repair-0"); + let too_many_calls = (0..=TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS) + .map(|index| call(&format!("call-{index}"), "project.verify", "{}")) + .collect(); + assert!(write_at( + project.path(), + &base_identity, + &base_identity.base_request_slot, + 0, + &provider_request_id("too-many-calls"), + &response("calls", too_many_calls), + ) + .expect_err("tool call count must be bounded") + .contains("tool calls")); + + let too_large_argument = "x".repeat(TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES + 1); + assert!(write_at( + project.path(), + &base_identity, + &base_identity.base_request_slot, + 0, + &provider_request_id("too-large-argument"), + &response( + "argument", + vec![call("call-large", "project.verify", &too_large_argument)], + ), + ) + .expect_err("tool arguments must be bounded") + .contains("arguments")); + + let total_too_large = (0..17) + .map(|index| { + call( + &format!("call-total-{index}"), + "project.verify", + &"x".repeat(TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES), + ) + }) + .collect(); + assert!(write_at( + project.path(), + &base_identity, + &base_identity.base_request_slot, + 0, + &provider_request_id("total-too-large"), + &response("total", total_too_large), + ) + .expect_err("tool-plan ledger bytes must be bounded") + .contains("字节上限")); + + let entries = (0..TOOL_PLAN_HANDOFF_MAX_ENTRIES) + .map(|index| { + let identity = identity(&format!("loop-{index}-repair-0")); + let response = response(&format!("entry-{index}"), Vec::new()); + let persisted = response_for_persistence(project.path(), &response) + .expect("build persisted response"); + AgentRuntimeToolPlanHandoffEntry { + identity, + provider_request_id: provider_request_id(&format!("entry-{index}")), + request_slot: format!("loop-{index}-repair-0"), + attempt: 0, + loop_iteration: index as u64, + repair_attempt: 0, + response_fingerprint: response_fingerprint(&persisted) + .expect("fingerprint response"), + response: persisted, + created_at_ms: index as u64 + 1, + } + }) + .collect::>(); + let ledger = AgentRuntimeToolPlanHandoffLedger { + schema_version: TOOL_PLAN_HANDOFF_SCHEMA_VERSION.to_string(), + agent_id: base_identity.agent_id.clone(), + run_id: base_identity.run_id.clone(), + entries, + }; + validate_ledger(project.path(), &ledger).expect("validate full handoff ledger"); + write_agent_runtime_json_sidecar_with_max_bytes( + project.path(), + &tool_plan_handoff_relative_path(&base_identity.agent_id, &base_identity.run_id), + TOOL_PLAN_HANDOFF_LABEL, + &ledger, + TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, + ) + .expect("write full handoff ledger"); + let next = identity(&format!("loop-{TOOL_PLAN_HANDOFF_MAX_ENTRIES}-repair-0")); + assert!(ensure_capacity_for_request_at( + project.path(), + &next.agent_id, + &next.run_id, + &next, + ) + .expect_err("entry capacity must fail before a Provider request") + .contains("请求前账本容量")); + assert!(write_at( + project.path(), + &next, + &next.base_request_slot, + 0, + &provider_request_id("entry-overflow"), + &response("overflow", Vec::new()), + ) + .expect_err("entry count must be bounded") + .contains("条上限")); + } + + #[test] + fn tool_plan_handoff_rejects_insufficient_byte_reserve_before_provider_request() { + let project = tempdir().expect("tool-plan handoff project"); + let base_identity = identity("loop-0-repair-0"); + let mut entries = Vec::new(); + while entries.len() < TOOL_PLAN_HANDOFF_MAX_ENTRIES { + let index = entries.len(); + let entry_identity = identity(&format!("loop-{index}-repair-0")); + let persisted = response_for_persistence( + project.path(), + &response(&"x".repeat(220_000), Vec::new()), + ) + .expect("build byte reserve response"); + entries.push(AgentRuntimeToolPlanHandoffEntry { + identity: entry_identity, + provider_request_id: provider_request_id(&format!("byte-reserve-{index}")), + request_slot: format!("loop-{index}-repair-0"), + attempt: 0, + loop_iteration: index as u64, + repair_attempt: 0, + response_fingerprint: response_fingerprint(&persisted) + .expect("fingerprint byte reserve response"), + response: persisted, + created_at_ms: index as u64 + 1, + }); + let candidate = AgentRuntimeToolPlanHandoffLedger { + schema_version: TOOL_PLAN_HANDOFF_SCHEMA_VERSION.to_string(), + agent_id: base_identity.agent_id.clone(), + run_id: base_identity.run_id.clone(), + entries: entries.clone(), + }; + let bytes = serde_json::to_vec_pretty(&candidate) + .expect("serialize byte reserve candidate") + .len() + + 1; + if bytes.saturating_add(TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES) + > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + && bytes <= TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES + { + validate_ledger(project.path(), &candidate).expect("validate byte reserve ledger"); + write_agent_runtime_json_sidecar_with_max_bytes( + project.path(), + &tool_plan_handoff_relative_path( + &base_identity.agent_id, + &base_identity.run_id, + ), + TOOL_PLAN_HANDOFF_LABEL, + &candidate, + TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, + ) + .expect("write byte reserve ledger"); + let next = identity(&format!("loop-{}-repair-0", entries.len())); + let error = ensure_capacity_for_request_at( + project.path(), + &next.agent_id, + &next.run_id, + &next, + ) + .expect_err("byte reserve must fail before a Provider request"); + assert!(error.contains("请求前账本剩余空间")); + return; + } + if bytes > TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES { + break; + } + } + panic!("test fixture did not reach the request reserve boundary"); + } + + #[test] + fn tool_plan_handoff_strict_read_rejects_unknown_and_corrupt_fields() { + fn mutate_and_read_error(mutate: impl FnOnce(&mut serde_json::Value)) -> String { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write( + project.path(), + &identity, + 0, + &response("strict", Vec::new()), + ); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let mut value: serde_json::Value = + serde_json::from_slice(&fs::read(&path).expect("read tool-plan handoff bytes")) + .expect("parse tool-plan handoff JSON"); + mutate(&mut value); + fs::write( + &path, + serde_json::to_vec_pretty(&value).expect("serialize mutated handoff"), + ) + .expect("write mutated handoff"); + read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect_err("mutated handoff must fail") + } + + let error = mutate_and_read_error(|value| { + value + .as_object_mut() + .expect("ledger object") + .insert("unknownField".to_string(), serde_json::json!(true)); + }); + assert!(error.contains("unknown field")); + + let error = mutate_and_read_error(|value| { + value["entries"][0]["responseFingerprint"] = serde_json::json!("0".repeat(64)); + }); + assert!(error.contains("responseFingerprint")); + + let error = mutate_and_read_error(|value| { + value["entries"][0]["requestSlot"] = serde_json::json!("loop-0-repair-0-transient-1"); + }); + assert!(error.contains("requestSlot/attempt")); + + let error = mutate_and_read_error(|value| { + value["entries"][0]["providerRequestId"] = serde_json::json!("invalid-request-id"); + }); + assert!(error.contains("providerRequestId")); + + let error = mutate_and_read_error(|value| { + value["agentId"] = serde_json::json!("other-agent"); + }); + assert!(error.contains("Agent/run")); + } + + #[test] + fn tool_plan_handoff_previous_recovers_and_remove_deletes_both_copies() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + let entry = write( + project.path(), + &identity, + 0, + &response("recover", Vec::new()), + ); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + fs::rename(&path, &backup_path).expect("move handoff to previous"); + assert_eq!( + read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("recover previous handoff") + .expect("recovered handoff") + .entries, + vec![entry] + ); + + fs::copy(&backup_path, &path).expect("restore primary while retaining previous"); + remove_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("remove both handoff copies"); + assert!(!path.exists()); + assert!(!backup_path.exists()); + remove_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("repeat handoff removal"); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_read_uses_open_agent_directory_after_path_replacement() { + use std::os::unix::fs::symlink; + + let project = tempdir().expect("tool-plan handoff project"); + let external = tempdir().expect("external handoff directory"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let handoff_root = agent_directory + .parent() + .expect("handoff root directory") + .to_path_buf(); + let displaced = handoff_root.join("displaced-read-agent-directory"); + let external_primary = external + .path() + .join(path.file_name().expect("primary file name")); + fs::write(&external_primary, b"external primary must remain") + .expect("write external primary"); + fs::set_permissions(&external_primary, fs::Permissions::from_mode(0o600)) + .expect("set external primary permissions"); + + let error = read_for_run_at_unix_with_agent_open_hook( + project.path(), + &identity.agent_id, + &identity.run_id, + |_| { + fs::rename(&agent_directory, &displaced).expect("displace opened read directory"); + symlink(external.path(), &agent_directory) + .expect("replace read directory with symlink"); + }, + ) + .expect_err("replaced read directory must fail closed after fixed-handle read"); + assert!(error.contains("发生替换"), "unexpected error: {error}"); + assert_eq!( + fs::read(&external_primary).expect("read external primary"), + b"external primary must remain" + ); + + fs::remove_file(&agent_directory).expect("remove replacement symlink"); + fs::rename(&displaced, &agent_directory).expect("restore read directory"); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_write_uses_open_agent_directory_after_path_replacement() { + use std::os::unix::fs::symlink; + + let project = tempdir().expect("tool-plan handoff project"); + let external = tempdir().expect("external handoff directory"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let ledger = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("read handoff ledger") + .expect("handoff ledger"); + let bytes = serialize_ledger_for_storage(&ledger).expect("serialize handoff ledger"); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let handoff_root = agent_directory + .parent() + .expect("handoff root directory") + .to_path_buf(); + let displaced = handoff_root.join("displaced-write-agent-directory"); + let external_primary = external + .path() + .join(path.file_name().expect("primary file name")); + fs::write(&external_primary, b"external primary must remain") + .expect("write external primary"); + fs::set_permissions(&external_primary, fs::Permissions::from_mode(0o600)) + .expect("set external primary permissions"); + + let error = + write_ledger_at_unix_with_agent_open_hook(project.path(), &ledger, &bytes, |_| { + fs::rename(&agent_directory, &displaced).expect("displace opened write directory"); + symlink(external.path(), &agent_directory) + .expect("replace write directory with symlink"); + }) + .expect_err("replaced write directory must fail closed after fixed-handle write"); + assert!(error.contains("发生替换"), "unexpected error: {error}"); + assert_eq!( + fs::read(&external_primary).expect("read external primary"), + b"external primary must remain" + ); + + fs::remove_file(&agent_directory).expect("remove replacement symlink"); + fs::rename(&displaced, &agent_directory).expect("restore write directory"); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_write_rolls_back_when_temp_name_is_rebound_before_install() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let original_primary = fs::read(&path).expect("read original primary"); + let ledger = read_for_run_at(project.path(), &identity.agent_id, &identity.run_id) + .expect("read handoff ledger") + .expect("handoff ledger"); + let bytes = serialize_ledger_for_storage(&ledger).expect("serialize handoff ledger"); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let displaced_temp = agent_directory.join("displaced-writer-temp"); + + let error = write_ledger_at_unix_with_hooks( + project.path(), + &ledger, + &bytes, + |_| {}, + |temporary_name| { + let temporary_path = agent_directory.join(temporary_name); + fs::rename(&temporary_path, &displaced_temp).expect("displace locked writer temp"); + fs::write(&temporary_path, b"replacement temp must not become primary") + .expect("write replacement temp"); + fs::set_permissions(&temporary_path, fs::Permissions::from_mode(0o600)) + .expect("set replacement temp permissions"); + }, + ) + .expect_err("rebound temp name must fail and restore the original primary"); + assert!(error.contains("身份冲突"), "unexpected error: {error}"); + assert_eq!( + fs::read(&path).expect("read restored primary"), + original_primary + ); + assert!(displaced_temp.exists()); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_remove_rejects_file_name_rebinding_before_unlink() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .expect("primary file name") + .to_string(); + let displaced = path.with_file_name("displaced-delete-ledger"); + let storage = open_unix_tool_plan_agent_storage(project.path(), &identity.agent_id, false) + .expect("open handoff storage") + .expect("handoff storage"); + lock_unix_tool_plan_directory( + &storage.agent_directory, + "tool-plan 成功响应交接 Agent 目录", + ) + .expect("lock handoff agent directory"); + + let error = remove_unix_tool_plan_file_at_with_hook( + &storage.agent_directory, + &file_name, + "tool-plan 成功响应交接账本", + |_| { + fs::rename(&path, &displaced).expect("displace opened primary"); + fs::write(&path, b"replacement must remain").expect("write replacement primary"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .expect("set replacement permissions"); + }, + ) + .expect_err("rebound delete name must fail before unlink"); + assert!(error.contains("名称换绑"), "unexpected error: {error}"); + assert_eq!( + fs::read(&path).expect("read replacement primary"), + b"replacement must remain" + ); + assert!(displaced.exists()); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_remove_uses_open_agent_directory_after_path_replacement() { + use std::os::unix::fs::symlink; + + let project = tempdir().expect("tool-plan handoff project"); + let external = tempdir().expect("external handoff directory"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + fs::copy(&path, &backup_path).expect("copy handoff previous"); + fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o600)) + .expect("set previous permissions"); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let handoff_root = agent_directory + .parent() + .expect("handoff root directory") + .to_path_buf(); + let displaced = handoff_root.join("displaced-remove-agent-directory"); + let external_primary = external + .path() + .join(path.file_name().expect("primary file name")); + let external_previous = external + .path() + .join(backup_path.file_name().expect("previous file name")); + fs::write(&external_primary, b"external primary must remain") + .expect("write external primary"); + fs::write(&external_previous, b"external previous must remain") + .expect("write external previous"); + fs::set_permissions(&external_primary, fs::Permissions::from_mode(0o600)) + .expect("set external primary permissions"); + fs::set_permissions(&external_previous, fs::Permissions::from_mode(0o600)) + .expect("set external previous permissions"); + + let error = remove_at_unix_with_agent_open_hook( + project.path(), + &identity.agent_id, + &identity.run_id, + |_| { + fs::rename(&agent_directory, &displaced).expect("displace opened remove directory"); + symlink(external.path(), &agent_directory) + .expect("replace remove directory with symlink"); + }, + ) + .expect_err("replaced remove directory must fail closed after fixed-handle deletion"); + assert!(error.contains("发生替换"), "unexpected error: {error}"); + assert!(external_primary.exists()); + assert!(external_previous.exists()); + assert!(!displaced + .join(path.file_name().expect("primary name")) + .exists()); + assert!(!displaced + .join(backup_path.file_name().expect("previous name")) + .exists()); + + fs::remove_file(&agent_directory).expect("remove replacement symlink"); + fs::rename(&displaced, &agent_directory).expect("restore remove directory"); + } + + #[test] + fn tool_plan_handoff_list_discovers_sorted_ledgers_validates_previous_and_cleans_safe_temp() { + let project = tempdir().expect("tool-plan handoff project"); + let identities = [ + identity_for("loop-0-repair-0", "quality-review", "run-2"), + identity_for("loop-0-repair-0", "design-director", "run-2"), + identity_for("loop-0-repair-0", "design-director", "run-1"), + ]; + for identity in &identities { + write(project.path(), identity, 0, &response("base", Vec::new())); + } + + let base = &identities[1]; + let path = tool_plan_handoff_path(project.path(), &base.agent_id, &base.run_id); + let base_bytes = fs::read(&path).expect("read base handoff bytes"); + let repair = identity_for("loop-0-repair-1", &base.agent_id, &base.run_id); + write( + project.path(), + &repair, + 0, + &response("repair", vec![call("call-repair", "project.verify", "{}")]), + ); + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + fs::write(&backup_path, base_bytes).expect("restore valid previous prefix"); + #[cfg(unix)] + fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o600)) + .expect("set previous permissions"); + + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.5678", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + i32::MAX, + )); + fs::write(&temp_path, b"partial atomic write").expect("write stale temp"); + #[cfg(unix)] + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set stale temp permissions"); + + let listed = list_at(project.path()).expect("list tool-plan handoffs"); + assert!(!temp_path.exists()); + assert_eq!( + listed + .iter() + .map(|ledger| (ledger.agent_id(), ledger.run_id(), ledger.entries.len())) + .collect::>(), + vec![ + ("design-director", "run-1", 1), + ("design-director", "run-2", 2), + ("quality-review", "run-2", 1), + ] + ); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_list_preserves_active_atomic_temp_file() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + std::process::id(), + provider_retry::now_ms(), + )); + let mut temp_file = std::fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .open(&temp_path) + .expect("create active temp"); + temp_file + .write_all(b"active atomic write") + .expect("write active temp"); + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set active temp permissions"); + // SAFETY: the test owns temp_file and intentionally models the writer lock. + assert_eq!( + unsafe { libc::flock(temp_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + 0 + ); + + let error = list_at(project.path()).expect_err("active temp must keep recovery busy"); + assert!(error.contains("活跃写入句柄"), "unexpected error: {error}"); + assert!(temp_path.exists()); + + drop(temp_file); + list_at(project.path()).expect("clean unlocked temp after writer closes"); + assert!(!temp_path.exists()); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_list_cleans_unlocked_atomic_temp_with_reused_pid() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + std::process::id(), + provider_retry::now_ms(), + )); + fs::write(&temp_path, b"stale temp from reused pid").expect("write stale temp"); + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set stale temp permissions"); + + list_at(project.path()).expect("same pid without writer lock is stale"); + assert!(!temp_path.exists()); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_temp_cleanup_rejects_name_rebinding_before_unlink() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + std::process::id(), + provider_retry::now_ms(), + )); + fs::write(&temp_path, b"stale temp").expect("write stale temp"); + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set stale temp permissions"); + let file_name = temp_path + .file_name() + .and_then(|value| value.to_str()) + .expect("temp file name") + .to_string(); + let displaced = temp_path.with_file_name("displaced-stale-temp"); + let storage = open_unix_tool_plan_agent_storage(project.path(), &identity.agent_id, false) + .expect("open handoff storage") + .expect("handoff storage"); + lock_unix_tool_plan_directory( + &storage.agent_directory, + "tool-plan 成功响应交接 Agent 目录", + ) + .expect("lock handoff agent directory"); + + let error = remove_stale_unix_handoff_temp_file_at_with_hook( + &storage.agent_directory, + &file_name, + |_| { + fs::rename(&temp_path, &displaced).expect("displace locked stale temp"); + fs::write(&temp_path, b"replacement must remain").expect("write replacement temp"); + fs::set_permissions(&temp_path, fs::Permissions::from_mode(0o600)) + .expect("set replacement permissions"); + }, + ) + .expect_err("rebound stale temp name must fail before unlink"); + assert!(error.contains("名称换绑"), "unexpected error: {error}"); + assert_eq!( + fs::read(&temp_path).expect("read replacement temp"), + b"replacement must remain" + ); + assert!(displaced.exists()); + } + + #[cfg(windows)] + #[test] + fn tool_plan_handoff_list_preserves_exclusively_open_windows_temp_file() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let storage = + open_windows_tool_plan_agent_storage(project.path(), &identity.agent_id, false) + .expect("open Windows handoff storage") + .expect("Windows handoff storage"); + let run_key = path_key(&identity.run_id); + let (temp_name, mut temp_file) = + create_windows_tool_plan_temp_file_at(&storage.agent_directory, &run_key) + .expect("create exclusive Windows temp"); + temp_file + .write_all(b"active atomic write") + .expect("write active Windows temp"); + temp_file.sync_data().expect("sync active Windows temp"); + let temp_path = + tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id) + .with_file_name(&temp_name); + + let error = list_at(project.path()).expect_err("exclusive temp must keep recovery busy"); + assert!( + temp_path.exists(), + "active Windows temp must remain: {error}" + ); + + drop(temp_file); + list_at(project.path()).expect("clean Windows temp after writer closes"); + assert!(!temp_path.exists()); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_list_detects_agent_directory_replacement_without_touching_external_files() + { + use std::os::unix::fs::symlink; + + let project = tempdir().expect("tool-plan handoff project"); + let external = tempdir().expect("external handoff directory"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let agent_directory = path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let handoff_root = agent_directory + .parent() + .expect("handoff root directory") + .to_path_buf(); + let displaced = handoff_root.join("displaced-agent-directory"); + let external_marker = external.path().join(format!( + ".{}.tmp.{}.1", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name"), + i32::MAX, + )); + fs::write(&external_marker, b"must remain").expect("write external marker"); + fs::set_permissions(&external_marker, fs::Permissions::from_mode(0o600)) + .expect("set external marker permissions"); + + let error = list_at_unix_with_agent_open_hook(project.path(), |agent_key| { + if agent_key == path_key(&identity.agent_id) { + fs::rename(&agent_directory, &displaced).expect("displace opened agent directory"); + symlink(external.path(), &agent_directory) + .expect("replace agent directory with symlink"); + } + }) + .expect_err("replaced agent directory must fail closed"); + assert!(error.contains("发生替换"), "unexpected error: {error}"); + assert!(external_marker.exists()); + + fs::remove_file(&agent_directory).expect("remove replacement symlink"); + fs::rename(&displaced, &agent_directory).expect("restore agent directory"); + } + + #[test] + fn tool_plan_handoff_list_rejects_unknown_hash_paths_and_divergent_previous() { + let unknown_project = tempdir().expect("tool-plan handoff project"); + let unknown_identity = identity("loop-0-repair-0"); + write( + unknown_project.path(), + &unknown_identity, + 0, + &response("unknown", Vec::new()), + ); + let unknown_agent_directory = tool_plan_handoff_path( + unknown_project.path(), + &unknown_identity.agent_id, + &unknown_identity.run_id, + ) + .parent() + .expect("handoff agent directory") + .to_path_buf(); + fs::write(unknown_agent_directory.join("unexpected.txt"), b"unknown") + .expect("write unknown handoff entry"); + let error = list_at(unknown_project.path()).expect_err("unknown entry must fail closed"); + assert!(error.contains("未知文件"), "unexpected error: {error}"); + + let hash_project = tempdir().expect("tool-plan handoff project"); + let hash_identity = identity("loop-0-repair-0"); + let hash_path = tool_plan_handoff_path( + hash_project.path(), + &hash_identity.agent_id, + &hash_identity.run_id, + ); + write( + hash_project.path(), + &hash_identity, + 0, + &response("hash", Vec::new()), + ); + let hash_agent_directory = hash_path + .parent() + .expect("handoff agent directory") + .to_path_buf(); + let wrong_agent_directory = hash_agent_directory + .parent() + .expect("handoff root directory") + .join("0".repeat(64)); + fs::rename(&hash_agent_directory, &wrong_agent_directory) + .expect("move handoff under wrong hash"); + let error = list_at(hash_project.path()).expect_err("wrong hash path must fail closed"); + assert!(error.contains("hash 路径"), "unexpected error: {error}"); + + let primary_project = tempdir().expect("tool-plan handoff project"); + let conflicting_project = tempdir().expect("tool-plan handoff project"); + let conflict_identity = identity("loop-0-repair-0"); + write( + primary_project.path(), + &conflict_identity, + 0, + &response("primary", Vec::new()), + ); + write( + conflicting_project.path(), + &conflict_identity, + 0, + &response("divergent previous", Vec::new()), + ); + let primary_path = tool_plan_handoff_path( + primary_project.path(), + &conflict_identity.agent_id, + &conflict_identity.run_id, + ); + let conflicting_path = tool_plan_handoff_path( + conflicting_project.path(), + &conflict_identity.agent_id, + &conflict_identity.run_id, + ); + let previous_path = agent_runtime_json_sidecar_backup_path(&primary_path); + fs::copy(conflicting_path, &previous_path).expect("install divergent previous"); + #[cfg(unix)] + fs::set_permissions(&previous_path, fs::Permissions::from_mode(0o600)) + .expect("set divergent previous permissions"); + let error = list_at(primary_project.path()) + .expect_err("divergent primary and previous must fail closed"); + assert!(error.contains("primary/.previous 内容冲突")); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_list_only_cleans_0600_single_link_atomic_temp_files() { + let project = tempdir().expect("tool-plan handoff project"); + let identity = identity("loop-0-repair-0"); + write(project.path(), &identity, 0, &response("base", Vec::new())); + let path = tool_plan_handoff_path(project.path(), &identity.agent_id, &identity.run_id); + let bad_mode_temp = path.with_file_name(format!( + ".{}.tmp.42.100", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name") + )); + fs::write(&bad_mode_temp, b"unsafe mode temp").expect("write bad mode temp"); + fs::set_permissions(&bad_mode_temp, fs::Permissions::from_mode(0o644)) + .expect("set bad temp permissions"); + let error = list_at(project.path()).expect_err("bad temp mode must fail closed"); + assert!(error.contains("0600")); + assert!(bad_mode_temp.exists()); + + fs::set_permissions(&bad_mode_temp, fs::Permissions::from_mode(0o600)) + .expect("repair temp permissions"); + let linked_temp = path.with_file_name(format!( + ".{}.tmp.42.101", + path.file_name() + .and_then(|value| value.to_str()) + .expect("handoff file name") + )); + fs::hard_link(&bad_mode_temp, &linked_temp).expect("hard link temp file"); + let error = list_at(project.path()).expect_err("linked temp must fail closed"); + assert!(error.contains("单链接")); + assert!(bad_mode_temp.exists()); + assert!(linked_temp.exists()); + } + + #[cfg(unix)] + #[test] + fn tool_plan_handoff_list_rejects_symlinked_root_agent_and_run_entries() { + use std::os::unix::fs::symlink; + + let root_project = tempdir().expect("tool-plan handoff project"); + let external_directory = tempdir().expect("external handoff directory"); + let handoff_root = root_project + .path() + .join(TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY); + fs::create_dir_all( + handoff_root + .parent() + .expect("tool-plan handoff runtime directory"), + ) + .expect("create runtime directory"); + symlink(external_directory.path(), &handoff_root).expect("symlink handoff root"); + let error = list_at(root_project.path()).expect_err("symlinked root must fail closed"); + assert!(error.contains("根目录"), "unexpected error: {error}"); + + let agent_project = tempdir().expect("tool-plan handoff project"); + let handoff_root = agent_project + .path() + .join(TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY); + fs::create_dir_all(&handoff_root).expect("create handoff root"); + symlink(external_directory.path(), handoff_root.join("a".repeat(64))) + .expect("symlink handoff agent"); + let error = list_at(agent_project.path()).expect_err("symlinked agent must fail closed"); + assert!(error.contains("Agent 目录"), "unexpected error: {error}"); + + let run_project = tempdir().expect("tool-plan handoff project"); + let run_identity = identity("loop-0-repair-0"); + write( + run_project.path(), + &run_identity, + 0, + &response("run symlink", Vec::new()), + ); + let run_path = tool_plan_handoff_path( + run_project.path(), + &run_identity.agent_id, + &run_identity.run_id, + ); + fs::remove_file(&run_path).expect("remove primary before symlink"); + let external_file = external_directory.path().join("external.json"); + fs::write(&external_file, b"{}").expect("write external file"); + symlink(&external_file, &run_path).expect("symlink handoff run"); + let error = list_at(run_project.path()).expect_err("symlinked run must fail closed"); + assert!(error.contains("账本文件"), "unexpected error: {error}"); + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0e3a87c23..9e4dbc0d6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4933,3 +4933,12 @@ - 确定性证据:fault proxy `14/14`、E2E self-test **PASS**、前端 `308/308`,以及 shell typecheck、`platform-llm 41/41`、`platform-agent 17/17`、`shared-contracts 7/7` 均已完成。 - 六轮记录:真实外部 Provider suite 共执行六轮,前五轮均为 **FAIL** 且不得拼接。第一、二轮沿用既有失败记录;第三轮已走通故障、重试和唯一回复,但过早观察到 `1` 个 finalization journal;第四轮在 quality-review 普通 tool-plan 连续 transport/connectivity 失败并耗尽重试,未进入目标故障;第五轮命中上述项目写锁竞争与绝对锁路径泄漏问题。第六轮在同一轮内完整 **PASS**。 - 第六轮证据:正式路由为 `gpt-5.5 / openai_chat`,`2` 条初始加 `1` 条 repair delivery、`3` 条专业 Agent assistant,目标为 Project Supervisor base final-reply,可信宿主 verify marker 门禁通过;受控 Provider `failed=1 / retry=1`,incidental `failure=0 / retry=0`,`30s` backoff,pidfd `claim=2 / signal=2`,Runner `resumed=true / identityStable=true`。父 tool-plan 故障前后均为 `13`,parent final-reply 与最终 assistant 唯一,response stream `sequence=2 / committed`;pending、retry、handoff、finalization、confirmation sidecar、全部重复计数及 API Key、私有正文、项目路径、正式配置路径和公共报告泄漏扫描命中均为 `0`。本决策不关闭 V1.41 handoff 原子落盘并回读前的 unknown-result 窗口,也不覆盖 tool-plan 成功响应/function arguments 的 durable handoff。 + +## 2026-07-20 AI 游戏创作 Agent Runtime V1.43 tool-plan 成功响应持久交接与 repair 链恢复 + +- 决策:保留 V1.41 `game-creator-provider-handoff.v1` 的无工具文本不变量,新增 `.agent/runtime/tool-plan-handoffs//.json` 与严格 `game-creator-tool-plan-handoff.v1`。同一 Agent/run 账本按 `(loopIteration, repairAttempt)` 单调记录 `repair-0..N`,每条绑定完整 retry identity、实际物理 requestId、slot/attempt、Provider/model、去 thinking 响应、完整 function call envelope/arguments、usage、指纹和时间。 +- 提交顺序: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。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 0605c0e7a..39a385d2d 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -522,3 +522,12 @@ npm run check:server-rs-ddd - Linux `command.exec / command.start / project.verify` 必须经过受信任系统 bubblewrap;缺失或 namespace / mount preflight 失败时工具失败关闭,不能回退宿主执行。 - 修改命令执行、PTY、项目验证或发布配置后,至少运行 `GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml command_sandbox -- --nocapture --test-threads=1`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml command_exec` 和 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml process_session -- --test-threads=1`。 - 真实门禁必须同时证明项目内 Cargo / npm / Git 成功,项目外普通文件读写失败,`.git / .agent / .agents / .codex / .hermes` 写入失败,网络默认不可达,shell / PTY 后代在 `setsid + chdir` 后仍继承相同边界;process record、poll / stdin / terminate 和 project.verify 审计必须断言 `bubblewrap / workspace-write / disabled / workspace-v1`。另测 `RUSTUP_HOME=$HOME` 与 `.rustup -> $HOME` 必须在目标执行前失败。Linux deb / rpm 包必须声明 `bubblewrap` 依赖;AppImage 发布说明必须要求宿主预装受支持的 bwrap,缺失时只能返回 sandbox unavailable。 + +## AI 游戏创作 App Provider 成功交接验证 + +- 文本回复继续使用 `game-creator-provider-handoff.v1`;tool-plan/function arguments 使用独立 `game-creator-tool-plan-handoff.v1`,禁止为省事放宽文本 handoff。两类 handoff 都必须在 Provider lifecycle `completed` 前原子落盘并回读。 +- tool-plan `repair-0..N` 必须共用持久 retry 与 handoff-first 恢复;测试停止点按 request kind/精确 slot 命中,不能让较早的 tool-plan 抢占 final-reply 断点。恢复测试必须关闭 mock Provider,证明零网络、原 requestId 唯一闭合、audit 幂等和终局零 sidecar。 +- 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。 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 380565121..29111050f 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 @@ -1471,12 +1471,33 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-final-reply-transie - 第六轮在同一轮内完整 **PASS**:正式路由为 `gpt-5.5 / openai_chat`;形成 `2` 条初始 delivery、`1` 条 repair delivery 和 `3` 条专业 Agent assistant;受控目标精确命中 Project Supervisor base final-reply,可信宿主 verify marker 门禁通过。受控 Provider `failed=1 / retry=1`,incidental `failure=0 / retry=0`;`30s` backoff 成立,pidfd `claim=2 / signal=2`,Runner `resumed=true / identityStable=true`;故障前后父 tool-plan 均为 `13`,只产生唯一 parent final-reply 和唯一最终 assistant,response stream 以 `sequence=2` 提交为 `committed`。pending、retry、handoff、finalization、confirmation sidecar 全部为 `0`,全部重复计数为 `0`,API Key、私有正文、项目路径、正式配置路径及公共报告泄漏扫描命中均为 `0`。 - 第六轮 PASS 只关闭“final-reply 瞬态失败进入持久退避后强杀 Runner”的真实证据缺口。V1.41 中“Provider 已成功返回、但 handoff 尚未原子落盘并回读”仍是 unknown-result 边界;tool-plan 成功响应及 function arguments 的 durable handoff 仍未覆盖。 +## V1.43 tool-plan 成功响应持久交接与 repair 链恢复 + +V1.43 不放宽 V1.41 的文本型 `game-creator-provider-handoff.v1`,而是为 `requestKind=tool-plan` 增加独立私有账本 `.agent/runtime/tool-plan-handoffs//.json`,schema 固定为 `game-creator-tool-plan-handoff.v1`。同一 Agent/run 账本按 `(loopIteration, repairAttempt)` 单调保存已成功的 `repair-0..N` Provider 响应,每条绑定完整 retry identity、实际物理 `providerRequestId`、真实 request slot/attempt、Provider/model、去除 thinking 后的响应、thinking 归一化哈希/计数、完整 function call envelope、usage、响应指纹和创建时间。账本使用既有 `0600`、原子替换、父目录同步、`.previous` 恢复和写后完整回读;未知字段、乱序/缺口、重复 slot 冲突、超限、危险可执行路径、密钥或配置痕迹一律失败关闭。 + +### 提交、重放与所有权 + +- 每个 tool-plan 物理请求的顺序固定为:Provider 成功 -> tool-plan handoff 追加并回读 -> 同一实际 requestId lifecycle `completed` -> 解析/格式修复或动作预检。function arguments 只存在于私有 handoff 与后续 pending/action batch。protocol/repair 公共审计共同保存 `agentId/taskId/sessionId/runId/source/loopIteration/repairAttempt/requestSlot/responseFingerprint/providerRequestIdSha256/protocol`;protocol 只额外保存 `functionCallCount/callIdSha256s/functionNames/responseIdSha256/responseIdChars` 和既有 normalization 字段,其中 function names 必须由 catalog 绑定;repair 只额外保存 attempt/maxAttempts、协议错误/preview 哈希与字符数及 `callIdSha256/functionNameSha256`。公共 task、event、Agent DB、CLI 和报告不得保存原始 callId/callIds/responseId/providerRequestId。两类审计都在 Agent DB append 锁内按完整 Agent/task/Session/run/source/slot 身份做全历史 compare-and-append,不能以受限尾部读取替代幂等。 +- `repair-0` 与全部 `repair-N` 统一使用持久 transient retry。Runner 恢复总是从当前 loop 的 `repair-0` 重建请求:账本中已成功的 entry 按序零网络回放并幂等补齐原 requestId lifecycle;若某条响应需要格式修复,则用同一有界 preview/protocol error 重建下一 repair 请求。后继 repair 的 retry sidecar 可以与前序 handoff entry 同时存在,前序回放不得误删后继 retry。 +- 账本保留同一 run 已成功的 tool-plan entry,直到当前 run 完成、取消、失败、作废或明确 reconciliation 清理。这样单动作、并行动作、confirmation、协作 batch 和直接回复都不会在下一 durable owner 建立前丢失 function arguments;后续 loop 可在同一账本中追加,条目数和总字节数都有硬上限。steer/cancel/终态/漂移删除前必须按整本账本补齐所有实际 requestId lifecycle,任一条失败则保留账本并进入 reconciliation。 +- 同 slot 同 identity/response 重写是幂等成功;同 slot 指纹、requestId、attempt 或响应冲突进入 reconciliation。Goal/steer/request/config 漂移命中旧 slot 时,先按账本顺序把旧 base 与全部后继 repair 的实际 requestId 幂等闭合,再删除旧 tool-plan handoff/retry 并在同一 run 重新规划。Runner 启动恢复会严格发现 hash 归属正确的 primary/`.previous`;Unix 扫描和删除固定在已打开目录句柄,只回收原子命名正确、`0600`、单链接且 owner PID 已死亡的临时文件,活跃 owner 临时文件保持 busy;Windows 拒绝 reparse/非普通项并只回收 owner PID 已死亡的原子临时文件。已终态 run 的合法遗留可回收,未知、链接、冲突或无所属任务项失败关闭并保持 Runner busy。跨 durable run、越序 entry、未来 entry 或不匹配 retry 不允许猜测恢复。 +- 有效和无效 Provider 响应都必须先做私有内容安全检查。完整 function arguments 为恢复语义不得被静默脱敏或改写;命中 API Key、Token、password/client secret/private key/credential 等敏感 key、配置痕迹,或结构化可执行路径/命令参数中的项目及其它绝对路径时,handoff 写入失败并进入 reconciliation。`content / patch / plan / step / response` 等正文与叙述字段仍做密钥检查,但不能用面向日志的路径 token 扫描把 HTML ``、源码字符串或计划标题误判为可执行路径。格式不合法但安全且有界的 arguments 可作为不执行的 opaque repair 输入持久化,只有既有严格 parser/schema/catalog 门禁通过后才可转成工具动作。未闭合或孤立 thinking wrapper 不保存 thinking 正文,只保存严格无效元数据;重放合成无正文协议标记并进入同一 repair,不能被清洗成可执行计划。 + +### 确定性与真实门禁 + +- 单元测试覆盖严格 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 不得替代它。 +- 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。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml agent_goal_ -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml goal_context_bundle_v4_migrates_v3_and_v2_then_rejects_plan_mismatch -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml provider_handoff_ -- --nocapture --test-threads=1` +- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml tool_plan_handoff_ -- --nocapture --test-threads=1` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml response_stream_ -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml finalization_v4_binds_response_request_slot_into_identity -- --nocapture` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml finalization_v3_without_response_request_slot_remains_readable -- --nocapture` @@ -1499,6 +1520,7 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-final-reply-transie - `npm run ai-game-creator-shell:typecheck` - `npm run test -- apps/ai-game-creator-shell/tests` - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` +- `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --target x86_64-pc-windows-gnu` - `cargo test -p platform-agent --manifest-path server-rs/Cargo.toml game_creation` - `cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml game_creation_app` - `npm run ai-game-creator-shell:agent-run:smoke` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 1b77ff836..9493ba0aa 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -618,3 +618,7 @@ game-project/ - 第五轮暴露并修复并行 Agent 的 `file.write` 与项目写锁竞争:失败 observation 携带绝对锁路径,导致 pending 持久化拒绝并进入 `needs-reconciliation`。现 `file.write / file.patch / file.delete` 统一使用 Runtime 短等待项目写锁,`file.write` 错误在持久化前脱敏,并新增 `2` 条 Rust 回归测试。 - 第六轮在同一轮内完整 **PASS**:`gpt-5.5 / openai_chat` 路由形成 `2` 条初始加 `1` 条 repair delivery、`3` 条专业 Agent assistant;故障精确命中 Project Supervisor base final-reply,可信宿主 verify marker 门禁通过。受控 Provider `failed=1 / retry=1`、incidental `failure=0 / retry=0`,`30s` backoff,pidfd `claim=2 / signal=2`,Runner `resumed=true / identityStable=true`;父 tool-plan 在故障前后均为 `13`,parent final-reply 与最终 assistant 唯一,response stream `sequence=2 / committed`。pending、retry、handoff、finalization、confirmation sidecar、全部重复计数,以及 API Key、私有正文、项目路径、正式配置路径和公共报告泄漏扫描命中均为 `0`。 - 第六轮 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。