diff --git a/.gitignore b/.gitignore index 3f96ce41..fe1edfae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /target /result /.direnv +/.yoi/dev .worktree *.local* .env diff --git a/.yoi/tickets/00001KXSJXMR5/artifacts/.gitkeep b/.yoi/tickets/00001KXSJXMR5/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KXSJXMR5/item.md b/.yoi/tickets/00001KXSJXMR5/item.md new file mode 100644 index 00000000..1ee12f3d --- /dev/null +++ b/.yoi/tickets/00001KXSJXMR5/item.md @@ -0,0 +1,31 @@ +--- +title: 'Enable Ticket tools in default profile' +state: 'closed' +created_at: '2026-07-18T03:04:36Z' +updated_at: '2026-07-18T08:32:27Z' +assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-18T03:05:10Z' +--- + +## 背景 + +通常の `builtin:default` Worker では Ticket feature が無効で、`TicketList` / `TicketShow` などの typed Ticket tools が露出しない。実装作業では Ticket の前提・受け入れ条件・thread を読めないと正しく作業できないため、default profile でも Ticket tools を有効化する。 + +Orchestrator 固有の relation / orchestration plan tools は引き続き `ticket_orchestration` で分離し、default profile では有効化しない。 + +## 要件 + +- `resources/profiles/default.dcdl` の Ticket feature を有効化する。 +- builtin default profile artifact の Ticket feature も同じ既定にする。 +- setup wizard が生成する `user:default` profile でも Ticket feature を有効化する。 +- `ticket_orchestration` は default では無効のままにする。 +- 裸の `WorkerManifestConfig::builtin_defaults()` の feature default は profile 既定とは別なので変更しない。 + +## 受け入れ条件 + +- `builtin:default` profile resolution で `feature.ticket.enabled == true` になる。 +- `builtin:default` profile resolution で `feature.ticket.access == lifecycle` になる。 +- `builtin:default` profile resolution で `feature.ticket_orchestration.enabled == false` のままになる。 +- setup model が生成する default profile に `[feature.ticket] enabled = true` が含まれる。 +- 関連する profile/setup focused tests が通る。 diff --git a/.yoi/tickets/00001KXSJXMR5/resolution.md b/.yoi/tickets/00001KXSJXMR5/resolution.md new file mode 100644 index 00000000..3d425773 --- /dev/null +++ b/.yoi/tickets/00001KXSJXMR5/resolution.md @@ -0,0 +1 @@ +Default profile now enables Ticket lifecycle tools by default while keeping ticket orchestration disabled. Builtin default profile and setup-generated user default profile were updated, with focused tests covering the new expectations. diff --git a/.yoi/tickets/00001KXSJXMR5/thread.md b/.yoi/tickets/00001KXSJXMR5/thread.md new file mode 100644 index 00000000..eef81e2c --- /dev/null +++ b/.yoi/tickets/00001KXSJXMR5/thread.md @@ -0,0 +1,85 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +`builtin:default` profile と setup wizard 生成の `user:default` profile で Ticket feature を有効化した。 + +変更点: +- `resources/profiles/default.dcdl` の `feature.ticket.enabled` を `true` に変更。 +- builtin default profile artifact の `feature.ticket.enabled` を `true` に変更。 +- setup wizard が生成する default profile の `[feature.ticket] enabled` を `true` に変更。 +- builtin default profile resolution / setup profile generation のテスト期待値を追加。 +- `ticket_orchestration` は default では無効のまま維持。 +- `WorkerManifestConfig::builtin_defaults()` の裸の feature default は変更していない。 + +検証: +- `cargo test -p manifest builtin_default_resolves_without_external_evaluator --quiet` +- `cargo test -p manifest builtin_role_profiles_preserve_role_tool_policy --quiet` +- `cargo test -p tui write_default_profile_config_creates_registry_and_profile --quiet` +- `cargo test -p worker installs_ticket_tools_when_default_root_is_usable --quiet` +- `cargo fmt --check` +- `git diff --check` + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Default profile now enables Ticket lifecycle tools by default while keeping ticket orchestration disabled. Builtin default profile and setup-generated user default profile were updated, with focused tests covering the new expectations. + + +--- diff --git a/.yoi/tickets/00001KXSKP1AB/artifacts/.gitkeep b/.yoi/tickets/00001KXSKP1AB/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KXSKP1AB/item.md b/.yoi/tickets/00001KXSKP1AB/item.md new file mode 100644 index 00000000..600a1c18 --- /dev/null +++ b/.yoi/tickets/00001KXSKP1AB/item.md @@ -0,0 +1,33 @@ +--- +title: 'Add development process switch script' +state: 'closed' +created_at: '2026-07-18T03:17:55Z' +updated_at: '2026-07-18T08:32:27Z' +assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-18T03:18:16Z' +--- + +## 背景 + +開発中の backend / runtime / workspace frontend は、手動で `cargo run` や `deno` task を main worktree から起動している。作業ブランチを切った worktree に付け替える時、既存 process を安全に止めて同じ port で起動し直す手順が必要になる。 + +この worker 自体が同じ開発 process 群に依存している可能性があるため、実装時には stop/restart/start の実行は行わず、静的検証に留める。 + +## 要件 + +- `scripts/dev-workspace.sh` を追加する。 +- 引数で `start` / `stop` / `restart` を選べる。 +- `start` は runtime / backend / frontend をこの checkout から起動する。 +- `stop` は runtime / backend / frontend を停止する。 +- `restart` は frontend を触らず、runtime / backend だけ停止・起動する。 +- frontend は `0.0.0.0` bind で起動する。 +- pid/log は repository-local な runtime directory に保存し、生成物は git 管理対象外にする。 +- port listener の付け替えに対応する。 + +## 受け入れ条件 + +- `bash -n scripts/dev-workspace.sh` が通る。 +- `scripts/dev-workspace.sh --help` が安全に使い方を表示する。 +- start/stop/restart は実装するが、この作業中には実行しない。 +- runtime/backend/frontend の command と default bind/port が script 内で確認できる。 diff --git a/.yoi/tickets/00001KXSKP1AB/resolution.md b/.yoi/tickets/00001KXSKP1AB/resolution.md new file mode 100644 index 00000000..1e1c4b55 --- /dev/null +++ b/.yoi/tickets/00001KXSKP1AB/resolution.md @@ -0,0 +1 @@ +Added `scripts/dev-workspace.sh` with start/stop/restart/status. Start moves runtime/backend/frontend to this checkout, frontend binds `0.0.0.0`, restart intentionally leaves frontend untouched. Runtime files are kept under ignored `.yoi/dev/`. diff --git a/.yoi/tickets/00001KXSKP1AB/thread.md b/.yoi/tickets/00001KXSKP1AB/thread.md new file mode 100644 index 00000000..4786d5ba --- /dev/null +++ b/.yoi/tickets/00001KXSKP1AB/thread.md @@ -0,0 +1,98 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +`script/dev-workspace.sh` ではなく repository root の `scripts/dev-workspace.sh` として開発 process 管理 script を追加した。 + +実装内容: +- `start` / `stop` / `restart` / `status` action を追加。 +- `start` は runtime, backend, frontend をこの checkout から起動する。 +- `stop` は runtime, backend, frontend を止める。 +- `restart` は frontend を触らず、runtime/backend だけ止めて起動し直す。 +- frontend は既定で `0.0.0.0:5173` bind。 +- backend は既定で `127.0.0.1:8787`、runtime は `127.0.0.1:38800`。 +- pid/log は `.yoi/dev/{pids,logs}` に保存し、`.gitignore` に `/.yoi/dev` を追加。 +- 既存 listener がいる場合、port listener PID を止めてからこの checkout の process を起動する。 +- managed process は pidfile の pid/process group を使って止める。pidfile 管理外の既存 listener は、caller の process group を巻き込まないよう PID 単位で止める。 + +確認: +- `bash -n scripts/dev-workspace.sh` +- `scripts/dev-workspace.sh --help` +- `scripts/dev-workspace.sh status` +- `git diff --check` + +実行しなかったこと: +- `scripts/dev-workspace.sh start` +- `scripts/dev-workspace.sh stop` +- `scripts/dev-workspace.sh restart` + +理由: +- 現在の worker 自体が既存 backend/runtime/frontend process 群に依存している可能性があり、停止・再起動するとこのセッションが落ちるため。 + +現状確認: +- frontend listener pid は `/home/hare/Projects/yoi/web/workspace` 由来。 +- backend/runtime listener pid は `/home/hare/Projects/yoi` 由来。 + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Added `scripts/dev-workspace.sh` with start/stop/restart/status. Start moves runtime/backend/frontend to this checkout, frontend binds `0.0.0.0`, restart intentionally leaves frontend untouched. Runtime files are kept under ignored `.yoi/dev/`. + + +--- diff --git a/.yoi/tickets/00001KXT5FEFR/artifacts/.gitkeep b/.yoi/tickets/00001KXT5FEFR/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KXT5FEFR/item.md b/.yoi/tickets/00001KXT5FEFR/item.md new file mode 100644 index 00000000..1350c5b0 --- /dev/null +++ b/.yoi/tickets/00001KXT5FEFR/item.md @@ -0,0 +1,28 @@ +--- +title: 'Limit workdir dirty source check to HEAD selector' +state: 'closed' +created_at: '2026-07-18T08:28:54Z' +updated_at: '2026-07-18T08:32:27Z' +assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-18T08:29:31Z' +--- + +## 背景 + +Runtime workdir materializer は `selector` を `git rev-parse ^{commit}` で commit に解決し、`git worktree add --detach` で detached worktree を作る。明示 branch / tag / commit selector の場合、source worktree の未コミット変更は生成される detached worktree には入らないため、dirty source を理由に拒否する必要はない。 + +一方で `HEAD` selector は現在の source worktree の状態を暗黙に参照する導線なので、既存の dirty source safety check を維持する。 + +## 要件 + +- workdir materialization の dirty source check は実質 selector が `HEAD` の時だけ行う。 +- 明示 branch selector では source repository が dirty でも commit 解決と detached worktree 作成を許す。 +- `HEAD` selector の dirty source rejection は維持する。 + +## 受け入れ条件 + +- `selector = HEAD` で source repository が dirty の場合、`working_directory_dirty_source_rejected` になる。 +- `selector = ` で source repository が dirty の場合、workdir materialization が成功する。 +- 明示 branch selector で作った workdir は dirty source の未コミットファイルを含まない。 +- focused worker-runtime tests が通る。 diff --git a/.yoi/tickets/00001KXT5FEFR/resolution.md b/.yoi/tickets/00001KXT5FEFR/resolution.md new file mode 100644 index 00000000..6c543f73 --- /dev/null +++ b/.yoi/tickets/00001KXT5FEFR/resolution.md @@ -0,0 +1 @@ +Workdir dirty source rejection is now scoped to the `HEAD` selector. Explicit branch selectors can materialize detached workdirs from their resolved commit even when the source worktree has uncommitted changes. diff --git a/.yoi/tickets/00001KXT5FEFR/thread.md b/.yoi/tickets/00001KXT5FEFR/thread.md new file mode 100644 index 00000000..1ae0bd35 --- /dev/null +++ b/.yoi/tickets/00001KXT5FEFR/thread.md @@ -0,0 +1,82 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +workdir materialization の dirty source check を `selector == "HEAD"` の場合だけ実行するようにした。 + +変更点: +- `LocalGitWorktreeMaterializer::materialize` で selector を先に確定。 +- selector が `HEAD` の時だけ `git status --porcelain` による dirty source rejection を行う。 +- 明示 branch selector では source worktree が dirty でも `git rev-parse ^{commit}` で commit 解決し、detached worktree を作る。 +- branch selector + dirty source の回帰テストを追加し、未コミットファイルが materialized worktree に入らないことを確認。 + +検証: +- `cargo test -p worker-runtime --features ws-server,fs-store branch_selector_allows_dirty_source_materialization --quiet` +- `cargo test -p worker-runtime --features ws-server,fs-store dirty_source_is_rejected_by_materialization --quiet` +- `cargo test -p worker-runtime --features ws-server,fs-store local_git_repo_materializes_detached_worktree_under_runtime_root --quiet` +- `cargo fmt --check` +- `git diff --check` + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Workdir dirty source rejection is now scoped to the `HEAD` selector. Explicit branch selectors can materialize detached workdirs from their resolved commit even when the source worktree has uncommitted changes. + + +--- diff --git a/.yoi/tickets/00001KXT5ZDRE/artifacts/.gitkeep b/.yoi/tickets/00001KXT5ZDRE/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KXT5ZDRE/item.md b/.yoi/tickets/00001KXT5ZDRE/item.md new file mode 100644 index 00000000..06bb473f --- /dev/null +++ b/.yoi/tickets/00001KXT5ZDRE/item.md @@ -0,0 +1,31 @@ +--- +title: 'Run dev workspace process actions detached by default' +state: 'closed' +created_at: '2026-07-18T08:37:37Z' +updated_at: '2026-07-18T08:40:59Z' +assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-18T08:38:38Z' +--- + +## 背景 + +`scripts/dev-workspace.sh` は backend/runtime/frontend の stop/start を扱うため、API worker が依存している process を foreground tool call 中に停止すると tool result 永続化前にセッションを壊す可能性がある。 + +呼び出し元が `start` / `stop` / `restart` を実行した時点では即座に戻り、実際の mutating action は detached scheduled job として後で走るようにする。 + +## 要件 + +- `start` / `stop` / `restart` は既定で detached background job として schedule する。 +- 既定 delay は 60 秒とし、呼び出し元が tool result を返す時間を確保する。 +- scheduled job の pid と log path を呼び出し元に表示する。 +- `status` / `help` は同期実行のままにする。 +- デバッグ用に foreground 実行へ戻せる環境変数を用意する。 +- 実装中に mutating action は実行しない。 + +## 受け入れ条件 + +- `bash -n scripts/dev-workspace.sh` が通る。 +- `scripts/dev-workspace.sh --help` に detached 既定挙動と override が表示される。 +- `scripts/dev-workspace.sh status` は引き続き mutation なしで動く。 +- `start` / `stop` / `restart` の実処理分岐は内部 foreground mode 経由に限定され、通常呼び出しでは detached schedule だけを行う。 diff --git a/.yoi/tickets/00001KXT5ZDRE/resolution.md b/.yoi/tickets/00001KXT5ZDRE/resolution.md new file mode 100644 index 00000000..773a033b --- /dev/null +++ b/.yoi/tickets/00001KXT5ZDRE/resolution.md @@ -0,0 +1 @@ +`scripts/dev-workspace.sh` now schedules `start`, `stop`, and `restart` as detached background jobs by default, returning schedule/log details immediately. `status` and `help` remain synchronous, and `YOI_DEV_WORKSPACE_FOREGROUND=1` preserves an explicit foreground/debug path. diff --git a/.yoi/tickets/00001KXT5ZDRE/thread.md b/.yoi/tickets/00001KXT5ZDRE/thread.md new file mode 100644 index 00000000..c261b547 --- /dev/null +++ b/.yoi/tickets/00001KXT5ZDRE/thread.md @@ -0,0 +1,92 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +`scripts/dev-workspace.sh` の mutating actions を既定で detached scheduled job として実行するように変更した。 + +変更点: +- `start` / `stop` / `restart` は通常呼び出しでは実処理をせず、detached job を schedule して即時 return する。 +- 既定 delay は `YOI_DEV_ACTION_DELAY_SECONDS=60`。 +- 呼び出し元には `scheduled_action` / `scheduled_pid` / `scheduled_after_seconds` / `scheduled_log` を表示する。 +- scheduled job は `YOI_DEV_WORKSPACE_FOREGROUND=1` を付けて同じ script を再入実行し、そこで実際の start/stop/restart を行う。 +- `status` / `help` は同期実行のまま。 +- デバッグ用に `YOI_DEV_WORKSPACE_FOREGROUND=1` で foreground 実行できるようにした。 +- help に detached 既定挙動と環境変数 override を追記した。 + +検証: +- `bash -n scripts/dev-workspace.sh` +- `scripts/dev-workspace.sh --help >/dev/null` +- `scripts/dev-workspace.sh status >/dev/null` +- `git diff --check` + +実行しなかったこと: +- `scripts/dev-workspace.sh start` +- `scripts/dev-workspace.sh stop` +- `scripts/dev-workspace.sh restart` + +理由: +- mutating action は schedule 後に backend/runtime/frontend を止めるため、この作業中には実行しない。 + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +`scripts/dev-workspace.sh` now schedules `start`, `stop`, and `restart` as detached background jobs by default, returning schedule/log details immediately. `status` and `help` remain synchronous, and `YOI_DEV_WORKSPACE_FOREGROUND=1` preserves an explicit foreground/debug path. + + +--- diff --git a/.yoi/tickets/00001KXTB7H5N/artifacts/.gitkeep b/.yoi/tickets/00001KXTB7H5N/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KXTB7H5N/item.md b/.yoi/tickets/00001KXTB7H5N/item.md new file mode 100644 index 00000000..7d90de88 --- /dev/null +++ b/.yoi/tickets/00001KXTB7H5N/item.md @@ -0,0 +1,28 @@ +--- +title: 'Document dev workspace process script usage' +state: 'closed' +created_at: '2026-07-18T10:09:26Z' +updated_at: '2026-07-18T10:10:43Z' +assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-18T10:09:33Z' +--- + +## 背景 + +`scripts/dev-workspace.sh` は backend/runtime/frontend を停止・再起動する開発用スクリプトで、誤って foreground 実行や frontend restart を行うと API セッションやブラウザ接続に影響する。スクリプト本体を読むだけで安全な使い方と注意が分かるようにする。 + +## 要件 + +- スクリプト先頭コメントに基本コマンドを記載する。 +- `start` / `stop` / `restart` が既定で detached schedule されることを明記する。 +- `restart` は frontend を触らないことを明記する。 +- frontend も付け替える時は `start` / `stop` を使う注意を書く。 +- foreground override は通常使わない注意を書く。 + +## 受け入れ条件 + +- `scripts/dev-workspace.sh` の先頭を読めば使い方と注意が分かる。 +- `bash -n scripts/dev-workspace.sh` が通る。 +- `scripts/dev-workspace.sh --help` が通る。 +- mutating action は実行しない。 diff --git a/.yoi/tickets/00001KXTB7H5N/resolution.md b/.yoi/tickets/00001KXTB7H5N/resolution.md new file mode 100644 index 00000000..7aaf19d8 --- /dev/null +++ b/.yoi/tickets/00001KXTB7H5N/resolution.md @@ -0,0 +1 @@ +Added top-of-file usage and safety comments to `scripts/dev-workspace.sh`, documenting detached default behavior, restart/frontend semantics, foreground override risk, and scheduled log follow-up. diff --git a/.yoi/tickets/00001KXTB7H5N/thread.md b/.yoi/tickets/00001KXTB7H5N/thread.md new file mode 100644 index 00000000..9ddeaf56 --- /dev/null +++ b/.yoi/tickets/00001KXTB7H5N/thread.md @@ -0,0 +1,85 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +`scripts/dev-workspace.sh` の先頭に使い方と注意コメントを追加した。 + +追記内容: +- `status` / `restart` / `start` / `stop` の基本用途。 +- `start` / `stop` / `restart` は既定で detached schedule されること。 +- `restart` は backend/runtime のみで frontend を触らないこと。 +- frontend を worktree 間で移す場合は `start` / `stop` を使うこと。 +- `YOI_DEV_WORKSPACE_FOREGROUND=1` は active API session 中に避けるべきこと。 +- detached action 後は `scheduled_log` を確認すること。 + +検証: +- `bash -n scripts/dev-workspace.sh` +- `scripts/dev-workspace.sh --help >/dev/null` +- `scripts/dev-workspace.sh status >/dev/null` +- `git diff --check` + +mutating action (`start`/`stop`/`restart`) は実行していない。 + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Added top-of-file usage and safety comments to `scripts/dev-workspace.sh`, documenting detached default behavior, restart/frontend semantics, foreground override risk, and scheduled log follow-up. + + +--- diff --git a/.yoi/tickets/00001KXTKS0VG/artifacts/.gitkeep b/.yoi/tickets/00001KXTKS0VG/artifacts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/.yoi/tickets/00001KXTKS0VG/item.md b/.yoi/tickets/00001KXTKS0VG/item.md new file mode 100644 index 00000000..9e089389 --- /dev/null +++ b/.yoi/tickets/00001KXTKS0VG/item.md @@ -0,0 +1,29 @@ +--- +title: 'Preserve Workdirs when Workers stop or delete' +state: 'closed' +created_at: '2026-07-18T12:38:47Z' +updated_at: '2026-07-18T12:49:12Z' +assignee: null +queued_by: 'yoi ticket' +queued_at: '2026-07-18T12:39:18Z' +--- + +## 背景 + +Backend-managed Workdir は Worker とは独立した再利用可能 resource として扱う。現在は Worker stop/delete 時に runtime が Worker の working directory binding を `materializer.cleanup` してしまい、workspace-server 側の Workdir record だけが残って `corrupted` と表示される。 + +Worker lifecycle と Workdir lifecycle を分離し、Worker の削除は Workdir の占有を解放するだけにする。Workdir 実体の削除は明示的な Workdir cleanup/delete API に限定する。 + +## 要件 + +- Worker stop/delete では Workdir 実体を削除しない。 +- Worker spawn failure rollback では、その spawn request で新規 materialize した Workdir だけ cleanup する。 +- 既存 Workdir に bind した spawn failure では Workdir を cleanup しない。 +- Workdir cleanup API 経由の明示 cleanup は維持する。 + +## 受け入れ条件 + +- `stop_worker` 後も Worker に bind されていた Workdir 実体が残る。 +- Worker spawn failure で既存 Workdir bind が使われた場合、Workdir 実体は残る。 +- Worker spawn failure で新規 materialize した場合だけ rollback cleanup される。 +- focused worker-runtime tests が通る。 diff --git a/.yoi/tickets/00001KXTKS0VG/resolution.md b/.yoi/tickets/00001KXTKS0VG/resolution.md new file mode 100644 index 00000000..6ba2d2da --- /dev/null +++ b/.yoi/tickets/00001KXTKS0VG/resolution.md @@ -0,0 +1 @@ +Worker stop/delete no longer cleans up Workdir materialization. Spawn failure rollback is limited to newly materialized Workdirs and uses Workdir cleanup by id so rollback does not leave corrupted records. Existing Workdir binds survive spawn failures. diff --git a/.yoi/tickets/00001KXTKS0VG/thread.md b/.yoi/tickets/00001KXTKS0VG/thread.md new file mode 100644 index 00000000..79f36a76 --- /dev/null +++ b/.yoi/tickets/00001KXTKS0VG/thread.md @@ -0,0 +1,89 @@ + + +## 作成 + +LocalTicketBackend によって作成されました。 + +--- + + + +## Intake summary + +Marked ready by `yoi ticket state`. + +--- + + + +## State changed + +Marked ready by `yoi ticket state`. + + +--- + + + +## State changed + +Ticket を `yoi ticket` が queued にしました。 + + +--- + + + +## State changed + +State changed to `inprogress`. + + +--- + + + +## Implementation report + +Worker lifecycle と Workdir lifecycle を分離した。 + +変更点: +- `stop_worker` から `materializer.cleanup(binding)` を削除し、Worker stop/delete で Workdir 実体を削除しないようにした。 +- live execution map の `working_directory` field は stop cleanup 用だけだったため削除した。 +- `spawn_worker` の failure rollback 対象を「この spawn request で新規 materialize した binding」だけに限定した。 +- rollback は `cleanup(binding)` ではなく `cleanup_working_directory(id)` を使い、worktree 実体だけでなく record root も消して corrupted 残骸を残さないようにした。 +- 既存 Workdir bind の spawn failure では Workdir を cleanup しない。 + +追加テスト: +- `stopping_and_deleting_worker_preserves_bound_working_directory` +- `spawn_failure_with_existing_working_directory_preserves_workdir` +- `spawn_failure_with_new_materialization_rolls_back_workdir_record` + +検証: +- `cargo test -p worker-runtime --features ws-server,fs-store stopping_and_deleting_worker_preserves_bound_working_directory --quiet` +- `cargo test -p worker-runtime --features ws-server,fs-store spawn_failure_with_existing_working_directory_preserves_workdir --quiet` +- `cargo test -p worker-runtime --features ws-server,fs-store spawn_failure_with_new_materialization_rolls_back_workdir_record --quiet` +- `cargo test -p worker-runtime --features ws-server,fs-store worker_backend --quiet` +- `cargo fmt --check` +- `git diff --check` + + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +Worker stop/delete no longer cleans up Workdir materialization. Spawn failure rollback is limited to newly materialized Workdirs and uses Workdir cleanup by id so rollback does not leave corrupted records. Existing Workdir binds survive spawn failures. + + +--- diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 460adb38..bd2adb68 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -895,7 +895,6 @@ fn builtin_profile_artifact(label: &str) -> Option { true, true, false, - false, ); Some(value) } @@ -909,7 +908,6 @@ fn builtin_profile_artifact(label: &str) -> Option { true, true, false, - true, false, ); Some(value) @@ -925,7 +923,6 @@ fn builtin_profile_artifact(label: &str) -> Option { true, true, true, - true, ); Some(value) } @@ -940,7 +937,6 @@ fn builtin_profile_artifact(label: &str) -> Option { true, false, false, - false, ); Some(value) } @@ -955,7 +951,6 @@ fn builtin_profile_artifact(label: &str) -> Option { true, false, false, - false, ); Some(value) } @@ -981,7 +976,7 @@ fn builtin_default_profile_artifact() -> serde_json::Value { "memory": { "enabled": true }, "web": { "enabled": true }, "workers": { "enabled": true }, - "ticket": { "enabled": false, "access": "lifecycle" }, + "ticket": { "enabled": true, "access": "lifecycle" }, "ticket_orchestration": { "enabled": false } }, "memory": { @@ -1009,19 +1004,16 @@ fn apply_role_profile( memory: bool, web: bool, workers: bool, - ticket: bool, ticket_orchestration: bool, ) { value["slug"] = serde_json::Value::String(slug.to_string()); value["description"] = serde_json::Value::String(description.to_string()); - value["feature"] = serde_json::json!({ - "task": { "enabled": task }, - "memory": { "enabled": memory }, - "web": { "enabled": web }, - "workers": { "enabled": workers }, - "ticket": { "enabled": ticket, "access": "lifecycle" }, - "ticket_orchestration": { "enabled": ticket_orchestration } - }); + value["feature"]["task"] = serde_json::json!({ "enabled": task }); + value["feature"]["memory"] = serde_json::json!({ "enabled": memory }); + value["feature"]["web"] = serde_json::json!({ "enabled": web }); + value["feature"]["workers"] = serde_json::json!({ "enabled": workers }); + value["feature"]["ticket_orchestration"] = + serde_json::json!({ "enabled": ticket_orchestration }); } fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { @@ -1441,12 +1433,13 @@ mod tests { let companion = resolve("companion"); assert!(companion.feature.task.enabled); assert!(companion.feature.workers.enabled); - assert!(!companion.feature.ticket.enabled); assert!(companion.scope.allow.is_empty()); assert!(companion.scope.deny.is_empty()); assert!(companion.delegation_scope.allow.is_empty()); assert_eq!(companion.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert!(companion.web.is_some()); + assert!(companion.feature.ticket.enabled); + assert!(!companion.feature.ticket_orchestration.enabled); assert_eq!( companion.compaction.as_ref().unwrap().threshold, Some(240000) @@ -1497,11 +1490,14 @@ mod tests { assert_eq!(coder.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); assert!(coder.web.is_some()); assert!(coder.compaction.is_some()); + assert!(coder.feature.ticket.enabled); + assert!(!coder.feature.ticket_orchestration.enabled); let reviewer = resolve("reviewer"); assert!(reviewer.feature.task.enabled); assert!(!reviewer.feature.workers.enabled); - assert!(!reviewer.feature.ticket.enabled); + assert!(reviewer.feature.ticket.enabled); + assert!(!reviewer.feature.ticket_orchestration.enabled); assert!(reviewer.scope.allow.is_empty()); assert!(reviewer.delegation_scope.allow.is_empty()); assert_eq!(reviewer.model.ref_.as_deref(), Some("codex-oauth/gpt-5.5")); @@ -1761,6 +1757,12 @@ worker_context_max_tokens = 68000 assert!(resolved.manifest.scope.allow.is_empty()); assert!(resolved.manifest.delegation_scope.allow.is_empty()); assert!(resolved.manifest.session.record_event_trace); + assert!(resolved.manifest.feature.ticket.enabled); + assert_eq!( + resolved.manifest.feature.ticket.access, + crate::TicketFeatureAccessConfig::Lifecycle + ); + assert!(!resolved.manifest.feature.ticket_orchestration.enabled); assert_eq!( resolved.profile.as_ref().unwrap().name.as_deref(), Some("default") diff --git a/crates/tui/src/setup_model.rs b/crates/tui/src/setup_model.rs index 43cb2e49..f7d2670e 100644 --- a/crates/tui/src/setup_model.rs +++ b/crates/tui/src/setup_model.rs @@ -237,7 +237,7 @@ enabled = true enabled = false [feature.ticket] -enabled = false +enabled = true access = "lifecycle" [feature.ticket_orchestration] @@ -298,6 +298,8 @@ mod tests { assert!(profile.contains("slug = \"default\"")); assert!(profile.contains("ref = \"codex-oauth/gpt-5.5\"")); assert!(profile.contains("scope = \"workspace_write\"")); + assert!(profile.contains("[feature.ticket]\nenabled = true\naccess = \"lifecycle\"")); + assert!(profile.contains("[feature.ticket_orchestration]\nenabled = false")); } #[test] diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 4772ee03..b1d94c74 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -543,7 +543,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { struct RuntimeWorkerExecution { handle: WorkerHandle, busy: Arc, - working_directory: Option, } /// `worker-runtime` execution backend backed by real `worker` crate Workers. @@ -621,7 +620,12 @@ where { let (tx, rx) = mpsc::sync_channel(1); self.spawn_on_adapter_runtime(async move { - let _ = tx.send(task.await); + let handle = tokio::spawn(task); + let result = match handle.await { + Ok(result) => result, + Err(err) => Err(format!("worker adapter task failed: {err}")), + }; + let _ = tx.send(result); })?; Self::wait_for_runtime_task(rx) } @@ -721,14 +725,7 @@ where )); } }; - workers.insert( - worker_ref.clone(), - RuntimeWorkerExecution { - handle, - busy, - working_directory: working_directory.clone(), - }, - ); + workers.insert(worker_ref.clone(), RuntimeWorkerExecution { handle, busy }); WorkerExecutionSpawnResult::Connected { handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()), @@ -816,6 +813,7 @@ where } let mut request = request; + let mut rollback_working_directory = None; let working_directory = match ( request.request.working_directory_request.as_ref(), request.request.working_directory.as_ref(), @@ -836,6 +834,7 @@ where match materializer.materialize(&request.worker_ref, working_directory_request) { Ok(binding) => { request.working_directory = Some(binding.clone()); + rollback_working_directory = Some(binding.clone()); Some(binding) } Err(error) => { @@ -887,9 +886,9 @@ where Err(message) => { if let (Some(materializer), Some(binding)) = ( self.working_directory_materializer.as_ref(), - working_directory.as_ref(), + rollback_working_directory.as_ref(), ) { - let _ = materializer.cleanup(binding); + let _ = materializer.cleanup_working_directory(&binding.working_directory.id); } return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored( WorkerExecutionOperation::Spawn, @@ -1089,19 +1088,12 @@ where "execution handle does not reference a live Worker", ); }; - let result = self.send_method( + self.send_method( WorkerExecutionOperation::Stop, execution.handle, Method::Shutdown, WorkerExecutionRunState::Stopped, - ); - if let (Some(materializer), Some(binding)) = ( - self.working_directory_materializer.as_ref(), - execution.working_directory.as_ref(), - ) { - let _ = materializer.cleanup(binding); - } - result + ) } fn cancel_worker(&self, handle: &WorkerExecutionHandle) -> WorkerExecutionResult { @@ -1162,7 +1154,8 @@ mod tests { use crate::Runtime as EmbeddedRuntime; use crate::catalog::{ ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector, - RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest, + RepositorySelector, WorkingDirectoryClaim, WorkingDirectoryRepository, + WorkingDirectoryRequest, }; use crate::execution::WorkerExecutionContext; use crate::management::RuntimeOptions; @@ -1404,6 +1397,42 @@ mod tests { assert!(status.success(), "git {:?} failed", args); } + #[derive(Clone)] + struct FailingFactory; + + #[async_trait] + impl RuntimeWorkerFactory for FailingFactory { + async fn spawn_controller( + &self, + _request: WorkerExecutionSpawnRequest, + ) -> Result { + Err("spawn failed".to_string()) + } + + async fn restore_controller( + &self, + _request: WorkerExecutionRestoreRequest, + ) -> Result { + Err("restore failed".to_string()) + } + } + + #[test] + fn adapter_runtime_reports_task_panic() { + let backend = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap(); + + let error = backend + .run_on_adapter_runtime(async { + panic!("adapter boom"); + #[allow(unreachable_code)] + Ok::<(), String>(()) + }) + .unwrap_err(); + + assert!(error.contains("worker adapter task failed")); + assert!(error.contains("adapter boom") || error.contains("panicked")); + } + fn create_clean_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); git(dir.path(), &["init"]); @@ -1432,6 +1461,17 @@ mod tests { } } + fn materialized_worktree_root( + runtime_base: &std::path::Path, + working_directory_id: &str, + ) -> PathBuf { + runtime_base + .join("working-directories") + .join(working_directory_id) + .join("root") + .join("repo-main") + } + #[test] fn runtime_worker_name_is_runtime_local() { let worker_ref = crate::identity::WorkerRef::new(crate::identity::WorkerId::new(1)); @@ -1617,4 +1657,117 @@ mod tests { assert!(!cwd.starts_with(repo.path())); assert!(cwd.join("README.md").exists()); } + + #[test] + fn stopping_and_deleting_worker_preserves_bound_working_directory() { + let client = MockClient::new(simple_text_events()); + let runtime_base = tempfile::tempdir().unwrap(); + let repo = create_clean_repo(); + let store = tempfile::tempdir().unwrap(); + let factory = MockFactory { + client, + runtime_base: runtime_base.path().to_path_buf(), + cwd: repo.path().to_path_buf(), + store_dir: store.path().join("sessions"), + worker_metadata_dir: store.path().join("workers"), + observed_cwds: Arc::new(Mutex::new(Vec::new())), + observed_workspace_clients: Arc::new(Mutex::new(Vec::new())), + }; + let backend = WorkerRuntimeExecutionBackend::new(factory) + .unwrap() + .with_working_directory_materializer(LocalGitWorktreeMaterializer::new( + runtime_base.path(), + )); + let runtime = + EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) + .unwrap(); + runtime.store_config_bundle(test_bundle()).unwrap(); + let mut request = create_request("chat"); + request.working_directory_request = Some(working_directory_request(repo.path())); + let detail = runtime.create_worker(request).unwrap(); + let workdir_id = detail + .execution + .working_directory + .as_ref() + .unwrap() + .summary + .working_directory_id + .clone(); + let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id); + assert!(worktree_root.join("README.md").exists()); + + runtime.stop_worker(&detail.worker_ref, None).unwrap(); + runtime.delete_worker(&detail.worker_ref).unwrap(); + + assert!(worktree_root.join("README.md").exists()); + let status = runtime.working_directory(&workdir_id).unwrap(); + assert_eq!( + status.summary.status, + crate::catalog::WorkingDirectoryStatusKind::Active + ); + assert_eq!(status.summary.cleanliness.as_deref(), Some("clean")); + assert_eq!(status.summary.primary_worker_id, None); + } + + #[test] + fn spawn_failure_with_existing_working_directory_preserves_workdir() { + let runtime_base = tempfile::tempdir().unwrap(); + let repo = create_clean_repo(); + let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) + .unwrap() + .with_working_directory_materializer(LocalGitWorktreeMaterializer::new( + runtime_base.path(), + )); + let runtime = + EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) + .unwrap(); + runtime.store_config_bundle(test_bundle()).unwrap(); + let status = runtime + .create_working_directory(working_directory_request(repo.path())) + .unwrap(); + let workdir_id = status.summary.working_directory_id.clone(); + let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id); + assert!(worktree_root.join("README.md").exists()); + let mut request = create_request("chat"); + request.working_directory = Some(WorkingDirectoryClaim { + working_directory_id: workdir_id.clone(), + relative_cwd: None, + }); + + let error = runtime.create_worker(request).unwrap_err(); + + assert!(format!("{error:?}").contains("spawn failed")); + assert!(worktree_root.join("README.md").exists()); + let status = runtime.working_directory(&workdir_id).unwrap(); + assert_eq!( + status.summary.status, + crate::catalog::WorkingDirectoryStatusKind::Active + ); + } + + #[test] + fn spawn_failure_with_new_materialization_rolls_back_workdir_record() { + let runtime_base = tempfile::tempdir().unwrap(); + let repo = create_clean_repo(); + let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) + .unwrap() + .with_working_directory_materializer(LocalGitWorktreeMaterializer::new( + runtime_base.path(), + )); + let runtime = + EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) + .unwrap(); + runtime.store_config_bundle(test_bundle()).unwrap(); + let mut request = create_request("chat"); + request.working_directory_request = Some(working_directory_request(repo.path())); + + let error = runtime.create_worker(request).unwrap_err(); + + assert!(format!("{error:?}").contains("spawn failed")); + let working_directories_root = runtime_base.path().join("working-directories"); + let remaining_entries = fs::read_dir(working_directories_root) + .map(|entries| entries.count()) + .unwrap_or(0); + assert_eq!(remaining_entries, 0); + } } diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 9884f715..151b57e9 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -320,20 +320,21 @@ impl LocalGitWorktreeMaterializer { ) })?; - let status = git_stdout(&source_root, ["status", "--porcelain"])?; - if !status.trim().is_empty() { - return Err(WorkingDirectoryDiagnostic::new( - "working_directory_dirty_source_rejected", - "working directory materialization rejects dirty source repository state", - )); - } - let selector = request .repository .selector .as_deref() .unwrap_or("HEAD") .to_string(); + if selector == "HEAD" { + let status = git_stdout(&source_root, ["status", "--porcelain"])?; + if !status.trim().is_empty() { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_dirty_source_rejected", + "working directory materialization rejects dirty source repository state", + )); + } + } let commit_spec = format!("{selector}^{{commit}}"); let resolved_commit = git_stdout(&source_root, ["rev-parse", commit_spec.as_str()])? .trim() @@ -851,6 +852,30 @@ mod tests { assert!(error.message.contains("dirty source")); } + #[test] + fn branch_selector_allows_dirty_source_materialization() { + let repo = create_clean_repo(); + git(repo.path(), &["branch", "pinned"]); + fs::write(repo.path().join("dirty.txt"), "dirty\n").unwrap(); + let runtime_root = tempfile::tempdir().unwrap(); + let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path()); + let mut request = request(repo.path()); + request.repository.selector = Some(RepositorySelector::from("pinned")); + + let binding = materializer.materialize(&worker_ref(1), &request).unwrap(); + + assert_eq!( + binding + .working_directory + .evidence + .requested_selector + .as_deref(), + Some("pinned") + ); + assert!(binding.root.join("README.md").exists()); + assert!(!binding.root.join("dirty.txt").exists()); + } + #[test] fn unsupported_remote_and_non_git_provider_return_typed_diagnostics() { let runtime_root = tempfile::tempdir().unwrap(); diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 28ec53e7..6fd4292e 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -313,7 +313,6 @@ impl FeatureModule for TicketFeature { struct WorkspaceHttpTicketBackend { workspace_id: String, base_url: String, - client: reqwest::blocking::Client, } impl WorkspaceHttpTicketBackend { @@ -321,7 +320,6 @@ impl WorkspaceHttpTicketBackend { Self { workspace_id, base_url: base_url.trim_end_matches('/').to_string(), - client: reqwest::blocking::Client::new(), } } @@ -335,13 +333,27 @@ impl WorkspaceHttpTicketBackend { fn invoke( &self, operation: TicketBackendOperation, + ) -> TicketResult { + let endpoint = self.endpoint(); + if tokio::runtime::Handle::try_current().is_ok() { + return std::thread::spawn(move || Self::invoke_http(endpoint, operation)) + .join() + .map_err(|_| { + TicketError::Conflict("ticket backend request thread panicked".to_string()) + })?; + } + Self::invoke_http(endpoint, operation) + } + + fn invoke_http( + endpoint: String, + operation: TicketBackendOperation, ) -> TicketResult { let body = serde_json::to_string(&operation).map_err(|error| { TicketError::Conflict(format!("serialize ticket operation: {error}")) })?; - let response = self - .client - .post(self.endpoint()) + let response = reqwest::blocking::Client::new() + .post(endpoint) .header(reqwest::header::CONTENT_TYPE, "application/json") .body(body) .send() @@ -981,6 +993,20 @@ provider = "github" assert!(!root.join("closed").exists()); } + #[tokio::test(flavor = "multi_thread")] + async fn workspace_http_backend_invoke_is_safe_inside_async_context() { + let backend = + WorkspaceHttpTicketBackend::new("workspace-a".to_string(), "not-a-url".to_string()); + + let error = backend + .invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody { + from: "planning".to_string(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("ticket backend request failed")); + } + #[test] fn workspace_http_backend_executes_ticket_create_operation() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/worker/src/feature/plugin.rs b/crates/worker/src/feature/plugin.rs index fe6ca886..7b0b322e 100644 --- a/crates/worker/src/feature/plugin.rs +++ b/crates/worker/src/feature/plugin.rs @@ -2552,8 +2552,39 @@ struct TungstenitePluginWebSocketClient; type AsyncSystemWebSocket = tokio_tungstenite::WebSocketStream>; +struct PluginWebSocketRuntime { + runtime: Option, +} + +impl PluginWebSocketRuntime { + fn new(runtime: TokioRuntime) -> Self { + Self { + runtime: Some(runtime), + } + } + + fn get(&self) -> &TokioRuntime { + self.runtime + .as_ref() + .expect("plugin websocket runtime missing") + } +} + +impl Drop for PluginWebSocketRuntime { + fn drop(&mut self) { + let Some(runtime) = self.runtime.take() else { + return; + }; + if TokioHandle::try_current().is_ok() { + let _ = tokio::task::spawn_blocking(move || drop(runtime)); + } else { + drop(runtime); + } + } +} + struct TungstenitePluginWebSocketConnection { - runtime: TokioRuntime, + runtime: PluginWebSocketRuntime, socket: AsyncSystemWebSocket, } @@ -2592,7 +2623,7 @@ impl PluginWebSocketClient for TungstenitePluginWebSocketClient { ) .await }; - let (socket, _response) = block_on_websocket_future(&runtime, open) + let (socket, _response) = block_on_websocket_future(runtime.get(), open) .map_err(|error| { PluginWebSocketError::new(format!( "WebSocket open timed out after {} ms for {}: {error}", @@ -2619,7 +2650,7 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection { PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, self.socket.send(Message::Text(text.to_string().into())), ); - block_on_websocket_future(&self.runtime, send) + block_on_websocket_future(self.runtime.get(), send) .map_err(|_| PluginWebSocketError::new("WebSocket send timed out"))? .map_err(|error| PluginWebSocketError::new(format!("WebSocket send failed: {error}"))) } @@ -2631,7 +2662,7 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection { ) -> Result { for _ in 0..PLUGIN_WEBSOCKET_MAX_CONTROL_FRAMES { let next = tokio::time::timeout(timeout, self.socket.next()); - let message = block_on_websocket_future(&self.runtime, next) + let message = block_on_websocket_future(self.runtime.get(), next) .map_err(|_| PluginWebSocketError::new("WebSocket receive timed out"))? .ok_or_else(|| PluginWebSocketError::new("WebSocket stream ended"))? .map_err(|error| { @@ -2660,7 +2691,7 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection { PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, self.socket.send(Message::Pong(payload)), ); - block_on_websocket_future(&self.runtime, send) + block_on_websocket_future(self.runtime.get(), send) .map_err(|_| PluginWebSocketError::new("WebSocket pong timed out"))? .map_err(|error| { PluginWebSocketError::new(format!("WebSocket pong failed: {error}")) @@ -2676,19 +2707,20 @@ impl PluginWebSocketConnection for TungstenitePluginWebSocketConnection { fn close(&mut self) -> Result<(), PluginWebSocketError> { let close = tokio::time::timeout(PLUGIN_WEBSOCKET_DEFAULT_TIMEOUT, self.socket.close(None)); - block_on_websocket_future(&self.runtime, close) + block_on_websocket_future(self.runtime.get(), close) .map_err(|_| PluginWebSocketError::new("WebSocket close timed out"))? .map_err(|error| PluginWebSocketError::new(format!("WebSocket close failed: {error}"))) } } -fn new_websocket_runtime() -> Result { - TokioRuntimeBuilder::new_current_thread() +fn new_websocket_runtime() -> Result { + let runtime = TokioRuntimeBuilder::new_current_thread() .enable_all() .build() .map_err(|error| { PluginWebSocketError::new(format!("WebSocket runtime build failed: {error}")) - }) + })?; + Ok(PluginWebSocketRuntime::new(runtime)) } fn block_on_websocket_future( @@ -5656,6 +5688,12 @@ mod tests { use std::sync::{Arc, Mutex}; use tempfile::TempDir; + #[tokio::test(flavor = "multi_thread")] + async fn websocket_runtime_drop_is_safe_inside_async_context() { + let runtime = new_websocket_runtime().unwrap(); + drop(runtime); + } + fn tool(name: &str) -> manifest::plugin::PluginToolManifest { manifest::plugin::PluginToolManifest { name: name.into(), diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl index c59288ec..ea53295d 100644 --- a/resources/profiles/coder.dcdl +++ b/resources/profiles/coder.dcdl @@ -8,7 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = false; }; - ticket = { enabled = false; access = "lifecycle"; }; ticket_orchestration = { enabled = false; }; }; } diff --git a/resources/profiles/companion.dcdl b/resources/profiles/companion.dcdl index 2b0b6623..2c1c3851 100644 --- a/resources/profiles/companion.dcdl +++ b/resources/profiles/companion.dcdl @@ -8,7 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket = { enabled = false; access = "lifecycle"; }; ticket_orchestration = { enabled = false; }; }; } diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl index df896714..81d06f66 100644 --- a/resources/profiles/default.dcdl +++ b/resources/profiles/default.dcdl @@ -26,7 +26,7 @@ feature = { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket = { enabled = false; access = "lifecycle"; }; + ticket = { enabled = true; access = "lifecycle"; }; ticket_orchestration = { enabled = false; }; }; diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index e4c7a799..91e98b5e 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -8,7 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = false; }; - ticket = { enabled = true; access = "lifecycle"; }; ticket_orchestration = { enabled = false; }; }; } diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index c40224eb..a502b999 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -8,7 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = true; }; - ticket = { enabled = true; access = "lifecycle"; }; ticket_orchestration = { enabled = true; }; }; } diff --git a/resources/profiles/reviewer.dcdl b/resources/profiles/reviewer.dcdl index 732fb2a0..9f714f86 100644 --- a/resources/profiles/reviewer.dcdl +++ b/resources/profiles/reviewer.dcdl @@ -8,7 +8,6 @@ import "./default.dcdl" // { memory = { enabled = true; }; web = { enabled = true; }; workers = { enabled = false; }; - ticket = { enabled = false; access = "lifecycle"; }; ticket_orchestration = { enabled = false; }; }; } diff --git a/scripts/dev-workspace.sh b/scripts/dev-workspace.sh new file mode 100755 index 00000000..9fe9e247 --- /dev/null +++ b/scripts/dev-workspace.sh @@ -0,0 +1,523 @@ +#!/usr/bin/env bash +# Development process switcher for the workspace web/backend/runtime stack. +# +# Common usage: +# scripts/dev-workspace.sh status # inspect managed pids and port listeners only +# scripts/dev-workspace.sh restart # restart backend/runtime only; frontend is left running +# scripts/dev-workspace.sh start # move runtime/backend/frontend listeners to this checkout +# scripts/dev-workspace.sh stop # stop runtime/backend/frontend listeners +# +# Safety notes: +# - start/stop/restart are detached by default and run after +# YOI_DEV_ACTION_DELAY_SECONDS=60. This gives API/tool-call sessions time to +# persist their result before backend/runtime processes are stopped. +# - Use restart for normal backend/runtime code changes. It intentionally does +# not touch the frontend dev server. +# - Use start or stop when the frontend listener must also move between +# worktrees; frontend binds to 0.0.0.0 by default for browser access. +# - Avoid YOI_DEV_WORKSPACE_FOREGROUND=1 during active API sessions; it runs the +# mutating action synchronously and can interrupt the session that invoked it. +# - Check the printed scheduled_log after a detached action completes. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNTIME_DIR="${YOI_DEV_RUNTIME_DIR:-$ROOT_DIR/.yoi/dev}" +PID_DIR="$RUNTIME_DIR/pids" +LOG_DIR="$RUNTIME_DIR/logs" + +BACKEND_LISTEN="${YOI_DEV_BACKEND_LISTEN:-127.0.0.1:8787}" +RUNTIME_BIND="${YOI_DEV_RUNTIME_BIND:-127.0.0.1:38800}" +FRONTEND_HOST="${YOI_DEV_FRONTEND_HOST:-0.0.0.0}" +FRONTEND_PORT="${YOI_DEV_FRONTEND_PORT:-5173}" + +RUNTIME_ENABLED="${YOI_DEV_RUNTIME_ENABLED:-1}" +ACTION_DELAY_SECONDS="${YOI_DEV_ACTION_DELAY_SECONDS:-60}" +WORKDIR_ID="$(basename "$(dirname "$(dirname "$ROOT_DIR")")")" +UNIT_PREFIX="${YOI_DEV_SYSTEMD_UNIT_PREFIX:-yoi-dev-$WORKDIR_ID}" +USE_SYSTEMD="${YOI_DEV_USE_SYSTEMD:-1}" +FOREGROUND_MODE="${YOI_DEV_WORKSPACE_FOREGROUND:-0}" + +usage() { + cat < + +Manage the local Yoi development stack for this checkout: + runtime target/debug/worker-runtime-rest-server --bind $RUNTIME_BIND + backend target/debug/yoi-workspace-server serve --workspace $ROOT_DIR --db $ROOT_DIR/.yoi/workspace.db --listen $BACKEND_LISTEN + frontend deno run -A npm:vite@7.2.7 dev --host $FRONTEND_HOST --port $FRONTEND_PORT (cwd: web/workspace) + +Actions: + start schedule a detached job that stops existing listeners, then starts runtime, backend, and frontend from this checkout + stop schedule a detached job that stops runtime, backend, and frontend + restart schedule a detached job that stops/starts runtime and backend only; frontend is left untouched + status print pidfile and port-listener status without mutating processes + +By default, start/stop/restart return immediately and run in a fully detached nohup+setsid job after $ACTION_DELAY_SECONDS seconds. +This keeps API/tool-call sessions intact while the backend/runtime are restarted. Set +YOI_DEV_WORKSPACE_FOREGROUND=1 to run the mutating action synchronously. + +Environment overrides: + YOI_DEV_ACTION_DELAY_SECONDS=60 delay before detached mutating actions run + YOI_DEV_WORKSPACE_FOREGROUND=1 run start/stop/restart synchronously instead of scheduling + YOI_DEV_BACKEND_LISTEN=127.0.0.1:8787 + YOI_DEV_RUNTIME_BIND=127.0.0.1:38800 + YOI_DEV_RUNTIME_ENABLED=1 set to 0 to skip the standalone runtime process + YOI_DEV_FRONTEND_HOST=0.0.0.0 + YOI_DEV_FRONTEND_PORT=5173 + YOI_DEV_RUNTIME_DIR=$ROOT_DIR/.yoi/dev +EOF +} + +log() { + printf '[dev-workspace] %s\n' "$*" >&2 +} + +run_cargo() { + if command -v cc >/dev/null 2>&1 && command -v pkg-config >/dev/null 2>&1; then + cargo "$@" + return + fi + if command -v nix >/dev/null 2>&1 && [[ -f "$ROOT_DIR/flake.nix" ]]; then + nix develop "$ROOT_DIR" -c cargo "$@" + return + fi + cargo "$@" +} + +ensure_dirs() { + mkdir -p "$PID_DIR" "$LOG_DIR" +} + +pid_file() { + printf '%s/%s.pid' "$PID_DIR" "$1" +} + +systemd_unit_name() { + local name="$1" + printf '%s-%s.service' "$UNIT_PREFIX" "$name" +} + +systemd_available() { + [[ "$USE_SYSTEMD" != "0" ]] || return 1 + command -v systemd-run >/dev/null 2>&1 || return 1 + systemctl --user is-system-running >/dev/null 2>&1 || return 1 +} + +systemd_main_pid() { + local name="$1" + local unit + unit="$(systemd_unit_name "$name")" + systemctl --user show -P MainPID "$unit" 2>/dev/null | awk '$1 != "" && $1 != "0" { print $1; exit }' +} + +log_file() { + printf '%s/%s.log' "$LOG_DIR" "$1" +} + +is_running() { + local pid="$1" + [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null +} + +service_pid() { + local file + file="$(pid_file "$1")" + [[ -f "$file" ]] || return 1 + local pid + pid="$(cat "$file")" + is_running "$pid" || return 1 + printf '%s' "$pid" +} + +split_addr_port() { + local value="$1" + local port="${value##*:}" + local host="${value%:*}" + if [[ "$host" == "$value" || -z "$port" ]]; then + printf 'invalid address:port value: %s\n' "$value" >&2 + return 1 + fi + printf '%s\t%s\n' "$host" "$port" +} + +port_for_addr() { + split_addr_port "$1" | awk '{ print $2 }' +} + +listener_pids_for_port() { + local port="$1" + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | sort -u + return 0 + fi + if command -v ss >/dev/null 2>&1; then + ss -ltnp "sport = :$port" 2>/dev/null \ + | sed -nE 's/.*pid=([0-9]+).*/\1/p' \ + | sort -u + return 0 + fi +} + +stop_pid() { + local pid="$1" + local label="$2" + is_running "$pid" || return 0 + + local pgid + pgid="$(ps -o pgid= -p "$pid" 2>/dev/null | tr -d '[:space:]' || true)" + if [[ "$pgid" == "$pid" ]]; then + log "stopping $label process group -$pid" + kill -TERM "-$pid" 2>/dev/null || true + else + log "stopping $label pid $pid" + kill -TERM "$pid" 2>/dev/null || true + fi + + for _ in {1..50}; do + is_running "$pid" || return 0 + sleep 0.1 + done + + log "forcing $label pid $pid" + if [[ "$pgid" == "$pid" ]]; then + kill -KILL "-$pid" 2>/dev/null || true + else + kill -KILL "$pid" 2>/dev/null || true + fi +} + +stop_managed_service() { + local name="$1" + local file unit pid + file="$(pid_file "$name")" + + if systemd_available; then + unit="$(systemd_unit_name "$name")" + if systemctl --user is-active --quiet "$unit" 2>/dev/null; then + log "stopping $name systemd unit $unit" + systemctl --user stop "$unit" 2>/dev/null || true + fi + fi + + if [[ -f "$file" ]]; then + pid="$(cat "$file")" + if is_running "$pid"; then + stop_pid "$pid" "$name" + fi + rm -f "$file" + fi +} + + +stop_port_listeners() { + local label="$1" + local port="$2" + local pids=() + mapfile -t pids < <(listener_pids_for_port "$port" || true) + if [[ "${#pids[@]}" -eq 0 ]]; then + return 0 + fi + + for pid in "${pids[@]}"; do + [[ -n "$pid" ]] || continue + log "stopping existing $label listener on port $port pid $pid" + # Unmanaged dev processes may share a process group with the caller's terminal + # or pod; stop only the listener PID here. Managed processes started by this + # script are stopped by process group via pidfiles above. + kill -TERM "$pid" 2>/dev/null || true + done + + for _ in {1..50}; do + mapfile -t pids < <(listener_pids_for_port "$port" || true) + [[ "${#pids[@]}" -eq 0 ]] && return 0 + sleep 0.1 + done + + for pid in "${pids[@]}"; do + [[ -n "$pid" ]] || continue + log "forcing existing $label listener on port $port pid $pid" + kill -KILL "$pid" 2>/dev/null || true + done +} + +start_service() { + local name="$1" + local cwd="$2" + local port="$3" + shift 3 + + stop_managed_service "$name" + stop_port_listeners "$name" "$port" + + local logfile pidfile pid + logfile="$(log_file "$name")" + pidfile="$(pid_file "$name")" + : >"$logfile" + + if systemd_available; then + local unit + unit="$(systemd_unit_name "$name")" + log "starting $name systemd unit $unit; log: $logfile" + systemd-run --user --unit="$unit" --collect --same-dir --working-directory="$cwd" \ + --property="StandardOutput=append:$logfile" \ + --property="StandardError=append:$logfile" \ + --property="KillMode=control-group" \ + "$@" >/dev/null + for _ in {1..50}; do + pid="$(systemd_main_pid "$name" || true)" + if [[ -n "$pid" ]]; then + printf '%s\n' "$pid" >"$pidfile" + log "$name systemd pid $pid" + return 0 + fi + sleep 0.1 + done + printf '%s\n' "$name systemd unit did not expose MainPID" >&2 + return 1 + fi + + log "starting $name; log: $logfile" + ( + cd "$cwd" + exec setsid "$@" + ) >"$logfile" 2>&1 & + pid="$!" + printf '%s\n' "$pid" >"$pidfile" + log "$name pid $pid" +} + + +build_runtime_backend() { + if [[ "$RUNTIME_ENABLED" != "0" ]]; then + log "building runtime binary" + ( + cd "$ROOT_DIR" + run_cargo build -p worker-runtime --features ws-server,fs-store --bin worker-runtime-rest-server + ) + else + log "runtime disabled by YOI_DEV_RUNTIME_ENABLED=0; skipping runtime build" + fi + + log "building backend binary" + ( + cd "$ROOT_DIR" + run_cargo build -p yoi-workspace-server --bin yoi-workspace-server + ) +} + +start_runtime() { + if [[ "$RUNTIME_ENABLED" == "0" ]]; then + log "runtime disabled by YOI_DEV_RUNTIME_ENABLED=0" + return 0 + fi + local port runtime_bin + port="$(port_for_addr "$RUNTIME_BIND")" + runtime_bin="$ROOT_DIR/target/debug/worker-runtime-rest-server" + if [[ ! -x "$runtime_bin" ]]; then + printf 'runtime binary not found or not executable: %s\n' "$runtime_bin" >&2 + return 1 + fi + start_service runtime "$ROOT_DIR" "$port" \ + env RUST_BACKTRACE="${RUST_BACKTRACE:-1}" "$runtime_bin" --bind "$RUNTIME_BIND" +} + +start_backend() { + local port backend_bin + port="$(port_for_addr "$BACKEND_LISTEN")" + backend_bin="$ROOT_DIR/target/debug/yoi-workspace-server" + if [[ ! -x "$backend_bin" ]]; then + printf 'backend binary not found or not executable: %s\n' "$backend_bin" >&2 + return 1 + fi + start_service backend "$ROOT_DIR" "$port" \ + "$backend_bin" serve --workspace "$ROOT_DIR" --db "$ROOT_DIR/.yoi/workspace.db" --listen "$BACKEND_LISTEN" +} + +start_frontend() { + local frontend_dir="$ROOT_DIR/web/workspace" + start_service frontend "$frontend_dir" "$FRONTEND_PORT" \ + deno run -A npm:vite@7.2.7 dev --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" +} + +stop_runtime_backend() { + stop_managed_service backend + stop_managed_service runtime + stop_port_listeners backend "$(port_for_addr "$BACKEND_LISTEN")" + if [[ "$RUNTIME_ENABLED" != "0" ]]; then + stop_port_listeners runtime "$(port_for_addr "$RUNTIME_BIND")" + fi +} + +start_runtime_backend() { + build_runtime_backend + start_runtime + start_backend +} + +start_all() { + ensure_dirs + start_runtime_backend + start_frontend +} + +stop_all() { + ensure_dirs + stop_managed_service frontend + stop_runtime_backend + stop_port_listeners frontend "$FRONTEND_PORT" +} + +restart_runtime_backend() { + ensure_dirs + log "restarting runtime/backend only; frontend is left untouched" + stop_runtime_backend + start_runtime_backend +} + +status_service() { + local name="$1" + local port="$2" + local managed="-" + local listeners="-" + local unit="-" + local systemd_pid="" + if systemd_available; then + unit="$(systemd_unit_name "$name")" + systemd_pid="$(systemd_main_pid "$name" || true)" + if [[ -n "$systemd_pid" ]]; then + managed="$systemd_pid" + fi + fi + if [[ "$managed" == "-" ]] && service_pid "$name" >/dev/null; then + managed="$(service_pid "$name")" + fi + mapfile -t pids < <(listener_pids_for_port "$port" || true) + if [[ "${#pids[@]}" -gt 0 ]]; then + listeners="${pids[*]}" + fi + printf '%-8s managed_pid=%-8s port=%-6s listener_pids=%-12s unit=%s\n' "$name" "$managed" "$port" "$listeners" "$unit" +} + + +status_all() { + ensure_dirs + status_service runtime "$(port_for_addr "$RUNTIME_BIND")" + status_service backend "$(port_for_addr "$BACKEND_LISTEN")" + status_service frontend "$FRONTEND_PORT" +} + +schedule_detached_action() { + local action="$1" + ensure_dirs + + local stamp job_log job_unit + stamp="$(date +%Y%m%d-%H%M%S)" + job_log="$LOG_DIR/${action}-$stamp.job.log" + job_unit="${UNIT_PREFIX}-${action}-${stamp}-job.service" + + log "scheduling $action in ${ACTION_DELAY_SECONDS}s; log: $job_log" + if systemd_available; then + systemd-run --user --unit="$job_unit" --collect --working-directory="$ROOT_DIR" \ + --property="StandardOutput=append:$job_log" \ + --property="StandardError=append:$job_log" \ + bash -c ' + set -uo pipefail + delay="$1" + root="$2" + action="$3" + sleep "$delay" + cd "$root" + printf "[%s] dev-workspace %s starting\n" "$(date -Is)" "$action" + YOI_DEV_WORKSPACE_FOREGROUND=1 "$root/scripts/dev-workspace.sh" "$action" + status=$? + printf "[%s] dev-workspace %s finished with status %s\n" "$(date -Is)" "$action" "$status" + exit "$status" + ' dev-workspace-job "$ACTION_DELAY_SECONDS" "$ROOT_DIR" "$action" >/dev/null + + local scheduled_pid + scheduled_pid="$(systemctl --user show -P MainPID "$job_unit" 2>/dev/null | awk '$1 != "" && $1 != "0" { print $1; exit }' || true)" + printf 'scheduled_action=%s\n' "$action" + printf 'scheduled_unit=%s\n' "$job_unit" + printf 'scheduled_pid=%s\n' "${scheduled_pid:--}" + printf 'scheduled_after_seconds=%s\n' "$ACTION_DELAY_SECONDS" + printf 'scheduled_log=%s\n' "$job_log" + return 0 + fi + + nohup setsid bash -c ' + set -uo pipefail + delay="$1" + root="$2" + action="$3" + sleep "$delay" + cd "$root" + printf "[%s] dev-workspace %s starting\n" "$(date -Is)" "$action" + YOI_DEV_WORKSPACE_FOREGROUND=1 "$root/scripts/dev-workspace.sh" "$action" + status=$? + printf "[%s] dev-workspace %s finished with status %s\n" "$(date -Is)" "$action" "$status" + exit "$status" + ' dev-workspace-job "$ACTION_DELAY_SECONDS" "$ROOT_DIR" "$action" >>"$job_log" 2>&1 < /dev/null & + local scheduled_pid="$!" + disown "$scheduled_pid" 2>/dev/null || true + + printf 'scheduled_action=%s\n' "$action" + printf 'scheduled_pid=%s\n' "$scheduled_pid" + printf 'scheduled_after_seconds=%s\n' "$ACTION_DELAY_SECONDS" + printf 'scheduled_log=%s\n' "$job_log" +} + +run_mutating_action() { + local action="$1" + if [[ "$FOREGROUND_MODE" != "1" ]]; then + schedule_detached_action "$action" + return 0 + fi + + case "$action" in + start) + start_all + ;; + stop) + stop_all + ;; + restart) + restart_runtime_backend + ;; + *) + printf 'unknown mutating action: %s\n' "$action" >&2 + return 2 + ;; + esac +} + +main() { + local action="${1:-}" + case "$action" in + start) + run_mutating_action start + ;; + stop) + run_mutating_action stop + ;; + restart) + run_mutating_action restart + ;; + status) + status_all + ;; + -h|--help|help) + usage + ;; + "") + usage + exit 2 + ;; + *) + printf 'unknown action: %s\n\n' "$action" >&2 + usage >&2 + exit 2 + ;; + esac +} + +main "$@"