新增运行中自主扩图提案能力

新增 GraphProposal、GraphLimits 与 TaskGraph 原子扩图校验。

补充严格 JSON、预算、环和失败原子性测试。

同步 Agent Runtime 技术方案与项目记忆。
This commit is contained in:
2026-08-26 20:24:06 +08:00
parent 1104215b2a
commit 92d53ea6cc
9 changed files with 1223 additions and 4 deletions
@@ -16,6 +16,13 @@
---
## 2026-08-26 运行中自主扩图提案留在编排层
- 背景:`agent-runtime-orchestration` 已能构造和调度动态 DAG,但 LLM 在执行中发现缺少步骤时没有通用的安全扩图合同。
- 决策:新增严格 serde 的 `GraphProposal``TaskProposal` + `GraphEdge`)和 `GraphLimits`,由 `TaskGraph::apply_proposal` / `expand_with_proposal` 在内存中构造不可变候选图;新节点默认 `Pending`,边方向为前置 `from` → 依赖方 `to`
- 安全与一致性:所有 Agent、端点、重复引用、环、节点/边/深度/扇出预算在候选返回前一次校验;边只能指向新节点,禁止给已运行任务原地追加依赖。任一失败保留旧图。成功后的 epoch、基图版本、proposal 幂等和持久化由宿主负责,crate 不调用 LLM/Provider/ToolHost/Runner,也不写 `.agent/runtime/**`
- 验证:非游戏 conformance 覆盖有效扩图、ready/wave 重算、未知 Agent/端点、重复边、已有任务修改、环、预算、严格 JSON 和原子失败;关联文档为 `docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.55。
## 2026-08-26 通用多 Agent DAG 编排与执行内核分层
- 背景:`agent-runtime-core` 已承接 catalog、run/action 生命周期、lane、宿主 ToolHost、spawn/all-join 和 Provider 契约,但动态任务图的 ready 选择、依赖波次与返工下游闭包仍混在 `platform-agent::game_creation`,其它产品无法复用且非法环会被合并成伪 wave。
@@ -51,7 +51,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.7.0` 对齐
## AGC DirectProject 与 UI workflow
- 通用 Agent Rust 分层为 `agent-runtime-core`catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次返工下游闭包)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。
- 通用 Agent Rust 分层为 `agent-runtime-core`catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。
- DirectProject 只连接客户端内置的 `agc_tools` STDIO MCP。它负责审核引用读取、标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用由客户端绑定回合、幂等账本、请求上限和投影权威。
- DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内可用;原生命令网络保持关闭。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。
- `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。
@@ -1679,6 +1679,28 @@ V1.48-V1.50 已把 catalog、执行生命周期与 Provider 契约收进 `agent-
- 未完成:Tauri `cargo check --tests` 已编译到 AGC 自定义 `build.rs`,随后因仓库四个候选路径均缺少内置 Codex CLI vendor 资源而退出(`build.rs:77`);本轮未执行 `npm ci`,因此不能将 Tauri 组合根或完整 `ai-game-creator-shell:check` 记为通过。
- 未执行:当前 Rust 1.96 工具链未安装 `clippy` component;没有把该静态检查结果用其它门禁结果替代。
## V1.55 运行中自主扩图提案
V1.54 的公共编排层可以在运行前构造动态 DAG,但 LLM 在执行过程中发现缺少步骤时还没有一个通用、受限的扩图入口。V1.55 在同一 `agent-runtime-orchestration` crate 增加结构化 `GraphProposal`,让宿主能够把 LLM 的 function-call arguments 解析为候选节点和边,并在不改变 `agent-runtime-core` 执行职责的前提下生成下一张图。
### 提案 DTO 与宿主边界
- `GraphProposal` 只包含 `nodes``edges`。节点使用 `{ id, agentId }`,边使用 `{ from, to }``from` 是前置任务,`to` 是依赖它的任务。DTO 使用 `camelCase` 且拒绝未知字段,节点和边的稳定标识沿用公共图模型约束。
- `TaskGraph::apply_proposal` / `expand_with_proposal` 是纯校验与候选构造 API:不调用 LLM、Provider、ToolHost、Runner 或持久化。宿主负责声明 function tool、把 arguments 反序列化为 `GraphProposal`,并在成功后把返回的候选图写入自己的新 epoch。
- 新节点统一以 `Pending` 加入,并保留现有任务状态和注册顺序。提案边必须指向本次新增节点;不允许在运行中的旧任务上原地追加前置依赖。若业务确实要改旧边,宿主应构造完整候选图并按自己的 CAS/epoch 合同一次替换。
### 原子校验与预算
- 候选图只有在所有检查通过后才返回;未知 Agent、未知端点、重复节点/边、自依赖、有向环和已有节点依赖修改都会失败,原图保持不变。
- `GraphLimits` 同时限制完整候选图的 `maxTasks``maxEdges``maxDepth`(根层计 1)和 `maxOutDegree`(一个前置任务的直接下游数)。默认值为 `128 / 512 / 32 / 32`;超限不截断、不部分提交。
- 成功扩图后,宿主必须把它视为新的 graph/epoch,重新计算 ready task 与 dependency waves,并在自己的持久层记录提案身份、基图版本和幂等结果。crate 不把 epoch、proposal ID 或执行事实写入图,也不自动重放 Provider。
### V1.55 验收
- 非游戏 conformance 覆盖有效新增节点/边、全部新节点 `Pending`、ready/wave 重算、未知 Agent、未知端点、重复边、已有任务修改、环、节点/边/深度/扇出预算和失败原子性。
- 覆盖 `GraphProposal``GraphEdge``GraphLimits``TaskGraph` 的严格 JSON round-trip;未知字段、非法标识和零预算均失败关闭。
- 真实 LLM 接入仍由宿主后续提供;本切片证明了宿主可在一个结构化 function call 回合中安全生成候选新图,但不把 provider 请求或持久化当作 crate 的事实源。
## 验收命令
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture`
@@ -1,5 +1,11 @@
# AI 游戏创作智能体 App 实施计划
## 2026-08-26 运行中自主扩图提案
- `agent-runtime-orchestration` 提供严格 serde 的 `GraphProposal``TaskProposal``GraphEdge``GraphLimits`。宿主可把 LLM function-call arguments 解析后交给 `TaskGraph::apply_proposal`,在内存中得到新的、完整校验过的候选 DAG。
- 新节点默认 `Pending`;边使用 `from`(前置)→ `to`(依赖方),且新增边只能指向本次新增节点。未知 Agent/端点、重复节点/边、自依赖、有向环和节点/边/深度/扇出预算超限整次失败,旧图不变。
- crate 不调用 Provider、Runner、ToolHost 或持久化;宿主负责 function tool 暴露、基图/epoch CAS、proposal 幂等、落盘和重新调度 ready/dependency waves。修改既有任务依赖时必须由宿主构造完整候选图并切换新 epoch。
## 2026-08-25 账户 / 项目画布 / 本地素材导入
- 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。
@@ -3,17 +3,35 @@ use std::fmt;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OrchestrationErrorKind {
InvalidInput,
InvalidLimits,
EmptyProposal,
DuplicateTask,
DuplicateDependency,
DuplicateEdge,
UnknownDependency,
SelfDependency,
Cycle,
UnknownAgent,
UnknownTask,
ExistingTaskMutation,
NodeBudgetExceeded,
EdgeBudgetExceeded,
DepthBudgetExceeded,
FanOutBudgetExceeded,
ConflictingTaskSet,
UnsatisfiedDependency,
}
#[allow(non_upper_case_globals)]
impl OrchestrationErrorKind {
/// Compatibility alias for callers that describe the node budget as a
/// task budget.
pub const TaskBudgetExceeded: Self = Self::NodeBudgetExceeded;
/// Compatibility alias for callers that use the shorter fan-out spelling.
pub const FanoutBudgetExceeded: Self = Self::FanOutBudgetExceeded;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OrchestrationError {
kind: OrchestrationErrorKind,
@@ -1,7 +1,7 @@
use std::collections::{BTreeMap, BTreeSet, HashSet, VecDeque};
use agent_runtime_core::AgentCatalog;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};
use crate::{OrchestrationError, OrchestrationErrorKind};
@@ -105,13 +105,32 @@ impl TaskNode {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct TaskGraph {
goal: String,
tasks: Vec<TaskNode>,
#[serde(skip)]
by_id: BTreeMap<String, usize>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct TaskGraphInput {
goal: String,
tasks: Vec<TaskNode>,
}
impl<'de> Deserialize<'de> for TaskGraph {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let input = TaskGraphInput::deserialize(deserializer)?;
Self::try_new(input.goal, input.tasks).map_err(serde::de::Error::custom)
}
}
impl TaskGraph {
pub fn try_new(
goal: impl Into<String>,
@@ -165,6 +184,44 @@ impl TaskGraph {
.and_then(|index| self.tasks.get(*index))
}
/// Number of tasks in this graph.
pub fn task_count(&self) -> usize {
self.tasks.len()
}
/// Alias for [`TaskGraph::task_count`] using graph terminology.
pub fn node_count(&self) -> usize {
self.task_count()
}
/// Number of prerequisite edges in this graph.
pub fn edge_count(&self) -> usize {
self.tasks.iter().map(|task| task.dependencies.len()).sum()
}
/// Longest dependency path measured in task layers. A root task has
/// depth 1. Graph construction rejects cycles, so this calculation is
/// total for every `TaskGraph` value.
pub fn depth(&self) -> usize {
graph_depth(&self.tasks, &self.by_id)
}
/// Number of direct dependents of a prerequisite task.
pub fn fan_out(&self, task_id: &str) -> Option<usize> {
self.get(task_id)?;
Some(
self.tasks
.iter()
.filter(|task| task.dependencies.iter().any(|id| id == task_id))
.count(),
)
}
/// Alias for [`TaskGraph::fan_out`].
pub fn out_degree(&self, task_id: &str) -> Option<usize> {
self.fan_out(task_id)
}
pub fn validate_agents(&self, catalog: &AgentCatalog) -> Result<(), OrchestrationError> {
for task in &self.tasks {
if catalog.get(&task.agent_id).is_none() {
@@ -328,7 +385,7 @@ impl TaskGraph {
}
}
fn validate_identifier(value: &str, field: &str) -> Result<(), OrchestrationError> {
pub(crate) fn validate_identifier(value: &str, field: &str) -> Result<(), OrchestrationError> {
if value != value.trim() {
return Err(OrchestrationError::new(
OrchestrationErrorKind::InvalidInput,
@@ -417,3 +474,47 @@ fn validate_acyclic(
format!("task graph 包含依赖环:{}", cyclic.join(", ")),
))
}
fn graph_depth(tasks: &[TaskNode], by_id: &BTreeMap<String, usize>) -> usize {
if tasks.is_empty() {
return 0;
}
let mut indegrees = tasks
.iter()
.map(|task| task.dependencies.len())
.collect::<Vec<_>>();
let mut dependents = vec![Vec::<usize>::new(); tasks.len()];
for (task_index, task) in tasks.iter().enumerate() {
for dependency in &task.dependencies {
// `TaskGraph::try_new` proves this lookup exists. Keeping the
// defensive branch makes this helper total if it is ever reused
// during a future internal refactor.
let Some(&dependency_index) = by_id.get(dependency) else {
return 0;
};
dependents[dependency_index].push(task_index);
}
}
let mut depths = vec![1usize; tasks.len()];
let mut ready = indegrees
.iter()
.enumerate()
.filter_map(|(index, indegree)| (*indegree == 0).then_some(index))
.collect::<VecDeque<_>>();
let mut visited = 0;
let mut maximum = 1;
while let Some(index) = ready.pop_front() {
visited += 1;
maximum = maximum.max(depths[index]);
for dependent in &dependents[index] {
depths[*dependent] = depths[*dependent].max(depths[index].saturating_add(1));
indegrees[*dependent] -= 1;
if indegrees[*dependent] == 0 {
ready.push_back(*dependent);
}
}
}
if visited == tasks.len() { maximum } else { 0 }
}
@@ -7,7 +7,13 @@
mod error;
mod graph;
mod plan;
mod proposal;
pub use error::{OrchestrationError, OrchestrationErrorKind};
pub use graph::{TaskGraph, TaskNode, TaskStatus};
pub use plan::{OrchestrationPlan, PlanSelection};
pub use proposal::{
AppliedGraphProposal, DEFAULT_GRAPH_MAX_DEPTH, DEFAULT_GRAPH_MAX_EDGES,
DEFAULT_GRAPH_MAX_OUT_DEGREE, DEFAULT_GRAPH_MAX_TASKS, GraphEdge, GraphExpansion, GraphLimits,
GraphProposal, TaskProposal,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,315 @@
use agent_runtime_core::{AgentCatalog, AgentDescriptor};
use agent_runtime_orchestration::{
GraphEdge, GraphLimits, GraphProposal, OrchestrationErrorKind, PlanSelection, TaskGraph,
TaskNode, TaskProposal, TaskStatus,
};
fn task(id: &str, agent_id: &str, status: TaskStatus, dependencies: &[&str]) -> TaskNode {
TaskNode::try_new(id, agent_id, status, dependencies.iter().copied()).expect("valid task")
}
fn catalog() -> AgentCatalog {
AgentCatalog::try_new([
AgentDescriptor::try_new("researcher", "research", std::iter::empty::<&str>())
.expect("researcher"),
AgentDescriptor::try_new("reviewer", "review", std::iter::empty::<&str>())
.expect("reviewer"),
AgentDescriptor::try_new("writer", "writing", std::iter::empty::<&str>()).expect("writer"),
])
.expect("catalog")
}
fn base_graph() -> TaskGraph {
TaskGraph::try_new(
"Review a document collection",
[
task("collect", "researcher", TaskStatus::Completed, &[]),
task("draft", "writer", TaskStatus::Pending, &["collect"]),
],
)
.expect("base graph")
}
fn proposal(nodes: &[(&str, &str)], edges: &[(&str, &str)]) -> GraphProposal {
GraphProposal::try_new(
nodes
.iter()
.map(|(id, agent)| TaskProposal::try_new(*id, *agent).expect("valid proposal node")),
edges
.iter()
.map(|(from, to)| GraphEdge::try_new(*from, *to).expect("valid proposal edge")),
)
.expect("valid proposal")
}
#[test]
fn llm_proposal_creates_a_new_pending_subgraph_without_mutating_the_old_graph() {
let graph = base_graph();
let candidate = graph
.apply_proposal(
&proposal(
&[("review", "reviewer"), ("publish", "writer")],
&[
("collect", "review"),
("draft", "publish"),
("review", "publish"),
],
),
&catalog(),
&GraphLimits::default(),
)
.expect("proposal should be accepted");
assert_eq!(graph.task_count(), 2);
assert_eq!(graph.edge_count(), 1);
assert_eq!(candidate.task_count(), 4);
assert_eq!(candidate.edge_count(), 4);
assert_eq!(
candidate.get("review").expect("review task").status(),
TaskStatus::Pending
);
assert_eq!(
candidate.get("review").expect("review task").dependencies(),
&["collect".to_string()]
);
assert_eq!(
candidate
.get("publish")
.expect("publish task")
.dependencies(),
&["draft".to_string(), "review".to_string()]
);
assert_eq!(candidate.ready_task_ids(), vec!["draft", "review"]);
let plan = candidate
.plan(PlanSelection::All)
.expect("expanded graph should produce dependency waves");
assert_eq!(
plan.dependency_waves(),
&[
vec!["collect".to_string()],
vec!["draft".to_string(), "review".to_string()],
vec!["publish".to_string()],
]
);
}
#[test]
fn expansion_report_contains_only_the_validated_delta() {
let graph = base_graph();
let change = proposal(&[("review", "reviewer")], &[("collect", "review")]);
let expansion = graph
.expand_with_proposal(&change, &catalog(), &GraphLimits::default())
.expect("proposal should be accepted");
assert_eq!(expansion.added_task_ids(), ["review"]);
assert_eq!(expansion.added_edges(), change.edges());
assert_eq!(
expansion.graph().get("review").map(TaskNode::id),
Some("review")
);
}
#[test]
fn proposal_rejects_unknown_agents_and_keeps_the_current_graph_intact() {
let graph = base_graph();
let error = graph
.apply_proposal(
&proposal(&[("review", "unknown-agent")], &[]),
&catalog(),
&GraphLimits::default(),
)
.expect_err("unknown agent");
assert_eq!(error.kind(), OrchestrationErrorKind::UnknownAgent);
assert_eq!(graph.task_count(), 2);
assert!(graph.get("review").is_none());
}
#[test]
fn proposal_cycle_is_rejected_atomically() {
let graph = base_graph();
let error = graph
.apply_proposal(
&proposal(
&[("left", "researcher"), ("right", "reviewer")],
&[("left", "right"), ("right", "left")],
),
&catalog(),
&GraphLimits::default(),
)
.expect_err("cycle");
assert_eq!(error.kind(), OrchestrationErrorKind::Cycle);
assert_eq!(graph.task_count(), 2);
assert!(graph.get("left").is_none());
}
#[test]
fn proposal_cannot_add_a_prerequisite_to_an_existing_task() {
let graph = base_graph();
let error = graph
.apply_proposal(
&proposal(&[("review", "reviewer")], &[("review", "draft")]),
&catalog(),
&GraphLimits::default(),
)
.expect_err("existing task mutation");
assert_eq!(error.kind(), OrchestrationErrorKind::ExistingTaskMutation);
assert_eq!(
graph.get("draft").expect("draft").dependencies(),
&["collect".to_string()]
);
}
#[test]
fn proposal_limits_cover_nodes_edges_depth_and_fan_out() {
let graph = base_graph();
let limits = GraphLimits::new(8, 8, 8, 1);
let fan_out = graph
.apply_proposal(
&proposal(
&[("review", "reviewer"), ("verify", "reviewer")],
&[("collect", "review"), ("collect", "verify")],
),
&catalog(),
&limits,
)
.expect_err("fan-out budget");
assert_eq!(fan_out.kind(), OrchestrationErrorKind::FanOutBudgetExceeded);
let node_budget = graph
.apply_proposal(
&proposal(&[("review", "reviewer"), ("verify", "reviewer")], &[]),
&catalog(),
&GraphLimits::new(3, 8, 8, 8),
)
.expect_err("node budget");
assert_eq!(
node_budget.kind(),
OrchestrationErrorKind::NodeBudgetExceeded
);
let edge_budget = graph
.apply_proposal(
&proposal(
&[("review", "reviewer"), ("verify", "reviewer")],
&[("collect", "review"), ("collect", "verify")],
),
&catalog(),
&GraphLimits::new(8, 2, 8, 8),
)
.expect_err("edge budget");
assert_eq!(
edge_budget.kind(),
OrchestrationErrorKind::EdgeBudgetExceeded
);
}
#[test]
fn strict_json_round_trips_proposals_and_rejects_unknown_fields() {
let change = proposal(&[("review", "reviewer")], &[("collect", "review")]);
let json = serde_json::to_value(&change).expect("serialize proposal");
assert_eq!(
json,
serde_json::json!({
"nodes": [{"id": "review", "agentId": "reviewer"}],
"edges": [{"from": "collect", "to": "review"}]
})
);
let decoded: GraphProposal = serde_json::from_value(json).expect("decode proposal");
assert_eq!(decoded, change);
let unknown = serde_json::from_str::<GraphProposal>(
r#"{"nodes":[{"id":"review","agentId":"reviewer","title":"not allowed"}],"edges":[]}"#,
)
.expect_err("unknown proposal field");
assert!(unknown.to_string().contains("unknown field"));
let graph = base_graph();
let graph_json = serde_json::to_value(&graph).expect("serialize graph");
let restored: TaskGraph = serde_json::from_value(graph_json).expect("decode graph");
assert_eq!(restored, graph);
let limits = GraphLimits::default();
let limits_json = serde_json::to_value(limits).expect("serialize limits");
assert_eq!(
limits_json,
serde_json::json!({
"maxTasks": 128,
"maxEdges": 512,
"maxDepth": 32,
"maxOutDegree": 32
})
);
let restored_limits: GraphLimits = serde_json::from_value(limits_json).expect("decode limits");
assert_eq!(restored_limits, limits);
let unknown_limits = serde_json::from_str::<GraphLimits>(
r#"{"maxTasks":1,"maxEdges":1,"maxDepth":1,"maxOutDegree":1,"extra":true}"#,
)
.expect_err("unknown limits field");
assert!(unknown_limits.to_string().contains("unknown field"));
}
#[test]
fn proposal_rejects_duplicate_edges_unknown_endpoints_and_invalid_limits() {
let duplicate = GraphProposal::try_new(
[TaskProposal::try_new("review", "reviewer").expect("node")],
[
GraphEdge::try_new("collect", "review").expect("edge"),
GraphEdge::try_new("collect", "review").expect("edge"),
],
)
.expect_err("duplicate edge");
assert_eq!(duplicate.kind(), OrchestrationErrorKind::DuplicateEdge);
let unknown = base_graph()
.apply_proposal(
&proposal(&[("review", "reviewer")], &[("missing", "review")]),
&catalog(),
&GraphLimits::default(),
)
.expect_err("unknown edge source");
assert_eq!(unknown.kind(), OrchestrationErrorKind::UnknownDependency);
let invalid_limits = GraphLimits::try_new(0, 1, 1, 1).expect_err("zero limit");
assert_eq!(invalid_limits.kind(), OrchestrationErrorKind::InvalidLimits);
}
#[test]
fn graph_reports_layer_depth_and_fan_out() {
let graph = TaskGraph::try_new(
"depth",
[
task("root", "researcher", TaskStatus::Pending, &[]),
task("middle", "reviewer", TaskStatus::Pending, &["root"]),
task("leaf", "writer", TaskStatus::Pending, &["middle"]),
],
)
.expect("graph");
assert_eq!(graph.depth(), 3);
assert_eq!(graph.fan_out("root"), Some(1));
assert_eq!(graph.node_count(), 3);
assert_eq!(graph.fan_out("missing"), None);
}
#[test]
fn proposal_rejects_empty_payload_and_depth_overflow() {
let empty = GraphProposal::try_new(
std::iter::empty::<TaskProposal>(),
std::iter::empty::<GraphEdge>(),
)
.expect_err("empty proposal");
assert_eq!(empty.kind(), OrchestrationErrorKind::EmptyProposal);
let graph = base_graph();
let error = graph
.apply_proposal(
&proposal(
&[("review", "reviewer"), ("publish", "writer")],
&[("collect", "review"), ("review", "publish")],
),
&catalog(),
&GraphLimits::new(8, 8, 2, 8),
)
.expect_err("candidate depth should exceed the limit");
assert_eq!(error.kind(), OrchestrationErrorKind::DepthBudgetExceeded);
}