From 2a7c96909c80732d74d26474f2f74b09e2c68444 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 11:38:08 +0900 Subject: [PATCH 01/10] ticket: reopen companion notify --- .yoi/tickets/00001KTTW04W2/item.md | 4 ++-- .yoi/tickets/00001KTTW04W2/thread.md | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KTTW04W2/item.md b/.yoi/tickets/00001KTTW04W2/item.md index 09797e95..e1c05d79 100644 --- a/.yoi/tickets/00001KTTW04W2/item.md +++ b/.yoi/tickets/00001KTTW04W2/item.md @@ -1,8 +1,8 @@ --- title: 'Orchestrator進捗をAutoKickなしでCompanionへ通知する' -state: 'closed' +state: 'planning' created_at: '2026-06-11T08:15:24Z' -updated_at: '2026-06-12T15:44:42Z' +updated_at: '2026-06-13T02:37:58Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-11T10:31:56Z' diff --git a/.yoi/tickets/00001KTTW04W2/thread.md b/.yoi/tickets/00001KTTW04W2/thread.md index b6ca1dbf..62d7481d 100644 --- a/.yoi/tickets/00001KTTW04W2/thread.md +++ b/.yoi/tickets/00001KTTW04W2/thread.md @@ -514,3 +514,25 @@ 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. --- + +--- + + + +## 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. From 7ff2f8e3e83781ff5f39c6902d7572a686251c8e Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 11:51:22 +0900 Subject: [PATCH 02/10] fix: remove panel companion progress feed --- crates/tui/src/multi_pod.rs | 428 ------------------ .../panel/companion_progress_notice.md | 12 - 2 files changed, 440 deletions(-) delete mode 100644 resources/prompts/panel/companion_progress_notice.md diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index d96adc09..11f991ab 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -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, -} - -impl CompanionProgressNoticeResult { - fn sent(fingerprint: String, updated_at: String) -> Self { - Self { - fingerprint, - updated_at, - error: None, - } - } - - fn failed(fingerprint: String, error: impl Into) -> Self { - Self { - fingerprint, - updated_at: String::new(), - error: Some(error.into()), - } - } -} - -#[derive(Debug, Serialize)] -struct CompanionProgressTemplateContext { - companion: CompanionProgressTemplateRole, - orchestrator: CompanionProgressTemplateRole, - tickets: Vec, - omitted_ticket_count: usize, - role_pods: Vec, -} - -#[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, @@ -748,7 +666,6 @@ pub(crate) struct MultiPodApp { runtime_command: PodRuntimeCommand, last_companion_lifecycle_failure: Option, last_orchestrator_lifecycle_failure: Option, - companion_progress: Option, orchestrator_work_set: OrchestratorWorkSet, orchestrator_queue_attention: Option, } @@ -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 { - 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 { - 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 { - 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::>(); - 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::>(); - 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 { - 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 { - 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 { @@ -5492,175 +5234,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)]); @@ -7254,7 +6827,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, }; diff --git a/resources/prompts/panel/companion_progress_notice.md b/resources/prompts/panel/companion_progress_notice.md deleted file mode 100644 index 7c52ee05..00000000 --- a/resources/prompts/panel/companion_progress_notice.md +++ /dev/null @@ -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 %} From a3233f04b186cfb3a0ee7158a029de44a7aa442d Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 12:32:03 +0900 Subject: [PATCH 03/10] ticket: accept event companion notify --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KTTW04W2/item.md | 4 +- .yoi/tickets/00001KTTW04W2/thread.md | 71 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KTTW04W2/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KTTW04W2/artifacts/orchestration-plan.jsonl index f37d9548..ce1f4a5b 100644 --- a/.yoi/tickets/00001KTTW04W2/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KTTW04W2/artifacts/orchestration-plan.jsonl @@ -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"} diff --git a/.yoi/tickets/00001KTTW04W2/item.md b/.yoi/tickets/00001KTTW04W2/item.md index e1c05d79..6636d8c3 100644 --- a/.yoi/tickets/00001KTTW04W2/item.md +++ b/.yoi/tickets/00001KTTW04W2/item.md @@ -1,8 +1,8 @@ --- title: 'Orchestrator進捗をAutoKickなしでCompanionへ通知する' -state: 'planning' +state: 'inprogress' created_at: '2026-06-11T08:15:24Z' -updated_at: '2026-06-13T02:37:58Z' +updated_at: '2026-06-13T03:32:03Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-11T10:31:56Z' diff --git a/.yoi/tickets/00001KTTW04W2/thread.md b/.yoi/tickets/00001KTTW04W2/thread.md index 62d7481d..376f979d 100644 --- a/.yoi/tickets/00001KTTW04W2/thread.md +++ b/.yoi/tickets/00001KTTW04W2/thread.md @@ -536,3 +536,74 @@ Next planning/fix question: - 送る場合の payload を snapshot ではなく bounded event notice に縮小するか。 Until this is resolved, current Companion progress notice implementation should be treated as defective and not closed. + +--- + + + +## 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 に実装を委譲する。 + +--- + + + +## State changed + +ユーザー判断により、通知発生源・粒度・非目標が確定したため implementation-ready とする。 + +--- + + + +## State changed + +ユーザーが実装続行を明示したため、Orchestrator routing 対象として queued にする。 + +--- + + + +## 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 を記録する。 From f58207d2dafd3e5a141f6c30436f3502e6bbbb97 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 12:33:09 +0900 Subject: [PATCH 04/10] ticket: delegate event companion notify coder --- .yoi/tickets/00001KTTW04W2/thread.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.yoi/tickets/00001KTTW04W2/thread.md b/.yoi/tickets/00001KTTW04W2/thread.md index 376f979d..4ce6c634 100644 --- a/.yoi/tickets/00001KTTW04W2/thread.md +++ b/.yoi/tickets/00001KTTW04W2/thread.md @@ -607,3 +607,18 @@ Next action: ## 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 を記録する。 + +--- + + + +## 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. From 465ef1004b253666af1dbd8ed4d329905f38eb71 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 12:56:46 +0900 Subject: [PATCH 05/10] feat: notify Companion on Orchestrator ticket events --- crates/pod/src/controller.rs | 64 +++++ crates/pod/src/discovery.rs | 169 ++++++++++- crates/pod/src/lib.rs | 1 + crates/pod/src/pod.rs | 7 + crates/pod/src/ticket_event_notify.rs | 400 ++++++++++++++++++++++++++ 5 files changed, 633 insertions(+), 8 deletions(-) create mode 100644 crates/pod/src/ticket_event_notify.rs diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 450ac4f3..fbb09bf2 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -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( // per-item commit channel is wired at the top of this function. } +fn install_ticket_event_companion_notify_hook( + pod: &mut Pod, + runtime_base: PathBuf, + spawned_registry: Arc, +) 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 diff --git a/crates/pod/src/discovery.rs b/crates/pod/src/discovery.rs index c5a8500a..9ca50323 100644 --- a/crates/pod/src/discovery.rs +++ b/crates/pod/src/discovery.rs @@ -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(value: &T) -> Result { @@ -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::().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(); diff --git a/crates/pod/src/lib.rs b/crates/pod/src/lib.rs index 647ebc94..efc44e94 100644 --- a/crates/pod/src/lib.rs +++ b/crates/pod/src/lib.rs @@ -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}; diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index c0809d65..d8a33863 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -728,6 +728,13 @@ impl Pod { &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). diff --git a/crates/pod/src/ticket_event_notify.rs b/crates/pod/src/ticket_event_notify.rs new file mode 100644 index 00000000..a5db8d31 --- /dev/null +++ b/crates/pod/src/ticket_event_notify.rs @@ -0,0 +1,400 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::Value; +use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug}; +use tracing::debug; + +use crate::discovery::PodDiscovery; +use crate::hook::{Hook, HookPostToolAction, PostToolCall, ToolResultSummary}; +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, + discovery: PodDiscovery, + companion_pod_name: String, +} + +impl TicketEventCompanionNotifyHook { + pub(crate) fn new( + backend: LocalTicketBackend, + discovery: PodDiscovery, + companion_pod_name: impl Into, + ) -> Self { + Self { + backend: Arc::new(backend), + discovery, + companion_pod_name: companion_pod_name.into(), + } + } +} + +#[async_trait] +impl Hook + for TicketEventCompanionNotifyHook +{ + 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 { + 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 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.meta.id, summary.tool_name.as_str()); + let raw_message = format!( + "Ticket event notice (weak; auto_run=false)\n\ + ticket: {ticket_id}\n\ + title: {title}\n\ + state: {state}\n\ + event: {event_kind}\n\ + summary: {output_summary}\n\ + ref: {ref_path}", + ticket_id = ticket.meta.id.as_str(), + ); + + Some(TicketEventNotice { + ticket_id: ticket.meta.id.as_str().to_string(), + event_kind, + message: bound_chars(&raw_message, MAX_MESSAGE_CHARS), + }) +} + +fn explicit_ticket_event_kind(tool_name: &str, content: &Value) -> Option { + 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::>().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 { + 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); + } + + #[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::().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("Ticket event notice")); + assert!(message.contains("event: state/queued->inprogress")); + assert!(message.contains("title: Companion event hook")); + companion.await.unwrap(); + } +} From 6f8571f77fc0be6fd81d4fd8756333ecc34becef Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 13:08:12 +0900 Subject: [PATCH 06/10] fix: render ticket event notice from prompt resource --- crates/pod/src/prompt/catalog.rs | 5 ++ crates/pod/src/ticket_event_notify.rs | 81 +++++++++++++++---- resources/prompts/internal.toml | 2 + .../pod/ticket_event_companion_notice.md | 7 ++ 4 files changed, 81 insertions(+), 14 deletions(-) create mode 100644 resources/prompts/pod/ticket_event_companion_notice.md diff --git a/crates/pod/src/prompt/catalog.rs b/crates/pod/src/prompt/catalog.rs index 266760b8..1989c296 100644 --- a/crates/pod/src/prompt/catalog.rs +++ b/crates/pod/src/prompt/catalog.rs @@ -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", ]; } diff --git a/crates/pod/src/ticket_event_notify.rs b/crates/pod/src/ticket_event_notify.rs index a5db8d31..aa9d1e96 100644 --- a/crates/pod/src/ticket_event_notify.rs +++ b/crates/pod/src/ticket_event_notify.rs @@ -1,12 +1,15 @@ 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; @@ -89,28 +92,56 @@ fn build_ticket_event_notice( .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.meta.id, summary.tool_name.as_str()); - let raw_message = format!( - "Ticket event notice (weak; auto_run=false)\n\ - ticket: {ticket_id}\n\ - title: {title}\n\ - state: {state}\n\ - event: {event_kind}\n\ - summary: {output_summary}\n\ - ref: {ref_path}", - ticket_id = ticket.meta.id.as_str(), - ); + 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.meta.id.as_str().to_string(), + ticket_id: ticket_id.to_string(), event_kind, - message: bound_chars(&raw_message, MAX_MESSAGE_CHARS), + 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 { + 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 { match tool_name { "TicketComment" => content @@ -230,6 +261,29 @@ mod tests { 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] @@ -392,7 +446,6 @@ mod tests { .await; assert_eq!(action, HookPostToolAction::Continue); let message = rx.recv().await.unwrap(); - assert!(message.contains("Ticket event notice")); assert!(message.contains("event: state/queued->inprogress")); assert!(message.contains("title: Companion event hook")); companion.await.unwrap(); diff --git a/resources/prompts/internal.toml b/resources/prompts/internal.toml index 466d9587..05837bfc 100644 --- a/resources/prompts/internal.toml +++ b/resources/prompts/internal.toml @@ -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. diff --git a/resources/prompts/pod/ticket_event_companion_notice.md b/resources/prompts/pod/ticket_event_companion_notice.md new file mode 100644 index 00000000..1551bdcb --- /dev/null +++ b/resources/prompts/pod/ticket_event_companion_notice.md @@ -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 }} From e9208295f16b8f7f196943d5141923d072e7f520 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 13:09:50 +0900 Subject: [PATCH 07/10] ticket: record event companion implementation --- .yoi/tickets/00001KTTW04W2/thread.md | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.yoi/tickets/00001KTTW04W2/thread.md b/.yoi/tickets/00001KTTW04W2/thread.md index 4ce6c634..61147aa6 100644 --- a/.yoi/tickets/00001KTTW04W2/thread.md +++ b/.yoi/tickets/00001KTTW04W2/thread.md @@ -622,3 +622,55 @@ Implementation worktree created and coder delegated. - 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. + +--- + + + +## 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. From 08baab8cbcaedfc7bf05a88b3aa6d6da1a3385c1 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 13:20:19 +0900 Subject: [PATCH 08/10] ticket: record event companion review --- .yoi/tickets/00001KTTW04W2/thread.md | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.yoi/tickets/00001KTTW04W2/thread.md b/.yoi/tickets/00001KTTW04W2/thread.md index 61147aa6..d455adfa 100644 --- a/.yoi/tickets/00001KTTW04W2/thread.md +++ b/.yoi/tickets/00001KTTW04W2/thread.md @@ -674,3 +674,40 @@ Orchestrator-verified validation: 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. + +--- + + + +## 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 証跡を採用した。 From ed639ac85fa11bcb8429ee1555855bba18bbd9b8 Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 13:21:40 +0900 Subject: [PATCH 09/10] ticket: mark event companion notify done --- .yoi/tickets/00001KTTW04W2/item.md | 4 +-- .yoi/tickets/00001KTTW04W2/thread.md | 44 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KTTW04W2/item.md b/.yoi/tickets/00001KTTW04W2/item.md index 6636d8c3..5e9a1ac0 100644 --- a/.yoi/tickets/00001KTTW04W2/item.md +++ b/.yoi/tickets/00001KTTW04W2/item.md @@ -1,8 +1,8 @@ --- title: 'Orchestrator進捗をAutoKickなしでCompanionへ通知する' -state: 'inprogress' +state: 'done' created_at: '2026-06-11T08:15:24Z' -updated_at: '2026-06-13T03:32:03Z' +updated_at: '2026-06-13T04:21:40Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-11T10:31:56Z' diff --git a/.yoi/tickets/00001KTTW04W2/thread.md b/.yoi/tickets/00001KTTW04W2/thread.md index d455adfa..f6384a66 100644 --- a/.yoi/tickets/00001KTTW04W2/thread.md +++ b/.yoi/tickets/00001KTTW04W2/thread.md @@ -711,3 +711,47 @@ Reviewer-run validation: 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 証跡を採用した。 + +--- + + + +## 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。 + +--- + + + +## 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. From 074f4b6ff90c2e906bb1b1b518770f381be1b03b Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 13 Jun 2026 13:22:26 +0900 Subject: [PATCH 10/10] ticket: close event companion notify --- .yoi/tickets/00001KTTW04W2/item.md | 4 +- .yoi/tickets/00001KTTW04W2/resolution.md | 49 +++++++++++------------- .yoi/tickets/00001KTTW04W2/thread.md | 24 ++++++++++++ 3 files changed, 49 insertions(+), 28 deletions(-) diff --git a/.yoi/tickets/00001KTTW04W2/item.md b/.yoi/tickets/00001KTTW04W2/item.md index 5e9a1ac0..0c756ca2 100644 --- a/.yoi/tickets/00001KTTW04W2/item.md +++ b/.yoi/tickets/00001KTTW04W2/item.md @@ -1,8 +1,8 @@ --- title: 'Orchestrator進捗をAutoKickなしでCompanionへ通知する' -state: 'done' +state: 'closed' created_at: '2026-06-11T08:15:24Z' -updated_at: '2026-06-13T04:21:40Z' +updated_at: '2026-06-13T04:22:26Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-06-11T10:31:56Z' diff --git a/.yoi/tickets/00001KTTW04W2/resolution.md b/.yoi/tickets/00001KTTW04W2/resolution.md index 320420eb..ad0ce792 100644 --- a/.yoi/tickets/00001KTTW04W2/resolution.md +++ b/.yoi/tickets/00001KTTW04W2/resolution.md @@ -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/` 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/` 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. \ No newline at end of file +Non-blocking note: +- Panel 非通知は TUI diff absence / `rg` check と focused behavior tests で確認した。将来の回帰防止として、Panel reload/open が Companion event notify を呼ばない明示 test を追加してもよい。 diff --git a/.yoi/tickets/00001KTTW04W2/thread.md b/.yoi/tickets/00001KTTW04W2/thread.md index f6384a66..7dfb3ec0 100644 --- a/.yoi/tickets/00001KTTW04W2/thread.md +++ b/.yoi/tickets/00001KTTW04W2/thread.md @@ -755,3 +755,27 @@ Behavior now: ## 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. + +--- + + + +## 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. + +--- + + + +## Closed + +Resolution written to `resolution.md`.