1280 lines
54 KiB
Rust
1280 lines
54 KiB
Rust
//! Ticket-role Pod launch planning and execution.
|
|
//!
|
|
//! This module keeps Ticket role configuration, generated first-run input, and
|
|
//! host-side Pod spawning behind the `client` crate so UI callers do not need to
|
|
//! depend on `pod` internals.
|
|
|
|
use std::io;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use manifest::{ProfileDiscovery, ProfileResolveOptions, ProfileResolver, ProfileSelector};
|
|
use protocol::{ErrorCode, Event, InvokeKind, Method, Segment};
|
|
use thiserror::Error;
|
|
pub use ticket::config::TicketRole;
|
|
use ticket::config::{TicketConfig, TicketConfigError, TicketRoleLaunchConfigError};
|
|
|
|
use crate::{PodClient, PodRuntimeCommand, SpawnConfig, SpawnError, SpawnReady, spawn_pod};
|
|
|
|
const MAX_FIELD_CHARS: usize = 8_000;
|
|
const MAX_POD_NAME_CHARS: usize = 80;
|
|
const RUN_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(10);
|
|
const PRE_RUN_ACTION_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
/// Ticket identifier carried by a role launch request.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct TicketRef {
|
|
pub id: Option<String>,
|
|
pub slug: Option<String>,
|
|
}
|
|
|
|
impl TicketRef {
|
|
pub fn id(id: impl Into<String>) -> Self {
|
|
Self {
|
|
id: Some(id.into()),
|
|
slug: None,
|
|
}
|
|
}
|
|
|
|
pub fn slug(slug: impl Into<String>) -> Self {
|
|
Self {
|
|
id: None,
|
|
slug: Some(slug.into()),
|
|
}
|
|
}
|
|
|
|
pub fn id_slug(id: impl Into<String>, slug: impl Into<String>) -> Self {
|
|
Self {
|
|
id: Some(id.into()),
|
|
slug: Some(slug.into()),
|
|
}
|
|
}
|
|
|
|
fn pod_name_seed(&self) -> Option<&str> {
|
|
non_empty(self.slug.as_deref()).or_else(|| non_empty(self.id.as_deref()))
|
|
}
|
|
|
|
fn append_prompt_lines(&self, out: &mut String) {
|
|
match (
|
|
non_empty(self.id.as_deref()),
|
|
non_empty(self.slug.as_deref()),
|
|
) {
|
|
(None, None) => out.push_str("Target Ticket: not specified\n"),
|
|
(id, slug) => {
|
|
out.push_str("Target Ticket:\n");
|
|
if let Some(id) = id {
|
|
push_bounded_bullet(out, "id", id);
|
|
}
|
|
if let Some(slug) = slug {
|
|
push_bounded_bullet(out, "slug", slug);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Auditable panel handoff target included in a Ticket Intake launch.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TicketIntakeHandoff {
|
|
pub orchestrator_pod: String,
|
|
pub workspace_label: String,
|
|
}
|
|
|
|
impl TicketIntakeHandoff {
|
|
pub fn new(orchestrator_pod: impl Into<String>, workspace_label: impl Into<String>) -> Self {
|
|
Self {
|
|
orchestrator_pod: orchestrator_pod.into(),
|
|
workspace_label: workspace_label.into(),
|
|
}
|
|
}
|
|
|
|
fn append_prompt_lines(&self, out: &mut String) {
|
|
out.push_str("\nPanel handoff:\n");
|
|
push_bounded_bullet(out, "workspace", &self.workspace_label);
|
|
push_bounded_bullet(out, "workspace_orchestrator_pod", &self.orchestrator_pod);
|
|
out.push_str("- When Intake has clarified the request and created/updated the Ticket, use the typed Ticket tool surface to append `intake_summary` and set `workflow_state = ready` when the Ticket is ready to queue; use planning language for Tickets that still need clarification/preparation.\n");
|
|
out.push_str("- Handoff report fields: created_or_updated_ticket_id_or_slug, workflow_state, open_questions_or_risk_flags, intake_summary.\n");
|
|
out.push_str("- Do not start implementation automatically; the user queues a ready Ticket via panel (`ready -> queued`), and Orchestrator treats `queued` as schedulable before moving it to `inprogress` when starting.\n");
|
|
}
|
|
}
|
|
|
|
/// Typed input for constructing a Ticket role launch.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TicketRoleLaunchContext {
|
|
pub workspace_root: PathBuf,
|
|
pub role: TicketRole,
|
|
pub pod_name: Option<String>,
|
|
pub ticket: Option<TicketRef>,
|
|
pub user_instruction: Option<String>,
|
|
pub intake_handoff: Option<TicketIntakeHandoff>,
|
|
pub ticket_record_language: Option<String>,
|
|
pub intent_packet: Option<String>,
|
|
pub worktree_path: Option<PathBuf>,
|
|
pub branch: Option<String>,
|
|
pub validation: Vec<String>,
|
|
pub report_expectations: Vec<String>,
|
|
}
|
|
|
|
impl TicketRoleLaunchContext {
|
|
pub fn new(workspace_root: impl Into<PathBuf>, role: TicketRole) -> Self {
|
|
Self {
|
|
workspace_root: workspace_root.into(),
|
|
role,
|
|
pod_name: None,
|
|
ticket: None,
|
|
user_instruction: None,
|
|
intake_handoff: None,
|
|
ticket_record_language: None,
|
|
intent_packet: None,
|
|
worktree_path: None,
|
|
branch: None,
|
|
validation: Vec::new(),
|
|
report_expectations: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pure launch plan usable by TUI/CLI surfaces before executing the launch.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TicketRoleLaunchPlan {
|
|
pub workspace_root: PathBuf,
|
|
pub role: TicketRole,
|
|
pub pod_name: String,
|
|
pub profile: String,
|
|
pub workflow: String,
|
|
pub launch_prompt_ref: Option<String>,
|
|
pub run_segments: Vec<Segment>,
|
|
}
|
|
|
|
impl TicketRoleLaunchPlan {
|
|
pub fn run_method(&self) -> Method {
|
|
Method::Run {
|
|
input: self.run_segments.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn spawn_config(
|
|
&self,
|
|
runtime_command: PodRuntimeCommand,
|
|
) -> Result<SpawnConfig, TicketRoleLaunchError> {
|
|
if self.profile == "inherit" {
|
|
return Err(TicketRoleLaunchError::UnsupportedInheritProfile);
|
|
}
|
|
Ok(SpawnConfig {
|
|
runtime_command,
|
|
pod_name: self.pod_name.clone(),
|
|
profile: Some(self.profile.clone()),
|
|
ticket_role: Some(self.role.as_str().to_string()),
|
|
workspace_root: self.workspace_root.clone(),
|
|
resume_from: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Result of executing a Ticket role launch.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TicketRoleLaunchResult {
|
|
pub plan: TicketRoleLaunchPlan,
|
|
pub ready: SpawnReady,
|
|
pub pre_run_warnings: Vec<TicketRolePreRunWarning>,
|
|
}
|
|
|
|
/// Non-fatal diagnostic produced by bounded pre-run launch actions.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TicketRolePreRunWarning {
|
|
pub message: String,
|
|
}
|
|
|
|
/// Optional bounded actions executed after spawn readiness and before the first Run.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct TicketRoleLaunchOptions {
|
|
pub pre_run_peer_registrations: Vec<String>,
|
|
}
|
|
|
|
impl TicketRoleLaunchOptions {
|
|
pub fn with_pre_run_peer_registration(mut self, pod_name: impl Into<String>) -> Self {
|
|
self.pre_run_peer_registrations.push(pod_name.into());
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum TicketRoleLaunchError {
|
|
#[error(transparent)]
|
|
Config(#[from] TicketConfigError),
|
|
#[error(transparent)]
|
|
LaunchConfig(#[from] TicketRoleLaunchConfigError),
|
|
#[error(
|
|
"Ticket role `{role}` profile selector `{selector}` is not resolvable before launch: {message}. Configure `[roles.{role}].profile` with an executable concrete profile selector such as `builtin:default` or a project/user profile"
|
|
)]
|
|
ProfileResolution {
|
|
role: TicketRole,
|
|
selector: String,
|
|
message: String,
|
|
},
|
|
#[error("Ticket role Pod name must not be empty")]
|
|
EmptyPodName,
|
|
#[error(
|
|
"Ticket role profile 'inherit' cannot be used for top-level launch execution; configure a concrete role profile selector"
|
|
)]
|
|
UnsupportedInheritProfile,
|
|
#[error(transparent)]
|
|
Spawn(#[from] SpawnError),
|
|
#[error("failed to connect to spawned Ticket role Pod at {}: {source}", .socket_path.display())]
|
|
Connect {
|
|
socket_path: PathBuf,
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
#[error("failed to send first run input to spawned Ticket role Pod: {source}")]
|
|
SendRun {
|
|
#[source]
|
|
source: io::Error,
|
|
},
|
|
#[error("Ticket role Pod rejected first run input with {code:?}: {message}")]
|
|
RunRejected { code: ErrorCode, message: String },
|
|
#[error("Ticket role Pod closed before confirming first run acceptance")]
|
|
RunAcceptanceClosed,
|
|
#[error("timed out waiting for Ticket role Pod to confirm first run acceptance")]
|
|
RunAcceptanceTimeout,
|
|
}
|
|
|
|
/// Load `.yoi/ticket.config.toml` from the workspace and construct a launch plan.
|
|
pub fn plan_ticket_role_launch(
|
|
context: TicketRoleLaunchContext,
|
|
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
|
|
let config = TicketConfig::load_workspace(&context.workspace_root)?;
|
|
plan_ticket_role_launch_with_config(context, &config)
|
|
}
|
|
|
|
/// Construct a launch plan from an already-loaded Ticket config.
|
|
pub fn plan_ticket_role_launch_with_config(
|
|
mut context: TicketRoleLaunchContext,
|
|
config: &TicketConfig,
|
|
) -> Result<TicketRoleLaunchPlan, TicketRoleLaunchError> {
|
|
if context.ticket_record_language.is_none() {
|
|
context.ticket_record_language = config.ticket_record_language().map(str::to_string);
|
|
}
|
|
let role_config = config.role_launch_config(context.role)?;
|
|
let profile = role_config.profile.as_str().to_string();
|
|
let workflow = role_config.workflow.as_str().to_string();
|
|
let launch_prompt_ref = role_config
|
|
.launch_prompt
|
|
.as_ref()
|
|
.map(|prompt| prompt.as_str().to_string());
|
|
let pod_name = match context.pod_name.as_deref().map(str::trim) {
|
|
Some("") => return Err(TicketRoleLaunchError::EmptyPodName),
|
|
Some(name) => name.to_string(),
|
|
None => default_pod_name(context.role, context.ticket.as_ref()),
|
|
};
|
|
validate_ticket_role_profile(context.role, &profile, &context.workspace_root, &pod_name)?;
|
|
let prompt = build_launch_prompt(&context, &profile, &workflow, launch_prompt_ref.as_deref());
|
|
|
|
Ok(TicketRoleLaunchPlan {
|
|
workspace_root: context.workspace_root,
|
|
role: context.role,
|
|
pod_name,
|
|
profile,
|
|
workflow: workflow.clone(),
|
|
launch_prompt_ref,
|
|
run_segments: vec![
|
|
Segment::WorkflowInvoke { slug: workflow },
|
|
Segment::Text {
|
|
content: format!("\n\n{prompt}"),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
fn validate_ticket_role_profile(
|
|
role: TicketRole,
|
|
profile: &str,
|
|
workspace_root: &std::path::Path,
|
|
pod_name: &str,
|
|
) -> Result<(), TicketRoleLaunchError> {
|
|
let selector = ProfileSelector::parse_cli(profile);
|
|
let registry = ProfileDiscovery::for_cwd(workspace_root)
|
|
.discover()
|
|
.map_err(|source| TicketRoleLaunchError::ProfileResolution {
|
|
role,
|
|
selector: profile.to_string(),
|
|
message: source.to_string(),
|
|
})?;
|
|
ProfileResolver::new()
|
|
.with_workspace_base(workspace_root)
|
|
.resolve_from_registry(
|
|
&selector,
|
|
®istry,
|
|
ProfileResolveOptions::with_pod_name(pod_name),
|
|
)
|
|
.map(|_| ())
|
|
.map_err(|source| TicketRoleLaunchError::ProfileResolution {
|
|
role,
|
|
selector: profile.to_string(),
|
|
message: source.to_string(),
|
|
})
|
|
}
|
|
|
|
/// Spawn the Pod, connect to its socket, send the first `Method::Run` input,
|
|
/// and wait for bounded acceptance evidence from the Pod event stream.
|
|
pub async fn launch_ticket_role_pod<F>(
|
|
context: TicketRoleLaunchContext,
|
|
runtime_command: PodRuntimeCommand,
|
|
progress: F,
|
|
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
|
|
where
|
|
F: FnMut(&str),
|
|
{
|
|
launch_ticket_role_pod_with_options(
|
|
context,
|
|
runtime_command,
|
|
progress,
|
|
TicketRoleLaunchOptions::default(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Spawn the Pod, run bounded pre-run launch options while it is still idle,
|
|
/// then send the first `Method::Run` input and wait for acceptance evidence.
|
|
pub async fn launch_ticket_role_pod_with_options<F>(
|
|
context: TicketRoleLaunchContext,
|
|
runtime_command: PodRuntimeCommand,
|
|
progress: F,
|
|
options: TicketRoleLaunchOptions,
|
|
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
|
|
where
|
|
F: FnMut(&str),
|
|
{
|
|
let plan = plan_ticket_role_launch(context)?;
|
|
let ready = spawn_pod(plan.spawn_config(runtime_command)?, progress).await?;
|
|
let mut client = PodClient::connect(&ready.socket_path)
|
|
.await
|
|
.map_err(|source| TicketRoleLaunchError::Connect {
|
|
socket_path: ready.socket_path.clone(),
|
|
source,
|
|
})?;
|
|
let pre_run_warnings = run_pre_run_options_then_send_run(&mut client, &plan, &options).await?;
|
|
wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?;
|
|
Ok(TicketRoleLaunchResult {
|
|
plan,
|
|
ready,
|
|
pre_run_warnings,
|
|
})
|
|
}
|
|
|
|
async fn run_pre_run_options_then_send_run(
|
|
client: &mut PodClient,
|
|
plan: &TicketRoleLaunchPlan,
|
|
options: &TicketRoleLaunchOptions,
|
|
) -> Result<Vec<TicketRolePreRunWarning>, TicketRoleLaunchError> {
|
|
let pre_run_warnings = perform_pre_run_peer_registrations(
|
|
client,
|
|
&options.pre_run_peer_registrations,
|
|
PRE_RUN_ACTION_TIMEOUT,
|
|
)
|
|
.await;
|
|
client
|
|
.send(&plan.run_method())
|
|
.await
|
|
.map_err(|source| TicketRoleLaunchError::SendRun { source })?;
|
|
Ok(pre_run_warnings)
|
|
}
|
|
|
|
async fn perform_pre_run_peer_registrations(
|
|
client: &mut PodClient,
|
|
peer_names: &[String],
|
|
timeout: Duration,
|
|
) -> Vec<TicketRolePreRunWarning> {
|
|
let mut warnings = Vec::new();
|
|
for peer_name in peer_names {
|
|
if peer_name.trim().is_empty() {
|
|
warnings.push(TicketRolePreRunWarning {
|
|
message: "pre-run peer registration skipped: peer Pod name is empty".to_string(),
|
|
});
|
|
continue;
|
|
}
|
|
if let Err(message) = pre_run_register_peer(client, peer_name, timeout).await {
|
|
warnings.push(TicketRolePreRunWarning { message });
|
|
}
|
|
}
|
|
warnings
|
|
}
|
|
|
|
async fn pre_run_register_peer(
|
|
client: &mut PodClient,
|
|
peer_name: &str,
|
|
timeout: Duration,
|
|
) -> Result<(), String> {
|
|
if let Err(source) = client
|
|
.send(&Method::RegisterPeer {
|
|
name: peer_name.to_string(),
|
|
})
|
|
.await
|
|
{
|
|
return Err(format!(
|
|
"pre-run peer registration for {peer_name} failed while sending request: {source}"
|
|
));
|
|
}
|
|
|
|
let wait = async {
|
|
loop {
|
|
let Some(event) = client.next_event().await else {
|
|
return Err(format!(
|
|
"pre-run peer registration for {peer_name} failed: connection closed before response"
|
|
));
|
|
};
|
|
match event {
|
|
Event::PeerRegistered { .. } => return Ok(()),
|
|
Event::Error { code, message } => {
|
|
return Err(format!(
|
|
"pre-run peer registration for {peer_name} failed with {code:?}: {message}"
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
};
|
|
|
|
tokio::time::timeout(timeout, wait)
|
|
.await
|
|
.unwrap_or_else(|_| {
|
|
Err(format!(
|
|
"pre-run peer registration for {peer_name} timed out before first Run"
|
|
))
|
|
})
|
|
}
|
|
|
|
async fn wait_for_run_acceptance(
|
|
client: &mut PodClient,
|
|
expected_segments: &[Segment],
|
|
timeout: Duration,
|
|
) -> Result<(), TicketRoleLaunchError> {
|
|
let wait = async {
|
|
loop {
|
|
let Some(event) = client.next_event().await else {
|
|
return Err(TicketRoleLaunchError::RunAcceptanceClosed);
|
|
};
|
|
match event {
|
|
Event::UserMessage { segments } if segments == expected_segments => return Ok(()),
|
|
Event::InvokeStart {
|
|
kind: InvokeKind::UserSend,
|
|
}
|
|
| Event::TurnStart { .. } => return Ok(()),
|
|
Event::Error { code, message } => {
|
|
return Err(TicketRoleLaunchError::RunRejected { code, message });
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
};
|
|
|
|
tokio::time::timeout(timeout, wait)
|
|
.await
|
|
.map_err(|_| TicketRoleLaunchError::RunAcceptanceTimeout)?
|
|
}
|
|
|
|
fn build_launch_prompt(
|
|
context: &TicketRoleLaunchContext,
|
|
profile: &str,
|
|
workflow: &str,
|
|
launch_prompt_ref: Option<&str>,
|
|
) -> String {
|
|
let mut out = String::new();
|
|
out.push_str("# Ticket role launch\n\n");
|
|
out.push_str("Profile supplies durable system/role behavior. The workflow segment supplies the procedural flow. This generated launch prompt supplies only the concrete Ticket/action context for the first committed user task.\n\n");
|
|
push_bounded_field(&mut out, "Role", context.role.as_str());
|
|
push_bounded_field(&mut out, "Profile selector", profile);
|
|
push_bounded_field(&mut out, "Workflow", workflow);
|
|
match launch_prompt_ref {
|
|
Some(prompt_ref) => push_bounded_field(
|
|
&mut out,
|
|
"Configured launch_prompt ref (unresolved)",
|
|
prompt_ref,
|
|
),
|
|
None => out.push_str("Configured launch_prompt ref: none\n"),
|
|
}
|
|
out.push('\n');
|
|
match non_empty(context.ticket_record_language.as_deref()) {
|
|
Some(language) => {
|
|
push_bounded_field(&mut out, "Ticket record language", language);
|
|
out.push_str("Ticket record language guidance: write durable Ticket item/thread/resolution text and Ticket tool bodies in this language. This does not change normal worker response language or memory/Knowledge generation language. Do not translate protocol literals, file paths, commands, logs, identifiers, or quoted external text solely because this language is configured.\n");
|
|
}
|
|
None => out.push_str("Ticket record language: not configured; preserve existing/default Ticket record language behavior.\n"),
|
|
}
|
|
out.push('\n');
|
|
|
|
if let Some(ticket) = &context.ticket {
|
|
ticket.append_prompt_lines(&mut out);
|
|
} else {
|
|
out.push_str("Target Ticket: not specified\n");
|
|
}
|
|
|
|
match non_empty(context.user_instruction.as_deref()) {
|
|
Some(instruction) => push_bounded_section(&mut out, "User/action instruction", instruction),
|
|
None => out.push_str("\nUser/action instruction: not specified\n"),
|
|
}
|
|
|
|
if let Some(handoff) = &context.intake_handoff {
|
|
handoff.append_prompt_lines(&mut out);
|
|
}
|
|
|
|
if let Some(intent_packet) = non_empty(context.intent_packet.as_deref()) {
|
|
push_bounded_section(&mut out, "Intent packet", intent_packet);
|
|
}
|
|
|
|
if context.worktree_path.is_some() || non_empty(context.branch.as_deref()).is_some() {
|
|
out.push_str("\nWorktree context:\n");
|
|
if let Some(path) = &context.worktree_path {
|
|
push_bounded_bullet(&mut out, "path", &path.display().to_string());
|
|
}
|
|
if let Some(branch) = non_empty(context.branch.as_deref()) {
|
|
push_bounded_bullet(&mut out, "branch", branch);
|
|
}
|
|
}
|
|
|
|
if !context.validation.is_empty() {
|
|
push_bounded_list(&mut out, "Validation expectations", &context.validation);
|
|
}
|
|
if !context.report_expectations.is_empty() {
|
|
push_bounded_list(
|
|
&mut out,
|
|
"Report expectations",
|
|
&context.report_expectations,
|
|
);
|
|
}
|
|
|
|
append_role_execution_guidance(&mut out, context.role);
|
|
|
|
out
|
|
}
|
|
|
|
fn append_role_execution_guidance(out: &mut String, role: TicketRole) {
|
|
match role {
|
|
TicketRole::Orchestrator => append_orchestrator_agent_routing_guidance(out),
|
|
TicketRole::Coder => append_coder_agent_routing_guidance(out),
|
|
TicketRole::Reviewer => append_reviewer_agent_routing_guidance(out),
|
|
TicketRole::Intake => {}
|
|
}
|
|
}
|
|
|
|
fn append_orchestrator_agent_routing_guidance(out: &mut String) {
|
|
out.push_str("\nOrchestrator worktree + agent routing guidance:\n");
|
|
out.push_str("- Treat `ticket-orchestrator-routing` as the routing gate. Read the Ticket and workspace state first; `ready -> queued` authorizes routing, not implementation side effects.\n");
|
|
out.push_str("- Create worktrees or spawn coder/reviewer Pods only after `workflow_state = inprogress` is already recorded and accepted. If the Ticket is still queued and unblocked, record `queued -> inprogress` before any worktree/SpawnPod side effect.\n");
|
|
out.push_str("- Use `worktree-workflow` for the mechanical worktree plan: create `.worktree/<task-name>`, keep tracked `.yoi` project records visible in the child worktree, exclude `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths, and keep active orchestration progress plus final review/approval/close in the main workspace unless explicitly designed otherwise.\n");
|
|
out.push_str("- Use `multi-agent-workflow` for the sibling loop: coder and reviewer are siblings under this Orchestrator; coder gets narrow write scope to the child worktree; reviewer is read-only by default.\n");
|
|
out.push_str("- Give the coder an intent packet that distinguishes binding decisions/invariants, implementation latitude, escalation conditions, child worktree/branch, validation commands, and report expectations; set SpawnPod `cwd` to the child worktree while delegating explicit scope separately, prohibit editing main-workspace `.yoi`/Ticket/workflow/docs records, and prohibit creating generated memory/local/runtime/secret-like files in the child worktree.\n");
|
|
out.push_str("- Give the reviewer the recorded Ticket intent, binding decisions/invariants, implementation latitude, acceptance criteria, explicit escalation conditions, diff/commits, validation evidence, and blocker/non-blocker criteria; reviewer judgment is against recorded requirements and decisions, not unrecorded preferred tactics. Keep branch-local reviewer verdicts in the review report or merge-ready dossier rather than recording them as final main-branch Ticket approval.\n");
|
|
out.push_str("- Ticket thread progress may record worktree plan, coder delegated/completed/blocked, reviewer delegated, blocker/fix-loop summaries, and merge-ready dossier pointer; do not merge, close, or record final main approval in this routing/branch-review phase.\n");
|
|
out.push_str("- Stop at a merge-ready dossier for `orchestrator-merge-completion` containing Ticket id/slug, branch/worktree, commits, intent/invariant check, implementation summary, coder/reviewer Pods, blockers fixed or rejected findings with reasons, validation performed, residual risks, dirty state, and parent/human decision needs if any.\n");
|
|
|
|
out.push_str("\nOrchestrator merge-completion guidance:\n");
|
|
out.push_str("- Enter merge-completion only for an `inprogress` Ticket with a merge-ready dossier. Conservative or missing authorization mode stops at the dossier; do not infer merge authority from public/default configuration.\n");
|
|
out.push_str("- Required dossier fields before merge: Ticket id/slug; branch/worktree; commits; intent/invariant check; implementation summary; coder/reviewer Pods; blockers fixed or rejected findings with reasons; validation performed; residual risks; dirty state; parent/human decision needs if any.\n");
|
|
out.push_str("- Before merging, verify the dossier branch/worktree/commits match the branch to merge, independent reviewer approval exists in the dossier or an explicit human override decision is recorded, the main workspace is safe, and unrelated dirty changes are understood.\n");
|
|
out.push_str("- Merge only when dogfooding/workspace policy grants merge authority. If authority is unavailable, record/return the dossier and stop without merge, close, final main Ticket approval, or cleanup.\n");
|
|
out.push_str("- Preserve the boundary: branch-local reviewer verdicts are dossier evidence; final main-branch Ticket approval or close happens only during authorized merge-completion after merge and validation evidence.\n");
|
|
out.push_str("- Authorized sequence: stop/reclaim coder and reviewer Pods where appropriate; merge with `git merge --no-ff <branch>` or the project-agreed method; run post-merge validation appropriate to the change; record review, merge, and validation outcomes in the Ticket thread during merge-completion; transition `inprogress -> done` or close according to typed Ticket workflow rules; then remove the merged child worktree and delete the merged branch unless explicitly kept.\n");
|
|
out.push_str("- Post-merge validation baseline: run focused tests from the Ticket/dossier, `cargo fmt --check`, `git diff --check`, and `target/debug/yoi ticket doctor` where applicable; add broader validation such as `cargo check --workspace --all-targets`, `nix build .#yoi`, or equivalent when risk, API surface, packaging, runtime resources, prompts, or touched files warrant it.\n");
|
|
}
|
|
|
|
fn append_coder_agent_routing_guidance(out: &mut String) {
|
|
out.push_str("\nCoder worktree routing guidance:\n");
|
|
out.push_str("- Implement only in the provided child worktree/branch. SpawnPod should set `cwd` to that worktree so Bash/tool defaults already start there; do not treat `cwd` as authority, and do not edit main-workspace `.yoi`, Ticket, workflow, docs, or memory records; child-worktree `.yoi` project records may be visible when they are part of the branch.\n");
|
|
out.push_str("- Do not create `.yoi/memory`, local/runtime state, logs, locks, caches, sockets, or secret-like files in the child worktree.\n");
|
|
out.push_str("- Treat the intent packet, binding decisions/invariants, implementation latitude, validation expectations, and report expectations as the contract. Investigate and choose local tactics only within the recorded implementation latitude; escalate to Orchestrator rather than expanding scope when design, permission, history, prompt-context, dependency, or Ticket-boundary questions appear.\n");
|
|
out.push_str("- Report worktree path, branch, commits/status, changed files, implementation summary, validation run, unresolved notes, and whether the branch is ready for external review. Do not merge, push, close Tickets, or delete worktrees.\n");
|
|
}
|
|
|
|
fn append_reviewer_agent_routing_guidance(out: &mut String) {
|
|
out.push_str("\nReviewer worktree routing guidance:\n");
|
|
out.push_str("- Review as a sibling of the coder under Orchestrator, read-only by default. Read the Ticket/intent packet, branch diff or commits, and validation evidence before judging. Judge implementation against recorded intent, binding decisions/invariants, implementation latitude, acceptance criteria, and explicit escalation conditions, not unrecorded preferred tactics.\n");
|
|
out.push_str("- Classify findings as blockers, non-blocking follow-ups, or parent-decision items against the recorded intent, binding decisions/invariants, implementation latitude, acceptance criteria, and explicit escalation conditions; include concrete file/line evidence where useful.\n");
|
|
out.push_str("- Keep the branch-local reviewer verdict in the review report for the Orchestrator merge-ready dossier. Do not record final main-branch Ticket approval, merge, close, push, or instruct the coder directly.\n");
|
|
}
|
|
|
|
fn default_pod_name(role: TicketRole, ticket: Option<&TicketRef>) -> String {
|
|
let mut name = format!("ticket-{}", role.as_str());
|
|
if let Some(seed) = ticket.and_then(TicketRef::pod_name_seed) {
|
|
let suffix = sanitise_pod_name_component(seed);
|
|
if !suffix.is_empty() {
|
|
name.push('-');
|
|
name.push_str(&suffix);
|
|
}
|
|
}
|
|
name.chars().take(MAX_POD_NAME_CHARS).collect()
|
|
}
|
|
|
|
fn sanitise_pod_name_component(value: &str) -> String {
|
|
let mut out = String::new();
|
|
let mut last_was_dash = false;
|
|
for ch in value.trim().chars() {
|
|
let mapped = if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.') {
|
|
Some(ch.to_ascii_lowercase())
|
|
} else if ch == '-' || ch.is_whitespace() {
|
|
Some('-')
|
|
} else {
|
|
Some('-')
|
|
};
|
|
if let Some(ch) = mapped {
|
|
if ch == '-' {
|
|
if !last_was_dash && !out.is_empty() {
|
|
out.push(ch);
|
|
}
|
|
last_was_dash = true;
|
|
} else {
|
|
out.push(ch);
|
|
last_was_dash = false;
|
|
}
|
|
}
|
|
}
|
|
out.trim_matches(|ch| matches!(ch, '-' | '_' | '.'))
|
|
.chars()
|
|
.take(MAX_POD_NAME_CHARS)
|
|
.collect()
|
|
}
|
|
|
|
fn push_bounded_field(out: &mut String, label: &str, value: &str) {
|
|
out.push_str(label);
|
|
out.push_str(": ");
|
|
out.push_str(&bounded(value));
|
|
out.push('\n');
|
|
}
|
|
|
|
fn push_bounded_bullet(out: &mut String, label: &str, value: &str) {
|
|
out.push_str("- ");
|
|
out.push_str(label);
|
|
out.push_str(": ");
|
|
out.push_str(&bounded(value));
|
|
out.push('\n');
|
|
}
|
|
|
|
fn push_bounded_section(out: &mut String, label: &str, value: &str) {
|
|
out.push('\n');
|
|
out.push_str(label);
|
|
out.push_str(":\n");
|
|
out.push_str(&bounded(value));
|
|
out.push('\n');
|
|
}
|
|
|
|
fn push_bounded_list(out: &mut String, label: &str, values: &[String]) {
|
|
out.push('\n');
|
|
out.push_str(label);
|
|
out.push_str(":\n");
|
|
for value in values
|
|
.iter()
|
|
.filter_map(|value| non_empty(Some(value.as_str())))
|
|
{
|
|
out.push_str("- ");
|
|
out.push_str(&bounded(value));
|
|
out.push('\n');
|
|
}
|
|
}
|
|
|
|
fn bounded(value: &str) -> String {
|
|
let trimmed = value.trim();
|
|
let mut out: String = trimmed.chars().take(MAX_FIELD_CHARS).collect();
|
|
if trimmed.chars().count() > MAX_FIELD_CHARS {
|
|
out.push_str("\n[truncated]");
|
|
}
|
|
out
|
|
}
|
|
|
|
fn non_empty(value: Option<&str>) -> Option<&str> {
|
|
value.map(str::trim).filter(|value| !value.is_empty())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use protocol::{Greeting, PodStatus};
|
|
use tempfile::TempDir;
|
|
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
|
|
use tokio::net::UnixListener;
|
|
|
|
fn write_config(workspace: &std::path::Path, content: &str) {
|
|
let dir = workspace.join(".yoi");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
std::fs::write(dir.join("ticket.config.toml"), content).unwrap();
|
|
}
|
|
|
|
fn write_builtin_role_config(workspace: &std::path::Path, roles: &[TicketRole]) {
|
|
let mut config = String::new();
|
|
for role in roles {
|
|
config.push_str(&format!(
|
|
"\n[roles.{role}]\nprofile = \"builtin:default\"\n"
|
|
));
|
|
}
|
|
write_config(workspace, &config);
|
|
}
|
|
|
|
fn text_segment(plan: &TicketRoleLaunchPlan) -> &str {
|
|
match &plan.run_segments[1] {
|
|
Segment::Text { content } => content,
|
|
other => panic!("expected text segment, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
async fn write_test_event<W>(writer: &mut W, event: Event)
|
|
where
|
|
W: AsyncWrite + Unpin,
|
|
{
|
|
writer
|
|
.write_all(serde_json::to_string(&event).unwrap().as_bytes())
|
|
.await
|
|
.unwrap();
|
|
writer.write_all(b"\n").await.unwrap();
|
|
}
|
|
|
|
fn test_snapshot() -> Event {
|
|
Event::Snapshot {
|
|
entries: vec![],
|
|
greeting: Greeting {
|
|
pod_name: "ticket-intake".to_string(),
|
|
cwd: "/tmp".to_string(),
|
|
provider: "test".to_string(),
|
|
model: "test".to_string(),
|
|
scope_summary: "test".to_string(),
|
|
tools: vec![],
|
|
context_window: 0,
|
|
context_tokens: 0,
|
|
},
|
|
status: PodStatus::Idle,
|
|
}
|
|
}
|
|
|
|
fn test_launch_plan(workspace: &std::path::Path) -> TicketRoleLaunchPlan {
|
|
TicketRoleLaunchPlan {
|
|
workspace_root: workspace.to_path_buf(),
|
|
role: TicketRole::Intake,
|
|
pod_name: "ticket-intake".to_string(),
|
|
profile: "project:intake".to_string(),
|
|
workflow: "ticket-intake-workflow".to_string(),
|
|
launch_prompt_ref: None,
|
|
run_segments: vec![Segment::Text {
|
|
content: "intake request".to_string(),
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_run_peer_registration_is_sent_before_first_run_submission() {
|
|
let temp = TempDir::new().unwrap();
|
|
let socket_path = temp.path().join("pod.sock");
|
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let (reader, mut writer) = stream.into_split();
|
|
write_test_event(&mut writer, test_snapshot()).await;
|
|
let mut reader = BufReader::new(reader);
|
|
|
|
let mut first = String::new();
|
|
reader.read_line(&mut first).await.unwrap();
|
|
match serde_json::from_str::<Method>(&first).unwrap() {
|
|
Method::RegisterPeer { name } => assert_eq!(name, "workspace-orchestrator"),
|
|
method => panic!("expected RegisterPeer before Run, got {method:?}"),
|
|
}
|
|
write_test_event(
|
|
&mut writer,
|
|
Event::PeerRegistered {
|
|
result: serde_json::json!({"peer": "workspace-orchestrator"}),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let mut second = String::new();
|
|
reader.read_line(&mut second).await.unwrap();
|
|
match serde_json::from_str::<Method>(&second).unwrap() {
|
|
Method::Run { input } => {
|
|
assert_eq!(
|
|
input,
|
|
test_launch_plan(std::path::Path::new("/tmp")).run_segments
|
|
)
|
|
}
|
|
method => panic!("expected Run after pre-run RegisterPeer, got {method:?}"),
|
|
}
|
|
});
|
|
|
|
let mut client = PodClient::connect(&socket_path).await.unwrap();
|
|
let options = TicketRoleLaunchOptions::default()
|
|
.with_pre_run_peer_registration("workspace-orchestrator");
|
|
let warnings = run_pre_run_options_then_send_run(
|
|
&mut client,
|
|
&test_launch_plan(std::path::Path::new("/tmp")),
|
|
&options,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
server.await.unwrap();
|
|
assert!(warnings.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pre_run_peer_registration_failure_warns_but_still_sends_run() {
|
|
let temp = TempDir::new().unwrap();
|
|
let socket_path = temp.path().join("pod.sock");
|
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await.unwrap();
|
|
let (reader, mut writer) = stream.into_split();
|
|
write_test_event(&mut writer, test_snapshot()).await;
|
|
let mut reader = BufReader::new(reader);
|
|
|
|
let mut first = String::new();
|
|
reader.read_line(&mut first).await.unwrap();
|
|
assert!(matches!(
|
|
serde_json::from_str::<Method>(&first).unwrap(),
|
|
Method::RegisterPeer { .. }
|
|
));
|
|
write_test_event(
|
|
&mut writer,
|
|
Event::Error {
|
|
code: protocol::ErrorCode::InvalidRequest,
|
|
message: "peer metadata unavailable".to_string(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let mut second = String::new();
|
|
reader.read_line(&mut second).await.unwrap();
|
|
assert!(matches!(
|
|
serde_json::from_str::<Method>(&second).unwrap(),
|
|
Method::Run { .. }
|
|
));
|
|
});
|
|
|
|
let mut client = PodClient::connect(&socket_path).await.unwrap();
|
|
let options = TicketRoleLaunchOptions::default()
|
|
.with_pre_run_peer_registration("workspace-orchestrator");
|
|
let warnings = run_pre_run_options_then_send_run(
|
|
&mut client,
|
|
&test_launch_plan(std::path::Path::new("/tmp")),
|
|
&options,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
server.await.unwrap();
|
|
assert_eq!(warnings.len(), 1);
|
|
assert!(warnings[0].message.contains("InvalidRequest"));
|
|
assert!(warnings[0].message.contains("workspace-orchestrator"));
|
|
}
|
|
|
|
#[test]
|
|
fn default_config_role_launch_plan_requires_explicit_role_config() {
|
|
let temp = TempDir::new().unwrap();
|
|
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
|
context.ticket = Some(TicketRef::slug("Ticket Role Pod Launcher"));
|
|
|
|
let err = plan_ticket_role_launch(context).unwrap_err();
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("Ticket role `coder` is not launch-configured")
|
|
);
|
|
assert!(err.to_string().contains("[roles.coder]"));
|
|
}
|
|
|
|
#[test]
|
|
fn backend_only_config_is_not_sufficient_for_role_launch_plan() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_config(
|
|
temp.path(),
|
|
r#"
|
|
[backend]
|
|
provider = "builtin:yoi_local"
|
|
root = ".yoi/tickets"
|
|
"#,
|
|
);
|
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
|
|
|
let err = plan_ticket_role_launch(context).unwrap_err();
|
|
|
|
assert!(
|
|
err.to_string()
|
|
.contains("Ticket role `intake` is not launch-configured")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_inherit_profile_fails_before_launch_planning() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_config(
|
|
temp.path(),
|
|
r#"
|
|
[roles.intake]
|
|
profile = "inherit"
|
|
"#,
|
|
);
|
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
|
|
|
let err = plan_ticket_role_launch(context).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("profile = \"inherit\""));
|
|
assert!(err.to_string().contains("top-level Ticket role launch"));
|
|
}
|
|
|
|
#[test]
|
|
fn unresolvable_profile_selector_fails_before_spawn() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_config(
|
|
temp.path(),
|
|
r#"
|
|
[roles.intake]
|
|
profile = "project:no-such-ticket-role-profile"
|
|
"#,
|
|
);
|
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
|
|
|
let err = plan_ticket_role_launch(context).unwrap_err();
|
|
|
|
assert!(
|
|
err.to_string().contains(
|
|
"profile selector `project:no-such-ticket-role-profile` is not resolvable"
|
|
)
|
|
);
|
|
assert!(err.to_string().contains("[roles.intake].profile"));
|
|
}
|
|
|
|
#[test]
|
|
fn full_concrete_role_config_allows_launch_planning() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_builtin_role_config(temp.path(), &[TicketRole::Intake]);
|
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
|
|
|
let plan = plan_ticket_role_launch(context).unwrap();
|
|
|
|
assert_eq!(plan.role, TicketRole::Intake);
|
|
assert_eq!(plan.profile, "builtin:default");
|
|
}
|
|
|
|
#[test]
|
|
fn configured_ticket_record_language_is_included_in_role_prompt() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_config(
|
|
temp.path(),
|
|
r#"
|
|
[ticket]
|
|
language = "Japanese"
|
|
|
|
[roles.intake]
|
|
profile = "builtin:default"
|
|
"#,
|
|
);
|
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
|
|
|
let plan = plan_ticket_role_launch(context).unwrap();
|
|
let text = text_segment(&plan);
|
|
|
|
assert!(text.contains("Ticket record language: Japanese"));
|
|
assert!(text.contains("write durable Ticket item/thread/resolution text"));
|
|
assert!(text.contains("does not change normal worker response language"));
|
|
assert!(text.contains("memory/Knowledge generation language"));
|
|
assert!(text.contains("Do not translate protocol literals"));
|
|
}
|
|
|
|
#[test]
|
|
fn scaffold_config_allows_intake_and_orchestrator_launch_planning() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_config(temp.path(), &ticket::config::ticket_config_scaffold());
|
|
|
|
let intake = plan_ticket_role_launch(TicketRoleLaunchContext::new(
|
|
temp.path(),
|
|
TicketRole::Intake,
|
|
))
|
|
.unwrap();
|
|
assert_eq!(intake.role, TicketRole::Intake);
|
|
assert_eq!(intake.profile, "builtin:default");
|
|
assert_eq!(intake.workflow, TicketRole::Intake.default_workflow());
|
|
|
|
let orchestrator = plan_ticket_role_launch(TicketRoleLaunchContext::new(
|
|
temp.path(),
|
|
TicketRole::Orchestrator,
|
|
))
|
|
.unwrap();
|
|
assert_eq!(orchestrator.role, TicketRole::Orchestrator);
|
|
assert_eq!(orchestrator.profile, "builtin:default");
|
|
assert_eq!(
|
|
orchestrator.workflow,
|
|
TicketRole::Orchestrator.default_workflow()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn spawn_config_still_rejects_inherit_profile_defensively() {
|
|
let temp = TempDir::new().unwrap();
|
|
let mut plan = test_launch_plan(temp.path());
|
|
plan.profile = "inherit".to_string();
|
|
|
|
let err = plan
|
|
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
|
|
.unwrap_err();
|
|
|
|
assert!(matches!(
|
|
err,
|
|
TicketRoleLaunchError::UnsupportedInheritProfile
|
|
));
|
|
assert!(err.to_string().contains("'inherit' cannot be used"));
|
|
}
|
|
|
|
#[test]
|
|
fn configured_role_refs_are_exposed_in_plan_and_prompt() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_config(
|
|
temp.path(),
|
|
r#"
|
|
[roles.reviewer]
|
|
profile = "builtin:default"
|
|
launch_prompt = "$workspace/ticket/reviewer/launch"
|
|
workflow = "ticket-review-workflow"
|
|
"#,
|
|
);
|
|
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
|
context.pod_name = Some("reviewer-fixed".to_string());
|
|
context.ticket = Some(TicketRef::id_slug(
|
|
"20260605-190330-ticket-role-pod-launcher",
|
|
"ticket-role-pod-launcher",
|
|
));
|
|
context.user_instruction = Some("Review the submitted implementation.".to_string());
|
|
|
|
let plan = plan_ticket_role_launch(context).unwrap();
|
|
let text = text_segment(&plan);
|
|
|
|
assert_eq!(plan.pod_name, "reviewer-fixed");
|
|
assert_eq!(plan.profile, "builtin:default");
|
|
assert_eq!(plan.workflow, "ticket-review-workflow");
|
|
assert_eq!(
|
|
plan.launch_prompt_ref.as_deref(),
|
|
Some("$workspace/ticket/reviewer/launch")
|
|
);
|
|
assert!(matches!(
|
|
&plan.run_segments[0],
|
|
Segment::WorkflowInvoke { slug } if slug == "ticket-review-workflow"
|
|
));
|
|
assert!(text.contains(
|
|
"Configured launch_prompt ref (unresolved): $workspace/ticket/reviewer/launch"
|
|
));
|
|
assert!(text.contains("Workflow: ticket-review-workflow"));
|
|
assert!(text.contains("Profile selector: builtin:default"));
|
|
assert!(!text.contains("system_instruction"));
|
|
let spawn = plan
|
|
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
|
|
.unwrap();
|
|
assert_eq!(spawn.pod_name, "reviewer-fixed");
|
|
assert_eq!(spawn.profile.as_deref(), Some("builtin:default"));
|
|
assert_eq!(spawn.ticket_role.as_deref(), Some("reviewer"));
|
|
assert_eq!(spawn.workspace_root, temp.path());
|
|
}
|
|
|
|
#[test]
|
|
fn generated_prompt_covers_intake_orchestrator_coder_and_reviewer_context() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_builtin_role_config(
|
|
temp.path(),
|
|
&[
|
|
TicketRole::Intake,
|
|
TicketRole::Orchestrator,
|
|
TicketRole::Coder,
|
|
TicketRole::Reviewer,
|
|
],
|
|
);
|
|
|
|
let mut intake = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
|
intake.user_instruction = Some("Clarify and materialize this request as a Ticket.".into());
|
|
let intake_plan = plan_ticket_role_launch(intake).unwrap();
|
|
let intake_text = text_segment(&intake_plan);
|
|
assert!(intake_text.contains("Role: intake"));
|
|
assert!(intake_text.contains("Clarify and materialize"));
|
|
assert!(intake_text.contains("Workflow: ticket-intake-workflow"));
|
|
|
|
let mut handoff_intake = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
|
handoff_intake.intake_handoff = Some(TicketIntakeHandoff::new(
|
|
"panel-orchestrator-demo",
|
|
"Demo workspace",
|
|
));
|
|
let handoff_plan = plan_ticket_role_launch(handoff_intake).unwrap();
|
|
let handoff_text = text_segment(&handoff_plan);
|
|
assert!(handoff_text.contains("Panel handoff:"));
|
|
assert!(handoff_text.contains("workspace_orchestrator_pod: panel-orchestrator-demo"));
|
|
assert!(handoff_text.contains("workspace: Demo workspace"));
|
|
assert!(handoff_text.contains("created_or_updated_ticket_id_or_slug"));
|
|
assert!(handoff_text.contains("workflow_state"));
|
|
assert!(handoff_text.contains("Ticket tool surface"));
|
|
assert!(handoff_text.contains("ready -> queued"));
|
|
assert!(handoff_text.contains("queued` as schedulable"));
|
|
assert!(!handoff_text.contains("user_go_required"));
|
|
assert!(!handoff_text.contains("human Go gates"));
|
|
|
|
let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator);
|
|
orchestrator.ticket = Some(TicketRef::slug("launcher"));
|
|
orchestrator.intent_packet = Some("Route to implementation after planning sync.".into());
|
|
orchestrator.validation = vec!["cargo check --workspace --all-targets".into()];
|
|
let orchestrator_plan = plan_ticket_role_launch(orchestrator).unwrap();
|
|
let orchestrator_text = text_segment(&orchestrator_plan);
|
|
assert!(orchestrator_text.contains("Role: orchestrator"));
|
|
assert!(orchestrator_text.contains("Route to implementation after planning sync."));
|
|
assert!(orchestrator_text.contains("cargo check --workspace --all-targets"));
|
|
assert!(orchestrator_text.contains("workflow_state = inprogress"));
|
|
assert!(orchestrator_text.contains("worktree-workflow"));
|
|
assert!(orchestrator_text.contains("keep tracked `.yoi` project records visible"));
|
|
assert!(orchestrator_text.contains("exclude `.yoi/memory`"));
|
|
assert!(
|
|
orchestrator_text
|
|
.contains("prohibit creating generated memory/local/runtime/secret-like files")
|
|
);
|
|
assert!(orchestrator_text.contains("multi-agent-workflow"));
|
|
assert!(orchestrator_text.contains("coder and reviewer are siblings"));
|
|
assert!(orchestrator_text.contains("branch-local reviewer verdicts"));
|
|
assert!(orchestrator_text.contains("binding decisions/invariants"));
|
|
assert!(orchestrator_text.contains("not unrecorded preferred tactics"));
|
|
assert!(orchestrator_text.contains("merge-ready dossier"));
|
|
assert!(orchestrator_text.contains("do not merge, close, or record final main approval"));
|
|
|
|
let mut coder = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
|
coder.ticket = Some(TicketRef::id("20260605-190330-ticket-role-pod-launcher"));
|
|
coder.worktree_path = Some(PathBuf::from("/tmp/yoi-code"));
|
|
coder.branch = Some("work/ticket-role-pod-launcher".into());
|
|
coder.validation = vec!["cargo test -p client ticket_role".into()];
|
|
coder.report_expectations = vec!["implementation report with validation".into()];
|
|
let coder_plan = plan_ticket_role_launch(coder).unwrap();
|
|
let coder_text = text_segment(&coder_plan);
|
|
assert!(coder_text.contains("Role: coder"));
|
|
assert!(coder_text.contains("path: /tmp/yoi-code"));
|
|
assert!(coder_text.contains("branch: work/ticket-role-pod-launcher"));
|
|
assert!(coder_text.contains("cargo test -p client ticket_role"));
|
|
assert!(coder_text.contains("provided child worktree/branch"));
|
|
assert!(coder_text.contains("do not edit main-workspace `.yoi`"));
|
|
assert!(coder_text.contains("child-worktree `.yoi` project records may be visible"));
|
|
assert!(coder_text.contains("Do not create `.yoi/memory`"));
|
|
assert!(coder_text.contains("implementation latitude"));
|
|
assert!(coder_text.contains("choose local tactics"));
|
|
assert!(coder_text.contains("Do not merge, push, close Tickets, or delete worktrees"));
|
|
|
|
let mut reviewer = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
|
|
reviewer.ticket = Some(TicketRef::id("20260605-190330-ticket-role-pod-launcher"));
|
|
reviewer.worktree_path = Some(PathBuf::from("/tmp/yoi-review"));
|
|
reviewer.branch = Some("work/ticket-role-pod-launcher".into());
|
|
reviewer.report_expectations = vec!["approve or request changes".into()];
|
|
let reviewer_plan = plan_ticket_role_launch(reviewer).unwrap();
|
|
let reviewer_text = text_segment(&reviewer_plan);
|
|
assert!(reviewer_text.contains("Role: reviewer"));
|
|
assert!(reviewer_text.contains("path: /tmp/yoi-review"));
|
|
assert!(reviewer_text.contains("branch: work/ticket-role-pod-launcher"));
|
|
assert!(reviewer_text.contains("approve or request changes"));
|
|
assert!(reviewer_text.contains("read-only by default"));
|
|
assert!(reviewer_text.contains("recorded intent, binding decisions/invariants"));
|
|
assert!(reviewer_text.contains("not unrecorded preferred tactics"));
|
|
assert!(reviewer_text.contains("branch-local reviewer verdict"));
|
|
assert!(reviewer_text.contains("Do not record final main-branch Ticket approval"));
|
|
}
|
|
|
|
#[test]
|
|
fn orchestrator_prompt_covers_merge_completion_authority_and_dossier_boundaries() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_builtin_role_config(temp.path(), &[TicketRole::Orchestrator]);
|
|
let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator);
|
|
orchestrator.ticket = Some(TicketRef::slug("orchestrator-merge-completion"));
|
|
orchestrator.intent_packet =
|
|
Some("Complete an already-reviewed merge-ready Ticket.".into());
|
|
orchestrator.validation = vec!["cargo test -p client ticket_role --lib".into()];
|
|
|
|
let plan = plan_ticket_role_launch(orchestrator).unwrap();
|
|
let text = text_segment(&plan);
|
|
|
|
assert!(text.contains("Orchestrator merge-completion guidance"));
|
|
assert!(text.contains("`inprogress` Ticket with a merge-ready dossier"));
|
|
assert!(text.contains("Conservative or missing authorization mode stops at the dossier"));
|
|
assert!(text.contains("do not infer merge authority"));
|
|
assert!(text.contains("dossier branch/worktree/commits match the branch to merge"));
|
|
assert!(text.contains("independent reviewer approval exists in the dossier"));
|
|
assert!(text.contains("explicit human override decision is recorded"));
|
|
assert!(text.contains("the main workspace is safe"));
|
|
assert!(text.contains("unrelated dirty changes are understood"));
|
|
assert!(text.contains("dogfooding/workspace policy grants merge authority"));
|
|
assert!(text.contains("branch-local reviewer verdicts are dossier evidence"));
|
|
assert!(text.contains("final main-branch Ticket approval or close happens only during authorized merge-completion"));
|
|
assert!(text.contains("stop/reclaim coder and reviewer Pods"));
|
|
assert!(text.contains("git merge --no-ff <branch>"));
|
|
assert!(text.contains("run post-merge validation appropriate to the change"));
|
|
assert!(
|
|
text.contains("record review, merge, and validation outcomes in the Ticket thread")
|
|
);
|
|
assert!(text.contains(
|
|
"transition `inprogress -> done` or close according to typed Ticket workflow rules"
|
|
));
|
|
assert!(text.contains(
|
|
"remove the merged child worktree and delete the merged branch unless explicitly kept"
|
|
));
|
|
assert!(text.contains("Required dossier fields before merge"));
|
|
assert!(text.contains("Ticket id/slug"));
|
|
assert!(text.contains("branch/worktree"));
|
|
assert!(text.contains("commits"));
|
|
assert!(text.contains("intent/invariant check"));
|
|
assert!(text.contains("implementation summary"));
|
|
assert!(text.contains("coder/reviewer Pods"));
|
|
assert!(text.contains("blockers fixed or rejected findings with reasons"));
|
|
assert!(text.contains("validation performed"));
|
|
assert!(text.contains("residual risks"));
|
|
assert!(text.contains("dirty state"));
|
|
assert!(text.contains("parent/human decision needs if any"));
|
|
assert!(text.contains("Post-merge validation baseline"));
|
|
assert!(text.contains("focused tests from the Ticket/dossier"));
|
|
assert!(text.contains("cargo fmt --check"));
|
|
assert!(text.contains("git diff --check"));
|
|
assert!(text.contains("target/debug/yoi ticket doctor"));
|
|
assert!(text.contains("where applicable"));
|
|
assert!(text.contains("cargo check --workspace --all-targets"));
|
|
assert!(text.contains("nix build .#yoi"));
|
|
assert!(text.contains("when risk, API surface, packaging, runtime resources, prompts, or touched files warrant it"));
|
|
}
|
|
|
|
#[test]
|
|
fn caller_provided_pod_name_is_used_exactly() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_builtin_role_config(temp.path(), &[TicketRole::Intake]);
|
|
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Intake);
|
|
context.pod_name = Some("custom-intake-pod".into());
|
|
|
|
let plan = plan_ticket_role_launch(context).unwrap();
|
|
|
|
assert_eq!(plan.pod_name, "custom-intake-pod");
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_ticket_config_surfaces_error() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_config(
|
|
temp.path(),
|
|
r#"
|
|
[roles.coder]
|
|
profile = "./coder.lua"
|
|
"#,
|
|
);
|
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
|
|
|
let err = plan_ticket_role_launch(context).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("path selectors are not supported"));
|
|
}
|
|
|
|
#[test]
|
|
fn system_instruction_is_not_a_launch_config_field() {
|
|
let temp = TempDir::new().unwrap();
|
|
write_config(
|
|
temp.path(),
|
|
r#"
|
|
[roles.coder]
|
|
profile = "inherit"
|
|
system_instruction = "$workspace/not-supported"
|
|
"#,
|
|
);
|
|
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
|
|
|
|
let err = plan_ticket_role_launch(context).unwrap_err();
|
|
|
|
assert!(err.to_string().contains("unknown field"));
|
|
assert!(err.to_string().contains("system_instruction"));
|
|
}
|
|
}
|