ticket: add orchestration plan tools
This commit is contained in:
parent
68770a2b66
commit
b28b7759f5
|
|
@ -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 に進む。
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
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<OrchestrationPlanRecord>;
|
||||
fn query_orchestration_plan_records(
|
||||
&self,
|
||||
ticket: Option<TicketIdOrSlug>,
|
||||
kind: Option<OrchestrationPlanKind>,
|
||||
) -> Result<Vec<OrchestrationPlanRecord>>;
|
||||
fn doctor(&self) -> Result<TicketDoctorReport>;
|
||||
}
|
||||
|
||||
|
|
@ -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<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 {
|
||||
|
|
@ -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> {
|
||||
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<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 {
|
||||
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")
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
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<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]
|
||||
impl Tool for TicketDoctorTool {
|
||||
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)),
|
||||
"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<ToolDefinition> {
|
|||
),
|
||||
tool_definition::<TicketStatusTool>("TicketStatus", STATUS_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),
|
||||
]
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user