feat: abstract worker runtime spawn boundary
This commit is contained in:
@@ -16,7 +16,10 @@ pub mod ticket_role;
|
||||
pub use runtime_command::PodRuntimeCommand;
|
||||
|
||||
pub use pod_client::PodClient;
|
||||
pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod};
|
||||
pub use spawn::{
|
||||
PodProcessLaunchConfig, PodProcessLaunchOptions, SpawnConfig, SpawnError, SpawnReady,
|
||||
spawn_pod, spawn_pod_with_options,
|
||||
};
|
||||
pub use ticket_role::{
|
||||
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
|
||||
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning, launch_ticket_role_pod,
|
||||
|
||||
+51
-18
@@ -23,7 +23,7 @@ const READY_PREFIX: &str = "YOI-READY\t";
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SpawnConfig {
|
||||
pub struct PodProcessLaunchConfig {
|
||||
pub runtime_command: PodRuntimeCommand,
|
||||
/// `pod.name` として使う識別子。runtime ディレクトリ
|
||||
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
|
||||
@@ -32,9 +32,6 @@ pub struct SpawnConfig {
|
||||
/// Optional reusable Profile selector. Pod identity is always supplied
|
||||
/// separately with `--pod`; profile selection must not imply a name.
|
||||
pub profile: Option<String>,
|
||||
/// Process-local Ticket role marker supplied only by Ticket role launches.
|
||||
/// This does not alter prompts, manifests, or Ticket claim records.
|
||||
pub ticket_role: Option<String>,
|
||||
/// Explicit runtime workspace root. The child receives it via
|
||||
/// `--workspace` so startup does not infer workspace identity from the
|
||||
/// parent process cwd.
|
||||
@@ -48,6 +45,28 @@ pub struct SpawnConfig {
|
||||
pub resume_from: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PodProcessLaunchOptions {
|
||||
/// Extra child CLI arguments supplied by an upper resolver layer. The
|
||||
/// low-level launch config intentionally does not model Ticket IDs,
|
||||
/// Ticket roles, orchestration roles, executable authority, or raw
|
||||
/// browser-provided profile/cwd/workspace inputs.
|
||||
pub extra_args: Vec<String>,
|
||||
}
|
||||
|
||||
impl PodProcessLaunchOptions {
|
||||
pub fn with_hidden_arg(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.extra_args.extend([name.into(), value.into()]);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.extra_args.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub type SpawnConfig = PodProcessLaunchConfig;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SpawnReady {
|
||||
pub pod_name: String,
|
||||
@@ -112,7 +131,7 @@ impl From<io::Error> for SpawnError {
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_args(config: &SpawnConfig) -> Vec<String> {
|
||||
fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptions) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"--workspace".to_string(),
|
||||
config.workspace_root.display().to_string(),
|
||||
@@ -130,9 +149,7 @@ fn runtime_args(config: &SpawnConfig) -> Vec<String> {
|
||||
args.extend(["--profile".to_string(), profile.clone()]);
|
||||
}
|
||||
}
|
||||
if let Some(ticket_role) = &config.ticket_role {
|
||||
args.extend(["--ticket-role".to_string(), ticket_role.clone()]);
|
||||
}
|
||||
args.extend(options.extra_args.clone());
|
||||
args
|
||||
}
|
||||
|
||||
@@ -140,7 +157,21 @@ fn runtime_args(config: &SpawnConfig) -> Vec<String> {
|
||||
///
|
||||
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
|
||||
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
|
||||
pub async fn spawn_pod<F>(config: SpawnConfig, mut progress: F) -> Result<SpawnReady, SpawnError>
|
||||
pub async fn spawn_pod<F>(
|
||||
config: PodProcessLaunchConfig,
|
||||
progress: F,
|
||||
) -> Result<SpawnReady, SpawnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
spawn_pod_with_options(config, PodProcessLaunchOptions::default(), progress).await
|
||||
}
|
||||
|
||||
pub async fn spawn_pod_with_options<F>(
|
||||
config: PodProcessLaunchConfig,
|
||||
options: PodProcessLaunchOptions,
|
||||
mut progress: F,
|
||||
) -> Result<SpawnReady, SpawnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
@@ -158,7 +189,7 @@ where
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.process_group(0);
|
||||
for arg in runtime_args(&config) {
|
||||
for arg in runtime_args(&config, &options) {
|
||||
command.arg(arg);
|
||||
}
|
||||
let mut child = command
|
||||
@@ -332,12 +363,11 @@ mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn base_config() -> SpawnConfig {
|
||||
SpawnConfig {
|
||||
fn base_config() -> PodProcessLaunchConfig {
|
||||
PodProcessLaunchConfig {
|
||||
runtime_command: PodRuntimeCommand::new("/bin/yoi", vec![OsString::from("pod")]),
|
||||
pod_name: "explicit-pod".to_string(),
|
||||
profile: Some("project:companion".to_string()),
|
||||
ticket_role: None,
|
||||
workspace_root: PathBuf::from("/work/other-project"),
|
||||
cwd: None,
|
||||
resume_from: None,
|
||||
@@ -347,7 +377,7 @@ mod tests {
|
||||
#[test]
|
||||
fn runtime_args_keep_workspace_pod_and_profile_separate() {
|
||||
assert_eq!(
|
||||
runtime_args(&base_config()),
|
||||
runtime_args(&base_config(), &PodProcessLaunchOptions::default()),
|
||||
vec![
|
||||
"--workspace",
|
||||
"/work/other-project",
|
||||
@@ -364,7 +394,7 @@ mod tests {
|
||||
let mut config = base_config();
|
||||
config.resume_from = Some(Uuid::nil());
|
||||
assert_eq!(
|
||||
runtime_args(&config),
|
||||
runtime_args(&config, &PodProcessLaunchOptions::default()),
|
||||
vec![
|
||||
"--workspace",
|
||||
"/work/other-project",
|
||||
@@ -377,13 +407,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_args_do_not_include_child_cwd() {
|
||||
fn runtime_args_include_upper_resolver_extra_args_without_child_cwd() {
|
||||
let mut config = base_config();
|
||||
config.ticket_role = Some("orchestrator".to_string());
|
||||
config.cwd = Some(PathBuf::from("/work/main/.worktree/orchestration/yoi"));
|
||||
|
||||
assert_eq!(
|
||||
runtime_args(&config),
|
||||
runtime_args(
|
||||
&config,
|
||||
&PodProcessLaunchOptions::default()
|
||||
.with_hidden_arg("--ticket-role", "orchestrator"),
|
||||
),
|
||||
vec![
|
||||
"--workspace",
|
||||
"/work/other-project",
|
||||
|
||||
@@ -14,7 +14,10 @@ use thiserror::Error;
|
||||
pub use ticket::config::TicketRole;
|
||||
use ticket::config::{TicketConfig, TicketConfigError, TicketRoleLaunchConfigError};
|
||||
|
||||
use crate::{PodClient, PodRuntimeCommand, SpawnConfig, SpawnError, SpawnReady, spawn_pod};
|
||||
use crate::{
|
||||
PodClient, PodProcessLaunchConfig, PodProcessLaunchOptions, PodRuntimeCommand, SpawnError,
|
||||
SpawnReady, spawn_pod_with_options,
|
||||
};
|
||||
|
||||
const MAX_FIELD_CHARS: usize = 8_000;
|
||||
const MAX_POD_NAME_CHARS: usize = 80;
|
||||
@@ -170,20 +173,24 @@ impl TicketRoleLaunchPlan {
|
||||
pub fn spawn_config(
|
||||
&self,
|
||||
runtime_command: PodRuntimeCommand,
|
||||
) -> Result<SpawnConfig, TicketRoleLaunchError> {
|
||||
) -> Result<PodProcessLaunchConfig, TicketRoleLaunchError> {
|
||||
if self.profile == "inherit" {
|
||||
return Err(TicketRoleLaunchError::UnsupportedInheritProfile);
|
||||
}
|
||||
Ok(SpawnConfig {
|
||||
Ok(PodProcessLaunchConfig {
|
||||
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(),
|
||||
cwd: self.cwd.clone(),
|
||||
resume_from: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn spawn_options(&self) -> PodProcessLaunchOptions {
|
||||
PodProcessLaunchOptions::default()
|
||||
.with_hidden_arg("--ticket-role", self.role.as_str().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of executing a Ticket role launch.
|
||||
@@ -191,9 +198,28 @@ impl TicketRoleLaunchPlan {
|
||||
pub struct TicketRoleLaunchResult {
|
||||
pub plan: TicketRoleLaunchPlan,
|
||||
pub ready: SpawnReady,
|
||||
/// Evidence that the spawned worker accepted the initial Run request.
|
||||
/// This is intentionally distinct from process readiness: a socket
|
||||
/// snapshot only proves that the runtime is reachable, not that the
|
||||
/// worker operation was durably queued/started.
|
||||
pub acceptance_evidence: TicketRoleLaunchAcceptanceEvidence,
|
||||
pub pre_run_warnings: Vec<TicketRolePreRunWarning>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketRoleLaunchAcceptanceEvidence {
|
||||
pub pod_name: String,
|
||||
pub accepted_run_segments: usize,
|
||||
pub event: TicketRoleLaunchAcceptanceEvent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TicketRoleLaunchAcceptanceEvent {
|
||||
UserMessage,
|
||||
UserSendInvokeStart,
|
||||
TurnStart,
|
||||
}
|
||||
|
||||
/// Non-fatal diagnostic produced by bounded pre-run launch actions.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TicketRolePreRunWarning {
|
||||
@@ -369,7 +395,9 @@ where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let plan = plan_ticket_role_launch(context)?;
|
||||
let ready = spawn_pod(plan.spawn_config(runtime_command)?, progress).await?;
|
||||
let spawn_config = plan.spawn_config(runtime_command)?;
|
||||
let spawn_options = plan.spawn_options();
|
||||
let ready = spawn_pod_with_options(spawn_config, spawn_options, progress).await?;
|
||||
let mut client = PodClient::connect(&ready.socket_path)
|
||||
.await
|
||||
.map_err(|source| TicketRoleLaunchError::Connect {
|
||||
@@ -377,10 +405,17 @@ where
|
||||
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?;
|
||||
let acceptance_event =
|
||||
wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?;
|
||||
let acceptance_evidence = TicketRoleLaunchAcceptanceEvidence {
|
||||
pod_name: ready.pod_name.clone(),
|
||||
accepted_run_segments: plan.run_segments.len(),
|
||||
event: acceptance_event,
|
||||
};
|
||||
Ok(TicketRoleLaunchResult {
|
||||
plan,
|
||||
ready,
|
||||
acceptance_evidence,
|
||||
pre_run_warnings,
|
||||
})
|
||||
}
|
||||
@@ -471,18 +506,20 @@ async fn wait_for_run_acceptance(
|
||||
client: &mut PodClient,
|
||||
expected_segments: &[Segment],
|
||||
timeout: Duration,
|
||||
) -> Result<(), TicketRoleLaunchError> {
|
||||
) -> Result<TicketRoleLaunchAcceptanceEvent, 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::UserMessage { segments } if segments == expected_segments => {
|
||||
return Ok(TicketRoleLaunchAcceptanceEvent::UserMessage);
|
||||
}
|
||||
Event::InvokeStart {
|
||||
kind: InvokeKind::UserSend,
|
||||
}
|
||||
| Event::TurnStart { .. } => return Ok(()),
|
||||
} => return Ok(TicketRoleLaunchAcceptanceEvent::UserSendInvokeStart),
|
||||
Event::TurnStart { .. } => return Ok(TicketRoleLaunchAcceptanceEvent::TurnStart),
|
||||
Event::Error { code, message } => {
|
||||
return Err(TicketRoleLaunchError::RunRejected { code, message });
|
||||
}
|
||||
@@ -1026,8 +1063,12 @@ workflow = "ticket-review-workflow"
|
||||
.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());
|
||||
assert!(spawn.cwd.is_none());
|
||||
assert_eq!(
|
||||
plan.spawn_options().extra_args,
|
||||
vec!["--ticket-role".to_string(), "reviewer".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user