做方案链路补无头审批入口,不再只能靠 GUI 拍板
decide_game_creator_plan_gdd 此前只有 Tauri command 一个入口,无头跑 --plan 到审批卡就永远收不了尾。补两条 CLI: - --plan-gdd-status <项目路径>:只读投影 - --plan-gdd-decide <项目路径> <approve|revise|reject> [--response-id] [--stdin] 审批卡的 identity 由 CLI 自己 hydrate,不让调用方手拼 gddId/fingerprint—— 拼错的后果是 PLAN_STALE_APPROVAL,而不是一条能读懂的用法错误。hydrate 抽成 hydrate_game_creator_plan_gdd_state_for_path,GUI 与 CLI 共用同一道权限闸。 swarm 测试脚本的审批必须和 CLI 并发做:Run 停在审批位时状态是 waiting-for-user-input,而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn 永远不 settle、CLI 也就永远不退出。等 CLI 退出再审批那一步根本到不了,实测 是干等到超时。改成认「等待 Fast GDD 审批决定」这条投影文案触发。并发子进程 单独跟踪,不占 activeChild 槽位,否则 Ctrl-C 杀不到 CLI。 decide 回执带 recoveryPending 时补一次 --agent-resume:审批回执落盘和唤醒 后台任务是两件事,唤醒失败被降级成 recoveryPending,审批已生效而 Run 仍停在 等待位,实测只有补这一次恢复才会重新起 turn。 真机验证:影子机器人解谜需求跑通到 state=approved、approvedGddRef.version=1、 game/fast_gdd.md 落盘,全程未开 GUI。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -960,12 +960,25 @@ export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) {
|
||||
return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1;
|
||||
}
|
||||
|
||||
// GDD 审批位不能等 CLI 退出之后再处理:Run 停在这里时状态是 waiting-for-user-input,
|
||||
// 而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn 永远不 settle,CLI 也就永远
|
||||
// 不退出。所以审批必须在 CLI 还活着的时候并发做完,让 Run 自己继续跑到收束。
|
||||
// 这一句是 PlanGddCompletionBlockerKind::AwaitingApprovalDecision 专有的投影文案,
|
||||
// 另外三个 blocked 子状态都不会打出它;即便认错了,真正的判据也是随后那次
|
||||
// --plan-gdd-status,没有待决定审批时不会有任何写入。
|
||||
const planGddApprovalWaitPattern = /等待 Fast GDD 审批决定/u;
|
||||
|
||||
export function swarmOutputAwaitsPlanGddApproval(line) {
|
||||
return planGddApprovalWaitPattern.test(line);
|
||||
}
|
||||
|
||||
async function runTaskCargo(
|
||||
cliArguments,
|
||||
task,
|
||||
setActiveChild,
|
||||
timeoutMs,
|
||||
autoPilot = false,
|
||||
onPlanGddApprovalWait = null,
|
||||
) {
|
||||
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
@@ -977,6 +990,23 @@ async function runTaskCargo(
|
||||
let taskSubmitted = false;
|
||||
let promptsSeen = 0;
|
||||
let sittingAtPrompt = false;
|
||||
let planGddApproval = null;
|
||||
let planGddApprovalError = null;
|
||||
let planGddApprovalStarted = false;
|
||||
let planGddApprovalPromise = null;
|
||||
const startPlanGddApproval = () => {
|
||||
planGddApprovalStarted = true;
|
||||
console.log('[自动审批] 检测到 Fast GDD 审批位,正在提交 approve');
|
||||
planGddApprovalPromise = onPlanGddApprovalWait()
|
||||
.then((value) => {
|
||||
planGddApproval = value;
|
||||
})
|
||||
.catch((error) => {
|
||||
planGddApprovalError = error;
|
||||
// 审批没成的话 Run 会一直停在等待位,干等到超时只会把真正的原因埋掉。
|
||||
void terminateChildTree(child).catch(() => {});
|
||||
});
|
||||
};
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => {
|
||||
process.stdout.write(chunk);
|
||||
@@ -989,6 +1019,13 @@ async function runTaskCargo(
|
||||
reportLines.push(normalizedLine);
|
||||
settled = true;
|
||||
}
|
||||
if (
|
||||
onPlanGddApprovalWait &&
|
||||
!planGddApprovalStarted &&
|
||||
swarmOutputAwaitsPlanGddApproval(normalizedLine)
|
||||
) {
|
||||
startPlanGddApproval();
|
||||
}
|
||||
}
|
||||
if (!autoPilot || child.stdin.writableEnded) return;
|
||||
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
|
||||
@@ -1027,7 +1064,13 @@ async function runTaskCargo(
|
||||
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
|
||||
reportLines.push(normalizedPendingLine);
|
||||
}
|
||||
return { ...result, turnReportOutput: reportLines.join('\n') };
|
||||
await planGddApprovalPromise;
|
||||
if (planGddApprovalError) throw planGddApprovalError;
|
||||
return {
|
||||
...result,
|
||||
turnReportOutput: reportLines.join('\n'),
|
||||
planGddApproval,
|
||||
};
|
||||
} finally {
|
||||
setActiveChild(null);
|
||||
}
|
||||
@@ -1752,6 +1795,137 @@ async function reportPlanningOutputs(projectPath) {
|
||||
}
|
||||
}
|
||||
|
||||
const planGddApprovalTimeoutMs = 60_000;
|
||||
export const planGddStatusOutputPrefix = 'planGddStateJson=';
|
||||
export const planGddDecisionOutputPrefix = 'planGddDecisionJson=';
|
||||
|
||||
function parsePrefixedJsonLine(output, prefix, label) {
|
||||
const line = output
|
||||
.split('\n')
|
||||
.map((value) => (value.endsWith('\r') ? value.slice(0, -1) : value))
|
||||
.find((value) => value.startsWith(prefix));
|
||||
if (!line) throw new Error(`${label}缺少 ${prefix} 输出`);
|
||||
try {
|
||||
return JSON.parse(line.slice(prefix.length));
|
||||
} catch (error) {
|
||||
throw new Error(`解析${label}失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePlanGddStatusOutput(output) {
|
||||
return parsePrefixedJsonLine(
|
||||
output,
|
||||
planGddStatusOutputPrefix,
|
||||
'Fast GDD 审批状态',
|
||||
);
|
||||
}
|
||||
|
||||
export function parsePlanGddDecisionOutput(output) {
|
||||
return parsePrefixedJsonLine(
|
||||
output,
|
||||
planGddDecisionOutputPrefix,
|
||||
'Fast GDD 审批回执',
|
||||
);
|
||||
}
|
||||
|
||||
// 审批卡是这条链路唯一的人类判据,所以自动应答只投 approve,且只在投影确实有一张
|
||||
// 待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把判据
|
||||
// 换成噪声;要跑那两条分支就手工调 --plan-gdd-decide。
|
||||
export function planGddAutoApprovalIsPending(state) {
|
||||
return Boolean(state?.pendingApproval);
|
||||
}
|
||||
|
||||
async function settlePlanGddApproval(
|
||||
projectPath,
|
||||
runtimeConfigPath,
|
||||
setActiveChild,
|
||||
) {
|
||||
const readStatus = async () => {
|
||||
const result = await runCapturedCargo(
|
||||
['--config-dir', runtimeConfigPath, '--plan-gdd-status', projectPath],
|
||||
setActiveChild,
|
||||
{
|
||||
timeoutMs: planGddApprovalTimeoutMs,
|
||||
label: 'Fast GDD 审批状态查询',
|
||||
},
|
||||
);
|
||||
if (result.code !== 0 || result.signal) {
|
||||
throw new Error(
|
||||
`读取 Fast GDD 审批状态失败:${result.stderr.trim() || result.stdout.trim()}`,
|
||||
);
|
||||
}
|
||||
return parsePlanGddStatusOutput(result.stdout);
|
||||
};
|
||||
|
||||
const before = await readStatus();
|
||||
if (!planGddAutoApprovalIsPending(before)) {
|
||||
return { decided: false, state: before };
|
||||
}
|
||||
const decision = await runCapturedCargo(
|
||||
[
|
||||
'--config-dir',
|
||||
runtimeConfigPath,
|
||||
'--plan-gdd-decide',
|
||||
projectPath,
|
||||
'approve',
|
||||
],
|
||||
setActiveChild,
|
||||
{
|
||||
timeoutMs: planGddApprovalTimeoutMs,
|
||||
label: 'Fast GDD 审批决定',
|
||||
},
|
||||
);
|
||||
if (decision.code !== 0 || decision.signal) {
|
||||
throw new Error(
|
||||
`提交 Fast GDD 审批决定失败:${decision.stderr.trim() || decision.stdout.trim()}`,
|
||||
);
|
||||
}
|
||||
const receipt = parsePlanGddDecisionOutput(decision.stdout);
|
||||
// 回执落盘和唤醒后台任务是两件事:decide 命令把唤醒失败降级成 recoveryPending,
|
||||
// 于是审批已经生效、Run 却仍停在 waiting-for-user-input。实测就是这样——只有
|
||||
// 补一次 --agent-resume 才会重新起 turn。这是仓库自己给这个状态定义的恢复动作。
|
||||
let recovered = false;
|
||||
if (receipt.recoveryPending) {
|
||||
const resume = await runCapturedCargo(
|
||||
['--config-dir', runtimeConfigPath, '--agent-resume', projectPath],
|
||||
setActiveChild,
|
||||
{
|
||||
timeoutMs: planGddApprovalTimeoutMs,
|
||||
label: 'Fast GDD 审批后恢复后台任务',
|
||||
},
|
||||
);
|
||||
if (resume.code !== 0 || resume.signal) {
|
||||
throw new Error(
|
||||
`审批已提交但恢复后台任务失败:${resume.stderr.trim() || resume.stdout.trim()}`,
|
||||
);
|
||||
}
|
||||
recovered = true;
|
||||
}
|
||||
return { decided: true, receipt, recovered, state: await readStatus() };
|
||||
}
|
||||
|
||||
async function reportPlanGddApproval(approval) {
|
||||
const { state } = approval;
|
||||
console.log('\nFast GDD 审批:');
|
||||
if (!approval.decided) {
|
||||
console.log(` [无待决定审批] 当前投影状态=${state.state}`);
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
` [已批准] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`,
|
||||
);
|
||||
if (approval.recovered) {
|
||||
console.log(
|
||||
' [已恢复] 审批回执的 recoveryPending 由一次 --agent-resume 收口',
|
||||
);
|
||||
}
|
||||
if (state.session) {
|
||||
console.log(
|
||||
` 澄清轮次=${state.session.clarificationRound} 返工深度=${state.session.repairDepth} phase=${state.session.phase}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasConfiguredEditorApiKey(configDir) {
|
||||
let configured = false;
|
||||
for (const fileName of [configFileName, localConfigFileName]) {
|
||||
@@ -1904,17 +2078,29 @@ export async function runSwarmTestChat(options) {
|
||||
const setActiveChild = (child) => {
|
||||
activeChild = child;
|
||||
};
|
||||
// GDD 审批要和 swarm CLI 并发跑,两者不能共用 activeChild 这一个槽位:审批子进程
|
||||
// 结束时的 setActiveChild(null) 会把 CLI 从槽里抹掉,Ctrl-C 就杀不到它了。
|
||||
const concurrentChildren = new Set();
|
||||
const setConcurrentChild = (child) => {
|
||||
if (child) concurrentChildren.add(child);
|
||||
else concurrentChildren.clear();
|
||||
};
|
||||
const stopRequested = () => receivedSignal !== null;
|
||||
const handleSignal = (signal) => {
|
||||
const repeatedSignal = receivedSignal !== null;
|
||||
receivedSignal ??= signal;
|
||||
if (!activeChild) return;
|
||||
void terminateChildTree(activeChild, signal, repeatedSignal).catch(
|
||||
() => {},
|
||||
);
|
||||
const targets = [activeChild, ...concurrentChildren].filter(Boolean);
|
||||
if (targets.length === 0) return;
|
||||
for (const target of targets) {
|
||||
void terminateChildTree(target, signal, repeatedSignal).catch(() => {});
|
||||
}
|
||||
if (repeatedSignal) return;
|
||||
forceTerminationHandle = setTimeout(() => {
|
||||
void terminateChildTree(activeChild, 'SIGKILL', true).catch(() => {});
|
||||
for (const target of [activeChild, ...concurrentChildren].filter(
|
||||
Boolean,
|
||||
)) {
|
||||
void terminateChildTree(target, 'SIGKILL', true).catch(() => {});
|
||||
}
|
||||
}, childTerminationGraceMs);
|
||||
forceTerminationHandle.unref();
|
||||
};
|
||||
@@ -2005,6 +2191,14 @@ export async function runSwarmTestChat(options) {
|
||||
? null
|
||||
: Math.max(1, timeoutDeadline - Date.now()),
|
||||
options.plan,
|
||||
options.plan
|
||||
? () =>
|
||||
settlePlanGddApproval(
|
||||
project.path,
|
||||
runtimeConfig.path,
|
||||
setConcurrentChild,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
: await runInteractiveCargo(chatArguments, setActiveChild);
|
||||
} catch (error) {
|
||||
@@ -2023,10 +2217,26 @@ export async function runSwarmTestChat(options) {
|
||||
if (options.plan) {
|
||||
// 立项策划不出游戏产物,正式验收在 GDD 审批卡上;这里只报告落盘情况,
|
||||
// 是否收束已经由 CLI 的退出码判过了。
|
||||
// 自动任务档的审批已经在 CLI 运行期间并发做完了;手工档(人自己敲 Ctrl+D
|
||||
// 退出)没有那次触发,退出后补一次,没有待决定审批时它是只读的。
|
||||
phase = 'plan-approval';
|
||||
const approval =
|
||||
chat.planGddApproval ??
|
||||
(await settlePlanGddApproval(
|
||||
project.path,
|
||||
runtimeConfig.path,
|
||||
setConcurrentChild,
|
||||
));
|
||||
if (receivedSignal) break session;
|
||||
phase = 'plan-report';
|
||||
await reportPlanGddApproval(approval);
|
||||
await reportPlanningOutputs(project.path);
|
||||
phase = 'complete';
|
||||
console.log('\n立项策划链路已收束:Run 正常结束,策划产物见上方清单。');
|
||||
console.log(
|
||||
approval.decided
|
||||
? '\n立项策划链路已收束:Fast GDD 已批准,策划产物见上方清单。'
|
||||
: '\n立项策划链路已收束:Run 正常结束但没有待决定审批,策划产物见上方清单。',
|
||||
);
|
||||
break session;
|
||||
}
|
||||
phase = 'artifact-validation';
|
||||
|
||||
@@ -113,6 +113,15 @@ pub(crate) enum CliCommand {
|
||||
AgentResume {
|
||||
project_path: PathBuf,
|
||||
},
|
||||
PlanGddStatus {
|
||||
project_path: PathBuf,
|
||||
},
|
||||
PlanGddDecide {
|
||||
project_path: PathBuf,
|
||||
action: String,
|
||||
response_id: Option<String>,
|
||||
read_comment_from_stdin: bool,
|
||||
},
|
||||
RunnerStatus,
|
||||
RunnerShutdownIfIdle,
|
||||
AgentRun {
|
||||
@@ -148,6 +157,7 @@ impl CliCommand {
|
||||
| Self::AgentGoalResume { .. }
|
||||
| Self::AgentGoalClear { .. }
|
||||
| Self::AgentResume { .. }
|
||||
| Self::PlanGddDecide { .. }
|
||||
| Self::RunnerShutdownIfIdle
|
||||
)
|
||||
}
|
||||
@@ -155,7 +165,10 @@ impl CliCommand {
|
||||
pub(crate) fn is_read_only_status(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::AgentRuntimeStatus { .. } | Self::AgentGoalStatus { .. } | Self::RunnerStatus
|
||||
Self::AgentRuntimeStatus { .. }
|
||||
| Self::AgentGoalStatus { .. }
|
||||
| Self::PlanGddStatus { .. }
|
||||
| Self::RunnerStatus
|
||||
)
|
||||
}
|
||||
|
||||
@@ -203,6 +216,8 @@ impl CliCommand {
|
||||
| Self::AgentRetry { project_path, .. }
|
||||
| Self::AgentSteer { project_path, .. }
|
||||
| Self::AgentResume { project_path }
|
||||
| Self::PlanGddStatus { project_path }
|
||||
| Self::PlanGddDecide { project_path, .. }
|
||||
| Self::PreviewServe { project_path }
|
||||
| Self::AgentRun { project_path, .. } => Some((project_path, false)),
|
||||
Self::LlmStatus | Self::RunnerStatus | Self::RunnerShutdownIfIdle => None,
|
||||
@@ -416,6 +431,56 @@ fn read_cli_agent_steer_instruction(reader: &mut impl Read) -> Result<String, St
|
||||
Ok(instruction.to_string())
|
||||
}
|
||||
|
||||
fn read_cli_plan_gdd_approval_comment(reader: &mut impl Read) -> Result<String, String> {
|
||||
const MAX_STDIN_BYTES: u64 = 8 * 1024;
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.take(MAX_STDIN_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| format!("从 stdin 读取 GDD 审批意见失败:{error}"))?;
|
||||
if bytes.len() as u64 > MAX_STDIN_BYTES {
|
||||
return Err(format!(
|
||||
"GDD 审批意见 stdin 超过 {MAX_STDIN_BYTES} 字节上限"
|
||||
));
|
||||
}
|
||||
let comment =
|
||||
String::from_utf8(bytes).map_err(|_| "GDD 审批意见 stdin 必须是 UTF-8 文本".to_string())?;
|
||||
let comment = comment.trim();
|
||||
if comment.is_empty() {
|
||||
return Err("GDD 审批意见 stdin 不能为空".to_string());
|
||||
}
|
||||
// 长度上界交给 normalize_plan_gdd_approval_comment:审批意见的 1~1000 scalar
|
||||
// 约束是写入侧权威,CLI 再抄一份就会有两个会漂移的判据。
|
||||
Ok(comment.to_string())
|
||||
}
|
||||
|
||||
fn take_cli_named_flag_value(
|
||||
args: &mut Vec<String>,
|
||||
flag: &str,
|
||||
usage: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let positions = args
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, arg)| (arg == flag).then_some(index))
|
||||
.collect::<Vec<_>>();
|
||||
if positions.len() > 1 {
|
||||
return Err(usage.to_string());
|
||||
}
|
||||
let Some(index) = positions.first().copied() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.map(String::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| usage.to_string())?
|
||||
.to_string();
|
||||
args.drain(index..=index + 1);
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn read_cli_agent_goal_payload(reader: &mut impl Read) -> Result<CliAgentGoalPayload, String> {
|
||||
const MAX_STDIN_BYTES: u64 = 64 * 1024;
|
||||
let mut bytes = Vec::new();
|
||||
@@ -645,6 +710,53 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, S
|
||||
project_path: PathBuf::from(&args[1]),
|
||||
}));
|
||||
}
|
||||
if args.first().map(String::as_str) == Some("--plan-gdd-status") {
|
||||
const USAGE: &str = "用法:--plan-gdd-status <本地项目绝对路径>";
|
||||
if args.len() != 2 || args[1].trim().is_empty() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
return Ok(Some(CliCommand::PlanGddStatus {
|
||||
project_path: PathBuf::from(&args[1]),
|
||||
}));
|
||||
}
|
||||
if args.first().map(String::as_str) == Some("--plan-gdd-decide") {
|
||||
const USAGE: &str = "用法:--plan-gdd-decide <本地项目绝对路径> <approve|revise|reject> [--response-id <gdd-response-…>] [--stdin]";
|
||||
let mut rest = args[1..].to_vec();
|
||||
let read_comment_from_stdin =
|
||||
match rest.iter().filter(|arg| arg.as_str() == "--stdin").count() {
|
||||
0 => false,
|
||||
1 => {
|
||||
let index = rest
|
||||
.iter()
|
||||
.position(|arg| arg == "--stdin")
|
||||
.ok_or_else(|| USAGE.to_string())?;
|
||||
rest.remove(index);
|
||||
true
|
||||
}
|
||||
_ => return Err(USAGE.to_string()),
|
||||
};
|
||||
let response_id = take_cli_named_flag_value(&mut rest, "--response-id", USAGE)?;
|
||||
if rest.len() != 2 || rest.iter().any(|value| value.trim().is_empty()) {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
let action = rest[1].trim().to_string();
|
||||
if !matches!(action.as_str(), "approve" | "revise" | "reject") {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
// revise/reject 的 comment 是写入侧硬约束,缺了必然在落盘前失败。在解析期就拒绝,
|
||||
// 错误才指得回命令行本身,而不是变成一条读起来像后端故障的存储错误。
|
||||
if action != "approve" && !read_comment_from_stdin {
|
||||
return Err(
|
||||
"revise/reject 审批必须通过 --stdin 提供 1~1000 scalar 的修改意见".to_string(),
|
||||
);
|
||||
}
|
||||
return Ok(Some(CliCommand::PlanGddDecide {
|
||||
project_path: PathBuf::from(&rest[0]),
|
||||
action,
|
||||
response_id,
|
||||
read_comment_from_stdin,
|
||||
}));
|
||||
}
|
||||
if args.first().map(String::as_str) == Some("--agent-enqueue") {
|
||||
let mut rest = args[1..].to_vec();
|
||||
let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") {
|
||||
@@ -1423,6 +1535,63 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
CliCommand::PlanGddStatus { project_path } => {
|
||||
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
|
||||
let state =
|
||||
hydrate_game_creator_plan_gdd_state_for_path(&project_path.display().to_string())?;
|
||||
println!("plan.gdd.status");
|
||||
println!(
|
||||
"planGddStateJson={}",
|
||||
serialize_agent_runtime_cli_payload(&state)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
CliCommand::PlanGddDecide {
|
||||
project_path,
|
||||
action,
|
||||
response_id,
|
||||
read_comment_from_stdin,
|
||||
} => {
|
||||
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
|
||||
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
|
||||
let comment = if read_comment_from_stdin {
|
||||
Some(read_cli_plan_gdd_approval_comment(
|
||||
&mut std::io::stdin().lock(),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let project_path_value = project_path.display().to_string();
|
||||
// 审批卡的 identity 只有投影这一个权威来源。CLI 不接受手工传 gddId/fingerprint:
|
||||
// 那样每个调用方都要自己拼一遍身份,拼错的后果是 PLAN_STALE_APPROVAL,
|
||||
// 而不是一条能读懂的用法错误。
|
||||
let state = hydrate_game_creator_plan_gdd_state_for_path(&project_path_value)?;
|
||||
let pending = state
|
||||
.pending_approval
|
||||
.ok_or_else(|| "当前没有待决定的 Fast GDD 审批".to_string())?;
|
||||
// 每次调用换新 responseId 是安全方向:重复键的最坏后果是 replayed 降级成
|
||||
// already-decided(两者都成功),而复用键改 action 会撞「同 responseId 的
|
||||
// 审批意图不一致」硬错误。要复放同一次决定时才显式传 --response-id。
|
||||
let response_id = response_id
|
||||
.unwrap_or_else(|| format!("gdd-response-{}", uuid::Uuid::new_v4().hyphenated()));
|
||||
let result = decide_game_creator_plan_gdd(
|
||||
project_path_value,
|
||||
pending.gdd_ref.gdd_id.clone(),
|
||||
pending.gdd_ref.version,
|
||||
pending.gdd_ref.fingerprint.clone(),
|
||||
pending.pending_action_id.clone(),
|
||||
pending.approval_request_id.clone(),
|
||||
response_id,
|
||||
action,
|
||||
comment,
|
||||
)?;
|
||||
println!("plan.gdd.decided");
|
||||
println!(
|
||||
"planGddDecisionJson={}",
|
||||
serialize_agent_runtime_cli_payload(&result)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
CliCommand::RunnerStatus => {
|
||||
println!(
|
||||
"runnerJson={}",
|
||||
@@ -1930,6 +2099,123 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plan_gdd_headless_approval_entries() {
|
||||
assert_eq!(
|
||||
parse_cli_command(&[
|
||||
"--plan-gdd-status".to_string(),
|
||||
"/tmp/game-project".to_string(),
|
||||
])
|
||||
.expect("parse plan gdd status")
|
||||
.expect("plan gdd status command"),
|
||||
CliCommand::PlanGddStatus {
|
||||
project_path: PathBuf::from("/tmp/game-project"),
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_cli_command(&[
|
||||
"--plan-gdd-decide".to_string(),
|
||||
"/tmp/game-project".to_string(),
|
||||
" approve ".to_string(),
|
||||
])
|
||||
.expect("parse plan gdd approve")
|
||||
.expect("plan gdd approve command"),
|
||||
CliCommand::PlanGddDecide {
|
||||
project_path: PathBuf::from("/tmp/game-project"),
|
||||
action: "approve".to_string(),
|
||||
response_id: None,
|
||||
read_comment_from_stdin: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_cli_command(&[
|
||||
"--plan-gdd-decide".to_string(),
|
||||
"/tmp/game-project".to_string(),
|
||||
"revise".to_string(),
|
||||
"--response-id".to_string(),
|
||||
" gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427 ".to_string(),
|
||||
"--stdin".to_string(),
|
||||
])
|
||||
.expect("parse plan gdd revise")
|
||||
.expect("plan gdd revise command"),
|
||||
CliCommand::PlanGddDecide {
|
||||
project_path: PathBuf::from("/tmp/game-project"),
|
||||
action: "revise".to_string(),
|
||||
response_id: Some("gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427".to_string()),
|
||||
read_comment_from_stdin: true,
|
||||
}
|
||||
);
|
||||
|
||||
// revise/reject 没有 --stdin 时必须在解析期就失败,否则错误会伪装成写入侧故障。
|
||||
for action in ["revise", "reject"] {
|
||||
assert!(parse_cli_command(&[
|
||||
"--plan-gdd-decide".to_string(),
|
||||
"/tmp/game-project".to_string(),
|
||||
action.to_string(),
|
||||
])
|
||||
.is_err());
|
||||
}
|
||||
for args in [
|
||||
vec!["--plan-gdd-decide"],
|
||||
vec!["--plan-gdd-decide", "/tmp/game-project"],
|
||||
vec!["--plan-gdd-decide", "/tmp/game-project", "confirm"],
|
||||
vec!["--plan-gdd-decide", "/tmp/game-project", "approve", "extra"],
|
||||
vec![
|
||||
"--plan-gdd-decide",
|
||||
"/tmp/game-project",
|
||||
"approve",
|
||||
"--response-id",
|
||||
],
|
||||
vec!["--plan-gdd-status", "/tmp/game-project", "extra"],
|
||||
] {
|
||||
assert!(
|
||||
parse_cli_command(&args.into_iter().map(str::to_string).collect::<Vec<_>>())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_gdd_decision_requires_started_external_runner_but_status_does_not() {
|
||||
let decide = CliCommand::PlanGddDecide {
|
||||
project_path: PathBuf::from("/tmp/game-project"),
|
||||
action: "approve".to_string(),
|
||||
response_id: None,
|
||||
read_comment_from_stdin: false,
|
||||
};
|
||||
assert!(decide.requires_external_agent_runner());
|
||||
assert!(decide.requires_started_external_agent_runner());
|
||||
assert!(!decide.is_read_only_status());
|
||||
|
||||
let status = CliCommand::PlanGddStatus {
|
||||
project_path: PathBuf::from("/tmp/game-project"),
|
||||
};
|
||||
assert!(!status.requires_external_agent_runner());
|
||||
assert!(status.is_read_only_status());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_plan_gdd_approval_comment_from_stdin() {
|
||||
assert_eq!(
|
||||
read_cli_plan_gdd_approval_comment(&mut Cursor::new(
|
||||
" 把核心循环写具体
|
||||
"
|
||||
))
|
||||
.expect("read approval comment"),
|
||||
"把核心循环写具体"
|
||||
);
|
||||
assert!(read_cli_plan_gdd_approval_comment(&mut Cursor::new(
|
||||
"
|
||||
"
|
||||
))
|
||||
.is_err());
|
||||
assert!(
|
||||
read_cli_plan_gdd_approval_comment(&mut Cursor::new("a".repeat(9 * 1024))).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_strict_structured_goal_payload_from_stdin() {
|
||||
let mut stdin = Cursor::new(
|
||||
|
||||
@@ -1506,7 +1506,16 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state(
|
||||
request: tauri::ipc::Request<'_>,
|
||||
) -> Result<PlanGddStateViewV1, String> {
|
||||
let input = parse_hydrate_plan_gdd_state_input(&request)?;
|
||||
let root = validated_local_project_directory_path(input.project_path.trim())?;
|
||||
hydrate_game_creator_plan_gdd_state_for_path(&input.project_path)
|
||||
}
|
||||
|
||||
/// Transport-independent projection read shared by the Tauri command and the
|
||||
/// headless CLI entry. Both must cross the same permission gate, otherwise the
|
||||
/// CLI would become a way to read a project the policy denies.
|
||||
pub(crate) fn hydrate_game_creator_plan_gdd_state_for_path(
|
||||
project_path: &str,
|
||||
) -> Result<PlanGddStateViewV1, String> {
|
||||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||||
enforce_project_permission_policy(&root, "conversation.read")?;
|
||||
hydrate_game_creator_plan_gdd_state_at(&root).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
@@ -36,9 +36,12 @@ import {
|
||||
inspectSwarmProjectArtifacts,
|
||||
localConfigFileName,
|
||||
nextSwarmAutoPilotReply,
|
||||
parsePlanGddDecisionOutput,
|
||||
parsePlanGddStatusOutput,
|
||||
parseRunnerShutdownOutput,
|
||||
parseSettledSwarmTurnReport,
|
||||
parseSwarmTestArguments,
|
||||
planGddAutoApprovalIsPending,
|
||||
prepareSwarmTestProject,
|
||||
prepareSwarmTestRuntimeConfig,
|
||||
removeDirectoryWithTimeout,
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
shouldStartPersistentPreview,
|
||||
swarmAutoPilotShouldCloseInput,
|
||||
swarmAutoPilotSitsAtPrompt,
|
||||
swarmOutputAwaitsPlanGddApproval,
|
||||
terminateChildTree,
|
||||
testProjectPrefix,
|
||||
testProjectSentinelName,
|
||||
@@ -739,6 +743,75 @@ describe('Swarm test argument parsing', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('triggers the approval while the chat CLI is still running', () => {
|
||||
// Run 停在审批位时状态是 waiting-for-user-input,而 swarm CLI 把这个状态算作
|
||||
// 「本轮还在跑」,turn 永远不 settle。等 CLI 退出再审批那一步根本到不了,所以
|
||||
// 触发必须认这条运行期状态行。
|
||||
expect(
|
||||
swarmOutputAwaitsPlanGddApproval(
|
||||
'[状态] project-supervisor running/waiting-for-user-input run=r queue=0/1/0/0 | 等待 Fast GDD 审批决定',
|
||||
),
|
||||
).toBe(true);
|
||||
// 另外三个 plan_gdd blocked 子状态各有自己的文案,都不该触发审批。
|
||||
expect(
|
||||
swarmOutputAwaitsPlanGddApproval(
|
||||
'[状态] project-supervisor running/planning | 推进本根 Run 的 Fast GDD 提交',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
swarmOutputAwaitsPlanGddApproval(
|
||||
'[状态] project-supervisor running/planning | 等待 Fast GDD 审批投影收尾',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
swarmOutputAwaitsPlanGddApproval(
|
||||
'[状态] project-supervisor needs-reconciliation | Fast GDD 审批投影需要人工核对',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the headless Fast GDD approval projection off the CLI output', () => {
|
||||
const state = parsePlanGddStatusOutput(
|
||||
[
|
||||
'plan.gdd.status',
|
||||
'planGddStateJson={"state":"ready_for_approval","pendingApproval":{"gddRef":{"gddId":"gdd-1","version":1,"fingerprint":"fp"},"pendingActionId":"action-1","approvalRequestId":"approval-1"},"session":{"clarificationRound":2,"phase":"awaiting_gdd_approval"}}',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
expect(state.state).toBe('ready_for_approval');
|
||||
expect(state.session.clarificationRound).toBe(2);
|
||||
expect(planGddAutoApprovalIsPending(state)).toBe(true);
|
||||
// CLI 在 Windows 上按行输出会带 CR,解析必须先剥掉再判前缀。
|
||||
expect(
|
||||
parsePlanGddStatusOutput('planGddStateJson={"state":"approved"}\r\n')
|
||||
.state,
|
||||
).toBe('approved');
|
||||
expect(
|
||||
parsePlanGddDecisionOutput(
|
||||
'plan.gdd.decided\nplanGddDecisionJson={"outcome":"decided","decisionRef":{"version":1}}',
|
||||
).outcome,
|
||||
).toBe('decided');
|
||||
});
|
||||
|
||||
it('auto-approves only when the projection really carries a pending card', () => {
|
||||
// 审批状态是「有没有待决定」的唯一权威。没有待决定时自动批准会把一次空跑
|
||||
// 报成通过,所以这里必须 false 而不是继续往下走。
|
||||
expect(
|
||||
planGddAutoApprovalIsPending({
|
||||
state: 'approved',
|
||||
pendingApproval: null,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(planGddAutoApprovalIsPending({ state: 'draft' })).toBe(false);
|
||||
expect(planGddAutoApprovalIsPending(null)).toBe(false);
|
||||
// 缺前缀或 JSON 坏掉都要报错,不能静默当成「没有待决定」。
|
||||
expect(() => parsePlanGddStatusOutput('plan.gdd.status')).toThrowError();
|
||||
expect(() =>
|
||||
parsePlanGddStatusOutput('planGddStateJson={oops'),
|
||||
).toThrowError();
|
||||
expect(() => parsePlanGddDecisionOutput('plan.gdd.decided')).toThrowError();
|
||||
});
|
||||
|
||||
it('keeps persistent preview only for manual chat mode', () => {
|
||||
expect(shouldStartPersistentPreview(parseSwarmTestArguments([]))).toBe(
|
||||
true,
|
||||
|
||||
Reference in New Issue
Block a user