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 483c1fa4d..09595474a 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 @@ -128,6 +128,12 @@ const supervisorCollaborationPolicySchemaVersion = 'game-creator-supervisor-collaboration-policy.v1'; const supervisorCollaborationContractSchemaVersion = 'game-creator-supervisor-collaboration-contract.v1'; +const supervisorCollaborationPolicySnapshotSchemaVersion = + 'game-creator-supervisor-collaboration-policy-snapshot.v1'; +const supervisorCollaborationPolicySnapshotBindingSchemaVersion = + 'game-creator-supervisor-collaboration-policy-snapshot-binding.v1'; +const supervisorCollaborationPolicySnapshotInitialBatchBinding = + 'initial-collaboration-batch'; const staticDelegateDeliverySchemaVersion = 'game-creator-static-delegate-delivery.v1'; const staticDelegateClaimSchemaVersion = @@ -775,6 +781,13 @@ const state = { autonomousTaskRecipeFree: false, autonomousRepositoryRecipeFree: false, interactiveCliUsed: false, + chatSessionUnexpectedlyClosed: false, + chatSessionFailureKind: 'none', + chatSessionExitCode: 'none', + chatSessionCloseSignal: 'none', + chatSessionProcessErrorCode: 'none', + chatSessionStderrChars: 0, + chatSessionStderrSha256: 'none', turnReport: null, mixedSpawnActionId: null, mixedSpawnRequestHash: null, @@ -784,6 +797,17 @@ const state = { staticIsolatedProviderOverlapObserved: false, preKillMixedIdentity: null, collaborationPolicyWritten: false, + collaborationPolicySnapshotInitialRecord: null, + collaborationPolicySnapshotInitialBytes: null, + collaborationPolicySnapshotInitialBytesSha256: null, + collaborationPolicySnapshotInitialIdentityHash: null, + collaborationPolicySnapshotBindingInitialRecord: null, + collaborationPolicySnapshotBindingInitialBytes: null, + collaborationPolicySnapshotBindingInitialBytesSha256: null, + chatSessionFailureDiagnostic: null, + collaborationPolicyDriftFixtureWritten: false, + collaborationPolicyDriftFixturePolicyFingerprint: null, + collaborationPolicyDriftedMinIsolatedGroupsBeforeClaim: null, initialBatchRecoveryBoundaryObserved: false, initialBatchRecoveryOldRunnerBootId: null, initialBatchRecoveryNewRunnerBootId: null, @@ -953,10 +977,22 @@ try { } if (isSupervisorSwarmInteractiveChatSuite() && supervisorSwarmCliSession) { try { + if ( + state.status !== 'PASS' && + supervisorSwarmCliSession.closed && + !state.supervisorSwarm.chatSessionFailureDiagnostic + ) { + recordSupervisorSwarmChatSessionFailureDiagnostic( + supervisorSwarmCliSession, + ); + } await closeInteractiveCli(supervisorSwarmCliSession); } catch (error) { state.status = 'FAIL'; - recordError('supervisor-swarm-autonomous-chat-cli-cleanup-failed', error); + recordError( + 'supervisor-swarm-autonomous-chat-cli-cleanup-failed', + error, + ); } supervisorSwarmCliSession = null; } @@ -981,7 +1017,15 @@ try { } } catch (error) { state.status = 'FAIL'; - recordError('isolated-owned-runner-cleanup-failed', error); + const safeCleanupErrorCode = + isNonEmptyString(error?.code) && + /^(?:isolated|source)-[a-z0-9-]+$/u.test(error.code) + ? error.code + : 'isolated-owned-runner-cleanup-failed'; + recordError( + safeCleanupErrorCode, + error, + ); await closeOwnedRunnerKillHandle( state.isolatedRunner.current?.killHandle, ).catch(() => {}); @@ -1020,7 +1064,8 @@ try { state.evidence.goalRunnerPidfdSignalCount = state.isolatedRunner.pidfdSignalCount; } else if (isResponseStreamSuite()) { - state.evidence.responseStreamRunnerStopped = state.isolatedRunner.stopped; + state.evidence.responseStreamRunnerStopped = + state.isolatedRunner.stopped; state.evidence.responseStreamAppDataCleanupPerformed = state.isolatedRunner.cleanupPerformed; state.evidence.responseStreamRunnerKillMethod = killMethod; @@ -1283,7 +1328,11 @@ try { recordError('goal-partial-evidence-read-failed', error); } } - if (isResponseStreamSuite() && state.projectRoot && state.status !== 'PASS') { + if ( + isResponseStreamSuite() && + state.projectRoot && + state.status !== 'PASS' + ) { try { state.evidence = { ...state.evidence, @@ -1453,9 +1502,10 @@ try { report = JSON.stringify(summary, null, 2); } } - state.commandMarkerReportLeakCount = countExactSecrets(Buffer.from(report), [ - commandRootErrorMarker, - ]); + state.commandMarkerReportLeakCount = countExactSecrets( + Buffer.from(report), + [commandRootErrorMarker], + ); if (state.commandMarkerReportLeakCount > 0) { state.status = 'FAIL'; recordError('command-output-marker-report-leak-detected'); @@ -1490,9 +1540,10 @@ try { if (isResponseStreamSuite()) { state.responseStream.reportLeakCount = countExactSecrets( Buffer.from(report), - [state.responseStream.finalText, ...responseStreamThinkingMarkers].filter( - isNonEmptyString, - ), + [ + state.responseStream.finalText, + ...responseStreamThinkingMarkers, + ].filter(isNonEmptyString), ); state.evidence.responseStreamReportLeakCount = state.responseStream.reportLeakCount; @@ -1623,7 +1674,8 @@ try { Buffer.from(report), disposableProjectPathVariants(), ); - state.evidence.projectPathReportLeakCount = state.projectPathReportLeakCount; + state.evidence.projectPathReportLeakCount = + state.projectPathReportLeakCount; if (state.projectPathReportLeakCount > 0) { state.status = 'FAIL'; recordError('disposable-project-path-report-leak-detected'); @@ -1654,7 +1706,10 @@ try { report = JSON.stringify(summary, null, 2); } } - state.reportLeakCount = countExactSecrets(Buffer.from(report), state.secrets); + state.reportLeakCount = countExactSecrets( + Buffer.from(report), + state.secrets, + ); if (state.reportLeakCount > 0) { state.status = 'FAIL'; recordError('report-key-leak-detected'); @@ -1768,15 +1823,16 @@ try { scopedAgentsReportLeakCount: remainingScopedAgentsReportLeakCount, projectSkillReportLeakCount: remainingProjectSkillReportLeakCount, parallelReadReportLeakCount: remainingParallelReadReportLeakCount, - supervisorSwarmReportLeakCount: remainingSupervisorSwarmReportLeakCount, + supervisorSwarmReportLeakCount: + remainingSupervisorSwarmReportLeakCount, formalConfigPathReportLeakCount: remainingFormalConfigPathReportLeakCount, + ...(isSupervisorSwarmSuite() + ? supervisorSwarmChatSessionFailureEvidence() + : {}), }, errorCount: state.errors.length, - errorHashes: state.errors.map((error) => ({ - code: error.code, - detailHash: error.detailHash, - })), + errorHashes: state.errors.map(summarizeRecordedError), }; safeSummary.summaryHash = hashValue(JSON.stringify(safeSummary)); report = JSON.stringify(safeSummary, null, 2); @@ -6108,12 +6164,20 @@ function supervisorSwarmCollaborationPolicyPath() { return path.join(state.projectRoot, '.agent/collaboration-policy.json'); } -async function writeSupervisorSwarmCollaborationPolicy() { - assert( - isSupervisorSwarmMixedHarnessSuite(), - 'supervisor-swarm-collaboration-policy-write-outside-suite', - ); +function driftedSupervisorSwarmCollaborationPolicy() { const policy = expectedSupervisorSwarmCollaborationPolicy(); + assert( + isSupervisorSwarmMultiIsolatedHarnessSuite() && + policy.minIsolatedGroupsBeforeClaim === 2, + 'supervisor-swarm-collaboration-policy-drift-fixture-outside-suite', + ); + return { + ...policy, + minIsolatedGroupsBeforeClaim: 1, + }; +} + +async function replaceSupervisorSwarmCollaborationPolicy(policy, failureCode) { const policyPath = supervisorSwarmCollaborationPolicyPath(); const temporaryPath = `${policyPath}.tmp-${process.pid}-${randomUUID()}`; try { @@ -6124,10 +6188,7 @@ async function writeSupervisorSwarmCollaborationPolicy() { await fs.rename(temporaryPath, policyPath); } catch (error) { await fs.rm(temporaryPath, { force: true }).catch(() => {}); - throw codedError( - 'supervisor-swarm-collaboration-policy-write-failed', - error, - ); + throw codedError(failureCode, error); } const [metadata, persisted] = await Promise.all([ fs.lstat(policyPath), @@ -6140,9 +6201,41 @@ async function writeSupervisorSwarmCollaborationPolicy() { JSON.stringify(canonicalJsonValue(policy)), 'supervisor-swarm-collaboration-policy-sidecar-invalid', ); + return persisted; +} + +async function writeSupervisorSwarmCollaborationPolicy() { + assert( + isSupervisorSwarmMixedHarnessSuite(), + 'supervisor-swarm-collaboration-policy-write-outside-suite', + ); + const policy = expectedSupervisorSwarmCollaborationPolicy(); + await replaceSupervisorSwarmCollaborationPolicy( + policy, + 'supervisor-swarm-collaboration-policy-write-failed', + ); state.supervisorSwarm.collaborationPolicyWritten = true; } +async function writeSupervisorSwarmCollaborationPolicyDriftFixture() { + const policy = driftedSupervisorSwarmCollaborationPolicy(); + const persisted = await replaceSupervisorSwarmCollaborationPolicy( + policy, + 'supervisor-swarm-collaboration-policy-drift-write-failed', + ); + const fingerprint = hashValue(JSON.stringify(persisted)); + assert( + persisted.minIsolatedGroupsBeforeClaim === 1 && + /^[0-9a-f]{64}$/u.test(fingerprint ?? ''), + 'supervisor-swarm-collaboration-policy-drift-fixture-invalid', + ); + state.supervisorSwarm.collaborationPolicyDriftFixtureWritten = true; + state.supervisorSwarm.collaborationPolicyDriftFixturePolicyFingerprint = + fingerprint; + state.supervisorSwarm.collaborationPolicyDriftedMinIsolatedGroupsBeforeClaim = + persisted.minIsolatedGroupsBeforeClaim; +} + function supervisorSwarmSeedPrivateValues(repositoryInstructions) { return [ repositoryInstructions, @@ -6176,8 +6269,9 @@ async function seedSupervisorSwarmDisposableProject() { ? [writeSupervisorSwarmCollaborationPolicy()] : []), ]); - state.supervisorSwarm.privateValues = - supervisorSwarmSeedPrivateValues(repositoryInstructions); + state.supervisorSwarm.privateValues = supervisorSwarmSeedPrivateValues( + repositoryInstructions, + ); await runProcess( 'git', @@ -6288,10 +6382,7 @@ async function readSupervisorSwarmJsonDirectory(relativePath) { return records; } -async function collectPartialSupervisorSwarmJsonSurface( - surface, - relativePath, -) { +async function collectPartialSupervisorSwarmJsonSurface(surface, relativePath) { let files; try { files = (await listFiles(path.join(state.projectRoot, relativePath))) @@ -6316,7 +6407,9 @@ async function collectPartialSupervisorSwarmJsonSurface( } records.push(record); } catch (error) { - errors.push(error?.code === 'ENOENT' ? 'file-disappeared' : 'invalid-record'); + errors.push( + error?.code === 'ENOENT' ? 'file-disappeared' : 'invalid-record', + ); } } if (errors.length > 0) { @@ -6371,6 +6464,8 @@ async function readSupervisorSwarmPersistence({ tolerateErrors = false } = {}) { claimSurface, runtimeStateSurface, contextBundleSurface, + collaborationPolicySnapshotSurface, + collaborationPolicySnapshotBindingSurface, legacyConversationSurface, isolatedGroupSurface, isolatedInstanceSurface, @@ -6401,7 +6496,10 @@ async function readSupervisorSwarmPersistence({ tolerateErrors = false } = {}) { ), readJsonlSurface( 'activity', - () => readOptionalJsonl(path.join(state.projectRoot, '.agent/activity.jsonl')), + () => + readOptionalJsonl( + path.join(state.projectRoot, '.agent/activity.jsonl'), + ), () => listOptionalSupervisorSwarmFile( path.join(state.projectRoot, '.agent/activity.jsonl'), @@ -6409,7 +6507,8 @@ async function readSupervisorSwarmPersistence({ tolerateErrors = false } = {}) { ), readJsonlSurface( 'output', - () => readOptionalJsonl(path.join(state.projectRoot, '.agent/output.jsonl')), + () => + readOptionalJsonl(path.join(state.projectRoot, '.agent/output.jsonl')), () => listOptionalSupervisorSwarmFile( path.join(state.projectRoot, '.agent/output.jsonl'), @@ -6419,6 +6518,14 @@ async function readSupervisorSwarmPersistence({ tolerateErrors = false } = {}) { readJsonSurface('claim', '.agent/runtime/delegation-claims'), readJsonSurface('runtime-state', '.agent/runtime/agents'), readJsonSurface('context-bundle', '.agent/runtime/context-bundles'), + readJsonSurface( + 'collaboration-policy-snapshot', + '.agent/runtime/collaboration-policy-snapshots', + ), + readJsonSurface( + 'collaboration-policy-snapshot-binding', + '.agent/runtime/collaboration-policy-snapshot-bindings', + ), readJsonlSurface( 'legacy-conversation', () => @@ -6435,7 +6542,10 @@ async function readSupervisorSwarmPersistence({ tolerateErrors = false } = {}) { 'isolated-instance', '.agent/runtime/isolated-agents/instances', ), - readJsonSurface('isolated-result', '.agent/runtime/isolated-agents/results'), + readJsonSurface( + 'isolated-result', + '.agent/runtime/isolated-agents/results', + ), readJsonSurface( 'isolated-join-delivery', '.agent/runtime/isolated-agents/join-deliveries', @@ -6454,6 +6564,34 @@ async function readSupervisorSwarmPersistence({ tolerateErrors = false } = {}) { const claims = claimSurface.records; const runtimeStates = runtimeStateSurface.records; const contextBundles = contextBundleSurface.records; + const collaborationPolicySnapshots = + collaborationPolicySnapshotSurface.records; + const collaborationPolicySnapshotBindings = + collaborationPolicySnapshotBindingSurface.records; + if ( + tolerateErrors && + supervisorSwarmInitialBatchBindsPolicySnapshot() && + collaborationPolicySnapshots.length === 0 && + collaborationPolicySnapshotSurface.errors.length === 0 + ) { + collaborationPolicySnapshotSurface.errors.push('missing-record'); + recordError( + 'supervisor-swarm-partial-collaboration-policy-snapshot-read-failed', + codedError('missing-record'), + ); + } + if ( + tolerateErrors && + supervisorSwarmInitialBatchBindsPolicySnapshot() && + collaborationPolicySnapshotBindings.length === 0 && + collaborationPolicySnapshotBindingSurface.errors.length === 0 + ) { + collaborationPolicySnapshotBindingSurface.errors.push('missing-record'); + recordError( + 'supervisor-swarm-partial-collaboration-policy-snapshot-binding-read-failed', + codedError('missing-record'), + ); + } const legacyConversation = legacyConversationSurface.records; const isolatedGroups = isolatedGroupSurface.records; const isolatedInstances = isolatedInstanceSurface.records; @@ -6464,11 +6602,17 @@ async function readSupervisorSwarmPersistence({ tolerateErrors = false } = {}) { 'supervisor-conversation', () => readOptionalJsonl( - agentConversationPath(projectSupervisorAgentId, supervisorSwarmSessionId), + agentConversationPath( + projectSupervisorAgentId, + supervisorSwarmSessionId, + ), ), () => listOptionalSupervisorSwarmFile( - agentConversationPath(projectSupervisorAgentId, supervisorSwarmSessionId), + agentConversationPath( + projectSupervisorAgentId, + supervisorSwarmSessionId, + ), ), ); const supervisorConversation = supervisorConversationSurface.records; @@ -6547,6 +6691,9 @@ async function readSupervisorSwarmPersistence({ tolerateErrors = false } = {}) { claim: claimSurface.errors, runtimeState: runtimeStateSurface.errors, contextBundle: contextBundleSurface.errors, + collaborationPolicySnapshot: collaborationPolicySnapshotSurface.errors, + collaborationPolicySnapshotBinding: + collaborationPolicySnapshotBindingSurface.errors, legacyConversation: legacyConversationSurface.errors, supervisorConversation: supervisorConversationSurface.errors, professionalConversation: [...new Set(professionalConversationErrors)], @@ -6568,6 +6715,8 @@ async function readSupervisorSwarmPersistence({ tolerateErrors = false } = {}) { claims, runtimeStates, contextBundles, + collaborationPolicySnapshots, + collaborationPolicySnapshotBindings, legacyConversation, supervisorConversation, professionalConversations, @@ -6650,7 +6799,10 @@ function collectSupervisorSwarmDynamicPrivateValues(persistence) { delivery.acceptanceCriteria, privateValues, ); - collectSupervisorSwarmResultPrivateValues(delivery.resultSummary, privateValues); + collectSupervisorSwarmResultPrivateValues( + delivery.resultSummary, + privateValues, + ); collectSupervisorSwarmResultPrivateValues(delivery.result, privateValues); collectSupervisorSwarmResultPrivateValues( delivery.structuredResult, @@ -6692,9 +6844,7 @@ function collectSupervisorSwarmDynamicPrivateValues(persistence) { } } return [ - ...new Set( - privateValues.filter((value) => [...value.trim()].length >= 8), - ), + ...new Set(privateValues.filter((value) => [...value.trim()].length >= 8)), ]; } @@ -7123,13 +7273,211 @@ function validateSupervisorSwarmIsolatedSpawnInput( assertSupervisorSwarmIsolatedChildBusinessContract(child, review); } supervisorSwarmIsolatedWriteScopeRoots(input.children); - assert( - expectedPaths.size === 0, - `${codePrefix}-child-boundaries-incomplete`, - ); + assert(expectedPaths.size === 0, `${codePrefix}-child-boundaries-incomplete`); return hashJsonValue(input); } +function supervisorSwarmCollaborationPolicySnapshotIdentity(snapshot) { + if (!isPlainObject(snapshot)) return null; + return { + schemaVersion: snapshot.schemaVersion, + projectId: snapshot.projectId, + parentAgentId: snapshot.parentAgentId, + parentRunId: snapshot.parentRunId, + boundFrom: snapshot.boundFrom, + policy: snapshot.policy, + policyFingerprint: snapshot.policyFingerprint, + }; +} + +function inspectSupervisorSwarmCollaborationPolicySnapshot(snapshot, expected) { + const identity = supervisorSwarmCollaborationPolicySnapshotIdentity(snapshot); + const identityHash = identity ? hashJsonValue(identity) : null; + const policyFingerprintValid = + /^[0-9a-f]{64}$/u.test(snapshot?.policyFingerprint ?? '') && + snapshot.policyFingerprint === hashValue(JSON.stringify(snapshot.policy)); + const snapshotFingerprintValid = /^[0-9a-f]{64}$/u.test( + snapshot?.snapshotFingerprint ?? '', + ); + const policyMatched = + JSON.stringify(canonicalJsonValue(snapshot?.policy)) === + JSON.stringify(canonicalJsonValue(expected?.policy)); + const projectIdentityMatched = + isNonEmptyString(expected?.projectId) && + snapshot?.projectId === expected.projectId; + const parentIdentityMatched = + snapshot?.parentAgentId === expected?.parentAgentId && + snapshot?.parentRunId === expected?.parentRunId; + const boundFromMatched = snapshot?.boundFrom === expected?.boundFrom; + const exactShape = hasExactKeys(snapshot, [ + 'schemaVersion', + 'projectId', + 'parentAgentId', + 'parentRunId', + 'boundFrom', + 'policy', + 'policyFingerprint', + 'snapshotFingerprint', + 'boundAt', + ]); + const boundAtValid = + Number.isSafeInteger(snapshot?.boundAt) && snapshot.boundAt > 0; + const identityHashMatched = + snapshotFingerprintValid && snapshot.snapshotFingerprint === identityHash; + const expectedPolicyFingerprintMatched = + isNonEmptyString(expected?.policyFingerprint) && + snapshot?.policyFingerprint === expected.policyFingerprint; + return { + snapshot, + identity, + identityHash, + exactShape, + schemaVersionMatched: + snapshot?.schemaVersion === + supervisorCollaborationPolicySnapshotSchemaVersion, + projectIdentityMatched, + parentIdentityMatched, + boundFromMatched, + policyMatched, + expectedPolicyFingerprintMatched, + policyFingerprintValid, + snapshotFingerprintValid, + identityHashMatched, + boundAtValid, + }; +} + +function validateSupervisorSwarmCollaborationPolicySnapshot( + snapshot, + expected, +) { + const inspected = inspectSupervisorSwarmCollaborationPolicySnapshot( + snapshot, + expected, + ); + assert( + inspected.exactShape && + inspected.schemaVersionMatched && + inspected.projectIdentityMatched && + inspected.parentIdentityMatched && + inspected.boundFromMatched && + inspected.policyMatched && + inspected.expectedPolicyFingerprintMatched && + inspected.policyFingerprintValid && + inspected.snapshotFingerprintValid && + inspected.identityHashMatched && + inspected.boundAtValid, + 'supervisor-swarm-collaboration-policy-snapshot-invalid', + ); + return inspected; +} + +function supervisorSwarmCollaborationPolicySnapshotBinding(snapshot) { + if (!isPlainObject(snapshot)) return null; + return { + schemaVersion: supervisorCollaborationPolicySnapshotBindingSchemaVersion, + projectId: snapshot.projectId, + parentAgentId: snapshot.parentAgentId, + parentRunId: snapshot.parentRunId, + boundFrom: snapshot.boundFrom, + policyFingerprint: snapshot.policyFingerprint, + snapshotFingerprint: snapshot.snapshotFingerprint, + boundAt: snapshot.boundAt, + }; +} + +function inspectSupervisorSwarmCollaborationPolicySnapshotBinding( + binding, + snapshot, +) { + const expected = supervisorSwarmCollaborationPolicySnapshotBinding(snapshot); + const exactShape = hasExactKeys(binding, [ + 'schemaVersion', + 'projectId', + 'parentAgentId', + 'parentRunId', + 'boundFrom', + 'policyFingerprint', + 'snapshotFingerprint', + 'boundAt', + ]); + const identityMatched = + isPlainObject(expected) && + binding?.projectId === expected.projectId && + binding?.parentAgentId === expected.parentAgentId && + binding?.parentRunId === expected.parentRunId && + binding?.boundFrom === expected.boundFrom; + const fingerprintsMatched = + /^[0-9a-f]{64}$/u.test(binding?.policyFingerprint ?? '') && + /^[0-9a-f]{64}$/u.test(binding?.snapshotFingerprint ?? '') && + binding?.policyFingerprint === expected?.policyFingerprint && + binding?.snapshotFingerprint === expected?.snapshotFingerprint; + const boundAtMatched = + Number.isSafeInteger(binding?.boundAt) && + binding.boundAt > 0 && + binding.boundAt === expected?.boundAt; + const schemaVersionMatched = + binding?.schemaVersion === + supervisorCollaborationPolicySnapshotBindingSchemaVersion; + return { + binding, + expected, + exactShape, + schemaVersionMatched, + identityMatched, + fingerprintsMatched, + boundAtMatched, + snapshotMatched: + exactShape && + schemaVersionMatched && + identityMatched && + fingerprintsMatched && + boundAtMatched, + }; +} + +function validateSupervisorSwarmCollaborationPolicySnapshotBinding( + binding, + snapshot, +) { + const inspected = inspectSupervisorSwarmCollaborationPolicySnapshotBinding( + binding, + snapshot, + ); + assert( + inspected.snapshotMatched, + 'supervisor-swarm-collaboration-policy-snapshot-binding-invalid', + ); + return inspected; +} + +function duplicateSupervisorSwarmCollaborationPolicySnapshotCount(records) { + return duplicateCount( + (records ?? []).map( + (record) => + `${record?.projectId ?? ''}\0${record?.parentAgentId ?? ''}\0${record?.parentRunId ?? ''}`, + ), + ); +} + +function duplicateSupervisorSwarmCollaborationPolicySnapshotBindingCount( + records, +) { + return duplicateSupervisorSwarmCollaborationPolicySnapshotCount(records); +} + +function supervisorSwarmCollaborationPolicyDriftObservationCount(agentDb) { + return (agentDb ?? []).filter( + (record) => + record.recordType === 'agent.runtime.tool_observation' && + record.agentId === projectSupervisorAgentId && + record.runId === state.initialRunId && + record.tool === 'agent.run_status' && + record.status === 'ok' && + String(record.summary ?? '').includes('项目协作策略已漂移'), + ).length; +} + function validateSupervisorSwarmCollaborationContract(contract, mixed) { const expectedPolicy = expectedSupervisorSwarmCollaborationPolicy(); const expectedStaticAgentIds = [ @@ -7344,9 +7692,12 @@ function validateSupervisorSwarmInitialProviderBatch(batch) { state.supervisorSwarm.mixedSpawnActionId = spawnActionId; state.supervisorSwarm.initialProviderBatch = { batchId: batch.batchId, + projectId: batch.projectId, + agentId: batch.agentId, taskId: batch.taskId, sessionId: batch.sessionId, runId: batch.runId, + status: batch.status, actionIds: [...actionIds], delegateActionIds: [...delegateActionIds], actions: batch.actions.map((pending) => ({ @@ -7392,6 +7743,183 @@ function supervisorSwarmInitialProviderBatchPath() { ); } +function supervisorSwarmCollaborationPolicySnapshotDirectory() { + return path.join( + state.projectRoot, + '.agent/runtime/collaboration-policy-snapshots', + ); +} + +function supervisorSwarmCollaborationPolicySnapshotPath() { + return path.join( + supervisorSwarmCollaborationPolicySnapshotDirectory(), + projectSupervisorAgentId, + `${state.initialRunId}.json`, + ); +} + +function supervisorSwarmCollaborationPolicySnapshotBindingDirectory() { + return path.join( + state.projectRoot, + '.agent/runtime/collaboration-policy-snapshot-bindings', + ); +} + +function supervisorSwarmCollaborationPolicySnapshotBindingPath() { + return path.join( + supervisorSwarmCollaborationPolicySnapshotBindingDirectory(), + projectSupervisorAgentId, + `${state.initialRunId}.json`, + ); +} + +function supervisorSwarmExpectedCollaborationPolicySnapshot() { + const initialBatch = state.supervisorSwarm.initialProviderBatch; + const contract = initialBatch?.collaborationContract; + return { + projectId: initialBatch?.projectId, + parentAgentId: projectSupervisorAgentId, + parentRunId: state.initialRunId, + boundFrom: supervisorCollaborationPolicySnapshotInitialBatchBinding, + policy: contract?.policySnapshot, + policyFingerprint: contract?.policyFingerprint, + }; +} + +function supervisorSwarmInitialBatchBindsPolicySnapshot( + initialBatch = state.supervisorSwarm.initialProviderBatch, + parentRunId = state.initialRunId, +) { + const contract = initialBatch?.collaborationContract; + return ( + initialBatch?.schemaVersion === providerActionBatchSchemaVersion && + isNonEmptyString(initialBatch.batchId) && + isNonEmptyString(initialBatch.projectId) && + initialBatch.agentId === projectSupervisorAgentId && + isNonEmptyString(initialBatch.taskId) && + initialBatch.sessionId === supervisorSwarmSessionId && + initialBatch.runId === parentRunId && + initialBatch.status !== 'aborted' && + contract?.schemaVersion === supervisorCollaborationContractSchemaVersion && + contract.policySchemaVersion === supervisorCollaborationPolicySchemaVersion && + isPlainObject(contract.policySnapshot) && + /^[0-9a-f]{64}$/u.test(contract.policyFingerprint ?? '') && + /^[0-9a-f]{64}$/u.test(contract.contractFingerprint ?? '') + ); +} + +async function readSupervisorSwarmCollaborationPolicySnapshotFile() { + const snapshotPath = supervisorSwarmCollaborationPolicySnapshotPath(); + const bytes = await fs.readFile(snapshotPath, 'utf8'); + let snapshot; + try { + snapshot = JSON.parse(bytes); + } catch (error) { + throw codedError( + 'supervisor-swarm-collaboration-policy-snapshot-json-invalid', + error, + ); + } + return { snapshotPath, bytes, snapshot }; +} + +async function readSupervisorSwarmCollaborationPolicySnapshotBindingFile() { + const bindingPath = supervisorSwarmCollaborationPolicySnapshotBindingPath(); + const bytes = await fs.readFile(bindingPath, 'utf8'); + let binding; + try { + binding = JSON.parse(bytes); + } catch (error) { + throw codedError( + 'supervisor-swarm-collaboration-policy-snapshot-binding-json-invalid', + error, + ); + } + return { bindingPath, bytes, binding }; +} + +async function captureSupervisorSwarmCollaborationPolicySnapshotAndDrift() { + if (!isSupervisorSwarmMultiIsolatedHarnessSuite()) return; + const expectedSnapshotPath = supervisorSwarmCollaborationPolicySnapshotPath(); + const expectedBindingPath = + supervisorSwarmCollaborationPolicySnapshotBindingPath(); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const [snapshotFiles, bindingFiles] = await Promise.all([ + listFiles(supervisorSwarmCollaborationPolicySnapshotDirectory()), + listFiles(supervisorSwarmCollaborationPolicySnapshotBindingDirectory()), + ]); + if ( + !snapshotFiles.includes(expectedSnapshotPath) || + !bindingFiles.includes(expectedBindingPath) + ) { + await sleep(50); + continue; + } + assert( + snapshotFiles.length === 1 && + snapshotFiles[0] === expectedSnapshotPath && + bindingFiles.length === 1 && + bindingFiles[0] === expectedBindingPath, + 'supervisor-swarm-collaboration-policy-snapshot-surface-not-unique', + ); + const [initial, initialBinding] = await Promise.all([ + readSupervisorSwarmCollaborationPolicySnapshotFile(), + readSupervisorSwarmCollaborationPolicySnapshotBindingFile(), + ]); + const inspected = validateSupervisorSwarmCollaborationPolicySnapshot( + initial.snapshot, + supervisorSwarmExpectedCollaborationPolicySnapshot(), + ); + validateSupervisorSwarmCollaborationPolicySnapshotBinding( + initialBinding.binding, + initial.snapshot, + ); + state.supervisorSwarm.collaborationPolicySnapshotInitialRecord = JSON.parse( + JSON.stringify(initial.snapshot), + ); + state.supervisorSwarm.collaborationPolicySnapshotInitialBytes = + initial.bytes; + state.supervisorSwarm.collaborationPolicySnapshotInitialBytesSha256 = + hashValue(initial.bytes); + state.supervisorSwarm.collaborationPolicySnapshotInitialIdentityHash = + inspected.identityHash; + state.supervisorSwarm.collaborationPolicySnapshotBindingInitialRecord = + JSON.parse(JSON.stringify(initialBinding.binding)); + state.supervisorSwarm.collaborationPolicySnapshotBindingInitialBytes = + initialBinding.bytes; + state.supervisorSwarm.collaborationPolicySnapshotBindingInitialBytesSha256 = + hashValue(initialBinding.bytes); + + await writeSupervisorSwarmCollaborationPolicyDriftFixture(); + const [afterDrift, bindingAfterDrift] = await Promise.all([ + readSupervisorSwarmCollaborationPolicySnapshotFile(), + readSupervisorSwarmCollaborationPolicySnapshotBindingFile(), + ]); + validateSupervisorSwarmCollaborationPolicySnapshot( + afterDrift.snapshot, + supervisorSwarmExpectedCollaborationPolicySnapshot(), + ); + validateSupervisorSwarmCollaborationPolicySnapshotBinding( + bindingAfterDrift.binding, + afterDrift.snapshot, + ); + assert( + afterDrift.bytes === initial.bytes && + JSON.stringify(afterDrift.snapshot) === + JSON.stringify(initial.snapshot) && + bindingAfterDrift.bytes === initialBinding.bytes && + JSON.stringify(bindingAfterDrift.binding) === + JSON.stringify(initialBinding.binding), + 'supervisor-swarm-collaboration-policy-snapshot-surface-changed-after-drift', + ); + return; + } + throw codedError( + 'supervisor-swarm-collaboration-policy-snapshot-capture-timeout', + ); +} + async function captureSupervisorSwarmInitialProviderBatch() { const batchPath = supervisorSwarmInitialProviderBatchPath(); const deadline = Date.now() + 5 * 60 * 1000; @@ -8187,8 +8715,7 @@ function supervisorSwarmMixedIsolatedGroupEntries( expectedReviewGroups = supervisorSwarmExpectedIsolatedReviewGroups(), ) { assert( - Array.isArray(groups) && - groups.length === expectedReviewGroups.length, + Array.isArray(groups) && groups.length === expectedReviewGroups.length, 'supervisor-swarm-mixed-group-count-invalid', ); const entries = groups.map((group) => ({ @@ -8209,10 +8736,7 @@ function supervisorSwarmMixedIsolatedGroupEntries( function supervisorSwarmMixedSpawnRequestHashesStable(groups) { const expectedReviewGroups = supervisorSwarmExpectedIsolatedReviewGroups(); - if ( - !Array.isArray(groups) || - groups.length !== expectedReviewGroups.length - ) { + if (!Array.isArray(groups) || groups.length !== expectedReviewGroups.length) { return false; } const entries = groups @@ -8250,9 +8774,7 @@ function supervisorSwarmReadyIsolatedGroupIds(detail) { ); let payload; try { - payload = JSON.parse( - payloadBlocks[0].slice('readyIsolatedJoins: '.length), - ); + payload = JSON.parse(payloadBlocks[0].slice('readyIsolatedJoins: '.length)); } catch (error) { throw codedError('supervisor-swarm-mixed-ready-join-json-invalid', error); } @@ -8319,9 +8841,7 @@ function supervisorSwarmMixedIsolatedClaimsReady(persistence) { records.groups.length === expectedGroupCount && records.instances.length === supervisorSwarmIsolatedReviews.length && records.results.length === supervisorSwarmIsolatedReviews.length && - records.results.every( - (record) => record.result?.status === 'completed', - ) && + records.results.every((record) => record.result?.status === 'completed') && records.joinDeliveries.length === expectedGroupCount && records.joinDeliveries.every( (delivery) => delivery.status === 'claimed-by-parent', @@ -8341,9 +8861,7 @@ function validateSupervisorSwarmMixedIsolatedPersistence(persistence) { persistence.isolatedGroups.length === groupEntries.length && isNonEmptyString(state.supervisorSwarm.mixedSpawnActionId) && (!isSupervisorSwarmMultiIsolatedHarnessSuite() || - (isNonEmptyString( - state.supervisorSwarm.mixedFollowupSpawnActionId, - ) && + (isNonEmptyString(state.supervisorSwarm.mixedFollowupSpawnActionId) && state.supervisorSwarm.mixedSpawnActionId !== state.supervisorSwarm.mixedFollowupSpawnActionId)), 'supervisor-swarm-mixed-group-invalid', @@ -9630,7 +10148,8 @@ async function prepareSupervisorSwarmRuntimeAppData() { if (!isSupervisorSwarmTransientRetrySuite()) { const configOverlay = { llm: { - maxRetries: 2, + requestTimeoutMs: 300_000, + maxRetries: 3, retryBackoffMs: 500, }, }; @@ -9639,7 +10158,11 @@ async function prepareSupervisorSwarmRuntimeAppData() { assert( supervisorSwarmRequiredAgentIds.every((agentId) => { const effective = effectiveAgentLlmConfig(isolated.config, agentId); - return effective.maxRetries === 2 && effective.retryBackoffMs === 500; + return ( + effective.requestTimeoutMs === 300_000 && + effective.maxRetries === 3 && + effective.retryBackoffMs === 500 + ); }) && state.isolatedRunner.configOverlayCreated, 'supervisor-swarm-real-network-retry-overlay-invalid', ); @@ -9973,6 +10496,7 @@ async function runSupervisorSwarmE2e() { ); await captureSupervisorSwarmInitialProviderBatch(); + await captureSupervisorSwarmCollaborationPolicySnapshotAndDrift(); await restartSupervisorSwarmRunnerAtInitialBatchBoundary(); await captureSupervisorSwarmTransientRetryCheckpoint(); await driveSupervisorSwarmToRepairKillBoundary(); @@ -10002,6 +10526,9 @@ async function waitForSupervisorSwarmAutonomousParentRuntime(task) { return runtime; } if (supervisorSwarmCliSession?.closed) { + recordSupervisorSwarmChatSessionFailureDiagnostic( + supervisorSwarmCliSession, + ); throw codedError('supervisor-swarm-autonomous-chat-closed-before-run'); } await sleep(50); @@ -10010,8 +10537,7 @@ async function waitForSupervisorSwarmAutonomousParentRuntime(task) { } async function captureSupervisorSwarmAutonomousTurnReport() { - const session = supervisorSwarmCliSession; - assert(session && !session.closed, 'supervisor-swarm-chat-session-missing'); + const session = requireOpenSupervisorSwarmChatSession(); const output = await waitForInteractiveCliOutput( session, (value) => value.includes('[turn.report] '), @@ -10370,10 +10896,7 @@ function validateSupervisorSwarmNativeProtocol( isolatedInstances = [], ) { const { relevant, repairEvidence } = - collectSupervisorSwarmToolPlanAuditEvidence( - agentDb, - isolatedInstances, - ); + collectSupervisorSwarmToolPlanAuditEvidence(agentDb, isolatedInstances); const protocols = relevant.filter( (record) => record.recordType === 'agent.runtime.tool_plan.protocol', ); @@ -10488,6 +11011,52 @@ function validateSupervisorSwarmNativeProtocol( }; } +function inspectSupervisorSwarmRecoveredRepairConfirmationLifecycle( + agentDb, + matchesRecord, + targetSessionId, + targetRunId, +) { + const indexedAgentDb = agentDb.map((record, index) => ({ record, index })); + const confirmationRequirements = indexedAgentDb.filter( + ({ record }) => + ((record.recordType === + 'agent.runtime.provider_action_batch.confirmation_required' && + record.sessionId === targetSessionId) || + (record.recordType === 'agent.runtime.tool_confirmation_required' && + (!Object.hasOwn(record, 'sessionId') || + record.sessionId === targetSessionId))) && + matchesRecord(record), + ); + const approvals = indexedAgentDb.filter( + ({ record }) => + record.recordType === 'agent.runtime.tool_confirmation.approved' && + record.sessionId === targetSessionId && + record.confirmedRunId === targetRunId && + matchesRecord(record), + ); + const successfulReceipts = indexedAgentDb.filter( + ({ record }) => + record.recordType === 'agent.runtime.action_receipt' && + record.sessionId === targetSessionId && + record.executionMode === 'confirmation' && + record.status === 'ok' && + matchesRecord(record), + ); + const orderValid = + confirmationRequirements.length === 1 && + approvals.length === 1 && + successfulReceipts.length === 1 && + confirmationRequirements[0].index < approvals[0].index && + approvals[0].index < successfulReceipts[0].index; + return { + confirmationRequirements, + approvals, + receipts: successfulReceipts, + valid: orderValid, + }; +} + function validateSupervisorSwarmActionPersistence( agentDb, deliveries, @@ -10797,8 +11366,7 @@ function validateSupervisorSwarmActionPersistence( ); } if (isSupervisorSwarmMultiIsolatedHarnessSuite()) { - const followupActionId = - state.supervisorSwarm.mixedFollowupSpawnActionId; + const followupActionId = state.supervisorSwarm.mixedFollowupSpawnActionId; assert( isNonEmptyString(followupActionId) && followupActionId !== state.supervisorSwarm.mixedSpawnActionId, @@ -10876,26 +11444,18 @@ function validateSupervisorSwarmActionPersistence( record.actionFingerprint === state.supervisorSwarm.repairPendingIdentity?.actionFingerprint && record.tool === state.supervisorSwarm.repairPendingTool; - const recoveredRepairConfirmationRequirements = agentDb.filter( - (record) => - record.recordType === 'agent.runtime.tool_confirmation_required' && - matchesRecoveredRepairPending(record), - ); - const recoveredRepairApprovals = agentDb.filter( - (record) => - record.recordType === 'agent.runtime.tool_confirmation.approved' && - matchesRecoveredRepairPending(record), - ); - const recoveredRepairReceipts = receipts.filter( - (record) => matchesRecoveredRepairPending(record) && record.status === 'ok', - ); + const recoveredRepairConfirmationLifecycle = + inspectSupervisorSwarmRecoveredRepairConfirmationLifecycle( + agentDb, + matchesRecoveredRepairPending, + state.supervisorSwarm.repairTargetSessionId, + state.supervisorSwarm.repairTargetRunId, + ); assert( isNonEmptyString(state.supervisorSwarm.repairPendingActionId) && isPlainObject(state.supervisorSwarm.repairPendingIdentity) && isNonEmptyString(state.supervisorSwarm.repairPendingTool) && - recoveredRepairConfirmationRequirements.length === 1 && - recoveredRepairApprovals.length === 1 && - recoveredRepairReceipts.length === 1, + recoveredRepairConfirmationLifecycle.valid, 'supervisor-swarm-recovered-repair-action-invalid', ); return { @@ -10908,7 +11468,8 @@ function validateSupervisorSwarmActionPersistence( mixedSpawnConfirmationRequiredCount, mixedSpawnApprovalCount, mixedSpawnConfirmationOrderValid, - recoveredRepairPendingActionCount: recoveredRepairReceipts.length, + recoveredRepairPendingActionCount: + recoveredRepairConfirmationLifecycle.receipts.length, failedRepairActionCount: repairAttempts.failedRepairActions.length, targetedContractReadCount: repairAttempts.targetedContractReads.length, }; @@ -11034,10 +11595,7 @@ function countSupervisorSwarmSensitiveValuesBySurface( scanErrors[surface] = [ ...new Set([...(scanErrors[surface] ?? []), 'serialization-failed']), ]; - recordError( - `supervisor-swarm-${surface}-privacy-scan-failed`, - error, - ); + recordError(`supervisor-swarm-${surface}-privacy-scan-failed`, error); } } counts[surface] = countExactSecrets( @@ -11098,6 +11656,9 @@ function collectSupervisorSwarmPublicLeakEvidence( isolatedResult: persistence.isolatedResults, isolatedJoinDelivery: persistence.isolatedJoinDeliveries, isolatedJoinClaim: persistence.isolatedJoinClaims, + collaborationPolicySnapshot: persistence.collaborationPolicySnapshots, + collaborationPolicySnapshotBinding: + persistence.collaborationPolicySnapshotBindings, runtimeState: persistence.runtimeStates, contextBundle: persistence.contextBundles, }; @@ -11184,7 +11745,10 @@ function collectSupervisorSwarmPublicLeakEvidence( publicLeakScanErrors: scanErrors, }; if (requireZero) { - assert(projectPathVariants.length >= 2, 'supervisor-swarm-public-path-variants-missing'); + assert( + projectPathVariants.length >= 2, + 'supervisor-swarm-public-path-variants-missing', + ); assert( Object.keys(scanErrors).length === 0, 'supervisor-swarm-public-leak-scan-incomplete', @@ -11230,6 +11794,26 @@ async function readSupervisorSwarmResidualSidecarCounts() { await listFiles(path.join(state.projectRoot, relative)) ).filter((file) => file.endsWith('.json')).length; } + const snapshotFiles = await listFiles( + supervisorSwarmCollaborationPolicySnapshotDirectory(), + ); + const expectedSnapshotPath = + supervisorSwarmInitialBatchBindsPolicySnapshot() + ? supervisorSwarmCollaborationPolicySnapshotPath() + : null; + counts.collaborationPolicySnapshotArtifacts = snapshotFiles.filter( + (file) => file !== expectedSnapshotPath, + ).length; + const bindingFiles = await listFiles( + supervisorSwarmCollaborationPolicySnapshotBindingDirectory(), + ); + const expectedBindingPath = + supervisorSwarmInitialBatchBindsPolicySnapshot() + ? supervisorSwarmCollaborationPolicySnapshotBindingPath() + : null; + counts.collaborationPolicySnapshotBindingArtifacts = bindingFiles.filter( + (file) => file !== expectedBindingPath, + ).length; return counts; } @@ -11387,15 +11971,159 @@ async function validateSupervisorSwarmEvidence() { /^[0-9a-f]{64}$/u.test(collaborationContract?.contractFingerprint ?? ''), 'supervisor-swarm-final-collaboration-contract-invalid', ); + const collaborationPolicySnapshotRequired = + supervisorSwarmInitialBatchBindsPolicySnapshot(initialBatch); + const collaborationPolicySnapshotDriftRequired = + isSupervisorSwarmMultiIsolatedHarnessSuite(); + const collaborationPolicySnapshots = + persistence.collaborationPolicySnapshots ?? []; + const collaborationPolicySnapshotBindings = + persistence.collaborationPolicySnapshotBindings ?? []; + const duplicateCollaborationPolicySnapshotCount = + duplicateSupervisorSwarmCollaborationPolicySnapshotCount( + collaborationPolicySnapshots, + ); + const duplicateCollaborationPolicySnapshotBindingCount = + duplicateSupervisorSwarmCollaborationPolicySnapshotBindingCount( + collaborationPolicySnapshotBindings, + ); + let collaborationPolicySnapshotInspection = + inspectSupervisorSwarmCollaborationPolicySnapshot( + collaborationPolicySnapshots[0], + supervisorSwarmExpectedCollaborationPolicySnapshot(), + ); + let collaborationPolicySnapshotBytesStable = false; + let collaborationPolicySnapshotFieldsStable = false; + let collaborationPolicySnapshotFingerprintStable = false; + let collaborationPolicySnapshotPolicyFingerprintStable = false; + let collaborationPolicySnapshotBindingInspection = + inspectSupervisorSwarmCollaborationPolicySnapshotBinding( + collaborationPolicySnapshotBindings[0], + collaborationPolicySnapshots[0], + ); + let collaborationPolicySnapshotBindingBytesStable = false; + let collaborationPolicySnapshotBindingFieldsStable = false; + const collaborationPolicyDriftObservationCount = + supervisorSwarmCollaborationPolicyDriftObservationCount( + persistence.agentDb, + ); + assert( + collaborationPolicySnapshotRequired, + 'supervisor-swarm-final-collaboration-policy-snapshot-contract-missing', + ); + if (collaborationPolicySnapshotRequired) { + assert( + collaborationPolicySnapshots.length === 1 && + duplicateCollaborationPolicySnapshotCount === 0 && + collaborationPolicySnapshotBindings.length === 1 && + duplicateCollaborationPolicySnapshotBindingCount === 0, + 'supervisor-swarm-final-collaboration-policy-snapshot-surface-count-invalid', + ); + const [finalSnapshotFile, finalBindingFile] = await Promise.all([ + readSupervisorSwarmCollaborationPolicySnapshotFile(), + readSupervisorSwarmCollaborationPolicySnapshotBindingFile(), + ]); + collaborationPolicySnapshotInspection = + validateSupervisorSwarmCollaborationPolicySnapshot( + finalSnapshotFile.snapshot, + supervisorSwarmExpectedCollaborationPolicySnapshot(), + ); + collaborationPolicySnapshotFieldsStable = + JSON.stringify(finalSnapshotFile.snapshot) === + JSON.stringify(collaborationPolicySnapshots[0]); + collaborationPolicySnapshotFingerprintStable = + finalSnapshotFile.snapshot.snapshotFingerprint === + collaborationPolicySnapshots[0].snapshotFingerprint && + collaborationPolicySnapshotInspection.identityHash === + collaborationPolicySnapshots[0].snapshotFingerprint; + collaborationPolicySnapshotPolicyFingerprintStable = + finalSnapshotFile.snapshot.policyFingerprint === + collaborationPolicySnapshots[0].policyFingerprint; + collaborationPolicySnapshotBindingInspection = + validateSupervisorSwarmCollaborationPolicySnapshotBinding( + finalBindingFile.binding, + finalSnapshotFile.snapshot, + ); + collaborationPolicySnapshotBindingFieldsStable = + JSON.stringify(finalBindingFile.binding) === + JSON.stringify(collaborationPolicySnapshotBindings[0]); + assert( + collaborationPolicySnapshotFieldsStable && + collaborationPolicySnapshotFingerprintStable && + collaborationPolicySnapshotPolicyFingerprintStable && + collaborationPolicySnapshotBindingInspection.snapshotMatched && + collaborationPolicySnapshotBindingFieldsStable, + 'supervisor-swarm-final-collaboration-policy-snapshot-integrity-invalid', + ); + if (collaborationPolicySnapshotDriftRequired) { + collaborationPolicySnapshotBytesStable = + isNonEmptyString( + state.supervisorSwarm.collaborationPolicySnapshotInitialBytes, + ) && + finalSnapshotFile.bytes === + state.supervisorSwarm.collaborationPolicySnapshotInitialBytes && + hashValue(finalSnapshotFile.bytes) === + state.supervisorSwarm.collaborationPolicySnapshotInitialBytesSha256; + collaborationPolicySnapshotBindingBytesStable = + isNonEmptyString( + state.supervisorSwarm + .collaborationPolicySnapshotBindingInitialBytes, + ) && + finalBindingFile.bytes === + state.supervisorSwarm + .collaborationPolicySnapshotBindingInitialBytes && + hashValue(finalBindingFile.bytes) === + state.supervisorSwarm + .collaborationPolicySnapshotBindingInitialBytesSha256; + assert( + isPlainObject( + state.supervisorSwarm.collaborationPolicySnapshotInitialRecord, + ) && + isPlainObject( + state.supervisorSwarm + .collaborationPolicySnapshotBindingInitialRecord, + ) && + collaborationPolicySnapshotBytesStable && + collaborationPolicySnapshotBindingBytesStable && + JSON.stringify(finalSnapshotFile.snapshot) === + JSON.stringify( + state.supervisorSwarm.collaborationPolicySnapshotInitialRecord, + ) && + JSON.stringify(finalBindingFile.binding) === + JSON.stringify( + state.supervisorSwarm + .collaborationPolicySnapshotBindingInitialRecord, + ) && + state.supervisorSwarm.collaborationPolicyDriftFixtureWritten && + state.supervisorSwarm + .collaborationPolicyDriftedMinIsolatedGroupsBeforeClaim === 1 && + /^[0-9a-f]{64}$/u.test( + state.supervisorSwarm + .collaborationPolicyDriftFixturePolicyFingerprint ?? '', + ) && + collaborationPolicyDriftObservationCount >= 1, + 'supervisor-swarm-final-collaboration-policy-snapshot-drift-invalid', + ); + } + } + let finalCollaborationPolicySidecar = null; if (isSupervisorSwarmMixedHarnessSuite()) { - const policySidecar = await readJson( + finalCollaborationPolicySidecar = await readJson( supervisorSwarmCollaborationPolicyPath(), ); + const expectedFinalPolicy = collaborationPolicySnapshotDriftRequired + ? driftedSupervisorSwarmCollaborationPolicy() + : expectedCollaborationPolicy; assert( state.supervisorSwarm.collaborationPolicyWritten && - JSON.stringify(canonicalJsonValue(policySidecar)) === - JSON.stringify(canonicalJsonValue(expectedCollaborationPolicy)), - 'supervisor-swarm-collaboration-policy-sidecar-drifted', + JSON.stringify(canonicalJsonValue(finalCollaborationPolicySidecar)) === + JSON.stringify(canonicalJsonValue(expectedFinalPolicy)) && + (!collaborationPolicySnapshotDriftRequired || + (finalCollaborationPolicySidecar.minIsolatedGroupsBeforeClaim === 1 && + hashValue(JSON.stringify(finalCollaborationPolicySidecar)) === + state.supervisorSwarm + .collaborationPolicyDriftFixturePolicyFingerprint)), + 'supervisor-swarm-collaboration-policy-sidecar-invalid', ); } const deliveries = supervisorSwarmParentDeliveries(persistence.deliveries); @@ -11466,6 +12194,16 @@ async function validateSupervisorSwarmEvidence() { const mixedState = isSupervisorSwarmMixedHarnessSuite() ? validateSupervisorSwarmMixedIsolatedPersistence(persistence) : null; + if (collaborationPolicySnapshotDriftRequired) { + assert( + mixedState?.groups.length === 2 && + mixedState.instances.length === 3 && + mixedState.joinClaims.length === 1 && + mixedState.joinClaims[0].status === 'observed' && + mixedState.joinClaims[0].joins.length === 2, + 'supervisor-swarm-final-collaboration-policy-drift-topology-invalid', + ); + } const [ designContent, @@ -12068,6 +12806,8 @@ async function validateSupervisorSwarmEvidence() { (state.transcriptScanner?.count ?? 0) + projectSecretLeakCount; assert(secretLeakCount === 0, 'loaded-key-leak-detected'); const autonomousModeEnabled = isSupervisorSwarmInteractiveChatSuite(); + const chatSessionFailureDiagnostic = + state.supervisorSwarm.chatSessionFailureDiagnostic; const turnReport = state.supervisorSwarm.turnReport; const turnReportPrivateLeakCount = autonomousModeEnabled ? countExactSecrets(Buffer.from(JSON.stringify(turnReport ?? {})), [ @@ -12119,6 +12859,16 @@ async function validateSupervisorSwarmEvidence() { autonomousRepositoryRecipeFree: state.supervisorSwarm.autonomousRepositoryRecipeFree, interactiveCliUsed: state.supervisorSwarm.interactiveCliUsed, + chatSessionUnexpectedlyClosed: Boolean(chatSessionFailureDiagnostic), + chatSessionFailureKind: + chatSessionFailureDiagnostic?.failureKind ?? 'none', + chatSessionExitCode: chatSessionFailureDiagnostic?.exitCode ?? 'none', + chatSessionCloseSignal: chatSessionFailureDiagnostic?.signal ?? 'none', + chatSessionProcessErrorCode: + chatSessionFailureDiagnostic?.processErrorCode ?? 'none', + chatSessionStderrChars: chatSessionFailureDiagnostic?.stderrChars ?? 0, + chatSessionStderrSha256: + chatSessionFailureDiagnostic?.stderrSha256 ?? 'none', turnReportCaptured: turnReport != null, turnReportOutcome: turnReport?.outcome ?? 'not-requested', turnReportParentIdentityStable: autonomousModeEnabled @@ -12200,8 +12950,7 @@ async function validateSupervisorSwarmEvidence() { initialCollaborationMinIsolatedChildren: collaborationContract.policySnapshot.minIsolatedChildren, initialCollaborationMinIsolatedGroupsBeforeClaim: - collaborationContract.policySnapshot - .minIsolatedGroupsBeforeClaim ?? 0, + collaborationContract.policySnapshot.minIsolatedGroupsBeforeClaim ?? 0, initialCollaborationIsolatedChildCount: collaborationContract.isolatedChildCount, initialCollaborationOrchestratorOnlyAfterDelegation: @@ -12220,6 +12969,81 @@ async function validateSupervisorSwarmEvidence() { ), collaborationPolicySidecarWritten: state.supervisorSwarm.collaborationPolicyWritten, + collaborationPolicySnapshotRequired, + collaborationPolicySnapshotCaptured: + collaborationPolicySnapshotRequired && + collaborationPolicySnapshotInspection.exactShape, + collaborationPolicySnapshotCount: collaborationPolicySnapshots.length, + collaborationPolicySnapshotSchemaVersion: + collaborationPolicySnapshotInspection.snapshot?.schemaVersion ?? + 'not-required', + collaborationPolicySnapshotExactShape: + collaborationPolicySnapshotInspection.exactShape, + collaborationPolicySnapshotProjectIdentityMatched: + collaborationPolicySnapshotInspection.projectIdentityMatched, + collaborationPolicySnapshotParentIdentityMatched: + collaborationPolicySnapshotInspection.parentIdentityMatched, + collaborationPolicySnapshotBoundFrom: + collaborationPolicySnapshotInspection.snapshot?.boundFrom ?? + 'not-required', + collaborationPolicySnapshotPolicyMatched: + collaborationPolicySnapshotInspection.policyMatched, + collaborationPolicySnapshotExpectedPolicyFingerprintMatched: + collaborationPolicySnapshotInspection.expectedPolicyFingerprintMatched, + collaborationPolicySnapshotPolicyFingerprintValid: + collaborationPolicySnapshotInspection.policyFingerprintValid, + collaborationPolicySnapshotFingerprintValid: + collaborationPolicySnapshotInspection.snapshotFingerprintValid, + collaborationPolicySnapshotIdentityHashMatched: + collaborationPolicySnapshotInspection.identityHashMatched, + collaborationPolicySnapshotBoundAtValid: + collaborationPolicySnapshotInspection.boundAtValid, + collaborationPolicySnapshotBytesStable, + collaborationPolicySnapshotFieldsStable, + collaborationPolicySnapshotFingerprintStable, + collaborationPolicySnapshotPolicyFingerprintStable, + collaborationPolicySnapshotStable: + collaborationPolicySnapshotBytesStable && + collaborationPolicySnapshotFieldsStable && + collaborationPolicySnapshotFingerprintStable && + collaborationPolicySnapshotPolicyFingerprintStable, + collaborationPolicySnapshotBindingCaptured: + collaborationPolicySnapshotRequired && + collaborationPolicySnapshotBindingInspection.exactShape, + collaborationPolicySnapshotBindingCount: + collaborationPolicySnapshotBindings.length, + collaborationPolicySnapshotBindingSchemaVersion: + collaborationPolicySnapshotBindingInspection.binding?.schemaVersion ?? + 'not-required', + collaborationPolicySnapshotBindingExactShape: + collaborationPolicySnapshotBindingInspection.exactShape, + collaborationPolicySnapshotBindingIdentityMatched: + collaborationPolicySnapshotBindingInspection.identityMatched, + collaborationPolicySnapshotBindingFingerprintsMatched: + collaborationPolicySnapshotBindingInspection.fingerprintsMatched, + collaborationPolicySnapshotBindingBoundAtMatched: + collaborationPolicySnapshotBindingInspection.boundAtMatched, + collaborationPolicySnapshotBindingSnapshotMatched: + collaborationPolicySnapshotBindingInspection.snapshotMatched, + collaborationPolicySnapshotBindingBytesStable, + collaborationPolicySnapshotBindingFieldsStable, + collaborationPolicySnapshotBindingStable: + collaborationPolicySnapshotBindingBytesStable && + collaborationPolicySnapshotBindingFieldsStable && + collaborationPolicySnapshotBindingInspection.snapshotMatched, + collaborationPolicyDriftFixtureWritten: + state.supervisorSwarm.collaborationPolicyDriftFixtureWritten, + collaborationPolicySidecarMinIsolatedGroupsBeforeClaim: + finalCollaborationPolicySidecar?.minIsolatedGroupsBeforeClaim ?? 0, + collaborationPolicyDriftStatusObserved: + collaborationPolicyDriftObservationCount >= 1, + collaborationPolicyDriftObservationCount, + duplicateCollaborationPolicySnapshotCount, + duplicateCollaborationPolicySnapshotBindingCount, + collaborationPolicySnapshotResidualArtifactCount: + residualSidecars.collaborationPolicySnapshotArtifacts, + collaborationPolicySnapshotBindingResidualArtifactCount: + residualSidecars.collaborationPolicySnapshotBindingArtifacts, initialBatchRecoveryRequired, initialBatchRecoveryBoundaryObserved: state.supervisorSwarm.initialBatchRecoveryBoundaryObserved, @@ -12499,6 +13323,8 @@ async function validateSupervisorSwarmEvidence() { '.agent/runtime/isolated-agents/results', '.agent/runtime/isolated-agents/join-deliveries', '.agent/runtime/isolated-agents/join-claims', + '.agent/runtime/collaboration-policy-snapshots', + '.agent/runtime/collaboration-policy-snapshot-bindings', '.agent/runtime/provider-action-batches', '.agent/runtime/tasks', '.agent/runtime/events', @@ -12681,16 +13507,15 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { return await reader(); } catch (error) { if (!(ignoreMissing && error?.code === 'ENOENT')) { + const safeFailureKind = error?.safeFailureDiagnostic?.failureKind; persistence.failureEvidenceErrors[surface] = [ ...new Set([ ...(persistence.failureEvidenceErrors[surface] ?? []), 'read-failed', + ...(isNonEmptyString(safeFailureKind) ? [safeFailureKind] : []), ]), ]; - recordError( - `supervisor-swarm-partial-${surface}-read-failed`, - error, - ); + recordError(`supervisor-swarm-partial-${surface}-read-failed`, error); } return fallback; } @@ -12703,6 +13528,9 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { owner, isolatedEvidenceContents, changedFiles, + collaborationPolicySnapshotFile, + collaborationPolicySnapshotBindingFile, + collaborationPolicySidecar, ] = await Promise.all([ toleratePartialRead( 'design-artifact', @@ -12735,6 +13563,8 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { pendingActions: 0, providerActionBatches: 0, userInput: 0, + collaborationPolicySnapshotArtifacts: 0, + collaborationPolicySnapshotBindingArtifacts: 0, }, ), toleratePartialRead( @@ -12769,6 +13599,28 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { ), { stdout: '' }, ), + toleratePartialRead( + 'collaboration-policy-snapshot', + () => + supervisorSwarmInitialBatchBindsPolicySnapshot() + ? readSupervisorSwarmCollaborationPolicySnapshotFile() + : null, + null, + ), + toleratePartialRead( + 'collaboration-policy-snapshot-binding', + () => + supervisorSwarmInitialBatchBindsPolicySnapshot() + ? readSupervisorSwarmCollaborationPolicySnapshotBindingFile() + : null, + null, + ), + toleratePartialRead( + 'collaboration-policy-sidecar', + () => readJson(supervisorSwarmCollaborationPolicyPath()), + null, + { ignoreMissing: true }, + ), ]); const parentTasks = persistence.taskSnapshot.all.filter( (task) => task.agentId === projectSupervisorAgentId, @@ -12833,6 +13685,88 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { const partialCollaborationContract = partialInitialBatch?.collaborationContract; const partialExpectedPolicy = expectedSupervisorSwarmCollaborationPolicy(); + const partialCollaborationPolicySnapshotRequired = + supervisorSwarmInitialBatchBindsPolicySnapshot(partialInitialBatch); + const partialCollaborationPolicySnapshots = + persistence.collaborationPolicySnapshots ?? []; + const partialCollaborationPolicySnapshotBindings = + persistence.collaborationPolicySnapshotBindings ?? []; + const partialCollaborationPolicySnapshotInspection = + inspectSupervisorSwarmCollaborationPolicySnapshot( + partialCollaborationPolicySnapshots[0], + supervisorSwarmExpectedCollaborationPolicySnapshot(), + ); + const partialCollaborationPolicySnapshotBytesStable = + isNonEmptyString( + state.supervisorSwarm.collaborationPolicySnapshotInitialBytes, + ) && + collaborationPolicySnapshotFile?.bytes === + state.supervisorSwarm.collaborationPolicySnapshotInitialBytes && + hashValue(collaborationPolicySnapshotFile.bytes) === + state.supervisorSwarm.collaborationPolicySnapshotInitialBytesSha256; + const partialCollaborationPolicySnapshotFieldsStable = + isPlainObject( + state.supervisorSwarm.collaborationPolicySnapshotInitialRecord, + ) && + isPlainObject(collaborationPolicySnapshotFile?.snapshot) && + JSON.stringify(collaborationPolicySnapshotFile.snapshot) === + JSON.stringify( + state.supervisorSwarm.collaborationPolicySnapshotInitialRecord, + ) && + JSON.stringify(collaborationPolicySnapshotFile.snapshot) === + JSON.stringify(partialCollaborationPolicySnapshots[0]); + const partialCollaborationPolicySnapshotFingerprintStable = + partialCollaborationPolicySnapshotFieldsStable && + collaborationPolicySnapshotFile.snapshot.snapshotFingerprint === + state.supervisorSwarm.collaborationPolicySnapshotInitialRecord + .snapshotFingerprint && + partialCollaborationPolicySnapshotInspection.identityHash === + state.supervisorSwarm.collaborationPolicySnapshotInitialIdentityHash; + const partialCollaborationPolicySnapshotPolicyFingerprintStable = + partialCollaborationPolicySnapshotFieldsStable && + collaborationPolicySnapshotFile.snapshot.policyFingerprint === + state.supervisorSwarm.collaborationPolicySnapshotInitialRecord + .policyFingerprint; + const partialCollaborationPolicySnapshotBindingInspection = + inspectSupervisorSwarmCollaborationPolicySnapshotBinding( + partialCollaborationPolicySnapshotBindings[0], + collaborationPolicySnapshotFile?.snapshot ?? + partialCollaborationPolicySnapshots[0], + ); + const partialCollaborationPolicySnapshotBindingBytesStable = + isNonEmptyString( + state.supervisorSwarm.collaborationPolicySnapshotBindingInitialBytes, + ) && + collaborationPolicySnapshotBindingFile?.bytes === + state.supervisorSwarm.collaborationPolicySnapshotBindingInitialBytes && + hashValue(collaborationPolicySnapshotBindingFile.bytes) === + state.supervisorSwarm + .collaborationPolicySnapshotBindingInitialBytesSha256; + const partialCollaborationPolicySnapshotBindingFieldsStable = + isPlainObject( + state.supervisorSwarm.collaborationPolicySnapshotBindingInitialRecord, + ) && + isPlainObject(collaborationPolicySnapshotBindingFile?.binding) && + JSON.stringify(collaborationPolicySnapshotBindingFile.binding) === + JSON.stringify( + state.supervisorSwarm.collaborationPolicySnapshotBindingInitialRecord, + ) && + JSON.stringify(collaborationPolicySnapshotBindingFile.binding) === + JSON.stringify(partialCollaborationPolicySnapshotBindings[0]); + const partialCollaborationPolicyDriftObservationCount = + supervisorSwarmCollaborationPolicyDriftObservationCount( + persistence.agentDb, + ); + const partialDuplicateCollaborationPolicySnapshotCount = + duplicateSupervisorSwarmCollaborationPolicySnapshotCount( + partialCollaborationPolicySnapshots, + ); + const partialDuplicateCollaborationPolicySnapshotBindingCount = + duplicateSupervisorSwarmCollaborationPolicySnapshotBindingCount( + partialCollaborationPolicySnapshotBindings, + ); + const partialChatSessionFailureDiagnostic = + state.supervisorSwarm.chatSessionFailureDiagnostic; const partialIdentityBefore = state.supervisorSwarm.initialBatchRecoveryPreKillIdentity; const partialIdentityAfter = @@ -12873,6 +13807,20 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { scenario: partialRecoveryRequired ? 'project-supervisor-collaboration-policy-mixed-initial-batch-recovery' : baseEvidence.scenario, + interactiveCliUsed: state.supervisorSwarm.interactiveCliUsed, + chatSessionUnexpectedlyClosed: Boolean(partialChatSessionFailureDiagnostic), + chatSessionFailureKind: + partialChatSessionFailureDiagnostic?.failureKind ?? 'none', + chatSessionExitCode: + partialChatSessionFailureDiagnostic?.exitCode ?? 'none', + chatSessionCloseSignal: + partialChatSessionFailureDiagnostic?.signal ?? 'none', + chatSessionProcessErrorCode: + partialChatSessionFailureDiagnostic?.processErrorCode ?? 'none', + chatSessionStderrChars: + partialChatSessionFailureDiagnostic?.stderrChars ?? 0, + chatSessionStderrSha256: + partialChatSessionFailureDiagnostic?.stderrSha256 ?? 'none', providerModel: state.supervisorSwarm.effectiveModel, providerApiKind: state.supervisorSwarm.effectiveApiKind, providerReasoningEffort: state.supervisorSwarm.effectiveReasoningEffort, @@ -12970,6 +13918,92 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { ), collaborationPolicySidecarWritten: state.supervisorSwarm.collaborationPolicyWritten, + collaborationPolicySnapshotRequired: + partialCollaborationPolicySnapshotRequired, + collaborationPolicySnapshotCaptured: + partialCollaborationPolicySnapshotRequired && + partialCollaborationPolicySnapshotInspection.exactShape, + collaborationPolicySnapshotCount: + partialCollaborationPolicySnapshots.length, + collaborationPolicySnapshotSchemaVersion: + partialCollaborationPolicySnapshotInspection.snapshot?.schemaVersion ?? + 'not-captured', + collaborationPolicySnapshotExactShape: + partialCollaborationPolicySnapshotInspection.exactShape, + collaborationPolicySnapshotProjectIdentityMatched: + partialCollaborationPolicySnapshotInspection.projectIdentityMatched, + collaborationPolicySnapshotParentIdentityMatched: + partialCollaborationPolicySnapshotInspection.parentIdentityMatched, + collaborationPolicySnapshotBoundFrom: + partialCollaborationPolicySnapshotInspection.snapshot?.boundFrom ?? + 'not-captured', + collaborationPolicySnapshotPolicyMatched: + partialCollaborationPolicySnapshotInspection.policyMatched, + collaborationPolicySnapshotExpectedPolicyFingerprintMatched: + partialCollaborationPolicySnapshotInspection.expectedPolicyFingerprintMatched, + collaborationPolicySnapshotPolicyFingerprintValid: + partialCollaborationPolicySnapshotInspection.policyFingerprintValid, + collaborationPolicySnapshotFingerprintValid: + partialCollaborationPolicySnapshotInspection.snapshotFingerprintValid, + collaborationPolicySnapshotIdentityHashMatched: + partialCollaborationPolicySnapshotInspection.identityHashMatched, + collaborationPolicySnapshotBoundAtValid: + partialCollaborationPolicySnapshotInspection.boundAtValid, + collaborationPolicySnapshotBytesStable: + partialCollaborationPolicySnapshotBytesStable, + collaborationPolicySnapshotFieldsStable: + partialCollaborationPolicySnapshotFieldsStable, + collaborationPolicySnapshotFingerprintStable: + partialCollaborationPolicySnapshotFingerprintStable, + collaborationPolicySnapshotPolicyFingerprintStable: + partialCollaborationPolicySnapshotPolicyFingerprintStable, + collaborationPolicySnapshotStable: + partialCollaborationPolicySnapshotBytesStable && + partialCollaborationPolicySnapshotFieldsStable && + partialCollaborationPolicySnapshotFingerprintStable && + partialCollaborationPolicySnapshotPolicyFingerprintStable, + collaborationPolicySnapshotBindingCaptured: + partialCollaborationPolicySnapshotRequired && + partialCollaborationPolicySnapshotBindingInspection.exactShape, + collaborationPolicySnapshotBindingCount: + partialCollaborationPolicySnapshotBindings.length, + collaborationPolicySnapshotBindingSchemaVersion: + partialCollaborationPolicySnapshotBindingInspection.binding + ?.schemaVersion ?? 'not-captured', + collaborationPolicySnapshotBindingExactShape: + partialCollaborationPolicySnapshotBindingInspection.exactShape, + collaborationPolicySnapshotBindingIdentityMatched: + partialCollaborationPolicySnapshotBindingInspection.identityMatched, + collaborationPolicySnapshotBindingFingerprintsMatched: + partialCollaborationPolicySnapshotBindingInspection.fingerprintsMatched, + collaborationPolicySnapshotBindingBoundAtMatched: + partialCollaborationPolicySnapshotBindingInspection.boundAtMatched, + collaborationPolicySnapshotBindingSnapshotMatched: + partialCollaborationPolicySnapshotBindingInspection.snapshotMatched, + collaborationPolicySnapshotBindingBytesStable: + partialCollaborationPolicySnapshotBindingBytesStable, + collaborationPolicySnapshotBindingFieldsStable: + partialCollaborationPolicySnapshotBindingFieldsStable, + collaborationPolicySnapshotBindingStable: + partialCollaborationPolicySnapshotBindingBytesStable && + partialCollaborationPolicySnapshotBindingFieldsStable && + partialCollaborationPolicySnapshotBindingInspection.snapshotMatched, + collaborationPolicyDriftFixtureWritten: + state.supervisorSwarm.collaborationPolicyDriftFixtureWritten, + collaborationPolicySidecarMinIsolatedGroupsBeforeClaim: + collaborationPolicySidecar?.minIsolatedGroupsBeforeClaim ?? 0, + collaborationPolicyDriftStatusObserved: + partialCollaborationPolicyDriftObservationCount >= 1, + collaborationPolicyDriftObservationCount: + partialCollaborationPolicyDriftObservationCount, + duplicateCollaborationPolicySnapshotCount: + partialDuplicateCollaborationPolicySnapshotCount, + duplicateCollaborationPolicySnapshotBindingCount: + partialDuplicateCollaborationPolicySnapshotBindingCount, + collaborationPolicySnapshotResidualArtifactCount: + residualSidecars.collaborationPolicySnapshotArtifacts, + collaborationPolicySnapshotBindingResidualArtifactCount: + residualSidecars.collaborationPolicySnapshotBindingArtifacts, initialBatchRecoveryRequired: partialRecoveryRequired, initialBatchRecoveryBoundaryObserved: state.supervisorSwarm.initialBatchRecoveryBoundaryObserved, @@ -13304,6 +14338,22 @@ async function collectPartialSupervisorSwarmEvidence(baseEvidence) { ...publicLeakEvidence, failureEvidenceErrors: persistence.failureEvidenceErrors, ...supervisorSwarmTransientRetrySnapshotEvidence(false), + paths: [ + '.agent/runtime/delegation-deliveries', + '.agent/runtime/delegation-claims', + '.agent/runtime/isolated-agents/groups', + '.agent/runtime/isolated-agents/instances', + '.agent/runtime/isolated-agents/results', + '.agent/runtime/isolated-agents/join-deliveries', + '.agent/runtime/isolated-agents/join-claims', + '.agent/runtime/collaboration-policy-snapshots', + '.agent/runtime/collaboration-policy-snapshot-bindings', + '.agent/runtime/provider-action-batches', + '.agent/runtime/tasks', + '.agent/runtime/events', + '.agent/agent.db', + '.agent/conversations/agents/project-supervisor.jsonl', + ], }); } @@ -13727,6 +14777,21 @@ function closeSourceAppDataDirectoryGuard() { state.isolatedRunner.sourceAppDataDirectoryWatcher = null; } +function sourceAppDataDirectoryEventIsViolation( + fileName, + profilePrefix, + sourceEndpointSnapshot, +) { + const name = Buffer.isBuffer(fileName) + ? fileName.toString('utf8') + : String(fileName ?? ''); + return ( + name.startsWith(profilePrefix) || + (sourceEndpointSnapshot?.exists === false && + name === runnerEndpointFileName) + ); +} + function startSourceAppDataDirectoryGuard(sourceConfigDir, profile) { if (!isolatedSuiteProtectsSourceAppData()) return; assert( @@ -13737,10 +14802,13 @@ function startSourceAppDataDirectoryGuard(sourceConfigDir, profile) { sourceConfigDir, { persistent: false }, (_eventType, fileName) => { - const name = Buffer.isBuffer(fileName) - ? fileName.toString('utf8') - : String(fileName ?? ''); - if (name.startsWith(profile.prefix)) { + if ( + sourceAppDataDirectoryEventIsViolation( + fileName, + profile.prefix, + state.isolatedRunner.sourceEndpointSnapshot, + ) + ) { state.isolatedRunner.sourceAppDataDirectoryViolationCount += 1; } }, @@ -15612,6 +16680,92 @@ function buildCliChildEnvironment() { : environment; } +function safeProcessFailureDiagnostic({ + code = null, + signal = null, + error = null, + stdout = '', + stderr = '', +} = {}) { + const stdoutText = Buffer.isBuffer(stdout) + ? stdout.toString('utf8') + : String(stdout); + const stderrText = Buffer.isBuffer(stderr) + ? stderr.toString('utf8') + : String(stderr); + const combined = `${error?.message ?? ''}\n${stdoutText}\n${stderrText}`; + const processErrorCode = /^[A-Z0-9_]+$/u.test(error?.code ?? '') + ? error.code + : 'none'; + const closeSignal = /^[A-Z0-9]+$/u.test(signal ?? '') ? signal : 'none'; + let failureKind = 'closed'; + if ( + processErrorCode === 'ENOSPC' || + /(?:\bENOSPC\b|No space left on device|os error 28)/iu.test(combined) + ) { + failureKind = 'enospc'; + } else if (processErrorCode !== 'none') { + failureKind = 'process-error'; + } else if (closeSignal !== 'none') { + failureKind = 'signal'; + } else if (Number.isInteger(code) && code !== 0) { + failureKind = 'nonzero-exit'; + } + return { + failureKind, + exitCode: Number.isInteger(code) ? String(code) : 'none', + signal: closeSignal, + processErrorCode, + stderrChars: [...stderrText].length, + stderrSha256: hashValue(stderrText) ?? 'none', + }; +} + +function codedProcessError(code, details) { + const error = codedError(code, details?.error); + error.safeFailureDiagnostic = safeProcessFailureDiagnostic(details); + return error; +} + +function recordSupervisorSwarmChatSessionFailureDiagnostic(session) { + const diagnostic = session + ? safeProcessFailureDiagnostic({ + ...session.closeInfo, + stdout: session.stdout, + stderr: session.stderr, + }) + : { + failureKind: 'missing', + exitCode: 'none', + signal: 'none', + processErrorCode: 'none', + stderrChars: 0, + stderrSha256: 'none', + }; + state.supervisorSwarm.chatSessionFailureDiagnostic = diagnostic; + return diagnostic; +} + +function supervisorSwarmChatSessionFailureEvidence() { + const diagnostic = state.supervisorSwarm.chatSessionFailureDiagnostic; + return { + chatSessionUnexpectedlyClosed: Boolean(diagnostic), + chatSessionFailureKind: diagnostic?.failureKind ?? 'none', + chatSessionExitCode: diagnostic?.exitCode ?? 'none', + chatSessionCloseSignal: diagnostic?.signal ?? 'none', + chatSessionProcessErrorCode: diagnostic?.processErrorCode ?? 'none', + chatSessionStderrChars: diagnostic?.stderrChars ?? 0, + chatSessionStderrSha256: diagnostic?.stderrSha256 ?? 'none', + }; +} + +function requireOpenSupervisorSwarmChatSession() { + const session = supervisorSwarmCliSession; + if (session && !session.closed) return session; + recordSupervisorSwarmChatSessionFailureDiagnostic(session); + throw codedError('supervisor-swarm-chat-session-missing'); +} + function startInteractiveCli(args) { assert(Boolean(state.cliBinary), 'interactive-cli-binary-not-ready'); assert(Boolean(state.runtimeConfigDir), 'interactive-config-dir-not-ready'); @@ -15688,7 +16842,12 @@ async function waitForInteractiveCliOutput( while (Date.now() < deadline) { const output = interactiveCliOutput(session); if (predicate(output)) return output; - if (session.closed) throw codedError(`${code}-cli-closed`); + if (session.closed) { + if (session === supervisorSwarmCliSession) { + recordSupervisorSwarmChatSessionFailureDiagnostic(session); + } + throw codedError(`${code}-cli-closed`); + } await sleep(50); } throw codedError(code); @@ -15766,7 +16925,13 @@ async function runProcess( child.on('error', (error) => { clearTimeout(timer); activeCommandChildren.delete(child); - reject(codedError('process-spawn-failed', error)); + reject( + codedProcessError('process-spawn-failed', { + error, + stdout, + stderr, + }), + ); }); child.on('close', (code, signal) => { clearTimeout(timer); @@ -15778,11 +16943,16 @@ async function runProcess( signal, }; if (timedOut) { - reject(codedError('process-timeout')); + reject(codedProcessError('process-timeout', result)); } else if (shutdownSignal && !cleanupInProgress) { - reject(codedError(`interrupted-${shutdownSignal.toLowerCase()}`)); + reject( + codedProcessError( + `interrupted-${shutdownSignal.toLowerCase()}`, + result, + ), + ); } else if (code !== 0 && !allowNonZero) { - reject(codedError('cli-command-failed')); + reject(codedProcessError('cli-command-failed', result)); } else { resolve(result); } @@ -19766,8 +20936,7 @@ async function confirmSupervisorSwarmPendingActionsInChat( allowedTools, shouldConfirm, ) { - const session = supervisorSwarmCliSession; - assert(session && !session.closed, 'supervisor-swarm-chat-session-missing'); + const session = requireOpenSupervisorSwarmChatSession(); const allowed = new Set( allowedTools ? [...allowedTools] : supervisorSwarmConfirmedTools, ); @@ -24733,10 +25902,7 @@ function buildSummary() { kept: Boolean(state.options?.keepProject), }, errorCount: state.errors.length, - errorHashes: state.errors.map((error) => ({ - code: error.code, - detailHash: error.detailHash, - })), + errorHashes: state.errors.map(summarizeRecordedError), }; base.summaryHash = hashValue(JSON.stringify(base)); return base; @@ -25397,6 +26563,13 @@ function supervisorSwarmEvidenceFieldTemplate() { autonomousTaskRecipeFree: false, autonomousRepositoryRecipeFree: false, interactiveCliUsed: false, + chatSessionUnexpectedlyClosed: false, + chatSessionFailureKind: 'none', + chatSessionExitCode: 'none', + chatSessionCloseSignal: 'none', + chatSessionProcessErrorCode: 'none', + chatSessionStderrChars: 0, + chatSessionStderrSha256: 'none', turnReportCaptured: false, turnReportOutcome: 'not-requested', turnReportParentIdentityStable: false, @@ -25457,6 +26630,44 @@ function supervisorSwarmEvidenceFieldTemplate() { initialCollaborationPolicyFingerprintValid: false, initialCollaborationContractFingerprintValid: false, collaborationPolicySidecarWritten: false, + collaborationPolicySnapshotRequired: false, + collaborationPolicySnapshotCaptured: false, + collaborationPolicySnapshotCount: 0, + collaborationPolicySnapshotSchemaVersion: 'not-required', + collaborationPolicySnapshotExactShape: false, + collaborationPolicySnapshotProjectIdentityMatched: false, + collaborationPolicySnapshotParentIdentityMatched: false, + collaborationPolicySnapshotBoundFrom: 'not-required', + collaborationPolicySnapshotPolicyMatched: false, + collaborationPolicySnapshotExpectedPolicyFingerprintMatched: false, + collaborationPolicySnapshotPolicyFingerprintValid: false, + collaborationPolicySnapshotFingerprintValid: false, + collaborationPolicySnapshotIdentityHashMatched: false, + collaborationPolicySnapshotBoundAtValid: false, + collaborationPolicySnapshotBytesStable: false, + collaborationPolicySnapshotFieldsStable: false, + collaborationPolicySnapshotFingerprintStable: false, + collaborationPolicySnapshotPolicyFingerprintStable: false, + collaborationPolicySnapshotStable: false, + collaborationPolicySnapshotBindingCaptured: false, + collaborationPolicySnapshotBindingCount: 0, + collaborationPolicySnapshotBindingSchemaVersion: 'not-required', + collaborationPolicySnapshotBindingExactShape: false, + collaborationPolicySnapshotBindingIdentityMatched: false, + collaborationPolicySnapshotBindingFingerprintsMatched: false, + collaborationPolicySnapshotBindingBoundAtMatched: false, + collaborationPolicySnapshotBindingSnapshotMatched: false, + collaborationPolicySnapshotBindingBytesStable: false, + collaborationPolicySnapshotBindingFieldsStable: false, + collaborationPolicySnapshotBindingStable: false, + collaborationPolicyDriftFixtureWritten: false, + collaborationPolicySidecarMinIsolatedGroupsBeforeClaim: 0, + collaborationPolicyDriftStatusObserved: false, + collaborationPolicyDriftObservationCount: 0, + duplicateCollaborationPolicySnapshotCount: 0, + duplicateCollaborationPolicySnapshotBindingCount: 0, + collaborationPolicySnapshotResidualArtifactCount: 0, + collaborationPolicySnapshotBindingResidualArtifactCount: 0, initialBatchRecoveryRequired: false, initialBatchRecoveryBoundaryObserved: false, initialBatchRecoveryBatchIdStable: false, @@ -26904,7 +28115,9 @@ function validateMainRunToolPlanProtocols(records) { } function emptyToolPlanRepairCountsByProtocolErrorKind() { - return Object.fromEntries(toolPlanProtocolErrorKinds.map((kind) => [kind, 0])); + return Object.fromEntries( + toolPlanProtocolErrorKinds.map((kind) => [kind, 0]), + ); } function hasSafeToolPlanAuditPayload(record) { @@ -29360,7 +30573,9 @@ function receiptAuditIdentity(record) { function countExactSecrets(content, secrets) { const values = [...new Set(secrets.filter(isNonEmptyString))]; if (values.length === 0) return 0; - const text = Buffer.isBuffer(content) ? content.toString('utf8') : String(content); + const text = Buffer.isBuffer(content) + ? content.toString('utf8') + : String(content); const structured = parseStructuredSecretScanDocuments(text); if (structured) { let count = 0; @@ -29441,6 +30656,38 @@ function countNonOverlappingTextOccurrences(text, value) { } function runAgentRuntimeRealE2eSelfTests() { + const syntheticSourceEndpointLifecycleEvents = [ + { phase: 'created', fileName: runnerEndpointFileName }, + { phase: 'deleted', fileName: Buffer.from(runnerEndpointFileName) }, + ]; + const sourceEndpointAbsentLifecycleGuardValidated = + syntheticSourceEndpointLifecycleEvents.every(({ fileName }) => + sourceAppDataDirectoryEventIsViolation( + fileName, + '.synthetic-isolated-suite-', + { exists: false, fingerprint: null }, + ), + ) && + !sourceAppDataDirectoryEventIsViolation( + runnerEndpointFileName, + '.synthetic-isolated-suite-', + { exists: true, fingerprint: 'synthetic-endpoint-fingerprint' }, + ) && + sourceAppDataDirectoryEventIsViolation( + '.synthetic-isolated-suite-created', + '.synthetic-isolated-suite-', + { exists: true, fingerprint: 'synthetic-endpoint-fingerprint' }, + ) && + !sourceAppDataDirectoryEventIsViolation( + 'unrelated-source-config-file.json', + '.synthetic-isolated-suite-', + { exists: false, fingerprint: null }, + ); + assert( + sourceEndpointAbsentLifecycleGuardValidated, + 'agent-runtime-real-e2e-self-test-source-endpoint-lifecycle-guard-invalid', + ); + const newlineValue = 'private first line\nprivate second line'; const quotedValue = 'private value says "quoted"'; const backslashValue = 'private\\nested\\value'; @@ -29463,7 +30710,9 @@ function runAgentRuntimeRealE2eSelfTests() { [combinedValue], ), escapedFallback: countExactSecrets( - Buffer.from(`prefix:${JSON.stringify(combinedValue).slice(1, -1)}:suffix`), + Buffer.from( + `prefix:${JSON.stringify(combinedValue).slice(1, -1)}:suffix`, + ), [combinedValue], ), duplicateSecretInput: countExactSecrets( @@ -29476,6 +30725,163 @@ function runAgentRuntimeRealE2eSelfTests() { 'agent-runtime-real-e2e-self-test-exact-secret-count-invalid', ); + const syntheticRecoveredRepairSessionId = 'synthetic-repair-session'; + const syntheticRecoveredRepairIdentity = { + agentId: 'synthetic-repair-agent', + runId: 'synthetic-repair-run', + actionId: 'synthetic-repair-action', + actionFingerprint: 'synthetic-repair-fingerprint', + tool: 'project.patchset', + }; + const matchesSyntheticRecoveredRepair = (record) => + Object.entries(syntheticRecoveredRepairIdentity).every( + ([field, value]) => record[field] === value, + ); + const syntheticRecoveredRepairRecords = (requirementRecordType) => [ + { + ...syntheticRecoveredRepairIdentity, + recordType: requirementRecordType, + ...(requirementRecordType === + 'agent.runtime.provider_action_batch.confirmation_required' + ? { + sessionId: syntheticRecoveredRepairSessionId, + batchId: 'synthetic-repair-batch', + actionCount: 1, + actionIndex: 0, + } + : { summary: 'synthetic legacy confirmation requirement' }), + }, + { + ...syntheticRecoveredRepairIdentity, + recordType: 'agent.runtime.tool_confirmation.approved', + sessionId: syntheticRecoveredRepairSessionId, + confirmedRunId: syntheticRecoveredRepairIdentity.runId, + commandId: syntheticRecoveredRepairIdentity.tool, + }, + { + ...syntheticRecoveredRepairIdentity, + recordType: 'agent.runtime.action_receipt', + sessionId: syntheticRecoveredRepairSessionId, + executionMode: 'confirmation', + status: 'ok', + }, + ]; + const inspectSyntheticRecoveredRepair = (records) => + inspectSupervisorSwarmRecoveredRepairConfirmationLifecycle( + records, + matchesSyntheticRecoveredRepair, + syntheticRecoveredRepairSessionId, + syntheticRecoveredRepairIdentity.runId, + ); + const syntheticModernRecoveredRepairRecords = + syntheticRecoveredRepairRecords( + 'agent.runtime.provider_action_batch.confirmation_required', + ); + const syntheticLegacyRecoveredRepairRecords = + syntheticRecoveredRepairRecords( + 'agent.runtime.tool_confirmation_required', + ); + const syntheticModernRecoveredRepairLifecycle = + inspectSyntheticRecoveredRepair(syntheticModernRecoveredRepairRecords); + const syntheticLegacyRecoveredRepairLifecycle = + inspectSyntheticRecoveredRepair(syntheticLegacyRecoveredRepairRecords); + const syntheticRecoveredRepairLifecycles = [ + syntheticModernRecoveredRepairLifecycle, + syntheticLegacyRecoveredRepairLifecycle, + ]; + const syntheticRecoveredRepairNegativeLifecycles = [ + syntheticModernRecoveredRepairRecords, + syntheticLegacyRecoveredRepairRecords, + ].flatMap((records) => [ + ...records.map((record) => + inspectSyntheticRecoveredRepair([...records, { ...record }]), + ), + ...records.map((_, missingIndex) => + inspectSyntheticRecoveredRepair( + records.filter((__, recordIndex) => recordIndex !== missingIndex), + ), + ), + ]); + const syntheticRecoveredRepairConflictLifecycle = + inspectSyntheticRecoveredRepair([ + syntheticModernRecoveredRepairRecords[0], + syntheticLegacyRecoveredRepairRecords[0], + ...syntheticModernRecoveredRepairRecords.slice(1), + ]); + const syntheticRecoveredRepairAutoReceiptLifecycle = + inspectSyntheticRecoveredRepair( + syntheticModernRecoveredRepairRecords.map((record) => + record.recordType === 'agent.runtime.action_receipt' + ? { ...record, executionMode: 'auto' } + : record, + ), + ); + const syntheticRecoveredRepairWrongRunLifecycle = + inspectSyntheticRecoveredRepair( + syntheticModernRecoveredRepairRecords.map((record) => + record.recordType === 'agent.runtime.tool_confirmation.approved' + ? { ...record, confirmedRunId: 'synthetic-other-run' } + : record, + ), + ); + const syntheticRecoveredRepairMissingSessionLifecycles = + syntheticModernRecoveredRepairRecords.map((_, missingSessionIndex) => + inspectSyntheticRecoveredRepair( + syntheticModernRecoveredRepairRecords.map((record, recordIndex) => { + if (recordIndex !== missingSessionIndex) return record; + const recordWithoutSession = { ...record }; + delete recordWithoutSession.sessionId; + return recordWithoutSession; + }), + ), + ); + const syntheticRecoveredRepairWrongSessionLifecycle = + inspectSyntheticRecoveredRepair( + syntheticModernRecoveredRepairRecords.map((record) => + isNonEmptyString(record.sessionId) + ? { ...record, sessionId: 'synthetic-other-session' } + : record, + ), + ); + const syntheticLegacyRecoveredRepairWrongSessionLifecycle = + inspectSyntheticRecoveredRepair([ + { + ...syntheticLegacyRecoveredRepairRecords[0], + sessionId: 'synthetic-other-session', + }, + ...syntheticLegacyRecoveredRepairRecords.slice(1), + ]); + const syntheticRecoveredRepairOutOfOrderLifecycle = + inspectSyntheticRecoveredRepair([ + syntheticModernRecoveredRepairRecords[1], + syntheticModernRecoveredRepairRecords[0], + syntheticModernRecoveredRepairRecords[2], + ]); + assert( + syntheticRecoveredRepairLifecycles.every( + (lifecycle) => + lifecycle.valid && + lifecycle.confirmationRequirements.length === 1 && + lifecycle.approvals.length === 1 && + lifecycle.receipts.length === 1, + ) && + syntheticRecoveredRepairNegativeLifecycles.every( + (lifecycle) => !lifecycle.valid, + ) && + !Object.hasOwn(syntheticLegacyRecoveredRepairRecords[0], 'sessionId') && + syntheticLegacyRecoveredRepairLifecycle.valid && + syntheticRecoveredRepairMissingSessionLifecycles.every( + (lifecycle) => !lifecycle.valid, + ) && + !syntheticRecoveredRepairConflictLifecycle.valid && + !syntheticRecoveredRepairAutoReceiptLifecycle.valid && + !syntheticRecoveredRepairWrongRunLifecycle.valid && + !syntheticRecoveredRepairWrongSessionLifecycle.valid && + !syntheticLegacyRecoveredRepairWrongSessionLifecycle.valid && + !syntheticRecoveredRepairOutOfOrderLifecycle.valid, + 'agent-runtime-real-e2e-self-test-recovered-repair-confirmation-lifecycle-invalid', + ); + const evidenceMetadata = { kind: 'project.verify.metadata', path: 'game/metadata-only.txt', @@ -29513,7 +30919,9 @@ function runAgentRuntimeRealE2eSelfTests() { isolatedConversations: [], }); assert( - expectedPrivateValues.every((value) => dynamicPrivateValues.includes(value)), + expectedPrivateValues.every((value) => + dynamicPrivateValues.includes(value), + ), 'agent-runtime-real-e2e-self-test-dynamic-private-body-missing', ); assert( @@ -29530,9 +30938,7 @@ function runAgentRuntimeRealE2eSelfTests() { (review) => seedPrivateValues.includes(review.content) && seedPrivateValues.includes(review.requirement) && - review.boundaryTerms.every( - (term) => !seedPrivateValues.includes(term), - ), + review.boundaryTerms.every((term) => !seedPrivateValues.includes(term)), ), 'agent-runtime-real-e2e-self-test-generic-boundary-term-private', ); @@ -29554,13 +30960,11 @@ function runAgentRuntimeRealE2eSelfTests() { }, }, ]; - const syntheticMixedEntries = - supervisorSwarmMixedIsolatedGroupEntries( + const syntheticMixedEntries = supervisorSwarmMixedIsolatedGroupEntries( syntheticMixedGroups, supervisorSwarmIsolatedReviewGroups, ); - const syntheticSingleGroupEntries = - supervisorSwarmMixedIsolatedGroupEntries( + const syntheticSingleGroupEntries = supervisorSwarmMixedIsolatedGroupEntries( [ { delegationGroupId: 'synthetic-single-group', @@ -29581,8 +30985,7 @@ function runAgentRuntimeRealE2eSelfTests() { })), })}\n\nagentId: ${projectSupervisorAgentId}`, ); - const syntheticObservedJoinClaim = - supervisorSwarmObservedJoinClaimForGroups( + const syntheticObservedJoinClaim = supervisorSwarmObservedJoinClaimForGroups( [ { schemaVersion: isolatedAgentJoinClaimSchemaVersion, @@ -29616,14 +31019,182 @@ function runAgentRuntimeRealE2eSelfTests() { supervisorSwarmFollowupBeforeClaimOrderValid(3, 5, [8, 9]) && !supervisorSwarmFollowupBeforeClaimOrderValid(3, 8, [8, 9]); const originalSuite = state.suite; + const originalInitialRunId = state.initialRunId; state.suite = supervisorSwarmStaticIsolatedAutonomousChatSuite; const multiGroupPolicy = expectedSupervisorSwarmCollaborationPolicy(); const multiGroupCount = supervisorSwarmExpectedIsolatedReviewGroups().length; + const syntheticSnapshotExpected = { + projectId: 'synthetic-project-id', + parentAgentId: projectSupervisorAgentId, + parentRunId: 'synthetic-parent-run', + boundFrom: supervisorCollaborationPolicySnapshotInitialBatchBinding, + policy: multiGroupPolicy, + policyFingerprint: hashValue(JSON.stringify(multiGroupPolicy)), + }; + const syntheticSnapshotBindingBatch = { + schemaVersion: providerActionBatchSchemaVersion, + batchId: 'synthetic-provider-batch', + projectId: syntheticSnapshotExpected.projectId, + agentId: projectSupervisorAgentId, + taskId: 'synthetic-parent-task', + sessionId: supervisorSwarmSessionId, + runId: syntheticSnapshotExpected.parentRunId, + status: 'waiting-confirmation', + collaborationContract: { + schemaVersion: supervisorCollaborationContractSchemaVersion, + policySchemaVersion: supervisorCollaborationPolicySchemaVersion, + policySnapshot: multiGroupPolicy, + policyFingerprint: syntheticSnapshotExpected.policyFingerprint, + contractFingerprint: hashValue('synthetic-collaboration-contract'), + }, + }; + const syntheticSnapshotBindingBatchAccepted = + supervisorSwarmInitialBatchBindsPolicySnapshot( + syntheticSnapshotBindingBatch, + syntheticSnapshotExpected.parentRunId, + ); + const syntheticSnapshotBindingBatchRejections = [ + { ...syntheticSnapshotBindingBatch, status: 'aborted' }, + { ...syntheticSnapshotBindingBatch, collaborationContract: null }, + { ...syntheticSnapshotBindingBatch, agentId: 'synthetic-other-agent' }, + { ...syntheticSnapshotBindingBatch, runId: 'synthetic-other-run' }, + { ...syntheticSnapshotBindingBatch, schemaVersion: 'legacy-v1' }, + ].every( + (batch) => + !supervisorSwarmInitialBatchBindsPolicySnapshot( + batch, + syntheticSnapshotExpected.parentRunId, + ), + ); + const syntheticSnapshotIdentity = { + schemaVersion: supervisorCollaborationPolicySnapshotSchemaVersion, + ...syntheticSnapshotExpected, + }; + const syntheticSnapshot = { + ...syntheticSnapshotIdentity, + snapshotFingerprint: hashJsonValue(syntheticSnapshotIdentity), + boundAt: 1_784_305_826, + }; + const syntheticSnapshotInspection = + validateSupervisorSwarmCollaborationPolicySnapshot( + syntheticSnapshot, + syntheticSnapshotExpected, + ); + const syntheticSnapshotBinding = + supervisorSwarmCollaborationPolicySnapshotBinding(syntheticSnapshot); + const syntheticSnapshotBindingInspection = + validateSupervisorSwarmCollaborationPolicySnapshotBinding( + syntheticSnapshotBinding, + syntheticSnapshot, + ); + const syntheticSnapshotBytes = JSON.stringify(syntheticSnapshot); + const syntheticSnapshotBindingBytes = JSON.stringify( + syntheticSnapshotBinding, + ); + const syntheticDriftedPolicy = driftedSupervisorSwarmCollaborationPolicy(); + state.initialRunId = syntheticSnapshotExpected.parentRunId; + const syntheticDriftObservationCount = + supervisorSwarmCollaborationPolicyDriftObservationCount([ + { + recordType: 'agent.runtime.tool_observation', + agentId: projectSupervisorAgentId, + runId: syntheticSnapshotExpected.parentRunId, + tool: 'agent.run_status', + status: 'ok', + summary: '项目协作策略已漂移,当前父 run 继续使用已绑定快照', + }, + ]); + state.initialRunId = originalInitialRunId; + let syntheticSnapshotTamperRejected = false; + try { + validateSupervisorSwarmCollaborationPolicySnapshot( + { ...syntheticSnapshot, snapshotFingerprint: '0'.repeat(64) }, + syntheticSnapshotExpected, + ); + } catch (error) { + syntheticSnapshotTamperRejected = + error?.code === 'supervisor-swarm-collaboration-policy-snapshot-invalid'; + } + let syntheticSnapshotBindingTamperRejected = false; + try { + validateSupervisorSwarmCollaborationPolicySnapshotBinding( + { ...syntheticSnapshotBinding, boundAt: syntheticSnapshot.boundAt + 1 }, + syntheticSnapshot, + ); + } catch (error) { + syntheticSnapshotBindingTamperRejected = + error?.code === + 'supervisor-swarm-collaboration-policy-snapshot-binding-invalid'; + } + const syntheticMissingSnapshotSafe = + inspectSupervisorSwarmCollaborationPolicySnapshot( + null, + syntheticSnapshotExpected, + ).identityHashMatched === false; + const syntheticMissingBindingSafe = + inspectSupervisorSwarmCollaborationPolicySnapshotBinding( + null, + syntheticSnapshot, + ).snapshotMatched === false; + const syntheticSnapshotDuplicateCount = + duplicateSupervisorSwarmCollaborationPolicySnapshotCount([ + syntheticSnapshot, + ]); + const syntheticSnapshotBindingDuplicateCount = + duplicateSupervisorSwarmCollaborationPolicySnapshotBindingCount([ + syntheticSnapshotBinding, + ]); + const syntheticEnospcDiagnostic = safeProcessFailureDiagnostic({ + code: 1, + stderr: 'No space left on device (os error 28)', + }); + const syntheticChatSessionFailureEvidence = buildSupervisorSwarmEvidence({ + chatSessionUnexpectedlyClosed: true, + chatSessionFailureKind: syntheticEnospcDiagnostic.failureKind, + chatSessionExitCode: syntheticEnospcDiagnostic.exitCode, + chatSessionCloseSignal: syntheticEnospcDiagnostic.signal, + chatSessionProcessErrorCode: syntheticEnospcDiagnostic.processErrorCode, + chatSessionStderrChars: syntheticEnospcDiagnostic.stderrChars, + chatSessionStderrSha256: syntheticEnospcDiagnostic.stderrSha256, + }); state.suite = supervisorSwarmCollaborationPolicyMixedRecoverySuite; const recoveryPolicy = expectedSupervisorSwarmCollaborationPolicy(); const recoveryGroupCount = supervisorSwarmExpectedIsolatedReviewGroups().length; + state.suite = supervisorSwarmAutonomousChatSuite; + const syntheticSnapshotBindingBatchSuiteIndependent = + supervisorSwarmInitialBatchBindsPolicySnapshot( + syntheticSnapshotBindingBatch, + syntheticSnapshotExpected.parentRunId, + ); state.suite = originalSuite; + assert( + syntheticSnapshotInspection.identityHashMatched && + syntheticSnapshotBindingInspection.snapshotMatched && + syntheticSnapshotBytes === JSON.stringify(syntheticSnapshot) && + syntheticSnapshotBindingBytes === + JSON.stringify(syntheticSnapshotBinding) && + multiGroupPolicy.minIsolatedGroupsBeforeClaim === 2 && + syntheticDriftedPolicy.minIsolatedGroupsBeforeClaim === 1 && + syntheticDriftObservationCount === 1 && + syntheticSnapshotTamperRejected && + syntheticSnapshotBindingTamperRejected && + syntheticMissingSnapshotSafe && + syntheticMissingBindingSafe && + syntheticSnapshotDuplicateCount === 0 && + syntheticSnapshotBindingDuplicateCount === 0 && + syntheticSnapshotBindingBatchAccepted && + syntheticSnapshotBindingBatchRejections && + syntheticSnapshotBindingBatchSuiteIndependent && + syntheticEnospcDiagnostic.failureKind === 'enospc' && + syntheticEnospcDiagnostic.stderrChars > 0 && + /^[0-9a-f]{64}$/u.test(syntheticEnospcDiagnostic.stderrSha256) && + syntheticChatSessionFailureEvidence.chatSessionUnexpectedlyClosed === + true && + syntheticChatSessionFailureEvidence.chatSessionFailureKind === + 'enospc', + 'agent-runtime-real-e2e-self-test-collaboration-policy-snapshot-binding-invalid', + ); assert( JSON.stringify( syntheticMixedEntries.map(({ groupIndex }) => groupIndex), @@ -29638,9 +31209,7 @@ function runAgentRuntimeRealE2eSelfTests() { ) === -1 && JSON.stringify(syntheticReadyGroupIds) === JSON.stringify( - syntheticMixedGroups - .map((group) => group.delegationGroupId) - .sort(), + syntheticMixedGroups.map((group) => group.delegationGroupId).sort(), ) && syntheticObservedJoinClaim.actionId === 'synthetic-claim-action' && syntheticWriteScopeRoots.length === @@ -29650,17 +31219,11 @@ function runAgentRuntimeRealE2eSelfTests() { syntheticOverlappingWriteScopesRejected && syntheticFollowupOrderValidated && multiGroupPolicy.minIsolatedChildren === 2 && - Object.hasOwn( - multiGroupPolicy, - 'minIsolatedGroupsBeforeClaim', - ) && + Object.hasOwn(multiGroupPolicy, 'minIsolatedGroupsBeforeClaim') && multiGroupPolicy.minIsolatedGroupsBeforeClaim === 2 && multiGroupCount === 2 && recoveryPolicy.minIsolatedChildren === 3 && - !Object.hasOwn( - recoveryPolicy, - 'minIsolatedGroupsBeforeClaim', - ) && + !Object.hasOwn(recoveryPolicy, 'minIsolatedGroupsBeforeClaim') && (recoveryPolicy.minIsolatedGroupsBeforeClaim ?? 0) === 0 && recoveryGroupCount === 1, 'agent-runtime-real-e2e-self-test-mixed-isolated-groups-invalid', @@ -29730,10 +31293,12 @@ function runAgentRuntimeRealE2eSelfTests() { fullRepairEvidence.toolPlanRepairCount === 8 && fullRepairEvidence.toolPlanRepairedLoopCount === 7 && fullRepairEvidence.toolPlanSecondRepairCount === 1 && - JSON.stringify(fullRepairEvidence.toolPlanRepairCountsByProtocolErrorKind) === - JSON.stringify(expectedRepairCounts) && - sumObjectValues(fullRepairEvidence.toolPlanRepairCountsByProtocolErrorKind) === - fullRepairEvidence.toolPlanRepairCount && + JSON.stringify( + fullRepairEvidence.toolPlanRepairCountsByProtocolErrorKind, + ) === JSON.stringify(expectedRepairCounts) && + sumObjectValues( + fullRepairEvidence.toolPlanRepairCountsByProtocolErrorKind, + ) === fullRepairEvidence.toolPlanRepairCount && fullRepairEvidence.toolPlanAuditPayloadLeakCount === 0 && toolPlanRepairEvidenceHasNoFatalLocalRepair(fullRepairEvidence) && !toolPlanRepairEvidenceHasNoFatalLocalRepair(fatalRepairEvidence), @@ -29791,6 +31356,11 @@ function runAgentRuntimeRealE2eSelfTests() { suite: 'agent-runtime-real-e2e-self-test', providerUsed: false, exactSecretCounts, + recoveredRepairConfirmationLifecycleValidated: true, + modernRecoveredRepairConfirmationLifecycleValidated: true, + legacyRecoveredRepairConfirmationLifecycleValidated: true, + recoveredRepairConfirmationConflictRejected: true, + recoveredRepairConfirmationModeAndOrderValidated: true, dynamicPrivateBodyCount: expectedPrivateValues.length, evidenceMetadataExcluded: true, genericBoundaryTermsExcluded: true, @@ -29801,6 +31371,24 @@ function runAgentRuntimeRealE2eSelfTests() { mixedFollowupBeforeClaimOrderValidated: true, legacySingleIsolatedGroupTopologyValidated: true, mixedSuitePolicyIsolationValidated: true, + collaborationPolicySnapshotCaptured: true, + collaborationPolicySnapshotStable: true, + collaborationPolicySnapshotBindingCaptured: true, + collaborationPolicySnapshotBindingStable: true, + durableSnapshotEligibilityAndContractBindingValidated: true, + sourceEndpointAbsentLifecycleGuardValidated, + collaborationPolicyDriftFixtureValidated: true, + collaborationPolicyDriftStatusObserved: true, + duplicateCollaborationPolicySnapshotCount: syntheticSnapshotDuplicateCount, + duplicateCollaborationPolicySnapshotBindingCount: + syntheticSnapshotBindingDuplicateCount, + collaborationPolicySnapshotTamperRejected: syntheticSnapshotTamperRejected, + collaborationPolicySnapshotBindingTamperRejected: + syntheticSnapshotBindingTamperRejected, + collaborationPolicyPartialMissingEvidenceSafe: + syntheticMissingSnapshotSafe && syntheticMissingBindingSafe, + enospcFailureDiagnosticClassified: true, + chatSessionFailureEvidenceSchemaValidated: true, toolPlanRepairCount: fullRepairEvidence.toolPlanRepairCount, toolPlanRepairedLoopCount: fullRepairEvidence.toolPlanRepairedLoopCount, toolPlanSecondRepairCount: fullRepairEvidence.toolPlanSecondRepairCount, @@ -29835,7 +31423,29 @@ function recordError(code, error) { error instanceof Error ? `${error.name}:${error.message}` : String(error ?? code); - state.errors.push({ code, detailHash: hashValue(redactSecrets(detail)) }); + state.errors.push({ + code, + detailHash: hashValue(redactSecrets(detail)), + safeFailureDiagnostic: error?.safeFailureDiagnostic ?? null, + }); +} + +function summarizeRecordedError(error) { + const diagnostic = error.safeFailureDiagnostic; + return { + code: error.code, + detailHash: error.detailHash, + ...(diagnostic + ? { + failureKind: diagnostic.failureKind, + exitCode: diagnostic.exitCode, + signal: diagnostic.signal, + processErrorCode: diagnostic.processErrorCode, + stderrChars: diagnostic.stderrChars, + stderrSha256: diagnostic.stderrSha256, + } + : {}), + }; } function redactSecrets(value) { 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 fcf987fc0..bc1e974d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -3646,6 +3646,44 @@ fn game_creator_agent_runtime_task_is_terminal_for_cancel( || (task.status == "failed" && task.phase != "needs-reconciliation" && !has_pending_action) } +pub(crate) fn game_creator_agent_runtime_run_is_non_terminal_for_collaboration_migration_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + if let Some(task) = + read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + { + return Ok(matches!( + task.status.as_str(), + "pending" | "running" | "waiting-for-confirmation" | "waiting-for-user-input" + ) && !matches!( + task.phase.as_str(), + "completed" + | "failed" + | "cancelled" + | "cancelling" + | "finalizing" + | "needs-reconciliation" + )); + } + let runtime = read_game_creator_agent_runtime_at(root, agent_id)?; + Ok(runtime.state.run_id == run_id + && matches!( + runtime.state.status.as_str(), + "pending" | "running" | "waiting-for-confirmation" | "waiting-for-user-input" + ) + && !matches!( + runtime.state.phase.as_str(), + "completed" + | "failed" + | "cancelled" + | "cancelling" + | "finalizing" + | "needs-reconciliation" + )) +} + fn resolve_game_creator_agent_runtime_cancel_target( root: &Path, agent_id: &str, @@ -13872,7 +13910,12 @@ pub(crate) async fn prepare_game_creator_agent_runtime_provider_action_batch( let (collaboration_policy, collaboration_state, collaboration_preflight) = if runtime.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - let collaboration_policy = read_supervisor_collaboration_policy_at(root)?; + let collaboration_policy = resolve_supervisor_collaboration_policy_for_run_at( + root, + &runtime.agent_id, + &runtime.run_id, + )? + .policy; let collaboration_state = read_supervisor_collaboration_state_at(root, &runtime.agent_id, &runtime.run_id)?; let collaboration_preflight = preflight_supervisor_collaboration_plan( @@ -15923,8 +15966,8 @@ fn supervisor_orchestrator_mutation_block_at( { return None; } - let policy = match read_supervisor_collaboration_policy_at(root) { - Ok(policy) => policy, + let policy = match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { + Ok(resolution) => resolution.policy, Err(error) => { return Some(AgentRuntimeToolObservation { tool: tool.to_string(), @@ -15980,8 +16023,8 @@ async fn supervisor_orchestrator_mcp_mutation_block_at( { return None; } - let policy = match read_supervisor_collaboration_policy_at(root) { - Ok(policy) => policy, + let policy = match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { + Ok(resolution) => resolution.policy, Err(error) => { return Some(AgentRuntimeToolObservation { tool: GAME_CREATOR_MCP_CALL_TOOL.to_string(), @@ -16433,8 +16476,8 @@ fn supervisor_collaboration_policy_completion_blocker_at_locked( if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return None; } - let policy = match read_supervisor_collaboration_policy_at(root) { - Ok(policy) => policy, + let policy = match resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id) { + Ok(resolution) => resolution.policy, Err(error) => { return Some(AgentRuntimeToolObservation { tool: "runtime.collaboration_policy".to_string(), @@ -16466,6 +16509,15 @@ fn supervisor_collaboration_policy_completion_blocker_at_locked( }) } +#[cfg(test)] +pub(crate) fn supervisor_collaboration_policy_completion_blocker_for_test_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Option { + supervisor_collaboration_policy_completion_blocker_at_locked(root, agent_id, run_id) +} + fn isolated_join_barrier_has_waiting_groups(detail: &str) -> bool { detail .split_whitespace() @@ -20035,7 +20087,7 @@ fn build_game_creator_agent_background_tool_plan_request( let tool_policy_json = serde_json::to_string_pretty(&tool_policy) .map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?; let collaboration_policy_json = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - render_supervisor_collaboration_policy_for_prompt_at(root)? + render_supervisor_collaboration_policy_for_prompt_at(root, agent_id, run_id)? } else { "null".to_string() }; @@ -21299,7 +21351,7 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str } } -fn agent_runtime_confirmation_path_component(value: &str, fallback: &str) -> String { +pub(crate) fn agent_runtime_confirmation_path_component(value: &str, fallback: &str) -> String { let normalized = value .trim() .chars() @@ -21725,6 +21777,16 @@ fn agent_runtime_provider_action_batch_id( fn validate_game_creator_agent_runtime_provider_action_batch( root: &Path, batch: &AgentRuntimeProviderActionBatch, +) -> Result<(), String> { + validate_game_creator_agent_runtime_provider_action_batch_with_recovery_policy( + root, batch, None, + ) +} + +fn validate_game_creator_agent_runtime_provider_action_batch_with_recovery_policy( + root: &Path, + batch: &AgentRuntimeProviderActionBatch, + recovery_policy: Option<&SupervisorCollaborationPolicy>, ) -> Result<(), String> { if !matches!( batch.schema_version.as_str(), @@ -21904,7 +21966,17 @@ fn validate_game_creator_agent_runtime_provider_action_batch( _ => {} } if batch.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { - let policy = read_supervisor_collaboration_policy_at(root)?; + let policy = match recovery_policy { + Some(policy) => policy.clone(), + None => { + resolve_supervisor_collaboration_policy_for_run_at( + root, + &batch.agent_id, + &batch.run_id, + )? + .policy + } + }; let state = read_supervisor_collaboration_state_at(root, &batch.agent_id, &batch.run_id)?; if let Some(contract) = batch.collaboration_contract.as_ref() { if batch.schema_version != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION { @@ -21986,6 +22058,11 @@ pub(crate) fn write_game_creator_agent_runtime_provider_action_batch( batch: &AgentRuntimeProviderActionBatch, ) -> Result<(), String> { validate_game_creator_agent_runtime_provider_action_batch(root, batch)?; + let requires_initial_snapshot = batch.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && batch.status != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED + && batch.collaboration_contract.is_some() + && read_supervisor_collaboration_policy_snapshot_at(root, &batch.agent_id, &batch.run_id)? + .is_none(); write_agent_runtime_json_sidecar_with_max_bytes( root, &game_creator_agent_runtime_provider_action_batch_relative_path( @@ -21995,7 +22072,42 @@ pub(crate) fn write_game_creator_agent_runtime_provider_action_batch( "Agent Runtime Provider action 批次", batch, AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SIDECAR_MAX_BYTES, - ) + )?; + if requires_initial_snapshot { + fail_supervisor_collaboration_policy_snapshot_after_batch_for_test_at(root)?; + let contract = batch + .collaboration_contract + .as_ref() + .ok_or_else(|| "Project Supervisor 首个协作批次缺少合同".to_string())?; + bind_supervisor_collaboration_policy_snapshot_for_new_batch_at( + root, + &batch.agent_id, + &batch.run_id, + &contract.policy, + )?; + validate_game_creator_agent_runtime_provider_action_batch(root, batch)?; + } + Ok(()) +} + +#[cfg(test)] +fn fail_supervisor_collaboration_policy_snapshot_after_batch_for_test_at( + root: &Path, +) -> Result<(), String> { + if root + .join(".agent/runtime/test-collaboration-policy-snapshot-after-batch-failpoint") + .exists() + { + return Err("测试断点:Provider action batch 已持久化,协作策略快照尚未绑定".to_string()); + } + Ok(()) +} + +#[cfg(not(test))] +fn fail_supervisor_collaboration_policy_snapshot_after_batch_for_test_at( + _root: &Path, +) -> Result<(), String> { + Ok(()) } pub(crate) fn read_game_creator_agent_runtime_provider_action_batch( @@ -22015,10 +22127,101 @@ pub(crate) fn read_game_creator_agent_runtime_provider_action_batch( if batch.agent_id != agent_id || batch.run_id != run_id { return Err("Agent Runtime Provider action 批次 Agent 或 run 身份不匹配".to_string()); } + if batch.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { + if let Some(contract) = batch.collaboration_contract.as_ref() { + if read_supervisor_collaboration_policy_snapshot_at( + root, + &batch.agent_id, + &batch.run_id, + )? + .is_none() + { + validate_game_creator_agent_runtime_provider_action_batch_with_recovery_policy( + root, + &batch, + Some(&contract.policy), + )?; + let can_recover_aborted_snapshot = batch.status + != AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED + || read_supervisor_collaboration_policy_snapshot_binding_at( + root, + &batch.agent_id, + &batch.run_id, + )? + .is_some(); + if can_recover_aborted_snapshot { + recover_supervisor_collaboration_policy_snapshot_from_batch_at( + root, + &batch.agent_id, + &batch.run_id, + &contract.policy, + )?; + } else { + return Ok(batch); + } + } + } + } validate_game_creator_agent_runtime_provider_action_batch(root, &batch)?; Ok(batch) } +pub(crate) fn recover_supervisor_collaboration_policy_snapshot_from_pending_batch_if_any_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result, String> { + if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || !game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id) + { + return Ok(None); + } + if let Some(snapshot) = + read_supervisor_collaboration_policy_snapshot_at(root, agent_id, run_id)? + { + return Ok(Some(snapshot)); + } + let relative_path = + game_creator_agent_runtime_provider_action_batch_relative_path(agent_id, run_id); + let batch = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &relative_path, + "Agent Runtime Provider action 批次", + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SIDECAR_MAX_BYTES, + )? + .ok_or_else(|| "Agent Runtime Provider action 批次不存在".to_string())?; + if batch.agent_id != agent_id || batch.run_id != run_id { + return Err("Agent Runtime Provider action 批次 Agent 或 run 身份不匹配".to_string()); + } + let Some(contract) = batch.collaboration_contract.as_ref() else { + if supervisor_collaboration_actions_require_contract(&batch.plan.actions)? { + return Err( + "旧版或缺失 collaborationContract 的 Project Supervisor 协作批次不能绑定当前项目策略;需要进入人工核对" + .to_string(), + ); + } + return Ok(None); + }; + validate_game_creator_agent_runtime_provider_action_batch_with_recovery_policy( + root, + &batch, + Some(&contract.policy), + )?; + if batch.status == AGENT_RUNTIME_PROVIDER_ACTION_BATCH_STATUS_ABORTED + && read_supervisor_collaboration_policy_snapshot_binding_at(root, agent_id, run_id)? + .is_none() + { + return Ok(None); + } + recover_supervisor_collaboration_policy_snapshot_from_batch_at( + root, + agent_id, + run_id, + &contract.policy, + ) + .map(Some) +} + fn remove_game_creator_agent_runtime_provider_action_batch( root: &Path, agent_id: &str, @@ -30317,6 +30520,14 @@ pub(crate) fn observe_agent_runtime_run_status( }) } .and_then(|mut detail| { + let collaboration_policy_status = (agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .then(|| { + supervisor_collaboration_policy_status_for_run_at(root, agent_id, run_id) + .unwrap_or_else(|_| "unavailable".to_string()) + }); + if let Some(policy_status) = collaboration_policy_status.as_deref() { + detail = format!("collaborationPolicy: {policy_status}\n\n{detail}"); + } let claim_action_id = action_id.map(str::trim).filter(|value| !value.is_empty()); let static_delegate_output_may_be_present = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { @@ -30477,6 +30688,7 @@ pub(crate) fn observe_agent_runtime_run_status( claimed_join_count, ready_delegate_count, claimed_delegate_count, + collaboration_policy_status, )) }); match result { @@ -30486,6 +30698,7 @@ pub(crate) fn observe_agent_runtime_run_status( claimed_join_count, ready_delegate_count, claimed_delegate_count, + collaboration_policy_status, )) => { let count = if is_all_scope { detail.matches("agentId: ").count() @@ -30516,6 +30729,17 @@ pub(crate) fn observe_agent_runtime_run_status( ",已有 {claimed_delegate_count} 个专业 Agent 回执被当前父 run 认领;语义复核或返工前按 delegationId 重读权威合同" )); } + if collaboration_policy_status + .as_deref() + .is_some_and(|status| status.contains("projectPolicyStatus=drifted")) + { + summary.push_str(",项目协作策略已漂移,当前父 run 继续使用已绑定快照"); + } else if collaboration_policy_status + .as_deref() + .is_some_and(|status| status.contains("projectPolicyStatus=unreadable")) + { + summary.push_str(",项目协作策略当前不可读,当前父 run 继续使用已绑定快照"); + } AgentRuntimeToolObservation { tool: "agent.run_status".to_string(), status: "ok".to_string(), @@ -30591,17 +30815,16 @@ fn ensure_supervisor_isolated_join_claim_policy_ready_at( if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return Ok(()); } - let policy = read_supervisor_collaboration_policy_at(root)?; - let required_group_count = policy.min_isolated_groups_before_claim; - if required_group_count == 0 { - return Ok(()); - } if let Some(action_id) = action_id { if read_isolated_join_claim_at(root, parent_agent_id, parent_run_id, action_id)?.is_some() { return Ok(()); } } - if claimed_isolated_join_count_for_parent_at(root, parent_agent_id, parent_run_id)? > 0 { + let policy = + resolve_supervisor_collaboration_policy_for_run_at(root, parent_agent_id, parent_run_id)? + .policy; + let required_group_count = policy.min_isolated_groups_before_claim; + if required_group_count == 0 { return Ok(()); } let state = read_supervisor_collaboration_state_at(root, parent_agent_id, parent_run_id)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs index 8587ea7df..c8e28ad0a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -6,9 +6,19 @@ const SUPERVISOR_COLLABORATION_POLICY_SCHEMA_VERSION: &str = "game-creator-supervisor-collaboration-policy.v1"; const SUPERVISOR_COLLABORATION_CONTRACT_SCHEMA_VERSION: &str = "game-creator-supervisor-collaboration-contract.v1"; +const SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_SCHEMA_VERSION: &str = + "game-creator-supervisor-collaboration-policy-snapshot.v1"; +const SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_BINDING_SCHEMA_VERSION: &str = + "game-creator-supervisor-collaboration-policy-snapshot-binding.v1"; pub(crate) const SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH: &str = ".agent/collaboration-policy.json"; const SUPERVISOR_COLLABORATION_POLICY_MAX_BYTES: usize = 16 * 1024; +const SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_MAX_BYTES: usize = 24 * 1024; +const SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_BINDING_MAX_BYTES: usize = 8 * 1024; +const SUPERVISOR_COLLABORATION_POLICY_BINDING_INITIAL_BATCH: &str = "initial-collaboration-batch"; +const SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_BATCH: &str = "legacy-provider-batch-contract"; +const SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT: &str = + "legacy-current-project-policy"; const SUPERVISOR_COLLABORATION_MAX_STATIC_DELEGATES: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_CHILDREN: usize = 3; const SUPERVISOR_COLLABORATION_MAX_ISOLATED_GROUPS_BEFORE_CLAIM: usize = 16; @@ -83,6 +93,44 @@ pub(crate) struct SupervisorCollaborationContract { pub(crate) contract_fingerprint: String, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SupervisorCollaborationPolicySnapshot { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) parent_agent_id: String, + pub(crate) parent_run_id: String, + pub(crate) bound_from: String, + pub(crate) policy: SupervisorCollaborationPolicy, + pub(crate) policy_fingerprint: String, + pub(crate) snapshot_fingerprint: String, + pub(crate) bound_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct SupervisorCollaborationPolicySnapshotBinding { + pub(crate) schema_version: String, + pub(crate) project_id: String, + pub(crate) parent_agent_id: String, + pub(crate) parent_run_id: String, + pub(crate) bound_from: String, + pub(crate) policy_fingerprint: String, + pub(crate) snapshot_fingerprint: String, + pub(crate) bound_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct SupervisorCollaborationPolicyResolution { + pub(crate) policy: SupervisorCollaborationPolicy, + pub(crate) policy_fingerprint: String, + pub(crate) snapshot_fingerprint: Option, + pub(crate) binding_source: Option, + pub(crate) source: &'static str, + pub(crate) project_policy_status: &'static str, + pub(crate) current_project_policy_fingerprint: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct SupervisorCollaborationViolation { pub(crate) summary: String, @@ -151,11 +199,555 @@ pub(crate) fn write_supervisor_collaboration_policy_at( pub(crate) fn render_supervisor_collaboration_policy_for_prompt_at( root: &Path, + parent_agent_id: &str, + parent_run_id: &str, ) -> Result { - serde_json::to_string_pretty(&read_supervisor_collaboration_policy_at(root)?) + let resolution = + resolve_supervisor_collaboration_policy_for_run_at(root, parent_agent_id, parent_run_id)?; + serde_json::to_string_pretty(&resolution.policy) .map_err(|error| format!("序列化 Project Supervisor 协作策略失败:{error}")) } +fn supervisor_collaboration_policy_snapshot_path_component(value: &str, fallback: &str) -> String { + let normalized = agent_runtime_confirmation_path_component(value, fallback); + if normalized == value { + return normalized; + } + let readable = normalized.chars().take(80).collect::(); + format!("{readable}--{:x}", Sha256::digest(value.as_bytes())) +} + +fn supervisor_collaboration_policy_snapshot_relative_path( + parent_agent_id: &str, + parent_run_id: &str, +) -> String { + format!( + ".agent/runtime/collaboration-policy-snapshots/{}/{}.json", + supervisor_collaboration_policy_snapshot_path_component(parent_agent_id, "agent"), + supervisor_collaboration_policy_snapshot_path_component(parent_run_id, "run") + ) +} + +fn supervisor_collaboration_policy_snapshot_binding_relative_path( + parent_agent_id: &str, + parent_run_id: &str, +) -> String { + format!( + ".agent/runtime/collaboration-policy-snapshot-bindings/{}/{}.json", + supervisor_collaboration_policy_snapshot_path_component(parent_agent_id, "agent"), + supervisor_collaboration_policy_snapshot_path_component(parent_run_id, "run") + ) +} + +fn supervisor_collaboration_policy_snapshot_lock_id( + parent_agent_id: &str, + parent_run_id: &str, +) -> String { + format!( + "snapshot-{:x}", + Sha256::digest(format!("{parent_agent_id}\0{parent_run_id}").as_bytes()) + ) +} + +pub(crate) fn supervisor_collaboration_policy_snapshot_path( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> PathBuf { + root.join(supervisor_collaboration_policy_snapshot_relative_path( + parent_agent_id, + parent_run_id, + )) +} + +pub(crate) fn supervisor_collaboration_policy_snapshot_binding_path( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> PathBuf { + root.join( + supervisor_collaboration_policy_snapshot_binding_relative_path( + parent_agent_id, + parent_run_id, + ), + ) +} + +fn supervisor_collaboration_policy_snapshot_fingerprint( + snapshot: &SupervisorCollaborationPolicySnapshot, +) -> Result { + let identity = serde_json::to_vec(&serde_json::json!({ + "schemaVersion": snapshot.schema_version, + "projectId": snapshot.project_id, + "parentAgentId": snapshot.parent_agent_id, + "parentRunId": snapshot.parent_run_id, + "boundFrom": snapshot.bound_from, + "policy": snapshot.policy, + "policyFingerprint": snapshot.policy_fingerprint, + })) + .map_err(|error| format!("序列化 Project Supervisor 协作策略快照指纹失败:{error}"))?; + Ok(format!("{:x}", Sha256::digest(identity))) +} + +fn validate_supervisor_collaboration_policy_snapshot( + root: &Path, + snapshot: &SupervisorCollaborationPolicySnapshot, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result<(), String> { + if snapshot.schema_version != SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_SCHEMA_VERSION { + return Err(format!( + "不支持的 Project Supervisor 协作策略快照版本:{}", + snapshot.schema_version + )); + } + if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || snapshot.parent_agent_id != parent_agent_id + || snapshot.parent_run_id != parent_run_id + || parent_run_id.trim().is_empty() + { + return Err("Project Supervisor 协作策略快照父 run 身份不匹配".to_string()); + } + if snapshot.project_id != game_creator_agent_runtime_context_project_id(root)? { + return Err("Project Supervisor 协作策略快照项目身份不匹配".to_string()); + } + if !matches!( + snapshot.bound_from.as_str(), + SUPERVISOR_COLLABORATION_POLICY_BINDING_INITIAL_BATCH + | SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_BATCH + | SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT + ) { + return Err("Project Supervisor 协作策略快照绑定来源无效".to_string()); + } + let normalized_policy = normalize_supervisor_collaboration_policy(snapshot.policy.clone())?; + if normalized_policy != snapshot.policy { + return Err("Project Supervisor 协作策略快照包含未规范化策略".to_string()); + } + let policy_fingerprint = supervisor_collaboration_policy_fingerprint(&snapshot.policy)?; + if snapshot.policy_fingerprint != policy_fingerprint { + return Err("Project Supervisor 协作策略快照的策略指纹不匹配".to_string()); + } + if snapshot.bound_at == 0 + || snapshot.snapshot_fingerprint + != supervisor_collaboration_policy_snapshot_fingerprint(snapshot)? + { + return Err("Project Supervisor 协作策略快照身份指纹已变化".to_string()); + } + Ok(()) +} + +fn supervisor_collaboration_policy_snapshot_binding( + snapshot: &SupervisorCollaborationPolicySnapshot, +) -> SupervisorCollaborationPolicySnapshotBinding { + SupervisorCollaborationPolicySnapshotBinding { + schema_version: SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_BINDING_SCHEMA_VERSION.to_string(), + project_id: snapshot.project_id.clone(), + parent_agent_id: snapshot.parent_agent_id.clone(), + parent_run_id: snapshot.parent_run_id.clone(), + bound_from: snapshot.bound_from.clone(), + policy_fingerprint: snapshot.policy_fingerprint.clone(), + snapshot_fingerprint: snapshot.snapshot_fingerprint.clone(), + bound_at: snapshot.bound_at, + } +} + +fn validate_supervisor_collaboration_policy_snapshot_binding( + root: &Path, + binding: &SupervisorCollaborationPolicySnapshotBinding, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result<(), String> { + if binding.schema_version != SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_BINDING_SCHEMA_VERSION { + return Err(format!( + "不支持的 Project Supervisor 协作策略快照绑定记录版本:{}", + binding.schema_version + )); + } + if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || binding.parent_agent_id != parent_agent_id + || binding.parent_run_id != parent_run_id + || parent_run_id.trim().is_empty() + { + return Err("Project Supervisor 协作策略快照绑定记录父 run 身份不匹配".to_string()); + } + if binding.project_id != game_creator_agent_runtime_context_project_id(root)? { + return Err("Project Supervisor 协作策略快照绑定记录项目身份不匹配".to_string()); + } + if !matches!( + binding.bound_from.as_str(), + SUPERVISOR_COLLABORATION_POLICY_BINDING_INITIAL_BATCH + | SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_BATCH + | SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT + ) || !matches!(binding.policy_fingerprint.len(), 64) + || !binding + .policy_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || !matches!(binding.snapshot_fingerprint.len(), 64) + || !binding + .snapshot_fingerprint + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + || binding.bound_at == 0 + { + return Err("Project Supervisor 协作策略快照绑定记录内容无效".to_string()); + } + Ok(()) +} + +pub(crate) fn read_supervisor_collaboration_policy_snapshot_binding_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + let relative_path = supervisor_collaboration_policy_snapshot_binding_relative_path( + parent_agent_id, + parent_run_id, + ); + let Some(binding) = read_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "Project Supervisor 协作策略快照绑定记录", + SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_BINDING_MAX_BYTES, + )? + else { + return Ok(None); + }; + validate_supervisor_collaboration_policy_snapshot_binding( + root, + &binding, + parent_agent_id, + parent_run_id, + )?; + Ok(Some(binding)) +} + +fn write_supervisor_collaboration_policy_snapshot_binding_at( + root: &Path, + snapshot: &SupervisorCollaborationPolicySnapshot, +) -> Result { + let binding = supervisor_collaboration_policy_snapshot_binding(snapshot); + let relative_path = supervisor_collaboration_policy_snapshot_binding_relative_path( + &snapshot.parent_agent_id, + &snapshot.parent_run_id, + ); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "Project Supervisor 协作策略快照绑定记录", + &binding, + SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_BINDING_MAX_BYTES, + )?; + let persisted = read_supervisor_collaboration_policy_snapshot_binding_at( + root, + &snapshot.parent_agent_id, + &snapshot.parent_run_id, + )? + .ok_or_else(|| "Project Supervisor 协作策略快照绑定记录写入后不存在".to_string())?; + if persisted != binding { + return Err("Project Supervisor 协作策略快照绑定记录并发写入后内容冲突".to_string()); + } + Ok(binding) +} + +pub(crate) fn read_supervisor_collaboration_policy_snapshot_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result, String> { + let relative_path = + supervisor_collaboration_policy_snapshot_relative_path(parent_agent_id, parent_run_id); + let Some(snapshot) = read_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "Project Supervisor 协作策略快照", + SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_MAX_BYTES, + )? + else { + return Ok(None); + }; + validate_supervisor_collaboration_policy_snapshot( + root, + &snapshot, + parent_agent_id, + parent_run_id, + )?; + Ok(Some(snapshot)) +} + +pub(crate) fn bind_supervisor_collaboration_policy_snapshot_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + policy: &SupervisorCollaborationPolicy, + bound_from: &str, +) -> Result { + let policy = normalize_supervisor_collaboration_policy(policy.clone())?; + if !matches!( + bound_from, + SUPERVISOR_COLLABORATION_POLICY_BINDING_INITIAL_BATCH + | SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_BATCH + | SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT + ) { + return Err("Project Supervisor 协作策略快照绑定来源无效".to_string()); + } + if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || parent_run_id.trim().is_empty() + { + return Err("只能为有效的 Project Supervisor 父 run 绑定协作策略快照".to_string()); + } + let lock_id = supervisor_collaboration_policy_snapshot_lock_id(parent_agent_id, parent_run_id); + let _binding_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( + root, + &lock_id, + "collaboration-policy-snapshot", + )? + .ok_or_else(|| "Project Supervisor 协作策略快照正被其他进程绑定".to_string())?; + let existing_binding = read_supervisor_collaboration_policy_snapshot_binding_at( + root, + parent_agent_id, + parent_run_id, + )?; + if let Some(existing) = + read_supervisor_collaboration_policy_snapshot_at(root, parent_agent_id, parent_run_id)? + { + if existing.policy != policy { + return Err("Project Supervisor 协作策略快照已绑定且与待恢复策略冲突".to_string()); + } + let expected_binding = supervisor_collaboration_policy_snapshot_binding(&existing); + match existing_binding { + Some(binding) if binding != expected_binding => { + return Err("Project Supervisor 协作策略快照与绑定记录冲突".to_string()); + } + Some(_) => {} + None => { + write_supervisor_collaboration_policy_snapshot_binding_at(root, &existing)?; + } + } + return Ok(existing); + } + let policy_fingerprint = supervisor_collaboration_policy_fingerprint(&policy)?; + let (effective_bound_from, bound_at) = if let Some(binding) = existing_binding.as_ref() { + if binding.policy_fingerprint != policy_fingerprint { + return Err("Project Supervisor 协作策略快照绑定记录与待恢复策略冲突".to_string()); + } + let existing_batch_derived = matches!( + binding.bound_from.as_str(), + SUPERVISOR_COLLABORATION_POLICY_BINDING_INITIAL_BATCH + | SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_BATCH + ); + let requested_batch_derived = matches!( + bound_from, + SUPERVISOR_COLLABORATION_POLICY_BINDING_INITIAL_BATCH + | SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_BATCH + ); + if binding.bound_from != bound_from && !(existing_batch_derived && requested_batch_derived) + { + return Err("Project Supervisor 协作策略快照绑定来源与恢复来源冲突".to_string()); + } + (binding.bound_from.clone(), binding.bound_at) + } else { + (bound_from.to_string(), unix_timestamp()) + }; + let mut snapshot = SupervisorCollaborationPolicySnapshot { + schema_version: SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_SCHEMA_VERSION.to_string(), + project_id: game_creator_agent_runtime_context_project_id(root)?, + parent_agent_id: parent_agent_id.to_string(), + parent_run_id: parent_run_id.to_string(), + bound_from: effective_bound_from, + policy_fingerprint, + policy, + snapshot_fingerprint: String::new(), + bound_at, + }; + snapshot.snapshot_fingerprint = + supervisor_collaboration_policy_snapshot_fingerprint(&snapshot)?; + if existing_binding.as_ref().is_some_and(|binding| { + binding != &supervisor_collaboration_policy_snapshot_binding(&snapshot) + }) { + return Err("Project Supervisor 协作策略快照无法按原绑定记录恢复".to_string()); + } + let relative_path = + supervisor_collaboration_policy_snapshot_relative_path(parent_agent_id, parent_run_id); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "Project Supervisor 协作策略快照", + &snapshot, + SUPERVISOR_COLLABORATION_POLICY_SNAPSHOT_MAX_BYTES, + )?; + let persisted = + read_supervisor_collaboration_policy_snapshot_at(root, parent_agent_id, parent_run_id)? + .ok_or_else(|| "Project Supervisor 协作策略快照写入后不存在".to_string())?; + if persisted != snapshot { + return Err("Project Supervisor 协作策略快照并发绑定后内容冲突".to_string()); + } + if existing_binding.is_none() { + write_supervisor_collaboration_policy_snapshot_binding_at(root, &snapshot)?; + } + Ok(snapshot) +} + +pub(crate) fn bind_supervisor_collaboration_policy_snapshot_for_new_batch_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + policy: &SupervisorCollaborationPolicy, +) -> Result { + let policy = normalize_supervisor_collaboration_policy(policy.clone())?; + bind_supervisor_collaboration_policy_snapshot_at( + root, + parent_agent_id, + parent_run_id, + &policy, + SUPERVISOR_COLLABORATION_POLICY_BINDING_INITIAL_BATCH, + ) +} + +pub(crate) fn recover_supervisor_collaboration_policy_snapshot_from_batch_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + policy: &SupervisorCollaborationPolicy, +) -> Result { + bind_supervisor_collaboration_policy_snapshot_at( + root, + parent_agent_id, + parent_run_id, + policy, + SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_BATCH, + ) +} + +pub(crate) fn resolve_supervisor_collaboration_policy_for_run_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + if let Some(snapshot) = + read_supervisor_collaboration_policy_snapshot_at(root, parent_agent_id, parent_run_id)? + { + let snapshot = bind_supervisor_collaboration_policy_snapshot_at( + root, + parent_agent_id, + parent_run_id, + &snapshot.policy, + &snapshot.bound_from, + )?; + return supervisor_collaboration_policy_resolution_from_snapshot(root, snapshot); + } + let existing_binding = read_supervisor_collaboration_policy_snapshot_binding_at( + root, + parent_agent_id, + parent_run_id, + )?; + if let Some(snapshot) = + recover_supervisor_collaboration_policy_snapshot_from_pending_batch_if_any_at( + root, + parent_agent_id, + parent_run_id, + )? + { + return supervisor_collaboration_policy_resolution_from_snapshot(root, snapshot); + } + if existing_binding.is_some() { + return Err( + "Project Supervisor 协作策略快照绑定记录存在,但快照与可信 v2 batch 均不可恢复" + .to_string(), + ); + } + let policy = read_supervisor_collaboration_policy_at(root)?; + let state = read_supervisor_collaboration_state_at(root, parent_agent_id, parent_run_id)?; + if state.has_collaboration() { + if !game_creator_agent_runtime_run_is_non_terminal_for_collaboration_migration_at( + root, + parent_agent_id, + parent_run_id, + )? { + return Err( + "只有仍在运行且没有可信 v2 contract 的旧 Project Supervisor 父 run 才能迁移当前项目策略" + .to_string(), + ); + } + let snapshot = bind_supervisor_collaboration_policy_snapshot_at( + root, + parent_agent_id, + parent_run_id, + &policy, + SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT, + )?; + return Ok(SupervisorCollaborationPolicyResolution { + policy: snapshot.policy, + policy_fingerprint: snapshot.policy_fingerprint, + snapshot_fingerprint: Some(snapshot.snapshot_fingerprint), + binding_source: Some(snapshot.bound_from), + source: "legacy-run-migration", + project_policy_status: "matched", + current_project_policy_fingerprint: Some(supervisor_collaboration_policy_fingerprint( + &policy, + )?), + }); + } + Ok(SupervisorCollaborationPolicyResolution { + policy_fingerprint: supervisor_collaboration_policy_fingerprint(&policy)?, + policy, + snapshot_fingerprint: None, + binding_source: None, + source: "project-policy-unbound", + project_policy_status: "current", + current_project_policy_fingerprint: None, + }) +} + +fn supervisor_collaboration_policy_resolution_from_snapshot( + root: &Path, + snapshot: SupervisorCollaborationPolicySnapshot, +) -> Result { + let current_project_policy = read_supervisor_collaboration_policy_at(root); + let (project_policy_status, current_project_policy_fingerprint) = match current_project_policy { + Ok(current) => { + let fingerprint = supervisor_collaboration_policy_fingerprint(¤t)?; + let status = if current == snapshot.policy { + "matched" + } else { + "drifted" + }; + (status, Some(fingerprint)) + } + Err(_) => ("unreadable", None), + }; + Ok(SupervisorCollaborationPolicyResolution { + policy: snapshot.policy, + policy_fingerprint: snapshot.policy_fingerprint, + snapshot_fingerprint: Some(snapshot.snapshot_fingerprint), + binding_source: Some(snapshot.bound_from), + source: "run-snapshot", + project_policy_status, + current_project_policy_fingerprint, + }) +} + +pub(crate) fn supervisor_collaboration_policy_status_for_run_at( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, +) -> Result { + let resolution = + resolve_supervisor_collaboration_policy_for_run_at(root, parent_agent_id, parent_run_id)?; + Ok(format!( + "source={} · bindingSource={} · policyFingerprint={} · snapshotFingerprint={} · projectPolicyStatus={} · currentProjectPolicyFingerprint={}", + resolution.source, + resolution.binding_source.as_deref().unwrap_or("none"), + resolution.policy_fingerprint, + resolution.snapshot_fingerprint.as_deref().unwrap_or("none"), + resolution.project_policy_status, + resolution + .current_project_policy_fingerprint + .as_deref() + .unwrap_or("unavailable"), + )) +} + pub(crate) fn read_supervisor_collaboration_state_at( root: &Path, parent_agent_id: &str, @@ -537,6 +1129,13 @@ fn summarize_supervisor_collaboration_actions( Ok(summary) } +pub(crate) fn supervisor_collaboration_actions_require_contract( + actions: &[AgentRuntimeToolAction], +) -> Result { + summarize_supervisor_collaboration_actions(actions) + .map(|summary| summary.has_collaboration_action()) +} + fn build_supervisor_collaboration_contract( policy: &SupervisorCollaborationPolicy, initial_wave: bool, diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index e49456587..2bc0a78eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -543,6 +543,13 @@ pub(crate) fn claim_ready_static_delegate_receipts_with_budget_at( &required_delegation_ids, max_payload_chars, )?; + if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && receipts + .iter() + .any(|receipt| !required_delegation_ids.contains(&receipt.delegation_id)) + { + resolve_supervisor_collaboration_policy_for_run_at(root, parent_agent_id, parent_run_id)?; + } let delivery_locks = acquire_static_delegate_delivery_locks_at( root, receipts @@ -635,6 +642,8 @@ fn select_static_delegate_receipt_batch( required_payload_chars, max_payload_chars )); } + // 缺失 claim journal 时只恢复已经归属当前 action 的 receipt,下一轮再认领新 Ready。 + return Ok(selected); } for receipt in receipts .iter() @@ -1742,6 +1751,14 @@ mod tests { .expect("project init"); let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; let parent_run_id = "target-summary-parent-run"; + bind_supervisor_collaboration_policy_snapshot_at( + &root, + parent_agent_id, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration snapshot"); let claimed = new_static_delegate_delivery( parent_agent_id, @@ -1850,6 +1867,14 @@ mod tests { .expect("project init"); let parent_run_id = "claim-monotonic-parent-run"; let claim_action_id = "claim-monotonic-action"; + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration snapshot"); let delivery = new_static_delegate_delivery( GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "claim-monotonic-parent-session", @@ -1932,6 +1957,14 @@ mod tests { let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; let parent_run_id = "required-recovery-parent-run"; let action_id = "required-recovery-claim-action"; + bind_supervisor_collaboration_policy_snapshot_at( + &root, + parent_agent_id, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration snapshot"); let required = new_static_delegate_delivery( parent_agent_id, "required-recovery-parent-session-with-long-identity", 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 006bbc737..43d6c3396 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -7690,6 +7690,2022 @@ fn downgrade_supervisor_collaboration_batch_to_v1_for_test( batch } +fn agent_runtime_previous_sidecar_path_for_test(path: &Path) -> PathBuf { + path.with_file_name(format!( + ".{}.previous", + path.file_name() + .and_then(|value| value.to_str()) + .expect("Agent Runtime sidecar file name") + )) +} + +fn supervisor_collaboration_policy_snapshot_paths_for_test( + root: &Path, + parent_run_id: &str, +) -> (PathBuf, PathBuf) { + let primary = supervisor_collaboration_policy_snapshot_path( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ); + let previous = agent_runtime_previous_sidecar_path_for_test(&primary); + (primary, previous) +} + +fn supervisor_collaboration_policy_snapshot_binding_paths_for_test( + root: &Path, + parent_run_id: &str, +) -> (PathBuf, PathBuf) { + let primary = supervisor_collaboration_policy_snapshot_binding_path( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ); + let previous = agent_runtime_previous_sidecar_path_for_test(&primary); + (primary, previous) +} + +fn remove_supervisor_collaboration_policy_snapshot_for_test(root: &Path, parent_run_id: &str) { + let (snapshot_path, snapshot_previous_path) = + supervisor_collaboration_policy_snapshot_paths_for_test(root, parent_run_id); + let (binding_path, binding_previous_path) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(root, parent_run_id); + for candidate in [ + snapshot_path, + snapshot_previous_path, + binding_path, + binding_previous_path, + ] { + match fs::remove_file(&candidate) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!( + "remove Supervisor collaboration snapshot fixture {}: {error}", + candidate.display() + ), + } + } +} + +fn read_supervisor_collaboration_policy_snapshot_for_test( + root: &Path, + parent_run_id: &str, +) -> SupervisorCollaborationPolicySnapshot { + read_supervisor_collaboration_policy_snapshot_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read Supervisor collaboration policy snapshot") + .expect("Supervisor collaboration policy snapshot exists") +} + +fn read_supervisor_collaboration_policy_snapshot_binding_for_test( + root: &Path, + parent_run_id: &str, +) -> SupervisorCollaborationPolicySnapshotBinding { + read_supervisor_collaboration_policy_snapshot_binding_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read Supervisor collaboration policy snapshot binding") + .expect("Supervisor collaboration policy snapshot binding exists") +} + +fn assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + root: &Path, + parent_run_id: &str, + action_ids: &[String], +) { + assert!(static_delegate_target_agent_ids_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read zero-side-effect static collaboration state") + .is_empty()); + assert_eq!( + isolated_agent_group_summary_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .expect("read zero-side-effect isolated collaboration state"), + IsolatedAgentGroupSummary::default(), + ); + assert!(list_isolated_agent_instances_at(root) + .expect("list zero-side-effect isolated instances") + .is_empty()); + assert!(list_isolated_join_claims_at(root) + .expect("list zero-side-effect isolated claims") + .is_empty()); + let static_claim_dir = root.join(".agent/runtime/delegation-claims"); + assert!( + !static_claim_dir.exists() + || fs::read_dir(&static_claim_dir) + .expect("read zero-side-effect static claim directory") + .next() + .is_none(), + "no static claim sidecar may be created", + ); + assert!(!game_creator_agent_runtime_pending_tool_action_path( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ) + .exists()); + let records = read_agent_db_records_for_test(root); + for action_id in action_ids { + assert_eq!( + records + .iter() + .filter(|record| { + record.get("actionId").and_then(Value::as_str) == Some(action_id.as_str()) + }) + .count(), + 0, + "no Agent DB side effect may be recorded for action {action_id}", + ); + } +} + +fn remove_supervisor_provider_action_batch_for_test(root: &Path, parent_run_id: &str) { + let path = game_creator_agent_runtime_provider_action_batch_path( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + ); + let previous_path = agent_runtime_previous_sidecar_path_for_test(&path); + for candidate in [path, previous_path] { + match fs::remove_file(&candidate) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => panic!( + "remove Supervisor Provider action batch fixture {}: {error}", + candidate.display() + ), + } + } +} + +fn write_supervisor_provider_action_batch_fixture_for_test( + root: &Path, + batch: &AgentRuntimeProviderActionBatch, +) { + fs::write( + game_creator_agent_runtime_provider_action_batch_path(root, &batch.agent_id, &batch.run_id), + serde_json::to_vec_pretty(batch).expect("serialize Provider action batch fixture"), + ) + .expect("write Provider action batch fixture"); +} + +async fn prepare_supervisor_collaboration_ready_batch_for_test( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, + actions: Vec, + repository_fingerprint_seed: &str, +) -> AgentRuntimeProviderActionBatch { + let plan = supervisor_collaboration_plan_for_test(actions); + let revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read Supervisor collaboration project revision"); + let repository_fingerprint = format!( + "{:x}", + Sha256::digest(repository_fingerprint_seed.as_bytes()) + ); + let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + root, + runtime, + task, + &plan, + &[], + &revision, + &repository_fingerprint, + ) + .await + .expect("prepare Supervisor collaboration Provider action batch"); + let AgentRuntimeProviderActionBatchPreparation::Ready(batch) = preparation else { + panic!("Supervisor collaboration plan must form a ready durable batch"); + }; + batch +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_failpoint_recovers_contract_once_without_side_effects( +) { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-failpoint", + "协作策略快照断点恢复测试", + ) + .expect("project init"); + let policy = supervisor_collaboration_mixed_policy_for_test(); + write_supervisor_collaboration_policy_at(&root, policy.clone()) + .expect("write mixed collaboration policy"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + let run_id = "supervisor-collaboration-snapshot-failpoint-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "在快照断点后恢复首批协作合同", + run_id, + "agent-chat", + "验证 batch 已落盘但 snapshot 尚未绑定", + vec!["恢复唯一策略快照且不执行动作".to_string()], + ) + .expect("start supervisor runtime"); + let plan = supervisor_collaboration_plan_for_test(vec![ + supervisor_collaboration_delegate_action_for_test("design-director", None), + supervisor_collaboration_delegate_action_for_test("art-director", None), + supervisor_collaboration_spawn_action_for_test(2), + ]); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read failpoint project revision"); + let marker = + root.join(".agent/runtime/test-collaboration-policy-snapshot-after-batch-failpoint"); + fs::create_dir_all(marker.parent().expect("failpoint marker parent")) + .expect("create failpoint marker parent"); + fs::write(&marker, b"armed\n").expect("arm collaboration snapshot failpoint"); + + let error = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + "在快照断点后恢复首批协作合同", + &plan, + &[], + &revision, + &format!("{:x}", Sha256::digest(b"snapshot-failpoint-repository")), + ) + .await + .expect_err("snapshot failpoint must interrupt after Provider batch persistence"); + assert!(error.contains("Provider action batch 已持久化"), "{error}"); + + let batch_path = game_creator_agent_runtime_provider_action_batch_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + assert!(batch_path.exists()); + let batch_bytes_before = fs::read(&batch_path).expect("read failpoint Provider batch bytes"); + let raw_batch = serde_json::from_slice::(&batch_bytes_before) + .expect("parse failpoint Provider batch"); + assert_eq!(raw_batch.actions.len(), 3); + assert_eq!( + raw_batch + .collaboration_contract + .as_ref() + .map(|contract| contract.policy.clone()), + Some(policy.clone()), + ); + let original_batch_id = raw_batch.batch_id.clone(); + let original_action_identity = raw_batch + .actions + .iter() + .map(|pending| { + ( + pending.action_id.clone(), + pending.action_fingerprint.clone(), + ) + }) + .collect::>(); + let original_action_ids = original_action_identity + .iter() + .map(|(action_id, _)| action_id.clone()) + .collect::>(); + let (snapshot_path, snapshot_previous_path) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + assert!(!snapshot_path.exists()); + assert!(!snapshot_previous_path.exists()); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &original_action_ids, + ); + + fs::remove_file(&marker).expect("disarm collaboration snapshot failpoint"); + let first_read = read_game_creator_agent_runtime_provider_action_batch( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("recover snapshot from the durable Provider batch contract"); + let snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + assert_eq!(snapshot.bound_from, "legacy-provider-batch-contract"); + assert_eq!(snapshot.policy, policy); + assert!(snapshot_path.exists()); + assert!(!snapshot_previous_path.exists()); + let snapshot_bytes = fs::read(&snapshot_path).expect("read recovered snapshot bytes"); + + let second_read = read_game_creator_agent_runtime_provider_action_batch( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("repeat recovered Provider batch read"); + assert_eq!(first_read, raw_batch); + assert_eq!(second_read, first_read); + assert_eq!(second_read.batch_id, original_batch_id); + assert_eq!( + second_read + .actions + .iter() + .map(|pending| { + ( + pending.action_id.clone(), + pending.action_fingerprint.clone(), + ) + }) + .collect::>(), + original_action_identity, + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id), + snapshot, + ); + assert_eq!( + fs::read(&snapshot_path).expect("re-read recovered snapshot bytes"), + snapshot_bytes, + ); + assert_eq!( + fs::read(&batch_path).expect("re-read failpoint Provider batch bytes"), + batch_bytes_before, + ); + assert!(!snapshot_previous_path.exists()); + assert!(!agent_runtime_previous_sidecar_path_for_test(&batch_path).exists()); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &original_action_ids, + ); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_missing_binding_is_repaired_from_same_snapshot() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-missing-binding", + "协作快照缺失绑定恢复测试", + ) + .expect("project init"); + let policy = SupervisorCollaborationPolicy::default(); + write_supervisor_collaboration_policy_at(&root, policy.clone()) + .expect("write collaboration policy"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + let run_id = "supervisor-collaboration-snapshot-missing-binding-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "补齐缺失的协作快照绑定", + run_id, + "agent-chat", + "验证 snapshot 存在但 binding 缺失", + vec!["保持首次快照身份".to_string()], + ) + .expect("start supervisor runtime"); + let batch = prepare_supervisor_collaboration_ready_batch_for_test( + &root, + &runtime, + "补齐缺失的协作快照绑定", + vec![supervisor_collaboration_delegate_action_for_test( + "design-director", + None, + )], + "snapshot-missing-binding", + ) + .await; + assert_eq!( + batch.schema_version, + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + ); + assert!(batch.collaboration_contract.is_some()); + let original_snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + let original_binding = + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id); + let (snapshot_path, snapshot_previous_path) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + let (binding_path, binding_previous_path) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, run_id); + let snapshot_bytes = fs::read(&snapshot_path).expect("read original snapshot bytes"); + let binding_bytes = fs::read(&binding_path).expect("read original binding bytes"); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + + fs::remove_file(&binding_path).expect("remove snapshot binding fixture"); + assert!(read_supervisor_collaboration_policy_snapshot_binding_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read missing snapshot binding") + .is_none()); + + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("repair binding from the existing snapshot"); + assert_eq!(resolution.source, "run-snapshot"); + assert_eq!(resolution.policy, policy); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id), + original_snapshot, + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id), + original_binding, + ); + assert_eq!( + fs::read(&snapshot_path).expect("re-read unchanged snapshot bytes"), + snapshot_bytes, + ); + assert_eq!( + fs::read(&binding_path).expect("read repaired binding bytes"), + binding_bytes, + ); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_missing_snapshot_recovers_from_trusted_v2_batch() +{ + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-missing-snapshot", + "协作快照按可信批次恢复测试", + ) + .expect("project init"); + let policy = SupervisorCollaborationPolicy::default(); + write_supervisor_collaboration_policy_at(&root, policy.clone()) + .expect("write collaboration policy"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + let run_id = "supervisor-collaboration-snapshot-missing-snapshot-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "按可信 v2 batch 恢复协作快照", + run_id, + "agent-chat", + "验证 binding 存在但 snapshot 缺失", + vec!["保持原 binding 身份".to_string()], + ) + .expect("start supervisor runtime"); + let batch = prepare_supervisor_collaboration_ready_batch_for_test( + &root, + &runtime, + "按可信 v2 batch 恢复协作快照", + vec![supervisor_collaboration_delegate_action_for_test( + "design-director", + None, + )], + "snapshot-missing-snapshot", + ) + .await; + assert_eq!( + batch.schema_version, + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + ); + assert_eq!(batch.status, "ready"); + assert!(batch.collaboration_contract.is_some()); + let original_snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + let original_binding = + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id); + let (snapshot_path, snapshot_previous_path) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + let (binding_path, binding_previous_path) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, run_id); + let snapshot_bytes = fs::read(&snapshot_path).expect("read original snapshot bytes"); + let binding_bytes = fs::read(&binding_path).expect("read original binding bytes"); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + + fs::remove_file(&snapshot_path).expect("remove snapshot fixture"); + write_supervisor_collaboration_policy_at( + &root, + SupervisorCollaborationPolicy { + min_isolated_groups_before_claim: 1, + ..SupervisorCollaborationPolicy::default() + }, + ) + .expect("drift project collaboration policy"); + assert!(read_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read missing snapshot") + .is_none()); + + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("recover snapshot from trusted pending v2 batch"); + assert_eq!(resolution.source, "run-snapshot"); + assert_eq!(resolution.project_policy_status, "drifted"); + assert_eq!(resolution.policy, policy); + assert_eq!( + resolution.binding_source.as_deref(), + Some(original_binding.bound_from.as_str()), + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id), + original_snapshot, + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id), + original_binding, + ); + assert_eq!( + fs::read(&snapshot_path).expect("read recovered snapshot bytes"), + snapshot_bytes, + ); + assert_eq!( + fs::read(&binding_path).expect("re-read unchanged binding bytes"), + binding_bytes, + ); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_orphan_binding_without_trusted_v2_batch_fails_closed( +) { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-orphan-binding", + "协作快照孤立绑定失败关闭测试", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("write collaboration policy"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + let run_id = "supervisor-collaboration-snapshot-orphan-binding-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "拒绝从不可信来源重建协作快照", + run_id, + "agent-chat", + "验证孤立 binding 失败关闭", + vec!["不得按当前项目策略重绑".to_string()], + ) + .expect("start supervisor runtime"); + let batch = prepare_supervisor_collaboration_ready_batch_for_test( + &root, + &runtime, + "拒绝从不可信来源重建协作快照", + vec![supervisor_collaboration_delegate_action_for_test( + "design-director", + None, + )], + "snapshot-orphan-binding", + ) + .await; + let action_ids = batch + .actions + .iter() + .map(|pending| pending.action_id.clone()) + .collect::>(); + let original_binding = + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id); + let (snapshot_path, snapshot_previous_path) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + let (binding_path, binding_previous_path) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, run_id); + let binding_bytes = fs::read(&binding_path).expect("read original binding bytes"); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + + fs::remove_file(&snapshot_path).expect("remove snapshot fixture"); + remove_supervisor_provider_action_batch_for_test(&root, run_id); + let error = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect_err("orphan binding without trusted v2 batch must fail closed"); + assert!(error.contains("绑定记录存在"), "{error}"); + assert!(error.contains("可信 v2 batch"), "{error}"); + assert!(read_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent snapshot after rejected recovery") + .is_none()); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id), + original_binding, + ); + assert_eq!( + fs::read(&binding_path).expect("re-read unchanged orphan binding bytes"), + binding_bytes, + ); + assert!(!snapshot_path.exists()); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &action_ids, + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_collaboration_policy_snapshot_run_paths_and_binding_locks_do_not_collide() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-run-paths", + "协作策略快照 run 路径隔离测试", + ) + .expect("project init"); + let slash_run_id = "run/a"; + let dash_run_id = "run-a"; + let (slash_snapshot, _) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, slash_run_id); + let (dash_snapshot, _) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, dash_run_id); + let (slash_binding, _) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, slash_run_id); + let (dash_binding, _) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, dash_run_id); + assert_ne!(slash_snapshot, dash_snapshot); + assert_ne!(slash_binding, dash_binding); + + let policies = [ + SupervisorCollaborationPolicy::default(), + SupervisorCollaborationPolicy { + min_isolated_groups_before_claim: 1, + ..SupervisorCollaborationPolicy::default() + }, + ]; + let barrier = Arc::new(Barrier::new(3)); + let workers = [slash_run_id, dash_run_id] + .into_iter() + .zip(policies.iter().cloned()) + .map(|(run_id, policy)| { + let root = root.clone(); + let barrier = barrier.clone(); + thread::spawn(move || { + barrier.wait(); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &policy, + "legacy-current-project-policy", + ) + }) + }) + .collect::>(); + barrier.wait(); + for worker in workers { + worker + .join() + .expect("join distinct run binding worker") + .expect("bind distinct run snapshot concurrently"); + } + assert_eq!( + read_supervisor_collaboration_policy_snapshot_for_test(&root, slash_run_id).policy, + policies[0], + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_for_test(&root, dash_run_id).policy, + policies[1], + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, slash_run_id) + .parent_run_id, + slash_run_id, + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, dash_run_id) + .parent_run_id, + dash_run_id, + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_collaboration_policy_snapshot_whitespace_run_ids_bind_concurrently_without_collision() +{ + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-whitespace-run-paths", + "协作策略快照危险 run 路径隔离测试", + ) + .expect("project init"); + let plain_run_id = "run/a"; + let padded_run_id = " run/a "; + let run_ids = [plain_run_id, padded_run_id]; + let policies = [ + SupervisorCollaborationPolicy::default(), + SupervisorCollaborationPolicy { + min_isolated_groups_before_claim: 1, + ..SupervisorCollaborationPolicy::default() + }, + ]; + let (plain_snapshot, plain_snapshot_previous) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, plain_run_id); + let (padded_snapshot, padded_snapshot_previous) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, padded_run_id); + let (plain_binding, plain_binding_previous) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, plain_run_id); + let (padded_binding, padded_binding_previous) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, padded_run_id); + assert_ne!(plain_snapshot, padded_snapshot); + assert_ne!(plain_binding, padded_binding); + + let barrier = Arc::new(Barrier::new(3)); + let workers = run_ids + .into_iter() + .zip(policies.iter().cloned()) + .map(|(run_id, policy)| { + let root = root.clone(); + let barrier = barrier.clone(); + thread::spawn(move || { + barrier.wait(); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &policy, + "legacy-current-project-policy", + ) + }) + }) + .collect::>(); + barrier.wait(); + for worker in workers { + worker + .join() + .expect("join whitespace run binding worker") + .expect("bind whitespace-distinct run snapshot concurrently"); + } + + for (run_id, policy) in run_ids.into_iter().zip(policies) { + let snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + let binding = read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id); + assert_eq!(snapshot.parent_run_id, run_id); + assert_eq!(snapshot.policy, policy); + assert_eq!(binding.parent_run_id, run_id); + assert_eq!(binding.snapshot_fingerprint, snapshot.snapshot_fingerprint); + } + assert!(plain_snapshot.exists()); + assert!(padded_snapshot.exists()); + assert!(plain_binding.exists()); + assert!(padded_binding.exists()); + assert!(!plain_snapshot_previous.exists()); + assert!(!padded_snapshot_previous.exists()); + assert!(!plain_binding_previous.exists()); + assert!(!padded_binding_previous.exists()); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_collaboration_static_new_claim_fails_closed_when_snapshot_is_unreadable() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-static-unreadable-snapshot", + "静态回执损坏快照失败关闭测试", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("write collaboration policy"); + let run_id = "supervisor-collaboration-static-unreadable-snapshot-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "损坏快照后拒绝静态新认领", + run_id, + "agent-chat", + "验证静态新 claim 零变更", + vec!["保留 Ready delivery".to_string()], + ) + .expect("start supervisor runtime"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration snapshot"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "static-unreadable-snapshot-delegate-action", + "static-unreadable-snapshot-delivery", + "design-director", + "static-unreadable-snapshot-child-session", + "static-unreadable-snapshot-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery).expect("create static delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "静态回执已完成", + ) + .expect("mark static delivery ready"); + let delivery_before = read_static_delegate_delivery_at(&root, &delivery.delegation_id) + .expect("read ready delivery before claim") + .expect("ready delivery exists before claim"); + let delivery_before_bytes = + serde_json::to_vec(&delivery_before).expect("serialize ready delivery before claim"); + let binding_before = + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id); + let (snapshot_path, snapshot_previous_path) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + assert!(!snapshot_previous_path.exists()); + fs::write(&snapshot_path, b"{broken-snapshot\n").expect("corrupt collaboration snapshot"); + let broken_snapshot_bytes = fs::read(&snapshot_path).expect("read broken snapshot bytes"); + let claim_dir = root.join(".agent/runtime/delegation-claims"); + assert!(!claim_dir.exists()); + + let error = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "static-unreadable-snapshot-new-claim-action", + ) + .expect_err("unreadable snapshot must reject a new static claim"); + assert!(!error.trim().is_empty()); + let delivery_after = read_static_delegate_delivery_at(&root, &delivery.delegation_id) + .expect("read ready delivery after rejected claim") + .expect("ready delivery remains after rejected claim"); + assert_eq!(delivery_after.status, StaticDelegateDeliveryStatus::Ready); + assert!(delivery_after.claimed_by_action_id.is_none()); + assert_eq!( + serde_json::to_vec(&delivery_after).expect("serialize ready delivery after claim"), + delivery_before_bytes, + ); + assert!(!claim_dir.exists()); + assert_eq!( + fs::read(&snapshot_path).expect("re-read broken snapshot bytes"), + broken_snapshot_bytes, + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id), + binding_before, + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_collaboration_static_legacy_claim_recovery_defers_new_ready_when_snapshot_sidecars_are_unreadable( +) { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-static-legacy-claim-recovery", + "静态回执旧认领优先恢复测试", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("write collaboration policy"); + let run_id = "supervisor-collaboration-static-legacy-claim-recovery-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "恢复缺失 journal 的旧静态认领", + run_id, + "agent-chat", + "验证新 Ready 留给下一 action", + vec!["当前 action 只恢复旧 receipt".to_string()], + ) + .expect("start supervisor runtime"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration snapshot"); + + let legacy_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "static-legacy-claim-delegate-action", + "static-legacy-claim-delivery", + "design-director", + "static-legacy-claim-child-session", + "static-legacy-claim-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &legacy_delivery) + .expect("create legacy static delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &legacy_delivery.target_agent_id, + &legacy_delivery.target_session_id, + &legacy_delivery.target_run_id, + &legacy_delivery.delegation_id, + "completed", + "旧静态回执已完成", + ) + .expect("mark legacy static delivery ready"); + let current_action_id = "static-legacy-claim-current-action"; + let initial_claim = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + current_action_id, + ) + .expect("claim legacy static receipt before journal loss"); + assert_eq!(initial_claim.len(), 1); + assert_eq!( + initial_claim[0].delegation_id, + legacy_delivery.delegation_id + ); + let claim_dir = root.join(".agent/runtime/delegation-claims"); + assert!(claim_dir.exists()); + fs::remove_dir_all(&claim_dir).expect("remove legacy static claim journal"); + + let next_delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "static-legacy-claim-next-delegate-action", + "static-legacy-claim-next-delivery", + "art-director", + "static-legacy-claim-next-child-session", + "static-legacy-claim-next-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &next_delivery) + .expect("create next static delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &next_delivery.target_agent_id, + &next_delivery.target_session_id, + &next_delivery.target_run_id, + &next_delivery.delegation_id, + "completed", + "新静态回执已完成", + ) + .expect("mark next static delivery ready"); + + let (snapshot_path, snapshot_previous_path) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + let (binding_path, binding_previous_path) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, run_id); + let snapshot_bytes = fs::read(&snapshot_path).expect("read valid snapshot bytes"); + let binding_bytes = fs::read(&binding_path).expect("read valid binding bytes"); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + fs::write(&snapshot_path, b"{broken-snapshot\n").expect("corrupt collaboration snapshot"); + fs::write(&binding_path, b"{broken-binding\n").expect("corrupt collaboration binding"); + let broken_snapshot_bytes = fs::read(&snapshot_path).expect("read broken snapshot bytes"); + let broken_binding_bytes = fs::read(&binding_path).expect("read broken binding bytes"); + + let recovered = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + current_action_id, + ) + .expect("recover only the legacy claimed receipt without readable policy sidecars"); + assert_eq!(recovered, initial_claim); + let legacy_after = read_static_delegate_delivery_at(&root, &legacy_delivery.delegation_id) + .expect("read recovered legacy delivery") + .expect("recovered legacy delivery exists"); + assert_eq!( + legacy_after.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + legacy_after.claimed_by_action_id.as_deref(), + Some(current_action_id) + ); + let next_after = read_static_delegate_delivery_at(&root, &next_delivery.delegation_id) + .expect("read deferred next delivery") + .expect("deferred next delivery exists"); + assert_eq!(next_after.status, StaticDelegateDeliveryStatus::Ready); + assert!(next_after.claimed_by_action_id.is_none()); + assert_eq!( + fs::read(&snapshot_path).expect("re-read broken snapshot bytes"), + broken_snapshot_bytes, + ); + assert_eq!( + fs::read(&binding_path).expect("re-read broken binding bytes"), + broken_binding_bytes, + ); + assert!(claim_dir.exists()); + + fs::write(&snapshot_path, snapshot_bytes).expect("restore collaboration snapshot"); + fs::write(&binding_path, binding_bytes).expect("restore collaboration binding"); + let next_action_id = "static-legacy-claim-next-action"; + let next_claim = claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + next_action_id, + ) + .expect("claim deferred Ready receipt from the next action"); + assert_eq!(next_claim.len(), 1); + assert_eq!(next_claim[0].delegation_id, next_delivery.delegation_id); + let next_claimed = read_static_delegate_delivery_at(&root, &next_delivery.delegation_id) + .expect("read next claimed delivery") + .expect("next claimed delivery exists"); + assert_eq!( + next_claimed.status, + StaticDelegateDeliveryStatus::ClaimedByParent + ); + assert_eq!( + next_claimed.claimed_by_action_id.as_deref(), + Some(next_action_id) + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_collaboration_isolated_new_claim_cannot_reuse_historical_claim_to_bypass_snapshot() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-isolated-unreadable-snapshot", + "隔离认领损坏快照失败关闭测试", + ) + .expect("project init"); + let run_id = "supervisor-collaboration-isolated-unreadable-snapshot-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "已有认领后拒绝新的隔离认领", + run_id, + "agent-chat", + "验证历史 claim 不能绕过快照", + vec!["新 group 保持未认领".to_string()], + ) + .expect("start supervisor runtime"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind collaboration snapshot"); + + let first = ready_isolated_join_for_claim_test( + &root, + &state.session_id, + run_id, + "isolated-unreadable-first-spawn-action", + "isolated-unreadable-first", + ); + let first_claim_action = "isolated-unreadable-first-claim-action"; + let first_observation = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(first_claim_action), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(first_observation.status, "ok", "{first_observation:?}"); + assert!(first_observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains(&first.delegation_group_id))); + assert!(mark_isolated_join_claim_observed_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + first_claim_action, + ) + .expect("mark first isolated claim observed")); + + let second = ready_isolated_join_for_claim_test( + &root, + &state.session_id, + run_id, + "isolated-unreadable-second-spawn-action", + "isolated-unreadable-second", + ); + let second_delivery_before = read_isolated_join_delivery_at(&root, &second) + .expect("read absent second join delivery before rejected claim"); + assert!(second_delivery_before.is_none()); + let (snapshot_path, _) = supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + fs::write(&snapshot_path, b"{broken-snapshot\n").expect("corrupt collaboration snapshot"); + + let second_claim_action = "isolated-unreadable-second-claim-action"; + let rejected = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(second_claim_action), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(rejected.status, "failed", "{rejected:?}"); + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + second_claim_action, + ) + .expect("read absent second isolated claim") + .is_none()); + assert_eq!( + read_isolated_join_delivery_at(&root, &second) + .expect("read second join delivery after rejected claim"), + second_delivery_before, + ); + let first_claim = read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + first_claim_action, + ) + .expect("read historical isolated claim") + .expect("historical isolated claim remains"); + assert_eq!(first_claim.status, IsolatedAgentJoinClaimStatus::Observed); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_rejects_tampered_contract_before_binding() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-tamper", + "协作快照篡改测试", + ) + .expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + let run_id = "supervisor-collaboration-snapshot-tamper-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验证损坏合同不能播种快照", + run_id, + "agent-chat", + "篡改 Provider batch 合同", + vec!["损坏合同失败关闭".to_string()], + ) + .expect("start supervisor runtime"); + let mut batch = prepare_supervisor_collaboration_ready_batch_for_test( + &root, + &runtime, + "验证损坏合同不能播种快照", + vec![supervisor_collaboration_delegate_action_for_test( + "design-director", + None, + )], + "snapshot-tampered-contract", + ) + .await; + let action_ids = batch + .actions + .iter() + .map(|pending| pending.action_id.clone()) + .collect::>(); + remove_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + batch + .collaboration_contract + .as_mut() + .expect("collaboration contract") + .contract_fingerprint = "0".repeat(64); + write_supervisor_provider_action_batch_fixture_for_test(&root, &batch); + + let error = read_game_creator_agent_runtime_provider_action_batch( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect_err("tampered contract must fail before snapshot recovery"); + assert!(error.contains("合同指纹"), "{error}"); + assert!(read_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent snapshot after tamper") + .is_none()); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &action_ids, + ); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_previous_recovers_and_conflict_fails_closed() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-previous", + "协作快照副本恢复测试", + ) + .expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + let run_id = "supervisor-collaboration-snapshot-previous-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "恢复快照 previous 副本", + run_id, + "agent-chat", + "验证快照恢复副本", + vec!["保持首次绑定身份".to_string()], + ) + .expect("start supervisor runtime"); + let _batch = prepare_supervisor_collaboration_ready_batch_for_test( + &root, + &runtime, + "恢复快照 previous 副本", + vec![supervisor_collaboration_delegate_action_for_test( + "design-director", + None, + )], + "snapshot-previous", + ) + .await; + let original = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + let (primary, previous) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + fs::rename(&primary, &previous).expect("move snapshot to previous recovery path"); + let previous_bytes = fs::read(&previous).expect("read snapshot previous bytes"); + + assert_eq!( + read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id), + original, + ); + let changed_policy = SupervisorCollaborationPolicy { + min_isolated_groups_before_claim: 1, + ..SupervisorCollaborationPolicy::default() + }; + write_supervisor_collaboration_policy_at(&root, changed_policy.clone()) + .expect("change project policy after snapshot binding"); + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("resolve snapshot from previous recovery copy"); + assert_eq!(resolution.policy, original.policy); + assert_eq!(resolution.project_policy_status, "drifted"); + let conflict = bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &changed_policy, + "legacy-current-project-policy", + ) + .expect_err("conflicting policy cannot replace previous snapshot"); + assert!(conflict.contains("冲突"), "{conflict}"); + assert!(!primary.exists()); + assert_eq!( + fs::read(&previous).expect("re-read snapshot previous bytes"), + previous_bytes, + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_collaboration_policy_snapshot_legacy_facts_migrate_once() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-legacy", + "旧协作事实迁移测试", + ) + .expect("project init"); + let original_policy = SupervisorCollaborationPolicy { + min_isolated_groups_before_claim: 2, + ..SupervisorCollaborationPolicy::default() + }; + write_supervisor_collaboration_policy_at(&root, original_policy.clone()) + .expect("write legacy project policy"); + let run_id = "supervisor-collaboration-snapshot-legacy-run"; + let state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "迁移旧协作事实", + run_id, + "agent-chat", + "绑定旧 run 策略", + vec!["只迁移一次".to_string()], + ) + .expect("start legacy supervisor runtime"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "legacy-snapshot-parent-action", + "legacy-snapshot-delivery", + "design-director", + "legacy-snapshot-child-session", + "legacy-snapshot-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("create legacy durable collaboration fact"); + assert!(read_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent pre-migration snapshot") + .is_none()); + + let first = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("migrate legacy collaboration facts"); + let snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + assert_eq!(first.source, "legacy-run-migration"); + assert_eq!(snapshot.bound_from, "legacy-current-project-policy"); + assert_eq!(snapshot.policy, original_policy); + let snapshot_bytes = fs::read(supervisor_collaboration_policy_snapshot_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + )) + .expect("read migrated snapshot bytes"); + + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("change project policy after legacy migration"); + let second = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("reuse migrated snapshot"); + assert_eq!(second.source, "run-snapshot"); + assert_eq!(second.project_policy_status, "drifted"); + assert_eq!(second.policy, snapshot.policy); + assert_eq!( + fs::read(supervisor_collaboration_policy_snapshot_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + )) + .expect("re-read migrated snapshot bytes"), + snapshot_bytes, + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_collaboration_terminal_legacy_run_cannot_bind_current_policy() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-terminal-legacy", + "终态旧协作 run 迁移测试", + ) + .expect("project init"); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("write collaboration policy"); + let run_id = "supervisor-collaboration-terminal-legacy-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "终态 run 不得迁移 live policy", + run_id, + "agent-chat", + "验证终态迁移失败关闭", + vec!["不创建快照".to_string()], + ) + .expect("start legacy supervisor runtime"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "terminal-legacy-parent-action", + "terminal-legacy-delivery", + "design-director", + "terminal-legacy-child-session", + "terminal-legacy-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("create legacy collaboration fact"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind fixture snapshot before claiming delivery"); + mark_static_delegate_delivery_ready_at( + &root, + &delivery.target_agent_id, + &delivery.target_session_id, + &delivery.target_run_id, + &delivery.delegation_id, + "completed", + "旧协作回执已完成", + ) + .expect("mark legacy delivery ready"); + assert_eq!( + claim_ready_static_delegate_receipts_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "terminal-legacy-claim-action", + ) + .expect("claim legacy delivery") + .len(), + 1, + ); + remove_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + state.status = "completed".to_string(); + state.phase = "completed".to_string(); + state.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(&root, &state).expect("write terminal runtime state"); + append_game_creator_agent_runtime_task(&root, &state).expect("append terminal runtime task"); + + let error = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect_err("terminal legacy run must not bind current project policy"); + assert!(error.contains("仍在运行"), "{error}"); + assert!(read_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent terminal legacy snapshot") + .is_none()); + assert!(read_supervisor_collaboration_policy_snapshot_binding_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent terminal legacy binding") + .is_none()); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn supervisor_collaboration_policy_snapshot_concurrent_conflict_has_one_winner() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-cas", + "协作策略快照 CAS 测试", + ) + .expect("project init"); + let run_id = "supervisor-collaboration-snapshot-cas-run"; + let policies = [ + SupervisorCollaborationPolicy::default(), + SupervisorCollaborationPolicy { + min_isolated_groups_before_claim: 1, + ..SupervisorCollaborationPolicy::default() + }, + ]; + let barrier = Arc::new(std::sync::Barrier::new(3)); + let workers = policies + .iter() + .cloned() + .map(|policy| { + let root = root.clone(); + let barrier = barrier.clone(); + thread::spawn(move || { + barrier.wait(); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &policy, + "legacy-current-project-policy", + ) + }) + }) + .collect::>(); + barrier.wait(); + let results = workers + .into_iter() + .map(|worker| worker.join().expect("join snapshot CAS worker")) + .collect::>(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); + assert!(results + .iter() + .filter_map(|result| result.as_ref().err()) + .all(|error| error.contains("冲突"))); + let snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + assert!(policies.contains(&snapshot.policy)); + let (primary, previous) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + assert!(primary.exists()); + assert!(!previous.exists()); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_controls_followup_claim_and_completion_after_drift( +) { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-followup", + "快照后续协作测试", + ) + .expect("project init"); + let original_policy = SupervisorCollaborationPolicy { + required_initial_wave: SupervisorInitialCollaborationWave::Isolated, + min_isolated_children: 1, + min_isolated_groups_before_claim: 2, + ..SupervisorCollaborationPolicy::default() + }; + write_supervisor_collaboration_policy_at(&root, original_policy.clone()) + .expect("write staged collaboration policy"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + let run_id = "supervisor-collaboration-snapshot-followup-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "建立两个隔离组后统一认领", + run_id, + "agent-chat", + "验证后续协作使用快照", + vec!["两组同时认领".to_string()], + ) + .expect("start supervisor runtime"); + let initial_batch = prepare_supervisor_collaboration_ready_batch_for_test( + &root, + &runtime, + "建立两个隔离组后统一认领", + vec![supervisor_collaboration_spawn_action_for_test(1)], + "snapshot-followup-initial", + ) + .await; + let original_snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + assert_eq!(original_snapshot.policy, original_policy); + assert!(initial_batch + .collaboration_contract + .as_ref() + .is_some_and(|contract| contract.initial_wave)); + remove_supervisor_provider_action_batch_for_test(&root, run_id); + + let first = ready_isolated_join_for_claim_test( + &root, + &runtime.session_id, + run_id, + "snapshot-followup-first-action", + "snapshot-followup-first", + ); + write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default()) + .expect("loosen project policy after snapshot binding"); + let claim_action_id = "snapshot-followup-claim-action"; + let early_claim = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(claim_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(early_claim.status, "failed", "{early_claim:?}"); + assert!(early_claim.summary.contains("readyIsolatedGroups=1/2")); + assert!(read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + claim_action_id, + ) + .expect("read absent early claim") + .is_none()); + let blocker = supervisor_collaboration_policy_completion_blocker_for_test_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("one group must not satisfy bound completion policy"); + assert!(blocker + .detail + .as_deref() + .unwrap_or_default() + .contains("isolatedGroups=1/2")); + + let followup_batch = prepare_supervisor_collaboration_ready_batch_for_test( + &root, + &runtime, + "建立两个隔离组后统一认领", + vec![supervisor_collaboration_spawn_action_for_test(1)], + "snapshot-followup-second", + ) + .await; + let followup_contract = followup_batch + .collaboration_contract + .as_ref() + .expect("follow-up collaboration contract"); + assert!(!followup_contract.initial_wave); + assert_eq!(followup_contract.policy, original_policy); + remove_supervisor_provider_action_batch_for_test(&root, run_id); + let second = ready_isolated_join_for_claim_test( + &root, + &runtime.session_id, + run_id, + "snapshot-followup-second-action", + "snapshot-followup-second", + ); + let claimed = observe_agent_runtime_run_status( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + Some(claim_action_id), + &serde_json::json!({ "scope": "self" }), + ); + assert_eq!(claimed.status, "ok", "{claimed:?}"); + assert!(claimed.summary.contains("项目协作策略已漂移")); + let detail = claimed.detail.as_deref().unwrap_or_default(); + assert!(detail.contains(&first.delegation_group_id)); + assert!(detail.contains(&second.delegation_group_id)); + let claim = read_isolated_join_claim_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + claim_action_id, + ) + .expect("read snapshot-bound claim") + .expect("snapshot-bound claim exists"); + assert_eq!(claim.status, IsolatedAgentJoinClaimStatus::Committed); + assert_eq!(claim.joins.len(), 2); + assert!( + supervisor_collaboration_policy_completion_blocker_for_test_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .is_none() + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id), + original_snapshot, + ); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_survives_terminal_runtime_cleanup() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-terminal-cleanup", + "协作快照终态保留测试", + ) + .expect("project init"); + let policy = SupervisorCollaborationPolicy::default(); + write_supervisor_collaboration_policy_at(&root, policy.clone()) + .expect("write collaboration policy"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + let run_id = "supervisor-collaboration-snapshot-terminal-cleanup-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "完成后保留父 run 协作快照", + run_id, + "agent-chat", + "验证终态只清理临时 sidecar", + vec!["快照与绑定继续可解析".to_string()], + ) + .expect("start supervisor runtime"); + let batch = prepare_supervisor_collaboration_ready_batch_for_test( + &root, + &runtime, + "完成后保留父 run 协作快照", + vec![supervisor_collaboration_delegate_action_for_test( + "design-director", + None, + )], + "snapshot-terminal-cleanup", + ) + .await; + let batch_path = game_creator_agent_runtime_provider_action_batch_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + let (snapshot_path, snapshot_previous_path) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + let (binding_path, binding_previous_path) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, run_id); + let snapshot_bytes = fs::read(&snapshot_path).expect("read bound snapshot bytes"); + let binding_bytes = fs::read(&binding_path).expect("read bound binding bytes"); + assert_eq!(batch.run_id, run_id); + assert!(batch_path.exists()); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + + let completed = finish_game_creator_agent_runtime_turn_at( + &root, + runtime, + "父 run 已完成并保留协作策略绑定。", + ) + .expect("finish supervisor runtime"); + assert_eq!(completed.phase, "completed"); + assert!(!batch_path.exists()); + assert_eq!( + fs::read(&snapshot_path).expect("re-read terminal snapshot bytes"), + snapshot_bytes, + ); + assert_eq!( + fs::read(&binding_path).expect("re-read terminal binding bytes"), + binding_bytes, + ); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("resolve terminal run snapshot"); + assert_eq!(resolution.source, "run-snapshot"); + assert_eq!(resolution.policy, policy); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_aborted_batch_does_not_bind() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-aborted", + "中止协作批次测试", + ) + .expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["agent.delegate".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write denied delegate policy"); + let run_id = "supervisor-collaboration-snapshot-aborted-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "拒绝首个协作批次", + run_id, + "agent-chat", + "验证 aborted 不绑定快照", + vec!["零副作用".to_string()], + ) + .expect("start supervisor runtime"); + let plan = supervisor_collaboration_plan_for_test(vec![ + supervisor_collaboration_delegate_action_for_test("design-director", None), + ]); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read aborted batch revision"); + let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + "拒绝首个协作批次", + &plan, + &[], + &revision, + &"a".repeat(64), + ) + .await + .expect("prepare denied collaboration batch"); + let AgentRuntimeProviderActionBatchPreparation::Aborted { batch, .. } = preparation else { + panic!("denied collaboration action must form aborted durable batch"); + }; + assert_eq!(batch.status, "aborted"); + assert!(read_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent aborted snapshot") + .is_none()); + assert!(read_supervisor_collaboration_policy_snapshot_binding_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent aborted snapshot binding") + .is_none()); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &batch + .actions + .iter() + .map(|pending| pending.action_id.clone()) + .collect::>(), + ); + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn supervisor_collaboration_policy_snapshot_matching_binding_recovers_from_aborted_v2_batch() +{ + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-snapshot-aborted-binding-recovery", + "中止协作批次绑定恢复测试", + ) + .expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["agent.delegate".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write denied delegate policy"); + let run_id = "supervisor-collaboration-snapshot-aborted-binding-recovery-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "从完整 aborted v2 batch 恢复已绑定快照", + run_id, + "agent-chat", + "验证 matching binding 保持首次身份", + vec!["不产生协作副作用".to_string()], + ) + .expect("start supervisor runtime"); + let plan = supervisor_collaboration_plan_for_test(vec![ + supervisor_collaboration_delegate_action_for_test("design-director", None), + ]); + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read aborted recovery batch revision"); + let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + "从完整 aborted v2 batch 恢复已绑定快照", + &plan, + &[], + &revision, + &"b".repeat(64), + ) + .await + .expect("prepare trusted aborted collaboration batch"); + let AgentRuntimeProviderActionBatchPreparation::Aborted { batch, .. } = preparation else { + panic!("denied collaboration action must form an aborted v2 batch"); + }; + assert_eq!( + batch.schema_version, + AGENT_RUNTIME_PROVIDER_ACTION_BATCH_SCHEMA_VERSION + ); + assert_eq!(batch.status, "aborted"); + let contract = batch + .collaboration_contract + .as_ref() + .expect("aborted v2 collaboration contract"); + let original_snapshot = bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &contract.policy, + "initial-collaboration-batch", + ) + .expect("bind pre-crash collaboration snapshot identity"); + let original_binding = + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id); + let (snapshot_path, snapshot_previous_path) = + supervisor_collaboration_policy_snapshot_paths_for_test(&root, run_id); + let (binding_path, binding_previous_path) = + supervisor_collaboration_policy_snapshot_binding_paths_for_test(&root, run_id); + let batch_path = game_creator_agent_runtime_provider_action_batch_path( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ); + let snapshot_bytes = fs::read(&snapshot_path).expect("read original aborted snapshot bytes"); + let binding_bytes = fs::read(&binding_path).expect("read original aborted binding bytes"); + let batch_bytes = fs::read(&batch_path).expect("read original aborted batch bytes"); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + + fs::remove_file(&snapshot_path).expect("remove aborted snapshot fixture"); + let recovered = read_game_creator_agent_runtime_provider_action_batch( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("recover matching snapshot from trusted aborted v2 batch"); + assert_eq!(recovered, batch); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id), + original_snapshot, + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id), + original_binding, + ); + assert_eq!( + fs::read(&snapshot_path).expect("read recovered aborted snapshot bytes"), + snapshot_bytes, + ); + assert_eq!( + fs::read(&binding_path).expect("re-read unchanged aborted binding bytes"), + binding_bytes, + ); + assert_eq!( + fs::read(&batch_path).expect("re-read unchanged aborted batch bytes"), + batch_bytes, + ); + assert!(!snapshot_previous_path.exists()); + assert!(!binding_previous_path.exists()); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &batch + .actions + .iter() + .map(|pending| pending.action_id.clone()) + .collect::>(), + ); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn supervisor_collaboration_policy_partial_mixed_wave_has_zero_side_effects() { let root = unique_project_path(); @@ -8288,33 +10304,32 @@ async fn supervisor_collaboration_policy_recovery_validates_contract_before_repl "恢复一个 executing 委派", run_id, "agent-chat", - "验证策略漂移先于副作用重放", - vec!["旧合同不得重放".to_string()], + "验证已绑定策略快照恢复", + vec!["按原合同恢复且不重复委派".to_string()], ) .expect("start supervisor runtime"); - let plan = supervisor_collaboration_plan_for_test(vec![ - supervisor_collaboration_delegate_action_for_test("design-director", None), - ]); - let revision = read_game_creator_agent_runtime_project_revision(&root) - .expect("read collaboration revision"); - let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + let batch = prepare_supervisor_collaboration_ready_batch_for_test( &root, &runtime, "恢复一个 executing 委派", - &plan, - &[], - &revision, - &"e".repeat(64), + vec![supervisor_collaboration_delegate_action_for_test( + "design-director", + None, + )], + "recovery-policy-snapshot", ) - .await - .expect("prepare single delegate batch"); - let AgentRuntimeProviderActionBatchPreparation::Ready(batch) = preparation else { - panic!("single delegate must form a durable provider batch"); - }; - let mut pending = batch.actions[0].clone(); - assert!( - mark_game_creator_agent_runtime_auto_action_executing_if_current(&root, &mut pending,) - .expect("persist executing member") + .await; + let original_batch_id = batch.batch_id.clone(); + let original_action_id = batch.actions[0].action_id.clone(); + let original_action_fingerprint = batch.actions[0].action_fingerprint.clone(); + let original_snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + assert_eq!(original_snapshot.bound_from, "initial-collaboration-batch"); + assert_eq!( + batch + .collaboration_contract + .as_ref() + .map(|contract| contract.policy_fingerprint.as_str()), + Some(original_snapshot.policy_fingerprint.as_str()) ); write_supervisor_collaboration_policy_at( &root, @@ -8325,35 +10340,52 @@ async fn supervisor_collaboration_policy_recovery_validates_contract_before_repl ..SupervisorCollaborationPolicy::default() }, ) - .expect("change collaboration policy after crash window"); + .expect("change collaboration policy after snapshot binding"); - let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .expect("acquire runtime lock") - .expect("runtime lock available"); - let resumed = resume_game_creator_agent_pending_tool_action_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - runtime_lock, - ) - .expect("resume pending with policy drift"); - assert!(matches!( - resumed, - AgentRuntimePendingActionResume::Handled(_) - )); - assert!(static_delegate_target_agent_ids_at( + let stable_batch = read_game_creator_agent_runtime_provider_action_batch( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, run_id, ) - .expect("read static state after blocked recovery") - .is_empty()); - let recovered = - read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - .expect("read reconciled runtime"); - assert_eq!(recovered.state.phase, "needs-reconciliation"); + .expect("read pending batch through bound snapshot"); + assert_eq!(stable_batch.batch_id, original_batch_id); + assert_eq!(stable_batch.actions[0].action_id, original_action_id); + assert_eq!( + stable_batch.actions[0].action_fingerprint, + original_action_fingerprint + ); + assert_eq!( + read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id), + original_snapshot + ); + + let snapshot_after = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + assert_eq!(snapshot_after, original_snapshot); + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("resolve drifted project policy through run snapshot"); + assert_eq!(resolution.source, "run-snapshot"); + assert_eq!(resolution.project_policy_status, "drifted"); + assert_eq!(resolution.policy, original_snapshot.policy); + let prompt_policy = render_supervisor_collaboration_policy_for_prompt_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("render bound policy after project policy drift"); + assert_eq!( + serde_json::from_str::(&prompt_policy) + .expect("parse bound prompt policy"), + original_snapshot.policy, + ); + assert_supervisor_collaboration_has_zero_runtime_side_effects_for_test( + &root, + run_id, + &[original_action_id], + ); fs::remove_dir_all(root).ok(); } @@ -8616,6 +10648,145 @@ fn supervisor_collaboration_rejects_dynamic_child_static_delivery_and_executor_t fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn supervisor_collaboration_contractless_v1_read_with_existing_facts_migrates_policy() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-collaboration-contractless-v1-existing-facts", + "旧非协作批次已有事实恢复测试", + ) + .expect("project init"); + let policy = SupervisorCollaborationPolicy::default(); + write_supervisor_collaboration_policy_at(&root, policy.clone()) + .expect("write collaboration policy"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("write permissive tool policy"); + write_local_project_file_at(&root, "game/context-a.txt", "alpha\n") + .expect("write first read fixture"); + write_local_project_file_at(&root, "game/context-b.txt", "beta\n") + .expect("write second read fixture"); + let run_id = "supervisor-collaboration-contractless-v1-existing-facts-run"; + let runtime = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "读取已有协作事实的项目上下文", + run_id, + "agent-chat", + "验证非协作型 v1 batch 可恢复", + vec!["不得进入人工核对".to_string()], + ) + .expect("start supervisor runtime"); + let delivery = new_static_delegate_delivery( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &runtime.session_id, + run_id, + "contractless-v1-existing-fact-action", + "contractless-v1-existing-fact-delivery", + "design-director", + "contractless-v1-existing-fact-child-session", + "contractless-v1-existing-fact-child-run", + ); + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("create existing collaboration fact"); + assert!(read_supervisor_collaboration_state_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read existing collaboration state") + .has_collaboration()); + + let actions = vec![ + AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("读取第一份项目上下文".to_string()), + input: serde_json::json!({ "path": "game/context-a.txt" }), + }, + AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("读取第二份项目上下文".to_string()), + input: serde_json::json!({ "path": "game/context-b.txt" }), + }, + ]; + assert!(!supervisor_collaboration_actions_require_contract(&actions) + .expect("classify non-collaboration actions")); + let plan = AgentRuntimeToolPlan { + thinking_summary: "读取已有协作 run 的项目上下文".to_string(), + plan_update: None, + plan: vec!["读取两份项目上下文".to_string()], + actions, + response: String::new(), + }; + let revision = read_game_creator_agent_runtime_project_revision(&root) + .expect("read contractless v1 project revision"); + let preparation = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + "读取已有协作事实的项目上下文", + &plan, + &[], + &revision, + &"c".repeat(64), + ) + .await + .expect("prepare non-collaboration Provider batch"); + let AgentRuntimeProviderActionBatchPreparation::Ready(batch) = preparation else { + panic!("non-collaboration reads must form a ready Provider batch"); + }; + assert!(batch.collaboration_contract.is_none()); + let legacy_batch = downgrade_supervisor_collaboration_batch_to_v1_for_test(batch); + remove_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + write_supervisor_provider_action_batch_fixture_for_test(&root, &legacy_batch); + assert!(read_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent contractless v1 snapshot before recovery") + .is_none()); + assert!(read_supervisor_collaboration_policy_snapshot_binding_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent contractless v1 binding before recovery") + .is_none()); + + let recovered = read_game_creator_agent_runtime_provider_action_batch( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read contractless v1 batch with existing collaboration facts"); + assert_eq!(recovered, legacy_batch); + let snapshot = read_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); + let binding = read_supervisor_collaboration_policy_snapshot_binding_for_test(&root, run_id); + assert_eq!(snapshot.bound_from, "legacy-current-project-policy"); + assert_eq!(snapshot.policy, policy); + assert_eq!(binding.parent_run_id, run_id); + assert_eq!(binding.snapshot_fingerprint, snapshot.snapshot_fingerprint); + let runtime_after = + read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .expect("read runtime after contractless v1 recovery"); + assert_ne!(runtime_after.state.phase, "needs-reconciliation"); + assert_eq!( + read_static_delegate_delivery_at(&root, &delivery.delegation_id) + .expect("read existing collaboration fact after recovery") + .expect("existing collaboration fact remains") + .status, + StaticDelegateDeliveryStatus::Dispatched, + ); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn supervisor_collaboration_v1_batch_only_recovery_preserves_evidence_for_reconciliation() { let root = unique_project_path(); @@ -8672,6 +10843,7 @@ async fn supervisor_collaboration_v1_batch_only_recovery_preserves_evidence_for_ panic!("mixed collaboration must form durable batch"); }; let legacy_batch = downgrade_supervisor_collaboration_batch_to_v1_for_test(batch); + remove_supervisor_collaboration_policy_snapshot_for_test(&root, run_id); let batch_path = game_creator_agent_runtime_provider_action_batch_path( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -8682,6 +10854,20 @@ async fn supervisor_collaboration_v1_batch_only_recovery_preserves_evidence_for_ serde_json::to_vec_pretty(&legacy_batch).expect("serialize legacy provider batch"), ) .expect("write legacy provider batch fixture"); + assert!(read_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent legacy snapshot before recovery") + .is_none()); + assert!(read_supervisor_collaboration_policy_snapshot_binding_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent legacy snapshot binding before recovery") + .is_none()); assert!(!game_creator_agent_runtime_pending_tool_action_path( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -8722,6 +10908,20 @@ async fn supervisor_collaboration_v1_batch_only_recovery_preserves_evidence_for_ .expect("read isolated state after legacy batch rejection"), IsolatedAgentGroupSummary::default(), ); + assert!(read_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent legacy snapshot after recovery") + .is_none()); + assert!(read_supervisor_collaboration_policy_snapshot_binding_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("read absent legacy snapshot binding after recovery") + .is_none()); fs::remove_dir_all(root).ok(); } @@ -48308,6 +50508,14 @@ fn project_supervisor_static_delivery_transitions_and_claims_idempotently() { assert!(!ready_barrier.has_waiting()); assert!(!ready_barrier.is_clear()); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-delivery-run", + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind default collaboration snapshot"); let first_claim = claim_ready_static_delegate_receipts_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -48528,6 +50736,14 @@ fn project_supervisor_static_delegate_contract_persists_evidence_and_receipt() { ) .expect("mark structured delivery ready"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-contract-run", + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind default collaboration snapshot"); let receipts = claim_ready_static_delegate_receipts_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -48620,6 +50836,14 @@ fn project_supervisor_legacy_ready_delivery_stays_byte_stable_until_claimed() { before ); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor-legacy-run", + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind default collaboration snapshot"); let receipts = claim_ready_static_delegate_receipts_at( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, @@ -51432,6 +53656,14 @@ fn project_supervisor_static_ready_receipts_claim_stable_delegation_prefix() { .count(); assert!(full_payload_chars > prefix_budget); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind default collaboration snapshot"); let action_id = "project-supervisor-static-prefix-claim-action"; let claimed = claim_ready_static_delegate_receipts_with_budget_at( &root, @@ -51499,6 +53731,14 @@ fn project_supervisor_static_claim_observation_requires_exact_receipt_ids() { ) .expect("mark exact observation delivery ready"); } + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind default collaboration snapshot"); let action_id = "project-supervisor-static-exact-observation-claim-action"; let receipts = claim_ready_static_delegate_receipts_at( &root, @@ -51813,6 +54053,14 @@ fn project_supervisor_ready_claim_is_atomic_when_later_delivery_lock_is_busy() { .expect("mark delivery ready"); } + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind default collaboration snapshot"); let second_delivery_lock = try_acquire_game_creator_agent_delegation_lock_with_wait( &root, &second_delivery.delegation_id, @@ -51886,6 +54134,14 @@ fn project_supervisor_unobserved_claim_blocks_finalization_until_observed() { "设计约束已经完成", ) .expect("mark delivery ready"); + bind_supervisor_collaboration_policy_snapshot_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + parent_run_id, + &SupervisorCollaborationPolicy::default(), + "legacy-current-project-policy", + ) + .expect("bind default collaboration snapshot"); assert_eq!( claim_ready_static_delegate_receipts_at( &root, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8b7a0c5dc..6856312d3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4827,6 +4827,7 @@ - 决策:新增独立项目控制面 `.agent/collaboration-policy.json`,声明首波 `auto / static / isolated / mixed`、最少 static delegate、required static Agent、最少 isolated child 和委派后总控只编排开关。缺失 sidecar 时不强制特定协作拓扑,但默认在当前父 run 形成任何 delivery/group 后禁止 Supervisor 直接修改项目。 - 原子性:Supervisor 首波协作复用 Provider action batch 的整批预检;策略不满足或协作批次混入总控项目 mutation 时,任何 pending、确认、delivery、group、child、revision 和项目写入发生前整批返回 blocked observation。通过时 batch v2 固化策略与动作合同指纹,恢复时重新校验策略漂移。 - 恢复顺序:batch 成员必须在委派或 spawn 副作用前持久化为 `executing`;恢复、确认和 replay 必须先校验当前策略、完整协作合同、batchId 与 action 身份。策略漂移或旧协作 batch 缺少合同只能进入 `needs-reconciliation`,不得重放 child 副作用。isolated 最低 child 数量按单一 durable group 计算,不能拼接多个不足最低数量的小 group。 +- 覆盖说明:上条关于“恢复时重验当前策略、live policy 漂移即 reconciliation”的部分自 V1.38 起不再是现行口径。V1.32 的其它 batch/contract/action 身份与副作用前门禁继续有效;现行策略选择、漂移和恢复顺序以本文件 V1.38 决策为准。 - 控制面边界:`.agent/collaboration-policy.json` 对 Agent 通用文件工具隐藏并拒绝写入;委派后的 Supervisor 只允许严格只读 MCP,注解不完整或 destructive MCP 失败关闭。`project.git_commit` 与 `canvas.asset_generate` 在取得项目锁后再次读取 durable 协作事实,堵住 dispatch 首检后的并发落盘窗口。 - 完成边界:finalization 只认可同一父 run 的 durable static delivery 和 isolated group。repair delegate、合法 isolated 检查、读取、状态查询和项目验证继续允许;源码写入、patch/restore、Git commit、命令启动及平台素材生成由专业 Agent 承担。 - 验证方式:运行 `supervisor_collaboration_`、`provider_action_batch_`、`project_supervisor_mixed_` 定向 Rust 回归,随后执行编码检查和 `git diff --check`;真实 Provider V1.32 必须在最终代码 diff 上独立完成,不能复用 V1.31 报告。 @@ -4881,3 +4882,14 @@ - Scope 边界:只读 isolated task 也必须声明 expected artifact 的最小目录 scope,不得扩大到 sibling scope 或共同父目录;该约束写入通用边界提示,不为单个验收任务硬编码。 - 定向验收:`supervisor_collaboration_policy_` 23/23、`project_supervisor_` 47/47 通过,E2E self-test **PASS**;Tauri/Rust 全量为 930 passed、4 个环境依赖用例按设计 ignored。 - 真实 Provider:第一次独立运行因模型初始 child scope 不符合 expected artifact 最小边界而 **FAIL**,child / claim / project mutation 均为 `0` 且现场自动清理,不与后续证据拼接。补强通用边界提示后的第二次独立运行 **PASS**:policy=`2`,2 个 group / 3 个 child,1 个 `observed` join claim 覆盖 2 个 group;Runner 强杀恢复身份稳定,Provider lifecycle `64/64` completed、failed=`0`,重复、残留、泄漏均为 `0`,最终回复唯一。 + +## 2026-07-18 AI 游戏创作 Agent Runtime V1.38 父 run 协作策略持久快照与绑定记录 + +- 决策:首个非 `aborted`、携带 v2 `collaborationContract` 的 durable collaboration batch 是父 run 策略线性化点。Runtime 必须按 `v2 batch -> snapshot -> binding sidecar -> action side effects` 持久化:snapshot 位于 `.agent/runtime/collaboration-policy-snapshots//.json`,独立 binding 位于 `.agent/runtime/collaboration-policy-snapshot-bindings//.json`,用于持久证明“该 run 曾绑定”。没有既存 binding 的首次 `aborted` batch 不创建两类 sidecar;若 binding 已证明该 run 先前完成绑定,snapshot 丢失时可用完整验真的 matching v2 contract 恢复原快照,即使当前保留的 batch 为 `aborted`,这不构成新绑定。batch -> snapshot 与 snapshot -> binding 都是零副作用可恢复窗口。 +- 数据契约:snapshot v1 固定且完整包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`;policy 先规范化,snapshot fingerprint 绑定除 `snapshotFingerprint / boundAt` 外的全部稳定字段。binding v1 固定包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`,必须与 snapshot 的绑定身份逐字段一致。两者按 project/parent run 共用锁并在锁内 CAS,冲突失败关闭,禁止通用 replace 覆盖。 +- 路径身份:安全 Agent/run ID 可原样作为 `agentKey/runKey`;任何不安全或规范化后变化的 ID 必须使用有界安全前缀加原始完整 ID 的稳定 SHA-256,不能让 lossy 字符替换制造路径碰撞。锁 key 固定对完整 `parentAgentId + NUL + parentRunId` 计算稳定 SHA-256,不复用路径规范化结果。 +- 恢复:优先级固定为 existing valid snapshot > 完整验真的 v2 batch contract > 符合严格状态门禁的 legacy 当前有效 policy。正常绑定使用 `boundFrom=initial-collaboration-batch`;v2 contract 必须先独立校验 batch/project/action/contract 全身份与两层指纹,才能以 `boundFrom=legacy-provider-batch-contract` 补绑。snapshot 存在但 binding 缺失时从 snapshot 补写;binding 存在但 snapshot 丢失时只按 matching binding 与可信 v2 contract 恢复,没有可信 v2 contract 时禁止按 live policy 重绑。 +- Legacy 与旧 batch:contractless/v1 collaboration batch 必须在任何 live policy 回退前失败关闭并进入 reconciliation,不能忽略旧 batch 后把已有 run 当成 fresh run。`boundFrom=legacy-current-project-policy` 仅允许无 snapshot/binding、无可信 v2 contract,且不存在上述旧 batch,并由 durable run 身份与状态明确证明属于 `pending / running / waiting-for-confirmation / waiting-for-user-input` 的旧父 run;terminal、`needs-reconciliation` 或身份/状态未知 run 的状态读取不得新建 snapshot。没有任何 durable run/collaboration 事实的真正新父 run只能读取 live policy 构造首个 v2 contract,不创建 legacy snapshot。 +- 漂移:snapshot 绑定后,后续 spawn、repair、新 claim、Supervisor mutation、MCP、prompt/status、completion/finalization 与恢复全部使用 snapshot。global policy 的 `matched / drifted / unreadable` 只作有界状态报告,不改变执行、revision、verification 或 reconciliation;已有 run 不重绑,新 policy 只由后续新父 run 采用。 +- Claim 兼容:旧 durable claim、未观察 claim 和 legacy claimed delivery 的恢复先于 effective snapshot 解析及新 claim 门禁,继续按原 action/group 身份推进且不得取得新 delivery;global policy、snapshot 或 binding 故障不能把已提交 claim 卡死。新的 claim 必须先成功解析 effective snapshot 并核对 binding,失败发生在 journal、delivery 锁和 mutation 之前;随后仍执行 V1.35-V1.37 的全锁、预算、完整 observation 和 group 数量门禁。 +- 覆盖与验收:本决策明确覆盖 V1.32 的 live drift reconciliation 旧口径,但不把 V1.32/V1.35/V1.37 历史 PASS 外推为 V1.38 证据。2026-07-19 self-test、snapshot/binding 双故障窗口与 CAS/丢失/篡改/旧 batch/危险 ID/claim 分流确定性覆盖、`supervisor_collaboration_` 52/52、`provider_action_batch_` 12/12、`project_supervisor_mixed_` 5/5 和 Tauri/Rust 全量 949 passed/4 ignored 已通过;终态 Runtime 清理后 snapshot/binding 保持原字节并继续解析为 `run-snapshot`。真实 mixed-swarm 独立功能样本已形成 2 group/3 child、policy drift、Runner 恢复、唯一最终回复和零重复/泄漏,但同轮正式 endpoint 被外部客户端重启;改用私有配置源后多轮又耗尽 transient Provider retry,最后在 `300000ms / maxRetries=3` 下于首批业务动作前形成 4 failed/3 retry 并干净终止。两类失败证据不得拼接,当前**仍不得声称 V1.38 真实 E2E 已 PASS**。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index bd387f4ca..b37057a39 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3243,6 +3243,17 @@ - 验证:Rust 定向回归使用 `project_supervisor_` 前缀,覆盖 delivery/claim 状态机、同 action 幂等、第 4 个新委派拒绝与已预留委派复用/拒绝 suppression、后续 delivery 锁忙时零部分认领、Agent DB 故障后回执仍可重放、未 Observed 阻断 final、Provider planning 前 durable 等待、parent-wake coalescing/结构性错误、重启损坏 barrier、错配和迟到 child、executing `run_status` 续接与 delegate policy 重验;`agent_background_enqueue_notifies_only_after_session_lane_release` 覆盖入队锁序,Runner 内部测试覆盖定向 wake 与不缓存重试。真实 Provider 必须同时证明专业 Agent 时间区间重叠、父 run 仅一次 waiting、同一 Observed claim 认领全部回执、唯一 assistant、第二轮历史引用不新增委派和项目范围密钥扫描为 0。 - 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`apps/ai-game-creator-shell/src-tauri/src/delegation.rs`、`agent.rs`、`runner.rs`、`tests.rs`。 +## 父 run 协作策略不能在绑定后继续按全局 live policy 重验 + +- 现象:同一 Supervisor 父 run 已经持久化合法 collaboration batch,管理员随后修改或损坏 `.agent/collaboration-policy.json`,后续 spawn、claim、mutation、MCP 或 finalization 却突然改用新策略、进入 reconciliation;或者 snapshot 被删除后,Runtime 又按 live policy 把已有 run 当成未绑定 run。另一类症状是 contractless/v1 batch 被跳过、两个不安全 run ID 经字符替换落到同一 snapshot/锁 key,或旧 `Prepared / Committed` claim 因 snapshot/binding 不可读而不能重放 observation。 +- 原因:把项目级 policy 当成每个动作的 live 执行事实,没有为父 run 设置明确线性化点、不可变策略快照和独立“曾绑定”记录;或者在 v2 batch 完整验真前就用 `contract.policy` 播种 snapshot。只对 run ID 做 lossy 规范化、让锁复用该路径片段,或用通用原子 replace 代替同一身份锁内 CAS,也会制造路径碰撞、并发覆盖和伪合同漂移。 +- 处理:V1.38 固定顺序为 `v2 batch -> snapshot -> binding sidecar -> action side effects`。snapshot 位于 `.agent/runtime/collaboration-policy-snapshots//.json`,其完整字段必须统一为 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`;snapshot fingerprint 覆盖除 `snapshotFingerprint / boundAt` 外的全部稳定字段。独立 binding 位于 `.agent/runtime/collaboration-policy-snapshot-bindings//.json`,固定包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`,与 snapshot 逐字段交叉验证并持久证明“该 run 曾绑定”。 +- 路径与恢复:不安全或规范化后变化的 Agent/run ID 使用有界安全前缀加原始 ID 稳定 SHA-256,锁 key 对完整 `parentAgentId + NUL + parentRunId` 计算稳定 SHA-256,不能只做字符替换。恢复顺序为 existing valid snapshot > 完整验真的 v2 batch contract > 符合严格状态门禁的 legacy 当前有效 policy;snapshot 缺 binding 可从 snapshot 补写,binding 存在但 snapshot 丢失只能按可信 v2 contract 和首次绑定身份恢复,无可信 v2 时禁止 live policy 重绑。contractless/v1 collaboration batch 必须先失败关闭。`legacy-current-project-policy` 只允许无 snapshot/binding、无可信 v2 contract,且不存在上述旧 batch,并由可信身份和状态明确证明属于 `pending / running / waiting-for-confirmation / waiting-for-user-input` 的旧父 run;terminal、`needs-reconciliation` 或身份/状态未知 run 的状态读取不得新建 snapshot。 +- 漂移与 Claim:绑定后 global policy 的 `matched / drifted / unreadable` 只报告状态,不能改变后续动作或完成门禁;新 policy 只用于后续新父 run。旧 durable claim、未观察 claim 和 legacy claimed delivery 先按原 action/group 身份恢复且不得取得新 delivery;新的 claim 必须先成功解析 effective snapshot 并核对 binding,再执行 V1.35-V1.37 的全锁、预算、完整 observation 和 group 数量门禁。 +- 真实 E2E 现场:正在运行的正式客户端可能在验收期间启动或重启正式 Runner,导致 source endpoint 身份真实变化。不得关闭 `sourceRunnerEndpointUnchanged` 门禁,也不得杀掉不属于验收器的进程;应把同一配置内容复制到仓库外的大容量磁盘私有目录,目录/文件权限分别为 `0700/0600`,不复制 endpoint、锁、会话或数据库,验收后删除。功能完整但 source endpoint 被外部改变的报告与后续干净清理报告不得拼接。 +- 验证:必须覆盖 snapshot 9 个完整字段、首次 `aborted` batch 无 snapshot/binding、matching binding 已存在时可用可信 `aborted` v2 contract 恢复缺失 snapshot、双故障窗口零副作用恢复、同内容并发 CAS、snapshot/binding 冲突或丢失、篡改 contract 不得播种、binding 已存在且无可信 v2 时禁止 live 重绑、contractless/v1 协作 batch 先失败关闭且非协作 v1 batch 不误伤、四种 legacy 非终态可迁移而 terminal/`needs-reconciliation`/身份状态未知读取不建 snapshot、危险 ID 路径/锁不碰撞、四类 global policy 状态,以及旧 claim 可恢复而新 claim 先过 effective snapshot。2026-07-19 上述确定性门禁、E2E self-test、52/52 collaboration 定向回归、终态 snapshot/binding 字节保留回归和 949 passed/4 ignored Rust 全量已完成;真实功能闭合轮受正式 endpoint 外部重启污染,私有配置源轮又连续耗尽 transient Provider retry,不能拼接为 PASS,故当前仍**不得声称 V1.38 真实 E2E 已 PASS**。 +- 关联:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`、`docs/project-memory/shared-memory/decision-log.md`、`apps/ai-game-creator-shell/src-tauri/src/collaboration.rs`、`agent.rs`、`tests.rs`、`scripts/agent-runtime-real-e2e.mjs`。 + ## 单 Agent 持久计划不能靠工具下标或恢复猜进度 - 现象:工具 action 1 成功后第二个计划步骤被自动标成完成,模型仍有 pending / in_progress 步骤却写出最终回复;或 Runner 重启、刷新 UI、same-run steer 后 `planRevision` 回退、已完成步骤消失,legacy `plan` 又覆盖新计划。另一类错误是仅更新计划就触发项目 revision 漂移、verification 失效或权限确认。 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 3b027b6b9..3ba549ea1 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 @@ -1175,6 +1175,8 @@ V1.31 证明真实 Provider 可以自主形成 static + isolated 混合协作, - finalization 在两类 delivery/join blocker 之外追加协作策略完成门禁。配置要求的 initial static/isolated 事实缺失、required static Agent 不完整或策略 sidecar 损坏时,原 Supervisor run 必须回到 planning,不能提交最终回复。完成事实只来自当前父 run 的 durable delivery/group,不接受 prompt 声明、计划文字或 Agent DB 诊断投影代替。 - 确定性验收至少覆盖:不完整 mixed 首波零 child/零 revision;完整 mixed 首波形成唯一 v2 合同;`executing` 先于首个 durable delivery 且 cursor 正确推进;恢复先验合同并在策略漂移时零 replay;委派后 mutation 在 confirmation 前被拒绝;Git commit 与画板生成在项目锁内再次阻断;破坏性 MCP、策略文件写/patch 和 revision 推进均为 0;repair delegate、isolated spawn、`project.verify` 和读取仍允许;batch round-trip/Runner 恢复保持 batchId、policy/contract fingerprint 和 actionId,重复 child/delivery/group 为 0。真实 Provider 另建 V1.32 suite,不能把 V1.31 的一次成功样本外推为 Runtime 合同已验收。 +**V1.38 覆盖说明:** 本节关于“恢复、确认和每次 batch 读取都重新校验当前项目策略,live policy 漂移即进入 `needs-reconciliation`”的口径自 V1.38 起废止。V1.32 的既有真实 PASS 只保留为当时实现的历史证据;现行编码与恢复语义统一以 V1.38 的父 run 持久策略快照为准,不能用 V1.32 报告宣称 V1.38 已通过。 + 2026-07-17 在最终代码 diff 上使用 `gpt-5.5 / openai_chat / high` 完成 V1.32 独立真实 PASS。`supervisor-swarm-collaboration-policy-mixed-recovery` 只在隔离 AppData 配置副本中把瞬态重试设为 `maxRetries=2 / retryBackoffMs=500`,正式 AppData、源配置和 Runner endpoint 均未修改;86 个 Provider lifecycle 全部完成,本轮未触发重试。首批 v2 batch 固化 3 个 action,包含 2 个指定 static delegate 与 1 个三 child isolated spawn;waiting-confirmation 零副作用边界、pidfd 强杀恢复、batch/contract/action identity、两类 Provider 重叠、1 次 repair、3 个 delivery、1 个 isolated group/3 个 instance/3 个 result/1 个 claimed join、宿主验证和唯一 Supervisor assistant 全部通过。 成功报告共记录 178 个 task snapshot、326 个 event、556 个 Agent DB record、30 个 action execution 和 38 个 receipt;重复 delivery/group/instance/result/join/claim/message/action/receipt/Provider lifecycle 与 pending/batch/finalization/confirmation sidecar 均为 0,私密正文、Provider payload、API Key、项目路径、正式配置路径和最终报告泄漏均为 0。`turn.report=settled` 且 reconciliation Agent 为 0。49 次 native tool plan 中发生 30 次格式修复,未破坏动作幂等与最终结果,但说明真实链路仍有明显延迟和 Provider 调用成本,后续应单独收敛工具合同表达和 repair 频率。 @@ -1268,6 +1270,82 @@ V1.35 已证明模型可以先建立一个 isolated group,再在首次 join cl 真实 Provider 第一次独立运行因模型给出的初始 child scope 不满足 expected artifact 最小边界而 **FAIL**;验收确认 child、claim 和 project mutation 均为 `0`,现场自动清理,该失败证据不得与后续运行拼接。补强通用 scope 边界提示后的第二次独立运行 **PASS**:报告记录 policy=`2`、2 个 group / 3 个 child、1 个 `observed` join claim 覆盖 2 个 group;Runner 强杀恢复前后身份稳定,Provider lifecycle `64/64` completed、failed=`0`,重复、残留与泄漏均为 `0`,且只产生唯一最终回复。 +## V1.38 父 run 协作策略持久快照、绑定记录与漂移隔离 + +V1.38 把 collaboration policy 的执行语义从“每次动作或恢复都读取项目级 live policy”改为“首个有效 durable collaboration batch 为父 run 固化策略”,并用独立 binding sidecar 持久证明“该 run 曾绑定”。本节覆盖 V1.32 的 live drift reconciliation 旧口径,不改变 V1.34 的 isolated child 能力边界、V1.35/V1.36 的 claim/observation 完整性或 V1.37 的首次认领数量门禁。 + +### 线性化点与写入顺序 + +- 同一 `project-supervisor` 父 run 的**首个非 `aborted` durable collaboration batch** 是策略线性化点。这里的 collaboration batch 必须是携带完整 v2 `collaborationContract` 的 Provider action batch;尚无 binding 时,只有 `aborted` 事实的批次不绑定快照,也不阻止后续合法批次选择届时有效的项目策略。 +- 固定提交顺序为:完整预检并构造 v2 batch/contract -> 先持久化 `status != aborted / nextActionIndex=0` 的 batch -> 在父 run 快照锁内 CAS 写入并验真 collaboration policy snapshot -> CAS 写入并验真独立 binding sidecar -> 两者一致后才允许任何成员进入会产生副作用的 dispatch。delivery、isolated group/child、新 join/receipt claim、项目 mutation、MCP 调用和 finalization 都不得出现在完整绑定之前;没有既存 binding 的首次 `aborted` batch 不创建 snapshot 或 binding。 +- batch 已落盘而 snapshot 尚未落盘、以及 snapshot 已落盘而 binding 尚未落盘,都是受支持且保持零 action 副作用的崩溃窗口。前者只能从已完整验真的 v2 contract 补绑,后者只能从有效 snapshot 补写完全一致的 binding;恢复完成后才从原 batch cursor 继续。 +- snapshot 一旦存在就是该父 run 的不可变策略事实源;binding 是独立的“曾绑定”持久记录和防降级屏障,不承载 policy 本体。后续 static/isolated spawn、repair、新 claim、Supervisor mutation/MCP 门禁和 finalization 只能使用有效 snapshot,不能在同一 run 重新选择或升级 policy,也不能通过删除 snapshot 把已绑定 run 伪装成未绑定 run。 + +### Snapshot schema 与指纹 + +快照固定写入 `.agent/runtime/collaboration-policy-snapshots//.json`。snapshot v1 固定且完整的字段集合为 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`,不得缺少字段或接受未知字段: + +```json +{ + "schemaVersion": "game-creator-supervisor-collaboration-policy-snapshot.v1", + "projectId": "project identity", + "parentAgentId": "project-supervisor", + "parentRunId": "parent run identity", + "boundFrom": "initial-collaboration-batch", + "policy": {}, + "policyFingerprint": "sha256", + "snapshotFingerprint": "sha256", + "boundAt": 0 +} +``` + +- `policy` 必须先经过 V1.32/V1.37 的完整规范化和上限校验;`policyFingerprint` 只对规范化 policy 的稳定序列化计算 SHA-256。 +- `snapshotFingerprint` 对稳定身份字段计算 SHA-256,必须绑定 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint`,不包含审计时间 `boundAt`。读取时逐字段复核当前项目身份和调用方 parent 身份,任何未知 schema、未规范 policy、非法指纹、跨项目或跨 run 内容都失败关闭。 +- `boundFrom=initial-collaboration-batch` 用于正常线性化,`boundFrom=legacy-provider-batch-contract` 用于从可信 v2 contract 恢复。`boundFrom=legacy-current-project-policy` 仅允许没有 snapshot/binding、没有可信 v2 contract,且不存在 contractless/v1 collaboration batch,并由 durable run 身份与状态明确证明仍处于 `pending / running / waiting-for-confirmation / waiting-for-user-input` 的旧父 run;terminal、`needs-reconciliation` 或身份/状态未知 run 的状态读取不得新建 snapshot。既有 snapshot 重读保持首次 `boundFrom / boundAt / snapshotFingerprint`,不得按本次调用重写。 + +### Binding sidecar 与安全路径键 + +独立 binding 固定写入 `.agent/runtime/collaboration-policy-snapshot-bindings//.json`,只保存不可变绑定身份,不复制 policy: + +```json +{ + "schemaVersion": "game-creator-supervisor-collaboration-policy-snapshot-binding.v1", + "projectId": "project identity", + "parentAgentId": "project-supervisor", + "parentRunId": "parent run identity", + "boundFrom": "initial-collaboration-batch", + "policyFingerprint": "sha256", + "snapshotFingerprint": "sha256", + "boundAt": 0 +} +``` + +- binding 与 snapshot 必须在同一父 run 锁内逐字段交叉验证 `projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`。已有有效 snapshot 但没有 binding 时可从 snapshot 幂等补写;binding 一旦存在不得删除、替换或按 live policy 重建。 +- `agentKey / runKey` 不能只做可能碰撞的 lossy 字符替换。原始 ID 本身满足安全路径组件规则时可原样使用;否则使用有界可读安全前缀加原始完整 ID 的稳定 SHA-256,例如 `--`,保证 `a/b`、`a\\b`、`a_b` 等不安全 run ID 不会落到同一路径。snapshot 与 binding 必须使用同一映射。 +- 锁 key 不复用规范化后的路径片段,固定对完整 `parentAgentId + NUL + parentRunId` 计算稳定 SHA-256。路径 key 或锁 key 的计算不能读取 live policy,也不能因进程重启、平台路径分隔符或 locale 改变。 + +### 锁内 CAS 与恢复优先级 + +- 创建或读取 snapshot/binding 必须在按完整 `projectId + parentAgentId + parentRunId` 身份隔离的同一锁内完成 CAS。锁内先重读两份 durable 记录:两者一致时返回首次记录并视为幂等;policy、项目、父 run 或任一绑定字段冲突都失败关闭,通用原子 replace 不能覆盖已经绑定的 snapshot/binding。 +- 恢复优先级固定为:**existing valid snapshot > 完整验真的 v2 batch contract > 符合严格状态门禁的 legacy 当前有效 project policy**。最后一层不是一般回退,只允许无 snapshot/binding 且身份可信、状态明确属于 `pending / running / waiting-for-confirmation / waiting-for-user-input` 的旧父 run;existing snapshot 与 v2 contract 不一致时是同一父 run 的 durable 身份冲突,进入 reconciliation,不得以 live policy 裁决哪份更新。 +- 从 v2 contract 迁移前必须先完成不依赖 snapshot 的两阶段校验:batch v2 schema、project/Agent/run 身份、action 成员与顺序、batchId、contract schema、规范 policy、policy fingerprint 和 contract fingerprint 全部成立。不得先把未验 contract.policy 写成 snapshot,再让后续 batch 校验失败。 +- snapshot 存在而 binding 缺失时,从有效 snapshot 补写 binding;binding 存在而 snapshot 缺失时,只能用完整验真的 v2 contract 按 binding 中的首次身份恢复 snapshot。matching binding 已存在时,完整验真的 `aborted` v2 contract 也可用于重建原 snapshot,因为这是恢复既有绑定而不是建立新绑定。binding 与恢复结果不一致、binding 损坏,或者 binding 已证明曾绑定而 snapshot 丢失且没有可信 v2 contract 时,都保持零新副作用并失败关闭,禁止按 live policy 重绑。 +- durable collaboration batch 的 contractless/v1 形态必须先失败关闭并进入 `needs-reconciliation`,不能跳过旧 batch、读取 live policy 后把 run 当成 fresh run;不含协作动作的 contractless/v1 batch 不受该门禁误伤。没有 snapshot/binding、没有上述旧协作 batch且没有可信 v2 contract 时,只有 durable task/run ledger 能同时证明父 run 身份和 `pending / running / waiting-for-confirmation / waiting-for-user-input` 状态,才可用 `legacy-current-project-policy` 迁移;terminal、`needs-reconciliation` 或身份/状态未知只允许无副作用读取状态。尚无任何 durable run/collaboration 事实的真正新父 run可在首批 planning/preflight 读取当前 project policy,并把它固化进首个 v2 contract,但这不创建 legacy snapshot。 + +### 绑定后的统一读取与漂移语义 + +- 绑定后,planning prompt、batch preflight/validation、后续 spawn、`agent.run_status` 新 claim、Supervisor mutation、破坏性 MCP 判断、completion blocker、finalization 创建与 finalization 恢复都必须通过同一个 effective-snapshot resolver 取得有效 snapshot,并核对 matching binding。项目级 `.agent/collaboration-policy.json` 不再参与这些执行裁决。 +- global project policy 相对 snapshot 的状态只允许报告 `matched / drifted / unreadable`:当前有效 policy 与 snapshot 相同为 `matched`,有效但不同为 `drifted`,读取或解析失败为 `unreadable`。三者只进入有界状态与诊断元数据;planning prompt 继续渲染 snapshot 中的规范 policy 本体,不改变现有 JSON 外形。漂移状态不能改变后续 spawn、claim、mutation、MCP、finalization 或 batch replay,也不能制造 project revision、verification 或 reconciliation。 +- policy 更新只供后续新父 run 在其首个非 aborted collaboration batch 选择;已有 snapshot 的 run 不原地重绑。需要应用新策略时必须创建新父 run,不能通过删除 snapshot、重写 batch 或 status 查询迁移活跃 run。 +- durable claim 恢复优先于 effective snapshot 解析和新 claim 门禁。已有 `Prepared / Committed / Observed` static 或 isolated claim、尚未观察 claim 及 legacy claimed delivery,允许先按原 action/group 身份幂等重放或补齐;global policy 漂移、snapshot 缺失或 binding 故障都不能把已经提交的 claim 卡成第二次认领。恢复路径不得取得新的 delivery。只有创建新 claim 时,才必须先成功解析 effective snapshot 并核对 binding;解析失败必须发生在新 claim journal、delivery 锁与 delivery mutation 之前,成功后再执行 V1.35-V1.37 的全锁、预算、完整观察和 group 数量门禁。 + +### 本轮验证状态 + +- 2026-07-19 确定性门禁已完成:E2E self-test **PASS**,同时覆盖 modern `provider_action_batch.confirmation_required` 与 legacy `tool_confirmation_required`、requirement/approval/receipt 唯一性、`confirmation` execution mode、目标 Session/run 和严格持久化顺序;snapshot/binding 的终态预期改为由已验真的非 `aborted` v2 collaboration batch 决定,不再用 mixed suite 拓扑代替 durable 事实。`supervisor_collaboration_` 52/52、`provider_action_batch_` 12/12、`project_supervisor_mixed_` 5/5 通过;Tauri/Rust 全量为 949 passed、4 个环境依赖用例按设计 ignored,`check:rustfmt` 通过。 +- 确定性覆盖包含 snapshot 的 9 个完整字段、首次 `aborted` 零绑定与 matching binding 的 `aborted` v2 恢复、batch -> snapshot -> binding 双故障窗口、CAS 冲突、篡改 contract、binding/snapshot 丢失组合、contractless/v1 协作批次失败关闭与非协作批次兼容、legacy 非终态迁移与终态拒绝、危险 Agent/run ID 路径及锁隔离、global policy 漂移、已有 claim 恢复与新 claim 失败关闭。新增 `supervisor_collaboration_policy_snapshot_survives_terminal_runtime_cleanup` 证明终态只删除 pending/provider batch/confirmation 等临时 sidecar,snapshot/binding 字节保持不变且 resolver 继续返回 `run-snapshot`。 +- 真实 `supervisor-swarm-static-isolated-autonomous-chat` 曾在单次独立运行中完整形成 2 个 isolated group / 3 个 child、1 个 observed join claim 覆盖两组、唯一 repair、宿主验证、Runner pidfd 强杀恢复和唯一 Supervisor assistant;snapshot/binding 均为唯一、字节及字段稳定,global policy drift 被观察,重复、临时 sidecar、正文、API Key、项目路径和配置路径泄漏均为 0。但该轮运行期间正式客户端在测试外部重启了正式 Runner,source endpoint 所有权门禁按设计失败,因此该功能样本不能记为 PASS。 +- 随后使用权限为 `0700/0600`、不含 endpoint/锁/会话的私有配置源副本隔离正式客户端干扰,source endpoint、源目录和清理门禁均稳定;五次最小 OpenAI-chat 探针全部 HTTP 200。然而多次独立完整运行仍在长链路耗尽 transient Provider retry。最后一轮隔离 overlay 已提高到 `requestTimeoutMs=300000 / maxRetries=3 / retryBackoffMs=500`,仍在首个业务批次前形成 4 个 failed lifecycle / 3 个 retry 后终止,child、delivery、claim 和项目 mutation 均为 0,现场清理与泄漏门禁通过。失败轮不得与前述功能完整轮拼接;截至当前,**V1.38 独立真实 Provider E2E 仍未 PASS**,需在外部 Provider 稳定后以最终代码重新独立运行。 + ## 验收命令 - `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 060c8dd48..9b5681ec3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -601,3 +601,7 @@ game-project/ - 2026-07-18 V1.35 后续真实 Provider E2E **PASS**:同一父 Session/run 先创建含 2 个 child 的初始 isolated all-join group;首次 `parent-wake` 后、任何 join claim 前,再创建含 1 个 child 的 follow-up group。两组的精确 `writeScopes` 集合互不重叠,最终由同一个状态为 `observed` 的 join claim journal 同时覆盖两个 group;Runner 强杀/恢复前后身份稳定。Provider lifecycle `53/53` 全部 completed、failed 为 `0`;重复、泄漏与残留均为 `0`。V1.35 真实 Provider 门禁据此关闭;V1.36 的 static + isolated 混合 observation 完整性仍按独立门禁验收。 - 2026-07-18 起,同一 Runtime 文档的“V1.36 混合协作 observation 完整性”补齐 `readyDelegateReceipts` 与 `readyIsolatedJoins` 同轮返回边界。静态回执完整 JSON 单批最多 6000 字符;isolated-only all-join 保持 10000 字符,和静态回执混合时降为 6000 字符;普通 Runtime 状态与 claimed 摘要最多占 3500 字符。完整 `agent.run_status` detail 仍以 16000 字符为硬上限,超过上限必须失败关闭,禁止先认领后静默截断证据。 - V1.36 的静态回执先完整保留已绑定当前 action 的 recovery receipts,再按 `delegationId` 为新 ready delivery 选择稳定前缀;只预取本批 delivery 锁,并在锁内重读核对快照,超预算或未选中的后续 delivery 保持 `Ready`,其锁竞争也不能阻断必选恢复。必选集合本身无法完整放入时在写 claim sidecar 和改 delivery 前失败。pending observation 从 `readyDelegateReceipts` 解析唯一 delegationId 集合,只有与 durable claim receipts 精确相等且前置区块唯一、`ready=true` 时才允许 `Committed -> Observed`;缺失、额外、重复或无效 ID 均继续阻断 finalization。mixed 路径仍保留 isolated 先认领、static 后续失败可由下一 action 完整重放 isolated claim 的 V1.35 恢复顺序。定向回归为 `project_supervisor` 46/46、mixed 5/5、`isolated` 37/37、`supervisor_collaboration` 27/27、`provider_action_batch` 12/12;Tauri/Rust 全量为 923 passed、4 个环境依赖用例按设计 ignored。本切片未重跑真实 Provider,不把既有 PASS 扩大解释为 V1.36 已重新外部验收。 +- 2026-07-18 起,同一 Runtime 文档的“V1.38 父 run 协作策略持久快照、绑定记录与漂移隔离”覆盖 V1.32 的 live drift reconciliation 旧口径。首个非 `aborted` durable collaboration batch 是线性化点:v2 batch 必须先落盘,随后在任何 action 副作用前,以同一父 run 锁内 CAS 依次绑定 `.agent/runtime/collaboration-policy-snapshots//.json` 和独立 `.agent/runtime/collaboration-policy-snapshot-bindings//.json`;没有既存 binding 的首次 `aborted` batch 两者都不创建,matching binding 已存在时则可用完整验真的 `aborted` v2 contract 恢复缺失 snapshot。binding 是“该 run 曾绑定”的持久记录,不能通过删除 snapshot 把 run 降级为未绑定。 +- snapshot v1 固定且完整包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policy / policyFingerprint / snapshotFingerprint / boundAt`;snapshot fingerprint 绑定除 `snapshotFingerprint / boundAt` 外的全部稳定字段。binding v1 固定包含 `schemaVersion / projectId / parentAgentId / parentRunId / boundFrom / policyFingerprint / snapshotFingerprint / boundAt`,必须与 snapshot 逐字段一致。安全 ID 可原样作 key;不安全 Agent/run ID 必须使用有界安全前缀加原始 ID 稳定 SHA-256,锁 key 对完整父 Agent/run 身份计算稳定指纹,禁止 lossy 规范化碰撞。 +- 恢复优先级为 existing valid snapshot > 完整验真的 v2 batch contract > 符合严格状态门禁的 legacy 当前有效 policy。snapshot 存在但 binding 缺失时可从 snapshot 补写;binding 存在但 snapshot 丢失时只允许可信 v2 contract 按首次身份恢复,没有可信 v2 contract 时禁止按 live policy 重绑。contractless/v1 collaboration batch 必须先失败关闭,不能伪装 fresh run。`legacy-current-project-policy` 仅允许无 snapshot/binding、无可信 v2 contract,且不存在上述旧 batch,并由 durable 身份和状态明确证明属于 `pending / running / waiting-for-confirmation / waiting-for-user-input` 的旧父 run;terminal、`needs-reconciliation` 或身份/状态未知 run 的状态读取不得新建 snapshot。 +- 已有 durable/未观察 claim 与 legacy claimed delivery 继续按原 action/group 身份恢复,不要求先创建新绑定;新 claim 必须先成功解析 effective snapshot 并核对 binding,再进入 V1.35-V1.37 的全锁、预算、完整 observation 和 group 数量门禁。snapshot 绑定后 global policy 的 `matched / drifted / unreadable` 只进入有界 status/诊断,不能改变后续执行;新 policy 只由后续新父 run 采用。2026-07-19 self-test、52/52 collaboration 定向回归和 949 passed/4 ignored Rust 全量已完成,终态快照保留也有独立回归;真实 mixed-swarm 功能样本已闭合但受正式 endpoint 外部重启污染,私有配置源的后续独立运行又连续耗尽 transient Provider retry,不能拼接证据,当前仍**不得声称 V1.38 真实 E2E 已 PASS**。详细报告以 Runtime 技术方案 V1.38 节为准。