merge: orchestration

This commit is contained in:
Keisuke Hirata 2026-07-11 13:44:15 +09:00
commit b6bb22f956
No known key found for this signature in database
32 changed files with 1796 additions and 292 deletions

View File

@ -1,2 +1,3 @@
{"id":"orch-plan-20260710-220020-1","ticket_id":"00001KX6WVNPD","kind":"after","related_ticket":"00001KX6Y2A9Q","note":"Ticket has explicit typed `depends_on` relation to `00001KX6Y2A9Q`. That prerequisite is currently `inprogress` implementing the WorkerFilesystemAuthority boundary this Ticket relies on. Start only after `00001KX6Y2A9Q` is approved, merged, and closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T22:00:20Z"} {"id":"orch-plan-20260710-220020-1","ticket_id":"00001KX6WVNPD","kind":"after","related_ticket":"00001KX6Y2A9Q","note":"Ticket has explicit typed `depends_on` relation to `00001KX6Y2A9Q`. That prerequisite is currently `inprogress` implementing the WorkerFilesystemAuthority boundary this Ticket relies on. Start only after `00001KX6Y2A9Q` is approved, merged, and closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T22:00:20Z"}
{"id":"orch-plan-20260710-220024-2","ticket_id":"00001KX6WVNPD","kind":"waiting_capacity_note","note":"Dashboard queue routing inspected. This Ticket is otherwise concrete, but typed relation metadata reports blocker `00001KX6Y2A9Q` in state `inprogress`. That prerequisite introduces/removes the Worker filesystem-authority/cwd boundary used by this Ticket. Starting now would duplicate or conflict with that refactor. Leave queued and re-route after prerequisite merge/close.","author":"orchestrator","at":"2026-07-10T22:00:24Z"} {"id":"orch-plan-20260710-220024-2","ticket_id":"00001KX6WVNPD","kind":"waiting_capacity_note","note":"Dashboard queue routing inspected. This Ticket is otherwise concrete, but typed relation metadata reports blocker `00001KX6Y2A9Q` in state `inprogress`. That prerequisite introduces/removes the Worker filesystem-authority/cwd boundary used by this Ticket. Starting now would duplicate or conflict with that refactor. Leave queued and re-route after prerequisite merge/close.","author":"orchestrator","at":"2026-07-10T22:00:24Z"}
{"id":"orch-plan-20260710-233758-3","ticket_id":"00001KX6WVNPD","kind":"accepted_plan","accepted_plan":{"summary":"Implement embedded Runtime no-workdir Worker creation policy using the merged `WorkerFilesystemAuthority` boundary: embedded Runtime may create Workers without selected workdir using `WorkerFilesystemAuthority::None`, non-embedded/local-authority paths preserve workdir requirements, no-workdir Workers expose no filesystem/Bash tool surface, and UI/API make workdir optional only for embedded Runtime.","branch":"work/00001KX6WVNPD-embedded-no-workdir-policy","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6WVNPD-embedded-no-workdir-policy","role_plan":"Orchestrator accepts queued Ticket after prerequisite `00001KX6Y2A9Q` was approved, merged, validated, and closed. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/worktree cleanup authority. Per current user instruction, do not call StopPod during cleanup."},"author":"orchestrator","at":"2026-07-10T23:37:58Z"}

View File

@ -1,9 +1,9 @@
--- ---
title: 'Embedded no-workdir Worker authority policy' title: 'Embedded no-workdir Worker authority policy'
state: 'queued' state: 'closed'
priority: 'P1' priority: 'P1'
created_at: '2026-07-10T20:52:43Z' created_at: '2026-07-10T20:52:43Z'
updated_at: '2026-07-10T22:00:33Z' updated_at: '2026-07-11T00:13:51Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-07-10T22:00:07Z' queued_at: '2026-07-10T22:00:07Z'

View File

@ -0,0 +1,28 @@
Embedded no-workdir Worker authority policy を実装・レビュー・merge・検証した。
実装内容:
- Runtime launch metadata に `working_directory_required` を追加し、embedded Runtime のみ workdir optional として扱うようにした。
- Workspace/API Worker create path で、embedded Runtime では no-workdir Worker creation を許可し、non-embedded Runtime では no-workdir creation を typed diagnostic で拒否するようにした。
- Embedded no-workdir launch は Runtime へ working directory なしで渡され、merged `WorkerFilesystemAuthority` 境界により `WorkerFilesystemAuthority::None` になる。
- Workdir-present embedded Worker は materialized binding を `WorkerFilesystemAuthority::Local` として保持する既存 behavior を維持。
- Browser Worker creation UI で embedded Runtime の場合だけ “No working directory — guidance-only Worker; filesystem tools are disabled” を選べるようにした。
- No-workdir launch では relative cwd input を隠し、filesystem tools / Bash が disabled であることを明示。
- Tests で launch metadata、non-embedded no-workdir rejection、embedded no-workdir request construction、および existing runtime tests による no-workdir tool omission/no cwd fallback を確認。
Review:
- Reviewer approved with no blockers。
- Evidence included API optionality bounds, non-embedded rejection, authority construction path, no filesystem/Bash tool surface coverage, workdir-present behavior, and Browser labeling。
Merge / validation:
- Merge commit: `2f7b8094 merge: embedded no-workdir worker policy`
- Final validation passed:
- `rg 'worker\\.cwd\\(' . || true`
- `git diff --check`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/embedded-no-workdir-final-validation-1783728763.txt`

View File

@ -88,3 +88,223 @@ Next action:
- Escalate only if a human explicitly requests combined implementation in the same branch/worktree。 - Escalate only if a human explicitly requests combined implementation in the same branch/worktree。
--- ---
<!-- event: decision author: orchestrator at: 2026-07-10T23:38:28Z -->
## Decision
Routing decision: implementation_ready
Reason:
- 前提 Ticket `00001KX6Y2A9Q` は approved / merged / final-validated / closed 済みで、`WorkerFilesystemAuthority::{None, Local}` と no-workdir core filesystem/Bash tool omission boundary が利用可能になった。
- この Ticket は embedded Runtime の workdir 未指定 Worker 作成 policy と UI/API 制約に集中しており、目的・binding decision・受け入れ条件が具体的である。
- `TicketRelationQuery(00001KX6WVNPD)` の blocker view は空で、残る outgoing `depends_on 00001KX6Y2A9Q` は closed prerequisite になっている。
- `TicketOrchestrationPlanQuery(00001KX6WVNPD)` の既存 waiting note は prerequisite inprogress を理由にしたものだったため、現在は解消済み。
- Orchestrator worktree は clean。既存 child implementation branch/worktree は見当たらない。
Evidence checked:
- Ticket body / thread / relations / orchestration plan。
- Related closed Ticket `00001KX6Y2A9Q` resolution/review/validation evidence。
- `TicketList(inprogress)`: 0 件。
- `git status --short --branch` and `git worktree list` in `/home/hare/Projects/yoi/.worktree/orchestration`
- Current queue also contains related `00001KX6Y6ZEA`, but its Worker workspace-backend refactor touches overlapping Worker context/constructor surfaces; it will be held separately with a conflict/migration-boundary reason rather than started in parallel。
IntentPacket:
Intent:
- Embedded Runtime 選択時に、workdir 未指定の guidance/conversation-oriented Worker を作成できるようにする。
- Embedded no-workdir Worker は `WorkerFilesystemAuthority::None` として作られ、repository filesystem authority / cwd fallback / filesystem tools / Bash を持たない。
Binding decisions / invariants:
- no-workdir authority は merged `WorkerFilesystemAuthority::None` を使う。
- workspace root / process cwd / runtime cwd / manifest override / default scope を filesystem authority fallback にしない。
- filesystem tools (`Read`, `Write`, `Edit`, `Glob`, `Grep`) と `Bash` は no-workdir embedded Worker では construct/register/model-visible にしない。
- Bash は filesystem sandbox ではないため、no-workdir embedded では登録しない。
- non-embedded または local/filesystem-authority-required launch path では、既存の workdir 必須制約または明示 authority requirement を維持する。
- Worker 直下の `cwd` property / `worker.cwd()` 前提に戻さない。
Requirements / acceptance criteria:
- UI/API 経由で embedded Runtime の workdir 未指定 Worker create ができる。
- embedded + workdir ありは materialized binding を `WorkerFilesystemAuthority::Local` と Worker summary/detail に反映する。
- embedded + workdir なしは `WorkerFilesystemAuthority::None` になり、model-visible tool surface に fs tools / Bash が出ないことをテストで確認できる。
- `Glob` / `Grep` path 省略や `Bash` initial directory が no-workdir embedded の権限漏れにならない。
- Browser/UI では workdir optional が embedded Runtime に限られ、non-embedded/local authority path の制約を緩めない。
Implementation latitude:
- API DTO / UI form model / runtime spawn request shape の具体的な整理は coder が既存 pattern に沿って選んでよい。
- workdir 未指定状態の UI 表示文言・validation placement は、binding invariant を満たす範囲で local tactic としてよい。
- tests の配置は worker-runtime / workspace-server / web の既存近接 test pattern に合わせてよい。
Escalate if:
- embedded no-workdir Worker に workspace API access を付けるために `WorkspaceBackend` / `WorkspaceClient` の先行導入が必須になる場合。
- no-workdir Worker へ fs/Bash tool の error-only stub や empty-scope fallback を登録したくなる場合。
- non-embedded Worker の workdir requirement を緩める必要が出る場合。
- `WorkerFilesystemAuthority` の既存 invariant を変更する必要が出る場合。
Validation:
- `git diff --check`
- focused grep/check that no no-workdir path reintroduces `worker.cwd()` or local cwd fallback。
- `cargo test -p worker --lib --tests` if worker crate is touched。
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check && deno task test` if web/API/types are touched。
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Current code map / likely touch points:
- `crates/worker/src/worker.rs`, `crates/worker/src/controller.rs` for existing authority boundary verification only if needed。
- `crates/worker-runtime/src/worker_backend.rs` / working-directory binding and embedded spawn paths。
- `crates/workspace-server/src/server.rs` / API DTOs for Worker create/runtime options。
- `web/workspace` worker creation UI/types if workdir optionality is exposed there。
Critical risks / reviewer focus:
- Hidden filesystem authority through workspace root / process cwd / runtime cwd fallback。
- no-workdir Worker showing fs tools/Bash in model-visible schema。
- empty-scope/error-only fs tool registration rather than no registration。
- UI/API accidentally allowing non-embedded no-workdir Worker where local filesystem authority is still required。
- regression of embedded workdir-present Worker status/tool cwd behavior。
---
<!-- event: state_changed author: orchestrator at: 2026-07-10T23:38:34Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
## State changed
Prerequisite `00001KX6Y2A9Q` is now closed and merged. This Ticket is accepted for implementation before creating a worktree or spawning role Pods.
---
<!-- event: implementation_report author: orchestrator at: 2026-07-11T00:00:55Z -->
## Implementation report
Implementation progress report:
- Coder Pod `yoi-coder-00001KX6WVNPD-no-workdir-policy` completed an implementation pass in `/home/hare/Projects/yoi/.worktree/00001KX6WVNPD-embedded-no-workdir-policy` on branch `work/00001KX6WVNPD-embedded-no-workdir-policy`
- Implementation commit: `10267869ae0dfe02e186dc69fec14151926f5009 feat: enforce embedded no-workdir worker policy`
- Files touched:
- `crates/workspace-server/src/server.rs`
- `web/workspace/src/lib/workspace-sidebar/types.ts`
- `web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts`
- `web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte`
- Orchestrator inspected worktree status, branch log, commit stats, `git diff --check 83c9d884..HEAD`, and `rg 'worker\\.cwd\\(\\)' .`; worktree was clean, diff check passed, and no `worker.cwd()` matches were found。
Implementation summary:
- Added Browser/API launch metadata for whether a runtime requires working directory selection。
- Embedded Runtime can be marked as workdir-optional; non-embedded Runtime remains workdir-required。
- Server-side creation path rejects no-workdir Worker creation unless the target runtime is embedded, using a typed diagnostic。
- Browser Worker creation UI offers “no workdir / embedded conversation only” only when the selected runtime allows it。
- Relative cwd input is hidden for no-workdir launch and UI text explains no filesystem tools or Bash are available without workdir。
- Tests were added/updated for launch metadata, non-embedded no-workdir rejection, and web request construction for embedded no-workdir launch。
Next action:
- Route to external Reviewer Pod before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-11T00:12:24Z status: approve -->
## Review: approve
External review result: approve
Evidence:
- UI/API optionality is correctly bounded: launch options expose `working_directory_required` and set it false only for embedded runtime (`crates/workspace-server/src/server.rs:749`, `:3823`), while browser request construction omits `working_directory` for embedded no-workdir mode and tests cover that (`web/workspace/src/lib/workspace-sidebar/worker-launch.test.ts:80`)。
- Non-embedded no-workdir is rejected in workspace/API launch paths (`server.rs:2639`, `:2839`) with tests covering the 400 behavior (`server.rs:6383`, `:6554`)。
- Authority boundary is preserved: absent materialized workdir reaches runtime as no working directory (`server.rs:2652`) and worker-runtime maps that to `WorkerFilesystemAuthority::None`; workdir-present maps to `Local(root, cwd)` (`crates/worker-runtime/src/worker_backend.rs:301`)。
- Tool surface/no fallback is covered by existing runtime tests asserting no cwd observation and omission of `Read`/`Write`/`Edit`/`Glob`/`Grep`/`Bash` for no-workdir workers (`worker_backend.rs:1151`, `:1216`)。
- Workdir-present behavior remains covered by materialized binding assertions (`worker_backend.rs:1151`) and server worker creation still links/summarizes workdir-present workers。
- Browser labeling is explicit: embedded mode offers “No working directory — guidance-only Worker; filesystem tools are disabled” and non-embedded paths require selection (`+page.svelte:153`, `:289`)。
Validation performed by reviewer:
- `cargo test -p yoi-workspace-server no_workdir -- --nocapture`
- `cargo test -p worker-runtime --features ws-server --lib -- --nocapture`
- `cd web/workspace && deno task test`
- Final `git status --short` was clean。
Blockers: none。
Non-blocking notes: none。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-11T00:13:31Z -->
## Implementation report
Merge / final validation report:
- Reviewer approved the implementation with no blockers。
- Merged branch `work/00001KX6WVNPD-embedded-no-workdir-policy` into orchestration branch with merge commit `2f7b8094 merge: embedded no-workdir worker policy`
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
Validation passed:
- `rg 'worker\\.cwd\\(' . || true`: no matches。
- `git diff --check`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/embedded-no-workdir-final-validation-1783728763.txt`
Reviewed/merged implementation commit:
- `10267869 feat: enforce embedded no-workdir worker policy`
---
<!-- event: state_changed author: orchestrator at: 2026-07-11T00:13:37Z from: inprogress to: done reason: implementation_validated field: state -->
## State changed
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
---
<!-- event: state_changed author: hare at: 2026-07-11T00:13:51Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-11T00:13:51Z status: closed -->
## 完了
Embedded no-workdir Worker authority policy を実装・レビュー・merge・検証した。
実装内容:
- Runtime launch metadata に `working_directory_required` を追加し、embedded Runtime のみ workdir optional として扱うようにした。
- Workspace/API Worker create path で、embedded Runtime では no-workdir Worker creation を許可し、non-embedded Runtime では no-workdir creation を typed diagnostic で拒否するようにした。
- Embedded no-workdir launch は Runtime へ working directory なしで渡され、merged `WorkerFilesystemAuthority` 境界により `WorkerFilesystemAuthority::None` になる。
- Workdir-present embedded Worker は materialized binding を `WorkerFilesystemAuthority::Local` として保持する既存 behavior を維持。
- Browser Worker creation UI で embedded Runtime の場合だけ “No working directory — guidance-only Worker; filesystem tools are disabled” を選べるようにした。
- No-workdir launch では relative cwd input を隠し、filesystem tools / Bash が disabled であることを明示。
- Tests で launch metadata、non-embedded no-workdir rejection、embedded no-workdir request construction、および existing runtime tests による no-workdir tool omission/no cwd fallback を確認。
Review:
- Reviewer approved with no blockers。
- Evidence included API optionality bounds, non-embedded rejection, authority construction path, no filesystem/Bash tool surface coverage, workdir-present behavior, and Browser labeling。
Merge / validation:
- Merge commit: `2f7b8094 merge: embedded no-workdir worker policy`
- Final validation passed:
- `rg 'worker\\.cwd\\(' . || true`
- `git diff --check`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/embedded-no-workdir-final-validation-1783728763.txt`
---

