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 6c4e234ca..ff44f3150 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 @@ -37,6 +37,7 @@ const idempotentObservationTools = new Set([ 'git.inspect', 'file.list', 'file.read', + 'agent.action_history', 'agent.run_status', ]); const pngSignature = Buffer.from([ @@ -549,7 +550,8 @@ function buildTaskPrompt(suite) { 6. project.patchset 成功后必须分别完成第二次且最后一次 git.inspect 与绑定 checkpointId 的 project.diff,两者先后顺序不限。git.inspect input 仍精确为 {"includeDiff":true,"maxFiles":20,"maxChars":24000};它必须看到 game/index.html 的 unstaged 内容 hunk 和 ${patchsetCreatedPath} 的安全 untracked 路径,且不得出现 ${gitSensitivePath}、.env、${configFileName} 或 .agent,整个任务只能调用两次 git.inspect。project.diff 的 checkpointId 必须来自 patchset observation,input 必须包含 {"checkpointId":"","includeContent":true},可使用默认预算或显式传入足以容纳两个文件的 maxFiles/maxChars;必须在内容 diff 中审查 game/index.html 的 changed hunk 和 ${patchsetCreatedPath} 的 added hunk,不得猜测 checkpointId 或只看路径摘要。 7. Git 与 checkpoint 内容 diff 审查后,先再次调用 command.exec,input 必须是 {"program":"npm","args":["run","check:e2e"],"cwd":".","timeoutSeconds":120},并取得 ${commandPassedMarker}。随后读取 package.json 的原始脚本并调用 project.verify,input 必须是 {"script":"check:e2e","expectedCommand":"${verificationCommand}","timeoutSeconds":120}。 8. 验证通过后调用 preview.validate,input 必须包含 {"viewports":["desktop","mobile"],"expectedText":["${visibleText}","${patchedText}"],"settleMs":1000,"failOnConsoleError":true},必须真实生成 desktop/mobile PNG 且通过。 -9. 只有 repository context、修改前后两次 Git 审阅、失败命令反馈、唯一 patchset 及其自动 checkpoint、绑定 checkpointId 的两项内容 hunks、成功命令复验、project.verify、preview.validate、三个隔离实例和单一 join 全部形成落盘证据后才可最终回复。不要输出或转述任何配置密钥。`; +9. 上述关键修改、修改后 Git 与 checkpoint 内容审阅、三个隔离实例的 all-join、project.verify 和 preview.validate 全部完成后,最终回复前必须且只能调用一次 agent.action_history。input 必须精确为 {"tool":"project.patchset","status":"ok","limit":5},必须省略 runId 和 actionId,以验证当前 Agent、当前 run 的默认身份边界;不得猜测或写死 actionId。必须依据返回 observation 确认 actions 中恰好包含本次 project.patchset 的真实 actionId、tool=project.patchset、status=ok,然后才可收束。 +10. 只有 repository context、修改前后两次 Git 审阅、失败命令反馈、唯一 patchset 及其自动 checkpoint、绑定 checkpointId 的两项内容 hunks、成功命令复验、project.verify、preview.validate、三个隔离实例、单一 join 和本次持久动作回查全部形成落盘证据后才可最终回复。不要输出或转述任何配置密钥。`; } async function prepareCliBinary() { @@ -762,11 +764,14 @@ async function isIsolatedJoinSettledForQuiescence(joinTasks) { const delivery = await readJson(file).catch(() => null); if (delivery?.parentRunId === state.initialRunId) deliveries.push(delivery); } + const delivery = deliveries[0]; + const deliveryTarget = isolatedJoinDeliveryTarget(delivery); return ( deliveries.length === 1 && - deliveries[0].status === 'claimed-by-parent' && - isNonEmptyString(deliveries[0].joinRunId) && - isNonEmptyString(deliveries[0].claimedByActionId) + delivery.status === 'claimed-by-parent' && + isNonEmptyString(delivery.joinRunId) && + isNonEmptyString(delivery.claimedByActionId) && + (deliveryTarget !== 'parent-wake' || delivery.queuedRunId == null) ); } @@ -1037,6 +1042,18 @@ async function validateLandedEvidence() { Number(auditInputValue(execution.inputSummary, 'maxChars')) >= 1_000, 'patchset-content-diff-action-invalid', ); + const actionHistoryExecution = requireSuccessfulToolExecution( + agentDb, + 'agent.action_history', + state.initialRunId, + (execution) => + auditInputValue(execution.inputSummary, 'runId') === '' && + auditInputValue(execution.inputSummary, 'actionId') === '' && + auditInputValue(execution.inputSummary, 'tool') === 'project.patchset' && + auditInputValue(execution.inputSummary, 'status') === 'ok' && + auditInputValue(execution.inputSummary, 'limit') === '5', + 'action-history-action-invalid', + ); const failedCommandArgsSha256 = createHash('sha256') .update(JSON.stringify(failedCommandArgs)) .digest('hex'); @@ -1232,6 +1249,10 @@ async function validateLandedEvidence() { verificationExecution.completionIndex < previewExecution.startIndex, 'preview-not-after-project-verification', ); + assert( + previewExecution.completionIndex < actionHistoryExecution.startIndex, + 'action-history-not-after-final-validation', + ); const initial = taskSnapshot.latest.find( (task) => task.agentId === mainAgentId && task.runId === state.initialRunId, @@ -1244,6 +1265,11 @@ async function validateLandedEvidence() { initial?.status === 'completed' || initial?.phase === 'completed', 'main-run-not-completed', ); + const actionReceiptEvidence = validateMainRunActionReceipts( + agentDb, + initial, + actionHistoryExecution, + ); const revision = await readJson( path.join(state.projectRoot, '.agent/runtime/project-revision.json'), @@ -1285,6 +1311,13 @@ async function validateLandedEvidence() { events, contextBundle.observations, ); + const actionHistoryEvidence = validateActionHistoryObservations( + events, + contextBundle.observations, + actionHistoryExecution, + patchsetExecution, + initial, + ); const checkpointManifestPath = path.join( state.projectRoot, @@ -1568,33 +1601,71 @@ async function validateLandedEvidence() { } assert(joinDeliveries.length === 1, 'isolated-join-delivery-count-invalid'); const joinDelivery = joinDeliveries[0]; + const joinDeliveryTarget = isolatedJoinDeliveryTarget(joinDelivery); assert( joinDelivery.joinRunId === spawnRecord.joinRunId && joinDelivery.parentRunId === state.initialRunId && - (joinDelivery.queuedRunId == null || - joinDelivery.queuedRunId === spawnRecord.joinRunId), + (joinDeliveryTarget === 'parent-wake' + ? joinDelivery.queuedRunId == null + : joinDelivery.queuedRunId == null || + joinDelivery.queuedRunId === spawnRecord.joinRunId), 'isolated-join-delivery-identity-invalid', ); + assert( + joinDeliveryTarget !== 'parent-wake' || joinTasks.length === 0, + 'isolated-parent-wake-continuation-task-invalid', + ); + const parentWakeDispatchRecords = agentDb + .map((record, index) => ({ record, index })) + .filter( + ({ record }) => + record.recordType === + 'agent.runtime.agent.isolated_join.parent_wake.dispatched' && + (record.delegationGroupId === spawnRecord.delegationGroupId || + record.joinRunId === spawnRecord.joinRunId), + ); + if (joinDeliveryTarget === 'parent-wake') { + assert( + parentWakeDispatchRecords.length === 1 && + parentWakeDispatchRecords[0].record.agentId === mainAgentId && + parentWakeDispatchRecords[0].record.sessionId === + state.initialSessionId && + parentWakeDispatchRecords[0].record.parentRunId === + state.initialRunId && + parentWakeDispatchRecords[0].record.parentActionId === + spawnExecution.actionId && + parentWakeDispatchRecords[0].record.delegationGroupId === + spawnRecord.delegationGroupId && + parentWakeDispatchRecords[0].record.joinRunId === spawnRecord.joinRunId, + 'isolated-parent-wake-dispatch-audit-invalid', + ); + } + let joinCompletionRecordIndex = -1; if (joinDelivery.status === 'claimed-by-parent') { assert( isNonEmptyString(joinDelivery.claimedByActionId) && - (joinTasks.length === 0 || - (joinTasks[0].status === 'cancelled' && - String(joinTasks[0].currentAction ?? '').includes( - `actionId=${joinDelivery.claimedByActionId}`, - ))), + (joinDeliveryTarget === 'parent-wake' + ? joinTasks.length === 0 + : joinTasks.length === 0 || + (joinTasks[0].status === 'cancelled' && + String(joinTasks[0].currentAction ?? '').includes( + `actionId=${joinDelivery.claimedByActionId}`, + ))), 'isolated-join-claim-task-invalid', ); - const claimRecords = agentDb.filter( - (record) => - record.recordType === - 'agent.runtime.agent.isolated_join.claimed_by_parent' && - record.runId === state.initialRunId && - record.joinRunId === spawnRecord.joinRunId && - record.delegationGroupId === spawnRecord.delegationGroupId && - record.actionId === joinDelivery.claimedByActionId, - ); + const claimRecords = agentDb + .map((record, index) => ({ record, index })) + .filter( + ({ record }) => + record.recordType === + 'agent.runtime.agent.isolated_join.claimed_by_parent' && + record.runId === state.initialRunId && + record.joinRunId === spawnRecord.joinRunId && + record.delegationGroupId === spawnRecord.delegationGroupId && + record.actionId === joinDelivery.claimedByActionId, + ); assert(claimRecords.length === 1, 'isolated-join-claim-audit-invalid'); + joinCompletionRecordIndex = claimRecords[0].index; assert( agentDb.some( (record) => @@ -1609,15 +1680,42 @@ async function validateLandedEvidence() { 'isolated-join-parent-observation-missing', ); } else if (joinDelivery.status === 'dispatched') { - assert( - joinDelivery.claimedByActionId == null && - joinTasks.length === 1 && - joinTasks[0].status === 'completed', - 'isolated-join-continuation-not-completed', - ); + if (joinDeliveryTarget === 'parent-wake') { + assert( + joinDelivery.claimedByActionId == null && joinTasks.length === 0, + 'isolated-parent-wake-delivery-invalid', + ); + joinCompletionRecordIndex = parentWakeDispatchRecords[0].index; + } else { + assert( + joinDelivery.claimedByActionId == null && + joinTasks.length === 1 && + joinTasks[0].status === 'completed', + 'isolated-join-continuation-not-completed', + ); + const dispatchRecords = agentDb + .map((record, index) => ({ record, index })) + .filter( + ({ record }) => + record.recordType === + 'agent.runtime.agent.isolated_join.dispatched' && + record.parentRunId === state.initialRunId && + record.joinRunId === spawnRecord.joinRunId && + record.delegationGroupId === spawnRecord.delegationGroupId, + ); + assert( + dispatchRecords.length === 1, + 'isolated-join-dispatch-audit-invalid', + ); + joinCompletionRecordIndex = dispatchRecords[0].index; + } } else { throw codedError('isolated-join-delivery-status-invalid'); } + assert( + joinCompletionRecordIndex < actionHistoryExecution.startIndex, + 'action-history-not-after-isolated-join', + ); const completedProjections = agentDb.filter( (record) => @@ -1729,6 +1827,11 @@ async function validateLandedEvidence() { ), 'final-assistant-audit-path-invalid', ); + assert( + agentDb.indexOf(finalAssistantAudits[0]) > + actionReceiptEvidence.actionHistoryReceiptIndex, + 'final-assistant-not-after-action-history', + ); const duplicateMessageCount = duplicateCount( conversations.map((message) => message.messageId).filter(Boolean), @@ -1739,14 +1842,12 @@ async function validateLandedEvidence() { ); const receiptRecords = agentDb.filter( (record) => + record.recordType === 'agent.runtime.action_receipt' || record.receiptRunId || String(record.recordType ?? '').includes('isolated_join'), ); const duplicateReceiptCount = duplicateCount( - receiptRecords.map( - (record) => - `${record.recordType}:${record.receiptRunId ?? record.joinRunId ?? record.delegationGroupId}`, - ), + receiptRecords.map(receiptAuditIdentity), ); assert(duplicateActionCount === 0, 'duplicate-action-detected'); assert(duplicateMessageCount === 0, 'duplicate-message-detected'); @@ -1799,6 +1900,7 @@ async function validateLandedEvidence() { verificationExecution, previewExecution, spawnExecution, + actionHistoryExecution, ...(canvasExecution ? [canvasExecution] : []), ]; return { @@ -1811,6 +1913,8 @@ async function validateLandedEvidence() { sideEffectActionCount: replayEvidence.sideEffectActionCount, sideEffectReplayCount: replayEvidence.sideEffectReplayCount, idempotentReplayActionCount: replayEvidence.idempotentReplayActionCount, + actionReceiptReplayRecordCount: + replayEvidence.actionReceiptReplayRecordCount, completedProjectionCount: 1, finalAssistantAuditCount: finalAssistantAudits.length, projectRevision: revision.revision, @@ -1837,6 +1941,19 @@ async function validateLandedEvidence() { isolatedInstanceCount: children.length, isolatedTemplateCount: templateCounts.size, isolatedJoinCount: joinTasks.length, + isolatedJoinDeliveryTarget: joinDeliveryTarget, + isolatedParentWakeDispatchCount: parentWakeDispatchRecords.length, + actionHistoryExecutionCount: 1, + actionHistoryResultCount: actionHistoryEvidence.resultCount, + actionHistoryRecursiveResultCount: + actionHistoryEvidence.recursiveResultCount, + actionReceiptCount: actionReceiptEvidence.receiptCount, + mainRunActionReceiptCount: actionReceiptEvidence.mainRunReceiptCount, + actionReceiptRequiredToolCount: actionReceiptEvidence.requiredToolCount, + actionReceiptDuplicateIdentityCount: + actionReceiptEvidence.duplicateIdentityCount, + actionReceiptSecretLeakCount: actionReceiptEvidence.secretLeakCount, + actionReceiptLureLeakCount: actionReceiptEvidence.lureLeakCount, conversationMessageCount: conversations.length, finalAssistantCount: finalAssistant.length, duplicateActionCount, @@ -1973,6 +2090,7 @@ function emptyEvidence() { sideEffectActionCount: 0, sideEffectReplayCount: 0, idempotentReplayActionCount: 0, + actionReceiptReplayRecordCount: 0, completedProjectionCount: 0, finalAssistantAuditCount: 0, projectRevision: 0, @@ -1998,6 +2116,17 @@ function emptyEvidence() { isolatedInstanceCount: 0, isolatedTemplateCount: 0, isolatedJoinCount: 0, + isolatedJoinDeliveryTarget: null, + isolatedParentWakeDispatchCount: 0, + actionHistoryExecutionCount: 0, + actionHistoryResultCount: 0, + actionHistoryRecursiveResultCount: 0, + actionReceiptCount: 0, + mainRunActionReceiptCount: 0, + actionReceiptRequiredToolCount: 0, + actionReceiptDuplicateIdentityCount: 0, + actionReceiptSecretLeakCount: 0, + actionReceiptLureLeakCount: 0, conversationMessageCount: 0, finalAssistantCount: 0, duplicateActionCount: 0, @@ -2231,6 +2360,200 @@ function validateConfirmedActionLifecycles(records) { return state.confirmedActionIds.size; } +function validateMainRunActionReceipts(records, mainTask, historyExecution) { + const receiptRecords = records.filter( + (record) => record.recordType === 'agent.runtime.action_receipt', + ); + const terminalObservations = records.filter( + (record) => + record.recordType === 'agent.runtime.tool_observation' && + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.status !== 'waiting-for-confirmation' && + isNonEmptyString(record.actionId), + ); + const mainActionIds = new Set( + terminalObservations.map((record) => record.actionId), + ); + const mainRunReceipts = receiptRecords.filter((record) => + mainActionIds.has(record.actionId), + ); + const declaredMainRunReceipts = receiptRecords.filter( + (record) => record.runId === state.initialRunId, + ); + assert(mainActionIds.size > 0, 'main-run-terminal-actions-missing'); + assert( + mainRunReceipts.length === mainActionIds.size && + declaredMainRunReceipts.length === mainRunReceipts.length, + 'main-run-action-receipt-count-invalid', + ); + assert( + mainRunReceipts.every( + (record) => + record.agentId === mainAgentId && + record.taskId === mainTask.taskId && + record.sessionId === state.initialSessionId && + record.runId === state.initialRunId && + /^action-[0-9a-f]{24}$/u.test(record.actionId) && + /^[0-9a-f]{64}$/u.test(record.actionFingerprint) && + isNonEmptyString(record.tool) && + isNonEmptyString(record.executionMode) && + isNonEmptyString(record.status) && + Object.hasOwn(record, 'inputSummary') && + (record.inputSummary == null || + isNonEmptyString(record.inputSummary)) && + isNonEmptyString(record.summary) && + Object.hasOwn(record, 'safeDetail') && + (record.safeDetail == null || typeof record.safeDetail === 'string') && + typeof record.detailUnavailable === 'boolean' && + Number.isSafeInteger(record.updatedAt), + ), + 'main-run-action-receipt-identity-invalid', + ); + + const duplicateIdentityCount = duplicateCount( + mainRunReceipts.map( + (record) => `${record.actionId}\0${record.actionFingerprint}`, + ), + ); + assert( + duplicateIdentityCount === 0 && + duplicateCount(mainRunReceipts.map((record) => record.actionId)) === 0, + 'main-run-action-receipt-duplicate', + ); + for (const observation of terminalObservations) { + const matches = mainRunReceipts.filter( + (record) => record.actionId === observation.actionId, + ); + const fingerprints = new Set( + records + .filter( + (record) => + record.agentId === mainAgentId && + record.runId === state.initialRunId && + record.actionId === observation.actionId && + isNonEmptyString(record.actionFingerprint), + ) + .map((record) => record.actionFingerprint), + ); + assert( + matches.length === 1 && + matches[0].tool === observation.tool && + matches[0].status === observation.status && + fingerprints.size === 1 && + fingerprints.has(matches[0].actionFingerprint), + 'terminal-action-receipt-mismatch', + ); + } + + const requiredTools = new Set([ + 'project.patchset', + 'git.inspect', + 'agent.action_history', + ]); + const coveredTools = new Set(mainRunReceipts.map((record) => record.tool)); + assert( + [...requiredTools].every((tool) => coveredTools.has(tool)), + 'required-action-receipt-tools-missing', + ); + const historyReceipts = mainRunReceipts.filter( + (record) => + record.actionId === historyExecution.actionId && + record.actionFingerprint === historyExecution.actionFingerprint && + record.tool === 'agent.action_history' && + record.status === 'ok', + ); + assert(historyReceipts.length === 1, 'action-history-receipt-count-invalid'); + + const serializedReceipts = Buffer.from( + receiptRecords.map((record) => JSON.stringify(record)).join('\n'), + ); + const secretLeakCount = countExactSecrets(serializedReceipts, state.secrets); + const lureLeakCount = countExactSecrets(serializedReceipts, state.lures); + assert(secretLeakCount === 0, 'action-receipt-secret-leak-detected'); + assert(lureLeakCount === 0, 'action-receipt-lure-leak-detected'); + + return { + receiptCount: receiptRecords.length, + mainRunReceiptCount: mainRunReceipts.length, + requiredToolCount: requiredTools.size, + duplicateIdentityCount, + secretLeakCount, + lureLeakCount, + actionHistoryReceiptIndex: records.indexOf(historyReceipts[0]), + }; +} + +function validateActionHistoryObservations( + events, + contextObservations, + historyExecution, + patchsetExecution, + mainTask, +) { + const eventObservations = events.filter( + (event) => + event.agentId === mainAgentId && + event.runId === state.initialRunId && + event.eventType === 'observation' && + String(event.summary ?? '').startsWith('agent.action_history:ok') && + isNonEmptyString(event.detail), + ); + const bundledObservations = (contextObservations ?? []).filter( + (observation) => + observation?.tool === 'agent.action_history' && + observation?.status === 'ok' && + isNonEmptyString(observation.detail), + ); + assert( + eventObservations.length === 1 && bundledObservations.length === 1, + 'action-history-observation-count-invalid', + ); + const payloads = [eventObservations[0], bundledObservations[0]].map( + (observation) => { + let payload; + try { + payload = JSON.parse(observation.detail); + } catch (error) { + throw codedError('action-history-observation-json-invalid', error); + } + const actions = Array.isArray(payload.actions) ? payload.actions : []; + const patchsetActions = actions.filter( + (action) => action.actionId === patchsetExecution.actionId, + ); + assert( + payload.runId === state.initialRunId && + payload.count === actions.length && + payload.truncated === false && + actions.length === 1 && + patchsetActions.length === 1 && + patchsetActions[0].agentId === mainAgentId && + patchsetActions[0].taskId === mainTask.taskId && + patchsetActions[0].sessionId === state.initialSessionId && + patchsetActions[0].actionFingerprint === + patchsetExecution.actionFingerprint && + patchsetActions[0].runId === state.initialRunId && + patchsetActions[0].tool === 'project.patchset' && + patchsetActions[0].status === 'ok' && + !actions.some( + (action) => + action.actionId === historyExecution.actionId || + action.tool === 'agent.action_history', + ), + 'action-history-observation-evidence-invalid', + ); + return payload; + }, + ); + const actions = payloads[0].actions; + return { + resultCount: actions.length, + recursiveResultCount: actions.filter( + (action) => action.tool === 'agent.action_history', + ).length, + }; +} + function validateToolActionReplays(records) { const attemptsByActionId = new Map(); for (const record of records) { @@ -2238,6 +2561,7 @@ function validateToolActionReplays(records) { ![ 'agent.runtime.tool_action.executing', 'agent.runtime.tool_confirmation_required', + 'agent.runtime.action_receipt', ].includes(record.recordType) ) { continue; @@ -2246,6 +2570,7 @@ function validateToolActionReplays(records) { isNonEmptyString(record.agentId) && isNonEmptyString(record.runId) && isNonEmptyString(record.actionId) && + isNonEmptyString(record.actionFingerprint) && isNonEmptyString(record.tool), 'tool-action-replay-identity-missing', ); @@ -2253,6 +2578,7 @@ function validateToolActionReplays(records) { agentId: record.agentId, runId: record.runId, actionId: record.actionId, + actionFingerprint: record.actionFingerprint, tool: record.tool, inputSummary: canonicalAuditInputSummary(record.inputSummary), }; @@ -2261,6 +2587,7 @@ function validateToolActionReplays(records) { assert( existing.agentId === attempt.agentId && existing.runId === attempt.runId && + existing.actionFingerprint === attempt.actionFingerprint && existing.tool === attempt.tool && existing.inputSummary === attempt.inputSummary, 'tool-action-replay-identity-conflict', @@ -2290,6 +2617,9 @@ function validateToolActionReplays(records) { ), sideEffectReplayCount, idempotentReplayActionCount: replayCount(observationsByIdentity), + actionReceiptReplayRecordCount: records.filter( + (record) => record.recordType === 'agent.runtime.action_receipt', + ).length, }; } @@ -2788,6 +3118,18 @@ function isNonEmptyString(value) { return typeof value === 'string' && value.trim().length > 0; } +function isolatedJoinDeliveryTarget(delivery) { + const target = + delivery && Object.hasOwn(delivery, 'deliveryTarget') + ? delivery.deliveryTarget + : 'continuation'; + assert( + target === 'continuation' || target === 'parent-wake', + 'isolated-join-delivery-target-invalid', + ); + return target; +} + function finalMessageId(agentId, sessionId, runId) { const fingerprint = createHash('sha256') .update(`${agentId}\n${sessionId}\n${runId}`) @@ -2817,6 +3159,13 @@ function actionAuditIdentity(record) { return `${record.recordType}:${record.actionId}${lifecycle}`; } +function receiptAuditIdentity(record) { + if (record.recordType === 'agent.runtime.action_receipt') { + return `${record.recordType}:${record.agentId}:${record.runId}:${record.actionId}:${record.actionFingerprint}`; + } + return `${record.recordType}:${record.receiptRunId ?? record.joinRunId ?? record.delegationGroupId}`; +} + function countExactSecrets(content, secrets) { let count = 0; for (const value of secrets) { 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 fba336665..20ba8efb8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -13,9 +13,10 @@ pub(crate) const AGENT_RUNTIME_PENDING_ACTION_SCHEMA_VERSION: &str = const AGENT_RUNTIME_PENDING_ACTION_STATUS_PENDING: &str = "pending-confirmation"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED: &str = "approved"; pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING: &str = "executing"; -const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED: &str = "observed-approved"; -const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED: &str = "observed-rejected"; -const AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION: &str = "needs-reconciliation"; +pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED: &str = "observed-approved"; +pub(crate) const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED: &str = "observed-rejected"; +pub(crate) const AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION: &str = + "needs-reconciliation"; pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO: &str = "auto"; pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION: &str = "confirmation"; pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v1"; @@ -556,6 +557,19 @@ pub(crate) fn resume_game_creator_agent_background_tasks_at( else { continue; }; + if task.phase == "waiting-for-isolated-join" { + match isolated_join_completion_barrier_at(root, &agent_id, &task.run_id) { + Ok(Some(detail)) if isolated_join_barrier_has_waiting_groups(&detail) => { + resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); + continue; + } + Err(_) => { + resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); + continue; + } + Ok(_) => {} + } + } let source = if task.source.trim().is_empty() { "agent-background-task" } else { @@ -885,20 +899,36 @@ fn resume_game_creator_agent_finalization_at( } } +fn agent_runtime_pending_has_persisted_terminal_observation( + pending: &AgentRuntimePendingToolAction, +) -> bool { + matches!( + pending.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + ) && pending.observation.is_some() +} + fn resume_game_creator_agent_pending_tool_action_at( root: &Path, agent_id: &str, runtime_lock: AgentRuntimeTaskLock, ) -> Result { - if game_creator_agent_runtime_has_reconciliation_barrier(root, agent_id)? { - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimePendingActionResume::Handled); - } + let has_reconciliation_barrier = + game_creator_agent_runtime_has_reconciliation_barrier(root, agent_id)?; let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state; if runtime.run_id.trim().is_empty() { + if has_reconciliation_barrier { + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } if !game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, &runtime.run_id) { + if has_reconciliation_barrier { + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } return Ok(AgentRuntimePendingActionResume::NotFound(runtime_lock)); } let pending = match read_game_creator_agent_runtime_pending_tool_action( @@ -926,6 +956,12 @@ fn resume_game_creator_agent_pending_tool_action_at( .map(AgentRuntimePendingActionResume::Handled); } }; + let can_repair_terminal_receipt = + agent_runtime_pending_has_persisted_terminal_observation(&pending); + if has_reconciliation_barrier && !can_repair_terminal_receipt { + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } let session_mismatch = pending.session_id != runtime.session_id || read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, &runtime.run_id)? .is_some_and(|task| task.session_id != pending.session_id); @@ -972,7 +1008,7 @@ fn resume_game_creator_agent_pending_tool_action_at( return read_game_creator_agent_runtime_at(root, agent_id) .map(AgentRuntimePendingActionResume::Handled); } - if game_creator_agent_runtime_cancel_requested(root, &runtime) { + if !can_repair_terminal_receipt && game_creator_agent_runtime_cancel_requested(root, &runtime) { mark_game_creator_agent_runtime_cancelled_at( root, &mut runtime, @@ -1876,18 +1912,27 @@ pub(crate) fn confirm_game_creator_agent_runtime_task_at( Ok(result) } -fn agent_runtime_tool_requires_repository_context_fingerprint_gate(tool: &str) -> bool { +pub(crate) fn agent_runtime_tool_requires_repository_context_fingerprint_gate(tool: &str) -> bool { matches!( tool, - "memory.write" + "memory.read" + | "memory.write" + | "conversation.read" + | "asset.list" | "project.index" + | "project.search" | "project.verify" | "project.checkpoint" | "project.patchset" | "project.restore" + | "project.diff" + | "git.inspect" + | "file.list" + | "file.read" | "file.write" | "file.patch" | "file.delete" + | "task.list" | "task.create" | "task.update" | "command.exec" @@ -1900,6 +1945,7 @@ fn agent_runtime_tool_requires_repository_context_fingerprint_gate(tool: &str) - | "agent.delegate" | "agent.spawn_isolated" | "agent.schedule_ready" + | "agent.action_history" | "agent.run_status" ) } @@ -2094,13 +2140,17 @@ fn resolve_game_creator_agent_runtime_pending_tool_action( Ok((agent_id, task, runtime, pending)) } -async fn continue_game_creator_agent_pending_tool_action( +pub(crate) async fn continue_game_creator_agent_pending_tool_action( root: PathBuf, agent_id: String, mut pending: AgentRuntimePendingToolAction, mut runtime: AgentRuntimeState, ) { - if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + let has_persisted_terminal_observation = + agent_runtime_pending_has_persisted_terminal_observation(&pending); + if !has_persisted_terminal_observation + && stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) + { drain_next_game_creator_agent_background_tasks(root, agent_id).await; return; } @@ -2155,16 +2205,18 @@ async fn continue_game_creator_agent_pending_tool_action( } observation } else { - if let Err(error) = - validate_agent_runtime_pending_verification_gate_before(&root, &pending) - { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( - &root, - &mut runtime, - &pending, - &error, - ); - return; + if agent_runtime_tool_requires_pending_revision_gate(&pending.action.tool) { + if let Err(error) = + validate_agent_runtime_pending_verification_gate_before(&root, &pending) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &error, + ); + return; + } } pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); pending.updated_at = unix_timestamp(); @@ -2267,15 +2319,19 @@ async fn continue_game_creator_agent_pending_tool_action( } }; if observation.requires_reconciliation() { - let _ = mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( + if persist_agent_runtime_reconciliation_observation_before_cancellation( &root, &mut runtime, &pending, &observation, - ); + ) { + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + } return; } - if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + if observation.is_waiting_for_confirmation() + && stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) + { drain_next_game_creator_agent_background_tasks(root, agent_id).await; return; } @@ -2289,6 +2345,7 @@ async fn continue_game_creator_agent_pending_tool_action( &pending.task, &action, &observation, + Some(&pending.action_id), ); complete_agent_runtime_active_plan_step( &mut runtime, @@ -2344,6 +2401,7 @@ async fn continue_game_creator_agent_pending_tool_action( &pending.task, &action, &observation, + Some(&pending.action_id), ); complete_agent_runtime_active_plan_step( &mut runtime, @@ -2373,11 +2431,15 @@ async fn continue_game_creator_agent_pending_tool_action( }; runtime.pending_tool_action = Some(pending.summary()); runtime.updated_at = unix_timestamp(); - let persisted = append_game_creator_agent_runtime_task(&root, &runtime) + let persisted = append_game_creator_agent_runtime_task_projection_once( + &root, + &runtime, + &pending.action_id, + ) .and_then(|_| refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime)) .and_then(|_| write_game_creator_agent_runtime_state(&root, &runtime)) .and_then(|_| { - append_game_creator_agent_runtime_event( + append_game_creator_agent_runtime_action_event( &root, &runtime, "observation", @@ -2385,11 +2447,15 @@ async fn continue_game_creator_agent_pending_tool_action( "observation", &observation_summary, observation.detail.as_deref(), + &pending.action_id, ) }) .and_then(|_| { - append_agent_db_record( + append_agent_db_terminal_observation_if_missing_for_action( &root, + &runtime.agent_id, + &runtime.run_id, + &pending.action_id, serde_json::json!({ "recordType": "agent.runtime.tool_observation", "agentId": runtime.agent_id, @@ -2399,9 +2465,27 @@ async fn continue_game_creator_agent_pending_tool_action( "status": observation.status, "summary": observation.summary, "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, "decision": if auto_execution { "auto" } else if approved { "approved" } else { "rejected" }, }), ) + .map(|_| ()) + }) + .and_then(|_| { + append_agent_runtime_action_receipt( + &root, + &runtime, + &pending.action_id, + &pending.action_fingerprint, + &observation.tool, + if auto_execution { + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO + } else { + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION + }, + pending.input_summary.as_deref(), + &observation, + ) }); if let Err(error) = persisted { let error = format!("持久化已完成工具动作的观察失败:{error}"); @@ -2413,6 +2497,10 @@ async fn continue_game_creator_agent_pending_tool_action( ); return; } + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + return; + } if !auto_execution { if let Some(command_id) = game_creator_agent_runtime_tool_command_id(&action.tool) { let _ = fs::remove_file(game_creator_agent_runtime_tool_confirmation_path( @@ -2529,7 +2617,7 @@ fn mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( runtime: &mut AgentRuntimeState, pending: &AgentRuntimePendingToolAction, observation: &AgentRuntimeToolObservation, -) -> Result<(), String> { +) -> Result { let observation_summary = observation.summary(); runtime.observations.push(observation_summary.clone()); append_agent_runtime_tool_call_record( @@ -2538,15 +2626,47 @@ fn mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( &pending.task, &pending.action, observation, + Some(&pending.action_id), ); complete_agent_runtime_active_plan_step(runtime, "failed", &observation_summary); - let error = observation + let mut error = observation .detail .as_deref() .filter(|detail| !detail.trim().is_empty()) .map(|detail| format!("{observation_summary};{detail}")) .unwrap_or(observation_summary); - mark_game_creator_agent_runtime_needs_reconciliation_at(root, runtime, pending, &error) + let receipt_result = append_agent_runtime_action_receipt( + root, + runtime, + &pending.action_id, + &pending.action_fingerprint, + &observation.tool, + &pending.execution_mode, + pending.input_summary.as_deref(), + observation, + ); + if let Err(receipt_error) = &receipt_result { + error.push_str(";持久动作回执写入失败:"); + error.push_str(receipt_error); + } + mark_game_creator_agent_runtime_needs_reconciliation_at(root, runtime, pending, &error)?; + Ok(receipt_result.is_ok()) +} + +pub(crate) fn persist_agent_runtime_reconciliation_observation_before_cancellation( + root: &Path, + runtime: &mut AgentRuntimeState, + pending: &AgentRuntimePendingToolAction, + observation: &AgentRuntimeToolObservation, +) -> bool { + mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( + root, + runtime, + pending, + observation, + ) + .unwrap_or(false) + && stop_game_creator_agent_runtime_if_cancel_requested(root, runtime) } fn mark_game_creator_agent_runtime_needs_reconciliation_at( @@ -2825,6 +2945,7 @@ pub(crate) async fn run_game_creator_agent_background_task_with_context( mut continuation: AgentRuntimeContinuationContext, ) -> AgentBackgroundTaskOutcome { loop { + let current_run_id = state.run_id.clone(); match run_game_creator_agent_background_task_pass_with_context( root.clone(), agent_id.clone(), @@ -2841,11 +2962,49 @@ pub(crate) async fn run_game_creator_agent_background_task_with_context( state = next_state; continuation = next_continuation; } + AgentBackgroundTaskOutcome::WaitingForIsolatedJoin => { + schedule_waiting_isolated_join_parent_wake_after_lane_release( + root, + agent_id, + current_run_id, + ); + return AgentBackgroundTaskOutcome::WaitingForIsolatedJoin; + } outcome => return outcome, } } } +fn schedule_waiting_isolated_join_parent_wake_after_lane_release( + root: PathBuf, + agent_id: String, + run_id: String, +) { + tauri::async_runtime::spawn(async move { + for _ in 0..20 { + tokio::time::sleep(Duration::from_millis(10)).await; + let Ok(Some(task)) = + read_latest_game_creator_agent_runtime_task_by_run_id(&root, &agent_id, &run_id) + else { + return; + }; + if task.status != "running" || task.phase != "waiting-for-isolated-join" { + return; + } + match isolated_join_completion_barrier_at(&root, &agent_id, &run_id) { + Ok(Some(detail)) if isolated_join_barrier_has_waiting_groups(&detail) => return, + Err(_) => return, + Ok(_) => {} + } + match wake_waiting_isolated_join_parent_run_at(&root, &task) { + Ok(true) => return, + Ok(false) => continue, + Err(_) => return, + } + } + }); +} + async fn run_game_creator_agent_background_task_pass_with_context( root: PathBuf, agent_id: String, @@ -3030,18 +3189,32 @@ async fn run_game_creator_agent_background_task_pass_with_context( } if plan.actions.is_empty() { - if let Some(blocker) = project_verification_completion_blocker_at( - &root, - &agent_id, - &runtime.run_id, - &observations, - ) { + let completion_blocker = + isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id).or_else( + || { + project_verification_completion_blocker_at( + &root, + &agent_id, + &runtime.run_id, + &observations, + ) + }, + ); + if let Some(blocker) = completion_blocker { let blocker_summary = blocker.summary(); - runtime.current_action = "拒绝在验证未闭环时完成任务".to_string(); - runtime.waiting_on = - "最后一次项目修改后的 project.verify、可验证 command.exec 或 game.static_smoke" - .to_string(); - runtime.next_step = "根据验证诊断继续修复,并在最后一次修改后重新验证".to_string(); + if blocker.tool == "runtime.isolated_join" { + runtime.status = "running".to_string(); + runtime.phase = "waiting-for-isolated-join".to_string(); + runtime.current_action = "等待动态隔离 Agent 的 all-join".to_string(); + runtime.waiting_on = "隔离子 Agent 完成并由父 run 认领 all-join".to_string(); + runtime.next_step = + "调用 agent.run_status 检查状态并取得 readyIsolatedJoins".to_string(); + } else { + runtime.current_action = "拒绝在验证未闭环时完成任务".to_string(); + runtime.waiting_on = "最后一次项目修改后的 project.verify、可验证 command.exec 或 game.static_smoke".to_string(); + runtime.next_step = + "根据验证诊断继续修复,并在最后一次修改后重新验证".to_string(); + } runtime.observations.push(blocker_summary.clone()); runtime.updated_at = unix_timestamp(); let _ = write_game_creator_agent_runtime_state(&root, &runtime); @@ -3056,6 +3229,62 @@ async fn run_game_creator_agent_background_task_pass_with_context( ); context_tracker.record(&blocker); observations.push(blocker); + if observations.last().is_some_and(|observation| { + observation.tool == "runtime.isolated_join" + && observation + .detail + .as_deref() + .is_some_and(isolated_join_barrier_has_waiting_groups) + }) { + let next_loop_index = loop_index.saturating_add(1); + if let Err(error) = persist_game_creator_agent_runtime_context( + &root, + &runtime, + &task, + &plan, + &observations, + next_loop_index, + &context_tracker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &error, + ); + } + let persistence = append_game_creator_agent_runtime_task(&root, &runtime) + .and_then(|_| { + refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime) + }) + .and_then(|_| write_game_creator_agent_runtime_state(&root, &runtime)) + .and_then(|_| { + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.agent.isolated_join.waiting", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "status": "waiting-for-isolated-join", + "nextLoopIndex": next_loop_index, + }), + ) + }); + if let Err(error) = persistence { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化 all-join 等待状态失败:{error}"), + ); + } + emit_game_creator_agent_runtime_update(&root, &agent_id); + return AgentBackgroundTaskOutcome::WaitingForIsolatedJoin; + } match checkpoint_game_creator_agent_runtime_context( &root, &mut runtime, @@ -3148,9 +3377,6 @@ async fn run_game_creator_agent_background_task_pass_with_context( .take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT) .enumerate() { - if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { - return AgentBackgroundTaskOutcome::Finished; - } activate_agent_runtime_plan_step( &mut runtime, action_index, @@ -3298,17 +3524,21 @@ async fn run_game_creator_agent_background_task_pass_with_context( durable_action = Some(pending_action); observation } else { - if let Err(error) = validate_agent_runtime_pending_verification_gate_before( - &root, - &pending_action, + if agent_runtime_tool_requires_pending_revision_gate( + &pending_action.action.tool, ) { - let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + if let Err(error) = validate_agent_runtime_pending_verification_gate_before( &root, - &mut runtime, &pending_action, - &error, - ); - return AgentBackgroundTaskOutcome::NeedsReconciliation; + ) { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &error, + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } } pending_action.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); @@ -3370,13 +3600,14 @@ async fn run_game_creator_agent_background_task_pass_with_context( &observation, ); if observation.requires_reconciliation() { - let _ = - mark_game_creator_agent_runtime_tool_observation_needs_reconciliation_at( + if persist_agent_runtime_reconciliation_observation_before_cancellation( &root, &mut runtime, &pending_action, &observation, - ); + ) { + return AgentBackgroundTaskOutcome::Finished; + } return AgentBackgroundTaskOutcome::NeedsReconciliation; } durable_action = Some(pending_action); @@ -3392,8 +3623,33 @@ async fn run_game_creator_agent_background_task_pass_with_context( ) .await }; - if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { - return AgentBackgroundTaskOutcome::Finished; + if !observation.is_waiting_for_confirmation() && durable_action.is_none() { + let mut pending_action = prepared_action + .take() + .expect("prepared action exists for a rejected terminal observation"); + pending_action.execution_mode = + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + pending_action.status = + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED.to_string(); + pending_action.observation = Some(observation.clone()); + pending_action.updated_at = unix_timestamp(); + if let Err(error) = + write_game_creator_agent_runtime_pending_tool_action(&root, &pending_action) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending_action, + &format!("被拒绝工具动作已返回终态,但无法持久化 observation:{error}"), + ); + return AgentBackgroundTaskOutcome::NeedsReconciliation; + } + let _ = append_game_creator_agent_runtime_auto_tool_action_observed_record( + &root, + &pending_action, + &observation, + ); + durable_action = Some(pending_action); } let observation_action_identity = durable_action .as_ref() @@ -3402,6 +3658,8 @@ async fn run_game_creator_agent_background_task_pass_with_context( ( pending.action_id.clone(), pending.action_fingerprint.clone(), + pending.input_summary.clone(), + pending.execution_mode.clone(), ) }); let repository_context_drifted = observation.is_repository_context_drift(); @@ -3413,6 +3671,9 @@ async fn run_game_creator_agent_background_task_pass_with_context( &action_task_context, action, &observation, + observation_action_identity + .as_ref() + .map(|identity| identity.0.as_str()), ); if observation.is_waiting_for_confirmation() { let mut pending_action = durable_action @@ -3476,26 +3737,42 @@ async fn run_game_creator_agent_background_task_pass_with_context( }; } runtime.updated_at = unix_timestamp(); - let persistence = append_game_creator_agent_runtime_task(&root, &runtime) - .and_then(|_| refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime)) - .and_then(|_| write_game_creator_agent_runtime_state(&root, &runtime)) - .and_then(|_| { - append_game_creator_agent_runtime_event( - &root, - &runtime, - "observation", - runtime.status.as_str(), - if observation.is_waiting_for_confirmation() { - "waiting-for-confirmation" - } else { - "observation" - }, - observation_summary.as_str(), - observation.detail.as_deref(), - ) - }) - .and_then(|_| { - append_agent_db_record( + let persistence = append_game_creator_agent_runtime_task_projection_once( + &root, + &runtime, + observation_action_identity + .as_ref() + .map(|identity| identity.0.as_str()) + .expect("durable observation has action identity"), + ) + .and_then(|_| refresh_game_creator_agent_runtime_task_queue(&root, &mut runtime)) + .and_then(|_| write_game_creator_agent_runtime_state(&root, &runtime)) + .and_then(|_| { + append_game_creator_agent_runtime_action_event( + &root, + &runtime, + "observation", + runtime.status.as_str(), + if observation.is_waiting_for_confirmation() { + "waiting-for-confirmation" + } else { + "observation" + }, + observation_summary.as_str(), + observation.detail.as_deref(), + observation_action_identity + .as_ref() + .map(|identity| identity.0.as_str()) + .expect("durable observation has action identity"), + ) + }) + .and_then(|_| { + let (action_id, action_fingerprint, _, execution_mode) = + observation_action_identity + .as_ref() + .expect("durable observation has action identity"); + if observation.is_waiting_for_confirmation() { + return append_agent_db_record( &root, serde_json::json!({ "recordType": "agent.runtime.tool_observation", @@ -3505,11 +3782,55 @@ async fn run_game_creator_agent_background_task_pass_with_context( "tool": observation.tool, "status": observation.status, "summary": observation.summary, - "actionId": observation_action_identity.as_ref().map(|identity| &identity.0), - "actionFingerprint": observation_action_identity.as_ref().map(|identity| &identity.1), + "actionId": action_id, + "actionFingerprint": action_fingerprint, }), - ) - }); + ); + } + append_agent_db_terminal_observation_if_missing_for_action( + &root, + &runtime.agent_id, + &runtime.run_id, + action_id, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "runId": runtime.run_id, + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + "actionId": action_id, + "actionFingerprint": action_fingerprint, + "decision": if execution_mode == AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO { + "auto" + } else { + "approved" + }, + }), + ) + .map(|_| ()) + }) + .and_then(|_| { + if observation.is_waiting_for_confirmation() { + return Ok(()); + } + let Some((action_id, action_fingerprint, input_summary, execution_mode)) = + observation_action_identity.as_ref() + else { + return Ok(()); + }; + append_agent_runtime_action_receipt( + &root, + &runtime, + action_id, + action_fingerprint, + &observation.tool, + execution_mode, + input_summary.as_deref(), + &observation, + ) + }); if let Err(error) = persistence { if observation.is_waiting_for_confirmation() { let _ = append_agent_db_record( @@ -3560,6 +3881,9 @@ async fn run_game_creator_agent_background_task_pass_with_context( Some(&error), ); } + if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { + return AgentBackgroundTaskOutcome::Finished; + } return AgentBackgroundTaskOutcome::WaitingForConfirmation; } context_tracker.record(&observation); @@ -3831,6 +4155,12 @@ const AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED: &str = "runtime-compl const AGENT_RUNTIME_FINALIZATION_RESPONSE_MAX_CHARS: usize = 32_000; pub(crate) const AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME: &str = "submit_agent_tool_plan"; const AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT: usize = 20; +pub(crate) const AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt"; +const AGENT_RUNTIME_ACTION_HISTORY_DEFAULT_LIMIT: usize = 5; +const AGENT_RUNTIME_ACTION_HISTORY_MAX_LIMIT: usize = 10; +const AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES: u64 = 32 * 1024 * 1024; +pub(crate) const AGENT_RUNTIME_ACTION_HISTORY_MAX_OUTPUT_CHARS: usize = 7_200; +const AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS: usize = 500; pub(crate) const AGENT_RUNTIME_PLAN_STEP_LIMIT: usize = 8; const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; const AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS: usize = 12_000; @@ -3858,6 +4188,7 @@ pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300; pub(crate) enum AgentBackgroundTaskOutcome { Finished, WaitingForConfirmation, + WaitingForIsolatedJoin, NeedsReconciliation, FinalizationPending, ContinueSameRun { @@ -3917,6 +4248,54 @@ pub(crate) struct AgentRuntimeToolObservation { pub(crate) detail: Option, } +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentRuntimeActionHistoryInput { + #[serde(default)] + run_id: Option, + #[serde(default)] + action_id: Option, + #[serde(default)] + tool: Option, + #[serde(default)] + status: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeActionHistoryItem { + agent_id: String, + task_id: String, + session_id: String, + action_id: String, + action_fingerprint: Option, + run_id: String, + tool: String, + execution_mode: Option, + status: String, + input_summary: Option, + summary: String, + safe_detail: Option, + detail_unavailable: bool, + updated_at: u64, + #[serde(skip)] + sequence: usize, +} + +#[derive(Clone, Debug, Default)] +struct AgentRuntimeActionHistoryMetadata { + task_id: Option, + session_id: Option, + action_fingerprint: Option, + tool: Option, + execution_mode: Option, + input_summary: Option, + updated_at: u64, + sequence: usize, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct AgentRuntimeProjectRevision { @@ -4175,11 +4554,14 @@ fn sanitize_agent_runtime_context_observation( root: &Path, observation: &AgentRuntimeToolObservation, ) -> AgentRuntimeToolObservation { - let detail_limit = if matches!(observation.tool.as_str(), "project.diff" | "git.inspect") + let detail_limit = if observation.tool == "agent.action_history" && observation.status == "ok" { + AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS + } else if matches!(observation.tool.as_str(), "project.diff" | "git.inspect") && observation.status == "ok" && observation.detail.as_deref().is_some_and(|detail| { detail.contains("contentFileCount:") || detail.contains("gitContentFileCount:") - }) { + }) + { AGENT_RUNTIME_CONTEXT_CONTENT_DIFF_DETAIL_MAX_CHARS } else { 1_600 @@ -5816,7 +6198,7 @@ fn append_game_creator_agent_runtime_auto_tool_action_observed_record( } impl AgentRuntimeToolObservation { - fn summary(&self) -> String { + pub(crate) fn summary(&self) -> String { format!("{}:{} · {}", self.tool, self.status, self.summary) } @@ -5958,6 +6340,23 @@ fn validate_agent_runtime_verification_gate_snapshot( Ok(()) } +pub(crate) fn agent_runtime_tool_requires_pending_revision_gate(tool: &str) -> bool { + !matches!( + tool, + "memory.read" + | "conversation.read" + | "asset.list" + | "project.index" + | "project.search" + | "project.diff" + | "git.inspect" + | "file.list" + | "file.read" + | "task.list" + | "agent.action_history" + ) +} + pub(crate) fn validate_agent_runtime_pending_verification_gate_before( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -6148,6 +6547,58 @@ fn agent_runtime_project_verification_label( } } +fn isolated_join_completion_blocker_at_locked( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Option { + match isolated_join_completion_barrier_at(root, agent_id, run_id) { + Ok(None) => None, + Ok(Some(detail)) => Some(AgentRuntimeToolObservation { + tool: "runtime.isolated_join".to_string(), + status: "blocked".to_string(), + summary: "动态隔离 Agent 的 all-join 尚未完成并认领,不能收束当前任务".to_string(), + detail: Some(detail), + }), + Err(error) => Some(AgentRuntimeToolObservation { + tool: "runtime.isolated_join".to_string(), + status: "blocked".to_string(), + summary: "无法确认动态隔离 Agent 的 all-join 状态,不能收束当前任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }), + } +} + +fn isolated_join_barrier_has_waiting_groups(detail: &str) -> bool { + detail + .split_whitespace() + .find_map(|part| part.strip_prefix("waitingGroups=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0) +} + +pub(crate) fn isolated_join_completion_blocker_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Option { + let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.isolated_join.complete", + ) { + Ok(lock) => lock, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: "runtime.isolated_join".to_string(), + status: "blocked".to_string(), + summary: "无法取得 all-join 完成复核锁,不能收束当前任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }); + } + }; + isolated_join_completion_blocker_at_locked(root, agent_id, run_id) +} + pub(crate) fn project_verification_completion_blocker( observations: &[AgentRuntimeToolObservation], ) -> Option { @@ -6346,14 +6797,16 @@ fn agent_runtime_tool_policy_block_observation( } } -fn append_agent_runtime_tool_call_record( +pub(crate) fn append_agent_runtime_tool_call_record( root: &Path, runtime: &mut AgentRuntimeState, task: &str, action: &AgentRuntimeToolAction, observation: &AgentRuntimeToolObservation, + action_id: Option<&str>, ) { - runtime.recent_tool_calls.push(AgentRuntimeToolCallRecord { + let record = AgentRuntimeToolCallRecord { + action_id: action_id.map(ToString::to_string), tool: observation.tool.clone(), status: observation.status.clone(), action_fingerprint: Some(agent_runtime_tool_action_fingerprint(action, task)), @@ -6370,7 +6823,17 @@ fn append_agent_runtime_tool_call_record( .map(|value| sanitize_agent_runtime_text(value, 500)) .filter(|value| !value.trim().is_empty()), updated_at: unix_timestamp(), - }); + }; + if let Some(index) = action_id.and_then(|action_id| { + runtime + .recent_tool_calls + .iter() + .position(|existing| existing.action_id.as_deref() == Some(action_id)) + }) { + runtime.recent_tool_calls[index] = record; + return; + } + runtime.recent_tool_calls.push(record); if runtime.recent_tool_calls.len() > AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT { let keep_from = runtime .recent_tool_calls @@ -6380,6 +6843,157 @@ fn append_agent_runtime_tool_call_record( } } +pub(crate) fn append_agent_runtime_action_receipt( + root: &Path, + runtime: &AgentRuntimeState, + action_id: &str, + action_fingerprint: &str, + tool: &str, + execution_mode: &str, + input_summary: Option<&str>, + observation: &AgentRuntimeToolObservation, +) -> Result<(), String> { + let agent_id = + agent_runtime_action_receipt_identity_text(root, &runtime.agent_id, 96, "agentId")?; + let task_id = agent_runtime_action_receipt_identity_text(root, &runtime.task_id, 96, "taskId")?; + let session_id = + agent_runtime_action_receipt_identity_text(root, &runtime.session_id, 160, "sessionId")?; + let run_id = agent_runtime_action_receipt_identity_text(root, &runtime.run_id, 160, "runId")?; + let tool = agent_runtime_action_receipt_identity_text(root, tool, 80, "tool")?; + let observation_tool = + agent_runtime_action_receipt_identity_text(root, &observation.tool, 80, "tool")?; + if tool != observation_tool { + return Err("Agent 持久动作回执的工具身份与 observation 不一致".to_string()); + } + if !is_valid_agent_runtime_action_id(action_id) { + return Err("Agent 持久动作回执的 actionId 无效".to_string()); + } + if !is_valid_agent_runtime_action_fingerprint(action_fingerprint) { + return Err("Agent 持久动作回执的 actionFingerprint 无效".to_string()); + } + if !is_valid_agent_runtime_action_execution_mode(execution_mode) { + return Err("Agent 持久动作回执的 executionMode 无效".to_string()); + } + if !is_terminal_agent_runtime_action_status(&observation.status) { + return Err("Agent 持久动作回执只能记录终态 observation".to_string()); + } + let safe_detail = agent_runtime_action_receipt_safe_detail(root, observation); + let detail_unavailable = observation.detail.is_some() && safe_detail.is_none(); + let input_summary = input_summary + .and_then(|value| agent_runtime_action_receipt_safe_text(root, value, 320, None)); + let summary = agent_runtime_action_receipt_safe_text( + root, + &observation.summary, + 320, + Some("工具动作已结束,敏感摘要已省略"), + ) + .unwrap_or_else(|| "工具动作已结束,敏感摘要已省略".to_string()); + let record = serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": agent_id, + "taskId": task_id, + "sessionId": session_id, + "runId": run_id, + "actionId": action_id, + "actionFingerprint": action_fingerprint, + "tool": tool, + "executionMode": execution_mode, + "status": observation.status, + "inputSummary": input_summary, + "summary": summary, + "safeDetail": safe_detail, + "detailUnavailable": detail_unavailable, + }); + append_agent_db_record_if_missing_for_action( + root, + AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + &runtime.agent_id, + &run_id, + action_id, + record, + )?; + Ok(()) +} + +fn agent_runtime_action_receipt_safe_detail( + root: &Path, + observation: &AgentRuntimeToolObservation, +) -> Option { + if observation.tool != "project.patchset" { + return None; + } + let allowed_keys = [ + "checkpointId", + "revision", + "changeCount", + "revisionAdvanced", + ]; + let fields = observation + .detail + .as_deref() + .unwrap_or_default() + .split('·') + .filter_map(|field| { + let (key, value) = field.trim().split_once('=')?; + let key = key.trim(); + let value = value.trim(); + if !allowed_keys.contains(&key) || value.is_empty() || value.contains(['\n', '\r']) { + return None; + } + let value = agent_runtime_action_receipt_safe_text(root, value, 160, None)?; + Some(format!("{key}={value}")) + }) + .collect::>(); + if fields.is_empty() { + None + } else { + Some(sanitize_agent_runtime_text( + &fields.join(" · "), + AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS, + )) + } +} + +fn agent_runtime_action_receipt_safe_text( + root: &Path, + value: &str, + max_chars: usize, + fallback: Option<&str>, +) -> Option { + let sanitized = + redact_absolute_path_tokens(&redact_agent_runtime_project_paths(root, value, max_chars)); + if sanitized.trim().is_empty() { + return fallback.map(ToString::to_string); + } + let serialized = serde_json::to_string(&sanitized).unwrap_or_default(); + if validate_agent_runtime_pending_serialized_content(root, &serialized).is_err() { + return fallback.map(ToString::to_string); + } + Some(sanitized) +} + +fn agent_runtime_action_receipt_identity_text( + root: &Path, + value: &str, + max_chars: usize, + field: &str, +) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().count() > max_chars || value.chars().any(char::is_control) + { + return Err(format!("Agent 持久动作回执的 {field} 无效")); + } + let Some(safe) = agent_runtime_action_receipt_safe_text(root, value, max_chars, None) else { + return Err(format!("Agent 持久动作回执的 {field} 包含敏感内容")); + }; + if safe != value { + return Err(format!( + "Agent 持久动作回执的 {field} 不能包含 file URI 或绝对路径" + )); + } + Ok(safe) +} + pub(crate) fn agent_runtime_tool_action_fingerprint( action: &AgentRuntimeToolAction, task: &str, @@ -6678,6 +7292,17 @@ pub(crate) fn agent_runtime_tool_action_input_summary( "limit={}", input.get("limit").and_then(|value| value.as_u64()).unwrap_or(1) ), + "agent.action_history" => format!( + "runId={} · actionId={} · tool={} · status={} · limit={}", + text(&["runId", "run_id"]), + text(&["actionId", "action_id"]), + text(&["tool"]), + text(&["status"]), + input + .get("limit") + .and_then(serde_json::Value::as_u64) + .unwrap_or(AGENT_RUNTIME_ACTION_HISTORY_DEFAULT_LIMIT as u64) + ), "agent.run_status" => format!( "scope={} · agentId={}", text(&["scope"]), @@ -6738,7 +7363,7 @@ fn activate_agent_runtime_plan_step( runtime.active_plan_step_index = Some(target_index as u32); } -fn complete_agent_runtime_active_plan_step( +pub(crate) fn complete_agent_runtime_active_plan_step( runtime: &mut AgentRuntimeState, status: &str, detail: &str, @@ -7036,6 +7661,10 @@ fn build_game_creator_agent_background_tool_plan_request( "agent.delegate|agent.schedule_ready", "agent.delegate|agent.spawn_isolated|agent.schedule_ready", ) + .replace( + "agent.schedule_ready|agent.run_status", + "agent.schedule_ready|agent.action_history|agent.run_status", + ) .replace( "task.update|command.run_limited", "task.update|command.exec|command.run_limited", @@ -7044,7 +7673,7 @@ fn build_game_creator_agent_background_tool_plan_request( "{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。command.exec 使用 {{\"program\":\"cargo|npm|node|git|rg\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120}},不接受 shell 字符串、管道、重定向、环境变量或项目外路径;该工具默认需要精确确认,适合运行定向测试、构建检查和只读诊断。只有 cargo check/test/clippy/fmt/build、npm test 或命名为 check/typecheck/test/lint/build/verify/validate 的验证脚本,以及精确 node --test 测试文件可签发验证凭证;git、rg、cargo metadata 和普通 npm run 只作为诊断结果。每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能返回空 actions 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。" ); let prompt = format!( - "{prompt}\n\n新增工具输入:preview.validate 使用 {{\"viewports\":[\"desktop\",\"mobile\"],\"expectedText\":[\"可选可见文本\"],\"settleMs\":800,\"failOnConsoleError\":true}},不得提供 URL、脚本、Cookie 或请求头;agent.spawn_isolated 使用 {{\"children\":[{{\"templateAgentId\":\"规范 taskId\",\"task\":\"边界清晰的子任务\",\"acceptanceCriteria\":[\"可验证条件\"],\"expectedArtifacts\":[\"项目内路径\"],\"writeScopes\":[\"互不重叠的目录/**\"]}}],\"joinMode\":\"all\"}},一次最多 3 个子实例;spawn 后用 agent.run_status 的 scope=all 检查进度,当 observation 出现 readyIsolatedJoins 时表示 all-join 已完成,必须直接使用其中结果继续父 run,不得继续等待。" + "{prompt}\n\n新增工具输入:preview.validate 使用 {{\"viewports\":[\"desktop\",\"mobile\"],\"expectedText\":[\"可选可见文本\"],\"settleMs\":800,\"failOnConsoleError\":true}},不得提供 URL、脚本、Cookie 或请求头;agent.spawn_isolated 使用 {{\"children\":[{{\"templateAgentId\":\"规范 taskId\",\"task\":\"边界清晰的子任务\",\"acceptanceCriteria\":[\"可验证条件\"],\"expectedArtifacts\":[\"项目内路径\"],\"writeScopes\":[\"互不重叠的目录/**\"]}}],\"joinMode\":\"all\"}},一次最多 3 个子实例;spawn 后用 agent.run_status 的 scope=all 检查进度,当 observation 出现 readyIsolatedJoins 时表示 all-join 已完成,必须直接使用其中结果继续父 run,不得继续等待。agent.action_history 使用 {{\"runId\":\"可选 run id\",\"actionId\":\"可选 action id\",\"tool\":\"可选工具名\",\"status\":\"可选终态\",\"limit\":5}},只查询当前 Agent 的持久终态动作;省略 runId 时只查当前 run,默认不返回 action_history 自身。" ); let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let mut request = LlmRunRequest::new(vec![ @@ -7332,12 +7961,57 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ } match tool { - "memory.read" => observe_agent_runtime_memory(root, agent_id, &action.input), + "memory.read" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_memory(root, agent_id, &action.input), + ), "memory.write" => observe_agent_runtime_memory_write(root, agent_id, &action.input), - "conversation.read" => observe_agent_runtime_conversation(root, agent_id, run_id), - "asset.list" => observe_agent_runtime_assets(root), - "project.index" => observe_agent_runtime_project_index(root), - "project.search" => observe_agent_runtime_project_search(root, &action.input), + "conversation.read" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_conversation(root, agent_id, run_id), + ), + "asset.list" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_assets(root), + ), + "project.index" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_project_index(root), + ), + "project.search" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_project_search(root, &action.input), + ), "project.verify" => { observe_agent_runtime_project_verify( root, @@ -7353,8 +8027,26 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ "project.restore" => { observe_agent_runtime_project_restore(root, agent_id, run_id, &action.input) } - "project.diff" => observe_agent_runtime_project_diff(root, &action.input), - "git.inspect" => observe_agent_runtime_git_inspect(root, &action.input), + "project.diff" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_project_diff(root, &action.input), + ), + "git.inspect" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_git_inspect(root, &action.input), + ), "project.patchset" => observe_agent_runtime_project_patchset( root, agent_id, @@ -7364,14 +8056,41 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ pending_action, &action.input, ), - "file.list" => observe_agent_runtime_file_list(root, &action.input), - "file.read" => observe_agent_runtime_file(root, &action.input), + "file.list" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_file_list(root, &action.input), + ), + "file.read" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_file(root, &action.input), + ), "file.write" => observe_agent_runtime_file_write(root, agent_id, run_id, &action.input), "file.patch" => observe_agent_runtime_file_patch(root, agent_id, run_id, &action.input), "file.delete" => { observe_agent_runtime_file_delete(root, agent_id, run_id, pending_action, &action.input) } - "task.list" => observe_agent_runtime_task_list(root), + "task.list" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || observe_agent_runtime_task_list(root), + ), "task.create" => observe_agent_runtime_task_create(root, agent_id, &action.input), "task.update" => observe_agent_runtime_task_update(root, agent_id, &action.input), "command.exec" => { @@ -7410,9 +8129,35 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ &action.input, ), "agent.schedule_ready" => observe_agent_runtime_schedule_ready_tasks(root, &action.input), - "agent.run_status" => { - observe_agent_runtime_run_status(root, agent_id, run_id, action_id, &action.input) - } + "agent.action_history" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + false, + || { + if let Some(mut blocker) = + isolated_join_completion_blocker_at_locked(root, agent_id, run_id) + { + blocker.tool = "agent.action_history".to_string(); + blocker + } else { + observe_agent_runtime_action_history(root, agent_id, run_id, &action.input) + } + }, + ), + "agent.run_status" => observe_agent_runtime_project_snapshot_with_lock( + root, + agent_id, + run_id, + action, + &action_fingerprint, + pending_action, + true, + || observe_agent_runtime_run_status(root, agent_id, run_id, action_id, &action.input), + ), _ => AgentRuntimeToolObservation { tool: tool.to_string(), status: "rejected".to_string(), @@ -7422,6 +8167,122 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ } } +fn observe_agent_runtime_project_snapshot_with_lock( + root: &Path, + agent_id: &str, + run_id: &str, + action: &AgentRuntimeToolAction, + action_fingerprint: &str, + pending_action: Option<&AgentRuntimePendingToolAction>, + validate_revision_gate: bool, + observe: F, +) -> AgentRuntimeToolObservation +where + F: FnOnce() -> AgentRuntimeToolObservation, +{ + let tool = action.tool.trim(); + let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + &format!("runtime.snapshot.{tool}"), + ) { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: tool.to_string(), + status: "failed".to_string(), + summary: "无法取得一致项目快照".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + }; + if let Some(caller_pending) = pending_action { + let durable_pending = + match read_game_creator_agent_runtime_pending_tool_action(root, agent_id, run_id) { + Ok(pending) => pending, + Err(error) => { + return agent_runtime_pending_reconciliation_observation(tool, root, &error); + } + }; + if &durable_pending != caller_pending { + return agent_runtime_pending_reconciliation_observation( + tool, + root, + "等待项目锁后 durable pending action 已被替换或迁移", + ); + } + let pending = &durable_pending; + let runtime = match read_game_creator_agent_runtime_at(root, agent_id) { + Ok(result) => result.state, + Err(error) => { + return agent_runtime_pending_reconciliation_observation(tool, root, &error); + } + }; + if runtime.run_id != run_id + || pending.action != *action + || validate_agent_runtime_pending_context(root, &runtime, pending).is_err() + || validate_agent_runtime_pending_action_after_lock( + root, + agent_id, + run_id, + tool, + Some(&pending.action_id), + action_fingerprint, + pending, + ) + .is_err() + { + return agent_runtime_pending_reconciliation_observation( + tool, + root, + "等待项目锁后 pending action 身份已变化", + ); + } + let Some(command_id) = game_creator_agent_runtime_tool_command_id(tool) else { + return agent_runtime_pending_reconciliation_observation( + tool, + root, + "等待项目锁后工具不再属于 Runtime 白名单", + ); + }; + if let Some(blocked) = game_creator_agent_runtime_tool_policy_block_after_lock( + root, + agent_id, + command_id, + Some(pending), + ) { + return agent_runtime_tool_policy_block_observation(tool, blocked); + } + match pending_repository_context_drift_observation(root, &runtime, pending) { + Ok(Some(observation)) => return observation, + Ok(None) => {} + Err(error) => { + return agent_runtime_pending_reconciliation_observation(tool, root, &error); + } + } + if validate_revision_gate { + if let Err(error) = + validate_agent_runtime_pending_verification_gate_before(root, pending) + { + return agent_runtime_pending_reconciliation_observation(tool, root, &error); + } + } + } + observe() +} + +fn agent_runtime_pending_reconciliation_observation( + tool: &str, + root: &Path, + error: &str, +) -> AgentRuntimeToolObservation { + AgentRuntimeToolObservation { + tool: tool.to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "工具动作等待一致性锁后无法通过持久门禁".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, error, 500)), + } +} + fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str> { match tool { "memory.read" => Some("memory.read"), @@ -7454,6 +8315,7 @@ fn game_creator_agent_runtime_tool_command_id(tool: &str) -> Option<&'static str "agent.delegate" => Some("agent.delegate"), "agent.spawn_isolated" => Some("agent.spawn_isolated"), "agent.schedule_ready" => Some("agent.schedule_ready"), + "agent.action_history" => Some("agent.audit"), "agent.run_status" => Some("agent.run_status"), _ => None, } @@ -7524,7 +8386,7 @@ fn game_creator_agent_runtime_pending_tool_action_relative_path( ) } -fn game_creator_agent_runtime_pending_tool_action_exists( +pub(crate) fn game_creator_agent_runtime_pending_tool_action_exists( root: &Path, agent_id: &str, run_id: &str, @@ -7983,6 +8845,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "agent.delegate", "agent.spawn_isolated", "agent.schedule_ready", + "agent.action_history", "agent.run_status", ] } @@ -9071,17 +9934,6 @@ fn observe_agent_runtime_project_diff( detail: None, }; } - let _lock = match acquire_project_write_lock(root, "project.diff") { - Ok(lock) => lock, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "project.diff".to_string(), - status: "failed".to_string(), - summary: "project.diff 无法取得项目读取锁".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 240)), - }; - } - }; let include_content = match input .get("includeContent") .or_else(|| input.get("include_content")) @@ -11659,6 +12511,64 @@ fn isolated_join_claim_action_id_from_cancelled_task( .filter(|action_id| !action_id.is_empty()) } +fn wake_waiting_isolated_join_parent_run_at( + root: &Path, + parent_task: &AgentRuntimeTaskRecord, +) -> Result { + if external_agent_runner_owns_background_execution() { + wake_external_agent_runner_pending(root)?; + return Ok(true); + } + let Some(runtime_lock) = + try_acquire_game_creator_agent_runtime_task_lock(root, &parent_task.agent_id)? + else { + return Ok(false); + }; + let Some(current_task) = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &parent_task.agent_id, + &parent_task.run_id, + )? + else { + return Ok(false); + }; + if current_task.status != "running" || current_task.phase != "waiting-for-isolated-join" { + return Ok(false); + } + match isolated_join_completion_barrier_at(root, ¤t_task.agent_id, ¤t_task.run_id) { + Ok(Some(detail)) if isolated_join_barrier_has_waiting_groups(&detail) => return Ok(false), + Err(error) => return Err(error), + Ok(_) => {} + } + let state = read_game_creator_agent_runtime_for_session_at( + root, + ¤t_task.agent_id, + Some(¤t_task.session_id), + )? + .state; + if state.run_id != current_task.run_id + || state.session_id != current_task.session_id + || state.current_task != current_task.task + { + return Err("动态隔离 Agent parent-wake 的父 run 状态身份不一致".to_string()); + } + let state = advance_game_creator_agent_runtime_turn_at( + root, + state, + "planning", + "隔离子 Agent 已完成,父 run 正在认领 all-join", + "动态隔离 Agent all-join 已就绪,恢复同一父 run。", + )?; + let root = root.to_path_buf(); + let agent_id = current_task.agent_id.clone(); + let task = current_task.task.clone(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = runtime_lock; + drain_game_creator_agent_background_tasks(root, agent_id, task, state).await; + }); + Ok(true) +} + pub(crate) fn dispatch_isolated_agent_join_at( root: &Path, join: JoinDispatch, @@ -11674,7 +12584,8 @@ pub(crate) fn dispatch_isolated_agent_join_at( join.delegation_group_id ) })?; - if let Some(delivery) = read_isolated_join_delivery_at(root, &join)? { + let existing_delivery = read_isolated_join_delivery_at(root, &join)?; + if let Some(delivery) = &existing_delivery { if matches!( delivery.status, IsolatedAgentJoinDeliveryStatus::ClaimedByParent @@ -11740,6 +12651,49 @@ pub(crate) fn dispatch_isolated_agent_join_at( )?; return Ok(()); } + let parent_task = parent_task.expect("terminal or missing parent returned above"); + if existing_delivery.as_ref().is_some_and(|delivery| { + delivery.status == IsolatedAgentJoinDeliveryStatus::Dispatched + && delivery.delivery_target == IsolatedAgentJoinDeliveryTarget::ParentWake + }) { + if parent_task.status == "running" && parent_task.phase == "waiting-for-isolated-join" { + let _ = wake_waiting_isolated_join_parent_run_at(root, &parent_task)?; + } + return Ok(()); + } + if existing_delivery.is_none() + && parent_task.status == "running" + && parent_task.phase == "waiting-for-isolated-join" + { + write_isolated_parent_wake_join_delivery_at(root, &join)?; + let event_state = agent_runtime_state_from_task_record(&parent_task); + let _ = append_game_creator_agent_runtime_event( + root, + &event_state, + "agent.isolated_join.parent_wake", + parent_task.status.as_str(), + parent_task.phase.as_str(), + "动态隔离 Agent all-join 已就绪,正在唤醒同一父 run。", + Some(&join.delegation_group_id), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.agent.isolated_join.parent_wake.dispatched", + "agentId": join.parent_agent_id, + "sessionId": join.parent_session_id, + "parentRunId": join.parent_run_id, + "parentActionId": join.parent_action_id, + "delegationGroupId": join.delegation_group_id, + "joinRunId": join.join_run_id, + }), + ); + let _ = wake_waiting_isolated_join_parent_run_at(root, &parent_task)?; + return Ok(()); + } + if existing_delivery.is_none() && parent_task.status == "running" { + return Ok(()); + } if let Some(existing) = read_latest_game_creator_agent_runtime_task_by_run_id( root, &join.parent_agent_id, @@ -12307,6 +13261,453 @@ fn observe_agent_runtime_schedule_ready_tasks( } } +pub(crate) fn observe_agent_runtime_action_history( + root: &Path, + agent_id: &str, + current_run_id: &str, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + match read_agent_runtime_action_history(root, agent_id, current_run_id, input) { + Ok((detail, item_count, truncated, output_truncated)) => AgentRuntimeToolObservation { + tool: "agent.action_history".to_string(), + status: "ok".to_string(), + summary: format!( + "已读取当前 Agent 的 {} 条终态动作{}", + item_count, + if truncated || output_truncated { + ",结果已显式截断" + } else { + "" + } + ), + detail: Some(detail), + }, + Err(error) => AgentRuntimeToolObservation { + tool: "agent.action_history".to_string(), + status: "failed".to_string(), + summary: "读取当前 Agent 的动作历史失败".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }, + } +} + +fn read_agent_runtime_action_history( + root: &Path, + agent_id: &str, + current_run_id: &str, + input: &serde_json::Value, +) -> Result<(String, usize, bool, bool), String> { + let query = serde_json::from_value::(input.clone()) + .map_err(|error| format!("agent.action_history 输入无效:{error}"))?; + let requested_run_id = query + .run_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(current_run_id) + .to_string(); + let run_id = agent_runtime_action_receipt_identity_text(root, &requested_run_id, 160, "runId") + .map_err(|_| "agent.action_history 的 runId 无效".to_string())?; + let action_id = query + .action_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + if action_id + .as_deref() + .is_some_and(|value| !is_valid_agent_runtime_action_id(value)) + { + return Err("agent.action_history 的 actionId 无效".to_string()); + } + let tool = query + .tool + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + if tool.as_deref().is_some_and(|value| { + !agent_runtime_executable_tools() + .into_iter() + .any(|candidate| candidate == value) + }) { + return Err("agent.action_history 的 tool 不在 Runtime 白名单中".to_string()); + } + let status = query + .status + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + if status + .as_deref() + .is_some_and(|value| value.chars().count() > 40 || value.chars().any(char::is_control)) + { + return Err("agent.action_history 的 status 无效".to_string()); + } + let limit = query + .limit + .unwrap_or(AGENT_RUNTIME_ACTION_HISTORY_DEFAULT_LIMIT); + if limit == 0 || limit > AGENT_RUNTIME_ACTION_HISTORY_MAX_LIMIT { + return Err(format!( + "agent.action_history 的 limit 必须在 1-{} 之间", + AGENT_RUNTIME_ACTION_HISTORY_MAX_LIMIT + )); + } + + let (records, scan_truncated) = + read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?; + let task_identity = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(root, agent_id), + )? + .into_iter() + .rev() + .find(|record| record.run_id == run_id); + let mut metadata = + std::collections::BTreeMap::::new(); + for (sequence, record) in records.iter().enumerate() { + if agent_db_record_text(record, "agentId") != Some(agent_id) + || agent_db_record_text(record, "runId") != Some(run_id.as_str()) + { + continue; + } + if agent_db_record_text(record, "recordType") + == Some(AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE) + { + continue; + } + let Some(record_action_id) = agent_db_record_text(record, "actionId") + .filter(|value| is_valid_agent_runtime_action_id(value)) + else { + continue; + }; + let entry = metadata.entry(record_action_id.to_string()).or_default(); + entry.task_id = agent_db_record_text(record, "taskId") + .and_then(|value| { + agent_runtime_action_receipt_identity_text(root, value, 96, "taskId").ok() + }) + .or_else(|| entry.task_id.clone()); + entry.session_id = agent_db_record_text(record, "sessionId") + .and_then(|value| { + agent_runtime_action_receipt_identity_text(root, value, 160, "sessionId").ok() + }) + .or_else(|| entry.session_id.clone()); + entry.action_fingerprint = agent_db_record_text(record, "actionFingerprint") + .filter(|value| is_valid_agent_runtime_action_fingerprint(value)) + .map(ToString::to_string) + .or_else(|| entry.action_fingerprint.clone()); + entry.tool = agent_db_record_text(record, "tool") + .and_then(|value| { + agent_runtime_action_receipt_identity_text(root, value, 80, "tool").ok() + }) + .or_else(|| entry.tool.clone()); + entry.execution_mode = agent_db_record_text(record, "executionMode") + .filter(|value| is_valid_agent_runtime_action_execution_mode(value)) + .map(ToString::to_string) + .or_else(|| entry.execution_mode.clone()); + entry.input_summary = agent_db_record_text(record, "inputSummary") + .and_then(|value| agent_runtime_action_receipt_safe_text(root, value, 160, None)) + .or_else(|| entry.input_summary.clone()); + entry.updated_at = record + .get("updatedAt") + .and_then(serde_json::Value::as_u64) + .unwrap_or(entry.updated_at); + entry.sequence = sequence; + } + + let mut receipts = std::collections::BTreeMap::::new(); + let mut receipt_actions = std::collections::BTreeSet::::new(); + for (sequence, record) in records.iter().enumerate() { + if agent_db_record_text(record, "agentId") != Some(agent_id) + || agent_db_record_text(record, "runId") != Some(run_id.as_str()) + { + continue; + } + let record_type = agent_db_record_text(record, "recordType").unwrap_or_default(); + if record_type != AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record_type != "agent.runtime.tool_observation" + && record_type != "agent.runtime.tool_action.observed" + { + continue; + } + let Some(record_action_id) = agent_db_record_text(record, "actionId") + .filter(|value| is_valid_agent_runtime_action_id(value)) + else { + if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE { + return Err("Agent 持久动作回执包含无效 actionId".to_string()); + } + continue; + }; + if receipt_actions.contains(record_action_id) + && record_type != AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + { + continue; + } + let fallback = metadata.get(record_action_id).cloned().unwrap_or_default(); + let record_tool = agent_db_record_text(record, "tool") + .map(ToString::to_string) + .or_else(|| fallback.tool.clone()) + .unwrap_or_else(|| "unknown".to_string()); + let record_tool = + agent_runtime_action_receipt_identity_text(root, &record_tool, 80, "tool") + .unwrap_or_else(|_| "unknown".to_string()); + let record_status = if record_type == "agent.runtime.tool_action.observed" { + agent_db_record_text(record, "observationStatus") + .or_else(|| agent_db_record_text(record, "status")) + } else { + agent_db_record_text(record, "status") + .or_else(|| agent_db_record_text(record, "observationStatus")) + } + .unwrap_or("unknown"); + if !is_terminal_agent_runtime_action_status(record_status) { + if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE { + return Err(format!( + "Agent 持久动作回执不是终态:actionId={record_action_id}" + )); + } + continue; + } + let record_task_id = agent_db_record_text(record, "taskId").and_then(|value| { + agent_runtime_action_receipt_identity_text(root, value, 96, "taskId").ok() + }); + let record_session_id = agent_db_record_text(record, "sessionId").and_then(|value| { + agent_runtime_action_receipt_identity_text(root, value, 160, "sessionId").ok() + }); + let record_action_fingerprint = agent_db_record_text(record, "actionFingerprint") + .filter(|value| is_valid_agent_runtime_action_fingerprint(value)) + .map(ToString::to_string); + let record_execution_mode = agent_db_record_text(record, "executionMode") + .filter(|value| is_valid_agent_runtime_action_execution_mode(value)) + .map(ToString::to_string); + if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && (record_tool == "unknown" + || record_task_id.is_none() + || record_session_id.is_none() + || record_action_fingerprint.is_none() + || record_execution_mode.is_none()) + { + return Err(format!( + "Agent 持久动作回执身份字段无效:actionId={record_action_id}" + )); + } + if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && task_identity.as_ref().is_some_and(|task| { + record_task_id.as_deref() != Some(task.task_id.as_str()) + || record_session_id.as_deref() != Some(task.session_id.as_str()) + }) + { + return Err(format!( + "Agent 持久动作回执与任务账本身份冲突:actionId={record_action_id}" + )); + } + if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && (fallback + .task_id + .as_deref() + .is_some_and(|value| record_task_id.as_deref() != Some(value)) + || fallback + .session_id + .as_deref() + .is_some_and(|value| record_session_id.as_deref() != Some(value)) + || fallback + .action_fingerprint + .as_deref() + .is_some_and(|value| record_action_fingerprint.as_deref() != Some(value)) + || fallback + .tool + .as_deref() + .is_some_and(|value| record_tool != value) + || fallback + .execution_mode + .as_deref() + .is_some_and(|value| record_execution_mode.as_deref() != Some(value))) + { + return Err(format!( + "Agent 持久动作回执与动作账本身份冲突:actionId={record_action_id}" + )); + } + let record_summary = agent_db_record_text(record, "summary").unwrap_or("工具动作已结束"); + let summary = agent_runtime_action_receipt_safe_text( + root, + record_summary, + 200, + Some("工具动作已结束,敏感摘要已省略"), + ) + .unwrap_or_else(|| "工具动作已结束,敏感摘要已省略".to_string()); + let persisted_safe_detail = agent_db_record_text(record, "safeDetail"); + let safe_detail = if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE { + persisted_safe_detail.and_then(|value| { + agent_runtime_action_receipt_safe_detail( + root, + &AgentRuntimeToolObservation { + tool: record_tool.clone(), + status: record_status.to_string(), + summary: summary.clone(), + detail: Some(value.to_string()), + }, + ) + }) + } else { + None + }; + let detail_unavailable = if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE { + record + .get("detailUnavailable") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + || persisted_safe_detail.is_some() && safe_detail.is_none() + } else { + true + }; + let item = AgentRuntimeActionHistoryItem { + agent_id: agent_id.to_string(), + task_id: record_task_id + .or_else(|| fallback.task_id.clone()) + .or_else(|| task_identity.as_ref().map(|record| record.task_id.clone())) + .unwrap_or_else(|| agent_id.to_string()), + session_id: record_session_id + .or_else(|| fallback.session_id.clone()) + .or_else(|| { + task_identity + .as_ref() + .map(|record| record.session_id.clone()) + }) + .unwrap_or_default(), + action_id: record_action_id.to_string(), + action_fingerprint: record_action_fingerprint.or(fallback.action_fingerprint), + run_id: run_id.clone(), + tool: record_tool, + execution_mode: record_execution_mode.or(fallback.execution_mode), + status: sanitize_agent_runtime_text(record_status, 40), + input_summary: agent_db_record_text(record, "inputSummary") + .and_then(|value| agent_runtime_action_receipt_safe_text(root, value, 160, None)) + .or(fallback.input_summary), + summary, + safe_detail, + detail_unavailable, + updated_at: record + .get("updatedAt") + .and_then(serde_json::Value::as_u64) + .unwrap_or(fallback.updated_at), + sequence: sequence.max(fallback.sequence), + }; + if record_type == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE { + receipt_actions.insert(record_action_id.to_string()); + } + receipts.insert(record_action_id.to_string(), item); + } + + let include_history_tool = tool.as_deref() == Some("agent.action_history"); + let mut items = receipts + .into_values() + .filter(|item| { + action_id + .as_deref() + .is_none_or(|value| item.action_id == value) + }) + .filter(|item| tool.as_deref().is_none_or(|value| item.tool == value)) + .filter(|item| status.as_deref().is_none_or(|value| item.status == value)) + .filter(|item| include_history_tool || item.tool != "agent.action_history") + .collect::>(); + items.sort_by(|left, right| { + (left.updated_at, left.sequence, left.action_id.as_str()).cmp(&( + right.updated_at, + right.sequence, + right.action_id.as_str(), + )) + }); + let mut truncated = scan_truncated || items.len() > limit; + if items.len() > limit { + items = items.split_off(items.len() - limit); + } + let mut output_truncated = false; + let initial = serialize_agent_runtime_action_history_detail( + &run_id, + &items, + truncated, + output_truncated, + )?; + if initial.chars().count() <= AGENT_RUNTIME_ACTION_HISTORY_MAX_OUTPUT_CHARS { + return Ok((initial, items.len(), truncated, output_truncated)); + } + output_truncated = true; + if output_truncated { + for item in items.iter_mut() { + item.safe_detail = None; + item.detail_unavailable = true; + item.summary = sanitize_agent_runtime_text(&item.summary, 100); + item.input_summary = None; + } + } + loop { + let detail = serialize_agent_runtime_action_history_detail( + &run_id, + &items, + truncated, + output_truncated, + )?; + if detail.chars().count() <= AGENT_RUNTIME_ACTION_HISTORY_MAX_OUTPUT_CHARS { + return Ok((detail, items.len(), truncated, output_truncated)); + } + if items.len() <= 1 { + return Err("单条 Agent 动作历史超过结构化输出上限".to_string()); + } + items.remove(0); + truncated = true; + } +} + +fn serialize_agent_runtime_action_history_detail( + run_id: &str, + items: &[AgentRuntimeActionHistoryItem], + truncated: bool, + output_truncated: bool, +) -> Result { + serde_json::to_string(&serde_json::json!({ + "runId": run_id, + "count": items.len(), + "truncated": truncated, + "outputTruncated": output_truncated, + "actions": items, + })) + .map_err(|error| format!("序列化 Agent 动作历史失败:{error}")) +} + +fn agent_db_record_text<'a>(record: &'a serde_json::Value, field: &str) -> Option<&'a str> { + record.get(field).and_then(serde_json::Value::as_str) +} + +pub(crate) fn is_valid_agent_runtime_action_id(value: &str) -> bool { + value.strip_prefix("action-").is_some_and(|suffix| { + suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) +} + +fn is_valid_agent_runtime_action_fingerprint(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn is_valid_agent_runtime_action_execution_mode(value: &str) -> bool { + matches!( + value, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO | AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION + ) +} + +fn is_terminal_agent_runtime_action_status(value: &str) -> bool { + matches!( + value, + "ok" | "failed" + | "command-failed" + | "verification-failed" + | "blocked" + | "rejected" + | "cancelled" + | "budget-exhausted" + | AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ) +} + pub(crate) fn observe_agent_runtime_run_status( root: &Path, agent_id: &str, @@ -13588,7 +14989,11 @@ where "runtime.background.complete", )?; let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if current_revision.revision != response_revision { + let blocker = if let Some(blocker) = + isolated_join_completion_blocker_at_locked(root, &state.agent_id, &state.run_id) + { + Some(blocker) + } else if current_revision.revision != response_revision { Some(agent_runtime_verification_blocker( "最终回复基于的项目 revision 已过期,不能把任务标记为完成", format!( @@ -14123,14 +15528,14 @@ fn game_creator_agent_runtime_session_path(root: &Path, agent_id: &str) -> PathB .join(format!("{agent_id}.json")) } -fn game_creator_agent_runtime_event_path(root: &Path, agent_id: &str) -> PathBuf { +pub(crate) fn game_creator_agent_runtime_event_path(root: &Path, agent_id: &str) -> PathBuf { root.join(".agent") .join("runtime") .join("events") .join(format!("{agent_id}.jsonl")) } -fn game_creator_agent_runtime_task_path(root: &Path, agent_id: &str) -> PathBuf { +pub(crate) fn game_creator_agent_runtime_task_path(root: &Path, agent_id: &str) -> PathBuf { root.join(".agent") .join("runtime") .join("tasks") @@ -14145,7 +15550,7 @@ fn game_creator_agent_runtime_cancel_path(root: &Path, agent_id: &str, run_id: & .join(format!("{run_id}.json")) } -fn write_game_creator_agent_runtime_cancel_request( +pub(crate) fn write_game_creator_agent_runtime_cancel_request( root: &Path, agent_id: &str, run_id: &str, @@ -14701,6 +16106,43 @@ fn append_game_creator_agent_runtime_event( phase: &str, summary: &str, detail: Option<&str>, +) -> Result<(), String> { + append_game_creator_agent_runtime_event_with_action( + root, state, event_type, status, phase, summary, detail, None, + ) +} + +pub(crate) fn append_game_creator_agent_runtime_action_event( + root: &Path, + state: &AgentRuntimeState, + event_type: &str, + status: &str, + phase: &str, + summary: &str, + detail: Option<&str>, + action_id: &str, +) -> Result<(), String> { + append_game_creator_agent_runtime_event_with_action( + root, + state, + event_type, + status, + phase, + summary, + detail, + Some(action_id), + ) +} + +fn append_game_creator_agent_runtime_event_with_action( + root: &Path, + state: &AgentRuntimeState, + event_type: &str, + status: &str, + phase: &str, + summary: &str, + detail: Option<&str>, + action_id: Option<&str>, ) -> Result<(), String> { let path = game_creator_agent_runtime_event_path(root, &state.agent_id); if let Some(parent) = path.parent() { @@ -14719,12 +16161,44 @@ fn append_game_creator_agent_runtime_event( run_id: state.run_id.clone(), source: state.source.clone(), event_type: event_type.to_string(), + action_id: action_id.map(ToString::to_string), status: status.to_string(), phase: phase.to_string(), summary: summary.to_string(), - detail: detail.map(|value| sanitize_agent_runtime_text(value, 500)), + detail: detail.map(|value| { + let max_chars = + if event_type == "observation" && summary.starts_with("agent.action_history:") { + AGENT_RUNTIME_FILE_CONTEXT_MAX_CHARS + } else { + 500 + }; + sanitize_agent_runtime_text(value, max_chars) + }), updated_at: unix_timestamp(), }; + if let Some(action_id) = action_id { + if let Some(existing) = read_recent_game_creator_agent_runtime_events(&path)? + .into_iter() + .rev() + .find(|candidate| { + candidate.run_id == event.run_id + && candidate.event_type == event.event_type + && candidate.action_id.as_deref() == Some(action_id) + && candidate.phase == event.phase + }) + { + if existing.status != event.status + || existing.phase != event.phase + || existing.summary != event.summary + || existing.detail != event.detail + { + return Err(format!( + "Agent Runtime action event 幂等身份冲突:actionId={action_id}" + )); + } + return Ok(()); + } + } let line = serde_json::to_string(&event) .map_err(|error| format!("序列化 Agent Runtime 事件失败:{error}"))?; append_jsonl_line(&path, &line, "Agent Runtime 事件")?; @@ -14736,7 +16210,87 @@ pub(crate) fn append_game_creator_agent_runtime_task( root: &Path, state: &AgentRuntimeState, ) -> Result<(), String> { - let record = AgentRuntimeTaskRecord { + append_game_creator_agent_runtime_task_record(root, &agent_runtime_task_record(state)) +} + +pub(crate) fn append_game_creator_agent_runtime_task_projection_once( + root: &Path, + state: &AgentRuntimeState, + action_id: &str, +) -> Result<(), String> { + let record = agent_runtime_task_record(state); + let mut projection = serde_json::to_value(&record) + .map_err(|error| format!("序列化 Agent Runtime action task 失败:{error}"))?; + projection + .as_object_mut() + .ok_or_else(|| "Agent Runtime action task 必须是 JSON object".to_string())? + .insert( + "actionId".to_string(), + serde_json::Value::String(action_id.to_string()), + ); + let _journal_lock = + acquire_game_creator_agent_runtime_task_journal_lock(root, &record.agent_id)?; + let path = game_creator_agent_runtime_task_path(root, &record.agent_id); + match File::open(&path) { + Ok(file) => { + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| { + format!( + "读取 Agent Runtime action task 失败:{}: {error}", + path.display() + ) + })?; + let existing = match serde_json::from_str::(line.trim()) { + Ok(existing) => existing, + Err(_) if line.trim().is_empty() => continue, + Err(error) => { + return Err(format!( + "解析 Agent Runtime action task 失败:{}: {error}", + path.display() + )); + } + }; + if existing.get("runId") != projection.get("runId") + || existing.get("actionId") != projection.get("actionId") + || existing.get("phase") != projection.get("phase") + { + continue; + } + for field in [ + "agentId", + "taskId", + "sessionId", + "runId", + "actionId", + "source", + "status", + "phase", + "currentAction", + ] { + if existing.get(field) != projection.get(field) { + return Err(format!( + "Agent Runtime action task 幂等身份冲突:actionId={action_id} field={field}" + )); + } + } + return Ok(()); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Agent Runtime action task 失败:{}: {error}", + path.display() + )); + } + } + let line = serde_json::to_string(&projection) + .map_err(|error| format!("序列化 Agent Runtime action task 失败:{error}"))?; + append_jsonl_line(&path, &line, "Agent Runtime 任务") +} + +fn agent_runtime_task_record(state: &AgentRuntimeState) -> AgentRuntimeTaskRecord { + AgentRuntimeTaskRecord { schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), agent_id: state.agent_id.clone(), task_id: state.task_id.clone(), @@ -14753,8 +16307,7 @@ pub(crate) fn append_game_creator_agent_runtime_task( terminal_detail: agent_runtime_terminal_detail(state), error: state.error.clone(), updated_at: state.updated_at, - }; - append_game_creator_agent_runtime_task_record(root, &record) + } } fn append_game_creator_agent_runtime_cancelled_task_record( @@ -14968,7 +16521,7 @@ fn agent_runtime_terminal_detail(state: &AgentRuntimeState) -> Option { (!detail.trim().is_empty()).then_some(detail) } -fn read_recent_game_creator_agent_runtime_events( +pub(crate) fn read_recent_game_creator_agent_runtime_events( path: &Path, ) -> Result, String> { read_recent_game_creator_agent_runtime_events_for_session(path, None) @@ -15306,7 +16859,7 @@ fn read_recoverable_runnable_game_creator_agent_runtime_task( } } -fn read_all_game_creator_agent_runtime_tasks( +pub(crate) fn read_all_game_creator_agent_runtime_tasks( path: &Path, ) -> Result, String> { let mut records = Vec::new(); @@ -15888,6 +17441,10 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { "agent.delegate、agent.schedule_ready", "agent.delegate、agent.spawn_isolated、agent.schedule_ready", ) + .replace( + "agent.schedule_ready、agent.run_status", + "agent.schedule_ready、agent.action_history、agent.run_status", + ) .replace( "task.update、command.run_limited", "task.update、command.exec、command.run_limited", @@ -16601,7 +18158,7 @@ async fn request_game_creator_agent_llm_text_retrying_recoverable( allow_stream: bool, ) -> Result { const MAX_EMPTY_RETRIES: u32 = 3; - const MAX_TRANSIENT_RETRIES: u32 = 2; + const MAX_TRANSIENT_RETRIES: u32 = 5; const TRANSIENT_RETRY_BACKOFF_MS: u64 = 500; let mut empty_retries = 0u32; let mut transient_retries = 0u32; diff --git a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs index be6d74d33..e56fa1114 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs @@ -100,6 +100,14 @@ pub(crate) enum IsolatedAgentJoinDeliveryStatus { Suppressed, } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum IsolatedAgentJoinDeliveryTarget { + #[default] + Continuation, + ParentWake, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct IsolatedAgentJoinDeliveryRecord { @@ -109,6 +117,8 @@ pub(crate) struct IsolatedAgentJoinDeliveryRecord { pub(crate) delegation_group_id: String, pub(crate) join_run_id: String, pub(crate) status: IsolatedAgentJoinDeliveryStatus, + #[serde(default)] + pub(crate) delivery_target: IsolatedAgentJoinDeliveryTarget, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) queued_run_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -582,6 +592,43 @@ pub(crate) fn reconcile_all_isolated_groups_at(root: &Path) -> Result Result, String> { + validate_safe_id(parent_agent_id, "parentAgentId", 96)?; + validate_safe_id(parent_run_id, "parentRunId", 160)?; + let groups = list_json_records( + root, + ISOLATED_AGENT_GROUP_DIR, + "动态隔离 Agent group", + |record| validate_isolated_group_record(root, record), + )?; + let mut waiting_groups = 0usize; + let mut ready_unclaimed_groups = 0usize; + for group in groups.into_iter().filter(|group| { + group.parent_agent_id == parent_agent_id && group.parent_run_id == parent_run_id + }) { + let Some(join) = build_join_dispatch_if_ready_at(root, &group.delegation_group_id)? else { + waiting_groups = waiting_groups.saturating_add(1); + continue; + }; + let claimed = read_isolated_join_delivery_at(root, &join)?.is_some_and(|delivery| { + delivery.status == IsolatedAgentJoinDeliveryStatus::ClaimedByParent + }); + if !claimed { + ready_unclaimed_groups = ready_unclaimed_groups.saturating_add(1); + } + } + if waiting_groups == 0 && ready_unclaimed_groups == 0 { + return Ok(None); + } + Ok(Some(format!( + "waitingGroups={waiting_groups} · readyUnclaimedGroups={ready_unclaimed_groups} · 必须调用 agent.run_status 取得并认领 all-join 后再继续" + ))) +} + pub(crate) fn read_isolated_join_delivery_at( root: &Path, join: &JoinDispatch, @@ -603,6 +650,38 @@ pub(crate) fn write_isolated_join_delivery_at( status: IsolatedAgentJoinDeliveryStatus, queued_run_id: Option<&str>, claimed_by_action_id: Option<&str>, +) -> Result { + write_isolated_join_delivery_with_target_at( + root, + join, + status, + None, + queued_run_id, + claimed_by_action_id, + ) +} + +pub(crate) fn write_isolated_parent_wake_join_delivery_at( + root: &Path, + join: &JoinDispatch, +) -> Result { + write_isolated_join_delivery_with_target_at( + root, + join, + IsolatedAgentJoinDeliveryStatus::Dispatched, + Some(IsolatedAgentJoinDeliveryTarget::ParentWake), + None, + None, + ) +} + +fn write_isolated_join_delivery_with_target_at( + root: &Path, + join: &JoinDispatch, + status: IsolatedAgentJoinDeliveryStatus, + requested_target: Option, + queued_run_id: Option<&str>, + claimed_by_action_id: Option<&str>, ) -> Result { let existing = read_isolated_join_delivery_at(root, join)?; if let Some(existing) = &existing { @@ -621,7 +700,15 @@ pub(crate) fn write_isolated_join_delivery_at( existing.status, status )); } + if requested_target.is_some_and(|target| target != existing.delivery_target) { + return Err("动态隔离 Agent join delivery 不能更改投递目标".to_string()); + } } + let delivery_target = existing + .as_ref() + .map(|record| record.delivery_target) + .or(requested_target) + .unwrap_or_default(); let queued_run_id = queued_run_id .map(str::trim) .filter(|value| !value.is_empty()) @@ -667,6 +754,15 @@ pub(crate) fn write_isolated_join_delivery_at( } else if claimed_by_action_id.is_some() { return Err("未认领的动态隔离 Agent join 不能保存 claimedByActionId".to_string()); } + if let Some(existing) = &existing { + if existing.status == status + && existing.delivery_target == delivery_target + && existing.queued_run_id == queued_run_id + && existing.claimed_by_action_id == claimed_by_action_id + { + return Ok(existing.clone()); + } + } let record = IsolatedAgentJoinDeliveryRecord { schema_version: ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION.to_string(), parent_agent_id: join.parent_agent_id.clone(), @@ -674,6 +770,7 @@ pub(crate) fn write_isolated_join_delivery_at( delegation_group_id: join.delegation_group_id.clone(), join_run_id: join.join_run_id.clone(), status, + delivery_target, queued_run_id, claimed_by_action_id, updated_at: unix_timestamp(), @@ -850,11 +947,22 @@ fn validate_isolated_join_delivery_record( { return Err("动态隔离 Agent join delivery 身份不一致".to_string()); } - if let Some(queued_run_id) = &record.queued_run_id { - validate_safe_id(queued_run_id, "queuedRunId", 160)?; - if queued_run_id != &record.join_run_id { - return Err("动态隔离 Agent join delivery 使用了非稳定 queuedRunId".to_string()); + match (record.delivery_target, record.queued_run_id.as_deref()) { + (IsolatedAgentJoinDeliveryTarget::ParentWake, Some(_)) => { + return Err("动态隔离 Agent parent-wake delivery 不能含 queuedRunId".to_string()); } + (IsolatedAgentJoinDeliveryTarget::Continuation, None) + if record.status == IsolatedAgentJoinDeliveryStatus::Dispatched => + { + return Err("动态隔离 Agent continuation delivery 缺少 queuedRunId".to_string()); + } + (_, Some(queued_run_id)) => { + validate_safe_id(queued_run_id, "queuedRunId", 160)?; + if queued_run_id != record.join_run_id { + return Err("动态隔离 Agent join delivery 使用了非稳定 queuedRunId".to_string()); + } + } + (_, None) => {} } match (record.status, record.claimed_by_action_id.as_deref()) { (IsolatedAgentJoinDeliveryStatus::ClaimedByParent, Some(action_id)) => { @@ -1640,6 +1748,11 @@ mod tests { resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap(); let second = resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[1]).unwrap(); + assert!( + isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run") + .unwrap() + .is_some_and(|detail| detail.contains("waitingGroups=1")) + ); assert!( record_isolated_child_result_at(temp.path(), &completed_result(&first)) .unwrap() @@ -1651,6 +1764,11 @@ mod tests { assert_eq!(dispatch.join_run_id, group.join_run_id); assert_eq!(dispatch.parent_session_id, "parent-session"); assert!(serde_json::from_str::(&dispatch.prompt).is_ok()); + assert!( + isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run") + .unwrap() + .is_some_and(|detail| detail.contains("readyUnclaimedGroups=1")) + ); let reconciled = reconcile_all_isolated_groups_at(temp.path()).unwrap(); assert_eq!(reconciled, vec![dispatch]); } @@ -1681,6 +1799,10 @@ mod tests { dispatched.status, IsolatedAgentJoinDeliveryStatus::Dispatched ); + assert_eq!( + dispatched.delivery_target, + IsolatedAgentJoinDeliveryTarget::Continuation + ); let claimed = write_isolated_join_delivery_at( temp.path(), &dispatch, @@ -1701,6 +1823,11 @@ mod tests { claimed.queued_run_id.as_deref(), Some(&*dispatch.join_run_id) ); + assert_eq!( + isolated_join_completion_barrier_at(temp.path(), "code-prototype", "parent-run") + .unwrap(), + None + ); assert!(write_isolated_join_delivery_at( temp.path(), &dispatch, @@ -1717,6 +1844,105 @@ mod tests { ); } + #[test] + fn parent_wake_delivery_is_persistent_idempotent_and_claim_inherits_target() { + let temp = tempdir().unwrap(); + let group = create_group( + temp.path(), + "action-parent-wake", + &request(vec![("code-prototype", "game/a/**")]), + ); + let instance = + resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap(); + let dispatch = record_isolated_child_result_at(temp.path(), &completed_result(&instance)) + .unwrap() + .unwrap(); + + let dispatched = + write_isolated_parent_wake_join_delivery_at(temp.path(), &dispatch).unwrap(); + assert_eq!( + dispatched.delivery_target, + IsolatedAgentJoinDeliveryTarget::ParentWake + ); + assert_eq!( + dispatched.status, + IsolatedAgentJoinDeliveryStatus::Dispatched + ); + assert!(dispatched.queued_run_id.is_none()); + assert_eq!( + write_isolated_parent_wake_join_delivery_at(temp.path(), &dispatch).unwrap(), + dispatched + ); + assert_eq!( + read_isolated_join_delivery_at(temp.path(), &dispatch) + .unwrap() + .unwrap(), + dispatched + ); + + let claimed = write_isolated_join_delivery_at( + temp.path(), + &dispatch, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent, + None, + Some("parent-wake-claim"), + ) + .unwrap(); + assert_eq!( + claimed.delivery_target, + IsolatedAgentJoinDeliveryTarget::ParentWake + ); + assert!(claimed.queued_run_id.is_none()); + assert_eq!( + claimed.claimed_by_action_id.as_deref(), + Some("parent-wake-claim") + ); + } + + #[test] + fn join_delivery_infers_legacy_continuation_and_rejects_invalid_target_combinations() { + let temp = tempdir().unwrap(); + let group = create_group( + temp.path(), + "action-delivery-target-validation", + &request(vec![("code-prototype", "game/a/**")]), + ); + let instance = + resolve_isolated_agent_instance_at(temp.path(), &group.instance_ids[0]).unwrap(); + let dispatch = record_isolated_child_result_at(temp.path(), &completed_result(&instance)) + .unwrap() + .unwrap(); + let path = isolated_join_delivery_relative_path(&dispatch.delegation_group_id); + let mut legacy = serde_json::json!({ + "schemaVersion": ISOLATED_AGENT_JOIN_DELIVERY_SCHEMA_VERSION, + "parentAgentId": dispatch.parent_agent_id, + "parentRunId": dispatch.parent_run_id, + "delegationGroupId": dispatch.delegation_group_id, + "joinRunId": dispatch.join_run_id, + "status": "dispatched", + "queuedRunId": dispatch.join_run_id, + "updatedAt": unix_timestamp(), + }); + write_json_record(temp.path(), &path, "动态隔离 Agent join delivery", &legacy).unwrap(); + let inferred = read_isolated_join_delivery_at(temp.path(), &dispatch) + .unwrap() + .unwrap(); + assert_eq!( + inferred.delivery_target, + IsolatedAgentJoinDeliveryTarget::Continuation + ); + assert!(write_isolated_parent_wake_join_delivery_at(temp.path(), &dispatch).is_err()); + + legacy["deliveryTarget"] = Value::String("parent-wake".to_string()); + write_json_record(temp.path(), &path, "动态隔离 Agent join delivery", &legacy).unwrap(); + assert!(read_isolated_join_delivery_at(temp.path(), &dispatch).is_err()); + + legacy["deliveryTarget"] = Value::String("continuation".to_string()); + legacy.as_object_mut().unwrap().remove("queuedRunId"); + write_json_record(temp.path(), &path, "动态隔离 Agent join delivery", &legacy).unwrap(); + assert!(read_isolated_join_delivery_at(temp.path(), &dispatch).is_err()); + } + #[test] fn corrupt_record_fails_closed() { let temp = tempdir().unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 7895d48bf..b79a3e129 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -243,6 +243,8 @@ impl Default for AgentRuntimeToolPolicySnapshot { #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeToolCallRecord { + #[serde(default)] + action_id: Option, #[serde(default)] tool: String, #[serde(default)] @@ -350,6 +352,8 @@ struct AgentRuntimeEvent { #[serde(default)] event_type: String, #[serde(default)] + action_id: Option, + #[serde(default)] status: String, #[serde(default)] phase: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 3312092e9..72fb6c085 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1,6 +1,7 @@ use super::*; use sha2::{Digest, Sha256}; use similar::TextDiff; +use std::io::{Seek, SeekFrom}; #[cfg(windows)] const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; @@ -26,14 +27,7 @@ pub(crate) fn init_local_game_project_at( return Err("项目名称不能为空".to_string()); } - for relative in [ - "game", - "assets", - "memory", - "memory/agents", - "exports", - ".agent/logs", - ] { + for relative in ["game", "assets", "memory", "memory/agents", "exports"] { fs::create_dir_all(root.join(relative)).map_err(|error| { format!( "创建本地项目目录失败:{}: {error}", @@ -60,6 +54,15 @@ pub(crate) fn init_local_game_project_at( )?; } + for relative in [".agent/logs", ".agent/runtime"] { + fs::create_dir_all(root.join(relative)).map_err(|error| { + format!( + "创建本地项目目录失败:{}: {error}", + root.join(relative).display() + ) + })?; + } + let manifest_path = root.join(".agent/manifest.json"); if !manifest_storage_exists(&manifest_path)? { let manifest = new_game_creation_app_manifest(project_id, name); @@ -74,6 +77,18 @@ pub(crate) fn init_local_game_project_at( }) } +const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024; +const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt"; +const AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES: u64 = 256 * 1024 * 1024; +const AGENT_DB_MAX_SCAN_RECORDS: usize = 1_000_000; +const AGENT_DB_TERMINAL_RESERVE_RECORDS: u64 = 64; +const AGENT_DB_TERMINAL_RESERVE_BYTES: u64 = + (AGENT_DB_MAX_RECORD_BYTES as u64 + 1) * AGENT_DB_TERMINAL_RESERVE_RECORDS; +const AGENT_DB_MAX_ORDINARY_APPEND_BYTES: u64 = + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES - AGENT_DB_TERMINAL_RESERVE_BYTES; +const AGENT_DB_MAX_BOUNDED_READ_BYTES: u64 = 32 * 1024 * 1024; +const AGENT_DB_MAX_BOUNDED_RECORDS: usize = 16_384; + fn serialize_agent_db_record(mut record: serde_json::Value) -> Result { let object = record .as_object_mut() @@ -86,13 +101,1276 @@ fn serialize_agent_db_record(mut record: serde_json::Value) -> Result AGENT_DB_MAX_RECORD_BYTES { + return Err(format!( + "Agent 本地索引单条记录超过 {} 字节上限", + AGENT_DB_MAX_RECORD_BYTES + )); + } + Ok(line) +} + +struct AgentDbDirectory { + root_path: PathBuf, + root_directory: File, + agent_directory: File, + path: PathBuf, +} + +struct AgentDbStorage { + file: File, + path: PathBuf, + root_path: PathBuf, + root_directory: File, + agent_directory: File, + #[cfg(unix)] + created: bool, +} + +#[cfg(unix)] +fn verify_unix_agent_db_root(root: &Path, opened: &File) -> Result<(), String> { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + + let root_name = std::ffi::CString::new(root.as_os_str().as_bytes()) + .map_err(|_| "Agent DB 项目目录包含 NUL".to_string())?; + // SAFETY: stat is plain data initialized by fstatat and root_name remains valid for the call. + let mut stat = unsafe { std::mem::zeroed::() }; + if unsafe { + libc::fstatat( + libc::AT_FDCWD, + root_name.as_ptr(), + &mut stat, + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + return Err(format!( + "复核 Agent DB 项目目录失败:{}: {}", + root.display(), + std::io::Error::last_os_error() + )); + } + let metadata = opened + .metadata() + .map_err(|error| format!("复核 Agent DB 项目目录句柄失败:{error}"))?; + if stat.st_dev != metadata.dev() + || stat.st_ino != metadata.ino() + || stat.st_mode & libc::S_IFMT != libc::S_IFDIR + { + return Err("Agent DB 项目目录在安全打开期间发生替换或不是普通目录".to_string()); + } + Ok(()) +} + +#[cfg(unix)] +fn verify_unix_agent_db_entry( + parent: &File, + name: &std::ffi::CStr, + opened: &File, + directory: bool, + label: &str, +) -> Result<(), String> { + use std::os::fd::AsRawFd; + use std::os::unix::fs::MetadataExt; + + // SAFETY: stat is plain data initialized by fstatat; parent and name stay valid for the call. + let mut stat = unsafe { std::mem::zeroed::() }; + if unsafe { + libc::fstatat( + parent.as_raw_fd(), + name.as_ptr(), + &mut stat, + libc::AT_SYMLINK_NOFOLLOW, + ) + } != 0 + { + return Err(format!( + "复核{label}目录项失败:{}", + std::io::Error::last_os_error() + )); + } + let metadata = opened + .metadata() + .map_err(|error| format!("复核{label}句柄失败:{error}"))?; + let expected_type = if directory { + libc::S_IFDIR + } else { + libc::S_IFREG + }; + if stat.st_dev != metadata.dev() + || stat.st_ino != metadata.ino() + || stat.st_mode & libc::S_IFMT != expected_type + { + return Err(format!("{label}在安全打开期间发生替换")); + } + Ok(()) +} + +#[cfg(unix)] +fn open_agent_db_directory(root: &Path, create: bool) -> Result, String> { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + if create { + fs::create_dir_all(root) + .map_err(|error| format!("创建 Agent DB 项目目录失败:{}: {error}", root.display()))?; + } + let root_name = std::ffi::CString::new(root.as_os_str().as_bytes()) + .map_err(|_| "Agent DB 项目目录包含 NUL".to_string())?; + // SAFETY: root_name is NUL terminated and a successful fd is transferred to File once. + let root_fd = unsafe { + libc::open( + root_name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if root_fd < 0 { + let error = std::io::Error::last_os_error(); + if !create && error.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } + return Err(format!( + "安全打开 Agent DB 项目目录失败:{}: {error}", + root.display() + )); + } + // SAFETY: root_fd is owned and transferred exactly once. + let root_directory = unsafe { File::from_raw_fd(root_fd) }; + if !root_directory + .metadata() + .map_err(|error| format!("复核 Agent DB 项目目录失败:{error}"))? + .is_dir() + { + return Err("Agent DB 项目路径必须是目录".to_string()); + } + verify_unix_agent_db_root(root, &root_directory)?; + + let agent_name = c".agent"; + let flags = libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC; + // SAFETY: root_directory and agent_name remain valid for openat. + let mut agent_fd = + unsafe { libc::openat(root_directory.as_raw_fd(), agent_name.as_ptr(), flags) }; + let mut created_agent_directory = false; + if agent_fd < 0 + && create + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ENOENT) + { + // SAFETY: mkdirat receives a stable directory fd and fixed relative component. + if unsafe { libc::mkdirat(root_directory.as_raw_fd(), agent_name.as_ptr(), 0o700) } == 0 { + created_agent_directory = true; + } else { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::EEXIST) { + return Err(format!("创建项目 .agent 目录失败:{error}")); + } + } + if created_agent_directory { + root_directory + .sync_all() + .map_err(|error| format!("同步项目 .agent 目录项失败:{error}"))?; + } + // SAFETY: same stable parent and fixed component as above. + agent_fd = unsafe { libc::openat(root_directory.as_raw_fd(), agent_name.as_ptr(), flags) }; + } + if agent_fd < 0 { + let error = std::io::Error::last_os_error(); + if !create && error.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } + return Err(format!( + "项目 .agent 目录必须是普通目录且不能是符号链接:{}: {error}", + root.join(".agent").display() + )); + } + // SAFETY: agent_fd is owned and transferred exactly once. + let agent_directory = unsafe { File::from_raw_fd(agent_fd) }; + if !agent_directory + .metadata() + .map_err(|error| format!("复核项目 .agent 目录失败:{error}"))? + .is_dir() + { + return Err("项目 .agent 路径必须是普通目录".to_string()); + } + verify_unix_agent_db_entry( + &root_directory, + agent_name, + &agent_directory, + true, + "项目 .agent 目录", + )?; + Ok(Some(AgentDbDirectory { + root_path: root.to_path_buf(), + root_directory, + agent_directory, + path: root.join(".agent"), + })) +} + +#[cfg(unix)] +fn verify_agent_db_directory_current(directory: &AgentDbDirectory) -> Result<(), String> { + verify_unix_agent_db_root(&directory.root_path, &directory.root_directory)?; + verify_unix_agent_db_entry( + &directory.root_directory, + c".agent", + &directory.agent_directory, + true, + "项目 .agent 目录", + ) +} + +#[cfg(unix)] +fn open_agent_db_storage( + directory: AgentDbDirectory, + writable: bool, + create: bool, +) -> Result, String> { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::fs::MetadataExt; + + verify_agent_db_directory_current(&directory)?; + let name = c"agent.db"; + let flags = if writable { + libc::O_RDWR + } else { + libc::O_RDONLY + } | libc::O_NOFOLLOW + | libc::O_CLOEXEC; + // SAFETY: the stable .agent directory and fixed component remain valid for openat. + let mut fd = unsafe { + libc::openat( + directory.agent_directory.as_raw_fd(), + name.as_ptr(), + flags, + 0o600, + ) + }; + let mut created = false; + if fd < 0 && create && std::io::Error::last_os_error().raw_os_error() == Some(libc::ENOENT) { + // SAFETY: the stable directory fd and fixed name remain valid for exclusive creation. + fd = unsafe { + libc::openat( + directory.agent_directory.as_raw_fd(), + name.as_ptr(), + flags | libc::O_CREAT | libc::O_EXCL, + 0o600, + ) + }; + if fd >= 0 { + created = true; + } else if std::io::Error::last_os_error().raw_os_error() == Some(libc::EEXIST) { + // SAFETY: a concurrent creator won; reopen the same fixed entry without O_CREAT. + fd = unsafe { + libc::openat( + directory.agent_directory.as_raw_fd(), + name.as_ptr(), + flags, + 0o600, + ) + }; + } + } + if fd < 0 { + let error = std::io::Error::last_os_error(); + if !create && error.kind() == std::io::ErrorKind::NotFound { + return Ok(None); + } + return Err(format!( + "Agent 本地索引必须是无硬链接普通文件且不能是符号链接:{}: {error}", + directory.path.join("agent.db").display() + )); + } + // SAFETY: fd is owned and transferred exactly once. + let file = unsafe { File::from_raw_fd(fd) }; + let metadata = file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引句柄元数据失败:{error}"))?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(format!( + "Agent 本地索引必须是无硬链接普通文件且不能是符号链接:{}", + directory.path.join("agent.db").display() + )); + } + acquire_unix_agent_db_file_lock(&file, &directory.path.join("agent.db"))?; + verify_unix_agent_db_entry( + &directory.agent_directory, + name, + &file, + false, + "Agent 本地索引", + )?; + verify_agent_db_directory_current(&directory)?; + Ok(Some(AgentDbStorage { + file, + path: directory.path.join("agent.db"), + root_path: directory.root_path, + root_directory: directory.root_directory, + agent_directory: directory.agent_directory, + created, + })) +} + +#[cfg(unix)] +fn verify_agent_db_storage_current(storage: &AgentDbStorage) -> Result<(), String> { + use std::os::unix::fs::MetadataExt; + + verify_unix_agent_db_root(&storage.root_path, &storage.root_directory)?; + verify_unix_agent_db_entry( + &storage.root_directory, + c".agent", + &storage.agent_directory, + true, + "项目 .agent 目录", + )?; + verify_unix_agent_db_entry( + &storage.agent_directory, + c"agent.db", + &storage.file, + false, + "Agent 本地索引", + )?; + let metadata = storage + .file + .metadata() + .map_err(|error| format!("复核 Agent 本地索引句柄失败:{error}"))?; + if metadata.nlink() != 1 { + return Err("Agent 本地索引在写入期间出现硬链接".to_string()); + } + Ok(()) +} + +#[cfg(unix)] +fn acquire_unix_agent_db_file_lock(file: &File, path: &Path) -> Result<(), String> { + use std::os::fd::AsRawFd; + + for attempt in 0..100 { + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::WouldBlock { + return Err(format!( + "获取 Agent 本地索引句柄锁失败:{}: {error}", + path.display() + )); + } + if attempt < 99 { + thread::sleep(Duration::from_millis(10)); + } + } + Err(format!("获取 Agent 本地索引句柄锁超时:{}", path.display())) +} + +#[cfg(windows)] +fn validate_windows_agent_db_directory_handle(file: &File, label: &str) -> Result<(), String> { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + let metadata = file + .metadata() + .map_err(|error| format!("读取{label}句柄元数据失败:{error}"))?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label}必须是普通目录且不能是 Windows junction/reparse point" + )); + } + Ok(()) +} + +#[cfg(windows)] +fn windows_agent_db_file_identity(file: &File) -> Result<(u32, u64), String> { + use std::ffi::c_void; + use std::os::windows::io::AsRawHandle; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + #[repr(C)] + struct ByHandleFileInformation { + file_attributes: u32, + creation_time: FileTime, + last_access_time: FileTime, + last_write_time: FileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandle( + file: *mut c_void, + information: *mut ByHandleFileInformation, + ) -> i32; + } + + // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. + let mut information = unsafe { std::mem::zeroed::() }; + // SAFETY: file owns a live handle and information is a valid output pointer. + if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 { + return Err(format!( + "读取 Windows 文件句柄身份失败:{}", + std::io::Error::last_os_error() + )); + } + Ok(( + information.volume_serial_number, + (u64::from(information.file_index_high) << 32) | u64::from(information.file_index_low), + )) +} + +#[cfg(windows)] +fn open_windows_agent_db_root(root: &Path, writable: bool) -> std::io::Result { + use std::os::windows::fs::OpenOptionsExt; + + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + + fs::OpenOptions::new() + .read(true) + .write(writable) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | PROJECT_FILE_FLAG_OPEN_REPARSE_POINT) + .open(root) +} + +#[cfg(windows)] +fn verify_windows_agent_db_root_current(root: &Path, opened: &File) -> Result<(), String> { + let current = open_windows_agent_db_root(root, false) + .map_err(|error| format!("复核 Agent DB 项目目录失败:{}: {error}", root.display()))?; + validate_windows_agent_db_directory_handle(¤t, "Agent DB 项目目录")?; + if windows_agent_db_file_identity(¤t)? != windows_agent_db_file_identity(opened)? { + return Err("Agent DB 项目目录在安全打开期间发生替换".to_string()); + } + Ok(()) +} + +#[cfg(windows)] +fn nt_open_windows_agent_db_relative( + parent: &File, + name: &str, + directory: bool, + writable: bool, + create: bool, +) -> std::io::Result { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; + + type Handle = *mut c_void; + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, + } + #[repr(C)] + struct ObjectAttributes { + length: u32, + root_directory: Handle, + object_name: *mut UnicodeString, + attributes: u32, + security_descriptor: *mut c_void, + security_quality_of_service: *mut c_void, + } + #[repr(C)] + struct IoStatusBlock { + status: isize, + information: usize, + } + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtCreateFile( + file_handle: *mut Handle, + desired_access: u32, + object_attributes: *mut ObjectAttributes, + io_status_block: *mut IoStatusBlock, + allocation_size: *mut i64, + file_attributes: u32, + share_access: u32, + create_disposition: u32, + create_options: u32, + ea_buffer: *mut c_void, + ea_length: u32, + ) -> i32; + fn RtlNtStatusToDosError(status: i32) -> u32; + } + + const OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_OPEN: u32 = 0x0000_0001; + const FILE_OPEN_IF: u32 = 0x0000_0003; + const FILE_DIRECTORY_FILE: u32 = 0x0000_0001; + const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020; + const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; + const FILE_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080; + const GENERIC_READ: u32 = 0x8000_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + const SYNCHRONIZE: u32 = 0x0010_0000; + + let mut wide_name = std::ffi::OsStr::new(name).encode_wide().collect::>(); + let byte_length = wide_name + .len() + .checked_mul(2) + .and_then(|length| u16::try_from(length).ok()) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "relative name too long") + })?; + let mut unicode_name = UnicodeString { + length: byte_length, + maximum_length: byte_length, + buffer: wide_name.as_mut_ptr(), + }; + let mut attributes = ObjectAttributes { + length: std::mem::size_of::() as u32, + root_directory: parent.as_raw_handle().cast(), + object_name: &mut unicode_name, + attributes: OBJ_CASE_INSENSITIVE, + security_descriptor: std::ptr::null_mut(), + security_quality_of_service: std::ptr::null_mut(), + }; + let mut io_status = IoStatusBlock { + status: 0, + information: 0, + }; + let mut handle = std::ptr::null_mut(); + let desired_access = GENERIC_READ | SYNCHRONIZE | if writable { GENERIC_WRITE } else { 0 }; + let create_options = if directory { + FILE_DIRECTORY_FILE + } else { + FILE_NON_DIRECTORY_FILE + } | FILE_SYNCHRONOUS_IO_NONALERT + | FILE_OPEN_REPARSE_POINT; + // SAFETY: all NT structures and buffers remain alive for the call; handle is an output. + let status = unsafe { + NtCreateFile( + &mut handle, + desired_access, + &mut attributes, + &mut io_status, + std::ptr::null_mut(), + FILE_ATTRIBUTE_NORMAL, + if directory { + FILE_SHARE_READ | FILE_SHARE_WRITE + } else { + 0 + }, + if create { FILE_OPEN_IF } else { FILE_OPEN }, + create_options, + std::ptr::null_mut(), + 0, + ) + }; + if status < 0 || handle.is_null() { + // SAFETY: conversion accepts any NTSTATUS and returns the corresponding Win32 code. + let code = unsafe { RtlNtStatusToDosError(status) }; + return Err(std::io::Error::from_raw_os_error(code as i32)); + } + // SAFETY: NtCreateFile returned an owned kernel handle transferred exactly once to File. + Ok(unsafe { File::from_raw_handle(handle.cast()) }) +} + +#[cfg(windows)] +fn open_agent_db_directory(root: &Path, create: bool) -> Result, String> { + if create { + fs::create_dir_all(root) + .map_err(|error| format!("创建 Agent DB 项目目录失败:{}: {error}", root.display()))?; + } + let root_directory = match open_windows_agent_db_root(root, create) { + Ok(file) => file, + Err(error) if !create && error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "安全打开 Agent DB 项目目录失败:{}: {error}", + root.display() + )); + } + }; + validate_windows_agent_db_directory_handle(&root_directory, "Agent DB 项目目录")?; + verify_windows_agent_db_root_current(root, &root_directory)?; + let agent_directory = match nt_open_windows_agent_db_relative( + &root_directory, + ".agent", + true, + create, + create, + ) { + Ok(file) => file, + Err(error) if !create && error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "项目 .agent 目录必须是普通目录且不能是 Windows junction/reparse point:{}: {error}", + root.join(".agent").display() + )); + } + }; + validate_windows_agent_db_directory_handle(&agent_directory, "项目 .agent 目录")?; + Ok(Some(AgentDbDirectory { + root_path: root.to_path_buf(), + root_directory, + agent_directory, + path: root.join(".agent"), + })) +} + +#[cfg(windows)] +fn verify_agent_db_directory_current(directory: &AgentDbDirectory) -> Result<(), String> { + verify_windows_agent_db_root_current(&directory.root_path, &directory.root_directory)?; + let current = + nt_open_windows_agent_db_relative(&directory.root_directory, ".agent", true, false, false) + .map_err(|error| format!("复核项目 .agent 目录失败:{error}"))?; + validate_windows_agent_db_directory_handle(¤t, "项目 .agent 目录")?; + if windows_agent_db_file_identity(¤t)? + != windows_agent_db_file_identity(&directory.agent_directory)? + { + return Err("项目 .agent 目录在安全打开期间发生替换".to_string()); + } + Ok(()) +} + +#[cfg(windows)] +fn open_agent_db_storage( + directory: AgentDbDirectory, + writable: bool, + create: bool, +) -> Result, String> { + verify_agent_db_directory_current(&directory)?; + let file = { + let mut opened = None; + for attempt in 0..100 { + match nt_open_windows_agent_db_relative( + &directory.agent_directory, + "agent.db", + false, + writable, + create, + ) { + Ok(file) => { + opened = Some(file); + break; + } + Err(error) if !create && error.kind() == std::io::ErrorKind::NotFound => { + return Ok(None); + } + Err(error) + if attempt < 99 + && matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) => + { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => { + return Err(format!( + "Agent 本地索引必须是无硬链接普通文件且不能是 Windows reparse point:{}: {error}", + directory.path.join("agent.db").display() + )); + } + } + } + opened.ok_or_else(|| { + format!( + "获取 Agent 本地索引句柄锁超时:{}", + directory.path.join("agent.db").display() + ) + })? + }; + if !file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引句柄元数据失败:{error}"))? + .is_file() + { + return Err("Agent 本地索引必须是普通文件".to_string()); + } + validate_windows_regular_file_handle(&file, "Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + Ok(Some(AgentDbStorage { + file, + path: directory.path.join("agent.db"), + root_path: directory.root_path, + root_directory: directory.root_directory, + agent_directory: directory.agent_directory, + })) +} + +#[cfg(windows)] +fn verify_agent_db_storage_current(storage: &AgentDbStorage) -> Result<(), String> { + verify_windows_agent_db_root_current(&storage.root_path, &storage.root_directory)?; + let current_agent = + nt_open_windows_agent_db_relative(&storage.root_directory, ".agent", true, false, false) + .map_err(|error| format!("复核项目 .agent 目录失败:{error}"))?; + validate_windows_agent_db_directory_handle(¤t_agent, "项目 .agent 目录")?; + if windows_agent_db_file_identity(¤t_agent)? + != windows_agent_db_file_identity(&storage.agent_directory)? + { + return Err("项目 .agent 目录在 Agent DB 写入期间发生替换".to_string()); + } + validate_windows_regular_file_handle(&storage.file, "Agent 本地索引") +} + +#[cfg(not(any(unix, windows)))] +fn open_agent_db_directory( + _root: &Path, + _create: bool, +) -> Result, String> { + Err("当前平台不支持安全打开 Agent 本地索引".to_string()) +} + +#[cfg(not(any(unix, windows)))] +fn verify_agent_db_directory_current(_directory: &AgentDbDirectory) -> Result<(), String> { + Err("当前平台不支持安全复核 Agent 本地索引目录".to_string()) +} + +#[cfg(not(any(unix, windows)))] +fn open_agent_db_storage( + _directory: AgentDbDirectory, + _writable: bool, + _create: bool, +) -> Result, String> { + Err("当前平台不支持安全打开 Agent 本地索引".to_string()) +} + +#[cfg(not(any(unix, windows)))] +fn verify_agent_db_storage_current(_storage: &AgentDbStorage) -> Result<(), String> { + Err("当前平台不支持安全复核 Agent 本地索引".to_string()) } pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) -> Result<(), String> { + if record.get("recordType").and_then(serde_json::Value::as_str) + == Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) + { + return Err("Agent 持久动作回执必须使用幂等终态 receipt 追加入口".to_string()); + } + append_agent_db_record_internal(root, record) +} + +fn append_agent_db_record_internal(root: &Path, record: serde_json::Value) -> Result<(), String> { + let uses_terminal_reserve = agent_db_record_uses_terminal_reserve(&record); let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; let line = serialize_agent_db_record(record)?; - append_jsonl_line(&path, &line, "Agent 本地索引") + if uses_terminal_reserve { + append_agent_db_terminal_line_unlocked(&mut storage, &line) + } else { + append_agent_db_line_unlocked(&mut storage, &line) + } +} + +#[cfg(test)] +pub(crate) fn append_agent_db_record_fixture( + root: &Path, + record: serde_json::Value, +) -> Result<(), String> { + append_agent_db_record_internal(root, record) +} + +fn agent_db_record_uses_terminal_reserve(record: &serde_json::Value) -> bool { + let has_action_id = record + .get("actionId") + .and_then(serde_json::Value::as_str) + .is_some_and(|action_id| !action_id.trim().is_empty()); + if !has_action_id { + return false; + } + match record.get("recordType").and_then(serde_json::Value::as_str) { + Some("agent.runtime.tool_observation") => record + .get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(is_terminal_agent_db_action_status), + Some("agent.runtime.tool_action.observed") => record + .get("observationStatus") + .and_then(serde_json::Value::as_str) + .is_some_and(is_terminal_agent_db_action_status), + Some( + "agent.runtime.tool_action.needs_reconciliation" + | "agent.runtime.tool_confirmation.needs_reconciliation", + ) => true, + _ => false, + } +} + +fn is_terminal_agent_db_action_status(status: &str) -> bool { + matches!( + status, + "ok" | "failed" + | "command-failed" + | "verification-failed" + | "blocked" + | "rejected" + | "cancelled" + | "budget-exhausted" + | "needs-reconciliation" + ) +} + +pub(crate) fn append_agent_db_record_if_missing_for_action( + root: &Path, + record_type: &str, + agent_id: &str, + run_id: &str, + action_id: &str, + record: serde_json::Value, +) -> Result { + append_agent_db_record_if_missing_for_action_with_before_lock( + root, + record_type, + agent_id, + run_id, + action_id, + record, + || {}, + ) +} + +pub(crate) fn append_agent_db_record_if_missing_for_action_with_before_lock( + root: &Path, + record_type: &str, + agent_id: &str, + run_id: &str, + action_id: &str, + record: serde_json::Value, + before_lock: F, +) -> Result +where + F: FnOnce(), +{ + validate_agent_db_action_receipt_input(record_type, &record)?; + append_agent_db_record_if_missing_for_action_internal( + root, + record_type, + agent_id, + run_id, + action_id, + record, + before_lock, + ) +} + +pub(crate) fn append_agent_db_terminal_observation_if_missing_for_action( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: &str, + record: serde_json::Value, +) -> Result { + validate_agent_db_terminal_observation_input(&record)?; + append_agent_db_record_if_missing_for_action_internal( + root, + "agent.runtime.tool_observation", + agent_id, + run_id, + action_id, + record, + || {}, + ) +} + +fn append_agent_db_record_if_missing_for_action_internal( + root: &Path, + record_type: &str, + agent_id: &str, + run_id: &str, + action_id: &str, + record: serde_json::Value, + before_lock: F, +) -> Result +where + F: FnOnce(), +{ + let matches_identity = record.get("recordType").and_then(serde_json::Value::as_str) + == Some(record_type) + && record.get("agentId").and_then(serde_json::Value::as_str) == Some(agent_id) + && record.get("runId").and_then(serde_json::Value::as_str) == Some(run_id) + && record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id); + if !matches_identity { + return Err("Agent 本地索引幂等记录身份不匹配".to_string()); + } + + let path = root.join(".agent/agent.db"); + before_lock(); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + let found_existing = validate_agent_db_action_records_unlocked( + &mut storage.file, + &storage.path, + record_type, + agent_id, + run_id, + action_id, + &record, + )?; + if found_existing { + return Ok(false); + } + let line = serialize_agent_db_record(record)?; + append_agent_db_terminal_line_unlocked(&mut storage, &line)?; + Ok(true) +} + +fn validate_agent_db_terminal_observation_input(record: &serde_json::Value) -> Result<(), String> { + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some("agent.runtime.tool_observation") + { + return Err("Agent DB 终态 observation 幂等入口只接受 tool_observation".to_string()); + } + for field in [ + "agentId", + "taskId", + "runId", + "actionId", + "actionFingerprint", + "tool", + "status", + "summary", + ] { + if record + .get(field) + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty()) + { + return Err(format!("Agent DB 终态 observation 缺少合法字段:{field}")); + } + } + let action_id = record["actionId"].as_str().unwrap_or_default(); + if !action_id.strip_prefix("action-").is_some_and(|suffix| { + suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + return Err("Agent DB 终态 observation 的 actionId 无效".to_string()); + } + let fingerprint = record["actionFingerprint"].as_str().unwrap_or_default(); + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("Agent DB 终态 observation 的 actionFingerprint 无效".to_string()); + } + if !record["status"] + .as_str() + .is_some_and(is_terminal_agent_db_action_status) + { + return Err("Agent DB 幂等 observation 必须是终态".to_string()); + } + Ok(()) +} + +fn validate_agent_db_action_receipt_input( + record_type: &str, + record: &serde_json::Value, +) -> Result<(), String> { + if record_type != AGENT_DB_ACTION_RECEIPT_RECORD_TYPE + || record.get("recordType").and_then(serde_json::Value::as_str) + != Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) + { + return Err("Agent DB 幂等动作入口只接受 terminal action receipt".to_string()); + } + for field in [ + "agentId", + "taskId", + "sessionId", + "runId", + "actionId", + "actionFingerprint", + "tool", + "executionMode", + "status", + "summary", + ] { + if record + .get(field) + .and_then(serde_json::Value::as_str) + .is_none_or(|value| value.trim().is_empty()) + { + return Err(format!("Agent 持久动作回执缺少合法字段:{field}")); + } + } + let action_id = record["actionId"].as_str().unwrap_or_default(); + if !action_id.strip_prefix("action-").is_some_and(|suffix| { + suffix.len() == 24 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + return Err("Agent 持久动作回执的 actionId 无效".to_string()); + } + let fingerprint = record["actionFingerprint"].as_str().unwrap_or_default(); + if fingerprint.len() != 64 || !fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("Agent 持久动作回执的 actionFingerprint 无效".to_string()); + } + if !matches!( + record["executionMode"].as_str(), + Some("auto" | "confirmation") + ) { + return Err("Agent 持久动作回执的 executionMode 无效".to_string()); + } + if !record["status"] + .as_str() + .is_some_and(is_terminal_agent_db_action_status) + { + return Err("Agent 持久动作回执只能记录终态 observation".to_string()); + } + if !matches!( + record.get("inputSummary"), + None | Some(serde_json::Value::Null | serde_json::Value::String(_)) + ) || !matches!( + record.get("safeDetail"), + None | Some(serde_json::Value::Null | serde_json::Value::String(_)) + ) || !record + .get("detailUnavailable") + .is_some_and(serde_json::Value::is_boolean) + { + return Err("Agent 持久动作回执的安全详情字段无效".to_string()); + } + Ok(()) +} + +struct AgentDbJsonlLine { + content: Vec, + complete: bool, +} + +fn read_agent_db_jsonl_line_bounded( + reader: &mut R, + path: &Path, +) -> Result, String> { + let mut framed = Vec::new(); + let read_limit = u64::try_from(AGENT_DB_MAX_RECORD_BYTES) + .unwrap_or(u64::MAX) + .saturating_add(2); + let bytes = reader + .take(read_limit) + .read_until(b'\n', &mut framed) + .map_err(|error| format!("读取 Agent 本地索引失败:{}: {error}", path.display()))?; + if bytes == 0 { + return Ok(None); + } + let complete = framed.last() == Some(&b'\n'); + let content_length = framed.len().saturating_sub(usize::from(complete)); + if content_length > AGENT_DB_MAX_RECORD_BYTES { + return Err(format!( + "Agent 本地索引单条记录超过 {} 字节上限:{}", + AGENT_DB_MAX_RECORD_BYTES, + path.display() + )); + } + if complete { + framed.pop(); + } + Ok(Some(AgentDbJsonlLine { + content: framed, + complete, + })) +} + +pub(crate) fn read_agent_db_records_bounded( + root: &Path, + max_bytes: u64, +) -> Result<(Vec, bool), String> { + let path = root.join(".agent/agent.db"); + let Some(directory) = open_agent_db_directory(root, false)? else { + return Ok((Vec::new(), false)); + }; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let Some(mut storage) = open_agent_db_storage(directory, false, false)? else { + return Ok((Vec::new(), false)); + }; + let metadata = storage.file.metadata().map_err(|error| { + format!( + "读取 Agent 本地索引元数据失败:{}: {error}", + storage.path.display() + ) + })?; + let max_bytes = max_bytes.min(AGENT_DB_MAX_BOUNDED_READ_BYTES); + let start = metadata.len().saturating_sub(max_bytes); + let read_start = start.saturating_sub(u64::from(start > 0)); + storage + .file + .seek(SeekFrom::Start(read_start)) + .map_err(|error| { + format!( + "定位 Agent 本地索引失败:{}: {error}", + storage.path.display() + ) + })?; + let mut content = Vec::new(); + storage.file.read_to_end(&mut content).map_err(|error| { + format!( + "读取 Agent 本地索引失败:{}: {error}", + storage.path.display() + ) + })?; + let mut truncated = start > 0; + if start > 0 { + let previous_byte = content.first().copied(); + if previous_byte.is_some() { + content.remove(0); + } + if previous_byte != Some(b'\n') { + if let Some(first_newline) = content.iter().position(|byte| *byte == b'\n') { + content.drain(..=first_newline); + } else { + return Ok((Vec::new(), true)); + } + } + } + let mut records = std::collections::VecDeque::new(); + let mut reader = content.as_slice(); + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, &storage.path)? { + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + if !line.complete { + break; + } + continue; + } + match serde_json::from_slice::(&line.content) { + Ok(record) => { + if records.len() == AGENT_DB_MAX_BOUNDED_RECORDS { + records.pop_front(); + truncated = true; + } + records.push_back(record); + } + Err(_) if !line.complete => { + truncated = true; + } + Err(error) => { + return Err(format!( + "解析 Agent 本地索引失败:{}: {error}", + storage.path.display() + )); + } + } + if !line.complete { + break; + } + } + Ok((records.into_iter().collect(), truncated)) +} + +fn validate_agent_db_action_records_unlocked( + file: &mut File, + path: &Path, + record_type: &str, + agent_id: &str, + run_id: &str, + action_id: &str, + expected: &serde_json::Value, +) -> Result { + let length = file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引元数据失败:{}: {error}", path.display()))? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + path.display() + )); + } + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut record_count = 0usize; + let mut found = false; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { + if !line.complete { + break; + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + record_count = record_count.saturating_add(1); + if record_count > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引超过 {} 条记录扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + let record = serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + if record.get("recordType").and_then(serde_json::Value::as_str) == Some(record_type) + && record.get("agentId").and_then(serde_json::Value::as_str) == Some(agent_id) + && record.get("runId").and_then(serde_json::Value::as_str) == Some(run_id) + && record.get("actionId").and_then(serde_json::Value::as_str) == Some(action_id) + { + if record_type == "agent.runtime.tool_observation" + && record + .get("status") + .and_then(serde_json::Value::as_str) + .is_none_or(|status| !is_terminal_agent_db_action_status(status)) + { + continue; + } + validate_agent_db_action_record_identity(&record, expected)?; + found = true; + } + } + if !found && record_count >= AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引已达到 {} 条记录扫描上限,无法追加 terminal receipt:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + Ok(found) +} + +fn validate_agent_db_action_record_identity( + existing: &serde_json::Value, + expected: &serde_json::Value, +) -> Result<(), String> { + for field in [ + "recordType", + "agentId", + "taskId", + "sessionId", + "runId", + "actionId", + "actionFingerprint", + "tool", + "executionMode", + "status", + "inputSummary", + "summary", + "safeDetail", + "detailUnavailable", + "decision", + ] { + if existing.get(field) != expected.get(field) { + return Err(format!( + "Agent 持久动作回执身份冲突:actionId={} field={field}", + expected + .get("actionId") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + )); + } + } + Ok(()) } static PROJECT_APPEND_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -116,6 +1394,12 @@ struct ProjectAppendGuard<'a> { } impl ProjectAppendLock { + fn lock_process(&self, error_label: &str) -> Result, String> { + self.process_lock + .lock() + .map_err(|_| format!("获取{error_label}进程内锁失败:锁已损坏")) + } + fn lock(&self, error_label: &str) -> Result, String> { let process_guard = self .process_lock @@ -249,11 +1533,205 @@ fn append_jsonl_line_unlocked(path: &Path, line: &str, error_label: &str) -> Res .append(true) .open(path) .map_err(|error| format!("打开{error_label}失败:{}: {error}", path.display()))?; - file.write_all(line.as_bytes()) - .and_then(|_| file.write_all(b"\n")) + let framed = format!("{line}\n"); + file.write_all(framed.as_bytes()) .map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display())) } +fn append_agent_db_line_unlocked(storage: &mut AgentDbStorage, line: &str) -> Result<(), String> { + ensure_agent_db_record_capacity_unlocked( + &mut storage.file, + &storage.path, + AGENT_DB_MAX_SCAN_RECORDS.saturating_sub( + usize::try_from(AGENT_DB_TERMINAL_RESERVE_RECORDS).unwrap_or(usize::MAX), + ), + "普通审计记录软上限", + )?; + append_agent_db_line_with_capacity_unlocked( + storage, + line, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES, + "普通审计追加软上限", + ) +} + +fn append_agent_db_terminal_line_unlocked( + storage: &mut AgentDbStorage, + line: &str, +) -> Result<(), String> { + ensure_agent_db_record_capacity_unlocked( + &mut storage.file, + &storage.path, + AGENT_DB_MAX_SCAN_RECORDS, + "terminal 记录硬上限", + )?; + append_agent_db_line_with_capacity_unlocked( + storage, + line, + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + "terminal receipt/reconciliation 追加硬上限", + ) +} + +fn ensure_agent_db_record_capacity_unlocked( + file: &mut File, + path: &Path, + capacity: usize, + capacity_label: &str, +) -> Result<(), String> { + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut record_count = 0usize; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { + if !line.complete { + break; + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + record_count = record_count.saturating_add(1); + if record_count >= capacity { + return Err(format!( + "Agent 本地索引已达到 {capacity} 条{capacity_label},无法继续追加:{}", + path.display() + )); + } + } + Ok(()) +} + +fn append_agent_db_line_with_capacity_unlocked( + storage: &mut AgentDbStorage, + line: &str, + capacity: u64, + capacity_label: &str, +) -> Result<(), String> { + verify_agent_db_storage_current(storage)?; + let append_start = storage.file.seek(SeekFrom::End(0)).map_err(|error| { + format!( + "定位 Agent 本地索引尾部失败:{}: {error}", + storage.path.display() + ) + })?; + let framed = format!("{line}\n"); + ensure_agent_db_append_capacity( + &storage.path, + append_start, + u64::try_from(framed.len()).unwrap_or(u64::MAX), + capacity, + capacity_label, + )?; + storage + .file + .write_all(framed.as_bytes()) + .and_then(|_| storage.file.flush()) + .and_then(|_| storage.file.sync_data()) + .map_err(|error| { + format!( + "写入 Agent 本地索引失败:{}: {error}", + storage.path.display() + ) + })?; + #[cfg(unix)] + if storage.created { + storage + .agent_directory + .sync_all() + .map_err(|error| format!("同步 Agent 本地索引目录项失败:{error}"))?; + storage.created = false; + } + verify_agent_db_storage_current(storage) +} + +fn ensure_agent_db_append_capacity( + path: &Path, + current_length: u64, + append_length: u64, + capacity: u64, + capacity_label: &str, +) -> Result<(), String> { + if current_length + .checked_add(append_length) + .is_some_and(|next_length| next_length <= capacity) + { + return Ok(()); + } + Err(format!( + "Agent 本地索引将超过 {capacity} 字节{capacity_label}:{};{} 字节是 receipt 幂等扫描硬上限,不是轮转阈值", + path.display(), + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES + )) +} + +fn repair_truncated_jsonl_tail_unlocked( + file: &mut File, + path: &Path, + error_label: &str, +) -> Result<(), String> { + let max_tail_bytes = u64::try_from(AGENT_DB_MAX_RECORD_BYTES).unwrap_or(u64::MAX); + let length = file + .metadata() + .map_err(|error| format!("读取{error_label}元数据失败:{}: {error}", path.display()))? + .len(); + if length == 0 { + return Ok(()); + } + file.seek(SeekFrom::End(-1)) + .map_err(|error| format!("定位{error_label}尾部失败:{}: {error}", path.display()))?; + let mut last_byte = [0u8; 1]; + file.read_exact(&mut last_byte) + .map_err(|error| format!("读取{error_label}尾部失败:{}: {error}", path.display()))?; + if last_byte[0] == b'\n' { + return Ok(()); + } + + let start = length.saturating_sub(max_tail_bytes); + let read_start = start.saturating_sub(u64::from(start > 0)); + file.seek(SeekFrom::Start(read_start)) + .map_err(|error| format!("定位{error_label}恢复窗口失败:{}: {error}", path.display()))?; + let mut tail = Vec::new(); + file.read_to_end(&mut tail) + .map_err(|error| format!("读取{error_label}恢复窗口失败:{}: {error}", path.display()))?; + let previous_byte = if read_start < start && !tail.is_empty() { + Some(tail.remove(0)) + } else { + None + }; + let last_newline = tail.iter().rposition(|byte| *byte == b'\n'); + if start > 0 && previous_byte != Some(b'\n') && last_newline.is_none() { + return Err(format!( + "{error_label}尾部单行超过恢复上限:{}", + path.display() + )); + } + let record_start = last_newline.map(|index| index + 1).unwrap_or(0); + let record = &tail[record_start..]; + if serde_json::from_slice::(record).is_ok() { + file.seek(SeekFrom::End(0)) + .and_then(|_| file.write_all(b"\n")) + .and_then(|_| file.flush()) + .and_then(|_| file.sync_data()) + .map_err(|error| { + format!("补齐{error_label}尾部换行失败:{}: {error}", path.display()) + })?; + return Ok(()); + } + + let complete_length = start + u64::try_from(record_start).unwrap_or(0); + file.set_len(complete_length) + .and_then(|_| file.flush()) + .and_then(|_| file.sync_data()) + .map_err(|error| { + format!( + "截断{error_label}不完整尾记录失败:{}: {error}", + path.display() + ) + }) +} + pub(crate) fn append_jsonl_line(path: &Path, line: &str, error_label: &str) -> Result<(), String> { let append_lock = project_append_lock_for(path)?; let _append_guard = append_lock.lock(error_label)?; @@ -261,39 +1739,32 @@ pub(crate) fn append_jsonl_line(path: &Path, line: &str, error_label: &str) -> R } fn agent_db_has_conversation_message_audit_unlocked( + file: &mut File, path: &Path, agent_id: Option<&str>, session_id: Option<&str>, message_id: &str, ) -> Result { - let file = match File::open(path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(format!( - "读取 Agent 本地索引失败:{}: {error}", - path.display() - )); + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut line_index = 0usize; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { + line_index = line_index.saturating_add(1); + if !line.complete { + break; } - }; - for (line_index, line) in BufReader::new(file).lines().enumerate() { - let line = line.map_err(|error| { - format!( - "读取 Agent 本地索引失败:{}:{}: {error}", - path.display(), - line_index + 1 - ) - })?; - if line.trim().is_empty() { + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { continue; } - let record = serde_json::from_str::(&line).map_err(|error| { - format!( - "解析 Agent 本地索引失败:{}:{}: {error}", - path.display(), - line_index + 1 - ) - })?; + let record = + serde_json::from_slice::(&line.content).map_err(|error| { + format!( + "解析 Agent 本地索引失败:{}:{}: {error}", + path.display(), + line_index + ) + })?; if record.get("recordType").and_then(serde_json::Value::as_str) == Some("conversation.message") && record.get("messageId").and_then(serde_json::Value::as_str) == Some(message_id) @@ -314,13 +1785,27 @@ fn ensure_conversation_message_audit_at( audit_record: serde_json::Value, ) -> Result<(), String> { let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; let append_lock = project_append_lock_for(&path)?; let _append_guard = append_lock.lock("Agent 本地索引追加写")?; - if agent_db_has_conversation_message_audit_unlocked(&path, agent_id, session_id, message_id)? { + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + if agent_db_has_conversation_message_audit_unlocked( + &mut storage.file, + &storage.path, + agent_id, + session_id, + message_id, + )? { return Ok(()); } let line = serialize_agent_db_record(audit_record)?; - append_jsonl_line_unlocked(&path, &line, "Agent 本地索引") + append_agent_db_line_unlocked(&mut storage, &line) } #[derive(Debug)] @@ -4648,6 +6133,769 @@ pub(crate) fn unix_millis() -> u128 { .unwrap_or(0) } +#[cfg(test)] +mod agent_db_security_tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_ROOT_NONCE: AtomicU64 = AtomicU64::new(1); + const TEST_ACTION_ID: &str = "action-000000000000000000000001"; + + fn unique_agent_db_test_root(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-agent-db-{test_name}-{}-{}", + std::process::id(), + TEST_ROOT_NONCE.fetch_add(1, Ordering::Relaxed) + )) + } + + fn action_record(summary: &str) -> serde_json::Value { + serde_json::json!({ + "recordType": AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "agentId": "implementation-engineer", + "taskId": "implementation-engineer", + "sessionId": "session-1", + "runId": "run-1", + "actionId": TEST_ACTION_ID, + "actionFingerprint": "1".repeat(64), + "tool": "file.list", + "executionMode": "auto", + "status": "ok", + "inputSummary": serde_json::Value::Null, + "summary": summary, + "safeDetail": serde_json::Value::Null, + "detailUnavailable": true, + }) + } + + fn write_agent_db_records(root: &Path, records: &[serde_json::Value]) { + let agent_dir = root.join(".agent"); + fs::create_dir_all(&agent_dir).expect("create agent db fixture directory"); + let content = records + .iter() + .map(|record| serde_json::to_string(record).expect("serialize agent db fixture")) + .collect::>() + .join("\n"); + fs::write(agent_dir.join("agent.db"), format!("{content}\n")) + .expect("write agent db fixture"); + } + + fn write_sparse_agent_db_with_complete_tail(root: &Path, length: u64) { + let agent_dir = root.join(".agent"); + fs::create_dir_all(&agent_dir).expect("create sparse agent db fixture directory"); + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(agent_dir.join("agent.db")) + .expect("open sparse agent db fixture"); + file.set_len(length).expect("size sparse agent db fixture"); + if length > 0 { + file.seek(SeekFrom::Start(length - 1)) + .expect("seek sparse agent db fixture tail"); + file.write_all(b"\n") + .expect("complete sparse agent db fixture tail"); + } + } + + fn assert_agent_db_read_and_append_rejected(root: &Path) { + let read_error = read_agent_db_records_bounded(root, 1024) + .expect_err("linked Agent DB read must be rejected"); + assert!(!read_error.is_empty()); + let append_error = + append_agent_db_record(root, serde_json::json!({"recordType": "security.test"})) + .expect_err("linked Agent DB append must be rejected"); + assert!(!append_error.is_empty()); + let action_error = append_agent_db_record_if_missing_for_action( + root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + action_record("expected"), + ) + .expect_err("linked Agent DB action scan must be rejected"); + assert!(!action_error.is_empty()); + let conversation_error = ensure_conversation_message_audit_at( + root, + Some("implementation-engineer"), + Some("session-1"), + "message-1", + serde_json::json!({ + "recordType": "conversation.message", + "agentId": "implementation-engineer", + "sessionId": "session-1", + "messageId": "message-1", + }), + ) + .expect_err("linked Agent DB conversation scan must be rejected"); + assert!(!conversation_error.is_empty()); + } + + #[cfg(unix)] + #[test] + fn agent_db_operations_reject_symlinks_dangling_symlinks_and_hardlinks() { + use std::os::unix::fs::symlink; + + let root = unique_agent_db_test_root("unsafe-storage"); + let outside = unique_agent_db_test_root("unsafe-storage-outside"); + fs::create_dir_all(root.join(".agent")).expect("create project agent directory"); + fs::create_dir_all(&outside).expect("create outside directory"); + + let outside_db = outside.join("outside-agent.db"); + fs::write(&outside_db, b"{\"recordType\":\"outside\"}\n").expect("write outside agent db"); + let original = fs::read(&outside_db).expect("read original outside agent db"); + symlink(&outside_db, root.join(".agent/agent.db")).expect("create agent db symlink"); + assert_agent_db_read_and_append_rejected(&root); + assert_eq!( + fs::read(&outside_db).expect("read outside agent db"), + original + ); + + fs::remove_file(root.join(".agent/agent.db")).expect("remove agent db symlink"); + let dangling_target = outside.join("missing-agent.db"); + symlink(&dangling_target, root.join(".agent/agent.db")) + .expect("create dangling agent db symlink"); + assert_agent_db_read_and_append_rejected(&root); + assert!(!dangling_target.exists()); + + fs::remove_file(root.join(".agent/agent.db")).expect("remove dangling agent db symlink"); + fs::hard_link(&outside_db, root.join(".agent/agent.db")).expect("create agent db hardlink"); + assert_agent_db_read_and_append_rejected(&root); + assert_eq!( + fs::read(&outside_db).expect("read hardlink target"), + original + ); + + fs::remove_dir_all(&root).ok(); + fs::remove_dir_all(&outside).ok(); + } + + #[cfg(unix)] + #[test] + fn agent_db_operations_reject_symlinked_agent_directory() { + use std::os::unix::fs::symlink; + + let root = unique_agent_db_test_root("symlinked-agent-directory"); + let outside = unique_agent_db_test_root("symlinked-agent-directory-outside"); + fs::create_dir_all(&root).expect("create project root"); + fs::create_dir_all(&outside).expect("create outside agent directory"); + let outside_db = outside.join("agent.db"); + fs::write(&outside_db, b"{\"recordType\":\"outside\"}\n").expect("write outside agent db"); + let original = fs::read(&outside_db).expect("read original outside agent db"); + symlink(&outside, root.join(".agent")).expect("create symlinked agent directory"); + + assert_agent_db_read_and_append_rejected(&root); + assert_eq!( + fs::read(&outside_db).expect("read outside agent db"), + original + ); + + fs::remove_dir_all(&root).ok(); + fs::remove_dir_all(&outside).ok(); + } + + #[cfg(unix)] + #[test] + fn project_init_rejects_symlinked_agent_directory_before_writing_outside() { + use std::os::unix::fs::symlink; + + let root = unique_agent_db_test_root("init-symlinked-agent-directory"); + let outside = unique_agent_db_test_root("init-symlinked-agent-directory-outside"); + fs::create_dir_all(&root).expect("create project root"); + fs::create_dir_all(&outside).expect("create outside agent directory"); + symlink(&outside, root.join(".agent")).expect("create symlinked agent directory"); + + let error = init_local_game_project_at(&root, "project-1", "安全初始化测试") + .expect_err("project init must reject a symlinked agent directory"); + assert!(error.contains(".agent"), "{error}"); + assert_eq!( + fs::read_dir(&outside) + .expect("read outside agent directory") + .count(), + 0, + "project init must not create logs, runtime, or Agent DB outside the project" + ); + + fs::remove_dir_all(&root).ok(); + fs::remove_dir_all(&outside).ok(); + } + + #[test] + fn action_append_rejects_a_conflicting_second_record_with_the_same_key() { + let root = unique_agent_db_test_root("duplicate-action-conflict"); + let expected = action_record("expected"); + let conflicting = action_record("conflicting"); + write_agent_db_records(&root, &[expected.clone(), conflicting]); + + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + expected, + ) + .expect_err("a conflicting second action record must be rejected"); + assert!(error.contains("field=summary"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn generic_append_rejects_action_receipts() { + let root = unique_agent_db_test_root("generic-receipt-rejected"); + let error = append_agent_db_record(&root, action_record("must use idempotent append")) + .expect_err("generic append must not bypass receipt identity checks"); + assert!(error.contains("幂等终态 receipt"), "{error}"); + assert!(!root.join(".agent/agent.db").exists()); + } + + #[test] + fn idempotent_action_append_accepts_only_terminal_receipts() { + let root = unique_agent_db_test_root("receipt-input-contract"); + let mut non_terminal = action_record("running receipt"); + non_terminal["status"] = serde_json::Value::String("running".to_string()); + let non_terminal_error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + non_terminal, + ) + .expect_err("non-terminal receipt must be rejected before opening storage"); + assert!( + non_terminal_error.contains("只能记录终态"), + "{non_terminal_error}" + ); + + let mut wrong_type = action_record("wrong type"); + wrong_type["recordType"] = + serde_json::Value::String("agent.runtime.tool_observation".to_string()); + let wrong_type_error = append_agent_db_record_if_missing_for_action( + &root, + "agent.runtime.tool_observation", + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + wrong_type, + ) + .expect_err("the idempotent receipt path must reject arbitrary record types"); + assert!(wrong_type_error.contains("只接受 terminal action receipt")); + assert!(!root.join(".agent/agent.db").exists()); + } + + #[test] + fn action_append_locked_full_scan_rejects_a_conflicting_second_record() { + let root = unique_agent_db_test_root("full-scan-action-conflict"); + fs::create_dir_all(root.join(".agent")).expect("create agent directory"); + let expected = action_record("expected"); + append_agent_db_record_internal(&root, expected.clone()) + .expect("append exact first receipt fixture"); + let thread_root = root.clone(); + let thread_expected = expected.clone(); + let (scanned_sender, scanned_receiver) = std::sync::mpsc::channel(); + let (release_sender, release_receiver) = std::sync::mpsc::channel(); + let append = std::thread::spawn(move || { + append_agent_db_record_if_missing_for_action_with_before_lock( + &thread_root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + thread_expected, + || { + scanned_sender.send(()).expect("signal before-lock hook"); + release_receiver.recv().expect("release locked full scan"); + }, + ) + }); + scanned_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("before-lock hook reached"); + append_agent_db_record_internal(&root, action_record("conflicting")) + .expect("append concurrent conflicting record"); + release_sender.send(()).expect("release locked full scan"); + + let error = append + .join() + .expect("join action append") + .expect_err("locked full-scan conflict must be rejected"); + assert!(error.contains("field=summary"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn terminal_observation_append_ignores_non_terminal_stage_and_retries_idempotently() { + let root = unique_agent_db_test_root("terminal-observation-transition"); + let waiting = serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": "implementation-engineer", + "taskId": "implementation-engineer", + "runId": "run-1", + "actionId": TEST_ACTION_ID, + "actionFingerprint": "1".repeat(64), + "tool": "file.read", + "status": "waiting-for-confirmation", + "summary": "等待确认", + }); + append_agent_db_record(&root, waiting).expect("append non-terminal observation stage"); + let terminal = serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": "implementation-engineer", + "taskId": "implementation-engineer", + "runId": "run-1", + "actionId": TEST_ACTION_ID, + "actionFingerprint": "1".repeat(64), + "tool": "file.read", + "status": "ok", + "summary": "读取完成", + "decision": "approved", + }); + assert!(append_agent_db_terminal_observation_if_missing_for_action( + &root, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + terminal.clone(), + ) + .expect("append terminal observation after waiting stage")); + assert!(!append_agent_db_terminal_observation_if_missing_for_action( + &root, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + terminal.clone(), + ) + .expect("retry identical terminal observation")); + + let mut conflicting = terminal; + conflicting["summary"] = serde_json::Value::String("冲突结果".to_string()); + let error = append_agent_db_terminal_observation_if_missing_for_action( + &root, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + conflicting, + ) + .expect_err("conflicting terminal observation must fail closed"); + assert!(error.contains("field=summary"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn ordinary_agent_db_append_stops_before_the_action_receipt_scan_limit() { + let root = unique_agent_db_test_root("ordinary-append-capacity"); + fs::create_dir_all(root.join(".agent")).expect("create agent directory"); + fs::write( + root.join(".agent/agent.db"), + b"{}\n".repeat( + AGENT_DB_MAX_SCAN_RECORDS + - usize::try_from(AGENT_DB_TERMINAL_RESERVE_RECORDS) + .expect("reserve count fits usize"), + ), + ) + .expect("write ordinary record-capacity fixture"); + + let error = append_agent_db_record( + &root, + serde_json::json!({"recordType": "test.ordinary-capacity"}), + ) + .expect_err("ordinary audit growth must stop before the receipt scan limit"); + assert!(error.contains("记录软上限"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn terminal_action_observation_statuses_use_the_reserved_capacity() { + for status in ["ok", "command-failed", "verification-failed"] { + assert!(agent_db_record_uses_terminal_reserve(&serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "actionId": TEST_ACTION_ID, + "status": status, + }))); + } + } + + #[test] + fn non_terminal_action_observation_cannot_consume_the_reserved_capacity() { + assert!(!agent_db_record_uses_terminal_reserve(&serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "actionId": TEST_ACTION_ID, + "status": "waiting-for-confirmation", + }))); + } + + #[test] + fn agent_db_capacity_reserves_terminal_receipt_space_without_rotation() { + let path = Path::new("agent.db"); + ensure_agent_db_append_capacity( + path, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES - 1, + 1, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES, + "普通审计追加软上限", + ) + .expect("ordinary audit may end exactly at its soft limit"); + let ordinary_error = ensure_agent_db_append_capacity( + path, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES, + 1, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES, + "普通审计追加软上限", + ) + .expect_err("ordinary audit must not consume the terminal receipt reserve"); + assert!(ordinary_error.contains("软上限"), "{ordinary_error}"); + assert!(ordinary_error.contains("不是轮转阈值"), "{ordinary_error}"); + + ensure_agent_db_append_capacity( + path, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES, + AGENT_DB_TERMINAL_RESERVE_BYTES, + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + "terminal receipt 追加硬上限", + ) + .expect("terminal receipts may consume the complete reserved capacity"); + let receipt_error = ensure_agent_db_append_capacity( + path, + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + 1, + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + "terminal receipt 追加硬上限", + ) + .expect_err("terminal receipt must not exceed the scan hard limit"); + assert!(receipt_error.contains("硬上限"), "{receipt_error}"); + } + + #[test] + fn ordinary_append_soft_limit_preserves_action_receipt_record_slots() { + assert_eq!( + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_TERMINAL_RESERVE_BYTES, + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES + ); + assert_eq!( + AGENT_DB_MAX_SCAN_RECORDS + - usize::try_from(AGENT_DB_TERMINAL_RESERVE_RECORDS) + .expect("reserve count fits usize"), + 999_936 + ); + } + + #[test] + fn generic_terminal_append_rejects_the_full_scan_record_count_boundary() { + let root = unique_agent_db_test_root("terminal-record-count-limit"); + fs::create_dir_all(root.join(".agent")).expect("create agent directory"); + fs::write( + root.join(".agent/agent.db"), + b"{}\n".repeat(AGENT_DB_MAX_SCAN_RECORDS), + ) + .expect("write terminal record-count fixture"); + + let error = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": "implementation-engineer", + "runId": "run-1", + "actionId": TEST_ACTION_ID, + "status": "ok", + }), + ) + .expect_err("generic terminal records must not exceed the scan record limit"); + assert!(error.contains("terminal 记录硬上限"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn action_receipt_append_rejects_the_full_scan_record_count_boundary() { + let root = unique_agent_db_test_root("preloaded-record-count-limit"); + let agent_dir = root.join(".agent"); + fs::create_dir_all(&agent_dir).expect("create record-count fixture directory"); + fs::write( + agent_dir.join("agent.db"), + b"{}\n".repeat(AGENT_DB_MAX_SCAN_RECORDS), + ) + .expect("write record-count fixture"); + + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + action_record("expected"), + ) + .expect_err("the scan record hard limit must leave no room for another receipt"); + assert!(error.contains("记录扫描上限"), "{error}"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn action_receipt_scan_rejects_a_preloaded_file_over_the_hard_limit() { + let root = unique_agent_db_test_root("preloaded-over-scan-limit"); + write_sparse_agent_db_with_complete_tail(&root, AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES + 1); + + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + action_record("expected"), + ) + .expect_err("a preloaded Agent DB over the receipt scan limit must fail closed"); + assert!(error.contains("扫描上限"), "{error}"); + assert_eq!( + fs::metadata(root.join(".agent/agent.db")) + .expect("read oversized agent db metadata") + .len(), + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES + 1 + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn bounded_agent_db_read_keeps_record_at_exact_line_boundary() { + let root = unique_agent_db_test_root("exact-tail-boundary"); + let records = [ + serde_json::json!({"recordType": "test", "sequence": 1}), + serde_json::json!({"recordType": "test", "sequence": 2}), + serde_json::json!({"recordType": "test", "sequence": 3}), + ]; + write_agent_db_records(&root, &records); + let path = root.join(".agent/agent.db"); + let content = fs::read(&path).expect("read agent db fixture bytes"); + let first_line_end = content + .iter() + .position(|byte| *byte == b'\n') + .expect("first line delimiter") + + 1; + let max_bytes = u64::try_from(content.len() - first_line_end).expect("tail length"); + + let (tail, truncated) = + read_agent_db_records_bounded(&root, max_bytes).expect("read bounded agent db tail"); + assert!(truncated); + assert_eq!( + tail.iter() + .map(|record| record["sequence"].as_u64().expect("sequence")) + .collect::>(), + vec![2, 3] + ); + + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn agent_db_operations_reject_a_symlinked_project_root() { + use std::os::unix::fs::symlink; + + let root = unique_agent_db_test_root("symlinked-project-root"); + let outside = unique_agent_db_test_root("symlinked-project-root-outside"); + write_agent_db_records(&outside, &[serde_json::json!({"recordType": "outside"})]); + let original = fs::read(outside.join(".agent/agent.db")).expect("read outside agent db"); + symlink(&outside, &root).expect("create symlinked project root"); + + assert_agent_db_read_and_append_rejected(&root); + assert_eq!( + fs::read(outside.join(".agent/agent.db")).expect("read unchanged outside agent db"), + original + ); + + fs::remove_file(root).ok(); + fs::remove_dir_all(outside).ok(); + } + + #[cfg(unix)] + #[test] + fn action_append_reopens_storage_after_agent_directory_replacement() { + let root = unique_agent_db_test_root("replaced-agent-directory-before-lock"); + let expected = action_record("expected"); + append_agent_db_record_internal(&root, expected.clone()) + .expect("append initial action receipt fixture"); + let replacement_root = root.clone(); + + let appended = append_agent_db_record_if_missing_for_action_with_before_lock( + &root, + AGENT_DB_ACTION_RECEIPT_RECORD_TYPE, + "implementation-engineer", + "run-1", + TEST_ACTION_ID, + expected, + move || { + fs::rename( + replacement_root.join(".agent"), + replacement_root.join(".agent-old"), + ) + .expect("move original agent directory"); + fs::create_dir(replacement_root.join(".agent")) + .expect("create replacement agent directory"); + let replacement = serde_json::json!({ + "recordType": "replacement", + "padding": "x".repeat(4096), + }); + fs::write( + replacement_root.join(".agent/agent.db"), + format!( + "{}\n", + serde_json::to_string(&replacement).expect("serialize replacement") + ), + ) + .expect("write replacement agent db"); + }, + ) + .expect("append against replacement agent db"); + + assert!( + appended, + "stale found_existing must not suppress the append" + ); + let current = + fs::read_to_string(root.join(".agent/agent.db")).expect("read replacement agent db"); + assert!(current.contains(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE)); + + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn agent_db_append_fails_if_agent_directory_is_replaced_after_open() { + let root = unique_agent_db_test_root("replaced-agent-directory-after-open"); + let directory = open_agent_db_directory(&root, true) + .expect("open agent db directory") + .expect("agent db directory"); + let mut storage = open_agent_db_storage(directory, true, true) + .expect("open agent db storage") + .expect("agent db storage"); + fs::rename(root.join(".agent"), root.join(".agent-old")) + .expect("move opened agent directory"); + fs::create_dir(root.join(".agent")).expect("create replacement agent directory"); + fs::write( + root.join(".agent/agent.db"), + b"{\"recordType\":\"replacement\"}\n", + ) + .expect("write replacement agent db"); + + let line = serialize_agent_db_record(serde_json::json!({"recordType": "test"})) + .expect("serialize append record"); + let error = append_agent_db_line_unlocked(&mut storage, &line) + .expect_err("post-write path identity check must fail"); + assert!(error.contains("替换"), "{error}"); + assert_eq!( + fs::read(root.join(".agent/agent.db")).expect("read current agent db"), + b"{\"recordType\":\"replacement\"}\n" + ); + assert_eq!( + fs::read(root.join(".agent-old/agent.db")).expect("read moved agent db"), + b"" + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn bounded_agent_db_read_keeps_complete_records_before_a_partial_utf8_tail() { + let root = unique_agent_db_test_root("partial-utf8-tail"); + let agent_directory = root.join(".agent"); + fs::create_dir_all(&agent_directory).expect("create agent directory"); + let mut content = b"{\"recordType\":\"complete\"}\n{\"content\":\"".to_vec(); + content.extend_from_slice(&"中".as_bytes()[..2]); + fs::write(agent_directory.join("agent.db"), content).expect("write partial utf8 tail"); + + let (records, truncated) = read_agent_db_records_bounded(&root, u64::MAX) + .expect("read complete records before partial utf8"); + assert!(truncated); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["recordType"], "complete"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn bounded_agent_db_read_caps_retained_json_objects() { + let root = unique_agent_db_test_root("bounded-record-count"); + fs::create_dir_all(root.join(".agent")).expect("create agent directory"); + let record_count = AGENT_DB_MAX_BOUNDED_RECORDS + 128; + let content = (0..record_count) + .map(|index| format!("{{\"index\":{index}}}\n")) + .collect::(); + fs::write(root.join(".agent/agent.db"), content).expect("write many small records"); + + let (records, truncated) = + read_agent_db_records_bounded(&root, u64::MAX).expect("read bounded record count"); + assert!(truncated); + assert_eq!(records.len(), AGENT_DB_MAX_BOUNDED_RECORDS); + assert_eq!( + records.first().and_then(|record| record["index"].as_u64()), + Some(128) + ); + assert_eq!( + records.last().and_then(|record| record["index"].as_u64()), + Some(u64::try_from(record_count - 1).expect("record count fits u64")) + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn agent_db_tail_repair_handles_an_exact_limit_record() { + fn exact_limit_record(valid: bool) -> Vec { + let framing_bytes = b"{\"payload\":\"\"}".len(); + let mut record = format!( + "{{\"payload\":\"{}\"}}", + "x".repeat(AGENT_DB_MAX_RECORD_BYTES - framing_bytes) + ) + .into_bytes(); + assert_eq!(record.len(), AGENT_DB_MAX_RECORD_BYTES); + if !valid { + record[0] = b'!'; + } + record + } + + let valid_root = unique_agent_db_test_root("exact-limit-valid-tail"); + fs::create_dir_all(valid_root.join(".agent")).expect("create valid agent directory"); + let mut valid_content = b"{\"recordType\":\"before\"}\n".to_vec(); + valid_content.extend_from_slice(&exact_limit_record(true)); + fs::write(valid_root.join(".agent/agent.db"), valid_content) + .expect("write valid exact-limit tail"); + append_agent_db_record(&valid_root, serde_json::json!({"recordType": "after"})) + .expect("repair valid exact-limit tail"); + let (records, truncated) = read_agent_db_records_bounded(&valid_root, u64::MAX) + .expect("read repaired valid exact-limit tail"); + assert!(!truncated); + assert_eq!(records.len(), 3); + assert_eq!( + records[1]["payload"].as_str().map(str::len), + Some(AGENT_DB_MAX_RECORD_BYTES - b"{\"payload\":\"\"}".len()) + ); + + let invalid_root = unique_agent_db_test_root("exact-limit-invalid-tail"); + fs::create_dir_all(invalid_root.join(".agent")).expect("create invalid agent directory"); + let mut invalid_content = b"{\"recordType\":\"before\"}\n".to_vec(); + invalid_content.extend_from_slice(&exact_limit_record(false)); + fs::write(invalid_root.join(".agent/agent.db"), invalid_content) + .expect("write invalid exact-limit tail"); + append_agent_db_record(&invalid_root, serde_json::json!({"recordType": "after"})) + .expect("truncate invalid exact-limit tail"); + let repaired = fs::read_to_string(invalid_root.join(".agent/agent.db")) + .expect("read repaired invalid tail"); + assert!(!repaired.contains("payload")); + assert!(repaired.contains("\"recordType\":\"before\"")); + assert!(repaired.contains("\"recordType\":\"after\"")); + + fs::remove_dir_all(valid_root).ok(); + fs::remove_dir_all(invalid_root).ok(); + } +} + #[cfg(test)] mod checkpoint_security_tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs index b6f6b227f..715cd6688 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/repository_context.rs @@ -1917,12 +1917,12 @@ fn unquoted_secret_value_end(value: &str, mut index: usize) -> usize { index } -fn redact_absolute_path_tokens(value: &str) -> String { +pub(crate) fn redact_absolute_path_tokens(value: &str) -> String { let bytes = value.as_bytes(); let mut output = String::with_capacity(value.len()); let mut index = 0; while index < bytes.len() { - if starts_absolute_path(bytes, index) { + if starts_absolute_path(bytes, index) || starts_file_uri(bytes, index) { output.push_str(""); index = consume_path_token(bytes, index); continue; @@ -1944,7 +1944,7 @@ fn starts_absolute_path(bytes: &[u8], index: usize) -> bool { return false; } if bytes[index] == b'/' { - return bytes.get(index + 1).is_none_or(|next| *next != b'/'); + return bytes.get(index + 1) != Some(&b'/') || starts_forward_slash_unc_path(bytes, index); } if bytes[index] == b'\\' && bytes.get(index + 1) == Some(&b'\\') { return true; @@ -1956,6 +1956,42 @@ fn starts_absolute_path(bytes: &[u8], index: usize) -> bool { .is_some_and(|separator| matches!(separator, b'/' | b'\\')) } +fn starts_forward_slash_unc_path(bytes: &[u8], index: usize) -> bool { + let server_start = index + 2; + let token_end = consume_path_token(bytes, server_start); + let Some(server_end) = bytes[server_start..token_end] + .iter() + .position(|byte| *byte == b'/') + .map(|offset| server_start + offset) + else { + return false; + }; + let share_start = server_end + 1; + server_end > server_start && share_start < token_end && bytes.get(share_start) != Some(&b'/') +} + +fn starts_file_uri(bytes: &[u8], index: usize) -> bool { + const FILE_URI_PREFIX: &[u8] = b"file:"; + + let boundary = index == 0 || is_path_boundary(bytes[index - 1]); + if !boundary { + return false; + } + let Some(prefix_end) = index.checked_add(FILE_URI_PREFIX.len()) else { + return false; + }; + if !bytes + .get(index..prefix_end) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(FILE_URI_PREFIX)) + { + return false; + } + let token_end = consume_path_token(bytes, prefix_end); + bytes.get(prefix_end) == Some(&b'/') + && token_end > prefix_end + && bytes.get(prefix_end..token_end) != Some(b"//") +} + fn is_path_boundary(byte: u8) -> bool { byte.is_ascii_whitespace() || matches!( @@ -2386,6 +2422,47 @@ sketch-color = green } } + #[test] + fn redacts_file_uri_tokens_without_redacting_relative_text() { + assert_eq!( + redact_absolute_path_tokens( + "open file:///home/alice/project, FILE:///C:/Users/Alice/project; \ + file://server/share/project file:///home/alice/My%20Project \ + file:///%68ome/alice%2Fproject" + ), + "open , ; \ + " + ); + assert_eq!( + redact_absolute_path_tokens( + "profile homeward docs/file.txt file:notes.txt file:// file:///" + ), + "profile homeward docs/file.txt file:notes.txt file:// " + ); + } + + #[test] + fn redacts_cross_platform_absolute_paths_without_redacting_urls() { + assert_eq!( + redact_absolute_path_tokens(concat!( + r#"open //server/share/private, \\server\share\private; "#, + "file:/home/user/private file:/C:/Users/Alice/private ", + r#"C:/Users/Alice/private C:\Users\Alice\private"# + )), + "open , ; \ + \ + " + ); + assert_eq!( + redact_absolute_path_tokens( + "keep docs/private file:notes.txt // not-a-path /// docs \ + https://example.test/private http://localhost:3000/private" + ), + "keep docs/private file:notes.txt // not-a-path /// docs \ + https://example.test/private http://localhost:3000/private" + ); + } + #[test] fn secret_changes_and_git_status_do_not_change_the_fingerprint() { let repository = TestDirectory::new("stable-fingerprint"); 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 a6e4715fe..bf73844cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -67,6 +67,18 @@ fn wait_for_agent_runtime_idle(root: &Path, agent_id: &str) -> AgentRuntimeState runtime } +fn wait_to_acquire_agent_runtime_lock(root: &Path, agent_id: &str) -> AgentRuntimeTaskLock { + for _ in 0..250 { + if let Some(runtime_lock) = try_acquire_game_creator_agent_runtime_task_lock(root, agent_id) + .expect("inspect Agent Runtime lock while waiting") + { + return runtime_lock; + } + std::thread::sleep(Duration::from_millis(20)); + } + panic!("Agent Runtime lock was not released for {agent_id}"); +} + fn wait_for_agent_runtime_confirmation(root: &Path, agent_id: &str) -> AgentRuntimeState { let mut runtime = read_game_creator_agent_runtime_at(root, agent_id) .expect("read runtime while waiting for confirmation") @@ -617,9 +629,7 @@ fn agent_runtime_system_lock_allows_only_one_owner() { .is_none() ); drop(first); - let replacement = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") - .expect("replacement lock") - .expect("replacement owner acquires released lock"); + let replacement = wait_to_acquire_agent_runtime_lock(&root, "design-director"); assert!(root .join(".agent/runtime/locks/design-director.lock") @@ -1419,6 +1429,49 @@ fn spawn_mock_llm_server_responses_with_capture( base_url } +fn spawn_mock_llm_transport_failures_then_response( + failure_count: usize, + response_content: String, + request_sender: Option>, +) -> String { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock transient llm bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + for _ in 0..failure_count { + let (mut stream, _) = listener.accept().expect("mock transient llm accept"); + let request_text = read_mock_http_request(&mut stream); + if let Some(sender) = request_sender.as_ref() { + let _ = sender.send(request_text); + } + drop(stream); + } + let (mut stream, _) = listener.accept().expect("mock recovered llm accept"); + let request_text = read_mock_http_request(&mut stream); + if let Some(sender) = request_sender.as_ref() { + let _ = sender.send(request_text); + } + let body = serde_json::json!({ + "id": "chatcmpl_transient_recovered", + "model": "mock-game-model", + "choices": [{ + "message": { "content": response_content }, + "finish_reason": "stop" + }], + "usage": { "prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33 } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("mock recovered llm response"); + }); + base_url +} + fn spawn_mock_llm_raw_responses_with_capture( response_bodies: Vec, request_sender: Option>, @@ -2684,6 +2737,105 @@ async fn background_agent_runtime_tool_action_respects_agent_policy() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_persists_receipts_for_rejected_actions() { + for (tool, expected_status, deny_file_read) in [ + ("file.read", "blocked", true), + ("runtime.unknown", "rejected", false), + ] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let mut agent_policies = BTreeMap::new(); + if deny_file_read { + agent_policies.insert( + "design-director".to_string(), + ProjectAgentPermissionPolicy { + denied_commands: vec!["file.read".to_string()], + confirm_commands: Vec::new(), + }, + ); + } + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies, + }, + ) + .expect("write rejected action policy"); + let plan = serde_json::json!({ + "thinkingSummary": "验证被拒绝动作仍形成持久回执", + "plan": ["请求工具", "根据拒绝结果收束"], + "actions": [{ + "tool": tool, + "reason": "验证终态回执", + "input": if tool == "file.read" { + serde_json::json!({ "path": "game/index.html" }) + } else { + serde_json::json!({}) + } + }], + "response": "" + }) + .to_string(); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + plan, + final_tool_plan_response("被拒绝动作已记录,可以安全继续。"), + ], + Some(sender), + ); + let config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let run_id = format!("design-rejected-receipt-{}", tool.replace('.', "-")); + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证拒绝动作回执", + &run_id, + ) + .expect("start rejected action task"); + receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial rejected action plan"); + let followup = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("followup after rejected action"); + assert!(followup.contains(tool)); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "completed"); + let receipts = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["runId"] == run_id + && record["tool"] == tool + }) + .collect::>(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["status"], expected_status); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + &root, + "design-director", + &run_id + )); + drop(config_guard); + fs::remove_dir_all(root).ok(); + } +} + #[tokio::test] async fn role_agent_runtime_turn_persists_session_events_and_index() { let root = unique_project_path(); @@ -3142,7 +3294,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read runtime") .state; - for _ in 0..250 { + for _ in 0..750 { if runtime.status == "failed" { break; } @@ -5816,8 +5968,20 @@ async fn isolated_agents_with_same_template_run_independently_and_join_once() { final_tool_plan_response("feature-a 子任务已完成"), final_tool_plan_response("feature-b 子任务已完成"), ]); - let parent_base_url = - spawn_mock_llm_server_responses(vec![final_tool_plan_response("已统一回收两个隔离子任务")]); + let parent_base_url = spawn_mock_llm_server_responses(vec![ + serde_json::json!({ + "thinkingSummary": "隔离子任务已经就绪,需要由同一父 run 认领 all-join", + "plan": ["读取并认领 all-join", "汇总隔离子任务结果"], + "actions": [{ + "tool": "agent.run_status", + "reason": "读取并认领当前父 run 的 ready all-join", + "input": { "scope": "all" } + }], + "response": "" + }) + .to_string(), + final_tool_plan_response("已统一回收两个隔离子任务"), + ]); let _config_guard = write_test_local_config(format!( r#"{{ "agentLlm": {{ @@ -5836,7 +6000,7 @@ async fn isolated_agents_with_same_template_run_independently_and_join_once() { }} }}"# )); - start_game_creator_agent_runtime_task_at( + let mut parent_state = start_game_creator_agent_runtime_task_at( &root, "design-director", "拆分两个隔离子任务", @@ -5892,24 +6056,46 @@ async fn isolated_agents_with_same_template_run_independently_and_join_once() { assert_eq!(runtime.source, AGENT_RUNTIME_ISOLATED_CHILD_SOURCE); assert_eq!(runtime.session_id, instance.session_id); } + parent_state.status = "running".to_string(); + parent_state.phase = "waiting-for-isolated-join".to_string(); + parent_state.current_action = "等待动态隔离 Agent 的 all-join".to_string(); + parent_state.waiting_on = "隔离子 Agent 完成".to_string(); + parent_state.next_step = "唤醒同一父 run 并认领 all-join".to_string(); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append waiting parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist waiting parent state"); drop(parent_lock); - spawn_next_game_creator_agent_background_task_drain(&root, "design-director") - .expect("drain isolated join after parent lane release"); + let joins = reconcile_all_isolated_groups_at(&root).expect("reconcile ready all-join"); + assert_eq!(joins.len(), 1); + let join = joins[0].clone(); + dispatch_isolated_agent_join_at(&root, join.clone()).expect("wake waiting parent run"); let parent = wait_for_agent_runtime_idle(&root, "design-director"); - assert_eq!(parent.source, AGENT_RUNTIME_ISOLATED_JOIN_SOURCE); + assert_eq!(parent.source, "agent-background-task"); + assert_eq!(parent.run_id, "isolated-parent-run"); assert_eq!( parent.last_response.as_deref(), Some("已统一回收两个隔离子任务") ); - let join_run_ids = read_game_creator_agent_runtime_at(&root, "design-director") + let parent_runtime = read_game_creator_agent_runtime_at(&root, "design-director") .expect("parent runtime") - .recent_tasks - .into_iter() - .filter(|task| task.source == AGENT_RUNTIME_ISOLATED_JOIN_SOURCE) - .map(|task| task.run_id) - .collect::>(); - assert_eq!(join_run_ids.len(), 1); + .recent_tasks; + assert!(parent_runtime.iter().all(|task| { + task.run_id != join.join_run_id && task.source != AGENT_RUNTIME_ISOLATED_JOIN_SOURCE + })); + let delivery = read_isolated_join_delivery_at(&root, &join) + .expect("read claimed parent-wake delivery") + .expect("claimed parent-wake delivery exists"); + assert_eq!( + delivery.status, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent + ); + assert_eq!( + delivery.delivery_target, + IsolatedAgentJoinDeliveryTarget::ParentWake + ); + assert!(delivery.queued_run_id.is_none()); let results_dir = root.join(".agent/runtime/isolated-agents/results"); assert_eq!( fs::read_dir(results_dir) @@ -5920,13 +6106,15 @@ async fn isolated_agents_with_same_template_run_independently_and_join_once() { ); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("agent.runtime.agent.spawn_isolated")); - assert!(agent_db.contains("agent.runtime.agent.isolated_join.dispatched")); + assert!(agent_db.contains("agent.runtime.agent.isolated_join.parent_wake.dispatched")); + assert!(agent_db.contains("agent.runtime.agent.isolated_join.claimed_by_parent")); + assert!(!agent_db.contains("\"recordType\":\"agent.runtime.agent.isolated_join.dispatched\"")); fs::remove_dir_all(root).ok(); } #[test] -fn isolated_join_claimed_by_active_parent_cancels_queued_continuation_once() { +fn isolated_join_claimed_by_active_parent_never_creates_continuation() { use platform_agent::game_creation::{ GameCreationIsolatedAgentArtifact, GameCreationIsolatedAgentChildResult, GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentEvidence, @@ -5993,7 +6181,17 @@ fn isolated_join_claimed_by_active_parent_cancels_queued_continuation_once() { let join = record_isolated_child_result_at(&root, &result) .expect("record isolated result") .expect("all join ready"); - dispatch_isolated_agent_join_at(&root, join.clone()).expect("queue isolated join"); + dispatch_isolated_agent_join_at(&root, join.clone()) + .expect("active parent keeps ready all-join in place"); + assert!(read_isolated_join_delivery_at(&root, &join) + .expect("read undispatched active-parent join") + .is_none()); + assert!(read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read active parent runtime before claim") + .recent_tasks + .iter() + .all(|task| task.run_id != group.join_run_id + && task.source != AGENT_RUNTIME_ISOLATED_JOIN_SOURCE)); for _ in 0..2 { let status = observe_agent_runtime_run_status( @@ -6030,21 +6228,18 @@ fn isolated_join_claimed_by_active_parent_cancels_queued_continuation_once() { delivery.status, IsolatedAgentJoinDeliveryStatus::ClaimedByParent ); + assert!(delivery.queued_run_id.is_none()); let join_tasks = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read parent runtime") .recent_tasks .into_iter() .filter(|task| task.source == AGENT_RUNTIME_ISOLATED_JOIN_SOURCE) .collect::>(); - assert_eq!(join_tasks.len(), 1); - assert_eq!(join_tasks[0].run_id, group.join_run_id); - assert_eq!(join_tasks[0].status, "cancelled"); + assert!(join_tasks.is_empty()); finish_game_creator_agent_runtime_turn_at(&root, parent_state, "已直接整合隔离结果") .expect("finish original parent run"); drop(parent_lock); - spawn_next_game_creator_agent_background_task_drain(&root, "design-director") - .expect("drain after parent claim"); let parent = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read completed parent") .state; @@ -6061,6 +6256,283 @@ fn isolated_join_claimed_by_active_parent_cancels_queued_continuation_once() { fs::remove_dir_all(root).ok(); } +#[test] +fn runtime_v11_waiting_isolated_join_dispatches_one_parent_wake_without_continuation() { + use platform_agent::game_creation::{ + GameCreationIsolatedAgentArtifact, GameCreationIsolatedAgentChildResult, + GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentEvidence, + GameCreationIsolatedAgentJoinMode, GameCreationIsolatedAgentResultStatus, + GameCreationIsolatedAgentSpawnRequest, + }; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "all-join parent wake 幂等测试") + .expect("project init"); + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "等待隔离子任务后继续同一父 run", + "runtime-v11-parent-wake-run", + "agent-background-task", + "等待隔离子任务", + vec!["等待 all-join".to_string()], + ) + .expect("start parent state"); + parent_state.status = "running".to_string(); + parent_state.phase = "waiting-for-isolated-join".to_string(); + parent_state.current_action = "等待动态隔离 Agent 的 all-join".to_string(); + parent_state.waiting_on = "隔离子 Agent 完成".to_string(); + parent_state.next_step = "唤醒同一父 run 并认领 all-join".to_string(); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append waiting parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist waiting parent state"); + + let request = GameCreationIsolatedAgentSpawnRequest { + children: vec![GameCreationIsolatedAgentChildSpec { + template_agent_id: "code-prototype".to_string(), + task: "完成独立功能".to_string(), + acceptance_criteria: vec!["产物存在".to_string()], + expected_artifacts: vec!["game/feature/output.txt".to_string()], + write_scopes: vec!["game/feature/**".to_string()], + }], + join_mode: GameCreationIsolatedAgentJoinMode::All, + }; + let group = create_or_read_isolated_group_at( + &root, + "design-director", + &parent_state.run_id, + &parent_state.session_id, + "runtime-v11-parent-wake-action", + &request, + ) + .expect("create isolated group"); + let instance = resolve_isolated_agent_instance_at(&root, &group.instance_ids[0]) + .expect("resolve isolated child"); + let join = record_isolated_child_result_at( + &root, + &GameCreationIsolatedAgentChildResult { + delegation_id: instance.delegation_id, + instance_id: instance.instance_id, + template_agent_id: instance.template_agent_id, + run_id: instance.run_id, + status: GameCreationIsolatedAgentResultStatus::Completed, + summary: "独立功能已完成".to_string(), + artifacts: vec![GameCreationIsolatedAgentArtifact { + path: "game/feature/output.txt".to_string(), + sha256: "a".repeat(64), + }], + evidence: vec![GameCreationIsolatedAgentEvidence { + kind: "project.verify".to_string(), + summary: "验证通过".to_string(), + path: None, + sha256: None, + }], + verified_revision: Some(1), + error: None, + }, + ) + .expect("record isolated result") + .expect("all-join ready"); + let parent_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director") + .expect("acquire parent lane") + .expect("parent lane available"); + + dispatch_isolated_agent_join_at(&root, join.clone()).expect("dispatch parent wake"); + dispatch_isolated_agent_join_at(&root, join.clone()).expect("repeat parent wake dispatch"); + for recovered in reconcile_all_isolated_groups_at(&root).expect("reconcile ready all-join") { + dispatch_isolated_agent_join_at(&root, recovered) + .expect("reconciled parent wake remains idempotent"); + } + + let delivery = read_isolated_join_delivery_at(&root, &join) + .expect("read parent-wake delivery") + .expect("parent-wake delivery exists"); + assert_eq!(delivery.status, IsolatedAgentJoinDeliveryStatus::Dispatched); + assert_eq!( + delivery.delivery_target, + IsolatedAgentJoinDeliveryTarget::ParentWake + ); + assert!(delivery.queued_run_id.is_none()); + assert!(delivery.claimed_by_action_id.is_none()); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read waiting parent runtime"); + assert!(runtime.recent_tasks.iter().all(|task| { + task.run_id != group.join_run_id && task.source != AGENT_RUNTIME_ISOLATED_JOIN_SOURCE + })); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert_eq!( + agent_db + .matches("agent.runtime.agent.isolated_join.parent_wake.dispatched") + .count(), + 1 + ); + assert!(!agent_db.contains("\"recordType\":\"agent.runtime.agent.isolated_join.dispatched\"")); + + parent_state.phase = "planning".to_string(); + parent_state.current_action = "同一父 run 已恢复规划".to_string(); + parent_state.waiting_on.clear(); + parent_state.next_step = "认领 ready all-join".to_string(); + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append resumed parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist resumed parent state"); + let task_path = PathBuf::from( + read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read resumed parent runtime") + .task_path, + ); + let task_log_before_repeat = fs::read(&task_path).expect("read task log before repeat"); + drop(parent_lock); + + dispatch_isolated_agent_join_at(&root, join.clone()) + .expect("dispatch after parent resumed planning"); + for recovered in reconcile_all_isolated_groups_at(&root).expect("repeat all-join reconcile") { + dispatch_isolated_agent_join_at(&root, recovered) + .expect("reconciled delivery after parent resumed planning"); + } + assert_eq!( + fs::read(&task_path).expect("read task log after repeat"), + task_log_before_repeat + ); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read stable resumed parent runtime"); + assert_eq!(runtime.state.run_id, parent_state.run_id); + assert_eq!(runtime.state.status, "running"); + assert_eq!(runtime.state.phase, "planning"); + assert!(runtime.recent_tasks.iter().all(|task| { + task.run_id != group.join_run_id && task.source != AGENT_RUNTIME_ISOLATED_JOIN_SOURCE + })); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn runtime_v11_waiting_isolated_join_resume_preserves_loop_and_child() { + use platform_agent::game_creation::{ + GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentJoinMode, + GameCreationIsolatedAgentSpawnRequest, + }; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "all-join 等待恢复测试").expect("project init"); + let task = "等待尚未完成的隔离子任务"; + let mut parent_state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + task, + "runtime-v11-waiting-resume-run", + "agent-background-task", + "等待隔离子任务", + vec!["等待 all-join".to_string()], + ) + .expect("start waiting parent state"); + parent_state.status = "running".to_string(); + parent_state.phase = "waiting-for-isolated-join".to_string(); + parent_state.current_action = "等待动态隔离 Agent 的 all-join".to_string(); + parent_state.waiting_on = "隔离子 Agent 完成".to_string(); + parent_state.next_step = "保持等待,不消耗 LLM 循环预算".to_string(); + parent_state.loop_iteration = 4; + parent_state.max_loop_iterations = 18; + append_game_creator_agent_runtime_task(&root, &parent_state) + .expect("append waiting parent task"); + write_game_creator_agent_runtime_state(&root, &parent_state) + .expect("persist waiting parent state"); + let context_bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &parent_state, + task, + &AgentRuntimeToolPlan::default(), + &[], + 4, + &AgentRuntimeContextWindowTracker::default(), + ) + .expect("build waiting context bundle"); + write_game_creator_agent_runtime_context_bundle(&root, &context_bundle) + .expect("persist waiting context bundle"); + + let request = GameCreationIsolatedAgentSpawnRequest { + children: vec![GameCreationIsolatedAgentChildSpec { + template_agent_id: "code-prototype".to_string(), + task: "继续执行长时间隔离子任务".to_string(), + acceptance_criteria: vec!["产物存在".to_string()], + expected_artifacts: vec!["game/long-task/output.txt".to_string()], + write_scopes: vec!["game/long-task/**".to_string()], + }], + join_mode: GameCreationIsolatedAgentJoinMode::All, + }; + let group = create_or_read_isolated_group_at( + &root, + "design-director", + &parent_state.run_id, + &parent_state.session_id, + "runtime-v11-waiting-resume-action", + &request, + ) + .expect("create waiting isolated group"); + let child = resolve_isolated_agent_instance_at(&root, &group.instance_ids[0]) + .expect("resolve waiting child"); + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read waiting runtime paths"); + let task_path = PathBuf::from(runtime.task_path); + let state_path = PathBuf::from(runtime.session_path); + let context_path = game_creator_agent_runtime_context_bundle_path( + &root, + "design-director", + &parent_state.run_id, + ); + let task_log_before_resume = fs::read(&task_path).expect("read waiting task log"); + let state_before_resume = fs::read(&state_path).expect("read waiting state"); + let context_before_resume = fs::read(&context_path).expect("read waiting context"); + let child_cancellation = root + .join(".agent/runtime/cancel") + .join(&child.instance_id) + .join(format!("{}.json", child.run_id)); + + for _ in 0..3 { + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume waiting parent without running LLM"); + let resumed_parent = resumed + .iter() + .find(|runtime| runtime.state.run_id == parent_state.run_id) + .expect("waiting parent included in resume result"); + assert_eq!(resumed_parent.state.status, "running"); + assert_eq!(resumed_parent.state.phase, "waiting-for-isolated-join"); + assert_eq!(resumed_parent.state.loop_iteration, 4); + assert_eq!(resumed_parent.state.max_loop_iterations, 18); + assert_eq!( + fs::read(&task_path).expect("read stable waiting task log"), + task_log_before_resume + ); + assert_eq!( + fs::read(&state_path).expect("read stable waiting state"), + state_before_resume + ); + assert_eq!( + fs::read(&context_path).expect("read stable waiting context"), + context_before_resume + ); + assert!(!child_cancellation.exists()); + } + + let runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read parent after repeated resume"); + assert_eq!(runtime.state.run_id, parent_state.run_id); + assert_eq!(runtime.state.phase, "waiting-for-isolated-join"); + assert_eq!(runtime.state.loop_iteration, 4); + assert!(runtime.recent_tasks.iter().all(|task| { + task.run_id != group.join_run_id + && task.phase != "budget-exhausted" + && task.status != "cancelled" + })); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(!agent_db.contains("loop-budget-exhausted")); + assert!(!agent_db.contains("agent.runtime.agent.isolated_join.suppressed")); + assert!(!child_cancellation.exists()); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutations() { const DRIFT_COMMAND: &str = r#"node -e "require('fs').writeFileSync('AGENTS.md','drifted rules\\n');process.stdout.write('DRIFTED')""#; @@ -6660,7 +7132,39 @@ fn runtime_v11_closure_started_join_cannot_be_reclaimed_by_parent() { ) .expect("record child result") .expect("join ready"); - dispatch_isolated_agent_join_at(&root, join.clone()).expect("queue stable join continuation"); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: "design-director".to_string(), + task_id: parent_state.task_id.clone(), + session_id: parent_state.session_id.clone(), + run_id: group.join_run_id.clone(), + source: AGENT_RUNTIME_ISOLATED_JOIN_SOURCE.to_string(), + parent_agent_id: None, + parent_run_id: Some(parent_state.run_id.clone()), + delegation_id: Some(group.delegation_group_id.clone()), + task: join.prompt.clone(), + status: "pending".to_string(), + phase: "queued".to_string(), + current_action: "等待执行旧版 join continuation".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }, + ); + let legacy_delivery = write_isolated_join_delivery_at( + &root, + &join, + IsolatedAgentJoinDeliveryStatus::Dispatched, + Some(&group.join_run_id), + None, + ) + .expect("write legacy continuation delivery"); + assert_eq!( + legacy_delivery.delivery_target, + IsolatedAgentJoinDeliveryTarget::Continuation + ); let join_task = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read parent tasks") .recent_tasks @@ -6694,6 +7198,14 @@ fn runtime_v11_closure_started_join_cannot_be_reclaimed_by_parent() { .expect("read join delivery") .expect("join delivery exists"); assert_eq!(delivery.status, IsolatedAgentJoinDeliveryStatus::Dispatched); + assert_eq!( + delivery.delivery_target, + IsolatedAgentJoinDeliveryTarget::Continuation + ); + assert_eq!( + delivery.queued_run_id.as_deref(), + Some(group.join_run_id.as_str()) + ); assert!(delivery.claimed_by_action_id.is_none()); let join_tasks = read_game_creator_agent_runtime_at(&root, "design-director") .expect("read stable parent tasks") @@ -11067,6 +11579,46 @@ fn project_verification_sidecar_failure_blocks_completion() { #[test] fn pending_action_gate_snapshot_blocks_stale_approved_replay_but_not_observed_recovery() { + for tool in [ + "memory.read", + "conversation.read", + "asset.list", + "project.index", + "project.search", + "project.diff", + "git.inspect", + "file.list", + "file.read", + "task.list", + "agent.action_history", + ] { + assert!( + !agent_runtime_tool_requires_pending_revision_gate(tool), + "{tool} should remain revision-neutral" + ); + assert!( + agent_runtime_tool_requires_repository_context_fingerprint_gate(tool), + "{tool} must retain the repository fingerprint gate" + ); + } + for tool in [ + "memory.write", + "project.patchset", + "file.write", + "command.exec", + "project.verify", + "preview.validate", + "agent.run_status", + ] { + assert!( + agent_runtime_tool_requires_pending_revision_gate(tool), + "{tool} must retain the pending revision gate" + ); + assert!( + agent_runtime_tool_requires_repository_context_fingerprint_gate(tool), + "{tool} must retain the repository fingerprint gate" + ); + } let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "待确认动作验证快照项目").expect("project init"); let mut state = start_game_creator_agent_runtime_task_at( @@ -13436,6 +13988,7 @@ fn agent_runtime_tool_plan_prompt_explains_named_verification_scripts_and_contex assert!(prompt.contains("同一 run")); assert!(prompt.contains("preview.validate")); assert!(prompt.contains("agent.spawn_isolated")); + assert!(prompt.contains("agent.action_history")); } #[tokio::test] @@ -13779,6 +14332,439 @@ async fn background_agent_runtime_resumes_approved_auto_action_once_without_llm_ fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_read_only_action_survives_cross_agent_revision_drift() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write( + root.join("game/revision-read.txt"), + "read-only action observes the latest revision", + ) + .expect("write read-only fixture"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow runtime reads"); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response( + "只读动作在其他 Agent 推进 revision 后仍读取成功。", + )], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "恢复跨 Agent revision 漂移后的只读动作", + "design-read-only-revision-drift-run", + "agent-background-task", + "模拟只读 pending 后其他 Agent 修改项目", + vec!["读取最新文件".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("读取最新项目状态".to_string()), + input: serde_json::json!({ + "path": "game/revision-read.txt", + "startLine": 1, + "maxLines": 20 + }), + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write approved read-only action"); + state.status = "running".to_string(); + state.phase = "action".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); + + advance_project_revision_for_test( + &root, + "art-director", + "art-cross-agent-mutation-run", + "file.write", + ); + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume stale read-only action"); + assert!(resumed.iter().any(|runtime| { + runtime.state.run_id == "design-read-only-revision-drift-run" + && runtime.state.status == "running" + })); + let replan_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("replan after latest read observation"); + assert!(replan_request.contains("read-only action observes the latest revision")); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.error, None); + assert!(runtime.recent_tool_calls.iter().any(|call| { + call.tool == "file.read" && call.status == "ok" && call.action_id.is_some() + })); + assert_eq!( + read_agent_db_records_for_test(&root) + .iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == pending.action_id + }) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_project_snapshot_read_waits_for_project_writer() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write(root.join("game/locked-read.txt"), "before\n").expect("write initial fixture"); + let project_lock = acquire_project_write_lock(&root, "test.snapshot.writer") + .expect("acquire project writer lock"); + let thread_root = root.clone(); + let (started_sender, started_receiver) = mpsc::channel(); + let reader = std::thread::spawn(move || { + started_sender.send(()).expect("signal reader start"); + tauri::async_runtime::block_on(execute_game_creator_agent_runtime_tool_action( + &thread_root, + "design-director", + "snapshot-lock-run", + "读取一致项目快照", + &AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("等待项目写入完成".to_string()), + input: serde_json::json!({ "path": "game/locked-read.txt" }), + }, + )) + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("reader started"); + std::thread::sleep(Duration::from_millis(40)); + fs::write(root.join("game/locked-read.txt"), "after-complete\n") + .expect("write complete fixture while locked"); + drop(project_lock); + let observation = reader.join().expect("join snapshot reader"); + assert_eq!(observation.status, "ok"); + let detail = observation.detail.expect("locked read detail"); + assert!(detail.contains("after-complete")); + assert!(!detail.contains("before")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn project_diff_and_action_history_recheck_policy_after_project_lock_wait() { + for (tool, command_id) in [ + ("project.diff", "project.diff"), + ("agent.action_history", "agent.audit"), + ] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "一致快照策略复核测试") + .expect("project init"); + let input = if tool == "project.diff" { + let checkpoint = create_local_project_checkpoint_at(&root).expect("create checkpoint"); + serde_json::json!({ "checkpointId": checkpoint.checkpoint_id }) + } else { + serde_json::json!({ "limit": 5 }) + }; + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow snapshot tool before wait"); + let run_id = format!("design-{}-policy-after-lock", tool.replace('.', "-")); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "等待一致项目快照后复核权限", + &run_id, + "agent-background-task", + "等待项目锁", + vec!["锁内复核权限".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: tool.to_string(), + reason: Some("验证锁内二次策略复核".to_string()), + input, + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action.clone(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write executing snapshot pending"); + state.status = "running".to_string(); + state.phase = "action".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); + + let project_lock = acquire_project_write_lock(&root, "test.snapshot.policy-writer") + .expect("acquire project writer lock"); + let thread_root = root.clone(); + let thread_state = state.clone(); + let thread_action = action.clone(); + let thread_pending = pending.clone(); + let (started_sender, started_receiver) = mpsc::channel(); + let (finished_sender, finished_receiver) = mpsc::channel(); + let reader = std::thread::spawn(move || { + started_sender.send(()).expect("signal snapshot start"); + let observation = tauri::async_runtime::block_on( + execute_game_creator_agent_runtime_tool_action_with_pending_action( + &thread_root, + "design-director", + &thread_state.run_id, + &thread_state.current_task, + &thread_action, + Some(&thread_pending.action_id), + Some(&thread_pending), + ), + ); + finished_sender + .send(()) + .expect("signal snapshot completion"); + observation + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("snapshot action starts"); + assert!( + finished_receiver + .recv_timeout(Duration::from_millis(80)) + .is_err(), + "{tool} must wait for the project consistency lock" + ); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec![command_id.to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny snapshot tool while it waits"); + drop(project_lock); + + let observation = reader.join().expect("join snapshot action"); + assert_eq!(observation.status, "blocked", "{tool}: {observation:?}"); + assert!( + observation.summary.contains("权限策略"), + "{tool}: {observation:?}" + ); + + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn project_snapshot_rereads_durable_pending_after_project_lock_wait() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "pending sidecar 锁内复核测试") + .expect("project init"); + fs::write(root.join("game/original.txt"), "original durable action") + .expect("write original fixture"); + fs::write( + root.join("game/replacement.txt"), + "replacement durable action", + ) + .expect("write replacement fixture"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "等待项目锁后复核 durable pending", + "design-pending-sidecar-after-lock", + "agent-background-task", + "等待项目锁", + vec!["锁内重读 pending sidecar".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("读取原始动作目标".to_string()), + input: serde_json::json!({ "path": "game/original.txt" }), + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action.clone(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write original pending sidecar"); + state.status = "running".to_string(); + state.phase = "action".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); + + let project_lock = acquire_project_write_lock(&root, "test.pending-sidecar.writer") + .expect("acquire project writer lock"); + let thread_root = root.clone(); + let thread_state = state.clone(); + let thread_action = action.clone(); + let thread_pending = pending.clone(); + let (started_sender, started_receiver) = mpsc::channel(); + let reader = std::thread::spawn(move || { + started_sender.send(()).expect("signal snapshot start"); + tauri::async_runtime::block_on( + execute_game_creator_agent_runtime_tool_action_with_pending_action( + &thread_root, + "design-director", + &thread_state.run_id, + &thread_state.current_task, + &thread_action, + Some(&thread_pending.action_id), + Some(&thread_pending), + ), + ) + }); + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("snapshot action starts"); + std::thread::sleep(Duration::from_millis(40)); + + let replacement_action = AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("读取替换动作目标".to_string()), + input: serde_json::json!({ "path": "game/replacement.txt" }), + }; + let mut replacement = pending_tool_action_for_test( + &root, + &state, + replacement_action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + replacement.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + assert_ne!(replacement.action_id, pending.action_id); + write_game_creator_agent_runtime_pending_tool_action(&root, &replacement) + .expect("replace durable pending sidecar while action waits"); + drop(project_lock); + + let observation = reader.join().expect("join snapshot action"); + assert_eq!( + observation.status, + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("durable pending action"))); + assert!(!observation + .detail + .as_deref() + .unwrap_or_default() + .contains("original durable action")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_run_status_rechecks_revision_inside_project_lock() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "复核 all-join 认领门禁", + "design-run-status-lock-run", + "agent-background-task", + "等待项目锁后重新验证 revision", + vec!["读取 Agent 状态".to_string()], + ) + .expect("start runtime state"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("检查并认领 ready all-join".to_string()), + input: serde_json::json!({ "scope": "self" }), + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action.clone(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write run status pending action"); + state.status = "running".to_string(); + state.phase = "action".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append running task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); + advance_project_revision_for_test( + &root, + "art-director", + "art-run-status-drift-run", + "file.write", + ); + + let observation = tauri::async_runtime::block_on( + execute_game_creator_agent_runtime_tool_action_with_pending_action( + &root, + "design-director", + &state.run_id, + &state.current_task, + &action, + Some(&pending.action_id), + Some(&pending), + ), + ); + assert_eq!( + observation.status, + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("revision 已变化"))); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_resumes_observed_auto_action_without_reexecution() { let root = unique_project_path(); @@ -13895,6 +14881,30 @@ async fn background_agent_runtime_resumes_observed_auto_action_without_reexecuti "memory.write", "ok", ); + let receipts = records + .iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["runId"] == "design-auto-observed-recovery-run" + && record["actionId"] == pending.action_id + }) + .collect::>(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["sessionId"], state.session_id); + assert_eq!(receipts[0]["status"], "ok"); + assert!(resume_game_creator_agent_background_tasks_at(&root) + .expect("second observed recovery scan") + .is_empty()); + assert_eq!( + read_agent_db_records_for_test(&root) + .iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == pending.action_id + }) + .count(), + 1 + ); assert!(!root .join( ".agent/runtime/pending-actions/design-director/design-auto-observed-recovery-run.json" @@ -14337,12 +15347,18 @@ fn background_agent_runtime_reconciliation_without_ledger_still_blocks_recovery( assert_eq!(queued.state.phase, "needs-reconciliation"); assert_eq!(queued.task_queue.pending, 1); - let resumed = resume_game_creator_agent_background_tasks_at(&root) - .expect("resume scan respects ledgerless reconciliation"); - let blocked = resumed - .iter() - .find(|runtime| runtime.state.agent_id == "design-director") - .expect("reconciliation runtime remains visible"); + let mut blocked = None; + for _ in 0..50 { + blocked = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume scan respects ledgerless reconciliation") + .into_iter() + .find(|runtime| runtime.state.agent_id == "design-director"); + if blocked.is_some() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + let blocked = blocked.expect("reconciliation runtime remains visible after lock release"); assert_eq!(blocked.state.run_id, "design-reconciliation-no-ledger-run"); assert_eq!(blocked.state.phase, "needs-reconciliation"); assert_eq!(blocked.task_queue.pending, 1); @@ -17276,6 +18292,57 @@ async fn background_agent_runtime_retries_empty_plan_and_final_responses() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_retries_repeated_transport_failures() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "连续传输失败重试测试").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_transport_failures_then_response( + 4, + final_tool_plan_response("连续传输失败后已恢复。TRANSIENT_RETRY_OK"), + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat", + "stream": false + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证连续 TLS/transport 故障自动恢复", + "design-transient-transport-retry-run", + ) + .expect("start transient retry task"); + + for request_index in 0..5 { + let request = receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_else(|_| panic!("captured transport retry request {}", request_index + 1)); + assert!(request.contains("POST /chat/completions HTTP/1.1")); + } + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime.last_response.as_deref(), + Some("连续传输失败后已恢复。TRANSIENT_RETRY_OK") + ); + assert!(runtime.error.is_none()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_llm_transient_error_classification_matches_retry_contract() { let transient_errors = [ @@ -18345,6 +19412,1985 @@ async fn background_agent_runtime_project_restore_respects_project_policy() { fs::remove_dir_all(root).ok(); } +#[test] +fn agent_runtime_action_receipt_is_idempotent_and_redacts_sensitive_detail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "核对持久动作回执", + "design-action-receipt-run", + "agent-background-task", + "读取动作结果", + vec!["检查动作回执".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("读取源码".to_string()), + input: serde_json::json!({ "path": "game/index.html" }), + }; + let fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let action_id = agent_runtime_tool_action_id(&runtime.run_id, 1, 0, 7, &fingerprint); + let secret = "sk-action-secret"; + let observation = AgentRuntimeToolObservation { + tool: "file.read".to_string(), + status: "ok".to_string(), + summary: "已读取 game/index.html".to_string(), + detail: Some(format!( + "{}\nconst token = '{secret}';", + root.join("game/index.html").display() + )), + }; + + for _ in 0..2 { + append_agent_runtime_action_receipt( + &root, + &runtime, + &action_id, + &fingerprint, + "file.read", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("path=game/index.html · startLine=1 · maxLines=120"), + &observation, + ) + .expect("append idempotent receipt"); + } + let conflict = append_agent_runtime_action_receipt( + &root, + &runtime, + &action_id, + &fingerprint, + "file.read", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("path=game/index.html · startLine=1 · maxLines=120"), + &AgentRuntimeToolObservation { + summary: "冲突摘要不能覆盖既有回执".to_string(), + ..observation.clone() + }, + ) + .expect_err("conflicting receipt identity must fail closed"); + assert!(conflict.contains("身份冲突")); + let message_action = AgentRuntimeToolAction { + tool: "agent.message".to_string(), + reason: Some("发送私密上下文".to_string()), + input: serde_json::json!({ "agentId": "art-director", "content": "omitted" }), + }; + let message_fingerprint = + agent_runtime_tool_action_fingerprint(&message_action, &runtime.current_task); + let message_action_id = + agent_runtime_tool_action_id(&runtime.run_id, 1, 1, 9, &message_fingerprint); + let private_detail = "internal-design-phrase-must-not-enter-receipt"; + append_agent_runtime_action_receipt( + &root, + &runtime, + &message_action_id, + &message_fingerprint, + "agent.message", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("agentId=art-director · contentChars=7"), + &AgentRuntimeToolObservation { + tool: "agent.message".to_string(), + status: "ok".to_string(), + summary: "已发送定向消息".to_string(), + detail: Some(private_detail.to_string()), + }, + ) + .expect("append message receipt without private detail"); + let non_terminal_error = append_agent_runtime_action_receipt( + &root, + &runtime, + "action-aaaaaaaaaaaaaaaaaaaaaaaa", + &"a".repeat(64), + "file.list", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &AgentRuntimeToolObservation { + tool: "file.list".to_string(), + status: "running".to_string(), + summary: "非终态不得写入 receipt".to_string(), + detail: None, + }, + ) + .expect_err("non-terminal receipt must be rejected"); + assert!(non_terminal_error.contains("终态")); + + let records = read_agent_db_records_for_test(&root); + let receipts = records + .iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == action_id + }) + .collect::>(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["detailUnavailable"], true); + assert!(receipts[0]["safeDetail"].is_null()); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(!agent_db.contains(secret)); + assert!(!agent_db.contains(private_detail)); + assert!(!agent_db.contains(root.to_string_lossy().as_ref())); + + let history = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "actionId": action_id }), + ); + assert_eq!(history.status, "ok"); + let detail = history.detail.expect("history detail"); + assert!(detail.contains("file.read")); + assert!(detail.contains("detailUnavailable")); + assert!(!detail.contains(secret)); + assert!(!detail.contains(root.to_string_lossy().as_ref())); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_action_receipt_repairs_truncated_agent_db_tail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "恢复半写入动作回执", + "design-action-tail-recovery-run", + "agent-background-task", + "补齐持久动作回执", + vec!["修复 JSONL 尾部".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "project.patchset".to_string(), + reason: Some("恢复回执".to_string()), + input: serde_json::json!({ "changes": [] }), + }; + let fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let action_id = agent_runtime_tool_action_id(&runtime.run_id, 1, 0, 19, &fingerprint); + for index in 0..12 { + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "test.action_history.padding", + "index": index, + "content": "用于验证有界尾窗不会从多字节字符中间解析".repeat(8), + }), + ) + .expect("append bounded history padding"); + } + let agent_db_path = root.join(".agent/agent.db"); + fs::OpenOptions::new() + .append(true) + .open(&agent_db_path) + .expect("open agent db for crash tail") + .write_all(br#"{"recordType":"agent.runtime.action_receipt"#) + .expect("write truncated crash tail"); + + append_agent_runtime_action_receipt( + &root, + &runtime, + &action_id, + &fingerprint, + "project.patchset", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("changes=1"), + &AgentRuntimeToolObservation { + tool: "project.patchset".to_string(), + status: "ok".to_string(), + summary: "project.patchset 已原子应用 1 项变更".to_string(), + detail: Some("checkpointId=checkpoint-tail · revision=2 · changeCount=1".to_string()), + }, + ) + .expect("repair tail and append receipt"); + + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == action_id + }) + .count(), + 1 + ); + let (bounded_records, bounded_truncated) = + read_agent_db_records_bounded(&root, 2_048).expect("read bounded agent db tail"); + assert!(bounded_truncated); + assert!(bounded_records.iter().any(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == action_id + })); + let history = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "actionId": action_id }), + ); + assert_eq!(history.status, "ok"); + assert!(history + .detail + .expect("history detail") + .contains("checkpoint-tail")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_action_receipt_rejects_corrupt_agent_db_middle() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "拒绝损坏的动作回执账本", + "design-action-middle-corruption-run", + "agent-background-task", + "验证 JSONL 中间损坏失败关闭", + vec!["保持既有审计字节不变".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("验证账本完整性".to_string()), + input: serde_json::json!({ "path": "game/index.html" }), + }; + let fingerprint = agent_runtime_tool_action_fingerprint(&action, &runtime.current_task); + let action_id = agent_runtime_tool_action_id(&runtime.run_id, 1, 0, 23, &fingerprint); + let agent_db_path = root.join(".agent/agent.db"); + fs::OpenOptions::new() + .append(true) + .open(&agent_db_path) + .expect("open agent db for middle corruption") + .write_all( + b"{\"recordType\":\"broken-middle\"\n{\"recordType\":\"test.after-corruption\"}\n", + ) + .expect("write corrupt middle and valid tail"); + let corrupt_bytes = fs::read(&agent_db_path).expect("read corrupt agent db"); + + let error = append_agent_runtime_action_receipt( + &root, + &runtime, + &action_id, + &fingerprint, + "file.read", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("path=game/index.html"), + &AgentRuntimeToolObservation { + tool: "file.read".to_string(), + status: "ok".to_string(), + summary: "已读取 game/index.html".to_string(), + detail: None, + }, + ) + .expect_err("middle corruption must fail closed"); + assert!(error.contains("解析 Agent 本地索引")); + assert_eq!( + fs::read(&agent_db_path).expect("read preserved corrupt agent db"), + corrupt_bytes + ); + + fs::remove_dir_all(root).ok(); +} + +#[cfg(unix)] +#[test] +fn agent_db_rejects_symlinked_project_root() { + use std::os::unix::fs::symlink; + + let root = unique_project_path(); + let link = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Agent DB 根目录链接测试") + .expect("project init"); + let agent_db_path = root.join(".agent/agent.db"); + let before = fs::read(&agent_db_path).expect("read agent db before symlink attempt"); + symlink(&root, &link).expect("create project root symlink"); + + let error = append_agent_db_record( + &link, + serde_json::json!({ + "recordType": "test.symlinked-project-root", + "mustNotPersist": true, + }), + ) + .expect_err("symlinked project root must be rejected"); + assert!(!error.trim().is_empty()); + assert_eq!( + fs::read(&agent_db_path).expect("read unchanged agent db"), + before + ); + + fs::remove_file(link).ok(); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_db_idempotent_append_reopens_storage_after_directory_replacement() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Agent DB 身份替换测试").expect("project init"); + let record_type = "agent.runtime.action_receipt"; + let agent_id = "design-director"; + let run_id = "design-agent-db-replacement-run"; + let action_id = "action-111111111111111111111111"; + let record = serde_json::json!({ + "recordType": record_type, + "agentId": agent_id, + "taskId": agent_id, + "sessionId": "session-agent-db-replacement", + "runId": run_id, + "actionId": action_id, + "actionFingerprint": "1".repeat(64), + "tool": "file.list", + "executionMode": "auto", + "status": "ok", + "inputSummary": serde_json::Value::Null, + "summary": "旧目录中的同 key 记录", + "safeDetail": serde_json::Value::Null, + "detailUnavailable": true, + }); + assert!(append_agent_db_record_if_missing_for_action( + &root, + record_type, + agent_id, + run_id, + action_id, + record.clone(), + ) + .expect("append original identity")); + + let swap_root = root.clone(); + let appended = append_agent_db_record_if_missing_for_action_with_before_lock( + &root, + record_type, + agent_id, + run_id, + action_id, + record, + move || { + fs::rename(swap_root.join(".agent"), swap_root.join(".agent-replaced")) + .expect("move old agent directory"); + fs::create_dir(swap_root.join(".agent")).expect("create replacement agent directory"); + }, + ) + .expect("append must rescan replacement database"); + assert!( + appended, + "the pre-lock directory identity must not suppress append to the new database" + ); + let records = fs::read_to_string(root.join(".agent/agent.db")) + .expect("read replacement agent db") + .lines() + .map(|line| serde_json::from_str::(line).expect("replacement record json")) + .collect::>(); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == record_type && record["actionId"] == action_id + }) + .count(), + 1 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn bounded_agent_db_read_keeps_complete_records_before_partial_utf8_tail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Agent DB UTF-8 尾部测试") + .expect("project init"); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "test.before-partial-utf8", + "marker": "完整中文记录", + }), + ) + .expect("append complete record"); + let mut file = fs::OpenOptions::new() + .append(true) + .open(root.join(".agent/agent.db")) + .expect("open agent db for partial utf8 tail"); + file.write_all(b"{\"recordType\":\"test.partial\",\"text\":\"") + .expect("write partial json prefix"); + file.write_all(&[0xe4, 0xb8]) + .expect("write incomplete utf8 codepoint"); + drop(file); + + let (records, truncated) = + read_agent_db_records_bounded(&root, 32 * 1024 * 1024).expect("read bounded records"); + assert!(truncated); + assert!(records.iter().any(|record| { + record["recordType"] == "test.before-partial-utf8" && record["marker"] == "完整中文记录" + })); + + fs::remove_dir_all(root).ok(); +} + +fn exact_agent_db_json_record(record_type: &str, length: usize) -> Vec { + let prefix = format!(r#"{{"recordType":"{record_type}","content":""#).into_bytes(); + let suffix = br#""}"#; + assert!(prefix.len() + suffix.len() <= length); + let mut record = prefix; + record.extend(std::iter::repeat_n( + b'x', + length.saturating_sub(record.len() + suffix.len()), + )); + record.extend_from_slice(suffix); + assert_eq!(record.len(), length); + serde_json::from_slice::(&record).expect("exact length record must be valid json"); + record +} + +#[test] +fn agent_db_tail_repair_accepts_exact_one_mib_record_boundary() { + const ONE_MIB: usize = 1024 * 1024; + + for valid_tail in [true, false] { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Agent DB 精确尾窗测试") + .expect("project init"); + let path = root.join(".agent/agent.db"); + let prefix = b"{\"recordType\":\"test.boundary-prefix\"}\n"; + let mut content = prefix.to_vec(); + if valid_tail { + content.extend_from_slice(&exact_agent_db_json_record( + "test.exact-one-mib-tail", + ONE_MIB, + )); + } else { + content.extend_from_slice(b"{\"recordType\":\"test.incomplete-one-mib-tail\","); + content.resize(prefix.len() + ONE_MIB, b'x'); + } + fs::write(&path, content).expect("write exact one MiB tail fixture"); + + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "test.after-exact-one-mib-tail", + "validTail": valid_tail, + }), + ) + .expect("repair exact boundary and append record"); + let records = fs::read_to_string(&path) + .expect("read repaired exact boundary database") + .lines() + .map(|line| serde_json::from_str::(line).expect("all repaired lines are json")) + .collect::>(); + assert!(records + .iter() + .any(|record| record["recordType"] == "test.boundary-prefix")); + assert!(records.iter().any(|record| { + record["recordType"] == "test.after-exact-one-mib-tail" + && record["validTail"] == valid_tail + })); + assert_eq!( + records + .iter() + .any(|record| record["recordType"] == "test.exact-one-mib-tail"), + valid_tail + ); + + fs::remove_dir_all(root).ok(); + } +} + +#[test] +fn agent_db_action_scan_rejects_oversized_complete_line() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "Agent DB 超长单行测试").expect("project init"); + let path = root.join(".agent/agent.db"); + let mut file = fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open agent db for oversized line"); + file.write_all(&exact_agent_db_json_record( + "test.oversized-complete-line", + 2 * 1024 * 1024, + )) + .expect("write oversized complete record"); + file.write_all(b"\n").expect("finish oversized record"); + drop(file); + + let error = append_agent_db_record_if_missing_for_action( + &root, + AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "design-director", + "design-oversized-line-run", + "action-222222222222222222222222", + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": "design-director", + "taskId": "design-director", + "sessionId": "session-oversized-line", + "runId": "design-oversized-line-run", + "actionId": "action-222222222222222222222222", + "actionFingerprint": "2".repeat(64), + "tool": "file.list", + "executionMode": "auto", + "status": "ok", + "inputSummary": serde_json::Value::Null, + "summary": "不得越过超长审计行", + "safeDetail": serde_json::Value::Null, + "detailUnavailable": true, + }), + ) + .expect_err("oversized complete line must fail closed"); + assert!(error.contains("单条记录"), "unexpected error: {error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_action_history_folds_receipts_and_legacy_observations() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "回查当前 run 动作", + "design-action-history-run", + "agent-background-task", + "读取动作历史", + vec!["按终态筛选".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + + let patch_action = AgentRuntimeToolAction { + tool: "project.patchset".to_string(), + reason: Some("批量修改".to_string()), + input: serde_json::json!({ "changes": [] }), + }; + let patch_fingerprint = + agent_runtime_tool_action_fingerprint(&patch_action, &runtime.current_task); + let patch_action_id = + agent_runtime_tool_action_id(&runtime.run_id, 1, 0, 11, &patch_fingerprint); + append_agent_runtime_action_receipt( + &root, + &runtime, + &patch_action_id, + &patch_fingerprint, + "project.patchset", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION, + Some("changes=2"), + &AgentRuntimeToolObservation { + tool: "project.patchset".to_string(), + status: "ok".to_string(), + summary: "project.patchset 已原子应用 2 项变更".to_string(), + detail: Some( + "checkpointId=checkpoint-history · revision=2 · changeCount=2 · source=/home/example/private/key.txt" + .to_string(), + ), + }, + ) + .expect("append patchset receipt"); + + let legacy_action = AgentRuntimeToolAction { + tool: "command.exec".to_string(), + reason: Some("执行失败测试".to_string()), + input: serde_json::json!({}), + }; + let legacy_fingerprint = + agent_runtime_tool_action_fingerprint(&legacy_action, &runtime.current_task); + let legacy_action_id = + agent_runtime_tool_action_id(&runtime.run_id, 1, 1, 13, &legacy_fingerprint); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.executing", + "agentId": runtime.agent_id, + "runId": runtime.run_id, + "actionId": legacy_action_id, + "actionFingerprint": legacy_fingerprint, + "tool": "command.exec", + "executionMode": "confirmation", + "inputSummary": "program=node · argsCount=2" + }), + ) + .expect("append legacy executing"); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": runtime.agent_id, + "runId": runtime.run_id, + "actionId": legacy_action_id, + "actionFingerprint": legacy_fingerprint, + "tool": "command.exec", + "status": "failed", + "summary": "命令退出码为 1" + }), + ) + .expect("append legacy observation"); + + let waiting_action_id = + agent_runtime_tool_action_id(&runtime.run_id, 1, 2, 15, &legacy_fingerprint); + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.observed", + "agentId": runtime.agent_id, + "runId": runtime.run_id, + "actionId": waiting_action_id, + "actionFingerprint": legacy_fingerprint, + "tool": "command.exec", + "executionMode": "auto", + "status": "pending", + "observationStatus": "waiting-for-confirmation" + }), + ) + .expect("append legacy waiting observation"); + + let history_action = AgentRuntimeToolAction { + tool: "agent.action_history".to_string(), + reason: Some("查询动作历史".to_string()), + input: serde_json::json!({}), + }; + let history_fingerprint = + agent_runtime_tool_action_fingerprint(&history_action, &runtime.current_task); + let history_action_id = + agent_runtime_tool_action_id(&runtime.run_id, 1, 2, 17, &history_fingerprint); + append_agent_runtime_action_receipt( + &root, + &runtime, + &history_action_id, + &history_fingerprint, + "agent.action_history", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + Some("limit=5"), + &AgentRuntimeToolObservation { + tool: "agent.action_history".to_string(), + status: "ok".to_string(), + summary: "已读取动作历史".to_string(), + detail: Some("不应递归进入默认结果".to_string()), + }, + ) + .expect("append history receipt"); + + let history = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "limit": 10 }), + ); + assert_eq!(history.status, "ok"); + assert!(history.summary.contains("2 条终态动作")); + let detail = history.detail.expect("history detail"); + assert!(detail.contains("\"agentId\":\"design-director\"")); + assert!(detail.contains(&format!("\"sessionId\":\"{}\"", runtime.session_id))); + assert!(detail.contains(&format!("\"runId\":\"{}\"", runtime.run_id))); + assert!(detail.contains(&patch_action_id)); + assert!(detail.contains("checkpoint-history")); + assert!(!detail.contains("/home/example/private/key.txt")); + assert!(!detail.contains("source=")); + assert!(detail.contains(&legacy_action_id)); + assert!(!detail.contains(&waiting_action_id)); + assert!(detail.contains("detailUnavailable\":true")); + assert!(!detail.contains(&history_action_id)); + + let filtered = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "tool": "agent.action_history", "status": "ok" }), + ); + assert_eq!(filtered.status, "ok"); + assert!(filtered + .detail + .expect("filtered detail") + .contains(&history_action_id)); + + let other_agent_action_id = "action-ffffffffffffffffffffffff"; + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": "art-director", + "taskId": "art-director", + "sessionId": "art-session", + "runId": "art-action-history-run", + "actionId": other_agent_action_id, + "actionFingerprint": "f".repeat(64), + "tool": "file.list", + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": "path=assets", + "status": "ok", + "summary": "已读取美术目录", + "safeDetail": serde_json::Value::Null, + "detailUnavailable": true, + }), + ) + .expect("append other agent receipt"); + let cross_agent = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "runId": "art-action-history-run" }), + ); + assert_eq!(cross_agent.status, "ok"); + let cross_agent_detail = cross_agent.detail.expect("cross agent history detail"); + assert!(cross_agent_detail.contains("\"count\":0")); + assert!(!cross_agent_detail.contains(other_agent_action_id)); + assert!(!cross_agent_detail.contains("art-director")); + + let mut default_limit_action_ids = Vec::new(); + for index in 0..6_u64 { + let action_id = format!("action-{:024x}", 0xabc000_u64 + index); + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": action_id, + "actionFingerprint": format!("{:064x}", 0xdef000_u64 + index), + "tool": "file.list", + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": format!("index={index}"), + "status": "ok", + "summary": format!("默认边界记录 {index}"), + "safeDetail": serde_json::Value::Null, + "detailUnavailable": true, + }), + ) + .expect("append default limit receipt"); + default_limit_action_ids.push(action_id); + } + let default_limited = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({}), + ); + assert_eq!(default_limited.status, "ok"); + let default_payload = serde_json::from_str::( + default_limited + .detail + .as_deref() + .expect("default limited history detail"), + ) + .expect("parse default limited history"); + assert_eq!(default_payload["count"], 5); + assert_eq!(default_payload["truncated"], true); + let default_encoded = default_payload.to_string(); + assert!(!default_encoded.contains(&default_limit_action_ids[0])); + assert!(default_encoded.contains(&default_limit_action_ids[5])); + + let invalid = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "limit": 11 }), + ); + assert_eq!(invalid.status, "failed"); + + let forged_detail_action_id = "action-eeeeeeeeeeeeeeeeeeeeeeee"; + let forged_detail_marker = "FORGED_FILE_READ_SOURCE_MUST_NOT_RETURN"; + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": forged_detail_action_id, + "actionFingerprint": "e".repeat(64), + "tool": "file.read", + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "status": "ok", + "summary": "伪造旧版 file.read receipt", + "safeDetail": forged_detail_marker, + "detailUnavailable": false, + }), + ) + .expect("append forged safe detail receipt"); + let sanitized_forged = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "actionId": forged_detail_action_id }), + ); + assert_eq!(sanitized_forged.status, "ok"); + let sanitized_forged_detail = sanitized_forged.detail.expect("sanitized forged history"); + assert!(sanitized_forged_detail.contains(forged_detail_action_id)); + assert!(sanitized_forged_detail.contains("detailUnavailable\":true")); + assert!(!sanitized_forged_detail.contains(forged_detail_marker)); + + let non_terminal_action_id = "action-dddddddddddddddddddddddd"; + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": non_terminal_action_id, + "actionFingerprint": "d".repeat(64), + "tool": "file.list", + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "status": "running", + "summary": "伪造非终态 receipt", + }), + ) + .expect("append forged non-terminal receipt"); + let non_terminal = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "actionId": non_terminal_action_id }), + ); + assert_eq!(non_terminal.status, "failed"); + assert!(non_terminal + .detail + .expect("non-terminal receipt failure detail") + .contains("不是终态")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_action_history_fails_closed_on_action_identity_conflict() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "动作身份冲突测试").expect("project init"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "读取冲突动作历史", + "design-action-history-conflict-run", + "agent-background-task", + "验证冲突失败关闭", + Vec::new(), + ) + .expect("start runtime"); + let action_id = "action-cccccccccccccccccccccccc"; + append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.tool_action.executing", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": action_id, + "actionFingerprint": "c".repeat(64), + "tool": "file.list", + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": "path=." + }), + ) + .expect("append action metadata"); + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": action_id, + "actionFingerprint": "b".repeat(64), + "tool": "file.list", + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "status": "ok", + "inputSummary": "path=.", + "summary": "伪造冲突 receipt", + "safeDetail": serde_json::Value::Null, + "detailUnavailable": true, + }), + ) + .expect("append conflicting receipt fixture"); + + let observation = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "actionId": action_id }), + ); + assert_eq!(observation.status, "failed"); + assert!(observation + .detail + .expect("identity conflict detail") + .contains("动作账本身份冲突")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_action_history_fails_closed_on_task_ledger_conflict() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "任务身份冲突测试").expect("project init"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "读取冲突任务历史", + "design-action-history-task-conflict-run", + "agent-background-task", + "验证任务身份失败关闭", + Vec::new(), + ) + .expect("start runtime"); + let action_id = "action-bbbbbbbbbbbbbbbbbbbbbbbb"; + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": runtime.agent_id, + "taskId": "forged-task", + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": action_id, + "actionFingerprint": "b".repeat(64), + "tool": "file.list", + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "status": "ok", + "inputSummary": serde_json::Value::Null, + "summary": "伪造任务身份 receipt", + "safeDetail": serde_json::Value::Null, + "detailUnavailable": true, + }), + ) + .expect("append task-conflicting receipt fixture"); + + let observation = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "actionId": action_id }), + ); + assert_eq!(observation.status, "failed"); + assert!(observation + .detail + .expect("task conflict detail") + .contains("任务账本身份冲突")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_action_history_drops_optional_detail_without_damaging_identity() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "读取超长动作历史", + &format!("design-action-history-output-{}", "r".repeat(120)), + "agent-background-task", + "验证结构化输出预算", + vec!["保持完整身份字段".to_string()], + ) + .expect("start runtime"); + let mut expected_fingerprints = BTreeMap::new(); + for index in 0..10_u64 { + let action_id = format!("action-{:024x}", 0xfeed00_u64 + index); + let fingerprint = format!("{:064x}", 0xbeef00_u64 + index); + expected_fingerprints.insert(action_id.clone(), fingerprint.clone()); + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "actionId": action_id, + "actionFingerprint": fingerprint, + "tool": "project.patchset", + "executionMode": AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + "inputSummary": "input".repeat(80), + "status": "ok", + "summary": "summary".repeat(80), + "safeDetail": format!( + "checkpointId=checkpoint-{index}-{} · revision={} · changeCount={} · revisionAdvanced=true", + "c".repeat(120), + "2".repeat(120), + "3".repeat(120) + ), + "detailUnavailable": false, + }), + ) + .expect("append oversized history receipt"); + } + let observation = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "tool": "project.patchset", "limit": 10 }), + ); + assert_eq!(observation.status, "ok"); + let detail = observation.detail.expect("bounded history detail"); + assert!(detail.chars().count() <= AGENT_RUNTIME_ACTION_HISTORY_MAX_OUTPUT_CHARS); + let payload = serde_json::from_str::(&detail).expect("history remains valid json"); + assert_eq!(payload["outputTruncated"], true); + assert_eq!(payload["truncated"], false); + let actions = payload["actions"].as_array().expect("history actions"); + assert_eq!(actions.len(), 10); + assert_eq!(payload["count"], actions.len()); + for action in actions { + let action_id = action["actionId"].as_str().expect("action id"); + assert_eq!(action["runId"], runtime.run_id); + assert_eq!( + action["actionFingerprint"].as_str(), + expected_fingerprints.get(action_id).map(String::as_str) + ); + } + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn action_history_and_final_reply_are_blocked_until_isolated_join_is_claimed() { + use platform_agent::game_creation::{ + GameCreationIsolatedAgentArtifact, GameCreationIsolatedAgentChildResult, + GameCreationIsolatedAgentChildSpec, GameCreationIsolatedAgentEvidence, + GameCreationIsolatedAgentJoinMode, GameCreationIsolatedAgentResultStatus, + GameCreationIsolatedAgentSpawnRequest, + }; + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "all-join 完成门禁测试").expect("project init"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "等待隔离子 Agent all-join", + "code-isolated-join-gate-run", + "agent-background-task", + "验证 all-join 门禁", + vec!["认领 join 后才能收束".to_string()], + ) + .expect("start runtime"); + let parent_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype") + .expect("acquire active parent lane") + .expect("parent lane available"); + let group = create_or_read_isolated_group_at( + &root, + "code-prototype", + &state.run_id, + &state.session_id, + "action-555555555555555555555555", + &GameCreationIsolatedAgentSpawnRequest { + children: vec![GameCreationIsolatedAgentChildSpec { + template_agent_id: "quality-review".to_string(), + task: "读取项目证据并给出独立结论".to_string(), + acceptance_criteria: vec!["已读取 repository context".to_string()], + expected_artifacts: vec!["e2e/review/evidence.txt".to_string()], + write_scopes: vec!["e2e/review/**".to_string()], + }], + join_mode: GameCreationIsolatedAgentJoinMode::All, + }, + ) + .expect("create isolated group"); + + let blocker = isolated_join_completion_blocker_at(&root, "code-prototype", &state.run_id) + .expect("incomplete all-join blocks final reply"); + assert_eq!(blocker.tool, "runtime.isolated_join"); + assert_eq!(blocker.status, "blocked"); + assert!(blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("waitingGroups=1"))); + let waiting_finalization = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "等待状态不得持久化的回复", + 0, + &[], + ) + .expect("waiting join is a recoverable finalization blocker"); + assert!(matches!( + waiting_finalization, + AgentBackgroundFinalizationOutcome::Stale(AgentRuntimeToolObservation { + ref tool, + .. + }) if tool == "runtime.isolated_join" + )); + + let instance = resolve_isolated_agent_instance_at(&root, &group.instance_ids[0]) + .expect("resolve isolated instance"); + let join = record_isolated_child_result_at( + &root, + &GameCreationIsolatedAgentChildResult { + delegation_id: instance.delegation_id.clone(), + instance_id: instance.instance_id.clone(), + template_agent_id: instance.template_agent_id.clone(), + run_id: instance.run_id.clone(), + status: GameCreationIsolatedAgentResultStatus::Completed, + summary: "独立审查已完成".to_string(), + artifacts: vec![GameCreationIsolatedAgentArtifact { + path: "e2e/review/evidence.txt".to_string(), + sha256: "a".repeat(64), + }], + evidence: vec![GameCreationIsolatedAgentEvidence { + kind: "repository.context".to_string(), + summary: "已读取 repository context".to_string(), + path: None, + sha256: None, + }], + verified_revision: None, + error: None, + }, + ) + .expect("record isolated child result") + .expect("all join ready"); + dispatch_isolated_agent_join_at(&root, join.clone()).expect("queue isolated join"); + + let ready_blocker = isolated_join_completion_blocker_at(&root, "code-prototype", &state.run_id) + .expect("ready but unclaimed all-join still blocks final reply"); + assert!(ready_blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("readyUnclaimedGroups=1"))); + let ready_finalization = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "未认领状态不得持久化的回复", + 0, + &[], + ) + .expect("ready unclaimed join is a recoverable finalization blocker"); + assert!(matches!( + ready_finalization, + AgentBackgroundFinalizationOutcome::Stale(AgentRuntimeToolObservation { + ref tool, + .. + }) if tool == "runtime.isolated_join" + )); + + let observation = execute_game_creator_agent_runtime_tool_action( + &root, + "code-prototype", + &state.run_id, + &state.current_task, + &AgentRuntimeToolAction { + tool: "agent.action_history".to_string(), + reason: Some("尝试提前验收动作历史".to_string()), + input: serde_json::json!({ "limit": 5 }), + }, + ) + .await; + assert_eq!(observation.tool, "agent.action_history"); + assert_eq!(observation.status, "blocked"); + assert!(observation.summary.contains("all-join")); + assert!(observation + .detail + .as_deref() + .is_some_and(|detail| detail.contains("agent.run_status"))); + + let run_status = execute_game_creator_agent_runtime_tool_action_with_action_id( + &root, + "code-prototype", + &state.run_id, + &state.current_task, + &AgentRuntimeToolAction { + tool: "agent.run_status".to_string(), + reason: Some("认领 ready all-join".to_string()), + input: serde_json::json!({ "scope": "all" }), + }, + Some("action-666666666666666666666666"), + ) + .await; + assert_eq!(run_status.status, "ok"); + assert!(run_status.summary.contains("ready all-join")); + let delivery = read_isolated_join_delivery_at(&root, &join) + .expect("read join delivery") + .expect("join delivery exists"); + assert_eq!( + delivery.status, + IsolatedAgentJoinDeliveryStatus::ClaimedByParent + ); + assert_eq!( + delivery.claimed_by_action_id.as_deref(), + Some("action-666666666666666666666666") + ); + assert!(isolated_join_completion_blocker_at(&root, "code-prototype", &state.run_id).is_none()); + + let history = execute_game_creator_agent_runtime_tool_action_with_action_id( + &root, + "code-prototype", + &state.run_id, + &state.current_task, + &AgentRuntimeToolAction { + tool: "agent.action_history".to_string(), + reason: Some("join 认领后读取动作历史".to_string()), + input: serde_json::json!({ "limit": 5 }), + }, + Some("action-777777777777777777777777"), + ) + .await; + assert_eq!(history.status, "ok"); + + let completed = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "认领 all-join 后允许持久化的回复", + 0, + &[], + ) + .expect("finalize after join claim"); + assert!(matches!( + completed, + AgentBackgroundFinalizationOutcome::Completed(_) + )); + let conversation = read_local_conversation_for_session_at( + &root, + Some("code-prototype"), + Some(&state.session_id), + ) + .expect("read finalization conversation"); + assert!(conversation.messages.iter().any(|message| { + message.role == "assistant" && message.content == "认领 all-join 后允许持久化的回复" + })); + assert!(!conversation.messages.iter().any(|message| { + message.role == "assistant" + && matches!( + message.content.as_str(), + "等待状态不得持久化的回复" | "未认领状态不得持久化的回复" + ) + })); + + drop(parent_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_action_receipt_never_persists_file_uri_identity_fields() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "动作回执身份清洗测试").expect("project init"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "拒绝路径型动作身份", + "design-safe-receipt-identity-run", + "agent-background-task", + "验证回执身份边界", + vec!["不持久化 file URI".to_string()], + ) + .expect("start runtime"); + let action_id = "action-333333333333333333333333"; + let fingerprint = "3".repeat(64); + let private_uri = "file:///home/alice/private-project"; + + let mut unsafe_run = runtime.clone(); + unsafe_run.run_id = private_uri.to_string(); + let _ = append_agent_runtime_action_receipt( + &root, + &unsafe_run, + action_id, + &fingerprint, + "file.list", + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &AgentRuntimeToolObservation { + tool: "file.list".to_string(), + status: "ok".to_string(), + summary: "读取完成".to_string(), + detail: None, + }, + ); + let _ = append_agent_runtime_action_receipt( + &root, + &runtime, + "action-444444444444444444444444", + &"4".repeat(64), + private_uri, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + None, + &AgentRuntimeToolObservation { + tool: private_uri.to_string(), + status: "rejected".to_string(), + summary: "未知工具已拒绝".to_string(), + detail: None, + }, + ); + + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("read agent db"); + assert!(!agent_db.contains(private_uri)); + assert!(!agent_db.contains("/home/alice/private-project")); + let invalid_query = observe_agent_runtime_action_history( + &root, + "design-director", + &runtime.run_id, + &serde_json::json!({ "runId": private_uri }), + ); + assert_eq!(invalid_query.status, "failed"); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_repairs_terminal_receipt_through_reconciliation_barrier() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![final_tool_plan_response("缺失的终态回执已经补齐。")], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "恢复缺失的终态回执", + "design-receipt-reconciliation-run", + "agent-background-task", + "只补回执,不重放工具", + vec!["恢复持久 observation".to_string()], + ) + .expect("start runtime"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.list".to_string(), + reason: Some("读取项目摘要".to_string()), + input: serde_json::json!({}), + }; + let observation = AgentRuntimeToolObservation { + tool: "file.list".to_string(), + status: "ok".to_string(), + summary: "已读取项目文件摘要,恢复时不得重放".to_string(), + detail: Some("fileCount=1".to_string()), + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + Some(observation.clone()), + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write observed pending action"); + let observation_summary = observation.summary(); + state.observations.push(observation_summary.clone()); + append_agent_runtime_tool_call_record( + &root, + &mut state, + &pending.task, + &pending.action, + &observation, + Some(&pending.action_id), + ); + complete_agent_runtime_active_plan_step(&mut state, "completed", &observation_summary); + state.status = "running".to_string(); + state.phase = "observation".to_string(); + state.current_action = "已执行自动工具 file.list".to_string(); + state.waiting_on = "Agent 根据工具观察修正计划".to_string(); + state.next_step = "把工具观察交给 Agent 修正计划".to_string(); + state.pending_tool_action = Some(pending.summary()); + state.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task_projection_once(&root, &state, &pending.action_id) + .expect("append observation task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write observation state"); + append_game_creator_agent_runtime_action_event( + &root, + &state, + "observation", + "running", + "observation", + &observation_summary, + observation.detail.as_deref(), + &pending.action_id, + ) + .expect("append observation event"); + append_agent_db_terminal_observation_if_missing_for_action( + &root, + &state.agent_id, + &state.run_id, + &pending.action_id, + serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": state.agent_id, + "taskId": state.task_id, + "runId": state.run_id, + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "decision": "auto", + }), + ) + .expect("append terminal observation audit without receipt"); + + let observation_task_count_before = read_all_game_creator_agent_runtime_tasks( + &game_creator_agent_runtime_task_path(&root, "design-director"), + ) + .expect("read task projections before recovery") + .into_iter() + .filter(|record| { + record.run_id == state.run_id + && record.phase == "observation" + && record.current_action == "已执行自动工具 file.list" + }) + .count(); + let observation_event_count_before = read_recent_game_creator_agent_runtime_events( + &game_creator_agent_runtime_event_path(&root, "design-director"), + ) + .expect("read event projections before recovery") + .into_iter() + .filter(|event| event.action_id.as_deref() == Some(pending.action_id.as_str())) + .count(); + assert_eq!(observation_task_count_before, 1); + assert_eq!(observation_event_count_before, 1); + + resume_game_creator_agent_background_tasks_at(&root).expect("resume receipt repair"); + let request = receiver + .recv_timeout(Duration::from_secs(5)) + .expect("replan after receipt repair"); + assert!(request.contains("已读取项目文件摘要,恢复时不得重放")); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime + .recent_tool_calls + .iter() + .filter(|record| record.action_id.as_deref() == Some(pending.action_id.as_str())) + .count(), + 1 + ); + let receipts = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == pending.action_id + }) + .collect::>(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["status"], "ok"); + let records = read_agent_db_records_for_test(&root); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_observation" + && record["actionId"] == pending.action_id + && record["status"] == "ok" + }) + .count(), + 1 + ); + assert_eq!( + read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path( + &root, + "design-director", + )) + .expect("read task projections after recovery") + .into_iter() + .filter(|record| { + record.run_id == state.run_id + && record.phase == "observation" + && record.current_action == "已执行自动工具 file.list" + }) + .count(), + 1 + ); + assert_eq!( + read_recent_game_creator_agent_runtime_events(&game_creator_agent_runtime_event_path( + &root, + "design-director", + )) + .expect("read event projections after recovery") + .into_iter() + .filter(|event| event.action_id.as_deref() == Some(pending.action_id.as_str())) + .count(), + 1 + ); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + &root, + "design-director", + &state.run_id + )); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_repairs_receipt_for_reconciliation_observation_without_replay() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "核对态回执恢复测试").expect("project init"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "恢复需要人工核对的终态回执", + "design-reconciliation-observation-receipt-run", + "agent-background-task", + "只补回执,不重放写入", + vec!["保留人工核对状态".to_string()], + ) + .expect("start runtime"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("模拟结果未知的写入".to_string()), + input: serde_json::json!({ + "path": "game/must-not-replay.txt", + "content": "该文件不得在恢复时出现" + }), + }; + let observation = AgentRuntimeToolObservation { + tool: "file.write".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "写入结果需要人工核对".to_string(), + detail: Some("原动作结果未知,恢复只能补回执".to_string()), + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + Some(observation), + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write reconciliation observation pending"); + state.status = "failed".to_string(); + state.phase = "needs-reconciliation".to_string(); + state.pending_tool_action = Some(pending.summary()); + state.error = Some("模拟终态 observation 已落盘但 receipt 缺失".to_string()); + append_game_creator_agent_runtime_task(&root, &state).expect("append reconciliation task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write reconciliation state"); + + resume_game_creator_agent_background_tasks_at(&root).expect("resume receipt-only repair"); + let mut receipts = Vec::new(); + for _ in 0..100 { + receipts = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == pending.action_id + }) + .collect::>(); + if !receipts.is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(receipts.len(), 1); + assert_eq!( + receipts[0]["status"], + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ); + assert!(!root.join("game/must-not-replay.txt").exists()); + let result = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read retained reconciliation runtime"); + assert_eq!(result.state.phase, "needs-reconciliation"); + assert!(game_creator_agent_runtime_pending_tool_action_exists( + &root, + "design-director", + &state.run_id + )); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn pending_terminal_observation_persists_receipt_before_cancellation() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "终态观察取消顺序测试").expect("project init"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "在取消前持久化已完成观察", + "design-terminal-observation-cancel-run", + "agent-background-task", + "先写回执再取消", + vec!["保持动作审计完整".to_string()], + ) + .expect("start runtime"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.list".to_string(), + reason: Some("读取项目目录".to_string()), + input: serde_json::json!({}), + }; + let observation = AgentRuntimeToolObservation { + tool: "file.list".to_string(), + status: "blocked".to_string(), + summary: "开发者拒绝待确认工具动作".to_string(), + detail: Some("取消请求到达前 observation 已持久化".to_string()), + }; + let pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED, + Some(observation), + ); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write terminal observed pending"); + state.status = "running".to_string(); + state.phase = "observation".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append observation task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write observation state"); + write_game_creator_agent_runtime_cancel_request( + &root, + "design-director", + &state.run_id, + "测试终态 observation 与取消竞态", + ) + .expect("write cancellation tombstone"); + + continue_game_creator_agent_pending_tool_action( + root.clone(), + "design-director".to_string(), + pending.clone(), + state.clone(), + ) + .await; + + let receipts = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == pending.action_id + }) + .collect::>(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["status"], "blocked"); + let result = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read cancelled runtime"); + assert_eq!(result.state.status, "cancelled"); + assert_eq!(result.state.phase, "cancelled"); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + &root, + "design-director", + &state.run_id + )); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn first_pass_reconciliation_observation_applies_cancellation_after_receipt() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "首轮核对态取消顺序测试").expect("project init"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "首轮核对态先写回执再取消", + "design-first-pass-reconciliation-cancel-run", + "agent-background-task", + "保持回执完整后结束任务", + vec!["验证取消顺序".to_string()], + ) + .expect("start runtime"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.write".to_string(), + reason: Some("模拟已返回但需核对的写入".to_string()), + input: serde_json::json!({ + "path": "game/reconciliation-result.txt", + "content": "动作不会在测试中重放" + }), + }; + let observation = AgentRuntimeToolObservation { + tool: "file.write".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "首轮工具结果需要人工核对".to_string(), + detail: Some("工具已返回终态,取消只能在 receipt 后生效".to_string()), + }; + let mut pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED, + Some(observation.clone()), + ); + pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write reconciliation pending"); + state.pending_tool_action = Some(pending.summary()); + write_game_creator_agent_runtime_state(&root, &state).expect("write running state"); + write_game_creator_agent_runtime_cancel_request( + &root, + "design-director", + &state.run_id, + "测试首轮 reconciliation 与取消竞态", + ) + .expect("write cancellation tombstone"); + + assert!( + persist_agent_runtime_reconciliation_observation_before_cancellation( + &root, + &mut state, + &pending, + &observation, + ) + ); + assert_eq!(state.status, "cancelled"); + assert_eq!(state.phase, "cancelled"); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + &root, + "design-director", + &state.run_id + )); + let receipts = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == pending.action_id + }) + .collect::>(); + assert_eq!(receipts.len(), 1); + assert_eq!( + receipts[0]["status"], + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn receipt_failure_keeps_terminal_pending_when_cancellation_is_already_requested() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "回执失败取消竞态测试").expect("project init"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "回执失败时保留终态动作", + "design-receipt-failure-cancel-run", + "agent-background-task", + "失败关闭且不清理 pending", + vec!["保留恢复入口".to_string()], + ) + .expect("start runtime"); + state.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "file.list".to_string(), + reason: Some("读取项目目录".to_string()), + input: serde_json::json!({}), + }; + let observation = AgentRuntimeToolObservation { + tool: "file.list".to_string(), + status: "blocked".to_string(), + summary: "开发者拒绝待确认工具动作".to_string(), + detail: None, + }; + let pending = pending_tool_action_for_test( + &root, + &state, + action, + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED, + Some(observation), + ); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write terminal observed pending"); + state.status = "running".to_string(); + state.phase = "observation".to_string(); + state.pending_tool_action = Some(pending.summary()); + append_game_creator_agent_runtime_task(&root, &state).expect("append observation task"); + write_game_creator_agent_runtime_state(&root, &state).expect("write observation state"); + append_agent_db_record_fixture( + &root, + serde_json::json!({ + "recordType": AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE, + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "actionId": pending.action_id, + "actionFingerprint": pending.action_fingerprint, + "tool": pending.action.tool, + "executionMode": pending.execution_mode, + "status": "blocked", + "inputSummary": pending.input_summary, + "summary": "故意制造身份冲突的既有回执", + "safeDetail": serde_json::Value::Null, + "detailUnavailable": false, + }), + ) + .expect("append conflicting receipt fixture"); + write_game_creator_agent_runtime_cancel_request( + &root, + "design-director", + &state.run_id, + "测试 receipt 失败与取消同时发生", + ) + .expect("write cancellation tombstone"); + + continue_game_creator_agent_pending_tool_action( + root.clone(), + "design-director".to_string(), + pending.clone(), + state.clone(), + ) + .await; + + let result = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read reconciliation runtime"); + assert_eq!(result.state.status, "failed"); + assert_eq!(result.state.phase, "needs-reconciliation"); + assert!(result + .state + .error + .as_deref() + .is_some_and(|error| error.contains("持久动作回执"))); + assert!(game_creator_agent_runtime_pending_tool_action_exists( + &root, + "design-director", + &state.run_id + )); + assert!(root + .join(".agent/runtime/cancel/design-director") + .join(format!("{}.json", state.run_id)) + .is_file()); + let receipts = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE + && record["actionId"] == pending.action_id + }) + .collect::>(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["summary"], "故意制造身份冲突的既有回执"); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_can_query_its_persisted_action_history() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let first_plan = serde_json::json!({ + "thinkingSummary": "先读取项目摘要,再通过持久回执核对动作", + "plan": ["读取项目摘要", "回查已完成动作"], + "actions": [{ + "tool": "file.list", + "reason": "取得项目目录摘要", + "input": {} + }], + "response": "" + }) + .to_string(); + let history_plan = serde_json::json!({ + "thinkingSummary": "项目摘要已返回,需要按工具名核对终态回执", + "plan": ["回查 file.list 终态"], + "actions": [{ + "tool": "agent.action_history", + "reason": "确认早先只读动作已经持久化且没有重放", + "input": { "tool": "file.list", "limit": 5 } + }], + "response": "" + }) + .to_string(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![ + first_plan, + history_plan, + final_tool_plan_response("项目摘要动作已有持久回执,可以继续下一步。"), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "读取项目摘要并核对持久动作回执", + "design-action-history-loop-run", + ) + .expect("start background task"); + + let first_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first plan request"); + assert!(first_request.contains("agent.action_history")); + let history_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("history plan request"); + assert!(history_request.contains("file.list")); + let final_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("final plan request"); + assert!(final_request.contains("agent.action_history")); + assert!(final_request.contains("actionId")); + assert!(final_request.contains("file.list")); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert!(runtime + .tool_policy + .auto_tools + .contains(&"agent.action_history".to_string())); + assert!(runtime.recent_tool_calls.iter().any(|call| { + call.tool == "file.list" + && call + .action_id + .as_deref() + .is_some_and(is_valid_agent_runtime_action_id) + })); + assert!(runtime.recent_tool_calls.iter().any(|call| { + call.tool == "agent.action_history" + && call + .action_id + .as_deref() + .is_some_and(is_valid_agent_runtime_action_id) + })); + let runtime_snapshot = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read runtime snapshot with history event"); + let history_event = runtime_snapshot + .recent_events + .iter() + .find(|event| { + event.event_type == "observation" + && event.summary.starts_with("agent.action_history:ok") + }) + .expect("action history event"); + let event_payload = serde_json::from_str::( + history_event + .detail + .as_deref() + .expect("history event detail"), + ) + .expect("history event detail remains valid json"); + assert_eq!(event_payload["actions"][0]["tool"], "file.list"); + let receipts = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| record["recordType"] == AGENT_RUNTIME_ACTION_RECEIPT_RECORD_TYPE) + .collect::>(); + assert_eq!(receipts.len(), 2); + assert!(receipts.iter().any(|record| record["tool"] == "file.list")); + assert!(receipts + .iter() + .any(|record| record["tool"] == "agent.action_history")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_read_other_agent_status() { let root = unique_project_path(); @@ -19545,14 +22591,8 @@ async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies( "assistant conversation must persist before Runtime completes for {agent_id}" ); } - assert!( - game_creator_agent_runtime_task_lock_is_available(&root, "art-director") - .expect("art runtime lock released") - ); - assert!( - game_creator_agent_runtime_task_lock_is_available(&root, "design-director") - .expect("design runtime lock released") - ); + drop(wait_to_acquire_agent_runtime_lock(&root, "art-director")); + drop(wait_to_acquire_agent_runtime_lock(&root, "design-director")); fs::remove_dir_all(root).ok(); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 83ca3a0a0..ebd48fc89 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4216,3 +4216,23 @@ - 决策:context window 压缩新增 `runtime.milestones` 安全摘要,跨窗口保留已成功完成的 `agent.spawn_isolated / agent.delegate / canvas.asset_generate / preview.validate / project.patchset / project.restore / task.create`,并明确禁止无任务依据的重复高成本或副作用动作。账本只用于规划连续性,不替代 pending-action、task/event、Agent DB、revision、verification 或 finalization 事实源。 - 决策:旧 `runtime.context / runtime.milestones` 不再占普通最近 observation 槽;账本在再次压缩时合并旧摘要与新里程碑,所有 detail 先做路径、凭据和长度清洗。`project.diff` checkpoint 内容 hunk 与 `git.inspect` 工作树 hunk 分别保留最新一项,不能互相顶掉;总 bundle 仍不得超过 128 KiB。 - 验证:新增连续窗口单测证明 spawn、patchset 和 checkpointId 经两次压缩仍存在,两类大 diff 同时保留。真实 `gpt-5.5` Git E2E 曾准确捕获一次上下文遗忘导致的重复 spawn;修复后 94 条 task、156 条 event、140 条 Agent DB、12 次成功工具执行中 `git.inspect=2 / patchset=1 / spawn=1 / join=1`,revision=3,Runner 强杀恢复身份稳定,验证与桌面/移动浏览器证据通过,副作用重放、重复 action/message/receipt、半完成文件、密钥和诱饵泄露均为 0。 + +## 2026-07-13 AI 游戏创作 Agent Runtime V1.6 持久动作回执与模型回查 + +- 决策:继续复用 `.agent/agent.db` 作为唯一长期审计源,不新增数据库或平行事实源。每个带 `actionId` 的已落盘终态 observation 必须追加或补齐 terminal receipt,身份固定包含 `agentId / taskId / sessionId / runId / actionId / actionFingerprint / tool / executionMode / status / inputSummary / summary / safeDetail / updatedAt`。 +- 决策:`safeDetail` 只允许按工具类型和字段名双重白名单抽取,禁止整段复制工具 detail,禁止保存 `file.read` 源码、命令完整输出、diff 正文、消息 / 记忆 / 委派正文、密钥和绝对路径;首版只保留 `project.patchset` 的 checkpoint / revision / count 等结构化字段,无法安全还原 detail 的历史旧记录允许显式标记 `detailUnavailable`。 +- 决策:动作历史读取必须把 receipt 当持久输入而不是可信展示 DTO,重新验证终态 status、actionId、fingerprint、executionMode、tool 和 task / session ledger 绑定,并按当前工具白名单重新解析 `safeDetail`;无法通过二次校验的 detail 只能标记 `detailUnavailable`。 +- 决策:新增只读模型工具 `agent.action_history`,只能查询当前 Agent;输入支持 `runId / actionId / tool / status / limit`,`limit` 默认 5、上限 10,省略 `runId` 时只查当前 run。结果优先使用终态 receipt,并兼容折叠历史 terminal observation;旧记录无法还原安全 detail 时标记 `detailUnavailable`。未指定 `tool` 时默认排除 `agent.action_history` 自身,只有显式 `tool=agent.action_history` 才允许回查它,避免递归污染。 +- 决策:`agent.action_history` 复用 `agent.audit` 权限,默认 `auto`,项目或 per-Agent policy 可改为 `confirm / deny`;查询不推进 revision、不改变 verification gate、不认领 join。receipt 写入失败时 durable action 必须进入 `needs-reconciliation`,恢复只按原 `actionId` 补齐 receipt,不得重放动作。 +- 决策:receipt 判重命中后还必须全等复核 Session、fingerprint、tool、executionMode、status 和安全结果字段,冲突失败关闭。普通 append 禁止写 `agent.runtime.action_receipt`,幂等动作入口只接受字段完整的终态 receipt。Agent DB 只自动修复强杀造成的最后一条不完整 JSONL,中间损坏不跳过;单条记录上限 1 MiB,receipt 幂等全量扫描在文件超过 256 MiB 或记录超过 100 万条时失败关闭,禁止复用锁外快照。普通审计约在 192 MiB 或 999,936 条停止,并给字节 / 记录门槛预留 64 条最大 1 MiB terminal 记录;仅 terminal receipt、带 actionId 的终态 observation / observed 和 reconciliation 可用预留区,`command-failed / verification-failed` 也是终态。普通和终态追加都在同一 DB 句柄锁内真实计数,容量判断和判重失败关闭,不做轮转。普通读取使用最近 32 MiB 有界尾窗、最多保留 16,384 个完整 JSON object,并显式标记 `truncated`。 +- 决策:Agent DB 不再依赖普通路径锁文件保障安全。Unix 必须从可信项目目录句柄使用 `openat + O_NOFOLLOW` 打开,校验普通文件与 `nlink=1` 后直接对 DB 文件句柄 `flock`;Windows 必须用相对 `NtCreateFile` 打开并拒绝 reparse point / hardlink,同时以独占 share 持有句柄。每次 append、尾部补换行或截断修复执行 `flush + sync_data`;Unix 新建 `.agent` 和 `agent.db` 后分别同步项目根目录与 `.agent` 目录,写入前后复核身份。32 MiB 尾窗恰好落在记录边界、UTF-8 半字符或精确 1 MiB 尾记录时不得丢弃合法记录;同 UID 恶意进程的 rename / hardlink ABA 不承诺绝对隔离。 +- 2026-07-13 真实验收修正:pending action 的精确 project revision / verification gate 不再拦截纯读取工具;不同 Agent 并行推进 revision 后,`file.read / project.search / git.inspect / agent.action_history` 等读取动作必须读取最新事实并返回 observation。写入、命令、验证、预览证据和 `agent.run_status` join 认领仍复核原 revision / gate,repository fingerprint gate 也继续独立生效。该修正来自真实 Provider 首轮中隔离子 Agent 因父 Agent revision 推进而把 `file.read` 误判为 `needs-reconciliation`、导致 all-join 无法形成的失败证据。 +- 决策:共享项目事实读取使用项目一致性锁;等待锁后必须重读 durable pending sidecar,并与调用方完整 pending 对象逐字段一致,再核对 policy 和 repository fingerprint。`agent.run_status` 的 all-join claim 必须在同一项目锁内重验 revision / gate 并完成认领,关闭检查与认领之间的 TOCTOU。policy denied 和未知工具也先形成 durable observed pending,再写 terminal receipt;一旦 terminal observation 已持久化,必须先成功落 receipt 才响应取消,receipt 失败统一保留 `needs-reconciliation` 补写入口且不得重放工具。 +- 决策:父 run 存在 `joinMode=all` 隔离组时,所有子结果终态且 ready join 被当前父 run 的 `agent.run_status` action 认领前,最终回复和 `agent.action_history` 都必须失败关闭并要求继续查询状态;只有持久 join 认领完成后才解除门禁。 +- 决策:最终回复在项目锁内创建 finalization journal 前必须再次复核 all-join 已由当前父 run 持久认领;未认领按 `Stale` 回到同 run planning,不写 journal、assistant 或 completed,不能只依赖 planning 阶段旧快照。 +- 决策:父 run 在 `waitingGroups > 0` 时必须持久进入 `waiting-for-isolated-join`、保存原 context cursor 并释放 Agent lane;重复 resume 只返回等待状态,不请求 LLM、不推进 loop,也不取消仍在工作的 child。最后一个 child 就绪后写 `deliveryTarget=parent-wake` 并唤醒同一 parent run / session,由模型通过持久 `agent.run_status` actionId 认领;不得创建 join continuation。活跃 planning / running 父 run 直接保留 ready join 等待认领,重复 dispatch 不创建任务。旧 delivery 缺少 target 时按 continuation 单向兼容。 +- 决策:action task / event 投影的幂等阶段键为 `runId + actionId + phase`,允许同一 action 从 waiting-for-confirmation 合法推进到终态 observation,同阶段冲突仍失败关闭。Agent DB 终态 observation 扫描跳过同 action 的非终态前置记录,只对既有终态做全字段一致性检查;`recentToolCalls` 按 actionId 原位更新,避免 waiting 投影遮住最终结果。 +- 决策:动作历史结构化 detail 上限 7,200 字符;超预算时只能先删除可选字段,再按最旧优先删除完整记录,不得字符截断 JSON,也不得清空 `runId / actionFingerprint` 破坏身份。运行时文本清洗必须覆盖 Unix、Windows 盘符、正反斜杠 UNC、`file:/...`、`file:///...` 及百分号编码绝对路径,统一替换为 ``,并保留普通相对文本与 HTTP(S) URL。 +- 决策:后台 planning 和最终回复的瞬时 LLM 错误重试上限提高为额外 5 次,覆盖 `Timeout / Connectivity / Transport` 与上游 `408 / 429 / 5xx`,按 500ms 线性递增退避;不可重试错误继续直接失败,任何重试都不得跨越工具执行或回复落盘提交点。 +- 决策:本轮只交付模型工具,不新增前端动作历史弹窗;UI 继续显示最近动作投影,后续历史查看必须使用独立弹窗。 +- 验证:Rust 全量 507 项中 504 通过、3 项真实浏览器 opt-in 用例按设计忽略;覆盖 receipt 折叠、组合过滤、默认值与上限、敏感清洗、旧记录、尾部修复、中间损坏失败关闭、身份冲突、句柄安全、目录同步、确认阶段到终态投影、`parent-wake` 和恢复补齐。最终真实 `gpt-5.5` V1.6 `llm-runtime` 套件中,模型实际调用 1 次 `agent.action_history` 并返回 1 条与 `.agent/agent.db` 全身份对齐的当前 run 记录;94 条 task、158 条 event、164 条 Agent DB、11 条合法工具协议、13 次成功工具执行和 24 条 terminal receipt 中,主 run receipt 为 18,递归历史、重复 receipt / action / message、receipt identity 冲突、密钥和诱饵泄漏均为 0。Runner 强杀后恢复原 run / session 且身份稳定,3 个隔离实例形成唯一 all-join 认领,本次真实竞态未创建 continuation task;动作历史只在父 run 认领 join 后执行,项目、桌面和移动验证通过。 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 621db5637..c05f77dd5 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 @@ -198,7 +198,7 @@ delegationId, instanceId, templateAgentId, runId, status, summary, artifacts[path, sha256], evidence[], verifiedRevision, error ``` -同一 group 全部终态后,Runtime 使用 `.agent/runtime/isolated-agents/join-deliveries/.json` 持久记录交付状态,并且只允许固定 `joinRunId` 入队一次。父 run 仍持有自身 lane 时,只能通过当前已持久化 `agent.run_status` 工具动作的 `actionId` 认领 ready all-join;交付记录保存 `claimedByActionId`,同一 action 崩溃重试可幂等重读,同一 run 的其他 action 不再看到该 join。认领后取消尚未执行的固定 continuation;continuation 已开始时拒绝认领。父 run 未认领时,唯一 continuation 才在 lane 释放后执行,`dispatched -> claimed-by-parent / suppressed` 单向不可逆。恢复、并发 child 终态和重复状态查询都不能重新开放交付、生成 `-dup-*` join run 或重复调用父 LLM。 +同一 group 全部终态后,Runtime 使用 `.agent/runtime/isolated-agents/join-deliveries/.json` 持久记录交付状态。父 run 仍在 planning / action 时不创建 continuation,只能通过当前已持久化 `agent.run_status` 工具动作的 `actionId` 认领 ready all-join;交付记录保存 `claimedByActionId`,同一 action 崩溃重试可幂等重读,同一 run 的其他 action 不再看到该 join。若父 run 在 child 尚未终态时返回空 actions,则持久进入 `waiting-for-isolated-join`、保存原 context cursor 并释放自身 lane,不继续请求 LLM,也不消耗后续上下文窗口;重复 resume 只读取等待状态。最后一个 child 就绪后写 `deliveryTarget=parent-wake` 并唤醒同一父 run / session,由模型继续调用 `agent.run_status` 认领,不能创建 `joinRunId` continuation。旧 delivery 缺少 `deliveryTarget` 时按 `continuation` 兼容;只有非活跃父任务或旧记录的兜底路径才保留固定 continuation。`dispatched -> claimed-by-parent / suppressed` 单向不可逆,恢复、并发 child 终态和重复状态查询都不能重新开放交付、生成 `-dup-*` join run 或重复调用父 LLM。 ## 5. 真实 Provider 验收 @@ -356,6 +356,38 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir - 修复后的真实 `gpt-5.5` 回归在 10 轮内完成并收束,唯一 spawn / patchset / join 均保持 1 次,两次 Git 审阅和两类 diff 同时留在最终 context bundle,重复副作用为 0。 +## V1.6 持久动作回执与模型回查 + +- 复用 `.agent/agent.db` 作为唯一长期审计源,不新增数据库或平行事实源。每个带 `actionId` 的已落盘终态 observation 都必须在该审计源中追加或补齐 terminal receipt,身份固定包含 `agentId / taskId / sessionId / runId / actionId / actionFingerprint / tool / executionMode / status / inputSummary / summary / safeDetail / updatedAt`。 +- `safeDetail` 必须按工具类型和字段名双重白名单抽取,不能把某类工具的整段 detail 直接复制到 receipt;不得写入 `file.read` 读取的源码、命令完整输出、diff 正文、消息 / 记忆 / 委派正文、密钥或绝对路径。首版只保留 `project.patchset` 的 `checkpointId / revision / changeCount / revisionAdvanced` 等结构化字段;历史旧记录无法安全还原 detail 时允许显式标记 `detailUnavailable`,不得为补齐字段重新读取或扩散敏感正文。 +- `agent.action_history` 不能把 Agent DB 中已有 receipt 当成已经可信的展示 DTO。读取时必须再次验证终态 status、actionId、64 位 fingerprint、executionMode、tool,并把 receipt 的 task / session 与同 run 的 task ledger 绑定;`safeDetail` 还要按当前工具白名单重新解析,无法通过时只返回 `detailUnavailable`,不得回显伪造或旧版本遗留的原始 detail。 +- 新增只读模型工具 `agent.action_history`,只能查询当前 Agent 的持久终态动作。输入支持 `runId / actionId / tool / status / limit`;`limit` 默认 5、上限 10,省略 `runId` 时只查询当前 run。结果优先使用 terminal receipt,并兼容折叠历史 terminal observation;旧记录缺少安全结构化 detail 时显式返回 `detailUnavailable`。 +- `agent.action_history` 自身默认不出现在未指定 `tool` 过滤条件的结果中,避免模型查询动作递归污染历史;只有显式传入 `tool=agent.action_history` 时才允许查询它自身的 receipt。 +- 权限复用 `agent.audit`,默认 `auto`,仍可由项目或 per-Agent policy 改为 `confirm` 或 `deny`。该工具只读,不推进 project revision、不改变 verification gate,也不认领 join。 +- terminal receipt 写入失败时,对应 durable action 必须进入 `needs-reconciliation`,不得出现动作已经完成但长期历史静默缺失。恢复只能按原 `actionId` 幂等补齐 receipt,不能重放动作。 +- receipt 幂等复用 `recordType + agentId + runId + actionId` 定位,但命中旧记录后必须继续全等复核 `taskId / sessionId / actionFingerprint / tool / executionMode / status` 和结果摘要,任何冲突都失败关闭。普通 Agent DB append 明确拒绝 `agent.runtime.action_receipt`;幂等动作入口只接受字段完整、身份合法且 status 终态的 receipt,不能被任意 recordType 或非终态记录借用。Agent DB 尾部因进程强杀形成不完整 JSONL 时,只允许在追加锁内修复最后一条不完整记录;中间损坏仍失败关闭。单条 JSONL 最大 1 MiB;receipt 幂等全量扫描在文件超过 256 MiB 或完整非空记录超过 100 万条时失败关闭,不能把该阈值误解成通用 append 自动轮转上限。普通审计在约 192 MiB 或 999,936 条的任一软上限停止,同时为字节容量和记录门槛预留 64 条最大 1 MiB terminal 记录;只有 terminal receipt、带 actionId 的终态 observation / observed 和 reconciliation 能使用预留区,`command-failed / verification-failed` 与通用 `failed` 同属终态,非终态记录不得消耗预留。普通和终态追加都在同一 DB 句柄锁内真实计数,连同字节容量一起失败关闭,不做静默轮转。 +- Agent DB 的跨进程安全以已验证文件句柄为边界,不再依赖可被路径替换的普通 lock 文件。Unix 从可信目录句柄以 `openat + O_NOFOLLOW` 打开并校验普通文件、`nlink=1`,随后直接 `flock` DB 句柄;Windows 使用相对 `NtCreateFile`,拒绝 reparse point / hardlink,并以独占 share 持有句柄。每次 append、尾部补换行或截断修复都执行 `flush + sync_data`;Unix 新建 `.agent` 后同步项目根目录,新建 `agent.db` 后同步 `.agent` 目录,写入完成后再复核路径身份。同 UID 恶意进程制造的 rename / hardlink ABA 不在 v1 绝对隔离承诺内。 +- 普通历史读取使用最近 32 MiB 的有界尾窗,最多保留 16,384 个完整 JSON object,超出时返回 `truncated=true`;尾窗恰好落在记录边界、UTF-8 半字符或精确 1 MiB 最后一条记录时都不能丢弃合法完整记录。 +- pending action 的精确 project revision / verification gate 复核继续约束写入、命令、验证、预览证据和会认领 join 的协调动作;`memory.read / conversation.read / asset.list / project.index / project.search / project.diff / git.inspect / file.list / file.read / task.list / agent.action_history` 等纯读取动作在其他 Agent 推进 revision 后允许读取最新事实,并把结果作为新 observation 返回。适用仓库规范的 fingerprint gate 仍独立生效,不能借只读分类绕过规范漂移。 +- 共享项目事实读取必须使用项目一致性锁;等待锁后重新读取 durable pending sidecar,并与调用方携带的完整 pending 对象逐字段一致,再重验 policy 和 repository fingerprint。pending 被替换、迁移或损坏时失败关闭,不能继续使用锁外旧对象。`agent.run_status` 的 all-join claim 在同一项目锁内完成 revision / verification gate 复核与认领,避免检查通过后项目状态又发生变化。 +- 父 run 创建 `joinMode=all` 的隔离组后,在所有子结果终态且 ready join 已由当前父 run 的 `agent.run_status` action 认领前,Runtime 必须阻止最终回复和 `agent.action_history`,并要求继续查询状态;认领成功后才解除门禁,避免模型绕过唯一 join 提前收束或先查询不完整动作历史。 +- all-join 门禁不能只在 planning 阶段检查。最终回复取得项目锁后、创建 finalization journal 前必须再次读取持久 join 交付并确认已由当前父 run 认领;未认领时按 `Stale` 回到同 run planning,不写 journal、assistant 或 completed,关闭 planning 到最终提交之间的竞态窗口。 +- all-join 等待不能占用父 Agent lane 或伪装成新的 continuation。`waitingGroups > 0` 时父 run 保存 task / state / context / audit 后直接释放 lane;最后一个 child 在父 lane 释放前后完成的竞态由持久 `parent-wake` delivery 和释放后有界复核共同关闭。父 run 已恢复 planning / running 时,重复 dispatch 只确认现状,不创建 join task。 +- policy denied 和未知工具也要先落 durable observed pending,再追加 terminal receipt。terminal observation 一旦持久化,就不能在 receipt 前响应取消;receipt 成功后再结束取消流程。receipt 写入失败统一进入 `needs-reconciliation`,恢复仅补回执,不重放工具。 +- action 关联的 task / event 投影按 `runId + actionId + phase` 幂等,同一动作允许从 `waiting-for-confirmation` 合法推进到终态 observation,但同阶段身份冲突继续失败关闭。Agent DB 终态 observation 扫描跳过同 action 的非终态前置记录,只对既有终态做全字段复核;`recentToolCalls` 按 actionId 原位更新,不能让 waiting 投影遮住后续 `ok / failed`。 +- `agent.action_history` 结构化 detail 上限 7,200 字符;超预算时先移除可选字段,再删除完整最旧项,禁止用字符截断破坏 JSON,禁止清空 `runId / actionFingerprint`。上下文清洗覆盖 Unix、Windows 盘符、正反斜杠 UNC、`file:/...`、`file:///...` 和百分号编码的 `file:` URI,绝对路径统一替换为 ``,同时保留普通相对文本与 HTTP(S) URL。 +- 后台 planning 与最终回复遇到 `Timeout / Connectivity / Transport` 或上游 `408 / 429 / 5xx` 时最多额外重试 5 次,按 `500 / 1000 / 1500 / 2000 / 2500ms` 退避;配置、请求、流协议、反序列化错误及其他 `4xx` 不重试。重试只发生在工具计划执行前或最终回复落盘前,不能重放已完成副作用。 +- 本轮只提供模型工具,不新增前端动作历史弹窗。UI 继续显示最近动作投影;后续若增加历史查看能力,必须使用点击后打开的独立弹窗,不得在当前面板下方追加内容。 + +### 2026-07-13 真实验收结果 + +- Rust 测试已覆盖 receipt 折叠、组合过滤、默认值与上限边界、跨 Agent 隔离、敏感信息清洗、历史旧记录 `detailUnavailable`、JSONL 尾部修复与中间损坏失败关闭,以及 `needs-reconciliation` 恢复按原 `actionId` 补齐。 +- 真实 Provider 已实际调用 `agent.action_history`,并证明返回的 `agentId / taskId / sessionId / runId / actionId` 与 `.agent/agent.db` identity 对齐;Runner 强制终止后恢复保持动作零重放、敏感内容零泄漏。 + +发布 AppData 中配置的真实 `gpt-5.5` 已通过最终 V1.6 `llm-runtime` 套件。模型实际调用 1 次 `agent.action_history` 并返回 1 条当前 Agent、当前 run 的历史,递归结果为 0;返回 identity 与 `.agent/agent.db` 全字段对齐。最终形成 94 条 task、158 条 event、164 条 Agent DB 记录、11 条合法工具协议、13 次结构化成功工具执行和 24 条 terminal receipt;主 run 含 18 条 receipt,要求覆盖的 3 类工具均有回执,重复 receipt、重复 action、重复 message、receipt identity 冲突、密钥泄漏和项目诱饵泄漏均为 0。 + +Runner 强制终止后恢复原 run / session 且身份稳定,project revision 为 3;项目验证、桌面 / 移动浏览器验证、3 个隔离实例和唯一 all-join 认领均通过,本次真实竞态走“活跃父 run 直接认领”路径,未创建 continuation task,真实执行顺序证明 `agent.action_history` 只在隔离结果被父 run 认领后发生。确定性 Rust 集成用例另覆盖 `parent-wake` 等待路径、多次 resume 零 LLM 请求、同父 run 唤醒和无 continuation。pending `file.read` 在其他 Agent 推进 revision 后仍读取最新事实;写入、命令、验证、预览和 join 认领保持严格 gate。 + ## 验收命令 - `npm run ai-game-creator-shell:typecheck` diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 675b1cc3e..c54c95fea 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -22,14 +22,19 @@ 同一文档的“V1.3 多文件变更集与内容审查”作为复杂代码修改的新事实源。`project.patchset` 在一个确认动作和一把项目锁内预检最多 12 个 create / update / delete,自动 checkpoint、只推进一次 revision,并以 SHA-256 乐观并发条件和回滚语义避免半完成修改;`project.diff(includeContent=true)` 返回有界统一 diff hunks。它不开放任意 `git apply` 文本,也不替代修改后的可执行验证。 +同一文档的 V1.4-V1.6 继续作为当前事实源:V1.4 用只读 `git.inspect` 提供有界工作树状态和安全 hunks;V1.5 用跨 context window 的 milestones 保留已完成副作用与验证证据;V1.6 用 terminal receipt 和 `agent.action_history` 提供可恢复动作回查,并对未认领 all-join 的最终回复与动作历史设置双重完成门禁。历史能力清单与这些版本冲突时,以 Runtime V1.1 技术方案和当前代码为准。 + 2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join;95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件仍要求 External Editor API 配置,缺失时必须返回 `BLOCKED(editorApi)`,不得记为通过。 2026-07-13 V1.3 真实验收:同一真实 Provider 套件已改为先读取 SHA-256,再用唯一一次 `project.patchset` 同时更新和创建文件,并使用自动 checkpointId 读取 2 项内容 hunks;prepared / completed 审计各 1 条、patchset revision 增量为 1,Runner 强杀恢复、命令和项目验证、双视口浏览器验证、隔离 Agent join、重复副作用与密钥扫描继续全部通过。 +2026-07-13 V1.6 最终真实验收:`llm-runtime` 形成 94 条 task、158 条 event、164 条 Agent DB、11 条合法工具协议、13 次成功工具执行和 24 条 terminal receipt;主 run receipt 为 18。`agent.action_history` 实际调用 1 次、返回 1 条、递归结果 0,且只在父 run 认领唯一 all-join 后执行;Runner 强杀恢复、revision 3、3 个隔离实例 / 2 个模板、双视口浏览器证据、重复项、身份冲突、半完成文件、密钥和诱饵泄漏均通过结构化检查。本次竞态走活跃父 run 直接认领路径,join continuation 数量为 0;`parent-wake` 等待路径由确定性 Rust 测试覆盖。这些数字是单次观测结果,不是脚本固定阈值;`full` 套件仍需 External Editor API,缺失时保持 `BLOCKED(editorApi)`。 + 以下能力清单保留 Runtime V1 的演进记录;其中“App 进程内 tokio task”“跨进程同项目写入不作为支持目标”和“恢复到当前 App 进程”的旧描述均已由 V1.1 替代。当前边界是 App / CLI 只落账并唤醒同一发布二进制的独立 Runner,append-only JSONL 使用进程内锁加 OS 文件锁,恢复继续由 Runner 接管同一 run / session。 Agent Runtime 负责: +- 当前执行边界:App / CLI 只负责 durable 入队、查询和唤醒;同一发布二进制的独立 Runner 取得项目 owner 与 per-Agent OS 锁后执行 loop。Agent DB、conversation、events、tasks、activity 和 output 的 append-only JSONL 同时使用进程内互斥与 OS 文件锁,恢复由 Runner 接管原 run / session,不再重接到当前 App 进程。 - 2026-07-12 安全边界补充,2026-07-13 调整:`project.verify` 的 script 最多 160 个字符,固定使用系统 script shell,并在解析和执行前拒绝项目级 `.npmrc` 改写 npm 语义。Runtime context bundle 绑定 `projectId / agentId / taskId / sessionId / runId / source / task`,结尾换行计入 128 KiB 上限;恢复时同时校验 `nextLoopIndex`、context window、当前窗口已完成轮数、观察指纹、计划、observation 数量,以及 `contextStalled` 只能位于非零上下文窗口边界。bundle 通过项目内无符号链接路径原子写入,并从同一文件句柄最多读取 128 KiB;项目路径和常见 `sk- / GitHub / npm / AWS / JWT` 凭据统一脱敏。工具 observation 进入 context checkpoint 后才删除 `observed-*` 动作账本,避免重启时旧 ledger 抢占有效 bundle。 - 总任务拆分和任务图状态流转。 @@ -46,6 +51,7 @@ Agent Runtime 负责: - 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,通过 `agentLlm.` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。这里的 `` 以 manifest taskId 为规范值,旧 `group-role` 别名只作为兼容输入映射到 taskId。 - 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。 - 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作;Runtime V1 会为单 Agent 对话和生成 loop 中的角色 brief 写入独立 runtime state / event,先解决“每个 Agent 正在做什么、跑到哪一步、最近一次 task/run 是什么”的可观测性。 +- 历史边界说明:下一条“后台任务能力”保留 V1.1 前的进程内演进记录,其中 App 内 tokio task、进程内 drain、旧工具箱和“不是独立 OS 进程”的描述均已失效;当前执行边界以上文独立 Runner 说明为准。 - 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/.json`、`.agent/runtime/events/.jsonl`、`.agent/runtime/tasks/.jsonl` 和 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:每轮让该 Agent 输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具动作,写入 `thinking_summary / plan / action / observation / response / error` 事件,再把 observation 放入下一轮 prompt 让 Agent 修正计划、继续行动或用空 actions + response 收束;单 Agent Runtime 每 6 轮形成一个上下文压缩窗口,而不是把 6 轮作为整个 run 的固定上限。窗口产生新的独立 observation 时压缩上下文并在同一 run 继续;最近 6 轮没有独立进展或相邻窗口重复时以 `failed / budget-exhausted` 和 `loop-budget-exhausted` 终止,不再生成总结伪装完成。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`file.write`、`task.create`、`task.update`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`project.checkpoint` 可在写入或批量修改前创建本地 checkpoint,`project.restore` 可在确认后把项目恢复到指定 checkpoint,`file.write` 只能写项目内相对路径并记录审计,`task.create` 只能追加经过校验的新 manifest 任务,`task.update` 只能更新已有 manifest 任务状态,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加 `memory/blackboard.md`,`agent.message` 给目标 `.agent/conversations/agents/.jsonl` 写入 tool 留言,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列,策略拒绝时不执行工具并返回 `blocked` observation;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务和最近事件,`read_game_creator_agent_runtimes` 批量读取所有规范 taskId 的 runtime;开发窗口和项目内 Agent 对话弹窗的 Runtime 状态面板展示最近事件、最近任务、当前目标、当前任务、当前动作、等待对象、下一步和运行阶段,主窗口 Agent 状态列表展示当前目标、任务、动作、等待对象和运行阶段摘要。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。 - 2026-07-10 补充:Agent Runtime state 新增 `toolPolicy`,按当前项目 `.agent/policy.json` 派生工具级 `allowedTools / autoTools / confirmTools / deniedTools` 快照;后台 planning prompt 会带入该快照,让 Agent 在规划时知道哪些工具会自动执行、需要确认或被拒绝。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略;实际执行仍以 Runtime 的白名单和项目权限 gate 为准。 - 2026-07-10 补充:`.agent/policy.json` 新增 `agentPolicies`,可按规范 Agent id 分别配置 `deniedCommands / confirmCommands`。有效策略为“项目级策略 + Agent 级策略”的保守叠加:项目级拒绝 / 确认仍对所有 Agent 生效,Agent 级策略只能进一步限制该 Agent,不能放宽项目级策略,拒绝优先于确认。主聊天新增 `/agent-policy-deny Agent 命令`、`/agent-policy-allow Agent 命令`、`/agent-policy-confirm Agent 命令` 和 `/agent-policy-auto Agent 命令`,写入前仍走 `project.policy_write` 确认卡。 @@ -53,6 +59,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台 Agent 任务支持按 Agent / runId 取消和重试。取消先写 `.agent/runtime/cancel//.json`;pending 任务被取消后不会再被同一 Agent drain 消费,running 任务在 worker 仍持锁时只对外投影为 `status/phase = cancelling`,必须等当前 LLM / 工具调用返回后的检查点真正停止后,才由持锁 worker 把任务 JSONL、事件流和 `agent.db` 记录到 `cancelled`,期间不允许重试,也不保存最终 assistant 回复。重试只能基于已有非 running / pending / waiting-for-confirmation / cancelling 任务创建新的 run,继续复用同一 Agent 队列锁和 `agent.resume` 自动权限。 - 2026-07-10 补充:后台 Agent 任务的 `runId` 在同一 Agent 内必须唯一,因为任务快照按 runId 去重表示同一 run 的最新状态。`start_game_creator_agent_runtime_task`、`agent.delegate` 和 retry 入队前会读取该 Agent 全量 task JSONL 历史;如果调用方传入的 runId 已存在,Runtime 自动追加 `-dup--` 后缀生成实际 runId,并在任务队列、delegate observation 和 `agent.db` 审计中使用该实际值,避免两个独立任务互相折叠。 - 2026-07-10 补充:后台 Agent 任务的 `memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆。若 action 指定其他 `agentId / targetAgentId`,Runtime 返回 `blocked` observation,不写目标 Agent 私有记忆,也不写 `agent.runtime.memory.write` 审计;跨 Agent 共享稳定结论必须使用 `blackboard.write`,给单个 Agent 留上下文必须使用 `agent.message`。 +- 历史边界说明:下一条“只做进程内 JSONL 串行化、跨进程不支持”已由 V1.1 的进程内互斥 + OS 文件锁替代,仅保留为演进记录。 - 2026-07-10 补充:本地 append-only JSONL 追加写入按目标文件路径做进程内串行化。`.agent/agent.db`、项目 / Agent 对话、Runtime events、Runtime tasks、Agent activity 和 output 都通过共享 helper 写入完整 JSON 行,防止多个后台 Agent 并行运行时 record 内容与换行交错;该约束服务于当前单客户端进程内并行,不把跨进程同项目写入作为 v1 支持目标。 - 2026-07-10 补充,2026-07-12 冻结并更新:后台 Agent 待确认工具动作改用 durable `AgentRuntimePendingToolAction`。完整记录包含精确工具 action、当前 task/run、loop 轮次、action 序号、计划、已有 observations、续跑上下文、创建时的全局 project revision 和 per-run verification gate;schema 升级为 `game-creator-pending-action.v3`。写入前拒绝密钥、Token、Cookie、App 配置痕迹和项目绝对路径,再通过临时文件替换原子写入 `.agent/runtime/pending-actions//.json`。公共 runtime state 的 `pendingToolAction` 只暴露 `actionId / actionFingerprint / tool / inputSummary / reason / requestedAt` 安全摘要。`actionFingerprint` 绑定工具名、完整输入 JSON 与实际执行 task context;`actionId` 再绑定 run、loop、action 序号和 occurrence nonce,防止同一 run 内相同输入的旧 UI 点击批准后一次动作。开发窗口和项目内 Agent 面板的“确认继续 / 拒绝并继续”都提交当前 `runId + actionId`,Runtime 与私有动作、公共摘要交叉校验后才迁移账本状态。批准或自动执行前必须重读并匹配创建时的全局 revision 与 gate;任一漂移都进入 `needs-reconciliation`,不得执行旧动作。确认后在同一 run 直接执行原 action 并把 observation 接回后续 loop,不创建新 run、不要求模型重复动作;拒绝不执行工具,写 `blocked` observation 后在同一 run 继续规划。账本状态使用 `pending-confirmation / approved / executing / observed-approved / observed-rejected`:重启可恢复 waiting、未执行的 approved action 或已落盘 observation;若进程中断在 `executing`,Runtime 进入 `failed / needs-reconciliation`,禁止自动重放外部副作用,开发者核对项目状态后先取消原任务。旧版 pending action 或缺失 revision / gate 关联的记录恢复时必须失败关闭,不得按默认值补齐、自动重放外部副作用或写 completed。等待期间同 Agent 新任务保持 `pending`,重启不会越过 waiting run,确认、拒绝或取消后再串行排空。`.agent/runtime/` 作为私有控制面,不允许通用文件工具列出、读取或写入;`file.delete` 进一步禁止整个 `.agent/**`。每 Agent 锁使用唯一 token,旧持有者不会删除替换后的新锁,Linux 上仍存活的其他进程锁不会按超时强占。 - 2026-07-10 补充:per-agent 锁最终采用 OS 级文件锁,取代上一条末尾的 token/PID/超时抢占方案。Unix 使用非阻塞独占 `flock`,Windows 使用禁止共享的文件句柄;`.agent/runtime/locks/.lock` 只保存诊断元数据并可长期存在,真正所有权随文件句柄和进程生命周期释放。任何确认、拒绝、恢复、取消和队列 drain 都必须使用同一系统锁;确认、拒绝和取消只能在拿锁后重新读取当前 runtime、task 与待确认动作再迁移状态,恢复也必须先拿锁再读取 durable pending action 或 recoverable task,不能用拿锁前的旧快照覆盖并发结果。waiting 状态只允许短暂等待原 worker 正常释放,不得按状态删除并重建锁文件;running 取消在拿不到锁时只保留取消 tombstone,由原 worker 在 LLM / 工具成功或失败返回后的检查点收束。 @@ -75,7 +82,7 @@ Agent Runtime 负责: - 2026-07-12 加固:删除动作在取得项目写锁后重新判定当前权限策略,锁竞争期间新增 deny 必须阻断,新增 confirm 必须让未获人工确认的自动动作退回待确认。Agent 私有记忆和客户端开发面板可修改项目内容的命令同样在项目锁内保守推进全局 revision;这样文件、记忆、资产、草案、导出或 checkpoint 恢复改写待删除目标后,旧 pending action 会在实际删除前因 revision 漂移失败关闭。`.agent/manifest.json` 的临时文件替换兼容 Windows:不能直接覆盖时先保留 `.manifest.json.previous`,主文件缺失可从恢复副本读取,安装新文件失败则恢复旧 manifest。 - 2026-07-11 补充,2026-07-12 更新:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds`;`script` 允许项目根 `package.json` 中的固定脚本 `check / typecheck / test / lint / build`,以及以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本。脚本必须真实存在于项目根普通文件 `package.json` 的 `scripts` 中,`expectedCommand` 必须与执行时重新读取的脚本正文完全一致,`timeoutSeconds` 为 1-300;当前执行器只支持 npm,非 npm `packageManager` 或 pnpm / yarn / bun 锁文件明确失败,不接受自由命令、参数或工作目录。`pre* / post*` 生命周期脚本名不在允许范围,执行器再通过 npm `--ignore-scripts` 禁止所选脚本关联的 pre/post lifecycle。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command run,Agent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。项目级 revision 独立持久化到 `.agent/runtime/project-revision.json`;每个 run 的 gate 与验证结果持久化到 `.agent/runtime/verification//.json`。`file.write / file.patch / file.delete / project.restore` 在项目写锁内、实际修改前先保守推进 revision,并把 `requiresVerification` 单向置为 `true`,操作失败或崩溃也不回退;成功的 `project.verify` 或 `command.run_limited / game.static_smoke` 只为执行时的当前 revision 写入凭证。空 actions 前如果门禁仍要求验证、验证失败或凭证 revision 已过期,Runtime 注入 `runtime.verification: blocked` 并继续 replan;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成当前 revision 的通过结果则保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke` 与 `game.static_smoke` 保持原边界,不暴露该开发工具。 - 2026-07-11 补充:开发验证可用 `npm run ai-game-creator-shell:agent-task -- [--init] ` 无 UI 启动单 Agent 后台任务。CLI 只负责可选初始化、调用现有 Runtime、按 runId 轮询终态并打印 `status / phase / replyText / pendingActionId`,不复制 planning 或工具执行逻辑;默认 10 分钟轮询上限。`waiting-for-confirmation` 会以非零状态退出并要求转到开发窗口确认,CLI 不提供跳过项目权限的自动确认参数。该入口用于真实 provider 的可重复端到端验收,不进入普通用户界面。 -- 2026-07-11 补充:后台工具规划与最终回复的 LLM 请求新增可恢复错误重试:`LlmError::EmptyResponse` 原样自动重试最多 3 次;`Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外重试 2 次并按 `500ms / 1000ms` 退避。配置、请求、流能力、反序列化错误及其他 `4xx` 不重试。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,因此不会重复执行已经落盘的工具副作用;重试耗尽后仍写入原有 `error / turn.failed` 事件并把失败消息追加到当前 Agent 会话。 +- 2026-07-11 补充,2026-07-13 调整:后台工具规划与最终回复的 LLM 请求新增可恢复错误重试:`LlmError::EmptyResponse` 原样自动重试最多 3 次;`Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外重试 5 次并按 `500ms / 1000ms / 1500ms / 2000ms / 2500ms` 退避。配置、请求、流能力、反序列化错误及其他 `4xx` 不重试。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,因此不会重复执行已经落盘的工具副作用;重试耗尽后仍写入原有 `error / turn.failed` 事件并把失败消息追加到当前 Agent 会话。 - 2026-07-11 调整,2026-07-12 更新:后台单 Agent planning loop 每 6 轮形成一个上下文压缩窗口,每轮最多 3 个工具动作;6 轮是窗口大小,不是单个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口的结束轮次;待确认或重启恢复后按 context bundle 的 `nextLoopIndex` 在同一 run 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续下一窗口,最近 6 轮没有独立进展或相邻窗口指纹重复时才写入 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不生成总结伪装完成。这只调整后台单 Agent Runtime;游戏草案 Generator/Evaluator 仍保持独立的 3 轮修复预算。旧摘要中“后台最多 3 轮”或“整个 run 最多 6 轮”的描述不再有效。 - 2026-07-12 补充并冻结,2026-07-13 调整容量:后台 Agent 每个 run 的可恢复 planning 上下文通过临时文件替换原子写入 `.agent/runtime/context-bundles//.json`,绑定 Agent、Task、Session、Run、任务正文和 revision / verification gate 关联,schema 固定升级为 `game-creator-runtime-context-bundle.v2`;保存 `nextLoopIndex`、当前窗口、计划、fallback response、压缩后的 observation、上一窗口指纹和 `contextStalled`。stale continuation 必须清空旧 actions 与 fallback response,保留 blocker、loop 位置和窗口进度;`contextStalled` 一旦在窗口边界成立,同 run 重规划和进程重启都不得清除。`runtime.verification` 的上下文指纹忽略动态 revision 数值前缀,仅保留稳定处置指引;成功 `project.verify / game.static_smoke` 的动态命令输出不进入窗口指纹。revision 数字或时间戳持续变化本身不算独立进展,重复 stale 最迟在相邻窗口指纹重复时以 `loop-budget-exhausted` 终止。单文件最多 128 KiB、最多 12 条 observation;普通 observation detail 仍压到 1,600 字符,最新内容 diff 可保留到 24,256 字符并在窗口压缩时优先保留。写入前统一截断并过滤敏感内容和项目绝对路径,安全校验失败时拒绝落盘。读取时要求普通文件并校验 schema、Agent、Session、Run、任务正文、observation 数量和 revision / gate 关联,身份不一致时拒绝续跑。v1 context bundle 恢复必须失败关闭,不自动迁移,也不能把缺失 gate 当成 `requiresVerification=false`;revision 与验证资格仍以锁内重读的独立持久化文件为准,bundle 只保存恢复上下文。该文件属于 Runtime 私有控制面,不等同于根级 `.agent/context.bundle.json`,不得由通用文件工具暴露。 - 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。 @@ -99,7 +106,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。 - 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。 - 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`。 -- 2026-07-10 补充:Runtime 增加 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时会对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/.jsonl` 中上一进程遗留的 `running` 或仍为 `pending` 的任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`;恢复动作写 `agent.runtime.background_task.recovered` 审计记录。该能力只是把本地 JSONL 队列重接到当前 App 进程,不是独立常驻 worker,也不承诺恢复已经发出的上游 LLM 请求。 +- 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。 - 2026-07-10 补充:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。普通前台聊天仍可使用角色上下文。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充:同一 Agent 的前台直接聊天、流式聊天和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。前台聊天不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的聊天结果改判为失败。不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 - 2026-07-10 补充:默认 `agent.resume=confirm` 时,客户端自动恢复命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口在首次读取项目 Runtime 时都必须显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 @@ -374,6 +381,11 @@ game-project/ - 2026-07-12 补充:开发单 Agent 聊天页的 Runtime 容器即使为空也必须保留网格行位,消息区和输入区固定落在第 5、6 行;消息区自身使用 `clamp(260px, 40vh, 380px)` 明确高度,长历史只增加消息区 `scrollHeight`,不得改变主面板高度。消息区仅在滚动位置接近底部时自动跟随最新回复,用户主动向上查看历史后,状态变化和流式片段不得强行拉回底部;切换会话、重新读取历史或主动发送新任务时恢复跟随。保存用户消息、连接 LLM、等待首个片段和流式接收期间,消息区持续显示等待状态并标记 `aria-busy=true`。OpenAI-compatible 流式响应中的空数组或 `null` `choices` usage / metadata 包必须正常消费;首个片段前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误可由 Rust 层回退一次非流式请求,鉴权、额度、上游状态、超时和连接错误不得由前端再次请求。 - 生成 loop 中的角色 brief 也写同一套 Agent Runtime state / event:active 角色用 `source=generate-draft` 和当前 `runId` 标记正在读取上下文、调用角色专属 LLM 或本地编排、生成 brief、完成或失败;carry-over 角色同样写入开始 / 完成事件,但不会伪装成重新调用 LLM。主窗口 Agent 状态列表、开发单 Agent 聊天页和项目内单 Agent 对话弹窗只读展示当前 Agent 的 runtime 状态、最近 task/run、阶段、当前目标、动作、等待对象、下一步、计划、观测和最近工具动作;这只是 V1 可观测性,不代表已经有独立后台常驻进程或可中断任意上游 LLM 请求。 - `.agent/agent.db` 当前作为最小本地项目索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题、本地产物路径、checkpoint 和 diff 摘要,上传 / 登记 / 画板导入资产时追加 `asset.register` 或 `asset.update`;v1 不引入 SQLite 依赖。 +- 2026-07-13 加固:Agent DB 单条 JSONL 上限 1 MiB;receipt 幂等全量扫描在文件超过 256 MiB 或记录超过 100 万条时失败关闭,该阈值不是通用 append 自动轮转上限。普通 append 禁止写 receipt,幂等动作入口只接受字段完整的终态 receipt。普通审计约在 192 MiB 或 999,936 条停止,并给字节 / 记录门槛预留 64 条最大 1 MiB terminal 记录,只有 terminal receipt、带 actionId 的终态 observation / observed 和 reconciliation 可以使用;`command-failed / verification-failed` 也属于终态。普通和终态写入都在 DB 句柄锁内真实计数并检查字节容量,receipt 判重流式校验全部同 key,不复用锁外快照;普通读取保留最近 32 MiB、最多 16,384 个完整 JSON object。Unix 使用 `openat + O_NOFOLLOW + flock + nlink=1`,Windows 使用相对 `NtCreateFile` 并拒绝 reparse point / hardlink;append 和尾部修复执行 `flush + sync_data`,Unix 新建 `.agent` 和 `agent.db` 后分别同步父目录,写入前后复核路径身份。 +- 2026-07-13 一致性加固:共享项目事实读取等待项目锁后必须重读 durable pending sidecar,并与调用方完整 pending 对象逐字段一致;动作历史读取还要重新验证 receipt 的终态 status、actionId、fingerprint、executionMode、tool、task / session ledger 绑定,并按当前工具白名单重新解析 `safeDetail`。运行时路径清洗覆盖 Unix、Windows 盘符、正反斜杠 UNC、`file:/...`、`file:///...` 和百分号编码绝对路径,同时保留相对文本与 HTTP(S) URL。 +- 2026-07-13 收束门禁:父 run 只要存在尚未终态或尚未由当前父 run 认领的 `joinMode=all` 隔离组,就不能生成最终回复,也不能调用 `agent.action_history`;模型必须先通过 `agent.run_status` 取得并认领唯一 join。`waitingGroups > 0` 时父 run 持久进入 `waiting-for-isolated-join` 并释放 lane,重复 resume 不请求 LLM;最后一个 child 就绪后以 `deliveryTarget=parent-wake` 唤醒同一 run,不创建 continuation。动作历史优先使用 receipt、兼容折叠旧 terminal observation,结构化 detail 上限 7,200 字符。最终真实 `gpt-5.5` E2E 形成 94 条 task、158 条 event、164 条 Agent DB、24 条 terminal receipt、3 个隔离实例和唯一 join 认领,动作历史只在 join 认领后执行;Runner 强杀恢复、项目验证、桌面 / 移动浏览器证据、重复项和密钥泄漏门禁全部通过。 +- 2026-07-13 最终提交门禁:最终回复取得项目锁后、创建 finalization journal 前再次读取持久 all-join 交付并确认当前父 run 已认领;未认领按 `Stale` 回到同 run planning,不写 journal、assistant 或 completed。 +- 2026-07-13 action 投影恢复:task / event 使用 `runId + actionId + phase` 区分等待确认与终态 observation;Agent DB 终态 observation 幂等扫描忽略同 action 的非终态前置记录,只对终态做全字段冲突检查;`recentToolCalls` 对同 action 原位更新,确保界面不会长期停留在 waiting 状态。 - `game.generate_draft`、资产登记 / 导入、记忆写入、预览状态写入、checkpoint / restore、agent 生命周期控制、画板资源回流 / 生成和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。`confirmCommands` 可把索引、状态读取、资产登记、checkpoint、预览、agent 生命周期、画板资源回流 / 生成、memory 读写删除和 conversation 读写等命令转成项目策略确认,命中时用户确认后才执行;用户可用 `/policy-confirm 命令` 加入确认列表,用 `/policy-auto 命令` 移除确认项。 - `ArtifactWriter` 写入最终产物前把当前项目文件保存到 `.agent/checkpoints//`,写入后把新增、修改、删除计数记录到 `.agent/agent.db`;聊天命令 `/checkpoint`、`/checkpoints`、`/diff checkpoint-id` 和 `/restore checkpoint-id` 允许用户手动保存、列出最近 checkpoint、对比和确认回滚到 checkpoint,回滚时会删除 checkpoint 后新增的受跟踪项目文件。`.agent/runtime/` 属于运行观测状态,不进入项目索引、checkpoint diff 或 restore 删除范围。 - Planner、组内角色和 Generator 读取上下文前会先做安全过滤:拒绝 `.env*`、`game-creator.config*`、Authorization / Cookie / API Key / Token / Bearer 等密钥样式内容,并清理 `sk-*` / `tnr_sk_*` token;memory、资产摘要和 conversation JSONL 中被过滤的内容不进入 LLM prompt。