审批观察不再抢提交点的幂等键,做方案链路能收束了
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:
@@ -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 = {}) {
|
||||
|
||||
+137
-20
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user