[修正] GATE 0+5: Issue=0 拒否 + ブランチ名バリデーション (#52, #53)

This commit is contained in:
林 駿甫 (Shunsuke Hayashi) 2026-04-10 06:51:39 +09:00
parent 58c8bf71e1
commit a1febb67eb
3 changed files with 560 additions and 141 deletions

View file

@ -189,6 +189,8 @@ enum GateCommand {
#[arg(long, value_enum)]
risk: ImpactRiskArg,
#[arg(long)]
approve: bool,
#[arg(long)]
symbols: usize,
#[arg(long, value_delimiter = ',')]
depth1: Vec<String>,
@ -198,20 +200,11 @@ enum GateCommand {
input_hash: Option<String>,
},
/// Record branch creation
Branch {
task_id: String,
name: String,
},
Branch { task_id: String, name: String },
/// Record PR creation
Pr {
task_id: String,
number: u64,
},
Pr { task_id: String, number: u64 },
/// Record merge verification
Merge {
task_id: String,
sha: String,
},
Merge { task_id: String, sha: String },
/// List active locks
Locks,
/// Show DAG levels
@ -405,7 +398,10 @@ async fn main() -> anyhow::Result<()> {
Ok(Some(rules)) => {
println!("Rules: {} rules loaded", rules.rules.len());
if !rules.agent_preferences.is_empty() {
println!("Agents: {} agent preferences", rules.agent_preferences.len());
println!(
"Agents: {} agent preferences",
rules.agent_preferences.len()
);
}
}
Ok(None) => {
@ -576,14 +572,20 @@ async fn main() -> anyhow::Result<()> {
"warning" => "🟡",
_ => "🔵",
};
println!(" {} {} {} - {}", status, severity, rule.name, rule.suggestion);
println!(
" {} {} {} - {}",
status, severity, rule.name, rule.suggestion
);
if verbose {
if let Some(pattern) = &rule.pattern {
println!(" Pattern: {}", pattern);
}
if !rule.file_extensions.is_empty() {
println!(" Extensions: {}", rule.file_extensions.join(", "));
println!(
" Extensions: {}",
rule.file_extensions.join(", ")
);
}
println!();
}
@ -616,7 +618,10 @@ async fn main() -> anyhow::Result<()> {
}
}
Ok(None) => {
println!("No .miyabirules file found in {} or parent directories.", cwd.display());
println!(
"No .miyabirules file found in {} or parent directories.",
cwd.display()
);
println!();
println!("Create a .miyabirules file to define project-specific rules.");
println!("See: miyabi --help for more information.");
@ -788,33 +793,32 @@ async fn main() -> anyhow::Result<()> {
// Get OpenClaw configuration
let gateway_url = env::var("OPENCLAW_GATEWAY_URL")
.unwrap_or_else(|_| "http://127.0.0.1:18789".to_string());
let token = env::var("OPENCLAW_TOKEN")
.unwrap_or_else(|_| {
// Try to read from openclaw.json
#[allow(unused_imports)]
use std::fs;
#[allow(unused_imports)]
use std::path::PathBuf;
let token = env::var("OPENCLAW_TOKEN").unwrap_or_else(|_| {
// Try to read from openclaw.json
#[allow(unused_imports)]
use std::fs;
#[allow(unused_imports)]
use std::path::PathBuf;
let config_path = PathBuf::from(env::var("HOME").unwrap_or_default())
.join(".openclaw")
.join("openclaw.json");
let config_path = PathBuf::from(env::var("HOME").unwrap_or_default())
.join(".openclaw")
.join("openclaw.json");
// Fallback: try to read token from config
if let Ok(content) = fs::read_to_string(&config_path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(gateway) = json.get("gateway") {
if let Some(auth) = gateway.get("auth") {
if let Some(t) = auth.get("token") {
return t.as_str().unwrap_or("").to_string();
}
// Fallback: try to read token from config
if let Ok(content) = fs::read_to_string(&config_path) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(gateway) = json.get("gateway") {
if let Some(auth) = gateway.get("auth") {
if let Some(t) = auth.get("token") {
return t.as_str().unwrap_or("").to_string();
}
}
}
}
}
String::new()
});
String::new()
});
if token.is_empty() {
eprintln!("❌ Error: OPENCLAW_TOKEN not set");
@ -899,8 +903,25 @@ async fn main() -> anyhow::Result<()> {
// Broadcast to specific society
let society_agents = match society.to_lowercase().as_str() {
"core" => vec!["maestro", "kade", "sakura", "tsubaki", "botan", "nagare"],
"investment" => vec!["scout", "crystal", "dealer", "sentinel", "architect", "watchman", "chart", "fundy", "scribe"],
"content" => vec!["tweeter", "pen", "vidpro", "artist", "optimizer", "scheduler"],
"investment" => vec![
"scout",
"crystal",
"dealer",
"sentinel",
"architect",
"watchman",
"chart",
"fundy",
"scribe",
],
"content" => vec![
"tweeter",
"pen",
"vidpro",
"artist",
"optimizer",
"scheduler",
],
"marketing" => vec!["hiro", "kazoeru", "funnel", "adops"],
_ => {
eprintln!("❌ 不明なSociety: {}", society);
@ -989,15 +1010,19 @@ async fn main() -> anyhow::Result<()> {
println!();
println!("【環境変数】");
println!();
println!(" OPENCLAW_GATEWAY_URL - Gateway URL (default: http://127.0.0.1:18789)");
println!(
" OPENCLAW_GATEWAY_URL - Gateway URL (default: http://127.0.0.1:18789)"
);
println!(" OPENCLAW_TOKEN - Gateway認証トークン");
println!();
println!("【設定ファイル】");
println!();
println!(" ~/.openclaw/openclaw.json - 設定ファイルから自動読み込み");
println!();
println!("---
🌸 Miyabi Framework - OpenClaw Integration");
println!(
"---
🌸 Miyabi Framework - OpenClaw Integration"
);
}
OpenclawCommand::Status => {
// Already handled above
@ -1007,8 +1032,8 @@ async fn main() -> anyhow::Result<()> {
}
Some(Commands::Collab { command }) => {
use std::process::Command;
use std::env;
use std::process::Command;
let collab_bin = {
let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
@ -1018,17 +1043,31 @@ async fn main() -> anyhow::Result<()> {
let mut args: Vec<String> = Vec::new();
match command {
CollabCommand::List { json, r#type, count } => {
CollabCommand::List {
json,
r#type,
count,
} => {
args.push("tile".to_string());
args.push("list".to_string());
if json { args.push("--json".to_string()); }
if count { args.push("--count".to_string()); }
if json {
args.push("--json".to_string());
}
if count {
args.push("--count".to_string());
}
if let Some(t) = r#type {
args.push("--type".to_string());
args.push(t);
}
}
CollabCommand::Add { tile_type, file, pos, size, idempotent } => {
CollabCommand::Add {
tile_type,
file,
pos,
size,
idempotent,
} => {
args.push("tile".to_string());
args.push("add".to_string());
args.push(tile_type);
@ -1044,7 +1083,9 @@ async fn main() -> anyhow::Result<()> {
args.push("--size".to_string());
args.push(s);
}
if idempotent { args.push("--idempotent".to_string()); }
if idempotent {
args.push("--idempotent".to_string());
}
}
CollabCommand::Rm { tile_id } => {
args.push("tile".to_string());
@ -1086,9 +1127,7 @@ async fn main() -> anyhow::Result<()> {
}
}
let status = Command::new(&collab_bin)
.args(&args)
.status();
let status = Command::new(&collab_bin).args(&args).status();
match status {
Ok(s) => {
@ -1098,7 +1137,9 @@ async fn main() -> anyhow::Result<()> {
}
Err(e) => {
eprintln!("error: failed to run collab CLI ({}): {}", collab_bin, e);
eprintln!(" → Install collab CLI: https://github.com/ShunsukeHayashi/collab-cli");
eprintln!(
" → Install collab CLI: https://github.com/ShunsukeHayashi/collab-cli"
);
std::process::exit(1);
}
}
@ -1140,6 +1181,7 @@ fn handle_gate_command(
protocol
.register(
RegisterTaskRequest {
issue,
task_id,
title,
dependencies,
@ -1162,27 +1204,29 @@ fn handle_gate_command(
}
})
}
GateCommand::Status { task_id } => protocol.status(task_id.as_deref()).map(|status| {
match status {
StatusReport::Task(task) => {
if matches!(format, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&task).unwrap());
} else {
println!("{}: {:?} - {}", task.id, task.current_state, task.title);
}
}
StatusReport::Snapshot(snapshot) => {
if matches!(format, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&snapshot).unwrap());
} else {
println!("tasks: {}", snapshot.tasks.len());
for task in snapshot.tasks {
println!(" {} [{:?}] {}", task.id, task.current_state, task.title);
GateCommand::Status { task_id } => {
protocol
.status(task_id.as_deref())
.map(|status| match status {
StatusReport::Task(task) => {
if matches!(format, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&task).unwrap());
} else {
println!("{}: {:?} - {}", task.id, task.current_state, task.title);
}
}
}
}
}),
StatusReport::Snapshot(snapshot) => {
if matches!(format, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&snapshot).unwrap());
} else {
println!("tasks: {}", snapshot.tasks.len());
for task in snapshot.tasks {
println!(" {} [{:?}] {}", task.id, task.current_state, task.title);
}
}
}
})
}
GateCommand::Assign {
task_id,
agent,
@ -1194,15 +1238,13 @@ fn handle_gate_command(
if matches!(format, OutputFormat::Json) {
println!("{}", serde_json::to_string_pretty(&result).unwrap());
} else {
println!(
"assigned: {} -> {}@{}",
result.task.id, agent, agent_node
);
println!("assigned: {} -> {}@{}", result.task.id, agent, agent_node);
}
}),
GateCommand::Impact {
task_id,
risk,
approve,
symbols,
depth1,
analyzed_commit,
@ -1221,6 +1263,7 @@ fn handle_gate_command(
depth1,
analyzed_commit,
input_hash,
approve,
},
actor,
&node,

View file

@ -5,7 +5,9 @@ use crate::store::{
CompletionMode, ExecutionTask, GitHubIssueState, GitHubPrState, ReviewDecision, TaskState,
TasksSnapshot,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -51,6 +53,16 @@ pub struct GateReport {
pub duration: Duration,
}
pub fn validate_branch_name(name: &str) -> bool {
static BRANCH_NAME_REGEX: OnceLock<Regex> = OnceLock::new();
BRANCH_NAME_REGEX
.get_or_init(|| {
Regex::new(r"^(feature|fix|hotfix)/issue-[1-9][0-9]*-[A-Za-z0-9][A-Za-z0-9._-]*$")
.expect("branch name regex must compile")
})
.is_match(name)
}
pub fn evaluate_gate(
gate: Gate,
task: &ExecutionTask,
@ -119,12 +131,13 @@ pub fn evaluate_gate(
.unwrap_or_else(|| "lock window available".to_string()),
)
}
Gate::Gate5 => (
task.branch_name.is_some(),
task.branch_name
.clone()
.unwrap_or_else(|| "missing branch_name".to_string()),
),
Gate::Gate5 => match task.branch_name.as_deref() {
Some(branch_name) if validate_branch_name(branch_name) => {
(true, format!("valid branch_name: {branch_name}"))
}
Some(branch_name) => (false, format!("invalid branch_name: {branch_name}")),
None => (false, "missing branch_name".to_string()),
},
Gate::Gate6 => {
let ok = task.github_evidence.as_ref().is_some_and(|evidence| {
evidence.pr_number > 0
@ -158,11 +171,10 @@ pub fn evaluate_gate(
}
Gate::Gate8 => {
let ok = match task.completion_mode {
CompletionMode::GithubPr => {
task.github_evidence.as_ref().is_some_and(|evidence| {
evidence.issue_state == GitHubIssueState::Closed
})
}
CompletionMode::GithubPr => task
.github_evidence
.as_ref()
.is_some_and(|evidence| evidence.issue_state == GitHubIssueState::Closed),
CompletionMode::Manual | CompletionMode::ExternalOp => true,
};
(
@ -263,4 +275,18 @@ mod tests {
assert!(evaluate_gate(Gate::Gate7, &task, &snapshot, &GateContext::default()).success);
assert!(evaluate_gate(Gate::Gate8, &task, &snapshot, &GateContext::default()).success);
}
#[test]
fn validate_branch_name_accepts_supported_prefixes() {
assert!(validate_branch_name("feature/issue-1-test"));
assert!(validate_branch_name("fix/issue-12-bugfix"));
assert!(validate_branch_name("hotfix/issue-999-hot-patch"));
}
#[test]
fn validate_branch_name_rejects_invalid_patterns() {
assert!(!validate_branch_name("feature/phase-a"));
assert!(!validate_branch_name("feature/issue-0-test"));
assert!(!validate_branch_name("main"));
}
}

View file

@ -1,12 +1,12 @@
//! Deterministic execution protocol entry point.
use crate::error::Error;
use crate::gate::{evaluate_gate, Gate, GateContext, GateReport};
use crate::gate::{evaluate_gate, validate_branch_name, Gate, GateContext, GateReport};
use crate::lock::{FileLockManager, LeaseConfig, LockConflict};
use crate::store::{
CompletionMode, EventStore, ExecutionTask, GitHubEvidence, GitHubIssueState, GitHubPrState,
ImpactRiskLevel, ReviewDecision, SnapshotStore, TaskEvent, TaskEventType, TaskImpact,
TaskState, TasksSnapshot,
HumanApproval, ImpactRiskLevel, ReviewDecision, SnapshotStore, TaskEvent, TaskEventType,
TaskImpact, TaskState, TasksSnapshot,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
@ -63,9 +63,9 @@ impl DeterministicExecutionProtocol {
for gate in gates {
let snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?;
let task = snapshot.get_task(task_id).ok_or_else(|| {
ProtocolError::input(format!("unknown task: {task_id}"))
})?;
let task = snapshot
.get_task(task_id)
.ok_or_else(|| ProtocolError::input(format!("unknown task: {task_id}")))?;
let context = if matches!(gate, Gate::Gate4) {
let files = task
@ -120,6 +120,9 @@ impl DeterministicExecutionProtocol {
actor: &str,
node: &str,
) -> ProtocolResult<ExecutionTask> {
if request.issue == 0 {
return Err(ProtocolError::input("issue number must be greater than 0"));
}
let mut snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?;
if snapshot.get_task(&request.task_id).is_some() {
return Err(ProtocolError::input(format!(
@ -179,17 +182,30 @@ impl DeterministicExecutionProtocol {
files: &[String],
) -> ProtocolResult<AssignmentResult> {
let snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?;
let blocked_by = snapshot
let task = snapshot
.get_task(task_id)
.ok_or_else(|| ProtocolError::input(format!("unknown task: {task_id}")))?
.ok_or_else(|| ProtocolError::input(format!("unknown task: {task_id}")))?;
let approval_granted = task
.human_approval
.as_ref()
.is_some_and(|approval| !approval.required || approval.approved_by.is_some());
if matches!(
task.impact.as_ref().map(|impact| impact.risk_level),
Some(ImpactRiskLevel::High | ImpactRiskLevel::Critical)
) && !approval_granted
{
return Err(ProtocolError::gate_rejected(
"HIGH/CRITICAL risk requires --approve",
));
}
let blocked_by = task
.dependencies
.iter()
.filter_map(|dependency| {
snapshot
.get_task(dependency)
.filter(|dep| {
!matches!(dep.current_state, TaskState::Done | TaskState::Merged)
})
.filter(|dep| !matches!(dep.current_state, TaskState::Done | TaskState::Merged))
.map(|_| dependency.clone())
})
.collect::<Vec<_>>();
@ -234,25 +250,46 @@ impl DeterministicExecutionProtocol {
actor: &str,
node: &str,
) -> ProtocolResult<ExecutionTask> {
let payload_risk = impact.risk_level;
let payload_symbols = impact.affected_symbols;
let depth1 = impact.depth1.clone();
let analyzed_commit = impact.analyzed_commit.clone();
let input_hash = impact.input_hash.clone();
self.update_task(task_id, actor, node, TaskEventType::ImpactRecorded, |task| {
task.impact = Some(TaskImpact {
risk_level: impact.risk_level,
affected_symbols: impact.affected_symbols,
depth1: depth1.clone(),
analyzed_at: Utc::now(),
analyzed_commit: analyzed_commit.clone(),
input_hash: input_hash.clone(),
});
Ok(serde_json::json!({
"risk_level": payload_risk,
"affected_symbols": payload_symbols
}))
})
let ImpactInput {
risk_level,
affected_symbols,
depth1,
analyzed_commit,
input_hash,
approve,
} = impact;
let approval = approve.then(|| HumanApproval {
required: matches!(
risk_level,
ImpactRiskLevel::High | ImpactRiskLevel::Critical
),
approved_by: Some(actor.to_string()),
approved_at: Some(Utc::now()),
reason: Some("approved via impact --approve".to_string()),
});
self.update_task(
task_id,
actor,
node,
TaskEventType::ImpactRecorded,
|task| {
task.impact = Some(TaskImpact {
risk_level,
affected_symbols,
depth1: depth1.clone(),
analyzed_at: Utc::now(),
analyzed_commit: analyzed_commit.clone(),
input_hash: input_hash.clone(),
});
task.human_approval = approval.clone();
Ok(serde_json::json!({
"risk_level": risk_level,
"affected_symbols": affected_symbols,
"approve": approve,
"human_approval": approval.clone()
}))
},
)
}
pub fn record_branch(
@ -265,6 +302,11 @@ impl DeterministicExecutionProtocol {
if branch_name.trim().is_empty() {
return Err(ProtocolError::input("branch name must not be empty"));
}
if !validate_branch_name(branch_name) {
return Err(ProtocolError::gate_rejected(format!(
"invalid branch name: {branch_name}"
)));
}
self.update_task(task_id, actor, node, TaskEventType::BranchCreated, |task| {
task.branch_name = Some(branch_name.to_string());
Ok(serde_json::json!({ "branch_name": branch_name }))
@ -305,26 +347,36 @@ impl DeterministicExecutionProtocol {
actor: &str,
node: &str,
) -> ProtocolResult<ExecutionTask> {
if merge_commit_sha.len() != 40 || !merge_commit_sha.chars().all(|c| c.is_ascii_hexdigit()) {
if merge_commit_sha.len() != 40 || !merge_commit_sha.chars().all(|c| c.is_ascii_hexdigit())
{
return Err(ProtocolError::gate_rejected(
"merge sha must be a 40-char hex string",
));
}
self.update_task(task_id, actor, node, TaskEventType::MergeVerified, |task| {
let mut evidence = task.github_evidence.clone().ok_or_else(|| {
ProtocolError::gate_rejected("pull request must be recorded before merge")
let merged =
self.update_task(task_id, actor, node, TaskEventType::MergeVerified, |task| {
let mut evidence = task.github_evidence.clone().ok_or_else(|| {
ProtocolError::gate_rejected("pull request must be recorded before merge")
})?;
evidence.pr_state = GitHubPrState::Merged;
evidence.merge_commit_sha = Some(merge_commit_sha.to_string());
evidence.merged_at = Some(Utc::now());
evidence.review_decision = Some(ReviewDecision::Approved);
evidence.issue_state = GitHubIssueState::Closed;
evidence.issue_closed_by_pr = true;
task.github_evidence = Some(evidence);
task.current_state = TaskState::Merged;
Ok(serde_json::json!({ "merge_commit_sha": merge_commit_sha }))
})?;
evidence.pr_state = GitHubPrState::Merged;
evidence.merge_commit_sha = Some(merge_commit_sha.to_string());
evidence.merged_at = Some(Utc::now());
evidence.review_decision = Some(ReviewDecision::Approved);
evidence.issue_state = GitHubIssueState::Closed;
evidence.issue_closed_by_pr = true;
task.github_evidence = Some(evidence);
task.current_state = TaskState::Merged;
Ok(serde_json::json!({ "merge_commit_sha": merge_commit_sha }))
})
self.lock_manager
.release_lock(task_id)
.map_err(ProtocolError::from)?;
self.unblock_dependents_after_merge(task_id, actor, node)?;
let snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?;
Ok(snapshot.get_task(task_id).cloned().unwrap_or(merged))
}
pub fn locks(&self) -> ProtocolResult<HashMap<String, crate::store::FileLockEntry>> {
@ -368,10 +420,16 @@ impl DeterministicExecutionProtocol {
actor: &str,
node: &str,
) -> ProtocolResult<ExecutionTask> {
self.update_task(task_id, actor, node, TaskEventType::StateTransition, |task| {
task.current_state = to;
Ok(serde_json::json!({ "to": to }))
})
self.update_task(
task_id,
actor,
node,
TaskEventType::StateTransition,
|task| {
task.current_state = to;
Ok(serde_json::json!({ "to": to }))
},
)
}
fn update_task<F>(
@ -455,6 +513,63 @@ impl DeterministicExecutionProtocol {
}),
)
}
fn unblock_dependents_after_merge(
&self,
task_id: &str,
actor: &str,
node: &str,
) -> ProtocolResult<()> {
let mut snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?;
let dependent_ids = snapshot
.get_task(task_id)
.ok_or_else(|| ProtocolError::input(format!("unknown task: {task_id}")))?
.dependents
.clone();
let pending_dependents: Vec<String> = dependent_ids
.into_iter()
.filter(|dependent_id| {
snapshot.get_task(dependent_id).is_some_and(|task| {
task.current_state == TaskState::Blocked
&& dependencies_satisfied(task, &snapshot)
})
})
.collect();
if pending_dependents.is_empty() {
return Ok(());
}
let now = Utc::now();
for dependent_id in &pending_dependents {
if let Some(task) = snapshot.get_task_mut(dependent_id) {
task.current_state = TaskState::Pending;
task.updated_at = now;
}
}
let version = snapshot.version;
self.snapshot_store
.save(&snapshot, version)
.map_err(ProtocolError::from)?;
for dependent_id in pending_dependents {
self.append_event(
&dependent_id,
TaskEventType::StateTransition,
actor,
node,
version + 1,
serde_json::json!({
"to": TaskState::Pending,
"reason": "dependencies_resolved_after_merge",
"unblocked_by": task_id,
}),
)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -467,6 +582,7 @@ pub struct ProtocolReport {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegisterTaskRequest {
pub issue: u64,
pub task_id: String,
pub title: String,
pub dependencies: Vec<String>,
@ -488,6 +604,7 @@ pub struct ImpactInput {
pub depth1: Vec<String>,
pub analyzed_commit: Option<String>,
pub input_hash: Option<String>,
pub approve: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -537,9 +654,9 @@ pub type ProtocolResult<T> = std::result::Result<T, ProtocolError>;
fn dependencies_satisfied(task: &ExecutionTask, snapshot: &TasksSnapshot) -> bool {
task.dependencies.iter().all(|dep_id| {
snapshot.get_task(dep_id).is_some_and(|dep| {
matches!(dep.current_state, TaskState::Done | TaskState::Merged)
})
snapshot
.get_task(dep_id)
.is_some_and(|dep| matches!(dep.current_state, TaskState::Done | TaskState::Merged))
})
}
@ -572,8 +689,11 @@ fn compute_dag(snapshot: &TasksSnapshot) -> DagReport {
.map(|task| task.id.clone())
.collect();
let mut levels = Vec::new();
let task_map: HashMap<&str, &ExecutionTask> =
snapshot.tasks.iter().map(|task| (task.id.as_str(), task)).collect();
let task_map: HashMap<&str, &ExecutionTask> = snapshot
.tasks
.iter()
.map(|task| (task.id.as_str(), task))
.collect();
while !queue.is_empty() {
let mut next = VecDeque::new();
@ -628,6 +748,7 @@ mod tests {
protocol
.register(
RegisterTaskRequest {
issue: 1,
task_id: "phase-0".into(),
title: "Phase 0".into(),
dependencies: vec![],
@ -642,6 +763,7 @@ mod tests {
protocol
.register(
RegisterTaskRequest {
issue: 2,
task_id: "phase-a".into(),
title: "Phase A".into(),
dependencies: vec!["phase-0".into()],
@ -676,6 +798,7 @@ mod tests {
protocol
.register(
RegisterTaskRequest {
issue: 1,
task_id: "phase-a".into(),
title: "Phase A".into(),
dependencies: vec![],
@ -699,12 +822,38 @@ mod tests {
assert_eq!(dispatchable.tasks[0].id, "phase-a");
}
#[test]
fn register_rejects_issue_zero() {
let (_tmp, protocol) = fixture();
let err = protocol
.register(
RegisterTaskRequest {
issue: 0,
task_id: "test-task".into(),
title: "Test Task".into(),
dependencies: vec![],
soft_dependencies: vec![],
priority: 0,
completion_mode: CompletionMode::GithubPr,
},
"codex",
"macbook",
)
.unwrap_err();
assert!(matches!(err, ProtocolError::Input(_)));
assert!(err
.to_string()
.contains("issue number must be greater than 0"));
}
#[test]
fn assign_branch_pr_merge_updates_task() {
let (_tmp, protocol) = fixture();
protocol
.register(
RegisterTaskRequest {
issue: 1,
task_id: "phase-a".into(),
title: "Phase A".into(),
dependencies: vec![],
@ -725,6 +874,7 @@ mod tests {
depth1: vec!["main".into()],
analyzed_commit: None,
input_hash: None,
approve: false,
},
"codex",
"macbook",
@ -742,9 +892,11 @@ mod tests {
assert_eq!(assigned.task.current_state, TaskState::Implementing);
protocol
.record_branch("phase-a", "feature/phase-a", "codex", "macbook")
.record_branch("phase-a", "feature/issue-1-phase-a", "codex", "macbook")
.unwrap();
protocol
.record_pr("phase-a", 12, "codex", "macbook")
.unwrap();
protocol.record_pr("phase-a", 12, "codex", "macbook").unwrap();
let merged = protocol
.record_merge(
"phase-a",
@ -764,14 +916,102 @@ mod tests {
.as_deref(),
Some("0123456789abcdef0123456789abcdef01234567")
);
assert!(protocol.locks().unwrap().is_empty());
}
#[test]
fn assign_returns_dependency_blocked_when_hard_dependency_is_unresolved() {
fn assign_rejects_high_risk_without_human_approval() {
let (_tmp, protocol) = fixture();
protocol
.register(
RegisterTaskRequest {
issue: 1,
task_id: "phase-a".into(),
title: "Phase A".into(),
dependencies: vec![],
soft_dependencies: vec![],
priority: 0,
completion_mode: CompletionMode::GithubPr,
},
"codex",
"macbook",
)
.unwrap();
protocol
.record_impact(
"phase-a",
ImpactInput {
risk_level: ImpactRiskLevel::High,
affected_symbols: 2,
depth1: vec!["assign".into()],
analyzed_commit: None,
input_hash: None,
approve: false,
},
"codex",
"macbook",
)
.unwrap();
let err = protocol
.assign("phase-a", "codex", "macbook", &[String::from("src/lib.rs")])
.unwrap_err();
assert!(matches!(err, ProtocolError::GateRejected(_)));
assert_eq!(
err.to_string(),
"gate rejected: HIGH/CRITICAL risk requires --approve"
);
}
#[test]
fn impact_approve_records_human_approval() {
let (_tmp, protocol) = fixture();
protocol
.register(
RegisterTaskRequest {
issue: 1,
task_id: "phase-a".into(),
title: "Phase A".into(),
dependencies: vec![],
soft_dependencies: vec![],
priority: 0,
completion_mode: CompletionMode::GithubPr,
},
"codex",
"macbook",
)
.unwrap();
let task = protocol
.record_impact(
"phase-a",
ImpactInput {
risk_level: ImpactRiskLevel::High,
affected_symbols: 2,
depth1: vec!["assign".into()],
analyzed_commit: None,
input_hash: None,
approve: true,
},
"codex",
"macbook",
)
.unwrap();
let approval = task.human_approval.as_ref().unwrap();
assert!(approval.required);
assert_eq!(approval.approved_by.as_deref(), Some("codex"));
assert!(approval.approved_at.is_some());
}
#[test]
fn merge_releases_locks_and_unblocks_dependents() {
let (_tmp, protocol) = fixture();
protocol
.register(
RegisterTaskRequest {
issue: 1,
task_id: "phase-a".into(),
title: "Phase A".into(),
dependencies: vec![],
@ -786,6 +1026,115 @@ mod tests {
protocol
.register(
RegisterTaskRequest {
issue: 2,
task_id: "phase-b".into(),
title: "Phase B".into(),
dependencies: vec!["phase-a".into()],
soft_dependencies: vec![],
priority: 0,
completion_mode: CompletionMode::GithubPr,
},
"codex",
"macbook",
)
.unwrap();
protocol
.record_impact(
"phase-a",
ImpactInput {
risk_level: ImpactRiskLevel::Low,
affected_symbols: 1,
depth1: vec!["main".into()],
analyzed_commit: None,
input_hash: None,
approve: false,
},
"codex",
"macbook",
)
.unwrap();
protocol
.assign("phase-a", "codex", "macbook", &[String::from("src/a.rs")])
.unwrap();
protocol
.record_branch("phase-a", "feature/issue-1-phase-a", "codex", "macbook")
.unwrap();
protocol
.record_pr("phase-a", 12, "codex", "macbook")
.unwrap();
let mut snapshot = protocol.snapshot_store.load().unwrap();
let version = snapshot.version;
let dependent = snapshot.get_task_mut("phase-b").unwrap();
dependent.current_state = TaskState::Blocked;
protocol.snapshot_store.save(&snapshot, version).unwrap();
protocol
.record_merge(
"phase-a",
"0123456789abcdef0123456789abcdef01234567",
"codex",
"macbook",
)
.unwrap();
assert!(protocol.locks().unwrap().is_empty());
let status = protocol.status(Some("phase-b")).unwrap();
match status {
StatusReport::Task(task) => assert_eq!(task.current_state, TaskState::Pending),
StatusReport::Snapshot(_) => panic!("expected task status"),
}
}
#[test]
fn record_branch_rejects_invalid_branch_names() {
let (_tmp, protocol) = fixture();
protocol
.register(
RegisterTaskRequest {
issue: 1,
task_id: "phase-a".into(),
title: "Phase A".into(),
dependencies: vec![],
soft_dependencies: vec![],
priority: 0,
completion_mode: CompletionMode::GithubPr,
},
"codex",
"macbook",
)
.unwrap();
let err = protocol
.record_branch("phase-a", "bad-name", "codex", "macbook")
.unwrap_err();
assert!(matches!(err, ProtocolError::GateRejected(_)));
assert!(err.to_string().contains("invalid branch name"));
}
#[test]
fn assign_returns_dependency_blocked_when_hard_dependency_is_unresolved() {
let (_tmp, protocol) = fixture();
protocol
.register(
RegisterTaskRequest {
issue: 1,
task_id: "phase-a".into(),
title: "Phase A".into(),
dependencies: vec![],
soft_dependencies: vec![],
priority: 0,
completion_mode: CompletionMode::GithubPr,
},
"codex",
"macbook",
)
.unwrap();
protocol
.register(
RegisterTaskRequest {
issue: 2,
task_id: "phase-b".into(),
title: "Phase B".into(),
dependencies: vec!["phase-a".into()],
@ -816,6 +1165,7 @@ mod tests {
protocol
.register(
RegisterTaskRequest {
issue: 1,
task_id: "phase-a".into(),
title: "Phase A".into(),
dependencies: vec![],