diff --git a/.yoi/workflow/ticket-orchestrator-routing.md b/.yoi/workflow/ticket-orchestrator-routing.md index f9d6c781..d823ecf2 100644 --- a/.yoi/workflow/ticket-orchestrator-routing.md +++ b/.yoi/workflow/ticket-orchestrator-routing.md @@ -66,6 +66,8 @@ Orchestrator は以下を行う。 - `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。 - `TicketStatus`: pending/open などの状態整理が明示的に許可された場合だけ使う。 - `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。 +- `TicketOrchestrationPlanQuery`: 対象 Ticket や関連 Ticket の ordering / blocker / conflict / waiting-capacity / accepted-plan 記録を読む。queued acceptance 前に必ず確認する。 +- `TicketOrchestrationPlanRecord`: Orchestrator が routing 中に project-relevant な ordering / dependency / conflict / capacity/waiting / accepted-plan decision を残す。これは queue reorder、自動起動、workflow_state 変更ではない。 - `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。 - `TicketDoctor`: routing 前後の整合性確認。 @@ -73,9 +75,10 @@ Orchestrator は以下を行う。 ## Queued acceptance contract -`workflow_state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket と workspace state を読んで、次のどちらかを行う。 +`workflow_state = queued` は、Ticket が routing 対象として人間により Orchestrator へ渡された状態である。Orchestrator は queued notification を受けたら、Ticket、workspace state、対象 Ticket の `TicketOrchestrationPlanQuery` 記録を読んで、次のどちらかを行う。 - unblocked と判断する場合: `queued -> inprogress` を記録してから worktree 作成、implementation/review Pod spawn、その他の implementation side effect に進む。 + - `before` / `after` / `blocked_by` / `blocks` / `conflicts_with` / `do_not_parallelize` / waiting-capacity 記録がある場合、それを acceptance 判断の入力にする。記録は自動 scheduler ではないため、実際に進めるかどうかは Orchestrator が読んだうえで明示的に決める。 - concrete missing decision / information がある場合: `TicketWorkflowState` で `queued -> planning` を記録し、reason/body に不足項目を残す。既存の claimed live/restorable Intake/Planning Pod があり、既存通知経路が使える場合は同じ理由を通知する。 - external action 待ちなど planning では解決しない blocker の場合: concise な理由を Ticket thread に記録し、queued のまま待つか、既存の Ticket status/state mechanism で明示的に defer/block する。 @@ -160,6 +163,7 @@ Action: Action: - `IntentPacket` を作る。 +- project-relevant な ordering / blocker / conflict / capacity decision や accepted work plan がある場合は、`TicketOrchestrationPlanRecord` に bounded typed record として残す。local session/socket/raw model output は入れない。 - `TicketComment` に routing decision と IntentPacket を記録する。 - 許可があれば `multi-agent-workflow` に接続し、worktree + coder/reviewer sibling loop に進む。 diff --git a/crates/pod/src/feature/builtin/ticket.rs b/crates/pod/src/feature/builtin/ticket.rs index 4163b38d..b0686f76 100644 --- a/crates/pod/src/feature/builtin/ticket.rs +++ b/crates/pod/src/feature/builtin/ticket.rs @@ -196,6 +196,12 @@ fn tool_description(name: &str) -> &'static str { } "TicketStatus" => "Move a Ticket between open and pending; use TicketClose for closed.", "TicketClose" => "Close a Ticket with a resolution through the typed local Ticket backend.", + "TicketOrchestrationPlanRecord" => { + "Append a durable typed Ticket orchestration plan record without changing workflow_state or starting work." + } + "TicketOrchestrationPlanQuery" => { + "Query durable Ticket orchestration plan records by Ticket and/or relation kind." + } "TicketDoctor" => "Run typed local Ticket backend consistency checks.", _ => "Typed Ticket backend tool.", } @@ -411,7 +417,7 @@ profile = "inherit" assert_eq!(report.reports[0].diagnostics.len(), 1); let message = &report.reports[0].diagnostics[0].message; assert!(message.contains("Ticket tools not registered")); - assert!(message.contains("unknown Ticket role `operator`")); + assert!(message.contains("unsupported Ticket role `operator`")); } #[test] diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 298fb854..ad20cb02 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf}; use chrono::Utc; use fs4::fs_std::FileExt; +use serde::{Deserialize, Serialize}; use serde_yaml::{Mapping as YamlMapping, Value as YamlValue}; use thiserror::Error; @@ -37,6 +38,9 @@ const REQUIRED_FIELDS: [&str; 10] = [ ]; const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024; const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024; +const ORCHESTRATION_PLAN_ARTIFACT: &str = "orchestration-plan.jsonl"; +const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024; +const MAX_ORCHESTRATION_PLAN_FIELD_BYTES: usize = 1024; const DEFAULT_TICKET_BODY: &str = "## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n"; const JAPANESE_TICKET_BODY: &str = @@ -550,6 +554,104 @@ pub struct TicketRef { pub status: TicketStatus, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OrchestrationPlanKind { + Before, + After, + BlockedBy, + Blocks, + ConflictsWith, + DoNotParallelize, + WaitingCapacityNote, + AcceptedPlan, +} + +impl OrchestrationPlanKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Before => "before", + Self::After => "after", + Self::BlockedBy => "blocked_by", + Self::Blocks => "blocks", + Self::ConflictsWith => "conflicts_with", + Self::DoNotParallelize => "do_not_parallelize", + Self::WaitingCapacityNote => "waiting_capacity_note", + Self::AcceptedPlan => "accepted_plan", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "before" => Some(Self::Before), + "after" => Some(Self::After), + "blocked_by" => Some(Self::BlockedBy), + "blocks" => Some(Self::Blocks), + "conflicts_with" => Some(Self::ConflictsWith), + "do_not_parallelize" => Some(Self::DoNotParallelize), + "waiting_capacity_note" => Some(Self::WaitingCapacityNote), + "accepted_plan" => Some(Self::AcceptedPlan), + _ => None, + } + } + + fn requires_related_ticket(self) -> bool { + matches!( + self, + Self::Before + | Self::After + | Self::BlockedBy + | Self::Blocks + | Self::ConflictsWith + | Self::DoNotParallelize + ) + } +} + +impl fmt::Display for OrchestrationPlanKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AcceptedOrchestrationPlan { + pub summary: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worktree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role_plan: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewOrchestrationPlanRecord { + pub kind: OrchestrationPlanKind, + pub related_ticket: Option, + pub note: Option, + pub accepted_plan: Option, + pub author: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OrchestrationPlanRecord { + pub id: String, + pub ticket_id: String, + pub ticket_slug: String, + pub kind: OrchestrationPlanKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub related_ticket: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accepted_plan: Option, + pub author: String, + pub at: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketMeta { pub id: String, @@ -700,6 +802,16 @@ pub trait TicketBackend { fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>; fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>; fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; + fn add_orchestration_plan_record( + &self, + id: TicketIdOrSlug, + record: NewOrchestrationPlanRecord, + ) -> Result; + fn query_orchestration_plan_records( + &self, + ticket: Option, + kind: Option, + ) -> Result>; fn doctor(&self) -> Result; } @@ -1021,6 +1133,20 @@ impl LocalTicketBackend { })?; atomic_write(item, updated.as_bytes()) } + + fn orchestration_plan_path(&self, dir: &Path) -> PathBuf { + dir.join("artifacts").join(ORCHESTRATION_PLAN_ARTIFACT) + } + + fn read_orchestration_plan_records_for_dir( + &self, + dir: &Path, + ) -> Result> { + let item = dir.join("item.md"); + let meta = ticket_meta(read_item_file(&item)?.frontmatter); + let path = self.orchestration_plan_path(dir); + read_orchestration_plan_artifact(&path, Some(&meta)) + } } impl TicketBackend for LocalTicketBackend { @@ -1446,6 +1572,90 @@ impl TicketBackend for LocalTicketBackend { ) } + fn add_orchestration_plan_record( + &self, + id: TicketIdOrSlug, + record: NewOrchestrationPlanRecord, + ) -> Result { + validate_new_orchestration_plan_record(&record)?; + let _lock = self.acquire_lock()?; + self.ensure_backend_dirs()?; + let dir = self.find_ticket_dir(&id)?; + let item = dir.join("item.md"); + let meta = ticket_meta(read_item_file(&item)?.frontmatter); + let artifacts = dir.join("artifacts"); + fs::create_dir_all(&artifacts).map_err(|e| io_err(&artifacts, e))?; + let path = self.orchestration_plan_path(&dir); + ensure_child_of(&artifacts, &path)?; + let line_count = if path.exists() { + fs::read_to_string(&path) + .map_err(|e| io_err(&path, e))? + .lines() + .filter(|line| !line.trim().is_empty()) + .count() + } else { + 0 + }; + let at = now_utc(); + let output = OrchestrationPlanRecord { + id: format!("orch-plan-{}-{}", compact_now_utc(), line_count + 1), + ticket_id: meta.id.clone(), + ticket_slug: meta.slug.clone(), + kind: record.kind, + related_ticket: record.related_ticket.map(trim_owned), + note: record.note.map(trim_owned), + accepted_plan: record.accepted_plan.map(trim_accepted_orchestration_plan), + author: record.author.map(trim_owned).unwrap_or_else(default_author), + at: at.clone(), + }; + validate_orchestration_plan_record(&output, Some(&meta))?; + let serialized = serde_json::to_string(&output).map_err(|e| { + TicketError::Conflict(format!( + "failed to serialize orchestration plan record: {e}" + )) + })?; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|e| io_err(&path, e))?; + writeln!(file, "{serialized}").map_err(|e| io_err(&path, e))?; + file.sync_all().map_err(|e| io_err(&path, e))?; + self.set_frontmatter_fields(&item, &[("updated_at", &at)])?; + Ok(output) + } + + fn query_orchestration_plan_records( + &self, + ticket: Option, + kind: Option, + ) -> Result> { + let mut records = Vec::new(); + if let Some(ticket) = ticket { + let dir = self.find_ticket_dir(&ticket)?; + records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); + } else { + for status in STATUSES { + let status_dir = self.status_dir(status); + if !status_dir.is_dir() { + continue; + } + for entry in fs::read_dir(&status_dir).map_err(|e| io_err(&status_dir, e))? { + let entry = entry.map_err(|e| io_err(&status_dir, e))?; + let dir = entry.path(); + if dir.is_dir() { + records.extend(self.read_orchestration_plan_records_for_dir(&dir)?); + } + } + } + } + if let Some(kind) = kind { + records.retain(|record| record.kind == kind); + } + records.sort_by(|a, b| a.at.cmp(&b.at).then_with(|| a.id.cmp(&b.id))); + Ok(records) + } + fn doctor(&self) -> Result { let mut report = TicketDoctorReport::default(); for status in STATUSES { @@ -1577,6 +1787,12 @@ impl TicketBackend for LocalTicketBackend { } if artifacts.exists() { doctor_artifacts(&artifacts, &mut report)?; + let meta = ticket_meta(parsed.frontmatter.clone()); + doctor_orchestration_plan_artifact( + &artifacts.join(ORCHESTRATION_PLAN_ARTIFACT), + &meta, + &mut report, + )?; } } } @@ -1866,6 +2082,224 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter) -> TicketMeta { } } +fn trim_owned(value: String) -> String { + value.trim().to_string() +} + +fn trim_accepted_orchestration_plan(plan: AcceptedOrchestrationPlan) -> AcceptedOrchestrationPlan { + AcceptedOrchestrationPlan { + summary: plan.summary.trim().to_string(), + branch: plan.branch.map(trim_owned), + worktree: plan.worktree.map(trim_owned), + role_plan: plan.role_plan.map(trim_owned), + } +} + +fn validate_plan_required_text(label: &str, value: &str, max_bytes: usize) -> Result<()> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(TicketError::Conflict(format!( + "orchestration plan {label} must not be empty" + ))); + } + validate_plan_optional_text(label, Some(trimmed), max_bytes) +} + +fn validate_plan_optional_text(label: &str, value: Option<&str>, max_bytes: usize) -> Result<()> { + if let Some(value) = value { + if value.as_bytes().len() > max_bytes { + return Err(TicketError::Conflict(format!( + "orchestration plan {label} exceeds {max_bytes} bytes" + ))); + } + if value.contains('\0') { + return Err(TicketError::Conflict(format!( + "orchestration plan {label} must not contain NUL bytes" + ))); + } + } + Ok(()) +} + +fn validate_plan_optional_single_line( + label: &str, + value: Option<&str>, + max_bytes: usize, +) -> Result<()> { + validate_plan_optional_text(label, value, max_bytes)?; + if let Some(value) = value { + if value.contains('\n') || value.contains('\r') { + return Err(TicketError::Conflict(format!( + "orchestration plan {label} must be a single line" + ))); + } + } + Ok(()) +} + +fn validate_accepted_orchestration_plan(plan: &AcceptedOrchestrationPlan) -> Result<()> { + validate_plan_required_text( + "accepted_plan.summary", + &plan.summary, + MAX_ORCHESTRATION_PLAN_TEXT_BYTES, + )?; + validate_plan_optional_single_line( + "accepted_plan.branch", + plan.branch.as_deref(), + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + validate_plan_optional_single_line( + "accepted_plan.worktree", + plan.worktree.as_deref(), + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + validate_plan_optional_text( + "accepted_plan.role_plan", + plan.role_plan.as_deref(), + MAX_ORCHESTRATION_PLAN_TEXT_BYTES, + ) +} + +fn validate_new_orchestration_plan_record(record: &NewOrchestrationPlanRecord) -> Result<()> { + if record.kind.requires_related_ticket() { + let related = record.related_ticket.as_deref().ok_or_else(|| { + TicketError::Conflict(format!( + "orchestration plan kind `{}` requires related_ticket", + record.kind + )) + })?; + validate_plan_required_text( + "related_ticket", + related, + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + validate_plan_optional_single_line( + "related_ticket", + Some(related), + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + } else if let Some(related) = record.related_ticket.as_deref() { + validate_plan_optional_single_line( + "related_ticket", + Some(related), + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + } + + if matches!(record.kind, OrchestrationPlanKind::AcceptedPlan) { + let plan = record.accepted_plan.as_ref().ok_or_else(|| { + TicketError::Conflict("accepted_plan record requires accepted_plan fields".to_string()) + })?; + validate_accepted_orchestration_plan(plan)?; + } else if record.accepted_plan.is_some() { + return Err(TicketError::Conflict( + "accepted_plan fields are only valid for accepted_plan records".to_string(), + )); + } + + if matches!(record.kind, OrchestrationPlanKind::WaitingCapacityNote) { + let note = record.note.as_deref().ok_or_else(|| { + TicketError::Conflict("waiting_capacity_note records require note".to_string()) + })?; + validate_plan_required_text("note", note, MAX_ORCHESTRATION_PLAN_TEXT_BYTES)?; + } else { + validate_plan_optional_text( + "note", + record.note.as_deref(), + MAX_ORCHESTRATION_PLAN_TEXT_BYTES, + )?; + } + validate_plan_optional_single_line( + "author", + record.author.as_deref(), + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + ) +} + +fn validate_orchestration_plan_record( + record: &OrchestrationPlanRecord, + meta: Option<&TicketMeta>, +) -> Result<()> { + validate_plan_required_text("id", &record.id, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?; + validate_plan_optional_single_line("id", Some(&record.id), MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?; + validate_plan_required_text( + "ticket_id", + &record.ticket_id, + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + validate_plan_optional_single_line( + "ticket_id", + Some(&record.ticket_id), + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + validate_plan_required_text( + "ticket_slug", + &record.ticket_slug, + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + validate_plan_optional_single_line( + "ticket_slug", + Some(&record.ticket_slug), + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + validate_plan_required_text("author", &record.author, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?; + validate_plan_optional_single_line( + "author", + Some(&record.author), + MAX_ORCHESTRATION_PLAN_FIELD_BYTES, + )?; + validate_plan_required_text("at", &record.at, MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?; + validate_plan_optional_single_line("at", Some(&record.at), MAX_ORCHESTRATION_PLAN_FIELD_BYTES)?; + let new_record = NewOrchestrationPlanRecord { + kind: record.kind, + related_ticket: record.related_ticket.clone(), + note: record.note.clone(), + accepted_plan: record.accepted_plan.clone(), + author: Some(record.author.clone()), + }; + validate_new_orchestration_plan_record(&new_record)?; + if let Some(meta) = meta { + if record.ticket_id != meta.id || record.ticket_slug != meta.slug { + return Err(TicketError::Conflict(format!( + "orchestration plan record {} targets {}/{} but artifact belongs to {}/{}", + record.id, record.ticket_id, record.ticket_slug, meta.id, meta.slug + ))); + } + } + Ok(()) +} + +fn read_orchestration_plan_artifact( + path: &Path, + meta: Option<&TicketMeta>, +) -> Result> { + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(path).map_err(|e| io_err(path, e))?; + let mut records = Vec::new(); + for (idx, line) in content.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let record: OrchestrationPlanRecord = + serde_json::from_str(line).map_err(|e| TicketError::Parse { + path: path.to_path_buf(), + message: format!("invalid orchestration plan record on line {}: {e}", idx + 1), + })?; + validate_orchestration_plan_record(&record, meta).map_err(|err| TicketError::Parse { + path: path.to_path_buf(), + message: format!( + "invalid orchestration plan record on line {}: {err}", + idx + 1 + ), + })?; + records.push(record); + } + Ok(records) +} + fn format_yaml_string_scalar(value: &str) -> String { let mut out = String::from("'"); for ch in value.chars() { @@ -2335,6 +2769,25 @@ fn collect_artifacts_inner( Ok(()) } +fn doctor_orchestration_plan_artifact( + path: &Path, + meta: &TicketMeta, + report: &mut TicketDoctorReport, +) -> Result<()> { + match read_orchestration_plan_artifact(path, Some(meta)) { + Ok(_) => Ok(()), + Err(TicketError::Parse { message, .. }) => { + report.push_error(message, Some(path.to_path_buf())); + Ok(()) + } + Err(TicketError::Conflict(message)) => { + report.push_error(message, Some(path.to_path_buf())); + Ok(()) + } + Err(err) => Err(err), + } +} + fn doctor_artifacts(dir: &Path, report: &mut TicketDoctorReport) -> Result<()> { for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? { let entry = entry.map_err(|e| io_err(dir, e))?; @@ -3135,4 +3588,120 @@ workflow_state: planning .unwrap_err(); assert!(matches!(err, TicketError::InvalidPathComponent(_))); } + + #[test] + fn orchestration_plan_records_persist_and_query_by_ticket_and_kind() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let first = backend.create(NewTicket::new("First ticket")).unwrap(); + let second = backend.create(NewTicket::new("Second ticket")).unwrap(); + + let before = backend + .add_orchestration_plan_record( + TicketIdOrSlug::Id(first.id.clone()), + NewOrchestrationPlanRecord { + kind: OrchestrationPlanKind::Before, + related_ticket: Some(second.slug.clone()), + note: Some( + "First must land before second because both touch routing.".to_string(), + ), + accepted_plan: None, + author: Some("orchestrator".to_string()), + }, + ) + .unwrap(); + assert_eq!(before.ticket_id, first.id); + assert_eq!(before.kind, OrchestrationPlanKind::Before); + + backend + .add_orchestration_plan_record( + TicketIdOrSlug::Slug(first.slug.clone()), + NewOrchestrationPlanRecord { + kind: OrchestrationPlanKind::AcceptedPlan, + related_ticket: None, + note: Some("Accepted during routing.".to_string()), + accepted_plan: Some(AcceptedOrchestrationPlan { + summary: "Implement in a sibling coder worktree, then review before merge." + .to_string(), + branch: Some("ticket-orchestration-plan-tool".to_string()), + worktree: Some(".worktree/ticket-orchestration-plan-tool".to_string()), + role_plan: Some( + "Coder implements; Reviewer checks capability boundaries.".to_string(), + ), + }), + author: Some("orchestrator".to_string()), + }, + ) + .unwrap(); + + let ticket_records = backend + .query_orchestration_plan_records(Some(TicketIdOrSlug::Query(first.slug.clone())), None) + .unwrap(); + assert_eq!(ticket_records.len(), 2); + assert!( + ticket_records + .iter() + .any(|record| record.kind == OrchestrationPlanKind::AcceptedPlan) + ); + + let before_records = backend + .query_orchestration_plan_records(None, Some(OrchestrationPlanKind::Before)) + .unwrap(); + assert_eq!(before_records.len(), 1); + assert_eq!( + before_records[0].related_ticket.as_deref(), + Some(second.slug.as_str()) + ); + + let path = temp + .path() + .join("tickets") + .join("open") + .join(&first.id) + .join("artifacts") + .join(ORCHESTRATION_PLAN_ARTIFACT); + assert!(path.is_file()); + let content = fs::read_to_string(path).unwrap(); + assert_eq!(content.lines().count(), 2); + assert_eq!(backend.doctor().unwrap().error_count(), 0); + } + + #[test] + fn orchestration_plan_validation_rejects_missing_related_ticket_and_bad_artifacts() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let ticket = backend + .create(NewTicket::new("Needs plan validation")) + .unwrap(); + + let err = backend + .add_orchestration_plan_record( + TicketIdOrSlug::Id(ticket.id.clone()), + NewOrchestrationPlanRecord { + kind: OrchestrationPlanKind::BlockedBy, + related_ticket: None, + note: Some("Missing related ticket should fail.".to_string()), + accepted_plan: None, + author: None, + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("requires related_ticket")); + + let artifact = temp + .path() + .join("tickets") + .join("open") + .join(&ticket.id) + .join("artifacts") + .join(ORCHESTRATION_PLAN_ARTIFACT); + fs::write(&artifact, "{not json}\n").unwrap(); + let report = backend.doctor().unwrap(); + assert!(report.error_count() > 0); + assert!(report.diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("invalid orchestration plan record") + })); + } } diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 532b8436..11c79abc 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -12,7 +12,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::{ - ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, Ticket, + AcceptedOrchestrationPlan, ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, + NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketRef, TicketReview, TicketReviewResult, TicketStateChange, TicketStatus, TicketSummary, TicketWorkflowState, @@ -29,7 +30,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024; const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100; const MAX_DIAGNOSTIC_LIMIT: usize = 500; -pub const TICKET_TOOL_NAMES: [&str; 10] = [ +pub const TICKET_TOOL_NAMES: [&str; 12] = [ "TicketCreate", "TicketList", "TicketShow", @@ -39,12 +40,19 @@ pub const TICKET_TOOL_NAMES: [&str; 10] = [ "TicketWorkflowState", "TicketStatus", "TicketClose", + "TicketOrchestrationPlanRecord", + "TicketOrchestrationPlanQuery", "TicketDoctor", ]; -pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 3] = ["TicketList", "TicketShow", "TicketDoctor"]; +pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 4] = [ + "TicketList", + "TicketShow", + "TicketOrchestrationPlanQuery", + "TicketDoctor", +]; -pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [ +pub const TICKET_MUTATING_TOOL_NAMES: [&str; 8] = [ "TicketCreate", "TicketComment", "TicketReview", @@ -52,6 +60,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [ "TicketWorkflowState", "TicketStatus", "TicketClose", + "TicketOrchestrationPlanRecord", ]; const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \ @@ -82,6 +91,12 @@ by `yoi ticket doctor`."; const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \ backend. The backend moves the Ticket to closed/, writes resolution.md, updates item.md, and appends \ a close event."; +const ORCHESTRATION_PLAN_RECORD_DESCRIPTION: &str = "Append a typed Ticket orchestration plan record \ +for ordering, dependency, conflict, waiting/capacity, or accepted-plan decisions. Records are durable \ +Ticket artifacts and do not move workflow_state, reorder queues, or start work."; +const ORCHESTRATION_PLAN_QUERY_DESCRIPTION: &str = "Query durable Ticket orchestration plan records by \ +Ticket id/slug and/or relation kind. This is read-only planning context; Orchestrator must still make \ +explicit workflow_state decisions."; const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \ diagnostics through the typed backend without shelling out to external commands."; @@ -304,6 +319,90 @@ struct TicketCloseParams { resolution: String, } +#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +enum OrchestrationPlanKindParam { + Before, + After, + BlockedBy, + Blocks, + ConflictsWith, + DoNotParallelize, + WaitingCapacityNote, + AcceptedPlan, +} + +impl OrchestrationPlanKindParam { + fn into_kind(self) -> OrchestrationPlanKind { + match self { + Self::Before => OrchestrationPlanKind::Before, + Self::After => OrchestrationPlanKind::After, + Self::BlockedBy => OrchestrationPlanKind::BlockedBy, + Self::Blocks => OrchestrationPlanKind::Blocks, + Self::ConflictsWith => OrchestrationPlanKind::ConflictsWith, + Self::DoNotParallelize => OrchestrationPlanKind::DoNotParallelize, + Self::WaitingCapacityNote => OrchestrationPlanKind::WaitingCapacityNote, + Self::AcceptedPlan => OrchestrationPlanKind::AcceptedPlan, + } + } +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct AcceptedOrchestrationPlanParams { + /// Bounded project-relevant accepted plan summary. + summary: String, + /// Optional branch name for the accepted plan. Do not include runtime/session/socket details. + #[serde(default)] + branch: Option, + /// Optional worktree path for the accepted plan. Do not include runtime/session/socket details. + #[serde(default)] + worktree: Option, + /// Optional bounded role/work allocation plan. Do not include raw model output or private runtime details. + #[serde(default)] + role_plan: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TicketOrchestrationPlanRecordParams { + /// Ticket id or slug that owns this orchestration plan record. + ticket: String, + /// Record kind: before/after, blocked_by/blocks, conflicts_with/do_not_parallelize, waiting_capacity_note, or accepted_plan. + kind: OrchestrationPlanKindParam, + /// Related Ticket id/slug for ordering, dependency, and conflict records. + #[serde(default)] + related_ticket: Option, + /// Optional bounded rationale/note. Required for waiting_capacity_note. + #[serde(default)] + note: Option, + /// Accepted plan fields. Required for accepted_plan and invalid for other kinds. + #[serde(default)] + accepted_plan: Option, + /// Optional record author. + #[serde(default)] + author: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct TicketOrchestrationPlanQueryParams { + /// Optional Ticket id or slug to query. Omit to query across the backend root. + #[serde(default)] + ticket: Option, + /// Optional relation kind filter. + #[serde(default)] + relation_kind: Option, + /// Maximum records to return. Defaults to 100, max 200. + #[serde(default)] + limit: Option, +} + +#[derive(Debug, Serialize)] +struct TicketOrchestrationPlanQueryOutput { + count: usize, + returned: usize, + truncated: bool, + records: Vec, +} + #[derive(Debug, Deserialize, schemars::JsonSchema)] struct TicketDoctorParams { /// Maximum diagnostics to return. Defaults to 100, max 500. @@ -382,6 +481,16 @@ struct TicketCloseTool { backend: LocalTicketBackend, } +#[derive(Clone)] +struct TicketOrchestrationPlanRecordTool { + backend: LocalTicketBackend, +} + +#[derive(Clone)] +struct TicketOrchestrationPlanQueryTool { + backend: LocalTicketBackend, +} + #[derive(Clone)] struct TicketDoctorTool { backend: LocalTicketBackend, @@ -673,6 +782,75 @@ impl Tool for TicketCloseTool { } } +#[async_trait] +impl Tool for TicketOrchestrationPlanRecordTool { + async fn execute(&self, input_json: &str) -> Result { + let params: TicketOrchestrationPlanRecordParams = + parse_input("TicketOrchestrationPlanRecord", input_json)?; + let accepted_plan = params.accepted_plan.map(|plan| AcceptedOrchestrationPlan { + summary: plan.summary, + branch: plan.branch, + worktree: plan.worktree, + role_plan: plan.role_plan, + }); + let record = NewOrchestrationPlanRecord { + kind: params.kind.into_kind(), + related_ticket: params.related_ticket, + note: params.note, + accepted_plan, + author: params.author, + }; + let output = self + .backend + .add_orchestration_plan_record(TicketIdOrSlug::Query(params.ticket.clone()), record) + .map_err(|error| backend_error("TicketOrchestrationPlanRecord", error))?; + Ok(json_output( + format!( + "Recorded orchestration plan {} for ticket {}", + output.kind, params.ticket + ), + output, + )) + } +} + +#[async_trait] +impl Tool for TicketOrchestrationPlanQueryTool { + async fn execute(&self, input_json: &str) -> Result { + let params: TicketOrchestrationPlanQueryParams = + parse_input("TicketOrchestrationPlanQuery", input_json)?; + let limit = bounded(params.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT); + let ticket = params.ticket.clone().map(TicketIdOrSlug::Query); + let kind = params + .relation_kind + .map(OrchestrationPlanKindParam::into_kind); + let records = self + .backend + .query_orchestration_plan_records(ticket, kind) + .map_err(|error| backend_error("TicketOrchestrationPlanQuery", error))?; + let count = records.len(); + let truncated = count > limit; + let returned_records = records + .into_iter() + .take(limit) + .map(|record| serde_json::to_value(record).unwrap_or_else(|_| json!({}))) + .collect::>(); + Ok(json_output( + format!( + "Found {} orchestration plan record(s){}", + count, + if truncated { " (truncated)" } else { "" } + ), + TicketOrchestrationPlanQueryOutput { + count, + returned: returned_records.len(), + truncated, + records: returned_records, + }, + )) + } +} + #[async_trait] impl Tool for TicketDoctorTool { async fn execute(&self, input_json: &str) -> Result { @@ -917,6 +1095,12 @@ fn input_schema(name: &str) -> Value { } "TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)), "TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)), + "TicketOrchestrationPlanRecord" => { + serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanRecordParams)) + } + "TicketOrchestrationPlanQuery" => { + serde_json::to_value(schemars::schema_for!(TicketOrchestrationPlanQueryParams)) + } "TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)), _ => Ok(json!({})), } @@ -942,6 +1126,8 @@ impl_from_backend!(TicketIntakeReadyTool); impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketStatusTool); impl_from_backend!(TicketCloseTool); +impl_from_backend!(TicketOrchestrationPlanRecordTool); +impl_from_backend!(TicketOrchestrationPlanQueryTool); impl_from_backend!(TicketDoctorTool); /// Build all MVP Ticket tool definitions over one local backend root. @@ -964,6 +1150,16 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec { ), tool_definition::("TicketStatus", STATUS_DESCRIPTION, backend.clone()), tool_definition::("TicketClose", CLOSE_DESCRIPTION, backend.clone()), + tool_definition::( + "TicketOrchestrationPlanRecord", + ORCHESTRATION_PLAN_RECORD_DESCRIPTION, + backend.clone(), + ), + tool_definition::( + "TicketOrchestrationPlanQuery", + ORCHESTRATION_PLAN_QUERY_DESCRIPTION, + backend.clone(), + ), tool_definition::("TicketDoctor", DOCTOR_DESCRIPTION, backend), ] } @@ -996,7 +1192,12 @@ mod tests { fn ticket_tool_name_partitions_are_explicit() { assert_eq!( TICKET_READ_ONLY_TOOL_NAMES, - ["TicketList", "TicketShow", "TicketDoctor"] + [ + "TicketList", + "TicketShow", + "TicketOrchestrationPlanQuery", + "TicketDoctor" + ] ); assert_eq!( TICKET_MUTATING_TOOL_NAMES, @@ -1007,7 +1208,8 @@ mod tests { "TicketIntakeReady", "TicketWorkflowState", "TicketStatus", - "TicketClose" + "TicketClose", + "TicketOrchestrationPlanRecord" ] ); for name in TICKET_READ_ONLY_TOOL_NAMES { @@ -1433,6 +1635,53 @@ mod tests { })); } + #[tokio::test] + async fn ticket_orchestration_plan_tools_record_and_query_without_state_changes() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let first = backend.create(NewTicket::new("Plan Tool First")).unwrap(); + let second = backend.create(NewTicket::new("Plan Tool Second")).unwrap(); + let record = tool_by_name(backend.clone(), "TicketOrchestrationPlanRecord"); + let query = tool_by_name(backend.clone(), "TicketOrchestrationPlanQuery"); + + let recorded = record + .execute( + &json!({ + "ticket": first.slug, + "kind": "blocked_by", + "related_ticket": second.slug, + "note": "Wait for the second Ticket's API boundary decision.", + "author": "orchestrator" + }) + .to_string(), + ) + .await + .unwrap(); + assert!( + recorded + .summary + .contains("Recorded orchestration plan blocked_by") + ); + + let found = query + .execute( + &json!({ + "ticket": first.id, + "relation_kind": "blocked_by" + }) + .to_string(), + ) + .await + .unwrap(); + let found_json: Value = serde_json::from_str(&found.content.unwrap()).unwrap(); + assert_eq!(found_json["count"], 1); + assert_eq!(found_json["records"][0]["kind"], "blocked_by"); + assert_eq!(found_json["records"][0]["related_ticket"], second.slug); + + let current = backend.show(TicketIdOrSlug::Id(first.id)).unwrap(); + assert_eq!(current.meta.workflow_state, TicketWorkflowState::Planning); + } + #[tokio::test] async fn ticket_show_requires_exactly_one_identifier() { let temp = TempDir::new().unwrap(); @@ -1470,6 +1719,23 @@ mod tests { .to_string(); assert!(!create_schema.contains("legacy_ticket")); assert!(!create_schema.contains("needs_preflight")); + let plan_record_schema = tools + .iter() + .map(|definition| definition().0) + .find(|meta| meta.name == "TicketOrchestrationPlanRecord") + .unwrap() + .input_schema + .to_string(); + assert!(plan_record_schema.contains("accepted_plan")); + assert!(plan_record_schema.contains("related_ticket")); + let plan_query_schema = tools + .iter() + .map(|definition| definition().0) + .find(|meta| meta.name == "TicketOrchestrationPlanQuery") + .unwrap() + .input_schema + .to_string(); + assert!(plan_query_schema.contains("relation_kind")); let names = tools .into_iter() .map(|definition| definition().0)