merge: integrate orchestrator companion event notify
This commit is contained in:
commit
ad4d0866ae
|
|
@ -1 +1,2 @@
|
|||
{"id":"orch-plan-20260611-160703-1","ticket_id":"00001KTTW04W2","kind":"accepted_plan","note":"Role Pods は今回起動しない。","accepted_plan":{"summary":"Routing では implementation_ready と判断した。ただし今回の launch instruction は role Pod spawn を explicit follow-up まで待つ指定のため、現時点では queued のまま保持し、worktree 作成・Pod 起動・merge/close は行わない。実装開始時は side effect 前に改めて blocker/workspace state を確認し、queued -> inprogress を記録してから進める。実装対象は Method::Notify の auto_run 追加、idle auto-run 抑止、live Companion への bounded progress notify、Panel freshness 表示、targeted tests。","branch":"ticket/orchestrator-progress-companion-notify","worktree":"/home/hare/Projects/yoi/.worktree/orchestrator-progress-companion-notify","role_plan":"次の明示 follow-up 後に Orchestrator が worktree-workflow で実装 worktree を作り、coder はその worktree に narrow write scope、reviewer は read-only scopeで sibling として起動する。Companion/Orchestrator/Ticket 権限境界、history-backed context、weak/best-effort notification、bounded/sensitive-safe summary を reviewer focus とする。"},"author":"orchestrator","at":"2026-06-11T16:07:03Z"}
|
||||
{"id":"orch-plan-20260613-032948-2","ticket_id":"00001KTTW04W2","kind":"accepted_plan","accepted_plan":{"summary":"再設計方針: Panel からは送らない。Orchestrator が Ticket tool で state/comment/review/close などの明示 Ticket event を記録した時だけ、live/reachable Companion へ bounded event notice を `Notify { auto_run:false }` で送る。長文 snapshot / periodic reload / polling / scheduler / auto-kick は作らない。既存 `Method::Notify { auto_run }` 互換部分は保持する。","branch":"ticket/orchestrator-ticket-event-companion-notify","worktree":"/home/hare/Projects/yoi/.worktree/orchestrator-ticket-event-companion-notify","role_plan":"Coder は child worktree に限定して、Panel reload ではなく Orchestrator/Pod 側の明示的な Ticket event に連動する Companion weak notification を実装する。Reviewer は read-only で、Panel 非依存、snapshot feed 不在、通知粒度、history-backed Notify、Companion authority 不変、`auto_run:false` semantics を確認する。"},"author":"orchestrator","at":"2026-06-13T03:29:48Z"}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
title: 'Orchestrator進捗をAutoKickなしでCompanionへ通知する'
|
||||
state: 'closed'
|
||||
created_at: '2026-06-11T08:15:24Z'
|
||||
updated_at: '2026-06-12T15:44:42Z'
|
||||
updated_at: '2026-06-13T04:22:26Z'
|
||||
assignee: null
|
||||
queued_by: 'workspace-panel'
|
||||
queued_at: '2026-06-11T10:31:56Z'
|
||||
|
|
|
|||
|
|
@ -1,41 +1,38 @@
|
|||
Orchestrator progress を AutoKick なしで live/reachable Companion に通知する仕組みを実装した。
|
||||
Orchestrator の明示 Ticket event を Companion に weak notify する形へ再実装した。
|
||||
|
||||
実装概要:
|
||||
- `Method::Notify { auto_run: bool }` を追加し、`auto_run: false` では idle Pod に `RunForNotification` を stage しない weak notification にした。
|
||||
- `auto_run: true` と legacy missing-field behavior は既存 Notify と互換にした。
|
||||
- Panel から live/reachable Companion へ bounded progress notice を `Notify { auto_run: false }` で送るようにした。
|
||||
- missing/stopped/unreachable Companion は best-effort no-op とし、spawn/restore しない。
|
||||
- Progress summary は Ticket id/title/state、role pod status、short reason、`.yoi/tickets/<id>` refs に限定し、full thread、Pod output、diagnostics、provider errors、secret-like content を含めない。
|
||||
- Panel に Companion progress freshness / last-updated indication を追加した。
|
||||
- Reviewer request_changes を受け、Companion progress notice の LLM-facing framing を Rust 直書きから `resources/prompts/panel/companion_progress_notice.md` へ移し、Rust は bounded runtime values の rendering に限定した。
|
||||
- Companion profile/tool authority は変更していない。
|
||||
- 以前の Panel reload / periodic refresh 起点の Companion progress feed は削除済みで、今回の実装でも再導入していない。
|
||||
- Orchestrator-role lifecycle Ticket tool の post-call event に限定して、live/reachable Companion peer へ `Notify { auto_run:false }` を送る。
|
||||
- 対象 event は state change、comment/plan/decision/implementation_report、review、close/resolution 系の explicit mutating Ticket event。
|
||||
- Passive Ticket reads/list/show/query では通知しない。
|
||||
- missing/stopped/unreachable Companion は no-op とし、spawn/restore しない。
|
||||
- Companion authority は増やしていない。
|
||||
- Payload は Ticket id/title/state、event kind、short summary、`.yoi/tickets/<id>` ref 程度の bounded event notice に限定し、Ticket list snapshot、full thread、Pod output、diagnostics、provider error details、長大 log は含めない。
|
||||
- LLM-facing notice framing は `resources/prompts/pod/ticket_event_companion_notice.md` に置き、Rust は bounded runtime values の構築と render に限定した。
|
||||
|
||||
Review / integration:
|
||||
- Implementation commits:
|
||||
- `a87d3154 feat: weak companion progress notify`
|
||||
- `61e6c068 fix: resource-back companion progress notice`
|
||||
- Reviewer: `yoi-reviewer-companion-progress-notify` が初回 request_changes、fix 後 approve。
|
||||
- Orchestrator merge commit: `56b10a2d merge: companion weak progress notify`
|
||||
- Ticket completion commit: `2b64f428 ticket: mark companion notify done`
|
||||
- `465ef100 feat: notify Companion on Orchestrator ticket events`
|
||||
- `6f8571f7 fix: render ticket event notice from prompt resource`
|
||||
- Reviewer: `yoi-reviewer-event-companion-notify` が approve。
|
||||
- Orchestrator merge commit: `2e5a60f4 merge: companion ticket event notify`
|
||||
- Ticket completion commit: `ee6213ee ticket: mark event companion notify done`
|
||||
|
||||
Validation:
|
||||
- `cargo test -p protocol`: pass, 39 tests
|
||||
- `cargo test -p pod --test controller_test`: pass, 36 tests
|
||||
- `cargo test -p tui companion_progress -- --nocapture`: pass, 6 tests
|
||||
- `cargo test -p tui send_notify_only_can_deliver_weak_notification_without_auto_run -- --nocapture`: pass, 1 test
|
||||
- `cargo check -p protocol -p pod -p tui`: pass
|
||||
- `cargo test -p pod ticket_event_notify`: pass
|
||||
- `cargo test -p pod ticket_event`: pass
|
||||
- `cargo test -p pod weak_notify_to_live_peer_uses_notify_without_auto_run_and_noops_when_missing`: pass
|
||||
- `cargo test -p tui companion_progress`: pass(0 matched; Panel feed remains absent)
|
||||
- `rg` check confirmed no `companion_progress` / progress feed / `send_weak_notify` references in `crates/tui/src/multi_pod.rs`
|
||||
- `cargo fmt --check`: pass
|
||||
- `git diff --check HEAD~1..HEAD`: pass
|
||||
- `./result/bin/yoi ticket doctor`: `doctor: ok`
|
||||
- `nix build .#yoi`: pass
|
||||
|
||||
Known unrelated broad-suite failures:
|
||||
- Existing prompt/TUI broad-suite failures noted in thread remain outside this Ticket and were not blockers for focused implementation/review.
|
||||
|
||||
Cleanup:
|
||||
- coder/reviewer Pods stopped。
|
||||
- child worktree `/home/hare/Projects/yoi/.worktree/orchestrator-progress-companion-notify` removed。
|
||||
- branch `ticket/orchestrator-progress-companion-notify` deleted。
|
||||
- child worktree `/home/hare/Projects/yoi/.worktree/orchestrator-ticket-event-companion-notify` removed。
|
||||
- branch `ticket/orchestrator-ticket-event-companion-notify` deleted。
|
||||
|
||||
Non-blocking risk:
|
||||
- Added `minijinja` dependency to `crates/tui`; it is already used elsewhere in the workspace, and `Cargo.lock` / `package.nix` were updated with passing Nix build.
|
||||
Non-blocking note:
|
||||
- Panel 非通知は TUI diff absence / `rg` check と focused behavior tests で確認した。将来の回帰防止として、Panel reload/open が Companion event notify を呼ばない明示 test を追加してもよい。
|
||||
|
|
|
|||
|
|
@ -514,3 +514,268 @@ Non-blocking risk:
|
|||
- Added `minijinja` dependency to `crates/tui`; it is already used elsewhere in the workspace, and `Cargo.lock` / `package.nix` were updated with passing Nix build.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: orchestrator at: 2026-06-13T02:37:58Z from: closed to: planning reason: reopen_implementation_mismatch field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
実運用で、実装が Ticket の意図を逸脱していることが確認されたため reopen する。
|
||||
|
||||
Reopen reason:
|
||||
- 現実の挙動は、Panel を開いているだけで periodic reload に連動して Companion へ progress notice を繰り返し送る形になっている。
|
||||
- Ticket は Orchestrator progress を Companion に共有することを求めていたが、Panel を actionless な notification feed source にすること、長文/スナップショット的 progress notice を Panel refresh ごとに送ることは要求していない。
|
||||
- `auto_run: false` は idle Pod を起こさないだけで、Notify は Companion history/model context に入るため、通知 spam / context 汚染として扱うべきだった。
|
||||
- Progress notice の発生源は Panel の定期 reload ではなく、Pod/Orchestrator の明示的な進捗イベント、または明示 user action に紐づくべきである。
|
||||
|
||||
Next planning/fix question:
|
||||
- 既存 `Method::Notify { auto_run }` 互換部分は残すか。
|
||||
- Panel reload から Companion への automatic dispatch を削除または default-off にするか。
|
||||
- Orchestrator/Pod 起点でどの明示イベントだけを Companion に通知するか。
|
||||
- 送る場合の payload を snapshot ではなく bounded event notice に縮小するか。
|
||||
|
||||
Until this is resolved, current Companion progress notice implementation should be treated as defective and not closed.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: decision author: orchestrator at: 2026-06-13T03:32:03Z -->
|
||||
|
||||
## Decision
|
||||
|
||||
Routing decision: implementation_ready(再設計)
|
||||
|
||||
User decision:
|
||||
- Panel に依存しない形で進める。
|
||||
- Companion へ送るのは、Orchestrator の明示的な Ticket event 通知だけにする。
|
||||
- Panel periodic reload / snapshot feed / actionless mass notification は実装しない。
|
||||
|
||||
Reason:
|
||||
- Reopen 後の不足点は、通知発生源と通知粒度だった。ユーザー判断により、発生源は Panel ではなく Orchestrator/Pod 側の Ticket event、粒度は snapshot ではなく event notice と確定した。
|
||||
- 既存 `Method::Notify { auto_run }` / idle no-run semantics は有効な基盤として残せる。
|
||||
- `auto_run:false` は Companion を起こさない weak notification として使うが、通知は history-backed context になるため、明示 Ticket event に限定する。
|
||||
- Relation blocker はなく、現在 visible child Pod はない。Orchestrator worktree は clean。
|
||||
|
||||
IntentPacket:
|
||||
|
||||
Intent:
|
||||
- Orchestrator が Ticket tool により明示的な Ticket event(state change / decision / plan / implementation_report / review / close など)を記録した時だけ、live/reachable Companion に bounded event notice を `Notify { auto_run:false }` で送る。
|
||||
|
||||
Binding decisions / invariants:
|
||||
- Panel reload、Panel periodic refresh、Panel snapshot から Companion へ通知しない。
|
||||
- 長文 snapshot、Ticket list 全体、full thread、Pod output、diagnostics、provider error detail を送らない。
|
||||
- 通知は Orchestrator/Pod 側の明示 Ticket event に紐づく event notice に限定する。
|
||||
- `auto_run:false` を使い、idle Companion を起こさない。
|
||||
- missing/stopped/unreachable Companion を spawn/restore しない。
|
||||
- Companion authority を増やさない。
|
||||
- 通知は history-backed Notify として扱い、hidden context-only injection はしない。
|
||||
- LLM-facing framing が必要なら `resources/prompts` に置き、Rust は bounded runtime values の組み立てと rendering に留める。
|
||||
|
||||
Requirements / acceptance criteria:
|
||||
- Ticket event 記録時に、live/reachable Companion が bounded event notice を weak Notify として受け取れる。
|
||||
- Panel を開いているだけでは Companion に通知が飛ばない。
|
||||
- 同一 event につき通知は高々一回で、periodic reload による再送がない。
|
||||
- 通知 payload は Ticket id/title/state、event kind、short summary、source path/ref 程度に限定される。
|
||||
- missing/stopped Companion で spawn/restore しない。
|
||||
- `auto_run:false` の idle no-run semantics は維持される。
|
||||
- focused tests で Panel 非依存、event-triggered delivery、no snapshot/feed、missing/stopped no-op、bounded/sensitive-safe content を確認する。
|
||||
- `nix build .#yoi` が通る。
|
||||
|
||||
Next action:
|
||||
- branch `ticket/orchestrator-ticket-event-companion-notify` / worktree `/home/hare/Projects/yoi/.worktree/orchestrator-ticket-event-companion-notify` で sibling coder に実装を委譲する。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: orchestrator at: 2026-06-13T03:32:03Z from: planning to: ready reason: planning_ready field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
ユーザー判断により、通知発生源・粒度・非目標が確定したため implementation-ready とする。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: orchestrator at: 2026-06-13T03:32:03Z from: ready to: queued reason: user_authorized_queue field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
ユーザーが実装続行を明示したため、Orchestrator routing 対象として queued にする。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: orchestrator at: 2026-06-13T03:32:03Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Ticket body/thread、reopen decision、relation blockers、accepted plan、current Orchestrator workspace state、visible Pods を再確認した。未解決 blocker や missing planning decision は残っていないため、implementation side effect 前に `queued -> inprogress` acceptance を記録する。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: plan author: orchestrator at: 2026-06-13T03:33:09Z -->
|
||||
|
||||
## Plan
|
||||
|
||||
Implementation worktree created and coder delegated.
|
||||
|
||||
- Ticket: `00001KTTW04W2`
|
||||
- Branch: `ticket/orchestrator-ticket-event-companion-notify`
|
||||
- Worktree: `/home/hare/Projects/yoi/.worktree/orchestrator-ticket-event-companion-notify`
|
||||
- Coder Pod: `yoi-coder-event-companion-notify`
|
||||
- Scope: write child worktree only; read child worktree plus minimal non-recursive runtime-root read required by spawn validation。
|
||||
- Task: implement Panel-independent Companion notification for explicit Orchestrator/Pod Ticket events only, using bounded `Notify { auto_run:false }` event notices; do not reintroduce Panel periodic reload/snapshot feed.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: orchestrator at: 2026-06-13T04:09:50Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Coder completed implementation and prompt-resource fix.
|
||||
|
||||
Coder Pod:
|
||||
- `yoi-coder-event-companion-notify`
|
||||
|
||||
Implementation branch/worktree:
|
||||
- Branch: `ticket/orchestrator-ticket-event-companion-notify`
|
||||
- Worktree: `/home/hare/Projects/yoi/.worktree/orchestrator-ticket-event-companion-notify`
|
||||
- Implementation commits:
|
||||
- `465ef100 feat: notify Companion on Orchestrator ticket events`
|
||||
- `6f8571f7 fix: render ticket event notice from prompt resource`
|
||||
- Worktree status: clean
|
||||
|
||||
Changed surfaces:
|
||||
- `crates/pod/src/controller.rs`
|
||||
- `crates/pod/src/discovery.rs`
|
||||
- `crates/pod/src/lib.rs`
|
||||
- `crates/pod/src/pod.rs`
|
||||
- `crates/pod/src/prompt/catalog.rs`
|
||||
- `crates/pod/src/ticket_event_notify.rs`
|
||||
- `resources/prompts/internal.toml`
|
||||
- `resources/prompts/pod/ticket_event_companion_notice.md`
|
||||
|
||||
Implementation summary:
|
||||
- Added Orchestrator-role Ticket tool post-call hook for explicit Ticket event tools.
|
||||
- Event tools include state/comment/plan/decision/implementation_report/review/close paths; passive reads/lists are ignored.
|
||||
- The hook sends a bounded event-scoped weak notification to live/reachable Companion peer via `Notify { auto_run:false }`.
|
||||
- missing/stopped/unreachable Companion is no-op; no spawn/restore.
|
||||
- Panel reload/open/periodic refresh path is not used and Panel progress feed remains removed.
|
||||
- Ticket event notice framing is prompt-resource backed by `resources/prompts/pod/ticket_event_companion_notice.md`; Rust fills bounded runtime values and renders the resource.
|
||||
- Companion authority is not expanded.
|
||||
|
||||
Orchestrator-verified validation:
|
||||
- `cargo test -p pod ticket_event_notify`: pass。
|
||||
- `cargo test -p pod ticket_event`: pass。
|
||||
- `cargo test -p pod weak_notify_to_live_peer_uses_notify_without_auto_run_and_noops_when_missing`: pass。
|
||||
- `cargo test -p tui companion_progress`: pass(0 matched; Panel feed tests remain absent)。
|
||||
- `rg` check confirmed no `companion_progress` / progress feed / `send_weak_notify` references in `crates/tui/src/multi_pod.rs`。
|
||||
- `cargo fmt --check`: pass。
|
||||
- `git diff --check orchestration/yoi-orchestrator...HEAD`: pass。
|
||||
- `cargo run -p yoi -- ticket doctor`: `doctor: ok`。
|
||||
- `nix build .#yoi`: pass。
|
||||
|
||||
Next:
|
||||
- Spawn read-only reviewer for the Panel-independent event-triggered design, prompt-resource boundary, weak notify semantics, no snapshot feed, and authority/no-spawn invariants.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: review author: reviewer:yoi-reviewer-event-companion-notify at: 2026-06-13T04:20:19Z result: approve -->
|
||||
|
||||
## Review: approve
|
||||
|
||||
Review target:
|
||||
- Worktree: `/home/hare/Projects/yoi/.worktree/orchestrator-ticket-event-companion-notify`
|
||||
- Commits:
|
||||
- `465ef100 feat: notify Companion on Orchestrator ticket events`
|
||||
- `6f8571f7 fix: render ticket event notice from prompt resource`
|
||||
|
||||
Evidence:
|
||||
- Panel 非依存: 通知処理は `crates/pod/src/ticket_event_notify.rs` の Pod/Orchestrator 側 hook として追加されており、Panel reload/open/periodic refresh 経路ではない。
|
||||
- `crates/tui/src/multi_pod.rs` に `send_weak_notify` / `ticket_event` / `companion_progress` の新規参照がない。
|
||||
- hook 登録は Orchestrator role 判定と lifecycle Ticket tool access に gated されている。
|
||||
- 対象は `TicketComment` / `TicketReview` / `TicketWorkflowState` / `TicketIntakeReady` / `TicketClose` の explicit mutating Ticket event のみで、passive read/list/show/query では発火しない。
|
||||
- tool error 時は通知しない。
|
||||
- payload は ticket id/title/state、event kind、summary、path/ref に限定され、full thread、Ticket snapshot、Pod output、diagnostics、provider error detail、長大 log を含めない。
|
||||
- LLM-facing notice framing は `resources/prompts/pod/ticket_event_companion_notice.md` と `resources/prompts/internal.toml` / `PodPrompt::TicketEventCompanionNotice` に置かれており、Rust 側は bounded runtime values を埋める。
|
||||
- `send_weak_notify_to_live_peer` は live/reachable peer にだけ `Method::Notify { auto_run:false }` を送り、missing/unreachable は no-op。spawn/restore 経路はない。
|
||||
- Companion authority expansion は見当たらない。
|
||||
- scheduler/feed loop/polling/periodic snapshot は追加されていない。
|
||||
|
||||
Reviewer-run validation:
|
||||
- `cargo test -p pod ticket_event_notify -- --nocapture`: pass。
|
||||
- `cargo test -p pod weak_notify_to_live_peer_uses_notify_without_auto_run_and_noops_when_missing -- --nocapture`: pass。
|
||||
- `cargo test -p tui companion_progress -- --nocapture`: pass(0 matched)。
|
||||
- `cargo test -p pod ticket_event -- --nocapture`: pass。
|
||||
- `git diff --check orchestration/yoi-orchestrator...HEAD`: pass。
|
||||
- `git status --short --branch`: clean。
|
||||
- focused `rg` checks for Panel/feed references and notify hook references。
|
||||
|
||||
Non-blocking note:
|
||||
- `cargo test -p tui companion_progress` は 0 matched。Panel 非通知は TUI diff がないことと `rg` で確認している。将来の回帰防止には Panel reload/open が Companion event notify を呼ばない明示 test を追加してもよい。
|
||||
- Reviewer は read-only 方針を優先して `nix build .#yoi` を再実行せず、Orchestrator の pass 証跡を採用した。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: orchestrator at: 2026-06-13T04:21:40Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Integration completed in the Orchestrator workspace.
|
||||
|
||||
Integrated implementation:
|
||||
- Ticket: `00001KTTW04W2`
|
||||
- Implementation branch: `ticket/orchestrator-ticket-event-companion-notify`
|
||||
- Implementation commits:
|
||||
- `465ef100 feat: notify Companion on Orchestrator ticket events`
|
||||
- `6f8571f7 fix: render ticket event notice from prompt resource`
|
||||
- Reviewer result: approve, no blockers
|
||||
- Orchestrator merge commit: `2e5a60f4 merge: companion ticket event notify`
|
||||
- Integration target: `orchestration/yoi-orchestrator`
|
||||
|
||||
Post-integration validation from Orchestrator worktree:
|
||||
- `cargo test -p pod ticket_event_notify`: pass。
|
||||
- `cargo test -p pod ticket_event`: pass。
|
||||
- `cargo test -p pod weak_notify_to_live_peer_uses_notify_without_auto_run_and_noops_when_missing`: pass。
|
||||
- `cargo test -p tui companion_progress`: pass(0 matched; Panel feed remains absent)。
|
||||
- `rg` check confirmed no `companion_progress` / progress feed / `send_weak_notify` references in `crates/tui/src/multi_pod.rs`。
|
||||
- `cargo fmt --check`: pass。
|
||||
- `git diff --check HEAD~1..HEAD`: pass。
|
||||
- `./result/bin/yoi ticket doctor`: `doctor: ok`。
|
||||
- `nix build .#yoi`: pass。
|
||||
|
||||
Behavior now:
|
||||
- Panel reload/open/periodic refresh does not send Companion notifications。
|
||||
- Orchestrator-role lifecycle Ticket tool post-call events can send bounded weak notifications to live/reachable Companion peer only。
|
||||
- Passive Ticket reads/lists/show/query do not notify。
|
||||
- missing/stopped/unreachable Companion remains no-op; no spawn/restore。
|
||||
- LLM-facing event notice framing is prompt-resource backed。
|
||||
|
||||
---
|
||||
|
||||
<!-- event: state_changed author: orchestrator at: 2026-06-13T04:21:40Z from: inprogress to: done reason: implementation_merged_and_validated field: state -->
|
||||
|
||||
## State changed
|
||||
|
||||
Implementation was merged into the Orchestrator branch, reviewer approved with no blockers, and post-integration validation passed. Cleanup and closure will be recorded next.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: implementation_report author: orchestrator at: 2026-06-13T04:22:26Z -->
|
||||
|
||||
## Implementation report
|
||||
|
||||
Cleanup completed.
|
||||
|
||||
- Stopped coder Pod: `yoi-coder-event-companion-notify`
|
||||
- Stopped reviewer Pod: `yoi-reviewer-event-companion-notify`
|
||||
- Removed child worktree: `/home/hare/Projects/yoi/.worktree/orchestrator-ticket-event-companion-notify`
|
||||
- Deleted implementation branch: `ticket/orchestrator-ticket-event-companion-notify`
|
||||
- Orchestrator worktree status after cleanup: clean
|
||||
|
||||
Cleanup was limited to child implementation worktree/branch and related child Pods. Root/original workspace was not used as an implementation target.
|
||||
|
||||
---
|
||||
|
||||
<!-- event: closed author: orchestrator at: 2026-06-13T04:22:26Z -->
|
||||
|
||||
## Closed
|
||||
|
||||
Resolution written to `resolution.md`.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ use llm_worker::llm_client::client::LlmClient;
|
|||
use manifest::TicketFeatureAccessConfig;
|
||||
use pod_store::PodMetadataStore;
|
||||
use session_store::Store;
|
||||
use ticket::LocalTicketBackend;
|
||||
use ticket::config::TicketConfig;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
use crate::discovery::{PodDiscovery, list_pods_tool, restore_pod_tool, send_to_peer_pod_tool};
|
||||
|
|
@ -25,6 +27,9 @@ use crate::shutdown_after_idle::{
|
|||
use crate::spawn::comm_tools::{read_pod_output_tool, send_to_pod_tool, stop_pod_tool};
|
||||
use crate::spawn::registry::SpawnedPodRegistry;
|
||||
use crate::spawn::tool::spawn_pod_tool;
|
||||
use crate::ticket_event_notify::{
|
||||
TicketEventCompanionNotifyHook, companion_pod_name_for_workspace,
|
||||
};
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, ErrorCode, Event, Method, PodStatus, RewindTargetId, RunResult,
|
||||
Segment, TurnResult,
|
||||
|
|
@ -230,6 +235,12 @@ impl PodController {
|
|||
spawned_registry.clone(),
|
||||
)?;
|
||||
|
||||
install_ticket_event_companion_notify_hook(
|
||||
&mut pod,
|
||||
runtime_base.to_path_buf(),
|
||||
spawned_registry.clone(),
|
||||
);
|
||||
|
||||
// Intake role Pods self-terminate only after a successful
|
||||
// TicketIntakeReady turn has fully settled back to Idle. The request
|
||||
// is transient controller state, not model-visible context or ticket
|
||||
|
|
@ -494,6 +505,59 @@ fn wire_event_bridges_on_worker<C, St>(
|
|||
// per-item commit channel is wired at the top of this function.
|
||||
}
|
||||
|
||||
fn install_ticket_event_companion_notify_hook<C, St>(
|
||||
pod: &mut Pod<C, St>,
|
||||
runtime_base: PathBuf,
|
||||
spawned_registry: Arc<SpawnedPodRegistry>,
|
||||
) where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + PodMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
if !is_ticket_orchestrator_role(pod.runtime_ticket_role()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ticket_feature = &pod.manifest().feature.ticket;
|
||||
if !ticket_feature.enabled
|
||||
|| !matches!(ticket_feature.access, TicketFeatureAccessConfig::Lifecycle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(companion_pod_name) = companion_pod_name_for_workspace(pod.workspace_root()) else {
|
||||
return;
|
||||
};
|
||||
if companion_pod_name == pod.manifest().pod.name {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(ticket_config) = TicketConfig::load_workspace(pod.cwd()) else {
|
||||
return;
|
||||
};
|
||||
let backend_root = ticket_config.backend_root().to_path_buf();
|
||||
if !backend_root.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let discovery = PodDiscovery::new(
|
||||
pod.pod_metadata_store(),
|
||||
pod.manifest().pod.name.clone(),
|
||||
runtime_base,
|
||||
pod.cwd().to_path_buf(),
|
||||
spawned_registry,
|
||||
);
|
||||
pod.add_post_tool_call_hook(TicketEventCompanionNotifyHook::new(
|
||||
LocalTicketBackend::new(backend_root),
|
||||
discovery,
|
||||
companion_pod_name,
|
||||
));
|
||||
}
|
||||
|
||||
fn is_ticket_orchestrator_role(role: Option<&str>) -> bool {
|
||||
role.map(|role| role.eq_ignore_ascii_case("orchestrator"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Register the builtin file-manipulation tools, optional memory tools,
|
||||
/// and the Pod-orchestration tools (SpawnPod + comm) on the Pod's
|
||||
/// Worker. Returns the `ScopedFs` clone used to attach a `PodFsView` to
|
||||
|
|
|
|||
|
|
@ -354,6 +354,18 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn send_weak_notify_to_live_peer(&self, peer_name: &str, message: String) -> bool {
|
||||
let Ok(detail) = self.inspect(peer_name).await else {
|
||||
return false;
|
||||
};
|
||||
if detail.visibility != VisibilityReason::Peer || !detail.live.reachable {
|
||||
return false;
|
||||
}
|
||||
send_notify(&detail.live.socket_path, message, false)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
async fn live_for_name(&self, pod_name: &str, socket_override: Option<&Path>) -> LiveInfo {
|
||||
let socket_path = socket_override
|
||||
.map(Path::to_path_buf)
|
||||
|
|
@ -913,14 +925,11 @@ where
|
|||
}
|
||||
|
||||
async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()> {
|
||||
connect_and_send(
|
||||
socket_path,
|
||||
&Method::Notify {
|
||||
message,
|
||||
auto_run: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
send_notify(socket_path, message, true).await
|
||||
}
|
||||
|
||||
async fn send_notify(socket_path: &Path, message: String, auto_run: bool) -> io::Result<()> {
|
||||
connect_and_send(socket_path, &Method::Notify { message, auto_run }).await
|
||||
}
|
||||
|
||||
fn json_content<T: Serialize>(value: &T) -> Result<String, ToolError> {
|
||||
|
|
@ -1421,6 +1430,150 @@ mod tests {
|
|||
target.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn weak_notify_to_live_peer_uses_notify_without_auto_run_and_noops_when_missing() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let store_dir = root.path().join("store");
|
||||
let runtime_base = root.path().join("runtime");
|
||||
std::fs::create_dir_all(runtime_base.join("target")).unwrap();
|
||||
let store = FsPodStore::new(&store_dir).unwrap();
|
||||
store
|
||||
.write(&PodMetadata {
|
||||
pod_name: "source".into(),
|
||||
active: None,
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: vec![pod_store::PodPeer {
|
||||
pod_name: "target".into(),
|
||||
}],
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.write(&PodMetadata {
|
||||
pod_name: "target".into(),
|
||||
active: None,
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: vec![pod_store::PodPeer {
|
||||
pod_name: "source".into(),
|
||||
}],
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap());
|
||||
let discovery = PodDiscovery::new(
|
||||
store,
|
||||
"source".into(),
|
||||
runtime_base.clone(),
|
||||
root.path().to_path_buf(),
|
||||
SpawnedPodRegistry::new(runtime_dir),
|
||||
);
|
||||
|
||||
let socket = runtime_base.join("target").join("sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||||
let target = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut writer = JsonLineWriter::new(stream);
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
greeting: protocol::Greeting {
|
||||
pod_name: "target".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: PodStatus::Idle,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let (reader_half, writer_half) = stream.into_split();
|
||||
let mut reader = JsonLineReader::new(reader_half);
|
||||
let mut writer = JsonLineWriter::new(writer_half);
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
greeting: protocol::Greeting {
|
||||
pod_name: "target".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: PodStatus::Idle,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let method = reader.next::<Method>().await.unwrap().unwrap();
|
||||
if let Method::Notify { message, auto_run } = method {
|
||||
assert!(!auto_run);
|
||||
tx.send(message).await.unwrap();
|
||||
} else {
|
||||
panic!("expected Notify, got {method:?}");
|
||||
}
|
||||
});
|
||||
|
||||
assert!(
|
||||
discovery
|
||||
.send_weak_notify_to_live_peer("target", "weak event".into())
|
||||
.await
|
||||
);
|
||||
assert_eq!(rx.recv().await.unwrap(), "weak event");
|
||||
target.await.unwrap();
|
||||
|
||||
assert!(
|
||||
!discovery
|
||||
.send_weak_notify_to_live_peer("missing", "no-op".into())
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn weak_notify_does_not_send_to_spawned_child_visibility() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let store_dir = root.path().join("store");
|
||||
let runtime_base = root.path().join("runtime");
|
||||
std::fs::create_dir_all(runtime_base.join("target")).unwrap();
|
||||
let store = FsPodStore::new(&store_dir).unwrap();
|
||||
let socket = runtime_base.join("target").join("sock");
|
||||
store
|
||||
.write(&PodMetadata {
|
||||
pod_name: "source".into(),
|
||||
active: None,
|
||||
spawned_children: vec![child("target", &socket)],
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: Vec::new(),
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store.write(&PodMetadata::new("target", None)).unwrap();
|
||||
let runtime_dir = Arc::new(RuntimeDir::create(&runtime_base, "source").await.unwrap());
|
||||
let discovery = PodDiscovery::new(
|
||||
store,
|
||||
"source".into(),
|
||||
runtime_base,
|
||||
root.path().to_path_buf(),
|
||||
SpawnedPodRegistry::new(runtime_dir),
|
||||
);
|
||||
|
||||
assert!(
|
||||
!discovery
|
||||
.send_weak_notify_to_live_peer("target", "must not send".into())
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn probe_socket_reads_status_after_replayed_alert() {
|
||||
let root = TempDir::new().unwrap();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ pub mod workflow;
|
|||
mod interrupt_prep;
|
||||
mod permission;
|
||||
mod pod;
|
||||
mod ticket_event_notify;
|
||||
|
||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||
pub use controller::{PodController, PodHandle, ShutdownReceiver};
|
||||
|
|
|
|||
|
|
@ -728,6 +728,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
&self.workspace_root
|
||||
}
|
||||
|
||||
pub(crate) fn pod_metadata_store(&self) -> St
|
||||
where
|
||||
St: Clone,
|
||||
{
|
||||
self.store.clone()
|
||||
}
|
||||
|
||||
/// The Pod's directory scope, as a shared atomically-swappable
|
||||
/// handle. Clone it to share scope state with another consumer
|
||||
/// (e.g. a tool that needs to mutate scope dynamically).
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ pub enum PodPrompt {
|
|||
/// Trailing Pod orchestration guidance, appended when registered tools
|
||||
/// include Pod-management capabilities.
|
||||
PodOrchestrationGuidanceSection,
|
||||
/// Weak Companion Notify payload for explicit Orchestrator Ticket events.
|
||||
TicketEventCompanionNotice,
|
||||
/// LLM-facing description for the SpawnPod tool, including discovered
|
||||
/// profile selectors.
|
||||
SpawnPodToolDescription,
|
||||
|
|
@ -115,6 +117,7 @@ impl PodPrompt {
|
|||
Self::ResidentKnowledgeSection => "resident_knowledge_section",
|
||||
Self::ResidentWorkflowsSection => "resident_workflows_section",
|
||||
Self::PodOrchestrationGuidanceSection => "pod_orchestration_guidance_section",
|
||||
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
|
||||
Self::SpawnPodToolDescription => "spawn_pod_tool_description",
|
||||
}
|
||||
}
|
||||
|
|
@ -135,6 +138,7 @@ impl PodPrompt {
|
|||
PodPrompt::ResidentKnowledgeSection,
|
||||
PodPrompt::ResidentWorkflowsSection,
|
||||
PodPrompt::PodOrchestrationGuidanceSection,
|
||||
PodPrompt::TicketEventCompanionNotice,
|
||||
PodPrompt::SpawnPodToolDescription,
|
||||
];
|
||||
|
||||
|
|
@ -151,6 +155,7 @@ impl PodPrompt {
|
|||
"resident_knowledge_section",
|
||||
"resident_workflows_section",
|
||||
"pod_orchestration_guidance_section",
|
||||
"ticket_event_companion_notice",
|
||||
"spawn_pod_tool_description",
|
||||
];
|
||||
}
|
||||
|
|
|
|||
453
crates/pod/src/ticket_event_notify.rs
Normal file
453
crates/pod/src/ticket_event_notify.rs
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use minijinja::Value as TemplateValue;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::discovery::PodDiscovery;
|
||||
use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary};
|
||||
use crate::prompt::catalog::{PodPrompt, PromptCatalog};
|
||||
use pod_store::PodMetadataStore;
|
||||
|
||||
const MAX_TITLE_CHARS: usize = 96;
|
||||
const MAX_SUMMARY_CHARS: usize = 160;
|
||||
const MAX_EVENT_KIND_CHARS: usize = 80;
|
||||
const MAX_MESSAGE_CHARS: usize = 768;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TicketEventCompanionNotifyHook<
|
||||
St: PodMetadataStore + Clone + Send + Sync + 'static,
|
||||
> {
|
||||
backend: Arc<LocalTicketBackend>,
|
||||
discovery: PodDiscovery<St>,
|
||||
companion_pod_name: String,
|
||||
}
|
||||
|
||||
impl<St: PodMetadataStore + Clone + Send + Sync + 'static> TicketEventCompanionNotifyHook<St> {
|
||||
pub(crate) fn new(
|
||||
backend: LocalTicketBackend,
|
||||
discovery: PodDiscovery<St>,
|
||||
companion_pod_name: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
backend: Arc::new(backend),
|
||||
discovery,
|
||||
companion_pod_name: companion_pod_name.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<St: PodMetadataStore + Clone + Send + Sync + 'static> Hook<PostToolCall>
|
||||
for TicketEventCompanionNotifyHook<St>
|
||||
{
|
||||
async fn call(&self, summary: &ToolResultSummary) -> HookPostToolAction {
|
||||
let Some(notice) = build_ticket_event_notice(&self.backend, summary) else {
|
||||
return HookPostToolAction::Continue;
|
||||
};
|
||||
let delivered = self
|
||||
.discovery
|
||||
.send_weak_notify_to_live_peer(&self.companion_pod_name, notice.message)
|
||||
.await;
|
||||
if delivered {
|
||||
debug!(
|
||||
ticket = %notice.ticket_id,
|
||||
event_kind = %notice.event_kind,
|
||||
companion = %self.companion_pod_name,
|
||||
"delivered weak Ticket event notification to Companion peer"
|
||||
);
|
||||
}
|
||||
HookPostToolAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct TicketEventNotice {
|
||||
ticket_id: String,
|
||||
event_kind: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn build_ticket_event_notice(
|
||||
backend: &LocalTicketBackend,
|
||||
summary: &ToolResultSummary,
|
||||
) -> Option<TicketEventNotice> {
|
||||
if summary.is_error {
|
||||
return None;
|
||||
}
|
||||
let output = &summary.output;
|
||||
let content = output.content.as_deref()?;
|
||||
let content: Value = serde_json::from_str(content).ok()?;
|
||||
if !content.get("ok").and_then(Value::as_bool).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let event_kind = explicit_ticket_event_kind(summary.tool_name.as_str(), &content)?;
|
||||
let ticket_query = content.get("ticket").and_then(Value::as_str)?;
|
||||
let ticket = backend
|
||||
.show(TicketIdOrSlug::Query(ticket_query.to_string()))
|
||||
.ok()?;
|
||||
|
||||
let event_kind = sanitize_one_line(&event_kind, MAX_EVENT_KIND_CHARS);
|
||||
let ticket_id = ticket.meta.id.as_str();
|
||||
let title = sanitize_one_line(&ticket.meta.title, MAX_TITLE_CHARS);
|
||||
let state = ticket.meta.workflow_state.as_str();
|
||||
let output_summary = sanitize_one_line(&output.summary, MAX_SUMMARY_CHARS);
|
||||
let ref_path = event_ref_path(ticket_id, summary.tool_name.as_str());
|
||||
let message = render_ticket_event_notice_message(TicketEventNoticeValues {
|
||||
ticket_id,
|
||||
title: &title,
|
||||
state,
|
||||
event_kind: &event_kind,
|
||||
summary: &output_summary,
|
||||
ref_path: &ref_path,
|
||||
})?;
|
||||
|
||||
Some(TicketEventNotice {
|
||||
ticket_id: ticket_id.to_string(),
|
||||
event_kind,
|
||||
message: bound_chars(&message, MAX_MESSAGE_CHARS),
|
||||
})
|
||||
}
|
||||
|
||||
struct TicketEventNoticeValues<'a> {
|
||||
ticket_id: &'a str,
|
||||
title: &'a str,
|
||||
state: &'a str,
|
||||
event_kind: &'a str,
|
||||
summary: &'a str,
|
||||
ref_path: &'a str,
|
||||
}
|
||||
|
||||
fn render_ticket_event_notice_message(values: TicketEventNoticeValues<'_>) -> Option<String> {
|
||||
PromptCatalog::builtins_only()
|
||||
.ok()?
|
||||
.render(PodPrompt::TicketEventCompanionNotice, values.to_template())
|
||||
.ok()
|
||||
}
|
||||
|
||||
impl TicketEventNoticeValues<'_> {
|
||||
fn to_template(&self) -> TemplateValue {
|
||||
let mut values: BTreeMap<&'static str, TemplateValue> = BTreeMap::new();
|
||||
values.insert("ticket_id", TemplateValue::from(self.ticket_id));
|
||||
values.insert("title", TemplateValue::from(self.title));
|
||||
values.insert("state", TemplateValue::from(self.state));
|
||||
values.insert("event_kind", TemplateValue::from(self.event_kind));
|
||||
values.insert("summary", TemplateValue::from(self.summary));
|
||||
values.insert("ref_path", TemplateValue::from(self.ref_path));
|
||||
TemplateValue::from(values)
|
||||
}
|
||||
}
|
||||
|
||||
fn explicit_ticket_event_kind(tool_name: &str, content: &Value) -> Option<String> {
|
||||
match tool_name {
|
||||
"TicketComment" => content
|
||||
.get("event")
|
||||
.and_then(Value::as_str)
|
||||
.map(|event| format!("comment/{event}")),
|
||||
"TicketReview" => content
|
||||
.get("review")
|
||||
.and_then(Value::as_str)
|
||||
.map(|review| format!("review/{review}")),
|
||||
"TicketWorkflowState" => {
|
||||
let from = content.get("from").and_then(Value::as_str).unwrap_or("?");
|
||||
let to = content.get("to").and_then(Value::as_str).unwrap_or("?");
|
||||
Some(format!("state/{from}->{to}"))
|
||||
}
|
||||
"TicketIntakeReady" => Some("state/planning->ready".to_string()),
|
||||
"TicketClose" => Some("close/resolution".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn event_ref_path(ticket_id: &str, tool_name: &str) -> String {
|
||||
let leaf = match tool_name {
|
||||
"TicketClose" => "resolution.md",
|
||||
"TicketIntakeReady" | "TicketWorkflowState" => "item.md",
|
||||
_ => "thread.md",
|
||||
};
|
||||
format!(".yoi/tickets/{ticket_id}/{leaf}")
|
||||
}
|
||||
|
||||
fn sanitize_one_line(input: &str, limit: usize) -> String {
|
||||
let collapsed = input.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
bound_chars(&collapsed, limit)
|
||||
}
|
||||
|
||||
fn bound_chars(input: &str, limit: usize) -> String {
|
||||
let mut out = String::new();
|
||||
for (idx, ch) in input.chars().filter(|ch| !ch.is_control()).enumerate() {
|
||||
if idx >= limit {
|
||||
out.push('…');
|
||||
break;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn companion_pod_name_for_workspace(workspace_root: &std::path::Path) -> Option<String> {
|
||||
workspace_root
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::PodStatus;
|
||||
use crate::runtime::dir::RuntimeDir;
|
||||
use crate::spawn::registry::SpawnedPodRegistry;
|
||||
use llm_worker::tool::ToolOutput;
|
||||
use pod_store::FsPodStore;
|
||||
use pod_store::PodMetadata;
|
||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||
use protocol::{Event, Method};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use ticket::NewTicket;
|
||||
use tokio::net::UnixListener;
|
||||
|
||||
fn create_backend_with_ticket(title: &str) -> (tempfile::TempDir, LocalTicketBackend, String) {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let backend = LocalTicketBackend::new(dir.path().to_path_buf());
|
||||
let mut input = NewTicket::new(title);
|
||||
input.body = ticket::MarkdownText::new("body");
|
||||
let ticket = backend.create(input).expect("create ticket");
|
||||
(dir, backend, ticket.id)
|
||||
}
|
||||
|
||||
fn tool_summary(tool_name: &str, output: ToolOutput) -> ToolResultSummary {
|
||||
ToolResultSummary {
|
||||
call_id: "test-call".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
output,
|
||||
is_error: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_bounded_event_scoped_notice_for_ticket_state_change() {
|
||||
let (_dir, backend, ticket_id) = create_backend_with_ticket(
|
||||
"A very long title that should be bounded but still identify the ticket precisely enough for Companion",
|
||||
);
|
||||
let output = ToolOutput {
|
||||
summary: "Changed ticket state from queued to inprogress with a deliberately long summary that should be bounded before entering the weak notification payload and should not contain large logs".into(),
|
||||
content: Some(
|
||||
json!({
|
||||
"ok": true,
|
||||
"ticket": ticket_id,
|
||||
"from": "queued",
|
||||
"to": "inprogress",
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let notice =
|
||||
build_ticket_event_notice(&backend, &tool_summary("TicketWorkflowState", output))
|
||||
.expect("notice");
|
||||
|
||||
assert_eq!(notice.ticket_id, ticket_id);
|
||||
assert_eq!(notice.event_kind, "state/queued->inprogress");
|
||||
assert!(notice.message.contains("auto_run=false"));
|
||||
assert!(notice.message.contains("event: state/queued->inprogress"));
|
||||
assert!(notice.message.contains("ref: .yoi/tickets/"));
|
||||
assert!(notice.message.chars().count() <= MAX_MESSAGE_CHARS + 1);
|
||||
|
||||
let expected = PromptCatalog::builtins_only()
|
||||
.expect("load prompt catalog")
|
||||
.render(
|
||||
PodPrompt::TicketEventCompanionNotice,
|
||||
TicketEventNoticeValues {
|
||||
ticket_id: ¬ice.ticket_id,
|
||||
title: &sanitize_one_line(
|
||||
"A very long title that should be bounded but still identify the ticket precisely enough for Companion",
|
||||
MAX_TITLE_CHARS,
|
||||
),
|
||||
state: "planning",
|
||||
event_kind: "state/queued->inprogress",
|
||||
summary: &sanitize_one_line(
|
||||
"Changed ticket state from queued to inprogress with a deliberately long summary that should be bounded before entering the weak notification payload and should not contain large logs",
|
||||
MAX_SUMMARY_CHARS,
|
||||
),
|
||||
ref_path: &format!(".yoi/tickets/{}/item.md", ticket_id),
|
||||
}
|
||||
.to_template(),
|
||||
)
|
||||
.expect("render prompt resource");
|
||||
assert_eq!(notice.message, bound_chars(&expected, MAX_MESSAGE_CHARS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_passive_or_non_event_ticket_tools() {
|
||||
let (_dir, backend, ticket_id) = create_backend_with_ticket("Passive list test");
|
||||
let output = ToolOutput {
|
||||
summary: "Listed tickets".into(),
|
||||
content: Some(json!({"ok": true, "ticket": ticket_id}).to_string()),
|
||||
};
|
||||
|
||||
assert!(build_ticket_event_notice(&backend, &tool_summary("TicketList", output)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notice_does_not_include_tool_content_body_or_error_details() {
|
||||
let (_dir, backend, ticket_id) = create_backend_with_ticket("Safe payload");
|
||||
let output = ToolOutput {
|
||||
summary: "Appended implementation_report to ticket".into(),
|
||||
content: Some(
|
||||
json!({
|
||||
"ok": true,
|
||||
"ticket": ticket_id,
|
||||
"event": "implementation_report",
|
||||
"body": "SECRET_TOKEN provider stack trace long diagnostic should not be copied",
|
||||
"error": "provider error details should not be copied"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let notice = build_ticket_event_notice(&backend, &tool_summary("TicketComment", output))
|
||||
.expect("notice");
|
||||
|
||||
assert!(
|
||||
notice
|
||||
.message
|
||||
.contains("event: comment/implementation_report")
|
||||
);
|
||||
assert!(!notice.message.contains("SECRET_TOKEN"));
|
||||
assert!(!notice.message.contains("provider error details"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ticket_event_hook_delivers_weak_companion_notification() {
|
||||
let root = tempdir().expect("tempdir");
|
||||
let runtime_base = root.path().join("runtime");
|
||||
let store_dir = root.path().join("store");
|
||||
std::fs::create_dir_all(runtime_base.join("companion")).unwrap();
|
||||
let store = FsPodStore::new(&store_dir).unwrap();
|
||||
store
|
||||
.write(&PodMetadata {
|
||||
pod_name: "orchestrator".into(),
|
||||
active: None,
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: vec![pod_store::PodPeer {
|
||||
pod_name: "companion".into(),
|
||||
}],
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.write(&PodMetadata {
|
||||
pod_name: "companion".into(),
|
||||
active: None,
|
||||
spawned_children: Vec::new(),
|
||||
reclaimed_children: Vec::new(),
|
||||
peers: vec![pod_store::PodPeer {
|
||||
pod_name: "orchestrator".into(),
|
||||
}],
|
||||
resolved_manifest_snapshot: None,
|
||||
})
|
||||
.unwrap();
|
||||
let (_ticket_dir, backend, ticket_id) = create_backend_with_ticket("Companion event hook");
|
||||
let runtime_dir = Arc::new(
|
||||
RuntimeDir::create(&runtime_base, "orchestrator")
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let hook = TicketEventCompanionNotifyHook::new(
|
||||
backend,
|
||||
PodDiscovery::new(
|
||||
store,
|
||||
"orchestrator".into(),
|
||||
runtime_base.clone(),
|
||||
root.path().to_path_buf(),
|
||||
SpawnedPodRegistry::new(runtime_dir),
|
||||
),
|
||||
"companion",
|
||||
);
|
||||
|
||||
let socket = runtime_base.join("companion").join("sock");
|
||||
let listener = UnixListener::bind(&socket).unwrap();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||||
let companion = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut writer = JsonLineWriter::new(stream);
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
greeting: protocol::Greeting {
|
||||
pod_name: "companion".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: PodStatus::Idle,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let (reader_half, writer_half) = stream.into_split();
|
||||
let mut reader = JsonLineReader::new(reader_half);
|
||||
let mut writer = JsonLineWriter::new(writer_half);
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
greeting: protocol::Greeting {
|
||||
pod_name: "companion".into(),
|
||||
cwd: "/tmp".into(),
|
||||
provider: "test".into(),
|
||||
model: "test".into(),
|
||||
scope_summary: String::new(),
|
||||
tools: Vec::new(),
|
||||
context_window: 0,
|
||||
context_tokens: 0,
|
||||
},
|
||||
status: PodStatus::Idle,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let method = reader.next::<Method>().await.unwrap().unwrap();
|
||||
if let Method::Notify { message, auto_run } = method {
|
||||
assert!(!auto_run);
|
||||
tx.send(message).await.unwrap();
|
||||
} else {
|
||||
panic!("expected Notify, got {method:?}");
|
||||
}
|
||||
});
|
||||
|
||||
let output = ToolOutput {
|
||||
summary: "Changed ticket state from queued to inprogress".into(),
|
||||
content: Some(
|
||||
json!({
|
||||
"ok": true,
|
||||
"ticket": ticket_id,
|
||||
"from": "queued",
|
||||
"to": "inprogress",
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
let action = hook
|
||||
.call(&tool_summary("TicketWorkflowState", output))
|
||||
.await;
|
||||
assert_eq!(action, HookPostToolAction::Continue);
|
||||
let message = rx.recv().await.unwrap();
|
||||
assert!(message.contains("event: state/queued->inprogress"));
|
||||
assert!(message.contains("title: Companion event hook"));
|
||||
companion.await.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -51,11 +51,6 @@ use crate::workspace_panel::{
|
|||
|
||||
const MAX_ENTRIES: usize = 50;
|
||||
const CLOSED_VISIBLE_ROWS: usize = 3;
|
||||
const COMPANION_PROGRESS_MAX_TICKETS: usize = 5;
|
||||
const COMPANION_PROGRESS_MAX_TITLE_CHARS: usize = 80;
|
||||
const COMPANION_PROGRESS_MAX_MESSAGE_CHARS: usize = 1_800;
|
||||
const COMPANION_PROGRESS_NOTICE_TEMPLATE: &str =
|
||||
include_str!("../../../resources/prompts/panel/companion_progress_notice.md");
|
||||
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_TEMPLATE: &str =
|
||||
include_str!("../../../resources/prompts/panel/orchestrator_idle_queue_notice.md");
|
||||
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
|
||||
|
|
@ -142,10 +137,6 @@ pub(crate) async fn run(
|
|||
let result = dispatch_orchestrator_queue_attention_notice(request).await;
|
||||
app.finish_orchestrator_queue_attention_notice(result);
|
||||
}
|
||||
if let Some(request) = app.prepare_companion_progress_notice() {
|
||||
let result = dispatch_companion_progress_notice(request).await;
|
||||
app.finish_companion_progress_notice(result);
|
||||
}
|
||||
}
|
||||
|
||||
terminal.draw(|f| draw(f, app))?;
|
||||
|
|
@ -542,79 +533,6 @@ struct PanelDiagnostic {
|
|||
details: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CompanionProgressFreshness {
|
||||
fingerprint: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CompanionProgressNotice {
|
||||
message: String,
|
||||
fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CompanionProgressNoticeRequest {
|
||||
pod_name: String,
|
||||
socket_path: PathBuf,
|
||||
notice: CompanionProgressNotice,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CompanionProgressNoticeResult {
|
||||
fingerprint: String,
|
||||
updated_at: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl CompanionProgressNoticeResult {
|
||||
fn sent(fingerprint: String, updated_at: String) -> Self {
|
||||
Self {
|
||||
fingerprint,
|
||||
updated_at,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed(fingerprint: String, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fingerprint,
|
||||
updated_at: String::new(),
|
||||
error: Some(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CompanionProgressTemplateContext {
|
||||
companion: CompanionProgressTemplateRole,
|
||||
orchestrator: CompanionProgressTemplateRole,
|
||||
tickets: Vec<CompanionProgressTemplateTicket>,
|
||||
omitted_ticket_count: usize,
|
||||
role_pods: Vec<CompanionProgressTemplateRolePod>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CompanionProgressTemplateRole {
|
||||
pod_name: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CompanionProgressTemplateTicket {
|
||||
id: String,
|
||||
state: String,
|
||||
title: String,
|
||||
reference: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CompanionProgressTemplateRolePod {
|
||||
name: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
struct OrchestratorWorkSet {
|
||||
active_inprogress: Vec<OrchestratorActiveWorkItem>,
|
||||
|
|
@ -748,7 +666,6 @@ pub(crate) struct MultiPodApp {
|
|||
runtime_command: PodRuntimeCommand,
|
||||
last_companion_lifecycle_failure: Option<CompanionPanelState>,
|
||||
last_orchestrator_lifecycle_failure: Option<OrchestratorPanelState>,
|
||||
companion_progress: Option<CompanionProgressFreshness>,
|
||||
orchestrator_work_set: OrchestratorWorkSet,
|
||||
orchestrator_queue_attention: Option<OrchestratorQueueAttentionFreshness>,
|
||||
}
|
||||
|
|
@ -784,7 +701,6 @@ impl MultiPodApp {
|
|||
runtime_command,
|
||||
last_companion_lifecycle_failure: None,
|
||||
last_orchestrator_lifecycle_failure: None,
|
||||
companion_progress: None,
|
||||
orchestrator_work_set: OrchestratorWorkSet::default(),
|
||||
orchestrator_queue_attention: None,
|
||||
}
|
||||
|
|
@ -839,7 +755,6 @@ impl MultiPodApp {
|
|||
self.ensure_composer_target_available();
|
||||
self.refresh_orchestrator_work_set();
|
||||
self.apply_orchestrator_work_set_detail();
|
||||
self.apply_companion_progress_freshness();
|
||||
}
|
||||
|
||||
fn prepare_orchestrator_queue_attention_notice(
|
||||
|
|
@ -895,49 +810,6 @@ impl MultiPodApp {
|
|||
apply_orchestrator_detail(&mut self.panel, detail);
|
||||
}
|
||||
|
||||
fn prepare_companion_progress_notice(&mut self) -> Option<CompanionProgressNoticeRequest> {
|
||||
let target = companion_progress_notice_target(&self.panel, &self.list)?;
|
||||
let notice = companion_progress_notice(&self.panel, &self.list)?;
|
||||
if self
|
||||
.companion_progress
|
||||
.as_ref()
|
||||
.is_some_and(|freshness| freshness.fingerprint == notice.fingerprint)
|
||||
{
|
||||
self.apply_companion_progress_freshness();
|
||||
return None;
|
||||
}
|
||||
Some(CompanionProgressNoticeRequest {
|
||||
pod_name: target.pod_name,
|
||||
socket_path: target.socket_path,
|
||||
notice,
|
||||
})
|
||||
}
|
||||
|
||||
fn finish_companion_progress_notice(&mut self, result: CompanionProgressNoticeResult) {
|
||||
if let Some(error) = result.error {
|
||||
self.notice = Some(format!("Companion progress notice not delivered: {error}"));
|
||||
return;
|
||||
}
|
||||
self.companion_progress = Some(CompanionProgressFreshness {
|
||||
fingerprint: result.fingerprint,
|
||||
updated_at: result.updated_at,
|
||||
});
|
||||
self.apply_companion_progress_freshness();
|
||||
}
|
||||
|
||||
fn apply_companion_progress_freshness(&mut self) {
|
||||
let Some(freshness) = self.companion_progress.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(companion) = self.panel.header.companion.as_mut() else {
|
||||
return;
|
||||
};
|
||||
companion.detail = Some(format!(
|
||||
"progress context updated at {} (weak notify)",
|
||||
freshness.updated_at
|
||||
));
|
||||
}
|
||||
|
||||
fn apply_companion_lifecycle_memory(&mut self, panel: &mut WorkspacePanelViewModel) {
|
||||
let Some(state) = panel.header.companion.as_ref() else {
|
||||
self.last_companion_lifecycle_failure = None;
|
||||
|
|
@ -2910,123 +2782,6 @@ fn apply_orchestrator_detail(panel: &mut WorkspacePanelViewModel, detail: Option
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CompanionProgressNoticeTarget {
|
||||
pod_name: String,
|
||||
socket_path: PathBuf,
|
||||
}
|
||||
|
||||
fn companion_progress_notice_target(
|
||||
panel: &WorkspacePanelViewModel,
|
||||
list: &PodList,
|
||||
) -> Option<CompanionProgressNoticeTarget> {
|
||||
let companion = panel.header.companion.as_ref()?;
|
||||
if !companion_status_is_peer_reachable(companion.status) {
|
||||
return None;
|
||||
}
|
||||
let entry = list
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.name == companion.pod_name)?;
|
||||
let live = entry.live.as_ref()?;
|
||||
if !live.reachable {
|
||||
return None;
|
||||
}
|
||||
Some(CompanionProgressNoticeTarget {
|
||||
pod_name: companion.pod_name.clone(),
|
||||
socket_path: live.socket_path.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn companion_status_is_peer_reachable(status: CompanionPanelStatus) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
CompanionPanelStatus::Live | CompanionPanelStatus::Restored | CompanionPanelStatus::Spawned
|
||||
)
|
||||
}
|
||||
|
||||
fn companion_progress_notice(
|
||||
panel: &WorkspacePanelViewModel,
|
||||
list: &PodList,
|
||||
) -> Option<CompanionProgressNotice> {
|
||||
let companion = panel.header.companion.as_ref()?;
|
||||
let orchestrator = panel.header.orchestrator.as_ref()?;
|
||||
let ticket_rows = panel
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|row| row.ticket.as_ref().map(|ticket| (row, ticket)))
|
||||
.collect::<Vec<_>>();
|
||||
let tickets = ticket_rows
|
||||
.iter()
|
||||
.take(COMPANION_PROGRESS_MAX_TICKETS)
|
||||
.map(|(row, ticket)| CompanionProgressTemplateTicket {
|
||||
id: bounded_progress_text(&ticket.id, COMPANION_PROGRESS_MAX_TITLE_CHARS),
|
||||
state: bounded_progress_text(&row.status, COMPANION_PROGRESS_MAX_TITLE_CHARS),
|
||||
title: bounded_progress_text(&row.title, COMPANION_PROGRESS_MAX_TITLE_CHARS),
|
||||
reference: format!(".yoi/tickets/{}", ticket.id),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let context = CompanionProgressTemplateContext {
|
||||
companion: CompanionProgressTemplateRole {
|
||||
pod_name: bounded_progress_text(
|
||||
&companion.pod_name,
|
||||
COMPANION_PROGRESS_MAX_TITLE_CHARS,
|
||||
),
|
||||
status: companion.status.label().to_string(),
|
||||
},
|
||||
orchestrator: CompanionProgressTemplateRole {
|
||||
pod_name: bounded_progress_text(
|
||||
&orchestrator.pod_name,
|
||||
COMPANION_PROGRESS_MAX_TITLE_CHARS,
|
||||
),
|
||||
status: orchestrator.status.label().to_string(),
|
||||
},
|
||||
tickets,
|
||||
omitted_ticket_count: ticket_rows
|
||||
.len()
|
||||
.saturating_sub(COMPANION_PROGRESS_MAX_TICKETS),
|
||||
role_pods: bounded_role_pod_values(list, companion, orchestrator),
|
||||
};
|
||||
let rendered = render_companion_progress_notice_template(&context).ok()?;
|
||||
let message = bounded_progress_text(&rendered, COMPANION_PROGRESS_MAX_MESSAGE_CHARS);
|
||||
let fingerprint = message.clone();
|
||||
Some(CompanionProgressNotice {
|
||||
message,
|
||||
fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_companion_progress_notice_template(
|
||||
context: &CompanionProgressTemplateContext,
|
||||
) -> Result<String, minijinja::Error> {
|
||||
let mut env = minijinja::Environment::new();
|
||||
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
|
||||
env.add_template(
|
||||
"companion_progress_notice",
|
||||
COMPANION_PROGRESS_NOTICE_TEMPLATE,
|
||||
)?;
|
||||
env.get_template("companion_progress_notice")?
|
||||
.render(context)
|
||||
}
|
||||
|
||||
fn bounded_role_pod_values(
|
||||
list: &PodList,
|
||||
companion: &CompanionPanelState,
|
||||
orchestrator: &OrchestratorPanelState,
|
||||
) -> Vec<CompanionProgressTemplateRolePod> {
|
||||
let mut role_pods = Vec::new();
|
||||
for name in [&companion.pod_name, &orchestrator.pod_name] {
|
||||
let Some(entry) = list.entries.iter().find(|entry| entry.name == *name) else {
|
||||
continue;
|
||||
};
|
||||
role_pods.push(CompanionProgressTemplateRolePod {
|
||||
name: bounded_progress_text(&entry.name, COMPANION_PROGRESS_MAX_TITLE_CHARS),
|
||||
status: row_status_label(entry).0.to_string(),
|
||||
});
|
||||
}
|
||||
role_pods
|
||||
}
|
||||
|
||||
fn bounded_progress_text(input: &str, max_chars: usize) -> String {
|
||||
let mut output = String::new();
|
||||
for (idx, ch) in input.chars().enumerate() {
|
||||
|
|
@ -3051,19 +2806,6 @@ fn progress_notice_timestamp() -> String {
|
|||
}
|
||||
}
|
||||
|
||||
async fn dispatch_companion_progress_notice(
|
||||
request: CompanionProgressNoticeRequest,
|
||||
) -> CompanionProgressNoticeResult {
|
||||
let fingerprint = request.notice.fingerprint.clone();
|
||||
match send_notify_only(&request.socket_path, request.notice.message, false).await {
|
||||
Ok(()) => CompanionProgressNoticeResult::sent(fingerprint, progress_notice_timestamp()),
|
||||
Err(err) => CompanionProgressNoticeResult::failed(
|
||||
fingerprint,
|
||||
format!("{}: {}", request.pod_name, err),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn dispatch_orchestrator_queue_attention_notice(
|
||||
request: OrchestratorQueueAttentionNoticeRequest,
|
||||
) -> OrchestratorQueueAttentionNoticeResult {
|
||||
|
|
@ -5687,175 +5429,6 @@ mod tests {
|
|||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_progress_notice_target_skips_missing_stopped_and_unreachable_without_spawn_restore()
|
||||
{
|
||||
let missing_app = ticket_enabled_app(vec![live_info("test-orchestrator", PodStatus::Idle)]);
|
||||
assert!(companion_progress_notice_target(&missing_app.panel, &missing_app.list).is_none());
|
||||
|
||||
let mut stopped_panel = WorkspacePanelViewModel::empty(Path::new("test"));
|
||||
stopped_panel.header.companion = Some(CompanionPanelState::new(
|
||||
"yoi",
|
||||
CompanionPanelStatus::Stopped,
|
||||
None,
|
||||
));
|
||||
stopped_panel.header.orchestrator = Some(OrchestratorPanelState::new(
|
||||
"test-orchestrator",
|
||||
OrchestratorPanelStatus::Live,
|
||||
None,
|
||||
));
|
||||
let stopped_list = PodList::from_sources(
|
||||
PodVisibilitySource::ResumePicker,
|
||||
vec![stopped_info("yoi")],
|
||||
vec![live_info("test-orchestrator", PodStatus::Idle)],
|
||||
None,
|
||||
10,
|
||||
);
|
||||
assert!(companion_progress_notice_target(&stopped_panel, &stopped_list).is_none());
|
||||
|
||||
let mut unreachable = live_info("yoi", PodStatus::Idle);
|
||||
unreachable.reachable = false;
|
||||
let unreachable_app = ticket_enabled_app(vec![
|
||||
unreachable,
|
||||
live_info("test-orchestrator", PodStatus::Idle),
|
||||
]);
|
||||
assert!(
|
||||
companion_progress_notice_target(&unreachable_app.panel, &unreachable_app.list)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_progress_notice_uses_prompt_resource_template() {
|
||||
let first_resource_line = COMPANION_PROGRESS_NOTICE_TEMPLATE.lines().next().unwrap();
|
||||
let context = CompanionProgressTemplateContext {
|
||||
companion: CompanionProgressTemplateRole {
|
||||
pod_name: "yoi".to_string(),
|
||||
status: "Live".to_string(),
|
||||
},
|
||||
orchestrator: CompanionProgressTemplateRole {
|
||||
pod_name: "test-orchestrator".to_string(),
|
||||
status: "Live".to_string(),
|
||||
},
|
||||
tickets: vec![CompanionProgressTemplateTicket {
|
||||
id: "RESOURCE-TICKET".to_string(),
|
||||
state: "inprogress".to_string(),
|
||||
title: "Rendered from runtime values".to_string(),
|
||||
reference: ".yoi/tickets/RESOURCE-TICKET".to_string(),
|
||||
}],
|
||||
omitted_ticket_count: 0,
|
||||
role_pods: vec![CompanionProgressTemplateRolePod {
|
||||
name: "yoi".to_string(),
|
||||
status: "idle".to_string(),
|
||||
}],
|
||||
};
|
||||
|
||||
let rendered = render_companion_progress_notice_template(&context).unwrap();
|
||||
assert!(rendered.contains(first_resource_line));
|
||||
assert!(rendered.contains("RESOURCE-TICKET"));
|
||||
assert!(rendered.contains("Rendered from runtime values"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_progress_notice_is_bounded_and_excludes_sensitive_unbounded_fields() {
|
||||
let mut app = ticket_enabled_app(vec![
|
||||
live_info("yoi", PodStatus::Idle),
|
||||
live_info("test-orchestrator", PodStatus::Running),
|
||||
]);
|
||||
app.panel.rows = (0..12)
|
||||
.map(|index| {
|
||||
let mut row = panel_test_ticket_row(
|
||||
&format!("TICKET-{index}"),
|
||||
&format!("Visible title {index} {}", "x".repeat(140)),
|
||||
ActionPriority::Background,
|
||||
NextUserAction::Wait,
|
||||
"inprogress",
|
||||
);
|
||||
if let Some(ticket) = row.ticket.as_mut() {
|
||||
ticket.latest_event_excerpt = Some(
|
||||
"SECRET_PROVIDER_ERROR_TOKEN should never be copied into progress notices"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
row.subtitle = Some("private thread excerpt should stay out".to_string());
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
app.panel
|
||||
.header
|
||||
.diagnostics
|
||||
.push("diagnostic with SECRET_PROVIDER_ERROR_TOKEN should stay out".to_string());
|
||||
|
||||
let notice = companion_progress_notice(&app.panel, &app.list).unwrap();
|
||||
assert!(notice.message.contains("TICKET-0"));
|
||||
assert!(notice.message.contains("ref: .yoi/tickets/TICKET-0"));
|
||||
assert!(notice.message.contains("more ticket(s) omitted"));
|
||||
assert!(notice.message.chars().count() <= COMPANION_PROGRESS_MAX_MESSAGE_CHARS + 1);
|
||||
assert!(!notice.message.contains("SECRET_PROVIDER_ERROR_TOKEN"));
|
||||
assert!(!notice.message.contains("private thread excerpt"));
|
||||
assert_eq!(notice.fingerprint, notice.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_progress_notice_success_sets_panel_freshness_without_persisting_snapshot() {
|
||||
let mut app = ticket_enabled_app(vec![
|
||||
live_info("yoi", PodStatus::Idle),
|
||||
live_info("test-orchestrator", PodStatus::Idle),
|
||||
]);
|
||||
app.panel.rows.push(panel_test_ticket_row(
|
||||
"TICKET-1",
|
||||
"Implement progress notices",
|
||||
ActionPriority::Background,
|
||||
NextUserAction::Wait,
|
||||
"inprogress",
|
||||
));
|
||||
|
||||
let request = app.prepare_companion_progress_notice().unwrap();
|
||||
assert_eq!(request.pod_name, "yoi");
|
||||
app.finish_companion_progress_notice(CompanionProgressNoticeResult::sent(
|
||||
request.notice.fingerprint,
|
||||
"unix:42".to_string(),
|
||||
));
|
||||
|
||||
let detail = app
|
||||
.panel
|
||||
.header
|
||||
.companion
|
||||
.as_ref()
|
||||
.and_then(|companion| companion.detail.as_deref())
|
||||
.unwrap();
|
||||
assert!(detail.contains("unix:42"));
|
||||
assert!(detail.contains("weak notify"));
|
||||
assert!(app.prepare_companion_progress_notice().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_progress_notice_target_accepts_live_running_companion() {
|
||||
let app = ticket_enabled_app(vec![
|
||||
live_info("yoi", PodStatus::Running),
|
||||
live_info("test-orchestrator", PodStatus::Running),
|
||||
]);
|
||||
let target = companion_progress_notice_target(&app.panel, &app.list).unwrap();
|
||||
assert_eq!(target.pod_name, "yoi");
|
||||
assert_eq!(target.socket_path, PathBuf::from("/tmp/yoi.sock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn companion_progress_failure_is_best_effort_and_does_not_mark_freshness() {
|
||||
let mut app = ticket_enabled_app(vec![
|
||||
live_info("yoi", PodStatus::Idle),
|
||||
live_info("test-orchestrator", PodStatus::Idle),
|
||||
]);
|
||||
let request = app.prepare_companion_progress_notice().unwrap();
|
||||
app.finish_companion_progress_notice(CompanionProgressNoticeResult::failed(
|
||||
request.notice.fingerprint,
|
||||
"socket closed",
|
||||
));
|
||||
|
||||
assert!(app.companion_progress.is_none());
|
||||
assert!(app.notice.as_deref().unwrap().contains("not delivered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ticket_selection_keeps_enter_pod_centric() {
|
||||
let mut app = test_app(vec![live_info("alpha", PodStatus::Idle)]);
|
||||
|
|
@ -7449,7 +7022,6 @@ mod tests {
|
|||
runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"),
|
||||
last_companion_lifecycle_failure,
|
||||
last_orchestrator_lifecycle_failure,
|
||||
companion_progress: None,
|
||||
orchestrator_work_set: OrchestratorWorkSet::default(),
|
||||
orchestrator_queue_attention: None,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ The following workflows are advertised resident. When a user request matches one
|
|||
|
||||
pod_orchestration_guidance_section = "{% include \"$yoi/common/pod-orchestration\" %}"
|
||||
|
||||
ticket_event_companion_notice = "{% include \"$yoi/pod/ticket_event_companion_notice\" %}"
|
||||
|
||||
spawn_pod_tool_description = """\
|
||||
Spawn a new Pod process to work on a delegated task. The spawner's write scope is reduced by the scope passed here; the spawned Pod receives its own socket and starts running `task` immediately. The spawned Pod outlives the spawner's current turn and can be contacted again through its socket path.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
Orchestrator progress context (read-only weak notification; no auto-run).
|
||||
Reason: workspace Panel refreshed bounded orchestration progress for Companion explanation.
|
||||
Roles: Companion {{ companion.pod_name }} is {{ companion.status }}; Orchestrator {{ orchestrator.pod_name }} is {{ orchestrator.status }}.
|
||||
|
||||
{% if tickets %}Tickets (first {{ tickets | length }} visible, bounded):
|
||||
{% for ticket in tickets %}- {{ ticket.id }} [{{ ticket.state }}] {{ ticket.title }} (ref: {{ ticket.reference }})
|
||||
{% endfor %}{% if omitted_ticket_count > 0 %}- … {{ omitted_ticket_count }} more ticket(s) omitted from this bounded notice.
|
||||
{% endif %}{% else %}Tickets: none visible in the current Panel snapshot.
|
||||
{% endif %}{% if role_pods %}
|
||||
Role pod status snapshot:
|
||||
{% for role_pod in role_pods %}- {{ role_pod.name }}: {{ role_pod.status }}
|
||||
{% endfor %}{% endif %}
|
||||
7
resources/prompts/pod/ticket_event_companion_notice.md
Normal file
7
resources/prompts/pod/ticket_event_companion_notice.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
Ticket event notice (weak; auto_run=false)
|
||||
ticket: {{ ticket_id }}
|
||||
title: {{ title }}
|
||||
state: {{ state }}
|
||||
event: {{ event_kind }}
|
||||
summary: {{ summary }}
|
||||
ref: {{ ref_path }}
|
||||
Loading…
Reference in New Issue
Block a user