From 9929d1c704a9acfd89def7fcf59a9c4b92677859 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:08:53 +0900 Subject: [PATCH 01/39] ticket: accept worker runtime execution boundary --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KW55B32Y/item.md | 4 +- .yoi/tickets/00001KW55B32Y/thread.md | 79 +++++++++++++++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 24 ++++++ .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 24 ++++++ 9 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 .yoi/tickets/00001KW55B32Y/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KW55B33B/artifacts/orchestration-plan.jsonl create mode 100644 .yoi/tickets/00001KW55B33H/artifacts/orchestration-plan.jsonl diff --git a/.yoi/tickets/00001KW55B32Y/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KW55B32Y/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..b483fb32 --- /dev/null +++ b/.yoi/tickets/00001KW55B32Y/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KW55B32Y/item.md b/.yoi/tickets/00001KW55B32Y/item.md index 43899c21..029b2b9f 100644 --- a/.yoi/tickets/00001KW55B32Y/item.md +++ b/.yoi/tickets/00001KW55B32Y/item.md @@ -1,8 +1,8 @@ --- title: 'worker-runtimeにWorker実行Backend境界を追加する' -state: 'queued' +state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:06:28Z' +updated_at: '2026-06-27T19:08:06Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:28Z' diff --git a/.yoi/tickets/00001KW55B32Y/thread.md b/.yoi/tickets/00001KW55B32Y/thread.md index e27d875f..ff6cfbc9 100644 --- a/.yoi/tickets/00001KW55B32Y/thread.md +++ b/.yoi/tickets/00001KW55B32Y/thread.md @@ -30,4 +30,83 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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 を先取りしすぎること。 + +--- + + + +## 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 に進める。 + --- diff --git a/.yoi/tickets/00001KW55B33B/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KW55B33B/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..b9ef3ed7 --- /dev/null +++ b/.yoi/tickets/00001KW55B33B/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"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"} diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index 27b2a07c..48ca72b1 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'queued' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:06:30Z' +updated_at: '2026-06-27T19:08:35Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index 859ef2fb..e16025a4 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -30,4 +30,28 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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 する。 + --- diff --git a/.yoi/tickets/00001KW55B33H/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KW55B33H/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..97335d0d --- /dev/null +++ b/.yoi/tickets/00001KW55B33H/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"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"} diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 493d691b..0db113f0 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'queued' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-27T19:06:32Z' +updated_at: '2026-06-27T19:08:35Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index fc686fb5..e6eabe35 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -30,4 +30,28 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## 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 する。 + --- From 542026523632d5688cf4dd01eeb47e7433cba797 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:09:48 +0900 Subject: [PATCH 02/39] ticket: start worker runtime execution boundary --- .yoi/tickets/00001KW55B32Y/item.md | 2 +- .yoi/tickets/00001KW55B32Y/thread.md | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B32Y/item.md b/.yoi/tickets/00001KW55B32Y/item.md index 029b2b9f..de0ea4f2 100644 --- a/.yoi/tickets/00001KW55B32Y/item.md +++ b/.yoi/tickets/00001KW55B32Y/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにWorker実行Backend境界を追加する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:08:06Z' +updated_at: '2026-06-27T19:09:41Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:28Z' diff --git a/.yoi/tickets/00001KW55B32Y/thread.md b/.yoi/tickets/00001KW55B32Y/thread.md index ff6cfbc9..3477442d 100644 --- a/.yoi/tickets/00001KW55B32Y/thread.md +++ b/.yoi/tickets/00001KW55B32Y/thread.md @@ -110,3 +110,24 @@ Ticket body / relation / workspace state / accepted plan を確認し、Worker e これ以降、worktree creation / coder Worker routing などの implementation side effect に進める。 --- + + + +## 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`. + +--- From 2d5971738478f832ba9a135601ea11dda60c565d Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:33:01 +0900 Subject: [PATCH 03/39] feat: add worker execution backend boundary --- crates/worker-runtime/src/catalog.rs | 3 + crates/worker-runtime/src/error.rs | 16 + crates/worker-runtime/src/execution.rs | 337 ++++++++++++++++ crates/worker-runtime/src/http_server.rs | 49 ++- crates/worker-runtime/src/lib.rs | 1 + crates/worker-runtime/src/runtime.rs | 480 +++++++++++++++++++++-- crates/workspace-server/src/hosts.rs | 19 +- crates/workspace-server/src/server.rs | 2 +- 8 files changed, 877 insertions(+), 30 deletions(-) create mode 100644 crates/worker-runtime/src/execution.rs diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index 85c45d37..d32623d3 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -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")] diff --git a/crates/worker-runtime/src/error.rs b/crates/worker-runtime/src/error.rs index 54484d64..1df15ba5 100644 --- a/crates/worker-runtime/src/error.rs +++ b/crates/worker-runtime/src/error.rs @@ -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 }, diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs new file mode 100644 index 00000000..46d9bba3 --- /dev/null +++ b/crates/worker-runtime/src/execution.rs @@ -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, +} + +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) -> Self { + Self { + operation, + outcome: WorkerExecutionOutcome::Busy, + run_state: WorkerExecutionRunState::Busy, + message: Some(message.into()), + } + } + + pub fn rejected(operation: WorkerExecutionOperation, message: impl Into) -> Self { + Self { + operation, + outcome: WorkerExecutionOutcome::Rejected, + run_state: WorkerExecutionRunState::Rejected, + message: Some(message.into()), + } + } + + pub fn errored(operation: WorkerExecutionOperation, message: impl Into) -> Self { + Self { + operation, + outcome: WorkerExecutionOutcome::Errored, + run_state: WorkerExecutionRunState::Errored, + message: Some(message.into()), + } + } + + pub fn unsupported(operation: WorkerExecutionOperation, message: impl Into) -> 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, +} + +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) -> 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 + + Send + + Sync, + >, +} + +impl WorkerExecutionContext { + #[cfg(feature = "ws-server")] + pub(crate) fn new( + worker_ref: WorkerRef, + observation_publisher: Arc< + dyn Fn(WorkerRef, protocol::Event) -> Result + + 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 { + (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, +} + +impl WorkerExecutionBackendRef { + pub(crate) fn new(backend: Arc) -> Result { + 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() + } +} diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 7bd2bdfb..588a03a5 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -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( 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( diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index e6fa362d..97a29e00 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -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")] diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index cde65c15..16b1283a 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -8,6 +8,11 @@ use crate::config_bundle::{ }; use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic}; use crate::error::RuntimeError; +use crate::execution::{ + WorkerExecutionBackend, WorkerExecutionBackendKind, WorkerExecutionBackendRef, + WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult, + WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, WorkerExecutionStatus, +}; #[cfg(feature = "fs-store")] use crate::fs_store::{ FsRuntimeStore, FsRuntimeStoreOptions, PersistedRuntimeState, PersistedWorkerRecord, @@ -63,6 +68,16 @@ impl Runtime { } } + /// Create a memory-backed Runtime with an attached execution backend. + pub fn with_execution_backend( + options: RuntimeOptions, + backend: Arc, + ) -> Result { + 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 +86,28 @@ impl Runtime { /// created before the Runtime is returned. #[cfg(feature = "fs-store")] pub fn with_fs_store(options: FsRuntimeStoreOptions) -> Result { + 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, + ) -> Result { + Self::with_fs_store_inner(options, Some(WorkerExecutionBackendRef::new(backend)?)) + } + + #[cfg(feature = "fs-store")] + fn with_fs_store_inner( + options: FsRuntimeStoreOptions, + execution_backend: Option, + ) -> Result { 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 +122,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 +243,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 +256,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,7 +272,14 @@ impl Runtime { state.persist_runtime_snapshot()?; state.persist_worker(&worker_id)?; state.persist_event_by_id(event_id)?; - Ok(detail) + 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. @@ -257,11 +305,11 @@ impl Runtime { worker_ref: &WorkerRef, input: WorkerInput, ) -> Result { - let mut state = self.lock()?; - state.ensure_running()?; - validate_worker_input(&input)?; - state.ensure_worker_ref(worker_ref)?; - { + 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 +317,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 +366,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 +413,103 @@ impl Runtime { }) } + fn apply_spawn_result( + &self, + worker_ref: &WorkerRef, + result: WorkerExecutionSpawnResult, + ) -> Result { + 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, ) -> Result { + self.dispatch_lifecycle_to_backend(worker_ref, WorkerExecutionOperation::Stop)?; self.transition_worker( worker_ref, WorkerStatus::Stopped, @@ -348,6 +524,7 @@ impl Runtime { worker_ref: &WorkerRef, reason: Option, ) -> Result { + self.dispatch_lifecycle_to_backend(worker_ref, WorkerExecutionOperation::Cancel)?; self.transition_worker( worker_ref, WorkerStatus::Cancelled, @@ -591,6 +768,30 @@ impl Runtime { }) } + fn install_execution_backend( + &self, + backend: Arc, + ) -> 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, RuntimeError> { self.inner.lock().map_err(|_| RuntimeError::StatePoisoned) } @@ -613,6 +814,7 @@ struct RuntimeState { persistence: RuntimePersistence, status: RuntimeStatus, limits: RuntimeLimits, + execution_backend: Option, next_worker_sequence: u64, next_event_id: u64, next_diagnostic_id: u64, @@ -637,6 +839,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 +870,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 +913,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 +929,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, @@ -1090,6 +1297,8 @@ struct WorkerRecord { worker_id: WorkerId, status: WorkerStatus, request: CreateWorkerRequest, + execution: WorkerExecutionStatus, + execution_handle: Option, transcript: Vec, next_transcript_sequence: u64, last_event_id: u64, @@ -1102,6 +1311,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 +1327,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 +1396,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 +1416,92 @@ mod tests { } } + #[derive(Default)] + struct TestExecutionBackend { + dispatch_result: Mutex>, + contexts: Mutex>, + } + + 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 { + 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) { + 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 +1659,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 { - limits: RuntimeLimits { - max_transcript_projection_items: 2, - max_event_batch_items: 16, + let runtime = Runtime::with_execution_backend( + RuntimeOptions { + limits: RuntimeLimits { + max_transcript_projection_items: 2, + max_event_batch_items: 16, + }, + ..RuntimeOptions::default() }, - ..RuntimeOptions::default() - }); + Arc::new(TestExecutionBackend::default()), + ) + .unwrap(); let detail = runtime.create_worker(task_request("chat")).unwrap(); let first = runtime @@ -1509,7 +1938,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,15 +1988,18 @@ 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 { - root: root.clone(), - runtime_id: Some(runtime_id.clone()), - display_name: Some("filesystem runtime".to_string()), - limits: RuntimeLimits { - max_transcript_projection_items: 2, - max_event_batch_items: 2, + 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()), + limits: RuntimeLimits { + max_transcript_projection_items: 2, + max_event_batch_items: 2, + }, }, - }) + Arc::new(TestExecutionBackend::default()), + ) .unwrap(); assert_eq!( runtime.summary().unwrap().backend, diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index f7ee4ea2..b2ba5548 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1260,9 +1260,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { &self.runtime_id, worker_id, diagnostic( - "embedded_worker_llm_not_connected", + "embedded_worker_execution_unavailable", DiagnosticSeverity::Error, - "Embedded Worker input is disabled until actual Worker/LLM execution is connected" + "Embedded Worker input is disabled until an execution backend is connected" .to_string(), ), ) @@ -2054,6 +2054,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, @@ -2640,7 +2650,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 +2668,8 @@ mod tests { "token", "credential", "provider", + "can_stream_events", + "can_read_bounded_transcript", ] { assert!( !json.contains(forbidden), @@ -3013,6 +3025,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, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index cca62427..8a38424e 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1337,7 +1337,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( From c242841957a6372607854806f377480ad62f04e8 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:34:29 +0900 Subject: [PATCH 04/39] ticket: record worker execution backend implementation --- .yoi/tickets/00001KW55B32Y/item.md | 2 +- .yoi/tickets/00001KW55B32Y/thread.md | 68 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B32Y/item.md b/.yoi/tickets/00001KW55B32Y/item.md index de0ea4f2..917e92f4 100644 --- a/.yoi/tickets/00001KW55B32Y/item.md +++ b/.yoi/tickets/00001KW55B32Y/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにWorker実行Backend境界を追加する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:09:41Z' +updated_at: '2026-06-27T19:34:20Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:28Z' diff --git a/.yoi/tickets/00001KW55B32Y/thread.md b/.yoi/tickets/00001KW55B32Y/thread.md index 3477442d..78ae0017 100644 --- a/.yoi/tickets/00001KW55B32Y/thread.md +++ b/.yoi/tickets/00001KW55B32Y/thread.md @@ -131,3 +131,71 @@ Queued dependency notes: - `00001KW55B33H` remains queued behind `00001KW55B33B`. --- + + + +## 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 は含めていない。 + +--- From 4240bd29bb63c860710da8a7dd766cfff36506fe Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:36:16 +0900 Subject: [PATCH 05/39] ticket: start worker execution backend review --- .yoi/tickets/00001KW55B32Y/item.md | 2 +- .yoi/tickets/00001KW55B32Y/thread.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B32Y/item.md b/.yoi/tickets/00001KW55B32Y/item.md index 917e92f4..e99c4a83 100644 --- a/.yoi/tickets/00001KW55B32Y/item.md +++ b/.yoi/tickets/00001KW55B32Y/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにWorker実行Backend境界を追加する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:34:20Z' +updated_at: '2026-06-27T19:36:13Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:28Z' diff --git a/.yoi/tickets/00001KW55B32Y/thread.md b/.yoi/tickets/00001KW55B32Y/thread.md index 78ae0017..9fba31d0 100644 --- a/.yoi/tickets/00001KW55B32Y/thread.md +++ b/.yoi/tickets/00001KW55B32Y/thread.md @@ -199,3 +199,27 @@ Residual risks / review focus: - `WorkerSummary` / `WorkerDetail` の public JSON に `execution` が増えるが、handle/path/socket/credential/session/manifest など raw internals は含めていない。 --- + + + +## 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 + +--- From b6bfad7f8bd17993875d6b5fb68c870288c169d9 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:41:14 +0900 Subject: [PATCH 06/39] ticket: request worker execution backend changes --- .yoi/tickets/00001KW55B32Y/item.md | 2 +- .yoi/tickets/00001KW55B32Y/thread.md | 32 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B32Y/item.md b/.yoi/tickets/00001KW55B32Y/item.md index e99c4a83..9a8bb9d7 100644 --- a/.yoi/tickets/00001KW55B32Y/item.md +++ b/.yoi/tickets/00001KW55B32Y/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにWorker実行Backend境界を追加する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:36:13Z' +updated_at: '2026-06-27T19:41:12Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:28Z' diff --git a/.yoi/tickets/00001KW55B32Y/thread.md b/.yoi/tickets/00001KW55B32Y/thread.md index 9fba31d0..ac9f9ec6 100644 --- a/.yoi/tickets/00001KW55B32Y/thread.md +++ b/.yoi/tickets/00001KW55B32Y/thread.md @@ -223,3 +223,35 @@ Review focus: - no scope creep into concrete `worker` crate adapter or Companion real LLM execution --- + + + +## 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。 + +--- From 761b60c85750d03c119733a088fb5073f9b37e9a Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:46:08 +0900 Subject: [PATCH 07/39] fix: initialize restored worker observations --- crates/worker-runtime/src/runtime.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 16b1283a..062005bb 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -937,6 +937,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, }) } @@ -2051,6 +2057,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); } From f7448c832e11e4ed947043e0b1f6406036c90424 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:46:48 +0900 Subject: [PATCH 08/39] ticket: record worker execution backend fix --- .yoi/tickets/00001KW55B32Y/item.md | 2 +- .yoi/tickets/00001KW55B32Y/thread.md | 38 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B32Y/item.md b/.yoi/tickets/00001KW55B32Y/item.md index 9a8bb9d7..aab3eaa8 100644 --- a/.yoi/tickets/00001KW55B32Y/item.md +++ b/.yoi/tickets/00001KW55B32Y/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにWorker実行Backend境界を追加する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:41:12Z' +updated_at: '2026-06-27T19:46:45Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:28Z' diff --git a/.yoi/tickets/00001KW55B32Y/thread.md b/.yoi/tickets/00001KW55B32Y/thread.md index ac9f9ec6..a35f785e 100644 --- a/.yoi/tickets/00001KW55B32Y/thread.md +++ b/.yoi/tickets/00001KW55B32Y/thread.md @@ -255,3 +255,41 @@ request_changes - 作業ツリーは review 後も clean。 --- + + + +## 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 の設計拡張は行っていない。 + +--- From d7d1fbbdcb4a724764594fd811775aba347d9900 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:48:34 +0900 Subject: [PATCH 09/39] ticket: approve worker execution backend --- .yoi/tickets/00001KW55B32Y/item.md | 2 +- .yoi/tickets/00001KW55B32Y/thread.md | 31 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B32Y/item.md b/.yoi/tickets/00001KW55B32Y/item.md index aab3eaa8..3d46b4f0 100644 --- a/.yoi/tickets/00001KW55B32Y/item.md +++ b/.yoi/tickets/00001KW55B32Y/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにWorker実行Backend境界を追加する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:46:45Z' +updated_at: '2026-06-27T19:48:30Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:28Z' diff --git a/.yoi/tickets/00001KW55B32Y/thread.md b/.yoi/tickets/00001KW55B32Y/thread.md index a35f785e..a8353ef7 100644 --- a/.yoi/tickets/00001KW55B32Y/thread.md +++ b/.yoi/tickets/00001KW55B32Y/thread.md @@ -293,3 +293,34 @@ Residual risk / review focus: - 今回は compile blocker の修正に限定し、backend adapter や永続 observation replay の設計拡張は行っていない。 --- + + + +## 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 のまま。 + +--- From 655c0a3ec630abe4eb232e9bfb03ebbd6d2dcb99 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 04:49:51 +0900 Subject: [PATCH 10/39] ticket: close worker execution backend --- .yoi/tickets/00001KW55B32Y/item.md | 4 +- .yoi/tickets/00001KW55B32Y/resolution.md | 28 +++++++ .yoi/tickets/00001KW55B32Y/thread.md | 94 ++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KW55B32Y/resolution.md diff --git a/.yoi/tickets/00001KW55B32Y/item.md b/.yoi/tickets/00001KW55B32Y/item.md index 3d46b4f0..844a7976 100644 --- a/.yoi/tickets/00001KW55B32Y/item.md +++ b/.yoi/tickets/00001KW55B32Y/item.md @@ -1,8 +1,8 @@ --- title: 'worker-runtimeにWorker実行Backend境界を追加する' -state: 'inprogress' +state: 'closed' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:48:30Z' +updated_at: '2026-06-27T19:49:46Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:28Z' diff --git a/.yoi/tickets/00001KW55B32Y/resolution.md b/.yoi/tickets/00001KW55B32Y/resolution.md new file mode 100644 index 00000000..2b2dcb1b --- /dev/null +++ b/.yoi/tickets/00001KW55B32Y/resolution.md @@ -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 のみを対象に実施する。 \ No newline at end of file diff --git a/.yoi/tickets/00001KW55B32Y/thread.md b/.yoi/tickets/00001KW55B32Y/thread.md index a8353ef7..6e77d98e 100644 --- a/.yoi/tickets/00001KW55B32Y/thread.md +++ b/.yoi/tickets/00001KW55B32Y/thread.md @@ -324,3 +324,97 @@ approve 作業ツリーも clean のまま。 --- + + + +## 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. + +--- + + + +## 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 + +--- + + + +## State changed + +Ticket を 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 のみを対象に実施する。 + +--- From 435863a07f975f857853c5ac20ad73d4355a53f5 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 05:09:48 +0900 Subject: [PATCH 11/39] ticket: record worker execution backend cleanup --- .yoi/tickets/00001KW55B32Y/item.md | 2 +- .yoi/tickets/00001KW55B32Y/thread.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B32Y/item.md b/.yoi/tickets/00001KW55B32Y/item.md index 844a7976..f97f1c74 100644 --- a/.yoi/tickets/00001KW55B32Y/item.md +++ b/.yoi/tickets/00001KW55B32Y/item.md @@ -2,7 +2,7 @@ title: 'worker-runtimeにWorker実行Backend境界を追加する' state: 'closed' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:49:46Z' +updated_at: '2026-06-27T20:09:45Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:28Z' diff --git a/.yoi/tickets/00001KW55B32Y/thread.md b/.yoi/tickets/00001KW55B32Y/thread.md index 6e77d98e..35ebf745 100644 --- a/.yoi/tickets/00001KW55B32Y/thread.md +++ b/.yoi/tickets/00001KW55B32Y/thread.md @@ -418,3 +418,20 @@ Validation: Cleanup は child implementation worktree / branch と related role Pods のみを対象に実施する。 --- + + + +## 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。 + +--- From e1e9fcb3265e253daa7e79245a7e7864d0a6f221 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 05:11:47 +0900 Subject: [PATCH 12/39] ticket: accept worker runtime worker adapter --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KW55B33B/item.md | 4 +- .yoi/tickets/00001KW55B33B/thread.md | 40 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KW55B33B/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KW55B33B/artifacts/orchestration-plan.jsonl index b9ef3ed7..9bab5804 100644 --- a/.yoi/tickets/00001KW55B33B/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KW55B33B/artifacts/orchestration-plan.jsonl @@ -1 +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"} diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index 48ca72b1..ed3568e4 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -1,8 +1,8 @@ --- title: 'embedded worker-runtimeをworker crate実行に接続する' -state: 'queued' +state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T19:08:35Z' +updated_at: '2026-06-27T20:11:44Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index e16025a4..45dd1a7e 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -55,3 +55,43 @@ Next action: - `00001KW55B32Y` が reviewer approve / merge / validation / done になった後に再 routing する。 --- + + + +## 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. + +--- + + + +## 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. + +--- From 1c4e7e5198b6d6f9165e4dcab91b19296adef27d Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 05:12:36 +0900 Subject: [PATCH 13/39] ticket: start worker runtime worker adapter --- .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index ed3568e4..73c43664 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T20:11:44Z' +updated_at: '2026-06-27T20:12:31Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index 45dd1a7e..ce0f352b 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -95,3 +95,27 @@ Operational note: 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. --- + + + +## 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 + +--- From 18526ee36264610048f48b07b5db50ce86852fd2 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 05:50:11 +0900 Subject: [PATCH 14/39] feat: connect runtime worker execution adapter --- Cargo.lock | 2 + crates/worker-runtime/src/runtime.rs | 65 +++ crates/worker/Cargo.toml | 5 + crates/worker/src/entrypoint.rs | 15 + crates/worker/src/lib.rs | 2 + crates/worker/src/runtime_adapter.rs | 672 ++++++++++++++++++++++++++ crates/workspace-server/Cargo.toml | 1 + crates/workspace-server/src/hosts.rs | 17 + crates/workspace-server/src/server.rs | 21 +- package.nix | 2 +- 10 files changed, 798 insertions(+), 4 deletions(-) create mode 100644 crates/worker/src/runtime_adapter.rs diff --git a/Cargo.lock b/Cargo.lock index 7d240f78..1f046047 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 062005bb..c5c57e2e 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -722,7 +722,12 @@ impl Runtime { ) -> Result { let mut state = self.lock()?; state.ensure_worker_ref(worker_ref)?; + let transcript_sequence = state.project_protocol_event_to_transcript(worker_ref, &payload); let event = state.push_worker_observation_event(worker_ref.clone(), payload); + if let Some(sequence) = transcript_sequence { + state.persist_worker(&worker_ref.worker_id)?; + state.persist_transcript_entry(&worker_ref.worker_id, sequence)?; + } Ok(event) } @@ -1259,6 +1264,66 @@ impl RuntimeState { event } + #[cfg(feature = "ws-server")] + fn append_worker_transcript_entry( + &mut self, + worker_ref: &WorkerRef, + role: TranscriptRole, + content: impl Into, + ) -> Option { + 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 { + 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, + } + } + fn push_diagnostic( &mut self, severity: DiagnosticSeverity, diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index d853cc1e..5b065cc3 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -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"] } diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 2801a25b..1ea6f81c 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -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>, diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 399dfc4c..1f89097d 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -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; diff --git a/crates/worker/src/runtime_adapter.rs b/crates/worker/src/runtime_adapter.rs new file mode 100644 index 00000000..34705657 --- /dev/null +++ b/crates/worker/src/runtime_adapter.rs @@ -0,0 +1,672 @@ +//! 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; +} + +/// 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, + pod_store_dir: Option, + profile: Option, + runtime_base_dir: Option, +} + +impl ProfileRuntimeWorkerFactory { + pub fn new(workspace_root: impl Into) -> 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) -> Self { + self.cwd = cwd.into(); + self + } + + pub fn with_store_dir(mut self, store_dir: impl Into) -> Self { + self.store_dir = Some(store_dir.into()); + self + } + + pub fn with_pod_store_dir(mut self, pod_store_dir: impl Into) -> 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) -> Self { + self.profile = Some(profile.into()); + self + } + + pub fn with_runtime_base_dir(mut self, runtime_base_dir: impl Into) -> Self { + self.runtime_base_dir = Some(runtime_base_dir.into()); + self + } + + fn store_dir(&self) -> Result { + 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 { + 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<'a>( + &'a self, + request: &'a WorkerExecutionSpawnRequest, + ) -> Option> { + if let Some(profile) = self.profile.as_deref() { + return Some(std::borrow::Cow::Borrowed(profile)); + } + match &request.request.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) => { + Some(std::borrow::Cow::Owned(format!("builtin:{name}"))) + } + } + } +} + +#[async_trait] +impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { + async fn spawn_controller( + &self, + request: WorkerExecutionSpawnRequest, + ) -> Result { + 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, +} + +/// `worker-runtime` execution backend backed by real `worker` crate Workers. +pub struct WorkerRuntimeExecutionBackend { + backend_id: String, + factory: Arc, + runtime: Mutex>, + workers: Mutex>, +} + +impl WorkerRuntimeExecutionBackend { + pub fn from_workspace(workspace_root: impl Into) -> Result { + Self::new(ProfileRuntimeWorkerFactory::new(workspace_root)) + } +} + +impl WorkerRuntimeExecutionBackend +where + F: RuntimeWorkerFactory, +{ + pub fn new(factory: F) -> Result { + 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) -> Self { + self.backend_id = backend_id.into(); + self + } + + fn wait_for_runtime_task(receiver: mpsc::Receiver>) -> Result { + receiver + .recv_timeout(RUNTIME_TASK_TIMEOUT) + .map_err(|err| format!("worker adapter task did not complete: {err}"))? + } + + fn spawn_on_adapter_runtime(&self, task: Fut) -> Result<(), String> + where + Fut: Future + 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(&self, task: Fut) -> Result + where + T: Send + 'static, + Fut: Future> + 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), 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, + ) -> 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, WorkerExecutionRunState::Idle)) + .unwrap_or_else(|message| WorkerExecutionResult::errored(operation, message)) + } +} + +impl Drop for WorkerRuntimeExecutionBackend { + 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 WorkerExecutionBackend for WorkerRuntimeExecutionBackend +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)], + }, + ); + 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) + } + + 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) + } +} + +#[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>>, + call_count: Arc, + captured: Arc>>, + } + + impl MockClient { + fn new(events: Vec) -> 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 { + Box::new(self.clone()) + } + + async fn stream( + &self, + request: Request, + ) -> Result> + 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 { + 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 { + 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 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 { .. })) + ); + } +} diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 9e6c20cd..842975df 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -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 diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index b2ba5548..667d560c 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -917,6 +917,23 @@ impl EmbeddedWorkerRuntime { Self::from_runtime(workspace_id, runtime) } + pub fn new_memory_with_execution_backend( + workspace_id: impl AsRef, + backend: std::sync::Arc, + ) -> Result { + 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, + )?; + Ok(Self::from_runtime(workspace_id, runtime)) + } + pub fn from_runtime(workspace_id: impl AsRef, runtime: worker_runtime::Runtime) -> Self { let runtime_id = runtime .runtime_id() diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 8a38424e..d9ba75c5 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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, @@ -97,9 +98,23 @@ impl WorkspaceApi { updated_at: config.workspace_created_at.clone(), }) .await?; - let mut runtime = RuntimeRegistry::for_workspace(EmbeddedWorkerRuntime::new_memory( - config.workspace_id.clone(), - )); + let execution_backend = WorkerRuntimeExecutionBackend::from_workspace( + config.workspace_root.clone(), + ) + .map_err(|err| { + crate::Error::Store(format!( + "failed to initialize embedded Worker backend: {err}" + )) + })?; + let mut runtime = RuntimeRegistry::for_workspace( + EmbeddedWorkerRuntime::new_memory_with_execution_backend( + config.workspace_id.clone(), + Arc::new(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())?); diff --git a/package.nix b/package.nix index 799b2b55..4ca9659d 100644 --- a/package.nix +++ b/package.nix @@ -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, From 2d275de3cc5a26399beaebd31269b07babc0d904 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 05:51:18 +0900 Subject: [PATCH 15/39] ticket: record worker adapter implementation --- .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 71 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index 73c43664..d01b6522 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T20:12:31Z' +updated_at: '2026-06-27T20:51:14Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index ce0f352b..ea14506c 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -119,3 +119,74 @@ Implementation focus: - leave downstream Workspace Companion real execution Ticket `00001KW55B33H` queued until this adapter is merged/validated --- + + + +## 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 してほしい。 + +--- From ca4498e001840b710d53d89dabdda5422e2b35be Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 05:51:46 +0900 Subject: [PATCH 16/39] ticket: start worker adapter review --- .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index d01b6522..3076d880 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T20:51:14Z' +updated_at: '2026-06-27T20:51:43Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index ea14506c..c1051a91 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -190,3 +190,27 @@ Residual risks / review focus: - `workspace-server` が `worker` を feature 付きで依存するため、crate layering と public API leak が意図通りかを重点 review してほしい。 --- + + + +## 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 + +--- From af1a1eb836f7da7f958b4f6c2224f7a2a11f1fca Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 05:58:09 +0900 Subject: [PATCH 17/39] ticket: request worker adapter changes --- .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 42 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index 3076d880..7ebbbccd 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T20:51:43Z' +updated_at: '2026-06-27T20:58:06Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index c1051a91..bdd671f2 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -214,3 +214,45 @@ Review focus: - no downstream Workspace Companion bootstrap/UX scope creep --- + + + +## 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 のみ。 + +--- From 9069b035041d17e7c52a454a7563cc5f0b7e1f61 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 06:20:34 +0900 Subject: [PATCH 18/39] fix: connect embedded runtime input lifecycle --- crates/worker-runtime/src/runtime.rs | 54 +++- crates/worker/src/runtime_adapter.rs | 64 ++++- crates/workspace-server/src/hosts.rs | 356 ++++++++++++++++++++++++-- crates/workspace-server/src/server.rs | 2 +- 4 files changed, 437 insertions(+), 39 deletions(-) diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index c5c57e2e..83f51cd1 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -11,7 +11,8 @@ use crate::error::RuntimeError; use crate::execution::{ WorkerExecutionBackend, WorkerExecutionBackendKind, WorkerExecutionBackendRef, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult, - WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, WorkerExecutionStatus, + WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, + WorkerExecutionStatus, }; #[cfg(feature = "fs-store")] use crate::fs_store::{ @@ -723,9 +724,13 @@ impl Runtime { 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 let Some(sequence) = transcript_sequence { + 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) @@ -1324,6 +1329,51 @@ impl RuntimeState { } } + #[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, diff --git a/crates/worker/src/runtime_adapter.rs b/crates/worker/src/runtime_adapter.rs index 34705657..22854447 100644 --- a/crates/worker/src/runtime_adapter.rs +++ b/crates/worker/src/runtime_adapter.rs @@ -123,6 +123,24 @@ impl ProfileRuntimeWorkerFactory { request.worker_ref.worker_id.to_string() } + fn runtime_profile_value( + profile: &worker_runtime::catalog::ProfileSelector, + ) -> Option> { + 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, @@ -130,15 +148,7 @@ impl ProfileRuntimeWorkerFactory { if let Some(profile) = self.profile.as_deref() { return Some(std::borrow::Cow::Borrowed(profile)); } - match &request.request.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) => { - Some(std::borrow::Cow::Owned(format!("builtin:{name}"))) - } - } + Self::runtime_profile_value(&request.request.profile) } } @@ -302,6 +312,7 @@ where operation: WorkerExecutionOperation, worker: WorkerHandle, method: Method, + accepted_run_state: WorkerExecutionRunState, ) -> WorkerExecutionResult { self.run_on_adapter_runtime(async move { worker @@ -309,7 +320,7 @@ where .await .map_err(|err| format!("failed to send Worker method: {err}")) }) - .map(|_| WorkerExecutionResult::accepted(operation, WorkerExecutionRunState::Idle)) + .map(|_| WorkerExecutionResult::accepted(operation, accepted_run_state)) .unwrap_or_else(|message| WorkerExecutionResult::errored(operation, message)) } } @@ -448,6 +459,7 @@ where Method::Run { input: vec![Segment::text(content)], }, + WorkerExecutionRunState::Busy, ); if result.outcome != worker_runtime::execution::WorkerExecutionOutcome::Accepted { busy.store(false, Ordering::SeqCst); @@ -463,7 +475,12 @@ where return result; } }; - self.send_method(WorkerExecutionOperation::Stop, worker, Method::Shutdown) + self.send_method( + WorkerExecutionOperation::Stop, + worker, + Method::Shutdown, + WorkerExecutionRunState::Stopped, + ) } fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult { @@ -474,7 +491,12 @@ where return result; } }; - self.send_method(WorkerExecutionOperation::Cancel, worker, Method::Cancel) + self.send_method( + WorkerExecutionOperation::Cancel, + worker, + Method::Cancel, + WorkerExecutionRunState::Idle, + ) } } @@ -611,6 +633,24 @@ mod tests { } } + #[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()); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 667d560c..62d0ac65 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -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 { @@ -931,7 +933,9 @@ impl EmbeddedWorkerRuntime { }, backend, )?; - Ok(Self::from_runtime(workspace_id, runtime)) + let mut embedded = Self::from_runtime(workspace_id, runtime); + embedded.execution_enabled = true; + Ok(embedded) } pub fn from_runtime(workspace_id: impl AsRef, runtime: worker_runtime::Runtime) -> Self { @@ -944,6 +948,7 @@ impl EmbeddedWorkerRuntime { runtime_id, host_id: host_id_for_embedded_workspace(workspace_id.as_ref()), runtime, + execution_enabled: false, } } @@ -954,6 +959,20 @@ impl EmbeddedWorkerRuntime { )) } + fn can_accept_embedded_input( + &self, + status: EmbeddedWorkerStatus, + run_state: WorkerExecutionRunState, + ) -> bool { + self.execution_enabled + && status == EmbeddedWorkerStatus::Running + && run_state != WorkerExecutionRunState::Busy + } + + fn can_stop_embedded_worker(&self, status: EmbeddedWorkerStatus) -> bool { + self.execution_enabled && status == EmbeddedWorkerStatus::Running + } + fn map_worker_summary(&self, summary: worker_runtime::catalog::WorkerSummary) -> WorkerSummary { WorkerSummary { runtime_id: self.runtime_id.clone(), @@ -967,15 +986,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.run_state), + can_stop: self.can_stop_embedded_worker(summary.status), can_spawn_followup: false, }, diagnostics: vec![diagnostic( @@ -999,15 +1019,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.run_state), + can_stop: self.can_stop_embedded_worker(detail.status), can_spawn_followup: false, }, diagnostics: vec![diagnostic( @@ -1037,7 +1058,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, }; } @@ -1057,7 +1078,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, } } @@ -1075,7 +1096,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, @@ -1254,6 +1275,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, @@ -1272,17 +1381,53 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { )) } - fn send_input(&self, worker_id: &str, _request: WorkerInputRequest) -> WorkerInputResult { - embedded_input_rejected( - &self.runtime_id, - worker_id, - diagnostic( - "embedded_worker_execution_unavailable", - DiagnosticSeverity::Error, - "Embedded Worker input is disabled until an execution backend is connected" - .to_string(), + 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_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( @@ -1865,14 +2010,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, @@ -1900,6 +2049,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 { @@ -1984,6 +2151,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, @@ -2394,9 +2575,10 @@ pub fn placeholder_spawn_response(host_id: impl Into) -> 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 { @@ -2425,6 +2607,74 @@ mod tests { .with_computed_digest() } + #[derive(Default)] + struct AcceptingExecutionBackend { + contexts: + Mutex>, + } + + 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, @@ -2598,6 +2848,64 @@ mod tests { )); } + #[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(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(), + }); + 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 = diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d9ba75c5..359f1da8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1149,7 +1149,7 @@ 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_eq!(companion_worker["capabilities"]["can_stop"], true); let companion_status = get_json(app.clone(), "/api/companion/status").await; assert_eq!(companion_status["state"], "ready"); From d549b4bd08304ac9095a872ea8cc8d94acee9fd2 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 06:22:05 +0900 Subject: [PATCH 19/39] ticket: record worker adapter fix --- .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 85 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index 7ebbbccd..e93fd48c 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T20:58:06Z' +updated_at: '2026-06-27T21:22:01Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index bdd671f2..0789d298 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -256,3 +256,88 @@ request_changes - Ticket 更新・merge・cleanup・ファイル変更・テスト実行はしていない。Static review のみ。 --- + + + +## 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。 + +--- From d02007c9935bf9581a80321f55ee49d33bdca8b8 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 06:27:40 +0900 Subject: [PATCH 20/39] ticket: request worker adapter spawn failure fix --- .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 35 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index e93fd48c..b976099f 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T21:22:01Z' +updated_at: '2026-06-27T21:27:37Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index 0789d298..8c5616ba 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -341,3 +341,38 @@ Fix commit: - v0 policy は引き続き queue なし。Busy 中 input は typed busy rejection。 --- + + + +## 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 を返す必要がある。 + +--- From 7e29ff5ec99dcc748fa5a511cda5bae31fec124b Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 06:39:03 +0900 Subject: [PATCH 21/39] fix: reject embedded spawn execution failures --- crates/workspace-server/src/hosts.rs | 196 +++++++++++++++++++++----- crates/workspace-server/src/server.rs | 22 ++- 2 files changed, 180 insertions(+), 38 deletions(-) diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 62d0ac65..a193ed62 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -962,15 +962,30 @@ impl EmbeddedWorkerRuntime { fn can_accept_embedded_input( &self, status: EmbeddedWorkerStatus, - run_state: WorkerExecutionRunState, + execution: &worker_runtime::execution::WorkerExecutionStatus, ) -> bool { self.execution_enabled && status == EmbeddedWorkerStatus::Running - && run_state != WorkerExecutionRunState::Busy + && 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) -> bool { - self.execution_enabled && status == EmbeddedWorkerStatus::Running + 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 { @@ -994,8 +1009,8 @@ impl EmbeddedWorkerRuntime { display_hint: "backend-internal worker-runtime Worker".to_string(), }, capabilities: WorkerCapabilitySummary { - can_accept_input: self.can_accept_embedded_input(summary.status, summary.execution.run_state), - can_stop: self.can_stop_embedded_worker(summary.status), + 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( @@ -1027,8 +1042,8 @@ impl EmbeddedWorkerRuntime { display_hint: "backend-internal worker-runtime Worker".to_string(), }, capabilities: WorkerCapabilitySummary { - can_accept_input: self.can_accept_embedded_input(detail.status, detail.execution.run_state), - can_stop: self.can_stop_embedded_worker(detail.status), + 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( @@ -1202,24 +1217,38 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { mount_refs: Vec::new(), }; match self.runtime.create_worker(create_request) { - Ok(detail) => 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" - .to_string(), - }, - WorkerSpawnAcceptanceEvidence { - kind: "embedded_runtime_backend_internal_projection".to_string(), - detail: "only runtime_id plus worker_id backend projections were exposed" - .to_string(), - }, - ], - diagnostics, - }, + 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" + .to_string(), + }, + WorkerSpawnAcceptanceEvidence { + kind: "embedded_runtime_backend_internal_projection".to_string(), + detail: + "only runtime_id plus worker_id backend projections were exposed" + .to_string(), + }, + ], + diagnostics, + } + } + } Err(err) => { diagnostics.push(embedded_runtime_diagnostic(&err)); WorkerSpawnResult { @@ -2041,6 +2070,48 @@ fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str { } } +fn embedded_spawn_execution_failure_diagnostic( + execution: &worker_runtime::execution::WorkerExecutionStatus, +) -> Option { + 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", @@ -2607,6 +2678,37 @@ 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: @@ -2848,14 +2950,8 @@ mod tests { )); } - #[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(WorkerSpawnRequest { + fn embedded_spawn_request() -> WorkerSpawnRequest { + WorkerSpawnRequest { intent: WorkerSpawnIntent::TicketRole { ticket_id: "00001KVZSGT0Q".to_string(), role: TicketWorkerRole::Coder, @@ -2867,7 +2963,37 @@ mod tests { 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); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 359f1da8..6b7944f0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1149,10 +1149,13 @@ 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"], true); + 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"], @@ -1315,7 +1318,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!( From a45797baff66c737e7a1bc5bce909889a8e48d9c Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 06:39:58 +0900 Subject: [PATCH 22/39] ticket: record worker adapter spawn failure fix --- .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 87 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index b976099f..44497aab 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T21:27:37Z' +updated_at: '2026-06-27T21:39:54Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index 8c5616ba..74886886 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -376,3 +376,90 @@ request_changes spawn execution failure は、少なくとも browser-facing API で sanitized diagnostic/rejection として扱うか、capability を false にして明確な failure projection を返す必要がある。 --- + + + +## 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` のどちらも取り得るため、既存テストはその環境差を許容する形に調整。 + +--- From 1ab270ba46f2762a0c6895a3e111d2d4a3d8db5f Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 06:42:27 +0900 Subject: [PATCH 23/39] ticket: approve worker adapter --- .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index 44497aab..ddeedc2d 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'inprogress' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T21:39:54Z' +updated_at: '2026-06-27T21:42:24Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index 74886886..0332f7d6 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -463,3 +463,25 @@ sanitized diagnostic 例: - Companion status は環境の profile/provider setup 可否により `ready` / `error` のどちらも取り得るため、既存テストはその環境差を許容する形に調整。 --- + + + +## 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 の静的確認のみで、テスト実行やファイル変更はしていない。 + +--- From 235cd88db9c0e35804f2c21a7b876646d7f4fa5a Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 06:45:04 +0900 Subject: [PATCH 24/39] ticket: close worker adapter --- .yoi/tickets/00001KW55B33B/item.md | 4 +- .yoi/tickets/00001KW55B33B/resolution.md | 35 ++++++++ .yoi/tickets/00001KW55B33B/thread.md | 108 +++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KW55B33B/resolution.md diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index ddeedc2d..0f1916b4 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -1,8 +1,8 @@ --- title: 'embedded worker-runtimeをworker crate実行に接続する' -state: 'inprogress' +state: 'closed' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T21:42:24Z' +updated_at: '2026-06-27T21:45:00Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/resolution.md b/.yoi/tickets/00001KW55B33B/resolution.md new file mode 100644 index 00000000..e4e3f487 --- /dev/null +++ b/.yoi/tickets/00001KW55B33B/resolution.md @@ -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 のみを対象に実施する。 \ No newline at end of file diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index 0332f7d6..8f7ce699 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -485,3 +485,111 @@ approve 検証は指示通り read-only の静的確認のみで、テスト実行やファイル変更はしていない。 --- + + + +## 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. + +--- + + + +## 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 + +--- + + + +## State changed + +Ticket を 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 のみを対象に実施する。 + +--- From 9c3347f0ec81b0e4587d4a1b851997404c13a2c2 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 15:02:21 +0900 Subject: [PATCH 25/39] ticket: record worker adapter cleanup --- .yoi/tickets/00001KW55B33B/item.md | 2 +- .yoi/tickets/00001KW55B33B/thread.md | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33B/item.md b/.yoi/tickets/00001KW55B33B/item.md index 0f1916b4..86248ebc 100644 --- a/.yoi/tickets/00001KW55B33B/item.md +++ b/.yoi/tickets/00001KW55B33B/item.md @@ -2,7 +2,7 @@ title: 'embedded worker-runtimeをworker crate実行に接続する' state: 'closed' created_at: '2026-06-27T18:26:46Z' -updated_at: '2026-06-27T21:45:00Z' +updated_at: '2026-06-28T06:02:16Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:30Z' diff --git a/.yoi/tickets/00001KW55B33B/thread.md b/.yoi/tickets/00001KW55B33B/thread.md index 8f7ce699..464bf898 100644 --- a/.yoi/tickets/00001KW55B33B/thread.md +++ b/.yoi/tickets/00001KW55B33B/thread.md @@ -593,3 +593,19 @@ Known non-blocking warning: Cleanup は child implementation worktree / branch と related role Pods のみを対象に実施する。 --- + + + +## 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. + +--- From 0c5a769abb213f8162ea5608b04eea36472f1151 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 15:02:59 +0900 Subject: [PATCH 26/39] ticket: accept workspace companion llm worker --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KW55B33H/item.md | 4 +- .yoi/tickets/00001KW55B33H/thread.md | 38 +++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KW55B33H/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KW55B33H/artifacts/orchestration-plan.jsonl index 97335d0d..850ac141 100644 --- a/.yoi/tickets/00001KW55B33H/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KW55B33H/artifacts/orchestration-plan.jsonl @@ -1 +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"} diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 0db113f0..071d462e 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace Companionを実LLM実行Workerとして起動する' -state: 'queued' +state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-27T19:08:35Z' +updated_at: '2026-06-28T06:02:54Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index e6eabe35..54c9ea14 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -55,3 +55,41 @@ Next action: - `00001KW55B33B` が done になった後に再 routing する。 --- + + + +## 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. + +--- + + + +## 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. + +--- From d91168825c0617a6ddf37d5078ad266f2851cacb Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 15:04:06 +0900 Subject: [PATCH 27/39] ticket: start workspace companion llm worker --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 071d462e..d7a40aa6 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T06:02:54Z' +updated_at: '2026-06-28T06:03:56Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index 54c9ea14..003937aa 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -93,3 +93,31 @@ Operational note: 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. --- + + + +## 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. + +--- From 865d1092a041335b05547968c81f6d1a3c3aeeca Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 15:09:58 +0900 Subject: [PATCH 28/39] ticket: record companion coder context error --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index d7a40aa6..5474d4a8 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T06:03:56Z' +updated_at: '2026-06-28T06:09:52Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index 003937aa..eefc8151 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -121,3 +121,18 @@ 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. --- + + + +## 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. + +--- From b43e6b7eeecb6057eef511d3021f9149f0ed6b27 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 15:20:36 +0900 Subject: [PATCH 29/39] ticket: record companion llm verification pass --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 33 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 5474d4a8..d354c552 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T06:09:52Z' +updated_at: '2026-06-28T06:20:33Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index eefc8151..c3179482 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -136,3 +136,36 @@ Operational response: - 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. --- + + + +## 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. + +--- From 05f1e1ed39e8933ce7ea3a8066b5736173019087 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 15:21:04 +0900 Subject: [PATCH 30/39] ticket: start companion llm review --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index d354c552..11974fa4 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T06:20:33Z' +updated_at: '2026-06-28T06:21:01Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index c3179482..bdfeb133 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -169,3 +169,27 @@ 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. --- + + + +## 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 + +--- From dc8d8a0df596a243bd86290cfaf007670e4f9ee3 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 15:27:12 +0900 Subject: [PATCH 31/39] ticket: request companion llm changes --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 58 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 11974fa4..90bdf0d9 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T06:21:01Z' +updated_at: '2026-06-28T06:27:08Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index bdfeb133..17451590 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -193,3 +193,61 @@ Review focus: - public `can_stream_events` / `can_read_bounded_transcript` flags are not revived --- + + + +## 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 にはならない。 + +--- From ee25cfbcfd90983a24091ef30c0128d653095003 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 15:56:00 +0900 Subject: [PATCH 32/39] fix: route workspace companion through worker runtime --- crates/workspace-server/src/companion.rs | 720 +++++++++++++++++------ crates/workspace-server/src/hosts.rs | 5 +- crates/workspace-server/src/server.rs | 169 +++++- 3 files changed, 692 insertions(+), 202 deletions(-) diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 1054cf9a..ff40c01e 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -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, - next_sequence: u64, -} - #[derive(Debug)] struct CompanionWorkerState { state: CompanionState, @@ -99,63 +100,86 @@ struct CompanionWorkerState { } pub struct CompanionConsole { + runtime: Arc, worker: Mutex, - transcript: Mutex, } impl CompanionConsole { pub fn new(runtime: Arc) -> 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 { - state: worker.state, - worker: worker.worker.clone(), - transport: companion_transport(), - diagnostics: worker.diagnostics.clone(), + match self.refresh_worker_state() { + Ok(worker) => CompanionStatusResponse { + state: worker.state, + worker: worker.worker.clone(), + 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 { - state: CompanionState::Error, - start, - limit, - total_items: 0, - next_start: None, - items: Vec::new(), - diagnostics: vec![diagnostic( - "companion_transcript_unavailable", - DiagnosticSeverity::Error, - "Companion transcript is unavailable", - )], - }; + 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, + total_items: 0, + next_start: None, + items: Vec::new(), + diagnostics: vec![diagnostic( + "companion_transcript_unavailable", + DiagnosticSeverity::Error, + format!("Companion Worker transcript is unavailable: {error:?}"), + )], + }, + } } - }; - project_transcript(&transcript, CompanionState::Ready, start, limit, Vec::new()) + 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], + }, + } } pub fn send_message(&self, request: CompanionMessageRequest) -> CompanionMessageResponse { @@ -175,11 +199,66 @@ impl CompanionConsole { )); } - self.rejected_message_response(diagnostic( - "companion_llm_not_connected", - DiagnosticSeverity::Error, - "Workspace Companion input is disabled until it is connected to actual Worker/LLM execution", - )) + 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 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, - 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", - )], - }, - diagnostics, - }, + let status = self.status(); + let projection = self.transcript(0, 200); + CompanionMessageResponse { + state: CompanionState::Cancelled, + worker: status.worker, + user_item: None, + 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 { - state: CompanionState::Rejected, - worker: self.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()], - }, - diagnostics: vec![diagnostic], - }, + let status = self.status(); + let projection = self.transcript(0, 200); + CompanionMessageResponse { + state: CompanionState::Rejected, + worker: status.worker, + user_item: None, + assistant_item: projection + .items + .iter() + .rev() + .find(|item| item.role == "assistant") + .cloned(), + transcript: projection, + diagnostics: vec![diagnostic], } } + + fn current_worker(&self) -> Result, RuntimeDiagnostic> { + self.refresh_worker_state() + .map(|state| state.worker.clone()) + } + + fn refresh_worker_state(&self) -> Result { + 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 { + status.worker.clone() } fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState { - let request = 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")], + 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.spawn_worker(COMPANION_RUNTIME_ID, request) { - Ok(result) if result.state == WorkerOperationState::Accepted => CompanionWorkerState { - state: CompanionState::Ready, - worker: result.worker, - diagnostics: result.diagnostics, - }, - Ok(result) => CompanionWorkerState { - state: CompanionState::Error, - worker: result.worker, - diagnostics: result.diagnostics, + + 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(selector), + config_bundle: Some(config_ref), + requested_capabilities: vec![CapabilityRequest::named("worker.input.user")], }, + ); + + 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: 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, - user_item: Option, - assistant_item: Option, - start: usize, - limit: usize, - diagnostics: Vec, -) -> 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, ) -> 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 { - 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(), +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: "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>, + } + + 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" + })); + } } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index a193ed62..0f754821 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -2160,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, } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 6b7944f0..2aff2549 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -89,6 +89,22 @@ pub struct WorkspaceApi { impl WorkspaceApi { pub async fn new(config: ServerConfig, store: Arc) -> Result { + 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, + execution_backend: Arc, + ) -> Result { store .upsert_workspace(&WorkspaceRecord { workspace_id: config.workspace_id.clone(), @@ -98,18 +114,10 @@ impl WorkspaceApi { updated_at: config.workspace_created_at.clone(), }) .await?; - let execution_backend = WorkerRuntimeExecutionBackend::from_workspace( - config.workspace_root.clone(), - ) - .map_err(|err| { - crate::Error::Store(format!( - "failed to initialize embedded Worker backend: {err}" - )) - })?; let mut runtime = RuntimeRegistry::for_workspace( EmbeddedWorkerRuntime::new_memory_with_execution_backend( config.workspace_id.clone(), - Arc::new(execution_backend), + execution_backend, ) .map_err(|err| { crate::Error::Store(format!("invalid embedded Worker backend: {err}")) @@ -228,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, @@ -335,7 +342,6 @@ async fn get_workspace(State(api): State) -> ApiResult, + >, + } + + 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(), @@ -1161,6 +1225,7 @@ mod tests { 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( @@ -1177,11 +1242,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")); @@ -1275,6 +1346,76 @@ 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 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(); From d09a6f5da11f95fa974d9e1534d5ae99a91a82ed Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 15:56:54 +0900 Subject: [PATCH 33/39] ticket: record companion llm implementation --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 44 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 90bdf0d9..42ebcf4b 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T06:27:08Z' +updated_at: '2026-06-28T06:56:50Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index 17451590..a3b2e461 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -251,3 +251,47 @@ request_changes - 現状の `cargo test -p yoi-workspace-server` は古い rejected Companion behavior を assert して通っている可能性があり、この Ticket の acceptance evidence にはならない。 --- + + + +## 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 には依存しない。 + +--- From f00252c8aa07ec4ad72f18ca3d159425eac52075 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 16:05:58 +0900 Subject: [PATCH 34/39] ticket: request companion workspace status fix --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 36 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 42ebcf4b..125c6219 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T06:56:50Z' +updated_at: '2026-06-28T07:05:54Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index a3b2e461..fcb0a50f 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -295,3 +295,39 @@ Residual risks / review focus: - deterministic test backend による検証のため、外部 secret/provider には依存しない。 --- + + + +## 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 にならない。 + +--- From 3be193223c7efc67636667d1ad526646da81fb63 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 16:13:33 +0900 Subject: [PATCH 35/39] fix: report companion console runtime status --- crates/workspace-server/src/server.rs | 74 ++++++++++++++++++- .../src/lib/workspace-sidebar/types.ts | 1 + 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2aff2549..ffa467b8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -254,6 +254,7 @@ pub struct ExtensionPoints { pub struct ExtensionPointState { pub status: String, pub note: String, + pub diagnostics: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -339,6 +340,8 @@ async fn get_workspace(State(api): State) -> ApiResult) -> ApiResult 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::>() + .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, ) -> ApiResult>> { @@ -1132,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"); @@ -1359,6 +1409,22 @@ mod tests { .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(); diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index 305e9da1..6702c710 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -9,6 +9,7 @@ export type { PodProtocolEvent, PodProtocolMethod, PodProtocolSegment }; export type ExtensionPoint = { status: string; note: string; + diagnostics: Diagnostic[]; }; export type WorkspaceResponse = { From 169a8d501e40661546e35e29d96c9007fad6def6 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 16:14:20 +0900 Subject: [PATCH 36/39] ticket: record companion workspace status fix --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 43 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 125c6219..cff23d99 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T07:05:54Z' +updated_at: '2026-06-28T07:14:16Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index fcb0a50f..5066a271 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -331,3 +331,46 @@ request_changes 報告された validation command は再実行していない。test/check/build success は plausibly true だが、残る stale `/api/workspace` Companion status は更新後の behavior assertion で cover されていないため、passing tests だけではこの acceptance point の evidence にならない。 --- + + + +## 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` と診断情報が表示される。 + +--- From a21a62301df8b54e368d5f321430ff7fa40256d6 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 16:18:41 +0900 Subject: [PATCH 37/39] ticket: approve workspace companion llm worker --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 36 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index cff23d99..0a7828a8 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'inprogress' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T07:14:16Z' +updated_at: '2026-06-28T07:18:37Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index 5066a271..5024a132 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -374,3 +374,39 @@ Residual risks / review focus: - Companion が provider/config/profile/secret/authority 欠落で input-capable でない環境では、`/api/workspace` でも `not_input_capable` と診断情報が表示される。 --- + + + +## 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 していることも含め、変更内容に対して妥当と判断した。 + +--- From 9c54bfe9978b192a9fbd3338fb566f6ad33c54a4 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 16:19:54 +0900 Subject: [PATCH 38/39] ticket: close workspace companion llm worker --- .yoi/tickets/00001KW55B33H/item.md | 4 +- .yoi/tickets/00001KW55B33H/resolution.md | 32 +++++++ .yoi/tickets/00001KW55B33H/thread.md | 103 +++++++++++++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KW55B33H/resolution.md diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 0a7828a8..84284ff6 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace Companionを実LLM実行Workerとして起動する' -state: 'inprogress' +state: 'closed' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T07:18:37Z' +updated_at: '2026-06-28T07:19:50Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/resolution.md b/.yoi/tickets/00001KW55B33H/resolution.md new file mode 100644 index 00000000..63ad8953 --- /dev/null +++ b/.yoi/tickets/00001KW55B33H/resolution.md @@ -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. \ No newline at end of file diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index 5024a132..33ae8ebb 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -410,3 +410,106 @@ Evidence: Reported validations は、updated server tests が Companion Console workspace status と runtime dispatch behavior を cover していることも含め、変更内容に対して妥当と判断した。 --- + + + +## 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. + +--- + + + +## 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 + +--- + + + +## State changed + +Ticket を 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. + +--- From e5b539faee27bfdecf6b5e5749fc5aa38d369121 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 28 Jun 2026 16:20:27 +0900 Subject: [PATCH 39/39] ticket: record companion llm cleanup --- .yoi/tickets/00001KW55B33H/item.md | 2 +- .yoi/tickets/00001KW55B33H/thread.md | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KW55B33H/item.md b/.yoi/tickets/00001KW55B33H/item.md index 84284ff6..6a2bd485 100644 --- a/.yoi/tickets/00001KW55B33H/item.md +++ b/.yoi/tickets/00001KW55B33H/item.md @@ -2,7 +2,7 @@ title: 'Workspace Companionを実LLM実行Workerとして起動する' state: 'closed' created_at: '2026-06-27T18:26:47Z' -updated_at: '2026-06-28T07:19:50Z' +updated_at: '2026-06-28T07:20:22Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-27T19:06:32Z' diff --git a/.yoi/tickets/00001KW55B33H/thread.md b/.yoi/tickets/00001KW55B33H/thread.md index 33ae8ebb..ae541ac3 100644 --- a/.yoi/tickets/00001KW55B33H/thread.md +++ b/.yoi/tickets/00001KW55B33H/thread.md @@ -513,3 +513,25 @@ 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. --- + + + +## 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` + +---