修复M1策划链路可靠性
阻止人工核对中的策划根写入审批回执 在投影恢复前校验项目身份并使用唯一项目ID 增加策划能力停用门禁与既有sidecar只读行为 拒绝非法plan与自主构建档位组合并收紧消费者 补齐前后端回归测试与M1技术决策记录
This commit is contained in:
@@ -20,5 +20,8 @@
|
||||
"baseUrl": "http://127.0.0.1:8082",
|
||||
"apiKey": ""
|
||||
},
|
||||
"mcpServers": {}
|
||||
"mcpServers": {},
|
||||
"planning": {
|
||||
"capabilityEnabled": true
|
||||
}
|
||||
}
|
||||
|
||||
+20
-33
@@ -780,6 +780,7 @@ mod tests {
|
||||
build_game_creator_agent_background_final_reply_request,
|
||||
build_game_creator_agent_background_tool_plan_request,
|
||||
game_creator_agent_context_preload_notice, game_creator_agent_runtime_role_overlay_prompt,
|
||||
game_creator_agent_runtime_run_profile_binding_path,
|
||||
game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at,
|
||||
new_game_creation_app_seed_tasks, provider_command_exec_contract,
|
||||
provider_command_start_contract, render_autonomous_manifest_ready_task_background_prompt,
|
||||
@@ -787,11 +788,13 @@ mod tests {
|
||||
start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft,
|
||||
AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
|
||||
AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
|
||||
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
|
||||
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD,
|
||||
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE,
|
||||
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
|
||||
AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND,
|
||||
AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
|
||||
AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
GAME_CREATOR_PROJECT_PLANNING_FINAL_REPLY_SYSTEM_PROMPT,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, PLAN_FAST_GDD_ACCEPTANCE_NODE_CRITERION,
|
||||
PLAN_FAST_GDD_ACCEPTANCE_NODE_EVIDENCE, PLAN_FAST_GDD_ACCEPTANCE_NODE_ID,
|
||||
@@ -1461,7 +1464,7 @@ mod tests {
|
||||
let root = directory.path().join("project");
|
||||
init_local_game_project_at(&root, "plan-profile-drift", "形成 Fast GDD")
|
||||
.expect("project init");
|
||||
let binding = bind_game_creator_agent_runtime_run_profile_at(
|
||||
let error = bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"provider-plan-profile-drift-root",
|
||||
@@ -1469,33 +1472,17 @@ mod tests {
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("bind deliberately drifted plan root");
|
||||
let state = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
&binding.agent_id,
|
||||
"形成 Fast GDD",
|
||||
&binding.run_id,
|
||||
&binding.source,
|
||||
"冻结立项目标",
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("start drifted plan root");
|
||||
let error = build_game_creator_agent_background_tool_plan_request(
|
||||
&root,
|
||||
&state.agent_id,
|
||||
&state.session_id,
|
||||
&state.run_id,
|
||||
&state.current_task,
|
||||
&[],
|
||||
0,
|
||||
&GameCreatorMcpCatalog {
|
||||
fingerprint: String::new(),
|
||||
servers: Vec::new(),
|
||||
tools: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect_err("plan source must pass the shared strong predicate");
|
||||
assert!(error.contains("project-supervisor-plan standard 顶层根 Run"));
|
||||
.expect_err("plan source/profile drift must be rejected before it becomes durable");
|
||||
assert!(error.contains(AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND));
|
||||
assert!(
|
||||
!game_creator_agent_runtime_run_profile_binding_path(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"provider-plan-profile-drift-root",
|
||||
)
|
||||
.exists(),
|
||||
"非法 plan/autonomous binding 不得落盘"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -225,6 +225,15 @@ pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_supervisor_source_is_autonomous_game_build(source: &str) -> bool {
|
||||
matches!(
|
||||
source.trim(),
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE
|
||||
| AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
|
||||
| AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_supervisor_source_is_plan(source: &str) -> bool {
|
||||
source.trim() == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE
|
||||
}
|
||||
|
||||
@@ -128,6 +128,11 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at(
|
||||
}
|
||||
let run_profile = normalize_agent_runtime_run_profile(Some(run_profile))?;
|
||||
reject_supervisor_plan_autonomous_profile(source, &run_profile)?;
|
||||
if agent_runtime_supervisor_source_is_plan(source)
|
||||
&& !crate::config::game_creator_planning_capability_enabled()?
|
||||
{
|
||||
return Err("PLAN_CAPABILITY_DISABLED: 立项策划能力当前已停用".to_string());
|
||||
}
|
||||
start_game_creator_agent_background_task_with_source_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
@@ -848,7 +853,7 @@ pub(crate) fn current_autonomous_game_build_root_task_at(
|
||||
&& record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& record.parent_agent_id.is_none()
|
||||
&& record.parent_run_id.is_none()
|
||||
&& agent_runtime_supervisor_source_is_trusted(&record.source)
|
||||
&& agent_runtime_supervisor_source_is_autonomous_game_build(&record.source)
|
||||
&& seen_run_ids.insert(record.run_id.clone())
|
||||
{
|
||||
root_run_ids.push(record.run_id.clone());
|
||||
|
||||
+6
-6
@@ -38,7 +38,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool {
|
||||
if read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID).is_ok_and(
|
||||
|runtime| {
|
||||
runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& agent_runtime_supervisor_source_is_trusted(&runtime.state.source)
|
||||
&& agent_runtime_supervisor_source_is_autonomous_game_build(&runtime.state.source)
|
||||
&& !matches!(
|
||||
runtime.state.phase.as_str(),
|
||||
"completed" | "failed" | "cancelled" | "budget-exhausted"
|
||||
@@ -55,7 +55,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool {
|
||||
tasks.into_iter().any(|task| {
|
||||
task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& task.parent_run_id.is_none()
|
||||
&& agent_runtime_supervisor_source_is_trusted(&task.source)
|
||||
&& agent_runtime_supervisor_source_is_autonomous_game_build(&task.source)
|
||||
&& matches!(
|
||||
task.status.as_str(),
|
||||
"pending"
|
||||
@@ -7075,7 +7075,7 @@ pub(in crate::agent) fn validate_autonomous_completion_contract(
|
||||
|| binding.parent_run_id.is_some()
|
||||
|| binding.root_agent_id != contract.agent_id
|
||||
|| binding.root_run_id != contract.run_id
|
||||
|| !agent_runtime_supervisor_source_is_trusted(&binding.source)
|
||||
|| !agent_runtime_supervisor_source_is_autonomous_game_build(&binding.source)
|
||||
{
|
||||
return Err("自主构建完成合同与根 Supervisor Run 不匹配".to_string());
|
||||
}
|
||||
@@ -7202,7 +7202,7 @@ fn failed_terminal_autonomous_root_contract_before_task_at(
|
||||
root: &Path,
|
||||
task: &AgentRuntimeTaskRecord,
|
||||
) -> Result<Option<AutonomousContinuationInheritance>, String> {
|
||||
if !agent_runtime_supervisor_source_is_trusted(&task.source)
|
||||
if !agent_runtime_supervisor_source_is_autonomous_game_build(&task.source)
|
||||
|| !is_pure_autonomous_continuation_intent(&task.task)
|
||||
{
|
||||
return Ok(None);
|
||||
@@ -7219,7 +7219,7 @@ fn failed_terminal_autonomous_root_contract_before_task_at(
|
||||
&& record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& record.parent_agent_id.is_none()
|
||||
&& record.parent_run_id.is_none()
|
||||
&& agent_runtime_supervisor_source_is_trusted(&record.source)
|
||||
&& agent_runtime_supervisor_source_is_autonomous_game_build(&record.source)
|
||||
&& seen_run_ids.insert(record.run_id.clone())
|
||||
{
|
||||
ordered_run_ids.push(record.run_id.clone());
|
||||
@@ -14622,7 +14622,7 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at(
|
||||
return Ok(());
|
||||
}
|
||||
if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
|| !agent_runtime_supervisor_source_is_trusted(&task.source)
|
||||
|| !agent_runtime_supervisor_source_is_autonomous_game_build(&task.source)
|
||||
{
|
||||
return Err("自主构建完成合同只允许可信根 Supervisor Run".to_string());
|
||||
}
|
||||
|
||||
+11
-12
@@ -9506,7 +9506,7 @@ fn plan_root_retry_identity_guard_precedes_delegated_and_autonomous_branches() {
|
||||
// ② autonomous 支:task.source 已损坏成非 plan,但 binding.source 是 plan。
|
||||
// 顶部守卫按 task.source 判定,挡不住这一种,必须由 autonomous 支内的
|
||||
// reject_supervisor_plan_autonomous_profile 兜住。
|
||||
let autonomous_binding = bind_game_creator_agent_runtime_run_profile_at(
|
||||
let autonomous_error = bind_game_creator_agent_runtime_run_profile_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"plan-retry-guard-autonomous",
|
||||
@@ -9514,21 +9514,20 @@ fn plan_root_retry_identity_guard_precedes_delegated_and_autonomous_branches() {
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("bind plan+autonomous");
|
||||
let corrupted_source = failed_supervisor_task(
|
||||
"plan-retry-guard-autonomous",
|
||||
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
|
||||
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD,
|
||||
&autonomous_binding.binding_fingerprint,
|
||||
&session_id,
|
||||
);
|
||||
let autonomous_error =
|
||||
resolve_game_creator_agent_runtime_retry_configuration_at(&root, &corrupted_source, false)
|
||||
.expect_err("autonomous 支不得取回 plan source 复活被禁组合");
|
||||
.expect_err("durable binding 不得写入 plan+autonomous 非法组合");
|
||||
assert!(
|
||||
autonomous_error.contains(AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND),
|
||||
"{autonomous_error}"
|
||||
);
|
||||
assert!(
|
||||
!game_creator_agent_runtime_run_profile_binding_path(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"plan-retry-guard-autonomous",
|
||||
)
|
||||
.exists(),
|
||||
"非法 binding 不得落盘"
|
||||
);
|
||||
|
||||
// 对照:合法 plan 根 run 不受本守卫影响,仍然保源。
|
||||
let healthy = failed_supervisor_task(
|
||||
|
||||
@@ -178,6 +178,14 @@ pub(crate) fn create_plan_gdd_approval_pending_at(
|
||||
root: &Path,
|
||||
gdd: &PlanGddV1,
|
||||
) -> Result<(), PlanningStorageError> {
|
||||
if !crate::config::game_creator_planning_capability_enabled()
|
||||
.map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))?
|
||||
{
|
||||
return Err(approval_error(
|
||||
"PLAN_CAPABILITY_DISABLED",
|
||||
"立项策划能力当前已停用",
|
||||
));
|
||||
}
|
||||
let _lock = acquire_project_write_lock(root, "planning.approval-pending.create")
|
||||
.map_err(|error| approval_error("PLAN_DURABILITY_FAILED", error))?;
|
||||
create_plan_gdd_approval_pending_locked(root, gdd)
|
||||
@@ -299,6 +307,11 @@ pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_locked(
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> Result<PlanGddAcceptanceGateOutcome, String> {
|
||||
if !crate::config::game_creator_planning_capability_enabled()? {
|
||||
// 停用是 rollout 状态而非 authority 损坏:该自动门不应把既有根 Run
|
||||
// 推入 needs-reconciliation,更不能借恢复路径写入新的 pending。
|
||||
return Ok(PlanGddAcceptanceGateOutcome::NotApplicable);
|
||||
}
|
||||
if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() {
|
||||
return Ok(PlanGddAcceptanceGateOutcome::NotApplicable);
|
||||
}
|
||||
@@ -501,6 +514,9 @@ pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_at(
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> Result<PlanGddAcceptanceGateOutcome, String> {
|
||||
if !crate::config::game_creator_planning_capability_enabled()? {
|
||||
return Ok(PlanGddAcceptanceGateOutcome::NotApplicable);
|
||||
}
|
||||
if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || run_id.trim().is_empty() {
|
||||
return Ok(PlanGddAcceptanceGateOutcome::NotApplicable);
|
||||
}
|
||||
@@ -1081,6 +1097,11 @@ fn project_receipt_locked(
|
||||
pub(crate) fn reconcile_plan_gdd_approval_projections_at(
|
||||
root: &Path,
|
||||
) -> Result<bool, PlanningStorageError> {
|
||||
if !crate::config::game_creator_planning_capability_enabled()
|
||||
.map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))?
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let _lock =
|
||||
acquire_project_write_lock(root, "planning.approval-recovery").map_err(|error| {
|
||||
approval_error(
|
||||
@@ -1088,6 +1109,12 @@ pub(crate) fn reconcile_plan_gdd_approval_projections_at(
|
||||
redact_agent_runtime_project_paths(root, &error, 500),
|
||||
)
|
||||
})?;
|
||||
reconcile_plan_gdd_approval_projections_locked(root)
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_plan_gdd_approval_projections_locked(
|
||||
root: &Path,
|
||||
) -> Result<bool, PlanningStorageError> {
|
||||
let gdds = read_plan_gdd_chain_locked(root)?;
|
||||
let plan_root_run_ids = gdds
|
||||
.iter()
|
||||
@@ -1184,6 +1211,14 @@ fn validate_active_plan_root_for_decision_locked(
|
||||
"审批 GDD 不再绑定当前唯一 active plan 根 Run",
|
||||
));
|
||||
}
|
||||
if active_roots[0].status == "needs-reconciliation"
|
||||
|| active_roots[0].phase == "needs-reconciliation"
|
||||
{
|
||||
return Err(approval_error(
|
||||
"PLAN_STALE_APPROVAL",
|
||||
"审批 GDD 前必须先完成 plan 根 Run 的人工核对",
|
||||
));
|
||||
}
|
||||
if active_roots[0].run_profile_binding_fingerprint != root_binding.binding_fingerprint {
|
||||
return Err(approval_error(
|
||||
"PLAN_SOURCE_PROFILE_MISMATCH",
|
||||
@@ -1197,6 +1232,14 @@ pub(crate) fn decide_plan_gdd_at(
|
||||
root: &Path,
|
||||
input: &DecidePlanGddInputV1,
|
||||
) -> Result<PlanGddDecisionResultV1, PlanningStorageError> {
|
||||
if !crate::config::game_creator_planning_capability_enabled()
|
||||
.map_err(|error| approval_error("PLAN_CAPABILITY_DISABLED", error))?
|
||||
{
|
||||
return Err(approval_error(
|
||||
"PLAN_CAPABILITY_DISABLED",
|
||||
"立项策划能力当前已停用",
|
||||
));
|
||||
}
|
||||
validate_decision_transport(input)?;
|
||||
let _lock = acquire_project_write_lock(root, "planning.gdd-decision").map_err(|error| {
|
||||
approval_error(
|
||||
|
||||
+156
-28
@@ -411,6 +411,48 @@ fn build_state_view_locked(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_hydrate_project_identity(
|
||||
project_id: &str,
|
||||
gdds: &[PlanGddV1],
|
||||
approvals: &[PlanGddApprovalV1],
|
||||
session: Option<&PlanSessionV1>,
|
||||
pending: Option<&PlanGddApprovalPendingV1>,
|
||||
) -> Result<(), PlanningStorageError> {
|
||||
for gdd in gdds {
|
||||
if gdd.project_id != project_id {
|
||||
return Err(plan_gdd_state_error(
|
||||
"PLAN_PROJECT_ID_MISMATCH",
|
||||
"GDD projectId 与当前项目 manifest 不一致",
|
||||
));
|
||||
}
|
||||
}
|
||||
for approval in approvals {
|
||||
if approval.project_id != project_id {
|
||||
return Err(plan_gdd_state_error(
|
||||
"PLAN_PROJECT_ID_MISMATCH",
|
||||
"approval receipt projectId 与当前项目 manifest 不一致",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(session) = session {
|
||||
if session.project_id != project_id {
|
||||
return Err(plan_gdd_state_error(
|
||||
"PLAN_PROJECT_ID_MISMATCH",
|
||||
"planning session projectId 与当前项目 manifest 不一致",
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(pending) = pending {
|
||||
if pending.project_id != project_id {
|
||||
return Err(plan_gdd_state_error(
|
||||
"PLAN_PROJECT_ID_MISMATCH",
|
||||
"approval pending projectId 与当前项目 manifest 不一致",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn hydrate_game_creator_plan_gdd_state_at(
|
||||
root: &Path,
|
||||
) -> Result<PlanGddStateViewV1, PlanningStorageError> {
|
||||
@@ -424,32 +466,9 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state_at(
|
||||
));
|
||||
}
|
||||
|
||||
// 这里的顺序与方案 §18.3 的固定顺序相反,是刻意保留的现状,改动前先读完这段。
|
||||
//
|
||||
// §18.3 要求身份校验(第 3 步)先于 session 前滚(第 5 步)与 index/pending 重建
|
||||
// (第 6 步)。本函数反过来:先 `reconcile`、再前滚 session、再重建 index,最后才在
|
||||
// `build_state_view_locked` 里比对 GDD/session 的 projectId 与 manifest。
|
||||
//
|
||||
// 现在没有后果,是因为那道比对恒过——App 的全部 init/import 调用点都传同一个常量
|
||||
// `seedManifest.projectId`(`local-project-draft`),本机每个项目的 manifest 里写的
|
||||
// 都是它,所以 projectId 分辨不出任意两个项目。该常量不属本模块管辖,此处不改。
|
||||
//
|
||||
// **一旦 projectId 改成每项目唯一,这道校验才真正开始工作,本顺序就必须一起改**:
|
||||
// 否则 hydrate 会先对一棵属于别的项目的 planning 树做完投影修复,才发现认错了人。
|
||||
//
|
||||
// 改的时候有个坑:**不能把 `reconcile` 直接挪到 hydrate 取锁之后**。它自己要取项目
|
||||
// 锁,而 `.agent/project.lock` 是 `create_new(true)` 的非重入文件锁,调用方已持锁再
|
||||
// 进去会死等满重试预算然后失败。可行的两条路是:在 `reconcile` 之前加一次锁外的
|
||||
// projectId 预检;或把 `reconcile` 拆成取锁薄壳 + `_at_locked` 核心,由 hydrate 在
|
||||
// 自己那把锁内调用(仓库已有同型拆法,见 `observe_agent_runtime_agent_delegate`)。
|
||||
//
|
||||
// 直接传播:`PlanningStorageError` 的 Display 已是 `"{code}: {detail}"`,
|
||||
// 再用同一个 code 把 `to_string()` 当 detail 重包一次,只会渲染出
|
||||
// `CODE: CODE: detail`,code 与 detail 都没有变化。
|
||||
let mut recovery_pending = reconcile_plan_gdd_approval_projections_at(root)?;
|
||||
// `acquire_project_write_lock` 的 Err 内嵌 `.agent/project.lock` 的真实绝对路径,
|
||||
// 而方案 §18.3 明令 hydrate 返回值「不包含绝对路径……或内部诊断」。任何后台写占着锁
|
||||
// 就会走到这里,是常态可达路径,必须先 redact 再进 typed error 的 detail。
|
||||
// §18.3:先在同一把项目锁内只读校验所有 authority 的 projectId,之后才允许
|
||||
// session/index/pending recovery 写入。这样复制到另一个项目的 sidecar 只能失败关闭,
|
||||
// 不会在发现错绑前改写任何投影。
|
||||
let _lock =
|
||||
crate::project::acquire_project_write_lock(root, "planning.hydrate").map_err(|error| {
|
||||
plan_gdd_state_error(
|
||||
@@ -459,6 +478,29 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state_at(
|
||||
})?;
|
||||
let gdds = read_plan_gdd_chain_locked(root)?;
|
||||
let approvals = read_plan_gdd_approvals_locked(root)?;
|
||||
let session_read_only = read_plan_session_read_only_locked(root)?;
|
||||
let pending_read_only = read_plan_gdd_approval_pending_locked(root)?;
|
||||
validate_hydrate_project_identity(
|
||||
project_id,
|
||||
&gdds,
|
||||
&approvals,
|
||||
session_read_only.as_ref(),
|
||||
pending_read_only.as_ref(),
|
||||
)?;
|
||||
if !crate::config::game_creator_planning_capability_enabled()
|
||||
.map_err(|error| plan_gdd_state_error("PLAN_CAPABILITY_DISABLED", error))?
|
||||
{
|
||||
return build_state_view_locked(
|
||||
root,
|
||||
project_id,
|
||||
gdds,
|
||||
approvals,
|
||||
session_read_only,
|
||||
pending_read_only,
|
||||
false,
|
||||
);
|
||||
}
|
||||
let mut recovery_pending = reconcile_plan_gdd_approval_projections_locked(root)?;
|
||||
let session = read_plan_session_with_recovery_locked(root)?;
|
||||
let pending = if gdds.last().is_some_and(|latest| {
|
||||
approvals
|
||||
@@ -547,6 +589,92 @@ mod tests {
|
||||
assert!(!root.join(PLAN_STORAGE_ROOT).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydrate_rejects_copied_planning_authority_before_repairing_projections() {
|
||||
let temporary = tempfile::tempdir().expect("create hydrate fixture");
|
||||
let source = temporary.path().join("source");
|
||||
let target = temporary.path().join("target");
|
||||
crate::project::init_local_game_project_at(&source, "hydrate-source", "源项目")
|
||||
.expect("initialize source project");
|
||||
crate::project::init_local_game_project_at(&target, "hydrate-target", "目标项目")
|
||||
.expect("initialize target project");
|
||||
let mut source_session = PlanSessionV1 {
|
||||
schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(),
|
||||
project_id: "hydrate-source".to_string(),
|
||||
gdd_id: "gdd-00000000-0000-4000-8000-000000000001".to_string(),
|
||||
session_revision: 1,
|
||||
previous_fingerprint: None,
|
||||
session_fingerprint: format!("sha256-serde-json-v2:{}", "0".repeat(64)),
|
||||
agent_id: GAME_CREATOR_PROJECT_PLANNING_AGENT_ID.to_string(),
|
||||
source: "agent-delegate".to_string(),
|
||||
run_profile: AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string(),
|
||||
run_profile_binding_fingerprint: "a".repeat(64),
|
||||
root_agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
|
||||
root_run_id: "run-hydrate-source".to_string(),
|
||||
latest_delegation_id: "delegation-hydrate-source".to_string(),
|
||||
session_id: "session-hydrate-source".to_string(),
|
||||
active_run_id: None,
|
||||
last_run_id: "run-hydrate-source".to_string(),
|
||||
phase: "collecting".to_string(),
|
||||
accumulated_agent_millis: 0,
|
||||
applied_steer_cursor: 0,
|
||||
decisions_summary: vec![PlanDecisionSummary {
|
||||
id: "initial-request".to_string(),
|
||||
topic: "初始需求".to_string(),
|
||||
state: "confirmed".to_string(),
|
||||
answer_source: "user_freeform".to_string(),
|
||||
round: 0,
|
||||
answer_summary: "做一款短局解谜游戏".to_string(),
|
||||
}],
|
||||
prototype_validation_items: Vec::new(),
|
||||
applied_answers: Vec::new(),
|
||||
latest_submitted_ref: None,
|
||||
last_decision_ref: None,
|
||||
updated_at_utc: "2026-08-19T00:00:00.000Z".to_string(),
|
||||
};
|
||||
source_session.session_fingerprint =
|
||||
plan_session_fingerprint(&source_session).expect("fingerprint source session");
|
||||
let source_planning = source.join(PLAN_STORAGE_ROOT);
|
||||
std::fs::create_dir_all(&source_planning).expect("create source planning storage");
|
||||
std::fs::write(
|
||||
source.join(PLAN_SESSION_PREVIOUS_PATH),
|
||||
canonical_plan_session_bytes(&source_session).expect("canonical source session"),
|
||||
)
|
||||
.expect("write source session recovery copy");
|
||||
let target_planning = target.join(PLAN_STORAGE_ROOT);
|
||||
std::fs::create_dir_all(&target_planning).expect("create target planning storage");
|
||||
std::fs::copy(
|
||||
source.join(PLAN_SESSION_PREVIOUS_PATH),
|
||||
target.join(PLAN_SESSION_PREVIOUS_PATH),
|
||||
)
|
||||
.expect("copy planning authority");
|
||||
std::fs::write(target_planning.join("index.json"), b"copied-index")
|
||||
.expect("write copied index projection");
|
||||
std::fs::create_dir_all(target.join("game")).expect("create target game directory");
|
||||
std::fs::write(target.join("game/fast_gdd.md"), b"copied-markdown")
|
||||
.expect("write copied markdown projection");
|
||||
let target_planning = target.join(PLAN_STORAGE_ROOT);
|
||||
let before_index =
|
||||
std::fs::read(target_planning.join("index.json")).expect("read copied index");
|
||||
let before_markdown =
|
||||
std::fs::read(target.join("game/fast_gdd.md")).expect("read copied markdown");
|
||||
let error = hydrate_game_creator_plan_gdd_state_at(&target)
|
||||
.expect_err("copied planning authority must fail project identity");
|
||||
assert_eq!(error.code(), "PLAN_PROJECT_ID_MISMATCH");
|
||||
assert_eq!(
|
||||
std::fs::read(target_planning.join("index.json")).expect("re-read copied index"),
|
||||
before_index,
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(target.join("game/fast_gdd.md")).expect("re-read copied markdown"),
|
||||
before_markdown,
|
||||
);
|
||||
assert!(
|
||||
!target_planning.join(PLAN_SESSION_PATH).exists(),
|
||||
"身份失败前不得把 previous session 提升为 primary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydrate_redacts_project_paths_from_contended_project_lock_errors() {
|
||||
let temporary = tempfile::tempdir().expect("create hydrate lock fixture");
|
||||
@@ -591,9 +719,9 @@ mod tests {
|
||||
rendered.contains("$PROJECT_ROOT"),
|
||||
"脱敏占位符应当保留,实际为 {rendered}"
|
||||
);
|
||||
// Display 已经是 `"{code}: {detail}"`,再用同一 code 重包会渲染成 `CODE: CODE: …`。
|
||||
// hydrate 现在先取自身项目锁;错误仍须保持单一 typed code,不能被二次包装。
|
||||
assert_eq!(
|
||||
rendered.matches("PLAN_DURABILITY_FAILED").count(),
|
||||
rendered.matches("PLAN_STORAGE_IO").count(),
|
||||
1,
|
||||
"typed 错误码不应重复拼接,实际为 {rendered}"
|
||||
);
|
||||
|
||||
@@ -4767,6 +4767,29 @@ pub(crate) fn read_plan_session_with_recovery_locked(
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect a planning session without promoting or deleting either recovery
|
||||
/// file. Hydrate uses this before project identity has been established: a
|
||||
/// copied sidecar must never be "repaired" in the receiving project.
|
||||
pub(crate) fn read_plan_session_read_only_locked(
|
||||
root: &Path,
|
||||
) -> Result<Option<PlanSessionV1>, PlanningStorageError> {
|
||||
let primary_path = resolve_planning_path(root, PLAN_SESSION_PATH)?;
|
||||
let previous_path = resolve_planning_path(root, PLAN_SESSION_PREVIOUS_PATH)?;
|
||||
let primary = read_optional_plan_session_file(root, &primary_path, "plan session primary")?;
|
||||
let previous = read_optional_plan_session_file(root, &previous_path, "plan session previous")?;
|
||||
match (primary, previous) {
|
||||
(None, None) => Ok(None),
|
||||
(Some(primary), None) => Ok(Some(primary)),
|
||||
(Some(primary), Some(previous)) => {
|
||||
if primary != previous {
|
||||
validate_plan_session_successor(&previous, &primary)?;
|
||||
}
|
||||
Ok(Some(primary))
|
||||
}
|
||||
(None, Some(previous)) => Ok(Some(previous)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) struct PlanningRuntimeWriteIdentity<'a> {
|
||||
pub(crate) agent_id: &'a str,
|
||||
|
||||
@@ -1754,6 +1754,14 @@ pub(crate) fn execute_plan_submit_gdd(
|
||||
context: &PlanSubmitGddRuntimeContext,
|
||||
input: &PlanSubmitGddInputV1,
|
||||
) -> Result<PlanSubmitGddResultV1, PlanningStorageError> {
|
||||
if !crate::config::game_creator_planning_capability_enabled()
|
||||
.map_err(|error| PlanningStorageError::new("PLAN_CAPABILITY_DISABLED", error))?
|
||||
{
|
||||
return Err(PlanningStorageError::new(
|
||||
"PLAN_CAPABILITY_DISABLED",
|
||||
"立项策划能力当前已停用",
|
||||
));
|
||||
}
|
||||
validate_runtime_context(context)?;
|
||||
validate_plan_submit_gdd_input(input)
|
||||
.map_err(|error| submit_error("PLAN_INVALID_REQUEST", error.to_string()))?;
|
||||
@@ -3056,6 +3064,19 @@ mod tests {
|
||||
cleanup_fixture(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_capability_disabled_rejects_new_gdd_without_writing_authority() {
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"planning":{"capabilityEnabled":false}}"#.to_string(),
|
||||
);
|
||||
let (root, context, input) = submit_fixture();
|
||||
let error = execute_plan_submit_gdd(&root, &context, &input)
|
||||
.expect_err("disabled planning capability must reject submit");
|
||||
assert_eq!(error.code(), "PLAN_CAPABILITY_DISABLED");
|
||||
assert!(!root.join(".agent/planning/gdd.v1.json").exists());
|
||||
cleanup_fixture(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_submit_rebuilds_each_derived_projection_without_allocating_v2() {
|
||||
let (root, context, input) = submit_fixture();
|
||||
@@ -3371,6 +3392,100 @@ mod tests {
|
||||
cleanup_fixture(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_rejects_reconciliation_root_without_creating_receipt() {
|
||||
let (root, context, input) = submit_fixture();
|
||||
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
|
||||
let gdd = read_plan_gdd_chain(&root)
|
||||
.expect("read submitted GDD")
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
|
||||
let task_path =
|
||||
game_creator_agent_runtime_task_path(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID);
|
||||
let mut root_task = latest_game_creator_agent_runtime_tasks(
|
||||
read_all_game_creator_agent_runtime_tasks(&task_path).expect("read root tasks"),
|
||||
)
|
||||
.into_iter()
|
||||
.find(|task| task.run_id == context.root_run_id)
|
||||
.expect("plan root task");
|
||||
root_task.status = "running".to_string();
|
||||
root_task.phase = "needs-reconciliation".to_string();
|
||||
root_task.updated_at = unix_timestamp();
|
||||
let root_runtime = agent_runtime_state_from_task_record(&root_task);
|
||||
append_game_creator_agent_runtime_task(&root, &root_runtime)
|
||||
.expect("append reconciled root");
|
||||
|
||||
let error = decide_plan_gdd_at(
|
||||
&root,
|
||||
&approval_input(
|
||||
&gdd,
|
||||
"approve",
|
||||
"gdd-response-00000000-0000-4000-8000-000000000099",
|
||||
None,
|
||||
),
|
||||
)
|
||||
.expect_err("reconciliation root must not create an approval receipt");
|
||||
assert_eq!(error.code(), "PLAN_STALE_APPROVAL");
|
||||
assert!(!root.join(".agent/planning/approvals/v1.json").exists());
|
||||
cleanup_fixture(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_capability_disabled_rejects_decision_without_creating_receipt() {
|
||||
let (root, context, input) = submit_fixture();
|
||||
execute_plan_submit_gdd(&root, &context, &input).expect("submit v1");
|
||||
let gdd = read_plan_gdd_chain(&root)
|
||||
.expect("read submitted GDD")
|
||||
.pop()
|
||||
.expect("GDD exists");
|
||||
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"planning":{"capabilityEnabled":false}}"#.to_string(),
|
||||
);
|
||||
|
||||
let error = decide_plan_gdd_at(
|
||||
&root,
|
||||
&approval_input(
|
||||
&gdd,
|
||||
"approve",
|
||||
"gdd-response-00000000-0000-4000-8000-000000000098",
|
||||
None,
|
||||
),
|
||||
)
|
||||
.expect_err("disabled planning capability must reject decision");
|
||||
assert_eq!(error.code(), "PLAN_CAPABILITY_DISABLED");
|
||||
assert!(!root.join(".agent/planning/approvals/v1.json").exists());
|
||||
cleanup_fixture(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_capability_disabled_keeps_existing_authority_read_only() {
|
||||
let (root, _gdd, root_runtime) = acceptance_gate_fixture(true);
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"planning":{"capabilityEnabled":false}}"#.to_string(),
|
||||
);
|
||||
|
||||
let outcome = ensure_plan_gdd_approval_pending_after_acceptance_at(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&root_runtime.run_id,
|
||||
)
|
||||
.expect("disabled acceptance recovery is a no-op");
|
||||
assert_eq!(outcome, PlanGddAcceptanceGateOutcome::NotApplicable);
|
||||
assert!(read_plan_gdd_approval_pending(&root)
|
||||
.expect("read pending")
|
||||
.is_none());
|
||||
|
||||
let view = hydrate_game_creator_plan_gdd_state_at(&root)
|
||||
.expect("disabled hydrate exposes an existing sidecar read-only");
|
||||
assert_eq!(view.state, "ready_for_approval");
|
||||
assert!(!view.recovery_pending);
|
||||
assert!(!root.join(".agent/planning/approval.pending.json").exists());
|
||||
cleanup_fixture(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_gdd_completion_blocker_requires_pending_and_terminal_observation() {
|
||||
let (root, context, input) = submit_fixture();
|
||||
|
||||
+2
-1
@@ -99,6 +99,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record(
|
||||
{
|
||||
return Err("Agent Runtime Run Profile 身份指纹无效".to_string());
|
||||
}
|
||||
reject_supervisor_plan_autonomous_profile(&binding.source, &binding.profile)?;
|
||||
if binding.parent_agent_id.is_some() != binding.parent_run_id.is_some() {
|
||||
return Err("Agent Runtime Run Profile 绑定父 Run 身份不完整".to_string());
|
||||
}
|
||||
@@ -111,7 +112,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record(
|
||||
}
|
||||
if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& (binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
|| !agent_runtime_supervisor_source_is_trusted(&binding.source))
|
||||
|| !agent_runtime_supervisor_source_is_autonomous_game_build(&binding.source))
|
||||
{
|
||||
return Err("自主构建 Run Profile 只允许可信 Supervisor 入口绑定".to_string());
|
||||
}
|
||||
|
||||
@@ -812,6 +812,11 @@ pub(crate) fn start_game_creator_supervisor_runtime_task(
|
||||
return Err("Project Supervisor 提交 source 不受信任".to_string());
|
||||
}
|
||||
reject_supervisor_plan_autonomous_profile(source, run_profile)?;
|
||||
if agent_runtime_supervisor_source_is_plan(source)
|
||||
&& !crate::config::game_creator_planning_capability_enabled()?
|
||||
{
|
||||
return Err("PLAN_CAPABILITY_DISABLED: 立项策划能力当前已停用".to_string());
|
||||
}
|
||||
start_game_creator_supervisor_background_task_for_session_at(
|
||||
root,
|
||||
session_id.as_deref(),
|
||||
|
||||
@@ -1438,9 +1438,18 @@ pub(crate) fn merge_game_creator_config_file(
|
||||
config.mcp_servers.insert(server_id, server);
|
||||
}
|
||||
}
|
||||
if let Some(planning) = file_config.planning {
|
||||
if let Some(capability_enabled) = planning.capability_enabled {
|
||||
config.planning.capability_enabled = capability_enabled;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_planning_capability_enabled() -> Result<bool, String> {
|
||||
Ok(load_game_creator_app_config()?.planning.capability_enabled)
|
||||
}
|
||||
|
||||
fn game_creator_config_backup_path(path: &Path) -> PathBuf {
|
||||
path.with_file_name(format!(
|
||||
".{}.previous",
|
||||
|
||||
@@ -732,6 +732,14 @@ struct GameCreatorAppConfigFile {
|
||||
agent_llm: Option<BTreeMap<String, GameCreatorLlmConfigFile>>,
|
||||
editor_api: Option<GameCreatorEditorApiConfigFile>,
|
||||
mcp_servers: Option<BTreeMap<String, GameCreatorMcpServerConfig>>,
|
||||
planning: Option<GameCreatorPlanningConfigFile>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorPlanningConfigFile {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
capability_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
@@ -783,6 +791,15 @@ struct GameCreatorAppConfig {
|
||||
editor_api: GameCreatorEditorApiConfig,
|
||||
#[serde(default)]
|
||||
mcp_servers: BTreeMap<String, GameCreatorMcpServerConfig>,
|
||||
#[serde(default)]
|
||||
planning: GameCreatorPlanningConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorPlanningConfig {
|
||||
#[serde(default = "default_game_creator_planning_capability_enabled")]
|
||||
capability_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -1244,6 +1261,11 @@ fn default_game_creator_llm_auto_compact_token_limit() -> u64 {
|
||||
fn default_game_creator_llm_tool_output_token_limit() -> u64 {
|
||||
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
|
||||
}
|
||||
|
||||
fn default_game_creator_planning_capability_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082";
|
||||
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
|
||||
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
|
||||
@@ -1310,6 +1332,15 @@ impl Default for GameCreatorAppConfig {
|
||||
agent_llm: BTreeMap::new(),
|
||||
editor_api: GameCreatorEditorApiConfig::default(),
|
||||
mcp_servers: BTreeMap::new(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GameCreatorPlanningConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
capability_enabled: default_game_creator_planning_capability_enabled(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,14 @@ fn canonical_agent_reasoning_effort_defaults_are_exhaustive_and_auditable() {
|
||||
template.llm.as_ref().and_then(|llm| llm.max_retries),
|
||||
Some(DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES)
|
||||
);
|
||||
assert_eq!(
|
||||
template
|
||||
.planning
|
||||
.as_ref()
|
||||
.and_then(|planning| planning.capability_enabled),
|
||||
Some(true),
|
||||
"bundled runtime config must keep planning capability enabled by default"
|
||||
);
|
||||
assert!(
|
||||
template.agent_llm.unwrap_or_default().is_empty(),
|
||||
"bundled template must not persist canonical defaults as explicit overrides"
|
||||
@@ -575,6 +583,7 @@ fn app_config_commands_write_runtime_config_file() {
|
||||
},
|
||||
agent_llm,
|
||||
mcp_servers: BTreeMap::new(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
})
|
||||
.expect("write runtime config");
|
||||
|
||||
@@ -655,6 +664,7 @@ fn app_config_write_rejects_invalid_api_kind() {
|
||||
editor_api: GameCreatorEditorApiConfig::default(),
|
||||
agent_llm: BTreeMap::new(),
|
||||
mcp_servers: BTreeMap::new(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
});
|
||||
|
||||
assert!(result
|
||||
@@ -679,6 +689,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() {
|
||||
editor_api: GameCreatorEditorApiConfig::default(),
|
||||
agent_llm: BTreeMap::new(),
|
||||
mcp_servers: BTreeMap::new(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
});
|
||||
|
||||
assert!(result
|
||||
@@ -703,6 +714,7 @@ fn app_config_write_rejects_too_small_request_timeout() {
|
||||
editor_api: GameCreatorEditorApiConfig::default(),
|
||||
agent_llm: BTreeMap::new(),
|
||||
mcp_servers: BTreeMap::new(),
|
||||
planning: GameCreatorPlanningConfig::default(),
|
||||
});
|
||||
|
||||
assert!(result
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
AGENT_RUN_HISTORY_VISIBLE_STEP,
|
||||
CONVERSATION_INITIAL_VISIBLE_COUNT,
|
||||
CONVERSATION_VISIBLE_STEP,
|
||||
createLocalProjectId,
|
||||
PROJECT_SUPERVISOR_AGENT_ID,
|
||||
PROJECT_SUPERVISOR_PLAN_SOURCE,
|
||||
seedManifest,
|
||||
@@ -3364,7 +3365,7 @@ export function App({
|
||||
}
|
||||
: await invoke<InitLocalProjectResult>('init_local_game_project', {
|
||||
projectPath: trimmedProjectPath,
|
||||
projectId: seedManifest.projectId,
|
||||
projectId: createLocalProjectId(),
|
||||
name: gameChatOnly
|
||||
? projectNameFromPath(trimmedProjectPath)
|
||||
: seedManifest.name,
|
||||
@@ -3497,7 +3498,7 @@ export function App({
|
||||
if (directoryStatus.isGodotProject) {
|
||||
await invoke<InitLocalProjectResult>('import_local_godot_project', {
|
||||
projectPath: trimmedProjectPath,
|
||||
projectId: seedManifest.projectId,
|
||||
projectId: createLocalProjectId(),
|
||||
name: projectNameFromPath(trimmedProjectPath),
|
||||
});
|
||||
await openWorkspace(trimmedProjectPath, false, 'open', 'godot');
|
||||
|
||||
@@ -5,6 +5,10 @@ export const seedManifest = createGameCreationAppManifest(
|
||||
'未命名游戏原型',
|
||||
);
|
||||
|
||||
export function createLocalProjectId(): string {
|
||||
return `local-project-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
export const AGENT_RUN_HISTORY_MAX_COUNT = 100;
|
||||
export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20;
|
||||
export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20;
|
||||
|
||||
@@ -747,6 +747,9 @@ export interface GameCreatorAppConfig {
|
||||
apiKey: string;
|
||||
};
|
||||
mcpServers: Record<string, GameCreatorMcpServerConfig>;
|
||||
planning?: {
|
||||
capabilityEnabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GameCreatorAppConfigView {
|
||||
|
||||
@@ -11,8 +11,8 @@ import type {
|
||||
GameCreationAppPreviewState,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
createLocalProjectId,
|
||||
PROJECT_SUPERVISOR_PLAN_SOURCE,
|
||||
seedManifest,
|
||||
} from '../../app/constants';
|
||||
import { useEscapeToClose } from '../../app/dialogs';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
@@ -266,7 +266,7 @@ export function useHomeProjectCreation({
|
||||
'init_local_game_project',
|
||||
{
|
||||
projectPath: trimmedProjectPath,
|
||||
projectId: seedManifest.projectId,
|
||||
projectId: createLocalProjectId(),
|
||||
name: projectNameFromPath(trimmedProjectPath),
|
||||
},
|
||||
);
|
||||
@@ -365,7 +365,7 @@ export function useHomeProjectCreation({
|
||||
'init_local_game_project',
|
||||
{
|
||||
projectPath: trimmedProjectPath,
|
||||
projectId: seedManifest.projectId,
|
||||
projectId: createLocalProjectId(),
|
||||
name: projectNameFromPath(trimmedProjectPath),
|
||||
},
|
||||
);
|
||||
@@ -444,7 +444,7 @@ export function useHomeProjectCreation({
|
||||
'import_local_godot_project',
|
||||
{
|
||||
projectPath: trimmedProjectPath,
|
||||
projectId: seedManifest.projectId,
|
||||
projectId: createLocalProjectId(),
|
||||
name: projectNameFromPath(trimmedProjectPath),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2246,7 +2246,7 @@ export function registerRecentProjectsTests() {
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
|
||||
projectPath: '/tmp/non-empty-game',
|
||||
projectId: 'local-project-draft',
|
||||
projectId: expect.stringMatching(/^local-project-/),
|
||||
name: 'non-empty-game',
|
||||
});
|
||||
expect(screen.getByLabelText('项目总控对话')).not.toBeNull();
|
||||
@@ -2289,7 +2289,7 @@ export function registerRecentProjectsTests() {
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
|
||||
projectPath: '/tmp/folder-named-game/',
|
||||
projectId: 'local-project-draft',
|
||||
projectId: expect.stringMatching(/^local-project-/),
|
||||
name: 'folder-named-game',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,9 +240,7 @@ export function registerProjectCommandTests() {
|
||||
|
||||
submitChat('/project /tmp/authorized-game');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
expect(
|
||||
await screen.findByText('已打开:authorized-game'),
|
||||
).not.toBeNull();
|
||||
expect(await screen.findByText('已打开:authorized-game')).not.toBeNull();
|
||||
|
||||
submitChat('/audit');
|
||||
|
||||
@@ -404,7 +402,7 @@ export function registerProjectCommandTests() {
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
|
||||
projectPath: '/tmp/main-non-empty-game',
|
||||
projectId: 'local-project-draft',
|
||||
projectId: expect.stringMatching(/^local-project-/),
|
||||
name: '未命名游戏原型',
|
||||
});
|
||||
});
|
||||
@@ -3447,9 +3445,7 @@ export function registerProjectCommandTests() {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
expect(
|
||||
await screen.findByText('已打开:authorized-game'),
|
||||
).not.toBeNull();
|
||||
expect(await screen.findByText('已打开:authorized-game')).not.toBeNull();
|
||||
|
||||
fireEvent.click(logPanel.getByRole('button', { name: '命令日志' }));
|
||||
expect(
|
||||
|
||||
@@ -4709,7 +4709,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('init_local_game_project', {
|
||||
projectPath,
|
||||
projectId: 'local-project-draft',
|
||||
projectId: expect.stringMatching(/^local-project-/),
|
||||
name: 'game-chat-non-empty',
|
||||
});
|
||||
});
|
||||
@@ -10504,7 +10504,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
const surface = await screen.findByLabelText('项目总控对话');
|
||||
expect(invoke).toHaveBeenCalledWith('import_local_godot_project', {
|
||||
projectPath,
|
||||
projectId: 'local-project-draft',
|
||||
projectId: expect.stringMatching(/^local-project-/),
|
||||
name: 'existing-godot-project',
|
||||
});
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-08-19 M1 审查后四项可靠性修复
|
||||
|
||||
- **审批 stale 收口**:`status` 或 `phase` 为 `needs-reconciliation` 的策划根不再满足审批所需的 active 身份;审批命令返回 `PLAN_STALE_APPROVAL`,并且不得创建 approval receipt。
|
||||
- **项目身份先验**:所有手动新建、首页新建、game-chat 新建与 Godot 导入都生成独立 `local-project-<uuid>`;hydrate 在同一把项目锁内用只读 session 读取 GDD、receipt、pending、session 的 projectId,任一与 manifest 不符即返回 `PLAN_PROJECT_ID_MISMATCH`,不提升 session previous,也不改 index、Markdown 或 audit。
|
||||
- **rollout capability**:AppData 配置增加 `planning.capabilityEnabled`,默认 `true`。关闭后拒绝新的 plan 根 run、`plan.submit_gdd`、approval pending 和 decision mutation;hydrate 仅返回已有 sidecar 的只读视图,不执行恢复写入。
|
||||
- **profile/source 边界**:durable run-profile binding 集中拒绝 `project-supervisor-plan + autonomous-game-build`;自主构建的 scheduler 与 completion consumer 使用不含 plan 的精确 source matcher,普通 plan `standard` 仍属于通用 trusted matcher。
|
||||
- **边界**:不新增既有项目首次进入策划入口;不改做游戏、做素材路径。回归覆盖 capability、stale receipt、错项目 sidecar 写前失败、非法 binding 不落盘与新项目 ID 形状。
|
||||
|
||||
## 2026-08-19 更正 M1D-2 首页入口映射
|
||||
|
||||
- **更正**:`bf2185fba` 把“做游戏”误接为 `standard + project-supervisor-plan`,并额外增加“直接开建”按钮;这与既有产品决定“做方案入口独立成链,不动做游戏路径”冲突。
|
||||
|
||||
@@ -1691,7 +1691,7 @@ Canvas 是条件外部能力,不是 M0-3 四个固定 owner 的通用前置:
|
||||
## 20. rollout、停用与回滚
|
||||
|
||||
- M1 以 AppData/Runtime capability 开关启用,不把 feature flag 写进用户项目。
|
||||
- 开关关闭时不创建新 plan run、不接受新的 submit/decision mutation;已有 sidecar 只读保留,“直接开建”保持现状。
|
||||
- 开关字段为 AppData 配置中的 `planning.capabilityEnabled`,默认 `true`;关闭时不创建新 plan run、不接受新的 submit/decision mutation,也不执行 approval pending、session/index/Markdown/audit 的恢复写入。已有 sidecar 仅以只读 hydrate 视图保留,“直接开建”保持现状。
|
||||
- 重新开启后从同一 gddId、版本、receipt 和 session chain 恢复,不重置版本。
|
||||
- unknown/newer schema 只读失败,不由旧客户端降级覆盖。
|
||||
- 任意冲突只前滚修复;禁止自动删除 planning 目录、重编号 GDD、改指纹、覆盖回执或抹去用户 comment。
|
||||
|
||||
Reference in New Issue
Block a user