merge: embedded worker execution

This commit is contained in:
Keisuke Hirata 2026-06-28 16:47:39 +09:00
commit e7d56bba73
No known key found for this signature in database
29 changed files with 4627 additions and 279 deletions

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260627-190733-1","ticket_id":"00001KW55B32Y","kind":"accepted_plan","note":"Dashboard Queue accepted. No outgoing blockers for this boundary Ticket; downstream queued Tickets depend on it. Orchestration worktree clean and no inprogress work.","accepted_plan":{"summary":"`worker-runtime` に Worker execution backend の trait/handle/enum と lifecycle/input/protocol event publish contract を追加する。具体的な `worker` crate adapter は後続 Ticket に残し、execution backend 未接続 Worker が input accepted にならない型・状態・テスト境界を作る。","branch":"work/00001KW55B32Y-worker-runtime-execution-backend","worktree":"/home/hare/Projects/yoi/.worktree/00001KW55B32Y-worker-runtime-execution-backend","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/worker-runtime` と必要な Backend projection support (`crates/workspace-server`)、Cargo/package files の write scope を委譲する。reviewer Worker は read-only で execution backend 境界、input rejection/dispatch contract、protocol event hook、Browser-facing non-leak、fake/providerless response absence を確認する。merge/validation/done/cleanup は Orchestrator が行う。"},"author":"yoi-orchestrator","at":"2026-06-27T19:07:33Z"}

View File

@ -1,8 +1,8 @@
---
title: 'worker-runtimeにWorker実行Backend境界を追加する'
state: 'queued'
state: 'closed'
created_at: '2026-06-27T18:26:46Z'
updated_at: '2026-06-27T19:06:28Z'
updated_at: '2026-06-27T20:09:45Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-27T19:06:28Z'

View File

@ -0,0 +1,28 @@
`worker-runtime` に Worker execution backend 境界を追加し、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `WorkerExecutionBackend` trait / handle / context / status / result / operation / outcome を追加。
- backend 未接続 Worker への input を typed `WorkerExecutionUnavailable` として拒否。
- backend dispatch の busy / rejected / errored / unsupported を typed rejection として扱う。
- backend から Runtime observation bus へ `protocol::Event` を publish する hook を追加。
- stop / cancel unsupported の typed rejection を追加。
- `WorkerSummary` / `WorkerDetail` に raw handle/path/credential を含まない execution status projection を追加。
- Runtime が fake/providerless assistant response を生成しない境界を維持。
- `can_stream_events` / `can_read_bounded_transcript` public capability は復活させていない。
- `ws-server + fs-store` restore path で observation bus state を正しく初期化。
Integrated commits:
- `2d5971738478f832ba9a135601ea11dda60c565d feat: add worker execution backend boundary`
- `761b60c85750d03c119733a088fb5073f9b37e9a fix: initialize restored worker observations`
- merge: `0753e155 merge: worker runtime execution backend`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Cleanup は child implementation worktree / branch と related role Pods のみを対象に実施する。

View File

@ -30,4 +30,408 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-27T19:07:57Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Dashboard Queue により routing 許可済み。
- 本 Ticket は後続 adapter / Companion 実行 Ticket の前段となる execution backend boundary であり、具体的な `worker` crate adapter 実装は Non-goal として分離されている。
- Outgoing blocking relation はなく、current `inprogress` は 0。orchestration worktree is clean。
- Downstream queued Tickets (`00001KW55B33B`, `00001KW55B33H`) は本 Ticket 完了まで待機させる。
Evidence checked:
- Ticket body: execution backend boundary、input rejection/dispatch、protocol event publish hook、Worker model invariant、Browser-facing non-leak、Non-goals、acceptance criteria。
- Relations: incoming/downstream dependency chain is present via queued Tickets; this Ticket itself has no unresolved outgoing blocker。
- Orchestration plan: accepted plan `orch-plan-20260627-190733-1` recorded。
- Code context: current `worker-runtime::Runtime` / `catalog` / `workspace-server::hosts` have Runtime/Worker model and projection foundation but no concrete execution backend boundary yet。
- Workspace state: queued 3 / inprogress 0、orchestration clean。
IntentPacket:
Intent:
- `worker-runtime` に Worker execution backend 境界を追加し、Runtime が input-capable Worker と backend-unconnected placeholder を混同しないようにする。
Binding decisions / invariants:
- Execution backend 未接続 Worker への user input は accepted にしない。
- Runtime 自身が fake / providerless assistant response を生成しない。
- Worker transcript / observation は正規 contract とし、`can_stream_events` / `can_read_bounded_transcript` を public capability として復活させない。
- Execution backend handle / socket path / credential / session path / raw worker handle は Browser-facing API に出さない。
- 既存 `worker` crate への具体 adapter 接続、Workspace Companion real LLM execution、Web Console UX redesign は実装しない。
Requirements / acceptance criteria:
- `worker-runtime` に execution backend trait / handle / enum / lifecycle contract がある。
- backend connected Worker の input dispatch 境界と run state/busy/rejected/errored typed result がある。
- stop/cancel unsupported は typed rejection。
- protocol event を Runtime observation bus へ publish する hook がある。
- Focused tests が backend 未接続 input rejection、connected backend input dispatch boundary、observation publish hook を確認する。
- `cargo test -p worker-runtime --features ws-server`, `cargo test -p yoi-workspace-server`, `cargo check -p yoi`, `git diff --check`, `nix build .#yoi --no-link` が通る。
Implementation latitude:
- Backend boundary の具体型名、trait sync/asyncの形、test backend/fake backend の実装、Runtime state/projectionの最小変更は Coder が既存 `worker-runtime` design に合わせて選べる。
- `workspace-server` projection に必要な最小 diagnostic/state 表現は追加してよい。
- 既存 `worker` crate adapter に必要そうな shape は見越してよいが、実 adapter 実装はしない。
Escalate if:
- `worker` crate に大きく手を入れないと boundary 自体が定義できない。
- Browser-facing API に raw execution handle/path/credential を出す必要が出る。
- public capability として `can_stream_events` / `can_read_bounded_transcript` を戻さないと UI/API が成立しない。
- fake/providerless response を再導入しないと tests/UX が通らない。
Validation:
- `cargo fmt --all`
- `cargo test -p worker-runtime --features ws-server`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `git diff --check`
- 可能なら `nix build .#yoi --no-link`
Critical risks / reviewer focus:
- backend 未接続 Worker が input accepted になる regression。
- Runtime-generated fake/providerless assistant response の再導入。
- Browser-facing projection に execution backend internals/path/credential/session が漏れること。
- Worker transcript/observation を optional display capability として扱うこと。
- 後続 adapter Ticket の scope を先取りしすぎること。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-27T19:08:06Z from: queued to: inprogress reason: routing_accepted_execution_backend_boundary field: state -->
## State changed
Routing decision: implementation_ready。
Ticket body / relation / workspace state / accepted plan を確認し、Worker execution backend boundary slice は unblocked と判断した。Downstream queued Tickets は本 Ticket の completion を待つ。accepted plan と IntentPacket は thread / orchestration plan に記録済み。
これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-27T19:09:41Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KW55B32Y-worker-runtime-execution-backend`
- Branch: `work/00001KW55B32Y-worker-runtime-execution-backend`
- Coder Worker spawned: `yoi-coder-00001KW55B32Y-exec-backend`
Scope notes:
- Coder has read access to the implementation worktree and non-recursive workspace root identity path needed by runtime startup.
- Coder write scope is limited to `crates/worker-runtime/**`, `crates/workspace-server/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if dependency/package changes are needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Queued dependency notes:
- `00001KW55B33B` remains queued while this execution backend boundary is inprogress.
- `00001KW55B33H` remains queued behind `00001KW55B33B`.
---
<!-- event: implementation_report author: yoi-coder-00001KW55B32Y-exec-backend at: 2026-06-27T19:34:20Z -->
## Implementation report
Implementation report:
実装完了。マージ / close / cleanup は未実施。
Commit:
- `2d5971738478f832ba9a135601ea11dda60c565d feat: add worker execution backend boundary`
変更概要:
### `worker-runtime`
追加/変更:
- `crates/worker-runtime/src/execution.rs` を追加。
- `WorkerExecutionBackend` trait
- `WorkerExecutionHandle`
- `WorkerExecutionContext`
- `WorkerExecutionStatus`
- `WorkerExecutionRunState`
- `WorkerExecutionResult`
- `WorkerExecutionOperation`
- `WorkerExecutionOutcome`
- `WorkerExecutionSpawnRequest`
- `WorkerExecutionSpawnResult`
- `Runtime::with_execution_backend(...)` を追加。
- `Runtime::with_fs_store_and_execution_backend(...)` を追加。
- `WorkerSummary` / `WorkerDetail``execution` status を追加。
- `Runtime::create_worker(...)`
- backend 接続時は spawn/initialize 境界を呼ぶ。
- backend 未接続時は `execution.backend = unconnected`
- `Runtime::send_input(...)`
- backend 未接続 Worker への input を `WorkerExecutionUnavailable` で拒否。
- backend dispatch が `Busy` / `Rejected` / `Errored` / `Unsupported` を返した場合、typed `WorkerExecutionRejected` として拒否。
- dispatch accept 後のみ transcript / event / observation を通常 Worker contract として記録。
- `Runtime::stop_worker(...)` / `cancel_worker(...)`
- active かつ connected handle ありの場合は backend lifecycle 境界を呼ぶ。
- default backend implementation は unsupported typed rejection。
- backend から Runtime observation bus へ `protocol::Event` を publish できる hook を追加。
- HTTP API error mapping に execution unavailable/rejected を追加。
### `workspace-server`
- Embedded Worker input unavailable diagnostic を LLM 固有表現から execution backend 境界表現へ変更。
- `embedded_worker_execution_unavailable`
- RuntimeRegistry / Backend projection のテストで、Browser-facing JSON に以下が出ないことを確認。
- `can_stream_events`
- `can_read_bounded_transcript`
- raw URL/path/socket/session/credential/provider 系の既存禁止項目。
Validation:
- `cargo fmt --all`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risks / review focus:
- concrete `worker` crate adapter は意図通り未実装。下流 Ticket `00001KW55B33B` 側で今回の `WorkerExecutionBackend` trait に接続する想定。
- Embedded Runtime は現時点では backend-unconnected placeholder のままなので、workspace-server 側では input unavailable diagnostic を返す。
- backend trait は sync 境界として実装。実 adapter 実装時に async/process lifecycle、busy state、cancel semantics のレビューが必要。
- `WorkerSummary` / `WorkerDetail` の public JSON に `execution` が増えるが、handle/path/socket/credential/session/manifest など raw internals は含めていない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-27T19:36:13Z -->
## Implementation report
Review kickoff:
Reviewer Worker spawned for `00001KW55B32Y` implementation.
- Reviewer Worker: `yoi-reviewer-00001KW55B32Y-exec-backend`
- Review target commit: `2d5971738478f832ba9a135601ea11dda60c565d feat: add worker execution backend boundary`
- Review mode: read-only implementation worktree review
Review focus:
- backend-unconnected input rejection and no transcript/event acceptance
- connected fake backend input dispatch boundary
- protocol event publish hook correctness
- stop/cancel unsupported typed rejection
- no fake/providerless assistant response
- no `can_stream_events` / `can_read_bounded_transcript` public capability regression
- no raw execution handle/path/socket/session/credential leakage
- no scope creep into concrete `worker` crate adapter or Companion real LLM execution
---
<!-- event: review author: yoi-reviewer-00001KW55B32Y-exec-backend at: 2026-06-27T19:41:12Z status: request_changes -->
## Review: request changes
request_changes
ブロッカー:
- `worker-runtime` の既存 feature 組み合わせ `ws-server + fs-store` がコンパイル不能。今回追加された observation bus 用フィールドが `RuntimeState::from_persisted` で初期化されていない。
- 実行: `CARGO_TARGET_DIR=/tmp/yoi-review-target-00001KW55B32Y cargo check -p worker-runtime --features "ws-server fs-store"`
- 失敗: `crates/worker-runtime/src/runtime.rs:925`
- エラー: missing fields `next_observation_sequence`, `observation_events`, `observation_tx` in initializer of `RuntimeState`
- 既存の fs-store runtime path と ws observation path の組み合わせを壊す regression なので修正が必要。
確認した範囲では、Ticket の主眼自体は概ね実装されている:
- unconnected Worker input は typed `WorkerExecutionUnavailable` で拒否され、user transcript / observation event は追加されない。
- connected fake backend による input dispatch test がある。
- `protocol::Event` を observation bus に publish する hook と test がある。
- stop/cancel unsupported は typed rejection になる。
- fake/providerless/canned assistant response 生成は見当たらない。
- `can_stream_events` / `can_read_bounded_transcript` の public capability 復活は見当たらない。
- Browser-facing projection で raw backend handle/socket/session/credential/path を漏らす変更は見当たらない。
- concrete `worker` crate adapter / Companion real LLM execution への scope creep は見当たらない。
追加検証:
- `git diff --check HEAD~1..HEAD`: pass
- 作業ツリーは review 後も clean。
---
<!-- event: implementation_report author: yoi-coder-00001KW55B32Y-exec-backend at: 2026-06-27T19:46:45Z -->
## Implementation report
Change-response implementation report:
Reviewer blocker を修正済み。マージ / close / cleanup は未実施。
Fix commit:
- `761b60c85750d03c119733a088fb5073f9b37e9a fix: initialize restored worker observations`
修正内容:
`RuntimeState::from_persisted` の fs-store restore path で、`ws-server` feature 有効時の observation bus 状態を初期化するようにした。
追加初期化:
- `next_observation_sequence: 1`
- `observation_events: VecDeque::new()`
- `observation_tx: broadcast::channel(256).0`
また、`fs_store_restores_workers_events_and_transcripts` に `ws-server` 有効時の focused assertion を追加し、restore 後の Runtime でも `observe_worker_event` / `read_worker_observation_events` が動くことを確認した。
Validation:
- `CARGO_TARGET_DIR=/tmp/yoi-review-target-00001KW55B32Y cargo check -p worker-runtime --features "ws-server fs-store"`: success
- `cargo fmt --all`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risk / review focus:
- observation events 自体は fs-store 永続化対象ではないため、restore 後の observation backlog は空から開始する。これは既存の observation bus/backlog の揮発的性質に沿った修正。
- 今回は compile blocker の修正に限定し、backend adapter や永続 observation replay の設計拡張は行っていない。
---
<!-- event: review author: yoi-reviewer-00001KW55B32Y-exec-backend at: 2026-06-27T19:48:30Z status: approve -->
## Review: approve
approve
確認結果:
- 先回ブロッカーだった `ws-server + fs-store``RuntimeState::from_persisted` 初期化漏れは修正済み。
- `next_observation_sequence`
- `observation_events`
- `observation_tx`
が restore path で初期化されていることを確認。
- 追加 test で restored Runtime の `observe_worker_event` / `read_worker_observation_events` が検証されていることを確認。
- 手元検証:
- `CARGO_TARGET_DIR=/tmp/yoi-review-target-00001KW55B32Y cargo check -p worker-runtime --features "ws-server fs-store"`: pass
- `CARGO_TARGET_DIR=/tmp/yoi-review-target-00001KW55B32Y cargo test -p worker-runtime --features "ws-server fs-store"`: pass
- original Ticket invariants について、前回確認済みの内容から regression は見当たらない。
- backend-unconnected input rejection / transcript 非追加
- fake/providerless assistant response なし
- fake backend dispatch boundary
- protocol event publish hook
- stop/cancel unsupported typed rejection
- `can_stream_events` / `can_read_bounded_transcript` public capability 復活なし
- Browser-facing projection で raw backend internals/path/credential leak なし
- concrete `worker` crate adapter / Companion real LLM execution への scope creep なし
作業ツリーも clean のまま。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-27T19:49:30Z -->
## Implementation report
Merge / validation report:
Implementation branch was integrated into the orchestration branch after reviewer approval.
Integrated commits:
- `2d5971738478f832ba9a135601ea11dda60c565d feat: add worker execution backend boundary`
- `761b60c85750d03c119733a088fb5073f9b37e9a fix: initialize restored worker observations`
Merge commit:
- `0753e155 merge: worker runtime execution backend`
Validation in orchestration worktree:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Outcome:
- `worker-runtime` now has an explicit Worker execution backend boundary.
- Backend-unconnected Workers reject input rather than accepting it or producing fake/providerless assistant responses.
- Connected backend dispatch and observation publish hooks are covered by focused tests.
- `ws-server + fs-store` restore path initializes observation bus state and is validated.
- Browser-facing projections continue not to expose raw backend handle/path/socket/session/credential internals or legacy public `can_stream_events` / `can_read_bounded_transcript` capabilities.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-27T19:49:36Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval後に implementation branch を orchestration branch へ merge し、要求された validation が成功したため done に遷移する。
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
---
<!-- event: state_changed author: hare at: 2026-06-27T19:49:46Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-27T19:49:46Z status: closed -->
## 完了
`worker-runtime` に Worker execution backend 境界を追加し、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `WorkerExecutionBackend` trait / handle / context / status / result / operation / outcome を追加。
- backend 未接続 Worker への input を typed `WorkerExecutionUnavailable` として拒否。
- backend dispatch の busy / rejected / errored / unsupported を typed rejection として扱う。
- backend から Runtime observation bus へ `protocol::Event` を publish する hook を追加。
- stop / cancel unsupported の typed rejection を追加。
- `WorkerSummary` / `WorkerDetail` に raw handle/path/credential を含まない execution status projection を追加。
- Runtime が fake/providerless assistant response を生成しない境界を維持。
- `can_stream_events` / `can_read_bounded_transcript` public capability は復活させていない。
- `ws-server + fs-store` restore path で observation bus state を正しく初期化。
Integrated commits:
- `2d5971738478f832ba9a135601ea11dda60c565d feat: add worker execution backend boundary`
- `761b60c85750d03c119733a088fb5073f9b37e9a fix: initialize restored worker observations`
- merge: `0753e155 merge: worker runtime execution backend`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Cleanup は child implementation worktree / branch と related role Pods のみを対象に実施する。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-27T20:09:45Z -->
## Implementation report
Cleanup report:
- `StopPod` は並列呼び出しでハングし得る不具合があるとの指摘を受けたため、今後は必ず 1 回ずつ実行する運用にする。
- 今回の role Pods は `StopPod` 実行時点で spawned child registry から既に見えず、`ListPods` でも spawned child は 0 件だったため、追加の concurrent stop retry は行っていない。
- Child implementation worktree を削除済み:
- `/home/hare/Projects/yoi/.worktree/00001KW55B32Y-worker-runtime-execution-backend`
- Child implementation branch を削除済み:
- `work/00001KW55B32Y-worker-runtime-execution-backend`
Orchestration worktree は clean。
---

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260627-190816-1","ticket_id":"00001KW55B33B","kind":"blocked_by","related_ticket":"00001KW55B32Y","note":"Queue routing checked. Embedded `worker` crate execution connection depends on execution backend boundary `00001KW55B32Y`, which has just been accepted and is now inprogress. Leave queued until backend boundary is reviewed/merged/done.","author":"yoi-orchestrator","at":"2026-06-27T19:08:16Z"}
{"id":"orch-plan-20260627-201131-2","ticket_id":"00001KW55B33B","kind":"accepted_plan","note":"Queue continuation requested by user. Dependency `00001KW55B32Y` is closed and validation passed; `00001KW55B33B` has no remaining blockers. `00001KW55B33H` remains queued because it depends on this Ticket.","accepted_plan":{"summary":"前段 `00001KW55B32Y` が closed になったため、embedded Runtime の execution backend 境界に既存 `worker` crate 実行 adapter を接続する。循環依存を避ける adapter placement を選び、理由を Ticket thread に記録し、input を実 Worker run に渡し、`protocol::Event` を Runtime observation bus / Backend WS へ bridge する。config/provider 不足時は typed diagnostic として拒否し、fake/providerless response は再導入しない。","branch":"work/00001KW55B33B-worker-runtime-worker-adapter","worktree":"/home/hare/Projects/yoi/.worktree/00001KW55B33B-worker-runtime-worker-adapter","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/worker-runtime`, `crates/worker`, `crates/workspace-server`, 必要な workspace Cargo/package files の write scope を委譲する。reviewer Worker は read-only で adapter placement/dependency boundary、Profile/config/authority resolution、input/run lifecycle、protocol event bridge、Browser-facing non-leak、fake/providerless response absence を確認する。merge/validation/done/cleanup は Orchestrator が行う。`StopPod` は cleanup 時に必ず 1 回ずつ直列実行する。"},"author":"yoi-orchestrator","at":"2026-06-27T20:11:31Z"}

View File

@ -1,8 +1,8 @@
---
title: 'embedded worker-runtimeをworker crate実行に接続する'
state: 'queued'
state: 'closed'
created_at: '2026-06-27T18:26:46Z'
updated_at: '2026-06-27T19:06:30Z'
updated_at: '2026-06-28T06:02:16Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-27T19:06:30Z'

View File

@ -0,0 +1,35 @@
embedded `worker-runtime` を既存 `worker` crate の実行 lifecycle に接続する adapter を実装し、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `worker` crate に `runtime-adapter` feature と `WorkerRuntimeExecutionBackend` adapter を追加。
- `worker-runtime``worker` に依存しない下位境界のまま維持し、crate dependency cycle を回避。
- embedded Runtime 作成時に `workspace-server` が adapter を install。
- Backend Worker input API から embedded Runtime execution backend に委譲し、既存 Worker run lifecycle (`Method::Run`) に接続。
- Worker の `protocol::Event` を Runtime observation bus / transcript projection に bridge。
- builtin profile selector double-prefix を修正。
- run_state/status projection を実 Worker lifecycle event に合わせて更新。
- spawn execution failure は Browser-facing API で sanitized `Rejected` diagnostic として扱い、failed/unconnected 状態を input-capable に見せない。
- fake/providerless/canned assistant response は導入していない。
- Browser-facing API に raw worker handle / execution handle / socket / session path / credential / secret ref / raw manifest path を出していない。
- `can_stream_events` / `can_read_bounded_transcript` public capability は復活させていない。
Integrated commits:
- `18526ee36264610048f48b07b5db50ce86852fd2 feat: connect runtime worker execution adapter`
- `9069b035041d17e7c52a454a7563cc5f0b7e1f61 fix: connect embedded runtime input lifecycle`
- `7e29ff5ec99dcc748fa5a511cda5bae31fec124b fix: reject embedded spawn execution failures`
- merge: `c3ed223d merge: worker runtime worker adapter`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker --features runtime-adapter runtime_adapter`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Known non-blocking warning:
- `cargo test -p yoi-workspace-server` reports existing warning `field next_sequence is never read` in `crates/workspace-server/src/companion.rs`.
Cleanup は child implementation worktree / branch と related role Pods のみを対象に実施する。

View File

@ -30,4 +30,582 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-27T19:08:35Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Dashboard Queue 後に Ticket / relations / orchestration plan / workspace state を確認した。
- 本 Ticket は `00001KW55B32Y` (`worker-runtimeにWorker実行Backend境界を追加する`) に `depends_on` relation を持つ。
- `00001KW55B32Y` は本 routing pass で accepted され `inprogress` になった。
- Adapter は execution backend boundary に接続する必要があるため、boundary の shape が review/merge/done になる前に開始しない。
Evidence checked:
- Ticket body: adapter placement/dependency boundary、Profile/config/authority resolution、input/run lifecycle、protocol event bridge、Non-goals。
- Relations: outgoing `depends_on -> 00001KW55B32Y`; incoming dependent `00001KW55B33H`
- Orchestration plan: blocker record `orch-plan-20260627-190816-1` を追加。
- Workspace state: `00001KW55B32Y` is inprogress; current worktree clean before implementation side effects.
Next action:
- 本 Ticket は queued のまま待機。
- `00001KW55B32Y` が reviewer approve / merge / validation / done になった後に再 routing する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-27T20:11:40Z -->
## Decision
Routing decision:
Queue continuation was explicitly requested. The upstream boundary Ticket `00001KW55B32Y` is closed after merge and validation, so this Ticket is now unblocked and accepted for implementation.
Plan:
- Create child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KW55B33B-worker-runtime-worker-adapter`
- Branch:
- `work/00001KW55B33B-worker-runtime-worker-adapter`
- Route implementation to a sibling Coder Worker with focused write scope.
- Route read-only review to a sibling Reviewer Worker after implementation.
- Orchestrator retains merge, final validation, Ticket completion, child worktree/branch cleanup, and role Pod shutdown.
Review/implementation focus:
- choose adapter placement without introducing crate dependency cycles, and record the rationale
- connect embedded `worker-runtime` execution backend to existing `worker` crate execution
- preserve Profile/config bundle and authority resolution boundaries
- dispatch input to real Worker run lifecycle, not providerless/fake responses
- bridge `protocol::Event` to Runtime observation bus / Backend WS
- return typed diagnostics/rejections for missing config/provider/busy/unsupported operations
- do not expose raw handles, sockets, credentials, session paths, or manifest paths to Browser-facing APIs
- leave `00001KW55B33H` queued until this adapter is merged/validated
Operational note:
- `StopPod` must be called serially during cleanup; do not use parallel StopPod calls.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-27T20:11:44Z from: queued to: inprogress reason: dependency_closed_and_queue_continues field: state -->
## State changed
Queue continuation requested by user. Upstream dependency `00001KW55B32Y` is closed with merge and validation complete, and this Ticket has no remaining blockers. Accepting implementation before child worktree / role Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-27T20:12:31Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KW55B33B-worker-runtime-worker-adapter`
- Branch: `work/00001KW55B33B-worker-runtime-worker-adapter`
- Coder Worker spawned: `yoi-coder-00001KW55B33B-worker-adapter`
Scope notes:
- Coder write scope is limited to `crates/worker-runtime/**`, `crates/worker/**`, `crates/workspace-server/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if dependency/package changes are needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Implementation focus:
- adapter placement without crate dependency cycles, with rationale reported back
- connect `worker-runtime` execution backend boundary to existing `worker` crate run lifecycle
- bridge existing `protocol::Event` to Runtime observation bus / Backend WS
- preserve typed diagnostics for missing config/provider/secret and busy/unsupported operations
- avoid fake/providerless responses and Browser-facing raw handle/path/credential/session leaks
- leave downstream Workspace Companion real execution Ticket `00001KW55B33H` queued until this adapter is merged/validated
---
<!-- event: implementation_report author: yoi-coder-00001KW55B33B-worker-adapter at: 2026-06-27T20:51:14Z -->
## Implementation report
Implementation report:
実装完了。マージ / close / cleanup は未実施。
Commit:
- `18526ee36264610048f48b07b5db50ce86852fd2 feat: connect runtime worker execution adapter`
Adapter 配置方針:
- `worker` crate 側に `runtime-adapter` feature 付きで配置。
- 理由:
- `worker-runtime` は Runtime 境界 / 投影 / Backend trait を持つ下位境界として保ち、実 Worker 実装への依存を持たせない。
- `worker` crate は既存の `WorkerController` / `WorkerHandle` / `Method::Run` lifecycle を直接扱えるため、fake/canned 応答を挟まずに adapter を組める。
- `workspace-server``worker = { features = ["runtime-adapter"] }` を有効化して embedded Runtime に接続する形にし、crate dependency cycle を避けた。
変更概要:
### `worker` crate
- `crates/worker/src/runtime_adapter.rs` を追加。
- `WorkerRuntimeExecutionBackend` を追加し、`worker_runtime::execution::WorkerExecutionBackend` を実装。
- Runtime worker 作成時に `WorkerController` を spawn し、内部にのみ real `WorkerHandle` を保持。
- Runtime input は `Method::Run { input: vec![Segment::text(...)] }` へ変換して既存 Worker run lifecycle に投入。
- Worker の `protocol::Event` broadcast を購読し、`WorkerExecutionContext::publish_protocol_event` 経由で Runtime observation bus へ橋渡し。
- Provider/profile/config 不足などは spawn/create 時の typed error/rejection として返し、fake assistant text は生成しない。
- v0 double-run policy として queue は持たず、adapter 側 atomic busy flag + Worker status で二重 input を typed busy rejection。
- stop/cancel は既存 `Method::Shutdown` / `Method::Cancel` へ接続。
- Focused test を追加し、fake deterministic LLM client で Runtime input -> real Worker run lifecycle -> assistant output -> `protocol::Event` -> Runtime transcript projection の流れを検証。
### `worker-runtime`
- `observe_worker_event` で受けた real `protocol::Event` から transcript projection を更新。
- `TextDone` -> Assistant transcript
- `Error` -> System transcript
- `ToolResult` -> System transcript
- observation bus には既存 `protocol::Event` をそのまま保持し、subset output model は追加していない。
### `workspace-server`
- embedded Runtime 作成時に `WorkerRuntimeExecutionBackend` を接続。
- `EmbeddedWorkerRuntime::new_memory_with_execution_backend` を追加。
- Browser/API-facing projection に raw `worker::Worker` handle / raw execution handle / socket path / session path / credential / secret ref / manifest path は出していない。
### package/dependency
- `worker` に optional `worker-runtime` dependency + `runtime-adapter` feature を追加。
- `workspace-server``worker``runtime-adapter` feature を有効化。
- `Cargo.lock``package.nix``cargoHash` を更新。
Validation:
- `cargo fmt --all`: success
- `cargo fmt --all --check`: success
- `cargo test -p worker --features runtime-adapter runtime_adapter`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Residual risks / review focus:
- Runtime worker profile 解決は production adapter が通常の Worker profile 解決を使う。workspace-server 起動直後に fake 応答へ fallback せず、profile/provider/config 不足は typed error になる。この挙動が downstream `00001KW55B33H` の Companion UX 設計と合っているか確認が必要。
- Transcript projection は現時点で `TextDone` / `Error` / `ToolResult` を投影。より細かい tool-call lifecycle や thinking block の Browser projection が必要なら downstream で拡張対象。
- Adapter は v0 non-queue policy。二重 input は busy rejection で、background queue/scheduler は実装していない。
- `workspace-server``worker` を feature 付きで依存するため、crate layering と public API leak が意図通りかを重点 review してほしい。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-27T20:51:43Z -->
## Implementation report
Review kickoff:
Reviewer Worker spawned for `00001KW55B33B` implementation.
- Reviewer Worker: `yoi-reviewer-00001KW55B33B-worker-adapter`
- Review target commit: `18526ee36264610048f48b07b5db50ce86852fd2 feat: connect runtime worker execution adapter`
- Review mode: read-only implementation worktree review
Review focus:
- crate layering / `runtime-adapter` feature dependency cycle risk
- production Profile/config/authority resolution and typed failure mode
- protocol event bridge into Runtime observation bus / Backend WS
- transcript projection consistency for real Worker run output
- no fake/providerless fallback
- busy / stop / cancel lifecycle semantics
- Browser-facing non-leak of worker handles, raw execution handles, sockets, session paths, credentials, secret refs, and raw manifest paths
- no downstream Workspace Companion bootstrap/UX scope creep
---
<!-- event: review author: yoi-reviewer-00001KW55B33B-worker-adapter at: 2026-06-27T20:58:06Z status: request_changes -->
## Review: request changes
request_changes
読取専用で差分・主要実装を確認した。crate layering 自体は概ね意図通りで、`worker` crate 側の `runtime-adapter` feature に adapter を置き、`worker-runtime` が `worker` に依存しない形は保たれている。また fake/providerless の canned response は見当たらない。
ただし、Ticket 受け入れ条件に対して以下が blocker。
## Blockers
1. **workspace-server の embedded Worker input API がまだ adapter に接続されていない**
- `workspace-server/src/server.rs:101-113` で adapter は embedded Runtime に install されている。
- しかし browser/backend registry 側の `EmbeddedWorkerRuntime::send_input``hosts.rs:1275-1285` で固定 reject のまま。
- projection も `hosts.rs:1008-1010``can_accept_input: false`, `can_stop: false` のまま。
- そのため Ticket 要件の「Backend Worker input API からの user input を `Method::Run` 相当に渡す」「Runtime / Backend / Web Console が正規経路を共有する」「embedded Worker への user input が実 Worker run に渡る」を満たしていない。
2. **production profile resolution に builtin 二重 prefix の不整合がある**
- workspace-server の Ticket role mapping は `hosts.rs:1920-1924``ProfileSelector::Builtin("builtin:coder")` のように値へ `builtin:` を含めている。
- adapter 側は `worker/src/runtime_adapter.rs:133-140``Builtin(name)` を常に `format!("builtin:{name}")` しているため、内部的に `builtin:builtin:coder` になる。
- `manifest::ProfileSelector::parse_cli``builtin:builtin:coder` を source=`builtin`, name=`builtin:coder` と解釈するため、通常の builtin `coder` 解決に失敗する。Ticket role Worker の実 spawn path で破綻する可能性が高く、Profile/config resolution の受け入れ条件を満たさない。
3. **run_state/status projection が実 run lifecycle と整合していない**
- `send_method` は input の `Method::Run` 送信成功時も `WorkerExecutionRunState::Idle` を返している(`worker/src/runtime_adapter.rs:300-313`, `445-455`)。
- Runtime 側はその `Idle` を input accepted 後の execution status として保存する(`worker-runtime/src/runtime.rs:369-373`)。
- bridge は `protocol::Event` を observation bus に流しているが、Worker の running/completed/errored/cancelled status を Runtime execution status に反映していない。
- Ticket 要件の「run started / completed / errored / cancelled を Runtime Worker status と transcript projection に反映」「busy/double-run lifecycle semantics」の観点で不足。
## 確認済み
- `worker-runtime` -> `worker` の依存逆流は見当たらず、adapter placement の方向性は良い。
- `protocol::Event` bridge は `publish_protocol_event` を使って observation bus に流す実装になっている。
- `can_stream_events` / `can_read_bounded_transcript` の public flag 復活は見当たらない。
- downstream `00001KW55B33H` の Companion UX 完成まで踏み込む scope creep は見当たらない。
- Ticket 更新・merge・cleanup・ファイル変更・テスト実行はしていない。Static review のみ。
---
<!-- event: implementation_report author: yoi-coder-00001KW55B33B-worker-adapter at: 2026-06-27T21:22:01Z -->
## Implementation report
Change-response implementation report:
Reviewer blockers を修正済み。マージ / close / cleanup は未実施。
Fix commit:
- `9069b035e0a93f83c702e206d3d15b2b683a8c19 fix: connect embedded runtime input lifecycle`
既存実装 commit:
- `18526ee36264610048f48b07b5db50ce86852fd2 feat: connect runtime worker execution adapter`
## 修正内容
### 1. workspace-server embedded Worker input API を adapter に接続
`EmbeddedWorkerRuntime::send_input` の固定 reject を撤去し、execution backend 接続済みの場合は `worker_runtime::Runtime::send_input` に委譲するようにした。
あわせて:
- `EmbeddedWorkerRuntime``execution_enabled` を追加。
- `new_memory_with_execution_backend(...)` で backend 接続済み runtime として扱う。
- Worker projection の capability を backend 接続状態に合わせて更新:
- `can_accept_input`
- `can_stop`
- `stop_worker` / `cancel_worker` も embedded Runtime lifecycle API に委譲。
- `workspace-server` 側の focused test を追加し、fake execution backend で embedded Runtime spawn / workspace-server host API 経由 input / backend dispatch / `protocol::Event` / transcript projection / capability/status projection を検証。
### 2. builtin profile selector double-prefix bug 修正
`worker/src/runtime_adapter.rs``ProfileSelector::Builtin` 変換を修正。
- `Builtin("coder")` -> `builtin:coder`
- `Builtin("builtin:coder")` -> `builtin:coder`
`builtin:builtin:coder` にならないことを focused unit test で追加確認。
### 3. run_state / status projection を real run lifecycle に合わせて修正
`send_method` が常に `Idle` を返していた点を修正。
- `Method::Run` accepted -> `WorkerExecutionRunState::Busy`
- `Method::Shutdown` accepted -> `Stopped`
- `Method::Cancel` accepted -> `Idle`
さらに `worker-runtime` 側で bridged `protocol::Event` を execution status に投影するようにした。
対応イベント:
- `Status { Running }` -> `Busy`
- `Status { Idle }` -> `Idle`
- `Status { Paused }` -> `Busy`
- `Snapshot { status }` -> status に応じて `Busy` / `Idle`
- `RunEnd { Finished | RolledBack }` -> `Idle`
- `RunEnd { Paused }` -> `Busy`
- `RunEnd { LimitReached }` -> `Errored`
- `Error` -> `Errored`
これにより Backend/Web projection の `status` は execution run_state を反映する。
## Validation
実行済み・成功:
- `cargo fmt --all`
- `cargo fmt --all --check`
- `cargo test -p worker --features runtime-adapter runtime_adapter`
- `cargo test -p worker-runtime --features ws-server`
- `cargo test -p worker-runtime --features "ws-server fs-store"`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `git diff --check`
- `nix build .#yoi --no-link`
補足:
- `cargo test -p yoi-workspace-server` では既存 warning: `field next_sequence is never read`
- 新規失敗や未解決の validation failure はない。
## 残リスク / review focus
- `workspace-server` の embedded Worker capability は backend 接続時に `can_accept_input` / `can_stop` が true になる。実 provider/profile 不足時は fake 応答ではなく typed rejection/error になるため、下流 Companion UX 側でその表示/導線を確認する必要がある。
- Runtime execution status projection は主要 lifecycle event に対応したが、より細かい paused/cancelled 表示語彙を Browser projection で増やす場合は別 Ticket 範囲。
- v0 policy は引き続き queue なし。Busy 中 input は typed busy rejection。
---
<!-- event: review author: yoi-reviewer-00001KW55B33B-worker-adapter at: 2026-06-27T21:27:37Z status: request_changes -->
## Review: request changes
request_changes
静的レビューのみ実施。対象 worktree の HEAD は `9069b035041d17e7c52a454a7563cc5f0b7e1f61` として確認。先回報告の full SHA suffix には差異があるため、以後この full SHA を正とする。
先回の blocker のうち、input delegate / builtin prefix normalization / protocol event -> execution status projection は概ね修正されている。
ただし、元の invariant に対してまだ blocker がある。
## Blocker
**spawn 時の provider/profile/config/setup 失敗が Browser-facing spawn API では `Accepted` 扱いになり、typed rejection/diagnostic になっていない。**
根拠:
- 実 Worker 作成時の profile/provider/session setup 失敗は adapter で `WorkerExecutionSpawnResult::Errored` になる。
- `worker/src/runtime_adapter.rs:161-194`
- `worker/src/runtime_adapter.rs:365-371`
- しかし `worker-runtime::Runtime::create_worker` は spawn `Rejected/Errored``Err` にせず、`Ok(WorkerDetail)` に execution status として記録するだけ。
- `worker-runtime/src/runtime.rs:278-280`
- `worker-runtime/src/runtime.rs:417-446`
- workspace-server 側は `create_worker``Ok(detail)` なら常に `WorkerOperationState::Accepted` として返し、execution failure の diagnostic/rejection を browser-facing result に出していない。
- `workspace-server/src/hosts.rs:1204-1222`
- さらに projection の `can_accept_input``run_state != Busy` だけを見るため、spawn `Errored/Rejected` でも status が `Running` のままなら input 可能に見える。
- `workspace-server/src/hosts.rs:962-970`
このため、missing provider/config/secret/setup が「typed diagnostic/rejection」ではなく「spawn accepted だが内部 execution errored」という中途半端な projection になり、Ticket の受け入れ条件を満たしていない。
spawn execution failure は、少なくとも browser-facing API で sanitized diagnostic/rejection として扱うか、capability を false にして明確な failure projection を返す必要がある。
---
<!-- event: implementation_report author: yoi-coder-00001KW55B33B-worker-adapter at: 2026-06-27T21:39:54Z -->
## Implementation report
Change-response implementation report:
Reviewer blocker を修正済み。Ticket ファイル変更、merge、close、cleanup は未実施。
Current HEAD:
- `7e29ff5ec99dcc748fa5a511cda5bae31fec124b fix: reject embedded spawn execution failures`
Prior fix commit:
- `9069b035041d17e7c52a454a7563cc5f0b7e1f61 fix: connect embedded runtime input lifecycle`
## 修正内容
### Spawn-time execution failure を Browser-facing API で `Rejected` として返す
`worker-runtime::Runtime::create_worker` は spawn backend の `Rejected/Errored``Ok(WorkerDetail)` 内の execution status として保持する設計のままなので、`workspace-server` 側で `detail.execution.last_result` を検査するようにした。
対象:
- `crates/workspace-server/src/hosts.rs`
変更:
- embedded Runtime spawn 後に execution spawn failure を検出。
- `WorkerExecutionOutcome::{Rejected, Errored, Unsupported, Busy}` の場合:
- Browser-facing `WorkerSpawnResult.state = Rejected`
- `acceptance_evidence = []`
- sanitized diagnostic を返す
- worker projection は残すが、capability は失敗状態として false にする
sanitized diagnostic 例:
- code: `embedded_worker_execution_spawn_errored`
- message: `Embedded Worker execution spawn was errored during setup; check runtime configuration`
### Failed execution state で capability が true にならないよう修正
`can_accept_input` / `can_stop` 判定を `run_state != Busy` からより厳密に変更。
`can_accept_input` は以下すべてを満たす場合のみ true:
- execution backend が接続済み
- worker status が `Running`
- execution backend kind が `Connected`
- run_state が `Idle`
- last_result が `Rejected/Errored/Unsupported` ではない
`can_stop` も spawn rejected/errored/unconnected 系では false。
### Focused tests
`workspace-server` に spawn execution failure の focused test を追加。
検証内容:
- fake failing execution backend が spawn `Errored` を返す
- Browser-facing spawn result が `Rejected`
- diagnostic が sanitized され、fake 内部 path を漏らさない
- projected worker status が `errored`
- `can_accept_input == false`
- `can_stop == false`
既存 server API projection test も、real backend setup failure が Browser-facing projection で sanitized rejection として出る前提に調整。
## Validation
実行済み・成功:
- `cargo fmt --all`
- `cargo fmt --all --check`
- `cargo test -p worker --features runtime-adapter runtime_adapter`
- `cargo test -p worker-runtime --features ws-server`
- `cargo test -p worker-runtime --features "ws-server fs-store"`
- `cargo test -p yoi-workspace-server`
- `cargo check -p yoi`
- `git diff --check`
- `nix build .#yoi --no-link`
補足:
- `cargo test -p yoi-workspace-server` では既存 warning: `field next_sequence is never read`
- `nix build` は dirty tree warning を出したが build 自体は成功。
## 残リスク / review focus
- `worker-runtime::Runtime::create_worker` 自体は引き続き spawn execution failure を `Ok(WorkerDetail)` に記録する設計。今回の修正は Browser-facing `workspace-server` projection で typed rejection/diagnostic として露出する対応。
- diagnostic は sanitized のため、具体的な provider/config/secret path 等は Browser-facing API には出さない。詳細原因の調査導線が必要なら、別途 backend/admin 向け diagnostics 設計が必要。
- Companion status は環境の profile/provider setup 可否により `ready` / `error` のどちらも取り得るため、既存テストはその環境差を許容する形に調整。
---
<!-- event: review author: yoi-reviewer-00001KW55B33B-worker-adapter at: 2026-06-27T21:42:24Z status: approve -->
## Review: approve
approve
読取専用で再レビューした。前回 blocker は解消されている。
確認した点:
- `workspace-server` の embedded spawn 後に `detail.execution.last_result` を検査し、`Rejected / Errored / Unsupported / Busy` を browser-facing `WorkerSpawnResult.state = Rejected` として返すようになっている。
- spawn execution failure の diagnostic は sanitized な固定文言・code で返され、内部 path / handle / credential / secret ref の露出は見当たらない。
- `can_accept_input` は backend 接続・Worker `Running`・backend kind `Connected`・`run_state == Idle`・last_result failure でないことを要求しており、spawn failure 後の入力可能 projection は塞がれている。
- `can_stop` も rejected / errored / unconnected failure state では false になる。
- 先回指摘の embedded input API delegate、builtin profile double-prefix、protocol event -> runtime execution status projection も修正済み。
- crate layering は `worker` 側 feature adapter -> `worker-runtime` boundary の向きで、`worker-runtime` が `worker` に依存する逆流は見当たらない。
- fake/providerless fallback、public capability flag 復活、下流 Ticket `00001KW55B33H` scope creep は見当たらない。
検証は指示通り read-only の静的確認のみで、テスト実行やファイル変更はしていない。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-27T21:44:40Z -->
## Implementation report
Merge / validation report:
Implementation branch was integrated into the orchestration branch after reviewer approval.
Integrated commits:
- `18526ee36264610048f48b07b5db50ce86852fd2 feat: connect runtime worker execution adapter`
- `9069b035041d17e7c52a454a7563cc5f0b7e1f61 fix: connect embedded runtime input lifecycle`
- `7e29ff5ec99dcc748fa5a511cda5bae31fec124b fix: reject embedded spawn execution failures`
Merge commit:
- `c3ed223d merge: worker runtime worker adapter`
Validation in orchestration worktree:
- `cargo fmt --all --check`: success
- `cargo test -p worker --features runtime-adapter runtime_adapter`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Known non-blocking warning:
- `cargo test -p yoi-workspace-server` reports existing warning `field next_sequence is never read` in `crates/workspace-server/src/companion.rs`.
Outcome:
- Embedded Runtime now installs a `worker` crate execution adapter via `worker/runtime-adapter` feature without making `worker-runtime` depend on `worker`.
- Backend input path delegates to the Runtime execution backend and reaches real Worker run lifecycle.
- `protocol::Event` output bridges to Runtime observation / transcript projection.
- Browser-facing spawn failure is reported as sanitized rejected diagnostic, not accepted fake output.
- Failed/unconnected execution states are not projected as input-capable.
- No public `can_stream_events` / `can_read_bounded_transcript` capability flags were revived.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-27T21:44:46Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval後に implementation branch を orchestration branch へ merge し、要求された validation が成功したため done に遷移する。
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker --features runtime-adapter runtime_adapter`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
---
<!-- event: state_changed author: hare at: 2026-06-27T21:45:00Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-27T21:45:00Z status: closed -->
## 完了
embedded `worker-runtime` を既存 `worker` crate の実行 lifecycle に接続する adapter を実装し、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `worker` crate に `runtime-adapter` feature と `WorkerRuntimeExecutionBackend` adapter を追加。
- `worker-runtime``worker` に依存しない下位境界のまま維持し、crate dependency cycle を回避。
- embedded Runtime 作成時に `workspace-server` が adapter を install。
- Backend Worker input API から embedded Runtime execution backend に委譲し、既存 Worker run lifecycle (`Method::Run`) に接続。
- Worker の `protocol::Event` を Runtime observation bus / transcript projection に bridge。
- builtin profile selector double-prefix を修正。
- run_state/status projection を実 Worker lifecycle event に合わせて更新。
- spawn execution failure は Browser-facing API で sanitized `Rejected` diagnostic として扱い、failed/unconnected 状態を input-capable に見せない。
- fake/providerless/canned assistant response は導入していない。
- Browser-facing API に raw worker handle / execution handle / socket / session path / credential / secret ref / raw manifest path を出していない。
- `can_stream_events` / `can_read_bounded_transcript` public capability は復活させていない。
Integrated commits:
- `18526ee36264610048f48b07b5db50ce86852fd2 feat: connect runtime worker execution adapter`
- `9069b035041d17e7c52a454a7563cc5f0b7e1f61 fix: connect embedded runtime input lifecycle`
- `7e29ff5ec99dcc748fa5a511cda5bae31fec124b fix: reject embedded spawn execution failures`
- merge: `c3ed223d merge: worker runtime worker adapter`
Validation:
- `cargo fmt --all --check`: success
- `cargo test -p worker --features runtime-adapter runtime_adapter`: success
- `cargo test -p worker-runtime --features ws-server`: success
- `cargo test -p worker-runtime --features "ws-server fs-store"`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Known non-blocking warning:
- `cargo test -p yoi-workspace-server` reports existing warning `field next_sequence is never read` in `crates/workspace-server/src/companion.rs`.
Cleanup は child implementation worktree / branch と related role Pods のみを対象に実施する。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T06:02:16Z -->
## Implementation report
Cleanup report:
- User instructed to stop using `StopPod` for now after repeated stop/hang symptoms. Role Pod shutdown was therefore intentionally skipped.
- Child implementation worktree was removed:
- `/home/hare/Projects/yoi/.worktree/00001KW55B33B-worker-runtime-worker-adapter`
- Child implementation branch was removed:
- `work/00001KW55B33B-worker-runtime-worker-adapter`
- No root/original workspace cleanup was performed.
- Orchestration worktree is clean after worktree/branch cleanup.
---

View File

@ -0,0 +1,2 @@
{"id":"orch-plan-20260627-190816-1","ticket_id":"00001KW55B33H","kind":"blocked_by","related_ticket":"00001KW55B33B","note":"Queue routing checked. Workspace Companion real LLM Worker execution depends on embedded worker-runtime to worker crate connection `00001KW55B33B`, which is queued and blocked by execution backend boundary `00001KW55B32Y`. Leave queued until dependency chain is done.","author":"yoi-orchestrator","at":"2026-06-27T19:08:16Z"}
{"id":"orch-plan-20260628-060239-2","ticket_id":"00001KW55B33H","kind":"accepted_plan","note":"Queue continuation requested by user. Dependency `00001KW55B33B` is closed and validated, so the Companion real LLM Worker Ticket is now unblocked. There are no other queued Tickets.","accepted_plan":{"summary":"前段 `00001KW55B33B` が closed になったため、Workspace Backend 起動時の `workspace_companion` を実 LLM execution backend 付き Worker として spawn し、Worker list / Worker Console から通常 Worker として attach/send できる状態を完成させる。Companion 専用 fake/providerless response は復活させず、provider/config/secret 不足は typed diagnostic として扱う。","branch":"work/00001KW55B33H-workspace-companion-llm-worker","worktree":"/home/hare/Projects/yoi/.worktree/00001KW55B33H-workspace-companion-llm-worker","role_plan":"Orchestrator が dedicated child worktree を作成し、coder Worker に `crates/workspace-server`, 必要に応じて `crates/worker`, `crates/worker-runtime`, `web/workspace`, root Cargo/package files の focused write scope を委譲する。reviewer Worker は read-only で Companion bootstrap、Worker input API 経路、protocol event / transcript / Web Console 表示、provider/config 不足時の typed diagnostic、fake response absence、Browser-facing non-leak を確認する。merge/validation/done/cleanup は Orchestrator が行う。現時点では `StopPod` は使用せず、cleanup は child worktree/branch のみ行う。"},"author":"yoi-orchestrator","at":"2026-06-28T06:02:39Z"}

View File

@ -1,8 +1,8 @@
---
title: 'Workspace Companionを実LLM実行Workerとして起動する'
state: 'queued'
state: 'closed'
created_at: '2026-06-27T18:26:47Z'
updated_at: '2026-06-27T19:06:32Z'
updated_at: '2026-06-28T07:20:22Z'
assignee: null
queued_by: 'workspace-panel'
queued_at: '2026-06-27T19:06:32Z'

View File

@ -0,0 +1,32 @@
Workspace Companion を embedded Runtime / `worker` runtime adapter 経由の実行可能 Worker として扱う実装を行い、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `/api/companion/messages` の旧 `companion_llm_not_connected` 固定拒否経路を廃止し、通常の Worker runtime input path (`RuntimeRegistry::send_input`) に接続。
- Companion bootstrap を `builtin:companion` profile / Companion config bundle path に変更。
- Companion transcript projection を Runtime transcript 由来に変更。
- Companion status / transport を input-capable なら `connected`、不可なら `not_input_capable` + typed diagnostics に変更。
- `/api/workspace``extension_points.companion_console` も Companion の実 status / diagnostics 由来に変更し、stale `not_connected` / disabled note を削除。
- `ExtensionPointState``diagnostics` を追加し、Workspace web type も更新。
- Browser-facing `/api/workspace` から raw `local_root` exposure を削除。
- deterministic test backend により Companion bootstrap / input dispatch / assistant output / observation event / transcript projection を Worker execution path 経由で検証。
- fake/providerless/canned assistant response は導入していない。
- Worker Console path は `runtime_id + worker_id` keyed のまま維持。
- public `can_stream_events` / `can_read_bounded_transcript` capability は復活させていない。
Integrated commits:
- `ee25cfbcfd90983a24091ef30c0128d653095003 fix: route workspace companion through worker runtime`
- `3be193223c7efc67636667d1ad526646da81fb63 fix: report companion console runtime status`
- merge: `eb06b8a9 merge: workspace companion llm worker`
Validation:
- `cargo fmt --all --check`: success
- `cd web/workspace && deno task test`: success
- `cd web/workspace && deno task check`: success
- `cd web/workspace && deno task build`: success
- `cargo test -p yoi-workspace-server`: success (`34 passed`)
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child implementation worktree / branch.

View File

@ -31,3 +31,507 @@ Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-27T19:08:35Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Dashboard Queue 後に Ticket / relations / orchestration plan / workspace state を確認した。
- 本 Ticket は `00001KW55B33B` (`embedded worker-runtimeをworker crate実行に接続する`) に `depends_on` relation を持つ。
- `00001KW55B33B` は queued で、さらに `00001KW55B32Y` execution backend boundary が inprogress のため blocked。
- Workspace Companion を実 LLM 実行 Worker として起動するには、embedded Runtime が既存 `worker` crate execution に接続済みである必要があるため、依存 chain 完了前に開始しない。
Evidence checked:
- Ticket body: Companion bootstrap、Worker Console behavior、API cleanup、Validation/evidence、Non-goals。
- Relations: outgoing `depends_on -> 00001KW55B33B` and related Web Console/Worker Console Tickets。
- Orchestration plan: blocker record `orch-plan-20260627-190816-1` を追加。
- Workspace state: `00001KW55B32Y` accepted/inprogress; `00001KW55B33B` queued/blocked。
Next action:
- 本 Ticket は queued のまま待機。
- `00001KW55B33B` が done になった後に再 routing する。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-28T06:02:49Z -->
## Decision
Routing decision:
Queue continuation was explicitly requested. The upstream dependency `00001KW55B33B` is now closed after review, merge, validation, and cleanup. This Ticket is therefore unblocked and accepted for implementation.
Plan:
- Create child implementation worktree:
- `/home/hare/Projects/yoi/.worktree/00001KW55B33H-workspace-companion-llm-worker`
- Branch:
- `work/00001KW55B33H-workspace-companion-llm-worker`
- Route implementation to a sibling Coder Worker with focused write scope.
- Route read-only review to a sibling Reviewer Worker after implementation.
- Orchestrator retains merge, final validation, Ticket completion, child worktree/branch cleanup, and final reporting.
Review/implementation focus:
- Workspace Backend bootstraps `workspace_companion` as an executable Worker with real `worker` crate execution adapter.
- Worker list / Worker Console use the normal `runtime_id + worker_id` authority and Backend Worker input API.
- Console Send reaches real Worker execution path and uses `protocol::Event` / transcript projection, not Companion-specific fake/providerless messages.
- provider/model/secret/profile/authority absence returns typed diagnostic and does not present the Worker as input-capable.
- `/api/companion/messages`, if retained, must not return fake response and should be a thin normal Worker input path wrapper or non-authoritative compatibility path.
- Browser-facing API must not leak raw handles, sockets, session paths, credentials, secret refs, raw manifest paths, or broad authority internals.
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child worktree/branch unless this is explicitly reauthorized.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-28T06:02:54Z from: queued to: inprogress reason: dependency_closed_and_queue_continues field: state -->
## State changed
Queue continuation requested by user. Dependency `00001KW55B33B` is closed with merge and validation complete, so this Ticket has no remaining blockers. Accepting implementation before child worktree / role Pod side effects.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T06:03:56Z -->
## Implementation report
Implementation kickoff:
- Worktree created: `/home/hare/Projects/yoi/.worktree/00001KW55B33H-workspace-companion-llm-worker`
- Branch: `work/00001KW55B33H-workspace-companion-llm-worker`
- Coder Worker spawned: `yoi-coder-00001KW55B33H-companion-llm`
Scope notes:
- Coder write scope is limited to `crates/workspace-server/**`, `crates/worker/**`, `crates/worker-runtime/**`, `web/workspace/**`, `resources/profiles/**`, `resources/prompts/**`, root `Cargo.toml`, `Cargo.lock`, and `package.nix` if dependency/package changes are needed.
- Coder is instructed not to modify Ticket records and not to operate in root/orchestration worktrees.
Implementation focus:
- bootstrap `workspace_companion` as an executable Worker using embedded Runtime + `worker/runtime-adapter`
- normal Worker list / Worker Console attach path via `runtime_id + worker_id`
- Send through Backend Worker input API to real Worker run lifecycle
- no fake/providerless Companion response
- provider/profile/model/secret/authority absence as typed diagnostic and not input-capable
- `/api/companion/messages`, if retained, must not be a separate fake response path
- preserve Browser-facing non-leak of raw handles, paths, credentials, secret refs, and raw manifest paths
Operational note:
- User instructed not to use `StopPod` for now. Later cleanup will skip role-Pod shutdown and remove only child implementation worktree/branch unless explicitly reauthorized.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T06:09:52Z -->
## Implementation report
Implementation routing diagnostic:
Initial Coder Worker `yoi-coder-00001KW55B33H-companion-llm` failed before producing implementation output with provider error `context_length_exceeded`. This appears to be a prompt/context-size failure rather than an implementation result.
Operational response:
- Per current user instruction, `StopPod` will not be used.
- A replacement Coder Worker will be spawned with a shorter bootstrap instruction and the same implementation worktree, if scope delegation is accepted.
- The replacement Coder should read the Ticket and current code directly from the worktree rather than receiving the full requirement text in the initial prompt.
---
<!-- event: implementation_report author: yoi-coder-00001KW55B33H-companion-2 at: 2026-06-28T06:20:33Z -->
## Implementation report
Implementation/verification report:
Replacement Coder completed a verification pass in the child worktree. No code changes were made and no implementation commit was created beyond the branch baseline.
Reported branch/head:
- `0c5a769aa90744cf12299c6d301ebfb3186d763f ticket: accept workspace companion llm worker`
Reported outcome:
- Existing code after upstream `00001KW55B33B` appears to satisfy this Ticket's main implementation goal.
- Workspace Companion is visible as an embedded Runtime normal Worker.
- The `worker` runtime-adapter path is used; providerless/fake responses were not found.
- Input unavailable state is represented through Worker input-capable/diagnostic projection.
- Workspace web Worker Console uses `runtime_id + worker_id` routing.
Reported validation:
- `cargo fmt --check`: success
- `cd web/workspace && deno task test`: success
- `cd web/workspace && deno task check`: success
- `cd web/workspace && deno task build`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi`: success
Orchestrator note:
- Because no code commit was produced for this Ticket, a reviewer will be asked to explicitly verify whether upstream merged implementation fully satisfies `00001KW55B33H` or whether additional changes are required.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T06:21:01Z -->
## Implementation report
Review kickoff:
Reviewer Worker spawned to verify the no-code implementation/verification outcome for `00001KW55B33H`.
- Reviewer Worker: `yoi-reviewer-00001KW55B33H-companion`
- Review mode: read-only review of current child worktree code state after upstream `00001KW55B33B` merge
- Special focus: confirm whether no additional code changes are required for this Ticket, or identify concrete blockers
Review focus:
- `workspace_companion` bootstraps as an executable normal Worker via embedded Runtime + `worker/runtime-adapter`
- Worker list / Worker Console attach path uses `runtime_id + worker_id`
- Send uses Backend Worker input API -> embedded Runtime execution backend -> real Worker run lifecycle
- protocol events / transcript use existing Console rendering path
- missing provider/config/profile/secret/authority is typed diagnostic and not input-capable
- `/api/companion/messages` does not return fake/providerless response
- Browser-facing API does not leak raw handles, socket paths, session paths, credentials, secret refs, raw manifest paths, or broad authority internals
- public `can_stream_events` / `can_read_bounded_transcript` flags are not revived
---
<!-- event: review author: yoi-reviewer-00001KW55B33H-companion at: 2026-06-28T06:27:08Z status: request_changes -->
## Review: request changes
request_changes
静的レビューの結果、upstream の `worker-runtime` adapter 実装は存在するが、Ticket `00001KW55B33H` 自体の受け入れ条件はまだ満たされていない。
## Blockers
1. **`/api/companion/messages` がまだ hard-coded な非実行 path のまま**
- `server.rs:533-537``/api/companion/messages` を直接 `api.companion.send_message(request)` に route している。
- `companion.rs:161-182` は non-empty input を常に `companion_llm_not_connected` で reject する。
- これは Backend Worker input API の thin wrapper ではなく、real Worker run lifecycle に到達しないため、Ticket の `/api/companion/messages` 要件を満たさない。
2. **Companion status / transport が古い “not connected” limitation をまだ宣言している**
- `companion.rs:337-342``completion: "not_connected"` を返し、実 Worker/LLM execution 接続まで browser input は disabled と説明している。
- Ticket の目的は Workspace Companion を real LLM execution に接続して Web Console から使えるようにすることなので矛盾している。
3. **Companion bootstrap が適切な Companion profile / config bundle を解決していない**
- `companion.rs:257-267``profile: Some(ProfileSelector::RuntimeDefault)` / `config_bundle: None` で spawn している。
- `hosts.rs:2188-2191` では `RuntimeDefault``runtime_default` と投影されるだけで、Companion profile として表示されない。
- Ticket は Companion Profile / config bundle を解決し、`workspace_companion` / 適切な companion Profile として表示することを求めている。
4. **focused tests が古い rejected/no-output behavior をまだ assert している**
- `companion.rs:384-399``send_message("hello")``Rejected`、`companion_llm_not_connected`、runtime transcript empty であることを assert している。
- `server.rs:1166-1185``/api/companion/messages` が rejected で transcript empty であることを assert している。
- Ticket は bootstrap、input dispatch、assistant output、observation event、transcript projection が Worker execution path を通ることを proving する tests を求めている。
5. **raw path leak 懸念**
- `server.rs:227-235``WorkspaceResponse``local_root: PathBuf` が含まれる。
- `server.rs:335-339``/api/workspace``api.config.workspace_root` を serialize している。
- “no raw handle/path/credential leaks” を Browser-facing workspace API 全体に適用するなら違反の可能性がある。Worker projection 自体はより注意深く redacted されているが、この public path field は残っているため要確認・必要なら修正が必要。
## 確認済み / non-blocking observations
- upstream embedded runtime adapter は存在する。
- `WorkspaceApi::new``WorkerRuntimeExecutionBackend::from_workspace(...)` を作り、`EmbeddedWorkerRuntime::new_memory_with_execution_backend(...)` に渡している。
- Worker list / Console routing は `runtime_id + worker_id` を使っている。
- Console Send は `/api/runtimes/{runtime_id}/workers/{worker_id}/input`
- Observation WS は `/api/runtimes/{runtime_id}/workers/{worker_id}/events/ws`
- public `can_stream_events` / `can_read_bounded_transcript` fields は見当たらない。
- `/api/companion/messages` は fake/canned assistant response は返していないが、same execution path / normal Worker input path 要件も満たしていない。
## Validation review
- `.yoi/tickets/00001KW55B33H/item.md``thread.md` を読んだ。
- `crates/workspace-server/src/{companion.rs,server.rs,hosts.rs}`、`crates/worker/src/runtime_adapter.rs`、Web Console Svelte/TS paths を静的レビューした。
- test/build は実行していない。
- 現状の `cargo test -p yoi-workspace-server` は古い rejected Companion behavior を assert して通っている可能性があり、この Ticket の acceptance evidence にはならない。
---
<!-- event: implementation_report author: yoi-coder-00001KW55B33H-companion-2 at: 2026-06-28T06:56:50Z -->
## Implementation report
Change-response implementation report:
Reviewer blockers を修正し、実装 commit を作成済み。Ticket ファイル編集、merge、close、cleanup は未実施。
Commit:
- `ee25cfbcfd90983a24091ef30c0128d653095003 fix: route workspace companion through worker runtime`
変更概要:
- `/api/companion/messages` の旧 `companion_llm_not_connected` 固定拒否経路を廃止し、通常の `RuntimeRegistry::send_input` / Worker runtime 入力経路へ接続。
- Companion status / transport を更新。
- Worker が input-capable の場合は `completion: "connected"`
- missing provider/config/profile/authority 等で input-capable でない場合は `completion: "not_input_capable"` と typed diagnostic。
- fake/providerless/canned assistant 応答は追加していない。
- Companion bootstrap を `builtin:companion` profile + Companion 用 config bundle sync へ変更。
- `WorkspaceCompanion` intent の embedded profile projection も `builtin:companion` に変更。
- Companion transcript projection を runtime transcript 由来に変更し、旧独自 transcript / 旧非実行 semantics を置換。
- `/api/workspace` の Browser-facing response から raw `local_root: PathBuf` を削除。
- Tests を更新・追加。
- Companion bootstrap / input dispatch / assistant output / observation event / transcript projection を deterministic test execution backend で検証。
- legacy `/api/companion/messages` route が Worker runtime 経由で input dispatch することを server test で検証。
- 旧 `companion_llm_not_connected` 依存 assertion を削除。
Validation:
- `cargo fmt --check`: success
- `cd web/workspace && deno task test`: success
- `cd web/workspace && deno task check`: success
- `cd web/workspace && deno task build`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi`: success
Residual risks / review focus:
- `/api/companion/*` は互換 route として残しつつ、内部動作は Worker runtime 経路へ寄せている。主 UI path は引き続き `runtime_id + worker_id` の Worker Console。
- provider/profile/secret/authority 欠落時は input 不可 diagnostic になる。実 provider 設定がない環境では Companion は起動 projection されても入力不可。
- deterministic test backend による検証のため、外部 secret/provider には依存しない。
---
<!-- event: review author: yoi-reviewer-00001KW55B33H-companion at: 2026-06-28T07:05:54Z status: request_changes -->
## Review: request changes
request_changes
今回の実装で prior functional routing issues は解消されているが、status / transport の blocker が 1 件残っている。
## Blocker
`/api/workspace` がまだ Companion Console を not connected / input disabled として advertise している。
- `crates/workspace-server/src/server.rs:341-365``WorkspaceResponse.extension_points.companion_console` を組み立てている。
- ここでまだ以下を返している。
- `status: "not_connected"`
- note: `"browser input remains disabled until actual Worker/LLM execution is connected"`
これは現在求められる挙動と矛盾する。Companion status / transport は、input-capable なら `connected`、provider/config/profile/authority 不足などで input 不可なら `not_input_capable` + typed diagnostic を返すべきである。`/api/companion/status` が正しくても、この Browser-facing workspace status が stale な “not connected / disabled” state を露出しているため、client を誤誘導し得る。
## 改善済み / apparently satisfied
- `/api/companion/messages``CompanionConsole::send_message` / `RuntimeRegistry::send_input` 経由になり、旧固定 `companion_llm_not_connected` rejection ではなくなっている。
- Companion bootstrap は `ProfileSelector::Builtin("companion")` と Companion config bundle label を使っている。
- Companion transcript は runtime transcript projection 由来になっている。
- main Worker Console path は `runtime_id + worker_id` を使い、`/api/runtimes/{runtime_id}/workers/{worker_id}/input` に post する。
- existing Console rendering は protocol event / transcript paths を消費している。
- `/api/workspace` は以前の `local_root: PathBuf` を公開しなくなっている。
- negative test assertions 以外で public `can_stream_events` / `can_read_bounded_transcript` fields は見当たらない。
- fake/canned Companion assistant response path は見当たらない。
## Validation note
報告された validation command は再実行していない。test/check/build success は plausibly true だが、残る stale `/api/workspace` Companion status は更新後の behavior assertion で cover されていないため、passing tests だけではこの acceptance point の evidence にならない。
---
<!-- event: implementation_report author: yoi-coder-00001KW55B33H-companion-2 at: 2026-06-28T07:14:16Z -->
## Implementation report
Change-response implementation report:
Remaining reviewer blocker を修正し、追加 commit を作成済み。Ticket ファイル編集、merge、close、cleanup は未実施。
Commit:
- `3be193223c7efc67636667d1ad526646da81fb63 fix: report companion console runtime status`
Previous implementation commit:
- `ee25cfbcfd90983a24091ef30c0128d653095003 fix: route workspace companion through worker runtime`
変更概要:
- `/api/workspace``extension_points.companion_console` を固定値から Companion の実際の status / transport projection に変更。
- input-capable なら `status: "connected"`
- input 不可なら `status: "not_input_capable"` と typed diagnostics を返す
- stale note `"browser input remains disabled until actual Worker/LLM execution is connected"` を削除
- `ExtensionPointState``diagnostics` を追加し、Browser-facing projection でも Companion の typed diagnostic を確認できるようにした。
- Workspace web 側の `ExtensionPoint` 型にも `diagnostics` を追加。
- Focused test を追加・更新。
- `/api/workspace``companion_console``not_connected` を返さないこと
- input-capable deterministic backend では `connected` になること
- `not_input_capable` の場合は diagnostics が空でないこと
Validation:
- `cargo fmt --check`: success
- `cargo test -p yoi-workspace-server`: success
- `cargo check -p yoi`: success
- `git diff --check`: success
- `cd web/workspace && deno task test`: success
- `cd web/workspace && deno task check`: success
- `cd web/workspace && deno task build`: success
- `nix build .#yoi`: success
Residual risks / review focus:
- `ExtensionPointState` の JSON に `diagnostics` が増えている。Workspace web 側の型は更新済み。
- Companion が provider/config/profile/secret/authority 欠落で input-capable でない環境では、`/api/workspace` でも `not_input_capable` と診断情報が表示される。
---
<!-- event: review author: yoi-reviewer-00001KW55B33H-companion at: 2026-06-28T07:18:37Z status: approve -->
## Review: approve
approve
同じ worktree / branch を静的再レビューし、前回 blocker は解消され、Ticket scope の受け入れ条件を満たしていると判断した。
Validation performed:
- `.yoi/tickets/00001KW55B33H/item.md` を再読。
- 現在の `crates/workspace-server/src/server.rs`, `companion.rs`, `hosts.rs`, worker runtime adapter paths, web workspace types を確認。
- stale blocker strings / public flags を grep:
- `browser input remains disabled`
- `companion_llm_not_connected`
- `can_stream_events`
- `can_read_bounded_transcript`
- `local_root`
- fake/canned response markers
- ファイル変更や Ticket record 更新はしていない。
- full reported validation suite は再実行していない。
Evidence:
- `/api/workspace``extension_points.companion_console` は固定 stale value ではなく `api.companion.status()` 由来の status / diagnostics を使っている。
- `ExtensionPointState``diagnostics` が追加され、workspace web types も更新されている。
- Companion message send path は `RuntimeRegistry::send_input` / Worker runtime input path を使っており、fake response や固定 rejection ではない。
- Companion status は input-capable なら `connected`、そうでなければ diagnostics 付き `not_input_capable` を返す。
- Companion bootstrap は Companion profile / config-bundle path を使っている。
- Transcript projection は runtime transcript 由来。
- Worker Console route / input path は `runtime_id + worker_id` keyed のまま。
- negative assertions 以外で public `can_stream_events` / `can_read_bounded_transcript` fields は見当たらない。
- 以前の `/api/workspace` raw `local_root` exposure は消えている。
Reported validations は、updated server tests が Companion Console workspace status と runtime dispatch behavior を cover していることも含め、変更内容に対して妥当と判断した。
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T07:19:29Z -->
## Implementation report
Merge / validation report:
Implementation branch was integrated into the orchestration branch after reviewer approval.
Integrated commits:
- `ee25cfbcfd90983a24091ef30c0128d653095003 fix: route workspace companion through worker runtime`
- `3be193223c7efc67636667d1ad526646da81fb63 fix: report companion console runtime status`
Merge commit:
- `eb06b8a9 merge: workspace companion llm worker`
Validation in orchestration worktree:
- `cargo fmt --all --check`: success
- `cd web/workspace && deno task test`: success
- `cd web/workspace && deno task check`: success
- `cd web/workspace && deno task build`: success
- `cargo test -p yoi-workspace-server`: success (`34 passed`)
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Outcome:
- Workspace Companion now routes through embedded Runtime / `worker` runtime adapter instead of fake/providerless response or fixed non-connected rejection.
- `/api/companion/messages` is a compatibility route over the normal Worker input path.
- Companion bootstrap uses Companion profile / config bundle path.
- Companion status and `/api/workspace` companion console status report `connected` when input-capable, otherwise `not_input_capable` with typed diagnostics.
- Transcript projection is sourced from Runtime transcript / Worker protocol event flow.
- Worker Console path remains keyed by `runtime_id + worker_id`.
- Browser-facing raw `local_root` exposure was removed from `/api/workspace`.
- Public `can_stream_events` / `can_read_bounded_transcript` capability flags were not revived.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-28T07:19:35Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
## State changed
Reviewer approval後に implementation branch を orchestration branch へ merge し、要求された validation が成功したため done に遷移する。
Validation:
- `cargo fmt --all --check`: success
- `cd web/workspace && deno task test`: success
- `cd web/workspace && deno task check`: success
- `cd web/workspace && deno task build`: success
- `cargo test -p yoi-workspace-server`: success (`34 passed`)
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
---
<!-- event: state_changed author: hare at: 2026-06-28T07:19:50Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-06-28T07:19:50Z status: closed -->
## 完了
Workspace Companion を embedded Runtime / `worker` runtime adapter 経由の実行可能 Worker として扱う実装を行い、reviewer approval 後に orchestration branch へ merge した。
実装内容:
- `/api/companion/messages` の旧 `companion_llm_not_connected` 固定拒否経路を廃止し、通常の Worker runtime input path (`RuntimeRegistry::send_input`) に接続。
- Companion bootstrap を `builtin:companion` profile / Companion config bundle path に変更。
- Companion transcript projection を Runtime transcript 由来に変更。
- Companion status / transport を input-capable なら `connected`、不可なら `not_input_capable` + typed diagnostics に変更。
- `/api/workspace``extension_points.companion_console` も Companion の実 status / diagnostics 由来に変更し、stale `not_connected` / disabled note を削除。
- `ExtensionPointState``diagnostics` を追加し、Workspace web type も更新。
- Browser-facing `/api/workspace` から raw `local_root` exposure を削除。
- deterministic test backend により Companion bootstrap / input dispatch / assistant output / observation event / transcript projection を Worker execution path 経由で検証。
- fake/providerless/canned assistant response は導入していない。
- Worker Console path は `runtime_id + worker_id` keyed のまま維持。
- public `can_stream_events` / `can_read_bounded_transcript` capability は復活させていない。
Integrated commits:
- `ee25cfbcfd90983a24091ef30c0128d653095003 fix: route workspace companion through worker runtime`
- `3be193223c7efc67636667d1ad526646da81fb63 fix: report companion console runtime status`
- merge: `eb06b8a9 merge: workspace companion llm worker`
Validation:
- `cargo fmt --all --check`: success
- `cd web/workspace && deno task test`: success
- `cd web/workspace && deno task check`: success
- `cd web/workspace && deno task build`: success
- `cargo test -p yoi-workspace-server`: success (`34 passed`)
- `cargo check -p yoi`: success
- `git diff --check`: success
- `nix build .#yoi --no-link`: success
Operational note:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Cleanup will skip role-Pod shutdown and remove only child implementation worktree / branch.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-28T07:20:22Z -->
## Implementation report
Cleanup report:
- User instructed not to use `StopPod` for now after repeated stop/hang symptoms. Role Pod shutdown was intentionally skipped.
- Child implementation worktree was removed:
- `/home/hare/Projects/yoi/.worktree/00001KW55B33H-workspace-companion-llm-worker`
- Child implementation branch was removed:
- `work/00001KW55B33H-workspace-companion-llm-worker`
- No root/original workspace cleanup was performed.
- Orchestration worktree is clean after worktree/branch cleanup.
Queue note:
- The three-ticket execution chain is now complete/closed:
- `00001KW55B32Y`
- `00001KW55B33B`
- `00001KW55B33H`
---

2
Cargo.lock generated
View File

@ -5932,6 +5932,7 @@ dependencies = [
"uuid",
"wasmtime",
"wat",
"worker-runtime",
"workflow",
"yoi-plugin-pdk",
]
@ -6054,6 +6055,7 @@ dependencies = [
"tower",
"tracing",
"uuid",
"worker",
"worker-runtime",
]

View File

@ -1,3 +1,4 @@
use crate::execution::WorkerExecutionStatus;
use crate::identity::{RuntimeId, WorkerId, WorkerRef};
use serde::{Deserialize, Serialize};
@ -142,6 +143,7 @@ pub struct WorkerSummary {
pub runtime_id: RuntimeId,
pub worker_id: WorkerId,
pub status: WorkerStatus,
pub execution: WorkerExecutionStatus,
pub intent: WorkerIntent,
pub profile: ProfileSelector,
pub requested_capability_count: usize,
@ -157,6 +159,7 @@ pub struct WorkerDetail {
pub runtime_id: RuntimeId,
pub worker_id: WorkerId,
pub status: WorkerStatus,
pub execution: WorkerExecutionStatus,
pub intent: WorkerIntent,
pub profile: ProfileSelector,
#[serde(default, skip_serializing_if = "Option::is_none")]

View File

@ -1,3 +1,4 @@
use crate::execution::WorkerExecutionResult;
use crate::identity::{RuntimeId, WorkerId};
use std::path::PathBuf;
@ -28,6 +29,21 @@ pub enum RuntimeError {
worker_id: WorkerId,
},
#[error("worker {worker_id} has no execution backend: {message}")]
WorkerExecutionUnavailable {
worker_id: WorkerId,
message: String,
},
#[error("worker {worker_id} execution {operation:?} returned {outcome:?}: {message}")]
WorkerExecutionRejected {
worker_id: WorkerId,
operation: crate::execution::WorkerExecutionOperation,
outcome: crate::execution::WorkerExecutionOutcome,
message: String,
result: WorkerExecutionResult,
},
#[error("limit {requested} exceeds maximum {max}")]
LimitTooLarge { requested: usize, max: usize },

View File

@ -0,0 +1,337 @@
use crate::catalog::CreateWorkerRequest;
use crate::error::RuntimeError;
use crate::identity::WorkerRef;
use crate::interaction::WorkerInput;
#[cfg(feature = "ws-server")]
use crate::observation::WorkerObservationEvent;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::Arc;
/// Coarse execution attachment visible through Worker catalog/detail responses.
///
/// This deliberately does not expose backend handles, process paths, sockets,
/// credentials, session files, or manifest paths. It only says whether Runtime
/// has an execution backend attached for the Worker.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerExecutionBackendKind {
#[default]
Unconnected,
Connected,
}
/// Current execution-side run state for a Worker.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerExecutionRunState {
#[default]
Unconnected,
Idle,
Busy,
Rejected,
Errored,
Stopped,
}
/// Execution operation that produced a result.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerExecutionOperation {
Spawn,
Input,
Stop,
Cancel,
}
/// Typed execution result class.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerExecutionOutcome {
Accepted,
Busy,
Rejected,
Errored,
Unsupported,
}
/// Backend result for a Worker execution operation.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerExecutionResult {
pub operation: WorkerExecutionOperation,
pub outcome: WorkerExecutionOutcome,
pub run_state: WorkerExecutionRunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl WorkerExecutionResult {
pub fn accepted(
operation: WorkerExecutionOperation,
run_state: WorkerExecutionRunState,
) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Accepted,
run_state,
message: None,
}
}
pub fn busy(operation: WorkerExecutionOperation, message: impl Into<String>) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Busy,
run_state: WorkerExecutionRunState::Busy,
message: Some(message.into()),
}
}
pub fn rejected(operation: WorkerExecutionOperation, message: impl Into<String>) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Rejected,
run_state: WorkerExecutionRunState::Rejected,
message: Some(message.into()),
}
}
pub fn errored(operation: WorkerExecutionOperation, message: impl Into<String>) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Errored,
run_state: WorkerExecutionRunState::Errored,
message: Some(message.into()),
}
}
pub fn unsupported(operation: WorkerExecutionOperation, message: impl Into<String>) -> Self {
Self {
operation,
outcome: WorkerExecutionOutcome::Unsupported,
run_state: WorkerExecutionRunState::Rejected,
message: Some(message.into()),
}
}
pub fn is_accepted(&self) -> bool {
self.outcome == WorkerExecutionOutcome::Accepted
}
pub fn message_or_default(&self) -> String {
self.message
.clone()
.unwrap_or_else(|| format!("{:?} {:?}", self.operation, self.outcome))
}
}
/// Execution status surfaced in Worker summary/detail responses.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerExecutionStatus {
pub backend: WorkerExecutionBackendKind,
pub run_state: WorkerExecutionRunState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<WorkerExecutionResult>,
}
impl WorkerExecutionStatus {
pub fn unconnected() -> Self {
Self::default()
}
pub fn connected(run_state: WorkerExecutionRunState) -> Self {
Self {
backend: WorkerExecutionBackendKind::Connected,
run_state,
last_result: None,
}
}
pub fn with_result(mut self, result: WorkerExecutionResult) -> Self {
self.run_state = result.run_state;
self.last_result = Some(result);
self
}
}
/// Opaque per-Worker execution handle returned by a backend.
///
/// The handle is a typed token for routing calls back into the same backend. It
/// intentionally contains no socket path, process id, credential, manifest path,
/// or session path.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerExecutionHandle {
worker_ref: WorkerRef,
backend_id: String,
}
impl WorkerExecutionHandle {
pub fn new(worker_ref: WorkerRef, backend_id: impl Into<String>) -> Self {
Self {
worker_ref,
backend_id: backend_id.into(),
}
}
pub fn worker_ref(&self) -> &WorkerRef {
&self.worker_ref
}
pub fn backend_id(&self) -> &str {
&self.backend_id
}
}
/// Runtime hooks available to an execution backend for one Worker.
#[derive(Clone)]
pub struct WorkerExecutionContext {
worker_ref: WorkerRef,
#[cfg(feature = "ws-server")]
observation_publisher: Arc<
dyn Fn(WorkerRef, protocol::Event) -> Result<WorkerObservationEvent, RuntimeError>
+ Send
+ Sync,
>,
}
impl WorkerExecutionContext {
#[cfg(feature = "ws-server")]
pub(crate) fn new(
worker_ref: WorkerRef,
observation_publisher: Arc<
dyn Fn(WorkerRef, protocol::Event) -> Result<WorkerObservationEvent, RuntimeError>
+ Send
+ Sync,
>,
) -> Self {
Self {
worker_ref,
observation_publisher,
}
}
#[cfg(not(feature = "ws-server"))]
pub(crate) fn new(worker_ref: WorkerRef) -> Self {
Self { worker_ref }
}
pub fn worker_ref(&self) -> &WorkerRef {
&self.worker_ref
}
/// Publish a protocol event into the Runtime observation bus.
#[cfg(feature = "ws-server")]
pub fn publish_protocol_event(
&self,
payload: protocol::Event,
) -> Result<WorkerObservationEvent, RuntimeError> {
(self.observation_publisher)(self.worker_ref.clone(), payload)
}
}
impl fmt::Debug for WorkerExecutionContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WorkerExecutionContext")
.field("worker_ref", &self.worker_ref)
.finish_non_exhaustive()
}
}
/// Spawn/initialization request passed to an execution backend.
#[derive(Clone, Debug)]
pub struct WorkerExecutionSpawnRequest {
pub worker_ref: WorkerRef,
pub request: CreateWorkerRequest,
pub context: WorkerExecutionContext,
}
/// Result of backend Worker spawn/initialization.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
run_state: WorkerExecutionRunState,
},
Rejected(WorkerExecutionResult),
Errored(WorkerExecutionResult),
}
/// Backend boundary for Worker execution.
///
/// Runtime owns Worker catalog, transcript, observation, and lifecycle state. A
/// backend owns concrete execution. The default Runtime has no backend, so input
/// to those Workers is rejected instead of producing providerless responses.
pub trait WorkerExecutionBackend: Send + Sync + 'static {
fn backend_id(&self) -> &str;
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult;
fn dispatch_input(
&self,
handle: &WorkerExecutionHandle,
input: WorkerInput,
) -> WorkerExecutionResult;
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
WorkerExecutionResult::unsupported(
WorkerExecutionOperation::Stop,
"execution backend does not support stopping workers",
)
}
fn cancel_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
WorkerExecutionResult::unsupported(
WorkerExecutionOperation::Cancel,
"execution backend does not support cancelling workers",
)
}
}
#[derive(Clone)]
pub(crate) struct WorkerExecutionBackendRef {
id: String,
backend: Arc<dyn WorkerExecutionBackend>,
}
impl WorkerExecutionBackendRef {
pub(crate) fn new(backend: Arc<dyn WorkerExecutionBackend>) -> Result<Self, RuntimeError> {
let id = backend.backend_id().trim().to_string();
if id.is_empty() {
return Err(RuntimeError::InvalidRequest(
"execution backend id must not be empty".to_string(),
));
}
Ok(Self { id, backend })
}
pub(crate) fn spawn_worker(
&self,
request: WorkerExecutionSpawnRequest,
) -> WorkerExecutionSpawnResult {
self.backend.spawn_worker(request)
}
pub(crate) fn dispatch_input(
&self,
handle: &WorkerExecutionHandle,
input: WorkerInput,
) -> WorkerExecutionResult {
self.backend.dispatch_input(handle, input)
}
pub(crate) fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
self.backend.stop_worker(handle)
}
pub(crate) fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
self.backend.cancel_worker(handle)
}
}
impl fmt::Debug for WorkerExecutionBackendRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WorkerExecutionBackendRef")
.field("id", &self.id)
.finish_non_exhaustive()
}
}

View File

@ -822,7 +822,9 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
RuntimeError::WorkerNotFound { .. } | RuntimeError::ConfigBundleMissing { .. } => {
StatusCode::NOT_FOUND
}
RuntimeError::RuntimeStopped { .. } => StatusCode::CONFLICT,
RuntimeError::RuntimeStopped { .. }
| RuntimeError::WorkerExecutionUnavailable { .. }
| RuntimeError::WorkerExecutionRejected { .. } => StatusCode::CONFLICT,
RuntimeError::LimitTooLarge { .. }
| RuntimeError::InvalidRequest(_)
| RuntimeError::ConfigBundleDigestMismatch { .. }
@ -843,6 +845,8 @@ fn code_for_runtime_error(error: &RuntimeError) -> &'static str {
RuntimeError::WrongRuntime { .. } => "wrong_runtime",
RuntimeError::WrongRuntimeCursor { .. } => "wrong_runtime_cursor",
RuntimeError::WorkerNotFound { .. } => "worker_not_found",
RuntimeError::WorkerExecutionUnavailable { .. } => "worker_execution_unavailable",
RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected",
RuntimeError::LimitTooLarge { .. } => "limit_too_large",
RuntimeError::InvalidRequest(_) => "invalid_request",
RuntimeError::ConfigBundleMissing { .. } => "config_bundle_missing",
@ -869,6 +873,12 @@ pub enum RuntimeHttpServerError {
mod tests {
use super::*;
use crate::catalog::{CapabilityRequest, ProfileSelector, WorkerIntent};
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
WorkerExecutionResult, WorkerExecutionRunState, WorkerExecutionSpawnRequest,
WorkerExecutionSpawnResult,
};
use crate::management::RuntimeOptions;
use axum::body::to_bytes;
use axum::http::Method;
use tower::ServiceExt;
@ -886,6 +896,39 @@ mod tests {
}
}
struct AcceptingBackend;
impl WorkerExecutionBackend for AcceptingBackend {
fn backend_id(&self) -> &str {
"http-test"
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
}
}
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
_input: WorkerInput,
) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
}
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Stop,
WorkerExecutionRunState::Stopped,
)
}
}
async fn json_request<T: Serialize>(
app: Router,
method: Method,
@ -923,7 +966,9 @@ mod tests {
#[tokio::test]
async fn rest_command_api_delegates_to_runtime() {
let runtime = Runtime::new_memory();
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let app = runtime_http_router(runtime.clone(), None);
let response = json_request(

View File

@ -11,6 +11,7 @@ pub mod catalog;
pub mod config_bundle;
pub mod diagnostics;
pub mod error;
pub mod execution;
#[cfg(feature = "fs-store")]
pub mod fs_store;
#[cfg(feature = "http-server")]

View File

@ -8,6 +8,12 @@ use crate::config_bundle::{
};
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
use crate::error::RuntimeError;
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionBackendKind, WorkerExecutionBackendRef,
WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
WorkerExecutionStatus,
};
#[cfg(feature = "fs-store")]
use crate::fs_store::{
FsRuntimeStore, FsRuntimeStoreOptions, PersistedRuntimeState, PersistedWorkerRecord,
@ -63,6 +69,16 @@ impl Runtime {
}
}
/// Create a memory-backed Runtime with an attached execution backend.
pub fn with_execution_backend(
options: RuntimeOptions,
backend: Arc<dyn WorkerExecutionBackend>,
) -> Result<Self, RuntimeError> {
let runtime = Self::with_options(options);
runtime.install_execution_backend(backend)?;
Ok(runtime)
}
/// Create or restore a filesystem-backed Runtime.
///
/// The store is scoped by typed Runtime identity under `options.root`; if the
@ -71,11 +87,28 @@ impl Runtime {
/// created before the Runtime is returned.
#[cfg(feature = "fs-store")]
pub fn with_fs_store(options: FsRuntimeStoreOptions) -> Result<Self, RuntimeError> {
Self::with_fs_store_inner(options, None)
}
/// Create or restore a filesystem-backed Runtime with an execution backend.
#[cfg(feature = "fs-store")]
pub fn with_fs_store_and_execution_backend(
options: FsRuntimeStoreOptions,
backend: Arc<dyn WorkerExecutionBackend>,
) -> Result<Self, RuntimeError> {
Self::with_fs_store_inner(options, Some(WorkerExecutionBackendRef::new(backend)?))
}
#[cfg(feature = "fs-store")]
fn with_fs_store_inner(
options: FsRuntimeStoreOptions,
execution_backend: Option<WorkerExecutionBackendRef>,
) -> Result<Self, RuntimeError> {
let runtime_id = options.runtime_id.unwrap_or_else(|| {
RuntimeId::generated(NEXT_RUNTIME_SEQUENCE.fetch_add(1, Ordering::Relaxed))
});
let opened = FsRuntimeStore::open_or_create(options.root, runtime_id.clone())?;
let state = if let Some(persisted) = opened.state {
let mut state = if let Some(persisted) = opened.state {
RuntimeState::from_persisted(persisted, opened.store)?
} else {
let mut state = RuntimeState::new_fs_backed(
@ -90,6 +123,7 @@ impl Runtime {
state.persist_event_by_id(event_id)?;
state
};
state.execution_backend = execution_backend;
Ok(Self {
inner: Arc::new(Mutex::new(state)),
})
@ -210,6 +244,12 @@ impl Runtime {
let worker_id = WorkerId::generated(state.next_worker_sequence);
state.next_worker_sequence += 1;
let worker_ref = WorkerRef::new(state.runtime_id.clone(), worker_id.clone());
let backend = state.execution_backend.clone();
let spawn_request = backend.as_ref().map(|_| WorkerExecutionSpawnRequest {
worker_ref: worker_ref.clone(),
request: request.clone(),
context: self.execution_context(worker_ref.clone()),
});
let event_id = state.push_event(
Some(worker_ref.clone()),
RuntimeEventKind::WorkerCreated,
@ -217,10 +257,12 @@ impl Runtime {
);
let record = WorkerRecord {
worker_ref,
worker_ref: worker_ref.clone(),
worker_id: worker_id.clone(),
status: WorkerStatus::Running,
request,
execution: WorkerExecutionStatus::unconnected(),
execution_handle: None,
transcript: Vec::new(),
next_transcript_sequence: 1,
last_event_id: event_id,
@ -231,8 +273,15 @@ impl Runtime {
state.persist_runtime_snapshot()?;
state.persist_worker(&worker_id)?;
state.persist_event_by_id(event_id)?;
drop(state);
if let (Some(backend), Some(spawn_request)) = (backend, spawn_request) {
let result = backend.spawn_worker(spawn_request);
self.apply_spawn_result(&worker_ref, result)
} else {
Ok(detail)
}
}
/// List Workers known to this Runtime.
pub fn list_workers(&self) -> Result<Vec<WorkerSummary>, RuntimeError> {
@ -257,11 +306,11 @@ impl Runtime {
worker_ref: &WorkerRef,
input: WorkerInput,
) -> Result<WorkerInteractionAck, RuntimeError> {
let (backend, handle) = {
let mut state = self.lock()?;
state.ensure_running()?;
validate_worker_input(&input)?;
state.ensure_worker_ref(worker_ref)?;
{
let worker = state.worker(worker_ref)?;
if !worker.status.is_active() {
return Err(RuntimeError::InvalidRequest(format!(
@ -269,8 +318,40 @@ impl Runtime {
worker_ref.worker_id
)));
}
let backend = state.execution_backend.clone();
let handle = worker.execution_handle.clone();
match (backend, handle) {
(Some(backend), Some(handle)) => (backend, handle),
_ => {
let result = WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
"worker has no execution backend",
);
let worker = state.worker_mut(worker_ref)?;
worker.execution = WorkerExecutionStatus::unconnected().with_result(result);
state.persist_worker(&worker_ref.worker_id)?;
return Err(RuntimeError::WorkerExecutionUnavailable {
worker_id: worker_ref.worker_id.clone(),
message: "worker has no execution backend".to_string(),
});
}
}
};
let dispatch_result = backend.dispatch_input(&handle, input.clone());
if !dispatch_result.is_accepted() {
self.record_execution_result(worker_ref, dispatch_result.clone())?;
return Err(RuntimeError::WorkerExecutionRejected {
worker_id: worker_ref.worker_id.clone(),
operation: dispatch_result.operation,
outcome: dispatch_result.outcome,
message: dispatch_result.message_or_default(),
result: dispatch_result,
});
}
let mut state = self.lock()?;
state.ensure_running()?;
let event_id = state.push_event(
Some(worker_ref.clone()),
RuntimeEventKind::WorkerInputAccepted,
@ -286,6 +367,11 @@ impl Runtime {
let transcript_sequence = worker.next_transcript_sequence;
worker.next_transcript_sequence += 1;
worker.last_event_id = event_id;
worker.execution = WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected,
run_state: dispatch_result.run_state,
last_result: Some(dispatch_result),
};
worker.transcript.push(TranscriptEntry {
sequence: transcript_sequence,
worker_ref: worker_ref.clone(),
@ -328,12 +414,103 @@ impl Runtime {
})
}
fn apply_spawn_result(
&self,
worker_ref: &WorkerRef,
result: WorkerExecutionSpawnResult,
) -> Result<WorkerDetail, RuntimeError> {
let mut state = self.lock()?;
let runtime_id = state.runtime_id.clone();
let detail = {
let worker = state.worker_mut(worker_ref)?;
match result {
WorkerExecutionSpawnResult::Connected { handle, run_state } => {
worker.execution_handle = Some(handle);
worker.execution = WorkerExecutionStatus::connected(run_state).with_result(
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn, run_state),
);
}
WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::Errored(result) => {
worker.execution_handle = None;
worker.execution = WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected,
run_state: result.run_state,
last_result: Some(result),
};
}
}
worker.detail(&runtime_id)
};
state.persist_worker(&worker_ref.worker_id)?;
Ok(detail)
}
fn record_execution_result(
&self,
worker_ref: &WorkerRef,
result: WorkerExecutionResult,
) -> Result<(), RuntimeError> {
let mut state = self.lock()?;
let worker = state.worker_mut(worker_ref)?;
worker.execution = WorkerExecutionStatus {
backend: WorkerExecutionBackendKind::Connected,
run_state: result.run_state,
last_result: Some(result),
};
state.persist_worker(&worker_ref.worker_id)?;
Ok(())
}
fn dispatch_lifecycle_to_backend(
&self,
worker_ref: &WorkerRef,
operation: WorkerExecutionOperation,
) -> Result<(), RuntimeError> {
let Some((backend, handle)) = ({
let state = self.lock()?;
state.ensure_worker_ref(worker_ref)?;
let worker = state.worker(worker_ref)?;
if !worker.status.is_active() {
return Ok(());
}
match (
state.execution_backend.clone(),
worker.execution_handle.clone(),
) {
(Some(backend), Some(handle)) => Some((backend, handle)),
_ => None,
}
}) else {
return Ok(());
};
let result = match operation {
WorkerExecutionOperation::Stop => backend.stop_worker(&handle),
WorkerExecutionOperation::Cancel => backend.cancel_worker(&handle),
WorkerExecutionOperation::Spawn | WorkerExecutionOperation::Input => return Ok(()),
};
if result.is_accepted() {
self.record_execution_result(worker_ref, result)?;
return Ok(());
}
self.record_execution_result(worker_ref, result.clone())?;
Err(RuntimeError::WorkerExecutionRejected {
worker_id: worker_ref.worker_id.clone(),
operation: result.operation,
outcome: result.outcome,
message: result.message_or_default(),
result,
})
}
/// Stop a Worker. Repeated stops are idempotent and return the last event id.
pub fn stop_worker(
&self,
worker_ref: &WorkerRef,
reason: Option<String>,
) -> Result<WorkerLifecycleAck, RuntimeError> {
self.dispatch_lifecycle_to_backend(worker_ref, WorkerExecutionOperation::Stop)?;
self.transition_worker(
worker_ref,
WorkerStatus::Stopped,
@ -348,6 +525,7 @@ impl Runtime {
worker_ref: &WorkerRef,
reason: Option<String>,
) -> Result<WorkerLifecycleAck, RuntimeError> {
self.dispatch_lifecycle_to_backend(worker_ref, WorkerExecutionOperation::Cancel)?;
self.transition_worker(
worker_ref,
WorkerStatus::Cancelled,
@ -545,7 +723,16 @@ impl Runtime {
) -> Result<WorkerObservationEvent, RuntimeError> {
let mut state = self.lock()?;
state.ensure_worker_ref(worker_ref)?;
let transcript_sequence = state.project_protocol_event_to_transcript(worker_ref, &payload);
let execution_state_changed =
state.project_protocol_event_to_execution(worker_ref, &payload);
let event = state.push_worker_observation_event(worker_ref.clone(), payload);
if transcript_sequence.is_some() || execution_state_changed {
state.persist_worker(&worker_ref.worker_id)?;
}
if let Some(sequence) = transcript_sequence {
state.persist_transcript_entry(&worker_ref.worker_id, sequence)?;
}
Ok(event)
}
@ -591,6 +778,30 @@ impl Runtime {
})
}
fn install_execution_backend(
&self,
backend: Arc<dyn WorkerExecutionBackend>,
) -> Result<(), RuntimeError> {
let backend = WorkerExecutionBackendRef::new(backend)?;
let mut state = self.lock()?;
state.execution_backend = Some(backend);
Ok(())
}
#[cfg(feature = "ws-server")]
fn execution_context(&self, worker_ref: WorkerRef) -> crate::execution::WorkerExecutionContext {
let runtime = self.clone();
crate::execution::WorkerExecutionContext::new(
worker_ref,
Arc::new(move |worker_ref, payload| runtime.observe_worker_event(&worker_ref, payload)),
)
}
#[cfg(not(feature = "ws-server"))]
fn execution_context(&self, worker_ref: WorkerRef) -> crate::execution::WorkerExecutionContext {
crate::execution::WorkerExecutionContext::new(worker_ref)
}
fn lock(&self) -> Result<MutexGuard<'_, RuntimeState>, RuntimeError> {
self.inner.lock().map_err(|_| RuntimeError::StatePoisoned)
}
@ -613,6 +824,7 @@ struct RuntimeState {
persistence: RuntimePersistence,
status: RuntimeStatus,
limits: RuntimeLimits,
execution_backend: Option<WorkerExecutionBackendRef>,
next_worker_sequence: u64,
next_event_id: u64,
next_diagnostic_id: u64,
@ -637,6 +849,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Memory,
status: RuntimeStatus::Running,
limits,
execution_backend: None,
next_worker_sequence: 1,
next_event_id: 1,
next_diagnostic_id: 1,
@ -667,6 +880,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store),
status: RuntimeStatus::Running,
limits,
execution_backend: None,
next_worker_sequence: 1,
next_event_id: 1,
next_diagnostic_id: 1,
@ -709,6 +923,8 @@ impl RuntimeState {
worker_id: worker.worker_id,
status: worker.status,
request: worker.request,
execution: WorkerExecutionStatus::unconnected(),
execution_handle: None,
transcript: worker.transcript,
next_transcript_sequence: worker.next_transcript_sequence,
last_event_id: worker.last_event_id,
@ -723,6 +939,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store),
status: persisted.status,
limits: persisted.limits,
execution_backend: None,
next_worker_sequence: persisted.next_worker_sequence,
next_event_id: persisted.next_event_id,
next_diagnostic_id: persisted.next_diagnostic_id,
@ -730,6 +947,12 @@ impl RuntimeState {
config_bundles: persisted.config_bundles,
events: persisted.events,
diagnostics: persisted.diagnostics,
#[cfg(feature = "ws-server")]
next_observation_sequence: 1,
#[cfg(feature = "ws-server")]
observation_events: VecDeque::new(),
#[cfg(feature = "ws-server")]
observation_tx: broadcast::channel(256).0,
})
}
@ -1046,6 +1269,111 @@ impl RuntimeState {
event
}
#[cfg(feature = "ws-server")]
fn append_worker_transcript_entry(
&mut self,
worker_ref: &WorkerRef,
role: TranscriptRole,
content: impl Into<String>,
) -> Option<u64> {
let content = content.into();
if content.trim().is_empty() {
return None;
}
let event_id = self.last_event_id();
let worker = self.workers.get_mut(&worker_ref.worker_id)?;
let sequence = worker.next_transcript_sequence;
worker.next_transcript_sequence += 1;
worker.transcript.push(TranscriptEntry {
sequence,
worker_ref: worker_ref.clone(),
role,
content,
event_id,
});
Some(sequence)
}
#[cfg(feature = "ws-server")]
fn project_protocol_event_to_transcript(
&mut self,
worker_ref: &WorkerRef,
event: &protocol::Event,
) -> Option<u64> {
match event {
protocol::Event::TextDone { text, .. } => self.append_worker_transcript_entry(
worker_ref,
TranscriptRole::Assistant,
text.clone(),
),
protocol::Event::Error { message, .. } => self.append_worker_transcript_entry(
worker_ref,
TranscriptRole::System,
format!("error: {message}"),
),
protocol::Event::ToolResult {
id,
summary,
is_error,
..
} => self.append_worker_transcript_entry(
worker_ref,
TranscriptRole::System,
format!(
"tool result {id}: {}{}",
if *is_error { "error: " } else { "" },
summary
),
),
_ => None,
}
}
#[cfg(feature = "ws-server")]
fn project_protocol_event_to_execution(
&mut self,
worker_ref: &WorkerRef,
event: &protocol::Event,
) -> bool {
let Some(worker) = self.workers.get_mut(&worker_ref.worker_id) else {
return false;
};
let status = &mut worker.execution;
let next_run_state = match event {
protocol::Event::Status {
status: protocol::WorkerStatus::Running,
} => Some(WorkerExecutionRunState::Busy),
protocol::Event::Status {
status: protocol::WorkerStatus::Idle,
} => Some(WorkerExecutionRunState::Idle),
protocol::Event::Status {
status: protocol::WorkerStatus::Paused,
} => Some(WorkerExecutionRunState::Busy),
protocol::Event::Snapshot { status, .. } => match status {
protocol::WorkerStatus::Running => Some(WorkerExecutionRunState::Busy),
protocol::WorkerStatus::Idle => Some(WorkerExecutionRunState::Idle),
protocol::WorkerStatus::Paused => Some(WorkerExecutionRunState::Busy),
},
protocol::Event::RunEnd { result } => match result {
protocol::RunResult::Finished | protocol::RunResult::RolledBack => {
Some(WorkerExecutionRunState::Idle)
}
protocol::RunResult::Paused => Some(WorkerExecutionRunState::Busy),
protocol::RunResult::LimitReached => Some(WorkerExecutionRunState::Errored),
},
protocol::Event::Error { .. } => Some(WorkerExecutionRunState::Errored),
_ => None,
};
let Some(next_run_state) = next_run_state else {
return false;
};
if status.run_state == next_run_state {
return false;
}
status.run_state = next_run_state;
true
}
fn push_diagnostic(
&mut self,
severity: DiagnosticSeverity,
@ -1090,6 +1418,8 @@ struct WorkerRecord {
worker_id: WorkerId,
status: WorkerStatus,
request: CreateWorkerRequest,
execution: WorkerExecutionStatus,
execution_handle: Option<WorkerExecutionHandle>,
transcript: Vec<TranscriptEntry>,
next_transcript_sequence: u64,
last_event_id: u64,
@ -1102,6 +1432,7 @@ impl WorkerRecord {
runtime_id: runtime_id.clone(),
worker_id: self.worker_id.clone(),
status: self.status,
execution: self.execution.clone(),
intent: self.request.intent.clone(),
profile: self.request.profile.clone(),
requested_capability_count: self.request.requested_capabilities.len(),
@ -1117,6 +1448,7 @@ impl WorkerRecord {
runtime_id: runtime_id.clone(),
worker_id: self.worker_id.clone(),
status: self.status,
execution: self.execution.clone(),
intent: self.request.intent.clone(),
profile: self.request.profile.clone(),
config_bundle: self.request.config_bundle.clone(),
@ -1185,7 +1517,12 @@ mod tests {
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
ConfigDeclarationKind, ConfigProfileDescriptor,
};
use crate::management::RuntimeLimits;
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
WorkerExecutionRunState,
};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
fn task_request(objective: &str) -> CreateWorkerRequest {
CreateWorkerRequest {
@ -1200,6 +1537,92 @@ mod tests {
}
}
#[derive(Default)]
struct TestExecutionBackend {
dispatch_result: Mutex<Option<WorkerExecutionResult>>,
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
}
impl TestExecutionBackend {
fn set_dispatch_result(&self, result: WorkerExecutionResult) {
*self.dispatch_result.lock().unwrap() = Some(result);
}
#[cfg(feature = "ws-server")]
fn publish_text_delta(
&self,
worker_ref: &WorkerRef,
text: &str,
) -> Result<crate::observation::WorkerObservationEvent, RuntimeError> {
let contexts = self.contexts.lock().unwrap();
let context = contexts.get(&worker_ref.worker_id).expect("context stored");
context.publish_protocol_event(protocol::Event::TextDelta { text: text.into() })
}
}
impl WorkerExecutionBackend for TestExecutionBackend {
fn backend_id(&self) -> &str {
"test-execution-backend"
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
self.contexts
.lock()
.unwrap()
.insert(request.worker_ref.worker_id.clone(), request.context);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
}
}
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
_input: WorkerInput,
) -> WorkerExecutionResult {
self.dispatch_result
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
})
}
fn stop_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Stop,
WorkerExecutionRunState::Stopped,
)
}
fn cancel_worker(&self, _handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Cancel,
WorkerExecutionRunState::Stopped,
)
}
}
fn runtime_with_backend() -> Runtime {
Runtime::with_execution_backend(
RuntimeOptions::default(),
Arc::new(TestExecutionBackend::default()),
)
.unwrap()
}
fn runtime_and_backend() -> (Runtime, Arc<TestExecutionBackend>) {
let backend = Arc::new(TestExecutionBackend::default());
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), backend.clone()).unwrap();
(runtime, backend)
}
fn test_bundle() -> ConfigBundle {
ConfigBundle {
metadata: ConfigBundleMetadata {
@ -1357,15 +1780,142 @@ mod tests {
);
}
#[test]
fn backend_unconnected_worker_input_is_rejected_and_not_transcribed() {
let runtime = Runtime::new_memory();
let detail = runtime.create_worker(task_request("placeholder")).unwrap();
assert_eq!(
detail.execution.backend,
WorkerExecutionBackendKind::Unconnected
);
let err = runtime
.send_input(&detail.worker_ref, WorkerInput::user("must reject"))
.unwrap_err();
assert!(matches!(
err,
RuntimeError::WorkerExecutionUnavailable { .. }
));
let projection = runtime
.transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 1))
.unwrap();
assert_eq!(projection.total_items, 0);
}
#[test]
fn connected_backend_busy_dispatch_is_typed_and_not_transcribed() {
let (runtime, backend) = runtime_and_backend();
backend.set_dispatch_result(WorkerExecutionResult::busy(
WorkerExecutionOperation::Input,
"worker is already running",
));
let detail = runtime.create_worker(task_request("busy")).unwrap();
let err = runtime
.send_input(&detail.worker_ref, WorkerInput::user("wait"))
.unwrap_err();
assert!(matches!(
err,
RuntimeError::WorkerExecutionRejected {
outcome: crate::execution::WorkerExecutionOutcome::Busy,
..
}
));
let refreshed = runtime.worker_detail(&detail.worker_ref).unwrap();
assert_eq!(refreshed.execution.run_state, WorkerExecutionRunState::Busy);
assert_eq!(
runtime
.transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 1))
.unwrap()
.total_items,
0
);
}
#[cfg(feature = "ws-server")]
#[test]
fn backend_protocol_publish_hook_writes_observation_bus() {
let (runtime, backend) = runtime_and_backend();
let detail = runtime.create_worker(task_request("observe")).unwrap();
let observation = backend
.publish_text_delta(&detail.worker_ref, "from backend")
.unwrap();
assert_eq!(observation.worker_ref, detail.worker_ref);
let observations = runtime
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
.unwrap();
assert_eq!(observations.len(), 1);
assert!(matches!(
observations[0].payload,
protocol::Event::TextDelta { .. }
));
}
struct InputOnlyBackend;
impl WorkerExecutionBackend for InputOnlyBackend {
fn backend_id(&self) -> &str {
"input-only"
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
}
}
fn dispatch_input(
&self,
_handle: &WorkerExecutionHandle,
_input: WorkerInput,
) -> WorkerExecutionResult {
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
}
}
#[test]
fn connected_backend_stop_unsupported_is_typed_rejection() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(InputOnlyBackend))
.unwrap();
let detail = runtime.create_worker(task_request("no stop")).unwrap();
let err = runtime
.stop_worker(&detail.worker_ref, Some("stop".to_string()))
.unwrap_err();
assert!(matches!(
err,
RuntimeError::WorkerExecutionRejected {
outcome: crate::execution::WorkerExecutionOutcome::Unsupported,
..
}
));
assert_eq!(
runtime.worker_detail(&detail.worker_ref).unwrap().status,
WorkerStatus::Running
);
}
#[test]
fn send_input_and_project_bounded_transcript() {
let runtime = Runtime::with_options(RuntimeOptions {
let runtime = Runtime::with_execution_backend(
RuntimeOptions {
limits: RuntimeLimits {
max_transcript_projection_items: 2,
max_event_batch_items: 16,
},
..RuntimeOptions::default()
});
},
Arc::new(TestExecutionBackend::default()),
)
.unwrap();
let detail = runtime.create_worker(task_request("chat")).unwrap();
let first = runtime
@ -1509,7 +2059,7 @@ mod tests {
#[test]
fn event_cursor_and_poll_only_subscription_are_bounded_placeholders() {
let runtime = Runtime::new_memory();
let runtime = runtime_with_backend();
let cursor = runtime.event_cursor_from_start().unwrap();
let subscription = runtime.subscribe_events(cursor.clone()).unwrap();
assert_eq!(subscription.mode, EventSubscriptionMode::PollOnly);
@ -1559,7 +2109,8 @@ mod tests {
fn fs_store_restores_workers_events_and_transcripts() {
let root = fs_store_root("restore");
let runtime_id = RuntimeId::new("runtime-fs-authority").unwrap();
let runtime = Runtime::with_fs_store(crate::fs_store::FsRuntimeStoreOptions {
let runtime = Runtime::with_fs_store_and_execution_backend(
crate::fs_store::FsRuntimeStoreOptions {
root: root.clone(),
runtime_id: Some(runtime_id.clone()),
display_name: Some("filesystem runtime".to_string()),
@ -1567,7 +2118,9 @@ mod tests {
max_transcript_projection_items: 2,
max_event_batch_items: 2,
},
})
},
Arc::new(TestExecutionBackend::default()),
)
.unwrap();
assert_eq!(
runtime.summary().unwrap().backend,
@ -1619,6 +2172,24 @@ mod tests {
.unwrap();
assert_eq!(direct_transcript.items[0].content, "second");
#[cfg(feature = "ws-server")]
{
let observation = restored
.observe_worker_event(
&worker.worker_ref,
protocol::Event::TextDelta {
text: "restored observation bus".to_string(),
},
)
.unwrap();
assert_eq!(observation.sequence, 1);
let observations = restored
.read_worker_observation_events(&worker.worker_ref, WorkerObservationCursor::zero())
.unwrap();
assert_eq!(observations.len(), 1);
assert_eq!(observations[0].cursor, observation.cursor);
}
let _ = std::fs::remove_dir_all(root);
}

View File

@ -5,6 +5,10 @@ edition.workspace = true
license.workspace = true
autobins = false
[features]
default = []
runtime-adapter = ["dep:worker-runtime"]
[dependencies]
async-trait = { workspace = true }
clap = { version = "4.6.0", features = ["derive"] }
@ -17,6 +21,7 @@ protocol = { workspace = true }
provider = { workspace = true }
client = { workspace = true }
pod-registry = { workspace = true }
worker-runtime = { workspace = true, features = ["ws-server"], optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "native-tls"] }

View File

@ -225,6 +225,21 @@ fn load_profile(
Ok((resolved.manifest, PromptLoader::builtins_only()))
}
#[cfg(feature = "runtime-adapter")]
pub(crate) fn resolve_runtime_profile_manifest(
profile: Option<&str>,
workspace_root: &Path,
worker_name: &str,
) -> Result<(WorkerManifest, PromptLoader), String> {
let selector = profile
.map(ProfileSelector::parse_cli)
.unwrap_or(ProfileSelector::Default);
let (mut manifest, loader) = load_profile(&selector, workspace_root, worker_name)?;
apply_profile_launch_policy(&mut manifest, workspace_root, None)?;
apply_plugin_resolution_plan(&mut manifest, workspace_root);
Ok((manifest, loader))
}
fn load_single_manifest(
path: &Path,
explicit_worker_name: Option<&str>,

View File

@ -10,6 +10,8 @@ pub(crate) mod in_flight;
pub mod ipc;
pub mod prompt;
pub mod runtime;
#[cfg(feature = "runtime-adapter")]
pub mod runtime_adapter;
pub mod segment_log_sink;
pub mod shared_state;
mod shutdown_after_idle;

View File

@ -0,0 +1,712 @@
//! Adapter from `worker-runtime` execution backend boundary to the real
//! `worker` crate controller/run lifecycle.
//!
//! The adapter intentionally owns real `WorkerHandle`s internally and exposes
//! only the opaque `worker-runtime` execution handle to callers. Browser/API
//! projections therefore keep the existing runtime redaction boundary: no raw
//! socket paths, session paths, manifests, credentials, or handles leave this
//! module.
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
use async_trait::async_trait;
use manifest::paths;
use pod_store::{CombinedStore, FsWorkerStore};
use protocol::{Method, Segment, WorkerStatus};
use session_store::FsStore;
use tokio::runtime::Runtime;
use tokio::sync::broadcast;
use worker_runtime::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use worker_runtime::interaction::{WorkerInput, WorkerInputKind};
use crate::{Worker, WorkerController, WorkerHandle};
const DEFAULT_BACKEND_ID: &str = "worker-crate";
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
/// Factory seam used by [`WorkerRuntimeExecutionBackend`] to construct a real
/// controller-backed Worker for a Runtime catalog entry.
#[async_trait]
pub trait RuntimeWorkerFactory: Send + Sync + 'static {
async fn spawn_controller(
&self,
request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String>;
}
/// Production factory that resolves a normal Worker profile and spawns it under
/// `WorkerController`.
#[derive(Debug, Clone)]
pub struct ProfileRuntimeWorkerFactory {
workspace_root: PathBuf,
cwd: PathBuf,
store_dir: Option<PathBuf>,
pod_store_dir: Option<PathBuf>,
profile: Option<String>,
runtime_base_dir: Option<PathBuf>,
}
impl ProfileRuntimeWorkerFactory {
pub fn new(workspace_root: impl Into<PathBuf>) -> Self {
let workspace_root = workspace_root.into();
Self {
cwd: workspace_root.clone(),
workspace_root,
store_dir: None,
pod_store_dir: None,
profile: None,
runtime_base_dir: None,
}
}
pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
self.cwd = cwd.into();
self
}
pub fn with_store_dir(mut self, store_dir: impl Into<PathBuf>) -> Self {
self.store_dir = Some(store_dir.into());
self
}
pub fn with_pod_store_dir(mut self, pod_store_dir: impl Into<PathBuf>) -> Self {
self.pod_store_dir = Some(pod_store_dir.into());
self
}
/// Set the profile selector used for Runtime-created Workers. When unset,
/// normal default profile discovery is used.
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
pub fn with_runtime_base_dir(mut self, runtime_base_dir: impl Into<PathBuf>) -> Self {
self.runtime_base_dir = Some(runtime_base_dir.into());
self
}
fn store_dir(&self) -> Result<PathBuf, String> {
self.store_dir
.clone()
.or_else(paths::sessions_dir)
.ok_or_else(|| {
"could not resolve sessions directory (set YOI_HOME, YOI_DATA_DIR, or HOME)"
.to_string()
})
}
fn pod_store_dir(&self, store_dir: &std::path::Path) -> PathBuf {
self.pod_store_dir
.clone()
.or_else(|| paths::data_dir().map(|data_dir| data_dir.join("pods")))
.or_else(|| store_dir.parent().map(|parent| parent.join("pods")))
.unwrap_or_else(|| PathBuf::from("workers"))
}
fn runtime_base_dir(&self) -> Result<PathBuf, String> {
self.runtime_base_dir
.clone()
.or_else(|| crate::runtime::dir::default_base().ok())
.ok_or_else(|| "could not resolve worker runtime directory".to_string())
}
fn runtime_worker_name(request: &WorkerExecutionSpawnRequest) -> String {
request.worker_ref.worker_id.to_string()
}
fn runtime_profile_value(
profile: &worker_runtime::catalog::ProfileSelector,
) -> Option<std::borrow::Cow<'_, str>> {
match profile {
worker_runtime::catalog::ProfileSelector::RuntimeDefault => None,
worker_runtime::catalog::ProfileSelector::Named(name) => {
Some(std::borrow::Cow::Borrowed(name.as_str()))
}
worker_runtime::catalog::ProfileSelector::Builtin(name) => {
if name.starts_with("builtin:") {
Some(std::borrow::Cow::Borrowed(name.as_str()))
} else {
Some(std::borrow::Cow::Owned(format!("builtin:{name}")))
}
}
}
}
fn runtime_profile<'a>(
&'a self,
request: &'a WorkerExecutionSpawnRequest,
) -> Option<std::borrow::Cow<'a, str>> {
if let Some(profile) = self.profile.as_deref() {
return Some(std::borrow::Cow::Borrowed(profile));
}
Self::runtime_profile_value(&request.request.profile)
}
}
#[async_trait]
impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
async fn spawn_controller(
&self,
request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String> {
let worker_name = Self::runtime_worker_name(&request);
let profile = self.runtime_profile(&request);
let (mut manifest, loader) = crate::entrypoint::resolve_runtime_profile_manifest(
profile.as_deref(),
&self.workspace_root,
&worker_name,
)?;
manifest.worker.name = worker_name;
let store_dir = self.store_dir()?;
let session_store = FsStore::new(&store_dir).map_err(|err| {
format!(
"failed to initialize session store at {}: {err}",
store_dir.display()
)
})?;
let pod_store_dir = self.pod_store_dir(&store_dir);
let pod_store = FsWorkerStore::new(&pod_store_dir).map_err(|err| {
format!(
"failed to initialize worker metadata store at {}: {err}",
pod_store_dir.display()
)
})?;
let store = CombinedStore::new(session_store, pod_store);
let worker = Worker::from_manifest_with_context(
manifest,
store,
loader,
self.workspace_root.clone(),
self.cwd.clone(),
)
.await
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
let runtime_base = self.runtime_base_dir()?;
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
.await
.map_err(|err| format!("failed to spawn Worker controller: {err}"))?;
Ok(handle)
}
}
struct RuntimeWorkerExecution {
handle: WorkerHandle,
busy: Arc<AtomicBool>,
}
/// `worker-runtime` execution backend backed by real `worker` crate Workers.
pub struct WorkerRuntimeExecutionBackend<F = ProfileRuntimeWorkerFactory> {
backend_id: String,
factory: Arc<F>,
runtime: Mutex<Option<Runtime>>,
workers: Mutex<HashMap<worker_runtime::identity::WorkerRef, RuntimeWorkerExecution>>,
}
impl WorkerRuntimeExecutionBackend<ProfileRuntimeWorkerFactory> {
pub fn from_workspace(workspace_root: impl Into<PathBuf>) -> Result<Self, String> {
Self::new(ProfileRuntimeWorkerFactory::new(workspace_root))
}
}
impl<F> WorkerRuntimeExecutionBackend<F>
where
F: RuntimeWorkerFactory,
{
pub fn new(factory: F) -> Result<Self, String> {
let runtime = tokio::runtime::Builder::new_multi_thread()
.thread_name("yoi-runtime-worker-adapter")
.enable_all()
.build()
.map_err(|err| format!("failed to build worker adapter runtime: {err}"))?;
Ok(Self {
backend_id: DEFAULT_BACKEND_ID.to_string(),
factory: Arc::new(factory),
runtime: Mutex::new(Some(runtime)),
workers: Mutex::new(HashMap::new()),
})
}
pub fn with_backend_id(mut self, backend_id: impl Into<String>) -> Self {
self.backend_id = backend_id.into();
self
}
fn wait_for_runtime_task<T>(receiver: mpsc::Receiver<Result<T, String>>) -> Result<T, String> {
receiver
.recv_timeout(RUNTIME_TASK_TIMEOUT)
.map_err(|err| format!("worker adapter task did not complete: {err}"))?
}
fn spawn_on_adapter_runtime<Fut>(&self, task: Fut) -> Result<(), String>
where
Fut: Future<Output = ()> + Send + 'static,
{
let runtime = self
.runtime
.lock()
.map_err(|_| "worker adapter runtime lock is poisoned".to_string())?;
let runtime = runtime
.as_ref()
.ok_or_else(|| "worker adapter runtime is shutting down".to_string())?;
runtime.spawn(task);
Ok(())
}
fn run_on_adapter_runtime<T, Fut>(&self, task: Fut) -> Result<T, String>
where
T: Send + 'static,
Fut: Future<Output = Result<T, String>> + Send + 'static,
{
let (tx, rx) = mpsc::sync_channel(1);
self.spawn_on_adapter_runtime(async move {
let _ = tx.send(task.await);
})?;
Self::wait_for_runtime_task(rx)
}
fn get_execution(
&self,
handle: &WorkerExecutionHandle,
) -> Result<(WorkerHandle, Arc<AtomicBool>), WorkerExecutionResult> {
if handle.backend_id() != self.backend_id() {
return Err(WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
format!(
"execution handle belongs to backend {}, not {}",
handle.backend_id(),
self.backend_id()
),
));
}
let workers = self.workers.lock().map_err(|_| {
WorkerExecutionResult::errored(
WorkerExecutionOperation::Input,
"worker adapter registry lock is poisoned",
)
})?;
workers
.get(handle.worker_ref())
.map(|execution| (execution.handle.clone(), execution.busy.clone()))
.ok_or_else(|| {
WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
"execution handle does not reference a live Worker",
)
})
}
fn send_method(
&self,
operation: WorkerExecutionOperation,
worker: WorkerHandle,
method: Method,
accepted_run_state: WorkerExecutionRunState,
) -> WorkerExecutionResult {
self.run_on_adapter_runtime(async move {
worker
.send(method)
.await
.map_err(|err| format!("failed to send Worker method: {err}"))
})
.map(|_| WorkerExecutionResult::accepted(operation, accepted_run_state))
.unwrap_or_else(|message| WorkerExecutionResult::errored(operation, message))
}
}
impl<F> Drop for WorkerRuntimeExecutionBackend<F> {
fn drop(&mut self) {
if let Ok(mut runtime) = self.runtime.lock()
&& let Some(runtime) = runtime.take()
{
let _ = std::thread::spawn(move || drop(runtime)).join();
}
}
}
impl<F> WorkerExecutionBackend for WorkerRuntimeExecutionBackend<F>
where
F: RuntimeWorkerFactory,
{
fn backend_id(&self) -> &str {
&self.backend_id
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
if self
.workers
.lock()
.map(|workers| workers.contains_key(&request.worker_ref))
.unwrap_or(false)
{
return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::busy(
WorkerExecutionOperation::Spawn,
"Worker is already connected to execution backend",
));
}
let factory = self.factory.clone();
let bridge_context = request.context.clone();
let worker_ref = request.worker_ref.clone();
let spawn_result =
self.run_on_adapter_runtime(async move { factory.spawn_controller(request).await });
let handle = match spawn_result {
Ok(handle) => handle,
Err(message) => {
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
message,
));
}
};
let mut events = handle.subscribe();
let bridge_handle = handle.clone();
let busy = Arc::new(AtomicBool::new(false));
let bridge_busy = busy.clone();
if let Err(message) = self.spawn_on_adapter_runtime(async move {
loop {
match events.recv().await {
Ok(event) => {
let _ = bridge_context.publish_protocol_event(event);
if bridge_handle.shared_state.get_status() == WorkerStatus::Idle {
bridge_busy.store(false, Ordering::SeqCst);
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
}) {
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
message,
));
}
let mut workers = match self.workers.lock() {
Ok(workers) => workers,
Err(_) => {
return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored(
WorkerExecutionOperation::Spawn,
"worker adapter registry lock is poisoned",
));
}
};
workers.insert(worker_ref.clone(), RuntimeWorkerExecution { handle, busy });
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
run_state: WorkerExecutionRunState::Idle,
}
}
fn dispatch_input(
&self,
handle: &WorkerExecutionHandle,
input: WorkerInput,
) -> WorkerExecutionResult {
let (worker, busy) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
result.operation = WorkerExecutionOperation::Input;
return result;
}
};
if worker.shared_state.get_status() != WorkerStatus::Idle
|| busy
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return WorkerExecutionResult::busy(
WorkerExecutionOperation::Input,
"Worker is already running; runtime adapter v0 does not queue input",
);
}
let WorkerInputKind::User = input.kind else {
busy.store(false, Ordering::SeqCst);
return WorkerExecutionResult::unsupported(
WorkerExecutionOperation::Input,
"runtime adapter currently dispatches user input only",
);
};
let content = input.content.trim().to_string();
if content.is_empty() {
busy.store(false, Ordering::SeqCst);
return WorkerExecutionResult::rejected(
WorkerExecutionOperation::Input,
"runtime adapter rejects empty user input",
);
}
let result = self.send_method(
WorkerExecutionOperation::Input,
worker,
Method::Run {
input: vec![Segment::text(content)],
},
WorkerExecutionRunState::Busy,
);
if result.outcome != worker_runtime::execution::WorkerExecutionOutcome::Accepted {
busy.store(false, Ordering::SeqCst);
}
result
}
fn stop_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
let (worker, _busy) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
result.operation = WorkerExecutionOperation::Stop;
return result;
}
};
self.send_method(
WorkerExecutionOperation::Stop,
worker,
Method::Shutdown,
WorkerExecutionRunState::Stopped,
)
}
fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult {
let (worker, _busy) = match self.get_execution(handle) {
Ok(execution) => execution,
Err(mut result) => {
result.operation = WorkerExecutionOperation::Cancel;
return result;
}
};
self.send_method(
WorkerExecutionOperation::Cancel,
worker,
Method::Cancel,
WorkerExecutionRunState::Idle,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use futures::Stream;
use llm_engine::Engine;
use llm_engine::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use llm_engine::llm_client::{ClientError, LlmClient, Request};
use manifest::{Scope, WorkerManifest};
use worker_runtime::Runtime as EmbeddedRuntime;
use worker_runtime::catalog::{CreateWorkerRequest, ProfileSelector, WorkerIntent};
use worker_runtime::identity::RuntimeId;
use worker_runtime::management::RuntimeOptions;
use worker_runtime::observation::{TranscriptQuery, TranscriptRole};
#[derive(Clone)]
struct MockClient {
responses: Arc<Vec<Vec<LlmEvent>>>,
call_count: Arc<AtomicUsize>,
captured: Arc<Mutex<Vec<Request>>>,
}
impl MockClient {
fn new(events: Vec<LlmEvent>) -> Self {
Self {
responses: Arc::new(vec![events]),
call_count: Arc::new(AtomicUsize::new(0)),
captured: Arc::new(Mutex::new(Vec::new())),
}
}
}
#[async_trait]
impl LlmClient for MockClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.captured.lock().unwrap().push(request);
let idx = self.call_count.fetch_add(1, Ordering::SeqCst);
let events = self.responses.get(idx).cloned().unwrap_or_default();
Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))))
}
}
struct MockFactory {
client: MockClient,
runtime_base: PathBuf,
cwd: PathBuf,
store_dir: PathBuf,
pod_store_dir: PathBuf,
}
#[async_trait]
impl RuntimeWorkerFactory for MockFactory {
async fn spawn_controller(
&self,
_request: WorkerExecutionSpawnRequest,
) -> Result<WorkerHandle, String> {
let manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "runtime-adapter-test"
pwd = "./"
[model]
scheme = "anthropic"
model_id = "test-model"
[engine]
max_tokens = 100
[[scope.allow]]
target = "./"
permission = "write"
"#,
)
.map_err(|err| err.to_string())?;
let store = CombinedStore::new(
FsStore::new(&self.store_dir).map_err(|err| err.to_string())?,
FsWorkerStore::new(&self.pod_store_dir).map_err(|err| err.to_string())?,
);
let scope = Scope::writable(&self.cwd).map_err(|err| err.to_string())?;
let worker = Worker::new(
manifest,
Engine::new(self.client.clone()),
store,
self.cwd.clone(),
scope,
)
.await
.map_err(|err| err.to_string())?;
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &self.runtime_base)
.await
.map_err(|err| err.to_string())?;
Ok(handle)
}
}
fn simple_text_events() -> Vec<LlmEvent> {
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "hello"),
LlmEvent::text_delta(0, " from worker"),
LlmEvent::text_block_stop(0, None),
LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
fn create_request(name: &str) -> CreateWorkerRequest {
CreateWorkerRequest {
intent: WorkerIntent::Role {
role: name.to_string(),
purpose: None,
},
profile: ProfileSelector::RuntimeDefault,
config_bundle: None,
requested_capabilities: Vec::new(),
workspace_refs: Vec::new(),
mount_refs: Vec::new(),
}
}
#[test]
fn builtin_profile_selector_is_not_double_prefixed() {
assert_eq!(
ProfileRuntimeWorkerFactory::runtime_profile_value(
&worker_runtime::catalog::ProfileSelector::Builtin("coder".to_string())
)
.as_deref(),
Some("builtin:coder")
);
assert_eq!(
ProfileRuntimeWorkerFactory::runtime_profile_value(
&worker_runtime::catalog::ProfileSelector::Builtin("builtin:coder".to_string())
)
.as_deref(),
Some("builtin:coder")
);
}
#[test]
fn adapter_dispatches_user_input_through_worker_run_lifecycle() {
let client = MockClient::new(simple_text_events());
let runtime_base = tempfile::tempdir().unwrap();
let cwd = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let factory = MockFactory {
client: client.clone(),
runtime_base: runtime_base.path().to_path_buf(),
cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"),
pod_store_dir: store.path().join("pods"),
};
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
let runtime = EmbeddedRuntime::with_execution_backend(
RuntimeOptions {
runtime_id: RuntimeId::new("embedded"),
..RuntimeOptions::default()
},
Arc::new(backend),
)
.unwrap();
let detail = runtime.create_worker(create_request("chat")).unwrap();
runtime
.send_input(&detail.worker_ref, WorkerInput::user("say hello"))
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let projection = runtime
.transcript_projection(&detail.worker_ref, TranscriptQuery::new(0, 10))
.unwrap();
if projection.items.iter().any(|item| {
item.role == TranscriptRole::Assistant && item.content == "hello from worker"
}) {
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for assistant transcript projection"
);
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(client.captured.lock().unwrap().len(), 1);
let observations = runtime
.read_worker_observation_events(
&detail.worker_ref,
worker_runtime::observation::WorkerObservationCursor::zero(),
)
.unwrap();
assert!(
observations
.iter()
.any(|event| matches!(event.payload, protocol::Event::TextDone { .. }))
);
}
}

View File

@ -22,6 +22,7 @@ thiserror.workspace = true
ticket.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
tokio-tungstenite.workspace = true
worker = { workspace = true, features = ["runtime-adapter"] }
worker-runtime = { workspace = true, features = ["ws-server"] }
toml.workspace = true
tracing.workspace = true

View File

@ -1,14 +1,21 @@
use std::sync::{Arc, Mutex};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use worker_runtime::catalog::{CapabilityRequest, ProfileSelector};
use worker_runtime::catalog::{CapabilityRequest, ConfigBundleRef, ProfileSelector};
use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
};
use crate::hosts::{
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerOperationState,
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSummary,
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
WorkerOperationState, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest,
WorkerSummary, WorkerTranscriptItem as RuntimeTranscriptItem, WorkerTranscriptProjection,
};
const COMPANION_RUNTIME_ID: &str = "embedded-worker-runtime";
const COMPANION_PROFILE_ID: &str = "builtin:companion";
const COMPANION_CONFIG_BUNDLE_ID: &str = "workspace-companion-config";
const MAX_MESSAGE_CHARS: usize = 8_000;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@ -85,12 +92,6 @@ pub struct CompanionTranscriptItem {
pub status: String,
}
#[derive(Debug, Default)]
struct CompanionTranscript {
items: Vec<CompanionTranscriptItem>,
next_sequence: u64,
}
#[derive(Debug)]
struct CompanionWorkerState {
state: CompanionState,
@ -99,48 +100,49 @@ struct CompanionWorkerState {
}
pub struct CompanionConsole {
runtime: Arc<RuntimeRegistry>,
worker: Mutex<CompanionWorkerState>,
transcript: Mutex<CompanionTranscript>,
}
impl CompanionConsole {
pub fn new(runtime: Arc<RuntimeRegistry>) -> Self {
let initial = spawn_companion_worker(&runtime);
Self {
runtime,
worker: Mutex::new(initial),
transcript: Mutex::new(CompanionTranscript::default()),
}
}
pub fn status(&self) -> CompanionStatusResponse {
let worker = match self.worker.lock() {
Ok(worker) => worker,
Err(_) => {
return CompanionStatusResponse {
state: CompanionState::Error,
worker: None,
transport: companion_transport(),
diagnostics: vec![diagnostic(
"companion_state_unavailable",
DiagnosticSeverity::Error,
"Companion state is unavailable",
)],
};
}
};
CompanionStatusResponse {
match self.refresh_worker_state() {
Ok(worker) => CompanionStatusResponse {
state: worker.state,
worker: worker.worker.clone(),
transport: companion_transport(),
transport: companion_transport(worker.worker.as_ref()),
diagnostics: worker.diagnostics.clone(),
},
Err(diagnostic) => CompanionStatusResponse {
state: CompanionState::Error,
worker: None,
transport: companion_transport(None),
diagnostics: vec![diagnostic],
},
}
}
pub fn transcript(&self, start: usize, limit: usize) -> CompanionTranscriptProjection {
let transcript = match self.transcript.lock() {
Ok(transcript) => transcript,
Err(_) => {
return CompanionTranscriptProjection {
match self.current_worker() {
Ok(Some(worker)) => {
match self
.runtime
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, start, limit)
{
Ok(transcript) => project_runtime_transcript(
&transcript,
companion_state_for_worker(&worker),
Vec::new(),
),
Err(error) => CompanionTranscriptProjection {
state: CompanionState::Error,
start,
limit,
@ -150,12 +152,34 @@ impl CompanionConsole {
diagnostics: vec![diagnostic(
"companion_transcript_unavailable",
DiagnosticSeverity::Error,
"Companion transcript is unavailable",
format!("Companion Worker transcript is unavailable: {error:?}"),
)],
};
},
}
}
Ok(None) => CompanionTranscriptProjection {
state: CompanionState::Error,
start,
limit,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic(
"companion_worker_unavailable",
DiagnosticSeverity::Error,
"Workspace Companion Worker is unavailable",
)],
},
Err(diagnostic) => CompanionTranscriptProjection {
state: CompanionState::Error,
start,
limit,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic],
},
}
};
project_transcript(&transcript, CompanionState::Ready, start, limit, Vec::new())
}
pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse {
@ -175,11 +199,66 @@ impl CompanionConsole {
));
}
self.rejected_message_response(diagnostic(
"companion_llm_not_connected",
let worker = match self.current_worker() {
Ok(Some(worker)) => worker,
Ok(None) => {
return self.rejected_message_response(diagnostic(
"companion_worker_unavailable",
DiagnosticSeverity::Error,
"Workspace Companion input is disabled until it is connected to actual Worker/LLM execution",
))
"Workspace Companion Worker is unavailable",
));
}
Err(diagnostic) => return self.rejected_message_response(diagnostic),
};
let response = self.runtime.send_input(
COMPANION_RUNTIME_ID,
&worker.worker_id,
WorkerInputRequest {
kind: WorkerInputKind::User,
content: content.clone(),
},
);
match response {
Ok(result) => {
let state = match result.state {
WorkerOperationState::Accepted => CompanionState::Accepted,
WorkerOperationState::Unsupported | WorkerOperationState::Rejected => {
CompanionState::Rejected
}
};
let diagnostics = if result.diagnostics.is_empty() {
Vec::new()
} else {
result.diagnostics.clone()
};
let projection = self.transcript(0, 200);
CompanionMessageResponse {
state,
worker: projection_worker(&self.status()),
user_item: projection
.items
.iter()
.rev()
.find(|item| item.role == "user" && item.content == content)
.cloned(),
assistant_item: projection
.items
.iter()
.rev()
.find(|item| item.role == "assistant")
.cloned(),
transcript: projection,
diagnostics,
}
}
Err(error) => self.rejected_message_response(diagnostic(
"companion_worker_input_failed",
DiagnosticSeverity::Error,
format!("Companion Worker input dispatch failed: {error:?}"),
)),
}
}
pub fn cancel(&self, _request: CompanionCancelRequest) -> CompanionMessageResponse {
@ -188,157 +267,267 @@ impl CompanionConsole {
DiagnosticSeverity::Info,
"Workspace Companion has no active generation to cancel",
)];
match self.transcript.lock() {
Ok(transcript) => response_from_locked_transcript(
&transcript,
CompanionState::Cancelled,
self.status().worker,
None,
None,
0,
200,
diagnostics,
),
Err(_) => CompanionMessageResponse {
state: CompanionState::Error,
worker: self.status().worker,
let status = self.status();
let projection = self.transcript(0, 200);
CompanionMessageResponse {
state: CompanionState::Cancelled,
worker: status.worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Error,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic(
"companion_transcript_unavailable",
DiagnosticSeverity::Error,
"Companion transcript is unavailable",
)],
},
assistant_item: projection
.items
.iter()
.rev()
.find(|item| item.role == "assistant")
.cloned(),
transcript: projection,
diagnostics,
},
}
}
fn rejected_message_response(&self, diagnostic: RuntimeDiagnostic) -> CompanionMessageResponse {
match self.transcript.lock() {
Ok(transcript) => response_from_locked_transcript(
&transcript,
CompanionState::Rejected,
self.status().worker,
None,
None,
0,
200,
vec![diagnostic],
),
Err(_) => CompanionMessageResponse {
let status = self.status();
let projection = self.transcript(0, 200);
CompanionMessageResponse {
state: CompanionState::Rejected,
worker: self.status().worker,
worker: status.worker,
user_item: None,
assistant_item: None,
transcript: CompanionTranscriptProjection {
state: CompanionState::Error,
start: 0,
limit: 200,
total_items: 0,
next_start: None,
items: Vec::new(),
diagnostics: vec![diagnostic.clone()],
},
assistant_item: projection
.items
.iter()
.rev()
.find(|item| item.role == "assistant")
.cloned(),
transcript: projection,
diagnostics: vec![diagnostic],
},
}
}
}
fn current_worker(&self) -> Result<Option<WorkerSummary>, RuntimeDiagnostic> {
self.refresh_worker_state()
.map(|state| state.worker.clone())
}
fn refresh_worker_state(&self) -> Result<CompanionWorkerState, RuntimeDiagnostic> {
let mut state = self.worker.lock().map_err(|_| {
diagnostic(
"companion_state_unavailable",
DiagnosticSeverity::Error,
"Companion state is unavailable",
)
})?;
let Some(worker_id) = state.worker.as_ref().map(|worker| worker.worker_id.clone()) else {
return Ok(CompanionWorkerState {
state: state.state,
worker: None,
diagnostics: state.diagnostics.clone(),
});
};
match self.runtime.worker(COMPANION_RUNTIME_ID, &worker_id) {
Ok(worker) => {
let mut diagnostics = if worker.capabilities.can_accept_input {
Vec::new()
} else {
state.diagnostics.clone()
};
if !worker.capabilities.can_accept_input
&& !diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "companion_worker_not_input_capable")
{
diagnostics.push(companion_not_input_capable_diagnostic(&worker));
}
state.state = companion_state_for_worker(&worker);
state.worker = Some(worker);
state.diagnostics = diagnostics;
}
Err(error) => {
state.state = CompanionState::Error;
state.diagnostics = vec![diagnostic(
"companion_worker_lookup_failed",
DiagnosticSeverity::Error,
format!("Companion Worker lookup failed: {error:?}"),
)];
}
}
Ok(CompanionWorkerState {
state: state.state,
worker: state.worker.clone(),
diagnostics: state.diagnostics.clone(),
})
}
}
fn projection_worker(status: &CompanionStatusResponse) -> Option<WorkerSummary> {
status.worker.clone()
}
fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState {
let request = WorkerSpawnRequest {
let selector = companion_profile_selector();
let mut diagnostics = Vec::new();
let config_bundle = companion_config_bundle();
let config_ref = ConfigBundleRef {
id: config_bundle.metadata.id.clone(),
digest: config_bundle.metadata.digest.clone(),
};
match runtime.sync_config_bundle(COMPANION_RUNTIME_ID, config_bundle) {
Ok(result) => diagnostics.extend(result.diagnostics),
Err(error) => diagnostics.push(diagnostic(
"companion_config_bundle_sync_failed",
DiagnosticSeverity::Error,
format!("Workspace Companion config bundle sync failed: {error:?}"),
)),
}
let response = runtime.spawn_worker(
COMPANION_RUNTIME_ID,
WorkerSpawnRequest {
intent: WorkerSpawnIntent::WorkspaceCompanion,
requested_worker_name: Some("workspace-companion".to_string()),
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: Some(ProfileSelector::RuntimeDefault),
config_bundle: None,
requested_capabilities: vec![CapabilityRequest::named("conversation")],
};
match runtime.spawn_worker(COMPANION_RUNTIME_ID, request) {
Ok(result) if result.state == WorkerOperationState::Accepted => CompanionWorkerState {
state: CompanionState::Ready,
worker: result.worker,
diagnostics: result.diagnostics,
profile: Some(selector),
config_bundle: Some(config_ref),
requested_capabilities: vec![CapabilityRequest::named("worker.input.user")],
},
Ok(result) => CompanionWorkerState {
);
match response {
Ok(response) => {
diagnostics.extend(response.diagnostics);
if let Some(worker) = response.worker {
if !worker.capabilities.can_accept_input {
diagnostics.push(companion_not_input_capable_diagnostic(&worker));
}
CompanionWorkerState {
state: companion_state_for_worker(&worker),
worker: Some(worker),
diagnostics,
}
} else {
diagnostics.push(diagnostic(
"companion_worker_missing",
DiagnosticSeverity::Error,
"Workspace Companion Worker spawn did not return a Worker projection",
));
CompanionWorkerState {
state: CompanionState::Error,
worker: result.worker,
diagnostics: result.diagnostics,
},
worker: None,
diagnostics,
}
}
}
Err(error) => CompanionWorkerState {
state: CompanionState::Error,
worker: None,
diagnostics: vec![diagnostic(
"companion_worker_spawn_failed",
DiagnosticSeverity::Error,
format!("Companion Worker spawn failed: {error:?}"),
format!("Workspace Companion Worker spawn failed: {error:?}"),
)],
},
}
}
fn response_from_locked_transcript(
transcript: &CompanionTranscript,
state: CompanionState,
worker: Option<WorkerSummary>,
user_item: Option<CompanionTranscriptItem>,
assistant_item: Option<CompanionTranscriptItem>,
start: usize,
limit: usize,
diagnostics: Vec<RuntimeDiagnostic>,
) -> CompanionMessageResponse {
CompanionMessageResponse {
state,
worker,
user_item,
assistant_item,
transcript: project_transcript(transcript, state, start, limit, diagnostics.clone()),
diagnostics,
fn companion_profile_selector() -> ProfileSelector {
ProfileSelector::Builtin(COMPANION_PROFILE_ID.to_string())
}
fn companion_config_bundle() -> ConfigBundle {
ConfigBundle {
metadata: ConfigBundleMetadata {
id: COMPANION_CONFIG_BUNDLE_ID.to_string(),
digest: String::new(),
revision: "1".to_string(),
workspace_id: "workspace-companion".to_string(),
created_at: Utc::now().to_rfc3339(),
provenance: ConfigBundleProvenance {
source: "workspace-server".to_string(),
detail: Some("workspace-companion".to_string()),
},
},
profiles: vec![ConfigProfileDescriptor {
selector: companion_profile_selector(),
label: Some("Workspace Companion".to_string()),
}],
declarations: Vec::new(),
}
.with_computed_digest()
}
fn companion_state_for_worker(worker: &WorkerSummary) -> CompanionState {
if !worker.capabilities.can_accept_input {
return CompanionState::Error;
}
match worker.status.as_str() {
"busy" | "running" | "stopping" => CompanionState::Busy,
"errored" | "error" | "stopped" | "unavailable" => CompanionState::Error,
_ => CompanionState::Ready,
}
}
fn project_transcript(
transcript: &CompanionTranscript,
fn companion_not_input_capable_diagnostic(worker: &WorkerSummary) -> RuntimeDiagnostic {
diagnostic(
"companion_worker_not_input_capable",
DiagnosticSeverity::Error,
format!(
"Workspace Companion Worker '{}' is not input-capable; check profile, provider, secret, and authority diagnostics",
worker.worker_id
),
)
}
fn project_runtime_transcript(
transcript: &WorkerTranscriptProjection,
state: CompanionState,
start: usize,
limit: usize,
diagnostics: Vec<RuntimeDiagnostic>,
) -> CompanionTranscriptProjection {
let limit = limit.min(200);
let total_items = transcript.items.len();
let end = start.saturating_add(limit).min(total_items);
let items = if start < total_items {
transcript.items[start..end].to_vec()
} else {
Vec::new()
};
CompanionTranscriptProjection {
state,
start,
limit,
total_items,
next_start: (end < total_items).then_some(end),
items,
start: transcript.start,
limit: transcript.limit,
total_items: transcript.total_items,
next_start: transcript.next_start,
items: transcript
.items
.iter()
.map(project_runtime_transcript_item)
.collect(),
diagnostics,
}
}
fn companion_transport() -> CompanionTransportSummary {
fn project_runtime_transcript_item(item: &RuntimeTranscriptItem) -> CompanionTranscriptItem {
CompanionTranscriptItem {
sequence: item.sequence,
role: item.role.clone(),
content: item.content.clone(),
created_at: format!("runtime_sequence:{}", item.sequence),
source: "worker_runtime".to_string(),
status: "committed".to_string(),
}
}
fn companion_transport(worker: Option<&WorkerSummary>) -> CompanionTransportSummary {
if worker.is_some_and(|worker| worker.capabilities.can_accept_input) {
CompanionTransportSummary {
kind: "embedded_worker_runtime".to_string(),
completion: "not_connected".to_string(),
limitation: "Workspace Companion is visible as an embedded Worker, but browser input is disabled until actual Worker/LLM execution is connected.".to_string(),
completion: "connected".to_string(),
limitation:
"Workspace Companion input is dispatched through the normal Worker runtime path."
.to_string(),
}
} else {
CompanionTransportSummary {
kind: "embedded_worker_runtime".to_string(),
completion: "not_input_capable".to_string(),
limitation:
"Workspace Companion is a Worker but is not input-capable; inspect typed diagnostics for missing profile, provider, secret, or authority."
.to_string(),
}
}
}
@ -358,52 +547,114 @@ fn diagnostic(
mod tests {
use super::*;
use crate::hosts::{EmbeddedWorkerRuntime, RuntimeRegistry};
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
use std::thread;
use std::time::{Duration, Instant};
use worker_runtime::execution::{
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionRunState,
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use worker_runtime::identity::WorkerRef;
use worker_runtime::interaction::WorkerInput;
#[derive(Default)]
struct DeterministicExecutionBackend {
contexts: StdMutex<HashMap<WorkerRef, WorkerExecutionContext>>,
}
impl WorkerExecutionBackend for DeterministicExecutionBackend {
fn backend_id(&self) -> &str {
"deterministic-companion-test"
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
self.contexts
.lock()
.unwrap()
.insert(request.worker_ref.clone(), request.context);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(
request.worker_ref.clone(),
"deterministic-companion-test",
),
run_state: WorkerExecutionRunState::Idle,
}
}
fn dispatch_input(
&self,
handle: &WorkerExecutionHandle,
request: WorkerInput,
) -> WorkerExecutionResult {
let worker = handle.worker_ref().clone();
let context = self
.contexts
.lock()
.unwrap()
.get(&worker)
.cloned()
.expect("execution context");
let content = request.content.clone();
thread::spawn(move || {
thread::sleep(Duration::from_millis(25));
let _ = context.publish_protocol_event(protocol::Event::TextDone {
text: format!("companion echoed: {content}"),
});
});
WorkerExecutionResult::accepted(
WorkerExecutionOperation::Input,
WorkerExecutionRunState::Idle,
)
}
}
#[test]
fn companion_spawns_visible_worker_without_fake_turn() {
fn companion_spawns_worker_with_companion_profile_and_diagnostic_when_not_input_capable() {
let registry =
RuntimeRegistry::for_workspace(EmbeddedWorkerRuntime::new_memory("local:test"));
let registry = Arc::new(registry);
let companion = CompanionConsole::new(registry.clone());
let status = companion.status();
assert_eq!(status.state, CompanionState::Ready);
let worker = status.worker.clone().expect("companion worker");
assert_eq!(worker.runtime_id, COMPANION_RUNTIME_ID);
assert_eq!(worker.role.as_deref(), Some("workspace_companion"));
assert!(!worker.capabilities.can_stop);
let workers = registry.list_workers(10);
assert!(!worker.capabilities.can_accept_input);
assert_eq!(status.transport.completion, "not_input_capable");
assert!(
workers
.items
status
.diagnostics
.iter()
.any(|item| item.worker_id == worker.worker_id)
.any(|diagnostic| diagnostic.code == "companion_worker_not_input_capable")
);
let response = companion.send_message(CompanionMessageRequest {
content: "hello".to_string(),
});
assert_eq!(response.state, CompanionState::Rejected);
assert!(response.transcript.items.is_empty());
assert!(
response
!response
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "companion_llm_not_connected")
);
assert!(response.transcript.items.is_empty());
let runtime_transcript = registry
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, 0, 10)
.unwrap();
assert!(runtime_transcript.items.is_empty());
let worker_detail = registry
.worker(COMPANION_RUNTIME_ID, &worker.worker_id)
.expect("worker detail");
assert_eq!(worker_detail.profile.as_deref(), Some(COMPANION_PROFILE_ID));
let browser_payload = serde_json::to_string(&(status, response)).unwrap();
let browser_payload = serde_json::to_string(&(status, response, worker_detail)).unwrap();
for forbidden in [
"/workspace/project",
"metadata.json",
".jsonl",
"/run/user/",
"session",
"manifest",
] {
assert!(
!browser_payload.contains(forbidden),
@ -411,4 +662,101 @@ mod tests {
);
}
}
#[test]
fn companion_dispatches_input_and_projects_assistant_output_from_worker_runtime() {
let registry = RuntimeRegistry::for_workspace(
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
Arc::new(DeterministicExecutionBackend::default()),
)
.expect("embedded runtime"),
);
let registry = Arc::new(registry);
let companion = CompanionConsole::new(registry.clone());
let status = companion.status();
let worker = status.worker.clone().expect("companion worker");
assert_eq!(status.transport.completion, "connected");
assert_eq!(worker.profile.as_deref(), Some(COMPANION_PROFILE_ID));
assert!(worker.capabilities.can_accept_input);
let source = registry
.observation_source(COMPANION_RUNTIME_ID, &worker.worker_id)
.expect("observation source");
let crate::observation::RuntimeObservationSource::Embedded(source) = source else {
panic!("expected embedded observation source");
};
let cursor = source
.runtime
.worker_observation_cursor_now(&source.worker_ref)
.expect("observation cursor");
let response = companion.send_message(CompanionMessageRequest {
content: "hello runtime".to_string(),
});
assert_eq!(response.state, CompanionState::Accepted);
assert!(
response
.user_item
.as_ref()
.is_some_and(|item| item.role == "user" && item.content == "hello runtime")
);
assert!(response.diagnostics.is_empty());
let deadline = Instant::now() + Duration::from_secs(2);
let observed = loop {
let observed = source
.runtime
.read_worker_observation_events(&source.worker_ref, cursor)
.expect("observation events");
if observed.iter().any(|event| {
serde_json::to_string(event)
.unwrap()
.contains("companion echoed: hello runtime")
}) {
break observed;
}
assert!(
Instant::now() < deadline,
"timed out waiting for observation event"
);
thread::sleep(Duration::from_millis(20));
};
let observed_json = serde_json::to_string(&observed).unwrap();
assert!(observed_json.contains("companion echoed: hello runtime"));
let deadline = Instant::now() + Duration::from_secs(2);
let transcript = loop {
let transcript = companion.transcript(0, 20);
if transcript.items.iter().any(|item| {
item.role == "assistant" && item.content == "companion echoed: hello runtime"
}) {
break transcript;
}
assert!(
Instant::now() < deadline,
"timed out waiting for companion assistant output: {transcript:?}"
);
thread::sleep(Duration::from_millis(20));
};
assert!(
transcript
.items
.iter()
.any(|item| item.role == "user" && item.content == "hello runtime")
);
assert!(transcript.items.iter().any(|item| {
item.role == "assistant"
&& item.source == "worker_runtime"
&& item.status == "committed"
}));
let runtime_transcript = registry
.transcript(COMPANION_RUNTIME_ID, &worker.worker_id, 0, 20)
.expect("runtime transcript");
assert!(runtime_transcript.items.iter().any(|item| {
item.role == "assistant" && item.content == "companion echoed: hello runtime"
}));
}
}

View File

@ -13,6 +13,7 @@ use worker_runtime::catalog::{
};
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
use worker_runtime::error::RuntimeError as EmbeddedRuntimeError;
use worker_runtime::execution::WorkerExecutionRunState;
use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpTranscriptResponse,
@ -903,6 +904,7 @@ pub struct EmbeddedWorkerRuntime {
runtime_id: String,
host_id: String,
runtime: worker_runtime::Runtime,
execution_enabled: bool,
}
impl EmbeddedWorkerRuntime {
@ -917,6 +919,25 @@ impl EmbeddedWorkerRuntime {
Self::from_runtime(workspace_id, runtime)
}
pub fn new_memory_with_execution_backend(
workspace_id: impl AsRef<str>,
backend: std::sync::Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
) -> Result<Self, worker_runtime::error::RuntimeError> {
let runtime_id = EmbeddedRuntimeId::new(EMBEDDED_RUNTIME_ID)
.expect("embedded runtime id is a non-empty literal");
let runtime = worker_runtime::Runtime::with_execution_backend(
EmbeddedRuntimeOptions {
runtime_id: Some(runtime_id),
display_name: Some("Workspace backend embedded Runtime".to_string()),
..EmbeddedRuntimeOptions::default()
},
backend,
)?;
let mut embedded = Self::from_runtime(workspace_id, runtime);
embedded.execution_enabled = true;
Ok(embedded)
}
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
let runtime_id = runtime
.runtime_id()
@ -927,6 +948,7 @@ impl EmbeddedWorkerRuntime {
runtime_id,
host_id: host_id_for_embedded_workspace(workspace_id.as_ref()),
runtime,
execution_enabled: false,
}
}
@ -937,6 +959,35 @@ impl EmbeddedWorkerRuntime {
))
}
fn can_accept_embedded_input(
&self,
status: EmbeddedWorkerStatus,
execution: &worker_runtime::execution::WorkerExecutionStatus,
) -> bool {
self.execution_enabled
&& status == EmbeddedWorkerStatus::Running
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected
&& execution.run_state == WorkerExecutionRunState::Idle
&& !execution_last_result_blocks_control(execution)
}
fn can_stop_embedded_worker(
&self,
status: EmbeddedWorkerStatus,
execution: &worker_runtime::execution::WorkerExecutionStatus,
) -> bool {
self.execution_enabled
&& status == EmbeddedWorkerStatus::Running
&& execution.backend == worker_runtime::execution::WorkerExecutionBackendKind::Connected
&& !matches!(
execution.run_state,
WorkerExecutionRunState::Rejected
| WorkerExecutionRunState::Errored
| WorkerExecutionRunState::Unconnected
)
&& !execution_last_result_blocks_control(execution)
}
fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary {
WorkerSummary {
runtime_id: self.runtime_id.clone(),
@ -950,15 +1001,16 @@ impl EmbeddedWorkerRuntime {
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(summary.status).to_string(),
status: embedded_worker_status_label(summary.status).to_string(),
status: embedded_worker_execution_status_label(summary.status, summary.execution.run_state)
.to_string(),
last_seen_at: None,
implementation: WorkerImplementationSummary {
kind: "embedded_worker_runtime".to_string(),
display_hint: "backend-internal worker-runtime Worker".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: false,
can_stop: false,
can_accept_input: self.can_accept_embedded_input(summary.status, &summary.execution),
can_stop: self.can_stop_embedded_worker(summary.status, &summary.execution),
can_spawn_followup: false,
},
diagnostics: vec![diagnostic(
@ -982,15 +1034,16 @@ impl EmbeddedWorkerRuntime {
identity: "runtime_registry_worker".to_string(),
},
state: embedded_worker_status_label(detail.status).to_string(),
status: embedded_worker_status_label(detail.status).to_string(),
status: embedded_worker_execution_status_label(detail.status, detail.execution.run_state)
.to_string(),
last_seen_at: None,
implementation: WorkerImplementationSummary {
kind: "embedded_worker_runtime".to_string(),
display_hint: "backend-internal worker-runtime Worker".to_string(),
},
capabilities: WorkerCapabilitySummary {
can_accept_input: false,
can_stop: false,
can_accept_input: self.can_accept_embedded_input(detail.status, &detail.execution),
can_stop: self.can_stop_embedded_worker(detail.status, &detail.execution),
can_spawn_followup: false,
},
diagnostics: vec![diagnostic(
@ -1020,7 +1073,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "unavailable".to_string(),
source: RuntimeSourceSummary::embedded_worker_runtime(),
host_ids: Vec::new(),
capabilities: embedded_runtime_capabilities(limit, false),
capabilities: embedded_runtime_capabilities(limit, false, false),
diagnostics,
};
}
@ -1040,7 +1093,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
} else {
vec![self.host_id.clone()]
},
capabilities: embedded_runtime_capabilities(limit, true),
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled),
diagnostics,
}
}
@ -1058,7 +1111,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "available".to_string(),
observed_at: Utc::now().to_rfc3339(),
last_seen_at: None,
capabilities: embedded_runtime_capabilities(limit, true),
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled),
diagnostics: vec![diagnostic(
"embedded_runtime_host_boundary",
DiagnosticSeverity::Info,
@ -1164,24 +1217,38 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
mount_refs: Vec::new(),
};
match self.runtime.create_worker(create_request) {
Ok(detail) => WorkerSpawnResult {
Ok(detail) => {
let execution_failure =
embedded_spawn_execution_failure_diagnostic(&detail.execution);
if let Some(diagnostic) = execution_failure {
diagnostics.push(diagnostic);
WorkerSpawnResult {
state: WorkerOperationState::Rejected,
worker: Some(self.map_worker_detail(detail)),
acceptance_evidence: Vec::new(),
diagnostics,
}
} else {
WorkerSpawnResult {
state: WorkerOperationState::Accepted,
worker: Some(self.map_worker_detail(detail)),
acceptance_evidence: vec![
WorkerSpawnAcceptanceEvidence {
kind: "embedded_runtime_worker_created".to_string(),
detail:
"worker-runtime catalog accepted a backend-internal tools-less Worker"
detail: "worker-runtime catalog accepted a backend-internal tools-less Worker"
.to_string(),
},
WorkerSpawnAcceptanceEvidence {
kind: "embedded_runtime_backend_internal_projection".to_string(),
detail: "only runtime_id plus worker_id backend projections were exposed"
detail:
"only runtime_id plus worker_id backend projections were exposed"
.to_string(),
},
],
diagnostics,
},
}
}
}
Err(err) => {
diagnostics.push(embedded_runtime_diagnostic(&err));
WorkerSpawnResult {
@ -1237,6 +1304,94 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
}
}
fn stop_worker(
&self,
worker_id: &str,
request: WorkerLifecycleRequest,
) -> WorkerLifecycleResult {
if !self.execution_enabled {
return embedded_lifecycle_rejected(
&self.runtime_id,
worker_id,
diagnostic(
"embedded_worker_execution_unavailable",
DiagnosticSeverity::Info,
format!("worker stop for '{worker_id}' requires an embedded execution backend"),
),
);
}
let Some(worker_ref) = self.worker_ref(worker_id) else {
return embedded_lifecycle_rejected(
&self.runtime_id,
worker_id,
diagnostic(
"embedded_worker_id_invalid",
DiagnosticSeverity::Warning,
"Worker id was empty and cannot be resolved".to_string(),
),
);
};
match self.runtime.stop_worker(&worker_ref, request.reason) {
Ok(ack) => WorkerLifecycleResult {
state: WorkerOperationState::Accepted,
runtime_id: self.runtime_id.clone(),
worker_id: worker_id.to_string(),
event_id: Some(ack.event_id),
diagnostics: Vec::new(),
},
Err(error) => embedded_lifecycle_rejected(
&self.runtime_id,
worker_id,
embedded_runtime_diagnostic(&error),
),
}
}
fn cancel_worker(
&self,
worker_id: &str,
request: WorkerLifecycleRequest,
) -> WorkerLifecycleResult {
if !self.execution_enabled {
return embedded_lifecycle_rejected(
&self.runtime_id,
worker_id,
diagnostic(
"embedded_worker_execution_unavailable",
DiagnosticSeverity::Info,
format!(
"worker cancel for '{worker_id}' requires an embedded execution backend"
),
),
);
}
let Some(worker_ref) = self.worker_ref(worker_id) else {
return embedded_lifecycle_rejected(
&self.runtime_id,
worker_id,
diagnostic(
"embedded_worker_id_invalid",
DiagnosticSeverity::Warning,
"Worker id was empty and cannot be resolved".to_string(),
),
);
};
match self.runtime.cancel_worker(&worker_ref, request.reason) {
Ok(ack) => WorkerLifecycleResult {
state: WorkerOperationState::Accepted,
runtime_id: self.runtime_id.clone(),
worker_id: worker_id.to_string(),
event_id: Some(ack.event_id),
diagnostics: Vec::new(),
},
Err(error) => embedded_lifecycle_rejected(
&self.runtime_id,
worker_id,
embedded_runtime_diagnostic(&error),
),
}
}
fn observation_source(
&self,
worker_id: &str,
@ -1255,17 +1410,53 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
))
}
fn send_input(&self, worker_id: &str, _request: WorkerInputRequest) -> WorkerInputResult {
embedded_input_rejected(
fn send_input(&self, worker_id: &str, request: WorkerInputRequest) -> WorkerInputResult {
if !self.execution_enabled {
return embedded_input_rejected(
&self.runtime_id,
worker_id,
diagnostic(
"embedded_worker_llm_not_connected",
DiagnosticSeverity::Error,
"Embedded Worker input is disabled until actual Worker/LLM execution is connected"
.to_string(),
"embedded_worker_execution_unavailable",
DiagnosticSeverity::Info,
format!(
"worker input for '{worker_id}' requires an embedded execution backend"
),
)
),
);
}
let Some(worker_ref) = self.worker_ref(worker_id) else {
return embedded_input_rejected(
&self.runtime_id,
worker_id,
diagnostic(
"embedded_worker_id_invalid",
DiagnosticSeverity::Warning,
"Worker id was empty and cannot be resolved".to_string(),
),
);
};
let input = EmbeddedWorkerInput {
kind: match request.kind {
WorkerInputKind::User => EmbeddedWorkerInputKind::User,
WorkerInputKind::System => EmbeddedWorkerInputKind::System,
},
content: request.content,
};
match self.runtime.send_input(&worker_ref, input) {
Ok(ack) => WorkerInputResult {
state: WorkerOperationState::Accepted,
runtime_id: self.runtime_id.clone(),
worker_id: worker_id.to_string(),
transcript_sequence: Some(ack.transcript_sequence),
event_id: Some(ack.event_id),
diagnostics: Vec::new(),
},
Err(error) => embedded_input_rejected(
&self.runtime_id,
worker_id,
embedded_runtime_diagnostic(&error),
),
}
}
fn transcript(
@ -1848,14 +2039,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
}
}
fn embedded_runtime_capabilities(limit: usize, available: bool) -> RuntimeCapabilitySummary {
fn embedded_runtime_capabilities(
limit: usize,
available: bool,
execution_enabled: bool,
) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: available,
can_get_worker: available,
can_spawn_worker: available,
can_stop_worker: false,
can_accept_input: false,
can_stop_worker: available && execution_enabled,
can_accept_input: available && execution_enabled,
has_workspace_fs: false,
has_shell: false,
has_git: false,
@ -1875,6 +2070,48 @@ fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str {
}
}
fn embedded_spawn_execution_failure_diagnostic(
execution: &worker_runtime::execution::WorkerExecutionStatus,
) -> Option<RuntimeDiagnostic> {
let result = execution.last_result.as_ref()?;
let severity = match result.outcome {
worker_runtime::execution::WorkerExecutionOutcome::Accepted => return None,
worker_runtime::execution::WorkerExecutionOutcome::Rejected
| worker_runtime::execution::WorkerExecutionOutcome::Busy
| worker_runtime::execution::WorkerExecutionOutcome::Unsupported => {
DiagnosticSeverity::Warning
}
worker_runtime::execution::WorkerExecutionOutcome::Errored => DiagnosticSeverity::Error,
};
let status = match result.outcome {
worker_runtime::execution::WorkerExecutionOutcome::Accepted => "accepted",
worker_runtime::execution::WorkerExecutionOutcome::Rejected => "rejected",
worker_runtime::execution::WorkerExecutionOutcome::Busy => "busy",
worker_runtime::execution::WorkerExecutionOutcome::Unsupported => "unsupported",
worker_runtime::execution::WorkerExecutionOutcome::Errored => "errored",
};
Some(diagnostic(
format!("embedded_worker_execution_spawn_{status}"),
severity,
format!(
"Embedded Worker execution spawn was {status} during setup; check runtime configuration"
),
))
}
fn execution_last_result_blocks_control(
execution: &worker_runtime::execution::WorkerExecutionStatus,
) -> bool {
execution.last_result.as_ref().is_some_and(|result| {
matches!(
result.outcome,
worker_runtime::execution::WorkerExecutionOutcome::Rejected
| worker_runtime::execution::WorkerExecutionOutcome::Errored
| worker_runtime::execution::WorkerExecutionOutcome::Unsupported
)
})
}
fn embedded_worker_status_label(status: EmbeddedWorkerStatus) -> &'static str {
match status {
EmbeddedWorkerStatus::Running => "running",
@ -1883,6 +2120,24 @@ fn embedded_worker_status_label(status: EmbeddedWorkerStatus) -> &'static str {
}
}
fn embedded_worker_execution_status_label(
status: EmbeddedWorkerStatus,
run_state: WorkerExecutionRunState,
) -> &'static str {
match status {
EmbeddedWorkerStatus::Stopped => "stopped",
EmbeddedWorkerStatus::Cancelled => "cancelled",
EmbeddedWorkerStatus::Running => match run_state {
WorkerExecutionRunState::Idle => "idle",
WorkerExecutionRunState::Busy => "running",
WorkerExecutionRunState::Stopped => "stopped",
WorkerExecutionRunState::Rejected => "rejected",
WorkerExecutionRunState::Errored => "errored",
WorkerExecutionRunState::Unconnected => "unconnected",
},
}
}
fn embedded_create_intent(intent: &WorkerSpawnIntent) -> WorkerIntent {
match intent {
WorkerSpawnIntent::WorkspaceCompanion => WorkerIntent::Role {
@ -1905,9 +2160,10 @@ fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
WorkerSpawnIntent::TicketRole { role, .. } => {
ProfileSelector::Builtin(format!("builtin:{}", ticket_role_profile_slug(role)))
}
WorkerSpawnIntent::WorkspaceCompanion | WorkerSpawnIntent::WorkspaceOrchestrator => {
ProfileSelector::RuntimeDefault
WorkerSpawnIntent::WorkspaceCompanion => {
ProfileSelector::Builtin("builtin:companion".to_string())
}
WorkerSpawnIntent::WorkspaceOrchestrator => ProfileSelector::RuntimeDefault,
}
}
@ -1967,6 +2223,20 @@ fn remote_input_rejected(
}
}
fn embedded_lifecycle_rejected(
runtime_id: &str,
worker_id: &str,
diagnostic: RuntimeDiagnostic,
) -> WorkerLifecycleResult {
WorkerLifecycleResult {
state: WorkerOperationState::Rejected,
runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(),
event_id: None,
diagnostics: vec![diagnostic],
}
}
fn remote_lifecycle_rejected(
runtime_id: &str,
worker_id: &str,
@ -2054,6 +2324,16 @@ fn embedded_runtime_diagnostic(error: &EmbeddedRuntimeError) -> RuntimeDiagnosti
DiagnosticSeverity::Warning,
"Embedded Runtime worker was not found".to_string(),
),
EmbeddedRuntimeError::WorkerExecutionUnavailable { .. } => diagnostic(
"embedded_worker_execution_unavailable",
DiagnosticSeverity::Warning,
"Embedded Worker has no execution backend attached".to_string(),
),
EmbeddedRuntimeError::WorkerExecutionRejected { .. } => diagnostic(
"embedded_worker_execution_rejected",
DiagnosticSeverity::Warning,
"Embedded Worker execution backend rejected the operation".to_string(),
),
EmbeddedRuntimeError::LimitTooLarge { requested, max } => diagnostic(
"embedded_runtime_limit_too_large",
DiagnosticSeverity::Warning,
@ -2367,9 +2647,10 @@ pub fn placeholder_spawn_response(host_id: impl Into<String>) -> WorkerSpawnResu
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
use std::io::{Read as _, Write as _};
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::thread;
fn test_config_bundle() -> ConfigBundle {
@ -2398,6 +2679,105 @@ mod tests {
.with_computed_digest()
}
struct FailingSpawnBackend;
impl worker_runtime::execution::WorkerExecutionBackend for FailingSpawnBackend {
fn backend_id(&self) -> &str {
"workspace-server-failing-spawn-backend"
}
fn spawn_worker(
&self,
_request: worker_runtime::execution::WorkerExecutionSpawnRequest,
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
worker_runtime::execution::WorkerExecutionSpawnResult::Errored(
worker_runtime::execution::WorkerExecutionResult::errored(
worker_runtime::execution::WorkerExecutionOperation::Spawn,
"provider setup failed at /tmp/secret-provider-config",
),
)
}
fn dispatch_input(
&self,
_handle: &worker_runtime::execution::WorkerExecutionHandle,
_input: EmbeddedWorkerInput,
) -> worker_runtime::execution::WorkerExecutionResult {
worker_runtime::execution::WorkerExecutionResult::rejected(
worker_runtime::execution::WorkerExecutionOperation::Input,
"spawn failed before input could be dispatched",
)
}
}
#[derive(Default)]
struct AcceptingExecutionBackend {
contexts:
Mutex<HashMap<EmbeddedWorkerRef, worker_runtime::execution::WorkerExecutionContext>>,
}
impl worker_runtime::execution::WorkerExecutionBackend for AcceptingExecutionBackend {
fn backend_id(&self) -> &str {
"workspace-server-test-backend"
}
fn spawn_worker(
&self,
request: worker_runtime::execution::WorkerExecutionSpawnRequest,
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
self.contexts
.lock()
.unwrap()
.insert(request.worker_ref.clone(), request.context);
worker_runtime::execution::WorkerExecutionSpawnResult::Connected {
handle: worker_runtime::execution::WorkerExecutionHandle::new(
request.worker_ref,
self.backend_id(),
),
run_state: WorkerExecutionRunState::Idle,
}
}
fn dispatch_input(
&self,
handle: &worker_runtime::execution::WorkerExecutionHandle,
input: EmbeddedWorkerInput,
) -> worker_runtime::execution::WorkerExecutionResult {
let context = self
.contexts
.lock()
.unwrap()
.get(handle.worker_ref())
.cloned();
let Some(context) = context else {
return worker_runtime::execution::WorkerExecutionResult::rejected(
worker_runtime::execution::WorkerExecutionOperation::Input,
"missing test context",
);
};
let content = input.content;
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(10));
let _ = context.publish_protocol_event(protocol::Event::Status {
status: protocol::WorkerStatus::Running,
});
let _ = context.publish_protocol_event(protocol::Event::TextDone {
text: format!("echo: {content}"),
});
let _ = context.publish_protocol_event(protocol::Event::RunEnd {
result: protocol::RunResult::Finished,
});
let _ = context.publish_protocol_event(protocol::Event::Status {
status: protocol::WorkerStatus::Idle,
});
});
worker_runtime::execution::WorkerExecutionResult::accepted(
worker_runtime::execution::WorkerExecutionOperation::Input,
WorkerExecutionRunState::Busy,
)
}
}
#[derive(Clone)]
struct FixtureRuntime {
runtime_id: String,
@ -2571,6 +2951,88 @@ mod tests {
));
}
fn embedded_spawn_request() -> WorkerSpawnRequest {
WorkerSpawnRequest {
intent: WorkerSpawnIntent::TicketRole {
ticket_id: "00001KVZSGT0Q".to_string(),
role: TicketWorkerRole::Coder,
},
requested_worker_name: None,
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 0,
},
profile: None,
config_bundle: None,
requested_capabilities: Vec::new(),
}
}
#[test]
fn embedded_runtime_spawn_execution_failure_is_rejected_and_not_input_capable() {
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
Arc::new(FailingSpawnBackend),
)
.expect("test backend should connect");
let spawned = runtime.spawn_worker(embedded_spawn_request());
assert_eq!(spawned.state, WorkerOperationState::Rejected);
assert!(spawned.acceptance_evidence.is_empty());
assert!(spawned.diagnostics.iter().any(|diagnostic| {
diagnostic.code == "embedded_worker_execution_spawn_errored"
&& !diagnostic.message.contains("/tmp/secret-provider-config")
}));
let worker = spawned.worker.expect("failed execution is still projected");
assert_eq!(worker.status, "errored");
assert!(!worker.capabilities.can_accept_input);
assert!(!worker.capabilities.can_stop);
}
#[test]
fn embedded_runtime_with_execution_backend_routes_input_and_projects_transcript() {
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
"local:test",
Arc::new(AcceptingExecutionBackend::default()),
)
.expect("test backend should connect");
let spawned = runtime.spawn_worker(embedded_spawn_request());
assert_eq!(spawned.state, WorkerOperationState::Accepted);
let worker = spawned.worker.expect("created embedded worker");
assert!(worker.capabilities.can_accept_input);
assert!(worker.capabilities.can_stop);
let input = runtime.send_input(
&worker.worker_id,
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "hello".to_string(),
},
);
assert_eq!(input.state, WorkerOperationState::Accepted);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
let detail = runtime
.worker(&worker.worker_id)
.worker
.expect("worker detail");
let transcript = runtime.transcript(&worker.worker_id, 0, 10);
if detail.status == "idle"
&& transcript
.items
.iter()
.any(|entry| entry.role == "assistant" && entry.content == "echo: hello")
{
assert!(detail.capabilities.can_accept_input);
break;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for embedded execution projection"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
#[test]
fn embedded_runtime_registers_routes_input_and_transcript_without_internal_leaks() {
let registry =
@ -2640,7 +3102,7 @@ mod tests {
input
.diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "embedded_worker_llm_not_connected")
.any(|diagnostic| diagnostic.code == "embedded_worker_execution_unavailable")
);
let transcript = registry
@ -2658,6 +3120,8 @@ mod tests {
"token",
"credential",
"provider",
"can_stream_events",
"can_read_bounded_transcript",
] {
assert!(
!json.contains(forbidden),
@ -3013,6 +3477,7 @@ mod tests {
"runtime_id": runtime_id,
"worker_id": worker_id,
"status": "running",
"execution": { "backend": "connected", "run_state": "idle" },
"intent": { "kind": "role", "role": "coder", "purpose": "remote test" },
"profile": { "kind": "builtin", "value": "coder" },
"config_bundle": null,

View File

@ -11,6 +11,7 @@ use axum::{Json, Router};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use worker::runtime_adapter::WorkerRuntimeExecutionBackend;
use crate::companion::{
CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse,
@ -88,6 +89,22 @@ pub struct WorkspaceApi {
impl WorkspaceApi {
pub async fn new(config: ServerConfig, store: Arc<dyn ControlPlaneStore>) -> Result<Self> {
let execution_backend = WorkerRuntimeExecutionBackend::from_workspace(
config.workspace_root.clone(),
)
.map_err(|err| {
crate::Error::Store(format!(
"failed to initialize embedded Worker backend: {err}"
))
})?;
Self::new_with_execution_backend(config, store, Arc::new(execution_backend)).await
}
async fn new_with_execution_backend(
config: ServerConfig,
store: Arc<dyn ControlPlaneStore>,
execution_backend: Arc<dyn worker_runtime::execution::WorkerExecutionBackend>,
) -> Result<Self> {
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: config.workspace_id.clone(),
@ -97,9 +114,15 @@ impl WorkspaceApi {
updated_at: config.workspace_created_at.clone(),
})
.await?;
let mut runtime = RuntimeRegistry::for_workspace(EmbeddedWorkerRuntime::new_memory(
let mut runtime = RuntimeRegistry::for_workspace(
EmbeddedWorkerRuntime::new_memory_with_execution_backend(
config.workspace_id.clone(),
));
execution_backend,
)
.map_err(|err| {
crate::Error::Store(format!("invalid embedded Worker backend: {err}"))
})?,
);
for remote_config in config.remote_runtime_sources.iter().cloned() {
runtime
.register(RemoteWorkerRuntime::new(remote_config).map_err(|err| err.into_error())?);
@ -213,7 +236,6 @@ pub async fn serve(
pub struct WorkspaceResponse {
pub workspace_id: String,
pub display_name: String,
pub local_root: PathBuf,
pub record_authority: String,
pub schema_version: i64,
pub auth: AuthConfig,
@ -232,6 +254,7 @@ pub struct ExtensionPoints {
pub struct ExtensionPointState {
pub status: String,
pub note: String,
pub diagnostics: Vec<RuntimeDiagnostic>,
}
#[derive(Debug, Serialize, Deserialize)]
@ -317,10 +340,11 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
.as_ref()
.map(|record| record.display_name.clone())
.unwrap_or_else(|| api.config.workspace_display_name.clone());
let companion_status = api.companion.status();
let companion_console = companion_console_extension_point(&companion_status);
Ok(Json(WorkspaceResponse {
workspace_id: api.config.workspace_id.clone(),
display_name,
local_root: api.config.workspace_root.clone(),
record_authority: "local_yoi_project_records".to_string(),
schema_version,
auth: api.config.auth.clone(),
@ -329,19 +353,48 @@ async fn get_workspace(State(api): State<WorkspaceApi>) -> ApiResult<Json<Worksp
event_stream: ExtensionPointState {
status: "backend_proxy".to_string(),
note: "Worker observation streams are exposed only through the Workspace server proxy keyed by runtime_id + worker_id; browser clients never receive raw Runtime endpoints or socket paths.".to_string(),
diagnostics: Vec::new(),
},
host_worker_bridge: ExtensionPointState {
status: "runtime_registry".to_string(),
note: "Hosts and Workers are projected from the Workspace RuntimeRegistry; raw Runtime endpoints, sockets, and local metadata paths are not exposed.".to_string(),
diagnostics: Vec::new(),
},
companion_console: ExtensionPointState {
status: "not_connected".to_string(),
note: "Workspace Companion is visible as an embedded Worker, but browser input is disabled until actual Worker/LLM execution is connected.".to_string(),
},
companion_console,
},
}))
}
fn companion_console_extension_point(status: &CompanionStatusResponse) -> ExtensionPointState {
let completion = status.transport.completion.clone();
let note = match completion.as_str() {
"connected" => "Workspace Companion is input-capable and browser input is dispatched through the normal Worker runtime path.".to_string(),
"not_input_capable" => {
let diagnostic_codes = status
.diagnostics
.iter()
.map(|diagnostic| diagnostic.code.as_str())
.collect::<Vec<_>>()
.join(", ");
if diagnostic_codes.is_empty() {
"Workspace Companion is not input-capable; check provider, config, profile, secret, and authority diagnostics.".to_string()
} else {
format!(
"Workspace Companion is not input-capable; check typed diagnostics: {diagnostic_codes}."
)
}
}
other => format!(
"Workspace Companion transport reports {other}; browser input follows the Companion Worker runtime capability state."
),
};
ExtensionPointState {
status: completion,
note,
diagnostics: status.diagnostics.clone(),
}
}
async fn list_tickets(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<ListResponse<crate::records::TicketSummary>>> {
@ -1021,6 +1074,64 @@ mod tests {
const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001";
const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z";
#[derive(Default)]
struct DeterministicExecutionBackend {
contexts: std::sync::Mutex<
std::collections::HashMap<
worker_runtime::identity::WorkerRef,
worker_runtime::execution::WorkerExecutionContext,
>,
>,
}
impl worker_runtime::execution::WorkerExecutionBackend for DeterministicExecutionBackend {
fn backend_id(&self) -> &str {
"deterministic-workspace-server-test"
}
fn spawn_worker(
&self,
request: worker_runtime::execution::WorkerExecutionSpawnRequest,
) -> worker_runtime::execution::WorkerExecutionSpawnResult {
self.contexts
.lock()
.unwrap()
.insert(request.worker_ref.clone(), request.context);
worker_runtime::execution::WorkerExecutionSpawnResult::Connected {
handle: worker_runtime::execution::WorkerExecutionHandle::new(
request.worker_ref,
self.backend_id(),
),
run_state: worker_runtime::execution::WorkerExecutionRunState::Idle,
}
}
fn dispatch_input(
&self,
handle: &worker_runtime::execution::WorkerExecutionHandle,
input: worker_runtime::interaction::WorkerInput,
) -> worker_runtime::execution::WorkerExecutionResult {
let context = self
.contexts
.lock()
.unwrap()
.get(handle.worker_ref())
.cloned()
.expect("execution context");
let content = input.content.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(25));
let _ = context.publish_protocol_event(protocol::Event::TextDone {
text: format!("server companion echoed: {content}"),
});
});
worker_runtime::execution::WorkerExecutionResult::accepted(
worker_runtime::execution::WorkerExecutionOperation::Input,
worker_runtime::execution::WorkerExecutionRunState::Idle,
)
}
}
fn test_identity() -> WorkspaceIdentity {
WorkspaceIdentity {
workspace_id: TEST_WORKSPACE_ID.to_string(),
@ -1053,6 +1164,24 @@ mod tests {
workspace["extension_points"]["host_worker_bridge"]["status"],
"runtime_registry"
);
let workspace_companion = &workspace["extension_points"]["companion_console"];
assert_ne!(workspace_companion["status"], "not_connected");
assert!(
!workspace_companion["note"]
.as_str()
.unwrap()
.contains("browser input remains disabled"),
"stale Companion Console note returned: {workspace_companion}"
);
if workspace_companion["status"] == "not_input_capable" {
assert!(
!workspace_companion["diagnostics"]
.as_array()
.unwrap()
.is_empty(),
"not_input_capable workspace companion_console lacks typed diagnostics: {workspace_companion}"
);
}
let tickets = get_json(app.clone(), "/api/tickets").await;
assert_eq!(tickets["items"][0]["id"], "00000000001J2");
@ -1134,15 +1263,19 @@ mod tests {
.find(|worker| worker["role"] == "workspace_companion")
.expect("companion worker is visible through runtime worker API");
assert_eq!(companion_worker["runtime_id"], "embedded-worker-runtime");
assert_eq!(companion_worker["capabilities"]["can_stop"], false);
assert!(companion_worker["capabilities"]["can_stop"].is_boolean());
let companion_status = get_json(app.clone(), "/api/companion/status").await;
assert_eq!(companion_status["state"], "ready");
assert!(matches!(
companion_status["state"].as_str(),
Some("ready") | Some("error")
));
assert_eq!(companion_status["worker"]["role"], "workspace_companion");
assert_eq!(
companion_status["transport"]["kind"],
"embedded_worker_runtime"
);
assert_ne!(companion_status["transport"]["completion"], "not_connected");
assert!(!companion_status.to_string().contains("/workspace/demo"));
let companion_message = post_json(
@ -1159,11 +1292,17 @@ mod tests {
.is_empty()
);
assert!(
companion_message["diagnostics"]
!companion_message
.to_string()
.contains("companion_llm_not_connected"),
"legacy non-execution diagnostic leaked: {companion_message}"
);
assert!(
!companion_message["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| diagnostic["code"] == "companion_llm_not_connected")
.is_empty(),
"missing typed diagnostic for non-input-capable Companion: {companion_message}"
);
assert!(!companion_message.to_string().contains("/workspace/demo"));
@ -1257,6 +1396,92 @@ mod tests {
);
}
#[tokio::test]
async fn legacy_companion_messages_route_dispatches_through_worker_runtime() {
let temp = tempfile::tempdir().unwrap();
let config = ServerConfig::local_dev(temp.path().join("workspace"), test_identity());
let api = WorkspaceApi::new_with_execution_backend(
config,
Arc::new(SqliteWorkspaceStore::in_memory().unwrap()),
Arc::new(DeterministicExecutionBackend::default()),
)
.await
.unwrap();
let app = build_router(api);
let workspace = get_json(app.clone(), "/api/workspace").await;
let workspace_companion = &workspace["extension_points"]["companion_console"];
assert_eq!(workspace_companion["status"], "connected");
assert!(
workspace_companion["diagnostics"]
.as_array()
.unwrap()
.is_empty()
);
assert!(
workspace_companion["note"]
.as_str()
.unwrap()
.contains("normal Worker runtime path")
);
let status = get_json(app.clone(), "/api/companion/status").await;
assert_eq!(status["transport"]["completion"], "connected");
let worker_id = status["worker"]["worker_id"].as_str().unwrap().to_string();
assert_eq!(status["worker"]["profile"], "builtin:companion");
let response = post_json(
app.clone(),
"/api/companion/messages",
serde_json::json!({ "content": "from legacy route" }),
)
.await;
assert_eq!(response["state"], "accepted");
assert_eq!(response["user_item"]["content"], "from legacy route");
assert!(!response.to_string().contains("companion_llm_not_connected"));
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
let transcript = loop {
let transcript = get_json(app.clone(), "/api/companion/transcript").await;
let has_assistant = transcript["items"].as_array().unwrap().iter().any(|item| {
item["role"] == "assistant"
&& item["content"] == "server companion echoed: from legacy route"
&& item["source"] == "worker_runtime"
});
if has_assistant {
break transcript;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for server companion transcript: {transcript}"
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
};
assert!(
transcript["items"]
.as_array()
.unwrap()
.iter()
.any(|item| { item["role"] == "user" && item["content"] == "from legacy route" })
);
let worker_transcript = get_json(
app,
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}/transcript"),
)
.await;
assert!(
worker_transcript["items"]
.as_array()
.unwrap()
.iter()
.any(|item| {
item["role"] == "assistant"
&& item["content"] == "server companion echoed: from legacy route"
})
);
}
#[tokio::test]
async fn embedded_runtime_api_routes_by_runtime_and_worker_ids_without_leaking_internals() {
let dir = tempfile::tempdir().unwrap();
@ -1300,7 +1525,20 @@ mod tests {
}),
)
.await;
assert_eq!(spawned["state"], "accepted");
assert_eq!(spawned["state"], "rejected");
assert!(
spawned["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| {
diagnostic["code"] == "embedded_worker_execution_spawn_errored"
&& !diagnostic["message"]
.as_str()
.unwrap()
.contains("/workspace/demo")
})
);
let worker_id = spawned["worker"]["worker_id"].as_str().unwrap().to_string();
assert_eq!(spawned["worker"]["runtime_id"], "embedded-worker-runtime");
assert_eq!(
@ -1337,7 +1575,7 @@ mod tests {
.as_array()
.unwrap()
.iter()
.any(|diagnostic| diagnostic["code"] == "embedded_worker_llm_not_connected")
.any(|diagnostic| diagnostic["code"] == "embedded_worker_execution_unavailable")
);
let transcript = get_json(

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-TPXVkDfy61HCWTfSr0boLKlFbvc6zdpRKQRUDXuPppU=";
cargoHash = "sha256-1jSDcivotZ0/v5AURQaetn9xjH5JyQNDeNlJ4AcwEUc=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -9,6 +9,7 @@ export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment };
export type ExtensionPoint = {
status: string;
note: string;
diagnostics: Diagnostic[];
};
export type WorkspaceResponse = {