use serde_json::{Map, Value};
use crate::model::{CompactLease, StagePlan};
#[derive(Debug, Clone)]
pub(crate) struct ReportReady {
pub(crate) lease_id: String,
pub(crate) task: String,
pub(crate) report: String,
}
impl ReportReady {
fn to_payload(&self) -> Map<String, Value> {
let mut map = Map::new();
map.insert("lease_id".to_string(), Value::String(self.lease_id.clone()));
map.insert("task".to_string(), Value::String(self.task.clone()));
map.insert("report".to_string(), Value::String(self.report.clone()));
map
}
}
#[derive(Debug, Clone)]
pub(crate) struct CleanupCandidate {
pub(crate) lease_id: String,
pub(crate) task: String,
}
impl CleanupCandidate {
fn to_payload(&self) -> Map<String, Value> {
let mut map = Map::new();
map.insert("lease_id".to_string(), Value::String(self.lease_id.clone()));
map.insert("task".to_string(), Value::String(self.task.clone()));
map
}
}
#[derive(Debug, Clone)]
pub(crate) struct ReadyTask {
pub(crate) id: String,
pub(crate) spec: String,
pub(crate) task: String,
pub(crate) path: String,
pub(crate) scope: Vec<String>,
pub(crate) verification_mode: String,
}
impl ReadyTask {
fn to_payload(&self) -> Map<String, Value> {
let mut map = Map::new();
map.insert("spec".to_string(), Value::String(self.spec.clone()));
map.insert("task".to_string(), Value::String(self.task.clone()));
map.insert("path".to_string(), Value::String(self.path.clone()));
map.insert(
"scope".to_string(),
Value::Array(self.scope.iter().cloned().map(Value::String).collect()),
);
map.insert(
"verification_mode".to_string(),
Value::String(self.verification_mode.clone()),
);
map
}
}
#[derive(Debug, Clone)]
pub(crate) struct BlockedTask {
pub(crate) task: String,
pub(crate) reason: String,
}
impl BlockedTask {
pub(crate) fn to_payload(&self) -> Map<String, Value> {
let mut map = Map::new();
map.insert("task".to_string(), Value::String(self.task.clone()));
map.insert("reason".to_string(), Value::String(self.reason.clone()));
map
}
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub(crate) enum Phase {
Blocked,
Cleanup,
Dispatch,
Done,
Recover,
Stage,
Validate,
Wait,
}
impl Phase {
fn as_str(self) -> &'static str {
match self {
Phase::Blocked => "blocked",
Phase::Cleanup => "cleanup",
Phase::Dispatch => "dispatch",
Phase::Done => "done",
Phase::Recover => "recover",
Phase::Stage => "stage",
Phase::Validate => "validate",
Phase::Wait => "wait",
}
}
}
pub(crate) struct NextInput {
pub(crate) selected_specs: Vec<String>,
pub(crate) stale: Vec<CompactLease>,
pub(crate) reports_ready: Vec<ReportReady>,
pub(crate) active: Vec<CompactLease>,
pub(crate) stage: Vec<StagePlan>,
pub(crate) cleanup: Vec<CleanupCandidate>,
pub(crate) ready: Vec<ReadyTask>,
pub(crate) blocked: Vec<BlockedTask>,
pub(crate) counts: Map<String, Value>,
pub(crate) older_than: String,
pub(crate) explain: bool,
}
pub(crate) struct NextDecision {
pub(crate) phase: Phase,
pub(crate) recommended_action: String,
pub(crate) selected_specs: Vec<String>,
pub(crate) details: Map<String, Value>,
}
impl NextDecision {
pub(crate) fn to_payload(&self) -> Map<String, Value> {
let mut map = Map::new();
map.insert("ok".to_string(), Value::Bool(true));
map.insert("action".to_string(), Value::String("next".to_string()));
map.insert(
"phase".to_string(),
Value::String(self.phase.as_str().to_string()),
);
map.insert(
"recommended_action".to_string(),
Value::String(self.recommended_action.clone()),
);
map.insert(
"selected_specs".to_string(),
Value::Array(
self.selected_specs
.iter()
.cloned()
.map(Value::String)
.collect(),
),
);
map.extend(self.details.clone());
map
}
}
pub(crate) fn decide_next(input: NextInput) -> NextDecision {
let NextInput {
selected_specs,
stale,
reports_ready,
active,
stage,
cleanup,
ready,
blocked,
counts,
older_than,
explain,
} = input;
let has_blocked = !blocked.is_empty();
let visible_blocked = if explain { blocked } else { Vec::new() };
if !stale.is_empty() {
let mut details = Map::new();
details.insert("stale".to_string(), compact_array(stale));
details.insert("ready".to_string(), Value::Array(Vec::new()));
details.insert("blocked".to_string(), blocked_array(visible_blocked));
return NextDecision {
phase: Phase::Recover,
recommended_action: format!(
"stale --older-than {older_than}; then heartbeat, release, or close --force"
),
selected_specs,
details,
};
}
if !reports_ready.is_empty() {
let first = &reports_ready[0];
let report = first.report.clone();
let lease_id = first.lease_id.clone();
let mut details = Map::new();
details.insert("reports_ready".to_string(), reports_array(reports_ready));
details.insert("ready".to_string(), Value::Array(Vec::new()));
details.insert("blocked".to_string(), blocked_array(visible_blocked));
return NextDecision {
phase: Phase::Validate,
recommended_action: format!("report-check {report}; git-touched --lease {lease_id}"),
selected_specs,
details,
};
}
if !active.is_empty() {
let mut details = Map::new();
details.insert("active".to_string(), compact_array(active));
details.insert("ready".to_string(), Value::Array(Vec::new()));
details.insert("blocked".to_string(), blocked_array(visible_blocked));
return NextDecision {
phase: Phase::Wait,
recommended_action:
"wait for active leases, heartbeat them, or run stale if they stop moving"
.to_string(),
selected_specs,
details,
};
}
let stage_candidates: Vec<StagePlan> = stage
.into_iter()
.filter(|item| !item.pathspecs.is_empty() || !item.safe_to_stage)
.collect();
if !stage_candidates.is_empty() {
let first = &stage_candidates[0];
let mut details = Map::new();
details.insert(
"stage".to_string(),
Value::Array(
stage_candidates
.iter()
.map(|item| Value::Object(item.to_payload()))
.collect(),
),
);
details.insert("ready".to_string(), Value::Array(Vec::new()));
details.insert("blocked".to_string(), blocked_array(visible_blocked));
return NextDecision {
phase: Phase::Stage,
recommended_action: format!("git-stage-plan --lease {}", first.lease_id),
selected_specs,
details,
};
}
if !cleanup.is_empty() {
let lease_id = cleanup[0].lease_id.clone();
let mut details = Map::new();
details.insert("cleanup".to_string(), cleanup_array(cleanup));
details.insert("ready".to_string(), Value::Array(Vec::new()));
details.insert("blocked".to_string(), blocked_array(visible_blocked));
return NextDecision {
phase: Phase::Cleanup,
recommended_action: format!("close --lease {lease_id}"),
selected_specs,
details,
};
}
if !ready.is_empty() {
let first = &ready[0];
let spec = first.spec.clone();
let id = first.id.clone();
let mut details = Map::new();
details.insert("ready".to_string(), ready_array(ready));
details.insert("blocked".to_string(), blocked_array(visible_blocked));
return NextDecision {
phase: Phase::Dispatch,
recommended_action: format!("lease {spec} {id} --owner worker:<agent-id>"),
selected_specs,
details,
};
}
let total: i64 = counts.values().filter_map(Value::as_i64).sum();
let done = counts.get("done").and_then(Value::as_i64).unwrap_or(0);
let (phase, recommended_action) = if total != 0 && done == total {
(Phase::Done, "stop; selected scope is complete")
} else if has_blocked {
(
Phase::Blocked,
"resolve blocked tasks or escalate; do not broaden scope",
)
} else {
(Phase::Done, "stop; no dispatchable tasks in selected scope")
};
let mut details = Map::new();
details.insert("counts".to_string(), Value::Object(counts));
details.insert("ready".to_string(), Value::Array(Vec::new()));
details.insert("blocked".to_string(), blocked_array(visible_blocked));
NextDecision {
phase,
recommended_action: recommended_action.to_string(),
selected_specs,
details,
}
}
fn compact_array(items: Vec<CompactLease>) -> Value {
Value::Array(
items
.into_iter()
.map(|item| Value::Object(item.to_payload()))
.collect(),
)
}
fn reports_array(items: Vec<ReportReady>) -> Value {
Value::Array(
items
.into_iter()
.map(|item| Value::Object(item.to_payload()))
.collect(),
)
}
fn cleanup_array(items: Vec<CleanupCandidate>) -> Value {
Value::Array(
items
.into_iter()
.map(|item| Value::Object(item.to_payload()))
.collect(),
)
}
fn ready_array(items: Vec<ReadyTask>) -> Value {
Value::Array(
items
.into_iter()
.map(|item| Value::Object(item.to_payload()))
.collect(),
)
}
fn blocked_array(items: Vec<BlockedTask>) -> Value {
Value::Array(
items
.into_iter()
.map(|item| Value::Object(item.to_payload()))
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn input() -> NextInput {
NextInput {
selected_specs: vec!["example".to_string()],
stale: Vec::new(),
reports_ready: Vec::new(),
active: Vec::new(),
stage: Vec::new(),
cleanup: Vec::new(),
ready: Vec::new(),
blocked: Vec::new(),
counts: Map::new(),
older_than: "30m".to_string(),
explain: false,
}
}
fn compact_lease(lease_id: &str) -> CompactLease {
CompactLease {
lease_id: Value::String(lease_id.to_string()),
task: Value::String("example/T001".to_string()),
owner: Value::String("worker:agent_123".to_string()),
lease_mode: "single".to_string(),
scope: Value::Array(Vec::new()),
heartbeat_at: "2026-01-01T00:00:00Z".to_string(),
age_seconds: 0,
heartbeat_age_seconds: 0,
stale: false,
}
}
fn report_ready() -> ReportReady {
ReportReady {
lease_id: "l_report".to_string(),
task: "example/T001".to_string(),
report: "reports/example/T001.md".to_string(),
}
}
fn cleanup_candidate() -> CleanupCandidate {
CleanupCandidate {
lease_id: "l_cleanup".to_string(),
task: "example/T001".to_string(),
}
}
fn ready_task() -> ReadyTask {
ReadyTask {
id: "T001".to_string(),
spec: "example".to_string(),
task: "example/T001".to_string(),
path: "specs/example/tasks/T001.md".to_string(),
scope: vec!["src/feature/".to_string()],
verification_mode: "validator".to_string(),
}
}
fn blocked_task() -> BlockedTask {
BlockedTask {
task: "example/T999".to_string(),
reason: "status:blocked".to_string(),
}
}
fn stage_plan(safe_to_stage: bool, pathspecs: Vec<&str>) -> StagePlan {
StagePlan {
lease_id: "l_stage".to_string(),
task: "example/T001".to_string(),
safe_to_stage,
pathspecs: pathspecs.into_iter().map(str::to_string).collect(),
excluded: Map::new(),
}
}
fn done_counts() -> Map<String, Value> {
let mut map = Map::new();
map.insert("done".to_string(), Value::Number(2.into()));
map
}
#[test]
fn next_phase_priority_is_table_driven() {
type Arrange = Box<dyn Fn(&mut NextInput)>;
let cases: Vec<(&str, Phase, Arrange)> = vec![
(
"stale/recover",
Phase::Recover,
Box::new(|input| {
input.stale = vec![compact_lease("l_stale")];
input.reports_ready = vec![report_ready()];
input.active = vec![compact_lease("l_active")];
input.stage = vec![stage_plan(true, vec!["src/planner.rs"])];
input.cleanup = vec![cleanup_candidate()];
input.ready = vec![ready_task()];
input.blocked = vec![blocked_task()];
input.counts = done_counts();
}),
),
(
"reports_ready/validate",
Phase::Validate,
Box::new(|input| {
input.reports_ready = vec![report_ready()];
input.active = vec![compact_lease("l_active")];
input.stage = vec![stage_plan(true, vec!["src/planner.rs"])];
input.cleanup = vec![cleanup_candidate()];
input.ready = vec![ready_task()];
input.blocked = vec![blocked_task()];
input.counts = done_counts();
}),
),
(
"active/wait",
Phase::Wait,
Box::new(|input| {
input.active = vec![compact_lease("l_active")];
input.stage = vec![stage_plan(true, vec!["src/planner.rs"])];
input.cleanup = vec![cleanup_candidate()];
input.ready = vec![ready_task()];
input.blocked = vec![blocked_task()];
input.counts = done_counts();
}),
),
(
"unsafe stage/stage",
Phase::Stage,
Box::new(|input| {
input.stage = vec![stage_plan(false, Vec::new())];
input.cleanup = vec![cleanup_candidate()];
input.ready = vec![ready_task()];
input.blocked = vec![blocked_task()];
input.counts = done_counts();
}),
),
(
"nonempty stage/stage",
Phase::Stage,
Box::new(|input| {
input.stage = vec![stage_plan(true, vec!["src/planner.rs"])];
input.cleanup = vec![cleanup_candidate()];
input.ready = vec![ready_task()];
input.blocked = vec![blocked_task()];
input.counts = done_counts();
}),
),
(
"cleanup/cleanup",
Phase::Cleanup,
Box::new(|input| {
input.cleanup = vec![cleanup_candidate()];
input.ready = vec![ready_task()];
input.blocked = vec![blocked_task()];
input.counts = done_counts();
}),
),
(
"ready/dispatch",
Phase::Dispatch,
Box::new(|input| {
input.ready = vec![ready_task()];
input.blocked = vec![blocked_task()];
input.counts = done_counts();
}),
),
(
"blocked-only/blocked",
Phase::Blocked,
Box::new(|input| {
input.blocked = vec![blocked_task()];
}),
),
(
"all-done/done",
Phase::Done,
Box::new(|input| {
input.counts = done_counts();
}),
),
("empty scope/done", Phase::Done, Box::new(|_input| {})),
];
for (name, expected_phase, arrange) in cases {
let mut input = input();
arrange(&mut input);
let decision = decide_next(input);
assert_eq!(decision.phase, expected_phase, "{name}");
}
}
}