merge: integrate orchestrator companion event notify

This commit is contained in:
Keisuke Hirata 2026-06-13 15:03:11 +09:00
commit ad4d0866ae
No known key found for this signature in database
14 changed files with 990 additions and 475 deletions

View File

@ -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"}

View File

@ -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'

View File

@ -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`: pass0 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 を追加してもよい。

View File

@ -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 eventstate 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`: pass0 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`: pass0 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`: pass0 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`.

View File

@ -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

View File

@ -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();

View File

@ -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};

View File

@ -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).

View File

@ -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",
];
}

View 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: &notice.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();
}
}

View File

@ -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,
};

View File

@ -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.

View File

@ -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 %}

View 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 }}