ticket: add orchestration plan tools

This commit is contained in:
Keisuke Hirata 2026-06-08 22:16:08 +09:00
parent 68770a2b66
commit b28b7759f5
No known key found for this signature in database
4 changed files with 853 additions and 8 deletions

View File

@ -66,6 +66,8 @@ Orchestrator は以下を行う。
- `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。 - `TicketComment`: routing decision / intent packet / blocked reason / next question の記録。
- `TicketStatus`: pending/open などの状態整理が明示的に許可された場合だけ使う。 - `TicketStatus`: pending/open などの状態整理が明示的に許可された場合だけ使う。
- `TicketWorkflowState`: `queued -> inprogress` acceptance、`inprogress -> done`、または concrete missing decision/information reason を伴う `ready|queued -> planning` に使う。 - `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 が揃っている場合だけ使う。 - `TicketClose`: 完了権限と resolution が揃っている場合だけ使う。
- `TicketDoctor`: routing 前後の整合性確認。 - `TicketDoctor`: routing 前後の整合性確認。
@ -73,9 +75,10 @@ Orchestrator は以下を行う。
## Queued acceptance contract ## 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 に進む。 - 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 があり、既存通知経路が使える場合は同じ理由を通知する。 - 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 する。 - external action 待ちなど planning では解決しない blocker の場合: concise な理由を Ticket thread に記録し、queued のまま待つか、既存の Ticket status/state mechanism で明示的に defer/block する。
@ -160,6 +163,7 @@ Action:
Action: Action:
- `IntentPacket` を作る。 - `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 を記録する。 - `TicketComment` に routing decision と IntentPacket を記録する。
- 許可があれば `multi-agent-workflow` に接続し、worktree + coder/reviewer sibling loop に進む。 - 許可があれば `multi-agent-workflow` に接続し、worktree + coder/reviewer sibling loop に進む。

View File

@ -196,6 +196,12 @@ fn tool_description(name: &str) -> &'static str {
} }
"TicketStatus" => "Move a Ticket between open and pending; use TicketClose for closed.", "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.", "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.", "TicketDoctor" => "Run typed local Ticket backend consistency checks.",
_ => "Typed Ticket backend tool.", _ => "Typed Ticket backend tool.",
} }
@ -411,7 +417,7 @@ profile = "inherit"
assert_eq!(report.reports[0].diagnostics.len(), 1); assert_eq!(report.reports[0].diagnostics.len(), 1);
let message = &report.reports[0].diagnostics[0].message; let message = &report.reports[0].diagnostics[0].message;
assert!(message.contains("Ticket tools not registered")); assert!(message.contains("Ticket tools not registered"));
assert!(message.contains("unknown Ticket role `operator`")); assert!(message.contains("unsupported Ticket role `operator`"));
} }
#[test] #[test]

View File

@ -12,6 +12,7 @@ use std::path::{Component, Path, PathBuf};
use chrono::Utc; use chrono::Utc;
use fs4::fs_std::FileExt; use fs4::fs_std::FileExt;
use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue}; use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use thiserror::Error; use thiserror::Error;
@ -37,6 +38,9 @@ const REQUIRED_FIELDS: [&str; 10] = [
]; ];
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024; const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 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 = const DEFAULT_TICKET_BODY: &str =
"## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n"; "## Background\n\nCreated by LocalTicketBackend.\n\n## Acceptance criteria\n\n- TBD\n";
const JAPANESE_TICKET_BODY: &str = const JAPANESE_TICKET_BODY: &str =
@ -550,6 +554,104 @@ pub struct TicketRef {
pub status: TicketStatus, 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<Self> {
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub role_plan: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewOrchestrationPlanRecord {
pub kind: OrchestrationPlanKind,
pub related_ticket: Option<String>,
pub note: Option<String>,
pub accepted_plan: Option<AcceptedOrchestrationPlan>,
pub author: Option<String>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub accepted_plan: Option<AcceptedOrchestrationPlan>,
pub author: String,
pub at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketMeta { pub struct TicketMeta {
pub id: String, pub id: String,
@ -700,6 +802,16 @@ pub trait TicketBackend {
fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>; fn review(&self, id: TicketIdOrSlug, review: TicketReview) -> Result<()>;
fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>; fn set_status(&self, id: TicketIdOrSlug, status: TicketStatus) -> Result<()>;
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>;
fn add_orchestration_plan_record(
&self,
id: TicketIdOrSlug,
record: NewOrchestrationPlanRecord,
) -> Result<OrchestrationPlanRecord>;
fn query_orchestration_plan_records(
&self,
ticket: Option<TicketIdOrSlug>,
kind: Option<OrchestrationPlanKind>,
) -> Result<Vec<OrchestrationPlanRecord>>;
fn doctor(&self) -> Result<TicketDoctorReport>; fn doctor(&self) -> Result<TicketDoctorReport>;
} }
@ -1021,6 +1133,20 @@ impl LocalTicketBackend {
})?; })?;
atomic_write(item, updated.as_bytes()) 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<Vec<OrchestrationPlanRecord>> {
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 { impl TicketBackend for LocalTicketBackend {
@ -1446,6 +1572,90 @@ impl TicketBackend for LocalTicketBackend {
) )
} }
fn add_orchestration_plan_record(
&self,
id: TicketIdOrSlug,
record: NewOrchestrationPlanRecord,
) -> Result<OrchestrationPlanRecord> {
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<TicketIdOrSlug>,
kind: Option<OrchestrationPlanKind>,
) -> Result<Vec<OrchestrationPlanRecord>> {
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<TicketDoctorReport> { fn doctor(&self) -> Result<TicketDoctorReport> {
let mut report = TicketDoctorReport::default(); let mut report = TicketDoctorReport::default();
for status in STATUSES { for status in STATUSES {
@ -1577,6 +1787,12 @@ impl TicketBackend for LocalTicketBackend {
} }
if artifacts.exists() { if artifacts.exists() {
doctor_artifacts(&artifacts, &mut report)?; 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<Vec<OrchestrationPlanRecord>> {
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 { fn format_yaml_string_scalar(value: &str) -> String {
let mut out = String::from("'"); let mut out = String::from("'");
for ch in value.chars() { for ch in value.chars() {
@ -2335,6 +2769,25 @@ fn collect_artifacts_inner(
Ok(()) 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<()> { fn doctor_artifacts(dir: &Path, report: &mut TicketDoctorReport) -> Result<()> {
for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? { for entry in fs::read_dir(dir).map_err(|e| io_err(dir, e))? {
let entry = entry.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(); .unwrap_err();
assert!(matches!(err, TicketError::InvalidPathComponent(_))); 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")
}));
}
} }

View File

@ -12,7 +12,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use crate::{ use crate::{
ExtensibleTicketStatus, LocalTicketBackend, MarkdownText, NewTicket, NewTicketEvent, Ticket, AcceptedOrchestrationPlan, ExtensibleTicketStatus, LocalTicketBackend, MarkdownText,
NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, OrchestrationPlanKind, Ticket,
TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketBackend, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError,
TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketRef, TicketReview, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketRef, TicketReview,
TicketReviewResult, TicketStateChange, TicketStatus, TicketSummary, TicketWorkflowState, TicketReviewResult, TicketStateChange, TicketStatus, TicketSummary, TicketWorkflowState,
@ -29,7 +30,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100; const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
const MAX_DIAGNOSTIC_LIMIT: usize = 500; const MAX_DIAGNOSTIC_LIMIT: usize = 500;
pub const TICKET_TOOL_NAMES: [&str; 10] = [ pub const TICKET_TOOL_NAMES: [&str; 12] = [
"TicketCreate", "TicketCreate",
"TicketList", "TicketList",
"TicketShow", "TicketShow",
@ -39,12 +40,19 @@ pub const TICKET_TOOL_NAMES: [&str; 10] = [
"TicketWorkflowState", "TicketWorkflowState",
"TicketStatus", "TicketStatus",
"TicketClose", "TicketClose",
"TicketOrchestrationPlanRecord",
"TicketOrchestrationPlanQuery",
"TicketDoctor", "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", "TicketCreate",
"TicketComment", "TicketComment",
"TicketReview", "TicketReview",
@ -52,6 +60,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 7] = [
"TicketWorkflowState", "TicketWorkflowState",
"TicketStatus", "TicketStatus",
"TicketClose", "TicketClose",
"TicketOrchestrationPlanRecord",
]; ];
const CREATE_DESCRIPTION: &str = "Create a Ticket through the configured typed Ticket backend. \ 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 \ 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 \ backend. The backend moves the Ticket to closed/, writes resolution.md, updates item.md, and appends \
a close event."; 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 \ const DOCTOR_DESCRIPTION: &str = "Run typed Ticket backend consistency checks and return bounded \
diagnostics through the typed backend without shelling out to external commands."; diagnostics through the typed backend without shelling out to external commands.";
@ -304,6 +319,90 @@ struct TicketCloseParams {
resolution: String, 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<String>,
/// Optional worktree path for the accepted plan. Do not include runtime/session/socket details.
#[serde(default)]
worktree: Option<String>,
/// Optional bounded role/work allocation plan. Do not include raw model output or private runtime details.
#[serde(default)]
role_plan: Option<String>,
}
#[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<String>,
/// Optional bounded rationale/note. Required for waiting_capacity_note.
#[serde(default)]
note: Option<String>,
/// Accepted plan fields. Required for accepted_plan and invalid for other kinds.
#[serde(default)]
accepted_plan: Option<AcceptedOrchestrationPlanParams>,
/// Optional record author.
#[serde(default)]
author: Option<String>,
}
#[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<String>,
/// Optional relation kind filter.
#[serde(default)]
relation_kind: Option<OrchestrationPlanKindParam>,
/// Maximum records to return. Defaults to 100, max 200.
#[serde(default)]
limit: Option<usize>,
}
#[derive(Debug, Serialize)]
struct TicketOrchestrationPlanQueryOutput {
count: usize,
returned: usize,
truncated: bool,
records: Vec<Value>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketDoctorParams { struct TicketDoctorParams {
/// Maximum diagnostics to return. Defaults to 100, max 500. /// Maximum diagnostics to return. Defaults to 100, max 500.
@ -382,6 +481,16 @@ struct TicketCloseTool {
backend: LocalTicketBackend, backend: LocalTicketBackend,
} }
#[derive(Clone)]
struct TicketOrchestrationPlanRecordTool {
backend: LocalTicketBackend,
}
#[derive(Clone)]
struct TicketOrchestrationPlanQueryTool {
backend: LocalTicketBackend,
}
#[derive(Clone)] #[derive(Clone)]
struct TicketDoctorTool { struct TicketDoctorTool {
backend: LocalTicketBackend, backend: LocalTicketBackend,
@ -673,6 +782,75 @@ impl Tool for TicketCloseTool {
} }
} }
#[async_trait]
impl Tool for TicketOrchestrationPlanRecordTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
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<ToolOutput, ToolError> {
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::<Vec<_>>();
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] #[async_trait]
impl Tool for TicketDoctorTool { impl Tool for TicketDoctorTool {
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> { async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
@ -917,6 +1095,12 @@ fn input_schema(name: &str) -> Value {
} }
"TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)), "TicketStatus" => serde_json::to_value(schemars::schema_for!(TicketStatusParams)),
"TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)), "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)), "TicketDoctor" => serde_json::to_value(schemars::schema_for!(TicketDoctorParams)),
_ => Ok(json!({})), _ => Ok(json!({})),
} }
@ -942,6 +1126,8 @@ impl_from_backend!(TicketIntakeReadyTool);
impl_from_backend!(TicketWorkflowStateTool); impl_from_backend!(TicketWorkflowStateTool);
impl_from_backend!(TicketStatusTool); impl_from_backend!(TicketStatusTool);
impl_from_backend!(TicketCloseTool); impl_from_backend!(TicketCloseTool);
impl_from_backend!(TicketOrchestrationPlanRecordTool);
impl_from_backend!(TicketOrchestrationPlanQueryTool);
impl_from_backend!(TicketDoctorTool); impl_from_backend!(TicketDoctorTool);
/// Build all MVP Ticket tool definitions over one local backend root. /// Build all MVP Ticket tool definitions over one local backend root.
@ -964,6 +1150,16 @@ pub fn ticket_tools(backend: LocalTicketBackend) -> Vec<ToolDefinition> {
), ),
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()), tool_definition::<TicketStatusTool>("TicketStatus", STATUS_DESCRIPTION, backend.clone()),
tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()), tool_definition::<TicketCloseTool>("TicketClose", CLOSE_DESCRIPTION, backend.clone()),
tool_definition::<TicketOrchestrationPlanRecordTool>(
"TicketOrchestrationPlanRecord",
ORCHESTRATION_PLAN_RECORD_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketOrchestrationPlanQueryTool>(
"TicketOrchestrationPlanQuery",
ORCHESTRATION_PLAN_QUERY_DESCRIPTION,
backend.clone(),
),
tool_definition::<TicketDoctorTool>("TicketDoctor", DOCTOR_DESCRIPTION, backend), tool_definition::<TicketDoctorTool>("TicketDoctor", DOCTOR_DESCRIPTION, backend),
] ]
} }
@ -996,7 +1192,12 @@ mod tests {
fn ticket_tool_name_partitions_are_explicit() { fn ticket_tool_name_partitions_are_explicit() {
assert_eq!( assert_eq!(
TICKET_READ_ONLY_TOOL_NAMES, TICKET_READ_ONLY_TOOL_NAMES,
["TicketList", "TicketShow", "TicketDoctor"] [
"TicketList",
"TicketShow",
"TicketOrchestrationPlanQuery",
"TicketDoctor"
]
); );
assert_eq!( assert_eq!(
TICKET_MUTATING_TOOL_NAMES, TICKET_MUTATING_TOOL_NAMES,
@ -1007,7 +1208,8 @@ mod tests {
"TicketIntakeReady", "TicketIntakeReady",
"TicketWorkflowState", "TicketWorkflowState",
"TicketStatus", "TicketStatus",
"TicketClose" "TicketClose",
"TicketOrchestrationPlanRecord"
] ]
); );
for name in TICKET_READ_ONLY_TOOL_NAMES { 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] #[tokio::test]
async fn ticket_show_requires_exactly_one_identifier() { async fn ticket_show_requires_exactly_one_identifier() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
@ -1470,6 +1719,23 @@ mod tests {
.to_string(); .to_string();
assert!(!create_schema.contains("legacy_ticket")); assert!(!create_schema.contains("legacy_ticket"));
assert!(!create_schema.contains("needs_preflight")); 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 let names = tools
.into_iter() .into_iter()
.map(|definition| definition().0) .map(|definition| definition().0)