From 217a4828d73ab553b5406cc7e22e43b1ec7be48e Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 19:24:18 +0900 Subject: [PATCH 1/2] feat: abstract worker runtime spawn boundary --- crates/client/src/lib.rs | 5 +- crates/client/src/spawn.rs | 69 ++++++--- crates/client/src/ticket_role.rs | 63 ++++++-- crates/tui/src/dashboard/mod.rs | 15 +- crates/tui/src/spawn.rs | 1 - crates/workspace-server/src/hosts.rs | 199 ++++++++++++++++++++++++++ crates/workspace-server/src/server.rs | 46 +++--- 7 files changed, 341 insertions(+), 57 deletions(-) diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index e753db71..71954c1b 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -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, diff --git a/crates/client/src/spawn.rs b/crates/client/src/spawn.rs index 00012fa6..70f265c9 100644 --- a/crates/client/src/spawn.rs +++ b/crates/client/src/spawn.rs @@ -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, - /// 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, /// 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, } +#[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, +} + +impl PodProcessLaunchOptions { + pub fn with_hidden_arg(mut self, name: impl Into, value: impl Into) -> 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 for SpawnError { } } -fn runtime_args(config: &SpawnConfig) -> Vec { +fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptions) -> Vec { let mut args = vec![ "--workspace".to_string(), config.workspace_root.display().to_string(), @@ -130,9 +149,7 @@ fn runtime_args(config: &SpawnConfig) -> Vec { 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 { /// /// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる /// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。 -pub async fn spawn_pod(config: SpawnConfig, mut progress: F) -> Result +pub async fn spawn_pod( + config: PodProcessLaunchConfig, + progress: F, +) -> Result +where + F: FnMut(&str), +{ + spawn_pod_with_options(config, PodProcessLaunchOptions::default(), progress).await +} + +pub async fn spawn_pod_with_options( + config: PodProcessLaunchConfig, + options: PodProcessLaunchOptions, + mut progress: F, +) -> Result 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", diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index b9265759..2c71c184 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -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 { + ) -> Result { 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, } +#[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 { 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] diff --git a/crates/tui/src/dashboard/mod.rs b/crates/tui/src/dashboard/mod.rs index 1cb2afce..dbd292b7 100644 --- a/crates/tui/src/dashboard/mod.rs +++ b/crates/tui/src/dashboard/mod.rs @@ -11,7 +11,9 @@ use client::ticket_role::{ TicketRoleLaunchOptions, TicketRoleLaunchResult, launch_ticket_role_pod, launch_ticket_role_pod_with_options, plan_ticket_role_launch, }; -use client::{PodRuntimeCommand, SpawnConfig, spawn_pod}; +use client::{ + PodProcessLaunchOptions, PodRuntimeCommand, SpawnConfig, spawn_pod, spawn_pod_with_options, +}; use crossterm::event::{ Event as TermEvent, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, poll, read, @@ -3281,7 +3283,6 @@ async fn restore_workspace_companion_pod( runtime_command, pod_name: pod_name.to_string(), profile: None, - ticket_role: None, workspace_root: workspace_root.to_path_buf(), cwd: None, resume_from: None, @@ -3298,7 +3299,6 @@ async fn spawn_workspace_companion_pod( runtime_command, pod_name: pod_name.to_string(), profile: None, - ticket_role: None, workspace_root: workspace_root.to_path_buf(), cwd: None, resume_from: None, @@ -3316,12 +3316,17 @@ async fn restore_orchestrator_pod( runtime_command, pod_name: pod_name.to_string(), profile: None, - ticket_role: Some("orchestrator".to_string()), workspace_root: original_workspace_root.to_path_buf(), cwd: Some(workspace_root.to_path_buf()), resume_from: None, }; - spawn_pod(config, |_| {}).await.map(|_| ()) + spawn_pod_with_options( + config, + PodProcessLaunchOptions::default().with_hidden_arg("--ticket-role", "orchestrator"), + |_| {}, + ) + .await + .map(|_| ()) } async fn spawn_orchestrator_pod( diff --git a/crates/tui/src/spawn.rs b/crates/tui/src/spawn.rs index 3cbdf524..cc1b7c19 100644 --- a/crates/tui/src/spawn.rs +++ b/crates/tui/src/spawn.rs @@ -378,7 +378,6 @@ async fn wait_for_ready( runtime_command: runtime_command.clone(), pod_name: form.name.clone(), profile: form.selected_profile_selector(), - ticket_role: None, workspace_root: form.cwd.clone(), cwd: None, resume_from: form.resume_from, diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 20d5a139..ac7027e7 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -63,6 +63,118 @@ pub struct WorkerImplementation { pub pod_name: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RuntimeList { + pub items: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerLookupResult { + #[serde(skip_serializing_if = "Option::is_none")] + pub worker: Option, + pub diagnostics: Vec, +} + +/// Browser-safe worker spawn request shape. +/// +/// The request intentionally carries only workspace policy intents and stable +/// worker identifiers. Raw workspace roots, child cwd, executable path, and raw +/// profile selectors are resolved by the host/runtime service and never accepted +/// from Workspace API callers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerSpawnRequest { + pub intent: WorkerSpawnIntent, + #[serde(skip_serializing_if = "Option::is_none")] + pub requested_worker_name: Option, + pub acceptance: WorkerSpawnAcceptanceRequirement, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkerSpawnIntent { + WorkspaceCompanion, + WorkspaceOrchestrator, + TicketRole { + ticket_id: String, + role: TicketWorkerRole, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TicketWorkerRole { + Intake, + Orchestrator, + Coder, + Reviewer, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkerSpawnAcceptanceRequirement { + SocketReady, + RunAccepted { expected_segments: usize }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerSpawnResult { + pub state: WorkerOperationState, + #[serde(skip_serializing_if = "Option::is_none")] + pub worker: Option, + pub acceptance_evidence: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkerOperationState { + Accepted, + Unsupported, + Rejected, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerSpawnAcceptanceEvidence { + pub kind: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerStopRequest { + pub worker_id: String, + pub mode: WorkerStopMode, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkerStopMode { + Graceful, + Force, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerStopResult { + pub state: WorkerOperationState, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkerProxyConnectPoint { + pub kind: String, + pub status: String, + pub diagnostics: Vec, +} + +pub trait WorkspaceWorkerRuntime: Send + Sync { + fn list_hosts(&self, limit: usize) -> RuntimeList; + fn list_workers(&self, limit: usize) -> RuntimeList; + fn worker(&self, worker_id: &str) -> WorkerLookupResult; + fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult; + fn stop_worker(&self, request: WorkerStopRequest) -> WorkerStopResult; + fn proxy_connect_points(&self, worker_id: &str) -> Vec; +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalRuntimeBridge { workspace_id: String, @@ -247,6 +359,85 @@ impl LocalRuntimeBridge { } } +impl WorkspaceWorkerRuntime for LocalRuntimeBridge { + fn list_hosts(&self, limit: usize) -> RuntimeList { + let (items, diagnostics) = LocalRuntimeBridge::list_hosts(self, limit); + RuntimeList { items, diagnostics } + } + + fn list_workers(&self, limit: usize) -> RuntimeList { + let (items, diagnostics) = LocalRuntimeBridge::list_workers(self, limit); + RuntimeList { items, diagnostics } + } + + fn worker(&self, worker_id: &str) -> WorkerLookupResult { + let RuntimeList { + items, + mut diagnostics, + } = WorkspaceWorkerRuntime::list_workers(self, 200); + let worker = items + .into_iter() + .find(|worker| worker.worker_id == worker_id); + if worker.is_none() { + diagnostics.push(RuntimeDiagnostic::new( + "worker_not_found", + "info", + format!("worker '{worker_id}' was not found on the local runtime"), + )); + } + truncate_diagnostics(&mut diagnostics); + WorkerLookupResult { + worker, + diagnostics, + } + } + + fn spawn_worker(&self, request: WorkerSpawnRequest) -> WorkerSpawnResult { + let diagnostic = RuntimeDiagnostic::new( + "worker_spawn_resolver_pending", + "info", + format!( + "worker spawn intent '{}' was accepted as a typed request shape, but local launch resolution is not implemented by this ticket", + worker_spawn_intent_label(&request.intent) + ), + ); + WorkerSpawnResult { + state: WorkerOperationState::Unsupported, + worker: None, + acceptance_evidence: Vec::new(), + diagnostics: vec![diagnostic], + } + } + + fn stop_worker(&self, request: WorkerStopRequest) -> WorkerStopResult { + WorkerStopResult { + state: WorkerOperationState::Unsupported, + diagnostics: vec![RuntimeDiagnostic::new( + "worker_stop_pending", + "info", + format!( + "worker stop for '{}' is reserved for the runtime service boundary and is not implemented by this ticket", + request.worker_id + ), + )], + } + } + + fn proxy_connect_points(&self, worker_id: &str) -> Vec { + vec![WorkerProxyConnectPoint { + kind: "stream_proxy".to_string(), + status: "not_implemented".to_string(), + diagnostics: vec![RuntimeDiagnostic::new( + "worker_stream_proxy_pending", + "info", + format!( + "future stream/proxy connection point for '{worker_id}' is reserved without opening a protocol surface" + ), + )], + }] + } +} + impl RuntimeDiagnostic { pub fn new( code: impl Into, @@ -393,6 +584,14 @@ fn safe_metadata_label(value: &str) -> Option { Some(value.to_string()) } +fn worker_spawn_intent_label(intent: &WorkerSpawnIntent) -> &'static str { + match intent { + WorkerSpawnIntent::WorkspaceCompanion => "workspace_companion", + WorkerSpawnIntent::WorkspaceOrchestrator => "workspace_orchestrator", + WorkerSpawnIntent::TicketRole { .. } => "ticket_role", + } +} + fn stable_local_host_id(workspace_id: &str) -> String { format!("local-{}", sanitize_identifier(workspace_id, 96)) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 7c9e786d..12fc7814 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -10,7 +10,9 @@ use axum::{Json, Router}; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; -use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary}; +use crate::hosts::{ + HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary, WorkspaceWorkerRuntime, +}; use crate::identity::WorkspaceIdentity; use crate::records::{ LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, @@ -61,6 +63,7 @@ pub struct WorkspaceApi { config: ServerConfig, store: Arc, records: LocalProjectRecordReader, + runtime: Arc, } impl WorkspaceApi { @@ -74,10 +77,16 @@ impl WorkspaceApi { updated_at: config.workspace_created_at.clone(), }) .await?; + let runtime = Arc::new(LocalRuntimeBridge::new( + config.workspace_id.clone(), + config.workspace_root.clone(), + config.local_runtime_data_dir.clone(), + )); Ok(Self { records: LocalProjectRecordReader::new(config.workspace_root.clone()), config, store, + runtime, }) } @@ -85,14 +94,6 @@ impl WorkspaceApi { self.config.workspace_id.as_str() } - fn local_runtime_bridge(&self) -> LocalRuntimeBridge { - LocalRuntimeBridge::new( - self.config.workspace_id.clone(), - self.config.workspace_root.clone(), - self.config.local_runtime_data_dir.clone(), - ) - } - fn local_repository_reader(&self) -> LocalRepositoryReader { LocalRepositoryReader::new( self.config.workspace_root.clone(), @@ -390,14 +391,13 @@ async fn list_hosts( State(api): State, ) -> ApiResult>> { let limit = api.config.max_records.min(200); - let bridge = api.local_runtime_bridge(); - let (items, diagnostics) = bridge.list_hosts(limit); + let runtime_hosts = api.runtime.list_hosts(limit); Ok(Json(RuntimeListResponse { workspace_id: api.config.workspace_id, limit, - items, - source: "local_pod_metadata".to_string(), - diagnostics, + items: runtime_hosts.items, + source: "worker_runtime".to_string(), + diagnostics: runtime_hosts.diagnostics, })) } @@ -411,8 +411,13 @@ async fn list_host_workers( State(api): State, AxumPath(host_id): AxumPath, ) -> ApiResult>> { - let bridge = api.local_runtime_bridge(); - if host_id != bridge.host_id() { + let runtime_hosts = api.runtime.list_hosts(1); + let expected_host_id = runtime_hosts + .items + .first() + .map(|host| host.host_id.as_str()) + .ok_or_else(|| Error::UnknownHost(host_id.clone()))?; + if host_id != expected_host_id { return Err(Error::UnknownHost(host_id).into()); } workers_response(api).map(Json) @@ -420,14 +425,13 @@ async fn list_host_workers( fn workers_response(api: WorkspaceApi) -> ApiResult> { let limit = api.config.max_records.min(200); - let bridge = api.local_runtime_bridge(); - let (items, diagnostics) = bridge.list_workers(limit); + let runtime_workers = api.runtime.list_workers(limit); Ok(RuntimeListResponse { workspace_id: api.config.workspace_id, limit, - items, - source: "local_pod_metadata".to_string(), - diagnostics, + items: runtime_workers.items, + source: "worker_runtime".to_string(), + diagnostics: runtime_workers.diagnostics, }) } From d62ab6e1de9ec9a3530f29912865fe150be43f44 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 24 Jun 2026 19:26:14 +0900 Subject: [PATCH 2/2] docs: record worker runtime implementation report --- .../implementation-report-217a4828.md | 37 +++++++++++++++ .yoi/tickets/00001KVTNAY20/item.md | 2 +- .yoi/tickets/00001KVTNAY20/thread.md | 45 +++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 .yoi/tickets/00001KVTNAY20/artifacts/implementation-report-217a4828.md diff --git a/.yoi/tickets/00001KVTNAY20/artifacts/implementation-report-217a4828.md b/.yoi/tickets/00001KVTNAY20/artifacts/implementation-report-217a4828.md new file mode 100644 index 00000000..138ec1de --- /dev/null +++ b/.yoi/tickets/00001KVTNAY20/artifacts/implementation-report-217a4828.md @@ -0,0 +1,37 @@ +# 実装報告: 00001KVTNAY20 + +## 変更概要 + +- `client::spawn` に `PodProcessLaunchConfig` と `PodProcessLaunchOptions` を導入し、低レベルの Pod プロセス起動設定から Ticket role marker を分離した。 +- Ticket role 起動は `TicketRoleLaunchPlan::spawn_options()` 経由で hidden CLI marker を渡す形にし、`TicketRoleLaunchResult` に Run 受理証跡 (`TicketRoleLaunchAcceptanceEvidence`) を追加した。 +- Workspace server の `LocalRuntimeBridge` を `WorkspaceWorkerRuntime` trait の実装として整理し、hosts/workers 一覧、worker lookup、spawn/stop typed request/result、将来の proxy/stream 接続点を型として追加した。 +- Workspace 側の spawn request shape は policy intent ベースにし、browser/API caller から raw `workspace_root` / `cwd` / executable path / raw profile selector を受け取らない形にした。 +- Dashboard/TUI 側の直接 spawn 呼び出しを新しい low-level config/options 分離に追従した。 + +## 変更ファイル + +- `crates/client/src/lib.rs` +- `crates/client/src/spawn.rs` +- `crates/client/src/ticket_role.rs` +- `crates/tui/src/dashboard/mod.rs` +- `crates/tui/src/spawn.rs` +- `crates/workspace-server/src/hosts.rs` +- `crates/workspace-server/src/server.rs` + +## 検証結果 + +- `cargo test -p yoi-workspace-server`: 成功 +- `cargo check -p yoi`: 成功 +- `cd web/workspace && deno task check && deno task build`: 成功 +- `cargo test -p client`: 成功(追加確認) +- `git diff --check`: 成功 + +## コミット + +- 実装コミット: `217a4828d73ab553b5406cc7e22e43b1ec7be48e` + +## 残リスク / 非ゴールとして残したもの + +- `WorkspaceWorkerRuntime::spawn_worker` / `stop_worker` は typed boundary と request/result を用意した段階で、実際の Worker operation UI 完成、stream proxy、remote Host protocol、認可/権限、registry locking までは実装していない。 +- low-level launcher は trusted in-process resolver からの追加 CLI args を受け取れるが、Ticket role などのドメイン概念は `PodProcessLaunchConfig` からは除外している。 +- TS 型生成やフロントエンド API surface の追加は本 Ticket の非ゴールとして変更していない。 diff --git a/.yoi/tickets/00001KVTNAY20/item.md b/.yoi/tickets/00001KVTNAY20/item.md index 25a42fe4..920a496f 100644 --- a/.yoi/tickets/00001KVTNAY20/item.md +++ b/.yoi/tickets/00001KVTNAY20/item.md @@ -2,7 +2,7 @@ title: 'Abstract Workspace Worker runtime spawn operations' state: 'inprogress' created_at: '2026-06-23T16:34:39Z' -updated_at: '2026-06-23T19:33:48Z' +updated_at: '2026-06-24T10:26:04Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-23T19:25:09Z' diff --git a/.yoi/tickets/00001KVTNAY20/thread.md b/.yoi/tickets/00001KVTNAY20/thread.md index 83228b4d..a92df08c 100644 --- a/.yoi/tickets/00001KVTNAY20/thread.md +++ b/.yoi/tickets/00001KVTNAY20/thread.md @@ -166,4 +166,49 @@ Next action: - Coder Pod/profile/provider startup issue の解消後に同じ worktree/branch で multi-agent workflow を再開する。 - あるいは人間が明示的に Orchestrator direct implementation を許可する場合のみ、Orchestrator がこの child worktree で実装へ進む。 +--- + + + +## Implementation report + +# 実装報告: 00001KVTNAY20 + +## 変更概要 + +- `client::spawn` に `PodProcessLaunchConfig` と `PodProcessLaunchOptions` を導入し、低レベルの Pod プロセス起動設定から Ticket role marker を分離した。 +- Ticket role 起動は `TicketRoleLaunchPlan::spawn_options()` 経由で hidden CLI marker を渡す形にし、`TicketRoleLaunchResult` に Run 受理証跡 (`TicketRoleLaunchAcceptanceEvidence`) を追加した。 +- Workspace server の `LocalRuntimeBridge` を `WorkspaceWorkerRuntime` trait の実装として整理し、hosts/workers 一覧、worker lookup、spawn/stop typed request/result、将来の proxy/stream 接続点を型として追加した。 +- Workspace 側の spawn request shape は policy intent ベースにし、browser/API caller から raw `workspace_root` / `cwd` / executable path / raw profile selector を受け取らない形にした。 +- Dashboard/TUI 側の直接 spawn 呼び出しを新しい low-level config/options 分離に追従した。 + +## 変更ファイル + +- `crates/client/src/lib.rs` +- `crates/client/src/spawn.rs` +- `crates/client/src/ticket_role.rs` +- `crates/tui/src/dashboard/mod.rs` +- `crates/tui/src/spawn.rs` +- `crates/workspace-server/src/hosts.rs` +- `crates/workspace-server/src/server.rs` + +## 検証結果 + +- `cargo test -p yoi-workspace-server`: 成功 +- `cargo check -p yoi`: 成功 +- `cd web/workspace && deno task check && deno task build`: 成功 +- `cargo test -p client`: 成功(追加確認) +- `git diff --check`: 成功 + +## コミット + +- 実装コミット: `217a4828d73ab553b5406cc7e22e43b1ec7be48e` + +## 残リスク / 非ゴールとして残したもの + +- `WorkspaceWorkerRuntime::spawn_worker` / `stop_worker` は typed boundary と request/result を用意した段階で、実際の Worker operation UI 完成、stream proxy、remote Host protocol、認可/権限、registry locking までは実装していない。 +- low-level launcher は trusted in-process resolver からの追加 CLI args を受け取れるが、Ticket role などのドメイン概念は `PodProcessLaunchConfig` からは除外している。 +- TS 型生成やフロントエンド API surface の追加は本 Ticket の非ゴールとして変更していない。 + + ---