feat: add ticket intake handoff
This commit is contained in:
parent
f9745dd7a6
commit
adbf3a1e34
|
|
@ -18,7 +18,8 @@ pub use runtime_command::PodRuntimeCommand;
|
||||||
pub use pod_client::PodClient;
|
pub use pod_client::PodClient;
|
||||||
pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod};
|
pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod};
|
||||||
pub use ticket_role::{
|
pub use ticket_role::{
|
||||||
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchPlan,
|
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
|
||||||
TicketRoleLaunchResult, launch_ticket_role_pod, plan_ticket_role_launch,
|
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning, launch_ticket_role_pod,
|
||||||
|
launch_ticket_role_pod_with_options, plan_ticket_role_launch,
|
||||||
plan_ticket_role_launch_with_config,
|
plan_ticket_role_launch_with_config,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use crate::{PodClient, PodRuntimeCommand, SpawnConfig, SpawnError, SpawnReady, s
|
||||||
const MAX_FIELD_CHARS: usize = 8_000;
|
const MAX_FIELD_CHARS: usize = 8_000;
|
||||||
const MAX_POD_NAME_CHARS: usize = 80;
|
const MAX_POD_NAME_CHARS: usize = 80;
|
||||||
const RUN_ACCEPTANCE_TIMEOUT: Duration = Duration::from_secs(10);
|
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.
|
/// Ticket identifier carried by a role launch request.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
|
@ -71,6 +72,31 @@ impl TicketRef {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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, notify/report readiness to this Orchestrator.\n");
|
||||||
|
out.push_str("- Handoff report fields: created_or_updated_ticket_id_or_slug, readiness, needs_preflight, risk_flags, user_go_required, intake_summary.\n");
|
||||||
|
out.push_str("- Do not start implementation automatically; wait for Orchestrator routing/preflight and human Go gates.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Typed input for constructing a Ticket role launch.
|
/// Typed input for constructing a Ticket role launch.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TicketRoleLaunchContext {
|
pub struct TicketRoleLaunchContext {
|
||||||
|
|
@ -79,6 +105,7 @@ pub struct TicketRoleLaunchContext {
|
||||||
pub pod_name: Option<String>,
|
pub pod_name: Option<String>,
|
||||||
pub ticket: Option<TicketRef>,
|
pub ticket: Option<TicketRef>,
|
||||||
pub user_instruction: Option<String>,
|
pub user_instruction: Option<String>,
|
||||||
|
pub intake_handoff: Option<TicketIntakeHandoff>,
|
||||||
pub intent_packet: Option<String>,
|
pub intent_packet: Option<String>,
|
||||||
pub worktree_path: Option<PathBuf>,
|
pub worktree_path: Option<PathBuf>,
|
||||||
pub branch: Option<String>,
|
pub branch: Option<String>,
|
||||||
|
|
@ -94,6 +121,7 @@ impl TicketRoleLaunchContext {
|
||||||
pod_name: None,
|
pod_name: None,
|
||||||
ticket: None,
|
ticket: None,
|
||||||
user_instruction: None,
|
user_instruction: None,
|
||||||
|
intake_handoff: None,
|
||||||
intent_packet: None,
|
intent_packet: None,
|
||||||
worktree_path: None,
|
worktree_path: None,
|
||||||
branch: None,
|
branch: None,
|
||||||
|
|
@ -145,6 +173,26 @@ impl TicketRoleLaunchPlan {
|
||||||
pub struct TicketRoleLaunchResult {
|
pub struct TicketRoleLaunchResult {
|
||||||
pub plan: TicketRoleLaunchPlan,
|
pub plan: TicketRoleLaunchPlan,
|
||||||
pub ready: SpawnReady,
|
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)]
|
#[derive(Debug, Error)]
|
||||||
|
|
@ -228,6 +276,26 @@ pub async fn launch_ticket_role_pod<F>(
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
progress: F,
|
progress: F,
|
||||||
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
|
) -> 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
|
where
|
||||||
F: FnMut(&str),
|
F: FnMut(&str),
|
||||||
{
|
{
|
||||||
|
|
@ -239,12 +307,95 @@ where
|
||||||
socket_path: ready.socket_path.clone(),
|
socket_path: ready.socket_path.clone(),
|
||||||
source,
|
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
|
client
|
||||||
.send(&plan.run_method())
|
.send(&plan.run_method())
|
||||||
.await
|
.await
|
||||||
.map_err(|source| TicketRoleLaunchError::SendRun { source })?;
|
.map_err(|source| TicketRoleLaunchError::SendRun { source })?;
|
||||||
wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?;
|
Ok(pre_run_warnings)
|
||||||
Ok(TicketRoleLaunchResult { plan, ready })
|
}
|
||||||
|
|
||||||
|
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(
|
async fn wait_for_run_acceptance(
|
||||||
|
|
@ -309,6 +460,10 @@ fn build_launch_prompt(
|
||||||
None => out.push_str("\nUser/action instruction: not specified\n"),
|
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()) {
|
if let Some(intent_packet) = non_empty(context.intent_packet.as_deref()) {
|
||||||
push_bounded_section(&mut out, "Intent packet", intent_packet);
|
push_bounded_section(&mut out, "Intent packet", intent_packet);
|
||||||
}
|
}
|
||||||
|
|
@ -431,7 +586,10 @@ fn non_empty(value: Option<&str>) -> Option<&str> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use protocol::{Greeting, PodStatus};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::UnixListener;
|
||||||
|
|
||||||
fn write_config(workspace: &std::path::Path, content: &str) {
|
fn write_config(workspace: &std::path::Path, content: &str) {
|
||||||
let dir = workspace.join(".yoi");
|
let dir = workspace.join(".yoi");
|
||||||
|
|
@ -446,6 +604,152 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]
|
#[test]
|
||||||
fn default_config_role_launch_plan_uses_defaults() {
|
fn default_config_role_launch_plan_uses_defaults() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
|
|
@ -534,6 +838,20 @@ workflow = "ticket-review-workflow"
|
||||||
assert!(intake_text.contains("Clarify and materialize"));
|
assert!(intake_text.contains("Clarify and materialize"));
|
||||||
assert!(intake_text.contains("Workflow: ticket-intake-workflow"));
|
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("Do not start implementation automatically"));
|
||||||
|
assert!(handoff_text.contains("human Go gates"));
|
||||||
|
|
||||||
let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator);
|
let mut orchestrator = TicketRoleLaunchContext::new(temp.path(), TicketRole::Orchestrator);
|
||||||
orchestrator.ticket = Some(TicketRef::slug("launcher"));
|
orchestrator.ticket = Some(TicketRef::slug("launcher"));
|
||||||
orchestrator.intent_packet = Some("Route to implementation after preflight.".into());
|
orchestrator.intent_packet = Some("Route to implementation after preflight.".into());
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use client::ticket_role::{
|
use client::ticket_role::{
|
||||||
TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchResult,
|
TicketIntakeHandoff, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError,
|
||||||
launch_ticket_role_pod,
|
TicketRoleLaunchOptions, TicketRoleLaunchResult, launch_ticket_role_pod,
|
||||||
|
launch_ticket_role_pod_with_options,
|
||||||
};
|
};
|
||||||
use client::{PodRuntimeCommand, SpawnConfig, spawn_pod};
|
use client::{PodRuntimeCommand, SpawnConfig, spawn_pod};
|
||||||
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
|
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
|
||||||
|
|
@ -147,9 +148,7 @@ pub(crate) async fn run(
|
||||||
MultiPodAction::LaunchIntake(request) => {
|
MultiPodAction::LaunchIntake(request) => {
|
||||||
pending_reload.abort();
|
pending_reload.abort();
|
||||||
terminal.draw(|f| draw(f, app))?;
|
terminal.draw(|f| draw(f, app))?;
|
||||||
let result =
|
let result = launch_intake_with_handoff(request).await;
|
||||||
launch_ticket_role_pod(request.context, request.runtime_command, |_| {})
|
|
||||||
.await;
|
|
||||||
app.finish_intake_launch(result);
|
app.finish_intake_launch(result);
|
||||||
app.reload_or_notice().await;
|
app.reload_or_notice().await;
|
||||||
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
||||||
|
|
@ -259,6 +258,81 @@ pub(crate) struct DirectSendRequest {
|
||||||
pub(crate) struct IntakeLaunchRequest {
|
pub(crate) struct IntakeLaunchRequest {
|
||||||
context: TicketRoleLaunchContext,
|
context: TicketRoleLaunchContext,
|
||||||
runtime_command: PodRuntimeCommand,
|
runtime_command: PodRuntimeCommand,
|
||||||
|
peer_registration: IntakePeerRegistrationRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum IntakePeerRegistrationRequest {
|
||||||
|
Register { orchestrator_pod: String },
|
||||||
|
Skip { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct IntakeLaunchOutcome {
|
||||||
|
launch: TicketRoleLaunchResult,
|
||||||
|
peer_registration: IntakePeerRegistrationStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum IntakePeerRegistrationStatus {
|
||||||
|
Registered { orchestrator_pod: String },
|
||||||
|
Warning { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntakePeerRegistrationStatus {
|
||||||
|
fn warning(message: impl Into<String>) -> Self {
|
||||||
|
Self::Warning {
|
||||||
|
message: bounded_panel_diagnostic(message.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) type IntakeLaunchResult = Result<IntakeLaunchOutcome, TicketRoleLaunchError>;
|
||||||
|
|
||||||
|
pub(crate) async fn launch_intake_with_handoff(request: IntakeLaunchRequest) -> IntakeLaunchResult {
|
||||||
|
let (options, orchestrator_pod, skip_warning) = match request.peer_registration.clone() {
|
||||||
|
IntakePeerRegistrationRequest::Register { orchestrator_pod } => (
|
||||||
|
TicketRoleLaunchOptions::default()
|
||||||
|
.with_pre_run_peer_registration(orchestrator_pod.clone()),
|
||||||
|
Some(orchestrator_pod),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
IntakePeerRegistrationRequest::Skip { reason } => (
|
||||||
|
TicketRoleLaunchOptions::default(),
|
||||||
|
None,
|
||||||
|
Some(IntakePeerRegistrationStatus::warning(format!(
|
||||||
|
"handoff peer registration skipped: {reason}"
|
||||||
|
))),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let launch = launch_ticket_role_pod_with_options(
|
||||||
|
request.context,
|
||||||
|
request.runtime_command,
|
||||||
|
|_| {},
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let peer_registration = match (orchestrator_pod, skip_warning) {
|
||||||
|
(_, Some(warning)) => warning,
|
||||||
|
(Some(orchestrator_pod), None) if launch.pre_run_warnings.is_empty() => {
|
||||||
|
IntakePeerRegistrationStatus::Registered { orchestrator_pod }
|
||||||
|
}
|
||||||
|
(Some(_), None) => IntakePeerRegistrationStatus::warning(
|
||||||
|
launch
|
||||||
|
.pre_run_warnings
|
||||||
|
.iter()
|
||||||
|
.map(|warning| warning.message.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; "),
|
||||||
|
),
|
||||||
|
(None, None) => IntakePeerRegistrationStatus::warning(
|
||||||
|
"handoff peer registration skipped: no Orchestrator target",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
Ok(IntakeLaunchOutcome {
|
||||||
|
launch,
|
||||||
|
peer_registration,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct MultiPodApp {
|
pub(crate) struct MultiPodApp {
|
||||||
|
|
@ -619,25 +693,55 @@ impl MultiPodApp {
|
||||||
let mut context =
|
let mut context =
|
||||||
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
|
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
|
||||||
context.user_instruction = Some(body);
|
context.user_instruction = Some(body);
|
||||||
|
let peer_registration = match self.panel.header.orchestrator.as_ref() {
|
||||||
|
Some(orchestrator) => {
|
||||||
|
context.intake_handoff = Some(TicketIntakeHandoff::new(
|
||||||
|
orchestrator.pod_name.clone(),
|
||||||
|
self.panel.header.workspace_label.clone(),
|
||||||
|
));
|
||||||
|
if orchestrator_status_is_peer_reachable(orchestrator.status) {
|
||||||
|
IntakePeerRegistrationRequest::Register {
|
||||||
|
orchestrator_pod: orchestrator.pod_name.clone(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IntakePeerRegistrationRequest::Skip {
|
||||||
|
reason: format!(
|
||||||
|
"workspace Orchestrator {} is {}; launch input still carries the auditable handoff target",
|
||||||
|
orchestrator.pod_name,
|
||||||
|
orchestrator.status.label()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => IntakePeerRegistrationRequest::Skip {
|
||||||
|
reason: "workspace Orchestrator is not configured for this panel".to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
self.sending = true;
|
self.sending = true;
|
||||||
self.notice = Some("Launching Ticket Intake…".to_string());
|
self.notice = Some("Launching Ticket Intake…".to_string());
|
||||||
Some(IntakeLaunchRequest {
|
Some(IntakeLaunchRequest {
|
||||||
context,
|
context,
|
||||||
runtime_command: self.runtime_command.clone(),
|
runtime_command: self.runtime_command.clone(),
|
||||||
|
peer_registration,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn finish_intake_launch(
|
pub(crate) fn finish_intake_launch(&mut self, result: IntakeLaunchResult) {
|
||||||
&mut self,
|
|
||||||
result: Result<TicketRoleLaunchResult, TicketRoleLaunchError>,
|
|
||||||
) {
|
|
||||||
self.sending = false;
|
self.sending = false;
|
||||||
match result {
|
match result {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let pod_name = result.plan.pod_name;
|
let pod_name = result.launch.plan.pod_name;
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
|
let peer_notice = match result.peer_registration {
|
||||||
|
IntakePeerRegistrationStatus::Registered { orchestrator_pod } => {
|
||||||
|
format!(" Handoff peer registered with {orchestrator_pod}.")
|
||||||
|
}
|
||||||
|
IntakePeerRegistrationStatus::Warning { message } => {
|
||||||
|
format!(" Handoff warning: {message}")
|
||||||
|
}
|
||||||
|
};
|
||||||
self.notice = Some(bounded_panel_diagnostic(format!(
|
self.notice = Some(bounded_panel_diagnostic(format!(
|
||||||
"Launched Ticket Intake Pod {pod_name}."
|
"Launched Ticket Intake Pod {pod_name}.{peer_notice}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
@ -955,6 +1059,15 @@ fn current_workspace_root() -> PathBuf {
|
||||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn orchestrator_status_is_peer_reachable(status: OrchestratorPanelStatus) -> bool {
|
||||||
|
matches!(
|
||||||
|
status,
|
||||||
|
OrchestratorPanelStatus::Live
|
||||||
|
| OrchestratorPanelStatus::Restored
|
||||||
|
| OrchestratorPanelStatus::Spawned
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_exact_pod_presence(pod_name: &str) -> Result<OrchestratorPodPresence, MultiPodError> {
|
async fn load_exact_pod_presence(pod_name: &str) -> Result<OrchestratorPodPresence, MultiPodError> {
|
||||||
let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?;
|
let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?;
|
||||||
Ok(orchestrator_pod_presence(pod_name, &list))
|
Ok(orchestrator_pod_presence(pod_name, &list))
|
||||||
|
|
@ -2421,11 +2534,48 @@ mod tests {
|
||||||
Some("please intake this work")
|
Some("please intake this work")
|
||||||
);
|
);
|
||||||
assert_eq!(request.runtime_command.program(), Path::new("/tmp/yoi"));
|
assert_eq!(request.runtime_command.program(), Path::new("/tmp/yoi"));
|
||||||
|
assert_eq!(
|
||||||
|
request.context.intake_handoff,
|
||||||
|
Some(TicketIntakeHandoff::new("test-orchestrator", "test"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request.peer_registration,
|
||||||
|
IntakePeerRegistrationRequest::Register {
|
||||||
|
orchestrator_pod: "test-orchestrator".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
assert!(app.sending);
|
assert!(app.sending);
|
||||||
assert!(app.notice.as_deref().unwrap().contains("Launching"));
|
assert!(app.notice.as_deref().unwrap().contains("Launching"));
|
||||||
assert_eq!(input_text(&app), "please intake this work");
|
assert_eq!(input_text(&app), "please intake this work");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_ticket_intake_handoff_skips_peer_registration_when_orchestrator_not_live() {
|
||||||
|
let mut app = ticket_enabled_app_with_orchestrator(
|
||||||
|
vec![live_info("idle", PodStatus::Idle)],
|
||||||
|
OrchestratorPanelStatus::Unavailable,
|
||||||
|
);
|
||||||
|
app.cycle_composer_target();
|
||||||
|
app.input.insert_str("please intake this work");
|
||||||
|
|
||||||
|
let request = match app.handle_key(key(KeyCode::Enter)) {
|
||||||
|
MultiPodAction::LaunchIntake(request) => request,
|
||||||
|
_ => panic!("Ticket Intake target should launch Intake"),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request.context.intake_handoff,
|
||||||
|
Some(TicketIntakeHandoff::new("test-orchestrator", "test"))
|
||||||
|
);
|
||||||
|
match request.peer_registration {
|
||||||
|
IntakePeerRegistrationRequest::Skip { reason } => {
|
||||||
|
assert!(reason.contains("test-orchestrator"));
|
||||||
|
assert!(reason.contains("unavailable"));
|
||||||
|
}
|
||||||
|
other => panic!("expected peer registration skip, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multi_ticket_intake_finish_success_clears_composer_and_reports_pod() {
|
fn multi_ticket_intake_finish_success_clears_composer_and_reports_pod() {
|
||||||
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||||
|
|
@ -2433,25 +2583,33 @@ mod tests {
|
||||||
app.input.insert_str("please intake this work");
|
app.input.insert_str("please intake this work");
|
||||||
app.sending = true;
|
app.sending = true;
|
||||||
|
|
||||||
app.finish_intake_launch(Ok(TicketRoleLaunchResult {
|
app.finish_intake_launch(Ok(IntakeLaunchOutcome {
|
||||||
plan: client::ticket_role::TicketRoleLaunchPlan {
|
launch: TicketRoleLaunchResult {
|
||||||
workspace_root: PathBuf::from("/tmp/workspace"),
|
plan: client::ticket_role::TicketRoleLaunchPlan {
|
||||||
role: TicketRole::Intake,
|
workspace_root: PathBuf::from("/tmp/workspace"),
|
||||||
pod_name: "intake-pod".to_string(),
|
role: TicketRole::Intake,
|
||||||
profile: "builtin:default".to_string(),
|
pod_name: "intake-pod".to_string(),
|
||||||
workflow: "ticket-intake-workflow".to_string(),
|
profile: "builtin:default".to_string(),
|
||||||
launch_prompt_ref: None,
|
workflow: "ticket-intake-workflow".to_string(),
|
||||||
run_segments: vec![],
|
launch_prompt_ref: None,
|
||||||
|
run_segments: vec![],
|
||||||
|
},
|
||||||
|
ready: client::SpawnReady {
|
||||||
|
pod_name: "intake-pod".to_string(),
|
||||||
|
socket_path: PathBuf::from("/tmp/intake.sock"),
|
||||||
|
},
|
||||||
|
pre_run_warnings: vec![],
|
||||||
},
|
},
|
||||||
ready: client::SpawnReady {
|
peer_registration: IntakePeerRegistrationStatus::Registered {
|
||||||
pod_name: "intake-pod".to_string(),
|
orchestrator_pod: "test-orchestrator".to_string(),
|
||||||
socket_path: PathBuf::from("/tmp/intake.sock"),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
assert!(!app.sending);
|
assert!(!app.sending);
|
||||||
assert_eq!(input_text(&app), "");
|
assert_eq!(input_text(&app), "");
|
||||||
assert!(app.notice.as_deref().unwrap().contains("intake-pod"));
|
let notice = app.notice.as_deref().unwrap();
|
||||||
|
assert!(notice.contains("intake-pod"));
|
||||||
|
assert!(notice.contains("Handoff peer registered"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -2499,8 +2657,20 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_enabled_app(live: Vec<LivePodInfo>) -> MultiPodApp {
|
fn ticket_enabled_app(live: Vec<LivePodInfo>) -> MultiPodApp {
|
||||||
|
ticket_enabled_app_with_orchestrator(live, OrchestratorPanelStatus::Live)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_enabled_app_with_orchestrator(
|
||||||
|
live: Vec<LivePodInfo>,
|
||||||
|
orchestrator_status: OrchestratorPanelStatus,
|
||||||
|
) -> MultiPodApp {
|
||||||
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
||||||
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
|
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
|
||||||
|
panel.header.orchestrator = Some(OrchestratorPanelState::new(
|
||||||
|
"test-orchestrator",
|
||||||
|
orchestrator_status,
|
||||||
|
None,
|
||||||
|
));
|
||||||
app_with_panel(
|
app_with_panel(
|
||||||
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10),
|
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10),
|
||||||
panel,
|
panel,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user