merge: sync orchestration before queue 00001KVZBCQH4

This commit is contained in:
Keisuke Hirata 2026-06-26 01:20:09 +09:00
commit 53c6799d4d
No known key found for this signature in database
210 changed files with 7459 additions and 6248 deletions

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260625-141406-1","ticket_id":"00001KVZG9BMS","kind":"accepted_plan","accepted_plan":{"summary":"Ticket `00001KVZG9BMS` は prerequisite `00001KVZD10ED` が done になったため implementation_ready。専用 worktree `/home/hare/Projects/yoi/.worktree/00001KVZG9BMS-worker-crate-rename` と branch `work/00001KVZG9BMS-worker-crate-rename` で、`crates/pod` を `crates/worker` へ rename し、public execution-unit API を Worker terminology へ整理する。`pod-store`/`pod-registry` standalone rename、worker-runtime実装、socket/session互換の完全削除は non-goals。","branch":"work/00001KVZG9BMS-worker-crate-rename","worktree":"/home/hare/Projects/yoi/.worktree/00001KVZG9BMS-worker-crate-rename","role_plan":"Orchestrator: accept/routing, worktree creation, final integration/validation/cleanup. Coder: repository-wide crate/API rename in dedicated child worktree. Reviewer: read-only review focusing on mechanical rename completeness, CLI/process launch consistency, no pod-store/registry rename, no responsibility rewrite, and validation evidence."},"author":"yoi-orchestrator","at":"2026-06-25T14:14:06Z"}

View File

@ -1,8 +1,8 @@
---
title: 'pod crateをworker crateへ改名する'
state: 'queued'
state: 'done'
created_at: '2026-06-25T13:42:37Z'
updated_at: '2026-06-25T14:13:35Z'
updated_at: '2026-06-25T16:19:23Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-25T14:13:35Z'

View File

@ -30,4 +30,718 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-25T14:14:52Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Dashboard Queue により人間が Orchestrator routing を許可した queued Ticket として確認した。
- outgoing `depends_on``00001KVZD10ED` で、`00001KVZD10ED` は done / merged / reviewed / validated 済み。`TicketShow` derived blockers は空で、implementation acceptance blocker は残っていない。
- incoming dependent `00001KVZBCQH4` は後続 worker-runtime creation であり、この Ticket の acceptance blocker ではない。
- Ticket body は `crates/pod` -> `crates/worker` crate/package/import rename、public execution-unit API rename、CLI/process surface update、non-goals、validation を具体的に列挙している。
- bounded context check で current active references to `crates/pod`, `pod::`, package `pod`, `yoi pod` process entrypoint, tests/docs/Nix を確認した。変更量は大きいが mechanical rename + bounded public surface alignment として進められる。
- CLI/process surface は risk だが、Ticket は backward compatibility alias 不要、dogfooding runtime/spawn path/scripts/tests 影響を明示処理することを要求している。設計未決定というより implementation slice 内の bounded choice と判断する。
Evidence checked:
- Ticket body / thread: `item.md`, `thread.md`。thread は create、planning->ready、ready->queued のみで未解決 blocker は記録されていない。
- Relations / orchestration plan: outgoing depends_on `00001KVZD10ED` is done; incoming dependent `00001KVZBCQH4`; routing 前 plan 0 件。accepted plan `orch-plan-20260625-141406-1` を記録済み。
- Code context: `git grep``crates/pod`, `pod::`, package/dependency `pod`, `yoi pod`, `Pod` public API refs を確認。
- Workspace state: `/home/hare/Projects/yoi/.worktree/orchestration` は clean。queued Ticket はこの 1 件、inprogress Ticket は 0 件。
IntentPacket:
Intent:
- 現在の single execution-unit host crate `pod``worker` に rename し、実行単位としての `worker::Worker` と LLM turn engine `llm_engine::Engine` の命名境界を揃える。
Binding decisions / invariants:
- This is a rename / API terminology alignment Ticket, not a responsibility rewrite.
- `worker` crate remains the current single Worker host: input handling, llm-engine integration, event emission, session/transcript compatibility, tool registry, workflow integration, legacy socket compatibility.
- Do not implement `worker-runtime` crate or multi-worker Runtime API.
- Do not standalone-rename `pod-store` / `pod-registry` to `worker-store` / `worker-registry` in this Ticket.
- Do not change provider request/tool-call/history semantics.
- Do not create `pod` crate/import compatibility alias.
- Existing on-disk/socket/session compatibility may retain legacy `pod` strings only where explicitly legacy/internal and documented.
Requirements / acceptance criteria:
- `crates/worker` exists; `crates/pod` does not remain.
- Cargo package/import path are `worker`.
- Public execution-unit type is `worker::Worker`, not `pod::Pod`.
- Active repository references to `pod` crate / `pod::` import / `crates/pod` are gone except intentional legacy context.
- Dependent crates compile against `worker` crate.
- `llm_engine::Engine` vs `worker::Worker` boundary is clear in code/docs/comments.
- Existing process/socket/session compatibility path still works or is explicitly updated without old-name alias.
- `pod-store` / `pod-registry` are not renamed as standalone crates.
- Validation target includes `cargo test -p worker`, `cargo test -p yoi` or relevant CLI tests, `cargo check -p yoi`, `git diff --check`, `nix build .#yoi --no-link`.
Implementation latitude:
- Exact CLI command spelling may be updated according to Ticket requirement, but no backward alias should be added unless a hard blocker appears. If command migration threatens current runtime dogfooding assumptions, escalate.
- Internal legacy file/socket/session names may remain only when required for compatibility and must be clearly legacy/internal, not active API guidance.
- Type/module rename can be staged mechanically; prioritize compile/test correctness and grep evidence.
Escalate if:
- Rename requires broad runtime architecture rewrite or worker-runtime implementation.
- Current process launch/spawn mechanics cannot work without a compatibility `pod` command/alias.
- `pod-store` / `pod-registry` must be renamed for compile correctness.
- Session/socket/on-disk migration would be required beyond explicit legacy compatibility labels.
- Behavior changes unrelated to naming are needed.
Validation:
- `cargo test -p worker`
- `cargo test -p yoi` or focused process/CLI tests
- `cargo check -p yoi`
- `git diff --check`
- `nix build .#yoi --no-link`
- grep evidence for stale active references.
Current code map:
- Primary: `crates/pod`, workspace `Cargo.toml`, dependent crates (`crates/yoi`, `crates/tui`, workspace-server/client as discovered), tests/docs/resources, `Cargo.lock`, `package.nix`.
- Avoid: `pod-store` / `pod-registry` standalone rename, worker-runtime implementation, root/original workspace operations.
Critical risks / reviewer focus:
- stale active `pod` crate/import/directory references.
- hidden compatibility alias.
- breaking runtime process launch / spawn command path.
- accidentally renaming persistence crates out of scope.
- behavior changes mixed into mechanical rename.
- confusion between `llm_engine::Engine` and `worker::Worker` responsibilities.
Next action:
- `queued -> inprogress` を記録してから worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVZG9BMS-worker-crate-rename` を作成し、multi-agent-workflow で Coder/Reviewer sibling loop に進める。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-25T14:15:18Z from: queued to: inprogress reason: orchestrator_acceptance_unblocked field: state -->
## State changed
Orchestrator acceptance: queued -> inprogress
- 直前確認で `TicketShow` は state `queued`、derived blockers は空。
- outgoing dependency `00001KVZD10ED` は done / merged / reviewed / validated 済み。
- accepted plan `orch-plan-20260625-141406-1` を確認した。
- routing decision と IntentPacket は Ticket thread に記録済み。
- これ以降に worktree-workflow で `/home/hare/Projects/yoi/.worktree/00001KVZG9BMS-worker-crate-rename` を作成し、multi-agent-workflow に接続する。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-25T14:16:23Z -->
## Implementation report
Implementation orchestration update:
- prerequisite `00001KVZD10ED` が done であることを確認し、`00001KVZG9BMS` を受理した。
- worktree-workflow に従い、implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVZG9BMS-worker-crate-rename` と branch `work/00001KVZG9BMS-worker-crate-rename` を作成した。
- child worktree では tracked `.yoi` project records を見える状態にし、`.yoi/memory`、local/runtime/log/lock/secret-like `.yoi` paths を sparse-checkout で除外する方針にした。
- Coder Pod `yoi-coder-00001KVZG9BMS-worker-rename` を sibling として起動した。write scope は child worktree に限定し、runtime workspace root `/home/hare/Projects/yoi` は startup 要件を満たすため non-recursive read のみにした。
- Coder には root/original workspace と orchestration worktree で read/write/validation/git operation を行わないよう明示した。
---
<!-- event: implementation_report author: yoi-coder-00001KVZG9BMS-worker-rename at: 2026-06-25T15:07:12Z -->
## Implementation report
Implementation report:
実装概要:
- `crates/pod``crates/worker` へ改名。
- Cargo package/import path を `pod` から `worker` へ更新。
- `worker::Worker`, `WorkerConfig`, `WorkerState`, `WorkerEvent`, `WorkerClient`, `WorkerRuntimeCommand` など Worker 用語へ更新。
- CLI/process surface を `yoi pod` から `yoi worker` へ変更。
- 後方互換の `yoi pod` alias は追加していません。
- `llm_engine::Engine``worker::Worker` の境界を明確化。
- 既存 manifest の turn-engine 側設定は `engine` 用語へ寄せ、Worker host 側設定と分離しました。
- `pod-store` / `pod-registry` は crate/package としては改名していません。
- ただし Worker API と整合するよう、関連する型・呼び出し側は Worker 用語へ更新。
- 既存互換・内部文脈として、legacy on-disk metadata directory `pods` などは残しています。
- resources/prompts、profiles、docs、Nix、Cargo workspace、Cargo.lock を更新。
- `package.nix``cargoHash` を更新。
主な変更ファイル:
- `crates/pod/**``crates/worker/**`
- `crates/client/src/pod_client.rs``crates/client/src/worker_client.rs`
- `crates/tui/src/pod_list.rs``crates/tui/src/worker_list.rs`
- `crates/yoi/src/pod_cleanup_cli.rs``crates/yoi/src/worker_cleanup_cli.rs`
- `resources/prompts/pod/**``resources/prompts/worker/**`
- `resources/prompts/common/pod-orchestration.md``resources/prompts/common/worker-orchestration.md`
- `docs/design/pod-session-state.md``docs/design/worker-session-state.md`
- `Cargo.toml`, `Cargo.lock`, `package.nix`
- dependent crates: `protocol`, `manifest`, `tui`, `client`, `workspace-server`, `yoi`, docs/resources
Validation:
- `cargo test -p worker`: success
- `cargo test -p yoi`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Grep evidence:
- `crates/pod` refs: intentional `pod-store` / `pod-registry` path を除きなし
- `pod::` refs: なし
- package/import `pod` refs: intentional `pod-store` / `pod-registry` dependencies を除きなし
- old public `Pod*` execution-unit type refs: intentional exclusions を除きなし
- active CLI refs:
- `yoi pod`: なし
- `"pod" =>`: なし
- `--pod`: なし
- `POD_OPTIONS`: なし
- `parse_pod`: なし
- `pod_name`: intentional exclusions を除きなし
残っている lower-case `pod` は主に以下の intentional legacy/internal context:
- crate/package name として残す指定のある `pod-store`
- crate/package name として残す指定のある `pod-registry`
- legacy on-disk metadata directory `pods`
- related comments/tests describing those legacy/internal boundaries
Commit:
- `6c59fe927b8094f735651ef0e18fad5e8b62ea83 refactor: rename pod crate to worker`
Residual risks / notes:
- `pod-store` / `pod-registry` crate 名は Ticket invariant に従って残しています。そのため grep 上の lower-case `pod` は完全にはゼロになりません。
- legacy on-disk directory `pods` は既存 metadata/session compatibility のため維持しました。
- merge / close / cleanup は実施していません。
---
<!-- event: review author: yoi-reviewer-00001KVZG9BMS-worker-rename at: 2026-06-25T15:15:27Z status: request_changes -->
## Review: request changes
Review result: request_changes
読取専用で Ticket / IntentPacket、`4c677640..HEAD` diff、grep/nl による focused inspection を行いました。`cargo`/`nix` 系の再実行は read-only 指示と書込み副作用回避のため行っていません。
良い点:
- `crates/worker` は存在し、`crates/pod` は削除済み。
- workspace/Cargo.lock 上の package/import は `worker` に移行済みで、`pod-store` / `pod-registry` は standalone rename されていません。
- CLI は `yoi worker` / `--worker` 側へ移行しており、明示的な `yoi pod` alias は見当たりませんでした。
Blockers:
1. 生成済み Web protocol が旧 Pod protocol のまま。
- `web/workspace/src/lib/generated/protocol.ts` が Rust 側の `Worker*` protocol と不整合です。
- 例: `PodStatus`, `Greeting { pod_name: ... }`, `PodEvent`, method `pod_event`, `list_pods`, `restore_pod`, events `pods_listed`, `pod_restored` など。
- `crates/protocol/src/lib.rs``WorkerStatus` / `WorkerEvent` / `worker_name` / `worker_event` 等へ更新済みのため、Web/TS client 側が socket protocol とズレるリスクがあります。
2. public API に旧 Pod 名の enum variant が残存。
- `crates/client/src/spawn.rs``SpawnError::PodLaunchFailed { ... }`
- `crates/client/src/lib.rs``SpawnError` は public re-export されているため、Ticket の “Public execution-unit type/API terminology is Worker, not Pod” に反します。
3. model-facing prompts / workflows / active docs に旧 Pod guidance が残存。
- `resources/prompts/common/worker-orchestration.md`: “Peer Pods ...”
- `resources/workflows/ticket-orchestrator-routing.md`: “Coder/Reviewer は sibling Pods ...”
- `resources/prompts/role/intake.md`, `resources/prompts/role/orchestrator.md`, `resources/prompts/panel/orchestrator_idle_queue_notice.md` にも active orchestration guidance として “Pods” が残っています。
- `docs/design/worker-session-state.md``SendToPeerPod` と記載されていますが、実装側は `SendToPeerWorker`
- これらは legacy/internal compatibility label ではなく current LLM/workflow guidance なので例外扱いできません。
4. active documentation に壊れた旧パス / 旧型名が残存。
- `crates/pod-store/README.md` に削除済み `docs/design/pod-session-state.md` へのリンク。
- `KNOWN_ISSUES.md``crates/worker/src/pod.rs` / `Pod::ensure_segment_head` 等、存在しない path/type。
Required fixes:
- `web/workspace/src/lib/generated/protocol.ts` を Rust protocol と一致させる。
- `client::SpawnError::PodLaunchFailed``WorkerLaunchFailed` に rename し、match/source mapping も更新する。
- `resources/prompts/**` / `resources/workflows/**` の active “Pod(s)” guidance を Worker terminology に更新する。
- `SendToPeerPod` など実装と不一致な docs を `SendToPeerWorker` に更新する。
- 削除済み `docs/design/pod-session-state.md` へのリンク、存在しない `crates/worker/src/pod.rs` / `Pod::ensure_segment_head` 参照を修正する。
- 修正後、報告済み validation に加えて stale grep を再実施する。
---
<!-- event: implementation_report author: yoi-coder-00001KVZG9BMS-worker-rename at: 2026-06-25T15:26:07Z -->
## Implementation report
Follow-up implementation report after review request_changes:
対応内容:
1. Web generated protocol の Worker 用語追従
- `web/workspace/src/lib/generated/protocol.ts` を Rust protocol から再生成。
- 旧 Pod protocol 名を Worker に更新。
- `PodStatus``WorkerStatus`
- `Greeting { pod_name }``Greeting { worker_name }`
- `PodEvent``WorkerEvent`
- `pod_event``worker_event`
- `list_pods``list_workers`
- `restore_pod``restore_worker`
- `pods_listed``workers_listed`
- `pod_restored``worker_restored`
2. public API enum variant の修正
- `crates/client/src/spawn.rs`
- `SpawnError::PodLaunchFailed``SpawnError::WorkerLaunchFailed`
- public re-export される型なので、match / source mapping も更新。
3. active model-facing guidance の Pod 表現修正
- 以下を Worker 用語へ更新。
- `resources/prompts/common/worker-orchestration.md`
- `resources/workflows/ticket-orchestrator-routing.md`
- `resources/prompts/role/intake.md`
- `resources/prompts/role/orchestrator.md`
- `resources/prompts/panel/orchestrator_idle_queue_notice.md`
4. stale docs/path/type の修正
- `docs/design/worker-session-state.md`
- `SendToPeerPod``SendToPeerWorker`
- `crates/pod-store/README.md`
- deleted path `docs/design/pod-session-state.md``docs/design/worker-session-state.md`
- crate 名は invariant に従い `pod-store` のまま、説明は legacy-named crate owning current Worker metadata と明記。
- `KNOWN_ISSUES.md`
- `crates/worker/src/pod.rs``crates/worker/src/worker.rs`
- `Pod::ensure_segment_head``Worker::ensure_segment_head`
- `PodError::WorkflowResolve``WorkerError::WorkflowResolve`
Validation:
- `cargo test -p worker`: success
- `cargo test -p yoi`: success
- `cargo check -p yoi`: success
- `cargo test -p protocol --features typescript`: success
- `cd web/workspace && deno task check`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Stale grep evidence:
- review blocker terms were checked and found no matches:
- `PodStatus`, `PodEvent`, `pod_name`, `pod_event`, `list_pods`, `restore_pod`, `pods_listed`, `pod_restored`, `PodLaunchFailed`, `SendToPeerPod`, `sibling Pods`, `Peer Pods`, `yoi pod`, `--pod`, `crates/worker/src/pod.rs`, `Pod::ensure_segment_head`, `pod-session-state.md`
- Strict stale grep was rerun; intentional `pod-store` / `pod-registry` crate names aside, active public Pod protocol/prompt/API reference remains absent.
Commit:
- `ebf50baa94f76bb000f70f63e133c7883729de5c fix: align worker rename followups`
Residual risks / notes:
- なし。child worktree は commit 後 clean。
---
<!-- event: review author: yoi-reviewer-00001KVZG9BMS-worker-rename at: 2026-06-25T15:29:25Z status: request_changes -->
## Review: request changes
Review result: request_changes
Follow-up を read-only で確認した。`6c59fe92..HEAD` の差分確認と blocker term grep を実施し、ビルド/テストは副作用回避のため再実行していない。
解消確認:
- `web/workspace/src/lib/generated/protocol.ts``WorkerStatus` / `worker_name` / `WorkerEvent` / `worker_event` / `list_workers` / `restore_worker` / `workers_listed` / `worker_restored` に更新済み。
- `SpawnError::PodLaunchFailed``WorkerLaunchFailed` に更新済み。
- 主要 prompt/workflow の `Peer Pods` / `sibling Pods` は一部修正済み。
Remaining blockers:
1. 削除済み docs path がまだ残存。
- `crates/pod-registry/README.md:29`
- `../../docs/design/pod-session-state.md` への link が残っている。対象 file は `worker-session-state.md` に移行済みなので、壊れた active doc link。
2. 旧 tool 名 `SendToPeerPod` が active code に残存。
- `crates/session-analytics/src/lib.rs:1600`
- 実装側は `SendToPeerWorker` へ移行済みだが、analytics の分類に `SendToPeerWorker` がなく `SendToPeerPod` だけ残っている。stale name であるだけでなく、現行 tool の分類漏れに見える。legacy log 対応で旧名を残すなら、`SendToPeerWorker` を追加した上で legacy comment を付けるのが安全。
3. active docs/comments に旧 Pod guidance がまだ残存。
- `docs/development/work-items.md:250`: `Reviewer Pods should be sibling Pods...`
- `crates/protocol/src/lib.rs:80`: `List Pods visible to this Worker...`
- `AGENTS.md:19`: `Notify / PodEvent / ...`
- いずれも compatibility/internal on-disk `pods` の説明ではなく、現在の active terminology / protocol guidance なので Worker terminology に寄せるべき。
Required verification after fix:
```sh
rg -n -S 'SendToPeerPod|pod-session-state\.md|sibling Pods|List Pods visible|PodEvent' \
AGENTS.md crates resources docs web/workspace/src/lib/generated/protocol.ts \
--glob '!target' --glob '!docs/report/**'
```
This grep should be empty or any remaining hit must be explicitly legacy/internal and justified.
---
<!-- event: implementation_report author: yoi-coder-00001KVZG9BMS-worker-rename at: 2026-06-25T15:44:18Z -->
## Implementation report
Second follow-up implementation report after review request_changes:
対応内容:
1. broken docs path 修正
- `crates/pod-registry/README.md`
- `../../docs/design/pod-session-state.md``../../docs/design/worker-session-state.md`
- `pod-registry` は invariant に従って crate 名を維持しつつ、本文では legacy-named crate と明記して Worker 用語へ更新。
2. old tool name classification 修正
- `crates/session-analytics/src/lib.rs`
- current tool name `SendToPeerWorker` を worker tool classification に追加。
- historical session logs の分類維持のため、`SendToPeerPod` は `LEGACY_SEND_TO_PEER_POD_TOOL` として明示的な legacy analytics-only comment 付きで保持。
3. active docs/comments の Pod guidance 修正
- `docs/development/work-items.md`
- `Reviewer Pods should be sibling Pods...``Reviewer Workers should be sibling Workers...`
- `crates/protocol/src/lib.rs`
- `List Pods visible...``List Workers visible...`
- `AGENTS.md`
- `Podの状態``Workerの状態`
- `Notify / PodEvent / ...``Notify / WorkerEvent / ...`
Validation:
- `cargo test -p session-analytics`: success
- `cargo test -p worker`: success
- `cargo test -p yoi`: success
- `cargo check -p yoi`: success
- `cargo test -p protocol --features typescript`: success
- `cd web/workspace && deno task check`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Specified grep evidence:
```text
crates/session-analytics/src/lib.rs:1595: const LEGACY_SEND_TO_PEER_POD_TOOL: &str = "SendToPeerPod";
```
- この 1 件のみ残存。
- historical session logs の分類維持のための analytics-only legacy tool name で、直下の classification branch にも legacy comment を付けている。
- active prompt/protocol/docs/API guidance の旧 Pod 参照ではない。
Commit:
- `94c7aa793a179b377108dea841fe9d381affbd9c fix: clean remaining worker rename references`
Residual risks / notes:
- child worktree は commit 後 clean。
---
<!-- event: review author: yoi-reviewer-00001KVZG9BMS-worker-rename at: 2026-06-25T15:46:40Z status: request_changes -->
## Review: request changes
Review result: request_changes
読取専用で `94c7aa79` を確認した。前回指摘分のうち、以下は解消済み。
- `crates/pod-registry/README.md``worker-session-state.md` link 更新。
- `crates/protocol/src/lib.rs``List Workers...` comment 更新。
- `AGENTS.md` / `docs/development/work-items.md:250` の前回 exact blocker 更新。
- `SendToPeerPod``LEGACY_SEND_TO_PEER_POD_TOOL` として analytics-only legacy comment 付きで残り、現行 `SendToPeerWorker` 分類も追加済みなので、この残存は許容可能。
Remaining blockers:
1. active E2E helper に旧 `--pod` CLI が残存。
- `tests/e2e/src/lib.rs:1541`
- `config.command_args` がまだ `"--pod"` を渡している。
- `yoi pod` / `--pod` alias を作らない方針なので、これは現行 CLI に対して壊れた active test/helper。`--worker` へ更新が必要。
2. active user-facing / developer-facing guidance に旧 Pod terminology が残存。
代表例:
- `crates/tui/src/dashboard/mod.rs:96``"no Tickets or Pods found ..."`
- `crates/tui/src/dashboard/mod.rs:2939``"spawning role Pods"`
- `crates/tui/src/dashboard/mod.rs:4951``"sibling coder/reviewer Pods"`
- `crates/tui/src/dashboard/tests.rs:837` — 旧文字列期待
- `crates/client/README.md:5` — “talking to Pods”
- `crates/manifest/src/config.rs:43` — “spawned child Pods”
- `docs/design/overview.md:14` — “between clients and Pods”
- `docs/development/workflows.md` / `docs/development/dogfooding.md` / `docs/development/work-items.md` にも current guidance としての `Pods` が残存。
これらは `pod-store` / `pod-registry` crate 名や on-disk `pods` compatibility の説明ではなく、現在の実行単位・CLI・Dashboard guidance なので Worker terminology に寄せるべき。
Required verification after fixes:
```sh
rg -n -S --glob '!target' --glob '!docs/report/**' -- '--pod|yoi pod' .
rg -n -S --glob '!target' --glob '!docs/report/**' \
'sibling coder/reviewer Pods|role Pods|talking to Pods|between clients and Pods|Spawned Pods|child Pods|Pods found|Pod categories|Pod row' \
crates docs tests resources AGENTS.md README.md KNOWN_ISSUES.md
```
These should be empty or any remaining hit must be explicitly legacy/internal and justified.
---
<!-- event: implementation_report author: yoi-coder-00001KVZG9BMS-worker-rename at: 2026-06-25T15:58:35Z -->
## Implementation report
Third follow-up implementation report after review request_changes:
対応内容:
1. E2E helper の旧 CLI 引数修正
- `tests/e2e/src/lib.rs`
- rewind fixture の起動引数を `--pod` から `--worker` に変更。
- 関連 helper 名・フィールド・説明も `worker_names` / `worker_rows` / Worker row に更新。
- `tests/e2e/tests/panel.rs`
- 期待値・メッセージを Worker terminology に更新。
2. Active user/developer guidance の Pod 表現修正
- `crates/tui/src/dashboard/mod.rs`
- `no Tickets or Pods found ...``no Tickets or Workers found ...`
- `spawning role Pods``spawning role Workers`
- `sibling coder/reviewer Pods``sibling coder/reviewer Workers`
- `crates/tui/src/dashboard/tests.rs`
- 期待文字列を Worker に更新。
- `crates/client/README.md`
- `talking to Pods``talking to Workers`
- `crates/manifest/src/config.rs`
- `spawned child Pods``spawned child Workers`
- `docs/design/overview.md`
- `between clients and Pods``between clients and Workers`
- `docs/design/profiles-manifests-prompts.md`
- `Spawned Pods``Spawned Workers`
- `docs/development/dogfooding.md`
- `child Pods``child Workers`
- `docs/development/workflows.md`
- `Spawned Pods``Spawned Workers`
Validation:
- `cargo test -p worker`: success
- `cargo test -p yoi`: success
- `cargo check -p yoi`: success
- `cargo test -p protocol --features typescript`: success
- `cargo check -p yoi-e2e`: success
- `cd web/workspace && deno task check`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Notes:
- `cargo check -p e2e` は package 名が存在しなかったため失敗し、正しい package 名 `yoi-e2e``cargo check -p yoi-e2e` を実行して成功。
Specified grep evidence:
- `rg -n -S --glob '!target' --glob '!docs/report/**' -- '--pod|yoi pod' .`: no matches.
- `rg -n -S --glob '!target' --glob '!docs/report/**' 'sibling coder/reviewer Pods|role Pods|talking to Pods|between clients and Pods|Spawned Pods|child Pods|Pods found|Pod categories|Pod row' crates docs tests resources AGENTS.md README.md KNOWN_ISSUES.md`: no matches.
- Additional E2E stale grep for `pod_rows`, `pod_names`, `row.key.kind == "pod"`, `"--pod"`: no matches.
Commit:
- `da96d06f25932ff60fc00a3fc516a276a2847125 fix: remove stale pod guidance references`
Residual risks / notes:
- child worktree は commit 後 clean。
---
<!-- event: review author: yoi-reviewer-00001KVZG9BMS-worker-rename at: 2026-06-25T16:00:39Z status: request_changes -->
## Review: request changes
Review result: request_changes
読取専用で `da96d06f` を確認した。指定 grep は報告通り概ね解消しており、`--pod` / `yoi pod`、`pod_rows` / `pod_names` / `row.key.kind == "pod"` は見当たらなかった。
ただし、前回 blocker の「active user/developer-facing guidance に旧 Pod terminology が残存」に対して、まだ active docs/test diagnostics に旧名が残っている。
Remaining blockers:
- `docs/development/workflows.md`
- `20: ## Child Pods`
- `38: Child Pods may commit ...`
- `docs/development/work-items.md`
- `27: Inside Pods ...`
- `32: Ticket tools inside Pods`
- `34: Pods with the Ticket built-in feature ...`
- `198: spawn coder/reviewer Pods ...`
- `229: coder Pods ...`
- `tests/e2e/tests/panel.rs`
- `77: fixture blocks host Pod launch`
- `626: host/fixture runtime Pod marker ...`
これらは `pod-store` / `pod-registry` crate 名や on-disk `pods` compatibility の説明ではなく、現在の Worker 実行単位・workflow guidance・test diagnostics なので、Ticket の Worker rename intent に合わせて `Worker(s)` へ更新が必要。
`SendToPeerPod` の analytics-only legacy constant は、現行 `SendToPeerWorker` 分類と明示 comment があるため許容。
---
<!-- event: implementation_report author: yoi-coder-00001KVZG9BMS-worker-rename at: 2026-06-25T16:07:26Z -->
## Implementation report
Fourth follow-up implementation report after review request_changes:
対応内容:
1. 指摘箇所の Worker terminology 更新
- `docs/development/workflows.md`
- `## Child Pods``## Child Workers`
- `Child Pods may commit ...``Child Workers may commit ...`
- `docs/development/work-items.md`
- `Inside Pods ...``Inside Workers ...`
- `Ticket tools inside Pods``Ticket tools inside Workers`
- `Pods with the Ticket built-in feature ...``Workers with the Ticket built-in feature ...`
- `spawn coder/reviewer Pods ...``spawn coder/reviewer Workers ...`
- `coder Pods ...``coder Workers ...`
- `tests/e2e/tests/panel.rs`
- `fixture blocks host Pod launch``fixture blocks host Worker launch`
- `host/fixture runtime Pod marker ...``host/fixture runtime Worker marker ...`
2. 追加で見つかった docs/tests の active Pod 表現も更新
- `docs/design/tool-permissions-scope.md`: `Child Pods``Child Workers`
- `docs/design/compaction.md`: `long-running Pods``long-running Workers`
- `docs/design/memory-knowledge.md`: `normal Pods``normal Workers`
- `docs/design/plugin-packages.md`: `Restored Pods``Restored Workers`
- `docs/development/environment.md`: `spawned Pods``spawned Workers`
- `tests/e2e/tests/rewind.rs`: `single-pod rewind fixture``single-worker rewind fixture`
- `tests/e2e/tests/panel.rs`: active test helper/fixture naming and diagnostics updated to Worker terminology.
Validation:
- `cargo test -p worker`: success
- `cargo test -p yoi`: success
- `cargo check -p yoi`: success
- `cargo test -p protocol --features typescript`: success
- `cargo check -p yoi-e2e`: success
- `cd web/workspace && deno task check`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Grep evidence:
- `rg -n -S --glob '!target' --glob '!docs/report/**' -- '--pod|yoi pod' .`: no matches.
- `rg -n -S --glob '!target' --glob '!docs/report/**' 'sibling coder/reviewer Pods|role Pods|talking to Pods|between clients and Pods|Spawned Pods|child Pods|Pods found|Pod categories|Pod row' crates docs tests resources AGENTS.md README.md KNOWN_ISSUES.md`: no matches.
- `rg -n -S 'Child Pods|Inside Pods|Ticket tools inside Pods|Pods with the Ticket built-in feature|spawn coder/reviewer Pods|coder Pods|host Pod launch|runtime Pod marker' docs/development/workflows.md docs/development/work-items.md tests/e2e/tests/panel.rs`: no matches.
- `rg -n -S '\bPod\b|\bPods\b' docs tests --glob '!target' --glob '!docs/report/**'`: no matches.
Commit:
- `cb0c52e7871849b9482c37ae63cacf4a2898da90 fix: update remaining active worker wording`
Residual risks / notes:
- child worktree は commit 後 clean。
---
<!-- event: review author: yoi-reviewer-00001KVZG9BMS-worker-rename at: 2026-06-25T16:11:36Z status: request_changes -->
## Review: request changes
Review result: request_changes
読取専用で `cb0c52e7` を確認した。前回指摘した `docs/development/workflows.md` / `docs/development/work-items.md` / `tests/e2e/tests/panel.rs` の blocker は解消している。指定 grep も報告通り空だった。
ただし、Ticket の「public type / module / doc comment の Pod terminology を Worker terminology に更新」「active references は legacy context として明示」条件に対して、まだ active な旧 wording が残っている。
Remaining blockers:
- `crates/tui/src/picker.rs:49`
- user-facing error message: `list all host/data-dir Pods`
- 現在の CLI/UX surface では Worker を表示しているため `Workers` へ更新が必要。
- `crates/tools/README.md:5`
- `used by Pods`
- crate README の active architecture doc なので `Workers` へ更新が必要。
- `crates/manifest/README.md:5`
- `used to create or restore Pods`
- `Worker` terminology に更新が必要。
- `crates/pod-store/Cargo.toml:3`
- description: `Durable Pod-name metadata/state persistence`
- `pod-store` crate 名を残すのは Ticket invariant 通りだが、metadata description は current Worker 実行単位に合わせるか、`legacy-named ... Worker metadata/state persistence` のように legacy context を明示する必要がある。
補足:
- `SendToPeerPod` の analytics-only legacy constant は、現行 `SendToPeerWorker` 分類と明示 comment があるため許容。
- `YOI_POD_RUNTIME_COMMAND` や on-disk `pods` compatibility は、別途 legacy/process compatibility context として扱われているため今回の blocker にはしていない。
---
<!-- event: implementation_report author: yoi-coder-00001KVZG9BMS-worker-rename at: 2026-06-25T16:13:43Z -->
## Implementation report
Fifth follow-up implementation report after review request_changes:
対応内容:
- `crates/tui/src/picker.rs`
- user-facing error message: `list all host/data-dir Pods``list all host/data-dir Workers`
- `crates/tools/README.md`
- `used by Pods``used by Workers`
- `crates/manifest/README.md`
- `used to create or restore Pods``used to create or restore Workers`
- `crates/pod-store/Cargo.toml`
- description: `Durable Pod-name metadata/state persistence``Legacy-named durable Worker metadata/state persistence`
- crate 名 `pod-store` は invariant 通り維持。
Validation:
- `cargo fmt --all`: success
- `cargo check -p yoi`: success
- `cargo test -p yoi`: success
- `git diff --check`: success
今回の変更は README / Cargo description / user-facing string の小修正のみのため、重い検証は追加していない。
Grep evidence:
- `rg -n -S 'list all host/data-dir Pods|used by Pods|used to create or restore Pods|Durable Pod-name metadata/state persistence' crates docs tests --glob '!target' --glob '!docs/report/**'`: no matches.
- `rg -n -S '\bPod\b|\bPods\b|\bpod-name\b|\bPod-name\b' crates docs tests --glob '!target' --glob '!docs/report/**'`: no matches.
Commit:
- `60dbd724c5496e80c7fe31a51f1425a7688e50ee fix: clean remaining worker wording`
Residual risks / notes:
- child worktree は commit 後 clean。
---
<!-- event: review author: yoi-reviewer-00001KVZG9BMS-worker-rename at: 2026-06-25T16:15:07Z status: approve -->
## Review: approve
Review result: approve
読取専用で `60dbd724` の follow-up diff と grep を確認した。追加の blocker は見つからなかった。
確認した点:
- `crates/tui/src/picker.rs` の user-facing wording は `Workers` に更新済み。
- `crates/tools/README.md` / `crates/manifest/README.md``Workers` terminology に更新済み。
- `crates/pod-store/Cargo.toml` description は `Legacy-named durable Worker metadata/state persistence` となり、crate 名を残す Ticket invariant と current Worker terminology の両方に整合。
- `crates/worker` は存在し、`crates/pod` は不在。
- workspace/Cargo 上の旧 `pod` package/import は見当たらず、`worker` crate 参照になっている。
- 指定 blocker phrase grep は空。
- repo-wide の高リスク旧語 grep では、`SendToPeerPod` は `crates/session-analytics` の analytics-only legacy constant のみで、現行 `SendToPeerWorker` 分類と明示 comment があるため許容範囲。
- `--pod` / `yoi pod` / `PodEvent` / `PodStatus` / `PodLaunchFailed` / `SpawnPod` / `pod-session-state.md` 等の active stale refs は見当たらなかった。
検証コマンド自体は read-only 方針のため再実行していないが、報告された validation 内容と今回の差分・grep 確認から、これまでの request_changes blockers は解消済みと判断する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-25T16:17:59Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Dashboard 起点の `/ticket-orchestrator-routing` で target Ticket は未指定だったため、queue 状態を再確認した。
- `TicketList` では `queued` 0 件、`ready` 0 件、`inprogress` 1 件のみ。追加で受理すべき queued Ticket はない。
- 唯一の active Ticket は本 Ticket `00001KVZG9BMS`。実装 branch は reviewer approve 済みで、orchestration worktree には merge commit `2a7e8775 merge: 00001KVZG9BMS worker crate rename` がある。
- ただし Orchestrator 側の最終 validation 実行はユーザー割り込みで中断され、merge 後の Orchestrator validation / done 遷移 / child worktree cleanup の evidence はまだ Ticket に記録されていない。
- 今回の workflow invocation は「Dashboard opened... wait for explicit follow-up before spawning role Pods」という指示付きであり、この routing pass では新規 role Pod は spawn しない。
Evidence checked:
- Ticket body / thread: rename 要件、accepted IntentPacket、Coder implementation reports、Reviewer approve (`60dbd724` follow-up) を確認。
- Relations: outgoing dependency `00001KVZD10ED`、incoming dependent `00001KVZBCQH4`。derived blockers は空。
- Orchestration plan: accepted plan `orch-plan-20260625-141406-1` は implementation worktree `/home/hare/Projects/yoi/.worktree/00001KVZG9BMS-worker-crate-rename` / branch `work/00001KVZG9BMS-worker-crate-rename`
- Repository/worktree: orchestration branch HEAD は `2a7e8775` merge commit、working tree は clean。implementation worktree remains at `60dbd724` and clean per prior checks.
- Visible Workers/Pods: existing coder/reviewer child Workers are idle; no new child Worker was spawned in this routing pass.
Next action:
- 明示 follow-up があれば、orchestration worktree で interrupted final validation を再開する。
- validation が通れば結果を implementation_report として記録し、`inprogress -> done`、必要な cleanupchild Workers stop、implementation worktree/branch cleanupへ進む。
- validation/review evidence に新 blocker が出た場合だけ、追加修正または reviewer loop を検討する。新規 role Worker spawn は明示 follow-up/authority のもとで行う。
Escalate if:
- merge 後 validation が失敗する。
- cleanup が dirty worktree / branch divergence / live Worker scope conflict で blocked になる。
- `develop` など merge target への反映権限・タイミングについて追加判断が必要になる。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-25T16:19:23Z from: inprogress to: done reason: user_confirmed_done_after_merge_and_review field: state -->
## State changed
ユーザー指示「StopPodでハングしてそうだったから再起しただけ / doneにしといて」を受け、完了済みとして記録する。
完了根拠:
- 実装 branch は orchestration worktree に merge 済み: `2a7e8775 merge: 00001KVZG9BMS worker crate rename`
- Reviewer は `60dbd724` follow-up を approve 済みで、残 blocker なし。
- Coder 側 validation は `cargo test -p worker`、`cargo test -p yoi`、`cargo check -p yoi`、`cargo test -p protocol --features typescript`、`cargo check -p yoi-e2e`、`web/workspace deno task check`、`git diff --check`、`nix build .#yoi --no-link` 成功報告あり。
- 最終 Orchestrator validation は operator restart により中断されていたが、ユーザーがその事情を説明し done 記録を明示許可した。
補足:
- この state change は close ではなく `done` 記録。必要なら別途 close / cleanup を扱う。
---

