审批观察不再抢提交点的幂等键,做方案链路能收束了

project_generic_submit_runtime_observation_locked 把 currentAction 改写成
「Fast GDD 审批观察已落盘」,然后用 pending.action_id 调
append_game_creator_agent_runtime_task_projection_once。但那把幂等键是
(runId, actionId, phase),提交点已经在 phase=completed 上用同一个 actionId
写过一条 currentAction=「Fast GDD v1 已完成 create-only 提交」。三项全同即判为
同一条投影,9 个比对字段里 currentAction 不同 —— 必然硬报错,不是偶发。

后果是撕裂写:报错发生在 standalone pending 已经被改成 observed-approved 之后,
v4 batch 还停在 ready/executing。两个恢复锚点从此不一致,恢复扫描只认原始
executing 形状,把策划子 Run 打成 needs-reconciliation;而重放路径的
phase == "completed" 闸门又因此永远过不去,撕裂再也修不回来。做方案链路的审批
因此从来没有成功过一次。

改法是让审批投影沿用提交点的 currentAction。9 字段全同,helper 视为重放直接
返回 Ok,链路继续往下推进 batch、清锚点、唤醒。审批这件事由 gdd_decided 审计
记录和 plan.submit_gdd.observed 事件承载,本来就不需要再占一条 task 投影——
全仓库另外 9 个调用点都是「一个 action 一条投影」,只有这里想写第二条。

同一处还补上诊断。project_receipt_locked 里 13 处失败原本全部塌缩成一个
recovery_pending bool,其中两处是显式 let _ = error; 丢掉错误原文。receipt 已经
是用户决策的线性化点,这些失败都不能报成命令错误,于是唯一逃出来的症状就是
recoveryPending=true —— 说不出是哪一步、为什么。真因就是这样被藏了整条链路。
现在每处先写一条 agent.runtime.plan.gdd_projection_gap 记录再置位,带 step 标签
和脱敏后的错误原文;记录是 best-effort 的,诊断不能把已提交的 receipt 变成失败。

真机验证(gpt-5.6-luna,未开 GUI):turn outcome=settled、
reconciliationAgentCount=0,state/phase=approved、approvedGddRef.version=1、
recoveryPending=false、两个锚点残留 0/0、project-planning 终态 idle/completed、
gdd_projection_gap 0 条、game/fast_gdd.md 落盘。修前同一条链路 2/2 复现
needs-reconciliation。

顺带给 buildCargoCliArguments 加 --quiet,压掉每次 spawn 重印的 cargo 进度行。
注意它不压 rustc 警告,只在重新编译时才有区别。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 10:34:40 +00:00
parent adfa6ed8d3
commit a8f3793865
4 changed files with 173 additions and 26 deletions
@@ -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 = {}) {
@@ -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;
}
}
@@ -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";
@@ -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', () => {