View File

@ -1,9 +1,9 @@
--- ---
title: 'Implement WorkerFilesystemAuthority for no-workdir Workers' title: 'Implement WorkerFilesystemAuthority for no-workdir Workers'
state: 'inprogress' state: 'closed'
priority: 'P1' priority: 'P1'
created_at: '2026-07-10T21:13:49Z' created_at: '2026-07-10T21:13:49Z'
updated_at: '2026-07-10T21:51:33Z' updated_at: '2026-07-10T22:45:01Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-07-10T21:51:00Z' queued_at: '2026-07-10T21:51:00Z'

View File

@ -0,0 +1,32 @@
Worker filesystem authority の明示境界を実装・レビュー・merge・検証した。
実装内容:
- `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` を追加。
- `LocalWorkingDirectory` は filesystem authority `root` と default `cwd` を分離して保持する。
- Worker-level `cwd: PathBuf` field と `worker.cwd()` accessor を削除。
- core filesystem/Bash tool registration は local filesystem authority がある場合だけ行うようにした。
- No-workdir Worker では `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` を construct/register/model-visible にしない。
- Embedded no-workdir Worker spawn は Runtime/process cwd fallback ではなく `WorkerFilesystemAuthority::None` を明示的に渡す。
- Ticket / worker-spawn / peer-discovery など cwd 依存箇所は local filesystem authority 必須に整理し、workspace root fallback を authority として使わないようにした。
- tests で no-workdir tool omission、no cwd fallback、local/materialized Worker の filesystem/Bash behavior を確認。
Review:
- Reviewer approved with no blockers。
- Evidence included explicit authority type, no Worker cwd field/accessor, filesystem/Bash registration gate, embedded Runtime authority construction, and tests。
- Non-blocking note: workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this is outside the focused core filesystem/Bash authority gate and may become follow-up if the invariant expands to “no local file reads of any kind”。
Merge / validation:
- Merge commit: `c29e9135 merge: worker filesystem authority`
- Final validation passed:
- `rg "worker\\.cwd\\(" crates || true`
- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`
- `git diff --check`
- `cargo test -p worker --lib --tests`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-fs-authority-final-validation-1783723253.txt`

View File

@ -101,3 +101,158 @@ Implementation focus:
Dashboard queue authorization was inspected, no blockers were found, and an accepted plan was recorded. Moving queued Ticket to inprogress before creating implementation worktree or spawning Pods. Dashboard queue authorization was inspected, no blockers were found, and an accepted plan was recorded. Moving queued Ticket to inprogress before creating implementation worktree or spawning Pods.
--- ---
<!-- event: implementation_report author: orchestrator at: 2026-07-10T22:31:16Z -->
## Implementation report
Implementation progress report:
- Coder Pod `yoi-coder-00001KX6Y2A9Q-worker-fs-authority` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KX6Y2A9Q-worker-filesystem-authority` on branch `work/00001KX6Y2A9Q-worker-filesystem-authority`
- Implementation commit: `b50b94612a1185dc5bdc935d83d63f9d6ace826b feat: add explicit worker filesystem authority`
- Orchestrator inspected the worktree status, branch log, commit stats, `git diff --check db3a7165..HEAD`, and grep checks for `worker.cwd()` / Worker `fn cwd`; worktree was clean, diff check passed, and no matches were found。
Coder-reported implementation summary:
- Added explicit `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` boundary。
- Removed Worker-level `cwd: PathBuf` and `worker.cwd()` accessor。
- Local Workers carry authority `root` and `cwd` separately。
- No-workdir embedded Worker spawn passes `WorkerFilesystemAuthority::None` explicitly instead of falling back to runtime/process cwd。
- Controller tool registration branches on local filesystem authority:
- Local Workers register `Read`, `Write`, `Edit`, `Glob`, `Grep`, `Bash` and get fs view/completions。
- No-workdir Workers do not construct/register filesystem or Bash tools。
- Ticket / worker-spawn / peer-discovery cwd-dependent paths now require local filesystem authority instead of using workspace root as fallback。
- Tests added/updated for no-workdir tool omission, no cwd fallback, and local/materialized Worker filesystem/Bash behavior。
Coder-reported validation passed:
- `cargo test -p worker --lib --tests`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `git diff --check`
- `cargo run -p yoi -- ticket doctor`
- `nix build .#yoi --no-link`
- `rg "worker\\.cwd\\(" crates || true`: no matches。
- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`: no Worker cwd accessor remains。
Not run by Coder:
- `cd web/workspace && deno task check && deno task test` because web/API/types were not touched。
Next action:
- Route to external Reviewer Pod before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-10T22:40:35Z status: approve -->
## Review: approve
External review result: approve
Evidence:
- `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` and separate `root`/`cwd` are present in `crates/worker/src/worker.rs`; `Worker` now stores `filesystem_authority` plus separate `workspace_root`, with no `cwd: PathBuf` Worker field。
- Core filesystem/Bash registration is gated on `worker.local_working_directory()` in `crates/worker/src/controller.rs:619-636`; no-local Workers return `(None, None)` and do not construct/register `Read`/`Write`/`Edit`/`Glob`/`Grep`/`Bash`。
- Ticket and worker-spawn surfaces require local authority rather than falling back to `workspace_root` (`controller.rs:647-667`, `719-733`)。
- Embedded runtime creates `WorkerFilesystemAuthority::Local(binding.root, binding.cwd)` only when a working directory binding exists, otherwise `WorkerFilesystemAuthority::None` (`crates/worker-runtime/src/worker_backend.rs:297-310`)。
- Tests cover no-workdir omission and no cwd fallback via empty observed materialized cwds plus forbidden core tool names (`worker_backend.rs:1133-1201`), and materialized local Workers exposing core tools with materialized cwd (`worker_backend.rs:1203-1260`)。
- Grep check found no `worker.cwd()` / Worker `fn cwd`; the only remaining `pub fn cwd` hit is `WorkingDirectory` binding API, not `Worker`
Blockers: none。
Validation performed by reviewer:
- Read-only ticket/diff/source/test inspection and grep checks。
- Reviewer did not rerun cargo/nix tests; coder-reported validation covers the requested test/build set。
Non-blocking note:
- Workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this was treated as outside this Tickets focused core filesystem/Bash authority gate, but may need follow-up if the intended invariant becomes “no local file reads of any kind” for no-workdir Workers。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-10T22:44:39Z -->
## Implementation report
Merge / final validation report:
- Reviewer approved the implementation with no blockers。
- Merged branch `work/00001KX6Y2A9Q-worker-filesystem-authority` into orchestration branch with merge commit `c29e9135 merge: worker filesystem authority`
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
Validation passed:
- `rg "worker\\.cwd\\(" crates || true`: no matches。
- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`: no Worker cwd accessor matches。
- `git diff --check`
- `cargo test -p worker --lib --tests`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-fs-authority-final-validation-1783723253.txt`
Reviewed/merged implementation commit:
- `b50b9461 feat: add explicit worker filesystem authority`
Non-blocking reviewer note:
- Workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this was treated as outside this Tickets focused core filesystem/Bash authority gate, but may be follow-up material if the intended invariant becomes “no local file reads of any kind” for no-workdir Workers。
---
<!-- event: state_changed author: orchestrator at: 2026-07-10T22:44:45Z from: inprogress to: done reason: implementation_validated field: state -->
## State changed
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
---
<!-- event: state_changed author: hare at: 2026-07-10T22:45:01Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-10T22:45:01Z status: closed -->
## 完了
Worker filesystem authority の明示境界を実装・レビュー・merge・検証した。
実装内容:
- `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` を追加。
- `LocalWorkingDirectory` は filesystem authority `root` と default `cwd` を分離して保持する。
- Worker-level `cwd: PathBuf` field と `worker.cwd()` accessor を削除。
- core filesystem/Bash tool registration は local filesystem authority がある場合だけ行うようにした。
- No-workdir Worker では `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` を construct/register/model-visible にしない。
- Embedded no-workdir Worker spawn は Runtime/process cwd fallback ではなく `WorkerFilesystemAuthority::None` を明示的に渡す。
- Ticket / worker-spawn / peer-discovery など cwd 依存箇所は local filesystem authority 必須に整理し、workspace root fallback を authority として使わないようにした。
- tests で no-workdir tool omission、no cwd fallback、local/materialized Worker の filesystem/Bash behavior を確認。
Review:
- Reviewer approved with no blockers。
- Evidence included explicit authority type, no Worker cwd field/accessor, filesystem/Bash registration gate, embedded Runtime authority construction, and tests。
- Non-blocking note: workspace-context surfaces such as memory/MCP/resident prompt loading can still be backed by `workspace_root` when enabled; this is outside the focused core filesystem/Bash authority gate and may become follow-up if the invariant expands to “no local file reads of any kind”。
Merge / validation:
- Merge commit: `c29e9135 merge: worker filesystem authority`
- Final validation passed:
- `rg "worker\\.cwd\\(" crates || true`
- `rg "pub fn cwd\\(" crates/worker/src/worker.rs || true`
- `git diff --check`
- `cargo test -p worker --lib --tests`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/worker-fs-authority-final-validation-1783723253.txt`
---

View File

