merge: 00001KVZG9BMS worker crate rename

This commit is contained in:
Keisuke Hirata 2026-06-26 01:15:29 +09:00
commit 2a7e877584
No known key found for this signature in database
207 changed files with 6742 additions and 6246 deletions

View File

@ -11,12 +11,12 @@
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。 LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
Podの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。 Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。 原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。 **禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / PodEvent / `<system-reminder>` 系はこの原則で扱う。 新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / `<system-reminder>` 系はこの原則で扱う。
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。 また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
--- ---

94
Cargo.lock generated
View File

@ -2873,52 +2873,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "pod"
version = "0.1.0"
dependencies = [
"arc-swap",
"async-trait",
"chrono",
"clap",
"client",
"dotenv",
"fs4",
"futures",
"futures-util",
"include_dir",
"libc",
"llm-engine",
"manifest",
"mcp",
"memory",
"minijinja",
"pod-registry",
"pod-store",
"protocol",
"provider",
"reqwest",
"schemars",
"serde",
"serde_json",
"session-metrics",
"session-store",
"tempfile",
"thiserror 2.0.18",
"ticket",
"tokio",
"tokio-tungstenite",
"toml",
"tools",
"tracing",
"tungstenite",
"uuid",
"wasmtime",
"wat",
"workflow",
"yoi-plugin-pdk",
]
[[package]] [[package]]
name = "pod-registry" name = "pod-registry"
version = "0.1.0" version = "0.1.0"
@ -5901,6 +5855,52 @@ dependencies = [
"wasmparser 0.248.0", "wasmparser 0.248.0",
] ]
[[package]]
name = "worker"
version = "0.1.0"
dependencies = [
"arc-swap",
"async-trait",
"chrono",
"clap",
"client",
"dotenv",
"fs4",
"futures",
"futures-util",
"include_dir",
"libc",
"llm-engine",
"manifest",
"mcp",
"memory",
"minijinja",
"pod-registry",
"pod-store",
"protocol",
"provider",
"reqwest",
"schemars",
"serde",
"serde_json",
"session-metrics",
"session-store",
"tempfile",
"thiserror 2.0.18",
"ticket",
"tokio",
"tokio-tungstenite",
"toml",
"tools",
"tracing",
"tungstenite",
"uuid",
"wasmtime",
"wat",
"workflow",
"yoi-plugin-pdk",
]
[[package]] [[package]]
name = "workflow" name = "workflow"
version = "0.1.0" version = "0.1.0"
@ -5942,7 +5942,6 @@ dependencies = [
"client", "client",
"manifest", "manifest",
"memory", "memory",
"pod",
"pod-store", "pod-store",
"project-record", "project-record",
"serde", "serde",
@ -5954,6 +5953,7 @@ dependencies = [
"ticket", "ticket",
"tokio", "tokio",
"tui", "tui",
"worker",
] ]
[[package]] [[package]]

View File

@ -8,7 +8,7 @@ members = [
"crates/secrets", "crates/secrets",
"crates/manifest", "crates/manifest",
"crates/mcp", "crates/mcp",
"crates/pod", "crates/worker",
"crates/plugin-pdk", "crates/plugin-pdk",
"crates/yoi", "crates/yoi",
"crates/pod-store", "crates/pod-store",
@ -35,7 +35,7 @@ default-members = [
"crates/secrets", "crates/secrets",
"crates/manifest", "crates/manifest",
"crates/mcp", "crates/mcp",
"crates/pod", "crates/worker",
"crates/plugin-pdk", "crates/plugin-pdk",
"crates/yoi", "crates/yoi",
"crates/pod-store", "crates/pod-store",
@ -69,7 +69,7 @@ lint-common = { path = "crates/lint-common" }
memory = { path = "crates/memory" } memory = { path = "crates/memory" }
ticket = { path = "crates/ticket" } ticket = { path = "crates/ticket" }
project-record = { path = "crates/project-record" } project-record = { path = "crates/project-record" }
pod = { path = "crates/pod" } worker = { path = "crates/worker" }
yoi-plugin-pdk = { path = "crates/plugin-pdk" } yoi-plugin-pdk = { path = "crates/plugin-pdk" }
yoi = { path = "crates/yoi" } yoi = { path = "crates/yoi" }
pod-registry = { path = "crates/pod-registry" } pod-registry = { path = "crates/pod-registry" }

View File

@ -2,7 +2,7 @@
Ticket を切るほどではないが、次に近所を触るときに合わせて拾いたい小粒な所見の置き場。 Ticket を切るほどではないが、次に近所を触るときに合わせて拾いたい小粒な所見の置き場。
- `crates/pod/src/controller.rs:1269-1278` — `worker_error_code``PodError::WorkflowResolve(_) => InvalidRequest` が post-commit な resolve エラー (`KnowledgeNotFound` 等) にも適用される。意味論的には妥当方向だが、resolve 系のエラー粒度を分けたくなったタイミングで再評価。 - `crates/worker/src/controller.rs:1453-1461` — `worker_error_code``WorkerError::WorkflowResolve(_) => InvalidRequest` が post-commit な resolve エラー (`KnowledgeNotFound` 等) にも適用される。意味論的には妥当方向だが、resolve 系のエラー粒度を分けたくなったタイミングで再評価。
- `crates/session-store/src/fs_store.rs:200-210``FsStore::read_entry_count``fs::read_to_string` で全文ロードしてから行数カウントするため O(n)。`ensure_head_or_fork` は run-start でしか呼ばれず現状は許容範囲だが、長期セッションが普通になった時点で `\n` バイト数の cheap count か末尾 seek に置き換える。 - `crates/session-store/src/fs_store.rs:200-210``FsStore::read_entry_count``fs::read_to_string` で全文ロードしてから行数カウントするため O(n)。`ensure_head_or_fork` は run-start でしか呼ばれず現状は許容範囲だが、長期セッションが普通になった時点で `\n` バイト数の cheap count か末尾 seek に置き換える。
- `crates/session-store/src/segment.rs:143-172` `ensure_head_or_fork` (free fn, test 専用・本番 caller ゼロ) と `crates/pod/src/pod.rs:1941-2006` `Pod::ensure_segment_head` (本番 inline) に live auto-fork の検知 + forked_from 記録が二重実装されている。entry-hash-abolish 以前からの重複で、両方独立にテスト済みだが drift 必至。session-store 側を本番から呼ぶ形に寄せるか free fn を畳むかは要設計判断。Pod state / fork 周辺を次に触るときに統合を検討。 - `crates/session-store/src/segment.rs:143-172` `ensure_head_or_fork` (free fn, test 専用・本番 caller ゼロ) と `crates/worker/src/worker.rs:2032` `Worker::ensure_segment_head` (本番 inline) に live auto-fork の検知 + forked_from 記録が二重実装されている。entry-hash-abolish 以前からの重複で、両方独立にテスト済みだが drift 必至。session-store 側を本番から呼ぶ形に寄せるか free fn を畳むかは要設計判断。Worker state / fork 周辺を次に触るときに統合を検討。
- `crates/pod/src/pod.rs:4100-4147` / `crates/pod/src/spawn/registry.rs:84-174` — restore 時の spawned child prune/reclaim が Pod restore path と spawned registry load path の両方に残っている。現状は安全側の重複チェックだが、Pod state / spawned registry 周辺を次に触るときに責務境界を再整理。 - `crates/worker/src/worker.rs` / `crates/worker/src/spawn/registry.rs:84-174` — restore 時の spawned child prune/reclaim が Worker restore path と spawned registry load path の両方に残っている。現状は安全側の重複チェックだが、Worker state / spawned registry 周辺を次に触るときに責務境界を再整理。

View File

@ -1,19 +1,19 @@
# 夜居 / Yoi agent # 夜居 / Yoi agent
Yoi is an agent runtime for building, running, and orchestrating LLM Pods while preserving explicit history, scoped capabilities, and developer-controlled workflows. Yoi is an agent runtime for building, running, and orchestrating LLM Workers while preserving explicit history, scoped capabilities, and developer-controlled workflows.
## 1. Yoi agent ## 1. Yoi agent
Yoi focuses on long-running agent operation rather than one-off prompt execution. A named Pod can keep durable session history, run with explicit tool and filesystem authority, delegate bounded work to child Pods, and be inspected or restored through CLI/TUI surfaces. Yoi focuses on long-running agent operation rather than one-off prompt execution. A named Worker can keep durable session history, run with explicit tool and filesystem authority, delegate bounded work to child Workers, and be inspected or restored through CLI/TUI surfaces.
Main highlights: Main highlights:
- Named long-running **Pods** with durable session and metadata records. - Named long-running **Workers** with durable session and metadata records.
- Explicit tool permissions and filesystem scopes. - Explicit tool permissions and filesystem scopes.
- Multi-agent orchestration with scoped coder/reviewer Pods. - Multi-agent orchestration with scoped coder/reviewer Workers.
- Profile, Manifest, and prompt-based runtime configuration. - Profile, Manifest, and prompt-based runtime configuration.
- Local Tickets and workflow files for auditable project coordination. - Local Tickets and workflow files for auditable project coordination.
- TUI and CLI entry points, including the `yoi panel` workspace Dashboard and single-Pod Console. - TUI and CLI entry points, including the `yoi panel` workspace Dashboard and single-Worker Console.
Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change. Yoi is actively dogfooded in this repository. Public APIs, configuration formats, and workflows may still change.
@ -39,14 +39,14 @@ nix build .#yoi
yoi --help yoi --help
yoi yoi
yoi panel yoi panel
yoi --pod <name> yoi --worker <name>
yoi pod --help yoi worker --help
``` ```
Typical flow: Typical flow:
1. Configure providers, models, profiles, prompts, and scopes. 1. Configure providers, models, profiles, prompts, and scopes.
2. Start or attach to a named Pod in the Console, or inspect workspace activity in the Dashboard. 2. Start or attach to a named Worker in the Console, or inspect workspace activity in the Dashboard.
3. Use explicit tools and scoped delegation for multi-agent work. 3. Use explicit tools and scoped delegation for multi-agent work.
4. Record project work through Tickets, workflow files, and git history. 4. Record project work through Tickets, workflow files, and git history.
@ -60,7 +60,7 @@ Key docs:
- [`docs/design/overview.md`](docs/design/overview.md) — architecture and crate ownership map. - [`docs/design/overview.md`](docs/design/overview.md) — architecture and crate ownership map.
- [`docs/design/context-history.md`](docs/design/context-history.md) — history/context invariants. - [`docs/design/context-history.md`](docs/design/context-history.md) — history/context invariants.
- [`docs/design/pod-session-state.md`](docs/design/pod-session-state.md) — Pod identity, metadata, and session logs. - [`docs/design/worker-session-state.md`](docs/design/worker-session-state.md) — Worker identity, metadata, and session logs.
- [`docs/design/profiles-manifests-prompts.md`](docs/design/profiles-manifests-prompts.md) — Profiles, Manifests, and prompt resources. - [`docs/design/profiles-manifests-prompts.md`](docs/design/profiles-manifests-prompts.md) — Profiles, Manifests, and prompt resources.
- [`docs/design/tool-permissions-scope.md`](docs/design/tool-permissions-scope.md) — tool policy and filesystem scope. - [`docs/design/tool-permissions-scope.md`](docs/design/tool-permissions-scope.md) — tool policy and filesystem scope.
- [`docs/development/work-items.md`](docs/development/work-items.md) — Ticket workflow and project records. - [`docs/development/work-items.md`](docs/development/work-items.md) — Ticket workflow and project records.

View File

@ -2,13 +2,13 @@
## Role ## Role
`client` contains reusable socket-client and runtime-command mechanics for talking to Pods from CLI/TUI code. `client` contains reusable socket-client and runtime-command mechanics for talking to Workers from CLI/TUI code.
## Boundaries ## Boundaries
Owns: Owns:
- one-shot Pod socket client behavior - one-shot Worker socket client behavior
- request/reply delivery mechanics - request/reply delivery mechanics
- runtime command construction below the product façade - runtime command construction below the product façade
- shared attach/status probing helpers used by higher layers - shared attach/status probing helpers used by higher layers
@ -16,15 +16,15 @@ Owns:
Does not own: Does not own:
- product command names (`yoi`) - product command names (`yoi`)
- Pod state authority (`pod`, `pod-store`, `session-store`) - Worker state authority (`worker`, `pod-store`, `session-store`)
- UI rendering (`tui`) - UI rendering (`tui`)
- Engine turn semantics (`llm-engine`) - Engine turn semantics (`llm-engine`)
## Design notes ## Design notes
The client boundary lets `tui` and `yoi` share Pod communication without making library crates depend on the product binary. Socket clients should drain connect-time snapshot/alert traffic before sending a method or deciding status. The client boundary lets `tui` and `yoi` share Worker communication without making library crates depend on the product binary. Socket clients should drain connect-time snapshot/alert traffic before sending a method or deciding status.
## See also ## See also
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md) - [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
- [`../../docs/design/overview.md`](../../docs/design/overview.md) - [`../../docs/design/overview.md`](../../docs/design/overview.md)

View File

@ -1,28 +1,28 @@
//! Pod プロトコルを喋るクライアント。 //! Worker プロトコルを喋るクライアント。
//! //!
//! - [`PodClient`]: 既存 pod の Unix ソケットへ接続して `Method` を送り、 //! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、
//! `Event` を受け取る低レベル接続。 //! `Event` を受け取る低レベル接続。
//! - [`spawn`]: pod バイナリをサブプロセスとして起動し、`YOI-READY` //! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY`
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が //! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
//! ない呼び出し側 (=既存 pod に attach する場合) は使わなくてよい。 //! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。
//! //!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 //! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
mod pod_client;
pub mod runtime_command; pub mod runtime_command;
pub mod spawn; pub mod spawn;
pub mod ticket_role; 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::{ pub use spawn::{
PodProcessLaunchConfig, PodProcessLaunchOptions, SpawnConfig, SpawnError, SpawnReady, SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
spawn_pod, spawn_pod_with_options, spawn_worker, spawn_worker_with_options,
}; };
pub use ticket_role::{ pub use ticket_role::{
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions, TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning, launch_ticket_role_pod, TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning,
launch_ticket_role_pod_with_options, plan_ticket_role_launch, launch_ticket_role_worker, launch_ticket_role_worker_with_options, plan_ticket_role_launch,
plan_ticket_role_launch_with_config, plan_ticket_role_launch_with_config,
}; };
pub use worker_client::WorkerClient;

View File

@ -6,12 +6,12 @@ use std::path::{Path, PathBuf};
const POD_RUNTIME_COMMAND_ENV: &str = "YOI_POD_RUNTIME_COMMAND"; const POD_RUNTIME_COMMAND_ENV: &str = "YOI_POD_RUNTIME_COMMAND";
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct PodRuntimeCommand { pub struct WorkerRuntimeCommand {
pub program: PathBuf, pub program: PathBuf,
pub prefix_args: Vec<OsString>, pub prefix_args: Vec<OsString>,
} }
impl PodRuntimeCommand { impl WorkerRuntimeCommand {
pub fn new(program: impl Into<PathBuf>, prefix_args: Vec<OsString>) -> Self { pub fn new(program: impl Into<PathBuf>, prefix_args: Vec<OsString>) -> Self {
Self { Self {
program: program.into(), program: program.into(),
@ -24,15 +24,15 @@ impl PodRuntimeCommand {
} }
pub fn for_executable(program: impl Into<PathBuf>) -> Self { 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 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; /// `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. /// shell command.
pub fn resolve() -> io::Result<Self> { pub fn resolve() -> io::Result<Self> {
Self::resolve_from_env_value( 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 { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.program.display())?; write!(f, "{}", self.program.display())?;
for arg in &self.prefix_args { for arg in &self.prefix_args {
@ -89,14 +89,14 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn yoi_binary_defaults_to_pod_prefix() { fn yoi_binary_defaults_to_worker_prefix() {
let command = PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi"); let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi");
assert_eq!(command.program(), Path::new("/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!( assert_eq!(
command.argv_with(["--pod", "agent"]), command.argv_with(["--worker", "agent"]),
vec!["pod", "--pod", "agent"] vec!["worker", "--worker", "agent"]
.into_iter() .into_iter()
.map(OsString::from) .map(OsString::from)
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -104,14 +104,14 @@ mod tests {
} }
#[test] #[test]
fn any_runtime_executable_gets_pod_prefix() { fn any_runtime_executable_gets_worker_prefix() {
let command = PodRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime"); let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime");
assert_eq!(command.program(), Path::new("/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!( assert_eq!(
command.argv_with(["--pod", "agent"]), command.argv_with(["--worker", "agent"]),
vec!["pod", "--pod", "agent"] vec!["worker", "--worker", "agent"]
.into_iter() .into_iter()
.map(OsString::from) .map(OsString::from)
.collect::<Vec<_>>() .collect::<Vec<_>>()
@ -120,43 +120,43 @@ mod tests {
#[test] #[test]
fn resolve_uses_current_exe_when_override_is_unset() { 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")) Ok(PathBuf::from("/opt/yoi/bin/yoi"))
}) })
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
command, command,
PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi") WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
); );
} }
#[test] #[test]
fn resolve_uses_current_exe_when_override_is_empty() { 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")) Ok(PathBuf::from("/opt/yoi/bin/yoi"))
}) })
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
command, command,
PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi") WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
); );
} }
#[test] #[test]
fn resolve_override_replaces_only_program_and_keeps_pod_prefix() { fn resolve_override_replaces_only_program_and_keeps_worker_prefix() {
let command = PodRuntimeCommand::resolve_from_env_value( let command = WorkerRuntimeCommand::resolve_from_env_value(
Some(OsString::from("/tmp/rebuilt yoi")), Some(OsString::from("/tmp/rebuilt yoi")),
|| panic!("override must not inspect current_exe"), || panic!("override must not inspect current_exe"),
) )
.unwrap(); .unwrap();
assert_eq!(command.program(), Path::new("/tmp/rebuilt yoi")); 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!( assert_eq!(
command.argv_with(["--pod", "agent"]), command.argv_with(["--worker", "agent"]),
vec!["pod", "--pod", "agent"] vec!["worker", "--worker", "agent"]
.into_iter() .into_iter()
.map(OsString::from) .map(OsString::from)
.collect::<Vec<_>>() .collect::<Vec<_>>()

View File

@ -1,13 +1,13 @@
//! Pod runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ //! Worker runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ
//! ハンドシェイク。 //! ハンドシェイク。
//! //!
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を //! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
//! 指定してこの関数に渡す。pod はそれを受けて socket を bind し、stderr に //! 指定してこの関数に渡す。worker はそれを受けて socket を bind し、stderr に
//! `YOI-READY\t<name>\t<socket>` を吐く。 //! `YOI-READY\t<name>\t<socket>` を吐く。
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。 //! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
//! UI の進捗表示や E2E のログ収集はここで賄う。 //! UI の進捗表示や E2E のログ収集はここで賄う。
//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス //! - `kill_on_drop = false` + `process_group(0)` により、親プロセス
//! ライフサイクルから切り離した detached pod を作る。ready 後の lifecycle //! ライフサイクルから切り離した detached worker を作る。ready 後の lifecycle
//! 管理は runtime ディレクトリ / socket を介して行う。 //! 管理は runtime ディレクトリ / socket を介して行う。
use std::io; use std::io;
@ -15,7 +15,7 @@ use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use std::time::Duration; use std::time::Duration;
use crate::PodRuntimeCommand; use crate::WorkerRuntimeCommand;
use tokio::process::Command; use tokio::process::Command;
use uuid::Uuid; use uuid::Uuid;
@ -23,14 +23,14 @@ const READY_PREFIX: &str = "YOI-READY\t";
const READY_TIMEOUT: Duration = Duration::from_secs(20); const READY_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PodProcessLaunchConfig { pub struct WorkerProcessLaunchConfig {
pub runtime_command: PodRuntimeCommand, pub runtime_command: WorkerRuntimeCommand,
/// `pod.name` として使う識別子。runtime ディレクトリ /// `worker.name` として使う識別子。runtime ディレクトリ
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る /// (`manifest::paths::worker_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。 /// 名前との突き合わせに使う。
pub pod_name: String, pub worker_name: String,
/// Optional reusable Profile selector. Pod identity is always supplied /// Optional reusable Profile selector. Worker identity is always supplied
/// separately with `--pod`; profile selection must not imply a name. /// separately with `--worker`; profile selection must not imply a name.
pub profile: Option<String>, pub profile: Option<String>,
/// Explicit runtime workspace root. The child receives it via /// Explicit runtime workspace root. The child receives it via
/// `--workspace` so startup does not infer workspace identity from the /// `--workspace` so startup does not infer workspace identity from the
@ -46,7 +46,7 @@ pub struct PodProcessLaunchConfig {
} }
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PodProcessLaunchOptions { pub struct WorkerProcessLaunchOptions {
/// Extra child CLI arguments supplied by an upper resolver layer. The /// Extra child CLI arguments supplied by an upper resolver layer. The
/// low-level launch config intentionally does not model Ticket IDs, /// low-level launch config intentionally does not model Ticket IDs,
/// Ticket roles, orchestration roles, executable authority, or raw /// Ticket roles, orchestration roles, executable authority, or raw
@ -54,7 +54,7 @@ pub struct PodProcessLaunchOptions {
pub extra_args: Vec<String>, 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 { 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.extra_args.extend([name.into(), value.into()]);
self self
@ -65,11 +65,11 @@ impl PodProcessLaunchOptions {
} }
} }
pub type SpawnConfig = PodProcessLaunchConfig; pub type SpawnConfig = WorkerProcessLaunchConfig;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnReady { pub struct SpawnReady {
pub pod_name: String, pub worker_name: String,
pub socket_path: PathBuf, pub socket_path: PathBuf,
} }
@ -78,11 +78,11 @@ pub enum SpawnError {
Io(io::Error), Io(io::Error),
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。 /// runtime ディレクトリが解決できなかった (環境変数未設定等)。
RuntimeDirUnavailable, RuntimeDirUnavailable,
PodLaunchFailed { WorkerLaunchFailed {
command: PodRuntimeCommand, command: WorkerRuntimeCommand,
source: io::Error, source: io::Error,
}, },
PodExitedEarly { WorkerExitedEarly {
stderr_tail: String, stderr_tail: String,
}, },
Timeout, Timeout,
@ -96,20 +96,20 @@ impl std::fmt::Display for SpawnError {
f, f,
"could not resolve runtime directory (set YOI_HOME, YOI_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)" "could not resolve runtime directory (set YOI_HOME, YOI_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)"
), ),
Self::PodLaunchFailed { command, source } => write!( Self::WorkerLaunchFailed { command, source } => write!(
f, 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() { if stderr_tail.is_empty() {
write!(f, "pod exited before becoming ready") write!(f, "worker exited before becoming ready")
} else { } else {
write!(f, "pod exited before becoming ready: {stderr_tail}") write!(f, "worker exited before becoming ready: {stderr_tail}")
} }
} }
Self::Timeout => write!( Self::Timeout => write!(
f, f,
"pod did not become ready within {}s", "worker did not become ready within {}s",
READY_TIMEOUT.as_secs() READY_TIMEOUT.as_secs()
), ),
} }
@ -119,8 +119,8 @@ impl std::fmt::Display for SpawnError {
impl std::error::Error for SpawnError { impl std::error::Error for SpawnError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self { match self {
Self::Io(error) | Self::PodLaunchFailed { source: error, .. } => Some(error), Self::Io(error) | Self::WorkerLaunchFailed { 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![ let mut args = vec![
"--workspace".to_string(), "--workspace".to_string(),
config.workspace_root.display().to_string(), config.workspace_root.display().to_string(),
@ -140,11 +143,11 @@ fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptio
args.extend([ args.extend([
"--session".to_string(), "--session".to_string(),
id.to_string(), id.to_string(),
"--pod".to_string(), "--worker".to_string(),
config.pod_name.clone(), config.worker_name.clone(),
]); ]);
} else { } 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 { if let Some(profile) = &config.profile {
args.extend(["--profile".to_string(), profile.clone()]); args.extend(["--profile".to_string(), profile.clone()]);
} }
@ -153,32 +156,32 @@ fn runtime_args(config: &PodProcessLaunchConfig, options: &PodProcessLaunchOptio
args args
} }
/// pod を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。 /// worker を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。
/// ///
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる /// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。 /// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
pub async fn spawn_pod<F>( pub async fn spawn_worker<F>(
config: PodProcessLaunchConfig, config: WorkerProcessLaunchConfig,
progress: F, progress: F,
) -> Result<SpawnReady, SpawnError> ) -> Result<SpawnReady, SpawnError>
where where
F: FnMut(&str), 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>( pub async fn spawn_worker_with_options<F>(
config: PodProcessLaunchConfig, config: WorkerProcessLaunchConfig,
options: PodProcessLaunchOptions, options: WorkerProcessLaunchOptions,
mut progress: F, mut progress: F,
) -> Result<SpawnReady, SpawnError> ) -> Result<SpawnReady, SpawnError>
where where
F: FnMut(&str), 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)?; .ok_or(SpawnError::RuntimeDirUnavailable)?;
std::fs::create_dir_all(&pod_runtime_dir).map_err(SpawnError::Io)?; std::fs::create_dir_all(&worker_runtime_dir).map_err(SpawnError::Io)?;
let stderr_path = pod_runtime_dir.join("stderr.log"); let stderr_path = worker_runtime_dir.join("stderr.log");
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?; let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
let mut command = Command::new(config.runtime_command.program()); let mut command = Command::new(config.runtime_command.program());
@ -194,15 +197,15 @@ where
} }
let mut child = command let mut child = command
.spawn() .spawn()
.map_err(|source| SpawnError::PodLaunchFailed { .map_err(|source| SpawnError::WorkerLaunchFailed {
command: config.runtime_command.clone(), command: config.runtime_command.clone(),
source, source,
})?; })?;
// Default `kill_on_drop = false` plus `process_group(0)` makes this // 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 // 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. // the source of truth after that point.
let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await { let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await {
Ok(ready) => ready, Ok(ready) => ready,
@ -240,10 +243,10 @@ where
for line in content[offset..].lines() { for line in content[offset..].lines() {
if let Some(rest) = line.strip_prefix(READY_PREFIX) { if let Some(rest) = line.strip_prefix(READY_PREFIX) {
let mut parts = rest.splitn(2, '\t'); 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(); let socket_str = parts.next().unwrap_or("").to_string();
if pod_name.is_empty() || socket_str.is_empty() { if worker_name.is_empty() || socket_str.is_empty() {
return Err(SpawnError::PodExitedEarly { return Err(SpawnError::WorkerExitedEarly {
stderr_tail: format!("malformed ready line: {line}"), stderr_tail: format!("malformed ready line: {line}"),
}); });
} }
@ -258,7 +261,7 @@ where
) )
.await?; .await?;
return Ok(SpawnReady { return Ok(SpawnReady {
pod_name, worker_name,
socket_path, socket_path,
}); });
} }
@ -274,11 +277,11 @@ where
tokio::select! { tokio::select! {
status = child.wait() => { status = child.wait() => {
let _ = status; let _ = status;
// Pod は exit 直前に最終 stderr 行を flush することがある。 // Worker は exit 直前に最終 stderr 行を flush することがある。
// child.wait() が解決した後に再読みして、原因行を取りこ // child.wait() が解決した後に再読みして、原因行を取りこ
// ぼさず PodExitedEarly に載せる。 // ぼさず WorkerExitedEarly に載せる。
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await; drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
return Err(SpawnError::PodExitedEarly { return Err(SpawnError::WorkerExitedEarly {
stderr_tail: tail.into_string(), stderr_tail: tail.into_string(),
}); });
} }
@ -310,7 +313,7 @@ async fn wait_for_socket(
status = child.wait() => { status = child.wait() => {
let _ = status; let _ = status;
drain_stderr_into_tail(stderr_path, tail, offset).await; drain_stderr_into_tail(stderr_path, tail, offset).await;
return Err(SpawnError::PodExitedEarly { return Err(SpawnError::WorkerExitedEarly {
stderr_tail: tail.as_string(), stderr_tail: tail.as_string(),
}); });
} }
@ -363,10 +366,10 @@ mod tests {
use super::*; use super::*;
use std::ffi::OsString; use std::ffi::OsString;
fn base_config() -> PodProcessLaunchConfig { fn base_config() -> WorkerProcessLaunchConfig {
PodProcessLaunchConfig { WorkerProcessLaunchConfig {
runtime_command: PodRuntimeCommand::new("/bin/yoi", vec![OsString::from("pod")]), runtime_command: WorkerRuntimeCommand::new("/bin/yoi", vec![OsString::from("worker")]),
pod_name: "explicit-pod".to_string(), worker_name: "explicit-worker".to_string(),
profile: Some("project:companion".to_string()), profile: Some("project:companion".to_string()),
workspace_root: PathBuf::from("/work/other-project"), workspace_root: PathBuf::from("/work/other-project"),
cwd: None, cwd: None,
@ -375,14 +378,14 @@ mod tests {
} }
#[test] #[test]
fn runtime_args_keep_workspace_pod_and_profile_separate() { fn runtime_args_keep_workspace_worker_and_profile_separate() {
assert_eq!( assert_eq!(
runtime_args(&base_config(), &PodProcessLaunchOptions::default()), runtime_args(&base_config(), &WorkerProcessLaunchOptions::default()),
vec![ vec![
"--workspace", "--workspace",
"/work/other-project", "/work/other-project",
"--pod", "--worker",
"explicit-pod", "explicit-worker",
"--profile", "--profile",
"project:companion", "project:companion",
] ]
@ -394,14 +397,14 @@ mod tests {
let mut config = base_config(); let mut config = base_config();
config.resume_from = Some(Uuid::nil()); config.resume_from = Some(Uuid::nil());
assert_eq!( assert_eq!(
runtime_args(&config, &PodProcessLaunchOptions::default()), runtime_args(&config, &WorkerProcessLaunchOptions::default()),
vec![ vec![
"--workspace", "--workspace",
"/work/other-project", "/work/other-project",
"--session", "--session",
"00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000000",
"--pod", "--worker",
"explicit-pod", "explicit-worker",
] ]
); );
} }
@ -414,14 +417,14 @@ mod tests {
assert_eq!( assert_eq!(
runtime_args( runtime_args(
&config, &config,
&PodProcessLaunchOptions::default() &WorkerProcessLaunchOptions::default()
.with_hidden_arg("--ticket-role", "orchestrator"), .with_hidden_arg("--ticket-role", "orchestrator"),
), ),
vec![ vec![
"--workspace", "--workspace",
"/work/other-project", "/work/other-project",
"--pod", "--worker",
"explicit-pod", "explicit-worker",
"--profile", "--profile",
"project:companion", "project:companion",
"--ticket-role", "--ticket-role",

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 //! 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 //! host-side Worker spawning behind the `client` crate so UI callers do not need to
//! depend on `pod` internals. //! depend on `worker` internals.
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -15,8 +15,8 @@ pub use ticket::config::TicketRole;
use ticket::config::{TicketConfig, TicketConfigError, TicketRoleLaunchConfigError}; use ticket::config::{TicketConfig, TicketConfigError, TicketRoleLaunchConfigError};
use crate::{ use crate::{
PodClient, PodProcessLaunchConfig, PodProcessLaunchOptions, PodRuntimeCommand, SpawnError, SpawnError, SpawnReady, WorkerClient, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
SpawnReady, spawn_pod_with_options, WorkerRuntimeCommand, spawn_worker_with_options,
}; };
const MAX_FIELD_CHARS: usize = 8_000; 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()) non_empty(self.id.as_deref())
} }
@ -55,14 +55,17 @@ impl TicketRef {
/// Auditable panel handoff target included in a Ticket Intake launch. /// Auditable panel handoff target included in a Ticket Intake launch.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketIntakeHandoff { pub struct TicketIntakeHandoff {
pub orchestrator_pod: String, pub workspace_orchestrator_worker: String,
pub workspace_label: String, pub workspace_label: String,
} }
impl TicketIntakeHandoff { 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 { Self {
orchestrator_pod: orchestrator_pod.into(), workspace_orchestrator_worker: workspace_orchestrator_worker.into(),
workspace_label: workspace_label.into(), workspace_label: workspace_label.into(),
} }
} }
@ -70,7 +73,11 @@ impl TicketIntakeHandoff {
fn append_submit_lines(&self, out: &mut String) { fn append_submit_lines(&self, out: &mut String) {
out.push_str("\nPanel handoff:\n"); out.push_str("\nPanel handoff:\n");
push_bounded_bullet(out, "workspace", &self.workspace_label); 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 original_workspace_root: Option<PathBuf>,
pub target_workspace_root: Option<PathBuf>, pub target_workspace_root: Option<PathBuf>,
pub role: TicketRole, pub role: TicketRole,
pub pod_name: Option<String>, pub worker_name: Option<String>,
pub ticket: Option<TicketRef>, pub ticket: Option<TicketRef>,
pub user_instruction: Option<String>, pub user_instruction: Option<String>,
pub intake_handoff: Option<TicketIntakeHandoff>, pub intake_handoff: Option<TicketIntakeHandoff>,
@ -102,7 +109,7 @@ impl TicketRoleLaunchContext {
original_workspace_root: None, original_workspace_root: None,
target_workspace_root: None, target_workspace_root: None,
role, role,
pod_name: None, worker_name: None,
ticket: None, ticket: None,
user_instruction: None, user_instruction: None,
intake_handoff: None, intake_handoff: None,
@ -156,7 +163,7 @@ pub struct TicketRoleLaunchPlan {
pub target_workspace_root: PathBuf, pub target_workspace_root: PathBuf,
pub implementation_worktree_root: PathBuf, pub implementation_worktree_root: PathBuf,
pub role: TicketRole, pub role: TicketRole,
pub pod_name: String, pub worker_name: String,
pub profile: String, pub profile: String,
pub workflow: String, pub workflow: String,
pub launch_prompt_ref: Option<String>, pub launch_prompt_ref: Option<String>,
@ -172,14 +179,14 @@ impl TicketRoleLaunchPlan {
pub fn spawn_config( pub fn spawn_config(
&self, &self,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<PodProcessLaunchConfig, TicketRoleLaunchError> { ) -> Result<WorkerProcessLaunchConfig, TicketRoleLaunchError> {
if self.profile == "inherit" { if self.profile == "inherit" {
return Err(TicketRoleLaunchError::UnsupportedInheritProfile); return Err(TicketRoleLaunchError::UnsupportedInheritProfile);
} }
Ok(PodProcessLaunchConfig { Ok(WorkerProcessLaunchConfig {
runtime_command, runtime_command,
pod_name: self.pod_name.clone(), worker_name: self.worker_name.clone(),
profile: Some(self.profile.clone()), profile: Some(self.profile.clone()),
workspace_root: self.workspace_root.clone(), workspace_root: self.workspace_root.clone(),
cwd: self.cwd.clone(), cwd: self.cwd.clone(),
@ -187,8 +194,8 @@ impl TicketRoleLaunchPlan {
}) })
} }
pub fn spawn_options(&self) -> PodProcessLaunchOptions { pub fn spawn_options(&self) -> WorkerProcessLaunchOptions {
PodProcessLaunchOptions::default() WorkerProcessLaunchOptions::default()
.with_hidden_arg("--ticket-role", self.role.as_str().to_string()) .with_hidden_arg("--ticket-role", self.role.as_str().to_string())
} }
} }
@ -208,7 +215,7 @@ pub struct TicketRoleLaunchResult {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketRoleLaunchAcceptanceEvidence { pub struct TicketRoleLaunchAcceptanceEvidence {
pub pod_name: String, pub worker_name: String,
pub accepted_run_segments: usize, pub accepted_run_segments: usize,
pub event: TicketRoleLaunchAcceptanceEvent, pub event: TicketRoleLaunchAcceptanceEvent,
} }
@ -233,8 +240,8 @@ pub struct TicketRoleLaunchOptions {
} }
impl TicketRoleLaunchOptions { impl TicketRoleLaunchOptions {
pub fn with_pre_run_peer_registration(mut self, pod_name: impl Into<String>) -> Self { pub fn with_pre_run_peer_registration(mut self, worker_name: impl Into<String>) -> Self {
self.pre_run_peer_registrations.push(pod_name.into()); self.pre_run_peer_registrations.push(worker_name.into());
self self
} }
} }
@ -253,30 +260,30 @@ pub enum TicketRoleLaunchError {
selector: String, selector: String,
message: String, message: String,
}, },
#[error("Ticket role Pod name must not be empty")] #[error("Ticket role Worker name must not be empty")]
EmptyPodName, EmptyWorkerName,
#[error( #[error(
"Ticket role profile 'inherit' cannot be used for top-level launch execution; configure a concrete role profile selector" "Ticket role profile 'inherit' cannot be used for top-level launch execution; configure a concrete role profile selector"
)] )]
UnsupportedInheritProfile, UnsupportedInheritProfile,
#[error(transparent)] #[error(transparent)]
Spawn(#[from] SpawnError), 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 { Connect {
socket_path: PathBuf, socket_path: PathBuf,
#[source] #[source]
source: io::Error, 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 { SendRun {
#[source] #[source]
source: io::Error, 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 }, 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, 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, RunAcceptanceTimeout,
} }
@ -303,12 +310,17 @@ pub fn plan_ticket_role_launch_with_config(
.launch_prompt .launch_prompt
.as_ref() .as_ref()
.map(|prompt| prompt.as_str().to_string()); .map(|prompt| prompt.as_str().to_string());
let pod_name = match context.pod_name.as_deref().map(str::trim) { let worker_name = match context.worker_name.as_deref().map(str::trim) {
Some("") => return Err(TicketRoleLaunchError::EmptyPodName), Some("") => return Err(TicketRoleLaunchError::EmptyWorkerName),
Some(name) => name.to_string(), 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 prompt = build_launch_prompt(&context);
let original_workspace_root = context.original_workspace_root().to_path_buf(); 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, target_workspace_root,
implementation_worktree_root, implementation_worktree_root,
role: context.role, role: context.role,
pod_name, worker_name,
profile, profile,
workflow: workflow.clone(), workflow: workflow.clone(),
launch_prompt_ref, launch_prompt_ref,
@ -339,7 +351,7 @@ fn validate_ticket_role_profile(
role: TicketRole, role: TicketRole,
profile: &str, profile: &str,
workspace_root: &std::path::Path, workspace_root: &std::path::Path,
pod_name: &str, worker_name: &str,
) -> Result<(), TicketRoleLaunchError> { ) -> Result<(), TicketRoleLaunchError> {
let selector = ProfileSelector::parse_cli(profile); let selector = ProfileSelector::parse_cli(profile);
let registry = ProfileDiscovery::for_cwd(workspace_root) let registry = ProfileDiscovery::for_cwd(workspace_root)
@ -354,7 +366,7 @@ fn validate_ticket_role_profile(
.resolve_from_registry( .resolve_from_registry(
&selector, &selector,
&registry, &registry,
ProfileResolveOptions::with_pod_name(pod_name), ProfileResolveOptions::with_worker_name(worker_name),
) )
.map(|_| ()) .map(|_| ())
.map_err(|source| TicketRoleLaunchError::ProfileResolution { .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, /// Spawn the Worker, connect to its socket, send the first `Method::Run` input,
/// and wait for bounded acceptance evidence from the Pod event stream. /// and wait for bounded acceptance evidence from the Worker event stream.
pub async fn launch_ticket_role_pod<F>( pub async fn launch_ticket_role_worker<F>(
context: TicketRoleLaunchContext, context: TicketRoleLaunchContext,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
progress: F, progress: F,
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError> ) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
where where
F: FnMut(&str), F: FnMut(&str),
{ {
launch_ticket_role_pod_with_options( launch_ticket_role_worker_with_options(
context, context,
runtime_command, runtime_command,
progress, progress,
@ -383,11 +395,11 @@ where
.await .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. /// 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, context: TicketRoleLaunchContext,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
progress: F, progress: F,
options: TicketRoleLaunchOptions, options: TicketRoleLaunchOptions,
) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError> ) -> Result<TicketRoleLaunchResult, TicketRoleLaunchError>
@ -397,8 +409,8 @@ where
let plan = plan_ticket_role_launch(context)?; let plan = plan_ticket_role_launch(context)?;
let spawn_config = plan.spawn_config(runtime_command)?; let spawn_config = plan.spawn_config(runtime_command)?;
let spawn_options = plan.spawn_options(); let spawn_options = plan.spawn_options();
let ready = spawn_pod_with_options(spawn_config, spawn_options, progress).await?; let ready = spawn_worker_with_options(spawn_config, spawn_options, progress).await?;
let mut client = PodClient::connect(&ready.socket_path) let mut client = WorkerClient::connect(&ready.socket_path)
.await .await
.map_err(|source| TicketRoleLaunchError::Connect { .map_err(|source| TicketRoleLaunchError::Connect {
socket_path: ready.socket_path.clone(), socket_path: ready.socket_path.clone(),
@ -408,7 +420,7 @@ where
let acceptance_event = let acceptance_event =
wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?; wait_for_run_acceptance(&mut client, &plan.run_segments, RUN_ACCEPTANCE_TIMEOUT).await?;
let acceptance_evidence = TicketRoleLaunchAcceptanceEvidence { let acceptance_evidence = TicketRoleLaunchAcceptanceEvidence {
pod_name: ready.pod_name.clone(), worker_name: ready.worker_name.clone(),
accepted_run_segments: plan.run_segments.len(), accepted_run_segments: plan.run_segments.len(),
event: acceptance_event, event: acceptance_event,
}; };
@ -421,7 +433,7 @@ where
} }
async fn run_pre_run_options_then_send_run( async fn run_pre_run_options_then_send_run(
client: &mut PodClient, client: &mut WorkerClient,
plan: &TicketRoleLaunchPlan, plan: &TicketRoleLaunchPlan,
options: &TicketRoleLaunchOptions, options: &TicketRoleLaunchOptions,
) -> Result<Vec<TicketRolePreRunWarning>, TicketRoleLaunchError> { ) -> Result<Vec<TicketRolePreRunWarning>, TicketRoleLaunchError> {
@ -439,7 +451,7 @@ async fn run_pre_run_options_then_send_run(
} }
async fn perform_pre_run_peer_registrations( async fn perform_pre_run_peer_registrations(
client: &mut PodClient, client: &mut WorkerClient,
peer_names: &[String], peer_names: &[String],
timeout: Duration, timeout: Duration,
) -> Vec<TicketRolePreRunWarning> { ) -> Vec<TicketRolePreRunWarning> {
@ -447,7 +459,7 @@ async fn perform_pre_run_peer_registrations(
for peer_name in peer_names { for peer_name in peer_names {
if peer_name.trim().is_empty() { if peer_name.trim().is_empty() {
warnings.push(TicketRolePreRunWarning { 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; continue;
} }
@ -459,7 +471,7 @@ async fn perform_pre_run_peer_registrations(
} }
async fn pre_run_register_peer( async fn pre_run_register_peer(
client: &mut PodClient, client: &mut WorkerClient,
peer_name: &str, peer_name: &str,
timeout: Duration, timeout: Duration,
) -> Result<(), String> { ) -> Result<(), String> {
@ -503,7 +515,7 @@ async fn pre_run_register_peer(
} }
async fn wait_for_run_acceptance( async fn wait_for_run_acceptance(
client: &mut PodClient, client: &mut WorkerClient,
expected_segments: &[Segment], expected_segments: &[Segment],
timeout: Duration, timeout: Duration,
) -> Result<TicketRoleLaunchAcceptanceEvent, TicketRoleLaunchError> { ) -> 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()); let mut name = format!("ticket-{}", role.as_str());
if let Some(seed) = ticket.and_then(TicketRef::pod_name_seed) { if let Some(seed) = ticket.and_then(TicketRef::worker_name_seed) {
let suffix = sanitise_pod_name_component(seed); let suffix = sanitise_worker_name_component(seed);
if !suffix.is_empty() { if !suffix.is_empty() {
name.push('-'); name.push('-');
name.push_str(&suffix); 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() 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 out = String::new();
let mut last_was_dash = false; let mut last_was_dash = false;
for ch in value.trim().chars() { for ch in value.trim().chars() {
@ -680,7 +692,7 @@ fn non_empty(value: Option<&str>) -> Option<&str> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use protocol::{Greeting, PodStatus}; use protocol::{Greeting, WorkerStatus};
use tempfile::TempDir; use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::net::UnixListener; use tokio::net::UnixListener;
@ -723,7 +735,7 @@ mod tests {
Event::Snapshot { Event::Snapshot {
entries: vec![], entries: vec![],
greeting: Greeting { greeting: Greeting {
pod_name: "ticket-intake".to_string(), worker_name: "ticket-intake".to_string(),
cwd: "/tmp".to_string(), cwd: "/tmp".to_string(),
provider: "test".to_string(), provider: "test".to_string(),
model: "test".to_string(), model: "test".to_string(),
@ -732,7 +744,7 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Idle, status: WorkerStatus::Idle,
in_flight: protocol::InFlightSnapshot::default(), in_flight: protocol::InFlightSnapshot::default(),
} }
} }
@ -745,7 +757,7 @@ mod tests {
target_workspace_root: workspace.to_path_buf(), target_workspace_root: workspace.to_path_buf(),
implementation_worktree_root: workspace.join(".worktree"), implementation_worktree_root: workspace.join(".worktree"),
role: TicketRole::Intake, role: TicketRole::Intake,
pod_name: "ticket-intake".to_string(), worker_name: "ticket-intake".to_string(),
profile: "project:intake".to_string(), profile: "project:intake".to_string(),
workflow: "ticket-intake-workflow".to_string(), workflow: "ticket-intake-workflow".to_string(),
launch_prompt_ref: None, launch_prompt_ref: None,
@ -758,7 +770,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn pre_run_peer_registration_is_sent_before_first_run_submission() { async fn pre_run_peer_registration_is_sent_before_first_run_submission() {
let temp = TempDir::new().unwrap(); 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 listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move { let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap(); 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() let options = TicketRoleLaunchOptions::default()
.with_pre_run_peer_registration("workspace-orchestrator"); .with_pre_run_peer_registration("workspace-orchestrator");
let warnings = run_pre_run_options_then_send_run( let warnings = run_pre_run_options_then_send_run(
@ -811,7 +823,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn pre_run_peer_registration_failure_warns_but_still_sends_run() { async fn pre_run_peer_registration_failure_warns_but_still_sends_run() {
let temp = TempDir::new().unwrap(); 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 listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move { let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap(); 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() let options = TicketRoleLaunchOptions::default()
.with_pre_run_peer_registration("workspace-orchestrator"); .with_pre_run_peer_registration("workspace-orchestrator");
let warnings = run_pre_run_options_then_send_run( 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() { fn default_config_role_launch_plan_requires_explicit_role_config() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder); 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(); let err = plan_ticket_role_launch(context).unwrap_err();
@ -1007,7 +1019,7 @@ profile = "builtin:default"
plan.profile = "inherit".to_string(); plan.profile = "inherit".to_string();
let err = plan let err = plan
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi")) .spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
@ -1030,14 +1042,14 @@ workflow = "ticket-review-workflow"
"#, "#,
); );
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer); let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
context.pod_name = Some("reviewer-fixed".to_string()); context.worker_name = Some("reviewer-fixed".to_string());
context.ticket = Some(TicketRef::id("20260605-190330-ticket-role-pod-launcher")); context.ticket = Some(TicketRef::id("20260605-190330-ticket-role-worker-launcher"));
context.user_instruction = Some("Review the submitted implementation.".to_string()); context.user_instruction = Some("Review the submitted implementation.".to_string());
let plan = plan_ticket_role_launch(context).unwrap(); let plan = plan_ticket_role_launch(context).unwrap();
let text = text_segment(&plan); 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.profile, "builtin:default");
assert_eq!(plan.workflow, "ticket-review-workflow"); assert_eq!(plan.workflow, "ticket-review-workflow");
assert_eq!( assert_eq!(
@ -1055,13 +1067,13 @@ workflow = "ticket-review-workflow"
assert!(!text.contains("Role: reviewer")); assert!(!text.contains("Role: reviewer"));
assert!(!text.contains("system_instruction")); assert!(!text.contains("system_instruction"));
assert!(text.contains("Target Ticket:")); 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("Action instruction:"));
assert!(text.contains("Review the submitted implementation.")); assert!(text.contains("Review the submitted implementation."));
let spawn = plan let spawn = plan
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi")) .spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
.unwrap(); .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.profile.as_deref(), Some("builtin:default"));
assert_eq!(spawn.workspace_root, temp.path()); assert_eq!(spawn.workspace_root, temp.path());
assert!(spawn.cwd.is_none()); 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_plan = plan_ticket_role_launch(handoff_intake).unwrap();
let handoff_text = text_segment(&handoff_plan); let handoff_text = text_segment(&handoff_plan);
assert!(handoff_text.contains("Panel handoff:")); 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("workspace: Demo workspace"));
assert!(!handoff_text.contains("created_or_updated_ticket_id")); assert!(!handoff_text.contains("created_or_updated_ticket_id"));
assert!(!handoff_text.contains("Ticket tool surface")); assert!(!handoff_text.contains("Ticket tool surface"));
@ -1125,15 +1140,15 @@ workflow = "ticket-review-workflow"
assert!(!orchestrator_text.contains("role_cwd")); assert!(!orchestrator_text.contains("role_cwd"));
let mut coder = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder); 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.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.validation = vec!["cargo test -p client ticket_role".into()];
coder.report_expectations = vec!["implementation report with validation".into()]; coder.report_expectations = vec!["implementation report with validation".into()];
let coder_plan = plan_ticket_role_launch(coder).unwrap(); let coder_plan = plan_ticket_role_launch(coder).unwrap();
let coder_text = text_segment(&coder_plan); let coder_text = text_segment(&coder_plan);
assert!(coder_text.contains("path: /tmp/yoi-code")); 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("cargo test -p client ticket_role"));
assert!(coder_text.contains("implementation report with validation")); assert!(coder_text.contains("implementation report with validation"));
assert!(!coder_text.contains("provided child worktree/branch")); assert!(!coder_text.contains("provided child worktree/branch"));
@ -1141,14 +1156,14 @@ workflow = "ticket-review-workflow"
assert!(!coder_text.contains("Do not merge, push")); assert!(!coder_text.contains("Do not merge, push"));
let mut reviewer = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer); 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.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()]; reviewer.report_expectations = vec!["approve or request changes".into()];
let reviewer_plan = plan_ticket_role_launch(reviewer).unwrap(); let reviewer_plan = plan_ticket_role_launch(reviewer).unwrap();
let reviewer_text = text_segment(&reviewer_plan); let reviewer_text = text_segment(&reviewer_plan);
assert!(reviewer_text.contains("path: /tmp/yoi-review")); 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("approve or request changes"));
assert!(!reviewer_text.contains("read-only by default")); assert!(!reviewer_text.contains("read-only by default"));
assert!(!reviewer_text.contains("Orchestrator-side integration")); 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")); assert_eq!(plan.target_workspace_root, temp.path().join("target"));
let spawn_config = plan let spawn_config = plan
.spawn_config(PodRuntimeCommand::for_executable("/bin/yoi")) .spawn_config(WorkerRuntimeCommand::for_executable("/bin/yoi"))
.unwrap(); .unwrap();
assert_eq!(spawn_config.workspace_root, temp.path()); assert_eq!(spawn_config.workspace_root, temp.path());
assert_eq!(spawn_config.cwd, None); assert_eq!(spawn_config.cwd, None);
@ -1194,15 +1209,15 @@ workflow = "ticket-review-workflow"
assert!(!text.contains("Orchestrator implementation integration guidance")); assert!(!text.contains("Orchestrator implementation integration guidance"));
} }
#[test] #[test]
fn caller_provided_pod_name_is_used_exactly() { fn caller_provided_worker_name_is_used_exactly() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
write_builtin_role_config(temp.path(), &[TicketRole::Intake]); write_builtin_role_config(temp.path(), &[TicketRole::Intake]);
let mut context = TicketRoleLaunchContext::new(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(); 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] #[test]

View File

@ -7,13 +7,13 @@ use tokio::net::UnixStream;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
pub struct PodClient { pub struct WorkerClient {
writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>, writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>,
event_rx: mpsc::Receiver<Event>, event_rx: mpsc::Receiver<Event>,
reader_task: JoinHandle<()>, reader_task: JoinHandle<()>,
} }
impl PodClient { impl WorkerClient {
pub async fn connect(path: &Path) -> Result<Self, io::Error> { pub async fn connect(path: &Path) -> Result<Self, io::Error> {
let stream = UnixStream::connect(path).await?; let stream = UnixStream::connect(path).await?;
let (reader, writer) = tokio::io::split(stream); 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) { fn drop(&mut self) {
self.reader_task.abort(); self.reader_task.abort();
} }
@ -61,7 +61,7 @@ mod tests {
use std::io::ErrorKind; use std::io::ErrorKind;
use std::time::Duration; use std::time::Duration;
use protocol::{PodStatus, Segment}; use protocol::{Segment, WorkerStatus};
use tempfile::tempdir; use tempfile::tempdir;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixListener; use tokio::net::UnixListener;
@ -91,13 +91,13 @@ mod tests {
let mut writer = JsonLineWriter::new(stream); let mut writer = JsonLineWriter::new(stream);
writer writer
.write(&Event::Status { .write(&Event::Status {
status: PodStatus::Idle, status: WorkerStatus::Idle,
}) })
.await .await
.unwrap(); .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()) let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
.await .await
@ -105,7 +105,7 @@ mod tests {
assert!(matches!( assert!(matches!(
event, event,
Some(Event::Status { Some(Event::Status {
status: PodStatus::Idle status: WorkerStatus::Idle
}) })
)); ));
server.await.unwrap(); server.await.unwrap();
@ -122,7 +122,7 @@ mod tests {
reader.next::<Method>().await.unwrap() 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 { let method = Method::Run {
input: vec![Segment::text("hello")], input: vec![Segment::text("hello")],
}; };
@ -155,7 +155,7 @@ mod tests {
}); });
for _ in 0..16 { for _ in 0..16 {
let client = PodClient::connect(&socket_path).await.unwrap(); let client = WorkerClient::connect(&socket_path).await.unwrap();
drop(client); drop(client);
} }
@ -177,7 +177,7 @@ mod tests {
.await; .await;
}); });
let client = PodClient::connect(&socket_path).await.unwrap(); let client = WorkerClient::connect(&socket_path).await.unwrap();
tokio::task::yield_now().await; tokio::task::yield_now().await;
drop(client); drop(client);

View File

@ -20,7 +20,7 @@ Does not own:
## Design notes ## Design notes
Macros reduce boilerplate, but they must not imply capability. A generated tool definition is still subject to manifest permissions, Pod scope, and runtime policy. Macros reduce boilerplate, but they must not imply capability. A generated tool definition is still subject to manifest permissions, Worker scope, and runtime policy.
## See also ## See also

View File

@ -16,10 +16,10 @@ Owns:
Does not own: Does not own:
- Pod names, sockets, process lifecycle, or scope delegation (`pod`) - Worker names, sockets, process lifecycle, or scope delegation (`worker`)
- product CLI shape (`yoi`) - product CLI shape (`yoi`)
- provider catalog and secret resolution (`provider`, `secrets`) - provider catalog and secret resolution (`provider`, `secrets`)
- durable Pod current state (`pod-store`) - durable Worker current state (`pod-store`)
## Design notes ## Design notes

View File

@ -213,7 +213,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
/// stream events become visible. /// stream events become visible.
lifecycle_trace_cbs: Vec<Arc<dyn Fn(usize, usize, &str, &Value) + Send + Sync>>, lifecycle_trace_cbs: Vec<Arc<dyn Fn(usize, usize, &str, &Value) + Send + Sync>>,
/// Non-fatal warning callbacks. Invoked when the Engine wants to /// Non-fatal warning callbacks. Invoked when the Engine wants to
/// surface an advisory message to the upper layer (e.g. Pod) so it /// surface an advisory message to the upper layer (e.g. Worker) so it
/// can be forwarded to the user — distinct from `tracing::warn!`, /// can be forwarded to the user — distinct from `tracing::warn!`,
/// which is for developer-facing logs. /// which is for developer-facing logs.
warning_cbs: Vec<Box<dyn Fn(&str) + Send + Sync>>, warning_cbs: Vec<Box<dyn Fn(&str) + Send + Sync>>,
@ -253,7 +253,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
/// Plumbed into [`Request::cache_anchor`] at request build time. /// Plumbed into [`Request::cache_anchor`] at request build time.
cache_anchor: Option<usize>, cache_anchor: Option<usize>,
/// Conversation-scoped cache key, set by higher layers. Plumbed into /// Conversation-scoped cache key, set by higher layers. Plumbed into
/// [`Request::cache_key`] at request build time. Pod 側では /// [`Request::cache_key`] at request build time. Worker 側では
/// `SegmentId` を渡す。 /// `SegmentId` を渡す。
cache_key: Option<String>, cache_key: Option<String>,
/// State marker /// State marker
@ -487,7 +487,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
/// Fired after `post_tool_call` interceptors and any `content` /// Fired after `post_tool_call` interceptors and any `content`
/// truncation from `tool_output_limits`, so the callback observes /// truncation from `tool_output_limits`, so the callback observes
/// exactly what is persisted to history. Intended for upper layers /// exactly what is persisted to history. Intended for upper layers
/// (e.g. Pod) to forward tool results to clients. /// (e.g. Worker) to forward tool results to clients.
pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) { pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
self.tool_result_cbs.push(Box::new(callback)); self.tool_result_cbs.push(Box::new(callback));
} }
@ -1121,7 +1121,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
} }
// Drain interceptor-side inputs that are meant to land in // Drain interceptor-side inputs that are meant to land in
// history (notifications, cross-Pod events, system // history (notifications, cross-Worker events, system
// reminders). These are committed *before* the per-request // reminders). These are committed *before* the per-request
// clone so they participate in the LLM request below and // clone so they participate in the LLM request below and
// get persisted by the upper layer that owns history.json. // get persisted by the upper layer that owns history.json.
@ -1302,7 +1302,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
// Collect and commit assistant items. Routed through // Collect and commit assistant items. Routed through
// `append_history_items` so observers (e.g. the // `append_history_items` so observers (e.g. the
// Pod-side per-item session-log committer) see each item // Worker-side per-item session-log committer) see each item
// as it lands. // as it lands.
let reasoning_items = self.thinking_block_collector.take_collected(); let reasoning_items = self.thinking_block_collector.take_collected();
let text_blocks = self.text_block_collector.take_collected(); let text_blocks = self.text_block_collector.take_collected();
@ -1603,7 +1603,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
} }
Ok(ToolExecutionResult::Completed(results)) => { Ok(ToolExecutionResult::Completed(results)) => {
// Route per-result pushes through the callback path so // Route per-result pushes through the callback path so
// observers (e.g. the Pod-side per-item session-log // observers (e.g. the Worker-side per-item session-log
// committer) see each tool result as it lands. // committer) see each tool result as it lands.
let items = results.into_iter().map(|result| { let items = results.into_iter().map(|result| {
Item::tool_result_item( Item::tool_result_item(
@ -1708,7 +1708,7 @@ impl<C: LlmClient> Engine<C, Mutable> {
/// Install byte-size caps for tool execution `content`. /// Install byte-size caps for tool execution `content`.
/// ///
/// Passing `None` (the default) disables truncation. Higher layers /// Passing `None` (the default) disables truncation. Higher layers
/// (e.g. Pod) translate manifest configuration into a concrete /// (e.g. Worker) translate manifest configuration into a concrete
/// [`ToolOutputLimits`] and install it here. /// [`ToolOutputLimits`] and install it here.
pub fn set_tool_output_limits(&mut self, limits: Option<ToolOutputLimits>) { pub fn set_tool_output_limits(&mut self, limits: Option<ToolOutputLimits>) {
self.tool_output_limits = limits; self.tool_output_limits = limits;

View File

@ -1,6 +1,6 @@
//! Interceptor - control flow delegation for the Engine execution loop //! Interceptor - control flow delegation for the Engine execution loop
//! //!
//! Defines the [`Interceptor`] trait that upper layers (e.g. Pod) implement //! Defines the [`Interceptor`] trait that upper layers (e.g. Worker) implement
//! to inject orchestration decisions (approval, skip, pause, abort) //! to inject orchestration decisions (approval, skip, pause, abort)
//! into the Engine's turn loop without the Engine knowing about //! into the Engine's turn loop without the Engine knowing about
//! higher-level concepts. //! higher-level concepts.
@ -132,7 +132,7 @@ pub struct ToolResultInfo {
/// Intercepts the Engine execution loop at key decision points. /// Intercepts the Engine execution loop at key decision points.
/// ///
/// All methods have default implementations that let the Engine /// All methods have default implementations that let the Engine
/// proceed without intervention. Upper layers (e.g. Pod) provide /// proceed without intervention. Upper layers (e.g. Worker) provide
/// richer implementations for approval flows, permission checks, etc. /// richer implementations for approval flows, permission checks, etc.
#[async_trait] #[async_trait]
pub trait Interceptor: Send + Sync { pub trait Interceptor: Send + Sync {
@ -149,7 +149,7 @@ pub trait Interceptor: Send + Sync {
/// ///
/// Use this for inputs that arrive from outside the LLM and need /// Use this for inputs that arrive from outside the LLM and need
/// to be reflected in the on-disk history — notifications, /// to be reflected in the on-disk history — notifications,
/// cross-Pod events, system reminders. Do **not** use /// cross-Worker events, system reminders. Do **not** use
/// [`Self::pre_llm_request`] for that purpose: it mutates a /// [`Self::pre_llm_request`] for that purpose: it mutates a
/// per-request clone, so any committed assistant response that /// per-request clone, so any committed assistant response that
/// reacts to the injection would have no visible trigger on the /// reacts to the injection would have no visible trigger on the

View File

@ -51,7 +51,7 @@ pub(crate) struct ResponsesRequest {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>, pub top_p: Option<f32>,
/// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが /// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが
/// 無いとプロンプトキャッシュがほぼ効かない。pod 側は `SegmentId` /// 無いとプロンプトキャッシュがほぼ効かない。worker 側は `SegmentId`
/// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。 /// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>, pub prompt_cache_key: Option<String>,

View File

@ -523,7 +523,7 @@ pub struct Request {
/// 会話単位の安定キー。`prompt_cache_key` として送られる /// 会話単位の安定キー。`prompt_cache_key` として送られる
/// (OpenAI Responses)。ChatGPT backend (codex-oauth) は明示キーが /// (OpenAI Responses)。ChatGPT backend (codex-oauth) は明示キーが
/// 無いと org/project ハッシュ衝突でプロンプトキャッシュが /// 無いと org/project ハッシュ衝突でプロンプトキャッシュが
/// ほぼヒットしないため、pod 側で `SegmentId` を渡す運用を想定。 /// ほぼヒットしないため、worker 側で `SegmentId` を渡す運用を想定。
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは /// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
/// 別の概念。`cache_anchor` を読まない provider と同じく、 /// 別の概念。`cache_anchor` を読まない provider と同じく、
/// `prompt_cache_key` を持たない provider は無視する。 /// `prompt_cache_key` を持たない provider は無視する。

View File

@ -8,7 +8,7 @@
//! //!
//! Prune は **コンテキスト射影** であり、history の変換ではない。 //! Prune は **コンテキスト射影** であり、history の変換ではない。
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、 //! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
//! 射影の適用は上位層(`pod::prune_hook` 等)が LLM に送る一時コンテキスト //! 射影の適用は上位層(`worker::prune_hook` 等)が LLM に送る一時コンテキスト
//! に対してだけ行う。Engine の永続履歴は決して変更されない。 //! に対してだけ行う。Engine の永続履歴は決して変更されない。
//! //!
//! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を //! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を
@ -75,7 +75,7 @@ pub enum PruneDecision {
} }
/// Optional observer invoked after each prune evaluation, regardless of /// Optional observer invoked after each prune evaluation, regardless of
/// branch. Pod 等の上位層が install して metrics を発行する。 /// branch. Worker 等の上位層が install して metrics を発行する。
pub type PruneObserver = Box<dyn Fn(&PruneEvaluation) + Send + Sync>; pub type PruneObserver = Box<dyn Fn(&PruneEvaluation) + Send + Sync>;
/// Configuration for the Prune algorithm. /// Configuration for the Prune algorithm.

View File

@ -130,13 +130,13 @@ mod tests {
let mut timeline = Timeline::new(); let mut timeline = Timeline::new();
timeline.on_tool_use_block(collector.clone()); timeline.on_tool_use_block(collector.clone());
timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListPods")); timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListWorkers"));
timeline.dispatch(&Event::tool_use_stop(0)); timeline.dispatch(&Event::tool_use_stop(0));
let calls = collector.take_collected(); let calls = collector.take_collected();
assert_eq!(calls.len(), 1); assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "tool_empty"); assert_eq!(calls[0].id, "tool_empty");
assert_eq!(calls[0].name, "ListPods"); assert_eq!(calls[0].name, "ListWorkers");
assert!(calls[0].input.is_object()); assert!(calls[0].input.is_object());
assert_eq!( assert_eq!(
calls[0].input, calls[0].input,

View File

@ -75,7 +75,7 @@ impl ToolServerHandle {
/// Execute all pending factories and register the resulting tools. /// Execute all pending factories and register the resulting tools.
/// ///
/// Called implicitly by `Engine::lock()` before the first turn. /// Called implicitly by `Engine::lock()` before the first turn.
/// Exposed as `pub` so higher layers (e.g. Pod) can force-materialise /// Exposed as `pub` so higher layers (e.g. Worker) can force-materialise
/// tools earlier — for example when building a system-prompt template /// tools earlier — for example when building a system-prompt template
/// context that needs the list of registered tool names. Redundant /// context that needs the list of registered tool names. Redundant
/// calls are no-ops. /// calls are no-ops.

View File

@ -2,7 +2,7 @@
//! //!
//! 1 リクエストの送信時点での「ある history prefix 長で計測した占有量」を //! 1 リクエストの送信時点での「ある history prefix 長で計測した占有量」を
//! 1 件分にまとめたもの。`UsageEvent` (provider stream イベント) を //! 1 件分にまとめたもの。`UsageEvent` (provider stream イベント) を
//! 受けて呼び出し側 (typically Pod) が組み立て、永続化層 //! 受けて呼び出し側 (typically Worker) が組み立て、永続化層
//! (session-store) に流したり、token accounting (`token_counter`) で //! (session-store) に流したり、token accounting (`token_counter`) で
//! 履歴として参照したりする。 //! 履歴として参照したりする。

View File

@ -2,7 +2,7 @@
## Role ## Role
`manifest` resolves reusable profile/configuration inputs into the concrete runtime Manifest used to create or restore Pods. `manifest` resolves reusable profile/configuration inputs into the concrete runtime Manifest used to create or restore Workers.
## Boundaries ## Boundaries
@ -17,13 +17,13 @@ Owns:
Does not own: Does not own:
- provider HTTP clients or secret lookup implementation (`provider`, `secrets`) - provider HTTP clients or secret lookup implementation (`provider`, `secrets`)
- Pod lifecycle (`pod`) - Worker lifecycle (`worker`)
- product CLI parsing (`yoi`) - product CLI parsing (`yoi`)
- generated memory records (`memory`) - generated memory records (`memory`)
## Design notes ## Design notes
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Pod name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles. Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
## See also ## See also

View File

@ -1,10 +1,10 @@
//! Partial-form of [`crate::PodManifest`] used as cascade layers. //! Partial-form of [`crate::WorkerManifest`] used as cascade layers.
//! //!
//! `PodManifestConfig` mirrors `PodManifest` but every field is optional //! `WorkerManifestConfig` mirrors `WorkerManifest` but every field is optional
//! so individual layers (builtin defaults, user manifest, project //! so individual layers (builtin defaults, user manifest, project
//! manifest, programmatic overlay) can be partial. Layers are combined //! manifest, programmatic overlay) can be partial. Layers are combined
//! via [`PodManifestConfig::merge`] and the final config is converted to //! via [`WorkerManifestConfig::merge`] and the final config is converted to
//! a validated [`PodManifest`] via `TryFrom`. //! a validated [`WorkerManifest`] via `TryFrom`.
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap};
use std::num::NonZeroU32; use std::num::NonZeroU32;
@ -17,19 +17,19 @@ use crate::defaults;
use crate::model::{AuthRef, ModelManifest, ReasoningControl}; use crate::model::{AuthRef, ModelManifest, ReasoningControl};
use crate::plugin::PluginConfig; use crate::plugin::PluginConfig;
use crate::{ use crate::{
CompactionConfig, FeatureConfig, FeatureFlagConfig, FileUploadLimits, McpConfig, McpEnvValue, CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
McpStdioCwdPolicy, MemoryConfig, PodManifest, PodMeta, ScopeConfig, SessionConfig, McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, ScopeConfig, SessionConfig,
SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits, SkillsConfig, TicketFeatureAccessConfig, TicketFeatureConfig, ToolOutputLimits,
ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerManifest, WorkerMeta,
}; };
/// Partial-form Pod manifest. Every field is optional; one or more /// Partial-form Worker manifest. Every field is optional; one or more
/// instances merge via [`PodManifestConfig::merge`] before being /// instances merge via [`WorkerManifestConfig::merge`] before being
/// converted to a validated [`PodManifest`] via `TryFrom`. /// converted to a validated [`WorkerManifest`] via `TryFrom`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PodManifestConfig { pub struct WorkerManifestConfig {
#[serde(default)] #[serde(default)]
pub pod: PodMetaConfig, pub worker: WorkerMetaConfig,
/// `[model]` セクションは partial でも完成形でも同じ /// `[model]` セクションは partial でも完成形でも同じ
/// [`ModelManifest`] を使う。ref / inline の両形を受け入れるための /// [`ModelManifest`] を使う。ref / inline の両形を受け入れるための
/// 全 Optional 構造なので、カスケード層と最終マニフェストで型を /// 全 Optional 構造なので、カスケード層と最終マニフェストで型を
@ -37,10 +37,10 @@ pub struct PodManifestConfig {
#[serde(default)] #[serde(default)]
pub model: ModelManifest, pub model: ModelManifest,
#[serde(default)] #[serde(default)]
pub worker: WorkerManifestConfig, pub engine: EngineManifestConfig,
#[serde(default)] #[serde(default)]
pub scope: ScopeConfig, pub scope: ScopeConfig,
/// Scope that may be subdelegated to spawned child Pods. Defaults empty. /// Scope that may be subdelegated to spawned child Workers. Defaults empty.
#[serde(default)] #[serde(default)]
pub delegation_scope: ScopeConfig, pub delegation_scope: ScopeConfig,
#[serde(default)] #[serde(default)]
@ -83,7 +83,7 @@ pub struct FeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub web: Option<FeatureFlagConfigPartial>, pub web: Option<FeatureFlagConfigPartial>,
#[serde(default)] #[serde(default)]
pub pods: Option<FeatureFlagConfigPartial>, pub workers: Option<FeatureFlagConfigPartial>,
#[serde(default)] #[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>, pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
@ -98,7 +98,7 @@ impl FeatureConfigPartial {
task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge), task: merge_option(self.task, other.task, FeatureFlagConfigPartial::merge),
memory: merge_option(self.memory, other.memory, FeatureFlagConfigPartial::merge), memory: merge_option(self.memory, other.memory, FeatureFlagConfigPartial::merge),
web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge), web: merge_option(self.web, other.web, FeatureFlagConfigPartial::merge),
pods: merge_option(self.pods, other.pods, FeatureFlagConfigPartial::merge), workers: merge_option(self.workers, other.workers, FeatureFlagConfigPartial::merge),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge), ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
ticket_orchestration: merge_option( ticket_orchestration: merge_option(
self.ticket_orchestration, self.ticket_orchestration,
@ -150,7 +150,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(), web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
pods: value.pods.map(FeatureFlagConfig::from).unwrap_or_default(), workers: value
.workers
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
ticket: value ticket: value
.ticket .ticket
.map(TicketFeatureConfig::from) .map(TicketFeatureConfig::from)
@ -207,7 +210,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
task: Some(value.task.into()), task: Some(value.task.into()),
memory: Some(value.memory.into()), memory: Some(value.memory.into()),
web: Some(value.web.into()), web: Some(value.web.into()),
pods: Some(value.pods.into()), workers: Some(value.workers.into()),
ticket: Some(value.ticket.into()), ticket: Some(value.ticket.into()),
ticket_orchestration: Some(value.ticket_orchestration.into()), ticket_orchestration: Some(value.ticket_orchestration.into()),
plugins: Some(value.plugins.into()), plugins: Some(value.plugins.into()),
@ -216,18 +219,18 @@ impl From<FeatureConfig> for FeatureConfigPartial {
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PodMetaConfig { pub struct WorkerMetaConfig {
#[serde(default)] #[serde(default)]
pub name: Option<String>, pub name: Option<String>,
/// Optional `PromptCatalog` manifest pack override. See /// Optional `PromptCatalog` manifest pack override. See
/// [`crate::PodMeta::prompt_pack`] for semantics. Relative paths /// [`crate::WorkerMeta::prompt_pack`] for semantics. Relative paths
/// are resolved through [`PodManifestConfig::resolve_paths`]. /// are resolved through [`WorkerManifestConfig::resolve_paths`].
#[serde(default)] #[serde(default)]
pub prompt_pack: Option<PathBuf>, pub prompt_pack: Option<PathBuf>,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerManifestConfig { pub struct EngineManifestConfig {
#[serde(default)] #[serde(default)]
pub instruction: Option<String>, pub instruction: Option<String>,
#[serde(default)] #[serde(default)]
@ -318,8 +321,8 @@ pub struct CompactionConfigPartial {
pub model: Option<ModelManifest>, pub model: Option<ModelManifest>,
} }
/// Errors raised when converting a [`PodManifestConfig`] to a validated /// Errors raised when converting a [`WorkerManifestConfig`] to a validated
/// [`PodManifest`] via `TryFrom`. /// [`WorkerManifest`] via `TryFrom`.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ResolveError { pub enum ResolveError {
#[error("missing required field: {0}")] #[error("missing required field: {0}")]
@ -359,7 +362,7 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
Ok(()) Ok(())
} }
impl PodManifestConfig { impl WorkerManifestConfig {
/// Parse a partial manifest from a TOML string. Unknown top-level or /// Parse a partial manifest from a TOML string. Unknown top-level or
/// nested fields emit a `tracing::warn!` and are ignored; use /// nested fields emit a `tracing::warn!` and are ignored; use
/// `tracing_subscriber` with `WARN` enabled to surface them to the /// `tracing_subscriber` with `WARN` enabled to surface them to the
@ -378,12 +381,12 @@ impl PodManifestConfig {
/// from this layer so every per-field default lives at exactly one /// from this layer so every per-field default lives at exactly one
/// call site (the `defaults` module). /// call site (the `defaults` module).
/// ///
/// `TryFrom<PodManifestConfig>` also reads the same constants as a /// `TryFrom<WorkerManifestConfig>` also reads the same constants as a
/// belt-and-suspenders fallback, so a manually-constructed config /// belt-and-suspenders fallback, so a manually-constructed config
/// that skips this layer still resolves to the same values. /// that skips this layer still resolves to the same values.
pub fn builtin_defaults() -> Self { pub fn builtin_defaults() -> Self {
Self { Self {
worker: WorkerManifestConfig { engine: EngineManifestConfig {
tool_output: ToolOutputLimitsPartial { tool_output: ToolOutputLimitsPartial {
default_max_bytes: Some(defaults::TOOL_OUTPUT_MAX_BYTES), default_max_bytes: Some(defaults::TOOL_OUTPUT_MAX_BYTES),
per_tool: HashMap::new(), per_tool: HashMap::new(),
@ -415,7 +418,7 @@ impl PodManifestConfig {
base.display() base.display()
); );
resolve_auth_file(&mut self.model.auth, base); resolve_auth_file(&mut self.model.auth, base);
if let Some(ref mut pack) = self.pod.prompt_pack { if let Some(ref mut pack) = self.worker.prompt_pack {
*pack = join_if_relative(base, pack); *pack = join_if_relative(base, pack);
} }
for rule in &mut self.scope.allow { for rule in &mut self.scope.allow {
@ -457,11 +460,11 @@ impl PodManifestConfig {
/// fields from `self`. Map entries merge key-wise with `upper` /// fields from `self`. Map entries merge key-wise with `upper`
/// winning on conflict. Scope rules from both layers accumulate /// winning on conflict. Scope rules from both layers accumulate
/// (see [`ScopeConfig`] semantics). /// (see [`ScopeConfig`] semantics).
pub fn merge(self, upper: PodManifestConfig) -> Self { pub fn merge(self, upper: WorkerManifestConfig) -> Self {
Self { Self {
pod: self.pod.merge(upper.pod),
model: self.model.merge(upper.model),
worker: self.worker.merge(upper.worker), worker: self.worker.merge(upper.worker),
model: self.model.merge(upper.model),
engine: self.engine.merge(upper.engine),
scope: merge_scope(self.scope, upper.scope), scope: merge_scope(self.scope, upper.scope),
delegation_scope: merge_scope(self.delegation_scope, upper.delegation_scope), delegation_scope: merge_scope(self.delegation_scope, upper.delegation_scope),
session: merge_option(self.session, upper.session, SessionConfigPartial::merge), session: merge_option(self.session, upper.session, SessionConfigPartial::merge),
@ -575,7 +578,7 @@ impl MemoryConfig {
} }
} }
impl PodMetaConfig { impl WorkerMetaConfig {
fn merge(self, upper: Self) -> Self { fn merge(self, upper: Self) -> Self {
Self { Self {
name: upper.name.or(self.name), name: upper.name.or(self.name),
@ -584,7 +587,7 @@ impl PodMetaConfig {
} }
} }
impl WorkerManifestConfig { impl EngineManifestConfig {
fn merge(self, upper: Self) -> Self { fn merge(self, upper: Self) -> Self {
Self { Self {
instruction: upper.instruction.or(self.instruction), instruction: upper.instruction.or(self.instruction),
@ -696,9 +699,9 @@ fn join_if_relative(base: &Path, p: &Path) -> PathBuf {
} }
} }
/// Invariant check: every path in a fully-resolved [`PodManifestConfig`] /// Invariant check: every path in a fully-resolved [`WorkerManifestConfig`]
/// must be absolute. Relative paths are resolved per-layer via /// must be absolute. Relative paths are resolved per-layer via
/// [`PodManifestConfig::resolve_paths`]; if one reaches `TryFrom` it /// [`WorkerManifestConfig::resolve_paths`]; if one reaches `TryFrom` it
/// indicates a caller skipped the per-layer resolve step. /// indicates a caller skipped the per-layer resolve step.
fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> { fn ensure_absolute(field: &'static str, path: &Path) -> Result<(), ResolveError> {
if path.is_absolute() { if path.is_absolute() {
@ -871,45 +874,48 @@ fn bounded_label(value: &str) -> String {
out out
} }
impl TryFrom<PodManifestConfig> for PodManifest { impl TryFrom<WorkerManifestConfig> for WorkerManifest {
type Error = ResolveError; type Error = ResolveError;
fn try_from(cfg: PodManifestConfig) -> Result<Self, Self::Error> { fn try_from(cfg: WorkerManifestConfig) -> Result<Self, Self::Error> {
let name = cfg.pod.name.ok_or(ResolveError::MissingField("pod.name"))?; let name = cfg
let prompt_pack = cfg.pod.prompt_pack; .worker
.name
.ok_or(ResolveError::MissingField("worker.name"))?;
let prompt_pack = cfg.worker.prompt_pack;
if let Some(ref p) = prompt_pack { if let Some(ref p) = prompt_pack {
ensure_absolute("pod.prompt_pack", p)?; ensure_absolute("worker.prompt_pack", p)?;
} }
validate_model_paths(&cfg.model, "model.auth.file")?; validate_model_paths(&cfg.model, "model.auth.file")?;
let worker = WorkerManifest { let engine = EngineManifest {
instruction: cfg instruction: cfg
.worker .engine
.instruction .instruction
.unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()), .unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()),
language: cfg language: cfg
.worker .engine
.language .language
.unwrap_or_else(|| defaults::WORKER_LANGUAGE.to_string()), .unwrap_or_else(|| defaults::WORKER_LANGUAGE.to_string()),
max_tokens: cfg.worker.max_tokens, max_tokens: cfg.engine.max_tokens,
max_turns: cfg.worker.max_turns, max_turns: cfg.engine.max_turns,
temperature: cfg.worker.temperature, temperature: cfg.engine.temperature,
top_p: cfg.worker.top_p, top_p: cfg.engine.top_p,
top_k: cfg.worker.top_k, top_k: cfg.engine.top_k,
stop_sequences: cfg.worker.stop_sequences.unwrap_or_default(), stop_sequences: cfg.engine.stop_sequences.unwrap_or_default(),
reasoning: cfg.worker.reasoning, reasoning: cfg.engine.reasoning,
tool_output: ToolOutputLimits { tool_output: ToolOutputLimits {
default_max_bytes: cfg default_max_bytes: cfg
.worker .engine
.tool_output .tool_output
.default_max_bytes .default_max_bytes
.unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES), .unwrap_or(defaults::TOOL_OUTPUT_MAX_BYTES),
per_tool: cfg.worker.tool_output.per_tool, per_tool: cfg.engine.tool_output.per_tool,
}, },
file_upload: FileUploadLimits { file_upload: FileUploadLimits {
max_bytes: cfg max_bytes: cfg
.worker .engine
.file_upload .file_upload
.max_bytes .max_bytes
.unwrap_or(defaults::FILE_UPLOAD_MAX_BYTES), .unwrap_or(defaults::FILE_UPLOAD_MAX_BYTES),
@ -1007,10 +1013,10 @@ impl TryFrom<PodManifestConfig> for PodManifest {
validate_mcp_config(&cfg.mcp)?; validate_mcp_config(&cfg.mcp)?;
Ok(PodManifest { Ok(WorkerManifest {
pod: PodMeta { name, prompt_pack }, worker: WorkerMeta { name, prompt_pack },
model: cfg.model, model: cfg.model,
worker, engine,
scope: cfg.scope, scope: cfg.scope,
delegation_scope: cfg.delegation_scope, delegation_scope: cfg.delegation_scope,
session, session,
@ -1041,9 +1047,9 @@ mod tests {
AuthRef::ApiKey { file: Some(path) } AuthRef::ApiKey { file: Some(path) }
} }
fn minimal_valid() -> PodManifestConfig { fn minimal_valid() -> WorkerManifestConfig {
PodManifestConfig { WorkerManifestConfig {
pod: PodMetaConfig { worker: WorkerMetaConfig {
name: Some("test".into()), name: Some("test".into()),
prompt_pack: None, prompt_pack: None,
}, },
@ -1052,10 +1058,10 @@ mod tests {
model_id: Some("claude-sonnet-4-20250514".into()), model_id: Some("claude-sonnet-4-20250514".into()),
..Default::default() ..Default::default()
}, },
worker: WorkerManifestConfig::default(), engine: EngineManifestConfig::default(),
scope: ScopeConfig { scope: ScopeConfig {
allow: vec![ScopeRule { allow: vec![ScopeRule {
target: abs("/pod"), target: abs("/worker"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
}], }],
@ -1076,8 +1082,8 @@ mod tests {
#[test] #[test]
fn resolve_minimal_succeeds() { fn resolve_minimal_succeeds() {
let manifest: PodManifest = minimal_valid().try_into().unwrap(); let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert_eq!(manifest.pod.name, "test"); assert_eq!(manifest.worker.name, "test");
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic)); assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
assert!(manifest.permissions.is_none()); assert!(manifest.permissions.is_none());
} }
@ -1113,7 +1119,7 @@ mod tests {
}, },
}); });
let manifest: PodManifest = cfg.try_into().unwrap(); let manifest: WorkerManifest = cfg.try_into().unwrap();
assert_eq!(manifest.mcp.stdio_servers.len(), 1); assert_eq!(manifest.mcp.stdio_servers.len(), 1);
let server = &manifest.mcp.stdio_servers[0]; let server = &manifest.mcp.stdio_servers[0];
@ -1137,7 +1143,7 @@ mod tests {
env: crate::McpEnvConfig::default(), env: crate::McpEnvConfig::default(),
}); });
let err = PodManifest::try_from(cfg).unwrap_err(); let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
ResolveError::InvalidMcpConfig { ResolveError::InvalidMcpConfig {
@ -1157,7 +1163,7 @@ mod tests {
}); });
} }
let err = PodManifest::try_from(cfg).unwrap_err(); let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
ResolveError::InvalidMcpConfig { ResolveError::InvalidMcpConfig {
@ -1186,7 +1192,7 @@ mod tests {
}, },
}); });
let err = PodManifest::try_from(cfg).unwrap_err(); let err = WorkerManifest::try_from(cfg).unwrap_err();
let rendered = err.to_string(); let rendered = err.to_string();
assert!(rendered.contains("secret_ref")); assert!(rendered.contains("secret_ref"));
assert!(!rendered.contains("bad secret id with spaces")); assert!(!rendered.contains("bad secret id with spaces"));
@ -1208,7 +1214,7 @@ mod tests {
env: crate::McpEnvConfig::default(), env: crate::McpEnvConfig::default(),
}); });
let manifest: PodManifest = cfg.try_into().unwrap(); let manifest: WorkerManifest = cfg.try_into().unwrap();
assert_eq!( assert_eq!(
manifest.mcp.stdio_servers[0].command, manifest.mcp.stdio_servers[0].command,
"definitely-not-a-command-yoi-must-spawn" "definitely-not-a-command-yoi-must-spawn"
@ -1222,7 +1228,7 @@ mod tests {
record_event_trace: Some(true), record_event_trace: Some(true),
}); });
let manifest: PodManifest = cfg.try_into().unwrap(); let manifest: WorkerManifest = cfg.try_into().unwrap();
assert!(manifest.session.record_event_trace); assert!(manifest.session.record_event_trace);
} }
@ -1234,7 +1240,7 @@ mod tests {
rules: Vec::new(), rules: Vec::new(),
}); });
let err = PodManifest::try_from(cfg).unwrap_err(); let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
@ -1261,7 +1267,7 @@ mod tests {
], ],
}); });
let manifest: PodManifest = cfg.try_into().unwrap(); let manifest: WorkerManifest = cfg.try_into().unwrap();
let permissions = manifest.permissions.unwrap(); let permissions = manifest.permissions.unwrap();
assert_eq!(permissions.default_action, crate::ToolPermissionAction::Ask); assert_eq!(permissions.default_action, crate::ToolPermissionAction::Ask);
@ -1318,7 +1324,7 @@ mod tests {
fn try_from_invariant_rejects_lingering_relative_auth_file() { fn try_from_invariant_rejects_lingering_relative_auth_file() {
let mut cfg = minimal_valid(); let mut cfg = minimal_valid();
cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/relative"))); cfg.model.auth = Some(api_key_file_auth(PathBuf::from("keys/relative")));
let err = PodManifest::try_from(cfg).unwrap_err(); let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
ResolveError::RelativePath { ResolveError::RelativePath {
@ -1332,7 +1338,7 @@ mod tests {
fn try_from_invariant_rejects_lingering_relative_scope_target() { fn try_from_invariant_rejects_lingering_relative_scope_target() {
let mut cfg = minimal_valid(); let mut cfg = minimal_valid();
cfg.scope.allow[0].target = PathBuf::from("docs"); cfg.scope.allow[0].target = PathBuf::from("docs");
let err = PodManifest::try_from(cfg).unwrap_err(); let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
ResolveError::RelativePath { ResolveError::RelativePath {
@ -1343,25 +1349,25 @@ mod tests {
} }
#[test] #[test]
fn resolve_rejects_missing_pod_name() { fn resolve_rejects_missing_worker_name() {
let mut cfg = minimal_valid(); let mut cfg = minimal_valid();
cfg.pod.name = None; cfg.worker.name = None;
let err = PodManifest::try_from(cfg).unwrap_err(); let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!(err, ResolveError::MissingField("pod.name"))); assert!(matches!(err, ResolveError::MissingField("worker.name")));
} }
#[test] #[test]
fn resolve_accepts_empty_scope_for_profile_launch_policy() { fn resolve_accepts_empty_scope_for_profile_launch_policy() {
let mut cfg = minimal_valid(); let mut cfg = minimal_valid();
cfg.scope.allow.clear(); cfg.scope.allow.clear();
let manifest = PodManifest::try_from(cfg).unwrap(); let manifest = WorkerManifest::try_from(cfg).unwrap();
assert!(manifest.scope.allow.is_empty()); assert!(manifest.scope.allow.is_empty());
} }
#[test] #[test]
fn merge_scalar_upper_wins() { fn merge_scalar_upper_wins() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
pod: PodMetaConfig { worker: WorkerMetaConfig {
name: Some("lower".into()), name: Some("lower".into()),
prompt_pack: None, prompt_pack: None,
}, },
@ -1371,30 +1377,30 @@ mod tests {
}, },
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
pod: PodMetaConfig { worker: WorkerMetaConfig {
name: Some("upper".into()), name: Some("upper".into()),
prompt_pack: None, prompt_pack: None,
}, },
..Default::default() ..Default::default()
}; };
let merged = lower.merge(upper); let merged = lower.merge(upper);
assert_eq!(merged.pod.name.as_deref(), Some("upper")); assert_eq!(merged.worker.name.as_deref(), Some("upper"));
// model_id not present in upper — retain lower // model_id not present in upper — retain lower
assert_eq!(merged.model.model_id.as_deref(), Some("lower-model")); assert_eq!(merged.model.model_id.as_deref(), Some("lower-model"));
} }
#[test] #[test]
fn merge_worker_reasoning_upper_wins() { fn merge_worker_reasoning_upper_wins() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
worker: WorkerManifestConfig { engine: EngineManifestConfig {
reasoning: Some(ReasoningControl::Effort(ReasoningEffort::Low)), reasoning: Some(ReasoningControl::Effort(ReasoningEffort::Low)),
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
worker: WorkerManifestConfig { engine: EngineManifestConfig {
reasoning: Some(ReasoningControl::BudgetTokens(4096)), reasoning: Some(ReasoningControl::BudgetTokens(4096)),
..Default::default() ..Default::default()
}, },
@ -1404,15 +1410,15 @@ mod tests {
let merged = lower.merge(upper); let merged = lower.merge(upper);
assert_eq!( assert_eq!(
merged.worker.reasoning, merged.engine.reasoning,
Some(ReasoningControl::BudgetTokens(4096)) Some(ReasoningControl::BudgetTokens(4096))
); );
} }
#[test] #[test]
fn merge_worker_generation_settings_upper_wins() { fn merge_worker_generation_settings_upper_wins() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
worker: WorkerManifestConfig { engine: EngineManifestConfig {
top_p: Some(0.8), top_p: Some(0.8),
top_k: Some(20), top_k: Some(20),
stop_sequences: Some(vec!["lower".into()]), stop_sequences: Some(vec!["lower".into()]),
@ -1420,8 +1426,8 @@ mod tests {
}, },
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
worker: WorkerManifestConfig { engine: EngineManifestConfig {
top_p: Some(0.9), top_p: Some(0.9),
stop_sequences: Some(vec!["upper".into()]), stop_sequences: Some(vec!["upper".into()]),
..Default::default() ..Default::default()
@ -1431,14 +1437,14 @@ mod tests {
let merged = lower.merge(upper); let merged = lower.merge(upper);
assert_eq!(merged.worker.top_p, Some(0.9)); assert_eq!(merged.engine.top_p, Some(0.9));
assert_eq!(merged.worker.top_k, Some(20)); assert_eq!(merged.engine.top_k, Some(20));
assert_eq!(merged.worker.stop_sequences, Some(vec!["upper".into()])); assert_eq!(merged.engine.stop_sequences, Some(vec!["upper".into()]));
} }
#[test] #[test]
fn merge_scope_accumulates_allow_and_deny() { fn merge_scope_accumulates_allow_and_deny() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
scope: ScopeConfig { scope: ScopeConfig {
allow: vec![ScopeRule { allow: vec![ScopeRule {
target: abs("/a"), target: abs("/a"),
@ -1449,7 +1455,7 @@ mod tests {
}, },
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
scope: ScopeConfig { scope: ScopeConfig {
allow: vec![ScopeRule { allow: vec![ScopeRule {
target: abs("/b"), target: abs("/b"),
@ -1471,7 +1477,7 @@ mod tests {
#[test] #[test]
fn merge_permissions_accumulates_rules_and_upper_default_wins() { fn merge_permissions_accumulates_rules_and_upper_default_wins() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
permissions: Some(PermissionConfigPartial { permissions: Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Allow), default_action: Some(crate::ToolPermissionAction::Allow),
rules: vec![ToolPermissionRule { rules: vec![ToolPermissionRule {
@ -1482,7 +1488,7 @@ mod tests {
}), }),
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
permissions: Some(PermissionConfigPartial { permissions: Some(PermissionConfigPartial {
default_action: Some(crate::ToolPermissionAction::Deny), default_action: Some(crate::ToolPermissionAction::Deny),
rules: vec![ToolPermissionRule { rules: vec![ToolPermissionRule {
@ -1507,8 +1513,8 @@ mod tests {
#[test] #[test]
fn merge_tool_output_per_tool_keywise() { fn merge_tool_output_per_tool_keywise() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
worker: WorkerManifestConfig { engine: EngineManifestConfig {
tool_output: ToolOutputLimitsPartial { tool_output: ToolOutputLimitsPartial {
default_max_bytes: Some(8192), default_max_bytes: Some(8192),
per_tool: [("Read".to_string(), 1024)].into_iter().collect(), per_tool: [("Read".to_string(), 1024)].into_iter().collect(),
@ -1517,8 +1523,8 @@ mod tests {
}, },
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
worker: WorkerManifestConfig { engine: EngineManifestConfig {
tool_output: ToolOutputLimitsPartial { tool_output: ToolOutputLimitsPartial {
default_max_bytes: None, default_max_bytes: None,
per_tool: [("Read".to_string(), 2048), ("Grep".to_string(), 512)] per_tool: [("Read".to_string(), 2048), ("Grep".to_string(), 512)]
@ -1530,7 +1536,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
let merged = lower.merge(upper); let merged = lower.merge(upper);
let to = &merged.worker.tool_output; let to = &merged.engine.tool_output;
assert_eq!(to.default_max_bytes, Some(8192)); assert_eq!(to.default_max_bytes, Some(8192));
assert_eq!(to.per_tool.get("Read"), Some(&2048)); assert_eq!(to.per_tool.get("Read"), Some(&2048));
assert_eq!(to.per_tool.get("Grep"), Some(&512)); assert_eq!(to.per_tool.get("Grep"), Some(&512));
@ -1538,8 +1544,8 @@ mod tests {
#[test] #[test]
fn merge_file_upload_max_bytes_upper_wins() { fn merge_file_upload_max_bytes_upper_wins() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
worker: WorkerManifestConfig { engine: EngineManifestConfig {
file_upload: FileUploadLimitsPartial { file_upload: FileUploadLimitsPartial {
max_bytes: Some(8192), max_bytes: Some(8192),
}, },
@ -1547,8 +1553,8 @@ mod tests {
}, },
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
worker: WorkerManifestConfig { engine: EngineManifestConfig {
file_upload: FileUploadLimitsPartial { file_upload: FileUploadLimitsPartial {
max_bytes: Some(54_321), max_bytes: Some(54_321),
}, },
@ -1557,11 +1563,11 @@ mod tests {
..Default::default() ..Default::default()
}; };
let merged = lower.merge(upper); let merged = lower.merge(upper);
assert_eq!(merged.worker.file_upload.max_bytes, Some(54_321)); assert_eq!(merged.engine.file_upload.max_bytes, Some(54_321));
} }
#[test] #[test]
fn merge_option_struct_field_wise() { fn merge_option_struct_field_wise() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
compaction: Some(CompactionConfigPartial { compaction: Some(CompactionConfigPartial {
threshold: Some(50_000), threshold: Some(50_000),
prune_protected_tokens: Some(5_000), prune_protected_tokens: Some(5_000),
@ -1577,7 +1583,7 @@ mod tests {
}), }),
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
compaction: Some(CompactionConfigPartial { compaction: Some(CompactionConfigPartial {
threshold: Some(80_000), threshold: Some(80_000),
..Default::default() ..Default::default()
@ -1604,32 +1610,32 @@ mod tests {
#[test] #[test]
fn from_toml_type_mismatch_is_hard_error() { fn from_toml_type_mismatch_is_hard_error() {
let bad = r#" let bad = r#"
[pod] [worker]
name = "x" name = "x"
[worker] [engine]
max_tokens = "not-a-number" max_tokens = "not-a-number"
"#; "#;
assert!(PodManifestConfig::from_toml(bad).is_err()); assert!(WorkerManifestConfig::from_toml(bad).is_err());
} }
#[test] #[test]
fn from_toml_accepts_unknown_field() { fn from_toml_accepts_unknown_field() {
// Unknown keys are warn-and-ignored, not hard errors. // Unknown keys are warn-and-ignored, not hard errors.
// `pod.pwd` specifically is silently dropped after the // `worker.pwd` specifically is silently dropped after the
// path-resolution ticket — keep it in the fixture to exercise // path-resolution ticket — keep it in the fixture to exercise
// that code path. // that code path.
let ok = r#" let ok = r#"
[pod] [worker]
name = "x" name = "x"
pwd = "/obsolete" pwd = "/obsolete"
[worker] [engine]
max_tokens = 1000 max_tokens = 1000
unknown_future_field = "tolerated" unknown_future_field = "tolerated"
"#; "#;
let cfg = PodManifestConfig::from_toml(ok).unwrap(); let cfg = WorkerManifestConfig::from_toml(ok).unwrap();
assert_eq!(cfg.worker.max_tokens, Some(1000)); assert_eq!(cfg.engine.max_tokens, Some(1000));
} }
#[test] #[test]
@ -1638,7 +1644,7 @@ unknown_future_field = "tolerated"
[compaction] [compaction]
prune_protected_turns = 3 prune_protected_turns = 3
"#; "#;
let err = PodManifestConfig::from_toml(bad).unwrap_err(); let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
assert!( assert!(
err.to_string().contains("compaction.prune_protected_turns"), err.to_string().contains("compaction.prune_protected_turns"),
"unexpected error: {err}" "unexpected error: {err}"
@ -1651,7 +1657,7 @@ prune_protected_turns = 3
[memory] [memory]
extract_worker_max_input_tokens = 30000 extract_worker_max_input_tokens = 30000
"#; "#;
let err = PodManifestConfig::from_toml(bad).unwrap_err(); let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
assert!( assert!(
err.to_string() err.to_string()
.contains("memory.extract_worker_max_input_tokens"), .contains("memory.extract_worker_max_input_tokens"),
@ -1661,7 +1667,7 @@ extract_worker_max_input_tokens = 30000
#[test] #[test]
fn from_toml_accepts_extract_worker_max_turns() { fn from_toml_accepts_extract_worker_max_turns() {
let cfg = PodManifestConfig::from_toml( let cfg = WorkerManifestConfig::from_toml(
r#" r#"
[memory] [memory]
extract_worker_max_turns = 2 extract_worker_max_turns = 2
@ -1673,36 +1679,36 @@ extract_worker_max_turns = 2
#[test] #[test]
fn from_toml_accepts_worker_reasoning_string_or_integer() { fn from_toml_accepts_worker_reasoning_string_or_integer() {
let effort = PodManifestConfig::from_toml( let effort = WorkerManifestConfig::from_toml(
r#" r#"
[worker] [engine]
reasoning = "xhigh" reasoning = "xhigh"
"#, "#,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
effort.worker.reasoning, effort.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::XHigh)) Some(ReasoningControl::Effort(ReasoningEffort::XHigh))
); );
let budget = PodManifestConfig::from_toml( let budget = WorkerManifestConfig::from_toml(
r#" r#"
[worker] [engine]
reasoning = -1 reasoning = -1
"#, "#,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
budget.worker.reasoning, budget.engine.reasoning,
Some(ReasoningControl::BudgetTokens(-1)) Some(ReasoningControl::BudgetTokens(-1))
); );
} }
#[test] #[test]
fn from_toml_accepts_worker_generation_settings() { fn from_toml_accepts_worker_generation_settings() {
let cfg = PodManifestConfig::from_toml( let cfg = WorkerManifestConfig::from_toml(
r#" r#"
[worker] [engine]
top_p = 0.9 top_p = 0.9
top_k = 40 top_k = 40
stop_sequences = ["\n\n", "</stop>"] stop_sequences = ["\n\n", "</stop>"]
@ -1710,17 +1716,17 @@ stop_sequences = ["\n\n", "</stop>"]
) )
.unwrap(); .unwrap();
assert_eq!(cfg.worker.top_p, Some(0.9)); assert_eq!(cfg.engine.top_p, Some(0.9));
assert_eq!(cfg.worker.top_k, Some(40)); assert_eq!(cfg.engine.top_k, Some(40));
assert_eq!( assert_eq!(
cfg.worker.stop_sequences, cfg.engine.stop_sequences,
Some(vec!["\n\n".into(), "</stop>".into()]) Some(vec!["\n\n".into(), "</stop>".into()])
); );
} }
#[test] #[test]
fn from_toml_accepts_worker_max_turns() { fn from_toml_accepts_worker_max_turns() {
let cfg = PodManifestConfig::from_toml( let cfg = WorkerManifestConfig::from_toml(
r#" r#"
[compaction] [compaction]
worker_max_turns = 7 worker_max_turns = 7
@ -1736,7 +1742,7 @@ worker_max_turns = 7
let mut cfg = minimal_valid(); let mut cfg = minimal_valid();
cfg.compaction = Some(CompactionConfigPartial::default()); cfg.compaction = Some(CompactionConfigPartial::default());
let manifest = PodManifest::try_from(cfg).unwrap(); let manifest = WorkerManifest::try_from(cfg).unwrap();
assert_eq!( assert_eq!(
manifest.compaction.unwrap().worker_max_turns, manifest.compaction.unwrap().worker_max_turns,
@ -1746,18 +1752,18 @@ worker_max_turns = 7
#[test] #[test]
fn feature_flags_default_disabled_in_resolved_manifest() { fn feature_flags_default_disabled_in_resolved_manifest() {
let manifest: PodManifest = minimal_valid().try_into().unwrap(); let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled); assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.enabled); assert!(!manifest.feature.memory.enabled);
assert!(!manifest.feature.web.enabled); assert!(!manifest.feature.web.enabled);
assert!(!manifest.feature.pods.enabled); assert!(!manifest.feature.workers.enabled);
assert!(!manifest.feature.ticket.enabled); assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.ticket_orchestration.enabled); assert!(!manifest.feature.ticket_orchestration.enabled);
} }
#[test] #[test]
fn from_toml_parses_explicit_feature_flags() { fn from_toml_parses_explicit_feature_flags() {
let cfg = PodManifestConfig::from_toml( let cfg = WorkerManifestConfig::from_toml(
r#" r#"
[feature.task] [feature.task]
enabled = true enabled = true
@ -1771,10 +1777,10 @@ enabled = true
"#, "#,
) )
.unwrap(); .unwrap();
let manifest: PodManifest = PodManifestConfig::builtin_defaults() let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
.merge(cfg) .merge(cfg)
.merge(PodManifestConfig { .merge(WorkerManifestConfig {
pod: PodMetaConfig { worker: WorkerMetaConfig {
name: Some("feature-test".into()), name: Some("feature-test".into()),
prompt_pack: None, prompt_pack: None,
}, },
@ -1785,7 +1791,7 @@ enabled = true
}, },
scope: ScopeConfig { scope: ScopeConfig {
allow: vec![ScopeRule { allow: vec![ScopeRule {
target: abs("/pod"), target: abs("/worker"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
}], }],
@ -1807,7 +1813,7 @@ enabled = true
#[test] #[test]
fn feature_flags_merge_as_partial_profile_layers() { fn feature_flags_merge_as_partial_profile_layers() {
let base = PodManifestConfig::from_toml( let base = WorkerManifestConfig::from_toml(
r#" r#"
[feature.memory] [feature.memory]
enabled = true enabled = true
@ -1818,7 +1824,7 @@ access = "read_only"
"#, "#,
) )
.unwrap(); .unwrap();
let upper = PodManifestConfig::from_toml( let upper = WorkerManifestConfig::from_toml(
r#" r#"
[feature.ticket] [feature.ticket]
access = "lifecycle" access = "lifecycle"
@ -1828,11 +1834,11 @@ enabled = true
"#, "#,
) )
.unwrap(); .unwrap();
let manifest: PodManifest = PodManifestConfig::builtin_defaults() let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
.merge(base) .merge(base)
.merge(upper) .merge(upper)
.merge(PodManifestConfig { .merge(WorkerManifestConfig {
pod: PodMetaConfig { worker: WorkerMetaConfig {
name: Some("feature-merge-test".into()), name: Some("feature-merge-test".into()),
prompt_pack: None, prompt_pack: None,
}, },
@ -1843,7 +1849,7 @@ enabled = true
}, },
scope: ScopeConfig { scope: ScopeConfig {
allow: vec![ScopeRule { allow: vec![ScopeRule {
target: abs("/pod"), target: abs("/worker"),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
}], }],
@ -1860,7 +1866,7 @@ enabled = true
TicketFeatureAccessConfig::Lifecycle TicketFeatureAccessConfig::Lifecycle
); );
assert!(manifest.feature.web.enabled); assert!(manifest.feature.web.enabled);
assert!(!manifest.feature.pods.enabled); assert!(!manifest.feature.workers.enabled);
} }
#[test] #[test]
@ -1871,20 +1877,20 @@ enabled = true
target = "/abs/project" target = "/abs/project"
permission = "write" permission = "write"
"#; "#;
let cfg = PodManifestConfig::from_toml(toml).unwrap(); let cfg = WorkerManifestConfig::from_toml(toml).unwrap();
assert!(cfg.pod.name.is_none()); assert!(cfg.worker.name.is_none());
assert_eq!(cfg.scope.allow.len(), 1); assert_eq!(cfg.scope.allow.len(), 1);
} }
#[test] #[test]
fn builtin_defaults_populates_worker_limit_defaults() { fn builtin_defaults_populates_worker_limit_defaults() {
let cfg = PodManifestConfig::builtin_defaults(); let cfg = WorkerManifestConfig::builtin_defaults();
assert_eq!( assert_eq!(
cfg.worker.tool_output.default_max_bytes, cfg.engine.tool_output.default_max_bytes,
Some(defaults::TOOL_OUTPUT_MAX_BYTES) Some(defaults::TOOL_OUTPUT_MAX_BYTES)
); );
assert_eq!( assert_eq!(
cfg.worker.file_upload.max_bytes, cfg.engine.file_upload.max_bytes,
Some(defaults::FILE_UPLOAD_MAX_BYTES) Some(defaults::FILE_UPLOAD_MAX_BYTES)
); );
} }
@ -1892,10 +1898,10 @@ permission = "write"
#[test] #[test]
fn builtin_defaults_merged_into_minimal_resolves_with_defaults() { fn builtin_defaults_merged_into_minimal_resolves_with_defaults() {
// Starting from builtin_defaults and overlaying only the // Starting from builtin_defaults and overlaying only the
// required fields must resolve to a PodManifest carrying the // required fields must resolve to a WorkerManifest carrying the
// centralised default values. // centralised default values.
let overlay = PodManifestConfig { let overlay = WorkerManifestConfig {
pod: PodMetaConfig { worker: WorkerMetaConfig {
name: Some("x".into()), name: Some("x".into()),
prompt_pack: None, prompt_pack: None,
}, },
@ -1906,7 +1912,7 @@ permission = "write"
}, },
scope: ScopeConfig { scope: ScopeConfig {
allow: vec![ScopeRule { allow: vec![ScopeRule {
target: abs("/pod"), target: abs("/worker"),
permission: Permission::Write, permission: Permission::Write,
recursive: true, recursive: true,
}], }],
@ -1914,22 +1920,22 @@ permission = "write"
}, },
..Default::default() ..Default::default()
}; };
let merged = PodManifestConfig::builtin_defaults().merge(overlay); let merged = WorkerManifestConfig::builtin_defaults().merge(overlay);
let manifest: PodManifest = merged.try_into().unwrap(); let manifest: WorkerManifest = merged.try_into().unwrap();
assert_eq!( assert_eq!(
manifest.worker.tool_output.default_max_bytes, manifest.engine.tool_output.default_max_bytes,
defaults::TOOL_OUTPUT_MAX_BYTES defaults::TOOL_OUTPUT_MAX_BYTES
); );
assert_eq!( assert_eq!(
manifest.worker.file_upload.max_bytes, manifest.engine.file_upload.max_bytes,
defaults::FILE_UPLOAD_MAX_BYTES defaults::FILE_UPLOAD_MAX_BYTES
); );
} }
#[test] #[test]
fn end_to_end_cascade() { fn end_to_end_cascade() {
let builtin = PodManifestConfig::default(); let builtin = WorkerManifestConfig::default();
let user = PodManifestConfig::from_toml( let user = WorkerManifestConfig::from_toml(
r#" r#"
[model] [model]
scheme = "anthropic" scheme = "anthropic"
@ -1937,7 +1943,7 @@ model_id = "claude-sonnet-4-20250514"
"#, "#,
) )
.unwrap(); .unwrap();
let project = PodManifestConfig::from_toml( let project = WorkerManifestConfig::from_toml(
r#" r#"
[[scope.allow]] [[scope.allow]]
target = "/abs/project" target = "/abs/project"
@ -1945,17 +1951,17 @@ permission = "write"
"#, "#,
) )
.unwrap(); .unwrap();
let overlay = PodManifestConfig::from_toml( let overlay = WorkerManifestConfig::from_toml(
r#" r#"
[pod] [worker]
name = "dbg" name = "dbg"
"#, "#,
) )
.unwrap(); .unwrap();
let merged = builtin.merge(user).merge(project).merge(overlay); let merged = builtin.merge(user).merge(project).merge(overlay);
let manifest: PodManifest = merged.try_into().unwrap(); let manifest: WorkerManifest = merged.try_into().unwrap();
assert_eq!(manifest.pod.name, "dbg"); assert_eq!(manifest.worker.name, "dbg");
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic)); assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
assert_eq!(manifest.scope.allow.len(), 1); assert_eq!(manifest.scope.allow.len(), 1);
} }
@ -1981,7 +1987,7 @@ name = "dbg"
cfg.skills = Some(SkillsConfig { cfg.skills = Some(SkillsConfig {
directories: vec![PathBuf::from("relative/skills")], directories: vec![PathBuf::from("relative/skills")],
}); });
let err = PodManifest::try_from(cfg).unwrap_err(); let err = WorkerManifest::try_from(cfg).unwrap_err();
assert!(matches!( assert!(matches!(
err, err,
ResolveError::RelativePath { ResolveError::RelativePath {
@ -1993,13 +1999,13 @@ name = "dbg"
#[test] #[test]
fn skills_merge_extends_directories() { fn skills_merge_extends_directories() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
skills: Some(SkillsConfig { skills: Some(SkillsConfig {
directories: vec![PathBuf::from("/a")], directories: vec![PathBuf::from("/a")],
}), }),
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
skills: Some(SkillsConfig { skills: Some(SkillsConfig {
directories: vec![PathBuf::from("/b")], directories: vec![PathBuf::from("/b")],
}), }),
@ -2013,13 +2019,13 @@ name = "dbg"
#[test] #[test]
fn from_toml_parses_skills_section() { fn from_toml_parses_skills_section() {
let toml = r#" let toml = r#"
[pod] [worker]
name = "x" name = "x"
[skills] [skills]
directories = [".claude/skills", ".cursor/skills"] directories = [".claude/skills", ".cursor/skills"]
"#; "#;
let cfg = PodManifestConfig::from_toml(toml).unwrap(); let cfg = WorkerManifestConfig::from_toml(toml).unwrap();
let dirs = cfg.skills.unwrap().directories; let dirs = cfg.skills.unwrap().directories;
assert_eq!( assert_eq!(
dirs, dirs,
@ -2032,14 +2038,14 @@ directories = [".claude/skills", ".cursor/skills"]
#[test] #[test]
fn merge_preserves_ref() { fn merge_preserves_ref() {
let lower = PodManifestConfig { let lower = WorkerManifestConfig {
model: ModelManifest { model: ModelManifest {
ref_: Some("anthropic/claude-sonnet-4-6".into()), ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default() ..Default::default()
}, },
..Default::default() ..Default::default()
}; };
let upper = PodManifestConfig { let upper = WorkerManifestConfig {
model: ModelManifest { model: ModelManifest {
// only override auth // only override auth
auth: Some(AuthRef::None), auth: Some(AuthRef::None),

View File

@ -1,7 +1,7 @@
//! Single source of truth for manifest default values. //! Single source of truth for manifest default values.
//! //!
//! Every default that would otherwise be duplicated between serde //! Every default that would otherwise be duplicated between serde
//! `#[serde(default = "...")]` attributes (on [`crate::PodManifest`]) //! `#[serde(default = "...")]` attributes (on [`crate::WorkerManifest`])
//! and the cascade resolution in [`crate::config`] lives here as a //! and the cascade resolution in [`crate::config`] lives here as a
//! `pub const`. Both paths read from this module, so changing a //! `pub const`. Both paths read from this module, so changing a
//! default requires editing exactly one line. //! default requires editing exactly one line.
@ -48,7 +48,7 @@ pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000;
pub const DEFAULT_INSTRUCTION: &str = "$yoi/default"; pub const DEFAULT_INSTRUCTION: &str = "$yoi/default";
/// Default language policy used by the main worker for normal prose /// Default language policy used by the main worker for normal prose
/// responses. See [`crate::WorkerManifest::language`]. /// responses. See [`crate::EngineManifest::language`].
pub const WORKER_LANGUAGE: &str = pub const WORKER_LANGUAGE: &str =
"match the user's language unless they explicitly request another language"; "match the user's language unless they explicitly request another language";

View File

@ -7,9 +7,9 @@ mod profile;
mod scope; mod scope;
pub use config::{ pub use config::{
CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig, CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
PodMetaConfig, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial, PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
WorkerManifestConfig, WorkerManifestConfig, WorkerMetaConfig,
}; };
pub use model::{ pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind, AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
@ -32,20 +32,20 @@ use std::path::PathBuf;
use serde::de::Error as _; use serde::de::Error as _;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Declarative configuration for a Pod. /// Declarative configuration for a Worker.
/// ///
/// Parsed from a TOML manifest file. Describes the model, system prompt, /// Parsed from a TOML manifest file. Describes the model, system prompt,
/// and directory scope (required). The Pod's working directory is **not** /// and directory scope (required). The Worker's working directory is **not**
/// part of the manifest — it is the process's `std::env::current_dir()` /// part of the manifest — it is the process's `std::env::current_dir()`
/// at construction time. /// at construction time.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PodManifest { pub struct WorkerManifest {
pub pod: PodMeta, pub worker: WorkerMeta,
pub model: ModelManifest, pub model: ModelManifest,
pub worker: WorkerManifest, pub engine: EngineManifest,
/// Direct filesystem authority for this Pod's own tools. /// Direct filesystem authority for this Worker's own tools.
pub scope: ScopeConfig, pub scope: ScopeConfig,
/// Filesystem authority this Pod may pass to spawned children. Missing /// Filesystem authority this Worker may pass to spawned children. Missing
/// metadata/config defaults to no delegation authority. /// metadata/config defaults to no delegation authority.
#[serde(default)] #[serde(default)]
pub delegation_scope: ScopeConfig, pub delegation_scope: ScopeConfig,
@ -91,14 +91,14 @@ pub struct PodManifest {
#[serde(default)] #[serde(default)]
pub skills: Option<SkillsConfig>, pub skills: Option<SkillsConfig>,
/// Optional profile provenance for manifests produced by profile resolution. /// Optional profile provenance for manifests produced by profile resolution.
/// Stored only after profile resolution so Pod restore can prefer the /// Stored only after profile resolution so Worker restore can prefer the
/// validated snapshot over current profile files or one-file Manifest input. /// validated snapshot over current profile files or one-file Manifest input.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<profile::ProfileManifestSnapshot>, pub profile: Option<profile::ProfileManifestSnapshot>,
} }
/// Explicit built-in feature/tool-surface enablement. These flags are /// Explicit built-in feature/tool-surface enablement. These flags are
/// profile/config data only: they do not carry runtime Pod names, sockets, /// profile/config data only: they do not carry runtime Worker names, sockets,
/// sessions, secrets, or resolved host state. Tool registration still applies /// sessions, secrets, or resolved host state. Tool registration still applies
/// the normal scope, host-authority, backend, memory, and network checks. /// the normal scope, host-authority, backend, memory, and network checks.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -110,7 +110,7 @@ pub struct FeatureConfig {
#[serde(default)] #[serde(default)]
pub web: FeatureFlagConfig, pub web: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
pub pods: FeatureFlagConfig, pub workers: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
pub ticket: TicketFeatureConfig, pub ticket: TicketFeatureConfig,
#[serde(default)] #[serde(default)]
@ -125,7 +125,7 @@ impl Default for FeatureConfig {
task: FeatureFlagConfig::disabled(), task: FeatureFlagConfig::disabled(),
memory: FeatureFlagConfig::disabled(), memory: FeatureFlagConfig::disabled(),
web: FeatureFlagConfig::disabled(), web: FeatureFlagConfig::disabled(),
pods: FeatureFlagConfig::disabled(), workers: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
ticket_orchestration: FeatureFlagConfig::disabled(), ticket_orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(), plugins: FeatureFlagConfig::disabled(),
@ -197,7 +197,7 @@ pub struct SkillsConfig {
/// Skills *roots*. Children of each root must be individual /// Skills *roots*. Children of each root must be individual
/// `<name>/SKILL.md` bundles; the directory itself is not a skill. /// `<name>/SKILL.md` bundles; the directory itself is not a skill.
/// Resolved against the manifest base directory before /// Resolved against the manifest base directory before
/// [`PodManifest`] is materialised. /// [`WorkerManifest`] is materialised.
#[serde(default)] #[serde(default)]
pub directories: Vec<PathBuf>, pub directories: Vec<PathBuf>,
} }
@ -206,7 +206,7 @@ pub struct SkillsConfig {
/// ///
/// The manifest layer records local stdio MCP server declarations but never /// The manifest layer records local stdio MCP server declarations but never
/// starts them. Future lifecycle code must opt in to spawning and must keep MCP /// starts them. Future lifecycle code must opt in to spawning and must keep MCP
/// process authority separate from Plugin permissions and `pod::feature` flags. /// process authority separate from Plugin permissions and `worker::feature` flags.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct McpConfig { pub struct McpConfig {
@ -363,7 +363,7 @@ pub struct WebFetchConfig {
/// Memory subsystem configuration. Presence in the manifest enables /// Memory subsystem configuration. Presence in the manifest enables
/// memory; `workspace_root` pins the memory workspace explicitly. When it /// memory; `workspace_root` pins the memory workspace explicitly. When it
/// is absent, memory resolution searches upward from the Pod's pwd for a /// is absent, memory resolution searches upward from the Worker's pwd for a
/// `.yoi/memory` marker rather than treating `.yoi` project records alone /// `.yoi/memory` marker rather than treating `.yoi` project records alone
/// as a memory root. /// as a memory root.
/// ///
@ -396,7 +396,7 @@ pub struct MemoryConfig {
#[serde(default)] #[serde(default)]
pub language: Option<String>, pub language: Option<String>,
/// Optional model for the extract worker. When `None`, /// Optional model for the extract worker. When `None`,
/// the main pod model is cloned via `clone_boxed()`. Lightweight /// the main engine model is cloned via `clone_boxed()`. Lightweight
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are /// reasoning-capable models (Haiku / 4o-mini / Flash class) are
/// recommended. /// recommended.
#[serde(default)] #[serde(default)]
@ -414,7 +414,7 @@ pub struct MemoryConfig {
#[serde(default)] #[serde(default)]
pub extract_worker_max_turns: Option<u32>, pub extract_worker_max_turns: Option<u32>,
/// Optional model for the consolidation worker. When /// Optional model for the consolidation worker. When
/// `None`, the main pod model is cloned via `clone_boxed()`. /// `None`, the main engine model is cloned via `clone_boxed()`.
/// Reasoning-class models are recommended. /// Reasoning-class models are recommended.
#[serde(default)] #[serde(default)]
pub consolidation_model: Option<ModelManifest>, pub consolidation_model: Option<ModelManifest>,
@ -432,12 +432,12 @@ pub struct MemoryConfig {
pub consolidation_threshold_bytes: Option<u64>, pub consolidation_threshold_bytes: Option<u64>,
} }
/// Pod metadata. /// Worker metadata.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PodMeta { pub struct WorkerMeta {
pub name: String, pub name: String,
/// Optional path to a TOML override file read as the top layer of /// Optional path to a TOML override file read as the top layer of
/// `pod::PromptCatalog`. Subject to the same relative-path /// `worker::PromptCatalog`. Subject to the same relative-path
/// resolution as other manifest paths (joined against the /// resolution as other manifest paths (joined against the
/// manifest's base directory). `None` leaves the 4th overlay layer /// manifest's base directory). `None` leaves the 4th overlay layer
/// empty; auto-discovered user and workspace packs still apply. /// empty; auto-discovered user and workspace packs still apply.
@ -453,7 +453,7 @@ pub struct PodMeta {
/// Worker-level configuration embedded in the manifest. /// Worker-level configuration embedded in the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerManifest { pub struct EngineManifest {
/// Reference to the instruction prompt asset used as the body of /// Reference to the instruction prompt asset used as the body of
/// the worker's system prompt. Uses the `PromptLoader` prefix /// the worker's system prompt. Uses the `PromptLoader` prefix
/// addressing scheme (`$yoi/...`, `$user/...`, /// addressing scheme (`$yoi/...`, `$user/...`,
@ -572,7 +572,7 @@ impl ToolOutputLimits {
/// Declarative scope configuration. /// Declarative scope configuration.
/// ///
/// A Pod may only touch paths whose effective permission (computed from /// A Worker may only touch paths whose effective permission (computed from
/// allow/deny rules below) is at least `Read` / `Write`. See /// allow/deny rules below) is at least `Read` / `Write`. See
/// [`Scope`] for the resolved runtime form. /// [`Scope`] for the resolved runtime form.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
@ -653,7 +653,7 @@ pub struct CompactionConfig {
/// Safety-net (between-requests) compaction threshold. /// Safety-net (between-requests) compaction threshold.
/// ///
/// Checked by `PodInterceptor::pre_llm_request` inside a turn. When /// Checked by `WorkerInterceptor::pre_llm_request` inside a turn. When
/// current occupancy exceeds this value, the run yields so that the /// current occupancy exceeds this value, the run yields so that the
/// Controller can compact before the next LLM request. `None` /// Controller can compact before the next LLM request. `None`
/// disables the between-requests check. /// disables the between-requests check.
@ -802,7 +802,7 @@ impl Default for CompactionConfig {
} }
} }
impl PodManifest { impl WorkerManifest {
/// Parse a manifest from a TOML string. /// Parse a manifest from a TOML string.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> { pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
config::reject_removed_manifest_fields(s)?; config::reject_removed_manifest_fields(s)?;
@ -818,14 +818,14 @@ mod tests {
use super::*; use super::*;
const MINIMAL_REQUIRED: &str = r#" const MINIMAL_REQUIRED: &str = r#"
[pod] [worker]
name = "test-agent" name = "test-agent"
[model] [model]
scheme = "anthropic" scheme = "anthropic"
model_id = "claude-sonnet-4-20250514" model_id = "claude-sonnet-4-20250514"
[worker] [engine]
[[scope.allow]] [[scope.allow]]
target = "/abs/scope" target = "/abs/scope"
@ -834,8 +834,8 @@ permission = "write"
#[test] #[test]
fn parse_minimal_manifest() { fn parse_minimal_manifest() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert_eq!(manifest.pod.name, "test-agent"); assert_eq!(manifest.worker.name, "test-agent");
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic)); assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
assert_eq!( assert_eq!(
manifest.model.model_id.as_deref(), manifest.model.model_id.as_deref(),
@ -846,19 +846,19 @@ permission = "write"
assert!(manifest.scope.deny.is_empty()); assert!(manifest.scope.deny.is_empty());
assert!(manifest.delegation_scope.allow.is_empty()); assert!(manifest.delegation_scope.allow.is_empty());
assert!(manifest.delegation_scope.deny.is_empty()); assert!(manifest.delegation_scope.deny.is_empty());
assert_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION); assert_eq!(manifest.engine.instruction, defaults::DEFAULT_INSTRUCTION);
assert!(manifest.worker.top_p.is_none()); assert!(manifest.engine.top_p.is_none());
assert!(manifest.worker.top_k.is_none()); assert!(manifest.engine.top_k.is_none());
assert!(manifest.worker.stop_sequences.is_empty()); assert!(manifest.engine.stop_sequences.is_empty());
assert!(manifest.web.is_none()); assert!(manifest.web.is_none());
} }
#[test] #[test]
fn deserialize_old_manifest_snapshot_defaults_to_no_delegation() { fn deserialize_old_manifest_snapshot_defaults_to_no_delegation() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let mut snapshot = serde_json::to_value(&manifest).unwrap(); let mut snapshot = serde_json::to_value(&manifest).unwrap();
snapshot.as_object_mut().unwrap().remove("delegation_scope"); snapshot.as_object_mut().unwrap().remove("delegation_scope");
let restored: PodManifest = serde_json::from_value(snapshot).unwrap(); let restored: WorkerManifest = serde_json::from_value(snapshot).unwrap();
assert_eq!(restored.scope.allow.len(), 1); assert_eq!(restored.scope.allow.len(), 1);
assert!(restored.delegation_scope.allow.is_empty()); assert!(restored.delegation_scope.allow.is_empty());
assert!(restored.delegation_scope.deny.is_empty()); assert!(restored.delegation_scope.deny.is_empty());
@ -870,7 +870,7 @@ permission = "write"
"{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_secret = \"web/brave/default\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n", "{}\n[web]\nenabled = true\n\n[web.search]\nprovider = \"brave\"\napi_key_secret = \"web/brave/default\"\ntimeout_secs = 12\n\n[web.fetch]\ntimeout_secs = 7\nredirect_limit = 3\nmax_response_bytes = 12345\nmax_output_bytes = 2048\n",
MINIMAL_REQUIRED MINIMAL_REQUIRED
); );
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let web = manifest.web.unwrap(); let web = manifest.web.unwrap();
assert_eq!(web.enabled, Some(true)); assert_eq!(web.enabled, Some(true));
let search = web.search.unwrap(); let search = web.search.unwrap();
@ -886,7 +886,7 @@ permission = "write"
#[test] #[test]
fn parse_full_manifest() { fn parse_full_manifest() {
let toml = r#" let toml = r#"
[pod] [worker]
name = "code-reviewer" name = "code-reviewer"
[model] [model]
@ -894,7 +894,7 @@ scheme = "anthropic"
model_id = "claude-sonnet-4-20250514" model_id = "claude-sonnet-4-20250514"
auth = { kind = "api_key", file = "/abs/keys/anthropic" } auth = { kind = "api_key", file = "/abs/keys/anthropic" }
[worker] [engine]
instruction = "$user/reviewer" instruction = "$user/reviewer"
max_tokens = 4096 max_tokens = 4096
temperature = 0.3 temperature = 0.3
@ -924,21 +924,21 @@ permission = "write"
target = "/abs/project/tasks/private" target = "/abs/project/tasks/private"
permission = "write" permission = "write"
"#; "#;
let manifest = PodManifest::from_toml(toml).unwrap(); let manifest = WorkerManifest::from_toml(toml).unwrap();
assert_eq!(manifest.pod.name, "code-reviewer"); assert_eq!(manifest.worker.name, "code-reviewer");
let file = match manifest.model.auth.as_ref() { let file = match manifest.model.auth.as_ref() {
Some(AuthRef::ApiKey { file, .. }) => file.as_deref(), Some(AuthRef::ApiKey { file, .. }) => file.as_deref(),
_ => panic!("expected ApiKey"), _ => panic!("expected ApiKey"),
}; };
assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic"))); assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic")));
assert_eq!(manifest.worker.instruction, "$user/reviewer"); assert_eq!(manifest.engine.instruction, "$user/reviewer");
assert_eq!(manifest.worker.max_tokens, Some(4096)); assert_eq!(manifest.engine.max_tokens, Some(4096));
assert_eq!(manifest.worker.temperature, Some(0.3)); assert_eq!(manifest.engine.temperature, Some(0.3));
assert_eq!(manifest.worker.top_p, Some(0.9)); assert_eq!(manifest.engine.top_p, Some(0.9));
assert_eq!(manifest.worker.top_k, Some(40)); assert_eq!(manifest.engine.top_k, Some(40));
assert_eq!(manifest.worker.stop_sequences, vec!["\n\n", "</stop>"]); assert_eq!(manifest.engine.stop_sequences, vec!["\n\n", "</stop>"]);
assert_eq!( assert_eq!(
manifest.worker.reasoning, manifest.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::Medium)) Some(ReasoningControl::Effort(ReasoningEffort::Medium))
); );
let allow = &manifest.scope.allow; let allow = &manifest.scope.allow;
@ -960,16 +960,16 @@ permission = "write"
#[test] #[test]
fn reject_missing_scope() { fn reject_missing_scope() {
let toml = r#" let toml = r#"
[pod] [worker]
name = "missing-scope" name = "missing-scope"
[model] [model]
scheme = "anthropic" scheme = "anthropic"
model_id = "claude-sonnet-4-20250514" model_id = "claude-sonnet-4-20250514"
[worker] [engine]
"#; "#;
assert!(PodManifest::from_toml(toml).is_err()); assert!(WorkerManifest::from_toml(toml).is_err());
} }
#[test] #[test]
@ -984,7 +984,7 @@ model_id = "claude-sonnet-4-20250514"
[plugins.enabled.config]\n\ [plugins.enabled.config]\n\
greeting = \"hello\"\n" greeting = \"hello\"\n"
); );
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.plugins.enabled.len(), 1); assert_eq!(manifest.plugins.enabled.len(), 1);
let enabled = &manifest.plugins.enabled[0]; let enabled = &manifest.plugins.enabled[0];
assert_eq!(enabled.id, "project:example"); assert_eq!(enabled.id, "project:example");
@ -1005,37 +1005,37 @@ model_id = "claude-sonnet-4-20250514"
#[test] #[test]
fn parse_max_turns() { fn parse_max_turns() {
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n"); let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nmax_turns = 50\n");
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.worker.max_turns.unwrap().get(), 50); assert_eq!(manifest.engine.max_turns.unwrap().get(), 50);
} }
#[test] #[test]
fn parse_reasoning_budget() { fn parse_reasoning_budget() {
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nreasoning = -1\n"); let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nreasoning = -1\n");
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
assert_eq!( assert_eq!(
manifest.worker.reasoning, manifest.engine.reasoning,
Some(ReasoningControl::BudgetTokens(-1)) Some(ReasoningControl::BudgetTokens(-1))
); );
} }
#[test] #[test]
fn omitted_max_turns_is_none() { fn omitted_max_turns_is_none() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert!(manifest.worker.max_turns.is_none()); assert!(manifest.engine.max_turns.is_none());
} }
#[test] #[test]
fn reject_max_turns_zero() { fn reject_max_turns_zero() {
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 0\n"); let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nmax_turns = 0\n");
assert!(PodManifest::from_toml(&toml).is_err()); assert!(WorkerManifest::from_toml(&toml).is_err());
} }
#[test] #[test]
fn parse_compaction_config() { fn parse_compaction_config() {
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nthreshold = 80000\n"); let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nthreshold = 80000\n");
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let c = manifest.compaction.unwrap(); let c = manifest.compaction.unwrap();
assert_eq!(c.prune_protected_tokens, 8000); assert_eq!(c.prune_protected_tokens, 8000);
assert_eq!(c.prune_min_savings, 4096); assert_eq!(c.prune_min_savings, 4096);
@ -1048,7 +1048,7 @@ model_id = "claude-sonnet-4-20250514"
#[test] #[test]
fn reject_removed_prune_protected_turns_field() { fn reject_removed_prune_protected_turns_field() {
let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nprune_protected_turns = 3\n"); let toml = format!("{MINIMAL_REQUIRED}\n[compaction]\nprune_protected_turns = 3\n");
let err = PodManifest::from_toml(&toml).unwrap_err(); let err = WorkerManifest::from_toml(&toml).unwrap_err();
assert!( assert!(
err.to_string().contains("compaction.prune_protected_turns"), err.to_string().contains("compaction.prune_protected_turns"),
"unexpected error: {err}" "unexpected error: {err}"
@ -1062,7 +1062,7 @@ model_id = "claude-sonnet-4-20250514"
[compaction]\n\ [compaction]\n\
worker_max_turns = 7\n" worker_max_turns = 7\n"
); );
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let c = manifest.compaction.unwrap(); let c = manifest.compaction.unwrap();
assert_eq!(c.worker_max_turns, Some(7)); assert_eq!(c.worker_max_turns, Some(7));
} }
@ -1075,7 +1075,7 @@ model_id = "claude-sonnet-4-20250514"
threshold = 80000\n\ threshold = 80000\n\
request_threshold = 90000\n" request_threshold = 90000\n"
); );
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let c = manifest.compaction.unwrap(); let c = manifest.compaction.unwrap();
assert_eq!(c.threshold, Some(80000)); assert_eq!(c.threshold, Some(80000));
assert_eq!(c.request_threshold, Some(90000)); assert_eq!(c.request_threshold, Some(90000));
@ -1088,7 +1088,7 @@ model_id = "claude-sonnet-4-20250514"
[compaction]\n\ [compaction]\n\
request_threshold = 90000\n" request_threshold = 90000\n"
); );
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let c = manifest.compaction.unwrap(); let c = manifest.compaction.unwrap();
assert_eq!(c.threshold, None); assert_eq!(c.threshold, None);
assert_eq!(c.request_threshold, Some(90000)); assert_eq!(c.request_threshold, Some(90000));
@ -1104,7 +1104,7 @@ model_id = "claude-sonnet-4-20250514"
scheme = \"gemini\"\n\ scheme = \"gemini\"\n\
model_id = \"gemini-2.0-flash\"\n" model_id = \"gemini-2.0-flash\"\n"
); );
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let c = manifest.compaction.unwrap(); let c = manifest.compaction.unwrap();
let p = c.model.unwrap(); let p = c.model.unwrap();
assert_eq!(p.scheme, Some(SchemeKind::Gemini)); assert_eq!(p.scheme, Some(SchemeKind::Gemini));
@ -1113,20 +1113,20 @@ model_id = "claude-sonnet-4-20250514"
#[test] #[test]
fn omitted_compaction_is_none() { fn omitted_compaction_is_none() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert!(manifest.compaction.is_none()); assert!(manifest.compaction.is_none());
} }
#[test] #[test]
fn omitted_memory_is_none() { fn omitted_memory_is_none() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert!(manifest.memory.is_none()); assert!(manifest.memory.is_none());
} }
#[test] #[test]
fn empty_memory_section_enables_with_default_root() { fn empty_memory_section_enables_with_default_root() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n"); let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n");
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.expect("memory section parsed"); let mem = manifest.memory.expect("memory section parsed");
assert!(mem.workspace_root.is_none()); assert!(mem.workspace_root.is_none());
assert_eq!(mem.inject_summary, None); assert_eq!(mem.inject_summary, None);
@ -1135,7 +1135,7 @@ model_id = "claude-sonnet-4-20250514"
#[test] #[test]
fn memory_section_with_inject_summary_false() { fn memory_section_with_inject_summary_false() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n"); let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap(); let mem = manifest.memory.unwrap();
assert_eq!(mem.inject_summary, Some(false)); assert_eq!(mem.inject_summary, Some(false));
} }
@ -1143,7 +1143,7 @@ model_id = "claude-sonnet-4-20250514"
#[test] #[test]
fn memory_section_with_explicit_root() { fn memory_section_with_explicit_root() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n"); let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap(); let mem = manifest.memory.unwrap();
assert_eq!( assert_eq!(
mem.workspace_root.unwrap(), mem.workspace_root.unwrap(),
@ -1154,7 +1154,7 @@ model_id = "claude-sonnet-4-20250514"
#[test] #[test]
fn memory_section_with_language() { fn memory_section_with_language() {
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n"); let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let mem = manifest.memory.unwrap(); let mem = manifest.memory.unwrap();
assert_eq!(mem.language.as_deref(), Some("Japanese")); assert_eq!(mem.language.as_deref(), Some("Japanese"));
} }
@ -1163,62 +1163,62 @@ model_id = "claude-sonnet-4-20250514"
fn reject_unknown_scheme() { fn reject_unknown_scheme() {
let toml = let toml =
MINIMAL_REQUIRED.replace("scheme = \"anthropic\"", "scheme = \"unknown_scheme\""); MINIMAL_REQUIRED.replace("scheme = \"anthropic\"", "scheme = \"unknown_scheme\"");
assert!(PodManifest::from_toml(&toml).is_err()); assert!(WorkerManifest::from_toml(&toml).is_err());
} }
#[test] #[test]
fn omitted_limits_fall_back_to_defaults() { fn omitted_limits_fall_back_to_defaults() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let limits = &manifest.worker.tool_output; let limits = &manifest.engine.tool_output;
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES); assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
assert!(limits.per_tool.is_empty()); assert!(limits.per_tool.is_empty());
assert_eq!( assert_eq!(
manifest.worker.file_upload.max_bytes, manifest.engine.file_upload.max_bytes,
defaults::FILE_UPLOAD_MAX_BYTES defaults::FILE_UPLOAD_MAX_BYTES
); );
} }
#[test] #[test]
fn worker_language_defaults_and_parses() { fn worker_language_defaults_and_parses() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap(); let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert_eq!(manifest.worker.language, defaults::WORKER_LANGUAGE); assert_eq!(manifest.engine.language, defaults::WORKER_LANGUAGE);
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nlanguage = \"Japanese\"\n"); let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nlanguage = \"Japanese\"\n");
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.worker.language, "Japanese"); assert_eq!(manifest.engine.language, "Japanese");
} }
#[test] #[test]
fn parse_worker_output_limits() { fn parse_worker_output_limits() {
let toml = MINIMAL_REQUIRED.replace( let toml = MINIMAL_REQUIRED.replace(
"[worker]\n", "[engine]\n",
"[worker]\n\ "[engine]\n\
[worker.tool_output]\n\ [worker.tool_output]\n\
default_max_bytes = 8192\n\n\ default_max_bytes = 8192\n\n\
[worker.tool_output.per_tool]\n\ [worker.tool_output.per_tool]\n\
Read = 32768\n\ Read = 32768\n\
Grep = 4096\n\n\ Grep = 4096\n\n\
[worker.file_upload]\n\ [engine.file_upload]\n\
max_bytes = 12345\n", max_bytes = 12345\n",
); );
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let limits = &manifest.worker.tool_output; let limits = &manifest.engine.tool_output;
assert_eq!(limits.default_max_bytes, 8192); assert_eq!(limits.default_max_bytes, 8192);
assert_eq!(limits.limit_for("Read"), 32768); assert_eq!(limits.limit_for("Read"), 32768);
assert_eq!(limits.limit_for("Grep"), 4096); assert_eq!(limits.limit_for("Grep"), 4096);
assert_eq!(limits.limit_for("Unknown"), 8192); assert_eq!(limits.limit_for("Unknown"), 8192);
assert_eq!(manifest.worker.file_upload.max_bytes, 12345); assert_eq!(manifest.engine.file_upload.max_bytes, 12345);
} }
#[test] #[test]
fn empty_tool_output_section_uses_default_max_bytes() { fn empty_tool_output_section_uses_default_max_bytes() {
let toml = MINIMAL_REQUIRED.replace( let toml = MINIMAL_REQUIRED.replace(
"[worker]\n", "[engine]\n",
"[worker]\n\ "[engine]\n\
[worker.tool_output]\n", [worker.tool_output]\n",
); );
let manifest = PodManifest::from_toml(&toml).unwrap(); let manifest = WorkerManifest::from_toml(&toml).unwrap();
let limits = &manifest.worker.tool_output; let limits = &manifest.engine.tool_output;
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES); assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
assert!(limits.per_tool.is_empty()); assert!(limits.per_tool.is_empty());
} }

View File

@ -1,6 +1,6 @@
//! LLM モデル宣言型 //! LLM モデル宣言型
//! //!
//! Pod マニフェストの `[model]` セクションで記述する型。`ref`(プロバイダ //! Worker マニフェストの `[model]` セクションで記述する型。`ref`(プロバイダ
//! とモデルを両方指し示す短縮形)と inline 指定(`scheme` / `model_id` //! とモデルを両方指し示す短縮形)と inline 指定(`scheme` / `model_id`
//! 直書き)の両方を受け入れるため、すべてのフィールドを `Option` として //! 直書き)の両方を受け入れるため、すべてのフィールドを `Option` として
//! 持つ 1 つの型 [`ModelManifest`] に統合している。実解決ref をプロバイダ //! 持つ 1 つの型 [`ModelManifest`] に統合している。実解決ref をプロバイダ
@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
// マニフェストで任意に override できるよう型だけ再エクスポートする。 // マニフェストで任意に override できるよう型だけ再エクスポートする。
pub use llm_engine::llm_client::capability::{ModelCapability, ReasoningControl, ReasoningEffort}; pub use llm_engine::llm_client::capability::{ModelCapability, ReasoningControl, ReasoningEffort};
/// Pod マニフェストの `[model]` セクション。 /// Worker マニフェストの `[model]` セクション。
/// ///
/// - ref だけ書く: `[model] ref = "anthropic/claude-sonnet-4-6"` /// - ref だけ書く: `[model] ref = "anthropic/claude-sonnet-4-6"`
/// - ref + 一部 override: ref で基底を引き、`auth` 等だけ書き換え /// - ref + 一部 override: ref で基底を引き、`auth` 等だけ書き換え

View File

@ -6,7 +6,7 @@
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等 //! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等 //! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
//! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket, //! - **`runtime_dir`** — 再起動で消えてよいランタイム状態。socket,
//! `pods.json`, `pid` ファイル等 //! `workers.json`, `pid` ファイル等
//! //!
//! ## 解決順 (優先順位高 → 低) //! ## 解決順 (優先順位高 → 低)
//! //!
@ -46,7 +46,7 @@ pub fn data_dir() -> Option<PathBuf> {
) )
} }
/// ランタイムディレクトリ。socket, `pods.json`, Pod ごとの `pid` / /// ランタイムディレクトリ。socket, `workers.json`, Worker ごとの `pid` /
/// `status.json` 等が置かれる。再起動で消えて構わない。 /// `status.json` 等が置かれる。再起動で消えて構わない。
pub fn runtime_dir() -> Option<PathBuf> { pub fn runtime_dir() -> Option<PathBuf> {
resolve_runtime_dir_from_parts( resolve_runtime_dir_from_parts(
@ -61,7 +61,7 @@ pub fn runtime_dir() -> Option<PathBuf> {
/// `<config_dir>/profiles.toml` — user profile registry/default configuration. /// `<config_dir>/profiles.toml` — user profile registry/default configuration.
/// ///
/// This is application/profile selection configuration, not a Pod manifest /// This is application/profile selection configuration, not a Worker manifest
/// layer. /// layer.
pub fn user_profiles_path() -> Option<PathBuf> { pub fn user_profiles_path() -> Option<PathBuf> {
user_profiles_path_from_config_dir(config_dir()) user_profiles_path_from_config_dir(config_dir())
@ -88,24 +88,24 @@ pub fn sessions_dir() -> Option<PathBuf> {
sessions_dir_from_data_dir(data_dir()) sessions_dir_from_data_dir(data_dir())
} }
/// `<runtime_dir>/pods.json` — machine-wide Pod allocation registry。 /// `<runtime_dir>/workers.json` — machine-wide Worker allocation registry。
pub fn pod_registry_path() -> Option<PathBuf> { pub fn pod_registry_path() -> Option<PathBuf> {
pod_registry_path_from_runtime_dir(runtime_dir()) pod_registry_path_from_runtime_dir(runtime_dir())
} }
/// `<runtime_dir>/<pod_name>/` — Pod ごとのランタイムディレクトリ。 /// `<runtime_dir>/<worker_name>/` — Worker ごとのランタイムディレクトリ。
pub fn pod_runtime_dir(pod_name: &str) -> Option<PathBuf> { pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
pod_runtime_dir_from_runtime_dir(runtime_dir(), pod_name) worker_runtime_dir_from_runtime_dir(runtime_dir(), worker_name)
} }
/// `<runtime_dir>/<pod_name>/sock` — Pod の Unix socket パス。 /// `<runtime_dir>/<worker_name>/sock` — Worker の Unix socket パス。
/// ///
/// Pod プロセス内で実際に socket を作成するのは `pod` crate の /// Worker プロセス内で実際に socket を作成するのは `worker` crate の
/// `RuntimeDir::socket_path()` で、Pod 名が分かっている外部 (TUI の /// `RuntimeDir::socket_path()` で、Worker 名が分かっている外部 (TUI の
/// attach フロー等) からの**予測**はこの関数で行う。両者は同じパス /// attach フロー等) からの**予測**はこの関数で行う。両者は同じパス
/// を返すことが期待される。 /// を返すことが期待される。
pub fn pod_socket_path(pod_name: &str) -> Option<PathBuf> { pub fn pod_socket_path(worker_name: &str) -> Option<PathBuf> {
pod_socket_path_from_runtime_dir(runtime_dir(), pod_name) pod_socket_path_from_runtime_dir(runtime_dir(), worker_name)
} }
// ---- internals -------------------------------------------------------------- // ---- internals --------------------------------------------------------------
@ -184,21 +184,21 @@ fn sessions_dir_from_data_dir(data_dir: Option<PathBuf>) -> Option<PathBuf> {
} }
fn pod_registry_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Option<PathBuf> { fn pod_registry_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(runtime_dir?.join("pods.json")) Some(runtime_dir?.join("workers.json"))
} }
fn pod_runtime_dir_from_runtime_dir( fn worker_runtime_dir_from_runtime_dir(
runtime_dir: Option<PathBuf>, runtime_dir: Option<PathBuf>,
pod_name: &str, worker_name: &str,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
Some(runtime_dir?.join(pod_name)) Some(runtime_dir?.join(worker_name))
} }
fn pod_socket_path_from_runtime_dir( fn pod_socket_path_from_runtime_dir(
runtime_dir: Option<PathBuf>, runtime_dir: Option<PathBuf>,
pod_name: &str, worker_name: &str,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
Some(pod_runtime_dir_from_runtime_dir(runtime_dir, pod_name)?.join("sock")) Some(worker_runtime_dir_from_runtime_dir(runtime_dir, worker_name)?.join("sock"))
} }
/// 空文字列の env は未設定として扱う。`std::env::var` は `Ok("")` と /// 空文字列の env は未設定として扱う。`std::env::var` は `Ok("")` と
@ -397,10 +397,10 @@ mod tests {
); );
assert_eq!( assert_eq!(
pod_registry_path_from_runtime_dir(runtime_dir.clone()).unwrap(), pod_registry_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
PathBuf::from("/sand/run/pods.json") PathBuf::from("/sand/run/workers.json")
); );
assert_eq!( assert_eq!(
pod_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(), worker_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
PathBuf::from("/sand/run/foo") PathBuf::from("/sand/run/foo")
); );
assert_eq!( assert_eq!(

View File

@ -2,7 +2,7 @@
//! //!
//! Profiles are reusable, human-authored recipes. They are intentionally not //! Profiles are reusable, human-authored recipes. They are intentionally not
//! complete runtime manifests: runtime-bound and authority-bearing fields such //! complete runtime manifests: runtime-bound and authority-bearing fields such
//! as `pod.name` and concrete `scope.allow` rules are supplied by the resolver //! as `worker.name` and concrete `scope.allow` rules are supplied by the resolver
//! from launch context. //! from launch context.
use std::cell::RefCell; use std::cell::RefCell;
@ -19,9 +19,9 @@ use crate::config::{
use crate::model::{AuthRef, ModelManifest}; use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig; use crate::plugin::PluginConfig;
use crate::{ use crate::{
McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig, EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig, ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
WorkerManifestConfig, paths, WorkerMetaConfig, paths,
}; };
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1"; const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
@ -408,26 +408,26 @@ pub struct WorkspaceOverrideSnapshot {
#[derive(Debug)] #[derive(Debug)]
struct WorkspaceOverrideLayer { struct WorkspaceOverrideLayer {
path: PathBuf, path: PathBuf,
config: PodManifestConfig, config: WorkerManifestConfig,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ResolvedProfile { pub struct ResolvedProfile {
pub source: ProfileSource, pub source: ProfileSource,
pub profile: Option<ProfileMetadata>, pub profile: Option<ProfileMetadata>,
pub manifest: PodManifest, pub manifest: WorkerManifest,
pub manifest_snapshot: serde_json::Value, pub manifest_snapshot: serde_json::Value,
pub raw_artifact: serde_json::Value, pub raw_artifact: serde_json::Value,
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct ProfileResolveOptions { pub struct ProfileResolveOptions {
pub pod_name: Option<String>, pub worker_name: Option<String>,
} }
impl ProfileResolveOptions { impl ProfileResolveOptions {
pub fn with_pod_name(name: impl Into<String>) -> Self { pub fn with_worker_name(name: impl Into<String>) -> Self {
Self { Self {
pod_name: Some(name.into()), worker_name: Some(name.into()),
} }
} }
} }
@ -469,8 +469,8 @@ impl ProfileResolver {
} }
} }
/// Resolve a registry/default selector against an already-discovered /// Resolve a registry/default selector against an already-discovered
/// registry. Callers such as SpawnPod use this to bind discovery to the /// registry. Callers such as SpawnWorker use this to bind discovery to the
/// Pod's cwd instead of the process current directory. /// Worker's cwd instead of the process current directory.
pub fn resolve_from_registry( pub fn resolve_from_registry(
&self, &self,
selector: &ProfileSelector, selector: &ProfileSelector,
@ -604,22 +604,22 @@ fn resolve_lua_profile_value(
let profile: ProfileConfig = serde_json::from_value(value.clone()) let profile: ProfileConfig = serde_json::from_value(value.clone())
.map_err(|source| ProfileError::ProfileDeserialize { source })?; .map_err(|source| ProfileError::ProfileDeserialize { source })?;
validate_profile_paths(&profile)?; validate_profile_paths(&profile)?;
let pod_name = options let worker_name = options
.pod_name .worker_name
.ok_or(ProfileError::MissingRuntimePodName)?; .ok_or(ProfileError::MissingRuntimeWorkerName)?;
let profile_meta = Some(ProfileMetadata { let profile_meta = Some(ProfileMetadata {
name: profile.slug.clone().or_else(|| source_name(&source)), name: profile.slug.clone().or_else(|| source_name(&source)),
description: profile.description.clone(), description: profile.description.clone(),
format: Some(PROFILE_FORMAT_V1.to_string()), format: Some(PROFILE_FORMAT_V1.to_string()),
}); });
let compaction = profile_compaction_to_partial(profile.compaction, &profile.model)?; let compaction = profile_compaction_to_partial(profile.compaction, &profile.model)?;
let config = PodManifestConfig { let config = WorkerManifestConfig {
pod: PodMetaConfig { worker: WorkerMetaConfig {
name: Some(pod_name), name: Some(worker_name),
prompt_pack: None, prompt_pack: None,
}, },
model: profile.model.unwrap_or_default(), model: profile.model.unwrap_or_default(),
worker: profile.worker.unwrap_or_default(), engine: profile.engine.unwrap_or_default(),
scope: profile_scope_to_config(profile.scope, workspace_base)?, scope: profile_scope_to_config(profile.scope, workspace_base)?,
delegation_scope: profile_delegation_scope_to_config( delegation_scope: profile_delegation_scope_to_config(
profile.delegation_scope, profile.delegation_scope,
@ -635,7 +635,8 @@ fn resolve_lua_profile_value(
memory: profile.memory, memory: profile.memory,
skills: profile.skills, skills: profile.skills,
}; };
let mut config = PodManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); let mut config =
WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
let workspace_override_snapshot = if let Some(override_layer) = workspace_override { let workspace_override_snapshot = if let Some(override_layer) = workspace_override {
let override_base = let override_base =
override_layer override_layer
@ -652,7 +653,7 @@ fn resolve_lua_profile_value(
} else { } else {
None None
}; };
let mut manifest = PodManifest::try_from(config).map_err(ProfileError::ManifestResolve)?; let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
manifest.profile = Some(ProfileManifestSnapshot { manifest.profile = Some(ProfileManifestSnapshot {
source: source.clone(), source: source.clone(),
profile: profile_meta.clone(), profile: profile_meta.clone(),
@ -679,7 +680,7 @@ struct ProfileConfig {
#[serde(default)] #[serde(default)]
model: Option<ModelManifest>, model: Option<ModelManifest>,
#[serde(default)] #[serde(default)]
worker: Option<WorkerManifestConfig>, engine: Option<EngineManifestConfig>,
#[serde(default)] #[serde(default)]
scope: Option<ProfileScopeConfig>, scope: Option<ProfileScopeConfig>,
#[serde(default)] #[serde(default)]
@ -814,16 +815,16 @@ fn load_workspace_override_file(path: &Path) -> Result<WorkspaceOverrideLayer, P
path: path.to_path_buf(), path: path.to_path_buf(),
source, source,
})?; })?;
let config = PodManifestConfig::from_toml(&content).map_err(|source| { let config = WorkerManifestConfig::from_toml(&content).map_err(|source| {
ProfileError::WorkspaceOverrideParse { ProfileError::WorkspaceOverrideParse {
path: path.to_path_buf(), path: path.to_path_buf(),
source, source,
} }
})?; })?;
if config.pod.name.is_some() { if config.worker.name.is_some() {
return Err(ProfileError::InvalidWorkspaceOverride { return Err(ProfileError::InvalidWorkspaceOverride {
path: path.to_path_buf(), path: path.to_path_buf(),
message: "workspace-local manifest overrides cannot set pod.name; Pod identity is a runtime input".into(), message: "workspace-local manifest overrides cannot set worker.name; Worker identity is a runtime input".into(),
}); });
} }
Ok(WorkspaceOverrideLayer { Ok(WorkspaceOverrideLayer {
@ -1209,8 +1210,8 @@ fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), Profi
))); )));
} }
} }
if map.contains_key("pod") { if map.contains_key("worker") {
return Err(ProfileError::InvalidProfile("field `pod` is runtime-bound and is not allowed in reusable Profiles; pass the Pod name via CLI/TUI runtime inputs".into())); return Err(ProfileError::InvalidProfile("field `worker` is runtime-bound and is not allowed in reusable Profiles; pass the Worker name via CLI/TUI runtime inputs".into()));
} }
if let Some(scope) = map.get("scope").and_then(|v| v.as_object()) { if let Some(scope) = map.get("scope").and_then(|v| v.as_object()) {
for key in ["allow", "deny"] { for key in ["allow", "deny"] {
@ -1442,7 +1443,7 @@ pub fn resolve_profile_artifact(
source, source,
base_dir, base_dir,
base_dir, base_dir,
ProfileResolveOptions::with_pod_name("artifact-pod"), ProfileResolveOptions::with_worker_name("artifact-worker"),
raw_artifact.clone(), raw_artifact.clone(),
raw_artifact, raw_artifact,
None, None,
@ -1489,8 +1490,8 @@ pub enum ProfileError {
InvalidWorkspaceOverride { path: PathBuf, message: String }, InvalidWorkspaceOverride { path: PathBuf, message: String },
#[error("no default profile is configured")] #[error("no default profile is configured")]
NoDefaultProfile, NoDefaultProfile,
#[error("profile resolution requires an explicit runtime Pod name")] #[error("profile resolution requires an explicit runtime Worker name")]
MissingRuntimePodName, MissingRuntimeWorkerName,
#[error("profile not found: {selector}")] #[error("profile not found: {selector}")]
ProfileNotFound { selector: String }, ProfileNotFound { selector: String },
#[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")] #[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")]
@ -1574,17 +1575,17 @@ mod tests {
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, expected), &ProfileSelector::source_named(ProfileRegistrySource::Builtin, expected),
ProfileResolveOptions::with_pod_name("role-pod"), ProfileResolveOptions::with_worker_name("role-worker"),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(), resolved.profile.as_ref().unwrap().name.as_deref(),
Some(expected) Some(expected)
); );
assert_eq!(resolved.manifest.pod.name, "role-pod"); assert_eq!(resolved.manifest.worker.name, "role-worker");
if matches!(expected, "intake" | "orchestrator" | "coder" | "reviewer") { if matches!(expected, "intake" | "orchestrator" | "coder" | "reviewer") {
let expected_instruction = format!("$yoi/role/{expected}"); let expected_instruction = format!("$yoi/role/{expected}");
assert_eq!(resolved.manifest.worker.instruction, expected_instruction); assert_eq!(resolved.manifest.engine.instruction, expected_instruction);
} }
} }
} }
@ -1597,7 +1598,7 @@ mod tests {
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, role), &ProfileSelector::source_named(ProfileRegistrySource::Builtin, role),
ProfileResolveOptions::with_pod_name("role-pod"), ProfileResolveOptions::with_worker_name("role-worker"),
) )
.unwrap() .unwrap()
.manifest .manifest
@ -1605,7 +1606,7 @@ mod tests {
let companion = resolve("companion"); let companion = resolve("companion");
assert!(companion.feature.task.enabled); assert!(companion.feature.task.enabled);
assert!(companion.feature.pods.enabled); assert!(companion.feature.workers.enabled);
assert!(companion.feature.ticket.enabled); assert!(companion.feature.ticket.enabled);
assert!(companion.scope.allow.is_empty()); assert!(companion.scope.allow.is_empty());
assert!(companion.scope.deny.is_empty()); assert!(companion.scope.deny.is_empty());
@ -1615,7 +1616,7 @@ mod tests {
let intake = resolve("intake"); let intake = resolve("intake");
assert!(!intake.feature.task.enabled); assert!(!intake.feature.task.enabled);
assert!(!intake.feature.pods.enabled); assert!(!intake.feature.workers.enabled);
assert!(intake.feature.ticket.enabled); assert!(intake.feature.ticket.enabled);
assert!(intake.scope.allow.is_empty()); assert!(intake.scope.allow.is_empty());
assert!(intake.delegation_scope.allow.is_empty()); assert!(intake.delegation_scope.allow.is_empty());
@ -1625,7 +1626,7 @@ mod tests {
let orchestrator = resolve("orchestrator"); let orchestrator = resolve("orchestrator");
assert!(!orchestrator.feature.task.enabled); assert!(!orchestrator.feature.task.enabled);
assert!(orchestrator.feature.pods.enabled); assert!(orchestrator.feature.workers.enabled);
assert!(orchestrator.feature.ticket.enabled); assert!(orchestrator.feature.ticket.enabled);
assert!(orchestrator.feature.ticket_orchestration.enabled); assert!(orchestrator.feature.ticket_orchestration.enabled);
assert!(orchestrator.scope.allow.is_empty()); assert!(orchestrator.scope.allow.is_empty());
@ -1638,7 +1639,7 @@ mod tests {
let coder = resolve("coder"); let coder = resolve("coder");
assert!(coder.feature.task.enabled); assert!(coder.feature.task.enabled);
assert!(!coder.feature.pods.enabled); assert!(!coder.feature.workers.enabled);
assert!(coder.scope.allow.is_empty()); assert!(coder.scope.allow.is_empty());
assert!(coder.delegation_scope.allow.is_empty()); assert!(coder.delegation_scope.allow.is_empty());
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
@ -1646,7 +1647,7 @@ mod tests {
let reviewer = resolve("reviewer"); let reviewer = resolve("reviewer");
assert!(!reviewer.feature.task.enabled); assert!(!reviewer.feature.task.enabled);
assert!(!reviewer.feature.pods.enabled); assert!(!reviewer.feature.workers.enabled);
assert!(!reviewer.feature.ticket.enabled); assert!(!reviewer.feature.ticket.enabled);
assert!(reviewer.scope.allow.is_empty()); assert!(reviewer.scope.allow.is_empty());
assert!(reviewer.delegation_scope.allow.is_empty()); assert!(reviewer.delegation_scope.allow.is_empty());
@ -1655,17 +1656,17 @@ mod tests {
} }
#[test] #[test]
fn profile_resolution_requires_runtime_pod_name() { fn profile_resolution_requires_runtime_worker_name() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let err = ProfileResolver::new() let err = ProfileResolver::new()
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default()) .resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
.unwrap_err(); .unwrap_err();
assert!(matches!(err, ProfileError::MissingRuntimePodName)); assert!(matches!(err, ProfileError::MissingRuntimeWorkerName));
} }
#[test] #[test]
fn resolves_plain_lua_profile_with_runtime_pod_name_and_scope_intent() { fn resolves_plain_lua_profile_with_runtime_worker_name_and_scope_intent() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let profile = write_profile( let profile = write_profile(
tmp.path(), tmp.path(),
@ -1676,7 +1677,7 @@ local scope = require("yoi.scope")
return profile { return profile {
slug = "coder", slug = "coder",
model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" }, model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" },
worker = { reasoning = "high" }, engine = { reasoning = "high" },
scope = scope.workspace_read(), scope = scope.workspace_read(),
} }
"#, "#,
@ -1687,13 +1688,13 @@ return profile {
.with_workspace_base(&workspace) .with_workspace_base(&workspace)
.resolve( .resolve(
&ProfileSelector::path(&profile), &ProfileSelector::path(&profile),
ProfileResolveOptions::with_pod_name("runtime-pod"), ProfileResolveOptions::with_worker_name("runtime-worker"),
) )
.unwrap(); .unwrap();
assert_eq!(resolved.manifest.pod.name, "runtime-pod"); assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic)); assert_eq!(resolved.manifest.model.scheme, Some(SchemeKind::Anthropic));
assert_eq!( assert_eq!(
resolved.manifest.worker.reasoning, resolved.manifest.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::High)) Some(ReasoningControl::Effort(ReasoningEffort::High))
); );
assert_eq!(resolved.manifest.scope.allow[0].target, workspace); assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
@ -1748,7 +1749,7 @@ return profile {
.with_workspace_base(&workspace) .with_workspace_base(&workspace)
.resolve( .resolve(
&ProfileSelector::path(&profile), &ProfileSelector::path(&profile),
ProfileResolveOptions::with_pod_name("runtime-pod"), ProfileResolveOptions::with_worker_name("runtime-worker"),
) )
.unwrap(); .unwrap();
@ -1785,7 +1786,7 @@ return profile {
task = { enabled = true }, task = { enabled = true },
memory = { enabled = false }, memory = { enabled = false },
web = { enabled = true }, web = { enabled = true },
pods = { enabled = true }, workers = { enabled = true },
ticket = { enabled = true, access = "read_only" }, ticket = { enabled = true, access = "read_only" },
ticket_orchestration = { enabled = false }, ticket_orchestration = { enabled = false },
}, },
@ -1798,14 +1799,14 @@ return profile {
.with_workspace_base(&workspace) .with_workspace_base(&workspace)
.resolve( .resolve(
&ProfileSelector::path(&profile), &ProfileSelector::path(&profile),
ProfileResolveOptions::with_pod_name("runtime-pod"), ProfileResolveOptions::with_worker_name("runtime-worker"),
) )
.unwrap(); .unwrap();
assert_eq!(resolved.manifest.pod.name, "runtime-pod"); assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert!(resolved.manifest.feature.task.enabled); assert!(resolved.manifest.feature.task.enabled);
assert!(!resolved.manifest.feature.memory.enabled); assert!(!resolved.manifest.feature.memory.enabled);
assert!(resolved.manifest.feature.web.enabled); assert!(resolved.manifest.feature.web.enabled);
assert!(resolved.manifest.feature.pods.enabled); assert!(resolved.manifest.feature.workers.enabled);
assert!(resolved.manifest.feature.ticket.enabled); assert!(resolved.manifest.feature.ticket.enabled);
assert_eq!( assert_eq!(
resolved.manifest.feature.ticket.access, resolved.manifest.feature.ticket.access,
@ -1844,7 +1845,7 @@ return yoi.profile {
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::path(profile), &ProfileSelector::path(profile),
ProfileResolveOptions::with_pod_name("p"), ProfileResolveOptions::with_worker_name("p"),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@ -1877,7 +1878,7 @@ p.slug = "assigned"
p.model = yoi.models.catalog("anthropic/claude-sonnet-4-6") p.model = yoi.models.catalog("anthropic/claude-sonnet-4-6")
p.feature = { p.feature = {
task = { enabled = false }, task = { enabled = false },
pods = { enabled = true }, workers = { enabled = true },
} }
p.web = { enabled = false } p.web = { enabled = false }
p.compaction = yoi.compact.tokens { threshold = 123, request_threshold = 456 } p.compaction = yoi.compact.tokens { threshold = 123, request_threshold = 456 }
@ -1888,7 +1889,7 @@ return p
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::path(profile), &ProfileSelector::path(profile),
ProfileResolveOptions::with_pod_name("p"), ProfileResolveOptions::with_worker_name("p"),
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@ -1896,7 +1897,7 @@ return p
Some("anthropic/claude-sonnet-4-6") Some("anthropic/claude-sonnet-4-6")
); );
assert!(!resolved.manifest.feature.task.enabled); assert!(!resolved.manifest.feature.task.enabled);
assert!(resolved.manifest.feature.pods.enabled); assert!(resolved.manifest.feature.workers.enabled);
assert_eq!(resolved.manifest.web.as_ref().unwrap().enabled, Some(false)); assert_eq!(resolved.manifest.web.as_ref().unwrap().enabled, Some(false));
assert!(resolved.manifest.web.as_ref().unwrap().search.is_none()); assert!(resolved.manifest.web.as_ref().unwrap().search.is_none());
assert_eq!( assert_eq!(
@ -1925,7 +1926,7 @@ return yoi.profile.extend("builtin:default", {
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::path(profile), &ProfileSelector::path(profile),
ProfileResolveOptions::with_pod_name("p"), ProfileResolveOptions::with_worker_name("p"),
) )
.unwrap_err(); .unwrap_err();
let message = err.to_string(); let message = err.to_string();
@ -1948,7 +1949,7 @@ return yoi.profile.extend("builtin:default", {
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::path(path), &ProfileSelector::path(path),
ProfileResolveOptions::with_pod_name("p"), ProfileResolveOptions::with_worker_name("p"),
) )
.unwrap_err(); .unwrap_err();
assert!(matches!( assert!(matches!(
@ -1962,7 +1963,7 @@ return yoi.profile.extend("builtin:default", {
for (value, needle) in [ for (value, needle) in [
(serde_json::json!({"manifest": {}}), "manifest"), (serde_json::json!({"manifest": {}}), "manifest"),
(serde_json::json!({"config": {}}), "config"), (serde_json::json!({"config": {}}), "config"),
(serde_json::json!({"pod": {"name": "bad"}}), "pod"), (serde_json::json!({"worker": {"name": "bad"}}), "worker"),
( (
serde_json::json!({"model": {"ref": "codex-oauth/gpt-5.5"}, "scope": {"allow": []}}), serde_json::json!({"model": {"ref": "codex-oauth/gpt-5.5"}, "scope": {"allow": []}}),
"scope.allow", "scope.allow",
@ -2008,7 +2009,7 @@ return profile {
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::path(profile), &ProfileSelector::path(profile),
ProfileResolveOptions::with_pod_name("p"), ProfileResolveOptions::with_worker_name("p"),
) )
.unwrap(); .unwrap();
let c = resolved.manifest.compaction.unwrap(); let c = resolved.manifest.compaction.unwrap();
@ -2023,10 +2024,10 @@ return profile {
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"), &ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
ProfileResolveOptions::with_pod_name("runtime-workspace"), ProfileResolveOptions::with_worker_name("runtime-workspace"),
) )
.unwrap(); .unwrap();
assert_eq!(resolved.manifest.pod.name, "runtime-workspace"); assert_eq!(resolved.manifest.worker.name, "runtime-workspace");
assert_eq!( assert_eq!(
resolved.manifest.model.ref_.as_deref(), resolved.manifest.model.ref_.as_deref(),
Some("codex-oauth/gpt-5.5") Some("codex-oauth/gpt-5.5")
@ -2060,9 +2061,9 @@ return profile {
std::fs::write( std::fs::write(
&override_path, &override_path,
r#" r#"
[pod]
prompt_pack = "prompts.toml"
[worker] [worker]
prompt_pack = "prompts.toml"
[engine]
language = "ja" language = "ja"
[session] [session]
record_event_trace = false record_event_trace = false
@ -2074,15 +2075,15 @@ record_event_trace = false
.with_workspace_base(&nested) .with_workspace_base(&nested)
.resolve( .resolve(
&ProfileSelector::Default, &ProfileSelector::Default,
ProfileResolveOptions::with_pod_name("runtime-pod"), ProfileResolveOptions::with_worker_name("runtime-worker"),
) )
.unwrap(); .unwrap();
assert_eq!(resolved.manifest.pod.name, "runtime-pod"); assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert_eq!(resolved.manifest.worker.language, "ja"); assert_eq!(resolved.manifest.engine.language, "ja");
assert!(!resolved.manifest.session.record_event_trace); assert!(!resolved.manifest.session.record_event_trace);
assert_eq!( assert_eq!(
resolved.manifest.pod.prompt_pack.as_deref(), resolved.manifest.worker.prompt_pack.as_deref(),
Some(yoi_dir.join("prompts.toml").as_path()) Some(yoi_dir.join("prompts.toml").as_path())
); );
assert!(resolved.manifest.scope.allow.is_empty()); assert!(resolved.manifest.scope.allow.is_empty());
@ -2111,9 +2112,9 @@ record_event_trace = false
std::fs::write( std::fs::write(
parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME), parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
r#" r#"
[pod]
prompt_pack = "parent-prompts.toml"
[worker] [worker]
prompt_pack = "parent-prompts.toml"
[engine]
language = "parent" language = "parent"
"#, "#,
) )
@ -2122,9 +2123,9 @@ language = "parent"
std::fs::write( std::fs::write(
&nested_override_path, &nested_override_path,
r#" r#"
[pod]
prompt_pack = "nested-prompts.toml"
[worker] [worker]
prompt_pack = "nested-prompts.toml"
[engine]
language = "nested" language = "nested"
"#, "#,
) )
@ -2134,13 +2135,13 @@ language = "nested"
.with_workspace_base(&child) .with_workspace_base(&child)
.resolve( .resolve(
&ProfileSelector::Default, &ProfileSelector::Default,
ProfileResolveOptions::with_pod_name("runtime-pod"), ProfileResolveOptions::with_worker_name("runtime-worker"),
) )
.unwrap(); .unwrap();
assert_eq!(resolved.manifest.worker.language, "nested"); assert_eq!(resolved.manifest.engine.language, "nested");
assert_eq!( assert_eq!(
resolved.manifest.pod.prompt_pack.as_deref(), resolved.manifest.worker.prompt_pack.as_deref(),
Some(nested_yoi.join("nested-prompts.toml").as_path()) Some(nested_yoi.join("nested-prompts.toml").as_path())
); );
assert_eq!( assert_eq!(
@ -2155,13 +2156,13 @@ language = "nested"
} }
#[test] #[test]
fn workspace_local_override_rejects_runtime_pod_name() { fn workspace_local_override_rejects_runtime_worker_name() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let yoi_dir = tmp.path().join(".yoi"); let yoi_dir = tmp.path().join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap(); std::fs::create_dir_all(&yoi_dir).unwrap();
std::fs::write( std::fs::write(
yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME), yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
"[pod]\nname = \"not-local\"\n", "[worker]\nname = \"not-local\"\n",
) )
.unwrap(); .unwrap();
@ -2169,11 +2170,11 @@ language = "nested"
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
.resolve( .resolve(
&ProfileSelector::Default, &ProfileSelector::Default,
ProfileResolveOptions::with_pod_name("runtime-pod"), ProfileResolveOptions::with_worker_name("runtime-worker"),
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. })); assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
assert!(err.to_string().contains("pod.name")); assert!(err.to_string().contains("worker.name"));
} }
#[test] #[test]

View File

@ -1,8 +1,8 @@
//! Runtime representation of a Pod's access scope. //! Runtime representation of a Worker's access scope.
//! //!
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every //! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
//! rule `target` must already be an absolute path — per-layer path //! rule `target` must already be an absolute path — per-layer path
//! resolution runs earlier, inside [`crate::PodManifestConfig::resolve_paths`]. //! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
//! All rule `target` paths inside the [`Scope`] are canonicalised (where //! All rule `target` paths inside the [`Scope`] are canonicalised (where
//! possible) so access checks are pure path comparisons. //! possible) so access checks are pure path comparisons.
@ -14,7 +14,7 @@ use arc_swap::{ArcSwap, Guard};
use crate::{Permission, ScopeConfig, ScopeRule}; use crate::{Permission, ScopeConfig, ScopeRule};
/// Parsed, pwd-resolved set of allow/deny rules for a Pod. /// Parsed, pwd-resolved set of allow/deny rules for a Worker.
/// ///
/// Read/write access decisions are pure functions of the path being /// Read/write access decisions are pure functions of the path being
/// queried and these rules — see [`Scope::permission_at`]. /// queried and these rules — see [`Scope::permission_at`].
@ -32,7 +32,7 @@ struct ResolvedRule {
recursive: bool, recursive: bool,
} }
/// Parsed filesystem authority this Pod may pass to spawned children. /// Parsed filesystem authority this Worker may pass to spawned children.
/// ///
/// Unlike [`Scope`], an empty allow list is valid and means no delegation /// Unlike [`Scope`], an empty allow list is valid and means no delegation
/// authority. Direct tools never consult this type. /// authority. Direct tools never consult this type.
@ -173,7 +173,7 @@ impl Scope {
/// ///
/// Every `target` in `config` must already be absolute — per-layer /// Every `target` in `config` must already be absolute — per-layer
/// resolution happens upstream in /// resolution happens upstream in
/// [`crate::PodManifestConfig::resolve_paths`] so that cascade merge /// [`crate::WorkerManifestConfig::resolve_paths`] so that cascade merge
/// operates on fully-qualified paths. A lingering relative target /// operates on fully-qualified paths. A lingering relative target
/// here signals an upstream bug and is rejected. /// here signals an upstream bug and is rejected.
pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> { pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
@ -266,7 +266,7 @@ impl Scope {
/// Allow rules with their targets resolved to absolute paths. /// Allow rules with their targets resolved to absolute paths.
/// ///
/// Used by the pod-registry, where every Pod's allocation /// Used by the pod-registry, where every Worker's allocation
/// must be expressed in absolute terms so prefix comparisons are /// must be expressed in absolute terms so prefix comparisons are
/// meaningful across processes. /// meaningful across processes.
pub fn allow_rules(&self) -> Vec<ScopeRule> { pub fn allow_rules(&self) -> Vec<ScopeRule> {
@ -324,7 +324,7 @@ impl Scope {
/// Build a new [`Scope`] equal to `self` with `extra_deny` appended /// Build a new [`Scope`] equal to `self` with `extra_deny` appended
/// to the deny set. Used by dynamic-scope shrink paths /// to the deny set. Used by dynamic-scope shrink paths
/// (e.g. SpawnPod-style delegation that strips Write from the /// (e.g. SpawnWorker-style delegation that strips Write from the
/// spawner without touching its allow rules). /// spawner without touching its allow rules).
pub fn with_added_deny_rules( pub fn with_added_deny_rules(
&self, &self,
@ -420,7 +420,7 @@ impl Scope {
/// not lose each other's contributions. /// not lose each other's contributions.
/// ///
/// All clones share the same underlying state — a `SharedScope` cloned /// All clones share the same underlying state — a `SharedScope` cloned
/// out to multiple consumers (Pod, ScopedFs, future grant/revoke /// out to multiple consumers (Worker, ScopedFs, future grant/revoke
/// callers) sees every update. /// callers) sees every update.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SharedScope { pub struct SharedScope {

View File

@ -17,7 +17,7 @@ Owns:
Does not own: Does not own:
- authoritative project records (`.yoi/tickets/`, git history) - authoritative project records (`.yoi/tickets/`, git history)
- normal Pod turn orchestration (`llm-engine`) - normal Worker turn orchestration (`llm-engine`)
- product CLI command shape (`yoi`) - product CLI command shape (`yoi`)
- curated workflow definitions (`workflow`) - curated workflow definitions (`workflow`)

View File

@ -31,7 +31,7 @@ pub fn build_consolidate_input(
"consolidation input. Run the integration step first \ "consolidation input. Run the integration step first \
(fold the staging activity logs into memory and knowledge), then the \ (fold the staging activity logs into memory and knowledge), then the \
tidy step (clean up existing records). Use the memory tools for \ tidy step (clean up existing records). Use the memory tools for \
every write direct file writes are denied by the pod scope.\n\n", every write direct file writes are denied by the worker scope.\n\n",
); );
out.push_str("## Staging entries (consumed by this run)\n\n"); out.push_str("## Staging entries (consumed by this run)\n\n");

View File

@ -2,7 +2,7 @@
//! //!
//! `docs/plan/memory.md` §並走防止 に従い: //! `docs/plan/memory.md` §並走防止 に従い:
//! //!
//! - ファイルが存在し、記録された Pod が動作している間、その Pod が排他占有 //! - ファイルが存在し、記録された Worker が動作している間、その Worker が排他占有
//! - クラッシュで残った stale lock は、所有者 PID が死んでいれば次回 spawn //! - クラッシュで残った stale lock は、所有者 PID が死んでいれば次回 spawn
//! 時に上書き取得できる //! 時に上書き取得できる
//! - cleanup は consumed ID の staging エントリのみ削除し、実行中に extract //! - cleanup は consumed ID の staging エントリのみ削除し、実行中に extract
@ -22,12 +22,12 @@ use crate::workspace::WorkspaceLayout;
const LOCK_FILE: &str = ".consolidation.lock"; const LOCK_FILE: &str = ".consolidation.lock";
/// 占有ファイルの中身。`pid` で stale 判定し、`pod_name` / `started_at` / /// 占有ファイルの中身。`pid` で stale 判定し、`worker_name` / `started_at` /
/// `consumed_ids` は診断とクラッシュ復旧時の参照に使う。 /// `consumed_ids` は診断とクラッシュ復旧時の参照に使う。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockRecord { pub struct LockRecord {
pub pid: u32, pub pid: u32,
pub pod_name: String, pub worker_name: String,
pub started_at: DateTime<Utc>, pub started_at: DateTime<Utc>,
/// この consolidation run が起動時スナップショットで確定した consumed staging /// この consolidation run が起動時スナップショットで確定した consumed staging
/// entry の UUIDv7 列。完了時はこの列のみ削除し、追加分は残す。 /// entry の UUIDv7 列。完了時はこの列のみ削除し、追加分は残す。
@ -38,8 +38,8 @@ pub struct LockRecord {
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum LockError { pub enum LockError {
/// 占有ファイルが既にあり、所有者 PID が生きているのでスキップ。 /// 占有ファイルが既にあり、所有者 PID が生きているのでスキップ。
#[error("consolidation lock held by live pid {pid} (pod {pod_name:?})")] #[error("consolidation lock held by live pid {pid} (worker {worker_name:?})")]
InUse { pid: u32, pod_name: String }, InUse { pid: u32, worker_name: String },
#[error("io error at {}: {source}", .path.display())] #[error("io error at {}: {source}", .path.display())]
Io { Io {
path: PathBuf, path: PathBuf,
@ -85,7 +85,7 @@ impl StagingLock {
pub fn acquire( pub fn acquire(
layout: &WorkspaceLayout, layout: &WorkspaceLayout,
pid: u32, pid: u32,
pod_name: impl Into<String>, worker_name: impl Into<String>,
consumed_ids: Vec<Uuid>, consumed_ids: Vec<Uuid>,
) -> Result<Self, LockError> { ) -> Result<Self, LockError> {
let staging_dir = layout.staging_dir(); let staging_dir = layout.staging_dir();
@ -99,12 +99,12 @@ impl StagingLock {
if pid_is_alive(existing.pid) { if pid_is_alive(existing.pid) {
return Err(LockError::InUse { return Err(LockError::InUse {
pid: existing.pid, pid: existing.pid,
pod_name: existing.pod_name, worker_name: existing.worker_name,
}); });
} }
tracing::warn!( tracing::warn!(
stale_pid = existing.pid, stale_pid = existing.pid,
stale_pod = %existing.pod_name, stale_pod = %existing.worker_name,
"consolidation stale lock detected, taking over" "consolidation stale lock detected, taking over"
); );
} else { } else {
@ -114,7 +114,7 @@ impl StagingLock {
let record = LockRecord { let record = LockRecord {
pid, pid,
pod_name: pod_name.into(), worker_name: worker_name.into(),
started_at: Utc::now(), started_at: Utc::now(),
consumed_ids, consumed_ids,
}; };
@ -213,11 +213,11 @@ mod tests {
#[test] #[test]
fn acquire_writes_lock_file() { fn acquire_writes_lock_file() {
let (_dir, layout) = make_layout(); let (_dir, layout) = make_layout();
let lock = StagingLock::acquire(&layout, std::process::id(), "pod", Vec::new()).unwrap(); let lock = StagingLock::acquire(&layout, std::process::id(), "worker", Vec::new()).unwrap();
let path = layout.staging_dir().join(LOCK_FILE); let path = layout.staging_dir().join(LOCK_FILE);
assert!(path.exists()); assert!(path.exists());
assert_eq!(lock.record().pid, std::process::id()); assert_eq!(lock.record().pid, std::process::id());
assert_eq!(lock.record().pod_name, "pod"); assert_eq!(lock.record().worker_name, "worker");
} }
#[test] #[test]
@ -225,8 +225,8 @@ mod tests {
let (_dir, layout) = make_layout(); let (_dir, layout) = make_layout();
// Use this test process's pid — it's definitely alive. // Use this test process's pid — it's definitely alive.
let _first = let _first =
StagingLock::acquire(&layout, std::process::id(), "pod-a", Vec::new()).unwrap(); StagingLock::acquire(&layout, std::process::id(), "worker-a", Vec::new()).unwrap();
let err = StagingLock::acquire(&layout, std::process::id(), "pod-b", Vec::new()) let err = StagingLock::acquire(&layout, std::process::id(), "worker-b", Vec::new())
.expect_err("expected InUse"); .expect_err("expected InUse");
assert!(matches!(err, LockError::InUse { .. })); assert!(matches!(err, LockError::InUse { .. }));
} }
@ -239,7 +239,7 @@ mod tests {
// dead on every platform we target. // dead on every platform we target.
let stale = LockRecord { let stale = LockRecord {
pid: u32::MAX, pid: u32::MAX,
pod_name: "ghost".into(), worker_name: "ghost".into(),
started_at: Utc::now(), started_at: Utc::now(),
consumed_ids: Vec::new(), consumed_ids: Vec::new(),
}; };
@ -249,7 +249,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let lock = StagingLock::acquire(&layout, std::process::id(), "pod", Vec::new()) let lock = StagingLock::acquire(&layout, std::process::id(), "worker", Vec::new())
.expect("stale lock must be overwritable"); .expect("stale lock must be overwritable");
assert_eq!(lock.record().pid, std::process::id()); assert_eq!(lock.record().pid, std::process::id());
} }
@ -276,7 +276,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let lock = StagingLock::acquire(&layout, std::process::id(), "pod", vec![id_a]).unwrap(); let lock = StagingLock::acquire(&layout, std::process::id(), "worker", vec![id_a]).unwrap();
let lock_path = lock.path().to_path_buf(); let lock_path = lock.path().to_path_buf();
lock.release_with_cleanup(&layout); lock.release_with_cleanup(&layout);
@ -295,7 +295,8 @@ mod tests {
fn release_is_resilient_to_missing_consumed_entries() { fn release_is_resilient_to_missing_consumed_entries() {
let (_dir, layout) = make_layout(); let (_dir, layout) = make_layout();
let phantom = uuid::Uuid::now_v7(); let phantom = uuid::Uuid::now_v7();
let lock = StagingLock::acquire(&layout, std::process::id(), "pod", vec![phantom]).unwrap(); let lock =
StagingLock::acquire(&layout, std::process::id(), "worker", vec![phantom]).unwrap();
let lock_path = lock.path().to_path_buf(); let lock_path = lock.path().to_path_buf();
// No file at <staging>/<phantom>.json — release must not panic. // No file at <staging>/<phantom>.json — release must not panic.
lock.release_with_cleanup(&layout); lock.release_with_cleanup(&layout);

View File

@ -2,8 +2,8 @@
//! //!
//! extract が staging に残した活動ログを `memory/*` / `knowledge/*` に //! extract が staging に残した活動ログを `memory/*` / `knowledge/*` に
//! 統合し、続けて既存 record を `outdated | superseded | unused | noisy` //! 統合し、続けて既存 record を `outdated | superseded | unused | noisy`
//! の観点で整理する disposable Engine を、Pod 側が組み立てるための //! の観点で整理する disposable Engine を、Worker 側が組み立てるための
//! ヘルパー群を提供する。Pod は次の手順で sub-Engine を構築する: //! ヘルパー群を提供する。Worker は次の手順で sub-Engine を構築する:
//! //!
//! - [`build_consolidate_input`] を sub-Engine の最初の user 入力に //! - [`build_consolidate_input`] を sub-Engine の最初の user 入力に
//! - memory 専用 Tool (read / write / edit) と Knowledge / memory 検索ツールを登録 //! - memory 専用 Tool (read / write / edit) と Knowledge / memory 検索ツールを登録
@ -11,8 +11,8 @@
//! - sub-Engine run 完了後、[`StagingLock::release_with_cleanup`] で //! - sub-Engine run 完了後、[`StagingLock::release_with_cleanup`] で
//! consumed ID 分の staging のみ削除し、占有ファイルを解放 //! consumed ID 分の staging のみ削除し、占有ファイルを解放
//! //!
//! system prompt は Pod の `PromptCatalog` //! system prompt は Worker の `PromptCatalog`
//! (`PodPrompt::MemoryConsolidationSystem`) で管理される。Usage report は //! (`WorkerPrompt::MemoryConsolidationSystem`) で管理される。Usage report は
//! 判断材料として渡すだけで、ここでは Knowledge 化や protection の hard decision はしない //! 判断材料として渡すだけで、ここでは Knowledge 化や protection の hard decision はしない
//! `docs/plan/memory.md` §Consolidation / 整理材料)。 //! `docs/plan/memory.md` §Consolidation / 整理材料)。

View File

@ -1,6 +1,6 @@
//! extract sub-Engine への入力テキスト組み立て。 //! extract sub-Engine への入力テキスト組み立て。
//! //!
//! `crates/pod/src/pod.rs::build_summary_prompt` と同じ方針で //! `crates/worker/src/worker.rs::build_summary_prompt` と同じ方針で
//! Item 列を flat な行に落とすreasoning は省く、tool call は名前のみ、 //! Item 列を flat な行に落とすreasoning は省く、tool call は名前のみ、
//! tool result は summary のみ。conversation 全体を Markdown の単一 //! tool result は summary のみ。conversation 全体を Markdown の単一
//! セクションとして渡し、抽出指示は system prompt 側に寄せる。 //! セクションとして渡し、抽出指示は system prompt 側に寄せる。

View File

@ -1,17 +1,17 @@
//! extract: 活動抽出。 //! extract: 活動抽出。
//! //!
//! 通常 Pod の post-run hook で発火する disposable Engine と、その //! 通常 Worker の post-run hook で発火する disposable Engine と、その
//! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す //! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す
//! ヘルパーを提供する。Pod 側はこのモジュールから: //! ヘルパーを提供する。Worker 側はこのモジュールから:
//! //!
//! - [`build_extract_input`] を sub-Engine の最初の user 入力に //! - [`build_extract_input`] を sub-Engine の最初の user 入力に
//! - [`write_extracted_tool`] を唯一のツールとして //! - [`write_extracted_tool`] を唯一のツールとして
//! - [`write_staging`] で受け取った JSON を staging に書き出し //! - [`write_staging`] で受け取った JSON を staging に書き出し
//! //!
//! の順で組み立てる。system prompt は Pod の `PromptCatalog` //! の順で組み立てる。system prompt は Worker の `PromptCatalog`
//! (`PodPrompt::MemoryExtractSystem`) で管理される。pointer 永続化 //! (`WorkerPrompt::MemoryExtractSystem`) で管理される。pointer 永続化
//! session-store の `LogEntry::Extension`、domain `"memory.extract"`)は //! session-store の `LogEntry::Extension`、domain `"memory.extract"`)は
//! Pod 側が責務を持つ。 //! Worker 側が責務を持つ。
//! //!
//! 出力 JSON の wrap は [`write_staging`] が `source: { segment_id, range }` //! 出力 JSON の wrap は [`write_staging`] が `source: { segment_id, range }`
//! を機械付与する形で担当し、LLM には source を推論させない。 //! を機械付与する形で担当し、LLM には source を推論させない。

View File

@ -1,6 +1,6 @@
//! extract 抽出の出力 schema。 //! extract 抽出の出力 schema。
//! //!
//! LLM は [`ExtractedPayload`] そのものsource 抜き)を返し、Pod //! LLM は [`ExtractedPayload`] そのものsource 抜き)を返し、Worker
//! ラッパーが [`StagingRecord`] に組み立てて staging へ書き出す。 //! ラッパーが [`StagingRecord`] に組み立てて staging へ書き出す。
//! source は機械付与する契約 (`docs/plan/memory.md` §Extract)。 //! source は機械付与する契約 (`docs/plan/memory.md` §Extract)。
@ -78,7 +78,7 @@ pub struct RequestEntry {
/// staging に書き出される 1 ファイル分のレコード。 /// staging に書き出される 1 ファイル分のレコード。
/// ///
/// `source` は Pod 側ラッパーが segment_id と log entry range を /// `source` は Worker 側ラッパーが segment_id と log entry range を
/// 機械付与する。LLM はこのフィールドを見ない / 推論しない。 /// 機械付与する。LLM はこのフィールドを見ない / 推論しない。
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StagingRecord { pub struct StagingRecord {

View File

@ -1,6 +1,6 @@
//! `LogEntry::Extension { domain: "memory.extract", payload }` の payload 形式と //! `LogEntry::Extension { domain: "memory.extract", payload }` の payload 形式と
//! restore 時の fold ヘルパー。memory crate がドメインを所有するので、 //! restore 時の fold ヘルパー。memory crate がドメインを所有するので、
//! session-store / Pod は payload 構造を知らない。 //! session-store / Worker は payload 構造を知らない。
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View File

@ -2,7 +2,7 @@
//! //!
//! sub-Engine からは extract worker が出した [`ExtractedPayload`] を //! sub-Engine からは extract worker が出した [`ExtractedPayload`] を
//! 受け取って `Mutex` 越しに [`ExtractWorkerContext`] に置くだけ。 //! 受け取って `Mutex` 越しに [`ExtractWorkerContext`] に置くだけ。
//! Pod 側はランループ完了後に `take_payload()` で取り出して //! Worker 側はランループ完了後に `take_payload()` で取り出して
//! [`super::staging::write_staging`] に渡す。 //! [`super::staging::write_staging`] に渡す。
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -22,7 +22,7 @@ the wrapper attaches provenance mechanically.";
pub struct ExtractWorkerContext { pub struct ExtractWorkerContext {
payload: Mutex<Option<ExtractedPayload>>, payload: Mutex<Option<ExtractedPayload>>,
/// `write_extracted` が複数回呼ばれた回数debug 用)。 /// `write_extracted` が複数回呼ばれた回数debug 用)。
/// 後勝ちで上書きするが、Pod 側で warn を出したい場合に参照する。 /// 後勝ちで上書きするが、Worker 側で warn を出したい場合に参照する。
call_count: Mutex<usize>, call_count: Mutex<usize>,
} }
@ -31,7 +31,7 @@ impl ExtractWorkerContext {
Self::default() Self::default()
} }
/// sub-Engine 終了後に Pod が呼んで payload を取り出す。 /// sub-Engine 終了後に Worker が呼んで payload を取り出す。
/// 一度も `write_extracted` が呼ばれなければ `None`。 /// 一度も `write_extracted` が呼ばれなければ `None`。
pub fn take_payload(&self) -> Option<ExtractedPayload> { pub fn take_payload(&self) -> Option<ExtractedPayload> {
self.payload self.payload

View File

@ -3,7 +3,7 @@
//! Self-contained: provides its own Tool implementations (read/write/edit) //! Self-contained: provides its own Tool implementations (read/write/edit)
//! that target `<workspace>/memory/` and `<workspace>/knowledge/` only, //! that target `<workspace>/memory/` and `<workspace>/knowledge/` only,
//! with a pre-write Linter built in. Generic CRUD tools (in the `tools` //! with a pre-write Linter built in. Generic CRUD tools (in the `tools`
//! crate) must not touch these directories — Pod is responsible for //! crate) must not touch these directories — Worker is responsible for
//! denying them at the Scope level when memory is enabled. //! denying them at the Scope level when memory is enabled.
pub mod audit; pub mod audit;

View File

@ -1,6 +1,6 @@
//! Workspace memory resident-enumeration helpers. //! Workspace memory resident-enumeration helpers.
//! //!
//! Surfaces used by the Pod system-prompt assembler: //! Surfaces used by the Worker system-prompt assembler:
//! //!
//! - [`collect_resident_knowledge`] — resident-injection candidates //! - [`collect_resident_knowledge`] — resident-injection candidates
//! (`model_invokation: true`) returned as `(slug, description)` pairs. //! (`model_invokation: true`) returned as `(slug, description)` pairs.
@ -8,7 +8,7 @@
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary //! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
//! record and has non-empty body. //! record and has non-empty body.
//! - [`list_knowledge_slugs`] — every slug whose file parses, regardless //! - [`list_knowledge_slugs`] — every slug whose file parses, regardless
//! of `model_invokation`. Used by the Pod IPC layer to answer TUI `#` //! of `model_invokation`. Used by the Worker IPC layer to answer TUI `#`
//! completion (`model_invokation` is a resident-injection flag, not a //! completion (`model_invokation` is a resident-injection flag, not a
//! user-visibility flag). //! user-visibility flag).
//! //!

View File

@ -1,7 +1,7 @@
//! Helpers for constructing `ScopeRule` entries that exclude the //! Helpers for constructing `ScopeRule` entries that exclude the
//! memory tree from the generic CRUD tools' write surface. //! memory tree from the generic CRUD tools' write surface.
//! //!
//! Pod is expected to call [`deny_write_rules`] when memory is enabled //! Worker is expected to call [`deny_write_rules`] when memory is enabled
//! and append the result to the manifest's `scope.deny` list before //! and append the result to the manifest's `scope.deny` list before
//! constructing the [`Scope`] passed to `tools::ScopedFs`. The memory //! constructing the [`Scope`] passed to `tools::ScopedFs`. The memory
//! tools themselves bypass `ScopedFs` and write directly under the //! tools themselves bypass `ScopedFs` and write directly under the

View File

@ -18,7 +18,7 @@
//! `.yoi/workflow/`. //! `.yoi/workflow/`.
//! //!
//! `memory.workspace_root` pins this root explicitly. Without an explicit //! `memory.workspace_root` pins this root explicitly. Without an explicit
//! root, resolution searches upward from the Pod pwd for a `.yoi/memory` //! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
//! marker; `.yoi` project records alone are not a memory marker. //! marker; `.yoi` project records alone are not a memory marker.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};

View File

@ -177,8 +177,8 @@ fn pdk_runtime_dependencies_are_guest_side_only() {
.as_table() .as_table()
.expect("dependencies table"); .expect("dependencies table");
let forbidden = [ let forbidden = [
"pod", "worker",
"yoi-pod", "yoi-worker",
"llm-engine", "llm-engine",
"tui", "tui",
"yoi-tui", "yoi-tui",

View File

@ -2,29 +2,29 @@
## Role ## Role
`pod-registry` tracks live Pod process ownership and delegated scope locks at runtime. `pod-registry` is the legacy-named crate that tracks live Worker process ownership and delegated scope locks at runtime.
## Boundaries ## Boundaries
Owns: Owns:
- machine-local live Pod registration - machine-local live Worker registration
- collision detection for running Pod names - collision detection for running Worker names
- delegated scope lock bookkeeping - delegated scope lock bookkeeping
- registry cleanup hooks for stopped or unreachable children - registry cleanup hooks for stopped or unreachable children
Does not own: Does not own:
- durable Pod metadata (`pod-store`) - durable Worker metadata (`pod-store`)
- replayable session logs (`session-store`) - replayable session logs (`session-store`)
- socket protocol definitions (`protocol`) - socket protocol definitions (`protocol`)
- project work item state - project work item state
## Design notes ## Design notes
The registry is a runtime coordination mechanism. It can help decide whether a Pod is live or colliding, but durable visibility/restoration should be backed by Pod metadata when possible. The registry is a runtime coordination mechanism. It can help decide whether a Worker is live or colliding, but durable visibility/restoration should be backed by Worker metadata when possible.
## See also ## See also
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md) - [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md) - [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)

View File

@ -80,18 +80,18 @@ pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule
!child_conflict !child_conflict
} }
/// The Pod and rule that actually own a conflicting write scope. /// The Worker and rule that actually own a conflicting write scope.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ConflictOwner { pub struct ConflictOwner {
pub pod_name: String, pub worker_name: String,
pub rule: ScopeRule, pub rule: ScopeRule,
} }
/// Find the Pod/rule that actually owns a write scope overlapping `rule`. /// Find the Worker/rule that actually owns a write scope overlapping `rule`.
/// ///
/// Walks the delegation tree: if an allocation overlaps `rule`, we /// Walks the delegation tree: if an allocation overlaps `rule`, we
/// descend into its children and return the deepest overlapping node /// descend into its children and return the deepest overlapping node
/// as the true owner. `exempt` names a Pod whose ownership is /// as the true owner. `exempt` names a Worker whose ownership is
/// permitted (used during delegation: the spawner itself is allowed /// permitted (used during delegation: the spawner itself is allowed
/// to still own the rule's region because it is handing it down). /// to still own the rule's region because it is handing it down).
pub fn find_conflict_owner( pub fn find_conflict_owner(
@ -115,7 +115,7 @@ pub fn find_conflict_owners(
.iter() .iter()
.filter(|a| a.delegated_from.is_none()) .filter(|a| a.delegated_from.is_none())
.filter_map(|alloc| find_conflict_in_subtree(lock, alloc, rule)) .filter_map(|alloc| find_conflict_in_subtree(lock, alloc, rule))
.filter(|owner| Some(owner.pod_name.as_str()) != exempt) .filter(|owner| Some(owner.worker_name.as_str()) != exempt)
.collect() .collect()
} }
@ -142,14 +142,14 @@ fn find_conflict_in_subtree(
for child in lock for child in lock
.allocations .allocations
.iter() .iter()
.filter(|a| a.delegated_from.as_deref() == Some(alloc.pod_name.as_str())) .filter(|a| a.delegated_from.as_deref() == Some(alloc.worker_name.as_str()))
{ {
if let Some(owner) = find_conflict_in_subtree(lock, child, rule) { if let Some(owner) = find_conflict_in_subtree(lock, child, rule) {
return Some(owner); return Some(owner);
} }
} }
Some(ConflictOwner { Some(ConflictOwner {
pod_name: alloc.pod_name.clone(), worker_name: alloc.worker_name.clone(),
rule: overlapping_rule.clone(), rule: overlapping_rule.clone(),
}) })
} }
@ -158,7 +158,7 @@ fn find_conflict_in_subtree(
mod tests { mod tests {
use super::*; use super::*;
use crate::test_util::*; use crate::test_util::*;
use crate::{ScopeLockError, delegate_scope, register_pod, register_pod_with_deny}; use crate::{ScopeLockError, delegate_scope, register_pod, register_worker_with_deny};
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
@ -192,9 +192,9 @@ mod tests {
#[test] #[test]
fn conflict_detection_descends_to_real_owner() { fn conflict_detection_descends_to_real_owner() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -213,9 +213,9 @@ mod tests {
&delegation_scope(vec![write_rule("/src", true)]), &delegation_scope(vec![write_rule("/src", true)]),
) )
.unwrap(); .unwrap();
// A different top-level Pod trying to register /src/core/x // A different top-level Worker trying to register /src/core/x
// should be blamed on B (deepest owner), not A. // should be blamed on B (deepest owner), not A.
let err = register_pod( let err = register_worker(
&mut g, &mut g,
"x".into(), "x".into(),
std::process::id(), std::process::id(),
@ -233,9 +233,9 @@ mod tests {
#[test] #[test]
fn denied_write_region_is_not_claimed_by_restored_parent() { fn denied_write_region_is_not_claimed_by_restored_parent() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod_with_deny( register_worker_with_deny(
&mut g, &mut g,
"parent".into(), "parent".into(),
std::process::id(), std::process::id(),
@ -245,7 +245,7 @@ mod tests {
sid(), sid(),
) )
.unwrap(); .unwrap();
register_pod( register_worker(
&mut g, &mut g,
"child".into(), "child".into(),
std::process::id(), std::process::id(),
@ -259,9 +259,9 @@ mod tests {
#[test] #[test]
fn partial_deny_does_not_hide_parent_conflict() { fn partial_deny_does_not_hide_parent_conflict() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod_with_deny( register_worker_with_deny(
&mut g, &mut g,
"parent".into(), "parent".into(),
std::process::id(), std::process::id(),
@ -272,7 +272,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let err = register_pod( let err = register_worker(
&mut g, &mut g,
"other".into(), "other".into(),
std::process::id(), std::process::id(),

View File

@ -9,10 +9,10 @@ use session_store::SegmentId;
/// Errors raised by the mutating pod-registry operations. /// Errors raised by the mutating pod-registry operations.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ScopeLockError { pub enum ScopeLockError {
#[error("I/O error on pods.json: {0}")] #[error("I/O error on workers.json: {0}")]
Io(#[from] io::Error), Io(#[from] io::Error),
#[error("pod name `{0}` is already registered")] #[error("pod name `{0}` is already registered")]
DuplicatePodName(String), DuplicateWorkerName(String),
#[error("requested scope `{}` conflicts with pod `{competitor}` rule `{}`", .rule.target.display(), .competitor_rule.target.display())] #[error("requested scope `{}` conflicts with pod `{competitor}` rule `{}`", .rule.target.display(), .competitor_rule.target.display())]
WriteConflict { WriteConflict {
competitor: String, competitor: String,
@ -27,14 +27,14 @@ pub enum ScopeLockError {
#[error("invalid delegation scope: {source}")] #[error("invalid delegation scope: {source}")]
InvalidScope { source: ScopeError }, InvalidScope { source: ScopeError },
#[error("pod `{0}` is not registered")] #[error("pod `{0}` is not registered")]
UnknownPod(String), UnknownWorker(String),
#[error( #[error(
"session {segment_id} is already held by pod `{pod_name}` at {}", "session {segment_id} is already held by pod `{worker_name}` at {}",
.socket.display() .socket.display()
)] )]
SegmentConflict { SegmentConflict {
segment_id: SegmentId, segment_id: SegmentId,
pod_name: String, worker_name: String,
socket: PathBuf, socket: PathBuf,
}, },
} }

View File

@ -1,16 +1,16 @@
//! Machine-wide Pod allocation registry. //! Machine-wide Worker allocation registry.
//! //!
//! A single JSON file at `<runtime_dir>/pods.json` records every live //! A single JSON file at `<runtime_dir>/workers.json` records every live
//! Pod's allocation (see [`manifest::paths::pod_registry_path`] for //! Worker's allocation (see [`manifest::paths::pod_registry_path`] for
//! how the path is resolved). File-level `flock(2)` serialises access //! how the path is resolved). File-level `flock(2)` serialises access
//! across processes so spawn sequences from unrelated Pods can't race. //! across processes so spawn sequences from unrelated Workers can't race.
//! //!
//! Each Pod, when starting, acquires the lock, reclaims stale entries //! Each Worker, when starting, acquires the lock, reclaims stale entries
//! (Pods whose PID has died), checks that its requested write scope //! (Workers whose PID has died), checks that its requested write scope
//! does not overlap any other allocation's effective write scope, and //! does not overlap any other allocation's effective write scope, and
//! registers itself. When it exits normally, it removes its entry and //! registers itself. When it exits normally, it removes its entry and
//! returns delegated scope to its `delegated_from` parent. Crash //! returns delegated scope to its `delegated_from` parent. Crash
//! recovery rides on the next Pod that opens the file — no background //! recovery rides on the next Worker that opens the file — no background
//! reaper. //! reaper.
mod conflict; mod conflict;
@ -31,7 +31,7 @@ pub use lifecycle::{
install_top_level_with_deny, lookup_segment, update_segment, install_top_level_with_deny, lookup_segment, update_segment,
}; };
pub use mutate::{ pub use mutate::{
delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_pod, delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_worker,
register_pod_with_deny, release_pod, register_worker_with_deny, release_worker,
}; };
pub use table::{Allocation, LockFile, LockFileGuard, default_registry_path}; pub use table::{Allocation, LockFile, LockFileGuard, default_registry_path};

View File

@ -8,21 +8,21 @@ use manifest::ScopeRule;
use session_store::SegmentId; use session_store::SegmentId;
use crate::error::ScopeLockError; use crate::error::ScopeLockError;
use crate::mutate::release_pod; use crate::mutate::release_worker;
use crate::table::{LockFileGuard, default_registry_path}; use crate::table::{LockFileGuard, default_registry_path};
/// Owned allocation: on drop, opens the lock file and releases this /// Owned allocation: on drop, opens the lock file and releases this
/// Pod's entry. The guard keeps only the name + lock-file path; it /// Worker's entry. The guard keeps only the name + lock-file path; it
/// does not hold the `flock` for the Pod's lifetime. /// does not hold the `flock` for the Worker's lifetime.
#[derive(Debug)] #[derive(Debug)]
pub struct ScopeAllocationGuard { pub struct ScopeAllocationGuard {
pod_name: String, worker_name: String,
lock_path: PathBuf, lock_path: PathBuf,
} }
impl ScopeAllocationGuard { impl ScopeAllocationGuard {
pub fn pod_name(&self) -> &str { pub fn worker_name(&self) -> &str {
&self.pod_name &self.worker_name
} }
pub fn lock_path(&self) -> &Path { pub fn lock_path(&self) -> &Path {
@ -33,28 +33,35 @@ impl ScopeAllocationGuard {
impl Drop for ScopeAllocationGuard { impl Drop for ScopeAllocationGuard {
fn drop(&mut self) { fn drop(&mut self) {
if let Ok(mut guard) = LockFileGuard::open(&self.lock_path) { if let Ok(mut guard) = LockFileGuard::open(&self.lock_path) {
let _ = release_pod(&mut guard, &self.pod_name); let _ = release_worker(&mut guard, &self.worker_name);
} }
} }
} }
/// Open the default lock file, register a top-level Pod, and return a /// Open the default lock file, register a top-level Worker, and return a
/// guard that will release the allocation on drop. /// guard that will release the allocation on drop.
pub fn install_top_level( pub fn install_top_level(
pod_name: String, worker_name: String,
pid: u32, pid: u32,
socket: PathBuf, socket: PathBuf,
scope_allow: Vec<ScopeRule>, scope_allow: Vec<ScopeRule>,
segment_id: SegmentId, segment_id: SegmentId,
) -> Result<ScopeAllocationGuard, ScopeLockError> { ) -> Result<ScopeAllocationGuard, ScopeLockError> {
install_top_level_with_deny(pod_name, pid, socket, scope_allow, Vec::new(), segment_id) install_top_level_with_deny(
worker_name,
pid,
socket,
scope_allow,
Vec::new(),
segment_id,
)
} }
/// Open the default lock file, register a top-level Pod with explicit /// Open the default lock file, register a top-level Worker with explicit
/// deny rules, and return a guard that will release the allocation on /// deny rules, and return a guard that will release the allocation on
/// drop. /// drop.
pub fn install_top_level_with_deny( pub fn install_top_level_with_deny(
pod_name: String, worker_name: String,
pid: u32, pid: u32,
socket: PathBuf, socket: PathBuf,
scope_allow: Vec<ScopeRule>, scope_allow: Vec<ScopeRule>,
@ -63,9 +70,9 @@ pub fn install_top_level_with_deny(
) -> Result<ScopeAllocationGuard, ScopeLockError> { ) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_registry_path()?; let lock_path = default_registry_path()?;
let mut guard = LockFileGuard::open(&lock_path)?; let mut guard = LockFileGuard::open(&lock_path)?;
crate::mutate::register_pod_with_deny( crate::mutate::register_worker_with_deny(
&mut guard, &mut guard,
pod_name.clone(), worker_name.clone(),
pid, pid,
socket, socket,
scope_allow, scope_allow,
@ -73,13 +80,13 @@ pub fn install_top_level_with_deny(
segment_id, segment_id,
)?; )?;
Ok(ScopeAllocationGuard { Ok(ScopeAllocationGuard {
pod_name, worker_name,
lock_path, lock_path,
}) })
} }
/// Take ownership of an existing allocation that was pre-registered by /// Take ownership of an existing allocation that was pre-registered by
/// a spawning Pod. /// a spawning Worker.
/// ///
/// The spawning flow is two-stage: the spawner calls /// The spawning flow is two-stage: the spawner calls
/// [`crate::delegate_scope`] (with its own pid as a live placeholder, /// [`crate::delegate_scope`] (with its own pid as a live placeholder,
@ -88,7 +95,7 @@ pub fn install_top_level_with_deny(
/// segment_id to its own and claim the [`ScopeAllocationGuard`] so /// segment_id to its own and claim the [`ScopeAllocationGuard`] so
/// the entry is released when the child exits. /// the entry is released when the child exits.
pub fn adopt_allocation( pub fn adopt_allocation(
pod_name: String, worker_name: String,
new_pid: u32, new_pid: u32,
segment_id: SegmentId, segment_id: SegmentId,
) -> Result<ScopeAllocationGuard, ScopeLockError> { ) -> Result<ScopeAllocationGuard, ScopeLockError> {
@ -96,24 +103,24 @@ pub fn adopt_allocation(
let mut guard = LockFileGuard::open(&lock_path)?; let mut guard = LockFileGuard::open(&lock_path)?;
let alloc = guard let alloc = guard
.data_mut() .data_mut()
.find_mut(&pod_name) .find_mut(&worker_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?; .ok_or_else(|| ScopeLockError::UnknownWorker(worker_name.clone()))?;
alloc.pid = new_pid; alloc.pid = new_pid;
alloc.segment_id = Some(segment_id); alloc.segment_id = Some(segment_id);
guard.save()?; guard.save()?;
Ok(ScopeAllocationGuard { Ok(ScopeAllocationGuard {
pod_name, worker_name,
lock_path, lock_path,
}) })
} }
/// Rewrite the `segment_id` recorded for `pod_name` to /// Rewrite the `segment_id` recorded for `worker_name` to
/// `new_segment_id`. /// `new_segment_id`.
/// ///
/// The Pod's in-memory `segment_id` can change underneath the /// The Worker's in-memory `segment_id` can change underneath the
/// allocation in two normal places: /// allocation in two normal places:
/// ///
/// - `Pod::compact` mints a fresh session and swaps it in. /// - `Worker::compact` mints a fresh session and swaps it in.
/// - `session_store::ensure_head_or_fork` auto-forks when another /// - `session_store::ensure_head_or_fork` auto-forks when another
/// writer has advanced the store head behind our back. /// writer has advanced the store head behind our back.
/// ///
@ -121,37 +128,37 @@ pub fn adopt_allocation(
/// find the live session id, not the old one. Without this update a /// find the live session id, not the old one. Without this update a
/// concurrent `restore_from_manifest(new_id)` would see "no live /// concurrent `restore_from_manifest(new_id)` would see "no live
/// writer" and proceed to register a competing allocation on the /// writer" and proceed to register a competing allocation on the
/// session this Pod just moved into. /// session this Worker just moved into.
/// ///
/// The lock is opened once and the allocation is rewritten inside the /// The lock is opened once and the allocation is rewritten inside the
/// guard, so the segment_id collision check is atomic with the /// guard, so the segment_id collision check is atomic with the
/// rewrite. /// rewrite.
pub fn update_segment(pod_name: &str, new_segment_id: SegmentId) -> Result<(), ScopeLockError> { pub fn update_segment(worker_name: &str, new_segment_id: SegmentId) -> Result<(), ScopeLockError> {
let lock_path = default_registry_path()?; let lock_path = default_registry_path()?;
let mut guard = LockFileGuard::open(&lock_path)?; let mut guard = LockFileGuard::open(&lock_path)?;
if let Some(other) = guard.data().find_by_segment(new_segment_id) { if let Some(other) = guard.data().find_by_segment(new_segment_id) {
if other.pod_name != pod_name { if other.worker_name != worker_name {
return Err(ScopeLockError::SegmentConflict { return Err(ScopeLockError::SegmentConflict {
segment_id: new_segment_id, segment_id: new_segment_id,
pod_name: other.pod_name.clone(), worker_name: other.worker_name.clone(),
socket: other.socket.clone(), socket: other.socket.clone(),
}); });
} }
} }
let alloc = guard let alloc = guard
.data_mut() .data_mut()
.find_mut(pod_name) .find_mut(worker_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.into()))?; .ok_or_else(|| ScopeLockError::UnknownWorker(worker_name.into()))?;
alloc.segment_id = Some(new_segment_id); alloc.segment_id = Some(new_segment_id);
guard.save()?; guard.save()?;
Ok(()) Ok(())
} }
/// Information about a Pod that currently holds an allocation for a /// Information about a Worker that currently holds an allocation for a
/// given session. /// given session.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SegmentLockInfo { pub struct SegmentLockInfo {
pub pod_name: String, pub worker_name: String,
pub socket: PathBuf, pub socket: PathBuf,
pub pid: u32, pub pid: u32,
} }
@ -159,7 +166,7 @@ pub struct SegmentLockInfo {
/// Open the default lock file, reclaim stale entries, and return the /// Open the default lock file, reclaim stale entries, and return the
/// allocation currently writing to `segment_id`, if any. /// allocation currently writing to `segment_id`, if any.
/// ///
/// Used by `Pod::restore_from_manifest` to refuse a resume that would /// Used by `Worker::restore_from_manifest` to refuse a resume that would
/// race a live writer on the same source session. /// race a live writer on the same source session.
pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>, ScopeLockError> { pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>, ScopeLockError> {
let lock_path = default_registry_path()?; let lock_path = default_registry_path()?;
@ -169,7 +176,7 @@ pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>,
.data() .data()
.find_by_segment(segment_id) .find_by_segment(segment_id)
.map(|a| SegmentLockInfo { .map(|a| SegmentLockInfo {
pod_name: a.pod_name.clone(), worker_name: a.worker_name.clone(),
socket: a.socket.clone(), socket: a.socket.clone(),
pid: a.pid, pid: a.pid,
})) }))
@ -185,11 +192,11 @@ mod tests {
/// Mimic what the spawner does before the child comes up: push an /// Mimic what the spawner does before the child comes up: push an
/// allocation for the child carrying the spawner's (live) pid as a /// allocation for the child carrying the spawner's (live) pid as a
/// placeholder. Exists only in tests. /// placeholder. Exists only in tests.
fn delegate_placeholder(g: &mut LockFileGuard, pod_name: &str, placeholder_pid: u32) { fn delegate_placeholder(g: &mut LockFileGuard, worker_name: &str, placeholder_pid: u32) {
g.data_mut().allocations.push(Allocation { g.data_mut().allocations.push(Allocation {
pod_name: pod_name.to_string(), worker_name: worker_name.to_string(),
pid: placeholder_pid, pid: placeholder_pid,
socket: sock(pod_name), socket: sock(worker_name),
scope_allow: vec![write_rule("/tmp/child", true)], scope_allow: vec![write_rule("/tmp/child", true)],
scope_deny: Vec::new(), scope_deny: Vec::new(),
delegated_from: None, delegated_from: None,
@ -202,7 +209,7 @@ mod tests {
fn scope_allocation_guard_releases_on_drop() { fn scope_allocation_guard_releases_on_drop() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path()); let _sandbox = RuntimeDirSandbox::new(dir.path());
let lock_path = dir.path().join("pods.json"); let lock_path = dir.path().join("workers.json");
let guard = install_top_level( let guard = install_top_level(
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -226,7 +233,7 @@ mod tests {
fn adopt_allocation_rewrites_pid_and_releases_on_drop() { fn adopt_allocation_rewrites_pid_and_releases_on_drop() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path()); let _sandbox = RuntimeDirSandbox::new(dir.path());
let lock_path = dir.path().join("pods.json"); let lock_path = dir.path().join("workers.json");
// Pre-register an allocation under spawner's pid, as delegate_scope would. // Pre-register an allocation under spawner's pid, as delegate_scope would.
{ {
let mut g = LockFileGuard::open(&lock_path).unwrap(); let mut g = LockFileGuard::open(&lock_path).unwrap();
@ -251,7 +258,7 @@ mod tests {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path()); let _sandbox = RuntimeDirSandbox::new(dir.path());
let err = adopt_allocation("ghost".into(), 42, sid()).unwrap_err(); let err = adopt_allocation("ghost".into(), 42, sid()).unwrap_err();
assert!(matches!(err, ScopeLockError::UnknownPod(ref n) if n == "ghost")); assert!(matches!(err, ScopeLockError::UnknownWorker(ref n) if n == "ghost"));
} }
#[test] #[test]
@ -268,7 +275,7 @@ mod tests {
) )
.unwrap(); .unwrap();
let info = lookup_segment(s).unwrap().expect("expected live writer"); let info = lookup_segment(s).unwrap().expect("expected live writer");
assert_eq!(info.pod_name, "live"); assert_eq!(info.worker_name, "live");
assert_eq!(info.socket, sock("live")); assert_eq!(info.socket, sock("live"));
drop(guard); drop(guard);
// After the guard's release, the lookup goes back to None. // After the guard's release, the lookup goes back to None.
@ -292,7 +299,7 @@ mod tests {
update_segment("p", updated).unwrap(); update_segment("p", updated).unwrap();
// lookup against the original is now empty, the updated id wins. // lookup against the original is now empty, the updated id wins.
assert!(lookup_segment(original).unwrap().is_none()); assert!(lookup_segment(original).unwrap().is_none());
assert_eq!(lookup_segment(updated).unwrap().unwrap().pod_name, "p"); assert_eq!(lookup_segment(updated).unwrap().unwrap().worker_name, "p");
} }
#[test] #[test]
@ -321,11 +328,11 @@ mod tests {
let err = update_segment("a", s_b).unwrap_err(); let err = update_segment("a", s_b).unwrap_err();
match err { match err {
ScopeLockError::SegmentConflict { ScopeLockError::SegmentConflict {
pod_name, worker_name,
segment_id, segment_id,
.. ..
} => { } => {
assert_eq!(pod_name, "b"); assert_eq!(worker_name, "b");
assert_eq!(segment_id, s_b); assert_eq!(segment_id, s_b);
} }
other => panic!("expected SegmentConflict, got {other:?}"), other => panic!("expected SegmentConflict, got {other:?}"),

View File

@ -11,24 +11,24 @@ use crate::conflict::{find_conflict_owner, find_conflict_owners};
use crate::error::ScopeLockError; use crate::error::ScopeLockError;
use crate::table::{Allocation, LockFileGuard}; use crate::table::{Allocation, LockFileGuard};
/// Register a top-level Pod (started directly by a human, no /// Register a top-level Worker (started directly by a human, no
/// delegation parent). Reclaims stale entries before checking /// delegation parent). Reclaims stale entries before checking
/// conflicts so a crashed Pod's allocation doesn't block the new one. /// conflicts so a crashed Worker's allocation doesn't block the new one.
/// ///
/// Rejects when another live allocation is already writing to /// Rejects when another live allocation is already writing to
/// `segment_id`, so two `restore_from_manifest` calls under different /// `segment_id`, so two `restore_from_manifest` calls under different
/// `pod_name`s cannot both grab the same session log. /// `worker_name`s cannot both grab the same session log.
pub fn register_pod( pub fn register_worker(
guard: &mut LockFileGuard, guard: &mut LockFileGuard,
pod_name: String, worker_name: String,
pid: u32, pid: u32,
socket: PathBuf, socket: PathBuf,
scope_allow: Vec<ScopeRule>, scope_allow: Vec<ScopeRule>,
segment_id: SegmentId, segment_id: SegmentId,
) -> Result<(), ScopeLockError> { ) -> Result<(), ScopeLockError> {
register_pod_with_deny( register_worker_with_deny(
guard, guard,
pod_name, worker_name,
pid, pid,
socket, socket,
scope_allow, scope_allow,
@ -37,21 +37,21 @@ pub fn register_pod(
) )
} }
/// Register a top-level Pod with explicit deny rules that reduce the /// Register a top-level Worker with explicit deny rules that reduce the
/// claimed effective write scope. /// claimed effective write scope.
/// ///
/// Conflict semantics: if every Pod overlapping a requested allow rule /// Conflict semantics: if every Worker overlapping a requested allow rule
/// is fully covered by one of `scope_deny`, the conflict is suppressed /// is fully covered by one of `scope_deny`, the conflict is suppressed
/// and the registration proceeds. The check is structural (deny ⊇ /// and the registration proceeds. The check is structural (deny ⊇
/// competitor.rule), not relational — it does not verify that the /// competitor.rule), not relational — it does not verify that the
/// competitor actually descends from this Pod's prior delegations. /// competitor actually descends from this Worker's prior delegations.
/// In practice this is safe because the canonical restore caller derives /// In practice this is safe because the canonical restore caller derives
/// `scope_deny` from outstanding `pod-store` child delegations, so any /// `scope_deny` from outstanding `pod-store` child delegations, so any
/// covered competitor is expected to be a descendant of the original /// covered competitor is expected to be a descendant of the original
/// allocation. Direct callers must uphold the same invariant. /// allocation. Direct callers must uphold the same invariant.
pub fn register_pod_with_deny( pub fn register_worker_with_deny(
guard: &mut LockFileGuard, guard: &mut LockFileGuard,
pod_name: String, worker_name: String,
pid: u32, pid: u32,
socket: PathBuf, socket: PathBuf,
scope_allow: Vec<ScopeRule>, scope_allow: Vec<ScopeRule>,
@ -59,13 +59,13 @@ pub fn register_pod_with_deny(
segment_id: SegmentId, segment_id: SegmentId,
) -> Result<(), ScopeLockError> { ) -> Result<(), ScopeLockError> {
reclaim_stale(guard); reclaim_stale(guard);
if guard.data().find(&pod_name).is_some() { if guard.data().find(&worker_name).is_some() {
return Err(ScopeLockError::DuplicatePodName(pod_name)); return Err(ScopeLockError::DuplicateWorkerName(worker_name));
} }
if let Some(existing) = guard.data().find_by_segment(segment_id) { if let Some(existing) = guard.data().find_by_segment(segment_id) {
return Err(ScopeLockError::SegmentConflict { return Err(ScopeLockError::SegmentConflict {
segment_id, segment_id,
pod_name: existing.pod_name.clone(), worker_name: existing.worker_name.clone(),
socket: existing.socket.clone(), socket: existing.socket.clone(),
}); });
} }
@ -86,14 +86,14 @@ pub fn register_pod_with_deny(
} }
if let Some(competitor) = conflicts.into_iter().next() { if let Some(competitor) = conflicts.into_iter().next() {
return Err(ScopeLockError::WriteConflict { return Err(ScopeLockError::WriteConflict {
competitor: competitor.pod_name, competitor: competitor.worker_name,
rule: rule.clone(), rule: rule.clone(),
competitor_rule: competitor.rule, competitor_rule: competitor.rule,
}); });
} }
} }
guard.data_mut().allocations.push(Allocation { guard.data_mut().allocations.push(Allocation {
pod_name, worker_name,
pid, pid,
socket, socket,
scope_allow, scope_allow,
@ -105,9 +105,9 @@ pub fn register_pod_with_deny(
Ok(()) Ok(())
} }
/// Register a spawned Pod whose scope is delegated from `spawner`. /// Register a spawned Worker whose scope is delegated from `spawner`.
/// The requested scope must be within the spawner's delegation authority; /// The requested scope must be within the spawner's delegation authority;
/// overlap with any Pod other than `spawner` is a conflict. /// overlap with any Worker other than `spawner` is a conflict.
pub fn delegate_scope( pub fn delegate_scope(
guard: &mut LockFileGuard, guard: &mut LockFileGuard,
spawner: &str, spawner: &str,
@ -119,10 +119,10 @@ pub fn delegate_scope(
) -> Result<(), ScopeLockError> { ) -> Result<(), ScopeLockError> {
reclaim_stale(guard); reclaim_stale(guard);
if guard.data().find(&spawned).is_some() { if guard.data().find(&spawned).is_some() {
return Err(ScopeLockError::DuplicatePodName(spawned)); return Err(ScopeLockError::DuplicateWorkerName(spawned));
} }
if guard.data().find(spawner).is_none() { if guard.data().find(spawner).is_none() {
return Err(ScopeLockError::UnknownPod(spawner.into())); return Err(ScopeLockError::UnknownWorker(spawner.into()));
} }
for rule in &scope_allow { for rule in &scope_allow {
let allowed = delegation_scope let allowed = delegation_scope
@ -137,7 +137,7 @@ pub fn delegate_scope(
if rule.permission == Permission::Write { if rule.permission == Permission::Write {
if let Some(competitor) = find_conflict_owner(guard.data(), rule, Some(spawner)) { if let Some(competitor) = find_conflict_owner(guard.data(), rule, Some(spawner)) {
return Err(ScopeLockError::WriteConflict { return Err(ScopeLockError::WriteConflict {
competitor: competitor.pod_name, competitor: competitor.worker_name,
rule: rule.clone(), rule: rule.clone(),
competitor_rule: competitor.rule, competitor_rule: competitor.rule,
}); });
@ -145,7 +145,7 @@ pub fn delegate_scope(
} }
} }
guard.data_mut().allocations.push(Allocation { guard.data_mut().allocations.push(Allocation {
pod_name: spawned, worker_name: spawned,
pid, pid,
socket, socket,
scope_allow, scope_allow,
@ -159,21 +159,21 @@ pub fn delegate_scope(
Ok(()) Ok(())
} }
/// Remove a Pod's allocation. Surviving children are reparented to /// Remove a Worker's allocation. Surviving children are reparented to
/// the removed Pod's own `delegated_from`, so the delegation tree /// the removed Worker's own `delegated_from`, so the delegation tree
/// stays connected. /// stays connected.
pub fn release_pod(guard: &mut LockFileGuard, pod_name: &str) -> Result<(), ScopeLockError> { pub fn release_worker(guard: &mut LockFileGuard, worker_name: &str) -> Result<(), ScopeLockError> {
let idx = guard let idx = guard
.data() .data()
.allocations .allocations
.iter() .iter()
.position(|a| a.pod_name == pod_name); .position(|a| a.worker_name == worker_name);
let Some(idx) = idx else { let Some(idx) = idx else {
return Err(ScopeLockError::UnknownPod(pod_name.into())); return Err(ScopeLockError::UnknownWorker(worker_name.into()));
}; };
let removed = guard.data().allocations[idx].clone(); let removed = guard.data().allocations[idx].clone();
for alloc in guard.data_mut().allocations.iter_mut() { for alloc in guard.data_mut().allocations.iter_mut() {
if alloc.delegated_from.as_deref() == Some(pod_name) { if alloc.delegated_from.as_deref() == Some(worker_name) {
alloc.delegated_from.clone_from(&removed.delegated_from); alloc.delegated_from.clone_from(&removed.delegated_from);
} }
} }
@ -187,7 +187,7 @@ pub fn release_pod(guard: &mut LockFileGuard, pod_name: &str) -> Result<(), Scop
/// This is idempotent for missing deny entries. For each delegated Write rule, /// This is idempotent for missing deny entries. For each delegated Write rule,
/// at most one exact matching deny rule is removed from the parent's `scope_deny` /// at most one exact matching deny rule is removed from the parent's `scope_deny`
/// even when the child allocation is already absent; restore reconciliation uses /// even when the child allocation is already absent; restore reconciliation uses
/// that case when durable Pod-state still records an outstanding delegation but /// that case when durable Worker-state still records an outstanding delegation but
/// the live lock file no longer has a child allocation. /// the live lock file no longer has a child allocation.
pub fn reclaim_delegated_scope( pub fn reclaim_delegated_scope(
guard: &mut LockFileGuard, guard: &mut LockFileGuard,
@ -199,7 +199,7 @@ pub fn reclaim_delegated_scope(
.data() .data()
.allocations .allocations
.iter() .iter()
.position(|a| a.pod_name == child); .position(|a| a.worker_name == child);
let removed_child_parent = child_idx let removed_child_parent = child_idx
.map(|idx| guard.data().allocations[idx].delegated_from.clone()) .map(|idx| guard.data().allocations[idx].delegated_from.clone())
.unwrap_or(None); .unwrap_or(None);
@ -229,8 +229,8 @@ pub fn reclaim_delegated_scope(
} }
/// Remove allocations whose PID is dead, reparenting children to the /// Remove allocations whose PID is dead, reparenting children to the
/// dead Pod's `delegated_from`. Idempotent and best-effort — I/O /// dead Worker's `delegated_from`. Idempotent and best-effort — I/O
/// errors on save are swallowed so a crashed Pod's entry never blocks /// errors on save are swallowed so a crashed Worker's entry never blocks
/// forward progress. /// forward progress.
pub fn reclaim_stale(guard: &mut LockFileGuard) { pub fn reclaim_stale(guard: &mut LockFileGuard) {
reclaim_stale_with(guard, pid_alive); reclaim_stale_with(guard, pid_alive);
@ -243,7 +243,7 @@ pub fn reclaim_stale_with(guard: &mut LockFileGuard, mut is_alive: impl FnMut(u3
.allocations .allocations
.iter() .iter()
.filter(|a| !is_alive(a.pid)) .filter(|a| !is_alive(a.pid))
.map(|a| a.pod_name.clone()) .map(|a| a.worker_name.clone())
.collect(); .collect();
if dead.is_empty() { if dead.is_empty() {
return; return;
@ -253,7 +253,7 @@ pub fn reclaim_stale_with(guard: &mut LockFileGuard, mut is_alive: impl FnMut(u3
.data() .data()
.allocations .allocations
.iter() .iter()
.position(|a| a.pod_name == *name) .position(|a| a.worker_name == *name)
else { else {
continue; continue;
}; };
@ -294,9 +294,9 @@ mod tests {
#[test] #[test]
fn register_detects_write_conflict() { fn register_detects_write_conflict() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -305,7 +305,7 @@ mod tests {
sid(), sid(),
) )
.unwrap(); .unwrap();
let err = register_pod( let err = register_worker(
&mut g, &mut g,
"b".into(), "b".into(),
std::process::id(), std::process::id(),
@ -321,11 +321,11 @@ mod tests {
} }
#[test] #[test]
fn duplicate_pod_name_rejected() { fn duplicate_worker_name_rejected() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -334,7 +334,7 @@ mod tests {
sid(), sid(),
) )
.unwrap(); .unwrap();
let err = register_pod( let err = register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -343,15 +343,15 @@ mod tests {
sid(), sid(),
) )
.unwrap_err(); .unwrap_err();
assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a")); assert!(matches!(err, ScopeLockError::DuplicateWorkerName(ref n) if n == "a"));
} }
#[test] #[test]
fn delegate_must_be_subset() { fn delegate_must_be_subset() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -376,9 +376,9 @@ mod tests {
#[test] #[test]
fn delegate_uses_delegation_scope_not_direct_effective_write() { fn delegate_uses_delegation_scope_not_direct_effective_write() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"orchestrator".into(), "orchestrator".into(),
std::process::id(), std::process::id(),
@ -406,9 +406,9 @@ mod tests {
#[test] #[test]
fn delegate_succeeds_within_parent_scope() { fn delegate_succeeds_within_parent_scope() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -445,9 +445,9 @@ mod tests {
#[test] #[test]
fn delegate_rejects_sibling_overlap() { fn delegate_rejects_sibling_overlap() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -486,9 +486,9 @@ mod tests {
#[test] #[test]
fn release_reparents_children() { fn release_reparents_children() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -517,7 +517,7 @@ mod tests {
&delegation_scope(vec![write_rule("/src/core", true)]), &delegation_scope(vec![write_rule("/src/core", true)]),
) )
.unwrap(); .unwrap();
release_pod(&mut g, "b").unwrap(); release_worker(&mut g, "b").unwrap();
// D should now list A as its delegated_from. // D should now list A as its delegated_from.
let d = g.data().find("d").unwrap(); let d = g.data().find("d").unwrap();
assert_eq!(d.delegated_from.as_deref(), Some("a")); assert_eq!(d.delegated_from.as_deref(), Some("a"));
@ -527,10 +527,10 @@ mod tests {
#[test] #[test]
fn reclaim_delegated_scope_removes_child_and_one_parent_deny_layer() { fn reclaim_delegated_scope_removes_child_and_one_parent_deny_layer() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
let delegated_rule = write_rule("/src/core", true); let delegated_rule = write_rule("/src/core", true);
register_pod_with_deny( register_worker_with_deny(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -540,7 +540,7 @@ mod tests {
sid(), sid(),
) )
.unwrap(); .unwrap();
register_pod( register_worker(
&mut g, &mut g,
"b".into(), "b".into(),
std::process::id(), std::process::id(),
@ -566,10 +566,10 @@ mod tests {
#[test] #[test]
fn reclaim_delegated_scope_removes_parent_deny_when_child_allocation_missing() { fn reclaim_delegated_scope_removes_parent_deny_when_child_allocation_missing() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
let delegated_rule = write_rule("/src/core", true); let delegated_rule = write_rule("/src/core", true);
register_pod_with_deny( register_worker_with_deny(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -595,9 +595,9 @@ mod tests {
#[test] #[test]
fn reclaim_stale_reparents_and_removes_dead_entries() { fn reclaim_stale_reparents_and_removes_dead_entries() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -630,7 +630,7 @@ mod tests {
// will treat as dead. // will treat as dead.
let fake_dead_pid: u32 = 0xffff_fff0; let fake_dead_pid: u32 = 0xffff_fff0;
for alloc in g.data_mut().allocations.iter_mut() { for alloc in g.data_mut().allocations.iter_mut() {
if alloc.pod_name == "b" { if alloc.worker_name == "b" {
alloc.pid = fake_dead_pid; alloc.pid = fake_dead_pid;
} }
} }
@ -643,9 +643,9 @@ mod tests {
#[test] #[test]
fn read_rules_do_not_conflict_with_write() { fn read_rules_do_not_conflict_with_write() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -655,7 +655,7 @@ mod tests {
) )
.unwrap(); .unwrap();
// B only reads under the same tree — allowed. // B only reads under the same tree — allowed.
register_pod( register_worker(
&mut g, &mut g,
"b".into(), "b".into(),
std::process::id(), std::process::id(),
@ -670,9 +670,9 @@ mod tests {
#[test] #[test]
fn releasing_pod_reopens_scope_for_fresh_registration() { fn releasing_pod_reopens_scope_for_fresh_registration() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -681,8 +681,8 @@ mod tests {
sid(), sid(),
) )
.unwrap(); .unwrap();
release_pod(&mut g, "a").unwrap(); release_worker(&mut g, "a").unwrap();
register_pod( register_worker(
&mut g, &mut g,
"b".into(), "b".into(),
std::process::id(), std::process::id(),
@ -696,9 +696,9 @@ mod tests {
#[test] #[test]
fn delegated_scope_returns_to_parent_on_release() { fn delegated_scope_returns_to_parent_on_release() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -722,7 +722,7 @@ mod tests {
"a", "a",
&write_rule("/src/core", true) &write_rule("/src/core", true)
)); ));
release_pod(&mut g, "b").unwrap(); release_worker(&mut g, "b").unwrap();
// /src/core is back in A's effective write scope. // /src/core is back in A's effective write scope.
assert!(is_within_effective_write( assert!(is_within_effective_write(
g.data(), g.data(),
@ -734,10 +734,10 @@ mod tests {
#[test] #[test]
fn register_pod_rejects_session_id_collision() { fn register_pod_rejects_session_id_collision() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
let shared_session = sid(); let shared_session = sid();
register_pod( register_worker(
&mut g, &mut g,
"first".into(), "first".into(),
std::process::id(), std::process::id(),
@ -747,9 +747,9 @@ mod tests {
) )
.unwrap(); .unwrap();
// Second registration tries to grab the same segment_id under // Second registration tries to grab the same segment_id under
// a different pod_name. Without the SegmentConflict check both // a different worker_name. Without the SegmentConflict check both
// would succeed and race on the same jsonl. // would succeed and race on the same jsonl.
let err = register_pod( let err = register_worker(
&mut g, &mut g,
"second".into(), "second".into(),
std::process::id(), std::process::id(),
@ -761,11 +761,11 @@ mod tests {
match err { match err {
ScopeLockError::SegmentConflict { ScopeLockError::SegmentConflict {
segment_id, segment_id,
pod_name, worker_name,
.. ..
} => { } => {
assert_eq!(segment_id, shared_session); assert_eq!(segment_id, shared_session);
assert_eq!(pod_name, "first"); assert_eq!(worker_name, "first");
} }
other => panic!("expected SegmentConflict, got {other:?}"), other => panic!("expected SegmentConflict, got {other:?}"),
} }

View File

@ -22,33 +22,33 @@ pub struct LockFile {
pub allocations: Vec<Allocation>, pub allocations: Vec<Allocation>,
} }
/// One Pod's scope allocation. /// One Worker's scope allocation.
/// ///
/// `scope_allow` is the full set of allow rules the Pod was granted. /// `scope_allow` is the full set of allow rules the Worker was granted.
/// Portions delegated out to child Pods are **not** subtracted in /// Portions delegated out to child Workers are **not** subtracted in
/// storage — the effective write scope is derived on the fly by /// storage — the effective write scope is derived on the fly by
/// removing rules owned by any Pod whose `delegated_from` points to /// removing rules owned by any Worker whose `delegated_from` points to
/// this one. Keeping the raw allow set makes reparenting (stale /// this one. Keeping the raw allow set makes reparenting (stale
/// reclaim) trivial. /// reclaim) trivial.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Allocation { pub struct Allocation {
/// Pod name — also the identity used throughout orchestration. /// Worker name — also the identity used throughout orchestration.
pub pod_name: String, pub worker_name: String,
/// Owning process. Checked with `kill(pid, 0)` for stale detection. /// Owning process. Checked with `kill(pid, 0)` for stale detection.
pub pid: u32, pub pid: u32,
/// Pod's Unix socket path. /// Worker's Unix socket path.
pub socket: PathBuf, pub socket: PathBuf,
/// Allow rules granted to this Pod (write + read). /// Allow rules granted to this Worker (write + read).
pub scope_allow: Vec<ScopeRule>, pub scope_allow: Vec<ScopeRule>,
/// Deny rules that cap this Pod's effective scope. Normally empty for /// Deny rules that cap this Worker's effective scope. Normally empty for
/// fresh allocations; restored Pods use this to avoid reclaiming /// fresh allocations; restored Workers use this to avoid reclaiming
/// previously delegated write regions. /// previously delegated write regions.
#[serde(default)] #[serde(default)]
pub scope_deny: Vec<ScopeRule>, pub scope_deny: Vec<ScopeRule>,
/// Name of the Pod that delegated scope to this one, or `None` for /// Name of the Worker that delegated scope to this one, or `None` for
/// a top-level Pod started directly by a human. /// a top-level Worker started directly by a human.
pub delegated_from: Option<String>, pub delegated_from: Option<String>,
/// Segment ID this Pod is currently writing to. `None` means this /// Segment ID this Worker is currently writing to. `None` means this
/// is a pre-reservation made by a spawner via [`crate::delegate_scope`] /// is a pre-reservation made by a spawner via [`crate::delegate_scope`]
/// before the child has come up; the child fills it in at /// before the child has come up; the child fills it in at
/// [`crate::adopt_allocation`] time. /// [`crate::adopt_allocation`] time.
@ -57,12 +57,16 @@ pub struct Allocation {
} }
impl LockFile { impl LockFile {
pub fn find(&self, pod_name: &str) -> Option<&Allocation> { pub fn find(&self, worker_name: &str) -> Option<&Allocation> {
self.allocations.iter().find(|a| a.pod_name == pod_name) self.allocations
.iter()
.find(|a| a.worker_name == worker_name)
} }
pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> { pub fn find_mut(&mut self, worker_name: &str) -> Option<&mut Allocation> {
self.allocations.iter_mut().find(|a| a.pod_name == pod_name) self.allocations
.iter_mut()
.find(|a| a.worker_name == worker_name)
} }
/// Find the allocation currently writing to `segment_id`. Skips /// Find the allocation currently writing to `segment_id`. Skips
@ -74,7 +78,7 @@ impl LockFile {
} }
} }
/// Default on-disk path: `<runtime_dir>/pods.json` resolved via /// Default on-disk path: `<runtime_dir>/workers.json` resolved via
/// [`manifest::paths::pod_registry_path`]. Tests should point this /// [`manifest::paths::pod_registry_path`]. Tests should point this
/// elsewhere by setting `YOI_HOME` or `YOI_RUNTIME_DIR` to a /// elsewhere by setting `YOI_HOME` or `YOI_RUNTIME_DIR` to a
/// tempdir. /// tempdir.
@ -82,7 +86,7 @@ pub fn default_registry_path() -> io::Result<PathBuf> {
paths::pod_registry_path().ok_or_else(|| { paths::pod_registry_path().ok_or_else(|| {
io::Error::new( io::Error::new(
io::ErrorKind::NotFound, io::ErrorKind::NotFound,
"could not resolve pods.json path (no YOI_HOME / \ "could not resolve workers.json path (no YOI_HOME / \
YOI_RUNTIME_DIR / XDG_RUNTIME_DIR / HOME)", YOI_RUNTIME_DIR / XDG_RUNTIME_DIR / HOME)",
) )
}) })
@ -173,7 +177,7 @@ impl LockFileGuard {
serde_json::from_str(&buf).map_err(|e| { serde_json::from_str(&buf).map_err(|e| {
io::Error::new( io::Error::new(
io::ErrorKind::InvalidData, io::ErrorKind::InvalidData,
format!("pods.json parse error: {e}"), format!("workers.json parse error: {e}"),
) )
})? })?
}; };
@ -215,7 +219,7 @@ mod tests {
#[test] #[test]
fn open_creates_empty_lock_file() { fn open_creates_empty_lock_file() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let guard = LockFileGuard::open(&path).unwrap(); let guard = LockFileGuard::open(&path).unwrap();
assert!(guard.data().allocations.is_empty()); assert!(guard.data().allocations.is_empty());
assert!(path.exists()); assert!(path.exists());
@ -226,7 +230,7 @@ mod tests {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let parent = dir.path().join("yoi"); let parent = dir.path().join("yoi");
let path = parent.join("pods.json"); let path = parent.join("workers.json");
let _guard = LockFileGuard::open(&path).unwrap(); let _guard = LockFileGuard::open(&path).unwrap();
let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; let file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(file_mode, 0o600, "file mode = {file_mode:o}"); assert_eq!(file_mode, 0o600, "file mode = {file_mode:o}");
@ -237,10 +241,10 @@ mod tests {
#[test] #[test]
fn save_and_reopen_roundtrip() { fn save_and_reopen_roundtrip() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
{ {
let mut g = open_empty(&path); let mut g = open_empty(&path);
register_pod( register_worker(
&mut g, &mut g,
"a".into(), "a".into(),
std::process::id(), std::process::id(),
@ -252,19 +256,19 @@ mod tests {
} }
let guard = LockFileGuard::open(&path).unwrap(); let guard = LockFileGuard::open(&path).unwrap();
assert_eq!(guard.data().allocations.len(), 1); assert_eq!(guard.data().allocations.len(), 1);
assert_eq!(guard.data().allocations[0].pod_name, "a"); assert_eq!(guard.data().allocations[0].worker_name, "a");
} }
#[test] #[test]
fn find_by_session_skips_none_placeholders() { fn find_by_session_skips_none_placeholders() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let path = dir.path().join("pods.json"); let path = dir.path().join("workers.json");
let mut g = open_empty(&path); let mut g = open_empty(&path);
// Pre-reservation: delegate_scope leaves segment_id = None // Pre-reservation: delegate_scope leaves segment_id = None
// until adopt_allocation rewrites it. find_by_segment must not // until adopt_allocation rewrites it. find_by_segment must not
// match those placeholders, otherwise a freshly-spawning child // match those placeholders, otherwise a freshly-spawning child
// would shadow itself before it has even chosen a session. // would shadow itself before it has even chosen a session.
register_pod( register_worker(
&mut g, &mut g,
"parent".into(), "parent".into(),
std::process::id(), std::process::id(),
@ -292,6 +296,6 @@ mod tests {
// After adopt-style rewrite, the same allocation is now found. // After adopt-style rewrite, the same allocation is now found.
g.data_mut().find_mut("child").unwrap().segment_id = Some(target_session); g.data_mut().find_mut("child").unwrap().segment_id = Some(target_session);
let found = g.data().find_by_segment(target_session).unwrap(); let found = g.data().find_by_segment(target_session).unwrap();
assert_eq!(found.pod_name, "child"); assert_eq!(found.worker_name, "child");
} }
} }

View File

@ -1,6 +1,6 @@
[package] [package]
name = "pod-store" name = "pod-store"
description = "Durable Pod-name metadata/state persistence" description = "Legacy-named durable Worker metadata/state persistence"
version = "0.1.0" version = "0.1.0"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true

View File

@ -2,13 +2,13 @@
## Role ## Role
`pod-store` owns current Pod metadata keyed by Pod name. `pod-store` is the legacy-named crate that owns current Worker metadata keyed by Worker name.
## Boundaries ## Boundaries
Owns: Owns:
- persisted Pod metadata files - persisted Worker metadata files
- current active/pending session pointers - current active/pending session pointers
- resolved manifest snapshots for restoration - resolved manifest snapshots for restoration
- parent-visible spawned-child metadata - parent-visible spawned-child metadata
@ -23,8 +23,8 @@ Does not own:
## Design notes ## Design notes
Pod metadata is intentionally thin. It should answer current-state questions without duplicating transcripts or becoming a second session log. Worker metadata is intentionally thin. It should answer current-state questions without duplicating transcripts or becoming a second session log.
## See also ## See also
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md) - [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)

View File

@ -1,14 +1,14 @@
//! Durable Pod-name metadata/state persistence. //! Durable Worker-name metadata/state persistence.
//! //!
//! This crate owns the name-keyed Pod state surface under a Pod-state root, //! This crate owns the name-keyed Worker state surface under a Worker-state root,
//! e.g. `{data_dir}/pods/{pod_name}/metadata.json`. Session JSONL replay stays //! e.g. `{data_dir}/workers/{worker_name}/metadata.json`. Session JSONL replay stays
//! in `session-store`; Pod metadata may point at a `(SessionId, SegmentId)` but //! in `session-store`; Worker metadata may point at a `(SessionId, SegmentId)` but
//! does not own or replay session logs. //! does not own or replay session logs.
//! //!
//! `resolved_manifest_snapshot` is authority only for Pod-name restore before //! `resolved_manifest_snapshot` is authority only for Worker-name restore before
//! loading the session log. Existing segment replay still uses `SegmentStart` //! loading the session log. Existing segment replay still uses `SegmentStart`
//! entries from `session-store`. `spawned_children` is durable current parent //! entries from `session-store`. `spawned_children` is durable current parent
//! Pod state for child registry/reclaim; child lifecycle messages shown to the //! Worker state for child registry/reclaim; child lifecycle messages shown to the
//! model remain session JSONL history. Socket and callback paths are last-known //! model remain session JSONL history. Socket and callback paths are last-known
//! runtime hints, not proof of liveness. //! runtime hints, not proof of liveness.
@ -17,9 +17,9 @@ use session_store::{SegmentId, SessionId};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
/// Errors from Pod metadata persistence. /// Errors from Worker metadata persistence.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum PodStoreError { pub enum WorkerStoreError {
#[error("I/O error: {0}")] #[error("I/O error: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
@ -30,15 +30,15 @@ pub enum PodStoreError {
InvalidPodName(String), InvalidPodName(String),
} }
/// Active Session/Segment pointer for a Pod. /// Active Session/Segment pointer for a Worker.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodActiveSegmentRef { pub struct WorkerActiveSegmentRef {
pub session_id: SessionId, pub session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub segment_id: Option<SegmentId>, pub segment_id: Option<SegmentId>,
} }
impl PodActiveSegmentRef { impl WorkerActiveSegmentRef {
/// Create a reference whose active Segment is not known yet. /// Create a reference whose active Segment is not known yet.
pub fn pending_segment(session_id: SessionId) -> Self { pub fn pending_segment(session_id: SessionId) -> Self {
Self { Self {
@ -57,21 +57,21 @@ impl PodActiveSegmentRef {
} }
/// One delegated scope rule for a spawned child, kept local to avoid depending /// One delegated scope rule for a spawned child, kept local to avoid depending
/// on manifest scope types in durable Pod state. /// on manifest scope types in durable Worker state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodSpawnedScopeRule { pub struct WorkerSpawnedScopeRule {
pub target: PathBuf, pub target: PathBuf,
pub permission: String, pub permission: String,
pub recursive: bool, pub recursive: bool,
} }
/// One child Pod spawned by this Pod and persisted with the spawner's /// One child Worker spawned by this Worker and persisted with the spawner's
/// name-keyed Pod state. Runtime paths are last-known hints only. /// name-keyed Worker state. Runtime paths are last-known hints only.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodSpawnedChild { pub struct WorkerSpawnedChild {
pub pod_name: String, pub worker_name: String,
pub socket_path: PathBuf, pub socket_path: PathBuf,
pub scope_delegated: Vec<PodSpawnedScopeRule>, pub scope_delegated: Vec<WorkerSpawnedScopeRule>,
pub callback_address: PathBuf, pub callback_address: PathBuf,
} }
@ -79,44 +79,44 @@ pub struct PodSpawnedChild {
/// restore can distinguish outstanding delegated scope from already-reclaimed /// restore can distinguish outstanding delegated scope from already-reclaimed
/// child state without consulting session logs. /// child state without consulting session logs.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodReclaimedChild { pub struct WorkerReclaimedChild {
pub pod_name: String, pub worker_name: String,
pub scope_delegated: Vec<PodSpawnedScopeRule>, pub scope_delegated: Vec<WorkerSpawnedScopeRule>,
} }
/// One peer Pod made visible by an explicit peer handshake. /// One peer Worker made visible by an explicit peer handshake.
/// ///
/// Peer visibility is intentionally separate from spawned-child delegation: it /// Peer visibility is intentionally separate from spawned-child delegation: it
/// does not carry filesystem scope, callback ownership, output cursors, or /// does not carry filesystem scope, callback ownership, output cursors, or
/// lifecycle-notification authority. /// lifecycle-notification authority.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodPeer { pub struct WorkerPeer {
pub pod_name: String, pub worker_name: String,
} }
/// Persistent metadata for a Pod name. /// Persistent metadata for a Worker name.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PodMetadata { pub struct WorkerMetadata {
pub pod_name: String, pub worker_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<PodActiveSegmentRef>, pub active: Option<WorkerActiveSegmentRef>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_root: Option<PathBuf>, pub workspace_root: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub spawned_children: Vec<PodSpawnedChild>, pub spawned_children: Vec<WorkerSpawnedChild>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reclaimed_children: Vec<PodReclaimedChild>, pub reclaimed_children: Vec<WorkerReclaimedChild>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peers: Vec<PodPeer>, pub peers: Vec<WorkerPeer>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_manifest_snapshot: Option<serde_json::Value>, pub resolved_manifest_snapshot: Option<serde_json::Value>,
} }
impl PodMetadata { impl WorkerMetadata {
/// Create Pod metadata for `pod_name`. /// Create Worker metadata for `worker_name`.
pub fn new(pod_name: impl Into<String>, active: Option<PodActiveSegmentRef>) -> Self { pub fn new(worker_name: impl Into<String>, active: Option<WorkerActiveSegmentRef>) -> Self {
Self { Self {
pod_name: pod_name.into(), worker_name: worker_name.into(),
active, active,
workspace_root: None, workspace_root: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
@ -132,35 +132,39 @@ impl PodMetadata {
} }
} }
/// Sync persistence backend for Pod metadata. /// Sync persistence backend for Worker metadata.
pub trait PodMetadataStore: Send + Sync { pub trait WorkerMetadataStore: Send + Sync {
/// Create or replace metadata for its `pod_name` key. /// Create or replace metadata for its `worker_name` key.
fn write(&self, metadata: &PodMetadata) -> Result<(), PodStoreError>; fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError>;
/// Read metadata by Pod name. Returns `None` when no metadata exists. /// Read metadata by Worker name. Returns `None` when no metadata exists.
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, PodStoreError>; fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError>;
/// List persisted Pod metadata keys. /// List persisted Worker metadata keys.
fn list_names(&self) -> Result<Vec<String>, PodStoreError>; fn list_names(&self) -> Result<Vec<String>, WorkerStoreError>;
/// Return the metadata root directory when this backend is path-backed. /// Return the metadata root directory when this backend is path-backed.
fn root_dir(&self) -> Option<PathBuf> { fn root_dir(&self) -> Option<PathBuf> {
None None
} }
/// Delete metadata by Pod name. Missing metadata is a successful no-op. /// Delete metadata by Worker name. Missing metadata is a successful no-op.
fn delete_by_name(&self, pod_name: &str) -> Result<(), PodStoreError>; fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError>;
/// Merge an update into one Pod's metadata, preserving unrelated fields. /// Merge an update into one Worker's metadata, preserving unrelated fields.
fn update_by_name<F>(&self, pod_name: &str, update: F) -> Result<PodMetadata, PodStoreError> fn update_by_name<F>(
&self,
worker_name: &str,
update: F,
) -> Result<WorkerMetadata, WorkerStoreError>
where where
F: FnOnce(&mut PodMetadata), F: FnOnce(&mut WorkerMetadata),
{ {
let mut metadata = self let mut metadata = self
.read_by_name(pod_name)? .read_by_name(worker_name)?
.unwrap_or_else(|| PodMetadata::new(pod_name, None)); .unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
update(&mut metadata); update(&mut metadata);
metadata.pod_name = pod_name.to_string(); metadata.worker_name = worker_name.to_string();
self.write(&metadata)?; self.write(&metadata)?;
Ok(metadata) Ok(metadata)
} }
@ -168,22 +172,22 @@ pub trait PodMetadataStore: Send + Sync {
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot. /// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
fn set_active( fn set_active(
&self, &self,
pod_name: &str, worker_name: &str,
active: Option<PodActiveSegmentRef>, active: Option<WorkerActiveSegmentRef>,
resolved_manifest_snapshot: Option<serde_json::Value>, resolved_manifest_snapshot: Option<serde_json::Value>,
) -> Result<PodMetadata, PodStoreError> { ) -> Result<WorkerMetadata, WorkerStoreError> {
self.set_active_with_workspace_root(pod_name, active, resolved_manifest_snapshot, None) self.set_active_with_workspace_root(worker_name, active, resolved_manifest_snapshot, None)
} }
/// Set the active pointer and workspace ownership while preserving unrelated fields. /// Set the active pointer and workspace ownership while preserving unrelated fields.
fn set_active_with_workspace_root( fn set_active_with_workspace_root(
&self, &self,
pod_name: &str, worker_name: &str,
active: Option<PodActiveSegmentRef>, active: Option<WorkerActiveSegmentRef>,
resolved_manifest_snapshot: Option<serde_json::Value>, resolved_manifest_snapshot: Option<serde_json::Value>,
workspace_root: Option<PathBuf>, workspace_root: Option<PathBuf>,
) -> Result<PodMetadata, PodStoreError> { ) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(pod_name, |metadata| { self.update_by_name(worker_name, |metadata| {
metadata.active = active; metadata.active = active;
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot; metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
if let Some(workspace_root) = workspace_root { if let Some(workspace_root) = workspace_root {
@ -195,38 +199,56 @@ pub trait PodMetadataStore: Send + Sync {
/// Set spawned-child registry state while preserving active pointer and manifest snapshot. /// Set spawned-child registry state while preserving active pointer and manifest snapshot.
fn set_spawned_children( fn set_spawned_children(
&self, &self,
pod_name: &str, worker_name: &str,
children: Vec<PodSpawnedChild>, children: Vec<WorkerSpawnedChild>,
) -> Result<PodMetadata, PodStoreError> { ) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(pod_name, |metadata| { self.update_by_name(worker_name, |metadata| {
metadata.spawned_children = children; metadata.spawned_children = children;
}) })
} }
/// Set peer visibility state while preserving active pointer, child state, /// Set peer visibility state while preserving active pointer, child state,
/// and manifest snapshot. /// and manifest snapshot.
fn set_peers(&self, pod_name: &str, peers: Vec<PodPeer>) -> Result<PodMetadata, PodStoreError> { fn set_peers(
self.update_by_name(pod_name, |metadata| { &self,
worker_name: &str,
peers: Vec<WorkerPeer>,
) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(worker_name, |metadata| {
metadata.peers = peers; metadata.peers = peers;
}) })
} }
/// Add one peer if absent while preserving every other metadata field. /// Add one peer if absent while preserving every other metadata field.
fn add_peer(&self, pod_name: &str, peer_name: &str) -> Result<PodMetadata, PodStoreError> { fn add_peer(
self.update_by_name(pod_name, |metadata| { &self,
if !metadata.peers.iter().any(|peer| peer.pod_name == peer_name) { worker_name: &str,
metadata.peers.push(PodPeer { peer_name: &str,
pod_name: peer_name.to_string(), ) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(worker_name, |metadata| {
if !metadata
.peers
.iter()
.any(|peer| peer.worker_name == peer_name)
{
metadata.peers.push(WorkerPeer {
worker_name: peer_name.to_string(),
}); });
metadata.peers.sort_by(|a, b| a.pod_name.cmp(&b.pod_name)); metadata
.peers
.sort_by(|a, b| a.worker_name.cmp(&b.worker_name));
} }
}) })
} }
/// Remove one peer while preserving every other metadata field. /// Remove one peer while preserving every other metadata field.
fn remove_peer(&self, pod_name: &str, peer_name: &str) -> Result<PodMetadata, PodStoreError> { fn remove_peer(
self.update_by_name(pod_name, |metadata| { &self,
metadata.peers.retain(|peer| peer.pod_name != peer_name); worker_name: &str,
peer_name: &str,
) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(worker_name, |metadata| {
metadata.peers.retain(|peer| peer.worker_name != peer_name);
}) })
} }
@ -234,47 +256,47 @@ pub trait PodMetadataStore: Send + Sync {
/// them in durable reclaim history. /// them in durable reclaim history.
fn reclaim_spawned_children( fn reclaim_spawned_children(
&self, &self,
pod_name: &str, worker_name: &str,
reclaimed: Vec<PodReclaimedChild>, reclaimed: Vec<WorkerReclaimedChild>,
) -> Result<PodMetadata, PodStoreError> { ) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(pod_name, |metadata| { self.update_by_name(worker_name, |metadata| {
for reclaimed_child in &reclaimed { for reclaimed_child in &reclaimed {
metadata metadata
.spawned_children .spawned_children
.retain(|child| child.pod_name != reclaimed_child.pod_name); .retain(|child| child.worker_name != reclaimed_child.worker_name);
} }
metadata.reclaimed_children.extend(reclaimed); metadata.reclaimed_children.extend(reclaimed);
}) })
} }
} }
/// Filesystem-backed Pod metadata store. /// Filesystem-backed Worker metadata store.
#[derive(Clone)] #[derive(Clone)]
pub struct FsPodStore { pub struct FsWorkerStore {
root: PathBuf, root: PathBuf,
} }
impl FsPodStore { impl FsWorkerStore {
/// Create a store rooted at the Pod-state directory, usually `{data_dir}/pods`. /// Create a store rooted at the Worker-state directory, usually `{data_dir}/workers`.
pub fn new(root: impl Into<PathBuf>) -> Result<Self, PodStoreError> { pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
let root = root.into(); let root = root.into();
fs::create_dir_all(&root)?; fs::create_dir_all(&root)?;
Ok(Self { root }) Ok(Self { root })
} }
fn pod_dir(&self, pod_name: &str) -> Result<PathBuf, PodStoreError> { fn pod_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
validate_pod_name(pod_name)?; validate_worker_name(worker_name)?;
Ok(self.root.join(pod_name)) Ok(self.root.join(worker_name))
} }
fn metadata_path(&self, pod_name: &str) -> Result<PathBuf, PodStoreError> { fn metadata_path(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
Ok(self.pod_dir(pod_name)?.join("metadata.json")) Ok(self.pod_dir(worker_name)?.join("metadata.json"))
} }
} }
impl PodMetadataStore for FsPodStore { impl WorkerMetadataStore for FsWorkerStore {
fn write(&self, metadata: &PodMetadata) -> Result<(), PodStoreError> { fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
let path = self.metadata_path(&metadata.pod_name)?; let path = self.metadata_path(&metadata.worker_name)?;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
@ -283,17 +305,17 @@ impl PodMetadataStore for FsPodStore {
Ok(()) Ok(())
} }
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, PodStoreError> { fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
let path = self.metadata_path(pod_name)?; let path = self.metadata_path(worker_name)?;
let content = match fs::read_to_string(path) { let content = match fs::read_to_string(path) {
Ok(content) => content, Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(PodStoreError::Io(err)), Err(err) => return Err(WorkerStoreError::Io(err)),
}; };
Ok(Some(serde_json::from_str(&content)?)) Ok(Some(serde_json::from_str(&content)?))
} }
fn list_names(&self) -> Result<Vec<String>, PodStoreError> { fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
let mut names = Vec::new(); let mut names = Vec::new();
if !self.root.exists() { if !self.root.exists() {
return Ok(names); return Ok(names);
@ -309,7 +331,7 @@ impl PodMetadataStore for FsPodStore {
let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else {
continue; continue;
}; };
if validate_pod_name(&name).is_ok() { if validate_worker_name(&name).is_ok() {
names.push(name); names.push(name);
} }
} }
@ -321,12 +343,12 @@ impl PodMetadataStore for FsPodStore {
Some(self.root.clone()) Some(self.root.clone())
} }
fn delete_by_name(&self, pod_name: &str) -> Result<(), PodStoreError> { fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
let path = self.metadata_path(pod_name)?; let path = self.metadata_path(worker_name)?;
match fs::remove_file(&path) { match fs::remove_file(&path) {
Ok(()) => {} Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(PodStoreError::Io(err)), Err(err) => return Err(WorkerStoreError::Io(err)),
} }
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
let _ = fs::remove_dir(parent); let _ = fs::remove_dir(parent);
@ -335,20 +357,20 @@ impl PodMetadataStore for FsPodStore {
} }
} }
pub fn validate_pod_name(pod_name: &str) -> Result<(), PodStoreError> { pub fn validate_worker_name(worker_name: &str) -> Result<(), WorkerStoreError> {
if pod_name.is_empty() if worker_name.is_empty()
|| pod_name == "." || worker_name == "."
|| pod_name == ".." || worker_name == ".."
|| pod_name.contains('/') || worker_name.contains('/')
|| pod_name.contains('\0') || worker_name.contains('\0')
{ {
return Err(PodStoreError::InvalidPodName(pod_name.to_string())); return Err(WorkerStoreError::InvalidPodName(worker_name.to_string()));
} }
Ok(()) Ok(())
} }
/// Convenience composition for callers that want one handle carrying separate /// Convenience composition for callers that want one handle carrying separate
/// session-log and Pod-state roots. /// session-log and Worker-state roots.
#[derive(Clone)] #[derive(Clone)]
pub struct CombinedStore<S, P> { pub struct CombinedStore<S, P> {
pub session_store: S, pub session_store: S,
@ -442,25 +464,25 @@ where
} }
} }
impl<S, P> PodMetadataStore for CombinedStore<S, P> impl<S, P> WorkerMetadataStore for CombinedStore<S, P>
where where
S: Send + Sync, S: Send + Sync,
P: PodMetadataStore, P: WorkerMetadataStore,
{ {
fn write(&self, metadata: &PodMetadata) -> Result<(), PodStoreError> { fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
self.pod_store.write(metadata) self.pod_store.write(metadata)
} }
fn read_by_name(&self, pod_name: &str) -> Result<Option<PodMetadata>, PodStoreError> { fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
self.pod_store.read_by_name(pod_name) self.pod_store.read_by_name(worker_name)
} }
fn list_names(&self) -> Result<Vec<String>, PodStoreError> { fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
self.pod_store.list_names() self.pod_store.list_names()
} }
fn root_dir(&self) -> Option<PathBuf> { fn root_dir(&self) -> Option<PathBuf> {
self.pod_store.root_dir() self.pod_store.root_dir()
} }
fn delete_by_name(&self, pod_name: &str) -> Result<(), PodStoreError> { fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
self.pod_store.delete_by_name(pod_name) self.pod_store.delete_by_name(worker_name)
} }
} }
@ -470,9 +492,9 @@ mod tests {
#[test] #[test]
fn pod_metadata_manifest_snapshot_roundtrips() { fn pod_metadata_manifest_snapshot_roundtrips() {
let mut metadata = PodMetadata::new( let mut metadata = WorkerMetadata::new(
"profile-pod", "profile-pod",
Some(PodActiveSegmentRef::pending_segment( Some(WorkerActiveSegmentRef::pending_segment(
session_store::new_session_id(), session_store::new_session_id(),
)), )),
); );
@ -482,7 +504,7 @@ mod tests {
})); }));
let json = serde_json::to_string(&metadata).unwrap(); let json = serde_json::to_string(&metadata).unwrap();
let restored: PodMetadata = serde_json::from_str(&json).unwrap(); let restored: WorkerMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(restored, metadata); assert_eq!(restored, metadata);
} }
@ -491,29 +513,29 @@ mod tests {
fn fs_store_writes_under_pod_state_root_only() { fn fs_store_writes_under_pod_state_root_only() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
let session_root = tmp.path().join("sessions"); let session_root = tmp.path().join("sessions");
let pod_root = tmp.path().join("pods"); let pod_root = tmp.path().join("workers");
fs::create_dir_all(&session_root).unwrap(); fs::create_dir_all(&session_root).unwrap();
let store = FsPodStore::new(&pod_root).unwrap(); let store = FsWorkerStore::new(&pod_root).unwrap();
store store
.write(&PodMetadata::new( .write(&WorkerMetadata::new(
"agent", "agent",
Some(PodActiveSegmentRef::pending_segment( Some(WorkerActiveSegmentRef::pending_segment(
session_store::new_session_id(), session_store::new_session_id(),
)), )),
)) ))
.unwrap(); .unwrap();
assert!(pod_root.join("agent/metadata.json").exists()); assert!(pod_root.join("agent/metadata.json").exists());
assert!(!session_root.join("pods/agent/metadata.json").exists()); assert!(!session_root.join("workers/agent/metadata.json").exists());
} }
#[test] #[test]
fn active_updates_preserve_children_and_manifest_snapshot() { fn active_updates_preserve_children_and_manifest_snapshot() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
let store = FsPodStore::new(tmp.path()).unwrap(); let store = FsWorkerStore::new(tmp.path()).unwrap();
let mut metadata = PodMetadata::new("agent", None); let mut metadata = WorkerMetadata::new("agent", None);
metadata.spawned_children.push(PodSpawnedChild { metadata.spawned_children.push(WorkerSpawnedChild {
pod_name: "child".into(), worker_name: "child".into(),
socket_path: std::path::Path::new("/tmp/child.sock").into(), socket_path: std::path::Path::new("/tmp/child.sock").into(),
scope_delegated: vec![], scope_delegated: vec![],
callback_address: std::path::Path::new("/tmp/parent.sock").into(), callback_address: std::path::Path::new("/tmp/parent.sock").into(),
@ -525,7 +547,7 @@ mod tests {
store store
.set_active( .set_active(
"agent", "agent",
Some(PodActiveSegmentRef::active_segment( Some(WorkerActiveSegmentRef::active_segment(
session_store::new_session_id(), session_store::new_session_id(),
session_store::new_segment_id(), session_store::new_segment_id(),
)), )),
@ -540,8 +562,8 @@ mod tests {
#[test] #[test]
fn child_updates_preserve_active_and_manifest_snapshot() { fn child_updates_preserve_active_and_manifest_snapshot() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
let store = FsPodStore::new(tmp.path()).unwrap(); let store = FsWorkerStore::new(tmp.path()).unwrap();
let active = PodActiveSegmentRef::active_segment( let active = WorkerActiveSegmentRef::active_segment(
session_store::new_session_id(), session_store::new_session_id(),
session_store::new_segment_id(), session_store::new_segment_id(),
); );
@ -552,8 +574,8 @@ mod tests {
store store
.set_spawned_children( .set_spawned_children(
"agent", "agent",
vec![PodSpawnedChild { vec![WorkerSpawnedChild {
pod_name: "child".into(), worker_name: "child".into(),
socket_path: std::path::Path::new("/tmp/child.sock").into(), socket_path: std::path::Path::new("/tmp/child.sock").into(),
scope_delegated: vec![], scope_delegated: vec![],
callback_address: std::path::Path::new("/tmp/parent.sock").into(), callback_address: std::path::Path::new("/tmp/parent.sock").into(),
@ -568,8 +590,8 @@ mod tests {
#[test] #[test]
fn peer_updates_preserve_active_children_and_manifest_snapshot() { fn peer_updates_preserve_active_children_and_manifest_snapshot() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
let store = FsPodStore::new(tmp.path()).unwrap(); let store = FsWorkerStore::new(tmp.path()).unwrap();
let active = PodActiveSegmentRef::active_segment( let active = WorkerActiveSegmentRef::active_segment(
session_store::new_session_id(), session_store::new_session_id(),
session_store::new_segment_id(), session_store::new_segment_id(),
); );
@ -580,8 +602,8 @@ mod tests {
store store
.set_spawned_children( .set_spawned_children(
"agent", "agent",
vec![PodSpawnedChild { vec![WorkerSpawnedChild {
pod_name: "child".into(), worker_name: "child".into(),
socket_path: std::path::Path::new("/tmp/child.sock").into(), socket_path: std::path::Path::new("/tmp/child.sock").into(),
scope_delegated: vec![], scope_delegated: vec![],
callback_address: std::path::Path::new("/tmp/parent.sock").into(), callback_address: std::path::Path::new("/tmp/parent.sock").into(),
@ -600,7 +622,7 @@ mod tests {
restored restored
.peers .peers
.iter() .iter()
.map(|peer| peer.pod_name.as_str()) .map(|peer| peer.worker_name.as_str())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec!["peer-a", "peer-b"] vec!["peer-a", "peer-b"]
); );
@ -608,14 +630,14 @@ mod tests {
store.remove_peer("agent", "peer-a").unwrap(); store.remove_peer("agent", "peer-a").unwrap();
let restored = store.read_by_name("agent").unwrap().unwrap(); let restored = store.read_by_name("agent").unwrap().unwrap();
assert_eq!(restored.peers.len(), 1); assert_eq!(restored.peers.len(), 1);
assert_eq!(restored.peers[0].pod_name, "peer-b"); assert_eq!(restored.peers[0].worker_name, "peer-b");
} }
#[test] #[test]
fn reclaim_children_removes_outstanding_and_records_history() { fn reclaim_children_removes_outstanding_and_records_history() {
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
let store = FsPodStore::new(tmp.path()).unwrap(); let store = FsWorkerStore::new(tmp.path()).unwrap();
let scope = PodSpawnedScopeRule { let scope = WorkerSpawnedScopeRule {
target: std::path::Path::new("/tmp/delegated").into(), target: std::path::Path::new("/tmp/delegated").into(),
permission: "write".into(), permission: "write".into(),
recursive: true, recursive: true,
@ -623,8 +645,8 @@ mod tests {
store store
.set_spawned_children( .set_spawned_children(
"agent", "agent",
vec![PodSpawnedChild { vec![WorkerSpawnedChild {
pod_name: "child".into(), worker_name: "child".into(),
socket_path: std::path::Path::new("/tmp/child.sock").into(), socket_path: std::path::Path::new("/tmp/child.sock").into(),
scope_delegated: vec![scope.clone()], scope_delegated: vec![scope.clone()],
callback_address: std::path::Path::new("/tmp/parent.sock").into(), callback_address: std::path::Path::new("/tmp/parent.sock").into(),
@ -635,8 +657,8 @@ mod tests {
store store
.reclaim_spawned_children( .reclaim_spawned_children(
"agent", "agent",
vec![PodReclaimedChild { vec![WorkerReclaimedChild {
pod_name: "child".into(), worker_name: "child".into(),
scope_delegated: vec![scope.clone()], scope_delegated: vec![scope.clone()],
}], }],
) )

View File

@ -1,32 +0,0 @@
# pod
## Role
`pod` turns an `llm-engine` Engine into a named runtime entity with manifest configuration, scoped tools, session persistence, protocol handling, and Pod metadata integration.
## Boundaries
Owns:
- Pod lifecycle and socket protocol serving
- Engine construction around a resolved Manifest
- session-store and pod-store coordination
- built-in tool registration under scope/policy
- spawned-child orchestration hooks
Does not own:
- provider-specific wire formats (`provider` / `llm-engine` clients)
- product CLI parsing (`yoi`)
- TUI display authority (`tui`)
- current-state storage schema outside Pod metadata (`pod-store`)
## Design notes
A Pod is runtime authority, not UI state. It should commit model-visible events through history/session paths and keep current Pod-name state in Pod metadata rather than in transient runtime files.
## See also
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md)
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)

View File

@ -2,7 +2,7 @@
## Role ## Role
`protocol` defines the JSONL message boundary between Pod clients and Pod servers. `protocol` defines the JSONL message boundary between Worker clients and Worker servers.
## Boundaries ## Boundaries
@ -14,7 +14,7 @@ Owns:
Does not own: Does not own:
- Unix socket implementation details (`client`, `pod`) - Unix socket implementation details (`client`, `worker`)
- TUI rendering (`tui`) - TUI rendering (`tui`)
- Engine history semantics (`llm-engine`) - Engine history semantics (`llm-engine`)
- durable storage (`session-store`, `pod-store`) - durable storage (`session-store`, `pod-store`)
@ -23,9 +23,9 @@ Does not own:
The exact enum variants are code authority. The README should describe the boundary, not duplicate every message shape. The exact enum variants are code authority. The README should describe the boundary, not duplicate every message shape.
Protocol events can inform UI and orchestration, but durable state changes still need to flow through Pod/session/metadata records. Protocol events can inform UI and orchestration, but durable state changes still need to flow through Worker/session/metadata records.
## See also ## See also
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md) - [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md) - [`../../docs/design/context-history.md`](../../docs/design/context-history.md)

View File

@ -20,7 +20,7 @@ fn is_false(value: &bool) -> bool {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Method (Client → Pod via Unix Socket) // Method (Client → Worker via Unix Socket)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -30,34 +30,34 @@ pub enum Method {
Run { Run {
input: Vec<Segment>, input: Vec<Segment>,
}, },
/// Human-readable text injected into the target Pod's LLM context /// Human-readable text injected into the target Worker's LLM context
/// as a non-blocking system message. `auto_run` controls whether an /// as a non-blocking system message. `auto_run` controls whether an
/// idle target is kicked into `RunForNotification`; weak notifications /// idle target is kicked into `RunForNotification`; weak notifications
/// (`auto_run: false`) are only queued for the next turn/resume/run. /// (`auto_run: false`) are only queued for the next turn/resume/run.
/// No side effects beyond LLM context; use `PodEvent` for typed /// No side effects beyond LLM context; use `WorkerEvent` for typed
/// lifecycle reports. /// lifecycle reports.
Notify { Notify {
message: String, message: String,
#[serde(default = "default_true", skip_serializing_if = "is_true")] #[serde(default = "default_true", skip_serializing_if = "is_true")]
auto_run: bool, auto_run: bool,
}, },
/// Typed lifecycle report from a child Pod to its direct parent. /// Typed lifecycle report from a child Worker to its direct parent.
PodEvent(PodEvent), WorkerEvent(WorkerEvent),
Resume, Resume,
Cancel, Cancel,
/// Stop the in-flight turn and transition to `Paused`. /// Stop the in-flight turn and transition to `Paused`.
/// ///
/// Unlike `Cancel` (which discards and returns to `Idle`), a paused /// Unlike `Cancel` (which discards and returns to `Idle`), a paused
/// Pod can resume the interrupted work via `Resume`, or start a /// Worker can resume the interrupted work via `Resume`, or start a
/// fresh turn via `Run` (orphan `tool_use` items are closed with a /// fresh turn via `Run` (orphan `tool_use` items are closed with a
/// synthetic tool result before the new user message is appended). /// synthetic tool result before the new user message is appended).
Pause, Pause,
/// Request an explicit compaction while the Pod is otherwise idle. /// Request an explicit compaction while the Worker is otherwise idle.
/// ///
/// This is a typed control method: clients must not send `compact` as a /// This is a typed control method: clients must not send `compact` as a
/// `Method::Run` user message. /// `Method::Run` user message.
Compact, Compact,
/// Ask the Pod to list valid rewind targets from its authoritative session log. /// Ask the Worker to list valid rewind targets from its authoritative session log.
ListRewindTargets, ListRewindTargets,
/// Truncate the current session back to the selected rewind target and /// Truncate the current session back to the selected rewind target and
/// return the selected user input to the client composer. /// return the selected user input to the client composer.
@ -66,7 +66,7 @@ pub enum Method {
expected_head_entries: usize, expected_head_entries: usize,
}, },
Shutdown, Shutdown,
/// Request a list of completion candidates from the Pod. /// Request a list of completion candidates from the Worker.
/// ///
/// Reply is sent on the same socket as `Event::Completions` (not /// Reply is sent on the same socket as `Event::Completions` (not
/// broadcast). The IPC server handles this directly and writes /// broadcast). The IPC server handles this directly and writes
@ -77,15 +77,15 @@ pub enum Method {
kind: CompletionKind, kind: CompletionKind,
prefix: String, prefix: String,
}, },
/// List Pods visible to this Pod from durable Pod state and the spawned-child /// List Workers visible to this Worker from durable Worker state and the spawned-child
/// registry. This is not a host-wide Pod universe query. /// registry. This is not a host-wide Worker universe query.
ListPods, ListWorkers,
/// Restore a visible stopped/restorable Pod, or report that it is already /// Restore a visible stopped/restorable Worker, or report that it is already
/// live. Missing state and not-visible state are distinct errors. /// live. Missing state and not-visible state are distinct errors.
RestorePod { RestoreWorker {
name: String, name: String,
}, },
/// Register another existing Pod as a reciprocal peer of this Pod. /// Register another existing Worker as a reciprocal peer of this Worker.
/// ///
/// This is metadata/control state only: it does not ask the target's live /// This is metadata/control state only: it does not ask the target's live
/// controller for consent, and it must not grant delegated scope, /// controller for consent, and it must not grant delegated scope,
@ -95,9 +95,9 @@ pub enum Method {
}, },
} }
/// Typed lifecycle events sent from a child Pod to its parent. /// Typed lifecycle events sent from a child Worker to its parent.
/// ///
/// Delivered as `Method::PodEvent` over the parent's Unix socket. The /// Delivered as `Method::WorkerEvent` over the parent's Unix socket. The
/// parent Controller always applies variant-specific side effects /// parent Controller always applies variant-specific side effects
/// (registry / pod-registry updates). Agent-visible variants are also /// (registry / pod-registry updates). Agent-visible variants are also
/// queued into the notification buffer; control-plane-only variants are /// queued into the notification buffer; control-plane-only variants are
@ -105,39 +105,42 @@ pub enum Method {
/// ///
/// Transport is fire-and-forget; receivers must tolerate out-of-order /// Transport is fire-and-forget; receivers must tolerate out-of-order
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same /// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
/// child Pod). /// child Worker).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum PodEvent { pub enum WorkerEvent {
/// Child finished one turn and is back to IDLE. /// Child finished one turn and is back to IDLE.
TurnEnded { pod_name: String }, TurnEnded { worker_name: String },
/// Engine execution error occurred inside the child's turn. /// Engine execution error occurred inside the child's turn.
/// ///
/// Limited to worker runtime failures (provider / tool errors) — /// Limited to worker runtime failures (provider / tool errors) —
/// does not include transient method-rejection responses such as /// does not include transient method-rejection responses such as
/// `AlreadyRunning`. /// `AlreadyRunning`.
Errored { pod_name: String, message: String }, Errored {
worker_name: String,
message: String,
},
/// Child has stopped (controller loop is exiting). /// Child has stopped (controller loop is exiting).
ShutDown { pod_name: String }, ShutDown { worker_name: String },
/// Child sub-delegated scope to a grandchild Pod via `SpawnPod`. /// Child sub-delegated scope to a grandchild Worker via `SpawnWorker`.
/// ///
/// Control-plane only: receivers apply registry side effects and /// Control-plane only: receivers apply registry side effects and
/// propagate upward, but do not expose this as an agent notification. /// propagate upward, but do not expose this as an agent notification.
/// ///
/// The parent uses this to add the grandchild to its own /// The parent uses this to add the grandchild to its own
/// `spawned_pods.json` so it can manage the grandchild directly /// `spawned_workers.json` so it can manage the grandchild directly
/// even if the intermediate child dies. The parent then re-fires /// even if the intermediate child dies. The parent then re-fires
/// this event upward (if it has a parent of its own) to maintain /// this event upward (if it has a parent of its own) to maintain
/// the chain to root. /// the chain to root.
ScopeSubDelegated { ScopeSubDelegated {
/// Sub-delegating Pod (= the sender itself). /// Sub-delegating Worker (= the sender itself).
parent_pod: String, parent_worker: String,
/// Name of the grandchild Pod. /// Name of the grandchild Worker.
sub_pod: String, sub_worker: String,
/// Unix-socket path where the grandchild is reachable. /// Unix-socket path where the grandchild is reachable.
sub_socket: PathBuf, sub_socket: PathBuf,
/// Scope delegated to the grandchild. /// Scope delegated to the grandchild.
@ -145,7 +148,7 @@ pub enum PodEvent {
}, },
} }
impl PodEvent { impl WorkerEvent {
/// Whether this event should become an agent-visible notification/history item. /// Whether this event should become an agent-visible notification/history item.
/// ///
/// Control-plane-only events still travel over the same wire enum and still /// Control-plane-only events still travel over the same wire enum and still
@ -153,10 +156,10 @@ impl PodEvent {
/// the notification buffer. /// the notification buffer.
pub fn should_notify_agent(&self) -> bool { pub fn should_notify_agent(&self) -> bool {
match self { match self {
PodEvent::TurnEnded { .. } | PodEvent::Errored { .. } | PodEvent::ShutDown { .. } => { WorkerEvent::TurnEnded { .. }
true | WorkerEvent::Errored { .. }
} | WorkerEvent::ShutDown { .. } => true,
PodEvent::ScopeSubDelegated { .. } => false, WorkerEvent::ScopeSubDelegated { .. } => false,
} }
} }
} }
@ -171,11 +174,11 @@ impl PodEvent {
/// clients (CLI piping, scripts) only need to produce a single /// clients (CLI piping, scripts) only need to produce a single
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms /// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
/// (paste chips, file refs, knowledge refs, workflow invocations) and /// (paste chips, file refs, knowledge refs, workflow invocations) and
/// send them through directly so the Pod side never has to re-parse a /// send them through directly so the Worker side never has to re-parse a
/// flattened string. /// flattened string.
/// ///
/// Forward compat: payloads with unknown `kind` deserialize to /// Forward compat: payloads with unknown `kind` deserialize to
/// `Segment::Unknown`. Pod treats this the same as known-but-unresolved /// `Segment::Unknown`. Worker treats this the same as known-but-unresolved
/// variants — emits an alert and inserts a `[unknown input segment]` /// variants — emits an alert and inserts a `[unknown input segment]`
/// placeholder into the LLM context so neither user nor LLM is blind to /// placeholder into the LLM context so neither user nor LLM is blind to
/// the dropped intent. /// the dropped intent.
@ -195,7 +198,7 @@ pub enum Segment {
lines: u32, lines: u32,
content: String, content: String,
}, },
/// `@<path>` file-system reference. Pod resolves readable files to /// `@<path>` file-system reference. Worker resolves readable files to
/// `[File: <path>]` attachments and readable normal directories to shallow /// `[File: <path>]` attachments and readable normal directories to shallow
/// `[Dir: <path>]` listings; the flattened user text keeps the literal /// `[Dir: <path>]` listings; the flattened user text keeps the literal
/// `@<path>` placeholder either way. /// `@<path>` placeholder either way.
@ -204,7 +207,7 @@ pub enum Segment {
KnowledgeRef { slug: String }, KnowledgeRef { slug: String },
/// `/<slug>` Workflow invocation (see `docs/plan/workflow.md`). /// `/<slug>` Workflow invocation (see `docs/plan/workflow.md`).
WorkflowInvoke { slug: String }, WorkflowInvoke { slug: String },
/// Unknown variant from a newer client. Pod treats this as an /// Unknown variant from a newer client. Worker treats this as an
/// unresolved input — surfaces an alert and inserts a placeholder. /// unresolved input — surfaces an alert and inserts a placeholder.
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`. /// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
#[serde(other)] #[serde(other)]
@ -220,7 +223,7 @@ impl Segment {
/// Flatten a segment slice into the single string the LLM receives /// Flatten a segment slice into the single string the LLM receives
/// as a user message. Pure — no I/O, no alerts. Callers that need /// as a user message. Pure — no I/O, no alerts. Callers that need
/// to surface user-visible alerts for unresolved refs should do so /// to surface user-visible alerts for unresolved refs should do so
/// alongside this call (Pod does so at submit time). /// alongside this call (Worker does so at submit time).
/// ///
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`) /// Sigil-prefixed variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`, /// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
@ -258,7 +261,7 @@ impl Segment {
impl Method { impl Method {
/// Convenience: a `Run` carrying a single `Segment::Text`. /// Convenience: a `Run` carrying a single `Segment::Text`.
/// Used by dumb clients, inter-Pod tools, and tests that only have /// Used by dumb clients, inter-Worker tools, and tests that only have
/// a string to forward. /// a string to forward.
pub fn run_text(s: impl Into<String>) -> Self { pub fn run_text(s: impl Into<String>) -> Self {
Self::Run { Self::Run {
@ -268,7 +271,7 @@ impl Method {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Event (Pod → Client via Unix Socket broadcast) // Event (Worker → Client via Unix Socket broadcast)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -291,8 +294,8 @@ pub enum Event {
/// One agent-injected system item committed to history. /// One agent-injected system item committed to history.
/// ///
/// Carries the JSON form of `session_store::SystemItem`. Covers /// Carries the JSON form of `session_store::SystemItem`. Covers
/// `Method::Notify` echoes, child-Pod lifecycle events from /// `Method::Notify` echoes, child-Worker lifecycle events from
/// `Method::PodEvent`, `@<path>` / `#<slug>` / `/<slug>` /// `Method::WorkerEvent`, `@<path>` / `#<slug>` / `/<slug>`
/// resolution payloads, and any future agent-side injection kind. /// resolution payloads, and any future agent-side injection kind.
/// Clients dispatch on the `kind` tag for typed rendering instead /// Clients dispatch on the `kind` tag for typed rendering instead
/// of parsing free-text prefixes like `[Notification] …` or /// of parsing free-text prefixes like `[Notification] …` or
@ -309,13 +312,13 @@ pub enum Event {
/// Marker event for the start of an Invoke range; the range extends /// Marker event for the start of an Invoke range; the range extends
/// implicitly until the next `InvokeStart`. Fires for every accepted /// implicitly until the next `InvokeStart`. Fires for every accepted
/// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`), /// `Method::Run` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),
/// `Method::PodEvent` re-injection (kind=`PodEvent`), and any other /// `Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other
/// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output, /// IDLE-breaking trigger. Mid-run interrupts (e.g. hook output,
/// `<system-reminder>` injection that doesn't break IDLE) do not /// `<system-reminder>` injection that doesn't break IDLE) do not
/// emit `InvokeStart` — they appear as `SystemItem` only. /// emit `InvokeStart` — they appear as `SystemItem` only.
/// ///
/// Carries `kind` only; the payload (user text / notify message / /// Carries `kind` only; the payload (user text / notify message /
/// pod event body) is delivered separately via the immediately /// worker event body) is delivered separately via the immediately
/// following `UserMessage` / `SystemItem` event. /// following `UserMessage` / `SystemItem` event.
InvokeStart { InvokeStart {
kind: InvokeKind, kind: InvokeKind,
@ -453,7 +456,7 @@ pub enum Event {
/// derived view. /// derived view.
/// ///
/// `greeting` and `status` accompany the snapshot so clients render /// `greeting` and `status` accompany the snapshot so clients render
/// pod identity and current controller state without an extra round /// worker identity and current controller state without an extra round
/// trip. /// trip.
/// ///
/// Live updates after the snapshot arrive through the streaming /// Live updates after the snapshot arrive through the streaming
@ -465,7 +468,7 @@ pub enum Event {
entries: Vec<serde_json::Value>, entries: Vec<serde_json::Value>,
greeting: Greeting, greeting: Greeting,
#[serde(default)] #[serde(default)]
status: PodStatus, status: WorkerStatus,
/// Unfinished model output that has already streamed in the current /// Unfinished model output that has already streamed in the current
/// run but is not yet represented by committed snapshot entries. /// run but is not yet represented by committed snapshot entries.
#[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")] #[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")]
@ -483,10 +486,10 @@ pub enum Event {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))] #[cfg_attr(feature = "typescript", ts(type = "unknown"))]
entry: serde_json::Value, entry: serde_json::Value,
}, },
/// Current Pod controller status. Broadcast on every controller-level /// Current Worker controller status. Broadcast on every controller-level
/// transition and included in `History` snapshots for late attach. /// transition and included in `History` snapshots for late attach.
Status { Status {
status: PodStatus, status: WorkerStatus,
}, },
/// Reply to `Method::ListCompletions`. Delivered only to the /// Reply to `Method::ListCompletions`. Delivered only to the
/// requesting socket (not broadcast). `entries` is empty when no /// requesting socket (not broadcast). `entries` is empty when no
@ -510,15 +513,15 @@ pub enum Event {
input: Vec<Segment>, input: Vec<Segment>,
summary: RewindSummary, summary: RewindSummary,
}, },
/// Reply to `Method::ListPods`. Payload is a stable JSON value so the Pod /// Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker
/// crate can evolve discovery fields without introducing a protocol /// crate can evolve discovery fields without introducing a protocol
/// dependency on session-store. /// dependency on session-store.
PodsListed { WorkersListed {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))] #[cfg_attr(feature = "typescript", ts(type = "unknown"))]
pods: serde_json::Value, workers: serde_json::Value,
}, },
/// Reply to `Method::RestorePod`. /// Reply to `Method::RestoreWorker`.
PodRestored { WorkerRestored {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))] #[cfg_attr(feature = "typescript", ts(type = "unknown"))]
result: serde_json::Value, result: serde_json::Value,
}, },
@ -533,7 +536,7 @@ pub enum Event {
/// This is not part of LLM history or prompt context; clients may display it /// This is not part of LLM history or prompt context; clients may display it
/// briefly as operational status. /// briefly as operational status.
MemoryWorker(MemoryWorkerEvent), MemoryWorker(MemoryWorkerEvent),
/// Pod has started compacting the current session. /// Worker has started compacting the current session.
/// ///
/// Fired immediately before a compaction run. Success is signalled by /// Fired immediately before a compaction run. Success is signalled by
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`. /// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
@ -554,10 +557,10 @@ pub enum Event {
Shutdown, Shutdown,
} }
/// User-facing alert emitted from the Pod layer. /// User-facing alert emitted from the Worker layer.
/// ///
/// This is a separate channel from `tracing` (developer logs): entries /// This is a separate channel from `tracing` (developer logs): entries
/// here are assembled explicitly by the Pod when a condition should be /// here are assembled explicitly by the Worker when a condition should be
/// surfaced to the person driving the client. Keep messages short and /// surfaced to the person driving the client. Keep messages short and
/// human-readable. /// human-readable.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -596,7 +599,7 @@ pub enum AlertLevel {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AlertSource { pub enum AlertSource {
Pod, Worker,
Engine, Engine,
Compactor, Compactor,
AgentsMd, AgentsMd,
@ -668,7 +671,7 @@ pub struct RewindSummary {
/// attach while an LLM response is still streaming. /// attach while an LLM response is still streaming.
/// ///
/// These blocks are presentation state only: they are reconstructed from the /// These blocks are presentation state only: they are reconstructed from the
/// active Pod controller and must not be treated as committed assistant /// active Worker controller and must not be treated as committed assistant
/// history. Finalized assistant items continue to come from ordinary snapshot /// history. Finalized assistant items continue to come from ordinary snapshot
/// entries. /// entries.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -723,21 +726,21 @@ impl InFlightToolCallState {
} }
} }
/// Pod self-description rendered by the TUI when a session starts empty. /// Worker self-description rendered by the TUI when a session starts empty.
/// ///
/// Built once in the Pod controller from the resolved manifest and /// Built once in the Worker controller from the resolved manifest and
/// transmitted alongside `Event::Snapshot` so clients don't need /// transmitted alongside `Event::Snapshot` so clients don't need
/// their own view of the manifest. /// their own view of the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct Greeting { pub struct Greeting {
pub pod_name: String, pub worker_name: String,
pub cwd: String, pub cwd: String,
pub provider: String, pub provider: String,
pub model: String, pub model: String,
pub scope_summary: String, pub scope_summary: String,
pub tools: Vec<String>, pub tools: Vec<String>,
/// Model context window in tokens. Always filled by the Pod greeting. /// Model context window in tokens. Always filled by the Worker greeting.
#[serde(default)] #[serde(default)]
pub context_window: u64, pub context_window: u64,
/// Estimated current session context tokens at connect time. /// Estimated current session context tokens at connect time.
@ -752,7 +755,7 @@ pub struct Greeting {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PodStatus { pub enum WorkerStatus {
#[default] #[default]
Idle, Idle,
Running, Running,
@ -771,7 +774,7 @@ pub enum TurnResult {
/// ///
/// One Invoke groups all entries from this trigger up to the next /// One Invoke groups all entries from this trigger up to the next
/// `Invoke` marker. The kind is the only payload — content (user text, /// `Invoke` marker. The kind is the only payload — content (user text,
/// notify message, pod event body) is delivered by the immediately /// notify message, worker event body) is delivered by the immediately
/// following Turn entry, not by the marker itself. /// following Turn entry, not by the marker itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@ -781,8 +784,8 @@ pub enum InvokeKind {
UserSend, UserSend,
/// `Method::Notify` — free-text notification injected into history. /// `Method::Notify` — free-text notification injected into history.
Notify, Notify,
/// `Method::PodEvent` — typed lifecycle report from a child Pod. /// `Method::WorkerEvent` — typed lifecycle report from a child Worker.
PodEvent, WorkerEvent,
/// `<system-reminder>` etc. that crosses an IDLE boundary (mid-run /// `<system-reminder>` etc. that crosses an IDLE boundary (mid-run
/// reminders that don't break IDLE are SystemItem-only and do not /// reminders that don't break IDLE are SystemItem-only and do not
/// open a new Invoke). /// open a new Invoke).
@ -801,8 +804,8 @@ pub enum RunResult {
Paused, Paused,
LimitReached, LimitReached,
/// The accepted Method::Run produced no assistant/tool output before /// The accepted Method::Run produced no assistant/tool output before
/// user interruption, so the Pod rolled the submit-time turn state back /// user interruption, so the Worker rolled the submit-time turn state back
/// to its pre-submit snapshot. Clients should treat the Pod as Idle and /// to its pre-submit snapshot. Clients should treat the Worker as Idle and
/// restore the just-submitted input into the editable composer if desired. /// restore the just-submitted input into the editable composer if desired.
RolledBack, RolledBack,
} }
@ -824,7 +827,7 @@ pub enum ErrorCode {
// Scope rule / permission (wire type) // Scope rule / permission (wire type)
// //
// Defined here so that both `manifest` (config parsing) and `protocol` // Defined here so that both `manifest` (config parsing) and `protocol`
// itself (inter-pod messaging such as `PodEvent::ScopeSubDelegated`) can // itself (inter-worker messaging such as `WorkerEvent::ScopeSubDelegated`) can
// reference the same type without introducing a reverse dependency. // reference the same type without introducing a reverse dependency.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -925,9 +928,9 @@ mod tests {
#[test] #[test]
fn segment_unknown_variant_decodes_as_unknown() { fn segment_unknown_variant_decodes_as_unknown() {
// A future client sends a segment kind this Pod has never heard of. // A future client sends a segment kind this Worker has never heard of.
// Forward compat requirement: deserialization must succeed and the // Forward compat requirement: deserialization must succeed and the
// unknown payload must surface as `Segment::Unknown` so the Pod // unknown payload must surface as `Segment::Unknown` so the Worker
// fallback path (placeholder + alert) can fire. // fallback path (placeholder + alert) can fire.
let json = r#"{"kind":"image_ref","url":"https://example.com/x.png"}"#; let json = r#"{"kind":"image_ref","url":"https://example.com/x.png"}"#;
let seg: Segment = serde_json::from_str(json).unwrap(); let seg: Segment = serde_json::from_str(json).unwrap();
@ -1028,7 +1031,7 @@ mod tests {
for kind in [ for kind in [
InvokeKind::UserSend, InvokeKind::UserSend,
InvokeKind::Notify, InvokeKind::Notify,
InvokeKind::PodEvent, InvokeKind::WorkerEvent,
InvokeKind::SystemReminder, InvokeKind::SystemReminder,
InvokeKind::Wakeup, InvokeKind::Wakeup,
] { ] {
@ -1219,7 +1222,7 @@ mod tests {
let event = Event::Snapshot { let event = Event::Snapshot {
entries: vec![serde_json::json!({"kind": "user_input", "ts": 1, "segments": []})], entries: vec![serde_json::json!({"kind": "user_input", "ts": 1, "segments": []})],
greeting: Greeting { greeting: Greeting {
pod_name: "test".into(), worker_name: "test".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
provider: "anthropic".into(), provider: "anthropic".into(),
model: "claude".into(), model: "claude".into(),
@ -1228,7 +1231,7 @@ mod tests {
context_window: 200_000, context_window: 200_000,
context_tokens: 42_000, context_tokens: 42_000,
}, },
status: PodStatus::Paused, status: WorkerStatus::Paused,
in_flight: InFlightSnapshot::default(), in_flight: InFlightSnapshot::default(),
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
@ -1236,7 +1239,7 @@ mod tests {
assert_eq!(parsed["event"], "snapshot"); assert_eq!(parsed["event"], "snapshot");
assert!(parsed["data"]["entries"].is_array()); assert!(parsed["data"]["entries"].is_array());
assert_eq!(parsed["data"]["entries"][0]["kind"], "user_input"); assert_eq!(parsed["data"]["entries"][0]["kind"], "user_input");
assert_eq!(parsed["data"]["greeting"]["pod_name"], "test"); assert_eq!(parsed["data"]["greeting"]["worker_name"], "test");
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read"); assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000); assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000); assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
@ -1245,7 +1248,7 @@ mod tests {
#[test] #[test]
fn event_snapshot_in_flight_roundtrip_and_default() { fn event_snapshot_in_flight_roundtrip_and_default() {
let inbound = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"pod_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#; let inbound = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap(); let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded { match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()), Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
@ -1255,7 +1258,7 @@ mod tests {
let event = Event::Snapshot { let event = Event::Snapshot {
entries: Vec::new(), entries: Vec::new(),
greeting: Greeting { greeting: Greeting {
pod_name: "test".into(), worker_name: "test".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
provider: "p".into(), provider: "p".into(),
model: "m".into(), model: "m".into(),
@ -1264,7 +1267,7 @@ mod tests {
context_window: 0, context_window: 0,
context_tokens: 0, context_tokens: 0,
}, },
status: PodStatus::Running, status: WorkerStatus::Running,
in_flight: InFlightSnapshot { in_flight: InFlightSnapshot {
blocks: vec![ blocks: vec![
InFlightBlock::Text { InFlightBlock::Text {
@ -1334,7 +1337,7 @@ mod tests {
#[test] #[test]
fn event_status_format() { fn event_status_format() {
let event = Event::Status { let event = Event::Status {
status: PodStatus::Running, status: WorkerStatus::Running,
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
@ -1345,20 +1348,20 @@ mod tests {
assert!(matches!( assert!(matches!(
decoded, decoded,
Event::Status { Event::Status {
status: PodStatus::Running status: WorkerStatus::Running
} }
)); ));
} }
#[test] #[test]
fn event_snapshot_legacy_without_status_defaults_to_idle() { fn event_snapshot_legacy_without_status_defaults_to_idle() {
let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"pod_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#; let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
let decoded: Event = serde_json::from_str(json).unwrap(); let decoded: Event = serde_json::from_str(json).unwrap();
match decoded { match decoded {
Event::Snapshot { Event::Snapshot {
status, greeting, .. status, greeting, ..
} => { } => {
assert_eq!(status, PodStatus::Idle); assert_eq!(status, WorkerStatus::Idle);
assert_eq!(greeting.context_window, 0); assert_eq!(greeting.context_window, 0);
assert_eq!(greeting.context_tokens, 0); assert_eq!(greeting.context_tokens, 0);
} }
@ -1367,34 +1370,37 @@ mod tests {
} }
#[test] #[test]
fn method_pod_event_turn_ended_roundtrip() { fn method_worker_event_turn_ended_roundtrip() {
let method = Method::PodEvent(PodEvent::TurnEnded { let method = Method::WorkerEvent(WorkerEvent::TurnEnded {
pod_name: "child".into(), worker_name: "child".into(),
}); });
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["method"], "pod_event"); assert_eq!(parsed["method"], "worker_event");
assert_eq!(parsed["params"]["kind"], "turn_ended"); assert_eq!(parsed["params"]["kind"], "turn_ended");
assert_eq!(parsed["params"]["pod_name"], "child"); assert_eq!(parsed["params"]["worker_name"], "child");
let decoded: Method = serde_json::from_str(&json).unwrap(); let decoded: Method = serde_json::from_str(&json).unwrap();
assert!(matches!( assert!(matches!(
decoded, decoded,
Method::PodEvent(PodEvent::TurnEnded { ref pod_name }) if pod_name == "child" Method::WorkerEvent(WorkerEvent::TurnEnded { ref worker_name }) if worker_name == "child"
)); ));
} }
#[test] #[test]
fn method_pod_event_errored_roundtrip() { fn method_worker_event_errored_roundtrip() {
let method = Method::PodEvent(PodEvent::Errored { let method = Method::WorkerEvent(WorkerEvent::Errored {
pod_name: "child".into(), worker_name: "child".into(),
message: "provider 429".into(), message: "provider 429".into(),
}); });
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap(); let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Method::PodEvent(PodEvent::Errored { pod_name, message }) => { Method::WorkerEvent(WorkerEvent::Errored {
assert_eq!(pod_name, "child"); worker_name,
message,
}) => {
assert_eq!(worker_name, "child");
assert_eq!(message, "provider 429"); assert_eq!(message, "provider 429");
} }
other => panic!("expected Errored, got {other:?}"), other => panic!("expected Errored, got {other:?}"),
@ -1402,43 +1408,43 @@ mod tests {
} }
#[test] #[test]
fn method_pod_event_shutdown_roundtrip() { fn method_worker_event_shutdown_roundtrip() {
let method = Method::PodEvent(PodEvent::ShutDown { let method = Method::WorkerEvent(WorkerEvent::ShutDown {
pod_name: "child".into(), worker_name: "child".into(),
}); });
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap(); let decoded: Method = serde_json::from_str(&json).unwrap();
assert!(matches!( assert!(matches!(
decoded, decoded,
Method::PodEvent(PodEvent::ShutDown { ref pod_name }) if pod_name == "child" Method::WorkerEvent(WorkerEvent::ShutDown { ref worker_name }) if worker_name == "child"
)); ));
} }
#[test] #[test]
fn pod_event_agent_notification_classification() { fn worker_event_agent_notification_classification() {
assert!( assert!(
PodEvent::TurnEnded { WorkerEvent::TurnEnded {
pod_name: "child".into() worker_name: "child".into()
} }
.should_notify_agent() .should_notify_agent()
); );
assert!( assert!(
PodEvent::Errored { WorkerEvent::Errored {
pod_name: "child".into(), worker_name: "child".into(),
message: "boom".into() message: "boom".into()
} }
.should_notify_agent() .should_notify_agent()
); );
assert!( assert!(
PodEvent::ShutDown { WorkerEvent::ShutDown {
pod_name: "child".into() worker_name: "child".into()
} }
.should_notify_agent() .should_notify_agent()
); );
assert!( assert!(
!PodEvent::ScopeSubDelegated { !WorkerEvent::ScopeSubDelegated {
parent_pod: "child".into(), parent_worker: "child".into(),
sub_pod: "grandchild".into(), sub_worker: "grandchild".into(),
sub_socket: "/tmp/grandchild.sock".into(), sub_socket: "/tmp/grandchild.sock".into(),
scope: vec![], scope: vec![],
} }
@ -1447,10 +1453,10 @@ mod tests {
} }
#[test] #[test]
fn method_pod_event_scope_sub_delegated_roundtrip() { fn method_worker_event_scope_sub_delegated_roundtrip() {
let method = Method::PodEvent(PodEvent::ScopeSubDelegated { let method = Method::WorkerEvent(WorkerEvent::ScopeSubDelegated {
parent_pod: "child".into(), parent_worker: "child".into(),
sub_pod: "grandchild".into(), sub_worker: "grandchild".into(),
sub_socket: "/run/yoi/grandchild/sock".into(), sub_socket: "/run/yoi/grandchild/sock".into(),
scope: vec![ScopeRule { scope: vec![ScopeRule {
target: "/tmp/work".into(), target: "/tmp/work".into(),
@ -1461,14 +1467,14 @@ mod tests {
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap(); let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Method::PodEvent(PodEvent::ScopeSubDelegated { Method::WorkerEvent(WorkerEvent::ScopeSubDelegated {
parent_pod, parent_worker,
sub_pod, sub_worker,
sub_socket, sub_socket,
scope, scope,
}) => { }) => {
assert_eq!(parent_pod, "child"); assert_eq!(parent_worker, "child");
assert_eq!(sub_pod, "grandchild"); assert_eq!(sub_worker, "grandchild");
assert_eq!(sub_socket, PathBuf::from("/run/yoi/grandchild/sock")); assert_eq!(sub_socket, PathBuf::from("/run/yoi/grandchild/sock"));
assert_eq!(scope.len(), 1); assert_eq!(scope.len(), 1);
assert_eq!(scope[0].target, PathBuf::from("/tmp/work")); assert_eq!(scope[0].target, PathBuf::from("/tmp/work"));
@ -1619,7 +1625,7 @@ mod tests {
fn event_error_format() { fn event_error_format() {
let event = Event::Error { let event = Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Pod is already executing a turn".into(), message: "Worker is already executing a turn".into(),
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
@ -1652,10 +1658,10 @@ mod tests {
} }
#[test] #[test]
fn pod_discovery_methods_roundtrip() { fn worker_discovery_methods_roundtrip() {
let methods = [ let methods = [
Method::ListPods, Method::ListWorkers,
Method::RestorePod { Method::RestoreWorker {
name: "child".into(), name: "child".into(),
}, },
Method::RegisterPeer { Method::RegisterPeer {
@ -1666,8 +1672,8 @@ mod tests {
let json = serde_json::to_string(&method).unwrap(); let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap(); let decoded: Method = serde_json::from_str(&json).unwrap();
match (decoded, method) { match (decoded, method) {
(Method::ListPods, Method::ListPods) (Method::ListWorkers, Method::ListWorkers)
| (Method::RestorePod { .. }, Method::RestorePod { .. }) | (Method::RestoreWorker { .. }, Method::RestoreWorker { .. })
| (Method::RegisterPeer { .. }, Method::RegisterPeer { .. }) => {} | (Method::RegisterPeer { .. }, Method::RegisterPeer { .. }) => {}
(decoded, expected) => panic!("decoded {decoded:?}, expected {expected:?}"), (decoded, expected) => panic!("decoded {decoded:?}, expected {expected:?}"),
} }
@ -1675,12 +1681,12 @@ mod tests {
} }
#[test] #[test]
fn pod_discovery_events_roundtrip() { fn worker_discovery_events_roundtrip() {
let events = [ let events = [
Event::PodsListed { Event::WorkersListed {
pods: serde_json::json!([{ "pod_name": "child" }]), workers: serde_json::json!([{ "worker_name": "child" }]),
}, },
Event::PodRestored { Event::WorkerRestored {
result: serde_json::json!({ "action": "already_live" }), result: serde_json::json!({ "action": "already_live" }),
}, },
Event::PeerRegistered { Event::PeerRegistered {
@ -1691,10 +1697,10 @@ mod tests {
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
match (decoded, event) { match (decoded, event) {
(Event::PodsListed { pods }, Event::PodsListed { pods: expected }) => { (Event::WorkersListed { workers }, Event::WorkersListed { workers: expected }) => {
assert_eq!(pods, expected) assert_eq!(workers, expected)
} }
(Event::PodRestored { result }, Event::PodRestored { result: expected }) => { (Event::WorkerRestored { result }, Event::WorkerRestored { result: expected }) => {
assert_eq!(result, expected) assert_eq!(result, expected)
} }
(Event::PeerRegistered { result }, Event::PeerRegistered { result: expected }) => { (Event::PeerRegistered { result }, Event::PeerRegistered { result: expected }) => {

View File

@ -5,8 +5,8 @@ use ts_rs::{Config, TS};
use crate::{ use crate::{
Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting,
InFlightBlock, InFlightSnapshot, InFlightToolCallState, InvokeKind, MemoryWorkerEvent, Method, InFlightBlock, InFlightSnapshot, InFlightToolCallState, InvokeKind, MemoryWorkerEvent, Method,
Permission, PodEvent, PodStatus, RewindSummary, RewindTarget, RewindTargetId, RunResult, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment,
ScopeRule, Segment, TurnResult, TurnResult, WorkerEvent, WorkerStatus,
}; };
const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts"; const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts";
@ -16,7 +16,7 @@ pub fn generated_typescript_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_RELATIVE_PATH) PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GENERATED_RELATIVE_PATH)
} }
/// Render Workspace web TypeScript bindings for the Pod wire protocol DTOs. /// Render Workspace web TypeScript bindings for the Worker wire protocol DTOs.
/// ///
/// Rust DTOs in this crate remain the source of truth; this function is used by /// Rust DTOs in this crate remain the source of truth; this function is used by
/// both the checked-in artifact generator and the stale-output drift test. /// both the checked-in artifact generator and the stale-output drift test.
@ -31,7 +31,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<AlertLevel>(&cfg, &mut output); push_decl::<AlertLevel>(&cfg, &mut output);
push_decl::<AlertSource>(&cfg, &mut output); push_decl::<AlertSource>(&cfg, &mut output);
push_decl::<CompletionKind>(&cfg, &mut output); push_decl::<CompletionKind>(&cfg, &mut output);
push_decl::<PodStatus>(&cfg, &mut output); push_decl::<WorkerStatus>(&cfg, &mut output);
push_decl::<TurnResult>(&cfg, &mut output); push_decl::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output); push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output); push_decl::<RunResult>(&cfg, &mut output);
@ -49,7 +49,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<Alert>(&cfg, &mut output); push_decl::<Alert>(&cfg, &mut output);
push_decl::<MemoryWorkerEvent>(&cfg, &mut output); push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
push_decl::<Segment>(&cfg, &mut output); push_decl::<Segment>(&cfg, &mut output);
push_decl::<PodEvent>(&cfg, &mut output); push_decl::<WorkerEvent>(&cfg, &mut output);
push_decl::<Method>(&cfg, &mut output); push_decl::<Method>(&cfg, &mut output);
push_decl::<Event>(&cfg, &mut output); push_decl::<Event>(&cfg, &mut output);

View File

@ -18,7 +18,7 @@ Does not own:
- Engine turn lifecycle (`llm-engine`) - Engine turn lifecycle (`llm-engine`)
- secret storage internals (`secrets`) - secret storage internals (`secrets`)
- Pod lifecycle (`pod`) - Worker lifecycle (`worker`)
- product CLI parsing (`yoi`) - product CLI parsing (`yoi`)
## Design notes ## Design notes

View File

@ -1,4 +1,4 @@
//! Pod マニフェストの [`ModelManifest`] を [`Box<dyn LlmClient>`] //! Worker マニフェストの [`ModelManifest`] を [`Box<dyn LlmClient>`]
//! に落とすファクトリ。 //! に落とすファクトリ。
//! //!
//! 段階: //! 段階:

View File

@ -1,7 +1,7 @@
//! Read-only analytics for Yoi session JSONL logs. //! Read-only analytics for Yoi session JSONL logs.
//! //!
//! This crate intentionally parses the persisted JSON shape tolerantly with //! This crate intentionally parses the persisted JSON shape tolerantly with
//! `serde_json::Value` rather than depending on Pod runtime or TUI crates. The //! `serde_json::Value` rather than depending on Worker runtime or TUI crates. The
//! report contains counts, paths, sizes, line/turn indexes, and bounded //! report contains counts, paths, sizes, line/turn indexes, and bounded
//! diagnostics; raw user messages, tool arguments, and tool output snippets are //! diagnostics; raw user messages, tool arguments, and tool output snippets are
//! not emitted. //! not emitted.
@ -1592,12 +1592,17 @@ fn line_count(value: &str) -> usize {
} }
fn tool_kind(name: &str) -> &'static str { fn tool_kind(name: &str) -> &'static str {
const LEGACY_SEND_TO_PEER_POD_TOOL: &str = "SendToPeerPod";
match name { match name {
"Read" | "Write" | "Edit" | "Glob" | "Grep" => "filesystem", "Read" | "Write" | "Edit" | "Glob" | "Grep" => "filesystem",
"Bash" => "shell", "Bash" => "shell",
"WebFetch" | "WebSearch" => "web", "WebFetch" | "WebSearch" => "web",
"SpawnPod" | "SendToPod" | "ReadPodOutput" | "ListPods" | "StopPod" | "RestorePod" "SpawnWorker" | "SendToWorker" | "SendToPeerWorker" | "ReadWorkerOutput"
| "SendToPeerPod" => "pod", | "ListWorkers" | "StopWorker" | "RestoreWorker" => "worker",
// Legacy session logs used the pre-rename peer tool name; keep analytics classification only.
/* legacy session-log tool name only */
LEGACY_SEND_TO_PEER_POD_TOOL => "worker",
name if name.starts_with("Memory") || name.starts_with("Knowledge") => "memory", name if name.starts_with("Memory") || name.starts_with("Knowledge") => "memory",
name if name.starts_with("Ticket") => "ticket", name if name.starts_with("Ticket") => "ticket",
name if name.starts_with("Task") => "task", name if name.starts_with("Task") => "task",

View File

@ -15,18 +15,18 @@ Owns:
Does not own: Does not own:
- current Pod-name metadata (`pod-store`) - current Worker-name metadata (`pod-store`)
- live process/socket discovery (`pod-registry`, `client`) - live process/socket discovery (`pod-registry`, `client`)
- UI state (`tui`) - UI state (`tui`)
- generated memory summaries (`memory`) - generated memory summaries (`memory`)
## Design notes ## Design notes
A session log records what happened. It is not the current Pod registry and should not be queried as the only source of "what does Pod X mean now?" A session log records what happened. It is not the current Worker registry and should not be queried as the only source of "what does Worker X mean now?"
Prefer explicit current log variants over broad legacy compatibility when schema changes; hidden compatibility can make future replay bugs silent. Prefer explicit current log variants over broad legacy compatibility when schema changes; hidden compatibility can make future replay bugs silent.
## See also ## See also
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md) - [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md) - [`../../docs/design/context-history.md`](../../docs/design/context-history.md)

View File

@ -11,7 +11,7 @@
//! the same Session. //! the same Session.
//! //!
//! This crate provides free functions for persistence operations. //! This crate provides free functions for persistence operations.
//! The caller (typically Pod) holds the Engine directly and calls these //! The caller (typically Worker) holds the Engine directly and calls these
//! functions after state-mutating operations. //! functions after state-mutating operations.
//! //!
//! Debug-mode [`TraceEntry`] records capture raw stream events in a separate //! Debug-mode [`TraceEntry`] records capture raw stream events in a separate
@ -51,7 +51,7 @@ pub use segment::{
}; };
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, collect_state}; pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, collect_state};
pub use store::{Store, StoreError}; pub use store::{Store, StoreError};
pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_pod_event}; pub use system_item::{SystemItem, SystemReminder, SystemReminderSource, render_worker_event};
/// Session identifier — the fork-tree root. UUID v7 (time-ordered). /// Session identifier — the fork-tree root. UUID v7 (time-ordered).
/// ///

View File

@ -1,7 +1,7 @@
//! Free functions for segment persistence operations. //! Free functions for segment persistence operations.
//! //!
//! These functions record and restore segment state without owning a Engine. //! These functions record and restore segment state without owning a Engine.
//! The caller (typically Pod) holds the Engine directly and calls these //! The caller (typically Worker) holds the Engine directly and calls these
//! functions after state-mutating operations. //! functions after state-mutating operations.
use crate::logged_item::{LoggedItem, to_logged}; use crate::logged_item::{LoggedItem, to_logged};
@ -36,7 +36,7 @@ pub fn create_segment(
/// Write a fresh `SegmentStart` entry using pre-generated IDs. /// Write a fresh `SegmentStart` entry using pre-generated IDs.
/// ///
/// Used by callers that need to reserve `(session_id, segment_id)` /// Used by callers that need to reserve `(session_id, segment_id)`
/// synchronously but defer the initial log append (e.g. Pod, which /// synchronously but defer the initial log append (e.g. Worker, which
/// resolves a templated system prompt only at first turn). /// resolves a templated system prompt only at first turn).
pub fn create_segment_with_ids( pub fn create_segment_with_ids(
store: &impl Store, store: &impl Store,
@ -102,7 +102,7 @@ pub fn restore(
/// Restore segment state when only the segment ID is known. Uses /// Restore segment state when only the segment ID is known. Uses
/// [`Store::lookup_session_of`] to resolve the parent Session. /// [`Store::lookup_session_of`] to resolve the parent Session.
/// ///
/// Shim for legacy entry points (`pod-cli --session <UUID>` etc.) that /// Shim for legacy entry points (`worker-cli --session <UUID>` etc.) that
/// receive a Segment ID without a Session ID. /// receive a Segment ID without a Session ID.
pub fn restore_by_segment( pub fn restore_by_segment(
store: &impl Store, store: &impl Store,
@ -174,7 +174,7 @@ pub fn ensure_head_or_fork(
/// Log a `UserInput` entry from the original typed `Vec<Segment>`. /// Log a `UserInput` entry from the original typed `Vec<Segment>`.
/// ///
/// Submit-time entry. Pod calls this at the head of a `Run` turn before /// Submit-time entry. Worker calls this at the head of a `Run` turn before
/// the worker pushes its flattened user message into history; replay /// the worker pushes its flattened user message into history; replay
/// derives the worker `Item::user_message` from these segments via /// derives the worker `Item::user_message` from these segments via
/// [`Segment::flatten_to_text`]. /// [`Segment::flatten_to_text`].
@ -250,7 +250,7 @@ pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
} }
/// Append a single typed system item as `LogEntry::SystemItem`. Helper /// Append a single typed system item as `LogEntry::SystemItem`. Helper
/// for the Pod-side interceptor commit path; mirrors the per-item /// for the Worker-side interceptor commit path; mirrors the per-item
/// commit shape used for assistant / tool result entries. /// commit shape used for assistant / tool result entries.
pub fn append_system_item( pub fn append_system_item(
store: &impl Store, store: &impl Store,

View File

@ -58,10 +58,10 @@ pub enum LogEntry {
/// IDLE → active marker. Records the start of a new self-driving /// IDLE → active marker. Records the start of a new self-driving
/// cycle (Invoke range). The range extends implicitly until the /// cycle (Invoke range). The range extends implicitly until the
/// next `Invoke` entry; this entry carries the trigger only — the /// next `Invoke` entry; this entry carries the trigger only — the
/// actual payload (user text / notify message / pod event body) is /// actual payload (user text / notify message / worker event body) is
/// in the immediately following Turn entry (`UserInput` / `SystemItem`). /// in the immediately following Turn entry (`UserInput` / `SystemItem`).
/// ///
/// Used by `pod-session-fork` style operations: the fork-point seq /// Used by `worker-session-fork` style operations: the fork-point seq
/// (`at_turn_index` in persistence-semantics) points at one of these /// (`at_turn_index` in persistence-semantics) points at one of these
/// `Invoke` entries so "back to N-th send" maps cleanly to the /// `Invoke` entries so "back to N-th send" maps cleanly to the
/// IDLE-break boundary the user sees. /// IDLE-break boundary the user sees.
@ -87,7 +87,7 @@ pub enum LogEntry {
/// One tool-execution result appended to history. /// One tool-execution result appended to history.
ToolResult { ts: u64, item: LoggedItem }, ToolResult { ts: u64, item: LoggedItem },
/// One typed agent-injected system item: notification, child-Pod /// One typed agent-injected system item: notification, child-Worker
/// lifecycle event, `@<path>` / `#<slug>` / `/<slug>` resolution /// lifecycle event, `@<path>` / `#<slug>` / `/<slug>` resolution
/// payload. Each `SystemItem` carries kind metadata that the LLM /// payload. Each `SystemItem` carries kind metadata that the LLM
/// itself never sees (the LLM gets `Item::system_message` with the /// itself never sees (the LLM gets `Item::system_message` with the
@ -117,7 +117,7 @@ pub enum LogEntry {
/// A paused interrupted turn was explicitly abandoned without calling /// A paused interrupted turn was explicitly abandoned without calling
/// `run()` or `resume()` again. Replay clears the interrupted marker so /// `run()` or `resume()` again. Replay clears the interrupted marker so
/// the restored Pod is idle and future user input starts a normal new turn. /// the restored Worker is idle and future user input starts a normal new turn.
PausedTurnAbandoned { ts: u64 }, PausedTurnAbandoned { ts: u64 },
/// `RequestConfig` changed. /// `RequestConfig` changed.

View File

@ -8,8 +8,8 @@
//! `< 1 KiB` line on local fs and completes well below a millisecond. Going //! `< 1 KiB` line on local fs and completes well below a millisecond. Going
//! through `tokio::fs` would force every caller — including `Engine`'s sync //! through `tokio::fs` would force every caller — including `Engine`'s sync
//! `on_history_append` callback — to bridge sync → async via a channel + //! `on_history_append` callback — to bridge sync → async via a channel +
//! drain task. Keeping the store sync lets the worker callback, Pod commit //! drain task. Keeping the store sync lets the worker callback, Worker commit
//! paths, and `PodInterceptor` all share one direct `append_entry` call. //! paths, and `WorkerInterceptor` all share one direct `append_entry` call.
use crate::event_trace::TraceEntry; use crate::event_trace::TraceEntry;
use crate::segment_log::LogEntry; use crate::segment_log::LogEntry;
@ -81,7 +81,7 @@ pub trait Store: Send + Sync {
/// Truncate a segment log to `entries_len` entries. /// Truncate a segment log to `entries_len` entries.
/// ///
/// Used by Pod's submit-time empty-turn rollback after it has proven /// Used by Worker's submit-time empty-turn rollback after it has proven
/// that no LLM output from the accepted turn was materialized. The /// that no LLM output from the accepted turn was materialized. The
/// default implementation rewrites the retained prefix through /// default implementation rewrites the retained prefix through
/// `create_segment`, matching the append-only logical model while still /// `create_segment`, matching the append-only logical model while still

View File

@ -1,8 +1,8 @@
//! Typed system-message items injected by the agent system. //! Typed system-message items injected by the agent system.
//! //!
//! Items in worker history with `role:system` are never produced by the //! Items in worker history with `role:system` are never produced by the
//! LLM — they are always inserted by the Pod itself (notifications, //! LLM — they are always inserted by the Worker itself (notifications,
//! file/knowledge/workflow ref resolutions, child-pod lifecycle events, //! file/knowledge/workflow ref resolutions, child-worker lifecycle events,
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the //! future `<system-reminder>` tags, …). [`SystemItem`] carries the
//! typed shape of each such injection so clients can dispatch on //! typed shape of each such injection so clients can dispatch on
//! `kind` instead of parsing text prefixes like `[Notification] …` or //! `kind` instead of parsing text prefixes like `[Notification] …` or
@ -19,7 +19,7 @@
//! system-message text. //! system-message text.
use llm_engine::llm_client::types::Item; use llm_engine::llm_client::types::Item;
use protocol::PodEvent; use protocol::WorkerEvent;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const SYSTEM_REMINDER_OPEN: &str = "<system-reminder>"; const SYSTEM_REMINDER_OPEN: &str = "<system-reminder>";
@ -105,7 +105,7 @@ fn render_system_reminder(body: &str) -> String {
/// One agent-injected system item, tagged by origin. /// One agent-injected system item, tagged by origin.
/// ///
/// Each variant carries the kind-specific raw data clients use for /// Each variant carries the kind-specific raw data clients use for
/// typed rendering (`Notification.message`, `PodEvent.event`, file /// typed rendering (`Notification.message`, `WorkerEvent.event`, file
/// path / knowledge slug / workflow slug / etc.), plus a pre-rendered /// path / knowledge slug / workflow slug / etc.), plus a pre-rendered
/// `body` (where applicable) that is the exact `role:system` text the /// `body` (where applicable) that is the exact `role:system` text the
/// LLM actually saw at commit time. `body` is denormalised so that /// LLM actually saw at commit time. `body` is denormalised so that
@ -122,15 +122,15 @@ fn render_system_reminder(body: &str) -> String {
pub enum SystemItem { pub enum SystemItem {
/// Free-form notification sent in by an external caller via /// Free-form notification sent in by an external caller via
/// `Method::Notify`. `message` is the raw caller-supplied text; /// `Method::Notify`. `message` is the raw caller-supplied text;
/// `body` is the wrapped LLM-context form (Pod renders it via /// `body` is the wrapped LLM-context form (Worker renders it via
/// `notify_wrapper` at commit time). /// `notify_wrapper` at commit time).
Notification { message: String, body: String }, Notification { message: String, body: String },
/// Lifecycle event reported by a child Pod via `Method::PodEvent`. /// Lifecycle event reported by a child Worker via `Method::WorkerEvent`.
/// `event` is the typed payload (so the TUI can render per-child /// `event` is the typed payload (so the TUI can render per-child
/// banners without re-parsing); `body` is the wrapped LLM-context /// banners without re-parsing); `body` is the wrapped LLM-context
/// form (same `notify_wrapper` path as `Notification`). /// form (same `notify_wrapper` path as `Notification`).
PodEvent { event: PodEvent, body: String }, WorkerEvent { event: WorkerEvent, body: String },
/// `@<path>` file reference resolution. `body` is the rendered /// `@<path>` file reference resolution. `body` is the rendered
/// LLM-context text (`[File: <path>]\n…` for regular files, /// LLM-context text (`[File: <path>]\n…` for regular files,
@ -140,7 +140,7 @@ pub enum SystemItem {
FileAttachment { path: String, body: String }, FileAttachment { path: String, body: String },
/// `#<slug>` Knowledge reference resolution. `body` is the /// `#<slug>` Knowledge reference resolution. `body` is the
/// rendered text the LLM saw (Pod composes the `[Knowledge: …]` /// rendered text the LLM saw (Worker composes the `[Knowledge: …]`
/// header + body). /// header + body).
Knowledge { slug: String, body: String }, Knowledge { slug: String, body: String },
@ -169,7 +169,7 @@ impl SystemItem {
pub fn history_text(&self) -> String { pub fn history_text(&self) -> String {
match self { match self {
SystemItem::Notification { body, .. } => body.clone(), SystemItem::Notification { body, .. } => body.clone(),
SystemItem::PodEvent { body, .. } => body.clone(), SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(), SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::Knowledge { body, .. } => body.clone(), SystemItem::Knowledge { body, .. } => body.clone(),
SystemItem::Workflow { body, .. } => body.clone(), SystemItem::Workflow { body, .. } => body.clone(),
@ -189,7 +189,7 @@ impl SystemItem {
pub fn kind_label(&self) -> &'static str { pub fn kind_label(&self) -> &'static str {
match self { match self {
SystemItem::Notification { .. } => "notification", SystemItem::Notification { .. } => "notification",
SystemItem::PodEvent { .. } => "pod_event", SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FileAttachment { .. } => "file_attachment", SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::Knowledge { .. } => "knowledge", SystemItem::Knowledge { .. } => "knowledge",
SystemItem::Workflow { .. } => "workflow", SystemItem::Workflow { .. } => "workflow",
@ -199,22 +199,25 @@ impl SystemItem {
} }
} }
/// Render a `PodEvent` as the one-line notification text the agent /// Render a `WorkerEvent` as the one-line notification text the agent
/// sees. Centralised here (rather than at the controller's render /// sees. Centralised here (rather than at the controller's render
/// site) so persistence and broadcast share the same rendering. /// site) so persistence and broadcast share the same rendering.
pub fn render_pod_event(event: &PodEvent) -> String { pub fn render_worker_event(event: &WorkerEvent) -> String {
match event { match event {
PodEvent::TurnEnded { pod_name } => format!("pod `{pod_name}` finished a turn"), WorkerEvent::TurnEnded { worker_name } => format!("worker `{worker_name}` finished a turn"),
PodEvent::Errored { pod_name, message } => { WorkerEvent::Errored {
format!("pod `{pod_name}` errored: {message}") worker_name,
message,
} => {
format!("worker `{worker_name}` errored: {message}")
} }
PodEvent::ShutDown { pod_name } => format!("pod `{pod_name}` shut down"), WorkerEvent::ShutDown { worker_name } => format!("worker `{worker_name}` shut down"),
PodEvent::ScopeSubDelegated { WorkerEvent::ScopeSubDelegated {
parent_pod, parent_worker,
sub_pod, sub_worker,
.. ..
} => { } => {
format!("pod `{parent_pod}` sub-delegated scope to `{sub_pod}`") format!("worker `{parent_worker}` sub-delegated scope to `{sub_worker}`")
} }
} }
} }
@ -236,10 +239,10 @@ mod tests {
} }
#[test] #[test]
fn pod_event_history_text_returns_stored_body() { fn worker_event_history_text_returns_stored_body() {
let item = SystemItem::PodEvent { let item = SystemItem::WorkerEvent {
event: PodEvent::TurnEnded { event: WorkerEvent::TurnEnded {
pod_name: "child".into(), worker_name: "child".into(),
}, },
body: "[Notification]\npod `child` finished a turn\n\n(non-blocking hint…)".into(), body: "[Notification]\npod `child` finished a turn\n\n(non-blocking hint…)".into(),
}; };
@ -321,21 +324,21 @@ mod tests {
} }
#[test] #[test]
fn round_trip_pod_event() { fn round_trip_worker_event() {
let item = SystemItem::PodEvent { let item = SystemItem::WorkerEvent {
event: PodEvent::TurnEnded { event: WorkerEvent::TurnEnded {
pod_name: "child".into(), worker_name: "child".into(),
}, },
body: "[Notification] pod `child` finished a turn".into(), body: "[Notification] worker `child` finished a turn".into(),
}; };
let json = serde_json::to_string(&item).unwrap(); let json = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&json).unwrap(); let parsed: SystemItem = serde_json::from_str(&json).unwrap();
match parsed { match parsed {
SystemItem::PodEvent { SystemItem::WorkerEvent {
event: PodEvent::TurnEnded { pod_name }, event: WorkerEvent::TurnEnded { worker_name },
body, body,
} => { } => {
assert_eq!(pod_name, "child"); assert_eq!(worker_name, "child");
assert!(body.contains("`child`")); assert!(body.contains("`child`"));
} }
other => panic!("unexpected: {other:?}"), other => panic!("unexpected: {other:?}"),

View File

@ -103,7 +103,7 @@ async fn run_and_persist(
segment_id: session_store::SegmentId, segment_id: session_store::SegmentId,
input: &str, input: &str,
) -> (Engine<MockLlmClient>, llm_engine::EngineResult) { ) -> (Engine<MockLlmClient>, llm_engine::EngineResult) {
// Mirror Pod's run-entry contract: log the user input as segments // Mirror Worker's run-entry contract: log the user input as segments
// before the worker pushes its flattened user_message; save_delta // before the worker pushes its flattened user_message; save_delta
// skips the resulting user_message item to avoid double-write. // skips the resulting user_message item to avoid double-write.
session_store::save_user_input( session_store::save_user_input(
@ -450,7 +450,7 @@ async fn session_auto_forks_on_conflict() {
// Writer tracked: just the SegmentStart we wrote. // Writer tracked: just the SegmentStart we wrote.
let mut entries_written: usize = 1; let mut entries_written: usize = 1;
// Simulate another Pod writing to the same segment behind our back. // Simulate another Worker writing to the same segment behind our back.
let extra_entry = LogEntry::UserInput { let extra_entry = LogEntry::UserInput {
ts: 9999, ts: 9999,
segments: vec![protocol::Segment::text("Interloper")], segments: vec![protocol::Segment::text("Interloper")],

View File

@ -2,7 +2,7 @@
//! //!
//! The config file lives at `.yoi/ticket.config.toml` under a workspace root. //! The config file lives at `.yoi/ticket.config.toml` under a workspace root.
//! It intentionally stores lightweight string references for Profile selectors, //! It intentionally stores lightweight string references for Profile selectors,
//! launch prompts, and workflows so this crate remains independent from `pod` //! launch prompts, and workflows so this crate remains independent from `worker`
//! and `manifest` runtime resolution. //! and `manifest` runtime resolution.
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};

View File

@ -1,6 +1,6 @@
//! LLM tool implementations for typed Ticket backend operations. //! LLM tool implementations for typed Ticket backend operations.
//! //!
//! These tools are intentionally owned by the `ticket` crate so Pod features can //! These tools are intentionally owned by the `ticket` crate so Worker features can
//! install Ticket behavior without reimplementing domain/backend logic or //! install Ticket behavior without reimplementing domain/backend logic or
//! granting generic filesystem write authority. //! granting generic filesystem write authority.
@ -154,7 +154,7 @@ fn base_tool_description(name: &str) -> &'static str {
/// ///
/// `record_language` is the durable Ticket record/tool-body language, distinct from /// `record_language` is the durable Ticket record/tool-body language, distinct from
/// worker response language and Memory/Knowledge language. Keeping this on the tool /// worker response language and Memory/Knowledge language. Keeping this on the tool
/// surface ensures every Ticket-capable Pod sees the policy without hidden context /// surface ensures every Ticket-capable Worker sees the policy without hidden context
/// injection or role-launch-only prose. /// injection or role-launch-only prose.
pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> String { pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> String {
let mut description = base_tool_description(name).to_string(); let mut description = base_tool_description(name).to_string();
@ -1909,7 +1909,7 @@ mod tests {
&json!({ &json!({
"ticket": created.id.clone(), "ticket": created.id.clone(),
"intake_summary": "Requirements accepted; implementation can be queued.", "intake_summary": "Requirements accepted; implementation can be queued.",
"author": "intake-pod" "author": "intake-worker"
}) })
.to_string(), .to_string(),
Default::default(), Default::default(),

View File

@ -2,13 +2,13 @@
## Role ## Role
`tools` implements built-in tools and shared tool execution helpers used by Pods. `tools` implements built-in tools and shared tool execution helpers used by Workers.
## Boundaries ## Boundaries
Owns: Owns:
- built-in filesystem, web, memory, and Pod-management tool implementations where applicable - built-in filesystem, web, memory, and Worker-management tool implementations where applicable
- bounded tool output formatting - bounded tool output formatting
- scope-aware file operation helpers - scope-aware file operation helpers
- tool-facing diagnostics suitable for history/model consumption - tool-facing diagnostics suitable for history/model consumption
@ -17,7 +17,7 @@ Does not own:
- manifest permission policy definition (`manifest`) - manifest permission policy definition (`manifest`)
- Engine tool-loop semantics (`llm-engine`) - Engine tool-loop semantics (`llm-engine`)
- Pod lifecycle decisions (`pod`) - Worker lifecycle decisions (`worker`)
- UI presentation (`tui`) - UI presentation (`tui`)
## Design notes ## Design notes

View File

@ -17,7 +17,7 @@ pub enum ToolsError {
OutOfScope(PathBuf), OutOfScope(PathBuf),
#[error( #[error(
"path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Pod {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target", "path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Worker {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target",
.path.display(), .path.display(),
.target.display() .target.display()
)] )]

View File

@ -4,14 +4,14 @@
//! `llm-engine` `Tool` infrastructure. Filesystem access is mediated by //! `llm-engine` `Tool` infrastructure. Filesystem access is mediated by
//! two orthogonal concerns: //! two orthogonal concerns:
//! //!
//! - [`ScopedFs`] — Pod-process lifetime, expresses the write-block //! - [`ScopedFs`] — Worker-process lifetime, expresses the write-block
//! boundary for the current scope. Derived from the manifest; not //! boundary for the current scope. Derived from the manifest; not
//! persisted across Pod restart. //! persisted across Worker restart.
//! - [`Tracker`] — Pod-process lifetime, enforces the "read before edit" //! - [`Tracker`] — Worker-process lifetime, enforces the "read before edit"
//! policy via content hashes and tracks the recency of touched files. //! policy via content hashes and tracks the recency of touched files.
//! Recreated fresh on each Pod start (including resume). //! Recreated fresh on each Worker start (including resume).
//! //!
//! The Pod layer owns both instances and passes them to //! The Worker layer owns both instances and passes them to
//! [`core_builtin_tools`] when registering tools on a `Engine`. //! [`core_builtin_tools`] when registering tools on a `Engine`.
//! //!
//! `Bash` is the lone exception — its child processes bypass `ScopedFs` //! `Bash` is the lone exception — its child processes bypass `ScopedFs`
@ -41,13 +41,13 @@ pub use tracker::Tracker;
pub use web::{web_fetch_tool, web_search_tool}; pub use web::{web_fetch_tool, web_search_tool};
pub use write::write_tool; pub use write::write_tool;
/// Register core builtin tools that do not require Pod-local task state, /// Register core builtin tools that do not require Worker-local task state,
/// wiring them to a shared `ScopedFs` (Pod-process lifetime) and `Tracker` /// wiring them to a shared `ScopedFs` (Worker-process lifetime) and `Tracker`
/// (Pod-process lifetime). /// (Worker-process lifetime).
/// ///
/// All returned factories share the same tracker instance so that /// All returned factories share the same tracker instance so that
/// `Read` / `Write` / `Edit` see a consistent history across tool /// `Read` / `Write` / `Edit` see a consistent history across tool
/// invocations within a single Pod run. /// invocations within a single Worker run.
/// ///
/// `bash_output_dir` is where the Bash tool spills long outputs. The /// `bash_output_dir` is where the Bash tool spills long outputs. The
/// caller is responsible for adding that path to the readable scope /// caller is responsible for adding that path to the readable scope

View File

@ -1,7 +1,7 @@
//! Scope-aware filesystem primitive. //! Scope-aware filesystem primitive.
//! //!
//! `ScopedFs` is the write/read gate layered on top of a [`manifest::Scope`] //! `ScopedFs` is the write/read gate layered on top of a [`manifest::Scope`]
//! and a Pod's working directory. The scope decides which paths are //! and a Worker's working directory. The scope decides which paths are
//! readable and writable; the cwd is carried alongside for convenience //! readable and writable; the cwd is carried alongside for convenience
//! (Glob/Grep default their search base to it). //! (Glob/Grep default their search base to it).
//! //!
@ -27,7 +27,7 @@ struct ScopedFsInner {
/// ///
/// The wrapped [`SharedScope`] is shared with every clone of this /// The wrapped [`SharedScope`] is shared with every clone of this
/// `ScopedFs` and with whoever else holds the same `SharedScope` /// `ScopedFs` and with whoever else holds the same `SharedScope`
/// handle (typically the owning Pod). Mutations to that `SharedScope` /// handle (typically the owning Worker). Mutations to that `SharedScope`
/// propagate atomically; the next permission check inside any /// propagate atomically; the next permission check inside any
/// `ScopedFs` reads the new view. /// `ScopedFs` reads the new view.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -63,7 +63,7 @@ impl ScopedFs {
/// Create a new [`ScopedFs`] wrapping `scope` and `cwd` in a fresh /// Create a new [`ScopedFs`] wrapping `scope` and `cwd` in a fresh
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you /// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
/// need the resulting `ScopedFs` to share scope state with another /// need the resulting `ScopedFs` to share scope state with another
/// holder of the `SharedScope` (typically the Pod). /// holder of the `SharedScope` (typically the Worker).
pub fn new(scope: Scope, cwd: PathBuf) -> Self { pub fn new(scope: Scope, cwd: PathBuf) -> Self {
Self::with_shared_scope(SharedScope::new(scope), cwd) Self::with_shared_scope(SharedScope::new(scope), cwd)
} }
@ -85,13 +85,13 @@ impl ScopedFs {
} }
/// Shared scope handle backing this `ScopedFs`. Cloning it lets a /// Shared scope handle backing this `ScopedFs`. Cloning it lets a
/// caller (usually the Pod) hold the same view and push updates /// caller (usually the Worker) hold the same view and push updates
/// that are immediately reflected in subsequent permission checks. /// that are immediately reflected in subsequent permission checks.
pub fn shared_scope(&self) -> &SharedScope { pub fn shared_scope(&self) -> &SharedScope {
&self.inner.scope &self.inner.scope
} }
/// The Pod's working directory. Glob/Grep default their search base /// The Worker's working directory. Glob/Grep default their search base
/// to this path when callers omit an explicit `path` parameter. /// to this path when callers omit an explicit `path` parameter.
pub fn cwd(&self) -> &Path { pub fn cwd(&self) -> &Path {
&self.inner.cwd &self.inner.cwd

View File

@ -1,4 +1,4 @@
//! Pod-lifetime tracker for file operations performed by the builtin //! Worker-lifetime tracker for file operations performed by the builtin
//! file-manipulation tools. //! file-manipulation tools.
//! //!
//! A `Tracker` serves two orthogonal purposes: //! A `Tracker` serves two orthogonal purposes:
@ -9,7 +9,7 @@
//! verify that the file has not been externally modified since then. //! verify that the file has not been externally modified since then.
//! //!
//! 2. **Recency of touched files.** It keeps an LRU-ordered list of //! 2. **Recency of touched files.** It keeps an LRU-ordered list of
//! files that have been touched by any of the tools, so the Pod //! files that have been touched by any of the tools, so the Worker
//! layer can ask "which files did the agent recently look at?" — //! layer can ask "which files did the agent recently look at?" —
//! used e.g. as a default reference set passed to context compaction. //! used e.g. as a default reference set passed to context compaction.
//! //!
@ -18,12 +18,12 @@
//! //!
//! # Lifetime //! # Lifetime
//! //!
//! A `Tracker` is **Pod-process scoped**: the Pod layer creates a fresh //! A `Tracker` is **Worker-process scoped**: the Worker layer creates a fresh
//! instance at the start of each Pod run (including resume) and discards //! instance at the start of each Worker run (including resume) and discards
//! it when the process exits — it is not persisted, so a resumed //! it when the process exits — it is not persisted, so a resumed
//! conversation starts with an empty read/edit history. The `ScopedFs` //! conversation starts with an empty read/edit history. The `ScopedFs`
//! write boundary is likewise Pod-process scoped (derived from the //! write boundary is likewise Worker-process scoped (derived from the
//! manifest). The two are orthogonal and the Pod wires them together //! manifest). The two are orthogonal and the Worker wires them together
//! when registering builtin tools. //! when registering builtin tools.
//! //!
//! ```no_run //! ```no_run
@ -31,7 +31,7 @@
//! # use manifest::Scope; //! # use manifest::Scope;
//! # use tools::{ScopedFs, Tracker, core_builtin_tools}; //! # use tools::{ScopedFs, Tracker, core_builtin_tools};
//! let scope = Scope::writable("/workspace").unwrap(); //! let scope = Scope::writable("/workspace").unwrap();
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // pod lifetime //! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // worker lifetime
//! let tracker = Tracker::new(); // session lifetime //! let tracker = Tracker::new(); // session lifetime
//! let bash_outputs = PathBuf::from("/run/yoi/bash-output"); //! let bash_outputs = PathBuf::from("/run/yoi/bash-output");
//! let defs = core_builtin_tools(fs, tracker, bash_outputs, None); //! let defs = core_builtin_tools(fs, tracker, bash_outputs, None);
@ -204,7 +204,7 @@ impl Tracker {
/// Return up to `n` most recently touched file paths, most-recent first. /// Return up to `n` most recently touched file paths, most-recent first.
/// ///
/// Intended for callers like the Pod's context-compaction path, which /// Intended for callers like the Worker's context-compaction path, which
/// wants to know which files the agent has been working with so it /// wants to know which files the agent has been working with so it
/// can pass them as default references to the compaction worker. /// can pass them as default references to the compaction worker.
pub fn recent_files(&self, n: usize) -> Vec<PathBuf> { pub fn recent_files(&self, n: usize) -> Vec<PathBuf> {

View File

@ -2,7 +2,7 @@
## Role ## Role
`tui` implements terminal UI clients for the single-Pod Console and workspace Dashboard surfaces. `tui` implements terminal UI clients for the single-Worker Console and workspace Dashboard surfaces.
## Boundaries ## Boundaries
@ -10,21 +10,21 @@ Owns:
- terminal rendering and input handling - terminal rendering and input handling
- local composer state and UI affordances - local composer state and UI affordances
- single-Pod Console attach/restore/chat screens - single-Worker Console attach/restore/chat screens
- workspace Dashboard presentation and role-action UI - workspace Dashboard presentation and role-action UI
Does not own: Does not own:
- durable transcript authority (`session-store`) - durable transcript authority (`session-store`)
- Pod current state (`pod-store`) - Worker current state (`pod-store`)
- Pod lifecycle policy (`pod`) - Worker lifecycle policy (`worker`)
- product CLI ownership (`yoi`) - product CLI ownership (`yoi`)
## Design notes ## Design notes
The TUI should display committed events and Pod snapshots rather than inventing durable state. Local input history and optimistic UI affordances are editing conveniences; they must not become hidden model context. The TUI should display committed events and Worker snapshots rather than inventing durable state. Local input history and optimistic UI affordances are editing conveniences; they must not become hidden model context.
## See also ## See also
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md) - [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
- [`../../docs/design/pod-session-state.md`](../../docs/design/pod-session-state.md) - [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)

View File

@ -4,7 +4,8 @@ use std::time::{Duration, Instant};
use protocol::{ use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, Method, PodStatus, RewindTarget, RunResult, Segment, InFlightSnapshot, InFlightToolCallState, Method, RewindTarget, RunResult, Segment,
WorkerStatus,
}; };
use crate::block::{ use crate::block::{
@ -40,7 +41,7 @@ pub struct CompletionState {
pub prefix_start: usize, pub prefix_start: usize,
/// Text typed after the sigil (sigil itself excluded). /// Text typed after the sigil (sigil itself excluded).
pub prefix: String, pub prefix: String,
/// Latest candidate set returned by the Pod for `(kind, prefix)`. /// Latest candidate set returned by the Worker for `(kind, prefix)`.
/// Initially empty until `Event::Completions` lands. /// Initially empty until `Event::Completions` lands.
pub entries: Vec<CompletionEntry>, pub entries: Vec<CompletionEntry>,
pub selected: usize, pub selected: usize,
@ -71,7 +72,7 @@ pub struct RewindPickerState {
pub selected: usize, pub selected: usize,
pub scroll: RewindPickerScroll, pub scroll: RewindPickerScroll,
/// True after Enter submitted an authoritative `RewindTo` and before the /// True after Enter submitted an authoritative `RewindTo` and before the
/// Pod replies with either `RewindApplied` or `Error`. While set, the /// Worker replies with either `RewindApplied` or `Error`. While set, the
/// picker remains visible but further submits/navigation are ignored so a /// picker remains visible but further submits/navigation are ignored so a
/// destructive rewind cannot be queued multiple times by key repeat. /// destructive rewind cannot be queued multiple times by key repeat.
pub applying: bool, pub applying: bool,
@ -227,14 +228,14 @@ impl ActionbarNotice {
} }
pub struct App { pub struct App {
pub pod_name: String, pub worker_name: String,
pub connected: bool, pub connected: bool,
/// Last controller status reported by the Pod. Drives the status line /// Last controller status reported by the Worker. Drives the status line
/// and Ctrl-key routing; do not infer this solely from replayed history. /// and Ctrl-key routing; do not infer this solely from replayed history.
pub pod_status: PodStatus, pub worker_status: WorkerStatus,
/// True while the Pod is in `PodStatus::Running`. /// True while the Worker is in `WorkerStatus::Running`.
pub running: bool, pub running: bool,
/// True while the Pod is in `PodStatus::Paused`. /// True while the Worker is in `WorkerStatus::Paused`.
pub paused: bool, pub paused: bool,
pub run_requests: usize, pub run_requests: usize,
/// Sum of `input_tokens - cache_read_input_tokens` across the /// Sum of `input_tokens - cache_read_input_tokens` across the
@ -243,7 +244,7 @@ pub struct App {
/// cache reads excluded). Reset on `RunEnd`. /// cache reads excluded). Reset on `RunEnd`.
pub run_upload_tokens: u64, pub run_upload_tokens: u64,
pub run_output_tokens: u64, pub run_output_tokens: u64,
/// Latest session context tokens reported by the Pod. This is the raw /// Latest session context tokens reported by the Worker. This is the raw
/// `input_tokens` value and is independent from per-run upload totals. /// `input_tokens` value and is independent from per-run upload totals.
pub session_context_tokens: u64, pub session_context_tokens: u64,
pub context_window: u64, pub context_window: u64,
@ -264,9 +265,9 @@ pub struct App {
pub command_registry: CommandRegistry, pub command_registry: CommandRegistry,
command_completion_selected: Option<usize>, command_completion_selected: Option<usize>,
pub quit: bool, pub quit: bool,
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press /// 2-tap guard for `Ctrl-C` when the Worker is not running. First press
/// records the instant; a second press within the timeout exits the /// records the instant; a second press within the timeout exits the
/// TUI (the Pod itself stays alive). /// TUI (the Worker itself stays alive).
pub quit_confirm: Option<std::time::Instant>, pub quit_confirm: Option<std::time::Instant>,
/// Full display history in render order. /// Full display history in render order.
pub blocks: Vec<Block>, pub blocks: Vec<Block>,
@ -284,18 +285,18 @@ pub struct App {
pub rewind_picker: Option<RewindPickerState>, pub rewind_picker: Option<RewindPickerState>,
rewind_request_pending: bool, rewind_request_pending: bool,
/// After a successful rewind restore, ignore any queued live-update events /// After a successful rewind restore, ignore any queued live-update events
/// until the authoritative Pod status/snapshot catches up. This prevents /// until the authoritative Worker status/snapshot catches up. This prevents
/// old stream tail events that were already in transit from re-polluting the /// old stream tail events that were already in transit from re-polluting the
/// just-restored display. /// just-restored display.
rewind_refresh_fence: bool, rewind_refresh_fence: bool,
greeting: Option<protocol::Greeting>, greeting: Option<protocol::Greeting>,
/// In-TUI mirror of the Pod's session task store, reconstructed /// In-TUI mirror of the Worker's session task store, reconstructed
/// directly from observed `TaskCreate` / `TaskUpdate` tool calls and /// directly from observed `TaskCreate` / `TaskUpdate` tool calls and
/// `[Session TaskStore snapshot]` system messages — no protocol /// `[Session TaskStore snapshot]` system messages — no protocol
/// surface added on the Pod side. /// surface added on the Worker side.
pub task_store: TaskStore, pub task_store: TaskStore,
/// Transient single-Pod transcript text selection. This is viewport-local /// Transient single-Worker transcript text selection. This is viewport-local
/// UI state only; it is never sent to the Pod, persisted, or appended to /// UI state only; it is never sent to the Worker, persisted, or appended to
/// session history/model context. /// session history/model context.
pub text_selection: TextSelectionState, pub text_selection: TextSelectionState,
/// Whether the right-side task pane is currently open. /// Whether the right-side task pane is currently open.
@ -303,14 +304,14 @@ pub struct App {
/// Top entry index of the task pane's visible window. Clamped on /// Top entry index of the task pane's visible window. Clamped on
/// render so it never points past the end of the list. /// render so it never points past the end of the list.
pub task_pane_scroll: usize, pub task_pane_scroll: usize,
/// TUI-local FIFO of user inputs submitted while the Pod is already running. /// TUI-local FIFO of user inputs submitted while the Worker is already running.
/// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally. /// Entries have not been sent to the Worker yet, so they remain editable/cancellable locally.
queued_inputs: VecDeque<QueuedInput>, queued_inputs: VecDeque<QueuedInput>,
/// TUI-local readline-style composer input history. This is intentionally /// TUI-local readline-style composer input history. This is intentionally
/// client-side only: recalled entries are plain drafts until submitted again. /// client-side only: recalled entries are plain drafts until submitted again.
input_history: ComposerInputHistory, input_history: ComposerInputHistory,
/// User-data backed persistence for composer recall entries. The saved /// User-data backed persistence for composer recall entries. The saved
/// contents are private input drafts and must not be logged or sent to Pod. /// contents are private input drafts and must not be logged or sent to Worker.
input_history_store: Option<ComposerHistoryStore>, input_history_store: Option<ComposerHistoryStore>,
/// Local submit state kept until the accepted run either completes /// Local submit state kept until the accepted run either completes
/// normally or reports that the empty assistant turn was rolled back. /// normally or reports that the empty assistant turn was rolled back.
@ -321,11 +322,11 @@ pub struct App {
} }
impl App { impl App {
pub fn new(pod_name: String) -> Self { pub fn new(worker_name: String) -> Self {
Self { Self {
pod_name, worker_name,
connected: false, connected: false,
pod_status: PodStatus::Idle, worker_status: WorkerStatus::Idle,
running: false, running: false,
paused: false, paused: false,
run_requests: 0, run_requests: 0,
@ -367,8 +368,8 @@ impl App {
} }
} }
pub fn new_with_persistent_input_history(pod_name: String, workspace_root: &Path) -> Self { pub fn new_with_persistent_input_history(worker_name: String, workspace_root: &Path) -> Self {
let mut app = Self::new(pod_name); let mut app = Self::new(worker_name);
match ComposerHistoryStore::default_for_workspace(workspace_root) { match ComposerHistoryStore::default_for_workspace(workspace_root) {
Ok(Some(store)) => { Ok(Some(store)) => {
match store.load() { match store.load() {
@ -407,8 +408,8 @@ impl App {
} }
#[cfg(test)] #[cfg(test)]
fn new_with_input_history_store(pod_name: String, store: ComposerHistoryStore) -> Self { fn new_with_input_history_store(worker_name: String, store: ComposerHistoryStore) -> Self {
let mut app = Self::new(pod_name); let mut app = Self::new(worker_name);
match store.load() { match store.load() {
Ok(entries) => { Ok(entries) => {
app.input_history = ComposerInputHistory::with_entries(entries); app.input_history = ComposerInputHistory::with_entries(entries);
@ -442,10 +443,10 @@ impl App {
self.task_pane_scroll = self.task_pane_scroll.saturating_add(n); self.task_pane_scroll = self.task_pane_scroll.saturating_add(n);
} }
pub fn set_pod_status(&mut self, status: PodStatus) { pub fn set_worker_status(&mut self, status: WorkerStatus) {
self.pod_status = status; self.worker_status = status;
self.running = status == PodStatus::Running; self.running = status == WorkerStatus::Running;
self.paused = status == PodStatus::Paused; self.paused = status == WorkerStatus::Paused;
if self.running { if self.running {
self.quit_confirm = None; self.quit_confirm = None;
} }
@ -639,7 +640,7 @@ impl App {
pub fn submit_input(&mut self) -> Option<Method> { pub fn submit_input(&mut self) -> Option<Method> {
let segments = self.input.submit_segments(); let segments = self.input.submit_segments();
if segments_are_blank(&segments) { if segments_are_blank(&segments) {
// Empty Enter only does something meaningful when the Pod // Empty Enter only does something meaningful when the Worker
// is paused: resume the interrupted turn. Otherwise no-op. // is paused: resume the interrupted turn. Otherwise no-op.
if self.paused { if self.paused {
self.input_history.cancel_browse(); self.input_history.cancel_browse();
@ -660,7 +661,7 @@ impl App {
} }
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method { fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
// TurnHeader / UserMessage blocks are pushed only after the Pod // TurnHeader / UserMessage blocks are pushed only after the Worker
// emits `Event::UserMessage` from a committed `LogEntry::UserInput`. // emits `Event::UserMessage` from a committed `LogEntry::UserInput`.
// Locally we only clear the input buffer and forward the method, // Locally we only clear the input buffer and forward the method,
// while remembering enough local state to undo the visible submit if // while remembering enough local state to undo the visible submit if
@ -812,7 +813,7 @@ impl App {
pub fn push_error(&mut self, message: impl Into<String>) { pub fn push_error(&mut self, message: impl Into<String>) {
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level: AlertLevel::Error, level: AlertLevel::Error,
source: AlertSource::Pod, source: AlertSource::Worker,
message: message.into(), message: message.into(),
}); });
} }
@ -856,7 +857,7 @@ impl App {
self.blocks.push(Block::TurnHeader { self.blocks.push(Block::TurnHeader {
turn: self.turn_index, turn: self.turn_index,
}); });
// Pod attaches the original `Vec<Segment>` to user // Worker attaches the original `Vec<Segment>` to user
// messages from live submissions, so we can rebuild // messages from live submissions, so we can rebuild
// typed atoms (paste chips, refs) here. Seed history // typed atoms (paste chips, refs) here. Seed history
// loaded post-compaction has no `segments` field — // loaded post-compaction has no `segments` field —
@ -971,7 +972,7 @@ impl App {
} }
} }
pub fn handle_pod_event(&mut self, event: Event) -> Option<Method> { pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) { if self.rewind_refresh_fence && event_is_stale_after_rewind(&event) {
return None; return None;
} }
@ -995,7 +996,7 @@ impl App {
self.assistant_streaming = false; self.assistant_streaming = false;
} }
Event::TurnStart { .. } => { Event::TurnStart { .. } => {
self.set_pod_status(PodStatus::Running); self.set_worker_status(WorkerStatus::Running);
self.run_requests += 1; self.run_requests += 1;
self.current_tool = None; self.current_tool = None;
self.latest_llm_wait_event = None; self.latest_llm_wait_event = None;
@ -1175,7 +1176,7 @@ impl App {
}; };
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level, level,
source: AlertSource::Pod, source: AlertSource::Worker,
message: format!("orphan tool result ({id}): {summary}"), message: format!("orphan tool result ({id}): {summary}"),
}); });
} }
@ -1211,9 +1212,9 @@ impl App {
}); });
self.pending_submit_rollback = None; self.pending_submit_rollback = None;
self.reset_run_state(match result { self.reset_run_state(match result {
RunResult::Paused => PodStatus::Paused, RunResult::Paused => WorkerStatus::Paused,
RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => { RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => {
PodStatus::Idle WorkerStatus::Idle
} }
}); });
if matches!(result, RunResult::Finished | RunResult::LimitReached) { if matches!(result, RunResult::Finished | RunResult::LimitReached) {
@ -1283,11 +1284,11 @@ impl App {
} => { } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.restore_snapshot(&entries, greeting, in_flight); self.restore_snapshot(&entries, greeting, in_flight);
self.set_pod_status(status); self.set_worker_status(status);
} }
Event::Status { status } => { Event::Status { status } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.set_pod_status(status); self.set_worker_status(status);
} }
Event::Completions { kind, entries } => { Event::Completions { kind, entries } => {
// Apply only if the popup is still on the same // Apply only if the popup is still on the same
@ -1324,7 +1325,7 @@ impl App {
}; };
self.completion = None; self.completion = None;
self.close_rewind_picker(); self.close_rewind_picker();
self.reset_run_state(self.pod_status); self.reset_run_state(self.worker_status);
let mut message = if restored_composer { let mut message = if restored_composer {
format!( format!(
"Rewound session: discarded {} log entries; restored selected input to composer.", "Rewound session: discarded {} log entries; restored selected input to composer.",
@ -1343,20 +1344,20 @@ impl App {
} }
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level: AlertLevel::Warn, level: AlertLevel::Warn,
source: AlertSource::Pod, source: AlertSource::Worker,
message, message,
}); });
} }
Event::PodsListed { .. } | Event::PodRestored { .. } => {} Event::WorkersListed { .. } | Event::WorkerRestored { .. } => {}
Event::PeerRegistered { result } => { Event::PeerRegistered { result } => {
let source = result let source = result
.get("source") .get("source")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.unwrap_or("this Pod"); .unwrap_or("this Worker");
let peer = result let peer = result
.get("peer") .get("peer")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.unwrap_or("peer Pod"); .unwrap_or("peer Worker");
self.flash_actionbar_notice( self.flash_actionbar_notice(
format!("Peer metadata registered: `{source}` ↔ `{peer}`"), format!("Peer metadata registered: `{source}` ↔ `{peer}`"),
ActionbarNoticeLevel::Info, ActionbarNoticeLevel::Info,
@ -1372,8 +1373,8 @@ impl App {
None None
} }
fn reset_run_state(&mut self, status: PodStatus) { fn reset_run_state(&mut self, status: WorkerStatus) {
self.set_pod_status(status); self.set_worker_status(status);
self.run_requests = 0; self.run_requests = 0;
self.run_upload_tokens = 0; self.run_upload_tokens = 0;
self.run_output_tokens = 0; self.run_output_tokens = 0;
@ -1403,10 +1404,10 @@ impl App {
"Rolled back empty assistant turn; no local submitted input was available to restore." "Rolled back empty assistant turn; no local submitted input was available to restore."
.to_owned() .to_owned()
}; };
self.reset_run_state(PodStatus::Idle); self.reset_run_state(WorkerStatus::Idle);
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level: AlertLevel::Warn, level: AlertLevel::Warn,
source: AlertSource::Pod, source: AlertSource::Worker,
message: hint, message: hint,
}); });
} }
@ -1706,15 +1707,17 @@ impl App {
pub fn request_rewind_picker(&mut self) -> Option<Method> { pub fn request_rewind_picker(&mut self) -> Option<Method> {
if self.rewind_submit_pending() { if self.rewind_submit_pending() {
self.push_command_diagnostic("rewind is already applying; wait for the Pod response"); self.push_command_diagnostic(
"rewind is already applying; wait for the Worker response",
);
return None; return None;
} }
if !self.connected { if !self.connected {
self.push_command_diagnostic("cannot rewind before the Pod is connected"); self.push_command_diagnostic("cannot rewind before the Worker is connected");
return None; return None;
} }
if self.running { if self.running {
self.push_command_diagnostic("cannot rewind while the Pod is running"); self.push_command_diagnostic("cannot rewind while the Worker is running");
return None; return None;
} }
self.completion = None; self.completion = None;
@ -1731,7 +1734,7 @@ impl App {
pub fn cancel_rewind_picker(&mut self) { pub fn cancel_rewind_picker(&mut self) {
if self.rewind_submit_pending() { if self.rewind_submit_pending() {
self.flash_actionbar_notice( self.flash_actionbar_notice(
"Rewind is applying; wait for the Pod response.", "Rewind is applying; wait for the Worker response.",
ActionbarNoticeLevel::Warn, ActionbarNoticeLevel::Warn,
ActionbarNoticeSource::Tui, ActionbarNoticeSource::Tui,
Duration::from_secs(3), Duration::from_secs(3),
@ -1764,12 +1767,14 @@ impl App {
pub fn submit_rewind_picker(&mut self) -> Option<Method> { pub fn submit_rewind_picker(&mut self) -> Option<Method> {
if self.rewind_submit_pending() { if self.rewind_submit_pending() {
self.push_command_diagnostic("rewind is already applying; wait for the Pod response"); self.push_command_diagnostic(
"rewind is already applying; wait for the Worker response",
);
return None; return None;
} }
if self.paused { if self.paused {
self.push_command_diagnostic( self.push_command_diagnostic(
"cannot apply rewind while the Pod is paused; resume or wait for idle first", "cannot apply rewind while the Worker is paused; resume or wait for idle first",
); );
return None; return None;
} }
@ -1783,7 +1788,9 @@ impl App {
return None; return None;
}; };
if picker.applying { if picker.applying {
self.push_command_diagnostic("rewind is already applying; wait for the Pod response"); self.push_command_diagnostic(
"rewind is already applying; wait for the Worker response",
);
return None; return None;
} }
let (target_id, expected_head_entries) = match picker.selected_target() { let (target_id, expected_head_entries) = match picker.selected_target() {
@ -1849,7 +1856,7 @@ impl App {
fn push_command_diagnostic(&mut self, message: impl Into<String>) { fn push_command_diagnostic(&mut self, message: impl Into<String>) {
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level: AlertLevel::Warn, level: AlertLevel::Warn,
source: AlertSource::Pod, source: AlertSource::Worker,
message: format!("TUI command: {}", message.into()), message: format!("TUI command: {}", message.into()),
}); });
} }
@ -1971,7 +1978,7 @@ impl App {
self.apply_in_flight_snapshot(in_flight); self.apply_in_flight_snapshot(in_flight);
} }
/// Restore after a successful destructive rewind. The Pod's /// Restore after a successful destructive rewind. The Worker's
/// `RewindApplied` event already contains the authoritative post-rewind /// `RewindApplied` event already contains the authoritative post-rewind
/// session tail; always clear/replay from it even if this TUI instance has /// session tail; always clear/replay from it even if this TUI instance has
/// somehow lost connect-time greeting metadata. Skipping the restore in /// somehow lost connect-time greeting metadata. Skipping the restore in
@ -1993,7 +2000,7 @@ impl App {
if missing_greeting { if missing_greeting {
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level: AlertLevel::Warn, level: AlertLevel::Warn,
source: AlertSource::Pod, source: AlertSource::Worker,
message: "Rewind applied, but greeting metadata was unavailable; restored the session tail without the header.".to_owned(), message: "Rewind applied, but greeting metadata was unavailable; restored the session tail without the header.".to_owned(),
}); });
} }
@ -2023,7 +2030,7 @@ impl App {
/// Drop the derived view in preparation for replaying a new /// Drop the derived view in preparation for replaying a new
/// `SegmentStart` (compaction / fork). Greeting is preserved /// `SegmentStart` (compaction / fork). Greeting is preserved
/// because the Pod identity hasn't changed. /// because the Worker identity hasn't changed.
fn reset_for_rotation(&mut self) { fn reset_for_rotation(&mut self) {
let greeting = self.blocks.iter().find_map(|b| match b { let greeting = self.blocks.iter().find_map(|b| match b {
Block::Greeting(g) => Some(g.clone()), Block::Greeting(g) => Some(g.clone()),
@ -2084,7 +2091,7 @@ impl App {
/// ///
/// Kind-based routing replaces the old free-text `[Notification]` / /// Kind-based routing replaces the old free-text `[Notification]` /
/// `[File: …]` parsing path: each kind maps directly to a typed /// `[File: …]` parsing path: each kind maps directly to a typed
/// block (`Block::Notify`, `Block::PodEvent`, …). /// block (`Block::Notify`, `Block::WorkerEvent`, …).
fn apply_system_item(&mut self, value: &serde_json::Value) { fn apply_system_item(&mut self, value: &serde_json::Value) {
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else { let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
// Unknown / forward-compat shape: fall back to rendering the // Unknown / forward-compat shape: fall back to rendering the
@ -2101,8 +2108,8 @@ impl App {
session_store::SystemItem::Notification { message, .. } => { session_store::SystemItem::Notification { message, .. } => {
self.blocks.push(Block::Notify { message }); self.blocks.push(Block::Notify { message });
} }
session_store::SystemItem::PodEvent { event, .. } => { session_store::SystemItem::WorkerEvent { event, .. } => {
self.blocks.push(Block::PodEvent { event }); self.blocks.push(Block::WorkerEvent { event });
} }
session_store::SystemItem::FileAttachment { body, .. } session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::Knowledge { body, .. } | session_store::SystemItem::Knowledge { body, .. }
@ -2230,7 +2237,7 @@ fn rollback_input_preview(text: &str) -> String {
pub fn alert_source_label(source: AlertSource) -> &'static str { pub fn alert_source_label(source: AlertSource) -> &'static str {
match source { match source {
AlertSource::Pod => "pod", AlertSource::Worker => "worker",
AlertSource::Engine => "engine", AlertSource::Engine => "engine",
AlertSource::Compactor => "compactor", AlertSource::Compactor => "compactor",
AlertSource::AgentsMd => "AGENTS.md", AlertSource::AgentsMd => "AGENTS.md",
@ -2244,7 +2251,7 @@ mod llm_wait_event_tests {
#[test] #[test]
fn llm_retry_updates_and_progress_clears_transient_status() { fn llm_retry_updates_and_progress_clears_transient_status() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_pod_event(Event::LlmRetry { app.handle_worker_event(Event::LlmRetry {
llm_call: 2, llm_call: 2,
failed_attempt: 1, failed_attempt: 1,
max_attempts: 4, max_attempts: 4,
@ -2258,14 +2265,14 @@ mod llm_wait_event_tests {
Some("retrying LLM request after HTTP 504 (attempt 2/4 in 1.2s)") Some("retrying LLM request after HTTP 504 (attempt 2/4 in 1.2s)")
); );
app.handle_pod_event(Event::TextDelta { text: "ok".into() }); app.handle_worker_event(Event::TextDelta { text: "ok".into() });
assert!(app.latest_llm_wait_event.is_none()); assert!(app.latest_llm_wait_event.is_none());
} }
#[test] #[test]
fn llm_continuation_updates_transient_status() { fn llm_continuation_updates_transient_status() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_pod_event(Event::LlmContinuation { app.handle_worker_event(Event::LlmContinuation {
llm_call: 3, llm_call: 3,
attempt: 1, attempt: 1,
max_attempts: 3, max_attempts: 3,
@ -2289,7 +2296,7 @@ mod actionbar_notice_tests {
let duration = Duration::from_secs(2); let duration = Duration::from_secs(2);
app.flash_actionbar_notice_at( app.flash_actionbar_notice_at(
"Pod keeps running", "Worker keeps running",
ActionbarNoticeLevel::Warn, ActionbarNoticeLevel::Warn,
ActionbarNoticeSource::Tui, ActionbarNoticeSource::Tui,
now, now,
@ -2297,7 +2304,7 @@ mod actionbar_notice_tests {
); );
let notice = app.current_actionbar_notice(now).expect("notice is active"); let notice = app.current_actionbar_notice(now).expect("notice is active");
assert_eq!(notice.text, "Pod keeps running"); assert_eq!(notice.text, "Worker keeps running");
assert_eq!(notice.level, ActionbarNoticeLevel::Warn); assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
assert_eq!(notice.source, ActionbarNoticeSource::Tui); assert_eq!(notice.source, ActionbarNoticeSource::Tui);
assert_eq!(notice.expires_at, now + duration); assert_eq!(notice.expires_at, now + duration);
@ -2325,7 +2332,7 @@ mod rewind_refresh_tests {
text: "old post-target output".into(), text: "old post-target output".into(),
}); });
app.handle_pod_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], entries: vec![],
input: vec![Segment::text("selected rewind input")], input: vec![Segment::text("selected rewind input")],
summary: summary(3), summary: summary(3),
@ -2344,7 +2351,7 @@ mod rewind_refresh_tests {
text: "old live tail without greeting".into(), text: "old live tail without greeting".into(),
}); });
app.handle_pod_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], entries: vec![],
input: vec![Segment::text("rewound input")], input: vec![Segment::text("rewound input")],
summary: summary(1), summary: summary(1),
@ -2367,7 +2374,7 @@ mod rewind_refresh_tests {
assert!(app.rewind_picker.as_ref().unwrap().applying); assert!(app.rewind_picker.as_ref().unwrap().applying);
assert!(app.submit_rewind_picker().is_none()); assert!(app.submit_rewind_picker().is_none());
app.handle_pod_event(Event::Error { app.handle_worker_event(Event::Error {
code: ErrorCode::InvalidRequest, code: ErrorCode::InvalidRequest,
message: "stale rewind target".into(), message: "stale rewind target".into(),
}); });
@ -2387,20 +2394,20 @@ mod rewind_refresh_tests {
text: "old tail before rewind".into(), text: "old tail before rewind".into(),
}); });
app.handle_pod_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], entries: vec![],
input: vec![Segment::text("rewound input")], input: vec![Segment::text("rewound input")],
summary: summary(2), summary: summary(2),
}); });
app.handle_pod_event(Event::TextDelta { app.handle_worker_event(Event::TextDelta {
text: "stale tail after rewind".into(), text: "stale tail after rewind".into(),
}); });
assert!(!blocks_contain(&app, "stale tail after rewind")); assert!(!blocks_contain(&app, "stale tail after rewind"));
app.handle_pod_event(Event::Status { app.handle_worker_event(Event::Status {
status: PodStatus::Idle, status: WorkerStatus::Idle,
}); });
app.handle_pod_event(Event::TextDelta { app.handle_worker_event(Event::TextDelta {
text: "new live tail after status".into(), text: "new live tail after status".into(),
}); });
assert!(blocks_contain(&app, "new live tail after status")); assert!(blocks_contain(&app, "new live tail after status"));
@ -2431,7 +2438,7 @@ mod rewind_refresh_tests {
fn greeting() -> protocol::Greeting { fn greeting() -> protocol::Greeting {
protocol::Greeting { protocol::Greeting {
pod_name: "test".into(), worker_name: "test".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
provider: "mock".into(), provider: "mock".into(),
model: "mock".into(), model: "mock".into(),
@ -2916,7 +2923,7 @@ mod completion_flow_tests {
} }
let _ = app.refresh_completion(); let _ = app.refresh_completion();
// Reply for a different kind shouldn't overwrite state. // Reply for a different kind shouldn't overwrite state.
app.handle_pod_event(Event::Completions { app.handle_worker_event(Event::Completions {
kind: CompletionKind::Workflow, kind: CompletionKind::Workflow,
entries: vec![CompletionEntry { entries: vec![CompletionEntry {
value: "stale".into(), value: "stale".into(),
@ -2939,10 +2946,10 @@ mod completion_flow_tests {
compacted_from: None, compacted_from: None,
}; };
app.handle_pod_event(Event::SegmentRotated { app.handle_worker_event(Event::SegmentRotated {
entry: serde_json::to_value(start).expect("LogEntry is Serialize"), entry: serde_json::to_value(start).expect("LogEntry is Serialize"),
}); });
app.handle_pod_event(Event::UserMessage { app.handle_worker_event(Event::UserMessage {
segments: vec![Segment::text("first persisted message")], segments: vec![Segment::text("first persisted message")],
}); });
@ -2960,20 +2967,20 @@ mod completion_flow_tests {
let submitted = submit_text(&mut app, "please wait"); let submitted = submit_text(&mut app, "please wait");
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
app.handle_pod_event(Event::UserMessage { app.handle_worker_event(Event::UserMessage {
segments: submitted, segments: submitted,
}); });
// Simulate run-derived attachment display after the submitted user line. // Simulate run-derived attachment display after the submitted user line.
app.blocks.push(Block::SystemMessage { app.blocks.push(Block::SystemMessage {
text: "[File: README.md]".into(), text: "[File: README.md]".into(),
}); });
app.handle_pod_event(Event::TurnStart { turn: 1 }); app.handle_worker_event(Event::TurnStart { turn: 1 });
app.handle_pod_event(Event::Usage { app.handle_worker_event(Event::Usage {
input_tokens: Some(100), input_tokens: Some(100),
output_tokens: Some(0), output_tokens: Some(0),
cache_read_input_tokens: Some(40), cache_read_input_tokens: Some(40),
}); });
app.handle_pod_event(Event::RunEnd { app.handle_worker_event(Event::RunEnd {
result: RunResult::RolledBack, result: RunResult::RolledBack,
}); });
@ -2987,7 +2994,7 @@ mod completion_flow_tests {
| Block::TurnStats { .. } | Block::TurnStats { .. }
))); )));
assert!(warning_contains(&app, "restored your input")); assert!(warning_contains(&app, "restored your input"));
assert!(matches!(app.pod_status, PodStatus::Idle)); assert!(matches!(app.worker_status, WorkerStatus::Idle));
assert!(!app.running); assert!(!app.running);
assert!(!app.paused); assert!(!app.paused);
assert_eq!(app.run_requests, 0); assert_eq!(app.run_requests, 0);
@ -3000,14 +3007,14 @@ mod completion_flow_tests {
fn rolled_back_run_does_not_overwrite_existing_unsent_input() { fn rolled_back_run_does_not_overwrite_existing_unsent_input() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let submitted = submit_text(&mut app, "original submit"); let submitted = submit_text(&mut app, "original submit");
app.handle_pod_event(Event::UserMessage { app.handle_worker_event(Event::UserMessage {
segments: submitted, segments: submitted,
}); });
for c in "draft while running".chars() { for c in "draft while running".chars() {
app.insert_char(c); app.insert_char(c);
} }
app.handle_pod_event(Event::RunEnd { app.handle_worker_event(Event::RunEnd {
result: RunResult::RolledBack, result: RunResult::RolledBack,
}); });
@ -3028,10 +3035,10 @@ mod completion_flow_tests {
for result in [RunResult::Paused, RunResult::Finished] { for result in [RunResult::Paused, RunResult::Finished] {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let submitted = submit_text(&mut app, "normal run"); let submitted = submit_text(&mut app, "normal run");
app.handle_pod_event(Event::UserMessage { app.handle_worker_event(Event::UserMessage {
segments: submitted, segments: submitted,
}); });
app.handle_pod_event(Event::RunEnd { result }); app.handle_worker_event(Event::RunEnd { result });
assert_eq!(input_text(&app), ""); assert_eq!(input_text(&app), "");
assert!( assert!(
@ -3057,7 +3064,7 @@ mod completion_flow_tests {
#[test] #[test]
fn running_submit_is_queued_locally_and_clears_composer() { fn running_submit_is_queued_locally_and_clears_composer() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "queued turn"); insert_text(&mut app, "queued turn");
assert!(app.submit_input().is_none()); assert!(app.submit_input().is_none());
@ -3070,11 +3077,11 @@ mod completion_flow_tests {
#[test] #[test]
fn finished_run_auto_sends_next_queued_input() { fn finished_run_auto_sends_next_queued_input() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "next turn"); insert_text(&mut app, "next turn");
assert!(app.submit_input().is_none()); assert!(app.submit_input().is_none());
let method = app.handle_pod_event(Event::RunEnd { let method = app.handle_worker_event(Event::RunEnd {
result: RunResult::Finished, result: RunResult::Finished,
}); });
@ -3090,11 +3097,11 @@ mod completion_flow_tests {
#[test] #[test]
fn limit_reached_run_auto_sends_next_queued_input() { fn limit_reached_run_auto_sends_next_queued_input() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "next after limit"); insert_text(&mut app, "next after limit");
assert!(app.submit_input().is_none()); assert!(app.submit_input().is_none());
let method = app.handle_pod_event(Event::RunEnd { let method = app.handle_worker_event(Event::RunEnd {
result: RunResult::LimitReached, result: RunResult::LimitReached,
}); });
@ -3111,11 +3118,11 @@ mod completion_flow_tests {
fn paused_and_rolled_back_run_do_not_auto_send_queue() { fn paused_and_rolled_back_run_do_not_auto_send_queue() {
for result in [RunResult::Paused, RunResult::RolledBack] { for result in [RunResult::Paused, RunResult::RolledBack] {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "held turn"); insert_text(&mut app, "held turn");
assert!(app.submit_input().is_none()); assert!(app.submit_input().is_none());
let method = app.handle_pod_event(Event::RunEnd { result }); let method = app.handle_worker_event(Event::RunEnd { result });
assert!(method.is_none()); assert!(method.is_none());
assert_eq!(app.queued_input_count(), 1); assert_eq!(app.queued_input_count(), 1);
@ -3126,7 +3133,7 @@ mod completion_flow_tests {
#[test] #[test]
fn paused_empty_submit_still_resumes_immediately() { fn paused_empty_submit_still_resumes_immediately() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_pod_status(PodStatus::Paused); app.set_worker_status(WorkerStatus::Paused);
assert!(matches!(app.submit_input(), Some(Method::Resume))); assert!(matches!(app.submit_input(), Some(Method::Resume)));
assert_eq!(app.queued_input_count(), 0); assert_eq!(app.queued_input_count(), 0);
@ -3135,7 +3142,7 @@ mod completion_flow_tests {
#[test] #[test]
fn queued_input_can_be_restored_to_composer_or_cleared() { fn queued_input_can_be_restored_to_composer_or_cleared() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
insert_text(&mut app, "edit me"); insert_text(&mut app, "edit me");
assert!(app.submit_input().is_none()); assert!(app.submit_input().is_none());
@ -3198,14 +3205,14 @@ mod completion_flow_tests {
compacted_from: None, compacted_from: None,
}; };
let session_start_value = serde_json::to_value(&session_start).unwrap(); let session_start_value = serde_json::to_value(&session_start).unwrap();
app.handle_pod_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![session_start_value], entries: vec![session_start_value],
status: PodStatus::Running, status: WorkerStatus::Running,
in_flight: Default::default(), in_flight: Default::default(),
}); });
assert!(matches!(app.pod_status, PodStatus::Running)); assert!(matches!(app.worker_status, WorkerStatus::Running));
assert!(app.running); assert!(app.running);
assert!(matches!( assert!(matches!(
app.blocks.get(1), app.blocks.get(1),
@ -3216,10 +3223,10 @@ mod completion_flow_tests {
#[test] #[test]
fn snapshot_in_flight_blocks_continue_with_live_deltas() { fn snapshot_in_flight_blocks_continue_with_live_deltas() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_pod_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: Vec::new(), entries: Vec::new(),
status: PodStatus::Running, status: WorkerStatus::Running,
in_flight: InFlightSnapshot { in_flight: InFlightSnapshot {
blocks: vec![ blocks: vec![
InFlightBlock::Thinking { InFlightBlock::Thinking {
@ -3240,9 +3247,9 @@ mod completion_flow_tests {
}, },
}); });
app.handle_pod_event(Event::TextDelta { text: "lo".into() }); app.handle_worker_event(Event::TextDelta { text: "lo".into() });
app.handle_pod_event(Event::ThinkingDelta { text: "?".into() }); app.handle_worker_event(Event::ThinkingDelta { text: "?".into() });
app.handle_pod_event(Event::ToolCallArgsDelta { app.handle_worker_event(Event::ToolCallArgsDelta {
id: "call_1".into(), id: "call_1".into(),
json: r#"\":\"src/lib.rs\"}"#.into(), json: r#"\":\"src/lib.rs\"}"#.into(),
}); });
@ -3269,7 +3276,7 @@ mod completion_flow_tests {
"slug": "build", "slug": "build",
"body": "[Workflow /build]\nRun the build", "body": "[Workflow /build]\nRun the build",
}); });
app.handle_pod_event(Event::SystemItem { item }); app.handle_worker_event(Event::SystemItem { item });
assert!(matches!( assert!(matches!(
app.blocks.as_slice(), app.blocks.as_slice(),
@ -3285,7 +3292,7 @@ mod completion_flow_tests {
"message": "hi", "message": "hi",
"body": "[Notification] hi", "body": "[Notification] hi",
}); });
app.handle_pod_event(Event::SystemItem { item }); app.handle_worker_event(Event::SystemItem { item });
assert!(matches!( assert!(matches!(
app.blocks.as_slice(), app.blocks.as_slice(),
[Block::Notify { message }] if message == "hi" [Block::Notify { message }] if message == "hi"
@ -3293,20 +3300,20 @@ mod completion_flow_tests {
} }
#[test] #[test]
fn live_system_item_pod_event_appends_pod_event_block() { fn live_system_item_worker_event_appends_worker_event_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let item = serde_json::json!({ let item = serde_json::json!({
"kind": "pod_event", "kind": "worker_event",
"event": { "kind": "turn_ended", "pod_name": "child" }, "event": { "kind": "turn_ended", "worker_name": "child" },
"body": "[Notification] pod `child` finished a turn", "body": "[Notification] worker `child` finished a turn",
}); });
app.handle_pod_event(Event::SystemItem { item }); app.handle_worker_event(Event::SystemItem { item });
assert_eq!(app.blocks.len(), 1); assert_eq!(app.blocks.len(), 1);
match &app.blocks[0] { match &app.blocks[0] {
Block::PodEvent { Block::WorkerEvent {
event: protocol::PodEvent::TurnEnded { pod_name }, event: protocol::WorkerEvent::TurnEnded { worker_name },
} => assert_eq!(pod_name, "child"), } => assert_eq!(worker_name, "child"),
_ => panic!("expected a PodEvent block"), _ => panic!("expected a WorkerEvent block"),
} }
} }
@ -3315,8 +3322,8 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap(); let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
app.handle_pod_event(Event::CompactStart); app.handle_worker_event(Event::CompactStart);
app.handle_pod_event(Event::CompactDone { new_segment_id: id }); app.handle_worker_event(Event::CompactDone { new_segment_id: id });
assert_eq!(compact_block_count(&app), 1); assert_eq!(compact_block_count(&app), 1);
assert!(matches!( assert!(matches!(
@ -3332,8 +3339,8 @@ mod completion_flow_tests {
fn compact_failed_replaces_live_block() { fn compact_failed_replaces_live_block() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_pod_event(Event::CompactStart); app.handle_worker_event(Event::CompactStart);
app.handle_pod_event(Event::CompactFailed { app.handle_worker_event(Event::CompactFailed {
error: "provider 429".into(), error: "provider 429".into(),
}); });
@ -3351,8 +3358,8 @@ mod completion_flow_tests {
fn shutdown_marks_live_compact_incomplete() { fn shutdown_marks_live_compact_incomplete() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_pod_event(Event::CompactStart); app.handle_worker_event(Event::CompactStart);
app.handle_pod_event(Event::Shutdown); app.handle_worker_event(Event::Shutdown);
assert!(app.quit); assert!(app.quit);
assert!(matches!( assert!(matches!(
@ -3372,7 +3379,7 @@ mod completion_flow_tests {
fn test_greeting() -> protocol::Greeting { fn test_greeting() -> protocol::Greeting {
protocol::Greeting { protocol::Greeting {
pod_name: "test".into(), worker_name: "test".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
provider: "test-provider".into(), provider: "test-provider".into(),
model: "test-model".into(), model: "test-model".into(),
@ -3390,10 +3397,10 @@ mod completion_flow_tests {
greeting.context_window = 123_000; greeting.context_window = 123_000;
greeting.context_tokens = 45_000; greeting.context_tokens = 45_000;
app.handle_pod_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
entries: Vec::new(), entries: Vec::new(),
greeting, greeting,
status: PodStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
}); });
@ -3405,7 +3412,7 @@ mod completion_flow_tests {
fn usage_updates_session_context_tokens_without_cache_discount() { fn usage_updates_session_context_tokens_without_cache_discount() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_pod_event(Event::Usage { app.handle_worker_event(Event::Usage {
input_tokens: Some(42_000), input_tokens: Some(42_000),
output_tokens: Some(9), output_tokens: Some(9),
cache_read_input_tokens: Some(40_000), cache_read_input_tokens: Some(40_000),
@ -3420,7 +3427,7 @@ mod completion_flow_tests {
fn memory_worker_event_updates_actionbar_state() { fn memory_worker_event_updates_actionbar_state() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_pod_event(Event::MemoryWorker(protocol::MemoryWorkerEvent { app.handle_worker_event(Event::MemoryWorker(protocol::MemoryWorkerEvent {
worker: "extract".into(), worker: "extract".into(),
status: "done".into(), status: "done".into(),
run_id: "00000000-0000-0000-0000-000000000000".into(), run_id: "00000000-0000-0000-0000-000000000000".into(),
@ -3441,7 +3448,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.session_context_tokens = 42_000; app.session_context_tokens = 42_000;
app.handle_pod_event(Event::CompactDone { app.handle_worker_event(Event::CompactDone {
new_segment_id: uuid::Uuid::nil(), new_segment_id: uuid::Uuid::nil(),
}); });
@ -3453,8 +3460,8 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.session_context_tokens = 42_000; app.session_context_tokens = 42_000;
app.handle_pod_event(Event::TurnStart { turn: 1 }); app.handle_worker_event(Event::TurnStart { turn: 1 });
app.handle_pod_event(Event::RunEnd { app.handle_worker_event(Event::RunEnd {
result: RunResult::Finished, result: RunResult::Finished,
}); });
@ -3464,11 +3471,11 @@ mod completion_flow_tests {
#[test] #[test]
fn live_task_create_updates_task_store() { fn live_task_create_updates_task_store() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_pod_event(Event::ToolCallStart { app.handle_worker_event(Event::ToolCallStart {
id: "c1".into(), id: "c1".into(),
name: "TaskCreate".into(), name: "TaskCreate".into(),
}); });
app.handle_pod_event(Event::ToolCallDone { app.handle_worker_event(Event::ToolCallDone {
id: "c1".into(), id: "c1".into(),
name: "TaskCreate".into(), name: "TaskCreate".into(),
arguments: r#"{"subject":"impl tasks","description":"do it"}"#.into(), arguments: r#"{"subject":"impl tasks","description":"do it"}"#.into(),
@ -3491,11 +3498,11 @@ mod completion_flow_tests {
} else { } else {
"TaskUpdate" "TaskUpdate"
}; };
app.handle_pod_event(Event::ToolCallStart { app.handle_worker_event(Event::ToolCallStart {
id: id.into(), id: id.into(),
name: name.into(), name: name.into(),
}); });
app.handle_pod_event(Event::ToolCallDone { app.handle_worker_event(Event::ToolCallDone {
id: id.into(), id: id.into(),
name: name.into(), name: name.into(),
arguments: args.into(), arguments: args.into(),
@ -3511,11 +3518,11 @@ mod completion_flow_tests {
fn live_system_snapshot_replaces_task_store() { fn live_system_snapshot_replaces_task_store() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
// Stale entry that the snapshot must wipe out. // Stale entry that the snapshot must wipe out.
app.handle_pod_event(Event::ToolCallStart { app.handle_worker_event(Event::ToolCallStart {
id: "c1".into(), id: "c1".into(),
name: "TaskCreate".into(), name: "TaskCreate".into(),
}); });
app.handle_pod_event(Event::ToolCallDone { app.handle_worker_event(Event::ToolCallDone {
id: "c1".into(), id: "c1".into(),
name: "TaskCreate".into(), name: "TaskCreate".into(),
arguments: r#"{"subject":"stale","description":""}"#.into(), arguments: r#"{"subject":"stale","description":""}"#.into(),
@ -3528,7 +3535,7 @@ mod completion_flow_tests {
\"description\": \"d\"\n }\n ]\n}\n```\n"; \"description\": \"d\"\n }\n ]\n}\n```\n";
// Snapshot text injected as a workflow body (kind doesn't matter // Snapshot text injected as a workflow body (kind doesn't matter
// for task-store parsing, only the text contents do). // for task-store parsing, only the text contents do).
app.handle_pod_event(Event::SystemItem { app.handle_worker_event(Event::SystemItem {
item: serde_json::json!({ item: serde_json::json!({
"kind": "workflow", "kind": "workflow",
"slug": "task-snapshot", "slug": "task-snapshot",
@ -3547,11 +3554,11 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
// Live tool call before the snapshot lands — restore must wipe // Live tool call before the snapshot lands — restore must wipe
// this so it doesn't double-count after replay. // this so it doesn't double-count after replay.
app.handle_pod_event(Event::ToolCallStart { app.handle_worker_event(Event::ToolCallStart {
id: "live".into(), id: "live".into(),
name: "TaskCreate".into(), name: "TaskCreate".into(),
}); });
app.handle_pod_event(Event::ToolCallDone { app.handle_worker_event(Event::ToolCallDone {
id: "live".into(), id: "live".into(),
name: "TaskCreate".into(), name: "TaskCreate".into(),
arguments: r#"{"subject":"live","description":""}"#.into(), arguments: r#"{"subject":"live","description":""}"#.into(),
@ -3589,10 +3596,10 @@ mod completion_flow_tests {
}, },
}), }),
]; ];
app.handle_pod_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: assistant_item_entries, entries: assistant_item_entries,
status: PodStatus::Running, status: WorkerStatus::Running,
in_flight: Default::default(), in_flight: Default::default(),
}); });

View File

@ -9,7 +9,7 @@
use std::time::Instant; use std::time::Instant;
use protocol::{AlertLevel, AlertSource, Greeting, PodEvent, Segment}; use protocol::{AlertLevel, AlertSource, Greeting, Segment, WorkerEvent};
pub enum Block { pub enum Block {
Greeting(Greeting), Greeting(Greeting),
@ -25,16 +25,16 @@ pub enum Block {
SystemMessage { SystemMessage {
text: String, text: String,
}, },
/// Echo of `Method::Notify` received by this Pod, surfaced as a log /// Echo of `Method::Notify` received by this Worker, surfaced as a log
/// element so subscribers see the external input that drove any /// element so subscribers see the external input that drove any
/// following auto-kicked turn. /// following auto-kicked turn.
Notify { Notify {
message: String, message: String,
}, },
/// Echo of `Method::PodEvent` received by this Pod. Same role as /// Echo of `Method::WorkerEvent` received by this Worker. Same role as
/// `Notify` — an input log element, not a turn-control signal. /// `Notify` — an input log element, not a turn-control signal.
PodEvent { WorkerEvent {
event: PodEvent, event: WorkerEvent,
}, },
AssistantText { AssistantText {
text: String, text: String,

View File

@ -142,7 +142,7 @@ impl CommandRegistry {
name: "compact", name: "compact",
aliases: &[], aliases: &[],
usage: "compact", usage: "compact",
description: "Request immediate Pod context compaction.", description: "Request immediate Worker context compaction.",
argument_parser: compact_args, argument_parser: compact_args,
can_execute: compact_available, can_execute: compact_available,
executor: compact_command, executor: compact_command,
@ -159,8 +159,8 @@ impl CommandRegistry {
registry.register(CommandSpec { registry.register(CommandSpec {
name: "peer", name: "peer",
aliases: &[], aliases: &[],
usage: "peer <pod-name>", usage: "peer <worker-name>",
description: "Register another existing Pod as a reciprocal metadata peer.", description: "Register another existing Worker as a reciprocal metadata peer.",
argument_parser: peer_args, argument_parser: peer_args,
can_execute: peer_available, can_execute: peer_available,
executor: peer_command, executor: peer_command,
@ -317,7 +317,7 @@ fn peer_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
Ok(args) Ok(args)
} else { } else {
Err(CommandDiagnostic::new( Err(CommandDiagnostic::new(
"Invalid arguments. Usage: peer <pod-name>", "Invalid arguments. Usage: peer <worker-name>",
)) ))
} }
} }
@ -325,17 +325,17 @@ fn peer_args(raw: &str) -> Result<CommandArgs, CommandDiagnostic> {
fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> { fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
if !environment.connected { if !environment.connected {
return Err(CommandDiagnostic::new( return Err(CommandDiagnostic::new(
"Cannot compact: not connected to a Pod.", "Cannot compact: not connected to a Worker.",
)); ));
} }
if environment.running { if environment.running {
return Err(CommandDiagnostic::new( return Err(CommandDiagnostic::new(
"Cannot compact while the Pod is running.", "Cannot compact while the Worker is running.",
)); ));
} }
if environment.paused { if environment.paused {
return Err(CommandDiagnostic::new( return Err(CommandDiagnostic::new(
"Cannot compact while the Pod is paused; resume or start a fresh turn first.", "Cannot compact while the Worker is paused; resume or start a fresh turn first.",
)); ));
} }
Ok(()) Ok(())
@ -344,12 +344,12 @@ fn compact_available(environment: &CommandEnvironment) -> Result<(), CommandDiag
fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> { fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
if !environment.connected { if !environment.connected {
return Err(CommandDiagnostic::new( return Err(CommandDiagnostic::new(
"Cannot rewind before the Pod is connected.", "Cannot rewind before the Worker is connected.",
)); ));
} }
if environment.running { if environment.running {
return Err(CommandDiagnostic::new( return Err(CommandDiagnostic::new(
"Cannot rewind while the Pod is running.", "Cannot rewind while the Worker is running.",
)); ));
} }
Ok(()) Ok(())
@ -358,12 +358,12 @@ fn rewind_available(environment: &CommandEnvironment) -> Result<(), CommandDiagn
fn peer_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> { fn peer_available(environment: &CommandEnvironment) -> Result<(), CommandDiagnostic> {
if !environment.connected { if !environment.connected {
return Err(CommandDiagnostic::new( return Err(CommandDiagnostic::new(
"Cannot register a peer before the Pod is connected.", "Cannot register a peer before the Worker is connected.",
)); ));
} }
if environment.running { if environment.running {
return Err(CommandDiagnostic::new( return Err(CommandDiagnostic::new(
"Cannot register a peer while the Pod is running.", "Cannot register a peer while the Worker is running.",
)); ));
} }
Ok(()) Ok(())
@ -596,7 +596,7 @@ mod tests {
let registry = CommandRegistry::builtins(); let registry = CommandRegistry::builtins();
let result = registry.dispatch("help peer", &env()); let result = registry.dispatch("help peer", &env());
assert!(result.method.is_none()); assert!(result.method.is_none());
assert!(result.diagnostics[0].message.contains("peer <pod-name>")); assert!(result.diagnostics[0].message.contains("peer <worker-name>"));
assert!(result.diagnostics[0].message.contains("metadata peer")); assert!(result.diagnostics[0].message.contains("metadata peer"));
} }

View File

@ -32,7 +32,7 @@ impl ComposerEditAction {
} }
} }
/// Shared readline-style composer editing keymap used by the normal Pod TUI /// Shared readline-style composer editing keymap used by the normal Worker TUI
/// and the workspace panel. Callers still own higher-level routing such as /// and the workspace panel. Callers still own higher-level routing such as
/// completion popups, Enter submission, Tab target switching, Esc focus, and /// completion popups, Enter submission, Tab target switching, Esc focus, and
/// row/list navigation. /// row/list navigation.

View File

@ -18,14 +18,14 @@ use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{Command, execute}; use crossterm::{Command, execute};
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
use protocol::{Method, PodStatus}; use protocol::{Method, WorkerStatus};
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use session_store::SegmentId; use session_store::SegmentId;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use client::{PodClient, PodRuntimeCommand}; use client::{WorkerClient, WorkerRuntimeCommand};
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::composer_keys::{ComposerEditAction, composer_edit_action}; use crate::composer_keys::{ComposerEditAction, composer_edit_action};
@ -35,9 +35,9 @@ use crate::{picker, spawn, ui};
pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>; pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Narrow request bridge used when the workspace Dashboard opens a Pod Console. /// Narrow request bridge used when the workspace Dashboard opens a Worker Console.
pub(crate) struct DashboardConsoleOpenRequest { pub(crate) struct DashboardConsoleOpenRequest {
pub(crate) pod_name: String, pub(crate) worker_name: String,
pub(crate) socket_override: Option<PathBuf>, pub(crate) socket_override: Option<PathBuf>,
} }
@ -128,39 +128,39 @@ fn copy_selection_to_terminal(app: &mut App) -> bool {
copy_selection_to_writer(app, &mut stdout) copy_selection_to_writer(app, &mut stdout)
} }
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf { fn resolve_socket(worker_name: &str, override_path: Option<PathBuf>) -> PathBuf {
if let Some(p) = override_path { if let Some(p) = override_path {
return p; return p;
} }
manifest::paths::pod_socket_path(pod_name).unwrap_or_else(|| { manifest::paths::pod_socket_path(worker_name).unwrap_or_else(|| {
PathBuf::from("/tmp") PathBuf::from("/tmp")
.join("yoi") .join("yoi")
.join(pod_name) .join(worker_name)
.join("sock") .join("sock")
}) })
} }
pub(crate) async fn run_pod_name( pub(crate) async fn run_worker_name(
pod_name: String, worker_name: String,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() { if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
terminal.clear()?; terminal.clear()?;
let result = run_e2e_rewind_fixture(&mut terminal, pod_name).await; let result = run_e2e_rewind_fixture(&mut terminal, worker_name).await;
let _ = leave_fullscreen(&mut terminal); let _ = leave_fullscreen(&mut terminal);
return result; return result;
} }
if let Some(client) = try_connect_live_pod(&pod_name, socket_override.clone()).await { if let Some(client) = try_connect_live_pod(&worker_name, socket_override.clone()).await {
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
run_connected_pod(&mut terminal, pod_name, client, runtime_command.clone()).await?; run_connected_pod(&mut terminal, worker_name, client, runtime_command.clone()).await?;
return Ok(()); return Ok(());
} }
let ready = match spawn::run_pod_name(pod_name, runtime_command.clone()).await? { let ready = match spawn::run_worker_name(worker_name, runtime_command.clone()).await? {
SpawnOutcome::Ready(r) => r, SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()), SpawnOutcome::Cancelled => return Ok(()),
}; };
@ -173,12 +173,12 @@ pub(crate) async fn run_pod_name(
async fn run_connected_pod( async fn run_connected_pod(
terminal: &mut ConsoleTerminal, terminal: &mut ConsoleTerminal,
pod_name: String, worker_name: String,
client: PodClient, client: WorkerClient,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new_with_persistent_input_history(pod_name, &workspace_root); let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
app.connected = true; app.connected = true;
run_loop(terminal, &mut app, client, runtime_command).await run_loop(terminal, &mut app, client, runtime_command).await
} }
@ -186,29 +186,29 @@ async fn run_connected_pod(
pub(crate) async fn open_from_dashboard( pub(crate) async fn open_from_dashboard(
terminal: &mut ConsoleTerminal, terminal: &mut ConsoleTerminal,
request: DashboardConsoleOpenRequest, request: DashboardConsoleOpenRequest,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let DashboardConsoleOpenRequest { let DashboardConsoleOpenRequest {
pod_name, worker_name,
socket_override, socket_override,
} = request; } = request;
if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await { if let Some(client) = try_connect_live_pod(&worker_name, socket_override).await {
return run_connected_pod(terminal, pod_name, client, runtime_command.clone()).await; return run_connected_pod(terminal, worker_name, client, runtime_command.clone()).await;
} }
let ready = let ready =
spawn_pod_name_from_fullscreen(terminal, &pod_name, runtime_command.clone()).await?; spawn_worker_name_from_fullscreen(terminal, &worker_name, runtime_command.clone()).await?;
run_ready_pod(terminal, ready, runtime_command).await run_ready_pod(terminal, ready, runtime_command).await
} }
async fn spawn_pod_name_from_fullscreen( async fn spawn_worker_name_from_fullscreen(
terminal: &mut ConsoleTerminal, terminal: &mut ConsoleTerminal,
pod_name: &str, worker_name: &str,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnReady, Box<dyn std::error::Error>> { ) -> Result<SpawnReady, Box<dyn std::error::Error>> {
leave_fullscreen(terminal)?; leave_fullscreen(terminal)?;
let outcome = spawn::run_pod_name(pod_name.to_string(), runtime_command).await; let outcome = spawn::run_worker_name(worker_name.to_string(), runtime_command).await;
enter_fullscreen_existing(terminal)?; enter_fullscreen_existing(terminal)?;
terminal.clear()?; terminal.clear()?;
@ -219,11 +219,11 @@ async fn spawn_pod_name_from_fullscreen(
} }
async fn try_connect_live_pod( async fn try_connect_live_pod(
pod_name: &str, worker_name: &str,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,
) -> Option<PodClient> { ) -> Option<WorkerClient> {
let preferred_socket = resolve_socket(pod_name, socket_override.clone()); let preferred_socket = resolve_socket(worker_name, socket_override.clone());
connect_live_pod(pod_name, preferred_socket, socket_override.is_none()) connect_live_pod(worker_name, preferred_socket, socket_override.is_none())
.await .await
.map(|(_, client)| client) .map(|(_, client)| client)
} }
@ -233,7 +233,7 @@ struct NestedOpenCancelled;
impl std::fmt::Display for NestedOpenCancelled { impl std::fmt::Display for NestedOpenCancelled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Pod open was cancelled") f.write_str("Worker open was cancelled")
} }
} }
@ -242,57 +242,57 @@ impl std::error::Error for NestedOpenCancelled {}
async fn run_ready_pod( async fn run_ready_pod(
terminal: &mut ConsoleTerminal, terminal: &mut ConsoleTerminal,
ready: SpawnReady, ready: SpawnReady,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let SpawnReady { let SpawnReady {
pod_name, worker_name,
socket_path, socket_path,
} = ready; } = ready;
run(terminal, pod_name, &socket_path, runtime_command).await run(terminal, worker_name, &socket_path, runtime_command).await
} }
async fn connect_live_pod( async fn connect_live_pod(
pod_name: &str, worker_name: &str,
preferred_socket: PathBuf, preferred_socket: PathBuf,
allow_registry_fallback: bool, allow_registry_fallback: bool,
) -> Option<(PathBuf, PodClient)> { ) -> Option<(PathBuf, WorkerClient)> {
if let Ok(client) = PodClient::connect(&preferred_socket).await { if let Ok(client) = WorkerClient::connect(&preferred_socket).await {
return Some((preferred_socket, client)); return Some((preferred_socket, client));
} }
if !allow_registry_fallback { if !allow_registry_fallback {
return None; return None;
} }
let registry_socket = picker::live_socket_for_pod(pod_name)?; let registry_socket = picker::live_socket_for_pod(worker_name)?;
if registry_socket == preferred_socket { if registry_socket == preferred_socket {
return None; return None;
} }
PodClient::connect(&registry_socket) WorkerClient::connect(&registry_socket)
.await .await
.ok() .ok()
.map(|client| (registry_socket, client)) .map(|client| (registry_socket, client))
} }
pub(crate) async fn run_resume( pub(crate) async fn run_resume(
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
workspace_root: PathBuf, workspace_root: PathBuf,
all: bool, all: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
// Pick a Pod in its own inline viewport, dropping the viewport before // Pick a Worker in its own inline viewport, dropping the viewport before
// attaching/restoring so each phase gets fresh vertical room. // attaching/restoring so each phase gets fresh vertical room.
let picker_options = if all { let picker_options = if all {
picker::PickerOptions::all() picker::PickerOptions::all()
} else { } else {
picker::PickerOptions::workspace(workspace_root) picker::PickerOptions::workspace(workspace_root)
}; };
let (pod_name, socket_override) = match picker::run(picker_options).await? { let (worker_name, socket_override) = match picker::run(picker_options).await? {
PickerOutcome::Picked { PickerOutcome::Picked {
pod_name, worker_name,
socket_override, socket_override,
} => (pod_name, socket_override), } => (worker_name, socket_override),
PickerOutcome::Cancelled => return Ok(()), PickerOutcome::Cancelled => return Ok(()),
}; };
run_pod_name(pod_name, socket_override, runtime_command).await run_worker_name(worker_name, socket_override, runtime_command).await
} }
pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static)) -> bool { pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static)) -> bool {
@ -301,32 +301,33 @@ pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static))
pub(crate) async fn run_spawn( pub(crate) async fn run_spawn(
resume_from: Option<SegmentId>, resume_from: Option<SegmentId>,
pod_name: Option<String>, worker_name: Option<String>,
profile: Option<String>, profile: Option<String>,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() { if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
terminal.clear()?; terminal.clear()?;
let fixture_pod_name = pod_name.unwrap_or_else(|| "e2e-rewind".to_string()); let fixture_worker_name = worker_name.unwrap_or_else(|| "e2e-rewind".to_string());
let result = run_e2e_rewind_fixture(&mut terminal, fixture_pod_name).await; let result = run_e2e_rewind_fixture(&mut terminal, fixture_worker_name).await;
let _ = leave_fullscreen(&mut terminal); let _ = leave_fullscreen(&mut terminal);
return result; return result;
} }
let ready = match spawn::run(resume_from, pod_name, profile, runtime_command.clone()).await? { let ready = match spawn::run(resume_from, worker_name, profile, runtime_command.clone()).await?
{
SpawnOutcome::Ready(r) => r, SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()), SpawnOutcome::Cancelled => return Ok(()),
}; };
let SpawnReady { let SpawnReady {
pod_name, worker_name,
socket_path, socket_path,
} = ready; } = ready;
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
let result = run(&mut terminal, pod_name, &socket_path, runtime_command).await; let result = run(&mut terminal, worker_name, &socket_path, runtime_command).await;
// Leave alt-screen explicitly before `main`'s terminal restore path. // Leave alt-screen explicitly before `main`'s terminal restore path.
let _ = execute!( let _ = execute!(
@ -383,17 +384,17 @@ pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io::
async fn run( async fn run(
terminal: &mut ConsoleTerminal, terminal: &mut ConsoleTerminal,
pod_name: String, worker_name: String,
socket_path: &std::path::Path, socket_path: &std::path::Path,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new_with_persistent_input_history(pod_name, &workspace_root); let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
match PodClient::connect(socket_path).await { match WorkerClient::connect(socket_path).await {
Ok(client) => { Ok(client) => {
app.connected = true; app.connected = true;
// The Pod sends `Event::Snapshot` automatically on connect; // The Worker sends `Event::Snapshot` automatically on connect;
// no explicit method call is required to fetch history. // no explicit method call is required to fetch history.
run_loop(terminal, &mut app, client, runtime_command).await?; run_loop(terminal, &mut app, client, runtime_command).await?;
} }
@ -470,16 +471,16 @@ fn read_terminal_events(stop: Arc<AtomicBool>, tx: mpsc::UnboundedSender<Termina
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
async fn run_e2e_rewind_fixture( async fn run_e2e_rewind_fixture(
terminal: &mut ConsoleTerminal, terminal: &mut ConsoleTerminal,
pod_name: String, worker_name: String,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new_with_persistent_input_history(pod_name.clone(), &workspace_root); let mut app = App::new_with_persistent_input_history(worker_name.clone(), &workspace_root);
app.connected = true; app.connected = true;
app.handle_pod_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
entries: Vec::new(), entries: Vec::new(),
status: PodStatus::Idle, status: WorkerStatus::Idle,
greeting: Greeting { greeting: Greeting {
pod_name: pod_name.clone(), worker_name: worker_name.clone(),
cwd: workspace_root.display().to_string(), cwd: workspace_root.display().to_string(),
provider: "e2e-fixture".to_string(), provider: "e2e-fixture".to_string(),
model: "canned".to_string(), model: "canned".to_string(),
@ -500,9 +501,9 @@ async fn run_e2e_rewind_fixture(
let apply_delay = Duration::from_millis(400); let apply_delay = Duration::from_millis(400);
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
crate::e2e_observer::emit( crate::e2e_observer::emit(
"single_pod", "single_worker",
"rewind_fixture_ready", "rewind_fixture_ready",
serde_json::json!({ "pod": pod_name.clone() }), serde_json::json!({ "worker": worker_name.clone() }),
); );
terminal.draw(|frame| ui::draw(frame, &mut app))?; terminal.draw(|frame| ui::draw(frame, &mut app))?;
@ -539,7 +540,7 @@ async fn run_e2e_rewind_fixture(
if let Some(method) = handle_key(&mut app, key) { if let Some(method) = handle_key(&mut app, key) {
match method { match method {
Method::ListRewindTargets => { Method::ListRewindTargets => {
app.handle_pod_event(Event::RewindTargets { app.handle_worker_event(Event::RewindTargets {
head_entries: 3, head_entries: 3,
targets: vec![RewindTarget { targets: vec![RewindTarget {
id: target_id.clone(), id: target_id.clone(),
@ -554,7 +555,7 @@ async fn run_e2e_rewind_fixture(
}], }],
}); });
crate::e2e_observer::emit( crate::e2e_observer::emit(
"single_pod", "single_worker",
"rewind_picker_opened", "rewind_picker_opened",
serde_json::json!({ serde_json::json!({
"targets": 1, "targets": 1,
@ -569,7 +570,7 @@ async fn run_e2e_rewind_fixture(
rewind_submit_count += 1; rewind_submit_count += 1;
pending_apply = Some(std::time::Instant::now()); pending_apply = Some(std::time::Instant::now());
crate::e2e_observer::emit( crate::e2e_observer::emit(
"single_pod", "single_worker",
"rewind_submit_sent", "rewind_submit_sent",
serde_json::json!({ serde_json::json!({
"segment_id": target.segment_id.to_string(), "segment_id": target.segment_id.to_string(),
@ -583,7 +584,7 @@ async fn run_e2e_rewind_fixture(
} }
} else if duplicate_enter_pending { } else if duplicate_enter_pending {
crate::e2e_observer::emit( crate::e2e_observer::emit(
"single_pod", "single_worker",
"rewind_duplicate_enter_suppressed", "rewind_duplicate_enter_suppressed",
serde_json::json!({ "submit_count": rewind_submit_count }), serde_json::json!({ "submit_count": rewind_submit_count }),
); );
@ -601,7 +602,7 @@ async fn run_e2e_rewind_fixture(
if let Some(submitted_at) = pending_apply { if let Some(submitted_at) = pending_apply {
if submitted_at.elapsed() >= apply_delay { if submitted_at.elapsed() >= apply_delay {
app.handle_pod_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: Vec::new(), entries: Vec::new(),
input: vec![Segment::text("rewind-live-refresh")], input: vec![Segment::text("rewind-live-refresh")],
summary: RewindSummary { summary: RewindSummary {
@ -613,7 +614,7 @@ async fn run_e2e_rewind_fixture(
pending_apply = None; pending_apply = None;
let composer_text = Segment::flatten_to_text(&app.input.submit_segments()); let composer_text = Segment::flatten_to_text(&app.input.submit_segments());
crate::e2e_observer::emit( crate::e2e_observer::emit(
"single_pod", "single_worker",
"rewind_applied", "rewind_applied",
serde_json::json!({ serde_json::json!({
"composer_text": composer_text, "composer_text": composer_text,
@ -644,7 +645,7 @@ enum E2eRewindInput {
enum LoopInput<P> { enum LoopInput<P> {
Terminal(TerminalEventResult), Terminal(TerminalEventResult),
Pod(Option<P>), Worker(Option<P>),
} }
async fn next_loop_input<P, F>( async fn next_loop_input<P, F>(
@ -666,15 +667,15 @@ where
)) ))
})) }))
} }
event = pod_next, if connected => LoopInput::Pod(event), event = pod_next, if connected => LoopInput::Worker(event),
} }
} }
async fn drain_terminal_events( async fn drain_terminal_events(
app: &mut App, app: &mut App,
client: &mut PodClient, client: &mut WorkerClient,
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>, term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
runtime_command: &PodRuntimeCommand, runtime_command: &WorkerRuntimeCommand,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false; let mut handled = false;
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT { for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
@ -698,16 +699,16 @@ async fn drain_terminal_events(
Ok(handled) Ok(handled)
} }
async fn drain_pod_events( async fn drain_worker_events(
app: &mut App, app: &mut App,
client: &mut PodClient, client: &mut WorkerClient,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false; let mut handled = false;
for _ in 0..POD_EVENT_DRAIN_LIMIT { for _ in 0..POD_EVENT_DRAIN_LIMIT {
match client.try_next_event() { match client.try_next_event() {
Some(ev) => { Some(ev) => {
handled = true; handled = true;
if let Some(method) = app.handle_pod_event(ev) { if let Some(method) = app.handle_worker_event(ev) {
client.send(&method).await?; client.send(&method).await?;
} }
} }
@ -720,8 +721,8 @@ async fn drain_pod_events(
async fn run_loop( async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App, app: &mut App,
mut client: PodClient, mut client: WorkerClient,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
@ -737,8 +738,8 @@ async fn run_loop(
if app.quit { if app.quit {
break; break;
} }
let handled_pod_event = drain_pod_events(app, &mut client).await?; let handled_worker_event = drain_worker_events(app, &mut client).await?;
if handled_term_event || handled_pod_event { if handled_term_event || handled_worker_event {
terminal.draw(|f| ui::draw(f, app))?; terminal.draw(|f| ui::draw(f, app))?;
continue; continue;
} }
@ -747,9 +748,9 @@ async fn run_loop(
LoopInput::Terminal(term_event) => { LoopInput::Terminal(term_event) => {
handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?; handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?;
} }
LoopInput::Pod(event) => match event { LoopInput::Worker(event) => match event {
Some(ev) => { Some(ev) => {
if let Some(method) = app.handle_pod_event(ev) { if let Some(method) = app.handle_worker_event(ev) {
client.send(&method).await?; client.send(&method).await?;
} }
} }
@ -769,9 +770,9 @@ async fn run_loop(
async fn handle_terminal_event( async fn handle_terminal_event(
app: &mut App, app: &mut App,
client: &mut PodClient, client: &mut WorkerClient,
event: TermEvent, event: TermEvent,
_runtime_command: &PodRuntimeCommand, _runtime_command: &WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
match event { match event {
TermEvent::Key(key) => { TermEvent::Key(key) => {
@ -937,12 +938,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
Some(None) Some(None)
} }
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)), KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(match app.pod_status { KeyCode::Char('x') if ctrl => Some(match app.worker_status {
PodStatus::Running | PodStatus::Paused => { WorkerStatus::Running | WorkerStatus::Paused => {
app.clear_queued_inputs(); app.clear_queued_inputs();
Some(Method::Cancel) Some(Method::Cancel)
} }
PodStatus::Idle => Some(Method::Shutdown), WorkerStatus::Idle => Some(Method::Shutdown),
}), }),
KeyCode::Char('d') if ctrl => { KeyCode::Char('d') if ctrl => {
app.quit = true; app.quit = true;
@ -1196,9 +1197,9 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// Running → send `Method::Pause`. /// Running → send `Method::Pause`.
/// Idle / Paused → 2-tap to quit the TUI (the Pod keeps running). /// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> { fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
if app.pod_status == PodStatus::Running { if app.worker_status == WorkerStatus::Running {
app.clear_queued_inputs(); app.clear_queued_inputs();
return Some(Method::Pause); return Some(Method::Pause);
} }
@ -1211,7 +1212,7 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
} }
app.quit_confirm = Some(std::time::Instant::now()); app.quit_confirm = Some(std::time::Instant::now());
app.flash_actionbar_notice( app.flash_actionbar_notice(
"Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).", "Press Ctrl-C again within 3 s to exit the TUI (the Worker keeps running).",
ActionbarNoticeLevel::Warn, ActionbarNoticeLevel::Warn,
ActionbarNoticeSource::Tui, ActionbarNoticeSource::Tui,
CONFIRM_TIMEOUT, CONFIRM_TIMEOUT,
@ -1226,7 +1227,7 @@ mod tests {
use protocol::{Event, RewindTarget, RewindTargetId, Segment}; use protocol::{Event, RewindTarget, RewindTargetId, Segment};
#[test] #[test]
fn single_pod_mouse_capture_avoids_drag_and_all_motion_modes() { fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() {
let mut ansi = String::new(); let mut ansi = String::new();
Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap(); Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap();
@ -1238,7 +1239,7 @@ mod tests {
#[test] #[test]
fn mouse_drag_updates_selection_state() { fn mouse_drag_updates_selection_state() {
let mut app = App::new("pod".into()); let mut app = App::new("worker".into());
app.text_selection.set_history_snapshot( app.text_selection.set_history_snapshot(
HistoryViewport { HistoryViewport {
x: 1, x: 1,
@ -1285,7 +1286,7 @@ mod tests {
#[test] #[test]
fn esc_clears_selection_without_editing_composer() { fn esc_clears_selection_without_editing_composer() {
let mut app = App::new("pod".into()); let mut app = App::new("worker".into());
app.text_selection.set_history_snapshot( app.text_selection.set_history_snapshot(
HistoryViewport { HistoryViewport {
x: 0, x: 0,
@ -1306,7 +1307,7 @@ mod tests {
#[test] #[test]
fn copy_selection_writes_osc52_and_clears_selection() { fn copy_selection_writes_osc52_and_clears_selection() {
let mut app = App::new("pod".into()); let mut app = App::new("worker".into());
app.text_selection.set_history_snapshot( app.text_selection.set_history_snapshot(
HistoryViewport { HistoryViewport {
x: 0, x: 0,
@ -1333,7 +1334,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn terminal_event_is_selected_before_ready_pod_event() { async fn terminal_event_is_selected_before_ready_worker_event() {
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
tx.send(Ok(TermEvent::Key(KeyEvent::new( tx.send(Ok(TermEvent::Key(KeyEvent::new(
KeyCode::Char('x'), KeyCode::Char('x'),
@ -1345,17 +1346,17 @@ mod tests {
LoopInput::Terminal(Ok(TermEvent::Key(key))) => { LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
assert_eq!(key.code, KeyCode::Char('x')); assert_eq!(key.code, KeyCode::Char('x'));
} }
_ => panic!("ready terminal input should win over a ready Pod event"), _ => panic!("ready terminal input should win over a ready Worker event"),
} }
} }
#[tokio::test] #[tokio::test]
async fn terminal_event_is_preserved_after_pod_event_wins() { async fn terminal_event_is_preserved_after_worker_event_wins() {
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await { match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await {
LoopInput::Pod(Some(1)) => {} LoopInput::Worker(Some(1)) => {}
_ => panic!("expected the first ready Pod event to win before any terminal input"), _ => panic!("expected the first ready Worker event to win before any terminal input"),
} }
tx.send(Ok(TermEvent::Key(KeyEvent::new( tx.send(Ok(TermEvent::Key(KeyEvent::new(
@ -1368,14 +1369,14 @@ mod tests {
LoopInput::Terminal(Ok(TermEvent::Key(key))) => { LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
assert_eq!(key.code, KeyCode::Char('y')); assert_eq!(key.code, KeyCode::Char('y'));
} }
_ => panic!("queued terminal input should not be lost to subsequent Pod events"), _ => panic!("queued terminal input should not be lost to subsequent Worker events"),
} }
} }
#[test] #[test]
fn running_status_still_allows_text_editing() { fn running_status_still_allows_text_editing() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
assert!( assert!(
handle_key( handle_key(
@ -1409,7 +1410,7 @@ mod tests {
#[test] #[test]
fn running_enter_queues_instead_of_sending_run() { fn running_enter_queues_instead_of_sending_run() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
for c in "queued".chars() { for c in "queued".chars() {
assert!( assert!(
handle_key( handle_key(
@ -1430,7 +1431,7 @@ mod tests {
#[test] #[test]
fn queued_input_keybindings_restore_and_clear() { fn queued_input_keybindings_restore_and_clear() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
for c in "edit queued".chars() { for c in "edit queued".chars() {
assert!( assert!(
handle_key( handle_key(
@ -1478,7 +1479,7 @@ mod tests {
#[test] #[test]
fn pause_and_cancel_clear_queued_input() { fn pause_and_cancel_clear_queued_input() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
for c in "queued".chars() { for c in "queued".chars() {
assert!( assert!(
handle_key( handle_key(
@ -1521,7 +1522,7 @@ mod tests {
#[test] #[test]
fn ctrl_x_cancels_paused_turn_without_shutdown() { fn ctrl_x_cancels_paused_turn_without_shutdown() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Paused); app.set_worker_status(WorkerStatus::Paused);
let cancel = handle_key( let cancel = handle_key(
&mut app, &mut app,
@ -1533,7 +1534,7 @@ mod tests {
#[test] #[test]
fn ctrl_x_shutdown_while_idle_is_unchanged() { fn ctrl_x_shutdown_while_idle_is_unchanged() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Idle); app.set_worker_status(WorkerStatus::Idle);
let shutdown = handle_key( let shutdown = handle_key(
&mut app, &mut app,
@ -1854,7 +1855,7 @@ mod tests {
#[test] #[test]
fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() { fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Idle); app.set_worker_status(WorkerStatus::Idle);
let method = handle_key( let method = handle_key(
&mut app, &mut app,
@ -1866,10 +1867,10 @@ mod tests {
let notice = app let notice = app
.current_actionbar_notice(std::time::Instant::now()) .current_actionbar_notice(std::time::Instant::now())
.expect("quit guard notice is active"); .expect("quit guard notice is active");
assert!(notice.text.contains("Pod keeps running")); assert!(notice.text.contains("Worker keeps running"));
assert_eq!(notice.level, ActionbarNoticeLevel::Warn); assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
assert_eq!(notice.source, ActionbarNoticeSource::Tui); assert_eq!(notice.source, ActionbarNoticeSource::Tui);
assert!(!has_alert(&app, "Pod keeps running")); assert!(!has_alert(&app, "Worker keeps running"));
let method = handle_key( let method = handle_key(
&mut app, &mut app,
@ -1889,7 +1890,7 @@ mod tests {
); );
assert!(matches!(idle, Some(Method::ListRewindTargets))); assert!(matches!(idle, Some(Method::ListRewindTargets)));
app.set_pod_status(PodStatus::Paused); app.set_worker_status(WorkerStatus::Paused);
let paused = handle_key( let paused = handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
@ -1901,7 +1902,7 @@ mod tests {
fn ctrl_r_is_rejected_while_running() { fn ctrl_r_is_rejected_while_running() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.connected = true; app.connected = true;
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
let method = handle_key( let method = handle_key(
&mut app, &mut app,
@ -1909,14 +1910,14 @@ mod tests {
); );
assert!(method.is_none()); assert!(method.is_none());
assert!(has_alert(&app, "cannot rewind while the Pod is running")); assert!(has_alert(&app, "cannot rewind while the Worker is running"));
} }
#[test] #[test]
fn rewind_picker_close_returns_to_history_view() { fn rewind_picker_close_returns_to_history_view() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.connected = true; app.connected = true;
app.handle_pod_event(Event::RewindTargets { app.handle_worker_event(Event::RewindTargets {
head_entries: 1, head_entries: 1,
targets: vec![], targets: vec![],
}); });
@ -1927,7 +1928,7 @@ mod tests {
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
); );
assert!(matches!(method, Some(Method::ListRewindTargets))); assert!(matches!(method, Some(Method::ListRewindTargets)));
app.handle_pod_event(Event::RewindTargets { app.handle_worker_event(Event::RewindTargets {
head_entries: 1, head_entries: 1,
targets: vec![], targets: vec![],
}); });
@ -1942,13 +1943,13 @@ mod tests {
#[test] #[test]
fn rewind_applied_reseeds_display_and_restores_composer() { fn rewind_applied_reseeds_display_and_restores_composer() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.handle_pod_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![], entries: vec![],
status: PodStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
}); });
app.handle_pod_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], entries: vec![],
input: vec![Segment::Text { input: vec![Segment::Text {
content: "retry this".into(), content: "retry this".into(),
@ -1968,15 +1969,15 @@ mod tests {
#[test] #[test]
fn rewind_applied_keeps_non_empty_composer() { fn rewind_applied_keeps_non_empty_composer() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.handle_pod_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![], entries: vec![],
status: PodStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
}); });
type_keys(&mut app, "draft"); type_keys(&mut app, "draft");
app.handle_pod_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], entries: vec![],
input: vec![Segment::Text { input: vec![Segment::Text {
content: "retry this".into(), content: "retry this".into(),
@ -2005,11 +2006,11 @@ mod tests {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()])); app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
app.set_pod_status(PodStatus::Paused); app.set_worker_status(WorkerStatus::Paused);
assert!(app.submit_rewind_picker().is_none()); assert!(app.submit_rewind_picker().is_none());
assert!(has_alert( assert!(has_alert(
&app, &app,
"cannot apply rewind while the Pod is paused" "cannot apply rewind while the Worker is paused"
)); ));
} }
@ -2055,7 +2056,7 @@ mod tests {
fn test_greeting() -> protocol::Greeting { fn test_greeting() -> protocol::Greeting {
protocol::Greeting { protocol::Greeting {
pod_name: "agent".into(), worker_name: "agent".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
provider: "test".into(), provider: "test".into(),
model: "test".into(), model: "test".into(),

File diff suppressed because it is too large Load Diff

View File

@ -296,7 +296,7 @@ pub(super) const TICKET_STATE_COLUMN_WIDTH: usize = 10;
pub(super) const POD_STATUS_COLUMN_WIDTH: usize = 18; pub(super) const POD_STATUS_COLUMN_WIDTH: usize = 18;
pub(super) fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec<Line<'static>> { pub(super) fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec<Line<'static>> {
if row.kind == PanelRowKind::TicketIntakePod { if row.kind == PanelRowKind::TicketIntakeWorker {
vec![panel_intake_child_line(row, selected, width)] vec![panel_intake_child_line(row, selected, width)]
} else { } else {
vec![ vec![
@ -438,7 +438,7 @@ pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
return parts.join(" · "); return parts.join(" · ");
} }
if row.kind == PanelRowKind::TicketIntakePod { if row.kind == PanelRowKind::TicketIntakeWorker {
let mut parts = row let mut parts = row
.subtitle .subtitle
.as_ref() .as_ref()
@ -538,8 +538,8 @@ pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
.map(|ticket| ticket.id.clone()) .map(|ticket| ticket.id.clone())
.unwrap_or_else(|| match &row.key { .unwrap_or_else(|| match &row.key {
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(), PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(), PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(),
PanelRowKey::Pod(name) => name.clone(), PanelRowKey::Worker(name) => name.clone(),
}) })
} }
@ -597,7 +597,7 @@ pub(super) fn intake_status_style(status: &str) -> Style {
} }
pub(super) fn section_rows( pub(super) fn section_rows(
list: &PodList, list: &WorkerList,
section: &DashboardSection, section: &DashboardSection,
selected: Option<&PanelRowKey>, selected: Option<&PanelRowKey>,
width: u16, width: u16,
@ -616,7 +616,7 @@ pub(super) fn section_rows(
))); )));
for index in visible { for index in visible {
if let Some(entry) = list.entries.get(index) { if let Some(entry) = list.entries.get(index) {
let key = PanelRowKey::Pod(entry.name.clone()); let key = PanelRowKey::Worker(entry.name.clone());
let selected = selected == Some(&key); let selected = selected == Some(&key);
rows.push(PanelListRow::selectable( rows.push(PanelListRow::selectable(
row_line(entry, selected, width), row_line(entry, selected, width),
@ -627,7 +627,7 @@ pub(super) fn section_rows(
rows rows
} }
pub(super) fn row_line(entry: &PodListEntry, selected: bool, width: u16) -> Line<'static> { pub(super) fn row_line(entry: &WorkerListEntry, selected: bool, width: u16) -> Line<'static> {
let marker = if selected { "" } else { " " }; let marker = if selected { "" } else { " " };
let name_style = if selected { let name_style = if selected {
Style::default() Style::default()

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@
//! //!
//! Display form: paste atoms render as //! Display form: paste atoms render as
//! `[Clipboard #N | X chars, Y lines]`. Submit form: paste atoms expand //! `[Clipboard #N | X chars, Y lines]`. Submit form: paste atoms expand
//! back to their original captured content so the Pod sees the full //! back to their original captured content so the Worker sees the full
//! pasted text (without the placeholder label). //! pasted text (without the placeholder label).
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Style};
@ -33,7 +33,7 @@ impl PasteRef {
} }
/// `@<path>` chip — confirmed completion of a file-system reference. /// `@<path>` chip — confirmed completion of a file-system reference.
/// Directories remain valid chips because Pod resolves normal directory refs /// Directories remain valid chips because Worker resolves normal directory refs
/// to shallow `[Dir: <path>]` listings at submit time. /// to shallow `[Dir: <path>]` listings at submit time.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct FileRefAtom { pub struct FileRefAtom {

View File

@ -12,7 +12,6 @@ mod input;
pub mod keys; pub mod keys;
mod markdown; mod markdown;
mod picker; mod picker;
mod pod_list;
mod role_session_registry; mod role_session_registry;
mod scroll; mod scroll;
pub mod setup_model; pub mod setup_model;
@ -22,6 +21,7 @@ mod text_selection;
mod tool; mod tool;
mod ui; mod ui;
mod view_mode; mod view_mode;
mod worker_list;
mod workspace_panel; mod workspace_panel;
use std::io; use std::io;
@ -33,37 +33,37 @@ use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
use session_store::SegmentId; use session_store::SegmentId;
use client::PodRuntimeCommand; use client::WorkerRuntimeCommand;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct LaunchOptions { pub struct LaunchOptions {
pub mode: LaunchMode, pub mode: LaunchMode,
pub runtime_command: PodRuntimeCommand, pub runtime_command: WorkerRuntimeCommand,
pub workspace_root: PathBuf, pub workspace_root: PathBuf,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum LaunchMode { pub enum LaunchMode {
Spawn { Spawn {
pod_name: Option<String>, worker_name: Option<String>,
profile: Option<String>, profile: Option<String>,
}, },
/// `yoi --pod <name>`: attach to a live Pod by name if possible; /// `yoi --worker <name>`: attach to a live Worker by name if possible;
/// otherwise launch the Pod runtime command with `--pod <name>` so it /// otherwise launch the Worker runtime command with `--worker <name>` so it
/// resumes from name-keyed state or creates a fresh same-name Pod. /// resumes from name-keyed state or creates a fresh same-name Worker.
PodName { WorkerName {
pod_name: String, worker_name: String,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,
}, },
/// `yoi resume`: open the Pod picker, then attach to the selected live Pod /// `yoi resume`: open the Worker picker, then attach to the selected live Worker
/// or restore the selected stopped Pod by name. Without `--all`, the picker /// or restore the selected stopped Worker by name. Without `--all`, the picker
/// is scoped to the current runtime workspace. /// is scoped to the current runtime workspace.
Resume { all: bool }, Resume { all: bool },
/// `yoi --session <UUID>`: skip the picker, go straight to the /// `yoi --session <UUID>`: skip the picker, go straight to the
/// resume name dialog with `id` baked in. /// resume name dialog with `id` baked in.
ResumeWithSession { ResumeWithSession {
id: SegmentId, id: SegmentId,
pod_name: Option<String>, worker_name: Option<String>,
}, },
/// `yoi panel`: open the workspace Dashboard from the current workspace. /// `yoi panel`: open the workspace Dashboard from the current workspace.
Panel, Panel,
@ -95,18 +95,19 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
} }
let result = match mode { let result = match mode {
LaunchMode::Spawn { pod_name, profile } => { LaunchMode::Spawn {
console::run_spawn(None, pod_name, profile, runtime_command).await worker_name,
} profile,
LaunchMode::PodName { } => console::run_spawn(None, worker_name, profile, runtime_command).await,
pod_name, LaunchMode::WorkerName {
worker_name,
socket_override, socket_override,
} => console::run_pod_name(pod_name, socket_override, runtime_command).await, } => console::run_worker_name(worker_name, socket_override, runtime_command).await,
LaunchMode::Resume { all } => { LaunchMode::Resume { all } => {
console::run_resume(runtime_command, workspace_root.clone(), all).await console::run_resume(runtime_command, workspace_root.clone(), all).await
} }
LaunchMode::ResumeWithSession { id, pod_name } => { LaunchMode::ResumeWithSession { id, worker_name } => {
console::run_spawn(Some(id), pod_name, None, runtime_command).await console::run_spawn(Some(id), worker_name, None, runtime_command).await
} }
LaunchMode::Panel => dashboard::launch(runtime_command).await, LaunchMode::Panel => dashboard::launch(runtime_command).await,
}; };
@ -138,7 +139,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
// SpawnError has already been painted into the inline // SpawnError has already been painted into the inline
// viewport's final frame, so it's already visible in the // viewport's final frame, so it's already visible in the
// user's scrollback — printing it again would be a noisy // user's scrollback — printing it again would be a noisy
// duplicate. Other errors (pod-name failures, terminal setup // duplicate. Other errors (worker-name failures, terminal setup
// hiccups, etc.) need surfacing here. // hiccups, etc.) need surfacing here.
if e.downcast_ref::<spawn::SpawnError>().is_none() { if e.downcast_ref::<spawn::SpawnError>().is_none() {
eprintln!("yoi: {e}"); eprintln!("yoi: {e}");

View File

@ -1,15 +1,15 @@
//! Inline-viewport "pick a Pod to attach or restore" UX. //! Inline-viewport "pick a Worker to attach or restore" UX.
//! //!
//! Reads live Pod allocations from the runtime registry and stopped Pod state //! Reads live Worker allocations from the runtime registry and stopped Worker state
//! from the pod-store name-keyed metadata. Picking a live row attaches to //! from the pod-store name-keyed metadata. Picking a live row attaches to
//! its socket; picking a stopped row restores via the Pod runtime command. //! its socket; picking a stopped row restores via the Worker runtime command.
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use pod_store::FsPodStore; use pod_store::FsWorkerStore;
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
@ -19,10 +19,10 @@ use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport}; use ratatui::{Frame, TerminalOptions, Viewport};
use session_store::FsStore; use session_store::FsStore;
use crate::pod_list::{ use crate::worker_list::{
LivePodInfo, PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, StoredPodInfo, LiveWorkerInfo, StoredMetadataState, StoredWorkerInfo, WorkerList, WorkerListEntry,
live_socket_for_pod as pod_list_live_socket_for_pod, read_reachable_live_pod_infos, WorkerVisibilitySource, live_socket_for_pod as worker_list_live_socket_for_pod,
read_stored_pod_infos, read_reachable_live_pod_infos, read_stored_worker_infos,
}; };
const MAX_ROWS: usize = 10; const MAX_ROWS: usize = 10;
@ -32,7 +32,7 @@ const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
pub enum PickerError { pub enum PickerError {
Io(io::Error), Io(io::Error),
Store(session_store::StoreError), Store(session_store::StoreError),
NoPods { all: bool }, NoWorkers { all: bool },
} }
impl std::fmt::Display for PickerError { impl std::fmt::Display for PickerError {
@ -40,13 +40,13 @@ impl std::fmt::Display for PickerError {
match self { match self {
Self::Io(e) => write!(f, "io error: {e}"), Self::Io(e) => write!(f, "io error: {e}"),
Self::Store(e) => write!(f, "session store error: {e}"), Self::Store(e) => write!(f, "session store error: {e}"),
Self::NoPods { all: true } => write!( Self::NoWorkers { all: true } => write!(
f, f,
"no pods found — start a fresh pod with `yoi` and try again" "no workers found — start a fresh Worker with `yoi` and try again"
), ),
Self::NoPods { all: false } => write!( Self::NoWorkers { all: false } => write!(
f, f,
"no pods found in this workspace — use `yoi resume --all` to list all host/data-dir Pods" "no workers found in this workspace — use `yoi resume --all` to list all host/data-dir Workers"
), ),
} }
} }
@ -67,11 +67,11 @@ impl From<session_store::StoreError> for PickerError {
} }
pub enum PickerOutcome { pub enum PickerOutcome {
/// User picked a Pod. `socket_override` is set for live rows when the /// User picked a Worker. `socket_override` is set for live rows when the
/// runtime registry knows the exact socket path; stopped rows leave it /// runtime registry knows the exact socket path; stopped rows leave it
/// empty so the caller restores by spawning the Pod runtime command. /// empty so the caller restores by spawning the Worker runtime command.
Picked { Picked {
pod_name: String, worker_name: String,
socket_override: Option<PathBuf>, socket_override: Option<PathBuf>,
}, },
Cancelled, Cancelled,
@ -103,13 +103,13 @@ enum PickerScope {
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PodRowState { enum WorkerRowState {
Live, Live,
Stopped, Stopped,
Corrupt, Corrupt,
} }
impl PodRowState { impl WorkerRowState {
fn label(self) -> &'static str { fn label(self) -> &'static str {
match self { match self {
Self::Live => "live", Self::Live => "live",
@ -131,22 +131,22 @@ impl PodRowState {
fn list_for_options( fn list_for_options(
options: &PickerOptions, options: &PickerOptions,
stored_pods: Vec<StoredPodInfo>, stored_workers: Vec<StoredWorkerInfo>,
live_pods: Vec<LivePodInfo>, live_workers: Vec<LiveWorkerInfo>,
) -> PodList { ) -> WorkerList {
match &options.scope { match &options.scope {
PickerScope::Workspace(workspace_root) => PodList::from_workspace_sources( PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources(
PodVisibilitySource::ResumePicker, WorkerVisibilitySource::ResumePicker,
stored_pods, stored_workers,
live_pods, live_workers,
None, None,
MAX_ROWS, MAX_ROWS,
workspace_root, workspace_root,
), ),
PickerScope::All => PodList::from_sources( PickerScope::All => WorkerList::from_sources(
PodVisibilitySource::ResumePicker, WorkerVisibilitySource::ResumePicker,
stored_pods, stored_workers,
live_pods, live_workers,
None, None,
MAX_ROWS, MAX_ROWS,
), ),
@ -156,14 +156,14 @@ fn list_for_options(
pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> { pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
let store_dir = default_store_dir()?; let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?; let store = FsStore::new(&store_dir)?;
let pod_store = FsPodStore::new(default_pod_store_dir()?).map_err(io::Error::other)?; let pod_store = FsWorkerStore::new(default_pod_store_dir()?).map_err(io::Error::other)?;
let stored_pods = read_stored_pod_infos(&store, &pod_store)?; let stored_workers = read_stored_worker_infos(&store, &pod_store)?;
let live_pods = read_reachable_live_pod_infos(&store) let live_workers = read_reachable_live_pod_infos(&store)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let mut list = list_for_options(&options, stored_pods, live_pods); let mut list = list_for_options(&options, stored_workers, live_workers);
if list.entries.is_empty() { if list.entries.is_empty() {
return Err(PickerError::NoPods { return Err(PickerError::NoWorkers {
all: matches!(options.scope, PickerScope::All), all: matches!(options.scope, PickerScope::All),
}); });
} }
@ -185,9 +185,9 @@ pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
} }
Some(Action::Submit) => { Some(Action::Submit) => {
close_viewport(&mut terminal)?; close_viewport(&mut terminal)?;
let entry = list.selected_entry().expect("non-empty pod list"); let entry = list.selected_entry().expect("non-empty worker list");
return Ok(PickerOutcome::Picked { return Ok(PickerOutcome::Picked {
pod_name: entry.name.clone(), worker_name: entry.name.clone(),
socket_override: entry.attach_socket_path().map(PathBuf::from), socket_override: entry.attach_socket_path().map(PathBuf::from),
}); });
} }
@ -229,14 +229,14 @@ fn default_pod_store_dir() -> Result<PathBuf, PickerError> {
.ok_or_else(|| { .ok_or_else(|| {
PickerError::Io(io::Error::new( PickerError::Io(io::Error::new(
io::ErrorKind::NotFound, io::ErrorKind::NotFound,
"could not resolve pod state directory \ "could not resolve worker state directory \
(set YOI_HOME, YOI_DATA_DIR, or HOME)", (set YOI_HOME, YOI_DATA_DIR, or HOME)",
)) ))
}) })
} }
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> { pub(crate) fn live_socket_for_pod(worker_name: &str) -> Option<PathBuf> {
pod_list_live_socket_for_pod(pod_name) worker_list_live_socket_for_pod(worker_name)
} }
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> { fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
@ -278,7 +278,7 @@ fn poll_event() -> io::Result<Option<Action>> {
} }
} }
fn draw(f: &mut Frame<'_>, list: &PodList) { fn draw(f: &mut Frame<'_>, list: &WorkerList) {
let area = f.area(); let area = f.area();
let mut constraints: Vec<Constraint> = Vec::with_capacity(list.entries.len() + 3); let mut constraints: Vec<Constraint> = Vec::with_capacity(list.entries.len() + 3);
constraints.push(Constraint::Length(1)); // title constraints.push(Constraint::Length(1)); // title
@ -320,10 +320,10 @@ fn draw(f: &mut Frame<'_>, list: &PodList) {
} }
fn picker_title() -> &'static str { fn picker_title() -> &'static str {
"resume pod pick a pod" "resume worker pick a worker"
} }
fn row_line(entry: &PodListEntry, selected: bool) -> Line<'_> { fn row_line(entry: &WorkerListEntry, selected: bool) -> Line<'_> {
let marker = if selected { "" } else { " " }; let marker = if selected { "" } else { " " };
let name_style = if selected { let name_style = if selected {
Style::default() Style::default()
@ -361,18 +361,18 @@ fn row_line(entry: &PodListEntry, selected: bool) -> Line<'_> {
Line::from(spans) Line::from(spans)
} }
fn row_state(entry: &PodListEntry) -> PodRowState { fn row_state(entry: &WorkerListEntry) -> WorkerRowState {
if entry.live.as_ref().is_some_and(|live| live.reachable) { if entry.live.as_ref().is_some_and(|live| live.reachable) {
return PodRowState::Live; return WorkerRowState::Live;
} }
if entry if entry
.stored .stored
.as_ref() .as_ref()
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_))) .is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_)))
{ {
return PodRowState::Corrupt; return WorkerRowState::Corrupt;
} }
PodRowState::Stopped WorkerRowState::Stopped
} }
fn format_updated_at(updated_at: u64) -> String { fn format_updated_at(updated_at: u64) -> String {
@ -383,7 +383,7 @@ fn format_updated_at(updated_at: u64) -> String {
} }
} }
fn debug_ids(entry: &PodListEntry) -> String { fn debug_ids(entry: &WorkerListEntry) -> String {
let session = entry let session = entry
.summary .summary
.active_session_id .active_session_id
@ -407,20 +407,20 @@ mod tests {
#[test] #[test]
fn picker_title_names_pods_not_sessions() { fn picker_title_names_pods_not_sessions() {
assert_eq!(picker_title(), "resume pod pick a pod"); assert_eq!(picker_title(), "resume worker pick a worker");
} }
#[test] #[test]
fn picker_no_pods_message_mentions_all_for_workspace_scope() { fn picker_no_pods_message_mentions_all_for_workspace_scope() {
let message = PickerError::NoPods { all: false }.to_string(); let message = PickerError::NoWorkers { all: false }.to_string();
assert!(message.contains("no pods found in this workspace")); assert!(message.contains("no workers found in this workspace"));
assert!(message.contains("yoi resume --all")); assert!(message.contains("yoi resume --all"));
} }
#[test] #[test]
fn picker_no_pods_message_keeps_fresh_pod_hint_for_all_scope() { fn picker_no_pods_message_keeps_fresh_pod_hint_for_all_scope() {
let message = PickerError::NoPods { all: true }.to_string(); let message = PickerError::NoWorkers { all: true }.to_string();
assert!(message.contains("start a fresh pod with `yoi`")); assert!(message.contains("start a fresh Worker with `yoi`"));
assert!(!message.contains("yoi resume --all")); assert!(!message.contains("yoi resume --all"));
} }
@ -464,9 +464,9 @@ mod tests {
assert_eq!(names, vec!["current", "other", "legacy"]); assert_eq!(names, vec!["current", "other", "legacy"]);
} }
fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredPodInfo { fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredWorkerInfo {
StoredPodInfo { StoredWorkerInfo {
pod_name: name.to_string(), worker_name: name.to_string(),
metadata_state: StoredMetadataState::Present, metadata_state: StoredMetadataState::Present,
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
@ -479,16 +479,16 @@ mod tests {
#[test] #[test]
fn picker_row_shows_live_pending_preview_and_runtime_segment_id() { fn picker_row_shows_live_pending_preview_and_runtime_segment_id() {
let segment_id = session_store::new_segment_id(); let segment_id = session_store::new_segment_id();
let entry = PodList::from_sources( let entry = WorkerList::from_sources(
PodVisibilitySource::ResumePicker, WorkerVisibilitySource::ResumePicker,
vec![], vec![],
vec![crate::pod_list::LivePodInfo { vec![crate::worker_list::LiveWorkerInfo {
pod_name: "pending".to_string(), worker_name: "pending".to_string(),
socket_path: PathBuf::from("/tmp/pending.sock"), socket_path: PathBuf::from("/tmp/pending.sock"),
status: Some(protocol::PodStatus::Idle), status: Some(protocol::WorkerStatus::Idle),
reachable: true, reachable: true,
segment_id: Some(segment_id), segment_id: Some(segment_id),
summary: crate::pod_list::PodEntrySummary::default(), summary: crate::worker_list::WorkerEntrySummary::default(),
}], }],
None, None,
10, 10,

View File

@ -28,7 +28,7 @@ pub(crate) struct RoleSessionRegistry {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct RoleSessionRecord { pub(crate) struct RoleSessionRecord {
pub role: String, pub role: String,
pub pod_name: String, pub worker_name: String,
pub origin: RoleSessionOrigin, pub origin: RoleSessionOrigin,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
@ -58,7 +58,7 @@ pub(crate) struct TicketClaim {
pub ticket_id: String, pub ticket_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket_slug: Option<String>, pub ticket_slug: Option<String>,
pub pod_name: String, pub worker_name: String,
pub role: String, pub role: String,
} }
@ -89,7 +89,7 @@ impl std::fmt::Display for PanelRegistryError {
Self::TicketAlreadyClaimed(claim) => write!( Self::TicketAlreadyClaimed(claim) => write!(
f, f,
"Ticket {} is already claimed locally by {} ({})", "Ticket {} is already claimed locally by {} ({})",
claim.ticket_id, claim.pod_name, claim.role claim.ticket_id, claim.worker_name, claim.role
), ),
} }
} }
@ -165,33 +165,33 @@ impl PanelRegistryStore {
pub(crate) fn record_session( pub(crate) fn record_session(
&self, &self,
pod_name: impl Into<String>, worker_name: impl Into<String>,
role: impl Into<String>, role: impl Into<String>,
origin: RoleSessionOrigin, origin: RoleSessionOrigin,
session_id: Option<String>, session_id: Option<String>,
related_tickets: impl IntoIterator<Item = RelatedTicketRef>, related_tickets: impl IntoIterator<Item = RelatedTicketRef>,
) -> Result<(), PanelRegistryError> { ) -> Result<(), PanelRegistryError> {
let pod_name = pod_name.into(); let worker_name = worker_name.into();
let role = role.into(); let role = role.into();
let related_tickets: Vec<RelatedTicketRef> = related_tickets.into_iter().collect(); let related_tickets: Vec<RelatedTicketRef> = related_tickets.into_iter().collect();
self.update_registry(|registry| { self.update_registry(|registry| {
let now = now_timestamp_string(); let now = now_timestamp_string();
let mut tickets: BTreeSet<RelatedTicketRef> = registry let mut tickets: BTreeSet<RelatedTicketRef> = registry
.sessions .sessions
.get(&pod_name) .get(&worker_name)
.map(|record| record.related_tickets.iter().cloned().collect()) .map(|record| record.related_tickets.iter().cloned().collect())
.unwrap_or_default(); .unwrap_or_default();
tickets.extend(related_tickets); tickets.extend(related_tickets);
let created_at = registry let created_at = registry
.sessions .sessions
.get(&pod_name) .get(&worker_name)
.map(|record| record.created_at.clone()) .map(|record| record.created_at.clone())
.unwrap_or_else(|| now.clone()); .unwrap_or_else(|| now.clone());
registry.sessions.insert( registry.sessions.insert(
pod_name.clone(), worker_name.clone(),
RoleSessionRecord { RoleSessionRecord {
role, role,
pod_name, worker_name,
origin, origin,
created_at, created_at,
updated_at: now, updated_at: now,
@ -207,7 +207,7 @@ impl PanelRegistryStore {
&self, &self,
ticket_id: &str, ticket_id: &str,
ticket_slug: Option<&str>, ticket_slug: Option<&str>,
pod_name: &str, worker_name: &str,
role: &str, role: &str,
) -> Result<TicketClaimResult, PanelRegistryError> { ) -> Result<TicketClaimResult, PanelRegistryError> {
fs::create_dir_all(self.claims_dir())?; fs::create_dir_all(self.claims_dir())?;
@ -215,13 +215,13 @@ impl PanelRegistryStore {
let claim = TicketClaim { let claim = TicketClaim {
ticket_id: ticket_id.to_string(), ticket_id: ticket_id.to_string(),
ticket_slug: ticket_slug.map(ToOwned::to_owned), ticket_slug: ticket_slug.map(ToOwned::to_owned),
pod_name: pod_name.to_string(), worker_name: worker_name.to_string(),
role: role.to_string(), role: role.to_string(),
}; };
match self.create_claim_file(&claim_path, &claim) { match self.create_claim_file(&claim_path, &claim) {
Ok(()) => { Ok(()) => {
if let Err(error) = self.record_session( if let Err(error) = self.record_session(
pod_name.to_string(), worker_name.to_string(),
role.to_string(), role.to_string(),
RoleSessionOrigin::TicketClaim, RoleSessionOrigin::TicketClaim,
None, None,
@ -237,7 +237,7 @@ impl PanelRegistryStore {
} }
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
let existing = self.load_claim(ticket_id)?; let existing = self.load_claim(ticket_id)?;
if existing.pod_name == pod_name && existing.role == role { if existing.worker_name == worker_name && existing.role == role {
Ok(TicketClaimResult::AlreadyOwned(existing)) Ok(TicketClaimResult::AlreadyOwned(existing))
} else { } else {
Err(PanelRegistryError::TicketAlreadyClaimed(existing)) Err(PanelRegistryError::TicketAlreadyClaimed(existing))
@ -485,7 +485,7 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(matches!(error, PanelRegistryError::TicketAlreadyClaimed(_))); assert!(matches!(error, PanelRegistryError::TicketAlreadyClaimed(_)));
let claim = store.claim_for_ticket("T-1").unwrap().unwrap(); let claim = store.claim_for_ticket("T-1").unwrap().unwrap();
assert_eq!(claim.pod_name, "ticket-one-intake"); assert_eq!(claim.worker_name, "ticket-one-intake");
assert_eq!(claim.ticket_slug.as_deref(), Some("ticket-one")); assert_eq!(claim.ticket_slug.as_deref(), Some("ticket-one"));
} }
@ -526,12 +526,12 @@ mod tests {
let preticket = snapshot let preticket = snapshot
.sessions .sessions
.iter() .iter()
.find(|session| session.pod_name == "ticket-intake-preticket") .find(|session| session.worker_name == "ticket-intake-preticket")
.unwrap(); .unwrap();
let shared = snapshot let shared = snapshot
.sessions .sessions
.iter() .iter()
.find(|session| session.pod_name == "ticket-intake-shared") .find(|session| session.worker_name == "ticket-intake-shared")
.unwrap(); .unwrap();
assert!(preticket.related_tickets.is_empty()); assert!(preticket.related_tickets.is_empty());

View File

@ -99,7 +99,7 @@ fn prompt_model_choice(
println!("yoi setup-model"); println!("yoi setup-model");
println!(); println!();
println!("Choose the default model Profile to write under the user config directory."); println!("Choose the default model Profile to write under the user config directory.");
println!("This command only writes Profile config; it does not start or attach a Pod."); println!("This command only writes Profile config; it does not start or attach a Worker.");
println!(); println!();
for (idx, choice) in choices.iter().enumerate() { for (idx, choice) in choices.iter().enumerate() {
println!( println!(
@ -237,7 +237,7 @@ return profile {{
task = {{ enabled = true }}, task = {{ enabled = true }},
memory = {{ enabled = true }}, memory = {{ enabled = true }},
web = {{ enabled = true }}, web = {{ enabled = true }},
pods = {{ enabled = false }}, workers = {{ enabled = false }},
ticket = {{ enabled = false, access = "lifecycle" }}, ticket = {{ enabled = false, access = "lifecycle" }},
ticket_orchestration = {{ enabled = false }}, ticket_orchestration = {{ enabled = false }},
}}, }},

View File

@ -1,9 +1,9 @@
//! Inline-viewport "spawn Pod and attach" UX. //! Inline-viewport "spawn Worker and attach" UX.
//! //!
//! Rendered at the user's current cursor position when `yoi` is invoked //! Rendered at the user's current cursor position when `yoi` is invoked
//! with no positional argument. Discovers `.yoi/profiles.toml` profile //! with no positional argument. Discovers `.yoi/profiles.toml` profile
//! choices plus bundled profiles, defaults to the builtin profile, prompts for //! choices plus bundled profiles, defaults to the builtin profile, prompts for
//! the Pod's name, and on confirmation launches the Pod runtime command as an //! the Worker's name, and on confirmation launches the Worker runtime command as an
//! independent process. Once the process reports its socket via the //! independent process. Once the process reports its socket via the
//! `YOI-READY` stderr line, the dialog hands control back so main can //! `YOI-READY` stderr line, the dialog hands control back so main can
//! switch the terminal to alternate-screen mode. //! switch the terminal to alternate-screen mode.
@ -15,7 +15,7 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use client::{PodRuntimeCommand, SpawnConfig, spawn_pod}; use client::{SpawnConfig, WorkerRuntimeCommand, spawn_worker};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::ProfileDiscovery; use manifest::ProfileDiscovery;
use ratatui::Terminal; use ratatui::Terminal;
@ -30,7 +30,7 @@ use session_store::SegmentId;
const VIEWPORT_LINES: u16 = 6; const VIEWPORT_LINES: u16 = 6;
pub struct SpawnReady { pub struct SpawnReady {
pub pod_name: String, pub worker_name: String,
pub socket_path: PathBuf, pub socket_path: PathBuf,
} }
@ -71,13 +71,13 @@ impl From<client::SpawnError> for SpawnError {
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>; type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Source session for a resume run. `None` = fresh spawn (current /// Source session for a resume run. `None` = fresh spawn (current
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and /// behaviour); `Some(id)` swaps the dialog into "Resume Worker" mode and
/// passes `--session <id>` to the spawned Pod runtime child. /// passes `--session <id>` to the spawned Worker runtime child.
pub async fn run( pub async fn run(
resume_from: Option<SegmentId>, resume_from: Option<SegmentId>,
pod_name: Option<String>, worker_name: Option<String>,
profile: Option<String>, profile: Option<String>,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> { ) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?; let defaults = load_spawn_defaults()?;
let mut profile_choices = if resume_from.is_some() { let mut profile_choices = if resume_from.is_some() {
@ -91,7 +91,7 @@ pub async fn run(
defaults.default_profile_index, defaults.default_profile_index,
); );
let selected_name = pod_name.unwrap_or(defaults.default_name); let selected_name = worker_name.unwrap_or(defaults.default_name);
let immediate = resume_from.is_some() || profile.is_some() && !selected_name.is_empty(); let immediate = resume_from.is_some() || profile.is_some() && !selected_name.is_empty();
let mut form = Form { let mut form = Form {
cwd: defaults.cwd.clone(), cwd: defaults.cwd.clone(),
@ -145,18 +145,18 @@ pub async fn run(
))); )));
} }
// Phase 2: launch pod and wait for ready line. Drop the cursor // Phase 2: launch worker and wait for ready line. Drop the cursor
// out of the name field — subsequent frames are passive status // out of the name field — subsequent frames are passive status
// updates, not input — so the cursor doesn't end up parked there // updates, not input — so the cursor doesn't end up parked there
// when the inline terminal is finally dropped. // when the inline terminal is finally dropped.
form.editing = false; form.editing = false;
form.message = Some(("starting pod...".to_string(), MessageKind::Progress)); form.message = Some(("starting worker...".to_string(), MessageKind::Progress));
terminal.draw(|f| draw_form(f, &form))?; terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await { match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
Ok(ready) => { Ok(ready) => {
form.message = Some(( form.message = Some((
format!("ready: {} attaching...", ready.pod_name), format!("ready: {} attaching...", ready.worker_name),
MessageKind::Ok, MessageKind::Ok,
)); ));
terminal.draw(|f| draw_form(f, &form))?; terminal.draw(|f| draw_form(f, &form))?;
@ -172,22 +172,22 @@ pub async fn run(
} }
} }
/// Launch a Pod runtime command with `--pod <name>` without opening the name dialog. The child Pod /// Launch a Worker runtime command with `--worker <name>` without opening the name dialog. The child Worker
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod /// resolves persisted Worker metadata if present, or creates a fresh same-name Worker
/// from the default profile. /// from the default profile.
pub async fn run_pod_name( pub async fn run_worker_name(
pod_name: String, worker_name: String,
runtime_command: PodRuntimeCommand, runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> { ) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?; let defaults = load_spawn_defaults()?;
let mut form = form_for_pod_name(pod_name, defaults); let mut form = form_for_worker_name(worker_name, defaults);
let mut terminal = make_inline_terminal()?; let mut terminal = make_inline_terminal()?;
terminal.draw(|f| draw_form(f, &form))?; terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await { match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
Ok(ready) => { Ok(ready) => {
form.message = Some(( form.message = Some((
format!("ready: {} attaching...", ready.pod_name), format!("ready: {} attaching...", ready.worker_name),
MessageKind::Ok, MessageKind::Ok,
)); ));
terminal.draw(|f| draw_form(f, &form))?; terminal.draw(|f| draw_form(f, &form))?;
@ -226,7 +226,7 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.map(sanitise_default_name) .map(sanitise_default_name)
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "pod".to_string()); .unwrap_or_else(|| "worker".to_string());
let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd); let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd);
@ -290,13 +290,13 @@ fn initial_profile_index(
choices.len() - 1 choices.len() - 1
} }
fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form { fn form_for_worker_name(worker_name: String, defaults: SpawnDefaults) -> Form {
Form { Form {
cwd: defaults.cwd, cwd: defaults.cwd,
scope_origin: defaults.scope_origin, scope_origin: defaults.scope_origin,
name_cursor: pod_name.chars().count(), name_cursor: worker_name.chars().count(),
name: pod_name, name: worker_name,
message: Some(("resuming pod...".to_string(), MessageKind::Progress)), message: Some(("resuming worker...".to_string(), MessageKind::Progress)),
editing: false, editing: false,
resume_from: None, resume_from: None,
profile_choices: Vec::new(), profile_choices: Vec::new(),
@ -359,7 +359,7 @@ fn poll_event() -> io::Result<Option<Action>> {
} }
fn is_safe_name_char(c: char) -> bool { fn is_safe_name_char(c: char) -> bool {
// Filesystem-safe; pod.name becomes a runtime-dir name. // Filesystem-safe; worker.name becomes a runtime-dir name.
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')
} }
@ -372,23 +372,23 @@ fn sanitise_default_name(s: &str) -> String {
async fn wait_for_ready( async fn wait_for_ready(
terminal: &mut InlineTerminal, terminal: &mut InlineTerminal,
form: &mut Form, form: &mut Form,
runtime_command: &PodRuntimeCommand, runtime_command: &WorkerRuntimeCommand,
) -> Result<SpawnReady, SpawnError> { ) -> Result<SpawnReady, SpawnError> {
let config = SpawnConfig { let config = SpawnConfig {
runtime_command: runtime_command.clone(), runtime_command: runtime_command.clone(),
pod_name: form.name.clone(), worker_name: form.name.clone(),
profile: form.selected_profile_selector(), profile: form.selected_profile_selector(),
workspace_root: form.cwd.clone(), workspace_root: form.cwd.clone(),
cwd: None, cwd: None,
resume_from: form.resume_from, resume_from: form.resume_from,
}; };
let ready = spawn_pod(config, |line| { let ready = spawn_worker(config, |line| {
form.message = Some((line.to_string(), MessageKind::Progress)); form.message = Some((line.to_string(), MessageKind::Progress));
let _ = terminal.draw(|f| draw_form(f, form)); let _ = terminal.draw(|f| draw_form(f, form));
}) })
.await?; .await?;
Ok(SpawnReady { Ok(SpawnReady {
pod_name: ready.pod_name, worker_name: ready.worker_name,
socket_path: ready.socket_path, socket_path: ready.socket_path,
}) })
} }
@ -421,14 +421,14 @@ struct Form {
/// cursor stays out so it does not collide with the shell prompt /// cursor stays out so it does not collide with the shell prompt
/// after the inline terminal is dropped. /// after the inline terminal is dropped.
editing: bool, editing: bool,
/// `Some(id)` flips the dialog into "Resume Pod" mode: the title /// `Some(id)` flips the dialog into "Resume Worker" mode: the title
/// switches, the source session is shown to the user, and the /// switches, the source session is shown to the user, and the
/// child pod is launched with `--session <id>` so it restores /// child worker is launched with `--session <id>` so it restores
/// from `id` and appends to the same session log. /// from `id` and appends to the same session log.
resume_from: Option<SegmentId>, resume_from: Option<SegmentId>,
/// Optional profile choices passed with `--profile` for /// Optional profile choices passed with `--profile` for
/// fresh spawns. This is not used for resume/attach flows because those must /// fresh spawns. This is not used for resume/attach flows because those must
/// restore Pod state rather than re-evaluate a profile source. /// restore Worker state rather than re-evaluate a profile source.
profile_choices: Vec<ProfileChoice>, profile_choices: Vec<ProfileChoice>,
profile_index: usize, profile_index: usize,
} }
@ -526,8 +526,8 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
.split(area); .split(area);
let title_text = match form.resume_from { let title_text = match form.resume_from {
Some(id) => format!("resume pod session: {}", short_segment(id)), Some(id) => format!("resume worker session: {}", short_segment(id)),
None => "spawn pod".to_string(), None => "spawn worker".to_string(),
}; };
let title = Paragraph::new(Line::from(vec![Span::styled( let title = Paragraph::new(Line::from(vec![Span::styled(
title_text, title_text,
@ -633,7 +633,7 @@ mod tests {
} }
#[test] #[test]
fn pod_name_form_restores_or_creates_by_pod_name() { fn worker_name_form_restores_or_creates_by_worker_name() {
let defaults = SpawnDefaults { let defaults = SpawnDefaults {
cwd: PathBuf::from("/work/example"), cwd: PathBuf::from("/work/example"),
scope_origin: ScopeOrigin::FromProfile, scope_origin: ScopeOrigin::FromProfile,
@ -641,7 +641,7 @@ mod tests {
default_profile_index: 0, default_profile_index: 0,
profile_choices: Vec::new(), profile_choices: Vec::new(),
}; };
let f = form_for_pod_name("agent".to_string(), defaults); let f = form_for_worker_name("agent".to_string(), defaults);
assert_eq!(f.name, "agent"); assert_eq!(f.name, "agent");
assert_eq!(f.name_cursor, "agent".chars().count()); assert_eq!(f.name_cursor, "agent".chars().count());
@ -649,7 +649,7 @@ mod tests {
assert!(!f.editing); assert!(!f.editing);
assert_eq!( assert_eq!(
f.message, f.message,
Some(("resuming pod...".to_string(), MessageKind::Progress)) Some(("resuming worker...".to_string(), MessageKind::Progress))
); );
} }

View File

@ -1,18 +1,18 @@
//! In-TUI mirror of the session-lifetime task store. //! In-TUI mirror of the session-lifetime task store.
//! //!
//! This deliberately does NOT depend on the Pod TaskStore. The TUI is a //! This deliberately does NOT depend on the Worker TaskStore. The TUI is a
//! presentation layer; pulling in `pod` would drag along the runtime //! presentation layer; pulling in `worker` would drag along the runtime
//! feature surface. Instead we mirror the small subset we //! feature surface. Instead we mirror the small subset we
//! need: //! need:
//! //!
//! - `TaskEntry` / `TaskStatus`: shaped to round-trip with Pod Task JSON //! - `TaskEntry` / `TaskStatus`: shaped to round-trip with Worker Task JSON
//! serialization (`#[serde(rename_all = "lowercase")]` on the status, //! serialization (`#[serde(rename_all = "lowercase")]` on the status,
//! matching field names on the entry). //! matching field names on the entry).
//! - Just enough state machine to apply `TaskCreate` / `TaskUpdate` //! - Just enough state machine to apply `TaskCreate` / `TaskUpdate`
//! tool-call arguments and the `[Session TaskStore snapshot]` system //! tool-call arguments and the `[Session TaskStore snapshot]` system
//! message that compaction emits. //! message that compaction emits.
//! //!
//! The snapshot text format is owned by the Pod Task feature. The TUI keeps //! The snapshot text format is owned by the Worker Task feature. The TUI keeps
//! local compatibility fixtures for the `[Session TaskStore snapshot]` system //! local compatibility fixtures for the `[Session TaskStore snapshot]` system
//! message shape emitted during compaction and restored on resume. //! message shape emitted during compaction and restored on resume.
@ -90,7 +90,7 @@ impl TaskStore {
/// Apply a completed `TaskCreate` / `TaskUpdate` tool_call. Other /// Apply a completed `TaskCreate` / `TaskUpdate` tool_call. Other
/// tool names and unparseable JSON are silent no-ops, matching the /// tool names and unparseable JSON are silent no-ops, matching the
/// resilience of the Pod TaskStore history replay. /// resilience of the Worker TaskStore history replay.
pub fn apply_tool_call(&mut self, name: &str, arguments: &str) { pub fn apply_tool_call(&mut self, name: &str, arguments: &str) {
match name { match name {
"TaskCreate" => { "TaskCreate" => {
@ -236,8 +236,8 @@ mod tests {
assert_eq!(c.active(), 2); assert_eq!(c.active(), 2);
} }
/// Snapshot text matches the wrapping `Pod::try_pre_run_compact` and the /// Snapshot text matches the wrapping `Worker::try_pre_run_compact` and the
/// Pod Task feature snapshot fixture shape: header line, blank, overview /// Worker Task feature snapshot fixture shape: header line, blank, overview
/// line, blank, fenced JSON, trailing prose. /// line, blank, fenced JSON, trailing prose.
fn wrap_snapshot(json_body: &str, overview: &str) -> String { fn wrap_snapshot(json_body: &str, overview: &str) -> String {
format!( format!(
@ -314,16 +314,16 @@ mod tests {
} }
/// Snapshot format compatibility tests. The TUI deliberately re-implements a /// Snapshot format compatibility tests. The TUI deliberately re-implements a
/// stripped-down TaskStore mirror instead of depending on the Pod Task feature; /// stripped-down TaskStore mirror instead of depending on the Worker Task feature;
/// it only consumes task tool calls and `[Session TaskStore snapshot]` system /// it only consumes task tool calls and `[Session TaskStore snapshot]` system
/// messages. These fixtures encode the Pod-owned Task snapshot JSON/text shape /// messages. These fixtures encode the Worker-owned Task snapshot JSON/text shape
/// so accidental TUI parser drift still fails locally without making `tui` /// so accidental TUI parser drift still fails locally without making `tui`
/// depend on `pod` or `tools`. /// depend on `worker` or `tools`.
#[cfg(test)] #[cfg(test)]
mod snapshot_format_contract { mod snapshot_format_contract {
use super::*; use super::*;
/// Mirrors the envelope `Pod::try_pre_run_compact` wraps the raw /// Mirrors the envelope `Worker::try_pre_run_compact` wraps the raw
/// snapshot text in. Hand-rolled here so the test fails loudly if /// snapshot text in. Hand-rolled here so the test fails loudly if
/// the prose around the JSON fence ever shifts. /// the prose around the JSON fence ever shifts.
fn wrap_pod_style(snapshot_text: &str) -> String { fn wrap_pod_style(snapshot_text: &str) -> String {
@ -397,7 +397,7 @@ mod snapshot_format_contract {
#[test] #[test]
fn taskentry_field_shape_deserializes_into_tui_taskentry() { fn taskentry_field_shape_deserializes_into_tui_taskentry() {
// A single Pod TaskEntry as JSON. Field renames like `taskid` → // A single Worker TaskEntry as JSON. Field renames like `taskid` →
// `task_id` or status case changes surface here as serde failures or // `task_id` or status case changes surface here as serde failures or
// wrong-status assertions. // wrong-status assertions.
let json = r#"{ let json = r#"{

View File

@ -1,4 +1,4 @@
//! Local, non-persistent text selection state for the single-Pod transcript view. //! Local, non-persistent text selection state for the single-Worker transcript view.
//! //!
//! This module deliberately stores only the most recent rendered history rows and //! This module deliberately stores only the most recent rendered history rows and
//! the active drag endpoints. Selected/copied text never leaves TUI-local state //! the active drag endpoints. Selected/copied text never leaves TUI-local state

View File

@ -25,7 +25,7 @@ use ratatui::widgets::{
}; };
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment}; use protocol::{AlertLevel, CompletionEntry, Greeting, Segment, WorkerEvent};
use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, fmt_tokens}; use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, fmt_tokens};
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
@ -509,7 +509,7 @@ fn draw_rewind_picker(
.fg(Color::Yellow) .fg(Color::Yellow)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw(" waiting for Pod response"), Span::raw(" waiting for Worker response"),
] ]
} else { } else {
vec![ vec![
@ -871,7 +871,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
match block { match block {
Block::Greeting(g) => match mode { Block::Greeting(g) => match mode {
Mode::Overview => { Mode::Overview => {
let text = format!("{} {} ({})", g.pod_name, g.model, g.provider); let text = format!("{} {} ({})", g.worker_name, g.model, g.provider);
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
text, text,
Style::default().fg(Color::Cyan), Style::default().fg(Color::Cyan),
@ -894,8 +894,8 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
_ => push_padded_lines(lines, &text, MessageKind::Notify), _ => push_padded_lines(lines, &text, MessageKind::Notify),
} }
} }
Block::PodEvent { event } => { Block::WorkerEvent { event } => {
let text = format_pod_event(event); let text = format_worker_event(event);
match mode { match mode {
Mode::Overview => push_overview_line(lines, &text, width, MessageKind::Notify, ""), Mode::Overview => push_overview_line(lines, &text, width, MessageKind::Notify, ""),
_ => push_padded_lines(lines, &text, MessageKind::Notify), _ => push_padded_lines(lines, &text, MessageKind::Notify),
@ -1595,7 +1595,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
conn, conn,
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(
app.pod_name.clone(), app.worker_name.clone(),
Style::default().add_modifier(Modifier::BOLD), Style::default().add_modifier(Modifier::BOLD),
), ),
]; ];
@ -1823,7 +1823,7 @@ fn greeting_lines(g: &Greeting) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
g.pod_name.clone(), g.worker_name.clone(),
Style::default() Style::default()
.fg(Color::Green) .fg(Color::Green)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@ -1856,9 +1856,9 @@ fn greeting_lines(g: &Greeting) -> Vec<Line<'static>> {
pub enum MessageKind { pub enum MessageKind {
TurnHeader, TurnHeader,
User, User,
/// External-input echoes (`Method::Notify` / `Method::PodEvent`). /// External-input echoes (`Method::Notify` / `Method::WorkerEvent`).
/// Visually distinct from User / Assistant / Notice so it's clear /// Visually distinct from User / Assistant / Notice so it's clear
/// the line came from another Pod or operator, not the local user. /// the line came from another Worker or operator, not the local user.
Notify, Notify,
/// Persisted role:system history item preview. /// Persisted role:system history item preview.
System, System,
@ -1891,27 +1891,30 @@ pub fn kind_style(kind: MessageKind) -> Style {
} }
} }
/// One-line summary of a `PodEvent` for display in the activity log. /// One-line summary of a `WorkerEvent` for display in the activity log.
/// Independent from the LLM-injection wrapper (`crate::ipc::event::render_event` /// Independent from the LLM-injection wrapper (`crate::ipc::event::render_event`
/// in the pod crate) — that path applies prompt-pack wrapping, while /// in the worker crate) — that path applies prompt-pack wrapping, while
/// this is the human-facing rendering of the raw structured event. /// this is the human-facing rendering of the raw structured event.
fn format_pod_event(event: &PodEvent) -> String { fn format_worker_event(event: &WorkerEvent) -> String {
match event { match event {
PodEvent::TurnEnded { pod_name } => { WorkerEvent::TurnEnded { worker_name } => {
format!("[pod_event] {pod_name} → turn_ended") format!("[worker_event] {worker_name} → turn_ended")
} }
PodEvent::Errored { pod_name, message } => { WorkerEvent::Errored {
format!("[pod_event] {pod_name} → errored: {message}") worker_name,
message,
} => {
format!("[worker_event] {worker_name} → errored: {message}")
} }
PodEvent::ShutDown { pod_name } => { WorkerEvent::ShutDown { worker_name } => {
format!("[pod_event] {pod_name} → shut_down") format!("[worker_event] {worker_name} → shut_down")
} }
PodEvent::ScopeSubDelegated { WorkerEvent::ScopeSubDelegated {
parent_pod, parent_worker,
sub_pod, sub_worker,
.. ..
} => { } => {
format!("[pod_event] {parent_pod} → scope_sub_delegated: {sub_pod}") format!("[worker_event] {parent_worker} → scope_sub_delegated: {sub_worker}")
} }
} }
} }
@ -1920,13 +1923,13 @@ fn format_pod_event(event: &PodEvent) -> String {
mod tests { mod tests {
use super::*; use super::*;
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use protocol::PodStatus; use protocol::WorkerStatus;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[test] #[test]
fn queue_status_text_includes_count_and_preview() { fn queue_status_text_includes_count_and_preview() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
for c in "queued preview".chars() { for c in "queued preview".chars() {
app.insert_char(c); app.insert_char(c);
} }
@ -1952,7 +1955,7 @@ mod tests {
app.latest_llm_wait_event = Some("retrying LLM request".into()); app.latest_llm_wait_event = Some("retrying LLM request".into());
app.latest_memory_worker_event = Some("memory extract running".into()); app.latest_memory_worker_event = Some("memory extract running".into());
app.flash_actionbar_notice_at( app.flash_actionbar_notice_at(
"Pod keeps running. Press Ctrl-C again to exit TUI.", "Worker keeps running. Press Ctrl-C again to exit TUI.",
ActionbarNoticeLevel::Warn, ActionbarNoticeLevel::Warn,
ActionbarNoticeSource::Tui, ActionbarNoticeSource::Tui,
now, now,
@ -1961,10 +1964,10 @@ mod tests {
assert_eq!( assert_eq!(
actionbar_left_item(&app, now).map(|(text, _)| text), actionbar_left_item(&app, now).map(|(text, _)| text),
Some("Pod keeps running. Press Ctrl-C again to exit TUI.".into()) Some("Worker keeps running. Press Ctrl-C again to exit TUI.".into())
); );
app.set_pod_status(PodStatus::Running); app.set_worker_status(WorkerStatus::Running);
for c in "queued turn".chars() { for c in "queued turn".chars() {
app.insert_char(c); app.insert_char(c);
} }
@ -2037,7 +2040,7 @@ mod tests {
#[test] #[test]
fn consecutive_thinking_blocks_render_as_one_normal_group() { fn consecutive_thinking_blocks_render_as_one_normal_group() {
let mut app = App::new("pod".to_string()); let mut app = App::new("worker".to_string());
app.mode = Mode::Normal; app.mode = Mode::Normal;
app.blocks = vec![finished_thinking("alpha"), finished_thinking("beta")]; app.blocks = vec![finished_thinking("alpha"), finished_thinking("beta")];
@ -2055,7 +2058,7 @@ mod tests {
#[test] #[test]
fn thinking_group_detail_keeps_each_body_readable() { fn thinking_group_detail_keeps_each_body_readable() {
let mut app = App::new("pod".to_string()); let mut app = App::new("worker".to_string());
app.mode = Mode::Detail; app.mode = Mode::Detail;
app.blocks = vec![ app.blocks = vec![
finished_thinking("alpha line 1\nalpha line 2"), finished_thinking("alpha line 1\nalpha line 2"),
@ -2074,7 +2077,7 @@ mod tests {
#[test] #[test]
fn non_thinking_separator_breaks_thinking_group() { fn non_thinking_separator_breaks_thinking_group() {
let mut app = App::new("pod".to_string()); let mut app = App::new("worker".to_string());
app.mode = Mode::Normal; app.mode = Mode::Normal;
app.blocks = vec![ app.blocks = vec![
finished_thinking("alpha"), finished_thinking("alpha"),
@ -2097,7 +2100,7 @@ mod tests {
#[test] #[test]
fn turn_header_breaks_thinking_group() { fn turn_header_breaks_thinking_group() {
let mut app = App::new("pod".to_string()); let mut app = App::new("worker".to_string());
app.mode = Mode::Normal; app.mode = Mode::Normal;
app.blocks = vec![ app.blocks = vec![
Block::TurnHeader { turn: 1 }, Block::TurnHeader { turn: 1 },
@ -2119,7 +2122,7 @@ mod tests {
#[test] #[test]
fn thinking_group_preserves_streaming_and_incomplete_state_visibility() { fn thinking_group_preserves_streaming_and_incomplete_state_visibility() {
let mut app = App::new("pod".to_string()); let mut app = App::new("worker".to_string());
app.mode = Mode::Normal; app.mode = Mode::Normal;
app.blocks = vec![ app.blocks = vec![
finished_thinking("finished"), finished_thinking("finished"),
@ -2141,7 +2144,7 @@ mod tests {
#[test] #[test]
fn single_thinking_block_rendering_stays_unchanged() { fn single_thinking_block_rendering_stays_unchanged() {
let mut app = App::new("pod".to_string()); let mut app = App::new("worker".to_string());
app.mode = Mode::Normal; app.mode = Mode::Normal;
app.blocks = vec![Block::Thinking(ThinkingBlock { app.blocks = vec![Block::Thinking(ThinkingBlock {
text: "private reasoning".to_string(), text: "private reasoning".to_string(),
@ -2159,7 +2162,7 @@ mod tests {
fn single_tool_block_rendering_stays_unchanged() { fn single_tool_block_rendering_stays_unchanged() {
use crate::block::{ToolCallBlock, ToolCallState}; use crate::block::{ToolCallBlock, ToolCallState};
let mut app = App::new("pod".to_string()); let mut app = App::new("worker".to_string());
app.mode = Mode::Normal; app.mode = Mode::Normal;
app.blocks = vec![Block::ToolCall(ToolCallBlock { app.blocks = vec![Block::ToolCall(ToolCallBlock {
id: "bash-1".to_string(), id: "bash-1".to_string(),
@ -2183,7 +2186,7 @@ mod tests {
fn read_tool_aggregation_still_consumes_consecutive_tool_blocks() { fn read_tool_aggregation_still_consumes_consecutive_tool_blocks() {
use crate::block::{ToolCallBlock, ToolCallState}; use crate::block::{ToolCallBlock, ToolCallState};
let mut app = App::new("pod".to_string()); let mut app = App::new("worker".to_string());
app.mode = Mode::Normal; app.mode = Mode::Normal;
app.blocks = vec![ app.blocks = vec![
Block::ToolCall(ToolCallBlock { Block::ToolCall(ToolCallBlock {
@ -2225,7 +2228,7 @@ mod tests {
#[test] #[test]
fn history_rows_mark_text_items_selectable_and_non_text_unselectable() { fn history_rows_mark_text_items_selectable_and_non_text_unselectable() {
let mut app = App::new("pod".to_string()); let mut app = App::new("worker".to_string());
app.blocks = vec![ app.blocks = vec![
Block::UserMessage { Block::UserMessage {
segments: vec![Segment::Text { segments: vec![Segment::Text {

View File

@ -3,45 +3,45 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::Duration; use std::time::Duration;
use client::PodClient; use client::WorkerClient;
use pod_registry::{LockFileGuard, default_registry_path}; use pod_registry::{LockFileGuard, default_registry_path};
use pod_store::{PodActiveSegmentRef, PodMetadata, PodMetadataStore}; use pod_store::{WorkerActiveSegmentRef, WorkerMetadata, WorkerMetadataStore};
use protocol::{Event, PodStatus}; use protocol::{Event, WorkerStatus};
use session_store::{FsStore, SegmentId, SessionId}; use session_store::{FsStore, SegmentId, SessionId};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PodList { pub(crate) struct WorkerList {
pub entries: Vec<PodListEntry>, pub entries: Vec<WorkerListEntry>,
pub selected_name: Option<String>, pub selected_name: Option<String>,
} }
impl PodList { impl WorkerList {
pub(crate) fn from_sources( pub(crate) fn from_sources(
source: PodVisibilitySource, source: WorkerVisibilitySource,
stored: Vec<StoredPodInfo>, stored: Vec<StoredWorkerInfo>,
live: Vec<LivePodInfo>, live: Vec<LiveWorkerInfo>,
selected_name: Option<String>, selected_name: Option<String>,
max_entries: usize, max_entries: usize,
) -> Self { ) -> Self {
let mut entries_by_name: BTreeMap<String, PodListEntry> = BTreeMap::new(); let mut entries_by_name: BTreeMap<String, WorkerListEntry> = BTreeMap::new();
for stored_info in stored { for stored_info in stored {
let name = stored_info.pod_name.clone(); let name = stored_info.worker_name.clone();
entries_by_name entries_by_name
.entry(name.clone()) .entry(name.clone())
.or_insert_with(|| PodListEntry::new(name, source)) .or_insert_with(|| WorkerListEntry::new(name, source))
.merge_stored(stored_info); .merge_stored(stored_info);
} }
for live_info in live { for live_info in live {
let name = live_info.pod_name.clone(); let name = live_info.worker_name.clone();
entries_by_name entries_by_name
.entry(name.clone()) .entry(name.clone())
.or_insert_with(|| PodListEntry::new(name, source)) .or_insert_with(|| WorkerListEntry::new(name, source))
.merge_live(live_info); .merge_live(live_info);
} }
let mut entries: Vec<PodListEntry> = entries_by_name.into_values().collect(); let mut entries: Vec<WorkerListEntry> = entries_by_name.into_values().collect();
for entry in &mut entries { for entry in &mut entries {
entry.finalize(); entry.finalize();
} }
@ -64,9 +64,9 @@ impl PodList {
} }
pub(crate) fn from_workspace_sources( pub(crate) fn from_workspace_sources(
source: PodVisibilitySource, source: WorkerVisibilitySource,
stored: Vec<StoredPodInfo>, stored: Vec<StoredWorkerInfo>,
live: Vec<LivePodInfo>, live: Vec<LiveWorkerInfo>,
selected_name: Option<String>, selected_name: Option<String>,
max_entries: usize, max_entries: usize,
workspace_root: &Path, workspace_root: &Path,
@ -81,14 +81,14 @@ impl PodList {
.as_deref() .as_deref()
.is_some_and(|root| workspace_root_key(root) == current_workspace); .is_some_and(|root| workspace_root_key(root) == current_workspace);
if matches { if matches {
current_names.insert(info.pod_name.clone()); current_names.insert(info.worker_name.clone());
} }
matches matches
}) })
.collect(); .collect();
let live = live let live = live
.into_iter() .into_iter()
.filter(|info| current_names.contains(&info.pod_name)) .filter(|info| current_names.contains(&info.worker_name))
.collect(); .collect();
Self::from_sources(source, stored, live, selected_name, max_entries) Self::from_sources(source, stored, live, selected_name, max_entries)
} }
@ -124,7 +124,7 @@ impl PodList {
self.selected_name = self.entries.get(index).map(|entry| entry.name.clone()); self.selected_name = self.entries.get(index).map(|entry| entry.name.clone());
} }
pub(crate) fn selected_entry(&self) -> Option<&PodListEntry> { pub(crate) fn selected_entry(&self) -> Option<&WorkerListEntry> {
let index = self.selected_index(); let index = self.selected_index();
self.entries.get(index) self.entries.get(index)
} }
@ -134,7 +134,7 @@ fn workspace_root_key(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
} }
fn entry_belongs_to_workspace(entry: &PodListEntry, current_workspace: &Path) -> bool { fn entry_belongs_to_workspace(entry: &WorkerListEntry, current_workspace: &Path) -> bool {
entry entry
.stored .stored
.as_ref() .as_ref()
@ -143,48 +143,49 @@ fn entry_belongs_to_workspace(entry: &PodListEntry, current_workspace: &Path) ->
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PodVisibilitySource { pub(crate) enum WorkerVisibilitySource {
ResumePicker, ResumePicker,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PodListSourceKind { pub(crate) enum WorkerListSourceKind {
RuntimeRegistry, RuntimeRegistry,
StoredMetadata, StoredMetadata,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PodListEntry { pub(crate) struct WorkerListEntry {
pub name: String, pub name: String,
pub visibility: PodVisibilitySource, pub visibility: WorkerVisibilitySource,
pub source_kinds: Vec<PodListSourceKind>, pub source_kinds: Vec<WorkerListSourceKind>,
pub live: Option<LivePodInfo>, pub live: Option<LiveWorkerInfo>,
pub stored: Option<StoredPodInfo>, pub stored: Option<StoredWorkerInfo>,
pub summary: PodEntrySummary, pub summary: WorkerEntrySummary,
pub actions: PodEntryActions, pub actions: WorkerEntryActions,
pub diagnostics: Vec<PodEntryDiagnostic>, pub diagnostics: Vec<WorkerEntryDiagnostic>,
} }
impl PodListEntry { impl WorkerListEntry {
fn new(name: String, visibility: PodVisibilitySource) -> Self { fn new(name: String, visibility: WorkerVisibilitySource) -> Self {
Self { Self {
name, name,
visibility, visibility,
source_kinds: Vec::new(), source_kinds: Vec::new(),
live: None, live: None,
stored: None, stored: None,
summary: PodEntrySummary::default(), summary: WorkerEntrySummary::default(),
actions: PodEntryActions::default(), actions: WorkerEntryActions::default(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
} }
} }
fn merge_live(&mut self, live: LivePodInfo) { fn merge_live(&mut self, live: LiveWorkerInfo) {
if !self if !self
.source_kinds .source_kinds
.contains(&PodListSourceKind::RuntimeRegistry) .contains(&WorkerListSourceKind::RuntimeRegistry)
{ {
self.source_kinds.push(PodListSourceKind::RuntimeRegistry); self.source_kinds
.push(WorkerListSourceKind::RuntimeRegistry);
} }
if live.summary.updated_at > self.summary.updated_at { if live.summary.updated_at > self.summary.updated_at {
self.summary.updated_at = live.summary.updated_at; self.summary.updated_at = live.summary.updated_at;
@ -201,12 +202,12 @@ impl PodListEntry {
self.live = Some(live); self.live = Some(live);
} }
fn merge_stored(&mut self, stored: StoredPodInfo) { fn merge_stored(&mut self, stored: StoredWorkerInfo) {
if !self if !self
.source_kinds .source_kinds
.contains(&PodListSourceKind::StoredMetadata) .contains(&WorkerListSourceKind::StoredMetadata)
{ {
self.source_kinds.push(PodListSourceKind::StoredMetadata); self.source_kinds.push(WorkerListSourceKind::StoredMetadata);
} }
if stored.updated_at > self.summary.updated_at { if stored.updated_at > self.summary.updated_at {
self.summary.updated_at = stored.updated_at; self.summary.updated_at = stored.updated_at;
@ -254,18 +255,18 @@ impl PodListEntry {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct LivePodInfo { pub(crate) struct LiveWorkerInfo {
pub pod_name: String, pub worker_name: String,
pub socket_path: PathBuf, pub socket_path: PathBuf,
pub status: Option<PodStatus>, pub status: Option<WorkerStatus>,
pub reachable: bool, pub reachable: bool,
pub segment_id: Option<SegmentId>, pub segment_id: Option<SegmentId>,
pub summary: PodEntrySummary, pub summary: WorkerEntrySummary,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct StoredPodInfo { pub(crate) struct StoredWorkerInfo {
pub pod_name: String, pub worker_name: String,
pub metadata_state: StoredMetadataState, pub metadata_state: StoredMetadataState,
pub active_session_id: Option<SessionId>, pub active_session_id: Option<SessionId>,
pub active_segment_id: Option<SegmentId>, pub active_segment_id: Option<SegmentId>,
@ -281,7 +282,7 @@ pub(crate) enum StoredMetadataState {
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub(crate) struct PodEntrySummary { pub(crate) struct WorkerEntrySummary {
pub active_session_id: Option<SessionId>, pub active_session_id: Option<SessionId>,
pub active_segment_id: Option<SegmentId>, pub active_segment_id: Option<SegmentId>,
pub updated_at: u64, pub updated_at: u64,
@ -289,7 +290,7 @@ pub(crate) struct PodEntrySummary {
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct PodEntryActions { pub(crate) struct WorkerEntryActions {
pub can_open: bool, pub can_open: bool,
pub can_restore: bool, pub can_restore: bool,
pub can_send_now: bool, pub can_send_now: bool,
@ -298,67 +299,67 @@ pub(crate) struct PodEntryActions {
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PodEntryDiagnostic { pub(crate) struct WorkerEntryDiagnostic {
pub kind: PodEntryDiagnosticKind, pub kind: WorkerEntryDiagnosticKind,
pub message: String, pub message: String,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PodEntryDiagnosticKind { pub(crate) enum WorkerEntryDiagnosticKind {
StoredMetadataCorrupt, StoredMetadataCorrupt,
LiveUnreachable, LiveUnreachable,
MissingStoredMetadata, MissingStoredMetadata,
MissingLiveStatus, MissingLiveStatus,
} }
pub(crate) fn read_stored_pod_infos( pub(crate) fn read_stored_worker_infos(
store: &FsStore, store: &FsStore,
pod_store: &impl PodMetadataStore, pod_store: &impl WorkerMetadataStore,
) -> Result<Vec<StoredPodInfo>, io::Error> { ) -> Result<Vec<StoredWorkerInfo>, io::Error> {
let mut records = Vec::new(); let mut records = Vec::new();
for pod_name in pod_store.list_names().map_err(io::Error::other)? { for worker_name in pod_store.list_names().map_err(io::Error::other)? {
let info = match pod_store.read_by_name(&pod_name) { let info = match pod_store.read_by_name(&worker_name) {
Ok(Some(metadata)) => stored_info_from_metadata(store, pod_name, metadata), Ok(Some(metadata)) => stored_info_from_metadata(store, worker_name, metadata),
Ok(None) => corrupt_stored_info( Ok(None) => corrupt_stored_info(
pod_name, worker_name,
"metadata disappeared during discovery".to_string(), "metadata disappeared during discovery".to_string(),
), ),
Err(e) => corrupt_stored_info(pod_name, e.to_string()), Err(e) => corrupt_stored_info(worker_name, e.to_string()),
}; };
records.push(info); records.push(info);
} }
Ok(records) Ok(records)
} }
pub(crate) fn read_live_pod_infos() -> Result<Vec<LivePodInfo>, io::Error> { pub(crate) fn read_live_pod_infos() -> Result<Vec<LiveWorkerInfo>, io::Error> {
let path = default_registry_path()?; let path = default_registry_path()?;
let guard = LockFileGuard::open(&path)?; let guard = LockFileGuard::open(&path)?;
Ok(guard Ok(guard
.data() .data()
.allocations .allocations
.iter() .iter()
.map(|allocation| LivePodInfo { .map(|allocation| LiveWorkerInfo {
pod_name: allocation.pod_name.clone(), worker_name: allocation.worker_name.clone(),
socket_path: allocation.socket.clone(), socket_path: allocation.socket.clone(),
status: None, status: None,
reachable: false, reachable: false,
segment_id: allocation.segment_id, segment_id: allocation.segment_id,
summary: PodEntrySummary::default(), summary: WorkerEntrySummary::default(),
}) })
.collect()) .collect())
} }
pub(crate) async fn read_reachable_live_pod_infos( pub(crate) async fn read_reachable_live_pod_infos(
store: &FsStore, store: &FsStore,
) -> Result<Vec<LivePodInfo>, io::Error> { ) -> Result<Vec<LiveWorkerInfo>, io::Error> {
let records = read_live_pod_infos()?; let records = read_live_pod_infos()?;
probe_reachable_live_pod_infos(store, records).await probe_reachable_live_pod_infos(store, records).await
} }
async fn probe_reachable_live_pod_infos( async fn probe_reachable_live_pod_infos(
_store: &FsStore, _store: &FsStore,
records: Vec<LivePodInfo>, records: Vec<LiveWorkerInfo>,
) -> Result<Vec<LivePodInfo>, io::Error> { ) -> Result<Vec<LiveWorkerInfo>, io::Error> {
let mut handles = Vec::with_capacity(records.len()); let mut handles = Vec::with_capacity(records.len());
for record in records { for record in records {
handles.push(tokio::spawn(probe_live_pod_info(record))); handles.push(tokio::spawn(probe_live_pod_info(record)));
@ -377,33 +378,33 @@ async fn probe_reachable_live_pod_infos(
Ok(reachable) Ok(reachable)
} }
async fn probe_live_pod_info(mut record: LivePodInfo) -> Result<LivePodInfo, io::Error> { async fn probe_live_pod_info(mut record: LiveWorkerInfo) -> Result<LiveWorkerInfo, io::Error> {
let status = probe_live_status(&record.socket_path).await?; let status = probe_live_status(&record.socket_path).await?;
record.reachable = true; record.reachable = true;
record.status = status; record.status = status;
Ok(record) Ok(record)
} }
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> { pub(crate) fn live_socket_for_pod(worker_name: &str) -> Option<PathBuf> {
read_live_pod_infos() read_live_pod_infos()
.ok()? .ok()?
.into_iter() .into_iter()
.find(|pod| pod.pod_name == pod_name) .find(|worker| worker.worker_name == worker_name)
.map(|pod| pod.socket_path) .map(|worker| worker.socket_path)
} }
fn stored_info_from_metadata( fn stored_info_from_metadata(
store: &FsStore, store: &FsStore,
pod_name: String, worker_name: String,
metadata: PodMetadata, metadata: WorkerMetadata,
) -> StoredPodInfo { ) -> StoredWorkerInfo {
let active = metadata.active; let active = metadata.active;
let active_session_id = active.as_ref().map(|a| a.session_id); let active_session_id = active.as_ref().map(|a| a.session_id);
let active_segment_id = active.as_ref().and_then(|a| a.segment_id); let active_segment_id = active.as_ref().and_then(|a| a.segment_id);
let summary = summarize_metadata(store, active.as_ref()); let summary = summarize_metadata(store, active.as_ref());
StoredPodInfo { StoredWorkerInfo {
pod_name, worker_name,
metadata_state: StoredMetadataState::Present, metadata_state: StoredMetadataState::Present,
active_session_id, active_session_id,
active_segment_id, active_segment_id,
@ -413,9 +414,9 @@ fn stored_info_from_metadata(
} }
} }
fn corrupt_stored_info(pod_name: String, message: String) -> StoredPodInfo { fn corrupt_stored_info(worker_name: String, message: String) -> StoredWorkerInfo {
StoredPodInfo { StoredWorkerInfo {
pod_name, worker_name,
metadata_state: StoredMetadataState::Corrupt(message.clone()), metadata_state: StoredMetadataState::Corrupt(message.clone()),
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
@ -427,8 +428,8 @@ fn corrupt_stored_info(pod_name: String, message: String) -> StoredPodInfo {
const LIVE_STATUS_PROBE_TIMEOUT: Duration = Duration::from_millis(200); const LIVE_STATUS_PROBE_TIMEOUT: Duration = Duration::from_millis(200);
async fn probe_live_status(socket_path: &Path) -> Result<Option<PodStatus>, io::Error> { async fn probe_live_status(socket_path: &Path) -> Result<Option<WorkerStatus>, io::Error> {
let mut client = PodClient::connect(socket_path).await?; let mut client = WorkerClient::connect(socket_path).await?;
let deadline = tokio::time::Instant::now() + LIVE_STATUS_PROBE_TIMEOUT; let deadline = tokio::time::Instant::now() + LIVE_STATUS_PROBE_TIMEOUT;
loop { loop {
@ -446,7 +447,7 @@ async fn probe_live_status(socket_path: &Path) -> Result<Option<PodStatus>, io::
} }
} }
fn status_from_event(event: &Event) -> Option<PodStatus> { fn status_from_event(event: &Event) -> Option<WorkerStatus> {
match event { match event {
Event::Snapshot { status, .. } | Event::Status { status } => Some(*status), Event::Snapshot { status, .. } | Event::Status { status } => Some(*status),
_ => None, _ => None,
@ -459,7 +460,7 @@ struct SegmentSummary {
preview: Option<String>, preview: Option<String>,
} }
fn summarize_metadata(_store: &FsStore, active: Option<&PodActiveSegmentRef>) -> SegmentSummary { fn summarize_metadata(_store: &FsStore, active: Option<&WorkerActiveSegmentRef>) -> SegmentSummary {
let Some(active) = active else { let Some(active) = active else {
return SegmentSummary { return SegmentSummary {
updated_at: 0, updated_at: 0,
@ -478,33 +479,33 @@ fn summarize_metadata(_store: &FsStore, active: Option<&PodActiveSegmentRef>) ->
} }
} }
fn build_diagnostics(entry: &PodListEntry) -> Vec<PodEntryDiagnostic> { fn build_diagnostics(entry: &WorkerListEntry) -> Vec<WorkerEntryDiagnostic> {
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
if let Some(stored) = entry.stored.as_ref() { if let Some(stored) = entry.stored.as_ref() {
if let StoredMetadataState::Corrupt(message) = &stored.metadata_state { if let StoredMetadataState::Corrupt(message) = &stored.metadata_state {
diagnostics.push(PodEntryDiagnostic { diagnostics.push(WorkerEntryDiagnostic {
kind: PodEntryDiagnosticKind::StoredMetadataCorrupt, kind: WorkerEntryDiagnosticKind::StoredMetadataCorrupt,
message: format!("metadata: {}", trim_one_line(message, 80)), message: format!("metadata: {}", trim_one_line(message, 80)),
}); });
} }
} else if entry.live.is_some() { } else if entry.live.is_some() {
diagnostics.push(PodEntryDiagnostic { diagnostics.push(WorkerEntryDiagnostic {
kind: PodEntryDiagnosticKind::MissingStoredMetadata, kind: WorkerEntryDiagnosticKind::MissingStoredMetadata,
message: "no stored pod metadata".to_string(), message: "no stored worker metadata".to_string(),
}); });
} }
if let Some(live) = entry.live.as_ref() { if let Some(live) = entry.live.as_ref() {
if !live.reachable { if !live.reachable {
diagnostics.push(PodEntryDiagnostic { diagnostics.push(WorkerEntryDiagnostic {
kind: PodEntryDiagnosticKind::LiveUnreachable, kind: WorkerEntryDiagnosticKind::LiveUnreachable,
message: format!("socket unreachable: {}", live.socket_path.display()), message: format!("socket unreachable: {}", live.socket_path.display()),
}); });
} else if live.status.is_none() { } else if live.status.is_none() {
diagnostics.push(PodEntryDiagnostic { diagnostics.push(WorkerEntryDiagnostic {
kind: PodEntryDiagnosticKind::MissingLiveStatus, kind: WorkerEntryDiagnosticKind::MissingLiveStatus,
message: "live pod status was not reported".to_string(), message: "live worker status was not reported".to_string(),
}); });
} }
} }
@ -512,7 +513,7 @@ fn build_diagnostics(entry: &PodListEntry) -> Vec<PodEntryDiagnostic> {
diagnostics diagnostics
} }
fn build_actions(entry: &PodListEntry) -> PodEntryActions { fn build_actions(entry: &WorkerListEntry) -> WorkerEntryActions {
let live_reachable = entry.live.as_ref().is_some_and(|live| live.reachable); let live_reachable = entry.live.as_ref().is_some_and(|live| live.reachable);
let stored_restorable = entry let stored_restorable = entry
.stored .stored
@ -522,19 +523,19 @@ fn build_actions(entry: &PodListEntry) -> PodEntryActions {
let can_restore = stored_restorable && !live_reachable; let can_restore = stored_restorable && !live_reachable;
let can_open = live_reachable || stored_restorable; let can_open = live_reachable || stored_restorable;
let can_send_now = live_reachable && live_status == Some(PodStatus::Idle); let can_send_now = live_reachable && live_status == Some(WorkerStatus::Idle);
let can_queue_send = live_reachable && live_status == Some(PodStatus::Running); let can_queue_send = live_reachable && live_status == Some(WorkerStatus::Running);
let disabled_reason = if can_open { let disabled_reason = if can_open {
None None
} else if entry.live.is_some() { } else if entry.live.is_some() {
Some("live pod is unreachable".to_string()) Some("live worker is unreachable".to_string())
} else if entry.stored.is_some() { } else if entry.stored.is_some() {
Some("stored pod metadata is corrupt".to_string()) Some("stored worker metadata is corrupt".to_string())
} else { } else {
Some("no live or stored pod state".to_string()) Some("no live or stored worker state".to_string())
}; };
PodEntryActions { WorkerEntryActions {
can_open, can_open,
can_restore, can_restore,
can_send_now, can_send_now,
@ -559,15 +560,15 @@ mod tests {
use std::sync::Arc; use std::sync::Arc;
use llm_engine::llm_client::types::RequestConfig; use llm_engine::llm_client::types::RequestConfig;
use pod_store::FsPodStore; use pod_store::FsWorkerStore;
use pod_store::{PodActiveSegmentRef, PodMetadataStore}; use pod_store::{WorkerActiveSegmentRef, WorkerMetadataStore};
use protocol::stream::JsonLineWriter; use protocol::stream::JsonLineWriter;
use session_store::{LogEntry, Store, new_segment_id, new_session_id}; use session_store::{LogEntry, Store, new_segment_id, new_session_id};
use tempfile::tempdir; use tempfile::tempdir;
use tokio::net::UnixListener; use tokio::net::UnixListener;
use tokio::sync::Barrier; use tokio::sync::Barrier;
const SOURCE: PodVisibilitySource = PodVisibilitySource::ResumePicker; const SOURCE: WorkerVisibilitySource = WorkerVisibilitySource::ResumePicker;
#[test] #[test]
fn stored_metadata_summary_uses_segment_marker_without_reading_session_log() { fn stored_metadata_summary_uses_segment_marker_without_reading_session_log() {
@ -585,7 +586,7 @@ mod tests {
"session log text should not be scanned", "session log text should not be scanned",
); );
let entry = single_entry(PodList::from_sources( let entry = single_entry(WorkerList::from_sources(
SOURCE, SOURCE,
vec![metadata_info(&store, "stored", session, segment)], vec![metadata_info(&store, "stored", session, segment)],
vec![], vec![],
@ -606,9 +607,9 @@ mod tests {
let stopped = (0..10) let stopped = (0..10)
.map(|index| stopped_info_with_updated_at(&format!("stopped-{index}"), 1_000 - index)) .map(|index| stopped_info_with_updated_at(&format!("stopped-{index}"), 1_000 - index))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let live = live_info_with_updated_at("live-pending", PodStatus::Idle, 0); let live = live_info_with_updated_at("live-pending", WorkerStatus::Idle, 0);
let entries = PodList::from_sources(SOURCE, stopped, vec![live], None, 10).entries; let entries = WorkerList::from_sources(SOURCE, stopped, vec![live], None, 10).entries;
assert_eq!(entries.len(), 10); assert_eq!(entries.len(), 10);
assert_eq!(entries[0].name, "live-pending"); assert_eq!(entries[0].name, "live-pending");
@ -617,11 +618,11 @@ mod tests {
#[test] #[test]
fn reachable_live_sort_does_not_promote_unreachable_registry_allocations() { fn reachable_live_sort_does_not_promote_unreachable_registry_allocations() {
let mut unreachable = live_info_with_updated_at("unreachable", PodStatus::Idle, 0); let mut unreachable = live_info_with_updated_at("unreachable", WorkerStatus::Idle, 0);
unreachable.reachable = false; unreachable.reachable = false;
unreachable.status = None; unreachable.status = None;
let entries = PodList::from_sources( let entries = WorkerList::from_sources(
SOURCE, SOURCE,
vec![stopped_info_with_updated_at("stopped", 100)], vec![stopped_info_with_updated_at("stopped", 100)],
vec![unreachable], vec![unreachable],
@ -638,12 +639,12 @@ mod tests {
fn live_pending_with_runtime_segment_is_attach_only_and_gets_pending_preview() { fn live_pending_with_runtime_segment_is_attach_only_and_gets_pending_preview() {
let session_id = new_session_id(); let session_id = new_session_id();
let runtime_segment_id = new_segment_id(); let runtime_segment_id = new_segment_id();
let entry = single_entry(PodList::from_sources( let entry = single_entry(WorkerList::from_sources(
SOURCE, SOURCE,
vec![pending_metadata_info("pending", session_id)], vec![pending_metadata_info("pending", session_id)],
vec![live_info_with_segment( vec![live_info_with_segment(
"pending", "pending",
PodStatus::Idle, WorkerStatus::Idle,
runtime_segment_id, runtime_segment_id,
)], )],
None, None,
@ -668,12 +669,12 @@ mod tests {
#[test] #[test]
fn live_only_runtime_segment_is_attach_only_and_not_restorable() { fn live_only_runtime_segment_is_attach_only_and_not_restorable() {
let runtime_segment_id = new_segment_id(); let runtime_segment_id = new_segment_id();
let entry = single_entry(PodList::from_sources( let entry = single_entry(WorkerList::from_sources(
SOURCE, SOURCE,
vec![], vec![],
vec![live_info_with_segment( vec![live_info_with_segment(
"runtime-only", "runtime-only",
PodStatus::Idle, WorkerStatus::Idle,
runtime_segment_id, runtime_segment_id,
)], )],
None, None,
@ -701,7 +702,7 @@ mod tests {
let segment_id = new_segment_id(); let segment_id = new_segment_id();
append_start(&store, session_id, segment_id, 10); append_start(&store, session_id, segment_id, 10);
let entry = single_entry(PodList::from_sources( let entry = single_entry(WorkerList::from_sources(
SOURCE, SOURCE,
vec![metadata_info(&store, "stored", session_id, segment_id)], vec![metadata_info(&store, "stored", session_id, segment_id)],
vec![], vec![],
@ -711,7 +712,10 @@ mod tests {
assert_eq!(entry.name, "stored"); assert_eq!(entry.name, "stored");
assert_eq!(entry.visibility, SOURCE); assert_eq!(entry.visibility, SOURCE);
assert_eq!(entry.source_kinds, vec![PodListSourceKind::StoredMetadata]); assert_eq!(
entry.source_kinds,
vec![WorkerListSourceKind::StoredMetadata]
);
assert!(entry.live.is_none()); assert!(entry.live.is_none());
assert!(entry.stored.is_some()); assert!(entry.stored.is_some());
assert!(entry.actions.can_open); assert!(entry.actions.can_open);
@ -722,17 +726,20 @@ mod tests {
#[test] #[test]
fn live_idle_reachable_row_can_open_and_send_now() { fn live_idle_reachable_row_can_open_and_send_now() {
let entry = single_entry(PodList::from_sources( let entry = single_entry(WorkerList::from_sources(
SOURCE, SOURCE,
vec![], vec![],
vec![live_info("live", PodStatus::Idle)], vec![live_info("live", WorkerStatus::Idle)],
None, None,
10, 10,
)); ));
assert_eq!(entry.name, "live"); assert_eq!(entry.name, "live");
assert_eq!(entry.visibility, SOURCE); assert_eq!(entry.visibility, SOURCE);
assert_eq!(entry.source_kinds, vec![PodListSourceKind::RuntimeRegistry]); assert_eq!(
entry.source_kinds,
vec![WorkerListSourceKind::RuntimeRegistry]
);
assert!(entry.actions.can_open); assert!(entry.actions.can_open);
assert!(!entry.actions.can_restore); assert!(!entry.actions.can_restore);
assert!(entry.actions.can_send_now); assert!(entry.actions.can_send_now);
@ -745,11 +752,17 @@ mod tests {
#[test] #[test]
fn live_reachable_row_without_reported_status_can_open_but_not_send_now() { fn live_reachable_row_without_reported_status_can_open_but_not_send_now() {
let mut live = live_info("live", PodStatus::Idle); let mut live = live_info("live", WorkerStatus::Idle);
live.status = None; live.status = None;
live.reachable = true; live.reachable = true;
let entry = single_entry(PodList::from_sources(SOURCE, vec![], vec![live], None, 10)); let entry = single_entry(WorkerList::from_sources(
SOURCE,
vec![],
vec![live],
None,
10,
));
assert!(entry.actions.can_open); assert!(entry.actions.can_open);
assert!(!entry.actions.can_restore); assert!(!entry.actions.can_restore);
@ -763,16 +776,16 @@ mod tests {
!entry !entry
.diagnostics .diagnostics
.iter() .iter()
.any(|diagnostic| diagnostic.kind == PodEntryDiagnosticKind::LiveUnreachable) .any(|diagnostic| diagnostic.kind == WorkerEntryDiagnosticKind::LiveUnreachable)
); );
} }
#[test] #[test]
fn live_running_reachable_row_can_open_but_not_send_now() { fn live_running_reachable_row_can_open_but_not_send_now() {
let entry = single_entry(PodList::from_sources( let entry = single_entry(WorkerList::from_sources(
SOURCE, SOURCE,
vec![], vec![],
vec![live_info("live", PodStatus::Running)], vec![live_info("live", WorkerStatus::Running)],
None, None,
10, 10,
)); ));
@ -785,11 +798,17 @@ mod tests {
#[test] #[test]
fn live_unreachable_row_has_diagnostic_and_cannot_open() { fn live_unreachable_row_has_diagnostic_and_cannot_open() {
let mut live = live_info("live", PodStatus::Idle); let mut live = live_info("live", WorkerStatus::Idle);
live.reachable = false; live.reachable = false;
live.status = None; live.status = None;
let entry = single_entry(PodList::from_sources(SOURCE, vec![], vec![live], None, 10)); let entry = single_entry(WorkerList::from_sources(
SOURCE,
vec![],
vec![live],
None,
10,
));
assert!(!entry.actions.can_open); assert!(!entry.actions.can_open);
assert!(!entry.actions.can_restore); assert!(!entry.actions.can_restore);
@ -797,11 +816,11 @@ mod tests {
assert!(!entry.actions.can_queue_send); assert!(!entry.actions.can_queue_send);
assert_eq!( assert_eq!(
entry.actions.disabled_reason.as_deref(), entry.actions.disabled_reason.as_deref(),
Some("live pod is unreachable") Some("live worker is unreachable")
); );
assert_eq!(entry.attach_socket_path(), None); assert_eq!(entry.attach_socket_path(), None);
assert!(entry.diagnostics.iter().any(|diagnostic| { assert!(entry.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PodEntryDiagnosticKind::LiveUnreachable diagnostic.kind == WorkerEntryDiagnosticKind::LiveUnreachable
&& diagnostic.message.contains("/tmp/live.sock") && diagnostic.message.contains("/tmp/live.sock")
})); }));
} }
@ -811,20 +830,20 @@ mod tests {
let events = [ let events = [
Event::Alert(protocol::Alert { Event::Alert(protocol::Alert {
level: protocol::AlertLevel::Warn, level: protocol::AlertLevel::Warn,
source: protocol::AlertSource::Pod, source: protocol::AlertSource::Worker,
message: "warming up".to_string(), message: "warming up".to_string(),
timestamp_ms: 0, timestamp_ms: 0,
}), }),
Event::Snapshot { Event::Snapshot {
entries: vec![], entries: vec![],
greeting: test_greeting(), greeting: test_greeting(),
status: PodStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
}, },
]; ];
let status = events.iter().find_map(status_from_event); let status = events.iter().find_map(status_from_event);
assert_eq!(status, Some(PodStatus::Idle)); assert_eq!(status, Some(WorkerStatus::Idle));
} }
#[tokio::test] #[tokio::test]
@ -838,8 +857,8 @@ mod tests {
let mut servers = Vec::new(); let mut servers = Vec::new();
for index in 0..probe_count { for index in 0..probe_count {
let pod_name = format!("pod-{index}"); let worker_name = format!("worker-{index}");
let socket_path = socket_dir.path().join(format!("{pod_name}.sock")); let socket_path = socket_dir.path().join(format!("{worker_name}.sock"));
let listener = UnixListener::bind(&socket_path).unwrap(); let listener = UnixListener::bind(&socket_path).unwrap();
let barrier = Arc::clone(&barrier); let barrier = Arc::clone(&barrier);
servers.push(tokio::spawn(async move { servers.push(tokio::spawn(async move {
@ -848,12 +867,12 @@ mod tests {
let mut writer = JsonLineWriter::new(stream); let mut writer = JsonLineWriter::new(stream);
writer writer
.write(&Event::Status { .write(&Event::Status {
status: PodStatus::Idle, status: WorkerStatus::Idle,
}) })
.await .await
.unwrap(); .unwrap();
})); }));
records.push(live_probe_record(&pod_name, socket_path)); records.push(live_probe_record(&worker_name, socket_path));
} }
let records = tokio::time::timeout( let records = tokio::time::timeout(
@ -869,7 +888,7 @@ mod tests {
assert!( assert!(
records records
.iter() .iter()
.all(|record| record.status == Some(PodStatus::Idle)) .all(|record| record.status == Some(WorkerStatus::Idle))
); );
for server in servers { for server in servers {
server.await.unwrap(); server.await.unwrap();
@ -896,7 +915,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(records.len(), 1); assert_eq!(records.len(), 1);
assert_eq!(records[0].pod_name, "silent"); assert_eq!(records[0].worker_name, "silent");
assert!(records[0].reachable); assert!(records[0].reachable);
assert_eq!(records[0].status, None); assert_eq!(records[0].status, None);
assert_eq!(records[0].socket_path, socket_path); assert_eq!(records[0].socket_path, socket_path);
@ -905,7 +924,7 @@ mod tests {
#[test] #[test]
fn corrupt_stored_metadata_has_diagnostic() { fn corrupt_stored_metadata_has_diagnostic() {
let entry = single_entry(PodList::from_sources( let entry = single_entry(WorkerList::from_sources(
SOURCE, SOURCE,
vec![corrupt_stored_info( vec![corrupt_stored_info(
"broken".to_string(), "broken".to_string(),
@ -919,7 +938,7 @@ mod tests {
assert_eq!(entry.name, "broken"); assert_eq!(entry.name, "broken");
assert!(!entry.actions.can_open); assert!(!entry.actions.can_open);
assert!(entry.diagnostics.iter().any(|diagnostic| { assert!(entry.diagnostics.iter().any(|diagnostic| {
diagnostic.kind == PodEntryDiagnosticKind::StoredMetadataCorrupt diagnostic.kind == WorkerEntryDiagnosticKind::StoredMetadataCorrupt
&& diagnostic.message.contains("expected value") && diagnostic.message.contains("expected value")
})); }));
assert!( assert!(
@ -933,25 +952,25 @@ mod tests {
} }
#[test] #[test]
fn selected_pod_name_is_kept_after_rebuild() { fn selected_worker_name_is_kept_after_rebuild() {
let first = PodList::from_sources( let first = WorkerList::from_sources(
SOURCE, SOURCE,
vec![], vec![],
vec![ vec![
live_info("alpha", PodStatus::Idle), live_info("alpha", WorkerStatus::Idle),
live_info("beta", PodStatus::Idle), live_info("beta", WorkerStatus::Idle),
], ],
Some("alpha".to_string()), Some("alpha".to_string()),
10, 10,
); );
assert_eq!(first.selected_entry().unwrap().name, "alpha"); assert_eq!(first.selected_entry().unwrap().name, "alpha");
let rebuilt = PodList::from_sources( let rebuilt = WorkerList::from_sources(
SOURCE, SOURCE,
vec![], vec![],
vec![ vec![
live_info_with_updated_at("beta", PodStatus::Idle, 20), live_info_with_updated_at("beta", WorkerStatus::Idle, 20),
live_info_with_updated_at("alpha", PodStatus::Idle, 10), live_info_with_updated_at("alpha", WorkerStatus::Idle, 10),
], ],
first.selected_name.clone(), first.selected_name.clone(),
10, 10,
@ -963,17 +982,17 @@ mod tests {
} }
#[test] #[test]
fn read_stored_pod_infos_reports_corrupt_metadata() { fn read_stored_worker_infos_reports_corrupt_metadata() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap(); let store = FsStore::new(dir.path()).unwrap();
let pod_store = FsPodStore::new(dir.path().join("pods")).unwrap(); let pod_store = FsWorkerStore::new(dir.path().join("pods")).unwrap();
let pod_dir = dir.path().join("pods").join("broken"); let pod_dir = dir.path().join("pods").join("broken");
std::fs::create_dir_all(&pod_dir).unwrap(); std::fs::create_dir_all(&pod_dir).unwrap();
std::fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap(); std::fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
let records = read_stored_pod_infos(&store, &pod_store).unwrap(); let records = read_stored_worker_infos(&store, &pod_store).unwrap();
assert_eq!(records.len(), 1); assert_eq!(records.len(), 1);
assert_eq!(records[0].pod_name, "broken"); assert_eq!(records[0].worker_name, "broken");
assert!(matches!( assert!(matches!(
records[0].metadata_state, records[0].metadata_state,
StoredMetadataState::Corrupt(_) StoredMetadataState::Corrupt(_)
@ -981,49 +1000,53 @@ mod tests {
} }
#[test] #[test]
fn read_stored_pod_infos_reads_metadata() { fn read_stored_worker_infos_reads_metadata() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();
let store = FsStore::new(dir.path()).unwrap(); let store = FsStore::new(dir.path()).unwrap();
let pod_store = FsPodStore::new(dir.path().join("pods")).unwrap(); let pod_store = FsWorkerStore::new(dir.path().join("pods")).unwrap();
let session_id = new_session_id(); let session_id = new_session_id();
let segment_id = new_segment_id(); let segment_id = new_segment_id();
pod_store pod_store
.write(&PodMetadata::new( .write(&WorkerMetadata::new(
"agent", "agent",
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)), Some(WorkerActiveSegmentRef::active_segment(
session_id, segment_id,
)),
)) ))
.unwrap(); .unwrap();
let records = read_stored_pod_infos(&store, &pod_store).unwrap(); let records = read_stored_worker_infos(&store, &pod_store).unwrap();
assert_eq!(records.len(), 1); assert_eq!(records.len(), 1);
assert_eq!(records[0].pod_name, "agent"); assert_eq!(records[0].worker_name, "agent");
assert_eq!(records[0].metadata_state, StoredMetadataState::Present); assert_eq!(records[0].metadata_state, StoredMetadataState::Present);
} }
fn single_entry(list: PodList) -> PodListEntry { fn single_entry(list: WorkerList) -> WorkerListEntry {
assert_eq!(list.entries.len(), 1); assert_eq!(list.entries.len(), 1);
list.entries.into_iter().next().unwrap() list.entries.into_iter().next().unwrap()
} }
fn metadata_info( fn metadata_info(
store: &FsStore, store: &FsStore,
pod_name: &str, worker_name: &str,
session_id: SessionId, session_id: SessionId,
segment_id: SegmentId, segment_id: SegmentId,
) -> StoredPodInfo { ) -> StoredWorkerInfo {
stored_info_from_metadata( stored_info_from_metadata(
store, store,
pod_name.to_string(), worker_name.to_string(),
PodMetadata::new( WorkerMetadata::new(
pod_name, worker_name,
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)), Some(WorkerActiveSegmentRef::active_segment(
session_id, segment_id,
)),
), ),
) )
} }
fn pending_metadata_info(pod_name: &str, session_id: SessionId) -> StoredPodInfo { fn pending_metadata_info(worker_name: &str, session_id: SessionId) -> StoredWorkerInfo {
StoredPodInfo { StoredWorkerInfo {
pod_name: pod_name.to_string(), worker_name: worker_name.to_string(),
metadata_state: StoredMetadataState::Present, metadata_state: StoredMetadataState::Present,
active_session_id: Some(session_id), active_session_id: Some(session_id),
active_segment_id: None, active_segment_id: None,
@ -1033,9 +1056,9 @@ mod tests {
} }
} }
fn stopped_info_with_updated_at(pod_name: &str, updated_at: u64) -> StoredPodInfo { fn stopped_info_with_updated_at(worker_name: &str, updated_at: u64) -> StoredWorkerInfo {
StoredPodInfo { StoredWorkerInfo {
pod_name: pod_name.to_string(), worker_name: worker_name.to_string(),
metadata_state: StoredMetadataState::Present, metadata_state: StoredMetadataState::Present,
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
@ -1045,32 +1068,32 @@ mod tests {
} }
} }
fn live_info(pod_name: &str, status: PodStatus) -> LivePodInfo { fn live_info(worker_name: &str, status: WorkerStatus) -> LiveWorkerInfo {
live_info_with_updated_at(pod_name, status, 0) live_info_with_updated_at(worker_name, status, 0)
} }
fn live_info_with_segment( fn live_info_with_segment(
pod_name: &str, worker_name: &str,
status: PodStatus, status: WorkerStatus,
segment_id: SegmentId, segment_id: SegmentId,
) -> LivePodInfo { ) -> LiveWorkerInfo {
let mut info = live_info(pod_name, status); let mut info = live_info(worker_name, status);
info.segment_id = Some(segment_id); info.segment_id = Some(segment_id);
info info
} }
fn live_info_with_updated_at( fn live_info_with_updated_at(
pod_name: &str, worker_name: &str,
status: PodStatus, status: WorkerStatus,
updated_at: u64, updated_at: u64,
) -> LivePodInfo { ) -> LiveWorkerInfo {
LivePodInfo { LiveWorkerInfo {
pod_name: pod_name.to_string(), worker_name: worker_name.to_string(),
socket_path: PathBuf::from(format!("/tmp/{pod_name}.sock")), socket_path: PathBuf::from(format!("/tmp/{worker_name}.sock")),
status: Some(status), status: Some(status),
reachable: true, reachable: true,
segment_id: None, segment_id: None,
summary: PodEntrySummary { summary: WorkerEntrySummary {
active_session_id: None, active_session_id: None,
active_segment_id: None, active_segment_id: None,
updated_at, updated_at,
@ -1079,20 +1102,20 @@ mod tests {
} }
} }
fn live_probe_record(pod_name: &str, socket_path: PathBuf) -> LivePodInfo { fn live_probe_record(worker_name: &str, socket_path: PathBuf) -> LiveWorkerInfo {
LivePodInfo { LiveWorkerInfo {
pod_name: pod_name.to_string(), worker_name: worker_name.to_string(),
socket_path, socket_path,
status: None, status: None,
reachable: false, reachable: false,
segment_id: None, segment_id: None,
summary: PodEntrySummary::default(), summary: WorkerEntrySummary::default(),
} }
} }
fn test_greeting() -> protocol::Greeting { fn test_greeting() -> protocol::Greeting {
protocol::Greeting { protocol::Greeting {
pod_name: "live".to_string(), worker_name: "live".to_string(),
cwd: "/tmp".to_string(), cwd: "/tmp".to_string(),
provider: "test".to_string(), provider: "test".to_string(),
model: "test".to_string(), model: "test".to_string(),
@ -1140,8 +1163,8 @@ mod tests {
.unwrap(); .unwrap();
} }
fn stopped_info_for_workspace(pod_name: &str, workspace_root: &Path) -> StoredPodInfo { fn stopped_info_for_workspace(worker_name: &str, workspace_root: &Path) -> StoredWorkerInfo {
let mut info = stopped_info_with_updated_at(pod_name, 10); let mut info = stopped_info_with_updated_at(worker_name, 10);
info.workspace_root = Some(workspace_root.to_path_buf()); info.workspace_root = Some(workspace_root.to_path_buf());
info info
} }
@ -1151,7 +1174,7 @@ mod tests {
let current = tempdir().unwrap(); let current = tempdir().unwrap();
let external = tempdir().unwrap(); let external = tempdir().unwrap();
let list = PodList::from_workspace_sources( let list = WorkerList::from_workspace_sources(
SOURCE, SOURCE,
vec![ vec![
stopped_info_for_workspace("current", current.path()), stopped_info_for_workspace("current", current.path()),
@ -1161,11 +1184,11 @@ mod tests {
corrupt_stored_info("corrupt".to_string(), "invalid metadata".to_string()), corrupt_stored_info("corrupt".to_string(), "invalid metadata".to_string()),
], ],
vec![ vec![
live_info("current", PodStatus::Idle), live_info("current", WorkerStatus::Idle),
live_info("current-orchestrator", PodStatus::Running), live_info("current-orchestrator", WorkerStatus::Running),
live_info("other-workspace", PodStatus::Idle), live_info("other-workspace", WorkerStatus::Idle),
live_info("legacy-unknown", PodStatus::Idle), live_info("legacy-unknown", WorkerStatus::Idle),
live_info("live-only", PodStatus::Idle), live_info("live-only", WorkerStatus::Idle),
], ],
None, None,
10, 10,
@ -1186,20 +1209,20 @@ mod tests {
let current = tempdir().unwrap(); let current = tempdir().unwrap();
let worktree_cwd = current.path().join(".worktree/impl"); let worktree_cwd = current.path().join(".worktree/impl");
let list = PodList::from_workspace_sources( let list = WorkerList::from_workspace_sources(
SOURCE, SOURCE,
vec![stopped_info_for_workspace("ticket-role", current.path())], vec![stopped_info_for_workspace("ticket-role", current.path())],
vec![live_info("ticket-role", PodStatus::Idle)], vec![live_info("ticket-role", WorkerStatus::Idle)],
None, None,
10, 10,
&worktree_cwd, &worktree_cwd,
); );
assert!(list.entries.is_empty()); assert!(list.entries.is_empty());
let list = PodList::from_workspace_sources( let list = WorkerList::from_workspace_sources(
SOURCE, SOURCE,
vec![stopped_info_for_workspace("ticket-role", current.path())], vec![stopped_info_for_workspace("ticket-role", current.path())],
vec![live_info("ticket-role", PodStatus::Idle)], vec![live_info("ticket-role", WorkerStatus::Idle)],
None, None,
10, 10,
current.path(), current.path(),

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
[package] [package]
name = "pod" name = "worker"
version = "0.1.0" version = "0.1.0"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true

32
crates/worker/README.md Normal file
View File

@ -0,0 +1,32 @@
# worker
## Role
`worker` turns an `llm-engine` Engine into a named runtime entity with manifest configuration, scoped tools, session persistence, protocol handling, and Worker metadata integration.
## Boundaries
Owns:
- Worker lifecycle and socket protocol serving
- Engine construction around a resolved Manifest
- session-store and pod-store coordination
- built-in tool registration under scope/policy
- spawned-child orchestration hooks
Does not own:
- provider-specific wire formats (`provider` / `llm-engine` clients)
- product CLI parsing (`yoi`)
- TUI display authority (`tui`)
- current-state storage schema outside Worker metadata (`pod-store`)
## Design notes
A Worker is runtime authority, not UI state. It should commit model-visible events through history/session paths and keep current Worker-name state in Worker metadata rather than in transient runtime files.
## See also
- [`../../docs/design/worker-session-state.md`](../../docs/design/worker-session-state.md)
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)

View File

@ -1,8 +1,8 @@
//! Emits `$OUT_DIR/internal_keys.rs` containing the sorted list of keys //! Emits `$OUT_DIR/internal_keys.rs` containing the sorted list of keys
//! present in `resources/prompts/internal.toml`. The generated slice is //! present in `resources/prompts/internal.toml`. The generated slice is
//! included into `src/prompts.rs` where a `const _` assertion compares //! included into `src/prompts.rs` where a `const _` assertion compares
//! it bidirectionally against the `PodPrompt` enum's own key list, so //! it bidirectionally against the `WorkerPrompt` enum's own key list, so
//! that a mismatch fails the build (see ticket: pod-prompt-catalog). //! that a mismatch fails the build (see ticket: worker-prompt-catalog).
use std::env; use std::env;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;

View File

@ -1,4 +1,4 @@
//! Minimal example: Pod running a single prompt with persistence. //! Minimal example: Worker running a single prompt with persistence.
//! //!
//! Demonstrates the core yoi abstraction — a TOML manifest drives //! Demonstrates the core yoi abstraction — a TOML manifest drives
//! provider selection, model config, and system prompt, while FsStore //! provider selection, model config, and system prompt, while FsStore
@ -8,26 +8,26 @@
//! //!
//! ```bash //! ```bash
//! echo "ANTHROPIC_API_KEY=your-key" > .env //! echo "ANTHROPIC_API_KEY=your-key" > .env
//! cargo run -p pod --example pod_cli //! cargo run -p worker --example worker_cli
//! ``` //! ```
use pod::{Pod, PodManifest, PodRunResult}; use pod_store::{CombinedStore, FsWorkerStore};
use pod_store::{CombinedStore, FsPodStore};
use session_store::FsStore; use session_store::FsStore;
use worker::{Worker, WorkerManifest, WorkerRunResult};
fn manifest_toml(pwd: &std::path::Path) -> String { fn manifest_toml(pwd: &std::path::Path) -> String {
let pwd = pwd.display(); let pwd = pwd.display();
format!( format!(
r#" r#"
[pod] [worker]
name = "hello-pod" name = "hello-worker"
pwd = "{pwd}" pwd = "{pwd}"
[model] [model]
scheme = "anthropic" scheme = "anthropic"
model_id = "claude-sonnet-4-20250514" model_id = "claude-sonnet-4-20250514"
[worker] [engine]
system_prompt = "You are a concise assistant. Reply in one or two sentences." system_prompt = "You are a concise assistant. Reply in one or two sentences."
max_tokens = 256 max_tokens = 256
@ -43,7 +43,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv::dotenv().ok(); dotenv::dotenv().ok();
// 1. Build a manifest rooted at the current working directory. // 1. Build a manifest rooted at the current working directory.
// All paths in a manifest must be absolute — see the pod-factory ticket. // All paths in a manifest must be absolute — see the worker-factory ticket.
let pwd = std::env::current_dir()?; let pwd = std::env::current_dir()?;
let toml = manifest_toml(&pwd); let toml = manifest_toml(&pwd);
@ -51,26 +51,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let tmp = tempfile::tempdir()?; let tmp = tempfile::tempdir()?;
let store = CombinedStore::new( let store = CombinedStore::new(
FsStore::new(tmp.path().join("sessions"))?, FsStore::new(tmp.path().join("sessions"))?,
FsPodStore::new(tmp.path().join("pods"))?, FsWorkerStore::new(tmp.path().join("pods"))?,
); );
// 3. Build the Pod from the single-layer manifest TOML // 3. Build the Worker from the single-layer manifest TOML
let mut pod = Pod::from_manifest_toml(&toml, store).await?; let mut worker = Worker::from_manifest_toml(&toml, store).await?;
let manifest: &PodManifest = pod.manifest(); let manifest: &WorkerManifest = worker.manifest();
println!("Pod: {}", manifest.pod.name); println!("Worker: {}", manifest.worker.name);
println!("Segment: {}", pod.segment_id()); println!("Segment: {}", worker.segment_id());
// 4. Run a prompt // 4. Run a prompt
let result = pod.run_text("What is the capital of France?").await?; let result = worker.run_text("What is the capital of France?").await?;
match result { match result {
PodRunResult::Finished => println!("(finished)"), WorkerRunResult::Finished => println!("(finished)"),
PodRunResult::Paused => println!("(paused)"), WorkerRunResult::Paused => println!("(paused)"),
PodRunResult::LimitReached => println!("(turn limit reached)"), WorkerRunResult::LimitReached => println!("(turn limit reached)"),
PodRunResult::RolledBack => println!("(empty turn rolled back)"), WorkerRunResult::RolledBack => println!("(empty turn rolled back)"),
} }
// 5. Extract the assistant's reply from history // 5. Extract the assistant's reply from history
let history = pod.engine().history(); let history = worker.engine().history();
if let Some(text) = history if let Some(text) = history
.iter() .iter()
.rev() .rev()
@ -81,7 +81,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
// 6. Session ID for potential restore // 6. Session ID for potential restore
println!("\nSegment ID: {}", pod.segment_id()); println!("\nSegment ID: {}", worker.segment_id());
Ok(()) Ok(())
} }

View File

@ -1,19 +1,19 @@
//! Pod Protocol example: control a Pod via PodHandle and stream events. //! Worker Protocol example: control a Worker via WorkerHandle and stream events.
//! //!
//! ```bash //! ```bash
//! echo "ANTHROPIC_API_KEY=your-key" > .env //! echo "ANTHROPIC_API_KEY=your-key" > .env
//! cargo run -p pod --example pod_protocol //! cargo run -p worker --example worker_protocol
//! ``` //! ```
use pod::{Event, Method, PodController}; use pod_store::{CombinedStore, FsWorkerStore};
use pod_store::{CombinedStore, FsPodStore};
use session_store::FsStore; use session_store::FsStore;
use worker::{Event, Method, WorkerController};
fn manifest_toml(pwd: &std::path::Path) -> String { fn manifest_toml(pwd: &std::path::Path) -> String {
let pwd = pwd.display(); let pwd = pwd.display();
format!( format!(
r#" r#"
[pod] [worker]
name = "protocol-demo" name = "protocol-demo"
pwd = "{pwd}" pwd = "{pwd}"
@ -21,7 +21,7 @@ pwd = "{pwd}"
scheme = "anthropic" scheme = "anthropic"
model_id = "claude-sonnet-4-20250514" model_id = "claude-sonnet-4-20250514"
[worker] [engine]
system_prompt = "You are a concise assistant. Reply in one or two sentences." system_prompt = "You are a concise assistant. Reply in one or two sentences."
max_tokens = 256 max_tokens = 256
@ -36,18 +36,18 @@ permission = "write"
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
dotenv::dotenv().ok(); dotenv::dotenv().ok();
// All manifest paths must be absolute — see the pod-factory ticket. // All manifest paths must be absolute — see the worker-factory ticket.
let pwd = std::env::current_dir()?; let pwd = std::env::current_dir()?;
let toml = manifest_toml(&pwd); let toml = manifest_toml(&pwd);
let tmp = tempfile::tempdir()?; let tmp = tempfile::tempdir()?;
let store = CombinedStore::new( let store = CombinedStore::new(
FsStore::new(tmp.path().join("sessions"))?, FsStore::new(tmp.path().join("sessions"))?,
FsPodStore::new(tmp.path().join("pods"))?, FsWorkerStore::new(tmp.path().join("pods"))?,
); );
let pod = pod::Pod::from_manifest_toml(&toml, store).await?; let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
let runtime_tmp = tempfile::tempdir()?; let runtime_tmp = tempfile::tempdir()?;
let (handle, _shutdown_rx) = PodController::spawn(pod, runtime_tmp.path()).await?; let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?;
// Check initial status via shared state // Check initial status via shared state
println!("[shared_state] {}", handle.shared_state.status_json()); println!("[shared_state] {}", handle.shared_state.status_json());

View File

@ -16,7 +16,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use session_store::{LogEntry, SystemItem, segment_log}; use session_store::{LogEntry, SystemItem, segment_log};
pub const DOMAIN: &str = "pod.active_workflows"; pub const DOMAIN: &str = "worker.active_workflows";
pub const REHYDRATION_MESSAGE_PREFIX: &str = "[Active workflow snapshot]"; pub const REHYDRATION_MESSAGE_PREFIX: &str = "[Active workflow snapshot]";
pub const INACTIVE_MESSAGE_PREFIX: &str = "[Active workflow state]"; pub const INACTIVE_MESSAGE_PREFIX: &str = "[Active workflow state]";
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 1;

Some files were not shown because too many files have changed in this diff Show More