Compare commits

..

2 Commits

Author SHA1 Message Date
kdletters 920bf90e5a 合并主线更新并保留后端依赖收敛改动
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m22s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m43s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m50s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 8m4s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m6s
Project CI / Backend tests (pull_request) Successful in 9m38s
Project CI / Repository checks (pull_request) Successful in 14m27s
Project CI / Native shell tests (pull_request) Successful in 17m24s
Project CI / Frontend tests (pull_request) Successful in 22m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 16m24s
同步最新主线代码与协作约定,保留本次窄依赖、鉴权和追踪实现
2026-09-18 20:59:05 +08:00
kdletters 896fcecbc1 收敛后端依赖装配并完善异步追踪与鉴权
集中装配后台、External API 与编辑器受保护路由并保持既有方法和请求大小限制
将项目元数据及 External/MCP 鉴权迁移为组合根装配的窄依赖并补齐替代依赖测试
使用 RAII 修复请求取消和 panic 的在途计数并统一 HTTP 路由模板追踪
补齐 LLM 普通与流式调用的安全异步追踪和本地 Provider 回归
同步两处依赖锁文件、后端规范、运维指标合同及团队协作约定
2026-09-18 20:58:46 +08:00
110 changed files with 12695 additions and 1464 deletions
@@ -957,6 +957,9 @@ async function runInteractiveCargo(cliArguments, setActiveChild) {
return result;
}
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
const swarmConfirmationPromptPattern = /输入 approve 或 reject$/u;
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
@@ -1590,11 +1590,13 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) {
);
}
if (!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)) {
if (
!(
tauriConfig.build?.beforeDevCommand?.includes(
'run ai-game-creator-shell:dev-server',
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
)
) {
throw new Error(
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
);
@@ -1192,7 +1192,7 @@ async function main() {
function isDirectModuleExecution() {
return Boolean(
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
);
}
+1
View File
@@ -3931,6 +3931,7 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
File diff suppressed because one or more lines are too long
@@ -2,4 +2,8 @@
需要等待专业 Agent 时不得调用 respond_to_userRuntime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。
委派 `project-planning` 时,acceptanceCriteria 只写产物形状、覆盖范围与红线(例如必须交付 `game/fast_gdd.md`、必须原创、必须只定义一个 MVP 闭环),**不得替用户预先裁定产品取舍**。用户没有指定的玩法规则、数值、关卡量级、美术方向和目标人群,一律留给策划子 Agent 按其 3 轮问询预算决定是提问还是按默认建议填写;不要写“未指定的标注为立项假设”“自行假设后继续”这类指令,那会把问询预算作废。平台事实(自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览)由 Runtime 固定注入,属于已定事实,不得要求标为待定、建议或开放项。
`project-planning` 的澄清 continuation,必须按 A/B/“需要原型验证”三项合同原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。若用户后续自由填写推翻已确认决定,保留用户答案原文逐字不改写、不拆分、不搬轮次,并在被推翻决定后注明“已被第 N 轮回答推翻,以后者为准”,在新决定 topic 中写明推翻关系。
只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。
@@ -1069,10 +1069,15 @@ pub(crate) async fn continue_design_agent_at(
.ok_or("策划 Agent 当前正在工作")?;
let mut session = match read_design_session(root)? {
Some(session) => session,
None => new_design_session(
&project_id,
&load_game_creator_app_config()?.selected_model_id,
),
None => {
if read_planning_session_v2(root)?.is_some() {
return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into());
}
new_design_session(
&project_id,
&load_game_creator_app_config()?.selected_model_id,
)
}
};
if session.project_id != project_id {
return Err("策划会话与当前项目不匹配".into());
@@ -549,7 +549,7 @@ fn append_agent_runtime_file_read_evidence_ref(
// 给的是**整个三元组**,不是一个碎片。`agent.acceptance_update` 的 evidence 引用
// 要求 {agentId, runId, actionId} 三个字段,回执查找也按三元组整体做 key。早期
// 只给 actionId,另外两个靠模型回忆——实测它第一次就把其中一个记错,白吃一次
// 拒绝。验收证据只接受当前根 run 的回执,
// 拒绝。反正 `validate_fast_gdd_evidence_identity` 只接受当前根 run 的回执,
// 合法取值唯一,本来就不该让它猜。
observation.summary = format!(
"{} · sourceAgentId={agent_id} · sourceRunId={run_id} · sourceActionId={action_id}",
@@ -919,9 +919,9 @@ mod file_read_source_action_id_tests {
AgentRuntimeToolObservation {
tool: "file.read".to_string(),
status: "ok".to_string(),
summary: "已读取 design.md 第 1-134 行(共 134 行)".to_string(),
summary: "已读取 game/fast_gdd.md 第 1-134 行(共 134 行)".to_string(),
detail: Some(format!(
"design.md · sha256={} · lines 1-134 of 134
"game/fast_gdd.md · sha256={} · lines 1-134 of 134
第一行内容",
"a".repeat(64)
)),
@@ -954,7 +954,7 @@ mod file_read_source_action_id_tests {
agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation)
.expect("safe detail still parses");
let value = serde_json::from_str::<serde_json::Value>(&safe_detail).expect("json");
assert_eq!(value["path"], "design.md");
assert_eq!(value["path"], "game/fast_gdd.md");
assert_eq!(value["lines"], "1-134 of 134");
assert_eq!(value["contentSha256"], "a".repeat(64));
}
@@ -78,6 +78,12 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str)
matches!(kind.as_str(), "empty-response" | "deserialize")
}
/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在
/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。
fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool {
matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT")
}
const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4;
/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的
@@ -7,6 +7,9 @@ mod design_session;
mod finalization;
mod json_sidecar;
mod models;
mod planning_gdd_model;
mod planning_policy_v2;
mod planning_session_v2;
mod provider_control;
mod provider_retry;
mod real_e2e_checkpoint;
@@ -21,6 +24,9 @@ pub(crate) use design_session::*;
pub(in crate::agent) use finalization::*;
pub(in crate::agent) use json_sidecar::*;
pub(in crate::agent) use models::*;
pub(crate) use planning_gdd_model::*;
pub(crate) use planning_policy_v2::*;
pub(crate) use planning_session_v2::*;
pub(in crate::agent) use provider_control::*;
pub(in crate::agent) use provider_retry::*;
pub(in crate::agent) use real_e2e_checkpoint::*;
@@ -781,7 +781,7 @@ mod provider_reconciliation_diagnostic_tests {
let directory = tempfile::tempdir().expect("diagnostic directory");
let snapshot = AgentRuntimeProviderRequestSnapshot {
project_id: "project-1".to_string(),
agent_id: "project-supervisor".to_string(),
agent_id: "project-planning".to_string(),
task_id: "task-1".to_string(),
session_id: "session-1".to_string(),
run_id: "run-1".to_string(),
@@ -805,7 +805,7 @@ mod provider_reconciliation_diagnostic_tests {
usage: None,
tool_calls: vec![platform_llm::LlmToolCall {
id: "call-1".to_string(),
name: "runtime_tool_agent_message".to_string(),
name: "runtime_tool_plan_submit_gdd".to_string(),
arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(),
}],
responses_output: Vec::new(),
@@ -201,17 +201,39 @@ pub(crate) fn observe_agent_runtime_agent_message(
}
}
/// 委派 task 末尾那句「你在这条链路上的位置」。返工和普通委派的语义不同,单独保留。
/// 委派 task 末尾那句「你在这条链路上的位置」。三种跳的语义互不相同,共用一句话
/// 就会说谎,所以这里把它显式化。
///
/// - `Repair`:质量返工,`repair_depth` 上限 1,「唯一返工轮」是真的。而且这句话是
/// 做游戏链路的**授权信号**——`design-foundation` / `art-director` /
/// `art-asset-plan` 的角色提示词都把「任务正文明确标识这是带 repairOfDelegationId
/// 的唯一返工轮」当作 `replaceExisting=true` 的唯一许可(见 agent/prompt.rs 的三处
/// 角色 prompt)。这一支逐字不能动。
/// - `PlanClarification`:澄清续跑不消耗 `repair_depth`,预算是
/// `static_delegate_clarification_round_limit_at`plan 链路 3 轮)。master 只有一道
/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后
/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后
/// 全部直接出稿,没有任何一个 run 走到第 2 轮。
/// - `UserRevision`:用户在审批卡上点「修改 / 退回」后的修订轮。它同样带
/// `repairOfDelegationId`,但 `repair_depth` 防的是 runaway agent,而这一跳每一轮
/// 都由人触发——人本身就是循环边界,所以 `static_delegate_lineage_counters` 早就
/// 把 depth/round 原样继承了。缺的是这句话:走 `Repair` 分支时用户第一次点修改就
/// 会被告知「这是唯一返工轮」,和澄清跳当初那个假天花板是同一个错误。原型对应的是
/// `USER_REVISION_SOFT_LIMIT = 16`,且超过只提示、不拒绝。
/// - `None`:普通委派,不加这一段。
pub(in crate::agent) enum StaticDelegateHopNote<'a> {
None,
Repair { original_delegation_id: &'a str },
Repair {
original_delegation_id: &'a str,
},
UserRevision {
original_delegation_id: &'a str,
},
PlanClarification {
original_delegation_id: &'a str,
rounds_used: u32,
rounds_limit: u32,
},
}
impl StaticDelegateHopNote<'_> {
@@ -225,6 +247,32 @@ impl StaticDelegateHopNote<'_> {
StaticDelegateHopNote::Repair {
original_delegation_id,
} => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"),
StaticDelegateHopNote::UserRevision {
original_delegation_id,
} => format!(
"\n\n这是对已认领委派 {original_delegation_id} 的用户修订轮,由用户在审批卡上提出,不是质量返工,不消耗返工深度,也不重置澄清轮次。按任务正文里的用户意见原文修订同一份 GDD 谱系后重新提交;用户看过新稿还可以再次提出修改,这不是最后一轮,不要因此压缩改动或提前收尾。"
),
// 预算用尽:planning_coordinator 出卡时会用
// `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问,
// 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。
StaticDelegateHopNote::PlanClarification {
original_delegation_id,
rounds_used,
rounds_limit,
} if rounds_used >= rounds_limit => format!(
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮。已用澄清轮次 {rounds_used}/{rounds_limit},澄清预算已用尽:本轮不得再输出 AGC_NEEDS_USER_INPUT_V1 信封,剩余空白按默认建议补齐并标 default_pending,立即提交 GDD。"
),
// 轮号必须和 planning_coordinator 出卡时的期望一致:那边用
// `validate_exact_plan_clarification_question(.., current_round + 1)`
// current_round 就是本 delivery 的谱系轮次,也就是这里的 rounds_used。
StaticDelegateHopNote::PlanClarification {
original_delegation_id,
rounds_used,
rounds_limit,
} => format!(
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 写成「第{next_round}轮·当前要决定:<主题>」,轮号必须是 {next_round},主题写这一轮真正要定的那件事。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。",
next_round = rounds_used.saturating_add(1),
),
}
}
}
@@ -1475,3 +1523,122 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated(
.map(|value| redact_agent_runtime_project_paths(root, &value, 3_600)),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 委派 task 末尾那句话是两条链路的合同,不能共用一份文案。
///
/// 上半条钉做游戏链路:`design-foundation` / `art-director` / `art-asset-plan`
/// 的角色提示词把「任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮」
/// 当作 `replaceExisting=true` 的唯一授权信号,改一个字就会让返工轮拿不到许可。
///
/// 下半条钉立项策划链路:澄清续跑不是返工轮,套用返工文案等于告诉策划子 Agent
/// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。
/// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header
/// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。
/// 用户修订轮同样不能套返工文案。
///
/// 「唯一返工轮」防的是 runaway agent,而这一跳由用户在审批卡上亲手点出来——人本身
/// 就是循环边界,`static_delegate_lineage_counters` 早就把 depth/round 原样继承了。
/// 套用返工文案就是告诉策划子 Agent「用户只能改这一次」,和澄清跳当初那个假天花板
/// 是同一个错误。原型对应的是软阈值 16 次、超过只提示不拒绝。
#[test]
fn user_revision_hop_note_is_not_the_repair_round_note() {
let revision = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::UserRevision {
original_delegation_id: "delegation-old",
},
)
.expect("render user revision hop note");
assert!(
!revision.contains("唯一返工轮"),
"用户修订轮不得复用返工文案,否则子 Agent 以为用户只能改这一次:{revision}"
);
assert!(
revision.contains("不消耗返工深度"),
"必须写明它不吃返工额度:{revision}"
);
assert!(
revision.contains("不是最后一轮"),
"必须写明用户还能再改,否则子 Agent 会把多条意见攒到一轮改完:{revision}"
);
}
#[test]
fn plan_clarification_hop_note_is_not_the_repair_round_note() {
let repair = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付产物".to_string()],
&["assets/art-spec.png".to_string()],
StaticDelegateHopNote::Repair {
original_delegation_id: "delegation-old",
},
)
.expect("render repair");
assert!(
repair.contains("这是对已认领委派 delegation-old 的唯一返工轮。"),
"返工轮文案是做游戏链路 replaceExisting 的授权信号,必须逐字保留:{repair}"
);
let clarification = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::PlanClarification {
original_delegation_id: "delegation-old",
rounds_used: 1,
rounds_limit: 3,
},
)
.expect("render clarification");
assert!(
!clarification.contains("唯一返工轮"),
"澄清续跑不得复用返工文案,否则策划子 Agent 以为只剩这一轮:{clarification}"
);
assert!(
clarification.contains("已用澄清轮次 1/3"),
"澄清续跑必须写明已用轮次与上限:{clarification}"
);
assert!(
clarification.contains("第2轮·当前要决定:"),
"task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1{clarification}"
);
let exhausted = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::PlanClarification {
original_delegation_id: "delegation-old",
rounds_used: 3,
rounds_limit: 3,
},
)
.expect("render exhausted clarification");
assert!(
exhausted.contains("澄清预算已用尽"),
"预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}"
);
assert!(
!exhausted.contains("第4轮·当前要决定:"),
"预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}"
);
}
}
@@ -10,6 +10,18 @@ pub(in crate::agent) fn agent_role_project_path_mutation_block(
if autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false) {
return None;
}
if is_agent_planning_storage_path(path) || is_plan_fast_gdd_projection_path(path) {
return Some(AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "blocked".to_string(),
summary: if is_agent_planning_storage_path(path) {
"`.agent/planning/**` 只能由立项策划 Runtime 专用存储层写入".to_string()
} else {
"`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入".to_string()
},
detail: Some(format!("agentId={agent_id} · runId={run_id} · path={path}")),
});
}
match autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id) {
Ok(true) => {
let allowed = autonomous_manifest_owner_artifact_paths(agent_id);
@@ -275,7 +275,8 @@ fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegis
///
/// `"__all_agents__"` 是个不对应任何真实 Agent 的哨兵:走这条路径拿到的是
/// 未按身份收窄的完整函数目录。生产代码必须调用 `_for_agent` 版本并传入真实
/// `agentId`,否则按身份收窄的工具面会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」
/// `agentId`,否则按身份收窄的工具面(如 `project-planning` 的 exact
/// allowlist)会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」
/// 从运行时静默扩权变成编译期错误。
#[cfg(test)]
pub(crate) fn build_agent_runtime_native_function_tools() -> Result<Vec<LlmFunctionTool>, String> {
@@ -934,6 +934,104 @@ pub(crate) fn validated_local_project_directory_path(
Ok(path.to_path_buf())
}
/// Open the approved Fast GDD Markdown in whatever application the OS has
/// registered for it.
///
/// The GDD is the one product artifact the 立项策划 lane hands back, and it is
/// already on disk — `plan.submit_gdd` renders `game/fast_gdd.md` and the
/// approval receipt re-renders it with the approved header. This command only
/// hands that existing path to the shell; it never creates or rewrites it.
#[tauri::command]
pub(crate) fn open_local_project_plan_gdd_markdown(
app: tauri::AppHandle,
project_path: String,
) -> Result<(), String> {
let path = validated_local_project_plan_gdd_markdown_path(project_path.trim())?;
app.opener()
.open_path(path.to_string_lossy().into_owned(), None::<&str>)
.map_err(|error| format!("打开 Fast GDD 文件失败:{error}"))
}
pub(crate) fn validated_local_project_plan_gdd_markdown_path(
project_path: &str,
) -> Result<PathBuf, String> {
let root = validated_local_project_directory_path(project_path)?;
// `resolve_local_project_path` 是项目内路径的唯一安全入口:它做根校验、相对路径
// 归一化,并逐段拒绝符号链接。这里的相对路径是常量,但仍然走它——GDD 的渲染侧
// `planning_storage`)用的也是同一个解析器,两边对「项目内的这个文件」必须是
// 同一个判定,不能一边解析一边拼字符串。
let path = resolve_local_project_path(&root, PLAN_FAST_GDD_PATH)?;
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(path),
Ok(_) => Err("Fast GDD 产物不是普通文件".to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err("Fast GDD 产物尚未生成,请先完成立项策划审批".to_string())
}
Err(error) => Err(format!("读取 Fast GDD 产物失败:{error}")),
}
}
#[cfg(test)]
mod plan_gdd_markdown_path_tests {
use super::*;
fn fixture() -> tempfile::TempDir {
let temporary = tempfile::tempdir().expect("create GDD path fixture");
crate::project::init_local_game_project_at(
&temporary.path().join("project"),
"gdd-open",
"打开 GDD 产物",
)
.expect("initialize GDD path fixture");
temporary
}
#[test]
fn resolves_the_rendered_markdown_under_the_project_root() {
let temporary = fixture();
let root = temporary.path().join("project");
fs::create_dir_all(root.join("game")).expect("create game directory");
fs::write(root.join(PLAN_FAST_GDD_PATH), "# Fast GDD").expect("render markdown");
let resolved =
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
.expect("resolve rendered markdown");
assert_eq!(resolved, root.join(PLAN_FAST_GDD_PATH));
}
#[test]
fn refuses_to_open_a_markdown_that_has_not_been_rendered_yet() {
// 恢复态下 `plan.submit_gdd` 的 Markdown 渲染可能还没落盘。这时按钮必须给出
// 明确原因,而不是把一个不存在的路径丢给 shell 由系统弹一个无从解释的错误。
let temporary = fixture();
let root = temporary.path().join("project");
let error =
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
.expect_err("missing markdown must fail closed");
assert!(error.contains("尚未生成"), "unexpected error: {error}");
}
#[test]
fn refuses_a_project_path_that_is_not_an_initialized_project() {
let temporary = tempfile::tempdir().expect("create bare fixture");
let error = validated_local_project_plan_gdd_markdown_path(
&temporary.path().to_string_lossy().into_owned(),
)
.expect_err("a directory without .agent is not a project root");
assert!(!error.is_empty());
}
#[test]
fn refuses_a_relative_project_path() {
let error = validated_local_project_plan_gdd_markdown_path("relative/project")
.expect_err("relative project path must fail");
assert!(error.contains("绝对路径"), "unexpected error: {error}");
}
}
#[tauri::command]
pub(crate) async fn get_local_game_manifest(
project_path: String,
@@ -1756,8 +1756,8 @@ pub(crate) fn build_static_delegate_structured_result_at(
// (实测:option 对象里多写了一个 `id` 字段)就把整条委派判成投递失败,父
// Supervisor 直接进 needs-reconciliation 停下等人。
//
// 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」
// 降级成 needs-repair 并把解析错误当返工理由
// 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」,和
// plan.submit_gdd 的业务拒绝同类。降级成 needs-repair 并把解析错误当返工理由
// 带上:Supervisor 用既有的一次返工额度就能让子 Agent 重写,不需要人工介入。
let (user_input_questions, user_input_questions_sha256, user_input_parse_error) =
match parse_static_delegate_user_input_request(error) {
@@ -3426,11 +3426,11 @@ mod tests {
assert!(failed.contains("客观证据冲突"));
let mut missing = base.clone();
missing.missing_expected_artifacts = vec!["design.md".to_string()];
missing.missing_expected_artifacts = vec!["game/fast_gdd.md".to_string()];
let missing_error = validate_static_delegate_structured_result(
&missing,
"completed",
&["design.md".to_string()],
&["game/fast_gdd.md".to_string()],
)
.expect_err("missing expected artifact must reject a user revision result");
assert!(missing_error.contains("客观证据冲突"));
@@ -2533,6 +2533,7 @@ fn main() {
read_agc_plugin_panel,
set_agc_plugin_project_path,
open_local_project_directory,
open_local_project_plan_gdd_markdown,
control_agent_run,
generate_local_game_draft,
chat_with_game_creator_agent,
@@ -2541,6 +2542,10 @@ fn main() {
chat_with_game_creator_direct_codex,
cancel_direct_codex_turn,
select_game_creator_reasoning_effort,
start_planning_session_v2,
continue_planning_session_v2,
decide_planning_artifact_v2,
hydrate_planning_session_v2,
hydrate_design_agent_session,
reset_design_agent_session,
get_design_agent_runtime_mode,
@@ -7,7 +7,9 @@ use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use unicode_normalization::UnicodeNormalization;
use crate::project::{normalize_relative_path, validate_project_root};
use crate::project::{
is_plan_fast_gdd_projection_path, normalize_relative_path, validate_project_root,
};
const PROJECT_PATCHSET_MAX_CHANGES: usize = 12;
const PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES: usize = 256 * 1024;
@@ -464,6 +466,11 @@ fn normalize_and_validate_patchset_inputs(
let path = normalize_relative_path(change.path())?;
reject_sensitive_patchset_path(&path)?;
if is_plan_fast_gdd_projection_path(&path) {
return Err(
"project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(),
);
}
let change = match change {
ParsedProjectPatchsetChange::Create { content, .. } => {
validate_input_text(&content, &path)?;
@@ -1365,6 +1372,11 @@ fn metadata_is_link_or_reparse(metadata: &Metadata) -> bool {
}
fn reject_sensitive_patchset_path(relative_path: &str) -> Result<(), String> {
if is_plan_fast_gdd_projection_path(relative_path) {
return Err(
"project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(),
);
}
let components = relative_path
.split('/')
.map(str::to_ascii_lowercase)
@@ -1694,6 +1706,8 @@ mod tests {
for path in [
".agent/runtime/state.json",
".agent/planning/gdd.v1.json",
"game/fast_gdd.md",
".env.local",
"config/private.pem",
"data/runtime.sqlite",
@@ -674,6 +674,7 @@ pub(crate) fn restore_local_project_checkpoint_at(
pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool {
should_skip_project_snapshot_path(relative_path)
|| is_plan_fast_gdd_projection_path(relative_path)
|| relative_path == ".agent/agent.db"
|| relative_path == PROJECT_PERMISSION_POLICY_PATH
|| relative_path == PROJECT_WRITE_LOCK_PATH
@@ -55,6 +55,7 @@ pub(crate) fn list_local_project_files_at(
if is_agent_runtime_private_control_path(&relative_path)
|| is_agent_checkpoint_control_path(&relative_path)
|| is_agent_workbench_control_path(&relative_path)
|| is_agent_planning_storage_path(&relative_path)
{
continue;
}
@@ -156,6 +157,56 @@ pub(crate) fn reject_agent_runtime_private_control_path(
Ok(())
}
/// `.agent/planning/**` is a Runtime-owned sidecar. It remains readable by
/// the narrow planning read tools, but generic project mutation helpers must
/// never be able to create, replace, patch, or delete it. Keeping this gate
/// separate from `reject_agent_runtime_private_control_path` is deliberate:
/// the planning Agent needs `file.read`/`file.list` observations while its
/// durable writer is still the only component allowed to mutate the sidecar.
pub(crate) fn is_agent_planning_storage_path(normalized_path: &str) -> bool {
let normalized_path = normalized_path.to_ascii_lowercase();
normalized_path == ".agent/planning"
|| normalized_path.starts_with(".agent/planning/")
|| normalized_path == ".agent/planning-v2"
|| normalized_path.starts_with(".agent/planning-v2/")
}
pub(crate) fn is_agent_planning_managed_write_path(normalized_path: &str) -> bool {
is_agent_planning_storage_path(normalized_path)
|| is_plan_fast_gdd_projection_path(normalized_path)
}
pub(crate) fn reject_agent_planning_storage_write_path(
normalized_path: &str,
) -> Result<(), String> {
if is_agent_planning_managed_write_path(normalized_path) {
return Err(
"`.agent/planning/**`、`.agent/planning-v2/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝"
.to_string(),
);
}
Ok(())
}
/// `game/fast_gdd.md` is the human-readable planning projection. It lives
/// outside `.agent/planning`, but it is still Runtime-owned and must not be
/// mutated by generic file tools. Keep this predicate write-only so planning
/// observations can continue to read the projection.
pub(crate) fn is_plan_fast_gdd_projection_path(normalized_path: &str) -> bool {
normalized_path.eq_ignore_ascii_case(PLAN_FAST_GDD_PATH)
}
pub(crate) fn reject_plan_projection_write_path(normalized_path: &str) -> Result<(), String> {
reject_agent_planning_storage_write_path(normalized_path)?;
if is_plan_fast_gdd_projection_path(normalized_path) {
return Err(
"`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入,通用文件写入被拒绝"
.to_string(),
);
}
Ok(())
}
fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> {
if matches!(
normalized_path.split('/').next(),
@@ -192,6 +243,7 @@ pub(crate) fn write_local_project_file_at(
) -> Result<LocalProjectFileMutationResult, String> {
let normalized_path = normalize_relative_path(relative_path)?;
reject_agent_runtime_private_control_path(&normalized_path)?;
reject_plan_projection_write_path(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?;
if path.exists() && !path.is_file() {
return Err("只能写入文件".to_string());
@@ -211,6 +263,7 @@ pub(crate) fn delete_local_project_file_at(
) -> Result<LocalProjectFileMutationResult, String> {
let normalized_path = normalize_relative_path(relative_path)?;
reject_agent_runtime_private_control_path(&normalized_path)?;
reject_plan_projection_write_path(&normalized_path)?;
reject_agent_control_path_delete(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?;
if !path.exists() {
@@ -416,7 +416,7 @@ pub(crate) fn project_write_lock_reclaim(
}
/// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用
/// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。
pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:";
@@ -520,7 +520,7 @@ impl ProjectWriteLockFailure {
}
/// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、
/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误
/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误
/// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。
pub(crate) fn message(&self) -> String {
match self {
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More