feat(orchestrator): run — planning mode-rotation retry loop

Replace the single hardcoded-Rich planning call with the mode-rotation
retry loop (S3b-1b Task C2). Resolves tier via model_profile, loops
attempt_modes(tier) ([Rich]/[Rich,Minimal]/[Rich,Minimal,Compact]),
handles stream-error rotation, abort short-circuit, parse failure
continue/fall-through, compact forced_style_guide_name backfill, and
exhaustion → build_fallback_plan. Adds tracing dep for warn! diagnostics.
This commit is contained in:
Fini 2026-05-23 02:48:00 +08:00
parent b3c462cd38
commit 599e9b4ffa
3 changed files with 276 additions and 21 deletions

14
Cargo.lock generated
View file

@ -2625,6 +2625,7 @@ dependencies = [
"op-i18n",
"op-mcp",
"op-opmerge",
"op-orchestrator",
"op-pen-loader",
"reqwest",
"rfd",
@ -2696,6 +2697,19 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "op-orchestrator"
version = "0.1.0"
dependencies = [
"futures",
"jian-ops-schema",
"op-ai-skills",
"op-editor-core",
"serde",
"serde_json",
"tracing",
]
[[package]]
name = "op-pen-loader"
version = "0.1.0"

View file

@ -14,6 +14,7 @@ jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema" }
serde = { workspace = true }
serde_json = { workspace = true }
futures = "0.3"
tracing = { workspace = true }
[dev-dependencies]
serde = { workspace = true, features = ["derive"] }

View file

@ -5,14 +5,16 @@
//! spec §6。
use crate::cleanup::{descendant_count, run_cleanup_passes};
use crate::plan::{build_fallback_plan, parse_plan};
use crate::plan_normalize::normalize;
use crate::plan::{build_fallback_plan, OrchestratorPlan};
use crate::plan_normalize::{normalize, NormInfo};
use crate::plan_repair::parse_orchestrator_response;
use crate::prompt::build_orchestrator_prompt;
use crate::retry::attempt_modes;
use crate::scaffold::build_scaffold;
use crate::subagent::run_subtask;
use crate::types::{
AbortFlag, DesignRequest, DocSink, LlmChunk, LlmClient, OrchestratorError, PlanningMode,
Progress, RunSummary, SubtaskOutcome,
AbortFlag, DesignRequest, DocSink, LlmChunk, LlmClient, OrchestratorError, Progress,
RunSummary, SubtaskOutcome,
};
use crate::variables::{rollback, seed_commands, snapshot_plan_vars};
use futures::StreamExt;
@ -37,24 +39,10 @@ impl Orchestrator {
on_progress: &mut dyn FnMut(Progress),
abort: &AbortFlag,
) -> Result<RunSummary, OrchestratorError> {
// -- 阶段 1:规划 --
// -- 阶段 1:规划(含 mode-rotation 重试循环 + 规范化, S3b-1b Task C2)--
// `planning_loop` 内部已 normalize 并回传 `NormInfo`,此处不再二次规范化。
on_progress(Progress::Planning);
let plan_call =
build_orchestrator_prompt(&request, PlanningMode::Rich, abort.clone()).call_request;
let mut plan = match collect_text(llm.call(plan_call)).await {
Ok(text) => parse_plan(&text).unwrap_or_else(|_| build_fallback_plan(&request)),
Err(aborted) => {
if aborted {
// abort 在规划阶段:尚未动文档,直接返回。
return Err(OrchestratorError::Aborted);
}
// 非 abort 失败 → 启发式兜底 plan。
build_fallback_plan(&request)
}
};
// -- 阶段 1.5:规范化 --
let norm = normalize(&mut plan, &request);
let (mut plan, norm) = planning_loop(&request, llm, abort).await?;
let planned_root_id = plan.root_frame.id.clone();
// -- 进入"已动文档"区,全程 undo batch 包裹 --
@ -190,6 +178,127 @@ impl Orchestrator {
}
}
/// 规划阶段: mode-rotation 重试循环。
///
/// Port of `callOrchestrator` planning stage in `orchestrator.ts:1323-1503`.
/// 解析 tier → `attempt_modes` → 遍历每个 mode,调用 LLM + parse,首次
/// 成功即回填 style_guide_name + normalize 后返回。全部失败 → fallback plan。
///
/// 返回 `(plan, NormInfo)` —— `planning_loop` 是唯一的规范化点,
/// `NormInfo` 透传给 `build_scaffold`,调用方不再二次 `normalize`。
async fn planning_loop(
request: &DesignRequest,
llm: &dyn LlmClient,
abort: &AbortFlag,
) -> Result<(OrchestratorPlan, NormInfo), OrchestratorError> {
let tier =
crate::model_profile::resolve_model_profile(request.model.as_deref().unwrap_or("")).tier;
let modes = attempt_modes(tier);
let last_idx = modes.len() - 1;
/// 规划失败的诊断记录 —— 仅用于 `tracing::warn!`,不影响控制流。
struct PlanningFailure {
reason: &'static str,
mode: &'static str,
detail: String,
}
let mut last_planning_failure: Option<PlanningFailure> = None;
for (attempt_idx, &mode) in modes.iter().enumerate() {
let pp = build_orchestrator_prompt(request, mode, abort.clone());
let collect_result = collect_text(llm.call(pp.call_request)).await;
let raw = match collect_result {
Ok(text) => text,
Err(true) => {
// abort 在流中发生 → 立即返回,不轮换
return Err(OrchestratorError::Aborted);
}
Err(false) => {
// 真实流错误 → 记录,继续下一档
let mode_name = mode_name(mode);
tracing::warn!(
mode = mode_name,
attempt = attempt_idx + 1,
"planning stream error; rotating to next mode"
);
last_planning_failure = Some(PlanningFailure {
reason: "stream_error",
mode: mode_name,
detail: String::new(),
});
if attempt_idx < last_idx {
continue;
} else {
break;
}
}
};
// abort 在流结束后被置位(两次检查对齐 TS)
if abort.is_set() {
return Err(OrchestratorError::Aborted);
}
match parse_orchestrator_response(&raw, request) {
Some((mut plan, _repaired)) => {
// compact 模式回填 forced_style_guide_name(若 plan 未携带)
if plan.style_guide_name.is_none() {
if let Some(forced) = pp.forced_style_guide_name {
plan.style_guide_name = Some(forced);
}
}
let norm = normalize(&mut plan, request);
return Ok((plan, norm));
}
None => {
let mode_name = mode_name(mode);
let preview = raw.trim().chars().take(150).collect::<String>();
tracing::warn!(
mode = mode_name,
attempt = attempt_idx + 1,
preview = %preview,
"planning parse failure; rotating to next mode"
);
last_planning_failure = Some(PlanningFailure {
reason: "parse_error",
mode: mode_name,
detail: preview,
});
if attempt_idx < last_idx {
continue;
}
// 最后一档 fall-through
}
}
}
// 所有档次耗尽 → fallback plan(规划不可出错)
if let Some(f) = &last_planning_failure {
tracing::warn!(
reason = f.reason,
mode = f.mode,
detail = %f.detail,
"planning exhausted all modes; using fallback plan"
);
}
let mut fallback = build_fallback_plan(request);
let norm = normalize(&mut fallback, request);
Ok((fallback, norm))
}
/// 返回 `PlanningMode` 的静态字符串名(用于日志)。
fn mode_name(mode: crate::types::PlanningMode) -> &'static str {
use crate::types::PlanningMode;
match mode {
PlanningMode::Rich => "rich",
PlanningMode::Minimal => "minimal",
PlanningMode::Compact => "compact",
}
}
/// 消费一次 LLM 调用的流 —— 拼接所有 `Text` chunk,丢弃 `Thinking`。
/// `Err(true)` 表示中止,`Err(false)` 表示真实错误。
async fn collect_text(
@ -220,6 +329,28 @@ mod tests {
}
}
// Standard tier → [Rich, Minimal]
fn req_standard() -> DesignRequest {
DesignRequest {
prompt: "a landing page".into(),
// "gpt-4o" matches Standard tier in model_profile table
model: Some("gpt-4o".into()),
provider: None,
design_md: None,
}
}
// Basic tier → [Rich, Minimal, Compact]
fn req_basic() -> DesignRequest {
DesignRequest {
prompt: "a landing page".into(),
// "glm" matches Basic tier in model_profile table
model: Some("glm-4".into()),
provider: None,
design_md: None,
}
}
const PLAN_JSON: &str = r##"{
"rootFrame": { "id": "root", "name": "Page", "width": 1200, "height": 800,
"layout": "vertical", "gap": 0,
@ -236,6 +367,8 @@ mod tests {
)
}
// ── existing tests (must stay green) ─────────────────────────────────────
#[test]
fn run_happy_path_applies_scaffold_and_subtasks() {
let llm = ScriptedLlm::new(vec![
@ -314,4 +447,111 @@ mod tests {
.expect("fallback run ok");
assert!(summary.total_nodes >= 1);
}
// ── Task C2: new tests (Step 1 — add failing, then implement) ─────────────
/// Attempt 1 returns bad JSON (parse_error), attempt 2 returns valid plan.
/// Standard tier → [Rich, Minimal] → rotation occurs.
#[test]
fn planning_rotation_uses_attempt2_plan_on_attempt1_parse_failure() {
let llm = ScriptedLlm::new(vec![
// attempt 1 (Rich) → bad JSON
ScriptResponse::Text("not valid json at all".into()),
// attempt 2 (Minimal) → valid plan
ScriptResponse::Text(PLAN_JSON.into()),
// subtasks
ScriptResponse::Text(node_json("hero")),
ScriptResponse::Text(node_json("feat")),
]);
let mut sink = VecDocSink::new();
let mut on_progress = |_p: Progress| {};
let summary = futures::executor::block_on(Orchestrator::new().run(
req_standard(), // Standard tier → [Rich, Minimal]
&mut sink,
&llm,
&mut on_progress,
&AbortFlag::new(),
))
.expect("rotation run ok");
// 2 subtasks from the attempt-2 plan
assert_eq!(summary.subtasks.len(), 2);
assert!(summary.total_nodes >= 2);
}
/// Attempt 1 returns a stream error, attempt 2 returns valid plan.
#[test]
fn planning_rotation_uses_attempt2_plan_on_attempt1_stream_error() {
use crate::types::LlmError;
let llm = ScriptedLlm::new(vec![
// attempt 1 → stream error (non-abort)
ScriptResponse::Fail(LlmError {
message: "HTTP 500 upstream".into(),
aborted: false,
}),
// attempt 2 → valid plan
ScriptResponse::Text(PLAN_JSON.into()),
// subtasks
ScriptResponse::Text(node_json("hero")),
ScriptResponse::Text(node_json("feat")),
]);
let mut sink = VecDocSink::new();
let mut on_progress = |_p: Progress| {};
let summary = futures::executor::block_on(Orchestrator::new().run(
req_standard(), // Standard tier → [Rich, Minimal]
&mut sink,
&llm,
&mut on_progress,
&AbortFlag::new(),
))
.expect("rotation on stream error ok");
assert_eq!(summary.subtasks.len(), 2);
}
/// All attempts fail (Basic tier → [Rich, Minimal, Compact]) →
/// fallback plan used, run succeeds.
#[test]
fn planning_all_attempts_fail_uses_fallback_plan() {
// Basic tier has 3 attempts; supply 3 bad responses + 1 subtask response
// for the fallback plan's single subtask.
let llm = ScriptedLlm::new(vec![
ScriptResponse::Text("garbage 1".into()),
ScriptResponse::Text("garbage 2".into()),
ScriptResponse::Text("garbage 3".into()),
ScriptResponse::Text(node_json("section-1")),
]);
let mut sink = VecDocSink::new();
let mut on_progress = |_p: Progress| {};
let summary = futures::executor::block_on(Orchestrator::new().run(
req_basic(),
&mut sink,
&llm,
&mut on_progress,
&AbortFlag::new(),
))
.expect("fallback after all failures ok");
assert!(summary.total_nodes >= 1);
}
/// Abort during planning stream → `OrchestratorError::Aborted`, no rotation.
#[test]
fn planning_abort_during_stream_returns_aborted() {
use crate::types::LlmError;
let llm = ScriptedLlm::new(vec![ScriptResponse::Fail(LlmError {
message: "user aborted".into(),
aborted: true,
})]);
let mut sink = VecDocSink::new();
let mut on_progress = |_p: Progress| {};
let abort = AbortFlag::new();
let result = futures::executor::block_on(Orchestrator::new().run(
req(),
&mut sink,
&llm,
&mut on_progress,
&abort,
));
assert!(matches!(result, Err(OrchestratorError::Aborted)));
// undo batch 在 abort 路径前返回,文档不应已进入批
assert_eq!(sink.batch_depth, 0);
}
}