diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 81dc05d2f..62355787c 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -669,7 +669,18 @@ export async function cleanupSwarmTestProject(project) { } export function buildCargoCliArguments(cliArguments) { - return ['run', '--manifest-path', cargoManifestPath, '--', ...cliArguments]; + // `--quiet` only silences cargo's own build chatter; compiler errors and the + // CLI's stdout still come through. Without it the crate's several hundred + // dead-code warnings are reprinted on every spawn and bury the run output + // this script exists to show. + return [ + 'run', + '--quiet', + '--manifest-path', + cargoManifestPath, + '--', + ...cliArguments, + ]; } function spawnChild(command, args, options = {}) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 1fa86435f..d2ca31887 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -583,7 +583,16 @@ fn project_generic_submit_runtime_observation_locked( runtime.observations.push(summary.clone()); } runtime.pending_tool_action = Some(pending.summary()); - runtime.current_action = "Fast GDD 审批观察已落盘".to_string(); + // `currentAction` deliberately keeps the submit-point wording. The task + // projection below is idempotent on (runId, actionId, phase) and the submit + // already claimed that key at phase=completed; writing a second, differently + // worded projection under it is a hard identity conflict, not an append. + // That error used to abort this function after the standalone pending had + // already been rewritten, tearing it away from its still-unadvanced v4 + // batch — a torn pair the recovery scan can only mark needs-reconciliation, + // which then fails this function's `phase == "completed"` gate forever. + // The approval itself stays visible through the `gdd_decided` audit record + // and the `plan.submit_gdd.observed` event appended below. runtime.next_step = "按审批结果等待下一步策划续跑".to_string(); runtime.updated_at = unix_timestamp(); append_game_creator_agent_runtime_task_projection_once(root, &runtime, &pending.action_id)?; @@ -869,6 +878,42 @@ fn project_plan_session_locked( write_plan_session_atomic_locked(root, &next) } +/// Record why one receipt projection step fell back to `recoveryPending`. +/// +/// Every gap in `project_receipt_locked` collapses a distinct failure into the +/// same bool. The receipt is already the user-decision linearization point, so +/// none of these failures can surface as a command error; without this record +/// the only escaping symptom is `recoveryPending=true`, which says a projection +/// is behind but never which one or why. That is exactly how a torn anchor +/// pair reaches the recovery scan with its cause already discarded. +/// +/// Best-effort on purpose: a diagnostic must never turn a committed receipt +/// into a failed command, so the append result is deliberately dropped. +fn note_plan_gdd_projection_gap( + root: &Path, + receipt: &PlanGddApprovalV1, + step: &str, + detail: &str, +) { + let _ = crate::project::append_agent_db_record( + root, + serde_json::json!({ + "recordType": PLAN_GDD_APPROVAL_PROJECTION_GAP_RECORD_TYPE, + "projectId": receipt.project_id, + "agentId": PLAN_GDD_APPROVAL_AGENT_ID, + "gddId": receipt.gdd_id, + "version": receipt.version, + "sessionId": receipt.session_id, + "runId": receipt.run_id, + "approvalRequestId": receipt.approval_request_id, + "responseId": receipt.response_id, + "action": receipt.action, + "step": step, + "detail": redact_agent_runtime_project_paths(root, detail, 500), + }), + ); +} + fn project_receipt_locked( root: &Path, gdds: &[PlanGddV1], @@ -877,7 +922,8 @@ fn project_receipt_locked( let mut recovery_pending = false; let approvals = read_plan_gdd_approvals_locked(root)?; let index = build_plan_gdd_index_with_approvals(gdds, &approvals, &receipt.decided_at_utc)?; - if write_plan_gdd_index_atomic_locked(root, &index).is_err() { + if let Err(error) = write_plan_gdd_index_atomic_locked(root, &index) { + note_plan_gdd_projection_gap(root, receipt, "gdd-index-write", &error.to_string()); recovery_pending = true; } let latest = gdds.last().ok_or_else(|| { @@ -915,11 +961,15 @@ fn project_receipt_locked( .unwrap_or("ready_for_approval"); match render_plan_fast_gdd_markdown(projection_gdd, projection_status) { Ok(markdown) => { - if write_plan_fast_gdd_markdown_atomic_locked(root, &markdown).is_err() { + if let Err(error) = write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) { + note_plan_gdd_projection_gap(root, receipt, "markdown-write", &error.to_string()); recovery_pending = true; } } - Err(_) => recovery_pending = true, + Err(error) => { + note_plan_gdd_projection_gap(root, receipt, "markdown-render", &error.to_string()); + recovery_pending = true; + } } let comment_hash = plan_gdd_approval_comment_fingerprint(receipt.comment.as_deref())?; @@ -957,6 +1007,7 @@ fn project_receipt_locked( // Receipt is already the user-facing linearization point. Preserve // the committed result and let a later retry repair ordinary audit // I/O or capacity failures. + note_plan_gdd_projection_gap(root, receipt, "decision-audit-append", &error); recovery_pending = true; } @@ -964,7 +1015,13 @@ fn project_receipt_locked( let mut approval_pending_cleanup_eligible = false; let approval_pending = match read_plan_gdd_approval_pending_locked(root) { Ok(value) => value, - Err(_) => { + Err(error) => { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-read", + &error.to_string(), + ); recovery_pending = true; None } @@ -972,12 +1029,27 @@ fn project_receipt_locked( match approval_pending { Some(mut pending) => { if !pending_identity_matches_gdd(&pending, receipt_gdd) { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-identity", + "approval pending 与 receipt GDD identity 不一致", + ); recovery_pending = true; } else { let expected_status = format!("observed_{}", receipt.action); if !matches!(pending.status.as_str(), "awaiting_decision") && pending.status != expected_status { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-status", + &format!( + "approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}", + pending.status + ), + ); recovery_pending = true; // Do not remove a projection whose durable state belongs // to another decision action. @@ -994,13 +1066,27 @@ fn project_receipt_locked( match plan_gdd_approval_pending_fingerprint(&pending) { Ok(fingerprint) => { pending.pending_fingerprint = fingerprint; - if write_plan_gdd_approval_pending_atomic_locked(&root, &pending) - .is_err() + if let Err(error) = + write_plan_gdd_approval_pending_atomic_locked(&root, &pending) { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-write", + &error.to_string(), + ); recovery_pending = true; } } - Err(_) => recovery_pending = true, + Err(error) => { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-fingerprint", + &error.to_string(), + ); + recovery_pending = true; + } } } } @@ -1015,31 +1101,46 @@ fn project_receipt_locked( let generic_submit_consumed = match project_generic_submit_observation_locked(root, receipt) { Ok(consumed) => { if consumed { - if approval_pending_cleanup_eligible - && remove_plan_gdd_approval_pending_locked(root).is_err() - { - recovery_pending = true; + if approval_pending_cleanup_eligible { + if let Err(error) = remove_plan_gdd_approval_pending_locked(root) { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-remove", + &error.to_string(), + ); + recovery_pending = true; + } } } else { + // The anchors were left deliberately: the standalone pending + // may already carry the receipt observation while the v4 batch + // is still un-advanced. Name that state so the next recovery + // pass is not the first place the gap becomes visible. + note_plan_gdd_projection_gap( + root, + receipt, + "generic-submit-not-consumed", + "原 plan.submit_gdd 锚点未被 receipt 完整消费,保留锚点等待重放", + ); recovery_pending = true; } consumed } Err(error) => { - let _ = error; + note_plan_gdd_projection_gap(root, receipt, "generic-submit-observation", &error); recovery_pending = true; false } }; if receipt.action != "approve" { - if mark_static_delegate_delivery_user_revision_requested_at( + if let Err(error) = mark_static_delegate_delivery_user_revision_requested_at( root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, &receipt_gdd.root_run_id, &receipt_gdd.delegation_id, - ) - .is_err() - { + ) { + note_plan_gdd_projection_gap(root, receipt, "delivery-revision-mark", &error); recovery_pending = true; } } @@ -1051,7 +1152,8 @@ fn project_receipt_locked( .as_ref() .and_then(|session| session.latest_submitted_ref.as_ref()) .is_some_and(|reference| reference == &receipt_plan_ref(receipt)), - Err(_) => { + Err(error) => { + note_plan_gdd_projection_gap(root, receipt, "plan-session-read", &error.to_string()); recovery_pending = true; false } @@ -1059,8 +1161,8 @@ fn project_receipt_locked( let mut session_projection_ready = false; if receipt.version == latest.version || session_points_to_receipt { if let Err(error) = project_plan_session_locked(root, receipt_gdd, receipt) { + note_plan_gdd_projection_gap(root, receipt, "plan-session-project", &error.to_string()); recovery_pending = true; - let _ = error; } else { session_projection_ready = true; } @@ -1079,7 +1181,22 @@ fn project_receipt_locked( | PlanProviderUsageFoldOutcome::Unchanged | PlanProviderUsageFoldOutcome::Advanced, ) => {} - Ok(PlanProviderUsageFoldOutcome::Deferred) | Err(_) => { + Ok(PlanProviderUsageFoldOutcome::Deferred) => { + note_plan_gdd_projection_gap( + root, + receipt, + "provider-usage-fold", + "provider usage 折叠被推迟,事实尚未可归并", + ); + recovery_pending = true; + } + Err(error) => { + note_plan_gdd_projection_gap( + root, + receipt, + "provider-usage-fold", + &error.to_string(), + ); recovery_pending = true; } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index 0a99060a7..6d07d3d9e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -160,6 +160,8 @@ pub(crate) const PLAN_GDD_APPROVAL_DECISION_AUDIT_SCHEMA_VERSION: &str = "agent-runtime-plan-gdd-decided.v1"; pub(crate) const PLAN_GDD_APPROVAL_DECISION_AUDIT_RECORD_TYPE: &str = "agent.runtime.plan.gdd_decided"; +pub(crate) const PLAN_GDD_APPROVAL_PROJECTION_GAP_RECORD_TYPE: &str = + "agent.runtime.plan.gdd_projection_gap"; pub(crate) const PLAN_GDD_APPROVAL_PENDING_KIND: &str = "gdd-approval"; pub(crate) const PLAN_GDD_APPROVAL_SOURCE: &str = "project-supervisor-plan"; pub(crate) const PLAN_GDD_APPROVAL_AGENT_ID: &str = "project-supervisor"; diff --git a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts index 53185ad36..0f1999d65 100644 --- a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts +++ b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts @@ -1267,13 +1267,30 @@ describe('cargo CLI argument construction', () => { const cliArguments = ['--config-dir', 'fixture-config', '--llm-status']; const cargoArguments = buildCargoCliArguments(cliArguments); - expect(cargoArguments.slice(0, 2)).toEqual(['run', '--manifest-path']); - expect(path.isAbsolute(cargoArguments[2])).toBe(true); - expect(path.relative(appRoot, cargoArguments[2])).toBe( + // Assert the separator invariants rather than fixed positions: cargo flags + // may be added before `--`, but everything after it must reach the CLI + // unchanged, and the manifest must stay this shell's own. + const separatorIndex = cargoArguments.indexOf('--'); + expect(cargoArguments[0]).toBe('run'); + expect(separatorIndex).toBeGreaterThan(0); + expect(cargoArguments.slice(separatorIndex + 1)).toEqual(cliArguments); + + const manifestIndex = cargoArguments.indexOf('--manifest-path'); + expect(manifestIndex).toBeGreaterThan(0); + expect(manifestIndex).toBeLessThan(separatorIndex); + expect(path.isAbsolute(cargoArguments[manifestIndex + 1])).toBe(true); + expect(path.relative(appRoot, cargoArguments[manifestIndex + 1])).toBe( path.join('src-tauri', 'Cargo.toml'), ); - expect(cargoArguments[3]).toBe('--'); - expect(cargoArguments.slice(4)).toEqual(cliArguments); + }); + + it('keeps cargo build chatter out of the run output', () => { + // The dead-code warnings are reprinted on every spawn; without --quiet they + // bury the swarm output this script exists to surface. + const cargoArguments = buildCargoCliArguments(['--llm-status']); + const separatorIndex = cargoArguments.indexOf('--'); + + expect(cargoArguments.slice(0, separatorIndex)).toContain('--quiet'); }); it('parses the idle Runner shutdown marker', () => {