@ -0,0 +1,4 @@
{"id":"orch-plan-20260710-220343-1","ticket_id":"00001KX6Y6ZEA","kind":"after","related_ticket":"00001KX6Y2A9Q","note":"Ticket has explicit typed `depends_on` relation to `00001KX6Y2A9Q`. The prerequisite WorkerFilesystemAuthority refactor is currently `inprogress` and establishes the filesystem-authority/workspace-boundary this Ticket builds on. Start only after the prerequisite is approved, merged, and closed or an explicit combined-work decision is made.","author":"orchestrator","at":"2026-07-10T22:03:43Z"}
{"id":"orch-plan-20260710-220349-2","ticket_id":"00001KX6Y6ZEA","kind":"waiting_capacity_note","note":"Dashboard queue routing inspected. This Ticket is concrete but blocked by typed `depends_on` relation to `00001KX6Y2A9Q`, which is currently inprogress. Starting now would conflict with the Worker struct/context/constructor refactor and filesystem-authority boundary. Leave queued and re-route after prerequisite merge/close.","author":"orchestrator","at":"2026-07-10T22:03:49Z"}
{"id":"orch-plan-20260710-233841-3","ticket_id":"00001KX6Y6ZEA","kind":"waiting_capacity_note","note":"Queue review resumed after prerequisite `00001KX6Y2A9Q` closed. `00001KX6WVNPD` was accepted first because it is the smaller embedded no-workdir policy MVP and explicitly does not require WorkspaceBackend migration. This Ticket remains queued due migration-boundary/conflict risk: it removes Worker `workspace_root` and introduces WorkspaceBackend/WorkspaceClient surfaces in the same Worker context/constructor/runtime code that `00001KX6WVNPD` is now changing. Re-route after `00001KX6WVNPD` is reviewed/merged/closed, unless a human explicitly requests a combined branch.","author":"orchestrator","at":"2026-07-10T23:38:41Z"}
{"id":"orch-plan-20260711-001433-4","ticket_id":"00001KX6Y6ZEA","kind":"accepted_plan","accepted_plan":{"summary":"Replace Worker `workspace_root: PathBuf` identity with path-free workspace identity and Runtime/host-owned WorkspaceBackend/WorkspaceClient boundary. Preserve filesystem authority exclusively under `WorkerFilesystemAuthority::Local`, allow workspace_id/client without local filesystem authority, and migrate workspace-aware Worker references away from local workspace paths where in scope.","branch":"work/00001KX6Y6ZEA-workspace-backend","worktree":"/home/hare/Projects/yoi/.worktree/00001KX6Y6ZEA-workspace-backend","role_plan":"Orchestrator accepts queued Ticket after prerequisites `00001KX6Y2A9Q` and related `00001KX6WVNPD` were approved, merged, validated, and closed. Use sibling Coder for implementation in a dedicated worktree, then sibling Reviewer for read-only review. Orchestrator retains merge/final-validation/close/worktree cleanup authority. Per user instruction, do not call StopPod during cleanup."},"author":"orchestrator","at":"2026-07-11T00:14:33Z"}

View File

@ -1,9 +1,9 @@
--- ---
title: 'Replace Worker workspace_root path with WorkspaceBackend' title: 'Replace Worker workspace_root path with WorkspaceBackend'
state: 'queued' state: 'closed'
priority: 'P1' priority: 'P1'
created_at: '2026-07-10T21:16:22Z' created_at: '2026-07-10T21:16:22Z'
updated_at: '2026-07-10T22:03:27Z' updated_at: '2026-07-11T01:09:59Z'
assignee: null assignee: null
queued_by: 'workspace-panel' queued_by: 'workspace-panel'
queued_at: '2026-07-10T22:03:27Z' queued_at: '2026-07-10T22:03:27Z'

View File

