merge: ticket intake orchestrator handoff
This commit is contained in:
commit
aaf8391a70
|
|
@ -18,7 +18,8 @@ pub use runtime_command::PodRuntimeCommand;
|
|||
pub use pod_client::PodClient;
|
||||
pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod};
|
||||
pub use ticket_role::{
|
||||
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchPlan,
|
||||
TicketRoleLaunchResult, launch_ticket_role_pod, plan_ticket_role_launch,
|
||||
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
|
||||
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning, launch_ticket_role_pod,
|
||||
launch_ticket_role_pod_with_options, plan_ticket_role_launch,
|
||||
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_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)]
|
||||
|
|
@ -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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketRoleLaunchContext {
|
||||
|
|
@ -79,6 +105,7 @@ pub struct TicketRoleLaunchContext {
|
|||
pub pod_name: Option<String>,
|
||||
pub ticket: Option<TicketRef>,
|
||||
pub user_instruction: Option<String>,
|
||||
pub intake_handoff: Option<TicketIntakeHandoff>,
|
||||
pub intent_packet: Option<String>,
|
||||
pub worktree_path: Option<PathBuf>,
|
||||
pub branch: Option<String>,
|
||||
|
|
@ -94,6 +121,7 @@ impl TicketRoleLaunchContext {
|
|||
pod_name: None,
|
||||
ticket: None,
|
||||
user_instruction: None,
|
||||
intake_handoff: None,
|
||||
intent_packet: None,
|
||||
worktree_path: None,
|
||||
branch: None,
|
||||
|
|
@ -145,6 +173,26 @@ impl TicketRoleLaunchPlan {
|
|||
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)]
|
||||
|
|
@ -228,6 +276,26 @@ pub async fn launch_ticket_role_pod<F>(
|
|||
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),
|
||||
{
|
||||
|
|
@ -239,12 +307,95 @@ where
|
|||
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 })?;
|
||||
wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?;
|
||||
Ok(TicketRoleLaunchResult { plan, ready })
|
||||
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(
|
||||
|
|
@ -309,6 +460,10 @@ fn build_launch_prompt(
|
|||
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);
|
||||
}
|
||||
|
|
@ -431,7 +586,10 @@ fn non_empty(value: Option<&str>) -> Option<&str> {
|
|||
#[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");
|
||||
|
|
@ -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]
|
||||
fn default_config_role_launch_plan_uses_defaults() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
|
|
@ -534,6 +838,20 @@ workflow = "ticket-review-workflow"
|
|||
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("Do not start implementation automatically"));
|
||||
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 preflight.".into());
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ use std::path::{Path, PathBuf};
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
use client::ticket_role::{
|
||||
TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchResult,
|
||||
launch_ticket_role_pod,
|
||||
TicketIntakeHandoff, TicketRole, TicketRoleLaunchContext, TicketRoleLaunchError,
|
||||
TicketRoleLaunchOptions, TicketRoleLaunchResult, launch_ticket_role_pod,
|
||||
launch_ticket_role_pod_with_options,
|
||||
};
|
||||
use client::{PodRuntimeCommand, SpawnConfig, spawn_pod};
|
||||
use crossterm::event::{Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, poll, read};
|
||||
|
|
@ -147,9 +148,7 @@ pub(crate) async fn run(
|
|||
MultiPodAction::LaunchIntake(request) => {
|
||||
pending_reload.abort();
|
||||
terminal.draw(|f| draw(f, app))?;
|
||||
let result =
|
||||
launch_ticket_role_pod(request.context, request.runtime_command, |_| {})
|
||||
.await;
|
||||
let result = launch_intake_with_handoff(request).await;
|
||||
app.finish_intake_launch(result);
|
||||
app.reload_or_notice().await;
|
||||
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
|
||||
|
|
@ -259,6 +258,81 @@ pub(crate) struct DirectSendRequest {
|
|||
pub(crate) struct IntakeLaunchRequest {
|
||||
context: TicketRoleLaunchContext,
|
||||
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 {
|
||||
|
|
@ -619,25 +693,55 @@ impl MultiPodApp {
|
|||
let mut context =
|
||||
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
|
||||
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.notice = Some("Launching Ticket Intake…".to_string());
|
||||
Some(IntakeLaunchRequest {
|
||||
context,
|
||||
runtime_command: self.runtime_command.clone(),
|
||||
peer_registration,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn finish_intake_launch(
|
||||
&mut self,
|
||||
result: Result<TicketRoleLaunchResult, TicketRoleLaunchError>,
|
||||
) {
|
||||
pub(crate) fn finish_intake_launch(&mut self, result: IntakeLaunchResult) {
|
||||
self.sending = false;
|
||||
match result {
|
||||
Ok(result) => {
|
||||
let pod_name = result.plan.pod_name;
|
||||
let pod_name = result.launch.plan.pod_name;
|
||||
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!(
|
||||
"Launched Ticket Intake Pod {pod_name}."
|
||||
"Launched Ticket Intake Pod {pod_name}.{peer_notice}"
|
||||
)));
|
||||
}
|
||||
Err(error) => {
|
||||
|
|
@ -955,6 +1059,15 @@ fn current_workspace_root() -> PathBuf {
|
|||
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> {
|
||||
let list = load_pod_list(Some(pod_name.to_string()), usize::MAX).await?;
|
||||
Ok(orchestrator_pod_presence(pod_name, &list))
|
||||
|
|
@ -2421,11 +2534,48 @@ mod tests {
|
|||
Some("please intake this work")
|
||||
);
|
||||
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.notice.as_deref().unwrap().contains("Launching"));
|
||||
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]
|
||||
fn multi_ticket_intake_finish_success_clears_composer_and_reports_pod() {
|
||||
let mut app = ticket_enabled_app(vec![live_info("idle", PodStatus::Idle)]);
|
||||
|
|
@ -2433,7 +2583,8 @@ mod tests {
|
|||
app.input.insert_str("please intake this work");
|
||||
app.sending = true;
|
||||
|
||||
app.finish_intake_launch(Ok(TicketRoleLaunchResult {
|
||||
app.finish_intake_launch(Ok(IntakeLaunchOutcome {
|
||||
launch: TicketRoleLaunchResult {
|
||||
plan: client::ticket_role::TicketRoleLaunchPlan {
|
||||
workspace_root: PathBuf::from("/tmp/workspace"),
|
||||
role: TicketRole::Intake,
|
||||
|
|
@ -2447,11 +2598,18 @@ mod tests {
|
|||
pod_name: "intake-pod".to_string(),
|
||||
socket_path: PathBuf::from("/tmp/intake.sock"),
|
||||
},
|
||||
pre_run_warnings: vec![],
|
||||
},
|
||||
peer_registration: IntakePeerRegistrationStatus::Registered {
|
||||
orchestrator_pod: "test-orchestrator".to_string(),
|
||||
},
|
||||
}));
|
||||
|
||||
assert!(!app.sending);
|
||||
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]
|
||||
|
|
@ -2499,8 +2657,20 @@ mod tests {
|
|||
}
|
||||
|
||||
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"));
|
||||
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
|
||||
panel.header.orchestrator = Some(OrchestratorPanelState::new(
|
||||
"test-orchestrator",
|
||||
orchestrator_status,
|
||||
None,
|
||||
));
|
||||
app_with_panel(
|
||||
PodList::from_sources(PodVisibilitySource::ResumePicker, vec![], live, None, 10),
|
||||
panel,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user