merge: sync orchestration before queue 00001KVWPW3KX

This commit is contained in:
2026-06-24 21:01:41 +09:00
20 changed files with 1825 additions and 536 deletions
+4 -1
View File
@@ -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
View File
@@ -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",
+52 -11
View File
@@ -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]
+10 -5
View File
@@ -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(
-1
View File
@@ -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,
+1
View File
@@ -16,6 +16,7 @@ rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
thiserror.workspace = true
ticket.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
File diff suppressed because it is too large Load Diff
+9
View File
@@ -40,6 +40,15 @@ pub enum Error {
MissingFrontmatter(String),
#[error("unknown local host `{0}`")]
UnknownHost(String),
#[error("unknown local worker `{0}`")]
UnknownWorker(String),
#[error("invalid runtime {kind} `{value}`")]
InvalidRuntimeIdentifier { kind: String, value: String },
#[error("runtime `{runtime_id}` does not support `{capability}`")]
RuntimeCapabilityUnsupported {
runtime_id: String,
capability: String,
},
#[error("unknown local repository `{0}`")]
UnknownRepository(String),
#[error("workspace identity error: {0}")]
+6 -2
View File
@@ -3,7 +3,7 @@ use std::process::{Command, Output};
use serde::{Deserialize, Serialize};
use crate::hosts::RuntimeDiagnostic;
use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic};
const LEGACY_LOCAL_REPOSITORY_ID: &str = "local";
const LOCAL_REPOSITORY_PREFIX: &str = "local-";
@@ -340,7 +340,11 @@ fn truncate_field(value: &str, limit: usize) -> String {
fn diagnostic(code: &str, severity: &str, message: String) -> RuntimeDiagnostic {
RuntimeDiagnostic {
code: code.to_string(),
severity: severity.to_string(),
severity: match severity {
"error" => DiagnosticSeverity::Error,
"warning" => DiagnosticSeverity::Warning,
_ => DiagnosticSeverity::Info,
},
message,
}
}
+70 -34
View File
@@ -10,7 +10,10 @@ use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use crate::hosts::{HostSummary, LocalRuntimeBridge, RuntimeDiagnostic, WorkerSummary};
use crate::hosts::{
DiagnosticSeverity, HostSummary, LocalPodRuntime, RuntimeDiagnostic, RuntimeSummary,
WorkerRuntimeRegistry, WorkerSummary,
};
use crate::identity::WorkspaceIdentity;
use crate::records::{
LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary,
@@ -61,6 +64,7 @@ pub struct WorkspaceApi {
config: ServerConfig,
store: Arc<dyn ControlPlaneStore>,
records: LocalProjectRecordReader,
runtime: Arc<WorkerRuntimeRegistry>,
}
impl WorkspaceApi {
@@ -74,10 +78,16 @@ impl WorkspaceApi {
updated_at: config.workspace_created_at.clone(),
})
.await?;
let runtime = Arc::new(WorkerRuntimeRegistry::for_local_pods(LocalPodRuntime::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 +95,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(),
@@ -124,6 +126,7 @@ pub fn build_router(api: WorkspaceApi) -> Router {
get(repository_tickets),
)
.route("/api/hosts", get(list_hosts))
.route("/api/runtimes", get(list_runtimes))
.route("/api/workers", get(list_workers))
.route("/api/hosts/{host_id}/workers", get(list_host_workers))
.fallback(get(static_or_spa_fallback))
@@ -380,7 +383,7 @@ async fn repository_tickets(
source: "workspace_local_ticket_fallback".to_string(),
diagnostics: vec![RuntimeDiagnostic {
code: "repository_ticket_target_metadata_absent".to_string(),
severity: "info".to_string(),
severity: DiagnosticSeverity::Info,
message: "Ticket target Repository metadata is not available yet; Kanban groups all workspace-local Tickets by state as a read-only fallback.".to_string(),
}],
}))
@@ -390,14 +393,27 @@ async fn list_hosts(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<HostSummary>>> {
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_registry".to_string(),
diagnostics: runtime_hosts.diagnostics,
}))
}
async fn list_runtimes(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RuntimeListResponse<RuntimeSummary>>> {
let limit = api.config.max_records.min(200);
let runtimes = api.runtime.list_runtimes(limit);
Ok(Json(RuntimeListResponse {
workspace_id: api.config.workspace_id,
limit,
items: runtimes.items,
source: "worker_runtime_registry".to_string(),
diagnostics: runtimes.diagnostics,
}))
}
@@ -411,23 +427,29 @@ async fn list_host_workers(
State(api): State<WorkspaceApi>,
AxumPath(host_id): AxumPath<String>,
) -> ApiResult<Json<RuntimeListResponse<WorkerSummary>>> {
let bridge = api.local_runtime_bridge();
if host_id != bridge.host_id() {
return Err(Error::UnknownHost(host_id).into());
}
workers_response(api).map(Json)
let limit = api.config.max_records.min(200);
let runtime_workers = api
.runtime
.list_workers_for_host(&host_id, limit)
.map_err(|err| err.into_error())?;
Ok(Json(RuntimeListResponse {
workspace_id: api.config.workspace_id,
limit,
items: runtime_workers.items,
source: "worker_runtime_registry".to_string(),
diagnostics: runtime_workers.diagnostics,
}))
}
fn workers_response(api: WorkspaceApi) -> ApiResult<RuntimeListResponse<WorkerSummary>> {
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_registry".to_string(),
diagnostics: runtime_workers.diagnostics,
})
}
@@ -585,11 +607,14 @@ impl From<Error> for ApiError {
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self.0 {
Error::InvalidRuntimeIdentifier { .. } => StatusCode::BAD_REQUEST,
Error::InvalidRecordId(_)
| Error::MissingFrontmatter(_)
| Error::UnknownHost(_)
| Error::UnknownWorker(_)
| Error::UnknownRepository(_) => StatusCode::NOT_FOUND,
Error::Ticket(_) => StatusCode::NOT_FOUND,
Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(
@@ -699,25 +724,36 @@ mod tests {
assert_eq!(unknown_repository_response.status(), StatusCode::NOT_FOUND);
let hosts = get_json(app.clone(), "/api/hosts").await;
assert_eq!(hosts["items"][0]["host_id"], TEST_REPOSITORY_ID);
assert_eq!(hosts["items"][0]["kind"], "local_host");
assert_eq!(hosts["source"], "worker_runtime_registry");
assert_eq!(hosts["items"][0]["runtime_id"], "local-pod-runtime");
let host_id = hosts["items"][0]["host_id"].as_str().unwrap().to_string();
assert!(host_id.starts_with("local-"));
assert!(host_id.len() <= 120);
assert_ne!(host_id, TEST_REPOSITORY_ID);
assert_eq!(hosts["items"][0]["kind"], "local-pod-host");
assert_eq!(
hosts["items"][0]["capabilities"]["local_pod_inspection"],
"unavailable"
"available"
);
assert_eq!(
hosts["items"][0]["capabilities"]["workspace_scope"],
"current_workspace"
);
assert!(!hosts.to_string().contains("metadata.json"));
let runtimes = get_json(app.clone(), "/api/runtimes").await;
assert_eq!(runtimes["source"], "worker_runtime_registry");
assert_eq!(runtimes["items"][0]["runtime_id"], "local-pod-runtime");
assert_eq!(runtimes["items"][0]["host_ids"][0], host_id);
let workers = get_json(app.clone(), "/api/workers").await;
assert!(workers["items"].as_array().unwrap().is_empty());
assert_eq!(
workers["diagnostics"][0]["code"],
"local_pod_metadata_root_missing"
"local_pod_registry_unreadable"
);
let host_workers = get_json(
app.clone(),
&format!("/api/hosts/{TEST_REPOSITORY_ID}/workers"),
)
.await;
let host_workers = get_json(app.clone(), &format!("/api/hosts/{host_id}/workers")).await;
assert!(host_workers["items"].as_array().unwrap().is_empty());
let runs_response = app