@ -0,0 +1,32 @@
Worker workspace identity / WorkspaceBackend boundary を実装・レビュー・merge・検証した。
実装内容:
- Worker-held `workspace_root: PathBuf` identity を削除し、path-free `WorkerWorkspaceContext`, `WorkspaceId`, `WorkspaceClient` に置き換えた。
- Worker は `workspace_context``filesystem_authority` を分離して保持し、local filesystem authority は `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` に閉じ込めた。
- Worker constructors / restore / spawn paths を workspace context と filesystem authority の分離形に更新した。
- Runtime 側に `RuntimeWorkspaceBackendRef` を追加し、host-owned local backend binding から narrow Worker workspace context を注入するようにした。
- `workspace_id/client` が存在しても local filesystem authority を意味しないようにした。
- no-filesystem Workers では memory/workflow local layouts を workspace path fallback せず unavailable/fail-closed にした。
- legacy metadata `workspace_root` は compatibility/local filesystem hint として、`WorkerFilesystemAuthority::Local` 由来の場合だけ扱うようにした。
- tests で path-free workspace identity/client と local cwd/root の分離、filesystem authority なしの workspace client、local path-returning Worker workspace accessor 不在、constructor/restore/spawn behavior を確認した。
Review:
- Reviewer approved with no blockers。
- Evidence included path-free Worker context, no Worker `workspace_root()` accessor, Runtime/host backend materialization boundary, workspace-client-without-filesystem tests, fail-closed local workspace features, and legacy metadata confinement。
- Non-blocking note: `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。
Merge / validation:
- Merge commit: `391f11fc merge: workspace backend worker context`
- Final validation passed:
- `rg -n 'workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`
- `rg -n 'worker\\.workspace_root|pub fn workspace_root|fn workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`
- `git diff --check`
- `cargo test -p worker --lib --tests`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-backend-final-validation-1783732052.txt`

View File

@ -72,4 +72,324 @@ Marked ready by `yoi ticket state`.
Ticket を `workspace-panel` が queued にしました。 Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-07-10T22:03:57Z -->
## Decision
Routing decision: blocked_by_dependency
Reason:
- Dashboard queue authorization was inspected, but this Ticket has an explicit typed `depends_on` relation to `00001KX6Y2A9Q`
- `00001KX6Y2A9Q` is currently `inprogress` and is refactoring Worker filesystem authority / cwd removal / no-workdir boundaries。
- This Ticket replaces Worker `workspace_root` path usage with `WorkspaceBackend`, which builds on the same Worker struct/context/constructor boundary。
- Starting now would likely conflict with the active filesystem-authority refactor and obscure review boundaries。
- Therefore this routing pass leaves the Ticket `queued` and does not record `queued -> inprogress`, create a worktree, or spawn role Pods。
Evidence checked:
- Ticket body / recent thread / artifacts。
- `TicketRelationQuery(00001KX6Y6ZEA)`: includes dependency on `00001KX6Y2A9Q`
- `TicketList(inprogress)`: `00001KX6Y2A9Q` is the active inprogress Ticket。
- Orchestrator worktree status: clean。
Next action:
- Re-route this Ticket after `00001KX6Y2A9Q` is approved, merged, validated, and closed。
- Escalate only if a human explicitly requests combined implementation in the same branch/worktree。
---
<!-- event: decision author: orchestrator at: 2026-07-10T23:38:54Z -->
## Decision
Routing decision: hold_queued_due_migration_boundary_conflict
Reason:
- 前提 `00001KX6Y2A9Q` は closed になったため dependency 自体は解消済み。
- ただし、この routing pass では `00001KX6WVNPD` を先に受理した。
- `00001KX6WVNPD` は embedded no-workdir policy MVP であり、WorkspaceBackend migration を必要としない小さい境界変更である。
- この Ticket は Worker `workspace_root` path を `WorkspaceBackend` に置き換える refactor で、Worker struct/context/constructor/runtime spawn/UI API 周辺に触れる。
- `00001KX6WVNPD` も同じ Worker create / embedded runtime / no-workdir authority surface に触るため、並行 branch で進めると conflict と review boundary 混濁の可能性が高い。
- よって、この Ticket は `queued` のまま保持し、`00001KX6WVNPD` が reviewed/merged/closed になってから再 routing する。
Evidence checked:
- Ticket body / thread / relations / orchestration plan。
- `TicketRelationQuery(00001KX6Y6ZEA)`: prerequisite `00001KX6Y2A9Q` は closed。
- `TicketList(inprogress)`: routing pass 中に `00001KX6WVNPD` を accepted for implementation。
- Orchestrator worktree status: clean。
Next action:
- `00001KX6WVNPD` の implementation/review/merge/close を完了後、この Ticket を再 routing する。
- 人間が combined branch を明示希望する場合のみ、この待機判断を再検討する。
---
<!-- event: decision author: orchestrator at: 2026-07-11T00:15:05Z -->
## Decision
Routing decision: implementation_ready
Reason:
- 前提 `00001KX6Y2A9Q` は closed 済みで、`WorkerFilesystemAuthority::{None, Local}` と cwd 除去境界が merge 済み。
- 関連 `00001KX6WVNPD` も closed 済みで、embedded no-workdir Worker policy / UI/API optionality が merge 済み。
- この Ticket の typed `depends_on 00001KX6Y2A9Q` は解消済み。`00001KX6WVNPD` は `related` であり blocker ではなく、直前の conflict/migration-boundary waiting note もその Ticket の merge/close により解消済み。
- `TicketList(inprogress)` は 0 件で、Orchestrator worktree は clean。
- Ticket body/thread に Worker-level local path identity をなくし、Runtime/host-owned backend/client 境界へ移す binding decision が記録されている。
Evidence checked:
- Ticket body / thread / relations / orchestration plan。
- Closed prerequisite `00001KX6Y2A9Q` and closed related policy Ticket `00001KX6WVNPD`
- `TicketRelationQuery(00001KX6Y6ZEA)`: outgoing `depends_on 00001KX6Y2A9Q` is closed; other relations are `related` only。
- `TicketOrchestrationPlanQuery(00001KX6Y6ZEA)`: prior waiting notes were dependency/conflict notes now resolved by prerequisite and related Ticket closure。
- `TicketList(inprogress)`: 0 件。
- Orchestrator worktree status: clean。
IntentPacket:
Intent:
- Worker の workspace identity/context を local path (`workspace_root: PathBuf`) から path-free identity (`Option<WorkspaceId>`) と Runtime/host injected workspace client/handle 境界へ移行する。
- Filesystem authority は `WorkerFilesystemAuthority` に閉じ込め、workspace identity/client が local filesystem authority を意味しないようにする。
Binding decisions / invariants:
- Worker struct は durable/context identity として local workspace path を保持しない。
- Worker-level `workspace_root: PathBuf` field と local path-returning workspace identity accessor を削除する。
- Worker が `WorkspaceBackendRef` / endpoint / auth / SecretRef / adapter を直接解決しない。
- Runtime/host 側が backend binding source を保持・解決し、Worker には narrow `WorkspaceClient` / handle を注入する。
- `workspace_id = Some(...)` かつ `filesystem = None` を表現できること。
- `workspace_id = None` かつ `filesystem = None` の会話専用 Worker も表現できること。
- local path が必要な処理は Runtime/host backend adapter または `WorkerFilesystemAuthority::Local` の内側に閉じ込める。
- capability を導入する場合でも authority source にせず、tool registration / UX / discovery hint として扱い、enforcement は backend 側に置く。
- `WorkerFilesystemAuthority` / embedded no-workdir policy を崩さない。workspace root fallback や process cwd fallback を filesystem authority として復活させない。
Requirements / acceptance criteria:
- Worker context に `workspace_id: Option<WorkspaceId>` equivalent と injected workspace client/handle equivalent を持てる。
- Worker struct に `workspace_root: PathBuf` が存在しない。
- workspace-aware featuresTicket / memory / workflow / project records / workspace metadataは local workspace path authority ではなく injected workspace client/handle 経由へ移行する、または移行が必要な箇所を fail-closed / unavailable diagnostic にする。
- Runtime/host が `WorkspaceBackendRef` equivalent を保持・解決し、Worker launch/restore/embedded/spawn path へ安全に materialize できる。
- No-workdir Worker は workspace API access と local filesystem authority の有無を独立に表現できる。
- Existing local/workdir Workers still work with `WorkerFilesystemAuthority::Local` and local-path-only operations confined there。
- Tests cover no local workspace path identity on Worker, workspace_id/client injection shape, no filesystem authority mixing, and representative workspace-aware API/tool behavior。
Implementation latitude:
- `WorkspaceId`, `WorkspaceBackendRef`, `WorkspaceClient` / handle の exact placement/name/API surface は existing crate boundaries に合わせてよい。
- Minimal client/handle can support only currently needed workspace-aware operations if broad API migration would exceed Ticket scope, but it must not leave Worker-local path authority as the active path。
- If a currently path-backed feature cannot be migrated safely in this Ticket, prefer explicit unavailable/fail-closed diagnostic with follow-up note rather than hidden path fallback。
- Capability discovery is optional unless needed for safe tool registration/UX。
Escalate if:
- Removing `workspace_root` from Worker requires changing product semantics for memory/Ticket/workflow availability beyond local refactor。
- A workspace-aware feature cannot be migrated without introducing a broad external backend/protocol design not captured by this Ticket。
- You need to reintroduce Worker-held local workspace path as identity/authority。
- You need to weaken no-workdir fs/Bash tool omission or `WorkerFilesystemAuthority` invariants。
- Public API/serialization migration requires incompatible durable-state handling beyond existing restore/test coverage。
Validation:
- `rg "workspace_root" crates/worker crates/worker-runtime crates/yoi-pod-client crates/yoi-workspace-server` with expected remaining hits only outside Worker identity/authority or documented adapter boundaries。
- `rg "worker\.workspace_root|workspace_root\(\)" crates` or equivalent showing Worker local path accessor removed。
- `git diff --check`
- `cargo test -p worker --lib --tests`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check && deno task test` if API/types/UI are touched。
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Current code map / likely touch points:
- `crates/worker/src/worker.rs` for Worker context fields and accessors。
- `crates/worker/src/controller.rs` for workspace-aware tool/resource setup currently tied to workspace path。
- `crates/worker-runtime/src/worker_backend.rs` for Runtime/embedded/restore construction and backend binding materialization。
- workspace-server/client crates if backend/client DTOs or Worker launch summaries expose workspace identity。
- tests around no-workdir Workers, restore, child spawn, Ticket/memory/workflow availability。
Critical risks / reviewer focus:
- Hidden local path authority retained in Worker under a renamed field/accessor。
- Workspace identity/client being treated as filesystem authority。
- WorkspaceBackendRef/endpoint/secret leaking into Worker-owned state。
- Breaking local/workdir Workers or child spawn/restore while removing path identity。
- Regressing embedded no-workdir policy or filesystem/Bash tool omission。
- Over-broad migration that invents a large remote-workspace protocol instead of the minimal safe boundary needed here。
---
<!-- event: state_changed author: orchestrator at: 2026-07-11T00:15:11Z from: queued to: inprogress reason: accepted_for_implementation field: state -->
## State changed
Prerequisite and prior conflict/migration-boundary Ticket are now closed. This Ticket is accepted for implementation before creating a worktree or spawning role Pods.
---
<!-- event: implementation_report author: orchestrator at: 2026-07-11T00:59:55Z -->
## Implementation report
Implementation progress report:
- Coder Pod `yoi-coder-00001KX6Y6ZEA-workspace-backend` completed implementation in `/home/hare/Projects/yoi/.worktree/00001KX6Y6ZEA-workspace-backend` on branch `work/00001KX6Y6ZEA-workspace-backend`
- Implementation commit: `142b60e1b3ca refactor: separate worker workspace identity`
- Orchestrator inspected worktree status, branch log, commit stats, `git diff --check 74b07bd7..HEAD`, and workspace-root accessor grep. Worktree was clean and diff check passed。
Implementation summary:
- Replaced Worker-held `workspace_root: PathBuf` identity with path-free `WorkerWorkspaceContext`, `WorkspaceId`, and `WorkspaceClient`
- Kept local filesystem authority under `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}`
- Updated Worker constructors / restore / spawn paths to receive workspace context separately from filesystem authority。
- Added runtime-side `RuntimeWorkspaceBackendRef` as host-owned local backend binding that injects narrow Worker workspace context。
- No-filesystem Workers keep memory/workflow local layouts unavailable/fail-closed rather than falling back to workspace paths。
- Preserved legacy metadata `workspace_root` only as a local filesystem hint derived from `WorkerFilesystemAuthority::Local`, not as Worker identity。
- Updated tests for path-free workspace identity/client separation from local tool cwd/root, workspace client without filesystem authority, no local path-returning Worker workspace accessor, and constructor usage。
Files touched:
- `crates/worker/src/worker.rs`
- `crates/worker/src/controller.rs`
- `crates/worker/src/entrypoint.rs`
- `crates/worker/src/lib.rs`
- `crates/worker/src/discovery.rs`
- `crates/worker/src/ticket_event_notify.rs`
- `crates/worker/tests/*`
- `crates/worker-runtime/src/worker_backend.rs`
- `crates/worker-runtime/Cargo.toml`
- `crates/session-store/src/worker_metadata.rs`
- `Cargo.lock`
- `package.nix`
Coder-reported validation passed:
- `rg -n "workspace_root" crates/worker crates/worker-runtime crates/client crates/workspace-server` with remaining hits as legacy metadata/local filesystem adapter or host/runtime local backend boundaries plus tests。
- `rg -n "worker\\.workspace_root|pub fn workspace_root|fn workspace_root" crates/worker crates/worker-runtime crates/client crates/workspace-server || true`; no Worker `workspace_root()` accessor hits. Orchestrator grep observed an unrelated `crates/workspace-server/src/records.rs` project-record accessor hit outside Worker identity。
- `git diff --check`
- `cargo test -p worker --lib --tests`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Not run by Coder:
- `cd web/workspace && deno task check && deno task test` because web/API/types were not touched。
Next action:
- Route to external Reviewer Pod before merge/close decisions。
---
<!-- event: review author: reviewer at: 2026-07-11T01:07:06Z status: approve -->
## Review: approve
External review result: approve
Evidence:
- Worker identity is path-free: `WorkerWorkspaceContext` carries `Option<WorkspaceId>` plus narrow `WorkspaceClient`/unavailable state, while `Worker` stores `workspace_context` and `filesystem_authority` instead of a `workspace_root: PathBuf` (`crates/worker/src/worker.rs:61-109`, `384-405`)。
- No `worker.workspace_root()` / local path-returning workspace accessor remains; local paths are exposed only through `WorkerFilesystemAuthority::Local` helpers such as `local_working_directory()` (`worker.rs:741-840`)。
- Runtime/host boundary now resolves local workspace backend context before constructing Workers (`crates/worker-runtime/src/worker_backend.rs:321-390`), and Worker construction accepts injected workspace context + filesystem authority (`worker.rs:4219-4260`)。
- Workspace/client without filesystem and no-workspace/no-filesystem Workers are represented and covered by tests (`worker.rs:5441-5530`; worker-runtime tests around `901+`)。
- Workspace-aware local features fail closed when no local filesystem authority is available; Ticket/memory/workflow/spawn-dependent surfaces require `filesystem_authority.local()` and otherwise return unavailable errors rather than cwd/root fallback (`crates/worker/src/controller.rs:630-672`, `709-779`)。
- Legacy `workspace_root` metadata is written only as a compatibility/local hint from local filesystem authority, not as Worker identity (`worker.rs:4604-4621`; `session-store/src/worker_metadata.rs`)。
Validation performed:
- `git diff --check 142b60e1^ 142b60e1`
- Focused grep for forbidden `workspace_root()` accessor: no matches。
- Focused tests:
- `cargo test -p worker spawned_context_tests --lib`
- `cargo test -p worker-runtime worker_spawn_receives_materialized_workspace_cwd_instead_of_source_repo --features ws-server,fs-store`
- `cargo test -p worker-runtime adapter_dispatches_user_input_through_worker_run_lifecycle --features ws-server,fs-store`
Blockers: none。
Non-blocking notes:
- Remaining `workspace_root` hits are local runtime/entrypoint/spawn-tool adapter, legacy metadata, or test contexts; reviewer did not find Worker identity/accessor use。
- `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; acceptable for this Ticket as compatibility metadata, but future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。
---
<!-- event: implementation_report author: orchestrator at: 2026-07-11T01:09:39Z -->
## Implementation report
Merge / final validation report:
- Reviewer approved the implementation with no blockers。
- Merged branch `work/00001KX6Y6ZEA-workspace-backend` into orchestration branch with merge commit `391f11fc merge: workspace backend worker context`
- Final validation was rerun in `/home/hare/Projects/yoi/.worktree/orchestration` after merge。
Validation passed:
- `rg -n 'workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true` with remaining hits limited to legacy metadata/local adapter/test contexts。
- `rg -n 'worker\\.workspace_root|pub fn workspace_root|fn workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`; no Worker identity/accessor hits were found. The expected unrelated workspace-server project-record accessor remains outside Worker identity。
- `git diff --check`
- `cargo test -p worker --lib --tests`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
Validation log:
- `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-backend-final-validation-1783732052.txt`
Reviewed/merged implementation commit:
- `142b60e1 refactor: separate worker workspace identity`
Non-blocking reviewer note:
- `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; acceptable for this Ticket as compatibility metadata, but future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。
---
<!-- event: state_changed author: orchestrator at: 2026-07-11T01:09:43Z from: inprogress to: done reason: implementation_validated field: state -->
## State changed
Implementation was reviewed, merged into orchestration branch, and final validation passed. Moving to done before closure.
---
<!-- event: state_changed author: hare at: 2026-07-11T01:09:59Z from: done to: closed reason: closed field: state -->
## State changed
Ticket を closed にしました。
---
<!-- event: close author: hare at: 2026-07-11T01:09:59Z status: closed -->
## 完了
Worker workspace identity / WorkspaceBackend boundary を実装・レビュー・merge・検証した。
実装内容:
- Worker-held `workspace_root: PathBuf` identity を削除し、path-free `WorkerWorkspaceContext`, `WorkspaceId`, `WorkspaceClient` に置き換えた。
- Worker は `workspace_context``filesystem_authority` を分離して保持し、local filesystem authority は `WorkerFilesystemAuthority::{None, Local(LocalWorkingDirectory)}` に閉じ込めた。
- Worker constructors / restore / spawn paths を workspace context と filesystem authority の分離形に更新した。
- Runtime 側に `RuntimeWorkspaceBackendRef` を追加し、host-owned local backend binding から narrow Worker workspace context を注入するようにした。
- `workspace_id/client` が存在しても local filesystem authority を意味しないようにした。
- no-filesystem Workers では memory/workflow local layouts を workspace path fallback せず unavailable/fail-closed にした。
- legacy metadata `workspace_root` は compatibility/local filesystem hint として、`WorkerFilesystemAuthority::Local` 由来の場合だけ扱うようにした。
- tests で path-free workspace identity/client と local cwd/root の分離、filesystem authority なしの workspace client、local path-returning Worker workspace accessor 不在、constructor/restore/spawn behavior を確認した。
Review:
- Reviewer approved with no blockers。
- Evidence included path-free Worker context, no Worker `workspace_root()` accessor, Runtime/host backend materialization boundary, workspace-client-without-filesystem tests, fail-closed local workspace features, and legacy metadata confinement。
- Non-blocking note: `set_active_with_workspace_context` preserves existing metadata fields when passed `None`; future no-workspace reuse of an existing Pod name may want explicit clearing semantics to avoid stale UI hints。
Merge / validation:
- Merge commit: `391f11fc merge: workspace backend worker context`
- Final validation passed:
- `rg -n 'workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`
- `rg -n 'worker\\.workspace_root|pub fn workspace_root|fn workspace_root' crates/worker crates/worker-runtime crates/client crates/workspace-server || true`
- `git diff --check`
- `cargo test -p worker --lib --tests`
- `cargo test -p worker-runtime --features ws-server,fs-store`
- `cargo test -p yoi-workspace-server --lib`
- `cargo check -p yoi`
- `cd web/workspace && deno task check`
- `cd web/workspace && deno task test`
- `yoi ticket doctor`
- `nix build .#yoi --no-link`
- Validation log: `/run/user/1000/yoi/yoi-orchestrator/bash-output/workspace-backend-final-validation-1783732052.txt`
--- ---

1
Cargo.lock generated
View File

@ -5848,6 +5848,7 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-tungstenite 0.29.0", "tokio-tungstenite 0.29.0",
"toml",
"tower", "tower",
"worker", "worker",
] ]

View File