View File

@ -11,12 +11,12 @@
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
Podの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
**禁止**: ターンを跨ぐことができない情報に基づいて、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"
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]]
name = "pod-registry"
version = "0.1.0"
@ -5901,6 +5855,52 @@ dependencies = [
"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]]
name = "workflow"
version = "0.1.0"
@ -5942,7 +5942,6 @@ dependencies = [
"client",
"manifest",
"memory",
"pod",
"pod-store",
"project-record",
"serde",
@ -5954,6 +5953,7 @@ dependencies = [
"ticket",
"tokio",
"tui",
"worker",
]
[[package]]

View File

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

View File

@ -2,7 +2,7 @@
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/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/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/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/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 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
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:
- 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.
- Multi-agent orchestration with scoped coder/reviewer Pods.
- Multi-agent orchestration with scoped coder/reviewer Workers.
- Profile, Manifest, and prompt-based runtime configuration.
- 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.
@ -39,14 +39,14 @@ nix build .#yoi
yoi --help
yoi
yoi panel
yoi --pod <name>
yoi pod --help
yoi --worker <name>
yoi worker --help
```
Typical flow:
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.
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/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/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.

View File

@ -2,13 +2,13 @@
## 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
Owns:
- one-shot Pod socket client behavior
- one-shot Worker socket client behavior
- request/reply delivery mechanics
- runtime command construction below the product façade
- shared attach/status probing helpers used by higher layers
@ -16,15 +16,15 @@ Owns:
Does not own:
- product command names (`yoi`)
- Pod state authority (`pod`, `pod-store`, `session-store`)
- Worker state authority (`worker`, `pod-store`, `session-store`)
- UI rendering (`tui`)
- Engine turn semantics (`llm-engine`)
## 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
- [`../../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)

View File

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

View File

@ -6,12 +6,12 @@ use std::path::{Path, PathBuf};
const POD_RUNTIME_COMMAND_ENV: &str = "YOI_POD_RUNTIME_COMMAND";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PodRuntimeCommand {
pub struct WorkerRuntimeCommand {
pub program: PathBuf,
pub prefix_args: Vec<OsString>,
}
impl PodRuntimeCommand {
impl WorkerRuntimeCommand {
pub fn new(program: impl Into<PathBuf>, prefix_args: Vec<OsString>) -> Self {
Self {
program: program.into(),
@ -24,15 +24,15 @@ impl PodRuntimeCommand {
}
pub fn for_executable(program: impl Into<PathBuf>) -> Self {
Self::new(program, vec![OsString::from("pod")])
Self::new(program, vec![OsString::from("worker")])
}
/// Resolve the Pod runtime command used for subprocess launches.
/// Resolve the Worker runtime command used for subprocess launches.
///
/// The default launch path is always the current `yoi` executable plus
/// the unified `pod` prefix argument. During development, a non-empty
/// the unified `worker` prefix argument. During development, a non-empty
/// `YOI_POD_RUNTIME_COMMAND` value replaces only the executable path;
/// the `pod` prefix is still added here and the env value is not parsed as a
/// the `worker` prefix is still added here and the env value is not parsed as a
/// shell command.
pub fn resolve() -> io::Result<Self> {
Self::resolve_from_env_value(
@ -74,7 +74,7 @@ impl PodRuntimeCommand {
}
}
impl fmt::Display for PodRuntimeCommand {
impl fmt::Display for WorkerRuntimeCommand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.program.display())?;
for arg in &self.prefix_args {
@ -89,14 +89,14 @@ mod tests {
use super::*;
#[test]
fn yoi_binary_defaults_to_pod_prefix() {
let command = PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi");
fn yoi_binary_defaults_to_worker_prefix() {
let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi");
assert_eq!(command.program(), Path::new("/opt/yoi/bin/yoi"));
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
assert_eq!(
command.argv_with(["--pod", "agent"]),
vec!["pod", "--pod", "agent"]
command.argv_with(["--worker", "agent"]),
vec!["worker", "--worker", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
@ -104,14 +104,14 @@ mod tests {
}
#[test]
fn any_runtime_executable_gets_pod_prefix() {
let command = PodRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime");
fn any_runtime_executable_gets_worker_prefix() {
let command = WorkerRuntimeCommand::for_executable("/opt/yoi/bin/custom-runtime");
assert_eq!(command.program(), Path::new("/opt/yoi/bin/custom-runtime"));
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
assert_eq!(
command.argv_with(["--pod", "agent"]),
vec!["pod", "--pod", "agent"]
command.argv_with(["--worker", "agent"]),
vec!["worker", "--worker", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()
@ -120,43 +120,43 @@ mod tests {
#[test]
fn resolve_uses_current_exe_when_override_is_unset() {
let command = PodRuntimeCommand::resolve_from_env_value(None, || {
let command = WorkerRuntimeCommand::resolve_from_env_value(None, || {
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
})
.unwrap();
assert_eq!(
command,
PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
);
}
#[test]
fn resolve_uses_current_exe_when_override_is_empty() {
let command = PodRuntimeCommand::resolve_from_env_value(Some(OsString::new()), || {
let command = WorkerRuntimeCommand::resolve_from_env_value(Some(OsString::new()), || {
Ok(PathBuf::from("/opt/yoi/bin/yoi"))
})
.unwrap();
assert_eq!(
command,
PodRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
WorkerRuntimeCommand::for_executable("/opt/yoi/bin/yoi")
);
}
#[test]
fn resolve_override_replaces_only_program_and_keeps_pod_prefix() {
let command = PodRuntimeCommand::resolve_from_env_value(
fn resolve_override_replaces_only_program_and_keeps_worker_prefix() {
let command = WorkerRuntimeCommand::resolve_from_env_value(
Some(OsString::from("/tmp/rebuilt yoi")),
|| panic!("override must not inspect current_exe"),
)
.unwrap();
assert_eq!(command.program(), Path::new("/tmp/rebuilt yoi"));
assert_eq!(command.prefix_args(), [OsString::from("pod")]);
assert_eq!(command.prefix_args(), [OsString::from("worker")]);
assert_eq!(
command.argv_with(["--pod", "agent"]),
vec!["pod", "--pod", "agent"]
command.argv_with(["--worker", "agent"]),
vec!["worker", "--worker", "agent"]
.into_iter()
.map(OsString::from)
.collect::<Vec<_>>()

View File

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

View File

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

View File

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

View File

@ -20,7 +20,7 @@ Does not own:
## 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

View File

@ -16,10 +16,10 @@ Owns:
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`)
- provider catalog and secret resolution (`provider`, `secrets`)
- durable Pod current state (`pod-store`)
- durable Worker current state (`pod-store`)
## Design notes

View File

@ -213,7 +213,7 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable> {
/// stream events become visible.
lifecycle_trace_cbs: Vec<Arc<dyn Fn(usize, usize, &str, &Value) + Send + Sync>>,
/// 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!`,
/// which is for developer-facing logs.
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.
cache_anchor: Option<usize>,
/// 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` を渡す。
cache_key: Option<String>,
/// State marker
@ -487,7 +487,7 @@ impl<C: LlmClient, S: EngineState> Engine<C, S> {
/// Fired after `post_tool_call` interceptors and any `content`
/// truncation from `tool_output_limits`, so the callback observes
/// 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) {
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
// history (notifications, cross-Pod events, system
// history (notifications, cross-Worker events, system
// reminders). These are committed *before* the per-request
// clone so they participate in the LLM request below and
// 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
// `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.
let reasoning_items = self.thinking_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)) => {
// 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.
let items = results.into_iter().map(|result| {
Item::tool_result_item(
@ -1708,7 +1708,7 @@ impl<C: LlmClient> Engine<C, Mutable> {
/// Install byte-size caps for tool execution `content`.
///
/// 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.
pub fn set_tool_output_limits(&mut self, limits: Option<ToolOutputLimits>) {
self.tool_output_limits = limits;

View File

@ -1,6 +1,6 @@
//! 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)
//! into the Engine's turn loop without the Engine knowing about
//! higher-level concepts.
@ -132,7 +132,7 @@ pub struct ToolResultInfo {
/// Intercepts the Engine execution loop at key decision points.
///
/// 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.
#[async_trait]
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
/// 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
/// per-request clone, so any committed assistant response that
/// 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")]
pub top_p: Option<f32>,
/// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが
/// 無いとプロンプトキャッシュがほぼ効かない。pod 側は `SegmentId`
/// 無いとプロンプトキャッシュがほぼ効かない。worker 側は `SegmentId`
/// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,

View File

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

View File

@ -8,7 +8,7 @@
//!
//! Prune は **コンテキスト射影** であり、history の変換ではない。
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
//! 射影の適用は上位層(`pod::prune_hook` 等)が LLM に送る一時コンテキスト
//! 射影の適用は上位層(`worker::prune_hook` 等)が LLM に送る一時コンテキスト
//! に対してだけ行う。Engine の永続履歴は決して変更されない。
//!
//! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を
@ -75,7 +75,7 @@ pub enum PruneDecision {
}
/// 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>;
/// Configuration for the Prune algorithm.

View File

@ -130,13 +130,13 @@ mod tests {
let mut timeline = Timeline::new();
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));
let calls = collector.take_collected();
assert_eq!(calls.len(), 1);
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_eq!(
calls[0].input,

View File

@ -75,7 +75,7 @@ impl ToolServerHandle {
/// Execute all pending factories and register the resulting tools.
///
/// 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
/// context that needs the list of registered tool names. Redundant
/// calls are no-ops.

View File

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

View File

@ -2,7 +2,7 @@
## 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
@ -17,13 +17,13 @@ Owns:
Does not own:
- provider HTTP clients or secret lookup implementation (`provider`, `secrets`)
- Pod lifecycle (`pod`)
- Worker lifecycle (`worker`)
- product CLI parsing (`yoi`)
- generated memory records (`memory`)
## 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

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

View File

@ -1,7 +1,7 @@
//! Single source of truth for manifest default values.
//!
//! 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
//! `pub const`. Both paths read from this module, so changing a
//! 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";
/// 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 =
"match the user's language unless they explicitly request another language";

View File

@ -7,9 +7,9 @@ mod profile;
mod scope;
pub use config::{
CompactionConfigPartial, FileUploadLimitsPartial, PermissionConfigPartial, PodManifestConfig,
PodMetaConfig, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
WorkerManifestConfig,
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
WorkerManifestConfig, WorkerMetaConfig,
};
pub use model::{
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
@ -32,20 +32,20 @@ use std::path::PathBuf;
use serde::de::Error as _;
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,
/// 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()`
/// at construction time.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PodManifest {
pub pod: PodMeta,
pub struct WorkerManifest {
pub worker: WorkerMeta,
pub model: ModelManifest,
pub worker: WorkerManifest,
/// Direct filesystem authority for this Pod's own tools.
pub engine: EngineManifest,
/// Direct filesystem authority for this Worker's own tools.
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.
#[serde(default)]
pub delegation_scope: ScopeConfig,
@ -91,14 +91,14 @@ pub struct PodManifest {
#[serde(default)]
pub skills: Option<SkillsConfig>,
/// 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.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<profile::ProfileManifestSnapshot>,
}
/// 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
/// the normal scope, host-authority, backend, memory, and network checks.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -110,7 +110,7 @@ pub struct FeatureConfig {
#[serde(default)]
pub web: FeatureFlagConfig,
#[serde(default)]
pub pods: FeatureFlagConfig,
pub workers: FeatureFlagConfig,
#[serde(default)]
pub ticket: TicketFeatureConfig,
#[serde(default)]
@ -125,7 +125,7 @@ impl Default for FeatureConfig {
task: FeatureFlagConfig::disabled(),
memory: FeatureFlagConfig::disabled(),
web: FeatureFlagConfig::disabled(),
pods: FeatureFlagConfig::disabled(),
workers: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(),
ticket_orchestration: FeatureFlagConfig::disabled(),
plugins: FeatureFlagConfig::disabled(),
@ -197,7 +197,7 @@ pub struct SkillsConfig {
/// Skills *roots*. Children of each root must be individual
/// `<name>/SKILL.md` bundles; the directory itself is not a skill.
/// Resolved against the manifest base directory before
/// [`PodManifest`] is materialised.
/// [`WorkerManifest`] is materialised.
#[serde(default)]
pub directories: Vec<PathBuf>,
}
@ -206,7 +206,7 @@ pub struct SkillsConfig {
///
/// 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
/// 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)]
#[serde(deny_unknown_fields)]
pub struct McpConfig {
@ -363,7 +363,7 @@ pub struct WebFetchConfig {
/// Memory subsystem configuration. Presence in the manifest enables
/// 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
/// as a memory root.
///
@ -396,7 +396,7 @@ pub struct MemoryConfig {
#[serde(default)]
pub language: Option<String>,
/// 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
/// recommended.
#[serde(default)]
@ -414,7 +414,7 @@ pub struct MemoryConfig {
#[serde(default)]
pub extract_worker_max_turns: Option<u32>,
/// 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.
#[serde(default)]
pub consolidation_model: Option<ModelManifest>,
@ -432,12 +432,12 @@ pub struct MemoryConfig {
pub consolidation_threshold_bytes: Option<u64>,
}
/// Pod metadata.
/// Worker metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PodMeta {
pub struct WorkerMeta {
pub name: String,
/// 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
/// manifest's base directory). `None` leaves the 4th overlay layer
/// empty; auto-discovered user and workspace packs still apply.
@ -453,7 +453,7 @@ pub struct PodMeta {
/// Worker-level configuration embedded in the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerManifest {
pub struct EngineManifest {
/// Reference to the instruction prompt asset used as the body of
/// the worker's system prompt. Uses the `PromptLoader` prefix
/// addressing scheme (`$yoi/...`, `$user/...`,
@ -572,7 +572,7 @@ impl ToolOutputLimits {
/// 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
/// [`Scope`] for the resolved runtime form.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@ -653,7 +653,7 @@ pub struct CompactionConfig {
/// 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
/// Controller can compact before the next LLM request. `None`
/// disables the between-requests check.
@ -802,7 +802,7 @@ impl Default for CompactionConfig {
}
}
impl PodManifest {
impl WorkerManifest {
/// Parse a manifest from a TOML string.
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
config::reject_removed_manifest_fields(s)?;
@ -818,14 +818,14 @@ mod tests {
use super::*;
const MINIMAL_REQUIRED: &str = r#"
[pod]
[worker]
name = "test-agent"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[worker]
[engine]
[[scope.allow]]
target = "/abs/scope"
@ -834,8 +834,8 @@ permission = "write"
#[test]
fn parse_minimal_manifest() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert_eq!(manifest.pod.name, "test-agent");
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert_eq!(manifest.worker.name, "test-agent");
assert_eq!(manifest.model.scheme, Some(SchemeKind::Anthropic));
assert_eq!(
manifest.model.model_id.as_deref(),
@ -846,19 +846,19 @@ permission = "write"
assert!(manifest.scope.deny.is_empty());
assert!(manifest.delegation_scope.allow.is_empty());
assert!(manifest.delegation_scope.deny.is_empty());
assert_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION);
assert!(manifest.worker.top_p.is_none());
assert!(manifest.worker.top_k.is_none());
assert!(manifest.worker.stop_sequences.is_empty());
assert_eq!(manifest.engine.instruction, defaults::DEFAULT_INSTRUCTION);
assert!(manifest.engine.top_p.is_none());
assert!(manifest.engine.top_k.is_none());
assert!(manifest.engine.stop_sequences.is_empty());
assert!(manifest.web.is_none());
}
#[test]
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();
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!(restored.delegation_scope.allow.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",
MINIMAL_REQUIRED
);
let manifest = PodManifest::from_toml(&toml).unwrap();
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let web = manifest.web.unwrap();
assert_eq!(web.enabled, Some(true));
let search = web.search.unwrap();
@ -886,7 +886,7 @@ permission = "write"
#[test]
fn parse_full_manifest() {
let toml = r#"
[pod]
[worker]
name = "code-reviewer"
[model]
@ -894,7 +894,7 @@ scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
auth = { kind = "api_key", file = "/abs/keys/anthropic" }
[worker]
[engine]
instruction = "$user/reviewer"
max_tokens = 4096
temperature = 0.3
@ -924,21 +924,21 @@ permission = "write"
target = "/abs/project/tasks/private"
permission = "write"
"#;
let manifest = PodManifest::from_toml(toml).unwrap();
assert_eq!(manifest.pod.name, "code-reviewer");
let manifest = WorkerManifest::from_toml(toml).unwrap();
assert_eq!(manifest.worker.name, "code-reviewer");
let file = match manifest.model.auth.as_ref() {
Some(AuthRef::ApiKey { file, .. }) => file.as_deref(),
_ => panic!("expected ApiKey"),
};
assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic")));
assert_eq!(manifest.worker.instruction, "$user/reviewer");
assert_eq!(manifest.worker.max_tokens, Some(4096));
assert_eq!(manifest.worker.temperature, Some(0.3));
assert_eq!(manifest.worker.top_p, Some(0.9));
assert_eq!(manifest.worker.top_k, Some(40));
assert_eq!(manifest.worker.stop_sequences, vec!["\n\n", "</stop>"]);
assert_eq!(manifest.engine.instruction, "$user/reviewer");
assert_eq!(manifest.engine.max_tokens, Some(4096));
assert_eq!(manifest.engine.temperature, Some(0.3));
assert_eq!(manifest.engine.top_p, Some(0.9));
assert_eq!(manifest.engine.top_k, Some(40));
assert_eq!(manifest.engine.stop_sequences, vec!["\n\n", "</stop>"]);
assert_eq!(
manifest.worker.reasoning,
manifest.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::Medium))
);
let allow = &manifest.scope.allow;
@ -960,16 +960,16 @@ permission = "write"
#[test]
fn reject_missing_scope() {
let toml = r#"
[pod]
[worker]
name = "missing-scope"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[worker]
[engine]
"#;
assert!(PodManifest::from_toml(toml).is_err());
assert!(WorkerManifest::from_toml(toml).is_err());
}
#[test]
@ -984,7 +984,7 @@ model_id = "claude-sonnet-4-20250514"
[plugins.enabled.config]\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);
let enabled = &manifest.plugins.enabled[0];
assert_eq!(enabled.id, "project:example");
@ -1005,37 +1005,37 @@ model_id = "claude-sonnet-4-20250514"
#[test]
fn parse_max_turns() {
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 50\n");
let manifest = PodManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.worker.max_turns.unwrap().get(), 50);
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nmax_turns = 50\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.engine.max_turns.unwrap().get(), 50);
}
#[test]
fn parse_reasoning_budget() {
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nreasoning = -1\n");
let manifest = PodManifest::from_toml(&toml).unwrap();
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nreasoning = -1\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
assert_eq!(
manifest.worker.reasoning,
manifest.engine.reasoning,
Some(ReasoningControl::BudgetTokens(-1))
);
}
#[test]
fn omitted_max_turns_is_none() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert!(manifest.worker.max_turns.is_none());
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert!(manifest.engine.max_turns.is_none());
}
#[test]
fn reject_max_turns_zero() {
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nmax_turns = 0\n");
assert!(PodManifest::from_toml(&toml).is_err());
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nmax_turns = 0\n");
assert!(WorkerManifest::from_toml(&toml).is_err());
}
#[test]
fn parse_compaction_config() {
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();
assert_eq!(c.prune_protected_tokens, 8000);
assert_eq!(c.prune_min_savings, 4096);
@ -1048,7 +1048,7 @@ model_id = "claude-sonnet-4-20250514"
#[test]
fn reject_removed_prune_protected_turns_field() {
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!(
err.to_string().contains("compaction.prune_protected_turns"),
"unexpected error: {err}"
@ -1062,7 +1062,7 @@ model_id = "claude-sonnet-4-20250514"
[compaction]\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();
assert_eq!(c.worker_max_turns, Some(7));
}
@ -1075,7 +1075,7 @@ model_id = "claude-sonnet-4-20250514"
threshold = 80000\n\
request_threshold = 90000\n"
);
let manifest = PodManifest::from_toml(&toml).unwrap();
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let c = manifest.compaction.unwrap();
assert_eq!(c.threshold, Some(80000));
assert_eq!(c.request_threshold, Some(90000));
@ -1088,7 +1088,7 @@ model_id = "claude-sonnet-4-20250514"
[compaction]\n\
request_threshold = 90000\n"
);
let manifest = PodManifest::from_toml(&toml).unwrap();
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let c = manifest.compaction.unwrap();
assert_eq!(c.threshold, None);
assert_eq!(c.request_threshold, Some(90000));
@ -1104,7 +1104,7 @@ model_id = "claude-sonnet-4-20250514"
scheme = \"gemini\"\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 p = c.model.unwrap();
assert_eq!(p.scheme, Some(SchemeKind::Gemini));
@ -1113,20 +1113,20 @@ model_id = "claude-sonnet-4-20250514"
#[test]
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());
}
#[test]
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());
}
#[test]
fn empty_memory_section_enables_with_default_root() {
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");
assert!(mem.workspace_root.is_none());
assert_eq!(mem.inject_summary, None);
@ -1135,7 +1135,7 @@ model_id = "claude-sonnet-4-20250514"
#[test]
fn memory_section_with_inject_summary_false() {
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();
assert_eq!(mem.inject_summary, Some(false));
}
@ -1143,7 +1143,7 @@ model_id = "claude-sonnet-4-20250514"
#[test]
fn memory_section_with_explicit_root() {
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();
assert_eq!(
mem.workspace_root.unwrap(),
@ -1154,7 +1154,7 @@ model_id = "claude-sonnet-4-20250514"
#[test]
fn memory_section_with_language() {
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();
assert_eq!(mem.language.as_deref(), Some("Japanese"));
}
@ -1163,62 +1163,62 @@ model_id = "claude-sonnet-4-20250514"
fn reject_unknown_scheme() {
let toml =
MINIMAL_REQUIRED.replace("scheme = \"anthropic\"", "scheme = \"unknown_scheme\"");
assert!(PodManifest::from_toml(&toml).is_err());
assert!(WorkerManifest::from_toml(&toml).is_err());
}
#[test]
fn omitted_limits_fall_back_to_defaults() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let limits = &manifest.worker.tool_output;
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
let limits = &manifest.engine.tool_output;
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
assert!(limits.per_tool.is_empty());
assert_eq!(
manifest.worker.file_upload.max_bytes,
manifest.engine.file_upload.max_bytes,
defaults::FILE_UPLOAD_MAX_BYTES
);
}
#[test]
fn worker_language_defaults_and_parses() {
let manifest = PodManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert_eq!(manifest.worker.language, defaults::WORKER_LANGUAGE);
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
assert_eq!(manifest.engine.language, defaults::WORKER_LANGUAGE);
let toml = MINIMAL_REQUIRED.replace("[worker]\n", "[worker]\nlanguage = \"Japanese\"\n");
let manifest = PodManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.worker.language, "Japanese");
let toml = MINIMAL_REQUIRED.replace("[engine]\n", "[engine]\nlanguage = \"Japanese\"\n");
let manifest = WorkerManifest::from_toml(&toml).unwrap();
assert_eq!(manifest.engine.language, "Japanese");
}
#[test]
fn parse_worker_output_limits() {
let toml = MINIMAL_REQUIRED.replace(
"[worker]\n",
"[worker]\n\
"[engine]\n",
"[engine]\n\
[worker.tool_output]\n\
default_max_bytes = 8192\n\n\
[worker.tool_output.per_tool]\n\
Read = 32768\n\
Grep = 4096\n\n\
[worker.file_upload]\n\
[engine.file_upload]\n\
max_bytes = 12345\n",
);
let manifest = PodManifest::from_toml(&toml).unwrap();
let limits = &manifest.worker.tool_output;
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let limits = &manifest.engine.tool_output;
assert_eq!(limits.default_max_bytes, 8192);
assert_eq!(limits.limit_for("Read"), 32768);
assert_eq!(limits.limit_for("Grep"), 4096);
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]
fn empty_tool_output_section_uses_default_max_bytes() {
let toml = MINIMAL_REQUIRED.replace(
"[worker]\n",
"[worker]\n\
"[engine]\n",
"[engine]\n\
[worker.tool_output]\n",
);
let manifest = PodManifest::from_toml(&toml).unwrap();
let limits = &manifest.worker.tool_output;
let manifest = WorkerManifest::from_toml(&toml).unwrap();
let limits = &manifest.engine.tool_output;
assert_eq!(limits.default_max_bytes, defaults::TOOL_OUTPUT_MAX_BYTES);
assert!(limits.per_tool.is_empty());
}

View File

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

View File

@ -6,7 +6,7 @@
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
//! - **`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` 等が置かれる。再起動で消えて構わない。
pub fn runtime_dir() -> Option<PathBuf> {
resolve_runtime_dir_from_parts(
@ -61,7 +61,7 @@ pub fn runtime_dir() -> Option<PathBuf> {
/// `<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.
pub fn user_profiles_path() -> Option<PathBuf> {
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())
}
/// `<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> {
pod_registry_path_from_runtime_dir(runtime_dir())
}
/// `<runtime_dir>/<pod_name>/` — Pod ごとのランタイムディレクトリ。
pub fn pod_runtime_dir(pod_name: &str) -> Option<PathBuf> {
pod_runtime_dir_from_runtime_dir(runtime_dir(), pod_name)
/// `<runtime_dir>/<worker_name>/` — Worker ごとのランタイムディレクトリ。
pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
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 の
/// `RuntimeDir::socket_path()` で、Pod 名が分かっている外部 (TUI の
/// Worker プロセス内で実際に socket を作成するのは `worker` crate の
/// `RuntimeDir::socket_path()` で、Worker 名が分かっている外部 (TUI の
/// attach フロー等) からの**予測**はこの関数で行う。両者は同じパス
/// を返すことが期待される。
pub fn pod_socket_path(pod_name: &str) -> Option<PathBuf> {
pod_socket_path_from_runtime_dir(runtime_dir(), pod_name)
pub fn pod_socket_path(worker_name: &str) -> Option<PathBuf> {
pod_socket_path_from_runtime_dir(runtime_dir(), worker_name)
}
// ---- 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> {
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>,
pod_name: &str,
worker_name: &str,
) -> Option<PathBuf> {
Some(runtime_dir?.join(pod_name))
Some(runtime_dir?.join(worker_name))
}
fn pod_socket_path_from_runtime_dir(
runtime_dir: Option<PathBuf>,
pod_name: &str,
worker_name: &str,
) -> 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("")` と
@ -397,10 +397,10 @@ mod tests {
);
assert_eq!(
pod_registry_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
PathBuf::from("/sand/run/pods.json")
PathBuf::from("/sand/run/workers.json")
);
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")
);
assert_eq!(

View File

@ -2,7 +2,7 @@
//!
//! Profiles are reusable, human-authored recipes. They are intentionally not
//! 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.
use std::cell::RefCell;
@ -19,9 +19,9 @@ use crate::config::{
use crate::model::{AuthRef, ModelManifest};
use crate::plugin::PluginConfig;
use crate::{
McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, PodManifest, PodManifestConfig,
PodMetaConfig, ResolveError, ScopeConfig, ScopeRule, SkillsConfig, WebConfig,
WorkerManifestConfig, paths,
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
WorkerMetaConfig, paths,
};
const PROFILE_FORMAT_V1: &str = "yoi.lua-profile.v1";
@ -408,26 +408,26 @@ pub struct WorkspaceOverrideSnapshot {
#[derive(Debug)]
struct WorkspaceOverrideLayer {
path: PathBuf,
config: PodManifestConfig,
config: WorkerManifestConfig,
}
#[derive(Debug, Clone)]
pub struct ResolvedProfile {
pub source: ProfileSource,
pub profile: Option<ProfileMetadata>,
pub manifest: PodManifest,
pub manifest: WorkerManifest,
pub manifest_snapshot: serde_json::Value,
pub raw_artifact: serde_json::Value,
}
#[derive(Debug, Clone, Default)]
pub struct ProfileResolveOptions {
pub pod_name: Option<String>,
pub worker_name: Option<String>,
}
impl ProfileResolveOptions {
pub fn with_pod_name(name: impl Into<String>) -> Self {
pub fn with_worker_name(name: impl Into<String>) -> 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
/// registry. Callers such as SpawnPod use this to bind discovery to the
/// Pod's cwd instead of the process current directory.
/// registry. Callers such as SpawnWorker use this to bind discovery to the
/// Worker's cwd instead of the process current directory.
pub fn resolve_from_registry(
&self,
selector: &ProfileSelector,
@ -604,22 +604,22 @@ fn resolve_lua_profile_value(
let profile: ProfileConfig = serde_json::from_value(value.clone())
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
validate_profile_paths(&profile)?;
let pod_name = options
.pod_name
.ok_or(ProfileError::MissingRuntimePodName)?;
let worker_name = options
.worker_name
.ok_or(ProfileError::MissingRuntimeWorkerName)?;
let profile_meta = Some(ProfileMetadata {
name: profile.slug.clone().or_else(|| source_name(&source)),
description: profile.description.clone(),
format: Some(PROFILE_FORMAT_V1.to_string()),
});
let compaction = profile_compaction_to_partial(profile.compaction, &profile.model)?;
let config = PodManifestConfig {
pod: PodMetaConfig {
name: Some(pod_name),
let config = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some(worker_name),
prompt_pack: None,
},
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)?,
delegation_scope: profile_delegation_scope_to_config(
profile.delegation_scope,
@ -635,7 +635,8 @@ fn resolve_lua_profile_value(
memory: profile.memory,
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 override_base =
override_layer
@ -652,7 +653,7 @@ fn resolve_lua_profile_value(
} else {
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 {
source: source.clone(),
profile: profile_meta.clone(),
@ -679,7 +680,7 @@ struct ProfileConfig {
#[serde(default)]
model: Option<ModelManifest>,
#[serde(default)]
worker: Option<WorkerManifestConfig>,
engine: Option<EngineManifestConfig>,
#[serde(default)]
scope: Option<ProfileScopeConfig>,
#[serde(default)]
@ -814,16 +815,16 @@ fn load_workspace_override_file(path: &Path) -> Result<WorkspaceOverrideLayer, P
path: path.to_path_buf(),
source,
})?;
let config = PodManifestConfig::from_toml(&content).map_err(|source| {
let config = WorkerManifestConfig::from_toml(&content).map_err(|source| {
ProfileError::WorkspaceOverrideParse {
path: path.to_path_buf(),
source,
}
})?;
if config.pod.name.is_some() {
if config.worker.name.is_some() {
return Err(ProfileError::InvalidWorkspaceOverride {
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 {
@ -1209,8 +1210,8 @@ fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), Profi
)));
}
}
if map.contains_key("pod") {
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()));
if map.contains_key("worker") {
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()) {
for key in ["allow", "deny"] {
@ -1442,7 +1443,7 @@ pub fn resolve_profile_artifact(
source,
base_dir,
base_dir,
ProfileResolveOptions::with_pod_name("artifact-pod"),
ProfileResolveOptions::with_worker_name("artifact-worker"),
raw_artifact.clone(),
raw_artifact,
None,
@ -1489,8 +1490,8 @@ pub enum ProfileError {
InvalidWorkspaceOverride { path: PathBuf, message: String },
#[error("no default profile is configured")]
NoDefaultProfile,
#[error("profile resolution requires an explicit runtime Pod name")]
MissingRuntimePodName,
#[error("profile resolution requires an explicit runtime Worker name")]
MissingRuntimeWorkerName,
#[error("profile not found: {selector}")]
ProfileNotFound { selector: String },
#[error("ambiguous profile name `{name}`; use a source-qualified selector such as {matches:?}")]
@ -1574,17 +1575,17 @@ mod tests {
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, expected),
ProfileResolveOptions::with_pod_name("role-pod"),
ProfileResolveOptions::with_worker_name("role-worker"),
)
.unwrap();
assert_eq!(
resolved.profile.as_ref().unwrap().name.as_deref(),
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") {
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())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, role),
ProfileResolveOptions::with_pod_name("role-pod"),
ProfileResolveOptions::with_worker_name("role-worker"),
)
.unwrap()
.manifest
@ -1605,7 +1606,7 @@ mod tests {
let companion = resolve("companion");
assert!(companion.feature.task.enabled);
assert!(companion.feature.pods.enabled);
assert!(companion.feature.workers.enabled);
assert!(companion.feature.ticket.enabled);
assert!(companion.scope.allow.is_empty());
assert!(companion.scope.deny.is_empty());
@ -1615,7 +1616,7 @@ mod tests {
let intake = resolve("intake");
assert!(!intake.feature.task.enabled);
assert!(!intake.feature.pods.enabled);
assert!(!intake.feature.workers.enabled);
assert!(intake.feature.ticket.enabled);
assert!(intake.scope.allow.is_empty());
assert!(intake.delegation_scope.allow.is_empty());
@ -1625,7 +1626,7 @@ mod tests {
let orchestrator = resolve("orchestrator");
assert!(!orchestrator.feature.task.enabled);
assert!(orchestrator.feature.pods.enabled);
assert!(orchestrator.feature.workers.enabled);
assert!(orchestrator.feature.ticket.enabled);
assert!(orchestrator.feature.ticket_orchestration.enabled);
assert!(orchestrator.scope.allow.is_empty());
@ -1638,7 +1639,7 @@ mod tests {
let coder = resolve("coder");
assert!(coder.feature.task.enabled);
assert!(!coder.feature.pods.enabled);
assert!(!coder.feature.workers.enabled);
assert!(coder.scope.allow.is_empty());
assert!(coder.delegation_scope.allow.is_empty());
assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5"));
@ -1646,7 +1647,7 @@ mod tests {
let reviewer = resolve("reviewer");
assert!(!reviewer.feature.task.enabled);
assert!(!reviewer.feature.pods.enabled);
assert!(!reviewer.feature.workers.enabled);
assert!(!reviewer.feature.ticket.enabled);
assert!(reviewer.scope.allow.is_empty());
assert!(reviewer.delegation_scope.allow.is_empty());
@ -1655,17 +1656,17 @@ mod tests {
}
#[test]
fn profile_resolution_requires_runtime_pod_name() {
fn profile_resolution_requires_runtime_worker_name() {
let tmp = TempDir::new().unwrap();
let err = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(&ProfileSelector::Default, ProfileResolveOptions::default())
.unwrap_err();
assert!(matches!(err, ProfileError::MissingRuntimePodName));
assert!(matches!(err, ProfileError::MissingRuntimeWorkerName));
}
#[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 profile = write_profile(
tmp.path(),
@ -1676,7 +1677,7 @@ local scope = require("yoi.scope")
return profile {
slug = "coder",
model = { scheme = "anthropic", model_id = "claude-sonnet-4-20250514" },
worker = { reasoning = "high" },
engine = { reasoning = "high" },
scope = scope.workspace_read(),
}
"#,
@ -1687,13 +1688,13 @@ return profile {
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(&profile),
ProfileResolveOptions::with_pod_name("runtime-pod"),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.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.worker.reasoning,
resolved.manifest.engine.reasoning,
Some(ReasoningControl::Effort(ReasoningEffort::High))
);
assert_eq!(resolved.manifest.scope.allow[0].target, workspace);
@ -1748,7 +1749,7 @@ return profile {
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(&profile),
ProfileResolveOptions::with_pod_name("runtime-pod"),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap();
@ -1785,7 +1786,7 @@ return profile {
task = { enabled = true },
memory = { enabled = false },
web = { enabled = true },
pods = { enabled = true },
workers = { enabled = true },
ticket = { enabled = true, access = "read_only" },
ticket_orchestration = { enabled = false },
},
@ -1798,14 +1799,14 @@ return profile {
.with_workspace_base(&workspace)
.resolve(
&ProfileSelector::path(&profile),
ProfileResolveOptions::with_pod_name("runtime-pod"),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.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.memory.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_eq!(
resolved.manifest.feature.ticket.access,
@ -1844,7 +1845,7 @@ return yoi.profile {
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(profile),
ProfileResolveOptions::with_pod_name("p"),
ProfileResolveOptions::with_worker_name("p"),
)
.unwrap();
assert_eq!(
@ -1877,7 +1878,7 @@ p.slug = "assigned"
p.model = yoi.models.catalog("anthropic/claude-sonnet-4-6")
p.feature = {
task = { enabled = false },
pods = { enabled = true },
workers = { enabled = true },
}
p.web = { enabled = false }
p.compaction = yoi.compact.tokens { threshold = 123, request_threshold = 456 }
@ -1888,7 +1889,7 @@ return p
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(profile),
ProfileResolveOptions::with_pod_name("p"),
ProfileResolveOptions::with_worker_name("p"),
)
.unwrap();
assert_eq!(
@ -1896,7 +1897,7 @@ return p
Some("anthropic/claude-sonnet-4-6")
);
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!(resolved.manifest.web.as_ref().unwrap().search.is_none());
assert_eq!(
@ -1925,7 +1926,7 @@ return yoi.profile.extend("builtin:default", {
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(profile),
ProfileResolveOptions::with_pod_name("p"),
ProfileResolveOptions::with_worker_name("p"),
)
.unwrap_err();
let message = err.to_string();
@ -1948,7 +1949,7 @@ return yoi.profile.extend("builtin:default", {
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(path),
ProfileResolveOptions::with_pod_name("p"),
ProfileResolveOptions::with_worker_name("p"),
)
.unwrap_err();
assert!(matches!(
@ -1962,7 +1963,7 @@ return yoi.profile.extend("builtin:default", {
for (value, needle) in [
(serde_json::json!({"manifest": {}}), "manifest"),
(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": []}}),
"scope.allow",
@ -2008,7 +2009,7 @@ return profile {
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::path(profile),
ProfileResolveOptions::with_pod_name("p"),
ProfileResolveOptions::with_worker_name("p"),
)
.unwrap();
let c = resolved.manifest.compaction.unwrap();
@ -2023,10 +2024,10 @@ return profile {
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
ProfileResolveOptions::with_pod_name("runtime-workspace"),
ProfileResolveOptions::with_worker_name("runtime-workspace"),
)
.unwrap();
assert_eq!(resolved.manifest.pod.name, "runtime-workspace");
assert_eq!(resolved.manifest.worker.name, "runtime-workspace");
assert_eq!(
resolved.manifest.model.ref_.as_deref(),
Some("codex-oauth/gpt-5.5")
@ -2060,9 +2061,9 @@ return profile {
std::fs::write(
&override_path,
r#"
[pod]
prompt_pack = "prompts.toml"
[worker]
prompt_pack = "prompts.toml"
[engine]
language = "ja"
[session]
record_event_trace = false
@ -2074,15 +2075,15 @@ record_event_trace = false
.with_workspace_base(&nested)
.resolve(
&ProfileSelector::Default,
ProfileResolveOptions::with_pod_name("runtime-pod"),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap();
assert_eq!(resolved.manifest.pod.name, "runtime-pod");
assert_eq!(resolved.manifest.worker.language, "ja");
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
assert_eq!(resolved.manifest.engine.language, "ja");
assert!(!resolved.manifest.session.record_event_trace);
assert_eq!(
resolved.manifest.pod.prompt_pack.as_deref(),
resolved.manifest.worker.prompt_pack.as_deref(),
Some(yoi_dir.join("prompts.toml").as_path())
);
assert!(resolved.manifest.scope.allow.is_empty());
@ -2111,9 +2112,9 @@ record_event_trace = false
std::fs::write(
parent_yoi.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
r#"
[pod]
prompt_pack = "parent-prompts.toml"
[worker]
prompt_pack = "parent-prompts.toml"
[engine]
language = "parent"
"#,
)
@ -2122,9 +2123,9 @@ language = "parent"
std::fs::write(
&nested_override_path,
r#"
[pod]
prompt_pack = "nested-prompts.toml"
[worker]
prompt_pack = "nested-prompts.toml"
[engine]
language = "nested"
"#,
)
@ -2134,13 +2135,13 @@ language = "nested"
.with_workspace_base(&child)
.resolve(
&ProfileSelector::Default,
ProfileResolveOptions::with_pod_name("runtime-pod"),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap();
assert_eq!(resolved.manifest.worker.language, "nested");
assert_eq!(resolved.manifest.engine.language, "nested");
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())
);
assert_eq!(
@ -2155,13 +2156,13 @@ language = "nested"
}
#[test]
fn workspace_local_override_rejects_runtime_pod_name() {
fn workspace_local_override_rejects_runtime_worker_name() {
let tmp = TempDir::new().unwrap();
let yoi_dir = tmp.path().join(".yoi");
std::fs::create_dir_all(&yoi_dir).unwrap();
std::fs::write(
yoi_dir.join(WORKSPACE_OVERRIDE_LOCAL_FILENAME),
"[pod]\nname = \"not-local\"\n",
"[worker]\nname = \"not-local\"\n",
)
.unwrap();
@ -2169,11 +2170,11 @@ language = "nested"
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::Default,
ProfileResolveOptions::with_pod_name("runtime-pod"),
ProfileResolveOptions::with_worker_name("runtime-worker"),
)
.unwrap_err();
assert!(matches!(err, ProfileError::InvalidWorkspaceOverride { .. }));
assert!(err.to_string().contains("pod.name"));
assert!(err.to_string().contains("worker.name"));
}
#[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
//! 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
//! possible) so access checks are pure path comparisons.
@ -14,7 +14,7 @@ use arc_swap::{ArcSwap, Guard};
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
/// queried and these rules — see [`Scope::permission_at`].
@ -32,7 +32,7 @@ struct ResolvedRule {
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
/// authority. Direct tools never consult this type.
@ -173,7 +173,7 @@ impl Scope {
///
/// Every `target` in `config` must already be absolute — per-layer
/// 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
/// here signals an upstream bug and is rejected.
pub fn from_config(config: &ScopeConfig) -> Result<Self, ScopeError> {
@ -266,7 +266,7 @@ impl Scope {
/// 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
/// meaningful across processes.
pub fn allow_rules(&self) -> Vec<ScopeRule> {
@ -324,7 +324,7 @@ impl Scope {
/// Build a new [`Scope`] equal to `self` with `extra_deny` appended
/// 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).
pub fn with_added_deny_rules(
&self,
@ -420,7 +420,7 @@ impl Scope {
/// not lose each other's contributions.
///
/// 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.
#[derive(Debug, Clone)]
pub struct SharedScope {

View File

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

View File

@ -31,7 +31,7 @@ pub fn build_consolidate_input(
"consolidation input. Run the integration step first \
(fold the staging activity logs into memory and knowledge), then the \
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");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
//! Self-contained: provides its own Tool implementations (read/write/edit)
//! that target `<workspace>/memory/` and `<workspace>/knowledge/` only,
//! 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.
pub mod audit;

View File

@ -1,6 +1,6 @@
//! 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
//! (`model_invokation: true`) returned as `(slug, description)` pairs.
@ -8,7 +8,7 @@
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
//! record and has non-empty body.
//! - [`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
//! user-visibility flag).
//!

View File

@ -1,7 +1,7 @@
//! Helpers for constructing `ScopeRule` entries that exclude the
//! 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
//! constructing the [`Scope`] passed to `tools::ScopedFs`. The memory
//! tools themselves bypass `ScopedFs` and write directly under the

View File

@ -18,7 +18,7 @@
//! `.yoi/workflow/`.
//!
//! `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.
use std::path::{Path, PathBuf};

View File

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

View File

@ -2,29 +2,29 @@
## 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
Owns:
- machine-local live Pod registration
- collision detection for running Pod names
- machine-local live Worker registration
- collision detection for running Worker names
- delegated scope lock bookkeeping
- registry cleanup hooks for stopped or unreachable children
Does not own:
- durable Pod metadata (`pod-store`)
- durable Worker metadata (`pod-store`)
- replayable session logs (`session-store`)
- socket protocol definitions (`protocol`)
- project work item state
## 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
- [`../../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)

View File

@ -80,18 +80,18 @@ pub fn is_within_effective_write(lock: &LockFile, parent: &str, rule: &ScopeRule
!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)]
pub struct ConflictOwner {
pub pod_name: String,
pub worker_name: String,
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
/// 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
/// to still own the rule's region because it is handing it down).
pub fn find_conflict_owner(
@ -115,7 +115,7 @@ pub fn find_conflict_owners(
.iter()
.filter(|a| a.delegated_from.is_none())
.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()
}
@ -142,14 +142,14 @@ fn find_conflict_in_subtree(
for child in lock
.allocations
.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) {
return Some(owner);
}
}
Some(ConflictOwner {
pod_name: alloc.pod_name.clone(),
worker_name: alloc.worker_name.clone(),
rule: overlapping_rule.clone(),
})
}
@ -158,7 +158,7 @@ fn find_conflict_in_subtree(
mod tests {
use super::*;
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;
#[test]
@ -192,9 +192,9 @@ mod tests {
#[test]
fn conflict_detection_descends_to_real_owner() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -213,9 +213,9 @@ mod tests {
&delegation_scope(vec![write_rule("/src", true)]),
)
.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.
let err = register_pod(
let err = register_worker(
&mut g,
"x".into(),
std::process::id(),
@ -233,9 +233,9 @@ mod tests {
#[test]
fn denied_write_region_is_not_claimed_by_restored_parent() {
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);
register_pod_with_deny(
register_worker_with_deny(
&mut g,
"parent".into(),
std::process::id(),
@ -245,7 +245,7 @@ mod tests {
sid(),
)
.unwrap();
register_pod(
register_worker(
&mut g,
"child".into(),
std::process::id(),
@ -259,9 +259,9 @@ mod tests {
#[test]
fn partial_deny_does_not_hide_parent_conflict() {
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);
register_pod_with_deny(
register_worker_with_deny(
&mut g,
"parent".into(),
std::process::id(),
@ -272,7 +272,7 @@ mod tests {
)
.unwrap();
let err = register_pod(
let err = register_worker(
&mut g,
"other".into(),
std::process::id(),

View File

@ -9,10 +9,10 @@ use session_store::SegmentId;
/// Errors raised by the mutating pod-registry operations.
#[derive(Debug, thiserror::Error)]
pub enum ScopeLockError {
#[error("I/O error on pods.json: {0}")]
#[error("I/O error on workers.json: {0}")]
Io(#[from] io::Error),
#[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())]
WriteConflict {
competitor: String,
@ -27,14 +27,14 @@ pub enum ScopeLockError {
#[error("invalid delegation scope: {source}")]
InvalidScope { source: ScopeError },
#[error("pod `{0}` is not registered")]
UnknownPod(String),
UnknownWorker(String),
#[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()
)]
SegmentConflict {
segment_id: SegmentId,
pod_name: String,
worker_name: String,
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
//! Pod's allocation (see [`manifest::paths::pod_registry_path`] for
//! A single JSON file at `<runtime_dir>/workers.json` records every live
//! Worker's allocation (see [`manifest::paths::pod_registry_path`] for
//! 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
//! (Pods whose PID has died), checks that its requested write scope
//! Each Worker, when starting, acquires the lock, reclaims stale entries
//! (Workers whose PID has died), checks that its requested write scope
//! does not overlap any other allocation's effective write scope, and
//! registers itself. When it exits normally, it removes its entry and
//! 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.
mod conflict;
@ -31,7 +31,7 @@ pub use lifecycle::{
install_top_level_with_deny, lookup_segment, update_segment,
};
pub use mutate::{
delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_pod,
register_pod_with_deny, release_pod,
delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_worker,
register_worker_with_deny, release_worker,
};
pub use table::{Allocation, LockFile, LockFileGuard, default_registry_path};

View File

@ -8,21 +8,21 @@ use manifest::ScopeRule;
use session_store::SegmentId;
use crate::error::ScopeLockError;
use crate::mutate::release_pod;
use crate::mutate::release_worker;
use crate::table::{LockFileGuard, default_registry_path};
/// Owned allocation: on drop, opens the lock file and releases this
/// Pod's entry. The guard keeps only the name + lock-file path; it
/// does not hold the `flock` for the Pod's lifetime.
/// Worker's entry. The guard keeps only the name + lock-file path; it
/// does not hold the `flock` for the Worker's lifetime.
#[derive(Debug)]
pub struct ScopeAllocationGuard {
pod_name: String,
worker_name: String,
lock_path: PathBuf,
}
impl ScopeAllocationGuard {
pub fn pod_name(&self) -> &str {
&self.pod_name
pub fn worker_name(&self) -> &str {
&self.worker_name
}
pub fn lock_path(&self) -> &Path {
@ -33,28 +33,35 @@ impl ScopeAllocationGuard {
impl Drop for ScopeAllocationGuard {
fn drop(&mut self) {
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.
pub fn install_top_level(
pod_name: String,
worker_name: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
segment_id: SegmentId,
) -> 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
/// drop.
pub fn install_top_level_with_deny(
pod_name: String,
worker_name: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
@ -63,9 +70,9 @@ pub fn install_top_level_with_deny(
) -> Result<ScopeAllocationGuard, ScopeLockError> {
let lock_path = default_registry_path()?;
let mut guard = LockFileGuard::open(&lock_path)?;
crate::mutate::register_pod_with_deny(
crate::mutate::register_worker_with_deny(
&mut guard,
pod_name.clone(),
worker_name.clone(),
pid,
socket,
scope_allow,
@ -73,13 +80,13 @@ pub fn install_top_level_with_deny(
segment_id,
)?;
Ok(ScopeAllocationGuard {
pod_name,
worker_name,
lock_path,
})
}
/// 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
/// [`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
/// the entry is released when the child exits.
pub fn adopt_allocation(
pod_name: String,
worker_name: String,
new_pid: u32,
segment_id: SegmentId,
) -> Result<ScopeAllocationGuard, ScopeLockError> {
@ -96,24 +103,24 @@ pub fn adopt_allocation(
let mut guard = LockFileGuard::open(&lock_path)?;
let alloc = guard
.data_mut()
.find_mut(&pod_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.clone()))?;
.find_mut(&worker_name)
.ok_or_else(|| ScopeLockError::UnknownWorker(worker_name.clone()))?;
alloc.pid = new_pid;
alloc.segment_id = Some(segment_id);
guard.save()?;
Ok(ScopeAllocationGuard {
pod_name,
worker_name,
lock_path,
})
}
/// Rewrite the `segment_id` recorded for `pod_name` to
/// Rewrite the `segment_id` recorded for `worker_name` to
/// `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:
///
/// - `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
/// 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
/// concurrent `restore_from_manifest(new_id)` would see "no live
/// 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
/// guard, so the segment_id collision check is atomic with the
/// 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 mut guard = LockFileGuard::open(&lock_path)?;
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 {
segment_id: new_segment_id,
pod_name: other.pod_name.clone(),
worker_name: other.worker_name.clone(),
socket: other.socket.clone(),
});
}
}
let alloc = guard
.data_mut()
.find_mut(pod_name)
.ok_or_else(|| ScopeLockError::UnknownPod(pod_name.into()))?;
.find_mut(worker_name)
.ok_or_else(|| ScopeLockError::UnknownWorker(worker_name.into()))?;
alloc.segment_id = Some(new_segment_id);
guard.save()?;
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.
#[derive(Debug, Clone)]
pub struct SegmentLockInfo {
pub pod_name: String,
pub worker_name: String,
pub socket: PathBuf,
pub pid: u32,
}
@ -159,7 +166,7 @@ pub struct SegmentLockInfo {
/// Open the default lock file, reclaim stale entries, and return the
/// 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.
pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>, ScopeLockError> {
let lock_path = default_registry_path()?;
@ -169,7 +176,7 @@ pub fn lookup_segment(segment_id: SegmentId) -> Result<Option<SegmentLockInfo>,
.data()
.find_by_segment(segment_id)
.map(|a| SegmentLockInfo {
pod_name: a.pod_name.clone(),
worker_name: a.worker_name.clone(),
socket: a.socket.clone(),
pid: a.pid,
}))
@ -185,11 +192,11 @@ mod tests {
/// Mimic what the spawner does before the child comes up: push an
/// allocation for the child carrying the spawner's (live) pid as a
/// 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 {
pod_name: pod_name.to_string(),
worker_name: worker_name.to_string(),
pid: placeholder_pid,
socket: sock(pod_name),
socket: sock(worker_name),
scope_allow: vec![write_rule("/tmp/child", true)],
scope_deny: Vec::new(),
delegated_from: None,
@ -202,7 +209,7 @@ mod tests {
fn scope_allocation_guard_releases_on_drop() {
let dir = TempDir::new().unwrap();
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(
"a".into(),
std::process::id(),
@ -226,7 +233,7 @@ mod tests {
fn adopt_allocation_rewrites_pid_and_releases_on_drop() {
let dir = TempDir::new().unwrap();
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.
{
let mut g = LockFileGuard::open(&lock_path).unwrap();
@ -251,7 +258,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let _sandbox = RuntimeDirSandbox::new(dir.path());
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]
@ -268,7 +275,7 @@ mod tests {
)
.unwrap();
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"));
drop(guard);
// After the guard's release, the lookup goes back to None.
@ -292,7 +299,7 @@ mod tests {
update_segment("p", updated).unwrap();
// lookup against the original is now empty, the updated id wins.
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]
@ -321,11 +328,11 @@ mod tests {
let err = update_segment("a", s_b).unwrap_err();
match err {
ScopeLockError::SegmentConflict {
pod_name,
worker_name,
segment_id,
..
} => {
assert_eq!(pod_name, "b");
assert_eq!(worker_name, "b");
assert_eq!(segment_id, s_b);
}
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::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
/// 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
/// `segment_id`, so two `restore_from_manifest` calls under different
/// `pod_name`s cannot both grab the same session log.
pub fn register_pod(
/// `worker_name`s cannot both grab the same session log.
pub fn register_worker(
guard: &mut LockFileGuard,
pod_name: String,
worker_name: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
segment_id: SegmentId,
) -> Result<(), ScopeLockError> {
register_pod_with_deny(
register_worker_with_deny(
guard,
pod_name,
worker_name,
pid,
socket,
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.
///
/// 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
/// and the registration proceeds. The check is structural (deny ⊇
/// 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
/// `scope_deny` from outstanding `pod-store` child delegations, so any
/// covered competitor is expected to be a descendant of the original
/// allocation. Direct callers must uphold the same invariant.
pub fn register_pod_with_deny(
pub fn register_worker_with_deny(
guard: &mut LockFileGuard,
pod_name: String,
worker_name: String,
pid: u32,
socket: PathBuf,
scope_allow: Vec<ScopeRule>,
@ -59,13 +59,13 @@ pub fn register_pod_with_deny(
segment_id: SegmentId,
) -> Result<(), ScopeLockError> {
reclaim_stale(guard);
if guard.data().find(&pod_name).is_some() {
return Err(ScopeLockError::DuplicatePodName(pod_name));
if guard.data().find(&worker_name).is_some() {
return Err(ScopeLockError::DuplicateWorkerName(worker_name));
}
if let Some(existing) = guard.data().find_by_segment(segment_id) {
return Err(ScopeLockError::SegmentConflict {
segment_id,
pod_name: existing.pod_name.clone(),
worker_name: existing.worker_name.clone(),
socket: existing.socket.clone(),
});
}
@ -86,14 +86,14 @@ pub fn register_pod_with_deny(
}
if let Some(competitor) = conflicts.into_iter().next() {
return Err(ScopeLockError::WriteConflict {
competitor: competitor.pod_name,
competitor: competitor.worker_name,
rule: rule.clone(),
competitor_rule: competitor.rule,
});
}
}
guard.data_mut().allocations.push(Allocation {
pod_name,
worker_name,
pid,
socket,
scope_allow,
@ -105,9 +105,9 @@ pub fn register_pod_with_deny(
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;
/// overlap with any Pod other than `spawner` is a conflict.
/// overlap with any Worker other than `spawner` is a conflict.
pub fn delegate_scope(
guard: &mut LockFileGuard,
spawner: &str,
@ -119,10 +119,10 @@ pub fn delegate_scope(
) -> Result<(), ScopeLockError> {
reclaim_stale(guard);
if guard.data().find(&spawned).is_some() {
return Err(ScopeLockError::DuplicatePodName(spawned));
return Err(ScopeLockError::DuplicateWorkerName(spawned));
}
if guard.data().find(spawner).is_none() {
return Err(ScopeLockError::UnknownPod(spawner.into()));
return Err(ScopeLockError::UnknownWorker(spawner.into()));
}
for rule in &scope_allow {
let allowed = delegation_scope
@ -137,7 +137,7 @@ pub fn delegate_scope(
if rule.permission == Permission::Write {
if let Some(competitor) = find_conflict_owner(guard.data(), rule, Some(spawner)) {
return Err(ScopeLockError::WriteConflict {
competitor: competitor.pod_name,
competitor: competitor.worker_name,
rule: rule.clone(),
competitor_rule: competitor.rule,
});
@ -145,7 +145,7 @@ pub fn delegate_scope(
}
}
guard.data_mut().allocations.push(Allocation {
pod_name: spawned,
worker_name: spawned,
pid,
socket,
scope_allow,
@ -159,21 +159,21 @@ pub fn delegate_scope(
Ok(())
}
/// Remove a Pod's allocation. Surviving children are reparented to
/// the removed Pod's own `delegated_from`, so the delegation tree
/// Remove a Worker's allocation. Surviving children are reparented to
/// the removed Worker's own `delegated_from`, so the delegation tree
/// 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
.data()
.allocations
.iter()
.position(|a| a.pod_name == pod_name);
.position(|a| a.worker_name == worker_name);
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();
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);
}
}
@ -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,
/// 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
/// 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.
pub fn reclaim_delegated_scope(
guard: &mut LockFileGuard,
@ -199,7 +199,7 @@ pub fn reclaim_delegated_scope(
.data()
.allocations
.iter()
.position(|a| a.pod_name == child);
.position(|a| a.worker_name == child);
let removed_child_parent = child_idx
.map(|idx| guard.data().allocations[idx].delegated_from.clone())
.unwrap_or(None);
@ -229,8 +229,8 @@ pub fn reclaim_delegated_scope(
}
/// Remove allocations whose PID is dead, reparenting children to the
/// dead Pod's `delegated_from`. Idempotent and best-effort — I/O
/// errors on save are swallowed so a crashed Pod's entry never blocks
/// dead Worker's `delegated_from`. Idempotent and best-effort — I/O
/// errors on save are swallowed so a crashed Worker's entry never blocks
/// forward progress.
pub fn reclaim_stale(guard: &mut LockFileGuard) {
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
.iter()
.filter(|a| !is_alive(a.pid))
.map(|a| a.pod_name.clone())
.map(|a| a.worker_name.clone())
.collect();
if dead.is_empty() {
return;
@ -253,7 +253,7 @@ pub fn reclaim_stale_with(guard: &mut LockFileGuard, mut is_alive: impl FnMut(u3
.data()
.allocations
.iter()
.position(|a| a.pod_name == *name)
.position(|a| a.worker_name == *name)
else {
continue;
};
@ -294,9 +294,9 @@ mod tests {
#[test]
fn register_detects_write_conflict() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -305,7 +305,7 @@ mod tests {
sid(),
)
.unwrap();
let err = register_pod(
let err = register_worker(
&mut g,
"b".into(),
std::process::id(),
@ -321,11 +321,11 @@ mod tests {
}
#[test]
fn duplicate_pod_name_rejected() {
fn duplicate_worker_name_rejected() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -334,7 +334,7 @@ mod tests {
sid(),
)
.unwrap();
let err = register_pod(
let err = register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -343,15 +343,15 @@ mod tests {
sid(),
)
.unwrap_err();
assert!(matches!(err, ScopeLockError::DuplicatePodName(ref n) if n == "a"));
assert!(matches!(err, ScopeLockError::DuplicateWorkerName(ref n) if n == "a"));
}
#[test]
fn delegate_must_be_subset() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -376,9 +376,9 @@ mod tests {
#[test]
fn delegate_uses_delegation_scope_not_direct_effective_write() {
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);
register_pod(
register_worker(
&mut g,
"orchestrator".into(),
std::process::id(),
@ -406,9 +406,9 @@ mod tests {
#[test]
fn delegate_succeeds_within_parent_scope() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -445,9 +445,9 @@ mod tests {
#[test]
fn delegate_rejects_sibling_overlap() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -486,9 +486,9 @@ mod tests {
#[test]
fn release_reparents_children() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -517,7 +517,7 @@ mod tests {
&delegation_scope(vec![write_rule("/src/core", true)]),
)
.unwrap();
release_pod(&mut g, "b").unwrap();
release_worker(&mut g, "b").unwrap();
// D should now list A as its delegated_from.
let d = g.data().find("d").unwrap();
assert_eq!(d.delegated_from.as_deref(), Some("a"));
@ -527,10 +527,10 @@ mod tests {
#[test]
fn reclaim_delegated_scope_removes_child_and_one_parent_deny_layer() {
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 delegated_rule = write_rule("/src/core", true);
register_pod_with_deny(
register_worker_with_deny(
&mut g,
"a".into(),
std::process::id(),
@ -540,7 +540,7 @@ mod tests {
sid(),
)
.unwrap();
register_pod(
register_worker(
&mut g,
"b".into(),
std::process::id(),
@ -566,10 +566,10 @@ mod tests {
#[test]
fn reclaim_delegated_scope_removes_parent_deny_when_child_allocation_missing() {
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 delegated_rule = write_rule("/src/core", true);
register_pod_with_deny(
register_worker_with_deny(
&mut g,
"a".into(),
std::process::id(),
@ -595,9 +595,9 @@ mod tests {
#[test]
fn reclaim_stale_reparents_and_removes_dead_entries() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -630,7 +630,7 @@ mod tests {
// will treat as dead.
let fake_dead_pid: u32 = 0xffff_fff0;
for alloc in g.data_mut().allocations.iter_mut() {
if alloc.pod_name == "b" {
if alloc.worker_name == "b" {
alloc.pid = fake_dead_pid;
}
}
@ -643,9 +643,9 @@ mod tests {
#[test]
fn read_rules_do_not_conflict_with_write() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -655,7 +655,7 @@ mod tests {
)
.unwrap();
// B only reads under the same tree — allowed.
register_pod(
register_worker(
&mut g,
"b".into(),
std::process::id(),
@ -670,9 +670,9 @@ mod tests {
#[test]
fn releasing_pod_reopens_scope_for_fresh_registration() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -681,8 +681,8 @@ mod tests {
sid(),
)
.unwrap();
release_pod(&mut g, "a").unwrap();
register_pod(
release_worker(&mut g, "a").unwrap();
register_worker(
&mut g,
"b".into(),
std::process::id(),
@ -696,9 +696,9 @@ mod tests {
#[test]
fn delegated_scope_returns_to_parent_on_release() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -722,7 +722,7 @@ mod tests {
"a",
&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.
assert!(is_within_effective_write(
g.data(),
@ -734,10 +734,10 @@ mod tests {
#[test]
fn register_pod_rejects_session_id_collision() {
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 shared_session = sid();
register_pod(
register_worker(
&mut g,
"first".into(),
std::process::id(),
@ -747,9 +747,9 @@ mod tests {
)
.unwrap();
// 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.
let err = register_pod(
let err = register_worker(
&mut g,
"second".into(),
std::process::id(),
@ -761,11 +761,11 @@ mod tests {
match err {
ScopeLockError::SegmentConflict {
segment_id,
pod_name,
worker_name,
..
} => {
assert_eq!(segment_id, shared_session);
assert_eq!(pod_name, "first");
assert_eq!(worker_name, "first");
}
other => panic!("expected SegmentConflict, got {other:?}"),
}

View File

@ -22,33 +22,33 @@ pub struct LockFile {
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.
/// Portions delegated out to child Pods are **not** subtracted in
/// `scope_allow` is the full set of allow rules the Worker was granted.
/// Portions delegated out to child Workers are **not** subtracted in
/// 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
/// reclaim) trivial.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Allocation {
/// Pod name — also the identity used throughout orchestration.
pub pod_name: String,
/// Worker name — also the identity used throughout orchestration.
pub worker_name: String,
/// Owning process. Checked with `kill(pid, 0)` for stale detection.
pub pid: u32,
/// Pod's Unix socket path.
/// Worker's Unix socket path.
pub socket: PathBuf,
/// Allow rules granted to this Pod (write + read).
/// Allow rules granted to this Worker (write + read).
pub scope_allow: Vec<ScopeRule>,
/// Deny rules that cap this Pod's effective scope. Normally empty for
/// fresh allocations; restored Pods use this to avoid reclaiming
/// Deny rules that cap this Worker's effective scope. Normally empty for
/// fresh allocations; restored Workers use this to avoid reclaiming
/// previously delegated write regions.
#[serde(default)]
pub scope_deny: Vec<ScopeRule>,
/// Name of the Pod that delegated scope to this one, or `None` for
/// a top-level Pod started directly by a human.
/// Name of the Worker that delegated scope to this one, or `None` for
/// a top-level Worker started directly by a human.
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`]
/// before the child has come up; the child fills it in at
/// [`crate::adopt_allocation`] time.
@ -57,12 +57,16 @@ pub struct Allocation {
}
impl LockFile {
pub fn find(&self, pod_name: &str) -> Option<&Allocation> {
self.allocations.iter().find(|a| a.pod_name == pod_name)
pub fn find(&self, worker_name: &str) -> Option<&Allocation> {
self.allocations
.iter()
.find(|a| a.worker_name == worker_name)
}
pub fn find_mut(&mut self, pod_name: &str) -> Option<&mut Allocation> {
self.allocations.iter_mut().find(|a| a.pod_name == pod_name)
pub fn find_mut(&mut self, worker_name: &str) -> Option<&mut Allocation> {
self.allocations
.iter_mut()
.find(|a| a.worker_name == worker_name)
}
/// 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
/// elsewhere by setting `YOI_HOME` or `YOI_RUNTIME_DIR` to a
/// tempdir.
@ -82,7 +86,7 @@ pub fn default_registry_path() -> io::Result<PathBuf> {
paths::pod_registry_path().ok_or_else(|| {
io::Error::new(
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)",
)
})
@ -173,7 +177,7 @@ impl LockFileGuard {
serde_json::from_str(&buf).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("pods.json parse error: {e}"),
format!("workers.json parse error: {e}"),
)
})?
};
@ -215,7 +219,7 @@ mod tests {
#[test]
fn open_creates_empty_lock_file() {
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();
assert!(guard.data().allocations.is_empty());
assert!(path.exists());
@ -226,7 +230,7 @@ mod tests {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
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 file_mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(file_mode, 0o600, "file mode = {file_mode:o}");
@ -237,10 +241,10 @@ mod tests {
#[test]
fn save_and_reopen_roundtrip() {
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);
register_pod(
register_worker(
&mut g,
"a".into(),
std::process::id(),
@ -252,19 +256,19 @@ mod tests {
}
let guard = LockFileGuard::open(&path).unwrap();
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]
fn find_by_session_skips_none_placeholders() {
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);
// Pre-reservation: delegate_scope leaves segment_id = None
// until adopt_allocation rewrites it. find_by_segment must not
// match those placeholders, otherwise a freshly-spawning child
// would shadow itself before it has even chosen a session.
register_pod(
register_worker(
&mut g,
"parent".into(),
std::process::id(),
@ -292,6 +296,6 @@ mod tests {
// After adopt-style rewrite, the same allocation is now found.
g.data_mut().find_mut("child").unwrap().segment_id = Some(target_session);
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]
name = "pod-store"
description = "Durable Pod-name metadata/state persistence"
description = "Legacy-named durable Worker metadata/state persistence"
version = "0.1.0"
edition.workspace = true
license.workspace = true

View File

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

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)]
@ -30,34 +30,34 @@ pub enum Method {
Run {
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
/// idle target is kicked into `RunForNotification`; weak notifications
/// (`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.
Notify {
message: String,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
auto_run: bool,
},
/// Typed lifecycle report from a child Pod to its direct parent.
PodEvent(PodEvent),
/// Typed lifecycle report from a child Worker to its direct parent.
WorkerEvent(WorkerEvent),
Resume,
Cancel,
/// Stop the in-flight turn and transition to `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
/// synthetic tool result before the new user message is appended).
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
/// `Method::Run` user message.
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,
/// Truncate the current session back to the selected rewind target and
/// return the selected user input to the client composer.
@ -66,7 +66,7 @@ pub enum Method {
expected_head_entries: usize,
},
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
/// broadcast). The IPC server handles this directly and writes
@ -77,15 +77,15 @@ pub enum Method {
kind: CompletionKind,
prefix: String,
},
/// List Pods visible to this Pod from durable Pod state and the spawned-child
/// registry. This is not a host-wide Pod universe query.
ListPods,
/// Restore a visible stopped/restorable Pod, or report that it is already
/// List Workers visible to this Worker from durable Worker state and the spawned-child
/// registry. This is not a host-wide Worker universe query.
ListWorkers,
/// Restore a visible stopped/restorable Worker, or report that it is already
/// live. Missing state and not-visible state are distinct errors.
RestorePod {
RestoreWorker {
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
/// 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
/// (registry / pod-registry updates). Agent-visible variants are also
/// 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
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
/// child Pod).
/// child Worker).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PodEvent {
pub enum WorkerEvent {
/// 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.
///
/// Limited to worker runtime failures (provider / tool errors) —
/// does not include transient method-rejection responses such as
/// `AlreadyRunning`.
Errored { pod_name: String, message: String },
Errored {
worker_name: String,
message: String,
},
/// 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
/// propagate upward, but do not expose this as an agent notification.
///
/// 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
/// this event upward (if it has a parent of its own) to maintain
/// the chain to root.
ScopeSubDelegated {
/// Sub-delegating Pod (= the sender itself).
parent_pod: String,
/// Name of the grandchild Pod.
sub_pod: String,
/// Sub-delegating Worker (= the sender itself).
parent_worker: String,
/// Name of the grandchild Worker.
sub_worker: String,
/// Unix-socket path where the grandchild is reachable.
sub_socket: PathBuf,
/// 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.
///
/// Control-plane-only events still travel over the same wire enum and still
@ -153,10 +156,10 @@ impl PodEvent {
/// the notification buffer.
pub fn should_notify_agent(&self) -> bool {
match self {
PodEvent::TurnEnded { .. } | PodEvent::Errored { .. } | PodEvent::ShutDown { .. } => {
true
}
PodEvent::ScopeSubDelegated { .. } => false,
WorkerEvent::TurnEnded { .. }
| WorkerEvent::Errored { .. }
| WorkerEvent::ShutDown { .. } => true,
WorkerEvent::ScopeSubDelegated { .. } => false,
}
}
}
@ -171,11 +174,11 @@ impl PodEvent {
/// clients (CLI piping, scripts) only need to produce a single
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
/// (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.
///
/// 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]`
/// placeholder into the LLM context so neither user nor LLM is blind to
/// the dropped intent.
@ -195,7 +198,7 @@ pub enum Segment {
lines: u32,
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
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
/// `@<path>` placeholder either way.
@ -204,7 +207,7 @@ pub enum Segment {
KnowledgeRef { slug: String },
/// `/<slug>` Workflow invocation (see `docs/plan/workflow.md`).
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.
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
#[serde(other)]
@ -220,7 +223,7 @@ impl Segment {
/// Flatten a segment slice into the single string the LLM receives
/// as a user message. Pure — no I/O, no alerts. Callers that need
/// 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`)
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
@ -258,7 +261,7 @@ impl Segment {
impl Method {
/// 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.
pub fn run_text(s: impl Into<String>) -> Self {
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)]
@ -291,8 +294,8 @@ pub enum Event {
/// One agent-injected system item committed to history.
///
/// Carries the JSON form of `session_store::SystemItem`. Covers
/// `Method::Notify` echoes, child-Pod lifecycle events from
/// `Method::PodEvent`, `@<path>` / `#<slug>` / `/<slug>`
/// `Method::Notify` echoes, child-Worker lifecycle events from
/// `Method::WorkerEvent`, `@<path>` / `#<slug>` / `/<slug>`
/// resolution payloads, and any future agent-side injection kind.
/// Clients dispatch on the `kind` tag for typed rendering instead
/// 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
/// implicitly until the next `InvokeStart`. Fires for every accepted
/// `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,
/// `<system-reminder>` injection that doesn't break IDLE) do not
/// emit `InvokeStart` — they appear as `SystemItem` only.
///
/// 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.
InvokeStart {
kind: InvokeKind,
@ -453,7 +456,7 @@ pub enum Event {
/// derived view.
///
/// `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.
///
/// Live updates after the snapshot arrive through the streaming
@ -465,7 +468,7 @@ pub enum Event {
entries: Vec<serde_json::Value>,
greeting: Greeting,
#[serde(default)]
status: PodStatus,
status: WorkerStatus,
/// Unfinished model output that has already streamed in the current
/// run but is not yet represented by committed snapshot entries.
#[serde(default, skip_serializing_if = "InFlightSnapshot::is_empty")]
@ -483,10 +486,10 @@ pub enum Event {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
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.
Status {
status: PodStatus,
status: WorkerStatus,
},
/// Reply to `Method::ListCompletions`. Delivered only to the
/// requesting socket (not broadcast). `entries` is empty when no
@ -510,15 +513,15 @@ pub enum Event {
input: Vec<Segment>,
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
/// dependency on session-store.
PodsListed {
WorkersListed {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
pods: serde_json::Value,
workers: serde_json::Value,
},
/// Reply to `Method::RestorePod`.
PodRestored {
/// Reply to `Method::RestoreWorker`.
WorkerRestored {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
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
/// briefly as operational status.
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
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
@ -554,10 +557,10 @@ pub enum Event {
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
/// 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
/// human-readable.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -596,7 +599,7 @@ pub enum AlertLevel {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum AlertSource {
Pod,
Worker,
Engine,
Compactor,
AgentsMd,
@ -668,7 +671,7 @@ pub struct RewindSummary {
/// attach while an LLM response is still streaming.
///
/// 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
/// entries.
#[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
/// their own view of the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct Greeting {
pub pod_name: String,
pub worker_name: String,
pub cwd: String,
pub provider: String,
pub model: String,
pub scope_summary: 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)]
pub context_window: u64,
/// Estimated current session context tokens at connect time.
@ -752,7 +755,7 @@ pub struct Greeting {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum PodStatus {
pub enum WorkerStatus {
#[default]
Idle,
Running,
@ -771,7 +774,7 @@ pub enum TurnResult {
///
/// One Invoke groups all entries from this trigger up to the next
/// `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.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@ -781,8 +784,8 @@ pub enum InvokeKind {
UserSend,
/// `Method::Notify` — free-text notification injected into history.
Notify,
/// `Method::PodEvent` — typed lifecycle report from a child Pod.
PodEvent,
/// `Method::WorkerEvent` — typed lifecycle report from a child Worker.
WorkerEvent,
/// `<system-reminder>` etc. that crosses an IDLE boundary (mid-run
/// reminders that don't break IDLE are SystemItem-only and do not
/// open a new Invoke).
@ -801,8 +804,8 @@ pub enum RunResult {
Paused,
LimitReached,
/// The accepted Method::Run produced no assistant/tool output before
/// user interruption, so the Pod rolled the submit-time turn state back
/// to its pre-submit snapshot. Clients should treat the Pod as Idle and
/// user interruption, so the Worker rolled the submit-time turn state back
/// to its pre-submit snapshot. Clients should treat the Worker as Idle and
/// restore the just-submitted input into the editable composer if desired.
RolledBack,
}
@ -824,7 +827,7 @@ pub enum ErrorCode {
// Scope rule / permission (wire type)
//
// 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.
// ---------------------------------------------------------------------------
@ -925,9 +928,9 @@ mod tests {
#[test]
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
// 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.
let json = r#"{"kind":"image_ref","url":"https://example.com/x.png"}"#;
let seg: Segment = serde_json::from_str(json).unwrap();
@ -1028,7 +1031,7 @@ mod tests {
for kind in [
InvokeKind::UserSend,
InvokeKind::Notify,
InvokeKind::PodEvent,
InvokeKind::WorkerEvent,
InvokeKind::SystemReminder,
InvokeKind::Wakeup,
] {
@ -1219,7 +1222,7 @@ mod tests {
let event = Event::Snapshot {
entries: vec![serde_json::json!({"kind": "user_input", "ts": 1, "segments": []})],
greeting: Greeting {
pod_name: "test".into(),
worker_name: "test".into(),
cwd: "/tmp".into(),
provider: "anthropic".into(),
model: "claude".into(),
@ -1228,7 +1231,7 @@ mod tests {
context_window: 200_000,
context_tokens: 42_000,
},
status: PodStatus::Paused,
status: WorkerStatus::Paused,
in_flight: InFlightSnapshot::default(),
};
let json = serde_json::to_string(&event).unwrap();
@ -1236,7 +1239,7 @@ mod tests {
assert_eq!(parsed["event"], "snapshot");
assert!(parsed["data"]["entries"].is_array());
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"]["context_window"], 200_000);
assert_eq!(parsed["data"]["greeting"]["context_tokens"], 42_000);
@ -1245,7 +1248,7 @@ mod tests {
#[test]
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();
match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
@ -1255,7 +1258,7 @@ mod tests {
let event = Event::Snapshot {
entries: Vec::new(),
greeting: Greeting {
pod_name: "test".into(),
worker_name: "test".into(),
cwd: "/tmp".into(),
provider: "p".into(),
model: "m".into(),
@ -1264,7 +1267,7 @@ mod tests {
context_window: 0,
context_tokens: 0,
},
status: PodStatus::Running,
status: WorkerStatus::Running,
in_flight: InFlightSnapshot {
blocks: vec![
InFlightBlock::Text {
@ -1334,7 +1337,7 @@ mod tests {
#[test]
fn event_status_format() {
let event = Event::Status {
status: PodStatus::Running,
status: WorkerStatus::Running,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
@ -1345,20 +1348,20 @@ mod tests {
assert!(matches!(
decoded,
Event::Status {
status: PodStatus::Running
status: WorkerStatus::Running
}
));
}
#[test]
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();
match decoded {
Event::Snapshot {
status, greeting, ..
} => {
assert_eq!(status, PodStatus::Idle);
assert_eq!(status, WorkerStatus::Idle);
assert_eq!(greeting.context_window, 0);
assert_eq!(greeting.context_tokens, 0);
}
@ -1367,34 +1370,37 @@ mod tests {
}
#[test]
fn method_pod_event_turn_ended_roundtrip() {
let method = Method::PodEvent(PodEvent::TurnEnded {
pod_name: "child".into(),
fn method_worker_event_turn_ended_roundtrip() {
let method = Method::WorkerEvent(WorkerEvent::TurnEnded {
worker_name: "child".into(),
});
let json = serde_json::to_string(&method).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"]["pod_name"], "child");
assert_eq!(parsed["params"]["worker_name"], "child");
let decoded: Method = serde_json::from_str(&json).unwrap();
assert!(matches!(
decoded,
Method::PodEvent(PodEvent::TurnEnded { ref pod_name }) if pod_name == "child"
Method::WorkerEvent(WorkerEvent::TurnEnded { ref worker_name }) if worker_name == "child"
));
}
#[test]
fn method_pod_event_errored_roundtrip() {
let method = Method::PodEvent(PodEvent::Errored {
pod_name: "child".into(),
fn method_worker_event_errored_roundtrip() {
let method = Method::WorkerEvent(WorkerEvent::Errored {
worker_name: "child".into(),
message: "provider 429".into(),
});
let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded {
Method::PodEvent(PodEvent::Errored { pod_name, message }) => {
assert_eq!(pod_name, "child");
Method::WorkerEvent(WorkerEvent::Errored {
worker_name,
message,
}) => {
assert_eq!(worker_name, "child");
assert_eq!(message, "provider 429");
}
other => panic!("expected Errored, got {other:?}"),
@ -1402,43 +1408,43 @@ mod tests {
}
#[test]
fn method_pod_event_shutdown_roundtrip() {
let method = Method::PodEvent(PodEvent::ShutDown {
pod_name: "child".into(),
fn method_worker_event_shutdown_roundtrip() {
let method = Method::WorkerEvent(WorkerEvent::ShutDown {
worker_name: "child".into(),
});
let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap();
assert!(matches!(
decoded,
Method::PodEvent(PodEvent::ShutDown { ref pod_name }) if pod_name == "child"
Method::WorkerEvent(WorkerEvent::ShutDown { ref worker_name }) if worker_name == "child"
));
}
#[test]
fn pod_event_agent_notification_classification() {
fn worker_event_agent_notification_classification() {
assert!(
PodEvent::TurnEnded {
pod_name: "child".into()
WorkerEvent::TurnEnded {
worker_name: "child".into()
}
.should_notify_agent()
);
assert!(
PodEvent::Errored {
pod_name: "child".into(),
WorkerEvent::Errored {
worker_name: "child".into(),
message: "boom".into()
}
.should_notify_agent()
);
assert!(
PodEvent::ShutDown {
pod_name: "child".into()
WorkerEvent::ShutDown {
worker_name: "child".into()
}
.should_notify_agent()
);
assert!(
!PodEvent::ScopeSubDelegated {
parent_pod: "child".into(),
sub_pod: "grandchild".into(),
!WorkerEvent::ScopeSubDelegated {
parent_worker: "child".into(),
sub_worker: "grandchild".into(),
sub_socket: "/tmp/grandchild.sock".into(),
scope: vec![],
}
@ -1447,10 +1453,10 @@ mod tests {
}
#[test]
fn method_pod_event_scope_sub_delegated_roundtrip() {
let method = Method::PodEvent(PodEvent::ScopeSubDelegated {
parent_pod: "child".into(),
sub_pod: "grandchild".into(),
fn method_worker_event_scope_sub_delegated_roundtrip() {
let method = Method::WorkerEvent(WorkerEvent::ScopeSubDelegated {
parent_worker: "child".into(),
sub_worker: "grandchild".into(),
sub_socket: "/run/yoi/grandchild/sock".into(),
scope: vec![ScopeRule {
target: "/tmp/work".into(),
@ -1461,14 +1467,14 @@ mod tests {
let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded {
Method::PodEvent(PodEvent::ScopeSubDelegated {
parent_pod,
sub_pod,
Method::WorkerEvent(WorkerEvent::ScopeSubDelegated {
parent_worker,
sub_worker,
sub_socket,
scope,
}) => {
assert_eq!(parent_pod, "child");
assert_eq!(sub_pod, "grandchild");
assert_eq!(parent_worker, "child");
assert_eq!(sub_worker, "grandchild");
assert_eq!(sub_socket, PathBuf::from("/run/yoi/grandchild/sock"));
assert_eq!(scope.len(), 1);
assert_eq!(scope[0].target, PathBuf::from("/tmp/work"));
@ -1619,7 +1625,7 @@ mod tests {
fn event_error_format() {
let event = Event::Error {
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 parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
@ -1652,10 +1658,10 @@ mod tests {
}
#[test]
fn pod_discovery_methods_roundtrip() {
fn worker_discovery_methods_roundtrip() {
let methods = [
Method::ListPods,
Method::RestorePod {
Method::ListWorkers,
Method::RestoreWorker {
name: "child".into(),
},
Method::RegisterPeer {
@ -1666,8 +1672,8 @@ mod tests {
let json = serde_json::to_string(&method).unwrap();
let decoded: Method = serde_json::from_str(&json).unwrap();
match (decoded, method) {
(Method::ListPods, Method::ListPods)
| (Method::RestorePod { .. }, Method::RestorePod { .. })
(Method::ListWorkers, Method::ListWorkers)
| (Method::RestoreWorker { .. }, Method::RestoreWorker { .. })
| (Method::RegisterPeer { .. }, Method::RegisterPeer { .. }) => {}
(decoded, expected) => panic!("decoded {decoded:?}, expected {expected:?}"),
}
@ -1675,12 +1681,12 @@ mod tests {
}
#[test]
fn pod_discovery_events_roundtrip() {
fn worker_discovery_events_roundtrip() {
let events = [
Event::PodsListed {
pods: serde_json::json!([{ "pod_name": "child" }]),
Event::WorkersListed {
workers: serde_json::json!([{ "worker_name": "child" }]),
},
Event::PodRestored {
Event::WorkerRestored {
result: serde_json::json!({ "action": "already_live" }),
},
Event::PeerRegistered {
@ -1691,10 +1697,10 @@ mod tests {
let json = serde_json::to_string(&event).unwrap();
let decoded: Event = serde_json::from_str(&json).unwrap();
match (decoded, event) {
(Event::PodsListed { pods }, Event::PodsListed { pods: expected }) => {
assert_eq!(pods, expected)
(Event::WorkersListed { workers }, Event::WorkersListed { workers: expected }) => {
assert_eq!(workers, expected)
}
(Event::PodRestored { result }, Event::PodRestored { result: expected }) => {
(Event::WorkerRestored { result }, Event::WorkerRestored { result: expected }) => {
assert_eq!(result, expected)
}
(Event::PeerRegistered { result }, Event::PeerRegistered { result: expected }) => {

View File

@ -5,8 +5,8 @@ use ts_rs::{Config, TS};
use crate::{
Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting,
InFlightBlock, InFlightSnapshot, InFlightToolCallState, InvokeKind, MemoryWorkerEvent, Method,
Permission, PodEvent, PodStatus, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, TurnResult,
Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment,
TurnResult, WorkerEvent, WorkerStatus,
};
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)
}
/// 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
/// 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::<AlertSource>(&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::<InvokeKind>(&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::<MemoryWorkerEvent>(&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::<Event>(&cfg, &mut output);

View File

@ -18,7 +18,7 @@ Does not own:
- Engine turn lifecycle (`llm-engine`)
- secret storage internals (`secrets`)
- Pod lifecycle (`pod`)
- Worker lifecycle (`worker`)
- product CLI parsing (`yoi`)
## 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.
//!
//! 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
//! diagnostics; raw user messages, tool arguments, and tool output snippets are
//! not emitted.
@ -1592,12 +1592,17 @@ fn line_count(value: &str) -> usize {
}
fn tool_kind(name: &str) -> &'static str {
const LEGACY_SEND_TO_PEER_POD_TOOL: &str = "SendToPeerPod";
match name {
"Read" | "Write" | "Edit" | "Glob" | "Grep" => "filesystem",
"Bash" => "shell",
"WebFetch" | "WebSearch" => "web",
"SpawnPod" | "SendToPod" | "ReadPodOutput" | "ListPods" | "StopPod" | "RestorePod"
| "SendToPeerPod" => "pod",
"SpawnWorker" | "SendToWorker" | "SendToPeerWorker" | "ReadWorkerOutput"
| "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("Ticket") => "ticket",
name if name.starts_with("Task") => "task",

View File

@ -15,18 +15,18 @@ Owns:
Does not own:
- current Pod-name metadata (`pod-store`)
- current Worker-name metadata (`pod-store`)
- live process/socket discovery (`pod-registry`, `client`)
- UI state (`tui`)
- generated memory summaries (`memory`)
## 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.
## 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)

View File

@ -11,7 +11,7 @@
//! the same Session.
//!
//! 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.
//!
//! 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 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).
///

View File

@ -1,7 +1,7 @@
//! Free functions for segment persistence operations.
//!
//! 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.
use crate::logged_item::{LoggedItem, to_logged};
@ -36,7 +36,7 @@ pub fn create_segment(
/// Write a fresh `SegmentStart` entry using pre-generated IDs.
///
/// 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).
pub fn create_segment_with_ids(
store: &impl Store,
@ -102,7 +102,7 @@ pub fn restore(
/// Restore segment state when only the segment ID is known. Uses
/// [`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.
pub fn restore_by_segment(
store: &impl Store,
@ -174,7 +174,7 @@ pub fn ensure_head_or_fork(
/// 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
/// derives the worker `Item::user_message` from these segments via
/// [`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
/// 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.
pub fn append_system_item(
store: &impl Store,

View File

@ -58,10 +58,10 @@ pub enum LogEntry {
/// IDLE → active marker. Records the start of a new self-driving
/// cycle (Invoke range). The range extends implicitly until 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`).
///
/// 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
/// `Invoke` entries so "back to N-th send" maps cleanly to the
/// IDLE-break boundary the user sees.
@ -87,7 +87,7 @@ pub enum LogEntry {
/// One tool-execution result appended to history.
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
/// payload. Each `SystemItem` carries kind metadata that the LLM
/// 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
/// `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 },
/// `RequestConfig` changed.

View File

@ -8,8 +8,8 @@
//! `< 1 KiB` line on local fs and completes well below a millisecond. Going
//! through `tokio::fs` would force every caller — including `Engine`'s sync
//! `on_history_append` callback — to bridge sync → async via a channel +
//! drain task. Keeping the store sync lets the worker callback, Pod commit
//! paths, and `PodInterceptor` all share one direct `append_entry` call.
//! drain task. Keeping the store sync lets the worker callback, Worker commit
//! paths, and `WorkerInterceptor` all share one direct `append_entry` call.
use crate::event_trace::TraceEntry;
use crate::segment_log::LogEntry;
@ -81,7 +81,7 @@ pub trait Store: Send + Sync {
/// 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
/// default implementation rewrites the retained prefix through
/// `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.
//!
//! Items in worker history with `role:system` are never produced by the
//! LLM — they are always inserted by the Pod itself (notifications,
//! file/knowledge/workflow ref resolutions, child-pod lifecycle events,
//! LLM — they are always inserted by the Worker itself (notifications,
//! file/knowledge/workflow ref resolutions, child-worker lifecycle events,
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
//! typed shape of each such injection so clients can dispatch on
//! `kind` instead of parsing text prefixes like `[Notification] …` or
@ -19,7 +19,7 @@
//! system-message text.
use llm_engine::llm_client::types::Item;
use protocol::PodEvent;
use protocol::WorkerEvent;
use serde::{Deserialize, Serialize};
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.
///
/// 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
/// `body` (where applicable) that is the exact `role:system` text the
/// 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 {
/// Free-form notification sent in by an external caller via
/// `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).
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
/// banners without re-parsing); `body` is the wrapped LLM-context
/// 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
/// LLM-context text (`[File: <path>]\n…` for regular files,
@ -140,7 +140,7 @@ pub enum SystemItem {
FileAttachment { path: String, body: String },
/// `#<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).
Knowledge { slug: String, body: String },
@ -169,7 +169,7 @@ impl SystemItem {
pub fn history_text(&self) -> String {
match self {
SystemItem::Notification { body, .. } => body.clone(),
SystemItem::PodEvent { body, .. } => body.clone(),
SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::Knowledge { body, .. } => body.clone(),
SystemItem::Workflow { body, .. } => body.clone(),
@ -189,7 +189,7 @@ impl SystemItem {
pub fn kind_label(&self) -> &'static str {
match self {
SystemItem::Notification { .. } => "notification",
SystemItem::PodEvent { .. } => "pod_event",
SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::Knowledge { .. } => "knowledge",
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
/// 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 {
PodEvent::TurnEnded { pod_name } => format!("pod `{pod_name}` finished a turn"),
PodEvent::Errored { pod_name, message } => {
format!("pod `{pod_name}` errored: {message}")
WorkerEvent::TurnEnded { worker_name } => format!("worker `{worker_name}` finished a turn"),
WorkerEvent::Errored {
worker_name,
message,
} => {
format!("worker `{worker_name}` errored: {message}")
}
PodEvent::ShutDown { pod_name } => format!("pod `{pod_name}` shut down"),
PodEvent::ScopeSubDelegated {
parent_pod,
sub_pod,
WorkerEvent::ShutDown { worker_name } => format!("worker `{worker_name}` shut down"),
WorkerEvent::ScopeSubDelegated {
parent_worker,
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]
fn pod_event_history_text_returns_stored_body() {
let item = SystemItem::PodEvent {
event: PodEvent::TurnEnded {
pod_name: "child".into(),
fn worker_event_history_text_returns_stored_body() {
let item = SystemItem::WorkerEvent {
event: WorkerEvent::TurnEnded {
worker_name: "child".into(),
},
body: "[Notification]\npod `child` finished a turn\n\n(non-blocking hint…)".into(),
};
@ -321,21 +324,21 @@ mod tests {
}
#[test]
fn round_trip_pod_event() {
let item = SystemItem::PodEvent {
event: PodEvent::TurnEnded {
pod_name: "child".into(),
fn round_trip_worker_event() {
let item = SystemItem::WorkerEvent {
event: WorkerEvent::TurnEnded {
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 parsed: SystemItem = serde_json::from_str(&json).unwrap();
match parsed {
SystemItem::PodEvent {
event: PodEvent::TurnEnded { pod_name },
SystemItem::WorkerEvent {
event: WorkerEvent::TurnEnded { worker_name },
body,
} => {
assert_eq!(pod_name, "child");
assert_eq!(worker_name, "child");
assert!(body.contains("`child`"));
}
other => panic!("unexpected: {other:?}"),

View File

@ -103,7 +103,7 @@ async fn run_and_persist(
segment_id: session_store::SegmentId,
input: &str,
) -> (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
// skips the resulting user_message item to avoid double-write.
session_store::save_user_input(
@ -450,7 +450,7 @@ async fn session_auto_forks_on_conflict() {
// Writer tracked: just the SegmentStart we wrote.
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 {
ts: 9999,
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.
//! 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.
use std::collections::{BTreeMap, BTreeSet};

View File

@ -1,6 +1,6 @@
//! 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
//! 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
/// 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.
pub fn ticket_tool_description(name: &str, record_language: Option<&str>) -> String {
let mut description = base_tool_description(name).to_string();
@ -1909,7 +1909,7 @@ mod tests {
&json!({
"ticket": created.id.clone(),
"intake_summary": "Requirements accepted; implementation can be queued.",
"author": "intake-pod"
"author": "intake-worker"
})
.to_string(),
Default::default(),

View File

@ -2,13 +2,13 @@
## 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
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
- scope-aware file operation helpers
- tool-facing diagnostics suitable for history/model consumption
@ -17,7 +17,7 @@ Does not own:
- manifest permission policy definition (`manifest`)
- Engine tool-loop semantics (`llm-engine`)
- Pod lifecycle decisions (`pod`)
- Worker lifecycle decisions (`worker`)
- UI presentation (`tui`)
## Design notes

View File

@ -17,7 +17,7 @@ pub enum ToolsError {
OutOfScope(PathBuf),
#[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(),
.target.display()
)]

View File

@ -4,14 +4,14 @@
//! `llm-engine` `Tool` infrastructure. Filesystem access is mediated by
//! 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
//! persisted across Pod restart.
//! - [`Tracker`] — Pod-process lifetime, enforces the "read before edit"
//! persisted across Worker restart.
//! - [`Tracker`] — Worker-process lifetime, enforces the "read before edit"
//! 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`.
//!
//! `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 write::write_tool;
/// Register core builtin tools that do not require Pod-local task state,
/// wiring them to a shared `ScopedFs` (Pod-process lifetime) and `Tracker`
/// (Pod-process lifetime).
/// Register core builtin tools that do not require Worker-local task state,
/// wiring them to a shared `ScopedFs` (Worker-process lifetime) and `Tracker`
/// (Worker-process lifetime).
///
/// All returned factories share the same tracker instance so that
/// `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
/// caller is responsible for adding that path to the readable scope

View File

@ -1,7 +1,7 @@
//! Scope-aware filesystem primitive.
//!
//! `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
//! (Glob/Grep default their search base to it).
//!
@ -27,7 +27,7 @@ struct ScopedFsInner {
///
/// The wrapped [`SharedScope`] is shared with every clone of this
/// `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
/// `ScopedFs` reads the new view.
#[derive(Debug, Clone)]
@ -63,7 +63,7 @@ impl ScopedFs {
/// Create a new [`ScopedFs`] wrapping `scope` and `cwd` in a fresh
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
/// 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 {
Self::with_shared_scope(SharedScope::new(scope), cwd)
}
@ -85,13 +85,13 @@ impl ScopedFs {
}
/// 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.
pub fn shared_scope(&self) -> &SharedScope {
&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.
pub fn cwd(&self) -> &Path {
&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.
//!
//! A `Tracker` serves two orthogonal purposes:
@ -9,7 +9,7 @@
//! verify that the file has not been externally modified since then.
//!
//! 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?" —
//! used e.g. as a default reference set passed to context compaction.
//!
@ -18,12 +18,12 @@
//!
//! # Lifetime
//!
//! A `Tracker` is **Pod-process scoped**: the Pod layer creates a fresh
//! instance at the start of each Pod run (including resume) and discards
//! A `Tracker` is **Worker-process scoped**: the Worker layer creates a fresh
//! instance at the start of each Worker run (including resume) and discards
//! it when the process exits — it is not persisted, so a resumed
//! conversation starts with an empty read/edit history. The `ScopedFs`
//! write boundary is likewise Pod-process scoped (derived from the
//! manifest). The two are orthogonal and the Pod wires them together
//! write boundary is likewise Worker-process scoped (derived from the
//! manifest). The two are orthogonal and the Worker wires them together
//! when registering builtin tools.
//!
//! ```no_run
@ -31,7 +31,7 @@
//! # use manifest::Scope;
//! # use tools::{ScopedFs, Tracker, core_builtin_tools};
//! 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 bash_outputs = PathBuf::from("/run/yoi/bash-output");
//! 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.
///
/// 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
/// can pass them as default references to the compaction worker.
pub fn recent_files(&self, n: usize) -> Vec<PathBuf> {

View File

@ -2,7 +2,7 @@
## 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
@ -10,21 +10,21 @@ Owns:
- terminal rendering and input handling
- 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
Does not own:
- durable transcript authority (`session-store`)
- Pod current state (`pod-store`)
- Pod lifecycle policy (`pod`)
- Worker current state (`pod-store`)
- Worker lifecycle policy (`worker`)
- product CLI ownership (`yoi`)
## 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
- [`../../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::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, Method, PodStatus, RewindTarget, RunResult, Segment,
InFlightSnapshot, InFlightToolCallState, Method, RewindTarget, RunResult, Segment,
WorkerStatus,
};
use crate::block::{
@ -40,7 +41,7 @@ pub struct CompletionState {
pub prefix_start: usize,
/// Text typed after the sigil (sigil itself excluded).
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.
pub entries: Vec<CompletionEntry>,
pub selected: usize,
@ -71,7 +72,7 @@ pub struct RewindPickerState {
pub selected: usize,
pub scroll: RewindPickerScroll,
/// 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
/// destructive rewind cannot be queued multiple times by key repeat.
pub applying: bool,
@ -227,14 +228,14 @@ impl ActionbarNotice {
}
pub struct App {
pub pod_name: String,
pub worker_name: String,
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.
pub pod_status: PodStatus,
/// True while the Pod is in `PodStatus::Running`.
pub worker_status: WorkerStatus,
/// True while the Worker is in `WorkerStatus::Running`.
pub running: bool,
/// True while the Pod is in `PodStatus::Paused`.
/// True while the Worker is in `WorkerStatus::Paused`.
pub paused: bool,
pub run_requests: usize,
/// Sum of `input_tokens - cache_read_input_tokens` across the
@ -243,7 +244,7 @@ pub struct App {
/// cache reads excluded). Reset on `RunEnd`.
pub run_upload_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.
pub session_context_tokens: u64,
pub context_window: u64,
@ -264,9 +265,9 @@ pub struct App {
pub command_registry: CommandRegistry,
command_completion_selected: Option<usize>,
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
/// TUI (the Pod itself stays alive).
/// TUI (the Worker itself stays alive).
pub quit_confirm: Option<std::time::Instant>,
/// Full display history in render order.
pub blocks: Vec<Block>,
@ -284,18 +285,18 @@ pub struct App {
pub rewind_picker: Option<RewindPickerState>,
rewind_request_pending: bool,
/// 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
/// just-restored display.
rewind_refresh_fence: bool,
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
/// `[Session TaskStore snapshot]` system messages — no protocol
/// surface added on the Pod side.
/// surface added on the Worker side.
pub task_store: TaskStore,
/// Transient single-Pod transcript text selection. This is viewport-local
/// UI state only; it is never sent to the Pod, persisted, or appended to
/// Transient single-Worker transcript text selection. This is viewport-local
/// UI state only; it is never sent to the Worker, persisted, or appended to
/// session history/model context.
pub text_selection: TextSelectionState,
/// 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
/// render so it never points past the end of the list.
pub task_pane_scroll: usize,
/// TUI-local FIFO of user inputs submitted while the Pod is already running.
/// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally.
/// TUI-local FIFO of user inputs submitted while the Worker is already running.
/// Entries have not been sent to the Worker yet, so they remain editable/cancellable locally.
queued_inputs: VecDeque<QueuedInput>,
/// TUI-local readline-style composer input history. This is intentionally
/// client-side only: recalled entries are plain drafts until submitted again.
input_history: ComposerInputHistory,
/// 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>,
/// Local submit state kept until the accepted run either completes
/// normally or reports that the empty assistant turn was rolled back.
@ -321,11 +322,11 @@ pub struct App {
}
impl App {
pub fn new(pod_name: String) -> Self {
pub fn new(worker_name: String) -> Self {
Self {
pod_name,
worker_name,
connected: false,
pod_status: PodStatus::Idle,
worker_status: WorkerStatus::Idle,
running: false,
paused: false,
run_requests: 0,
@ -367,8 +368,8 @@ impl App {
}
}
pub fn new_with_persistent_input_history(pod_name: String, workspace_root: &Path) -> Self {
let mut app = Self::new(pod_name);
pub fn new_with_persistent_input_history(worker_name: String, workspace_root: &Path) -> Self {
let mut app = Self::new(worker_name);
match ComposerHistoryStore::default_for_workspace(workspace_root) {
Ok(Some(store)) => {
match store.load() {
@ -407,8 +408,8 @@ impl App {
}
#[cfg(test)]
fn new_with_input_history_store(pod_name: String, store: ComposerHistoryStore) -> Self {
let mut app = Self::new(pod_name);
fn new_with_input_history_store(worker_name: String, store: ComposerHistoryStore) -> Self {
let mut app = Self::new(worker_name);
match store.load() {
Ok(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);
}
pub fn set_pod_status(&mut self, status: PodStatus) {
self.pod_status = status;
self.running = status == PodStatus::Running;
self.paused = status == PodStatus::Paused;
pub fn set_worker_status(&mut self, status: WorkerStatus) {
self.worker_status = status;
self.running = status == WorkerStatus::Running;
self.paused = status == WorkerStatus::Paused;
if self.running {
self.quit_confirm = None;
}
@ -639,7 +640,7 @@ impl App {
pub fn submit_input(&mut self) -> Option<Method> {
let segments = self.input.submit_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.
if self.paused {
self.input_history.cancel_browse();
@ -660,7 +661,7 @@ impl App {
}
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`.
// Locally we only clear the input buffer and forward the method,
// 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>) {
self.blocks.push(Block::Alert {
level: AlertLevel::Error,
source: AlertSource::Pod,
source: AlertSource::Worker,
message: message.into(),
});
}
@ -856,7 +857,7 @@ impl App {
self.blocks.push(Block::TurnHeader {
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
// typed atoms (paste chips, refs) here. Seed history
// 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) {
return None;
}
@ -995,7 +996,7 @@ impl App {
self.assistant_streaming = false;
}
Event::TurnStart { .. } => {
self.set_pod_status(PodStatus::Running);
self.set_worker_status(WorkerStatus::Running);
self.run_requests += 1;
self.current_tool = None;
self.latest_llm_wait_event = None;
@ -1175,7 +1176,7 @@ impl App {
};
self.blocks.push(Block::Alert {
level,
source: AlertSource::Pod,
source: AlertSource::Worker,
message: format!("orphan tool result ({id}): {summary}"),
});
}
@ -1211,9 +1212,9 @@ impl App {
});
self.pending_submit_rollback = None;
self.reset_run_state(match result {
RunResult::Paused => PodStatus::Paused,
RunResult::Paused => WorkerStatus::Paused,
RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => {
PodStatus::Idle
WorkerStatus::Idle
}
});
if matches!(result, RunResult::Finished | RunResult::LimitReached) {
@ -1283,11 +1284,11 @@ impl App {
} => {
self.rewind_refresh_fence = false;
self.restore_snapshot(&entries, greeting, in_flight);
self.set_pod_status(status);
self.set_worker_status(status);
}
Event::Status { status } => {
self.rewind_refresh_fence = false;
self.set_pod_status(status);
self.set_worker_status(status);
}
Event::Completions { kind, entries } => {
// Apply only if the popup is still on the same
@ -1324,7 +1325,7 @@ impl App {
};
self.completion = None;
self.close_rewind_picker();
self.reset_run_state(self.pod_status);
self.reset_run_state(self.worker_status);
let mut message = if restored_composer {
format!(
"Rewound session: discarded {} log entries; restored selected input to composer.",
@ -1343,20 +1344,20 @@ impl App {
}
self.blocks.push(Block::Alert {
level: AlertLevel::Warn,
source: AlertSource::Pod,
source: AlertSource::Worker,
message,
});
}
Event::PodsListed { .. } | Event::PodRestored { .. } => {}
Event::WorkersListed { .. } | Event::WorkerRestored { .. } => {}
Event::PeerRegistered { result } => {
let source = result
.get("source")
.and_then(serde_json::Value::as_str)
.unwrap_or("this Pod");
.unwrap_or("this Worker");
let peer = result
.get("peer")
.and_then(serde_json::Value::as_str)
.unwrap_or("peer Pod");
.unwrap_or("peer Worker");
self.flash_actionbar_notice(
format!("Peer metadata registered: `{source}` ↔ `{peer}`"),
ActionbarNoticeLevel::Info,
@ -1372,8 +1373,8 @@ impl App {
None
}
fn reset_run_state(&mut self, status: PodStatus) {
self.set_pod_status(status);
fn reset_run_state(&mut self, status: WorkerStatus) {
self.set_worker_status(status);
self.run_requests = 0;
self.run_upload_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."
.to_owned()
};
self.reset_run_state(PodStatus::Idle);
self.reset_run_state(WorkerStatus::Idle);
self.blocks.push(Block::Alert {
level: AlertLevel::Warn,
source: AlertSource::Pod,
source: AlertSource::Worker,
message: hint,
});
}
@ -1706,15 +1707,17 @@ impl App {
pub fn request_rewind_picker(&mut self) -> Option<Method> {
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;
}
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;
}
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;
}
self.completion = None;
@ -1731,7 +1734,7 @@ impl App {
pub fn cancel_rewind_picker(&mut self) {
if self.rewind_submit_pending() {
self.flash_actionbar_notice(
"Rewind is applying; wait for the Pod response.",
"Rewind is applying; wait for the Worker response.",
ActionbarNoticeLevel::Warn,
ActionbarNoticeSource::Tui,
Duration::from_secs(3),
@ -1764,12 +1767,14 @@ impl App {
pub fn submit_rewind_picker(&mut self) -> Option<Method> {
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;
}
if self.paused {
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;
}
@ -1783,7 +1788,9 @@ impl App {
return None;
};
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;
}
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>) {
self.blocks.push(Block::Alert {
level: AlertLevel::Warn,
source: AlertSource::Pod,
source: AlertSource::Worker,
message: format!("TUI command: {}", message.into()),
});
}
@ -1971,7 +1978,7 @@ impl App {
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
/// session tail; always clear/replay from it even if this TUI instance has
/// somehow lost connect-time greeting metadata. Skipping the restore in
@ -1993,7 +2000,7 @@ impl App {
if missing_greeting {
self.blocks.push(Block::Alert {
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(),
});
}
@ -2023,7 +2030,7 @@ impl App {
/// Drop the derived view in preparation for replaying a new
/// `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) {
let greeting = self.blocks.iter().find_map(|b| match b {
Block::Greeting(g) => Some(g.clone()),
@ -2084,7 +2091,7 @@ impl App {
///
/// Kind-based routing replaces the old free-text `[Notification]` /
/// `[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) {
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
// Unknown / forward-compat shape: fall back to rendering the
@ -2101,8 +2108,8 @@ impl App {
session_store::SystemItem::Notification { message, .. } => {
self.blocks.push(Block::Notify { message });
}
session_store::SystemItem::PodEvent { event, .. } => {
self.blocks.push(Block::PodEvent { event });
session_store::SystemItem::WorkerEvent { event, .. } => {
self.blocks.push(Block::WorkerEvent { event });
}
session_store::SystemItem::FileAttachment { 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 {
match source {
AlertSource::Pod => "pod",
AlertSource::Worker => "worker",
AlertSource::Engine => "engine",
AlertSource::Compactor => "compactor",
AlertSource::AgentsMd => "AGENTS.md",
@ -2244,7 +2251,7 @@ mod llm_wait_event_tests {
#[test]
fn llm_retry_updates_and_progress_clears_transient_status() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::LlmRetry {
app.handle_worker_event(Event::LlmRetry {
llm_call: 2,
failed_attempt: 1,
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)")
);
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());
}
#[test]
fn llm_continuation_updates_transient_status() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::LlmContinuation {
app.handle_worker_event(Event::LlmContinuation {
llm_call: 3,
attempt: 1,
max_attempts: 3,
@ -2289,7 +2296,7 @@ mod actionbar_notice_tests {
let duration = Duration::from_secs(2);
app.flash_actionbar_notice_at(
"Pod keeps running",
"Worker keeps running",
ActionbarNoticeLevel::Warn,
ActionbarNoticeSource::Tui,
now,
@ -2297,7 +2304,7 @@ mod actionbar_notice_tests {
);
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.source, ActionbarNoticeSource::Tui);
assert_eq!(notice.expires_at, now + duration);
@ -2325,7 +2332,7 @@ mod rewind_refresh_tests {
text: "old post-target output".into(),
});
app.handle_pod_event(Event::RewindApplied {
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
input: vec![Segment::text("selected rewind input")],
summary: summary(3),
@ -2344,7 +2351,7 @@ mod rewind_refresh_tests {
text: "old live tail without greeting".into(),
});
app.handle_pod_event(Event::RewindApplied {
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
input: vec![Segment::text("rewound input")],
summary: summary(1),
@ -2367,7 +2374,7 @@ mod rewind_refresh_tests {
assert!(app.rewind_picker.as_ref().unwrap().applying);
assert!(app.submit_rewind_picker().is_none());
app.handle_pod_event(Event::Error {
app.handle_worker_event(Event::Error {
code: ErrorCode::InvalidRequest,
message: "stale rewind target".into(),
});
@ -2387,20 +2394,20 @@ mod rewind_refresh_tests {
text: "old tail before rewind".into(),
});
app.handle_pod_event(Event::RewindApplied {
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
input: vec![Segment::text("rewound input")],
summary: summary(2),
});
app.handle_pod_event(Event::TextDelta {
app.handle_worker_event(Event::TextDelta {
text: "stale tail after rewind".into(),
});
assert!(!blocks_contain(&app, "stale tail after rewind"));
app.handle_pod_event(Event::Status {
status: PodStatus::Idle,
app.handle_worker_event(Event::Status {
status: WorkerStatus::Idle,
});
app.handle_pod_event(Event::TextDelta {
app.handle_worker_event(Event::TextDelta {
text: "new live tail after status".into(),
});
assert!(blocks_contain(&app, "new live tail after status"));
@ -2431,7 +2438,7 @@ mod rewind_refresh_tests {
fn greeting() -> protocol::Greeting {
protocol::Greeting {
pod_name: "test".into(),
worker_name: "test".into(),
cwd: "/tmp".into(),
provider: "mock".into(),
model: "mock".into(),
@ -2916,7 +2923,7 @@ mod completion_flow_tests {
}
let _ = app.refresh_completion();
// Reply for a different kind shouldn't overwrite state.
app.handle_pod_event(Event::Completions {
app.handle_worker_event(Event::Completions {
kind: CompletionKind::Workflow,
entries: vec![CompletionEntry {
value: "stale".into(),
@ -2939,10 +2946,10 @@ mod completion_flow_tests {
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"),
});
app.handle_pod_event(Event::UserMessage {
app.handle_worker_event(Event::UserMessage {
segments: vec![Segment::text("first persisted message")],
});
@ -2960,20 +2967,20 @@ mod completion_flow_tests {
let submitted = submit_text(&mut app, "please wait");
assert_eq!(input_text(&app), "");
app.handle_pod_event(Event::UserMessage {
app.handle_worker_event(Event::UserMessage {
segments: submitted,
});
// Simulate run-derived attachment display after the submitted user line.
app.blocks.push(Block::SystemMessage {
text: "[File: README.md]".into(),
});
app.handle_pod_event(Event::TurnStart { turn: 1 });
app.handle_pod_event(Event::Usage {
app.handle_worker_event(Event::TurnStart { turn: 1 });
app.handle_worker_event(Event::Usage {
input_tokens: Some(100),
output_tokens: Some(0),
cache_read_input_tokens: Some(40),
});
app.handle_pod_event(Event::RunEnd {
app.handle_worker_event(Event::RunEnd {
result: RunResult::RolledBack,
});
@ -2987,7 +2994,7 @@ mod completion_flow_tests {
| Block::TurnStats { .. }
)));
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.paused);
assert_eq!(app.run_requests, 0);
@ -3000,14 +3007,14 @@ mod completion_flow_tests {
fn rolled_back_run_does_not_overwrite_existing_unsent_input() {
let mut app = App::new("test".into());
let submitted = submit_text(&mut app, "original submit");
app.handle_pod_event(Event::UserMessage {
app.handle_worker_event(Event::UserMessage {
segments: submitted,
});
for c in "draft while running".chars() {
app.insert_char(c);
}
app.handle_pod_event(Event::RunEnd {
app.handle_worker_event(Event::RunEnd {
result: RunResult::RolledBack,
});
@ -3028,10 +3035,10 @@ mod completion_flow_tests {
for result in [RunResult::Paused, RunResult::Finished] {
let mut app = App::new("test".into());
let submitted = submit_text(&mut app, "normal run");
app.handle_pod_event(Event::UserMessage {
app.handle_worker_event(Event::UserMessage {
segments: submitted,
});
app.handle_pod_event(Event::RunEnd { result });
app.handle_worker_event(Event::RunEnd { result });
assert_eq!(input_text(&app), "");
assert!(
@ -3057,7 +3064,7 @@ mod completion_flow_tests {
#[test]
fn running_submit_is_queued_locally_and_clears_composer() {
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");
assert!(app.submit_input().is_none());
@ -3070,11 +3077,11 @@ mod completion_flow_tests {
#[test]
fn finished_run_auto_sends_next_queued_input() {
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");
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,
});
@ -3090,11 +3097,11 @@ mod completion_flow_tests {
#[test]
fn limit_reached_run_auto_sends_next_queued_input() {
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");
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,
});
@ -3111,11 +3118,11 @@ mod completion_flow_tests {
fn paused_and_rolled_back_run_do_not_auto_send_queue() {
for result in [RunResult::Paused, RunResult::RolledBack] {
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");
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_eq!(app.queued_input_count(), 1);
@ -3126,7 +3133,7 @@ mod completion_flow_tests {
#[test]
fn paused_empty_submit_still_resumes_immediately() {
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_eq!(app.queued_input_count(), 0);
@ -3135,7 +3142,7 @@ mod completion_flow_tests {
#[test]
fn queued_input_can_be_restored_to_composer_or_cleared() {
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");
assert!(app.submit_input().is_none());
@ -3198,14 +3205,14 @@ mod completion_flow_tests {
compacted_from: None,
};
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(),
entries: vec![session_start_value],
status: PodStatus::Running,
status: WorkerStatus::Running,
in_flight: Default::default(),
});
assert!(matches!(app.pod_status, PodStatus::Running));
assert!(matches!(app.worker_status, WorkerStatus::Running));
assert!(app.running);
assert!(matches!(
app.blocks.get(1),
@ -3216,10 +3223,10 @@ mod completion_flow_tests {
#[test]
fn snapshot_in_flight_blocks_continue_with_live_deltas() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::Snapshot {
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: Vec::new(),
status: PodStatus::Running,
status: WorkerStatus::Running,
in_flight: InFlightSnapshot {
blocks: vec![
InFlightBlock::Thinking {
@ -3240,9 +3247,9 @@ mod completion_flow_tests {
},
});
app.handle_pod_event(Event::TextDelta { text: "lo".into() });
app.handle_pod_event(Event::ThinkingDelta { text: "?".into() });
app.handle_pod_event(Event::ToolCallArgsDelta {
app.handle_worker_event(Event::TextDelta { text: "lo".into() });
app.handle_worker_event(Event::ThinkingDelta { text: "?".into() });
app.handle_worker_event(Event::ToolCallArgsDelta {
id: "call_1".into(),
json: r#"\":\"src/lib.rs\"}"#.into(),
});
@ -3269,7 +3276,7 @@ mod completion_flow_tests {
"slug": "build",
"body": "[Workflow /build]\nRun the build",
});
app.handle_pod_event(Event::SystemItem { item });
app.handle_worker_event(Event::SystemItem { item });
assert!(matches!(
app.blocks.as_slice(),
@ -3285,7 +3292,7 @@ mod completion_flow_tests {
"message": "hi",
"body": "[Notification] hi",
});
app.handle_pod_event(Event::SystemItem { item });
app.handle_worker_event(Event::SystemItem { item });
assert!(matches!(
app.blocks.as_slice(),
[Block::Notify { message }] if message == "hi"
@ -3293,20 +3300,20 @@ mod completion_flow_tests {
}
#[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 item = serde_json::json!({
"kind": "pod_event",
"event": { "kind": "turn_ended", "pod_name": "child" },
"body": "[Notification] pod `child` finished a turn",
"kind": "worker_event",
"event": { "kind": "turn_ended", "worker_name": "child" },
"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);
match &app.blocks[0] {
Block::PodEvent {
event: protocol::PodEvent::TurnEnded { pod_name },
} => assert_eq!(pod_name, "child"),
_ => panic!("expected a PodEvent block"),
Block::WorkerEvent {
event: protocol::WorkerEvent::TurnEnded { worker_name },
} => assert_eq!(worker_name, "child"),
_ => panic!("expected a WorkerEvent block"),
}
}
@ -3315,8 +3322,8 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
app.handle_pod_event(Event::CompactStart);
app.handle_pod_event(Event::CompactDone { new_segment_id: id });
app.handle_worker_event(Event::CompactStart);
app.handle_worker_event(Event::CompactDone { new_segment_id: id });
assert_eq!(compact_block_count(&app), 1);
assert!(matches!(
@ -3332,8 +3339,8 @@ mod completion_flow_tests {
fn compact_failed_replaces_live_block() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::CompactStart);
app.handle_pod_event(Event::CompactFailed {
app.handle_worker_event(Event::CompactStart);
app.handle_worker_event(Event::CompactFailed {
error: "provider 429".into(),
});
@ -3351,8 +3358,8 @@ mod completion_flow_tests {
fn shutdown_marks_live_compact_incomplete() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::CompactStart);
app.handle_pod_event(Event::Shutdown);
app.handle_worker_event(Event::CompactStart);
app.handle_worker_event(Event::Shutdown);
assert!(app.quit);
assert!(matches!(
@ -3372,7 +3379,7 @@ mod completion_flow_tests {
fn test_greeting() -> protocol::Greeting {
protocol::Greeting {
pod_name: "test".into(),
worker_name: "test".into(),
cwd: "/tmp".into(),
provider: "test-provider".into(),
model: "test-model".into(),
@ -3390,10 +3397,10 @@ mod completion_flow_tests {
greeting.context_window = 123_000;
greeting.context_tokens = 45_000;
app.handle_pod_event(Event::Snapshot {
app.handle_worker_event(Event::Snapshot {
entries: Vec::new(),
greeting,
status: PodStatus::Idle,
status: WorkerStatus::Idle,
in_flight: Default::default(),
});
@ -3405,7 +3412,7 @@ mod completion_flow_tests {
fn usage_updates_session_context_tokens_without_cache_discount() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::Usage {
app.handle_worker_event(Event::Usage {
input_tokens: Some(42_000),
output_tokens: Some(9),
cache_read_input_tokens: Some(40_000),
@ -3420,7 +3427,7 @@ mod completion_flow_tests {
fn memory_worker_event_updates_actionbar_state() {
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(),
status: "done".into(),
run_id: "00000000-0000-0000-0000-000000000000".into(),
@ -3441,7 +3448,7 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
app.session_context_tokens = 42_000;
app.handle_pod_event(Event::CompactDone {
app.handle_worker_event(Event::CompactDone {
new_segment_id: uuid::Uuid::nil(),
});
@ -3453,8 +3460,8 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
app.session_context_tokens = 42_000;
app.handle_pod_event(Event::TurnStart { turn: 1 });
app.handle_pod_event(Event::RunEnd {
app.handle_worker_event(Event::TurnStart { turn: 1 });
app.handle_worker_event(Event::RunEnd {
result: RunResult::Finished,
});
@ -3464,11 +3471,11 @@ mod completion_flow_tests {
#[test]
fn live_task_create_updates_task_store() {
let mut app = App::new("test".into());
app.handle_pod_event(Event::ToolCallStart {
app.handle_worker_event(Event::ToolCallStart {
id: "c1".into(),
name: "TaskCreate".into(),
});
app.handle_pod_event(Event::ToolCallDone {
app.handle_worker_event(Event::ToolCallDone {
id: "c1".into(),
name: "TaskCreate".into(),
arguments: r#"{"subject":"impl tasks","description":"do it"}"#.into(),
@ -3491,11 +3498,11 @@ mod completion_flow_tests {
} else {
"TaskUpdate"
};
app.handle_pod_event(Event::ToolCallStart {
app.handle_worker_event(Event::ToolCallStart {
id: id.into(),
name: name.into(),
});
app.handle_pod_event(Event::ToolCallDone {
app.handle_worker_event(Event::ToolCallDone {
id: id.into(),
name: name.into(),
arguments: args.into(),
@ -3511,11 +3518,11 @@ mod completion_flow_tests {
fn live_system_snapshot_replaces_task_store() {
let mut app = App::new("test".into());
// Stale entry that the snapshot must wipe out.
app.handle_pod_event(Event::ToolCallStart {
app.handle_worker_event(Event::ToolCallStart {
id: "c1".into(),
name: "TaskCreate".into(),
});
app.handle_pod_event(Event::ToolCallDone {
app.handle_worker_event(Event::ToolCallDone {
id: "c1".into(),
name: "TaskCreate".into(),
arguments: r#"{"subject":"stale","description":""}"#.into(),
@ -3528,7 +3535,7 @@ mod completion_flow_tests {
\"description\": \"d\"\n }\n ]\n}\n```\n";
// Snapshot text injected as a workflow body (kind doesn't matter
// 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!({
"kind": "workflow",
"slug": "task-snapshot",
@ -3547,11 +3554,11 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
// Live tool call before the snapshot lands — restore must wipe
// this so it doesn't double-count after replay.
app.handle_pod_event(Event::ToolCallStart {
app.handle_worker_event(Event::ToolCallStart {
id: "live".into(),
name: "TaskCreate".into(),
});
app.handle_pod_event(Event::ToolCallDone {
app.handle_worker_event(Event::ToolCallDone {
id: "live".into(),
name: "TaskCreate".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(),
entries: assistant_item_entries,
status: PodStatus::Running,
status: WorkerStatus::Running,
in_flight: Default::default(),
});

View File

@ -9,7 +9,7 @@
use std::time::Instant;
use protocol::{AlertLevel, AlertSource, Greeting, PodEvent, Segment};
use protocol::{AlertLevel, AlertSource, Greeting, Segment, WorkerEvent};
pub enum Block {
Greeting(Greeting),
@ -25,16 +25,16 @@ pub enum Block {
SystemMessage {
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
/// following auto-kicked turn.
Notify {
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.
PodEvent {
event: PodEvent,
WorkerEvent {
event: WorkerEvent,
},
AssistantText {
text: String,

View File

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

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
/// completion popups, Enter submission, Tab target switching, Esc focus, and
/// row/list navigation.

View File

@ -18,14 +18,14 @@ use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{Command, execute};
#[cfg(feature = "e2e-test")]
use protocol::{Event, Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
use protocol::{Method, PodStatus};
use protocol::{Method, WorkerStatus};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use session_store::SegmentId;
use tokio::sync::mpsc;
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::composer_keys::{ComposerEditAction, composer_edit_action};
@ -35,9 +35,9 @@ use crate::{picker, spawn, ui};
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) pod_name: String,
pub(crate) worker_name: String,
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)
}
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 {
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")
.join("yoi")
.join(pod_name)
.join(worker_name)
.join("sock")
})
}
pub(crate) async fn run_pod_name(
pod_name: String,
pub(crate) async fn run_worker_name(
worker_name: String,
socket_override: Option<PathBuf>,
runtime_command: PodRuntimeCommand,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "e2e-test")]
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
let mut terminal = enter_fullscreen()?;
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);
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()?;
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(());
}
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::Cancelled => return Ok(()),
};
@ -173,12 +173,12 @@ pub(crate) async fn run_pod_name(
async fn run_connected_pod(
terminal: &mut ConsoleTerminal,
pod_name: String,
client: PodClient,
runtime_command: PodRuntimeCommand,
worker_name: String,
client: WorkerClient,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
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;
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(
terminal: &mut ConsoleTerminal,
request: DashboardConsoleOpenRequest,
runtime_command: PodRuntimeCommand,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let DashboardConsoleOpenRequest {
pod_name,
worker_name,
socket_override,
} = request;
if let Some(client) = try_connect_live_pod(&pod_name, socket_override).await {
return run_connected_pod(terminal, pod_name, client, runtime_command.clone()).await;
if let Some(client) = try_connect_live_pod(&worker_name, socket_override).await {
return run_connected_pod(terminal, worker_name, client, runtime_command.clone()).await;
}
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
}
async fn spawn_pod_name_from_fullscreen(
async fn spawn_worker_name_from_fullscreen(
terminal: &mut ConsoleTerminal,
pod_name: &str,
runtime_command: PodRuntimeCommand,
worker_name: &str,
runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
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)?;
terminal.clear()?;
@ -219,11 +219,11 @@ async fn spawn_pod_name_from_fullscreen(
}
async fn try_connect_live_pod(
pod_name: &str,
worker_name: &str,
socket_override: Option<PathBuf>,
) -> Option<PodClient> {
let preferred_socket = resolve_socket(pod_name, socket_override.clone());
connect_live_pod(pod_name, preferred_socket, socket_override.is_none())
) -> Option<WorkerClient> {
let preferred_socket = resolve_socket(worker_name, socket_override.clone());
connect_live_pod(worker_name, preferred_socket, socket_override.is_none())
.await
.map(|(_, client)| client)
}
@ -233,7 +233,7 @@ struct NestedOpenCancelled;
impl std::fmt::Display for NestedOpenCancelled {
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(
terminal: &mut ConsoleTerminal,
ready: SpawnReady,
runtime_command: PodRuntimeCommand,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let SpawnReady {
pod_name,
worker_name,
socket_path,
} = ready;
run(terminal, pod_name, &socket_path, runtime_command).await
run(terminal, worker_name, &socket_path, runtime_command).await
}
async fn connect_live_pod(
pod_name: &str,
worker_name: &str,
preferred_socket: PathBuf,
allow_registry_fallback: bool,
) -> Option<(PathBuf, PodClient)> {
if let Ok(client) = PodClient::connect(&preferred_socket).await {
) -> Option<(PathBuf, WorkerClient)> {
if let Ok(client) = WorkerClient::connect(&preferred_socket).await {
return Some((preferred_socket, client));
}
if !allow_registry_fallback {
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 {
return None;
}
PodClient::connect(&registry_socket)
WorkerClient::connect(&registry_socket)
.await
.ok()
.map(|client| (registry_socket, client))
}
pub(crate) async fn run_resume(
runtime_command: PodRuntimeCommand,
runtime_command: WorkerRuntimeCommand,
workspace_root: PathBuf,
all: bool,
) -> 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.
let picker_options = if all {
picker::PickerOptions::all()
} else {
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 {
pod_name,
worker_name,
socket_override,
} => (pod_name, socket_override),
} => (worker_name, socket_override),
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 {
@ -301,32 +301,33 @@ pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static))
pub(crate) async fn run_spawn(
resume_from: Option<SegmentId>,
pod_name: Option<String>,
worker_name: Option<String>,
profile: Option<String>,
runtime_command: PodRuntimeCommand,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "e2e-test")]
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
let mut terminal = enter_fullscreen()?;
terminal.clear()?;
let fixture_pod_name = pod_name.unwrap_or_else(|| "e2e-rewind".to_string());
let result = run_e2e_rewind_fixture(&mut terminal, fixture_pod_name).await;
let fixture_worker_name = worker_name.unwrap_or_else(|| "e2e-rewind".to_string());
let result = run_e2e_rewind_fixture(&mut terminal, fixture_worker_name).await;
let _ = leave_fullscreen(&mut terminal);
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::Cancelled => return Ok(()),
};
let SpawnReady {
pod_name,
worker_name,
socket_path,
} = ready;
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.
let _ = execute!(
@ -383,17 +384,17 @@ pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io::
async fn run(
terminal: &mut ConsoleTerminal,
pod_name: String,
worker_name: String,
socket_path: &std::path::Path,
runtime_command: PodRuntimeCommand,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
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) => {
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.
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")]
async fn run_e2e_rewind_fixture(
terminal: &mut ConsoleTerminal,
pod_name: String,
worker_name: String,
) -> Result<(), Box<dyn std::error::Error>> {
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.handle_pod_event(Event::Snapshot {
app.handle_worker_event(Event::Snapshot {
entries: Vec::new(),
status: PodStatus::Idle,
status: WorkerStatus::Idle,
greeting: Greeting {
pod_name: pod_name.clone(),
worker_name: worker_name.clone(),
cwd: workspace_root.display().to_string(),
provider: "e2e-fixture".to_string(),
model: "canned".to_string(),
@ -500,9 +501,9 @@ async fn run_e2e_rewind_fixture(
let apply_delay = Duration::from_millis(400);
#[cfg(feature = "e2e-test")]
crate::e2e_observer::emit(
"single_pod",
"single_worker",
"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))?;
@ -539,7 +540,7 @@ async fn run_e2e_rewind_fixture(
if let Some(method) = handle_key(&mut app, key) {
match method {
Method::ListRewindTargets => {
app.handle_pod_event(Event::RewindTargets {
app.handle_worker_event(Event::RewindTargets {
head_entries: 3,
targets: vec![RewindTarget {
id: target_id.clone(),
@ -554,7 +555,7 @@ async fn run_e2e_rewind_fixture(
}],
});
crate::e2e_observer::emit(
"single_pod",
"single_worker",
"rewind_picker_opened",
serde_json::json!({
"targets": 1,
@ -569,7 +570,7 @@ async fn run_e2e_rewind_fixture(
rewind_submit_count += 1;
pending_apply = Some(std::time::Instant::now());
crate::e2e_observer::emit(
"single_pod",
"single_worker",
"rewind_submit_sent",
serde_json::json!({
"segment_id": target.segment_id.to_string(),
@ -583,7 +584,7 @@ async fn run_e2e_rewind_fixture(
}
} else if duplicate_enter_pending {
crate::e2e_observer::emit(
"single_pod",
"single_worker",
"rewind_duplicate_enter_suppressed",
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 submitted_at.elapsed() >= apply_delay {
app.handle_pod_event(Event::RewindApplied {
app.handle_worker_event(Event::RewindApplied {
entries: Vec::new(),
input: vec![Segment::text("rewind-live-refresh")],
summary: RewindSummary {
@ -613,7 +614,7 @@ async fn run_e2e_rewind_fixture(
pending_apply = None;
let composer_text = Segment::flatten_to_text(&app.input.submit_segments());
crate::e2e_observer::emit(
"single_pod",
"single_worker",
"rewind_applied",
serde_json::json!({
"composer_text": composer_text,
@ -644,7 +645,7 @@ enum E2eRewindInput {
enum LoopInput<P> {
Terminal(TerminalEventResult),
Pod(Option<P>),
Worker(Option<P>),
}
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(
app: &mut App,
client: &mut PodClient,
client: &mut WorkerClient,
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
runtime_command: &PodRuntimeCommand,
runtime_command: &WorkerRuntimeCommand,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false;
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
@ -698,16 +699,16 @@ async fn drain_terminal_events(
Ok(handled)
}
async fn drain_pod_events(
async fn drain_worker_events(
app: &mut App,
client: &mut PodClient,
client: &mut WorkerClient,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false;
for _ in 0..POD_EVENT_DRAIN_LIMIT {
match client.try_next_event() {
Some(ev) => {
handled = true;
if let Some(method) = app.handle_pod_event(ev) {
if let Some(method) = app.handle_worker_event(ev) {
client.send(&method).await?;
}
}
@ -720,8 +721,8 @@ async fn drain_pod_events(
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
mut client: PodClient,
runtime_command: PodRuntimeCommand,
mut client: WorkerClient,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
@ -737,8 +738,8 @@ async fn run_loop(
if app.quit {
break;
}
let handled_pod_event = drain_pod_events(app, &mut client).await?;
if handled_term_event || handled_pod_event {
let handled_worker_event = drain_worker_events(app, &mut client).await?;
if handled_term_event || handled_worker_event {
terminal.draw(|f| ui::draw(f, app))?;
continue;
}
@ -747,9 +748,9 @@ async fn run_loop(
LoopInput::Terminal(term_event) => {
handle_terminal_event(app, &mut client, term_event?, &runtime_command).await?;
}
LoopInput::Pod(event) => match event {
LoopInput::Worker(event) => match event {
Some(ev) => {
if let Some(method) = app.handle_pod_event(ev) {
if let Some(method) = app.handle_worker_event(ev) {
client.send(&method).await?;
}
}
@ -769,9 +770,9 @@ async fn run_loop(
async fn handle_terminal_event(
app: &mut App,
client: &mut PodClient,
client: &mut WorkerClient,
event: TermEvent,
_runtime_command: &PodRuntimeCommand,
_runtime_command: &WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
match event {
TermEvent::Key(key) => {
@ -937,12 +938,12 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
Some(None)
}
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(match app.pod_status {
PodStatus::Running | PodStatus::Paused => {
KeyCode::Char('x') if ctrl => Some(match app.worker_status {
WorkerStatus::Running | WorkerStatus::Paused => {
app.clear_queued_inputs();
Some(Method::Cancel)
}
PodStatus::Idle => Some(Method::Shutdown),
WorkerStatus::Idle => Some(Method::Shutdown),
}),
KeyCode::Char('d') if ctrl => {
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);
/// 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> {
if app.pod_status == PodStatus::Running {
if app.worker_status == WorkerStatus::Running {
app.clear_queued_inputs();
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.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,
ActionbarNoticeSource::Tui,
CONFIRM_TIMEOUT,
@ -1226,7 +1227,7 @@ mod tests {
use protocol::{Event, RewindTarget, RewindTargetId, Segment};
#[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();
Command::write_ansi(&EnableSinglePodMouseCapture, &mut ansi).unwrap();
@ -1238,7 +1239,7 @@ mod tests {
#[test]
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(
HistoryViewport {
x: 1,
@ -1285,7 +1286,7 @@ mod tests {
#[test]
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(
HistoryViewport {
x: 0,
@ -1306,7 +1307,7 @@ mod tests {
#[test]
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(
HistoryViewport {
x: 0,
@ -1333,7 +1334,7 @@ mod tests {
}
#[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();
tx.send(Ok(TermEvent::Key(KeyEvent::new(
KeyCode::Char('x'),
@ -1345,17 +1346,17 @@ mod tests {
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
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]
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();
match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await {
LoopInput::Pod(Some(1)) => {}
_ => panic!("expected the first ready Pod event to win before any terminal input"),
LoopInput::Worker(Some(1)) => {}
_ => panic!("expected the first ready Worker event to win before any terminal input"),
}
tx.send(Ok(TermEvent::Key(KeyEvent::new(
@ -1368,14 +1369,14 @@ mod tests {
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
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]
fn running_status_still_allows_text_editing() {
let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Running);
app.set_worker_status(WorkerStatus::Running);
assert!(
handle_key(
@ -1409,7 +1410,7 @@ mod tests {
#[test]
fn running_enter_queues_instead_of_sending_run() {
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() {
assert!(
handle_key(
@ -1430,7 +1431,7 @@ mod tests {
#[test]
fn queued_input_keybindings_restore_and_clear() {
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() {
assert!(
handle_key(
@ -1478,7 +1479,7 @@ mod tests {
#[test]
fn pause_and_cancel_clear_queued_input() {
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() {
assert!(
handle_key(
@ -1521,7 +1522,7 @@ mod tests {
#[test]
fn ctrl_x_cancels_paused_turn_without_shutdown() {
let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Paused);
app.set_worker_status(WorkerStatus::Paused);
let cancel = handle_key(
&mut app,
@ -1533,7 +1534,7 @@ mod tests {
#[test]
fn ctrl_x_shutdown_while_idle_is_unchanged() {
let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Idle);
app.set_worker_status(WorkerStatus::Idle);
let shutdown = handle_key(
&mut app,
@ -1854,7 +1855,7 @@ mod tests {
#[test]
fn ctrl_c_quit_guard_uses_actionbar_notice_without_transcript_alert() {
let mut app = App::new("agent".to_string());
app.set_pod_status(PodStatus::Idle);
app.set_worker_status(WorkerStatus::Idle);
let method = handle_key(
&mut app,
@ -1866,10 +1867,10 @@ mod tests {
let notice = app
.current_actionbar_notice(std::time::Instant::now())
.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.source, ActionbarNoticeSource::Tui);
assert!(!has_alert(&app, "Pod keeps running"));
assert!(!has_alert(&app, "Worker keeps running"));
let method = handle_key(
&mut app,
@ -1889,7 +1890,7 @@ mod tests {
);
assert!(matches!(idle, Some(Method::ListRewindTargets)));
app.set_pod_status(PodStatus::Paused);
app.set_worker_status(WorkerStatus::Paused);
let paused = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
@ -1901,7 +1902,7 @@ mod tests {
fn ctrl_r_is_rejected_while_running() {
let mut app = App::new("agent".to_string());
app.connected = true;
app.set_pod_status(PodStatus::Running);
app.set_worker_status(WorkerStatus::Running);
let method = handle_key(
&mut app,
@ -1909,14 +1910,14 @@ mod tests {
);
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]
fn rewind_picker_close_returns_to_history_view() {
let mut app = App::new("agent".to_string());
app.connected = true;
app.handle_pod_event(Event::RewindTargets {
app.handle_worker_event(Event::RewindTargets {
head_entries: 1,
targets: vec![],
});
@ -1927,7 +1928,7 @@ mod tests {
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
);
assert!(matches!(method, Some(Method::ListRewindTargets)));
app.handle_pod_event(Event::RewindTargets {
app.handle_worker_event(Event::RewindTargets {
head_entries: 1,
targets: vec![],
});
@ -1942,13 +1943,13 @@ mod tests {
#[test]
fn rewind_applied_reseeds_display_and_restores_composer() {
let mut app = App::new("agent".to_string());
app.handle_pod_event(Event::Snapshot {
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: vec![],
status: PodStatus::Idle,
status: WorkerStatus::Idle,
in_flight: Default::default(),
});
app.handle_pod_event(Event::RewindApplied {
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
input: vec![Segment::Text {
content: "retry this".into(),
@ -1968,15 +1969,15 @@ mod tests {
#[test]
fn rewind_applied_keeps_non_empty_composer() {
let mut app = App::new("agent".to_string());
app.handle_pod_event(Event::Snapshot {
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: vec![],
status: PodStatus::Idle,
status: WorkerStatus::Idle,
in_flight: Default::default(),
});
type_keys(&mut app, "draft");
app.handle_pod_event(Event::RewindApplied {
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
input: vec![Segment::Text {
content: "retry this".into(),
@ -2005,11 +2006,11 @@ mod tests {
let mut app = App::new("agent".to_string());
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!(has_alert(
&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 {
protocol::Greeting {
pod_name: "agent".into(),
worker_name: "agent".into(),
cwd: "/tmp".into(),
provider: "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) 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)]
} else {
vec![
@ -438,7 +438,7 @@ pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
return parts.join(" · ");
}
if row.kind == PanelRowKind::TicketIntakePod {
if row.kind == PanelRowKind::TicketIntakeWorker {
let mut parts = row
.subtitle
.as_ref()
@ -538,8 +538,8 @@ pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
.map(|ticket| ticket.id.clone())
.unwrap_or_else(|| match &row.key {
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(),
PanelRowKey::Pod(name) => name.clone(),
PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(),
PanelRowKey::Worker(name) => name.clone(),
})
}
@ -597,7 +597,7 @@ pub(super) fn intake_status_style(status: &str) -> Style {
}
pub(super) fn section_rows(
list: &PodList,
list: &WorkerList,
section: &DashboardSection,
selected: Option<&PanelRowKey>,
width: u16,
@ -616,7 +616,7 @@ pub(super) fn section_rows(
)));
for index in visible {
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);
rows.push(PanelListRow::selectable(
row_line(entry, selected, width),
@ -627,7 +627,7 @@ pub(super) fn section_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 name_style = if selected {
Style::default()

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@
//!
//! Display form: paste atoms render as
//! `[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).
use ratatui::style::{Color, Style};
@ -33,7 +33,7 @@ impl PasteRef {
}
/// `@<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.
#[derive(Debug, Clone)]
pub struct FileRefAtom {

View File

@ -12,7 +12,6 @@ mod input;
pub mod keys;
mod markdown;
mod picker;
mod pod_list;
mod role_session_registry;
mod scroll;
pub mod setup_model;
@ -22,6 +21,7 @@ mod text_selection;
mod tool;
mod ui;
mod view_mode;
mod worker_list;
mod workspace_panel;
use std::io;
@ -33,37 +33,37 @@ use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
use session_store::SegmentId;
use client::PodRuntimeCommand;
use client::WorkerRuntimeCommand;
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub mode: LaunchMode,
pub runtime_command: PodRuntimeCommand,
pub runtime_command: WorkerRuntimeCommand,
pub workspace_root: PathBuf,
}
#[derive(Debug, Clone)]
pub enum LaunchMode {
Spawn {
pod_name: Option<String>,
worker_name: Option<String>,
profile: Option<String>,
},
/// `yoi --pod <name>`: attach to a live Pod by name if possible;
/// otherwise launch the Pod runtime command with `--pod <name>` so it
/// resumes from name-keyed state or creates a fresh same-name Pod.
PodName {
pod_name: String,
/// `yoi --worker <name>`: attach to a live Worker by name if possible;
/// otherwise launch the Worker runtime command with `--worker <name>` so it
/// resumes from name-keyed state or creates a fresh same-name Worker.
WorkerName {
worker_name: String,
socket_override: Option<PathBuf>,
},
/// `yoi resume`: open the Pod picker, then attach to the selected live Pod
/// or restore the selected stopped Pod by name. Without `--all`, the picker
/// `yoi resume`: open the Worker picker, then attach to the selected live Worker
/// or restore the selected stopped Worker by name. Without `--all`, the picker
/// is scoped to the current runtime workspace.
Resume { all: bool },
/// `yoi --session <UUID>`: skip the picker, go straight to the
/// resume name dialog with `id` baked in.
ResumeWithSession {
id: SegmentId,
pod_name: Option<String>,
worker_name: Option<String>,
},
/// `yoi panel`: open the workspace Dashboard from the current workspace.
Panel,
@ -95,18 +95,19 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
}
let result = match mode {
LaunchMode::Spawn { pod_name, profile } => {
console::run_spawn(None, pod_name, profile, runtime_command).await
}
LaunchMode::PodName {
pod_name,
LaunchMode::Spawn {
worker_name,
profile,
} => console::run_spawn(None, worker_name, profile, runtime_command).await,
LaunchMode::WorkerName {
worker_name,
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 } => {
console::run_resume(runtime_command, workspace_root.clone(), all).await
}
LaunchMode::ResumeWithSession { id, pod_name } => {
console::run_spawn(Some(id), pod_name, None, runtime_command).await
LaunchMode::ResumeWithSession { id, worker_name } => {
console::run_spawn(Some(id), worker_name, None, 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
// viewport's final frame, so it's already visible in the
// 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.
if e.downcast_ref::<spawn::SpawnError>().is_none() {
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
//! 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::path::PathBuf;
use std::time::Duration;
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use pod_store::FsPodStore;
use pod_store::FsWorkerStore;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
@ -19,10 +19,10 @@ use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use session_store::FsStore;
use crate::pod_list::{
LivePodInfo, PodList, PodListEntry, PodVisibilitySource, StoredMetadataState, StoredPodInfo,
live_socket_for_pod as pod_list_live_socket_for_pod, read_reachable_live_pod_infos,
read_stored_pod_infos,
use crate::worker_list::{
LiveWorkerInfo, StoredMetadataState, StoredWorkerInfo, WorkerList, WorkerListEntry,
WorkerVisibilitySource, live_socket_for_pod as worker_list_live_socket_for_pod,
read_reachable_live_pod_infos, read_stored_worker_infos,
};
const MAX_ROWS: usize = 10;
@ -32,7 +32,7 @@ const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
pub enum PickerError {
Io(io::Error),
Store(session_store::StoreError),
NoPods { all: bool },
NoWorkers { all: bool },
}
impl std::fmt::Display for PickerError {
@ -40,13 +40,13 @@ impl std::fmt::Display for PickerError {
match self {
Self::Io(e) => write!(f, "io error: {e}"),
Self::Store(e) => write!(f, "session store error: {e}"),
Self::NoPods { all: true } => write!(
Self::NoWorkers { all: true } => write!(
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,
"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 {
/// 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
/// empty so the caller restores by spawning the Pod runtime command.
/// empty so the caller restores by spawning the Worker runtime command.
Picked {
pod_name: String,
worker_name: String,
socket_override: Option<PathBuf>,
},
Cancelled,
@ -103,13 +103,13 @@ enum PickerScope {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PodRowState {
enum WorkerRowState {
Live,
Stopped,
Corrupt,
}
impl PodRowState {
impl WorkerRowState {
fn label(self) -> &'static str {
match self {
Self::Live => "live",
@ -131,22 +131,22 @@ impl PodRowState {
fn list_for_options(
options: &PickerOptions,
stored_pods: Vec<StoredPodInfo>,
live_pods: Vec<LivePodInfo>,
) -> PodList {
stored_workers: Vec<StoredWorkerInfo>,
live_workers: Vec<LiveWorkerInfo>,
) -> WorkerList {
match &options.scope {
PickerScope::Workspace(workspace_root) => PodList::from_workspace_sources(
PodVisibilitySource::ResumePicker,
stored_pods,
live_pods,
PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources(
WorkerVisibilitySource::ResumePicker,
stored_workers,
live_workers,
None,
MAX_ROWS,
workspace_root,
),
PickerScope::All => PodList::from_sources(
PodVisibilitySource::ResumePicker,
stored_pods,
live_pods,
PickerScope::All => WorkerList::from_sources(
WorkerVisibilitySource::ResumePicker,
stored_workers,
live_workers,
None,
MAX_ROWS,
),
@ -156,14 +156,14 @@ fn list_for_options(
pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?;
let pod_store = FsPodStore::new(default_pod_store_dir()?).map_err(io::Error::other)?;
let stored_pods = read_stored_pod_infos(&store, &pod_store)?;
let live_pods = read_reachable_live_pod_infos(&store)
let pod_store = FsWorkerStore::new(default_pod_store_dir()?).map_err(io::Error::other)?;
let stored_workers = read_stored_worker_infos(&store, &pod_store)?;
let live_workers = read_reachable_live_pod_infos(&store)
.await
.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() {
return Err(PickerError::NoPods {
return Err(PickerError::NoWorkers {
all: matches!(options.scope, PickerScope::All),
});
}
@ -185,9 +185,9 @@ pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
}
Some(Action::Submit) => {
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 {
pod_name: entry.name.clone(),
worker_name: entry.name.clone(),
socket_override: entry.attach_socket_path().map(PathBuf::from),
});
}
@ -229,14 +229,14 @@ fn default_pod_store_dir() -> Result<PathBuf, PickerError> {
.ok_or_else(|| {
PickerError::Io(io::Error::new(
io::ErrorKind::NotFound,
"could not resolve pod state directory \
"could not resolve worker state directory \
(set YOI_HOME, YOI_DATA_DIR, or HOME)",
))
})
}
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
pod_list_live_socket_for_pod(pod_name)
pub(crate) fn live_socket_for_pod(worker_name: &str) -> Option<PathBuf> {
worker_list_live_socket_for_pod(worker_name)
}
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 mut constraints: Vec<Constraint> = Vec::with_capacity(list.entries.len() + 3);
constraints.push(Constraint::Length(1)); // title
@ -320,10 +320,10 @@ fn draw(f: &mut Frame<'_>, list: &PodList) {
}
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 name_style = if selected {
Style::default()
@ -361,18 +361,18 @@ fn row_line(entry: &PodListEntry, selected: bool) -> Line<'_> {
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) {
return PodRowState::Live;
return WorkerRowState::Live;
}
if entry
.stored
.as_ref()
.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 {
@ -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
.summary
.active_session_id
@ -407,20 +407,20 @@ mod tests {
#[test]
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]
fn picker_no_pods_message_mentions_all_for_workspace_scope() {
let message = PickerError::NoPods { all: false }.to_string();
assert!(message.contains("no pods found in this workspace"));
let message = PickerError::NoWorkers { all: false }.to_string();
assert!(message.contains("no workers found in this workspace"));
assert!(message.contains("yoi resume --all"));
}
#[test]
fn picker_no_pods_message_keeps_fresh_pod_hint_for_all_scope() {
let message = PickerError::NoPods { all: true }.to_string();
assert!(message.contains("start a fresh pod with `yoi`"));
let message = PickerError::NoWorkers { all: true }.to_string();
assert!(message.contains("start a fresh Worker with `yoi`"));
assert!(!message.contains("yoi resume --all"));
}
@ -464,9 +464,9 @@ mod tests {
assert_eq!(names, vec!["current", "other", "legacy"]);
}
fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredPodInfo {
StoredPodInfo {
pod_name: name.to_string(),
fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredWorkerInfo {
StoredWorkerInfo {
worker_name: name.to_string(),
metadata_state: StoredMetadataState::Present,
active_session_id: None,
active_segment_id: None,
@ -479,16 +479,16 @@ mod tests {
#[test]
fn picker_row_shows_live_pending_preview_and_runtime_segment_id() {
let segment_id = session_store::new_segment_id();
let entry = PodList::from_sources(
PodVisibilitySource::ResumePicker,
let entry = WorkerList::from_sources(
WorkerVisibilitySource::ResumePicker,
vec![],
vec![crate::pod_list::LivePodInfo {
pod_name: "pending".to_string(),
vec![crate::worker_list::LiveWorkerInfo {
worker_name: "pending".to_string(),
socket_path: PathBuf::from("/tmp/pending.sock"),
status: Some(protocol::PodStatus::Idle),
status: Some(protocol::WorkerStatus::Idle),
reachable: true,
segment_id: Some(segment_id),
summary: crate::pod_list::PodEntrySummary::default(),
summary: crate::worker_list::WorkerEntrySummary::default(),
}],
None,
10,

View File

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

View File

@ -99,7 +99,7 @@ fn prompt_model_choice(
println!("yoi setup-model");
println!();
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!();
for (idx, choice) in choices.iter().enumerate() {
println!(
@ -237,7 +237,7 @@ return profile {{
task = {{ enabled = true }},
memory = {{ enabled = true }},
web = {{ enabled = true }},
pods = {{ enabled = false }},
workers = {{ enabled = false }},
ticket = {{ enabled = false, access = "lifecycle" }},
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
//! with no positional argument. Discovers `.yoi/profiles.toml` profile
//! 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
//! `YOI-READY` stderr line, the dialog hands control back so main can
//! switch the terminal to alternate-screen mode.
@ -15,7 +15,7 @@ use std::io;
use std::path::{Path, PathBuf};
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 manifest::ProfileDiscovery;
use ratatui::Terminal;
@ -30,7 +30,7 @@ use session_store::SegmentId;
const VIEWPORT_LINES: u16 = 6;
pub struct SpawnReady {
pub pod_name: String,
pub worker_name: String,
pub socket_path: PathBuf,
}
@ -71,13 +71,13 @@ impl From<client::SpawnError> for SpawnError {
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Source session for a resume run. `None` = fresh spawn (current
/// behaviour); `Some(id)` swaps the dialog into "Resume Pod" mode and
/// passes `--session <id>` to the spawned Pod runtime child.
/// behaviour); `Some(id)` swaps the dialog into "Resume Worker" mode and
/// passes `--session <id>` to the spawned Worker runtime child.
pub async fn run(
resume_from: Option<SegmentId>,
pod_name: Option<String>,
worker_name: Option<String>,
profile: Option<String>,
runtime_command: PodRuntimeCommand,
runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let mut profile_choices = if resume_from.is_some() {
@ -91,7 +91,7 @@ pub async fn run(
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 mut form = Form {
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
// updates, not input — so the cursor doesn't end up parked there
// when the inline terminal is finally dropped.
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))?;
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
Ok(ready) => {
form.message = Some((
format!("ready: {} attaching...", ready.pod_name),
format!("ready: {} attaching...", ready.worker_name),
MessageKind::Ok,
));
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
/// resolves persisted Pod metadata if present, or creates a fresh same-name Pod
/// Launch a Worker runtime command with `--worker <name>` without opening the name dialog. The child Worker
/// resolves persisted Worker metadata if present, or creates a fresh same-name Worker
/// from the default profile.
pub async fn run_pod_name(
pod_name: String,
runtime_command: PodRuntimeCommand,
pub async fn run_worker_name(
worker_name: String,
runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
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()?;
terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
Ok(ready) => {
form.message = Some((
format!("ready: {} attaching...", ready.pod_name),
format!("ready: {} attaching...", ready.worker_name),
MessageKind::Ok,
));
terminal.draw(|f| draw_form(f, &form))?;
@ -226,7 +226,7 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
.and_then(|s| s.to_str())
.map(sanitise_default_name)
.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);
@ -290,13 +290,13 @@ fn initial_profile_index(
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 {
cwd: defaults.cwd,
scope_origin: defaults.scope_origin,
name_cursor: pod_name.chars().count(),
name: pod_name,
message: Some(("resuming pod...".to_string(), MessageKind::Progress)),
name_cursor: worker_name.chars().count(),
name: worker_name,
message: Some(("resuming worker...".to_string(), MessageKind::Progress)),
editing: false,
resume_from: None,
profile_choices: Vec::new(),
@ -359,7 +359,7 @@ fn poll_event() -> io::Result<Option<Action>> {
}
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, '-' | '_' | '.')
}
@ -372,23 +372,23 @@ fn sanitise_default_name(s: &str) -> String {
async fn wait_for_ready(
terminal: &mut InlineTerminal,
form: &mut Form,
runtime_command: &PodRuntimeCommand,
runtime_command: &WorkerRuntimeCommand,
) -> Result<SpawnReady, SpawnError> {
let config = SpawnConfig {
runtime_command: runtime_command.clone(),
pod_name: form.name.clone(),
worker_name: form.name.clone(),
profile: form.selected_profile_selector(),
workspace_root: form.cwd.clone(),
cwd: None,
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));
let _ = terminal.draw(|f| draw_form(f, form));
})
.await?;
Ok(SpawnReady {
pod_name: ready.pod_name,
worker_name: ready.worker_name,
socket_path: ready.socket_path,
})
}
@ -421,14 +421,14 @@ struct Form {
/// cursor stays out so it does not collide with the shell prompt
/// after the inline terminal is dropped.
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
/// 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.
resume_from: Option<SegmentId>,
/// Optional profile choices passed with `--profile` for
/// 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_index: usize,
}
@ -526,8 +526,8 @@ fn draw_form(f: &mut Frame<'_>, form: &Form) {
.split(area);
let title_text = match form.resume_from {
Some(id) => format!("resume pod session: {}", short_segment(id)),
None => "spawn pod".to_string(),
Some(id) => format!("resume worker session: {}", short_segment(id)),
None => "spawn worker".to_string(),
};
let title = Paragraph::new(Line::from(vec![Span::styled(
title_text,
@ -633,7 +633,7 @@ mod tests {
}
#[test]
fn pod_name_form_restores_or_creates_by_pod_name() {
fn worker_name_form_restores_or_creates_by_worker_name() {
let defaults = SpawnDefaults {
cwd: PathBuf::from("/work/example"),
scope_origin: ScopeOrigin::FromProfile,
@ -641,7 +641,7 @@ mod tests {
default_profile_index: 0,
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_cursor, "agent".chars().count());
@ -649,7 +649,7 @@ mod tests {
assert!(!f.editing);
assert_eq!(
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.
//!
//! This deliberately does NOT depend on the Pod TaskStore. The TUI is a
//! presentation layer; pulling in `pod` would drag along the runtime
//! This deliberately does NOT depend on the Worker TaskStore. The TUI is a
//! presentation layer; pulling in `worker` would drag along the runtime
//! feature surface. Instead we mirror the small subset we
//! 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,
//! matching field names on the entry).
//! - Just enough state machine to apply `TaskCreate` / `TaskUpdate`
//! tool-call arguments and the `[Session TaskStore snapshot]` system
//! 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
//! message shape emitted during compaction and restored on resume.
@ -90,7 +90,7 @@ impl TaskStore {
/// Apply a completed `TaskCreate` / `TaskUpdate` tool_call. Other
/// 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) {
match name {
"TaskCreate" => {
@ -236,8 +236,8 @@ mod tests {
assert_eq!(c.active(), 2);
}
/// Snapshot text matches the wrapping `Pod::try_pre_run_compact` and the
/// Pod Task feature snapshot fixture shape: header line, blank, overview
/// Snapshot text matches the wrapping `Worker::try_pre_run_compact` and the
/// Worker Task feature snapshot fixture shape: header line, blank, overview
/// line, blank, fenced JSON, trailing prose.
fn wrap_snapshot(json_body: &str, overview: &str) -> String {
format!(
@ -314,16 +314,16 @@ mod tests {
}
/// 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
/// 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`
/// depend on `pod` or `tools`.
/// depend on `worker` or `tools`.
#[cfg(test)]
mod snapshot_format_contract {
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
/// the prose around the JSON fence ever shifts.
fn wrap_pod_style(snapshot_text: &str) -> String {
@ -397,7 +397,7 @@ mod snapshot_format_contract {
#[test]
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
// wrong-status assertions.
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
//! 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 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::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
@ -509,7 +509,7 @@ fn draw_rewind_picker(
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" waiting for Pod response"),
Span::raw(" waiting for Worker response"),
]
} else {
vec![
@ -871,7 +871,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
match block {
Block::Greeting(g) => match mode {
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(
text,
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),
}
}
Block::PodEvent { event } => {
let text = format_pod_event(event);
Block::WorkerEvent { event } => {
let text = format_worker_event(event);
match mode {
Mode::Overview => push_overview_line(lines, &text, width, MessageKind::Notify, ""),
_ => push_padded_lines(lines, &text, MessageKind::Notify),
@ -1595,7 +1595,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
conn,
Span::raw(" "),
Span::styled(
app.pod_name.clone(),
app.worker_name.clone(),
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();
lines.push(Line::from(Span::styled(
g.pod_name.clone(),
g.worker_name.clone(),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
@ -1856,9 +1856,9 @@ fn greeting_lines(g: &Greeting) -> Vec<Line<'static>> {
pub enum MessageKind {
TurnHeader,
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
/// 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,
/// Persisted role:system history item preview.
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`
/// 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.
fn format_pod_event(event: &PodEvent) -> String {
fn format_worker_event(event: &WorkerEvent) -> String {
match event {
PodEvent::TurnEnded { pod_name } => {
format!("[pod_event] {pod_name} → turn_ended")
WorkerEvent::TurnEnded { worker_name } => {
format!("[worker_event] {worker_name} → turn_ended")
}
PodEvent::Errored { pod_name, message } => {
format!("[pod_event] {pod_name} → errored: {message}")
WorkerEvent::Errored {
worker_name,
message,
} => {
format!("[worker_event] {worker_name} → errored: {message}")
}
PodEvent::ShutDown { pod_name } => {
format!("[pod_event] {pod_name} → shut_down")
WorkerEvent::ShutDown { worker_name } => {
format!("[worker_event] {worker_name} → shut_down")
}
PodEvent::ScopeSubDelegated {
parent_pod,
sub_pod,
WorkerEvent::ScopeSubDelegated {
parent_worker,
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 {
use super::*;
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use protocol::PodStatus;
use protocol::WorkerStatus;
use std::time::{Duration, Instant};
#[test]
fn queue_status_text_includes_count_and_preview() {
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() {
app.insert_char(c);
}
@ -1952,7 +1955,7 @@ mod tests {
app.latest_llm_wait_event = Some("retrying LLM request".into());
app.latest_memory_worker_event = Some("memory extract running".into());
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,
ActionbarNoticeSource::Tui,
now,
@ -1961,10 +1964,10 @@ mod tests {
assert_eq!(
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() {
app.insert_char(c);
}
@ -2037,7 +2040,7 @@ mod tests {
#[test]
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.blocks = vec![finished_thinking("alpha"), finished_thinking("beta")];
@ -2055,7 +2058,7 @@ mod tests {
#[test]
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.blocks = vec![
finished_thinking("alpha line 1\nalpha line 2"),
@ -2074,7 +2077,7 @@ mod tests {
#[test]
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.blocks = vec![
finished_thinking("alpha"),
@ -2097,7 +2100,7 @@ mod tests {
#[test]
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.blocks = vec![
Block::TurnHeader { turn: 1 },
@ -2119,7 +2122,7 @@ mod tests {
#[test]
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.blocks = vec![
finished_thinking("finished"),
@ -2141,7 +2144,7 @@ mod tests {
#[test]
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.blocks = vec![Block::Thinking(ThinkingBlock {
text: "private reasoning".to_string(),
@ -2159,7 +2162,7 @@ mod tests {
fn single_tool_block_rendering_stays_unchanged() {
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.blocks = vec![Block::ToolCall(ToolCallBlock {
id: "bash-1".to_string(),
@ -2183,7 +2186,7 @@ mod tests {
fn read_tool_aggregation_still_consumes_consecutive_tool_blocks() {
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.blocks = vec![
Block::ToolCall(ToolCallBlock {
@ -2225,7 +2228,7 @@ mod tests {
#[test]
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![
Block::UserMessage {
segments: vec![Segment::Text {

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
[package]
name = "pod"
name = "worker"
version = "0.1.0"
edition.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
//! present in `resources/prompts/internal.toml`. The generated slice is
//! included into `src/prompts.rs` where a `const _` assertion compares
//! it bidirectionally against the `PodPrompt` enum's own key list, so
//! that a mismatch fails the build (see ticket: pod-prompt-catalog).
//! it bidirectionally against the `WorkerPrompt` enum's own key list, so
//! that a mismatch fails the build (see ticket: worker-prompt-catalog).
use std::env;
use std::fs;
use std::path::PathBuf;

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