refactor: rename pod crate to worker

This commit is contained in:
2026-06-26 00:05:57 +09:00
parent 4c677640f4
commit 6c59fe927b
194 changed files with 6637 additions and 6146 deletions
+11 -11
View File
@@ -1,28 +1,28 @@
//! Pod プロトコルを喋るクライアント。
//! Worker プロトコルを喋るクライアント。
//!
//! - [`PodClient`]: 既存 pod の Unix ソケットへ接続して `Method` を送り、
//! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、
//! `Event` を受け取る低レベル接続。
//! - [`spawn`]: pod バイナリをサブプロセスとして起動し、`YOI-READY`
//! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY`
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
//! ない呼び出し側 (=既存 pod に attach する場合) は使わなくてよい。
//! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。
//!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
mod pod_client;
pub mod runtime_command;
pub mod spawn;
pub mod ticket_role;
mod worker_client;
pub use runtime_command::PodRuntimeCommand;
pub use runtime_command::WorkerRuntimeCommand;
pub use pod_client::PodClient;
pub use spawn::{
PodProcessLaunchConfig, PodProcessLaunchOptions, SpawnConfig, SpawnError, SpawnReady,
spawn_pod, spawn_pod_with_options,
SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
spawn_worker, spawn_worker_with_options,
};
pub use ticket_role::{
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning, launch_ticket_role_pod,
launch_ticket_role_pod_with_options, plan_ticket_role_launch,
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning,
launch_ticket_role_worker, launch_ticket_role_worker_with_options, plan_ticket_role_launch,
plan_ticket_role_launch_with_config,
};
pub use worker_client::WorkerClient;
+26 -26
View File
@@ -6,12 +6,12 @@ use std::path::{Path, PathBuf};
const POD_RUNTIME_COMMAND_ENV: &str = "YOI_POD_RUNTIME_COMMAND";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PodRuntimeCommand {
pub struct WorkerRuntimeCommand {
pub program: PathBuf,
pub prefix_args: Vec<OsString>,
}
impl PodRuntimeCommand {
impl WorkerRuntimeCommand {
pub fn new(program: impl Into<PathBuf>, prefix_args: Vec<OsString>) -> Self {
Self {
program: program.into(),
@@ -24,15 +24,15 @@ impl PodRuntimeCommand {
}
pub fn for_executable(program: impl Into<PathBuf>) -> Self {
Self::new(program, vec![OsString::from("pod")])
Self::new(program, vec![OsString::from("worker")])
}
/// Resolve the Pod runtime command used for subprocess launches.
/// Resolve the Worker runtime command used for subprocess launches.
///
/// The default launch path is always the current `yoi` executable plus
/// the unified `pod` prefix argument. During development, a non-empty
/// the unified `worker` prefix argument. During development, a non-empty
/// `YOI_POD_RUNTIME_COMMAND` value replaces only the executable path;
/// the `pod` prefix is still added here and the env value is not parsed as a
/// the `worker` prefix is still added here and the env value is not parsed as a
/// shell command.
pub fn resolve() -> io::Result<Self> {
Self::resolve_from_env_value(
@@ -74,7 +74,7 @@ impl PodRuntimeCommand {
}
}
impl fmt::Display for PodRuntimeCommand {
impl fmt::Display for WorkerRuntimeCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.program.display())?;
for arg in &self.prefix_args {
@@ -89,14 +89,14 @@ mod tests {
use super::*;
#[test]
fn yoi_binary_defaults_to_pod_prefix() {
let command = PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi");
fn yoi_binary_defaults_to_worker_prefix() {
let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi");
assert_eq!(command.program(), Path::new("/opt/yoi/bin/yoi"));
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
assert_eq!(
command.argv_with(["--pod", "agent"]),
vec!["pod", "--pod", "agent"]
command.argv_with(["--worker", "agent"]),
vec!["worker", "--worker", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
@@ -104,14 +104,14 @@ mod tests {
}
#[test]
fn any_runtime_executable_gets_pod_prefix() {
let command = PodRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime");
fn any_runtime_executable_gets_worker_prefix() {
let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime");
assert_eq!(command.program(), Path::new("/opt/yoi/bin/custom-runtime"));
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
assert_eq!(
command.argv_with(["--pod", "agent"]),
vec!["pod", "--pod", "agent"]
command.argv_with(["--worker", "agent"]),
vec!["worker", "--worker", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
@@ -120,43 +120,43 @@ mod tests {
#[test]
fn resolve_uses_current_exe_when_override_is_unset() {
let command = PodRuntimeCommand::resolve_from_env_value(None, || {
let command = WorkerRuntimeCommand::resolve_from_env_value(None, || {
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
})
.unwrap();
assert_eq!(
command,
PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
);
}
#[test]
fn resolve_uses_current_exe_when_override_is_empty() {
let command = PodRuntimeCommand::resolve_from_env_value(Some(OsString::new()), || {
let command = WorkerRuntimeCommand::resolve_from_env_value(Some(OsString::new()), || {
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
})
.unwrap();
assert_eq!(
command,
PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
);
}
#[test]
fn resolve_override_replaces_only_program_and_keeps_pod_prefix() {
let command = PodRuntimeCommand::resolve_from_env_value(
fn resolve_override_replaces_only_program_and_keeps_worker_prefix() {
let command = WorkerRuntimeCommand::resolve_from_env_value(
Some(OsString::from("/tmp/rebuilt yoi")),
|| panic!("override must not inspect current_exe"),
)
.unwrap();
assert_eq!(command.program(), Path::new("/tmp/rebuilt yoi"));
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
assert_eq!(
command.argv_with(["--pod", "agent"]),
vec!["pod", "--pod", "agent"]
command.argv_with(["--worker", "agent"]),
vec!["worker", "--worker", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
+64 -61
View File
@@ -1,13 +1,13 @@
//! Pod runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ
//! Worker runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ
//! ハンドシェイク。
//!
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
//! 指定してこの関数に渡す。pod はそれを受けて socket を bind し、stderr に
//! 指定してこの関数に渡す。worker はそれを受けて socket を bind し、stderr に
//! `YOI-READY\t<name>\t<socket>` を吐く。
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
//! UI の進捗表示や E2E のログ収集はここで賄う。
//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス
//! ライフサイクルから切り離した detached pod を作る。ready 後の lifecycle
//! ライフサイクルから切り離した detached worker を作る。ready 後の lifecycle
//! 管理は runtime ディレクトリ / socket を介して行う。
use std::io;
@@ -15,7 +15,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use crate::PodRuntimeCommand;
use crate::WorkerRuntimeCommand;
use tokio::process::Command;
use uuid::Uuid;
@@ -23,14 +23,14 @@ const READY_PREFIX: &str = "YOI-READY\t";
const READY_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PodProcessLaunchConfig {
pub runtime_command: PodRuntimeCommand,
/// `pod.name` として使う識別子。runtime ディレクトリ
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
pub struct WorkerProcessLaunchConfig {
pub runtime_command: WorkerRuntimeCommand,
/// `worker.name` として使う識別子。runtime ディレクトリ
/// (`manifest::paths::worker_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。
pub pod_name: String,
/// Optional reusable Profile selector. Pod identity is always supplied
/// separately with `--pod`; profile selection must not imply a name.
pub worker_name: String,
/// Optional reusable Profile selector. Worker identity is always supplied
/// separately with `--worker`; profile selection must not imply a name.
pub profile: Option<String>,
/// Explicit runtime workspace root. The child receives it via
/// `--workspace` so startup does not infer workspace identity from the
@@ -46,7 +46,7 @@ pub struct PodProcessLaunchConfig {
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PodProcessLaunchOptions {
pub struct WorkerProcessLaunchOptions {
/// 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
@@ -54,7 +54,7 @@ pub struct PodProcessLaunchOptions {
pub extra_args: Vec<String>,
}
impl PodProcessLaunchOptions {
impl WorkerProcessLaunchOptions {
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
@@ -65,11 +65,11 @@ impl PodProcessLaunchOptions {
}
}
pub type SpawnConfig = PodProcessLaunchConfig;
pub type SpawnConfig = WorkerProcessLaunchConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnReady {
pub pod_name: String,
pub worker_name: String,
pub socket_path: PathBuf,
}
@@ -79,10 +79,10 @@ pub enum SpawnError {
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。
RuntimeDirUnavailable,
PodLaunchFailed {
command: PodRuntimeCommand,
command: WorkerRuntimeCommand,
source: io::Error,
},
PodExitedEarly {
WorkerExitedEarly {
stderr_tail: String,
},
Timeout,
@@ -98,18 +98,18 @@ impl std::fmt::Display for SpawnError {
),
Self::PodLaunchFailed { command, source } => write!(
f,
"failed to launch pod runtime command `{command}`: {source}"
"failed to launch worker runtime command `{command}`: {source}"
),
Self::PodExitedEarly { stderr_tail } => {
Self::WorkerExitedEarly { stderr_tail } => {
if stderr_tail.is_empty() {
write!(f, "pod exited before becoming ready")
write!(f, "worker exited before becoming ready")
} else {
write!(f, "pod exited before becoming ready: {stderr_tail}")
write!(f, "worker exited before becoming ready: {stderr_tail}")
}
}
Self::Timeout => write!(
f,
"pod did not become ready within {}s",
"worker did not become ready within {}s",
READY_TIMEOUT.as_secs()
),
}
@@ -120,7 +120,7 @@ impl std::error::Error for SpawnError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) | Self::PodLaunchFailed { source: error, .. } => Some(error),
Self::RuntimeDirUnavailable | Self::PodExitedEarly { .. } | Self::Timeout => None,
Self::RuntimeDirUnavailable | Self::WorkerExitedEarly { .. } | Self::Timeout => None,
}
}
}
@@ -131,7 +131,10 @@ impl From<io::Error> for SpawnError {
}
}
fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptions) -> Vec<String> {
fn runtime_args(
config: &WorkerProcessLaunchConfig,
options: &WorkerProcessLaunchOptions,
) -> Vec<String> {
let mut args = vec![
"--workspace".to_string(),
config.workspace_root.display().to_string(),
@@ -140,11 +143,11 @@ fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptio
args.extend([
"--session".to_string(),
id.to_string(),
"--pod".to_string(),
config.pod_name.clone(),
"--worker".to_string(),
config.worker_name.clone(),
]);
} else {
args.extend(["--pod".to_string(), config.pod_name.clone()]);
args.extend(["--worker".to_string(), config.worker_name.clone()]);
if let Some(profile) = &config.profile {
args.extend(["--profile".to_string(), profile.clone()]);
}
@@ -153,32 +156,32 @@ fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptio
args
}
/// pod を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。
/// worker を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。
///
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
pub async fn spawn_pod<F>(
config: PodProcessLaunchConfig,
pub async fn spawn_worker<F>(
config: WorkerProcessLaunchConfig,
progress: F,
) -> Result<SpawnReady, SpawnError>
where
F: FnMut(&str),
{
spawn_pod_with_options(config, PodProcessLaunchOptions::default(), progress).await
spawn_worker_with_options(config, WorkerProcessLaunchOptions::default(), progress).await
}
pub async fn spawn_pod_with_options<F>(
config: PodProcessLaunchConfig,
options: PodProcessLaunchOptions,
pub async fn spawn_worker_with_options<F>(
config: WorkerProcessLaunchConfig,
options: WorkerProcessLaunchOptions,
mut progress: F,
) -> Result<SpawnReady, SpawnError>
where
F: FnMut(&str),
{
let pod_runtime_dir = manifest::paths::pod_runtime_dir(&config.pod_name)
let worker_runtime_dir = manifest::paths::worker_runtime_dir(&config.worker_name)
.ok_or(SpawnError::RuntimeDirUnavailable)?;
std::fs::create_dir_all(&pod_runtime_dir).map_err(SpawnError::Io)?;
let stderr_path = pod_runtime_dir.join("stderr.log");
std::fs::create_dir_all(&worker_runtime_dir).map_err(SpawnError::Io)?;
let stderr_path = worker_runtime_dir.join("stderr.log");
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
let mut command = Command::new(config.runtime_command.program());
@@ -200,9 +203,9 @@ where
})?;
// Default `kill_on_drop = false` plus `process_group(0)` makes this
// a detached Pod once startup succeeds: dropping the handle does not
// a detached Worker once startup succeeds: dropping the handle does not
// terminate it, and terminal-generated signals for the parent's
// process group do not hit the Pod. Runtime state/socket files are
// process group do not hit the Worker. Runtime state/socket files are
// the source of truth after that point.
let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await {
Ok(ready) => ready,
@@ -240,10 +243,10 @@ where
for line in content[offset..].lines() {
if let Some(rest) = line.strip_prefix(READY_PREFIX) {
let mut parts = rest.splitn(2, '\t');
let pod_name = parts.next().unwrap_or("").to_string();
let worker_name = parts.next().unwrap_or("").to_string();
let socket_str = parts.next().unwrap_or("").to_string();
if pod_name.is_empty() || socket_str.is_empty() {
return Err(SpawnError::PodExitedEarly {
if worker_name.is_empty() || socket_str.is_empty() {
return Err(SpawnError::WorkerExitedEarly {
stderr_tail: format!("malformed ready line: {line}"),
});
}
@@ -258,7 +261,7 @@ where
)
.await?;
return Ok(SpawnReady {
pod_name,
worker_name,
socket_path,
});
}
@@ -274,11 +277,11 @@ where
tokio::select! {
status = child.wait() => {
let _ = status;
// Pod は exit 直前に最終 stderr 行を flush することがある。
// Worker は exit 直前に最終 stderr 行を flush することがある。
// child.wait() が解決した後に再読みして、原因行を取りこ
// ぼさず PodExitedEarly に載せる。
// ぼさず WorkerExitedEarly に載せる。
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
return Err(SpawnError::PodExitedEarly {
return Err(SpawnError::WorkerExitedEarly {
stderr_tail: tail.into_string(),
});
}
@@ -310,7 +313,7 @@ async fn wait_for_socket(
status = child.wait() => {
let _ = status;
drain_stderr_into_tail(stderr_path, tail, offset).await;
return Err(SpawnError::PodExitedEarly {
return Err(SpawnError::WorkerExitedEarly {
stderr_tail: tail.as_string(),
});
}
@@ -363,10 +366,10 @@ mod tests {
use super::*;
use std::ffi::OsString;
fn base_config() -> PodProcessLaunchConfig {
PodProcessLaunchConfig {
runtime_command: PodRuntimeCommand::new("/bin/yoi", vec![OsString::from("pod")]),
pod_name: "explicit-pod".to_string(),
fn base_config() -> WorkerProcessLaunchConfig {
WorkerProcessLaunchConfig {
runtime_command: WorkerRuntimeCommand::new("/bin/yoi", vec![OsString::from("worker")]),
worker_name: "explicit-worker".to_string(),
profile: Some("project:companion".to_string()),
workspace_root: PathBuf::from("/work/other-project"),
cwd: None,
@@ -375,14 +378,14 @@ mod tests {
}
#[test]
fn runtime_args_keep_workspace_pod_and_profile_separate() {
fn runtime_args_keep_workspace_worker_and_profile_separate() {
assert_eq!(
runtime_args(&base_config(), &PodProcessLaunchOptions::default()),
runtime_args(&base_config(), &WorkerProcessLaunchOptions::default()),
vec![
"--workspace",
"/work/other-project",
"--pod",
"explicit-pod",
"--worker",
"explicit-worker",
"--profile",
"project:companion",
]
@@ -394,14 +397,14 @@ mod tests {
let mut config = base_config();
config.resume_from = Some(Uuid::nil());
assert_eq!(
runtime_args(&config, &PodProcessLaunchOptions::default()),
runtime_args(&config, &WorkerProcessLaunchOptions::default()),
vec![
"--workspace",
"/work/other-project",
"--session",
"00000000-0000-0000-0000-000000000000",
"--pod",
"explicit-pod",
"--worker",
"explicit-worker",
]
);
}
@@ -414,14 +417,14 @@ mod tests {
assert_eq!(
runtime_args(
&config,
&PodProcessLaunchOptions::default()
&WorkerProcessLaunchOptions::default()
.with_hidden_arg("--ticket-role", "orchestrator"),
),
vec![
"--workspace",
"/work/other-project",
"--pod",
"explicit-pod",
"--worker",
"explicit-worker",
"--profile",
"project:companion",
"--ticket-role",
+98 -83
View File
@@ -1,8 +1,8 @@
//! Ticket-role Pod launch planning and execution.
//! Ticket-role Worker 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.
//! host-side Worker spawning behind the `client` crate so UI callers do not need to
//! depend on `worker` internals.
use std::io;
use std::path::{Path, PathBuf};
@@ -15,8 +15,8 @@ pub use ticket::config::TicketRole;
use ticket::config::{TicketConfig, TicketConfigError, TicketRoleLaunchConfigError};
use crate::{
PodClient, PodProcessLaunchConfig, PodProcessLaunchOptions, PodRuntimeCommand, SpawnError,
SpawnReady, spawn_pod_with_options,
SpawnError, SpawnReady, WorkerClient, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
WorkerRuntimeCommand, spawn_worker_with_options,
};
const MAX_FIELD_CHARS: usize = 8_000;
@@ -37,7 +37,7 @@ impl TicketRef {
}
}
fn pod_name_seed(&self) -> Option<&str> {
fn worker_name_seed(&self) -> Option<&str> {
non_empty(self.id.as_deref())
}
@@ -55,14 +55,17 @@ 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_orchestrator_worker: String,
pub workspace_label: String,
}
impl TicketIntakeHandoff {
pub fn new(orchestrator_pod: impl Into<String>, workspace_label: impl Into<String>) -> Self {
pub fn new(
workspace_orchestrator_worker: impl Into<String>,
workspace_label: impl Into<String>,
) -> Self {
Self {
orchestrator_pod: orchestrator_pod.into(),
workspace_orchestrator_worker: workspace_orchestrator_worker.into(),
workspace_label: workspace_label.into(),
}
}
@@ -70,7 +73,11 @@ impl TicketIntakeHandoff {
fn append_submit_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);
push_bounded_bullet(
out,
"workspace_workspace_orchestrator_worker",
&self.workspace_orchestrator_worker,
);
}
}
@@ -82,7 +89,7 @@ pub struct TicketRoleLaunchContext {
pub original_workspace_root: Option<PathBuf>,
pub target_workspace_root: Option<PathBuf>,
pub role: TicketRole,
pub pod_name: Option<String>,
pub worker_name: Option<String>,
pub ticket: Option<TicketRef>,
pub user_instruction: Option<String>,
pub intake_handoff: Option<TicketIntakeHandoff>,
@@ -102,7 +109,7 @@ impl TicketRoleLaunchContext {
original_workspace_root: None,
target_workspace_root: None,
role,
pod_name: None,
worker_name: None,
ticket: None,
user_instruction: None,
intake_handoff: None,
@@ -156,7 +163,7 @@ pub struct TicketRoleLaunchPlan {
pub target_workspace_root: PathBuf,
pub implementation_worktree_root: PathBuf,
pub role: TicketRole,
pub pod_name: String,
pub worker_name: String,
pub profile: String,
pub workflow: String,
pub launch_prompt_ref: Option<String>,
@@ -172,14 +179,14 @@ impl TicketRoleLaunchPlan {
pub fn spawn_config(
&self,
runtime_command: PodRuntimeCommand,
) -> Result<PodProcessLaunchConfig, TicketRoleLaunchError> {
runtime_command: WorkerRuntimeCommand,
) -> Result<WorkerProcessLaunchConfig, TicketRoleLaunchError> {
if self.profile == "inherit" {
return Err(TicketRoleLaunchError::UnsupportedInheritProfile);
}
Ok(PodProcessLaunchConfig {
Ok(WorkerProcessLaunchConfig {
runtime_command,
pod_name: self.pod_name.clone(),
worker_name: self.worker_name.clone(),
profile: Some(self.profile.clone()),
workspace_root: self.workspace_root.clone(),
cwd: self.cwd.clone(),
@@ -187,8 +194,8 @@ impl TicketRoleLaunchPlan {
})
}
pub fn spawn_options(&self) -> PodProcessLaunchOptions {
PodProcessLaunchOptions::default()
pub fn spawn_options(&self) -> WorkerProcessLaunchOptions {
WorkerProcessLaunchOptions::default()
.with_hidden_arg("--ticket-role", self.role.as_str().to_string())
}
}
@@ -208,7 +215,7 @@ pub struct TicketRoleLaunchResult {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketRoleLaunchAcceptanceEvidence {
pub pod_name: String,
pub worker_name: String,
pub accepted_run_segments: usize,
pub event: TicketRoleLaunchAcceptanceEvent,
}
@@ -233,8 +240,8 @@ pub struct TicketRoleLaunchOptions {
}
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());
pub fn with_pre_run_peer_registration(mut self, worker_name: impl Into<String>) -> Self {
self.pre_run_peer_registrations.push(worker_name.into());
self
}
}
@@ -253,30 +260,30 @@ pub enum TicketRoleLaunchError {
selector: String,
message: String,
},
#[error("Ticket role Pod name must not be empty")]
EmptyPodName,
#[error("Ticket role Worker name must not be empty")]
EmptyWorkerName,
#[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())]
#[error("failed to connect to spawned Ticket role Worker 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}")]
#[error("failed to send first run input to spawned Ticket role Worker: {source}")]
SendRun {
#[source]
source: io::Error,
},
#[error("Ticket role Pod rejected first run input with {code:?}: {message}")]
#[error("Ticket role Worker rejected first run input with {code:?}: {message}")]
RunRejected { code: ErrorCode, message: String },
#[error("Ticket role Pod closed before confirming first run acceptance")]
#[error("Ticket role Worker closed before confirming first run acceptance")]
RunAcceptanceClosed,
#[error("timed out waiting for Ticket role Pod to confirm first run acceptance")]
#[error("timed out waiting for Ticket role Worker to confirm first run acceptance")]
RunAcceptanceTimeout,
}
@@ -303,12 +310,17 @@ pub fn plan_ticket_role_launch_with_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),
let worker_name = match context.worker_name.as_deref().map(str::trim) {
Some("") => return Err(TicketRoleLaunchError::EmptyWorkerName),
Some(name) => name.to_string(),
None => default_pod_name(context.role, context.ticket.as_ref()),
None => default_worker_name(context.role, context.ticket.as_ref()),
};
validate_ticket_role_profile(context.role, &profile, &context.workspace_root, &pod_name)?;
validate_ticket_role_profile(
context.role,
&profile,
&context.workspace_root,
&worker_name,
)?;
let prompt = build_launch_prompt(&context);
let original_workspace_root = context.original_workspace_root().to_path_buf();
@@ -322,7 +334,7 @@ pub fn plan_ticket_role_launch_with_config(
target_workspace_root,
implementation_worktree_root,
role: context.role,
pod_name,
worker_name,
profile,
workflow: workflow.clone(),
launch_prompt_ref,
@@ -339,7 +351,7 @@ fn validate_ticket_role_profile(
role: TicketRole,
profile: &str,
workspace_root: &std::path::Path,
pod_name: &str,
worker_name: &str,
) -> Result<(), TicketRoleLaunchError> {
let selector = ProfileSelector::parse_cli(profile);
let registry = ProfileDiscovery::for_cwd(workspace_root)
@@ -354,7 +366,7 @@ fn validate_ticket_role_profile(
.resolve_from_registry(
&selector,
&registry,
ProfileResolveOptions::with_pod_name(pod_name),
ProfileResolveOptions::with_worker_name(worker_name),
)
.map(|_| ())
.map_err(|source| TicketRoleLaunchError::ProfileResolution {
@@ -364,17 +376,17 @@ fn validate_ticket_role_profile(
})
}
/// 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>(
/// Spawn the Worker, connect to its socket, send the first `Method::Run` input,
/// and wait for bounded acceptance evidence from the Worker event stream.
pub async fn launch_ticket_role_worker<F>(
context: TicketRoleLaunchContext,
runtime_command: PodRuntimeCommand,
runtime_command: WorkerRuntimeCommand,
progress: F,
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
where
F: FnMut(&str),
{
launch_ticket_role_pod_with_options(
launch_ticket_role_worker_with_options(
context,
runtime_command,
progress,
@@ -383,11 +395,11 @@ where
.await
}
/// Spawn the Pod, run bounded pre-run launch options while it is still idle,
/// Spawn the Worker, 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>(
pub async fn launch_ticket_role_worker_with_options<F>(
context: TicketRoleLaunchContext,
runtime_command: PodRuntimeCommand,
runtime_command: WorkerRuntimeCommand,
progress: F,
options: TicketRoleLaunchOptions,
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
@@ -397,8 +409,8 @@ where
let plan = plan_ticket_role_launch(context)?;
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)
let ready = spawn_worker_with_options(spawn_config, spawn_options, progress).await?;
let mut client = WorkerClient::connect(&ready.socket_path)
.await
.map_err(|source| TicketRoleLaunchError::Connect {
socket_path: ready.socket_path.clone(),
@@ -408,7 +420,7 @@ where
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(),
worker_name: ready.worker_name.clone(),
accepted_run_segments: plan.run_segments.len(),
event: acceptance_event,
};
@@ -421,7 +433,7 @@ where
}
async fn run_pre_run_options_then_send_run(
client: &mut PodClient,
client: &mut WorkerClient,
plan: &TicketRoleLaunchPlan,
options: &TicketRoleLaunchOptions,
) -> Result<Vec<TicketRolePreRunWarning>, TicketRoleLaunchError> {
@@ -439,7 +451,7 @@ async fn run_pre_run_options_then_send_run(
}
async fn perform_pre_run_peer_registrations(
client: &mut PodClient,
client: &mut WorkerClient,
peer_names: &[String],
timeout: Duration,
) -> Vec<TicketRolePreRunWarning> {
@@ -447,7 +459,7 @@ async fn perform_pre_run_peer_registrations(
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(),
message: "pre-run peer registration skipped: peer Worker name is empty".to_string(),
});
continue;
}
@@ -459,7 +471,7 @@ async fn perform_pre_run_peer_registrations(
}
async fn pre_run_register_peer(
client: &mut PodClient,
client: &mut WorkerClient,
peer_name: &str,
timeout: Duration,
) -> Result<(), String> {
@@ -503,7 +515,7 @@ async fn pre_run_register_peer(
}
async fn wait_for_run_acceptance(
client: &mut PodClient,
client: &mut WorkerClient,
expected_segments: &[Segment],
timeout: Duration,
) -> Result<TicketRoleLaunchAcceptanceEvent, TicketRoleLaunchError> {
@@ -593,10 +605,10 @@ fn append_operation_targets(out: &mut String, context: &TicketRoleLaunchContext)
);
}
fn default_pod_name(role: TicketRole, ticket: Option<&TicketRef>) -> String {
fn default_worker_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 let Some(seed) = ticket.and_then(TicketRef::worker_name_seed) {
let suffix = sanitise_worker_name_component(seed);
if !suffix.is_empty() {
name.push('-');
name.push_str(&suffix);
@@ -605,7 +617,7 @@ fn default_pod_name(role: TicketRole, ticket: Option<&TicketRef>) -> String {
name.chars().take(MAX_POD_NAME_CHARS).collect()
}
fn sanitise_pod_name_component(value: &str) -> String {
fn sanitise_worker_name_component(value: &str) -> String {
let mut out = String::new();
let mut last_was_dash = false;
for ch in value.trim().chars() {
@@ -680,7 +692,7 @@ fn non_empty(value: Option<&str>) -> Option<&str> {
#[cfg(test)]
mod tests {
use super::*;
use protocol::{Greeting, PodStatus};
use protocol::{Greeting, WorkerStatus};
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::UnixListener;
@@ -723,7 +735,7 @@ mod tests {
Event::Snapshot {
entries: vec![],
greeting: Greeting {
pod_name: "ticket-intake".to_string(),
worker_name: "ticket-intake".to_string(),
cwd: "/tmp".to_string(),
provider: "test".to_string(),
model: "test".to_string(),
@@ -732,7 +744,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: PodStatus::Idle,
status: WorkerStatus::Idle,
in_flight: protocol::InFlightSnapshot::default(),
}
}
@@ -745,7 +757,7 @@ mod tests {
target_workspace_root: workspace.to_path_buf(),
implementation_worktree_root: workspace.join(".worktree"),
role: TicketRole::Intake,
pod_name: "ticket-intake".to_string(),
worker_name: "ticket-intake".to_string(),
profile: "project:intake".to_string(),
workflow: "ticket-intake-workflow".to_string(),
launch_prompt_ref: None,
@@ -758,7 +770,7 @@ mod tests {
#[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 socket_path = temp.path().join("worker.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
@@ -793,7 +805,7 @@ mod tests {
}
});
let mut client = PodClient::connect(&socket_path).await.unwrap();
let mut client = WorkerClient::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(
@@ -811,7 +823,7 @@ mod tests {
#[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 socket_path = temp.path().join("worker.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
@@ -842,7 +854,7 @@ mod tests {
));
});
let mut client = PodClient::connect(&socket_path).await.unwrap();
let mut client = WorkerClient::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(
@@ -863,7 +875,7 @@ mod tests {
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::id("Ticket Role Pod Launcher"));
context.ticket = Some(TicketRef::id("Ticket Role Worker Launcher"));
let err = plan_ticket_role_launch(context).unwrap_err();
@@ -1007,7 +1019,7 @@ profile = "builtin:default"
plan.profile = "inherit".to_string();
let err = plan
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
.spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
.unwrap_err();
assert!(matches!(
@@ -1030,14 +1042,14 @@ 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("20260605-190330-ticket-role-pod-launcher"));
context.worker_name = Some("reviewer-fixed".to_string());
context.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-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.worker_name, "reviewer-fixed");
assert_eq!(plan.profile, "builtin:default");
assert_eq!(plan.workflow, "ticket-review-workflow");
assert_eq!(
@@ -1055,13 +1067,13 @@ workflow = "ticket-review-workflow"
assert!(!text.contains("Role: reviewer"));
assert!(!text.contains("system_instruction"));
assert!(text.contains("Target Ticket:"));
assert!(text.contains("id: 20260605-190330-ticket-role-pod-launcher"));
assert!(text.contains("id: 20260605-190330-ticket-role-worker-launcher"));
assert!(text.contains("Action instruction:"));
assert!(text.contains("Review the submitted implementation."));
let spawn = plan
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
.spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
.unwrap();
assert_eq!(spawn.pod_name, "reviewer-fixed");
assert_eq!(spawn.worker_name, "reviewer-fixed");
assert_eq!(spawn.profile.as_deref(), Some("builtin:default"));
assert_eq!(spawn.workspace_root, temp.path());
assert!(spawn.cwd.is_none());
@@ -1102,7 +1114,10 @@ workflow = "ticket-review-workflow"
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_workspace_orchestrator_worker: panel-orchestrator-demo")
);
assert!(handoff_text.contains("workspace: Demo workspace"));
assert!(!handoff_text.contains("created_or_updated_ticket_id"));
assert!(!handoff_text.contains("Ticket tool surface"));
@@ -1125,15 +1140,15 @@ workflow = "ticket-review-workflow"
assert!(!orchestrator_text.contains("role_cwd"));
let mut coder = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
coder.ticket = Some(TicketRef::id("20260605-190330-ticket-role-pod-launcher"));
coder.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-launcher"));
coder.worktree_path = Some(PathBuf::from("/tmp/yoi-code"));
coder.branch = Some("work/ticket-role-pod-launcher".into());
coder.branch = Some("work/ticket-role-worker-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("path: /tmp/yoi-code"));
assert!(coder_text.contains("branch: work/ticket-role-pod-launcher"));
assert!(coder_text.contains("branch: work/ticket-role-worker-launcher"));
assert!(coder_text.contains("cargo test -p client ticket_role"));
assert!(coder_text.contains("implementation report with validation"));
assert!(!coder_text.contains("provided child worktree/branch"));
@@ -1141,14 +1156,14 @@ workflow = "ticket-review-workflow"
assert!(!coder_text.contains("Do not merge, push"));
let mut reviewer = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
reviewer.ticket = Some(TicketRef::id("20260605-190330-ticket-role-pod-launcher"));
reviewer.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-launcher"));
reviewer.worktree_path = Some(PathBuf::from("/tmp/yoi-review"));
reviewer.branch = Some("work/ticket-role-pod-launcher".into());
reviewer.branch = Some("work/ticket-role-worker-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("path: /tmp/yoi-review"));
assert!(reviewer_text.contains("branch: work/ticket-role-pod-launcher"));
assert!(reviewer_text.contains("branch: work/ticket-role-worker-launcher"));
assert!(reviewer_text.contains("approve or request changes"));
assert!(!reviewer_text.contains("read-only by default"));
assert!(!reviewer_text.contains("Orchestrator-side integration"));
@@ -1177,7 +1192,7 @@ workflow = "ticket-review-workflow"
);
assert_eq!(plan.target_workspace_root, temp.path().join("target"));
let spawn_config = plan
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi"))
.spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
.unwrap();
assert_eq!(spawn_config.workspace_root, temp.path());
assert_eq!(spawn_config.cwd, None);
@@ -1194,15 +1209,15 @@ workflow = "ticket-review-workflow"
assert!(!text.contains("Orchestrator implementation integration guidance"));
}
#[test]
fn caller_provided_pod_name_is_used_exactly() {
fn caller_provided_worker_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());
context.worker_name = Some("custom-intake-worker".into());
let plan = plan_ticket_role_launch(context).unwrap();
assert_eq!(plan.pod_name, "custom-intake-pod");
assert_eq!(plan.worker_name, "custom-intake-worker");
}
#[test]
@@ -7,13 +7,13 @@ use tokio::net::UnixStream;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
pub struct PodClient {
pub struct WorkerClient {
writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>,
event_rx: mpsc::Receiver<Event>,
reader_task: JoinHandle<()>,
}
impl PodClient {
impl WorkerClient {
pub async fn connect(path: &Path) -> Result<Self, io::Error> {
let stream = UnixStream::connect(path).await?;
let (reader, writer) = tokio::io::split(stream);
@@ -50,7 +50,7 @@ impl PodClient {
}
}
impl Drop for PodClient {
impl Drop for WorkerClient {
fn drop(&mut self) {
self.reader_task.abort();
}
@@ -61,7 +61,7 @@ mod tests {
use std::io::ErrorKind;
use std::time::Duration;
use protocol::{PodStatus, Segment};
use protocol::{Segment, WorkerStatus};
use tempfile::tempdir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixListener;
@@ -91,13 +91,13 @@ mod tests {
let mut writer = JsonLineWriter::new(stream);
writer
.write(&Event::Status {
status: PodStatus::Idle,
status: WorkerStatus::Idle,
})
.await
.unwrap();
});
let mut client = PodClient::connect(&socket_path).await.unwrap();
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
.await
@@ -105,7 +105,7 @@ mod tests {
assert!(matches!(
event,
Some(Event::Status {
status: PodStatus::Idle
status: WorkerStatus::Idle
})
));
server.await.unwrap();
@@ -122,7 +122,7 @@ mod tests {
reader.next::<Method>().await.unwrap()
});
let mut client = PodClient::connect(&socket_path).await.unwrap();
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
let method = Method::Run {
input: vec![Segment::text("hello")],
};
@@ -155,7 +155,7 @@ mod tests {
});
for _ in 0..16 {
let client = PodClient::connect(&socket_path).await.unwrap();
let client = WorkerClient::connect(&socket_path).await.unwrap();
drop(client);
}
@@ -177,7 +177,7 @@ mod tests {
.await;
});
let client = PodClient::connect(&socket_path).await.unwrap();
let client = WorkerClient::connect(&socket_path).await.unwrap();
tokio::task::yield_now().await;
drop(client);