@ -100,8 +100,13 @@ pub struct WorkerMetadata {
pub worker_name: String, pub worker_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<WorkerActiveSegmentRef>, pub active: Option<WorkerActiveSegmentRef>,
/// Legacy local path hint retained for host/runtime compatibility. It is not
/// Worker workspace identity or authority.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_root: Option<PathBuf>, pub workspace_root: Option<PathBuf>,
/// Path-free workspace identity supplied by the host/runtime boundary.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub spawned_children: Vec<WorkerSpawnedChild>, pub spawned_children: Vec<WorkerSpawnedChild>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
@ -119,6 +124,7 @@ impl WorkerMetadata {
worker_name: worker_name.into(), worker_name: worker_name.into(),
active, active,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -130,6 +136,11 @@ impl WorkerMetadata {
self.workspace_root = Some(workspace_root); self.workspace_root = Some(workspace_root);
self self
} }
pub fn with_workspace_id(mut self, workspace_id: impl Into<String>) -> Self {
self.workspace_id = Some(workspace_id.into());
self
}
} }
/// Sync persistence backend for Worker metadata. /// Sync persistence backend for Worker metadata.
@ -180,6 +191,44 @@ pub trait WorkerMetadataStore: Send + Sync {
} }
/// Set the active pointer and workspace ownership while preserving unrelated fields. /// Set the active pointer and workspace ownership while preserving unrelated fields.
fn set_active_with_workspace_context(
&self,
worker_name: &str,
active: Option<WorkerActiveSegmentRef>,
resolved_manifest_snapshot: Option<serde_json::Value>,
workspace_id: Option<String>,
workspace_root: Option<PathBuf>,
) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(worker_name, |metadata| {
metadata.active = active;
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
if let Some(workspace_id) = workspace_id {
metadata.workspace_id = Some(workspace_id);
}
if let Some(workspace_root) = workspace_root {
metadata.workspace_root = Some(workspace_root);
}
})
}
/// Set the active pointer and path-free workspace identity while preserving unrelated fields.
fn set_active_with_workspace_id(
&self,
worker_name: &str,
active: Option<WorkerActiveSegmentRef>,
resolved_manifest_snapshot: Option<serde_json::Value>,
workspace_id: Option<String>,
) -> Result<WorkerMetadata, WorkerStoreError> {
self.update_by_name(worker_name, |metadata| {
metadata.active = active;
metadata.resolved_manifest_snapshot = resolved_manifest_snapshot;
if let Some(workspace_id) = workspace_id {
metadata.workspace_id = Some(workspace_id);
}
})
}
/// Set the active pointer and legacy local workspace-root hint while preserving unrelated fields.
fn set_active_with_workspace_root( fn set_active_with_workspace_root(
&self, &self,
worker_name: &str, worker_name: &str,

View File

@ -32,6 +32,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
tar.workspace = true tar.workspace = true
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
toml.workspace = true
tower = { workspace = true, features = ["util"], optional = true } tower = { workspace = true, features = ["util"], optional = true }
worker.workspace = true worker.workspace = true

View File

@ -9,7 +9,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future; use std::future::Future;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc}; use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration; use std::time::Duration;
@ -35,7 +35,10 @@ use session_store::{CombinedStore, FsWorkerStore};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use worker::{Worker, WorkerController, WorkerHandle}; use worker::{
Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle, WorkerWorkspaceContext,
WorkspaceId,
};
const DEFAULT_BACKEND_ID: &str = "worker-crate"; const DEFAULT_BACKEND_ID: &str = "worker-crate";
const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10); const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
@ -230,6 +233,41 @@ fn sanitize_worker_name_component(value: &str) -> String {
.collect() .collect()
} }
#[derive(Debug, Clone)]
enum RuntimeWorkspaceBackendRef {
None,
LocalFilesystem { root: PathBuf },
}
impl RuntimeWorkspaceBackendRef {
fn from_working_directory(binding: Option<&WorkingDirectoryBinding>) -> Self {
match binding {
Some(binding) => Self::LocalFilesystem {
root: binding.root().to_path_buf(),
},
None => Self::None,
}
}
fn worker_context(&self) -> WorkerWorkspaceContext {
match self {
Self::None => WorkerWorkspaceContext::no_workspace(),
Self::LocalFilesystem { root } => local_workspace_context(root),
}
}
}
fn local_workspace_context(root: &Path) -> WorkerWorkspaceContext {
WorkerWorkspaceContext::local_filesystem(read_workspace_id_hint(root))
}
fn read_workspace_id_hint(root: &Path) -> Option<WorkspaceId> {
let contents = std::fs::read_to_string(root.join(".yoi/workspace.toml")).ok()?;
let value = toml::from_str::<toml::Value>(&contents).ok()?;
let id = value.get("id")?.as_str()?.to_string();
WorkspaceId::new(id).ok()
}
#[cfg(feature = "http-server")] #[cfg(feature = "http-server")]
async fn fetch_profile_source_archive_http( async fn fetch_profile_source_archive_http(
location: &ProfileSourceArchiveHttpRef, location: &ProfileSourceArchiveHttpRef,
@ -298,11 +336,19 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.as_ref() .as_ref()
.map(|binding| binding.root().to_path_buf()) .map(|binding| binding.root().to_path_buf())
.unwrap_or_else(|| self.profile_base_dir.clone()); .unwrap_or_else(|| self.profile_base_dir.clone());
let cwd = request let filesystem_authority = request
.working_directory .working_directory
.as_ref() .as_ref()
.map(|binding| binding.cwd().to_path_buf()) .map(|binding| {
.unwrap_or_else(|| self.cwd.clone()); WorkerFilesystemAuthority::local(
binding.root().to_path_buf(),
binding.cwd().to_path_buf(),
)
})
.unwrap_or(WorkerFilesystemAuthority::None);
let workspace_backend_ref =
RuntimeWorkspaceBackendRef::from_working_directory(request.working_directory.as_ref());
let workspace_context = workspace_backend_ref.worker_context();
let selector = profile.as_deref().unwrap_or("builtin:default"); let selector = profile.as_deref().unwrap_or("builtin:default");
let archive = self let archive = self
.resolve_profile_source_archive(&request.request.profile_source) .resolve_profile_source_archive(&request.request.profile_source)
@ -335,9 +381,15 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
})?; })?;
let store = CombinedStore::new(session_store, worker_metadata_store); let store = CombinedStore::new(session_store, worker_metadata_store);
let worker = Worker::from_manifest_with_context(manifest, store, loader, worker_root, cwd) let worker = Worker::from_manifest_with_context(
.await manifest,
.map_err(|err| format!("failed to create Worker from profile: {err}"))?; store,
loader,
workspace_context,
filesystem_authority,
)
.await
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
let runtime_base = self.runtime_base_dir()?; let runtime_base = self.runtime_base_dir()?;
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base) let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
@ -806,7 +858,7 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::fs; use std::fs;
use std::pin::Pin; use std::pin::Pin;
use std::process::Command; use std::process::Command;
@ -902,18 +954,31 @@ mod tests {
FsStore::new(&self.store_dir).map_err(|err| err.to_string())?, FsStore::new(&self.store_dir).map_err(|err| err.to_string())?,
FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?, FsWorkerStore::new(&self.worker_metadata_dir).map_err(|err| err.to_string())?,
); );
let cwd = request let filesystem_authority = request
.working_directory .working_directory
.as_ref() .as_ref()
.map(|binding| binding.cwd().to_path_buf()) .map(|binding| {
let cwd = binding.cwd().to_path_buf();
self.observed_cwds.lock().unwrap().push(cwd.clone());
WorkerFilesystemAuthority::local(binding.root().to_path_buf(), cwd)
})
.unwrap_or(WorkerFilesystemAuthority::None);
let scope_root = request
.working_directory
.as_ref()
.map(|binding| binding.root().to_path_buf())
.unwrap_or_else(|| self.cwd.clone()); .unwrap_or_else(|| self.cwd.clone());
self.observed_cwds.lock().unwrap().push(cwd.clone()); let workspace_backend_ref = RuntimeWorkspaceBackendRef::from_working_directory(
let scope = Scope::writable(&cwd).map_err(|err| err.to_string())?; request.working_directory.as_ref(),
);
let workspace_context = workspace_backend_ref.worker_context();
let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
let worker = Worker::new( let worker = Worker::new(
manifest, manifest,
Engine::new(self.client.clone()), Engine::new(self.client.clone()),
store, store,
cwd, workspace_context,
filesystem_authority,
scope, scope,
) )
.await .await
@ -925,6 +990,20 @@ mod tests {
} }
} }
fn core_filesystem_tool_names() -> BTreeSet<&'static str> {
["Read", "Write", "Edit", "Glob", "Grep", "Bash"]
.into_iter()
.collect()
}
fn captured_tool_names(client: &MockClient, index: usize) -> BTreeSet<String> {
client.captured.lock().unwrap()[index]
.tools
.iter()
.map(|tool| tool.name.clone())
.collect()
}
fn simple_text_events() -> Vec<LlmEvent> { fn simple_text_events() -> Vec<LlmEvent> {
vec![ vec![
LlmEvent::text_block_start(0), LlmEvent::text_block_start(0),
@ -1109,7 +1188,7 @@ mod tests {
cwd: cwd.path().to_path_buf(), cwd: cwd.path().to_path_buf(),
store_dir: store.path().join("sessions"), store_dir: store.path().join("sessions"),
worker_metadata_dir: store.path().join("workers"), worker_metadata_dir: store.path().join("workers"),
observed_cwds, observed_cwds: observed_cwds.clone(),
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap(); let backend = WorkerRuntimeExecutionBackend::new(factory).unwrap();
let runtime = EmbeddedRuntime::with_execution_backend( let runtime = EmbeddedRuntime::with_execution_backend(
@ -1148,6 +1227,14 @@ mod tests {
} }
assert_eq!(client.captured.lock().unwrap().len(), 1); assert_eq!(client.captured.lock().unwrap().len(), 1);
assert!(observed_cwds.lock().unwrap().is_empty());
let names = captured_tool_names(&client, 0);
for forbidden in core_filesystem_tool_names() {
assert!(
!names.contains(forbidden),
"no-workdir Worker unexpectedly exposed {forbidden}; tools={names:?}"
);
}
let observations = runtime let observations = runtime
.read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero()) .read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero())
.unwrap(); .unwrap();
@ -1166,7 +1253,7 @@ mod tests {
let store = tempfile::tempdir().unwrap(); let store = tempfile::tempdir().unwrap();
let observed_cwds = Arc::new(Mutex::new(Vec::new())); let observed_cwds = Arc::new(Mutex::new(Vec::new()));
let factory = MockFactory { let factory = MockFactory {
client, client: client.clone(),
runtime_base: runtime_base.path().to_path_buf(), runtime_base: runtime_base.path().to_path_buf(),
cwd: repo.path().to_path_buf(), cwd: repo.path().to_path_buf(),
store_dir: store.path().join("sessions"), store_dir: store.path().join("sessions"),
@ -1191,6 +1278,24 @@ mod tests {
request.working_directory_request = Some(working_directory_request(repo.path())); request.working_directory_request = Some(working_directory_request(repo.path()));
let detail = runtime.create_worker(request).unwrap(); let detail = runtime.create_worker(request).unwrap();
runtime
.send_input(&detail.worker_ref, WorkerInput::user("inspect tools"))
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while client.captured.lock().unwrap().is_empty() {
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for materialized-worker request"
);
std::thread::sleep(Duration::from_millis(20));
}
let names = captured_tool_names(&client, 0);
for expected in core_filesystem_tool_names() {
assert!(
names.contains(expected),
"local Worker did not expose {expected}; tools={names:?}"
);
}
assert!(detail.execution.working_directory.is_some()); assert!(detail.execution.working_directory.is_some());
let cwds = observed_cwds.lock().unwrap(); let cwds = observed_cwds.lock().unwrap();

View File

@ -274,7 +274,9 @@ impl WorkerController {
manifest_toml.clone(), manifest_toml.clone(),
greeting, greeting,
)); ));
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view)); if let Some(fs_for_view) = fs_for_view {
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
}
shared_state.set_workflows( shared_state.set_workflows(
worker worker
.workflow_completions() .workflow_completions()
@ -520,15 +522,17 @@ fn install_ticket_event_companion_notify_hook<C, St>(
return; return;
} }
let Some(companion_worker_name) = companion_worker_name_for_workspace(worker.workspace_root()) let Some(local) = worker.local_working_directory() else {
else { return;
};
let Some(companion_worker_name) = companion_worker_name_for_workspace(&local.root) else {
return; return;
}; };
if companion_worker_name == worker.manifest().worker.name { if companion_worker_name == worker.manifest().worker.name {
return; return;
} }
let Ok(ticket_config) = TicketConfig::load_workspace(worker.cwd()) else { let Ok(ticket_config) = TicketConfig::load_workspace(&local.cwd) else {
return; return;
}; };
let backend_root = ticket_config.backend_root().to_path_buf(); let backend_root = ticket_config.backend_root().to_path_buf();
@ -540,7 +544,7 @@ fn install_ticket_event_companion_notify_hook<C, St>(
worker.worker_metadata_store(), worker.worker_metadata_store(),
worker.manifest().worker.name.clone(), worker.manifest().worker.name.clone(),
runtime_base, runtime_base,
worker.cwd().to_path_buf(), Some(local.cwd.clone()),
spawned_registry, spawned_registry,
); );
match discovery.ensure_existing_peer(&companion_worker_name) { match discovery.ensure_existing_peer(&companion_worker_name) {
@ -589,7 +593,7 @@ async fn register_worker_tools<C, St>(
spawner_socket: PathBuf, spawner_socket: PathBuf,
runtime_base: PathBuf, runtime_base: PathBuf,
spawned_registry: Arc<SpawnedWorkerRegistry>, spawned_registry: Arc<SpawnedWorkerRegistry>,
) -> std::io::Result<tools::ScopedFs> ) -> std::io::Result<Option<tools::ScopedFs>>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + 'static, St: Store + WorkerMetadataStore + Clone + 'static,
@ -597,8 +601,8 @@ where
// Worker-immutable snapshots taken before the mutable worker borrow // Worker-immutable snapshots taken before the mutable worker borrow
// below so the worker borrow doesn't conflict with reads on `worker`. // below so the worker borrow doesn't conflict with reads on `worker`.
let scope_handle = worker.scope().clone(); let scope_handle = worker.scope().clone();
let cwd = worker.cwd().to_path_buf(); let local_filesystem = worker.local_working_directory().cloned();
let workspace_root = worker.workspace_root().to_path_buf(); let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
let task_feature = worker.task_feature(); let task_feature = worker.task_feature();
let session_id_for_usage = worker.segment_id().to_string(); let session_id_for_usage = worker.segment_id().to_string();
let memory_config = worker.manifest().memory.clone(); let memory_config = worker.manifest().memory.clone();
@ -611,24 +615,24 @@ where
let worker_metadata_store = worker.store().clone(); let worker_metadata_store = worker.store().clone();
let self_parent_socket = worker.callback_socket().cloned(); let self_parent_socket = worker.callback_socket().cloned();
// The Worker's SharedScope (already augmented with the bash-output // The Worker's SharedScope is the single source of truth for every
// Read rule by the caller) is the single source of truth — every // ScopedFs when local filesystem authority exists. No-workdir Workers
// ScopedFs (builtin tools, fs_view, compact worker) reads from it, // deliberately skip constructing/registering filesystem and Bash tools.
// and any future scope mutation (SpawnWorker-style revoke, future let (fs_for_view, tracker) = if let Some(local) = local_filesystem.as_ref() {
// GrantScope) propagates through it. let fs = tools::ScopedFs::with_shared_scope(scope_handle.clone(), local.cwd.clone());
let fs = tools::ScopedFs::with_shared_scope(scope_handle.clone(), cwd.clone()); let tracker = tools::Tracker::new();
let tracker = tools::Tracker::new(); let fs_for_view = fs.clone();
// Same ScopedFs also powers the IPC `ListCompletions` query — keep worker
// a clone for the FS view we attach below, since the tools consume .engine_mut()
// `fs` itself. .register_tools(tools::core_builtin_tools(
let fs_for_view = fs.clone(); fs,
worker tracker.clone(),
.engine_mut() bash_output_dir,
.register_tools(tools::core_builtin_tools( ));
fs, (Some(fs_for_view), Some(tracker))
tracker.clone(), } else {
bash_output_dir, (None, None)
)); };
if feature_config.web.enabled { if feature_config.web.enabled {
worker worker
.engine_mut() .engine_mut()
@ -649,11 +653,20 @@ where
} }
}; };
// Ticket tools are typed operations over the currently checked-out work // Ticket tools are typed operations over the currently checked-out work
// tree. Use the Worker cwd rather than the runtime workspace root so a // tree. They require explicit local filesystem authority and must not
// dedicated Orchestrator worktree gets its own `.yoi/tickets` backend. // use workspace identity as a cwd fallback.
let ticket_cwd = local_filesystem
.as_ref()
.map(|local| &local.cwd)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"ticket tools require local Worker filesystem authority",
)
})?;
feature_registry.add_module( feature_registry.add_module(
crate::feature::builtin::ticket::ticket_tools_feature_with_options( crate::feature::builtin::ticket::ticket_tools_feature_with_options(
&cwd, ticket_cwd,
feature_config.ticket.enabled.then_some(ticket_access), feature_config.ticket.enabled.then_some(ticket_access),
feature_config.ticket_orchestration.enabled, feature_config.ticket_orchestration.enabled,
), ),
@ -665,10 +678,12 @@ where
) { ) {
feature_registry = feature_registry.with_module(module); feature_registry = feature_registry.with_module(module);
} }
if let Some(module) = if let Some(workspace_root) = local_workspace_root.as_ref() {
crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, &workspace_root).await if let Some(module) =
{ crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, workspace_root).await
feature_registry = feature_registry.with_module(module); {
feature_registry = feature_registry.with_module(module);
}
} }
{ {
@ -684,7 +699,13 @@ where
"[feature.memory].enabled = true requires a [memory] configuration section", "[feature.memory].enabled = true requires a [memory] configuration section",
) )
})?; })?;
let layout = memory::WorkspaceLayout::resolve(mem, &workspace_root); let workspace_root = local_workspace_root.as_ref().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"memory tools require local Worker filesystem authority",
)
})?;
let layout = memory::WorkspaceLayout::resolve(mem, workspace_root);
let query_cfg = memory::tool::QueryConfig::from(mem); let query_cfg = memory::tool::QueryConfig::from(mem);
worker.register_tool(memory::tool::read_tool_with_usage( worker.register_tool(memory::tool::read_tool_with_usage(
layout.clone(), layout.clone(),
@ -709,12 +730,27 @@ where
"[feature.workers].enabled = true requires non-empty [[delegation_scope.allow]]", "[feature.workers].enabled = true requires non-empty [[delegation_scope.allow]]",
)); ));
} }
let spawner_cwd = local_filesystem
.as_ref()
.map(|local| local.cwd.clone())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"worker spawn tools require local Worker filesystem authority",
)
})?;
let spawner_workspace_root = local_workspace_root.clone().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"worker spawn tools require local Worker filesystem authority",
)
})?;
worker.register_tool(spawn_worker_tool( worker.register_tool(spawn_worker_tool(
spawner_name.clone(), spawner_name.clone(),
spawner_socket, spawner_socket,
runtime_base.clone(), runtime_base.clone(),
workspace_root.clone(), spawner_workspace_root,
cwd.clone(), spawner_cwd.clone(),
spawned_registry.clone(), spawned_registry.clone(),
self_parent_socket, self_parent_socket,
spawner_manifest, spawner_manifest,
@ -728,7 +764,7 @@ where
worker_metadata_store, worker_metadata_store,
spawner_name, spawner_name,
runtime_base, runtime_base,
cwd, Some(spawner_cwd),
spawned_registry, spawned_registry,
); );
worker.register_tool(list_workers_tool(discovery.clone())); worker.register_tool(list_workers_tool(discovery.clone()));
@ -737,7 +773,9 @@ where
} }
} }
let _feature_install_report = worker.install_features(feature_registry); let _feature_install_report = worker.install_features(feature_registry);
worker.attach_tracker(tracker); if let Some(tracker) = tracker {
worker.attach_tracker(tracker);
}
Ok(fs_for_view) Ok(fs_for_view)
} }
@ -774,11 +812,14 @@ async fn controller_loop<C, St>(
.parent() .parent()
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|| runtime_dir.path().to_path_buf()); .unwrap_or_else(|| runtime_dir.path().to_path_buf());
let discovery_cwd = worker
.local_working_directory()
.map(|local| local.cwd.clone());
let discovery = WorkerDiscovery::new( let discovery = WorkerDiscovery::new(
worker.store().clone(), worker.store().clone(),
spawner_name.clone(), spawner_name.clone(),
discovery_runtime_base, discovery_runtime_base,
worker.cwd().to_path_buf(), discovery_cwd,
spawned_registry.clone(), spawned_registry.clone(),
); );
let mut pending: Option<PendingRun> = None; let mut pending: Option<PendingRun> = None;
@ -1445,7 +1486,10 @@ where
.collect(); .collect();
protocol::Greeting { protocol::Greeting {
worker_name: manifest.worker.name.clone(), worker_name: manifest.worker.name.clone(),
cwd: worker.cwd().display().to_string(), cwd: worker
.local_working_directory()
.map(|local| local.cwd.display().to_string())
.unwrap_or_default(),
provider: provider_name, provider: provider_name,
model: model_id, model: model_id,
scope_summary: worker.scope_snapshot().summary(), scope_summary: worker.scope_snapshot().summary(),

View File

@ -42,7 +42,7 @@ pub struct WorkerDiscovery<St> {
store: St, store: St,
self_worker_name: String, self_worker_name: String,
runtime_base: PathBuf, runtime_base: PathBuf,
cwd: PathBuf, cwd: Option<PathBuf>,
store_dir: Option<PathBuf>, store_dir: Option<PathBuf>,
spawned_registry: Arc<SpawnedWorkerRegistry>, spawned_registry: Arc<SpawnedWorkerRegistry>,
} }
@ -55,7 +55,7 @@ where
store: St, store: St,
self_worker_name: String, self_worker_name: String,
runtime_base: PathBuf, runtime_base: PathBuf,
cwd: PathBuf, cwd: Option<PathBuf>,
spawned_registry: Arc<SpawnedWorkerRegistry>, spawned_registry: Arc<SpawnedWorkerRegistry>,
) -> Self { ) -> Self {
let store_dir = store.root_dir(); let store_dir = store.root_dir();
@ -432,13 +432,19 @@ where
) -> Result<(), WorkerDiscoveryError> { ) -> Result<(), WorkerDiscoveryError> {
let runtime_command = let runtime_command =
WorkerRuntimeCommand::resolve().map_err(WorkerDiscoveryError::RestoreSpawn)?; WorkerRuntimeCommand::resolve().map_err(WorkerDiscoveryError::RestoreSpawn)?;
let Some(cwd) = &self.cwd else {
return Err(WorkerDiscoveryError::NotRestorable {
worker_name: worker_name.to_string(),
reason: "restore requires local Worker filesystem authority".into(),
});
};
let mut command = Command::new(runtime_command.program()); let mut command = Command::new(runtime_command.program());
command command
.args(runtime_command.prefix_args()) .args(runtime_command.prefix_args())
.arg("--worker") .arg("--worker")
.arg(worker_name) .arg(worker_name)
.arg("--require-worker-state") .arg("--require-worker-state")
.current_dir(&self.cwd) .current_dir(cwd)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
@ -1139,6 +1145,7 @@ mod tests {
worker_name: "parent".into(), worker_name: "parent".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: vec![ spawned_children: vec![
child("child-live", &live_socket), child("child-live", &live_socket),
child("child-stale", &stale_socket), child("child-stale", &stale_socket),
@ -1159,6 +1166,7 @@ mod tests {
active_child_segment, active_child_segment,
)), )),
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1173,6 +1181,7 @@ mod tests {
active_child_segment, active_child_segment,
)), )),
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1184,6 +1193,7 @@ mod tests {
worker_name: "child-pending".into(), worker_name: "child-pending".into(),
active: Some(WorkerActiveSegmentRef::pending_segment(pending_session_id)), active: Some(WorkerActiveSegmentRef::pending_segment(pending_session_id)),
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1198,6 +1208,7 @@ mod tests {
new_segment_id(), new_segment_id(),
)), )),
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1209,6 +1220,7 @@ mod tests {
worker_name: "peer".into(), worker_name: "peer".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![session_store::WorkerPeer { peers: vec![session_store::WorkerPeer {
@ -1228,7 +1240,7 @@ mod tests {
store.clone(), store.clone(),
"parent".into(), "parent".into(),
runtime_base.clone(), runtime_base.clone(),
root.path().to_path_buf(), Some(root.path().to_path_buf()),
registry, registry,
); );
@ -1355,7 +1367,7 @@ mod tests {
store.clone(), store.clone(),
"source".into(), "source".into(),
runtime_base.clone(), runtime_base.clone(),
root.path().to_path_buf(), Some(root.path().to_path_buf()),
SpawnedWorkerRegistry::new(runtime_dir), SpawnedWorkerRegistry::new(runtime_dir),
); );
let result = discovery.register_peer("target").unwrap(); let result = discovery.register_peer("target").unwrap();
@ -1390,7 +1402,7 @@ mod tests {
store, store,
"source".into(), "source".into(),
runtime_base, runtime_base,
root.path().to_path_buf(), Some(root.path().to_path_buf()),
SpawnedWorkerRegistry::new(runtime_dir), SpawnedWorkerRegistry::new(runtime_dir),
); );
@ -1415,6 +1427,7 @@ mod tests {
worker_name: "source".into(), worker_name: "source".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![session_store::WorkerPeer { peers: vec![session_store::WorkerPeer {
@ -1430,7 +1443,7 @@ mod tests {
store.clone(), store.clone(),
"source".into(), "source".into(),
runtime_base, runtime_base,
root.path().to_path_buf(), Some(root.path().to_path_buf()),
SpawnedWorkerRegistry::new(runtime_dir), SpawnedWorkerRegistry::new(runtime_dir),
); );
@ -1455,6 +1468,7 @@ mod tests {
worker_name: "source".into(), worker_name: "source".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![session_store::WorkerPeer { peers: vec![session_store::WorkerPeer {
@ -1468,6 +1482,7 @@ mod tests {
worker_name: "target".into(), worker_name: "target".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![session_store::WorkerPeer { peers: vec![session_store::WorkerPeer {
@ -1481,7 +1496,7 @@ mod tests {
store, store,
"source".into(), "source".into(),
runtime_base.clone(), runtime_base.clone(),
root.path().to_path_buf(), Some(root.path().to_path_buf()),
SpawnedWorkerRegistry::new(runtime_dir), SpawnedWorkerRegistry::new(runtime_dir),
); );
@ -1573,6 +1588,7 @@ mod tests {
worker_name: "source".into(), worker_name: "source".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![session_store::WorkerPeer { peers: vec![session_store::WorkerPeer {
@ -1586,6 +1602,7 @@ mod tests {
worker_name: "target".into(), worker_name: "target".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: vec![session_store::WorkerPeer { peers: vec![session_store::WorkerPeer {
@ -1599,7 +1616,7 @@ mod tests {
store, store,
"source".into(), "source".into(),
runtime_base.clone(), runtime_base.clone(),
root.path().to_path_buf(), Some(root.path().to_path_buf()),
SpawnedWorkerRegistry::new(runtime_dir), SpawnedWorkerRegistry::new(runtime_dir),
); );
@ -1689,6 +1706,7 @@ mod tests {
worker_name: "source".into(), worker_name: "source".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: vec![child("target", &socket)], spawned_children: vec![child("target", &socket)],
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -1701,7 +1719,7 @@ mod tests {
store, store,
"source".into(), "source".into(),
runtime_base, runtime_base,
root.path().to_path_buf(), Some(root.path().to_path_buf()),
SpawnedWorkerRegistry::new(runtime_dir), SpawnedWorkerRegistry::new(runtime_dir),
); );

View File

@ -2,7 +2,10 @@ use std::ffi::OsString;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::ExitCode; use std::process::ExitCode;
use crate::{PromptLoader, Worker, WorkerController}; use crate::{
PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority, WorkerWorkspaceContext,
WorkspaceId,
};
use clap::{CommandFactory, FromArgMatches, Parser}; use clap::{CommandFactory, FromArgMatches, Parser};
use manifest::{ use manifest::{
Permission, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ScopeRule, Permission, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig, ScopeRule,
@ -103,6 +106,24 @@ fn runtime_workspace_root(cli: &Cli) -> Result<PathBuf, String> {
} }
} }
fn runtime_workspace_context(workspace_root: &Path) -> WorkerWorkspaceContext {
WorkerWorkspaceContext::local_filesystem(read_workspace_id_hint(workspace_root))
}
fn read_workspace_id_hint(workspace_root: &Path) -> Option<WorkspaceId> {
let path = workspace_root.join(".yoi/workspace.toml");
let contents = std::fs::read_to_string(path).ok()?;
let value = toml::from_str::<toml::Value>(&contents).ok()?;
let id = value.get("id")?.as_str()?.to_string();
match WorkspaceId::new(id) {
Ok(id) => Some(id),
Err(err) => {
tracing::warn!("ignoring invalid workspace id in .yoi/workspace.toml: {err}");
None
}
}
}
fn runtime_worker_name(cli: &Cli, workspace_root: &Path) -> String { fn runtime_worker_name(cli: &Cli, workspace_root: &Path) -> String {
cli.worker cli.worker
.as_deref() .as_deref()
@ -511,6 +532,9 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
} }
}; };
let store = CombinedStore::new(session_store, worker_metadata_store); let store = CombinedStore::new(session_store, worker_metadata_store);
let filesystem_authority =
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone());
let workspace_context = runtime_workspace_context(&workspace_root);
let mut worker = if cli.adopt { let mut worker = if cli.adopt {
let callback = match cli.callback.clone() { let callback = match cli.callback.clone() {
@ -525,8 +549,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
store, store,
loader, loader,
callback, callback,
workspace_root.clone(), workspace_context.clone(),
cwd.clone(), filesystem_authority.clone(),
) )
.await .await
{ {
@ -556,8 +580,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
manifest, manifest,
store, store,
loader, loader,
workspace_root.clone(), workspace_context.clone(),
cwd.clone(), filesystem_authority.clone(),
) )
.await .await
{ {
@ -576,8 +600,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
manifest, manifest,
store, store,
loader, loader,
workspace_root.clone(), workspace_context.clone(),
cwd.clone(), filesystem_authority.clone(),
) )
.await .await
{ {
@ -597,8 +621,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
manifest, manifest,
store, store,
loader, loader,
workspace_root.clone(), workspace_context.clone(),
cwd.clone(), filesystem_authority.clone(),
) )
.await .await
{ {
@ -619,8 +643,8 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
manifest, manifest,
store, store,
loader, loader,
workspace_root.clone(), workspace_context.clone(),
cwd.clone(), filesystem_authority.clone(),
) )
.await .await
{ {

View File

@ -38,4 +38,7 @@ pub use provider::{ProviderError, build_client};
pub use runtime::dir::RuntimeDir; pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink; pub use segment_log_sink::SegmentLogSink;
pub use shared_state::WorkerSharedState; pub use shared_state::WorkerSharedState;
pub use worker::{Worker, WorkerError, WorkerRunResult, apply_worker_manifest}; pub use worker::{
LocalWorkingDirectory, Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult,
WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, WorkspaceIdError, apply_worker_manifest,
};

View File

@ -13,7 +13,9 @@
//! `set_system_prompt`. Subsequent turns and compactions reuse that //! `set_system_prompt`. Subsequent turns and compactions reuse that
//! materialised string verbatim. //! materialised string verbatim.
use std::borrow::Cow;
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[cfg(test)]
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
@ -146,7 +148,7 @@ impl std::fmt::Debug for SystemPromptTemplate {
/// templates cannot drop them on the floor. /// templates cannot drop them on the floor.
pub struct SystemPromptContext<'a> { pub struct SystemPromptContext<'a> {
pub now: DateTime<Utc>, pub now: DateTime<Utc>,
pub cwd: &'a Path, pub cwd: Cow<'a, str>,
/// Language policy exposed to instruction templates as `{{ language }}`. /// Language policy exposed to instruction templates as `{{ language }}`.
pub language: &'a str, pub language: &'a str,
pub scope: &'a Scope, pub scope: &'a Scope,
@ -189,7 +191,7 @@ impl<'a> SystemPromptContext<'a> {
"datetime".into(), "datetime".into(),
Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)), Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)),
); );
root.insert("cwd".into(), Value::from(self.cwd.display().to_string())); root.insert("cwd".into(), Value::from(self.cwd.as_ref()));
root.insert("language".into(), Value::from(self.language)); root.insert("language".into(), Value::from(self.language));
root.insert( root.insert(
"tools".into(), "tools".into(),
@ -442,7 +444,7 @@ mod tests {
) -> SystemPromptContext<'a> { ) -> SystemPromptContext<'a> {
SystemPromptContext { SystemPromptContext {
now: fixed_now(), now: fixed_now(),
cwd, cwd: cwd.display().to_string().into(),
language: manifest::defaults::WORKER_LANGUAGE, language: manifest::defaults::WORKER_LANGUAGE,
scope, scope,
tool_names: tools, tool_names: tools,
@ -461,7 +463,7 @@ mod tests {
) -> SystemPromptContext<'a> { ) -> SystemPromptContext<'a> {
SystemPromptContext { SystemPromptContext {
now: fixed_now(), now: fixed_now(),
cwd, cwd: cwd.display().to_string().into(),
language: manifest::defaults::WORKER_LANGUAGE, language: manifest::defaults::WORKER_LANGUAGE,
scope, scope,
tool_names: Vec::new(), tool_names: Vec::new(),
@ -480,7 +482,7 @@ mod tests {
) -> SystemPromptContext<'a> { ) -> SystemPromptContext<'a> {
SystemPromptContext { SystemPromptContext {
now: fixed_now(), now: fixed_now(),
cwd, cwd: cwd.display().to_string().into(),
language: manifest::defaults::WORKER_LANGUAGE, language: manifest::defaults::WORKER_LANGUAGE,
scope, scope,
tool_names: Vec::new(), tool_names: Vec::new(),
@ -499,7 +501,7 @@ mod tests {
) -> SystemPromptContext<'a> { ) -> SystemPromptContext<'a> {
SystemPromptContext { SystemPromptContext {
now: fixed_now(), now: fixed_now(),
cwd, cwd: cwd.display().to_string().into(),
language: manifest::defaults::WORKER_LANGUAGE, language: manifest::defaults::WORKER_LANGUAGE,
scope, scope,
tool_names: Vec::new(), tool_names: Vec::new(),

View File

@ -384,6 +384,7 @@ mod tests {
worker_name: "orchestrator".into(), worker_name: "orchestrator".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -395,6 +396,7 @@ mod tests {
worker_name: "companion".into(), worker_name: "companion".into(),
active: None, active: None,
workspace_root: None, workspace_root: None,
workspace_id: None,
spawned_children: Vec::new(), spawned_children: Vec::new(),
reclaimed_children: Vec::new(), reclaimed_children: Vec::new(),
peers: Vec::new(), peers: Vec::new(),
@ -414,7 +416,7 @@ mod tests {
store, store,
"orchestrator".into(), "orchestrator".into(),
runtime_base.clone(), runtime_base.clone(),
root.path().to_path_buf(), Some(root.path().to_path_buf()),
SpawnedWorkerRegistry::new(runtime_dir), SpawnedWorkerRegistry::new(runtime_dir),
), ),
"companion", "companion",

File diff suppressed because it is too large Load Diff

View File

@ -164,9 +164,16 @@ async fn make_worker_with_manifest(
std::mem::forget(pwd_tmp); std::mem::forget(pwd_tmp);
let worker = Engine::new(client); let worker = Engine::new(client);
let mut worker = Worker::new(manifest, worker, store, pwd, scope) let mut worker = Worker::new(
.await manifest,
.unwrap(); worker,
store,
worker::WorkerWorkspaceContext::local_filesystem(None),
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
scope,
)
.await
.unwrap();
worker.enable_worker_metadata_write_through().unwrap(); worker.enable_worker_metadata_write_through().unwrap();
worker worker
} }

View File

@ -170,9 +170,16 @@ async fn make_worker_with(
let scope = worker::Scope::writable(&pwd).unwrap(); let scope = worker::Scope::writable(&pwd).unwrap();
let worker = Engine::new(client); let worker = Engine::new(client);
Worker::new(manifest, worker, store, pwd, scope) Worker::new(
.await manifest,
.unwrap() worker,
store,
worker::WorkerWorkspaceContext::local_filesystem(None),
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
scope,
)
.await
.unwrap()
} }
fn write_n_staging(layout: &WorkspaceLayout, n: usize) -> Vec<uuid::Uuid> { fn write_n_staging(layout: &WorkspaceLayout, n: usize) -> Vec<uuid::Uuid> {

View File

@ -12,7 +12,10 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use session_store::{CombinedStore, FsWorkerStore}; use session_store::{CombinedStore, FsWorkerStore};
use session_store::{FsStore, LogEntry}; use session_store::{FsStore, LogEntry};
use worker::{Event, Method, Worker, WorkerController, WorkerHandle, WorkerManifest, WorkerStatus}; use worker::{
Event, Method, Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle,
WorkerManifest, WorkerStatus, WorkerWorkspaceContext,
};
type TestStore = CombinedStore<FsStore, FsWorkerStore>; type TestStore = CombinedStore<FsStore, FsWorkerStore>;
@ -186,9 +189,17 @@ async fn make_worker_with_pwd_and_manifest(
std::mem::forget(pwd_tmp); std::mem::forget(pwd_tmp);
let worker = Engine::new(client); let worker = Engine::new(client);
let worker = Worker::new(manifest, worker, store, pwd.clone(), scope) let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
.await let worker = Worker::new(
.unwrap(); manifest,
worker,
store,
WorkerWorkspaceContext::local_filesystem(None),
authority,
scope,
)
.await
.unwrap();
(worker, pwd) (worker, pwd)
} }

View File

@ -190,9 +190,16 @@ async fn make_worker(
let mut worker = Engine::new(client); let mut worker = Engine::new(client);
worker.register_tool(big_content_tool_definition(tool_name)); worker.register_tool(big_content_tool_definition(tool_name));
let worker = Worker::new(manifest, worker, store, pwd, scope) let worker = Worker::new(
.await manifest,
.unwrap(); worker,
store,
worker::WorkerWorkspaceContext::local_filesystem(None),
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
scope,
)
.await
.unwrap();
(worker, store_tmp, pwd_tmp) (worker, store_tmp, pwd_tmp)
} }
@ -453,9 +460,16 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() {
// the failure path: at least one metric attempts to write. // the failure path: at least one metric attempts to write.
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]); let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
let worker = Engine::new(client); let worker = Engine::new(client);
let mut worker = Worker::new(manifest, worker, store.clone(), pwd, scope) let mut worker = Worker::new(
.await manifest,
.unwrap(); worker,
store.clone(),
worker::WorkerWorkspaceContext::local_filesystem(None),
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
scope,
)
.await
.unwrap();
let (tx, mut rx) = broadcast::channel::<Event>(64); let (tx, mut rx) = broadcast::channel::<Event>(64);
let alerter = worker::Alerter::new(tx); let alerter = worker::Alerter::new(tx);
@ -522,9 +536,16 @@ permission = "write"
let pwd = pwd_tmp.path().to_path_buf(); let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap(); let scope = worker::Scope::writable(&pwd).unwrap();
let worker = Engine::new(client); let worker = Engine::new(client);
let mut worker = Worker::new(manifest, worker, store.clone(), pwd, scope) let mut worker = Worker::new(
.await manifest,
.unwrap(); worker,
store.clone(),
worker::WorkerWorkspaceContext::local_filesystem(None),
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
scope,
)
.await
.unwrap();
let session_id = worker.session_id(); let session_id = worker.session_id();
let segment_id = worker.segment_id(); let segment_id = worker.segment_id();
worker.run_text("hello").await.unwrap(); worker.run_text("hello").await.unwrap();

View File

@ -123,7 +123,15 @@ async fn make_worker_with_body(
std::mem::forget(user_prompts_tmp); std::mem::forget(user_prompts_tmp);
let worker = Engine::new(client); let worker = Engine::new(client);
let mut worker = Worker::new(manifest, worker, store, pwd.clone(), scope).await?; let mut worker = Worker::new(
manifest,
worker,
store,
worker::WorkerWorkspaceContext::local_filesystem(None),
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
scope,
)
.await?;
let template = SystemPromptTemplate::parse("$user/test", loader) let template = SystemPromptTemplate::parse("$user/test", loader)
.map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?; .map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?;

View File

@ -752,6 +752,7 @@ pub struct WorkerLaunchRuntimeOption {
pub display_name: String, pub display_name: String,
pub built_in: bool, pub built_in: bool,
pub can_spawn_worker: bool, pub can_spawn_worker: bool,
pub working_directory_required: bool,
pub status: String, pub status: String,
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
@ -2640,6 +2641,9 @@ async fn create_workspace_worker(
relative_cwd: selection.relative_cwd, relative_cwd: selection.relative_cwd,
}); });
validate_working_directory_claim_for_browser(resolved_working_directory.as_ref())?; validate_working_directory_claim_for_browser(resolved_working_directory.as_ref())?;
if resolved_working_directory.is_none() {
reject_no_workdir_for_non_embedded_runtime(&request.runtime_id)?;
}
let result = api let result = api
.runtime .runtime
.spawn_worker( .spawn_worker(
@ -2809,11 +2813,36 @@ struct RuntimeConfigBundleAvailabilityQuery {
digest: String, digest: String,
} }
fn reject_no_workdir_for_non_embedded_runtime(
runtime_id: &str,
) -> std::result::Result<(), ApiError> {
if runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Ok(());
}
Err(ApiError::with_diagnostics(
Error::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "workspace_worker_workdir_required".to_string(),
message: "Only the embedded Runtime can launch a Worker without a working directory"
.to_string(),
},
vec![RuntimeDiagnostic {
code: "workspace_worker_workdir_required".to_string(),
severity: DiagnosticSeverity::Error,
message: "Select a working directory for this Runtime, or choose the embedded Runtime for a conversation-only Worker."
.to_string(),
}],
))
}
async fn create_runtime_worker( async fn create_runtime_worker(
State(api): State<WorkspaceApi>, State(api): State<WorkspaceApi>,
AxumPath(runtime_id): AxumPath<String>, AxumPath(runtime_id): AxumPath<String>,
Json(mut request): Json<WorkerSpawnRequest>, Json(mut request): Json<WorkerSpawnRequest>,
) -> ApiResult<Json<WorkerSpawnResult>> { ) -> ApiResult<Json<WorkerSpawnResult>> {
if request.working_directory_request.is_none() && request.resolved_working_directory.is_none() {
reject_no_workdir_for_non_embedded_runtime(&runtime_id)?;
}
request.resolved_working_directory_request = request request.resolved_working_directory_request = request
.working_directory_request .working_directory_request
.as_ref() .as_ref()
@ -3765,6 +3794,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp
display_name: runtime.label, display_name: runtime.label,
built_in, built_in,
can_spawn_worker: runtime.capabilities.can_spawn_worker, can_spawn_worker: runtime.capabilities.can_spawn_worker,
working_directory_required: !built_in,
status: runtime.status, status: runtime.status,
diagnostics: runtime.diagnostics, diagnostics: runtime.diagnostics,
} }
@ -4694,6 +4724,7 @@ impl IntoResponse for ApiError {
|| code.starts_with("unsupported_worker_profile") || code.starts_with("unsupported_worker_profile")
|| code.starts_with("working_directory_") || code.starts_with("working_directory_")
|| code.starts_with("workspace_cleanup_") || code.starts_with("workspace_cleanup_")
|| code == "workspace_worker_workdir_required"
|| code.ends_with("_already_exists") || code.ends_with("_already_exists")
|| code.ends_with("_not_config_managed") || code.ends_with("_not_config_managed")
|| code.ends_with("_unsupported") => || code.ends_with("_unsupported") =>
@ -6034,13 +6065,17 @@ mod tests {
); );
let launch_options = get_json(app.clone(), "/api/workers/launch-options").await; let launch_options = get_json(app.clone(), "/api/workers/launch-options").await;
assert!( let runtimes = launch_options["runtimes"].as_array().unwrap();
launch_options["runtimes"] let embedded_runtime = runtimes
.as_array() .iter()
.unwrap() .find(|runtime| runtime["runtime_id"] == EMBEDDED_WORKER_RUNTIME_ID)
.iter() .expect("embedded runtime launch option");
.any(|runtime| runtime["runtime_id"] == "team-runtime") assert_eq!(embedded_runtime["working_directory_required"], false);
); let team_runtime = runtimes
.iter()
.find(|runtime| runtime["runtime_id"] == "team-runtime")
.expect("team runtime launch option");
assert_eq!(team_runtime["working_directory_required"], true);
let deleted = request_json( let deleted = request_json(
app.clone(), app.clone(),
@ -6091,18 +6126,6 @@ mod tests {
) )
.await; .await;
assert_eq!(added["restart_required"], false); assert_eq!(added["restart_required"], false);
let created = post_json(
app.clone(),
"/api/workers",
serde_json::json!({
"runtime_id": "busy-runtime",
"display_name": "Remote Test Worker",
"profile": "runtime_default",
"initial_text": ""
}),
)
.await;
assert_eq!(created["runtime_id"], "busy-runtime");
let workers = get_json(app.clone(), "/api/workers").await; let workers = get_json(app.clone(), "/api/workers").await;
assert!( assert!(
workers["items"] workers["items"]
@ -6306,6 +6329,38 @@ mod tests {
assert!(!projected.contains("http://")); assert!(!projected.contains("http://"));
} }
#[tokio::test]
async fn browser_worker_create_rejects_non_embedded_no_workdir() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path()).await;
let response = request_json(
app,
"POST",
"/api/workers",
Some(serde_json::json!({
"runtime_id": "remote-runtime",
"display_name": "Remote Worker",
"profile": "runtime_default",
"initial_text": ""
})),
StatusCode::BAD_REQUEST,
)
.await;
assert!(
response["message"]
.as_str()
.unwrap_or_default()
.contains("workspace_worker_workdir_required")
);
assert!(
response["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|diagnostic| { diagnostic["code"] == "workspace_worker_workdir_required" })
);
}
#[tokio::test] #[tokio::test]
async fn runtime_worker_spawn_rejects_raw_working_directory_fields() { async fn runtime_worker_spawn_rejects_raw_working_directory_fields() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-vM2ea6W3hqnmzqOXfzQo+ZYrQBoy2Kh69vz1fkU+DIs="; cargoHash = "sha256-av+Iix50MMLcrzxw8UDTT0tUqBFIPkSN1i23WYegNcM=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -101,6 +101,7 @@ export type WorkerLaunchRuntimeOption = {
display_name: string; display_name: string;
built_in: boolean; built_in: boolean;
can_spawn_worker: boolean; can_spawn_worker: boolean;
working_directory_required: boolean;
status: string; status: string;
diagnostics: Diagnostic[]; diagnostics: Diagnostic[];
}; };

View File

@ -25,6 +25,7 @@ const options: WorkerLaunchOptionsResponse = {
status: "active", status: "active",
can_spawn_worker: true, can_spawn_worker: true,
built_in: false, built_in: false,
working_directory_required: true,
diagnostics: [], diagnostics: [],
}, },
{ {
@ -32,7 +33,8 @@ const options: WorkerLaunchOptionsResponse = {
display_name: "Embedded", display_name: "Embedded",
status: "active", status: "active",
can_spawn_worker: true, can_spawn_worker: true,
built_in: false, built_in: true,
working_directory_required: false,
diagnostics: [], diagnostics: [],
}, },
], ],
@ -107,3 +109,23 @@ Deno.test("buildBrowserCreateWorkerRequest sends working_directory id and relati
}, },
}); });
}); });
Deno.test("buildBrowserCreateWorkerRequest omits working_directory for embedded no-workdir launches", () => {
const request = buildBrowserCreateWorkerRequest({
runtime_id: "embedded",
display_name: "Worker",
profile: "builtin:companion",
initial_text: "chat",
working_directory_id: "",
working_directory_repository_id: "",
working_directory_selector: "",
relative_cwd: "",
});
assertEquals(request, {
runtime_id: "embedded",
display_name: "Worker",
profile: "builtin:companion",
initial_text: "chat",
});
});

View File

@ -41,7 +41,14 @@
let relativeCwd = $state(''); let relativeCwd = $state('');
let creatingWorkingDirectory = $state(false); let creatingWorkingDirectory = $state(false);
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE); let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
let canStartWorker = $derived(Boolean(runtimeId && profile && workingDirectoryId && !isNewWorkingDirectorySelected)); let selectedRuntime = $derived(options?.runtimes.find((runtime) => runtime.runtime_id === runtimeId));
let selectedRuntimeAllowsNoWorkdir = $derived(selectedRuntime?.working_directory_required === false);
let hasSelectedExistingWorkdir = $derived(Boolean(workingDirectoryId && !isNewWorkingDirectorySelected));
let canStartWorker = $derived(Boolean(
runtimeId &&
profile &&
(hasSelectedExistingWorkdir || (selectedRuntimeAllowsNoWorkdir && !isNewWorkingDirectorySelected)),
));
function workerApiPath(path: string): string { function workerApiPath(path: string): string {
return workspaceApiPath(workspaceId, path); return workspaceApiPath(workspaceId, path);
@ -81,7 +88,7 @@
runtimeId = form.runtime_id; runtimeId = form.runtime_id;
displayName = form.display_name; displayName = form.display_name;
profile = form.profile; profile = form.profile;
workingDirectoryId = form.working_directory_id || NEW_WORKING_DIRECTORY_VALUE; workingDirectoryId = form.working_directory_id;
workingDirectoryRepositoryId = form.working_directory_repository_id; workingDirectoryRepositoryId = form.working_directory_repository_id;
workingDirectorySelector = form.working_directory_selector; workingDirectorySelector = form.working_directory_selector;
relativeCwd = form.relative_cwd; relativeCwd = form.relative_cwd;
@ -149,8 +156,8 @@
submitError = { message: 'workspace id is unavailable', diagnostics: [] }; submitError = { message: 'workspace id is unavailable', diagnostics: [] };
return; return;
} }
if (isNewWorkingDirectorySelected || !workingDirectoryId) { if (isNewWorkingDirectorySelected || (!workingDirectoryId && !selectedRuntimeAllowsNoWorkdir)) {
submitError = { message: 'select or create a workdir before starting a Worker', diagnostics: [] }; submitError = { message: 'select or create a workdir before starting a Worker; only embedded Runtime can start without one', diagnostics: [] };
return; return;
} }
@ -220,7 +227,7 @@
<header class="worker-new-page-header"> <header class="worker-new-page-header">
<div> <div>
<h1 id="new-worker-heading">New Worker</h1> <h1 id="new-worker-heading">New Worker</h1>
<p>Create a Worker on a selected Runtime and workdir.</p> <p>Create a Worker on a selected Runtime. Workdir-less conversation Workers are only available on embedded Runtime.</p>
</div> </div>
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a> <a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
</header> </header>
@ -237,7 +244,11 @@
<div class="worker-launch-sentence"> <div class="worker-launch-sentence">
<span>Run at</span> <span>Run at</span>
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir"> <select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Workdir">
<option value="">Select workdir</option> {#if selectedRuntimeAllowsNoWorkdir}
<option value="">No workdir · embedded conversation only</option>
{:else}
<option value="" disabled>Select workdir</option>
{/if}
{#each options?.working_directories ?? [] as directory} {#each options?.working_directories ?? [] as directory}
<option value={directory.working_directory_id} disabled={directory.status !== 'active'}> <option value={directory.working_directory_id} disabled={directory.status !== 'active'}>
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'} {directory.repository_id} · {directory.requested_selector ?? 'HEAD'}
@ -259,6 +270,12 @@
</select> </select>
</div> </div>
{#if !selectedRuntimeAllowsNoWorkdir && !workingDirectoryId && !isNewWorkingDirectorySelected}
<p class="worker-workdir-note">This Runtime requires a selected workdir before starting a Worker.</p>
{:else if selectedRuntimeAllowsNoWorkdir && !workingDirectoryId}
<p class="worker-workdir-note">No filesystem tools or Bash will be available without a workdir.</p>
{/if}
{#if isNewWorkingDirectorySelected} {#if isNewWorkingDirectorySelected}
<div class="new-working-directory-panel"> <div class="new-working-directory-panel">
<h3>New workdir</h3> <h3>New workdir</h3>
@ -286,10 +303,12 @@
</div> </div>
{/if} {/if}
<label class="relative-cwd-field"> {#if hasSelectedExistingWorkdir || isNewWorkingDirectorySelected}
<span>Relative cwd inside workdir</span> <label class="relative-cwd-field">
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside workdir" /> <span>Relative cwd inside workdir</span>
</label> <input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside workdir" />
</label>
{/if}
</section> </section>
<section class="worker-form-section" aria-labelledby="worker-details-heading"> <section class="worker-form-section" aria-labelledby="worker-details-heading">