From e95af466cebe94719c8a2da234cc5d5cbaeecfbf Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:40:56 +0900 Subject: [PATCH 01/28] ticket: defer plugin package routing for capacity --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KT0Z4BK8/item.md | 2 +- .yoi/tickets/00001KT0Z4BK8/thread.md | 26 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .yoi/tickets/00001KT0Z4BK8/artifacts/orchestration-plan.jsonl diff --git a/.yoi/tickets/00001KT0Z4BK8/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KT0Z4BK8/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..e4a0551a --- /dev/null +++ b/.yoi/tickets/00001KT0Z4BK8/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260614-154052-1","ticket_id":"00001KT0Z4BK8","kind":"waiting_capacity_note","note":"Ticket 自体は implementation_ready で blocking relation なし。現在 `00001KTFY8V80` と `00001KV09WYC6` の Coder Pod が running で、review/integration follow-up capacity も必要なため、追加 spawn は一時待機する。","author":"yoi-orchestrator","at":"2026-06-14T15:40:52Z"} diff --git a/.yoi/tickets/00001KT0Z4BK8/item.md b/.yoi/tickets/00001KT0Z4BK8/item.md index bceac9a3..7b77de25 100644 --- a/.yoi/tickets/00001KT0Z4BK8/item.md +++ b/.yoi/tickets/00001KT0Z4BK8/item.md @@ -2,7 +2,7 @@ title: 'Plugin distribution package format and discovery' state: 'queued' created_at: '2026-06-01T06:49:53Z' -updated_at: '2026-06-14T15:40:15Z' +updated_at: '2026-06-14T15:40:52Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:40:15Z' --- diff --git a/.yoi/tickets/00001KT0Z4BK8/thread.md b/.yoi/tickets/00001KT0Z4BK8/thread.md index 8712111a..860b509d 100644 --- a/.yoi/tickets/00001KT0Z4BK8/thread.md +++ b/.yoi/tickets/00001KT0Z4BK8/thread.md @@ -134,4 +134,30 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready_but_waiting_capacity + +Reason: +- Ticket body / thread / artifacts、relation、OrchestrationPlan、Orchestrator workspace state を確認した。Plugin package / discovery / enablement boundary の design work item として要件・受け入れ条件・non-goals・invariants は十分に具体化されている。 +- blocking relation / OrchestrationPlan blocker はない。 +- Plugin package work は現在 active な Panel/TUI implementation と source surface が大きく重ならないため、設計上の conflict blocker ではない。 +- ただし現在 `00001KTFY8V80` と `00001KV09WYC6` の2件が inprogress で Coder Pod running。Reviewer follow-up と integration capacity も未使用ではなく、さらに queued Panel/TUI work 2件を待機させている。 +- 現時点では追加 Coder Pod を spawn せず、active Coder のいずれかが implementation report を返して review/integration 見通しが立ってから acceptance する。 + +Evidence checked: +- Ticket body/thread: Plugin package design requirements、過去の Plugin/MCP/feature-layer decision、`planning -> ready`、Panel `ready -> queued` を確認。 +- Ticket relations: blocker なし。 +- OrchestrationPlan: 既存 record なし。 +- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、queue commit `4be6c966` 上。 +- Visible Pods: `yoi-coder-00001KTFY8V80` と `yoi-coder-00001KV09WYC6` が running。 + +Next action: +- 先行 inprogress Ticket の少なくとも1件が implementation report / review stage に進み、Coder capacity が空いた時点で再確認し、unblocked なら `queued -> inprogress` acceptance と dedicated worktree 作成へ進む。 +- planning return ではなく queued のまま waiting とする。 + --- From 2664cdd9928c000f777df060aeb6a31996b1df6a Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:47:08 +0900 Subject: [PATCH 02/28] feat: show ticket intake pods in panel --- crates/tui/src/multi_pod.rs | 95 +++++++--- crates/tui/src/workspace_panel.rs | 295 ++++++++++++++++++++++++++---- 2 files changed, 331 insertions(+), 59 deletions(-) diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index f4e6b44c..bdce4d9e 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -48,11 +48,11 @@ use crate::workspace_panel::{ ActionPriority, CompanionLifecyclePlan, CompanionPanelState, CompanionPanelStatus, CompanionPodPresence, ComposerTarget, NextUserAction, OrchestratorLifecyclePlan, OrchestratorPanelState, OrchestratorPanelStatus, OrchestratorPodPresence, PanelRow, - PanelRowKey, TicketConfigAvailability, TicketLocalClaimStatus, WorkspacePanelViewModel, - bounded_panel_diagnostic, build_current_ticket_row, build_workspace_panel, - companion_pod_presence, decide_companion_lifecycle, decide_orchestrator_lifecycle, - local_claim_status_for_pod, orchestrator_pod_presence, ticket_config_availability, - workspace_companion_pod_name, workspace_orchestrator_pod_name, + PanelRowKey, PanelRowKind, TicketConfigAvailability, TicketLocalClaimStatus, + WorkspacePanelViewModel, bounded_panel_diagnostic, build_current_ticket_row, + build_workspace_panel, companion_pod_presence, decide_companion_lifecycle, + decide_orchestrator_lifecycle, local_claim_status_for_pod, orchestrator_pod_presence, + ticket_config_availability, workspace_companion_pod_name, workspace_orchestrator_pod_name, }; const MAX_ENTRIES: usize = 50; @@ -958,6 +958,13 @@ fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { kind: "ticket", id: id.clone(), }, + PanelRowKey::TicketIntakePod { + ticket_id, + pod_name, + } => PanelE2eRowKey { + kind: "ticket_intake_pod", + id: format!("{ticket_id}:{pod_name}"), + }, PanelRowKey::Pod(name) => PanelE2eRowKey { kind: "pod", id: name.clone(), @@ -1207,12 +1214,8 @@ impl MultiPodApp { } fn selected_pod_entry(&self) -> Option<&PodListEntry> { - match self.selected_row.as_ref() { - Some(PanelRowKey::Pod(name)) => { - self.list.entries.iter().find(|entry| &entry.name == name) - } - _ => None, - } + let name = self.selected_row.as_ref().and_then(PanelRowKey::pod_name)?; + self.list.entries.iter().find(|entry| entry.name == name) } #[cfg(test)] @@ -1238,11 +1241,14 @@ impl MultiPodApp { }), ); } - let entry = self.selected_pod_entry()?; - if entry.actions.can_open { - return None; + if let Some(entry) = self.selected_pod_entry() { + if entry.actions.can_open { + return None; + } + return Some(open_disabled_reason(entry)); } - Some(open_disabled_reason(entry)) + self.selected_panel_row() + .and_then(|row| row.disabled_reason.clone().or_else(|| row.key_hint.clone())) } pub(crate) fn select_next(&mut self) { @@ -1354,6 +1360,9 @@ impl MultiPodApp { None => match &hit.key { PanelRowKey::Pod(name) => (name.clone(), None, None), PanelRowKey::Ticket(id) => (id.clone(), None, None), + PanelRowKey::TicketIntakePod { pod_name, .. } => { + (pod_name.clone(), None, None) + } }, }; PanelE2eRenderedRow { @@ -1406,7 +1415,7 @@ impl MultiPodApp { } if let Some(key) = visible.iter().find(|key| match key { PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name, - PanelRowKey::Ticket(_) => true, + PanelRowKey::Ticket(_) | PanelRowKey::TicketIntakePod { .. } => true, }) { self.select_panel_key(key.clone()); return; @@ -4677,6 +4686,13 @@ fn selected_ticket_notice(row: Option<&PanelRow>) -> String { row.title ) } + Some(row) if row.kind == PanelRowKind::TicketIntakePod => row + .disabled_reason + .clone() + .or_else(|| row.key_hint.clone()) + .unwrap_or_else(|| { + "Open/attach this Ticket's Intake Pod from the associated row.".to_string() + }), _ => "No Pod is selected.".to_string(), } } @@ -4793,7 +4809,7 @@ fn visible_panel_keys(panel: &WorkspacePanelViewModel, list: &PodList) -> Vec>(); keys.extend( @@ -5145,7 +5161,7 @@ fn panel_action_rows( let rows = panel .rows .iter() - .filter(|row| row.is_ticket_action()) + .filter(|row| row.is_ticket_section_row()) .collect::>(); if rows.is_empty() { return Vec::new(); @@ -5181,11 +5197,15 @@ fn panel_action_header_line(total: usize, width: u16) -> Line<'static> { const TICKET_STATE_COLUMN_WIDTH: usize = 10; const POD_STATUS_COLUMN_WIDTH: usize = 18; -fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> [Line<'static>; 2] { - [ - panel_row_title_line(row, selected, width), - panel_row_detail_line(row, selected, width), - ] +fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec> { + if row.kind == PanelRowKind::TicketIntakePod { + vec![panel_row_title_line(row, selected, width)] + } else { + vec![ + panel_row_title_line(row, selected, width), + panel_row_detail_line(row, selected, width), + ] + } } fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> { @@ -5242,6 +5262,21 @@ fn push_ticket_marker_span(spans: &mut Vec>, selected: bool, remai } fn panel_ticket_detail(row: &PanelRow) -> String { + if row.kind == PanelRowKind::TicketIntakePod { + let mut parts = row + .subtitle + .as_ref() + .map(|subtitle| vec![subtitle.clone()]) + .unwrap_or_else(|| vec![panel_ticket_reference(row)]); + if let Some(action) = row.next_action { + parts.push(format!("Action: {}", action.label())); + } + if let Some(reason) = panel_ticket_reason(row) { + parts.push(format!("Reason: {reason}")); + } + return parts.join(" · "); + } + let mut parts = vec![panel_ticket_reference(row)]; if let Some(blocked_reason) = row .ticket @@ -5303,6 +5338,7 @@ fn panel_ticket_reference(row: &PanelRow) -> String { .map(|ticket| ticket.id.clone()) .unwrap_or_else(|| match &row.key { PanelRowKey::Ticket(id) => id.clone(), + PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(), PanelRowKey::Pod(name) => name.clone(), }) } @@ -7321,7 +7357,8 @@ branch = "orchestration/custom-panel" "inprogress", ); - let [title, detail] = panel_row_lines(&row, true, 160); + let lines = panel_row_lines(&row, true, 160); + let (title, detail) = (&lines[0], &lines[1]); let title_line = plain_line(&title); let detail_line = plain_line(&detail); let state_start = 2; @@ -7352,7 +7389,8 @@ branch = "orchestration/custom-panel" "ready", ); - let [title, detail] = panel_row_lines(&row, false, 160); + let lines = panel_row_lines(&row, false, 160); + let (title, detail) = (&lines[0], &lines[1]); let title_line = plain_line(&title); let detail_line = plain_line(&detail); let state_start = 2; @@ -7377,7 +7415,8 @@ branch = "orchestration/custom-panel" "ready", ); - let [title, detail] = panel_row_lines(&row, false, 42); + let lines = panel_row_lines(&row, false, 42); + let (title, detail) = (&lines[0], &lines[1]); let title_line = plain_line(&title); let detail_line = plain_line(&detail); let title_start = 2 + TICKET_STATE_COLUMN_WIDTH + 1; @@ -7402,7 +7441,8 @@ branch = "orchestration/custom-panel" row.disabled_reason = Some("Queue disabled: waiting for BLOCKER-1".to_string()); row.ticket.as_mut().unwrap().blocked_reason = Some("BLOCKER-1 via depends_on".to_string()); - let [_title, detail] = panel_row_lines(&row, true, 160); + let lines = panel_row_lines(&row, true, 160); + let detail = &lines[1]; let detail_line = plain_line(&detail); assert!(detail_line.contains("Gate: waiting for BLOCKER-1 via depends_on")); @@ -8602,6 +8642,7 @@ branch = "orchestration/custom-panel" blocked_reason: None, related_pods: Vec::new(), local_claim: None, + intake_pods: Vec::new(), }; PanelRow { key: PanelRowKey::Ticket(ticket.id.clone()), diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index fdeb1db4..58e11b0a 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -182,15 +182,26 @@ impl OrchestratorPanelStatus { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(crate) enum PanelRowKey { Ticket(String), + TicketIntakePod { ticket_id: String, pod_name: String }, Pod(String), } +impl PanelRowKey { + pub(crate) fn pod_name(&self) -> Option<&str> { + match self { + Self::Pod(name) | Self::TicketIntakePod { pod_name: name, .. } => Some(name), + Self::Ticket(_) => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PanelRowKind { Planning, Ticket, Review, ActiveWork, + TicketIntakePod, Pod, } @@ -236,6 +247,30 @@ pub(crate) struct TicketPanelEntry { pub(crate) blocked_reason: Option, pub(crate) related_pods: Vec, pub(crate) local_claim: Option, + pub(crate) intake_pods: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TicketAssociatedIntakeEntry { + pub(crate) ticket_id: String, + pub(crate) pod_name: String, + pub(crate) status: TicketLocalClaimStatus, + pub(crate) source: TicketAssociatedIntakeSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TicketAssociatedIntakeSource { + LocalClaim, + RelatedSession, +} + +impl TicketAssociatedIntakeSource { + pub(crate) fn label(self) -> &'static str { + match self { + Self::LocalClaim => "local claim", + Self::RelatedSession => "related session", + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -279,11 +314,22 @@ pub(crate) struct PanelRow { impl PanelRow { pub(crate) fn is_ticket_action(&self) -> bool { - !matches!(self.kind, PanelRowKind::Pod) + matches!( + self.kind, + PanelRowKind::Planning + | PanelRowKind::Ticket + | PanelRowKind::Review + | PanelRowKind::ActiveWork + ) + } + + pub(crate) fn is_ticket_section_row(&self) -> bool { + self.is_ticket_action() || matches!(self.kind, PanelRowKind::TicketIntakePod) } } const MAX_POD_NAME_CHARS: usize = 80; +const MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET: usize = 3; const ORCHESTRATOR_SUFFIX: &str = "-orchestrator"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -574,12 +620,6 @@ fn build_workspace_panel_with_registry_model( } model.rows.extend(pod_rows(pods)); - model.rows.sort_by(|a, b| { - a.priority - .cmp(&b.priority) - .then_with(|| row_updated_at(b).cmp(row_updated_at(a))) - .then_with(|| a.title.cmp(&b.title)) - }); model } @@ -628,13 +668,13 @@ fn build_ticket_rows( pods: &PodList, registry: &PanelRegistrySnapshot, ) -> ticket::Result> { - let mut rows = Vec::new(); + let mut ticket_rows = Vec::new(); for summary in backend.list(TicketFilter::all())? { if summary.workflow_state == TicketWorkflowState::Closed { continue; } let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?; - rows.push(ticket_row( + ticket_rows.push(ticket_row( summary, &ticket.events, &ticket.relations.blockers, @@ -642,6 +682,19 @@ fn build_ticket_rows( registry, )); } + ticket_rows.sort_by(|a, b| { + a.priority + .cmp(&b.priority) + .then_with(|| row_updated_at(b).cmp(row_updated_at(a))) + .then_with(|| a.title.cmp(&b.title)) + }); + + let mut rows = Vec::new(); + for row in ticket_rows { + let intake_rows = ticket_intake_pod_rows(&row); + rows.push(row); + rows.extend(intake_rows); + } Ok(rows) } @@ -653,7 +706,17 @@ fn ticket_row( registry: &PanelRegistrySnapshot, ) -> PanelRow { let local_claim = local_claim_for_ticket(&summary, pods, registry); - let related_pods = related_pods_for_ticket(&summary, pods, registry); + let intake_pods = + associated_intake_entries_for_ticket(&summary, pods, registry, local_claim.as_ref()); + let mut related_pods = Vec::new(); + if let Some(claim) = local_claim.as_ref() { + related_pods.push(claim.pod_name.clone()); + } + for pod_name in intake_pods.iter().map(|intake| intake.pod_name.clone()) { + if !related_pods.iter().any(|existing| existing == &pod_name) { + related_pods.push(pod_name); + } + } let derived = derive_ticket_state(&summary, relation_blockers); let latest_event = events.last(); let entry = TicketPanelEntry { @@ -669,6 +732,7 @@ fn ticket_row( blocked_reason: derived.blocked_reason.clone(), related_pods: related_pods.clone(), local_claim, + intake_pods, }; let subtitle = ticket_subtitle(&entry); PanelRow { @@ -802,32 +866,111 @@ fn derive_ticket_state( } } -fn related_pods_for_ticket( +fn associated_intake_entries_for_ticket( summary: &TicketSummary, pods: &PodList, registry: &PanelRegistrySnapshot, -) -> Vec { - let id = lowercase(&summary.id); - let mut names = Vec::new(); - if let Some(claim) = registry.claim_for_ticket(&summary.id) { - names.push(claim.pod_name.clone()); + local_claim: Option<&TicketLocalClaimEntry>, +) -> Vec { + let mut entries = Vec::new(); + if let Some(claim) = local_claim.filter(|claim| is_intake_role(&claim.role)) { + entries.push(TicketAssociatedIntakeEntry { + ticket_id: summary.id.clone(), + pod_name: claim.pod_name.clone(), + status: claim.status, + source: TicketAssociatedIntakeSource::LocalClaim, + }); } - for pod in pods.entries.iter().filter_map(|pod| { - let name = lowercase(&pod.name); - if !id.is_empty() && name.contains(&id) { - Some(pod.name.clone()) - } else { - None + + let mut related_sessions = registry + .sessions + .iter() + .filter(|session| { + is_intake_role(&session.role) + && session + .related_tickets + .iter() + .any(|related| related.id == summary.id.as_str()) + }) + .map(|session| session.pod_name.clone()) + .collect::>(); + related_sessions.sort(); + related_sessions.dedup(); + + for pod_name in related_sessions { + if entries.iter().any(|entry| entry.pod_name == pod_name) { + continue; } - }) { - if !names.iter().any(|existing| existing == &pod) { - names.push(pod); - } - if names.len() >= 5 { + entries.push(TicketAssociatedIntakeEntry { + ticket_id: summary.id.clone(), + status: local_claim_status_for_pod(&pod_name, pods), + pod_name, + source: TicketAssociatedIntakeSource::RelatedSession, + }); + if entries.len() >= MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET { break; } } - names + + entries.truncate(MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET); + entries +} + +fn is_intake_role(role: &str) -> bool { + role.eq_ignore_ascii_case("intake") +} + +fn ticket_intake_pod_rows(row: &PanelRow) -> Vec { + row.ticket + .as_ref() + .map(|ticket| { + ticket + .intake_pods + .iter() + .map(ticket_intake_pod_row) + .collect() + }) + .unwrap_or_default() +} + +fn ticket_intake_pod_row(intake: &TicketAssociatedIntakeEntry) -> PanelRow { + let stale = intake.status == TicketLocalClaimStatus::Stale; + PanelRow { + key: PanelRowKey::TicketIntakePod { + ticket_id: intake.ticket_id.clone(), + pod_name: intake.pod_name.clone(), + }, + kind: PanelRowKind::TicketIntakePod, + title: format!("↳ Intake Pod: {}", intake.pod_name), + subtitle: Some(format!( + "Ticket {} · {} · {}", + intake.ticket_id, + intake.source.label(), + intake.status.label() + )), + status: intake.status.label().to_string(), + priority: ActionPriority::ActiveWork, + next_action: if stale { + None + } else { + Some(NextUserAction::OpenPod) + }, + ticket: None, + related_pods: vec![intake.pod_name.clone()], + disabled_reason: if stale { + Some( + "Associated Intake Pod is stale; no live or restorable Pod entry is available." + .to_string(), + ) + } else { + None + }, + key_hint: Some(if stale { + "Stale Intake claim/session; restore is unavailable".to_string() + } else { + "Open/attach this Ticket's Intake Pod".to_string() + }), + } } fn local_claim_for_ticket( @@ -962,15 +1105,12 @@ fn excerpt(markdown: &str, max_chars: usize) -> Option { } } -fn lowercase(value: &str) -> String { - value.to_ascii_lowercase() -} - #[allow(dead_code)] #[cfg(test)] mod tests { use super::*; use crate::pod_list::{LivePodInfo, PodEntrySummary}; + use crate::role_session_registry::{PanelRegistryStore, RelatedTicketRef, RoleSessionOrigin}; use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; @@ -1196,6 +1336,97 @@ mod tests { assert_eq!(done.next_action, Some(NextUserAction::Close)); } + #[test] + fn workspace_panel_shows_ticket_associated_intake_pods_adjacent_to_ticket() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket(&backend, "Ticket With Intake", |input| { + input.workflow_state = Some(TicketWorkflowState::Ready); + }); + let ticket_id = backend + .list(TicketFilter::all()) + .unwrap() + .into_iter() + .find(|ticket| ticket.title == "Ticket With Intake") + .unwrap() + .id; + let preticket_pod = format!("pre-{ticket_id}-intake"); + let registry = PanelRegistryStore::from_root(temp.path().join("registry")); + registry + .claim_ticket(&ticket_id, None, "claimed-intake", "intake") + .unwrap(); + registry + .record_session( + "shared-intake", + "intake", + RoleSessionOrigin::RoleLaunch, + None, + [RelatedTicketRef { + id: ticket_id.clone(), + slug: None, + }], + ) + .unwrap(); + registry + .record_session( + &preticket_pod, + "intake", + RoleSessionOrigin::PreTicketIntake, + None, + [], + ) + .unwrap(); + + let pods = live_pods(&["claimed-intake", "shared-intake", &preticket_pod]); + let model = + build_workspace_panel_with_registry(temp.path(), &pods, ®istry.snapshot().unwrap()); + + let ticket_index = model + .rows + .iter() + .position(|row| row.key == PanelRowKey::Ticket(ticket_id.clone())) + .unwrap(); + let ticket_row = &model.rows[ticket_index]; + let ticket = ticket_row.ticket.as_ref().unwrap(); + assert_eq!( + ticket + .intake_pods + .iter() + .map(|entry| entry.pod_name.as_str()) + .collect::>(), + vec!["claimed-intake", "shared-intake"] + ); + assert_eq!(ticket.related_pods, vec!["claimed-intake", "shared-intake"]); + assert_eq!( + model.rows[ticket_index + 1].key, + PanelRowKey::TicketIntakePod { + ticket_id: ticket_id.clone(), + pod_name: "claimed-intake".to_string(), + } + ); + assert_eq!( + model.rows[ticket_index + 1].kind, + PanelRowKind::TicketIntakePod + ); + assert_eq!(model.rows[ticket_index + 1].status, "live"); + assert_eq!( + model.rows[ticket_index + 1].next_action, + Some(NextUserAction::OpenPod) + ); + assert_eq!( + model.rows[ticket_index + 2].key, + PanelRowKey::TicketIntakePod { + ticket_id: ticket_id.clone(), + pod_name: "shared-intake".to_string(), + } + ); + assert!(model.rows.iter().all(|row| { + row.kind != PanelRowKind::TicketIntakePod + || row.key.pod_name() != Some(preticket_pod.as_str()) + })); + } + #[test] fn workspace_panel_displays_local_ticket_claim_status() { let temp = TempDir::new().unwrap(); From 80a9e40d3abfc3677239560d376c6d8bd2673637 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:48:17 +0900 Subject: [PATCH 03/28] ticket: record panel intake pod implementation --- .yoi/tickets/00001KV09WYC6/item.md | 2 +- .yoi/tickets/00001KV09WYC6/thread.md | 57 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV09WYC6/item.md b/.yoi/tickets/00001KV09WYC6/item.md index 7963620e..7f94f1aa 100644 --- a/.yoi/tickets/00001KV09WYC6/item.md +++ b/.yoi/tickets/00001KV09WYC6/item.md @@ -2,7 +2,7 @@ title: 'Workspace panel: show Ticket-associated Intake Pods adjacent to Ticket rows' state: 'inprogress' created_at: '2026-06-13T10:54:31Z' -updated_at: '2026-06-14T15:24:58Z' +updated_at: '2026-06-14T15:48:12Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel-ux', 'local-role-session-registry', 'pod-session-state'] diff --git a/.yoi/tickets/00001KV09WYC6/thread.md b/.yoi/tickets/00001KV09WYC6/thread.md index 4c5f7bd5..9b765b5e 100644 --- a/.yoi/tickets/00001KV09WYC6/thread.md +++ b/.yoi/tickets/00001KV09WYC6/thread.md @@ -109,3 +109,60 @@ Critical risks / reviewer focus: Routing decision と accepted implementation plan を記録済み。blocking relation / orchestration-plan blocker はなく、Orchestrator workspace は clean。00001KTFY8V80 とは主対象が workflow/compaction と TUI Panel で分かれており、独立 worktree/branch で並行開始可能と判断したため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。 --- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KV09WYC6`: + +Commit: +- `2664cdd9 feat: show ticket intake pods in panel` + +Changed files: +- `crates/tui/src/workspace_panel.rs` +- `crates/tui/src/multi_pod.rs` + +Implemented behavior: +- Workspace Panel derives Ticket-associated Intake Pods from the local role/session registry: + - active local Intake claim for a Ticket + - related Intake sessions whose `related_tickets` explicitly contain that Ticket id +- Pre-Ticket Intake sessions without an explicit Ticket relation are not associated, even if the Pod name contains the Ticket id. +- Ticket rows now get adjacent child rows: + - `↳ Intake Pod: ` + - status column shows `live`, `restorable`, or `stale` + - non-stale associated Intake rows expose `Open/attach` behavior via the existing Pod open path + - stale rows show unavailable/stale reason instead of trying to open +- Added typed row key/kind for Ticket Intake Pod rows: + - `PanelRowKey::TicketIntakePod` + - `PanelRowKind::TicketIntakePod` +- Kept related display bounded with `MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET = 3`. +- Preserved existing local claim handling for generic `related_pods`, while using the new typed `intake_pods` field for visible Ticket Intake child rows. +- Updated panel selection/open plumbing so associated Intake rows resolve to existing Pod open/attach behavior. + +Focused coverage: +- Added `workspace_panel_shows_ticket_associated_intake_pods_adjacent_to_ticket`, covering: + - local Intake claim adjacent row + - related Intake session adjacent row + - live status/action contract + - Pre-Ticket Intake session not falsely associated + +Validation reported by coder: +- Passed: `cargo test -p tui workspace_panel --lib` +- Passed: `cargo test -p tui role_session_registry --lib` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` +- Ran relevant: `cargo test -p tui multi_pod --lib` + - Failed in one existing environment/path-sensitive test: + - `tests::orchestrator_launch_uses_runtime_workspace_not_process_cwd` + - Failure expected `/tmp/.../runtime-workspace` but observed the worktree path. + - The suite compiled; coder assessed this as unrelated to the Intake-row changes. + +Repository status: +- Child implementation worktree clean after commit. + +Residual risks / blockers: +- No known implementation blocker. +- Validation caveat: unrelated `multi_pod` filtered-suite failure noted above remains for reviewer/orchestrator assessment. + +--- From 362fedfbe6689886f1e2e7c29da61e39b0ce1e38 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:48:39 +0900 Subject: [PATCH 04/28] fix: preserve active workflows across compaction --- crates/pod/src/active_workflow.rs | 580 +++++++++++++++++++ crates/pod/src/lib.rs | 1 + crates/pod/src/pod.rs | 118 +++- crates/pod/src/segment_log_sink.rs | 18 + resources/prompts/internal/compact_system.md | 3 +- 5 files changed, 704 insertions(+), 16 deletions(-) create mode 100644 crates/pod/src/active_workflow.rs diff --git a/crates/pod/src/active_workflow.rs b/crates/pod/src/active_workflow.rs new file mode 100644 index 00000000..07cda103 --- /dev/null +++ b/crates/pod/src/active_workflow.rs @@ -0,0 +1,580 @@ +//! Durable active workflow invocation state. +//! +//! Workflow bodies are resolved at invocation time and snapshotted here. The +//! snapshot, not whatever resource version is installed later, is the procedural +//! authority that survives compaction for the currently governed task. + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use llm_worker::Item; +use llm_worker::tool::{ + Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use session_store::{LogEntry, SystemItem, segment_log}; + +pub const DOMAIN: &str = "pod.active_workflows"; +const SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActiveWorkflowSnapshot { + pub schema_version: u32, + pub workflows: Vec, +} + +impl Default for ActiveWorkflowSnapshot { + fn default() -> Self { + Self { + schema_version: SCHEMA_VERSION, + workflows: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActiveWorkflowRecord { + pub slug: String, + pub status: ActiveWorkflowStatus, + pub invocation: WorkflowInvocationInfo, + pub task_scope: String, + pub body_snapshot_policy: WorkflowBodySnapshotPolicy, + pub guidance_snapshot: String, + pub obligations: Vec, + pub checkpoints: Vec, + pub updated_at_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub completion: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ActiveWorkflowStatus { + Active, + Completed, + Cancelled, +} + +impl std::fmt::Display for ActiveWorkflowStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::Active => "active", + Self::Completed => "completed", + Self::Cancelled => "cancelled", + }; + f.write_str(s) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowInvocationInfo { + pub source: WorkflowInvocationSource, + pub invoked_at_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowInvocationSource { + UserWorkflowInvokeSegment, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowBodySnapshotPolicy { + SnapshottedAtInvocation, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowCheckpoint { + pub label: String, + pub status: WorkflowCheckpointStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowCheckpointStatus { + Open, + Done, + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowCompletionInfo { + pub completed_at_ms: u64, + pub reason: String, +} + +#[derive(Debug, Clone, Default)] +pub struct ActiveWorkflowStore { + inner: Arc>, +} + +impl ActiveWorkflowStore { + pub fn new() -> Self { + Self::default() + } + + pub fn snapshot(&self) -> ActiveWorkflowSnapshot { + self.inner.lock().unwrap_or_else(|e| e.into_inner()).clone() + } + + pub fn replace_with(&self, snapshot: ActiveWorkflowSnapshot) { + *self.inner.lock().unwrap_or_else(|e| e.into_inner()) = snapshot; + } + + pub fn active_records(&self) -> Vec { + self.snapshot() + .workflows + .into_iter() + .filter(|record| record.status == ActiveWorkflowStatus::Active) + .collect() + } + + pub fn activate_from_system_items( + &self, + items: &[SystemItem], + task_scope: String, + invoked_at_ms: u64, + ) -> bool { + let mut grouped: BTreeMap> = BTreeMap::new(); + for item in items { + if let SystemItem::Workflow { slug, body } = item { + grouped.entry(slug.clone()).or_default().push(body.clone()); + } + } + if grouped.is_empty() { + return false; + } + + let mut snapshot = self.snapshot(); + snapshot.schema_version = SCHEMA_VERSION; + for (slug, bodies) in grouped { + let guidance_snapshot = bodies.join("\n\n---\n\n"); + let obligations = extract_obligations(&guidance_snapshot); + let checkpoints = obligations + .iter() + .take(32) + .map(|label| WorkflowCheckpoint { + label: label.clone(), + status: WorkflowCheckpointStatus::Open, + }) + .collect(); + let record = ActiveWorkflowRecord { + slug: slug.clone(), + status: ActiveWorkflowStatus::Active, + invocation: WorkflowInvocationInfo { + source: WorkflowInvocationSource::UserWorkflowInvokeSegment, + invoked_at_ms, + }, + task_scope: truncate_chars(&task_scope, 2_000), + body_snapshot_policy: WorkflowBodySnapshotPolicy::SnapshottedAtInvocation, + guidance_snapshot, + obligations, + checkpoints, + updated_at_ms: invoked_at_ms, + completion: None, + }; + upsert_record(&mut snapshot.workflows, record); + } + self.replace_with(snapshot); + true + } + + pub fn set_status( + &self, + slug: &str, + status: ActiveWorkflowStatus, + reason: String, + now_ms: u64, + ) -> Result { + let mut snapshot = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + let record = snapshot + .workflows + .iter_mut() + .find(|record| record.slug == slug) + .ok_or_else(|| format!("active workflow `{slug}` not found"))?; + record.status = status; + record.updated_at_ms = now_ms; + record.completion = Some(WorkflowCompletionInfo { + completed_at_ms: now_ms, + reason, + }); + for checkpoint in &mut record.checkpoints { + checkpoint.status = match status { + ActiveWorkflowStatus::Active => WorkflowCheckpointStatus::Open, + ActiveWorkflowStatus::Completed => WorkflowCheckpointStatus::Done, + ActiveWorkflowStatus::Cancelled => WorkflowCheckpointStatus::Cancelled, + }; + } + Ok(record.clone()) + } + + pub fn snapshot_text(&self) -> Option { + let active = self.active_records(); + (!active.is_empty()).then(|| render_snapshot_text(&active)) + } + + pub fn rehydration_message(&self) -> Option { + let active = self.active_records(); + (!active.is_empty()).then(|| render_rehydration_message(&active)) + } + + pub fn extension_entry(&self) -> LogEntry { + LogEntry::Extension { + ts: segment_log::now_millis(), + domain: DOMAIN.into(), + payload: serde_json::to_value(self.snapshot()) + .expect("ActiveWorkflowSnapshot is always JSON-serializable"), + } + } + + pub fn restore_from_history_and_extensions( + &self, + history: &[Item], + extensions: &[(String, serde_json::Value)], + ) { + let (mut snapshot, diagnostics) = fold_extensions(extensions); + for diagnostic in diagnostics { + tracing::warn!(diagnostic, "failed to restore active workflow state"); + } + replay_history_tools(&mut snapshot, history); + self.replace_with(snapshot); + } +} + +pub fn fold_extensions( + extensions: &[(String, serde_json::Value)], +) -> (ActiveWorkflowSnapshot, Vec) { + let mut latest = None; + let mut diagnostics = Vec::new(); + for (domain, payload) in extensions { + if domain != DOMAIN { + continue; + } + match serde_json::from_value::(payload.clone()) { + Ok(snapshot) if snapshot.schema_version == SCHEMA_VERSION => latest = Some(snapshot), + Ok(snapshot) => { + latest = None; + diagnostics.push(format!( + "unsupported active workflow schema_version {}", + snapshot.schema_version + )); + } + Err(err) => { + latest = None; + diagnostics.push(format!("corrupt active workflow payload: {err}")); + } + } + } + (latest.unwrap_or_default(), diagnostics) +} + +fn replay_history_tools(snapshot: &mut ActiveWorkflowSnapshot, history: &[Item]) { + for item in history { + let Item::ToolCall { + name, arguments, .. + } = item + else { + continue; + }; + let status = match name.as_str() { + "ActiveWorkflowComplete" => ActiveWorkflowStatus::Completed, + "ActiveWorkflowCancel" => ActiveWorkflowStatus::Cancelled, + _ => continue, + }; + if let Ok(params) = serde_json::from_str::(arguments) { + if let Some(record) = snapshot + .workflows + .iter_mut() + .find(|record| record.slug == params.slug) + { + let reason = params.reason.unwrap_or_else(|| status.to_string()); + record.status = status; + record.updated_at_ms = record.updated_at_ms.saturating_add(1); + record.completion = Some(WorkflowCompletionInfo { + completed_at_ms: record.updated_at_ms, + reason, + }); + for checkpoint in &mut record.checkpoints { + checkpoint.status = match status { + ActiveWorkflowStatus::Active => WorkflowCheckpointStatus::Open, + ActiveWorkflowStatus::Completed => WorkflowCheckpointStatus::Done, + ActiveWorkflowStatus::Cancelled => WorkflowCheckpointStatus::Cancelled, + }; + } + } + } + } +} + +pub fn active_workflow_tools(store: ActiveWorkflowStore) -> Vec { + vec![ + list_tool(store.clone()), + status_tool(store.clone(), ActiveWorkflowStatus::Completed), + status_tool(store, ActiveWorkflowStatus::Cancelled), + ] +} + +fn list_tool(store: ActiveWorkflowStore) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new("ActiveWorkflowList") + .description("List durable active workflow invocations and their status") + .input_schema( + json!({"type":"object","properties":{},"additionalProperties":false}), + ), + Arc::new(ActiveWorkflowListTool { + store: store.clone(), + }) as Arc, + ) + }) +} + +fn status_tool(store: ActiveWorkflowStore, status: ActiveWorkflowStatus) -> ToolDefinition { + let name = match status { + ActiveWorkflowStatus::Completed => "ActiveWorkflowComplete", + ActiveWorkflowStatus::Cancelled => "ActiveWorkflowCancel", + ActiveWorkflowStatus::Active => unreachable!("active status tool is not exposed"), + }; + let description = match status { + ActiveWorkflowStatus::Completed => { + "Mark an active workflow as completed when its governed task is finished" + } + ActiveWorkflowStatus::Cancelled => { + "Cancel an active workflow when the governed task is explicitly abandoned" + } + ActiveWorkflowStatus::Active => unreachable!("active status tool is not exposed"), + }; + let store_for_tool = store.clone(); + Arc::new(move || { + ( + ToolMeta::new(name) + .description(description) + .input_schema(json!({ + "type":"object", + "properties":{ + "slug":{"type":"string","description":"Workflow slug to update"}, + "reason":{"type":"string","description":"Brief completion/cancellation reason"} + }, + "required":["slug"], + "additionalProperties":false + })), + Arc::new(ActiveWorkflowStatusTool { + store: store_for_tool.clone(), + status, + }) as Arc, + ) + }) +} + +struct ActiveWorkflowListTool { + store: ActiveWorkflowStore, +} + +#[async_trait] +impl Tool for ActiveWorkflowListTool { + async fn execute( + &self, + _input_json: &str, + _ctx: ToolExecutionContext, + ) -> Result { + let snapshot = self.store.snapshot(); + let content = serde_json::to_string_pretty(&snapshot) + .map_err(|err| ToolError::Internal(err.to_string()))?; + let active = snapshot + .workflows + .iter() + .filter(|record| record.status == ActiveWorkflowStatus::Active) + .count(); + Ok(ToolOutput { + summary: format!( + "ActiveWorkflowStore: {} workflow(s), {active} active", + snapshot.workflows.len() + ), + content: Some(content), + }) + } +} + +struct ActiveWorkflowStatusTool { + store: ActiveWorkflowStore, + status: ActiveWorkflowStatus, +} + +#[async_trait] +impl Tool for ActiveWorkflowStatusTool { + async fn execute( + &self, + input_json: &str, + _ctx: ToolExecutionContext, + ) -> Result { + let params: WorkflowStatusParams = serde_json::from_str(input_json) + .map_err(|err| ToolError::InvalidArgument(err.to_string()))?; + let reason = params.reason.unwrap_or_else(|| self.status.to_string()); + let record = self + .store + .set_status(¶ms.slug, self.status, reason, segment_log::now_millis()) + .map_err(ToolError::InvalidArgument)?; + let content = serde_json::to_string_pretty(&record) + .map_err(|err| ToolError::Internal(err.to_string()))?; + Ok(ToolOutput { + summary: format!("workflow {} marked {}", record.slug, record.status), + content: Some(content), + }) + } +} + +#[derive(Debug, Deserialize)] +struct WorkflowStatusParams { + slug: String, + #[serde(default)] + reason: Option, +} + +fn upsert_record(records: &mut Vec, record: ActiveWorkflowRecord) { + if let Some(existing) = records + .iter_mut() + .find(|existing| existing.slug == record.slug) + { + *existing = record; + } else { + records.push(record); + } +} + +fn extract_obligations(body: &str) -> Vec { + let mut obligations = Vec::new(); + for line in body.lines() { + let trimmed = line.trim(); + let candidate = trimmed + .strip_prefix("- ") + .or_else(|| trimmed.strip_prefix("* ")) + .or_else(|| trimmed.strip_prefix("• ")) + .unwrap_or(trimmed); + let lower = candidate.to_ascii_lowercase(); + let looks_obligating = lower.contains("must") + || lower.contains("require") + || lower.contains("obligation") + || lower.contains("review") + || lower.contains("merge") + || lower.contains("close") + || lower.contains("report") + || lower.contains("handoff"); + if looks_obligating && !candidate.is_empty() { + obligations.push(truncate_chars(candidate, 240)); + } + if obligations.len() >= 32 { + break; + } + } + if obligations.is_empty() { + obligations + .push("Follow the snapshotted workflow body until completion or cancellation".into()); + } + obligations +} + +fn render_snapshot_text(records: &[ActiveWorkflowRecord]) -> String { + let json = serde_json::to_string_pretty(&ActiveWorkflowSnapshot { + schema_version: SCHEMA_VERSION, + workflows: records.to_vec(), + }) + .unwrap_or_else(|_| String::from("{\"schema_version\":1,\"workflows\":[]}")); + format!( + "ActiveWorkflowStore: {} active workflow(s)\n\n```json\n{}\n```", + records.len(), + json + ) +} + +fn render_rehydration_message(records: &[ActiveWorkflowRecord]) -> String { + let mut out = String::from( + "[Active workflow snapshot]\n\n\ + The following workflow invocation state is durable state carried across compaction. \ + Continue to follow each active workflow's snapshotted guidance until the governed task \ + is completed with ActiveWorkflowComplete or explicitly cancelled with ActiveWorkflowCancel. \ + Missing or obsolete workflow resources must not replace these invocation snapshots.\n", + ); + for record in records { + out.push_str(&format!( + "\n## /{} ({})\n- invoked_at_ms: {}\n- invocation_source: {:?}\n- body_snapshot_policy: {:?}\n- task_scope: {}\n\n### Current obligations/checkpoints\n", + record.slug, + record.status, + record.invocation.invoked_at_ms, + record.invocation.source, + record.body_snapshot_policy, + record.task_scope.replace('\n', " "), + )); + for checkpoint in &record.checkpoints { + out.push_str(&format!( + "- [{}] {}\n", + checkpoint.status_label(), + checkpoint.label + )); + } + out.push_str("\n### Snapshotted workflow guidance\n"); + out.push_str(record.guidance_snapshot.trim_end()); + out.push_str("\n"); + } + out +} + +impl WorkflowCheckpoint { + fn status_label(&self) -> &'static str { + match self.status { + WorkflowCheckpointStatus::Open => "open", + WorkflowCheckpointStatus::Done => "done", + WorkflowCheckpointStatus::Cancelled => "cancelled", + } + } +} + +fn truncate_chars(text: &str, max_chars: usize) -> String { + let mut out = String::new(); + for (idx, ch) in text.chars().enumerate() { + if idx >= max_chars { + out.push('…'); + return out; + } + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_workflow_guidance_carries_merge_close_obligations() { + let store = ActiveWorkflowStore::new(); + let items = vec![SystemItem::Workflow { + slug: "multi-agent-workflow".into(), + body: "# Multi-agent workflow\n- Delegate implementation to coder.\n- Require external review before merge.\n- Close the Ticket after merge and report evidence.\n".into(), + }]; + + assert!(store.activate_from_system_items( + &items, + "/multi-agent-workflow implement ticket".into(), + 42, + )); + let msg = store.rehydration_message().unwrap(); + + assert!(msg.contains("multi-agent-workflow")); + assert!(msg.contains("external review before merge")); + assert!(msg.contains("Close the Ticket after merge")); + assert!(msg.contains("Snapshotted workflow guidance")); + } + + #[test] + fn corrupt_extension_fails_closed_with_diagnostic() { + let entries = vec![(DOMAIN.to_string(), json!({"schema_version":"bad"}))]; + + let (snapshot, diagnostics) = fold_extensions(&entries); + + assert!(snapshot.workflows.is_empty()); + assert_eq!(diagnostics.len(), 1); + } +} diff --git a/crates/pod/src/lib.rs b/crates/pod/src/lib.rs index efc44e94..2153b268 100644 --- a/crates/pod/src/lib.rs +++ b/crates/pod/src/lib.rs @@ -1,3 +1,4 @@ +pub mod active_workflow; pub mod compact; pub mod controller; pub mod discovery; diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 0b029b28..c2cee73a 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -26,6 +26,7 @@ use manifest::{ ScopeError, ScopeRule, SharedScope, WorkerManifest, }; +use crate::active_workflow::{self, ActiveWorkflowStore}; use crate::compact::state::CompactState; use crate::compact::usage_tracker::UsageTracker; use crate::feature::builtin::TaskFeature; @@ -145,6 +146,7 @@ struct EmptyTurnRollbackSnapshot { usage_history_len: usize, ai_activity_count: usize, last_run_interrupted: bool, + active_workflows: active_workflow::ActiveWorkflowSnapshot, } fn is_ai_materialized_item(item: &Item) -> bool { @@ -274,6 +276,10 @@ pub struct Pod { /// the narrow snapshot/restore surface Pod needs for compaction and rewind. /// Store/reminder ownership stays inside the Task feature module. task_feature: TaskFeature, + /// Durable state for workflow invocations that are active for the current task. + /// The store is persisted as typed session-log extensions and rehydrated into + /// prompt context during compaction. + active_workflows: ActiveWorkflowStore, /// Parsed system-prompt template awaiting first-turn materialisation. /// `Some` until `ensure_system_prompt_materialized` renders it once, /// then `None` forever — including after compaction. @@ -435,6 +441,7 @@ impl Pod { usage_history: self.usage_history.clone(), tracker: None, task_feature: self.task_feature.clone(), + active_workflows: self.active_workflows.clone(), system_prompt_template: None, alerter: self.alerter.clone(), event_tx: self.event_tx.clone(), @@ -618,6 +625,7 @@ impl Pod { usage_history: Arc::new(Mutex::new(Vec::::new())), tracker: None, task_feature: TaskFeature::new(), + active_workflows: ActiveWorkflowStore::new(), system_prompt_template: None, alerter: None, event_tx: None, @@ -813,7 +821,11 @@ impl Pod { registry: FeatureRegistryBuilder, ) -> FeatureRegistryInstallReport { let worker = self.worker.as_mut().expect("worker taken during run"); - registry.install_into_worker(worker, &mut self.hook_builder) + let report = registry.install_into_worker(worker, &mut self.hook_builder); + worker.register_tools(active_workflow::active_workflow_tools( + self.active_workflows.clone(), + )); + report } /// Reference to the store. @@ -876,6 +888,8 @@ impl Pod { self.sink.truncate_silent(truncate_entries); self.task_feature.restore_from_history(&state.history); + self.active_workflows + .restore_from_history_and_extensions(&state.history, &state.extensions); self.worker_mut().set_history(state.history); self.worker_mut().set_request_config(state.config); self.worker_mut().set_turn_count(state.turn_count); @@ -1428,6 +1442,7 @@ impl Pod { usage_history_len, ai_activity_count: self.ai_activity_counter.load(Ordering::SeqCst), last_run_interrupted: self.worker().last_run_interrupted(), + active_workflows: self.active_workflows.snapshot(), } } @@ -1465,6 +1480,8 @@ impl Pod { .truncate(snapshot.usage_history_len); let _ = self.usage_tracker.drain(); let _ = self.metrics_tracker.drain(); + self.active_workflows + .replace_with(snapshot.active_workflows); let loc = self.segment_state.location(); self.store @@ -1535,6 +1552,14 @@ impl Pod { let mut attachments = self.resolve_file_refs(&input); attachments.extend(self.resolve_knowledge_refs(&input)); attachments.extend(self.resolve_workflow_invocations(&input)?); + let flattened = self.flatten_segments(&input); + if self.active_workflows.activate_from_system_items( + &attachments, + flattened.clone(), + segment_log::now_millis(), + ) { + self.commit_entry(self.active_workflows.extension_entry())?; + } if !attachments.is_empty() { *self .pending_attachments @@ -1542,8 +1567,6 @@ impl Pod { .expect("pending_attachments poisoned") = attachments; } - let flattened = self.flatten_segments(&input); - let history_before = self.worker.as_ref().unwrap().history().len(); // lock → run → unlock @@ -2428,13 +2451,15 @@ impl Pod { .unwrap_or_default(); // Input text fed to the compact worker. Includes the default - // references, current TaskStore snapshot, and the (pruned) - // conversation text. + // references, current TaskStore snapshot, active workflow invocation + // state, and the (pruned) conversation text. let task_snapshot_text = self.task_feature.snapshot_text(); + let active_workflow_snapshot_text = self.active_workflows.snapshot_text(); let summary_input = build_summary_input( &items_to_summarise, &default_refs, Some(task_snapshot_text.as_str()), + active_workflow_snapshot_text.as_deref(), SummaryInputOptions { overview_target_tokens, overview_warning_tokens, @@ -2609,20 +2634,31 @@ impl Pod { .filter(|i| i.is_user_message()) .count(); - // Build new history: [summary, ...auto-read, references, ...retained, task snapshot, TaskList synthetic call/result]. + // Build new history: [summary, ...auto-read, references, ...retained, active workflow snapshot, task snapshot, TaskList synthetic call/result]. + // The active workflow snapshot is inserted from durable typed state so + // workflow-governed tasks keep their procedural authority after the + // compacted segment starts. // The TaskStore snapshot trails the retained items so that, on resume, // `replay_history` walks any pre-compact Task* calls preserved verbatim // in retained_items first and the trailing snapshot's `replace_with` // is the final word — pre-compact `TaskCreate` calls cannot leak as // duplicate entries. + let active_workflow_message = self + .active_workflows + .rehydration_message() + .map(Item::system_message); let mut new_history = Vec::with_capacity( 1 + auto_read_messages.len() + 3 + reference_message.is_some() as usize + + active_workflow_message.is_some() as usize + retained_items.len(), ); - let mut compact_introduced_system_messages = - Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize); + let mut compact_introduced_system_messages = Vec::with_capacity( + 2 + auto_read_messages.len() + + reference_message.is_some() as usize + + active_workflow_message.is_some() as usize, + ); let summary_message = Item::system_message(format!("[Compacted context summary]\n\n{summary_text}")); compact_introduced_system_messages.push(summary_message.clone()); @@ -2635,6 +2671,9 @@ impl Pod { This is the complete session task list preserved across compaction. \ The following TaskList tool result presents the same state through the tool lane." )); + if let Some(msg) = active_workflow_message.as_ref() { + compact_introduced_system_messages.push(msg.clone()); + } compact_introduced_system_messages.push(task_snapshot_message.clone()); new_history.push(summary_message); @@ -2643,6 +2682,9 @@ impl Pod { new_history.push(msg); } new_history.extend(retained_items); + if let Some(msg) = active_workflow_message { + new_history.push(msg); + } new_history.push(task_snapshot_message); new_history.push(Item::tool_call("compact-tasklist", "TaskList", "{}")); new_history.push(Item::tool_result_with_content( @@ -2680,18 +2722,23 @@ impl Pod { at_turn_index: source_turn_count, }), }; + let active_workflow_extension = self.active_workflows.extension_entry(); + let initial_entries = vec![entry.clone(), active_workflow_extension.clone()]; self.store - .create_segment(old_loc.session_id, new_segment_id, &[entry.clone()])?; + .create_segment(old_loc.session_id, new_segment_id, &initial_entries)?; self.segment_state.set_location(SegmentLocation { session_id: old_loc.session_id, segment_id: new_segment_id, }); - self.segment_state.set_entries_written(1); + self.segment_state + .set_entries_written(initial_entries.len()); let session_start = entry; // Broadcast the SegmentStart through the sink. This atomically - // resets the mirror to `[SegmentStart]` so any subscriber - // querying after this point sees the post-compaction prefix. - self.sink.reset_with_initial(session_start); + // resets the mirror to the replacement segment prefix so any subscriber + // querying after this point sees the post-compaction prefix, including + // durable extension state. + self.sink + .reset_with_initial_entries(vec![session_start, active_workflow_extension]); // Keep pods.json pointing at the live segment_id. Without this // a concurrent `restore_from_manifest(new_segment_id)` would // see no live writer and grab the session this Pod just moved @@ -3794,6 +3841,7 @@ where usage_history: Arc::new(Mutex::new(Vec::new())), tracker: None, task_feature: TaskFeature::new(), + active_workflows: ActiveWorkflowStore::new(), system_prompt_template: common.system_prompt_template, alerter: None, event_tx: None, @@ -3902,6 +3950,7 @@ where usage_history: Arc::new(Mutex::new(Vec::new())), tracker: None, task_feature: TaskFeature::new(), + active_workflows: ActiveWorkflowStore::new(), system_prompt_template: common.system_prompt_template, alerter: None, event_tx: None, @@ -4111,6 +4160,8 @@ where let extract_pointer = memory::extract::fold_pointer(&state.extensions); let task_feature = TaskFeature::from_history(&state.history); + let active_workflows = ActiveWorkflowStore::new(); + active_workflows.restore_from_history_and_extensions(&state.history, &state.extensions); let pod_metadata_writer = Some(pod_metadata_writer_for_store(&store)); let mut pod = Self { @@ -4131,6 +4182,7 @@ where usage_history: Arc::new(Mutex::new(state.usage_history)), tracker: None, task_feature, + active_workflows, // Restore replays the saved system_prompt verbatim — no // template re-render on resume. system_prompt_template: None, @@ -4335,12 +4387,13 @@ struct SummaryInputBuild { } /// Build the compact worker's input: default-reference instructions, -/// the list of recently-touched files, task snapshot, and a bounded overview -/// rather than a prefix-wide transcript. +/// the list of recently-touched files, task snapshot, active workflow snapshot, +/// and a bounded overview rather than a prefix-wide transcript. fn build_summary_input( items: &[Item], default_refs: &[PathBuf], task_snapshot: Option<&str>, + active_workflow_snapshot: Option<&str>, options: SummaryInputOptions, ) -> SummaryInputBuild { let overview = build_summary_overview( @@ -4392,6 +4445,17 @@ fn build_summary_input( out.push_str(task_snapshot); out.push_str("\n\n"); } + if let Some(active_workflow_snapshot) = active_workflow_snapshot { + out.push_str( + "## Active Workflow Invocation State\n\ + This is durable typed workflow state for workflow-governed tasks. Preserve active \ + slugs, invocation scope, status, obligations/checkpoints, and the snapshotted \ + workflow guidance in the summary; do not substitute advertised/latest workflow \ + resources for this invocation state.\n", + ); + out.push_str(active_workflow_snapshot); + out.push_str("\n\n"); + } out.push_str("## Conversation overview/index\n"); out.push_str(&overview); out.push_str("\n\nWhen you are done, call `write_summary` with the final 5-section text."); @@ -5278,6 +5342,7 @@ mod build_summary_prompt_tests { items, &[], None, + None, SummaryInputOptions { overview_target_tokens: 512, overview_warning_tokens: 1024, @@ -5326,6 +5391,27 @@ mod build_summary_prompt_tests { assert!(!prompt.contains("deliberation")); } + #[test] + fn includes_active_workflow_snapshot_section() { + let prompt = build_summary_input( + &[Item::user_message("continue after review")], + &[], + None, + Some("ActiveWorkflowStore: 1 active workflow\n- review before merge\n- close ticket"), + SummaryInputOptions { + overview_target_tokens: 512, + overview_warning_tokens: 1024, + overview_deadline_tokens: 2048, + summary_target_tokens: 256, + }, + ) + .text; + + assert!(prompt.contains("## Active Workflow Invocation State")); + assert!(prompt.contains("review before merge")); + assert!(prompt.contains("close ticket")); + } + #[test] fn overview_warning_does_not_drop_input() { let items = vec![Item::user_message("x".repeat(4_000))]; @@ -5333,6 +5419,7 @@ mod build_summary_prompt_tests { &items, &[], None, + None, SummaryInputOptions { overview_target_tokens: 10, overview_warning_tokens: 100, @@ -5352,6 +5439,7 @@ mod build_summary_prompt_tests { &items, &[], None, + None, SummaryInputOptions { overview_target_tokens: 10, overview_warning_tokens: 10, diff --git a/crates/pod/src/segment_log_sink.rs b/crates/pod/src/segment_log_sink.rs index 42e6e6c4..cc4feff7 100644 --- a/crates/pod/src/segment_log_sink.rs +++ b/crates/pod/src/segment_log_sink.rs @@ -147,6 +147,24 @@ impl SegmentLogSink { let _ = self.inner.broadcast_tx.send(initial); } + /// Atomically swap the mirror to the supplied replacement-session prefix + /// and broadcast the first entry as the live rotation signal. Entries after + /// the first are already reflected in reconnect snapshots but are not + /// broadcast live; this is intended for non-live extension state that must + /// share the new segment prefix with SegmentStart. + pub fn reset_with_initial_entries(&self, entries: Vec) { + let first = entries.first().cloned(); + let mut mirror = self + .inner + .mirror + .lock() + .expect("session log mirror mutex poisoned"); + *mirror = entries; + if let Some(initial) = first { + let _ = self.inner.broadcast_tx.send(initial); + } + } + /// Replace the mirror with the supplied prefix without broadcasting. /// /// Used by restore paths that load a session's complete log into diff --git a/resources/prompts/internal/compact_system.md b/resources/prompts/internal/compact_system.md index 61a26b18..1241e54c 100644 --- a/resources/prompts/internal/compact_system.md +++ b/resources/prompts/internal/compact_system.md @@ -4,7 +4,7 @@ The conversation input is a bounded overview/index, not the full transcript. Tre ## Workflow -1. Read the provided overview/index and current TaskStore snapshot. +1. Read the provided overview/index, current TaskStore snapshot, and any Active Workflow Invocation State section. 2. If the overview does not contain enough detail, use `search_session_log` to find relevant compact-target history items, then `read_session_items` to inspect only the needed range. 3. Use `read_file` to inspect referenced files before deciding what the next session needs. Prefer skimming over blind inclusion. 4. For files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately. @@ -39,5 +39,6 @@ Produce the summary in this exact format: ## Constraints +- Preserve active workflow invocation state when present: active slug, invocation scope/source/time, status, open obligations/checkpoints, and snapshotted workflow guidance. Do not replace a snapshotted invocation with merely advertised/latest workflow resources. - Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for. - Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version. From d73f748ee8d2e25217cafe3754eb9fa8870ddbed Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:49:44 +0900 Subject: [PATCH 05/28] ticket: accept plugin package implementation --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KT0Z4BK8/item.md | 4 +- .yoi/tickets/00001KT0Z4BK8/thread.md | 78 +++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KT0Z4BK8/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KT0Z4BK8/artifacts/orchestration-plan.jsonl index e4a0551a..be37a511 100644 --- a/.yoi/tickets/00001KT0Z4BK8/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KT0Z4BK8/artifacts/orchestration-plan.jsonl @@ -1 +1,2 @@ {"id":"orch-plan-20260614-154052-1","ticket_id":"00001KT0Z4BK8","kind":"waiting_capacity_note","note":"Ticket 自体は implementation_ready で blocking relation なし。現在 `00001KTFY8V80` と `00001KV09WYC6` の Coder Pod が running で、review/integration follow-up capacity も必要なため、追加 spawn は一時待機する。","author":"yoi-orchestrator","at":"2026-06-14T15:40:52Z"} +{"id":"orch-plan-20260614-154934-2","ticket_id":"00001KT0Z4BK8","kind":"accepted_plan","accepted_plan":{"summary":"Accept queued Plugin package/discovery design Ticket now that one active Coder has moved to review stage. Implement as design proposal and minimal safe references, preserving Plugin/MCP/feature authority boundaries.","branch":"impl/00001KT0Z4BK8-plugin-package-discovery","worktree":"/home/hare/Projects/yoi/.worktree/00001KT0Z4BK8-plugin-package-discovery","role_plan":"Orchestrator creates a dedicated implementation worktree and spawns a Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. This work is documentation/design-focused and source-disjoint from active Panel/TUI implementation."},"author":"yoi-orchestrator","at":"2026-06-14T15:49:34Z"} diff --git a/.yoi/tickets/00001KT0Z4BK8/item.md b/.yoi/tickets/00001KT0Z4BK8/item.md index 7b77de25..07f89afb 100644 --- a/.yoi/tickets/00001KT0Z4BK8/item.md +++ b/.yoi/tickets/00001KT0Z4BK8/item.md @@ -1,8 +1,8 @@ --- title: 'Plugin distribution package format and discovery' -state: 'queued' +state: 'inprogress' created_at: '2026-06-01T06:49:53Z' -updated_at: '2026-06-14T15:40:52Z' +updated_at: '2026-06-14T15:49:39Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:40:15Z' --- diff --git a/.yoi/tickets/00001KT0Z4BK8/thread.md b/.yoi/tickets/00001KT0Z4BK8/thread.md index 860b509d..e961cd3c 100644 --- a/.yoi/tickets/00001KT0Z4BK8/thread.md +++ b/.yoi/tickets/00001KT0Z4BK8/thread.md @@ -161,3 +161,81 @@ Next action: - planning return ではなく queued のまま waiting とする。 --- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- 先行 Coder のうち `00001KV09WYC6` が implementation report を返し review stage に入ったため、Plugin work 用の Coder capacity を再評価した。 +- Ticket body / thread / relations / orchestration plan / Orchestrator workspace state を再確認した。blocking relation はなく、既存 waiting note は capacity 起因であり、現在は1件分の Coder capacity を空けられる。 +- 本 Ticket は Plugin package / discovery / enablement boundary の design/documentation work が主で、active Panel/TUI implementation と source surface が大きく重ならない。 +- Plugin/MCP/feature-layer authority boundary に関する prior decisions は Ticket thread に記録済みで、残る不確実性は proposal の構成・記述・必要最小限の config shape 調査に閉じている。 + +Evidence checked: +- Ticket body / thread: package format、store/source mapping、discovery vs enablement、manifest semantics、runtime-specific notes、cache/pinning、diagnostics、prior Plugin/MCP/feature-layer decisions を確認。 +- Ticket relations: blocker なし。 +- OrchestrationPlan: capacity waiting note 1件のみ。blocking/conflict record なし。 +- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`80a9e40d` 上。 +- Active Pods: `00001KTFY8V80` coder running、`00001KV09WYC6` reviewer running。 +- Bounded code/doc map: Plugin docs は未作成。関連 candidate は `docs/design/*`, `crates/manifest/src/{config,profile}.rs`, `crates/pod/src/feature.rs`, `crates/pod/src/hook.rs`。 + +IntentPacket: + +Intent: +- `.yoi-plugin` package distribution/discovery/enablement boundary の durable design proposal を repository に追加し、後続 implementation Ticket を独立して切れる状態にする。 + +Binding decisions / invariants: +- Package presence in user/workspace plugin stores is discovery only; registration, WASM init, Hooks/Tools contribution, process/server startup, and MCP server launch require explicit enablement and grants. +- Source-qualified identity is required: `user:`, `project:`, `builtin:` are distinct; ambiguous unqualified IDs fail closed. +- Plugin permission declarations are requests, not grants. Effective grants are Plugin-layer policy plus existing manifest/profile/scope/tool/web/secret/runtime allowlists. +- Do not model Plugin permissions with `pod::feature` HostAuthority/grant concepts. +- MCP remains a separate feature-backed integration and is out of initial Plugin packaging/runtime unless future Ticket explicitly approves a bridge. +- Archive handling must reject path traversal and unsafe layout, use bounded extraction, compute deterministic digest, and materialize into digest-keyed cache before runtime initialization. +- Restore should use resolved manifest/session metadata for enabled Plugin plan; fresh discovery must not silently upgrade a restored Pod. + +Requirements / acceptance criteria: +- Repository contains a documented Plugin distribution/package proposal covering `.yoi-plugin` archive structure, root `plugin.toml`, assets, user/workspace/builtin stores, source/trust mapping, identity collision rules, discovery vs enablement, manifest fields, archive safety, cache/digest/pinning, diagnostics, and runtime-specific notes for declarative hooks and WASM. +- Proposal explicitly states store placement is discovery only, not execution or registration. +- Proposal distinguishes Plugin permission request/grant model from `pod::feature` authority concepts. +- Proposal calls out MCP as separate and out of initial Plugin packaging. +- Follow-up implementation cuts are clear for manifest/profile enablement, package discovery, archive validation/cache, Plugin permission policy, WASM packaging, and any future MCP/plugin bridge. + +Implementation latitude: +- Primary deliverable may be a design doc plus minimal cross-references; code changes are optional and should stay within safe internal boundaries. +- Coder may choose exact doc path/name consistent with existing docs organization. +- If proposing config shape, prefer illustrative schemas over broad runtime implementation unless obviously small and safe. + +Escalate if: +- A real runtime implementation becomes necessary to satisfy the Ticket. +- Plugin package design would require changing Profile/manifest authority semantics, Pod restore semantics, secret handling, or MCP enablement model. +- The proposal would imply workspace package execution or silent restore upgrades. + +Validation: +- `cargo fmt --check` if code or Rust doc tests are touched. +- `git diff --check` always. +- If only Markdown docs are touched, focused validation may be `git diff --check` plus link/path sanity review. + +Current code/doc map: +- Likely doc destination: `docs/design/`. +- Related architecture candidates: `crates/manifest/src/config.rs`, `crates/manifest/src/profile.rs`, `crates/pod/src/feature.rs`, `crates/pod/src/hook.rs`. + +Critical risks / reviewer focus: +- Discovery vs enablement separation. +- Plugin permission requests vs grants. +- MCP separation. +- Source identity collision/fail-closed behavior. +- Archive safety and digest/cache semantics. +- Restore/fresh discovery no silent upgrade invariant。 + +--- + + + +## State changed + +Routing decision と accepted implementation plan を記録済み。blocking relation / orchestration-plan blocker はなく、capacity waiting reason は解消した。implementation side effects の前に `queued -> inprogress` acceptance を記録する。 + +--- From 24d0c2139f447f1f2888ec65735616fcb02d0211 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:50:45 +0900 Subject: [PATCH 06/28] ticket: record active workflow implementation --- .yoi/tickets/00001KTFY8V80/item.md | 2 +- .yoi/tickets/00001KTFY8V80/thread.md | 55 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KTFY8V80/item.md b/.yoi/tickets/00001KTFY8V80/item.md index 4daf90bb..a82f61ee 100644 --- a/.yoi/tickets/00001KTFY8V80/item.md +++ b/.yoi/tickets/00001KTFY8V80/item.md @@ -2,7 +2,7 @@ title: "Preserve active workflows across compaction" state: 'inprogress' created_at: "2026-06-07T02:23:28Z" -updated_at: '2026-06-14T15:24:58Z' +updated_at: '2026-06-14T15:50:38Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:23:07Z' --- diff --git a/.yoi/tickets/00001KTFY8V80/thread.md b/.yoi/tickets/00001KTFY8V80/thread.md index b7808870..0769a098 100644 --- a/.yoi/tickets/00001KTFY8V80/thread.md +++ b/.yoi/tickets/00001KTFY8V80/thread.md @@ -145,3 +145,58 @@ Critical risks / reviewer focus: Routing decision と accepted implementation plan を記録済み。blocking relation / orchestration-plan blocker はなく、Orchestrator workspace は clean。implementation side effects の前に `queued -> inprogress` acceptance を記録する。 --- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KTFY8V80`: + +Commit: +- `362fedfb fix: preserve active workflows across compaction` + +Changed files: +- `crates/pod/src/active_workflow.rs` +- `crates/pod/src/lib.rs` +- `crates/pod/src/pod.rs` +- `crates/pod/src/segment_log_sink.rs` +- `resources/prompts/internal/compact_system.md` + +Implemented behavior: +- Added durable typed active workflow state as session-log extension domain `pod.active_workflows`. +- State records include: + - workflow slug + - invocation source/time + - task scope + - active/completed/cancelled status + - snapshotted workflow guidance + - extracted obligations/checkpoints + - completion/cancellation reason/time +- Workflow bodies are snapshotted at invocation time rather than resolved to latest resource/builtin version during rehydration. Rationale: active workflow authority remains traceable to the original governed task and does not silently change when resource files change later. +- Compaction now: + - feeds active workflow state into compact worker input + - writes active workflow state into the replacement segment as typed extension state + - injects post-compaction workflow guidance into `SegmentStart.history` from durable state, not transient turn-local data +- Added `ActiveWorkflowList`, `ActiveWorkflowComplete`, and `ActiveWorkflowCancel` tools so active workflow state can be inspected, completed, or explicitly cancelled. +- Missing/corrupt/unsupported active workflow extension state fails closed with bounded diagnostics rather than reusing stale prior state. + +Validation reported by coder: +- Passed: `cargo fmt --check` +- Passed: `git diff --check` +- Passed: `cargo test -p pod active_workflow --lib` + - includes focused coverage for review/merge/close-style obligations crossing compaction/rehydration +- Passed: `cargo test -p pod includes_active_workflow_snapshot_section --lib` +- Ran: `cargo test -p pod --lib` + - Failed on 2 prompt text assertions reported as unrelated/pre-existing: + - `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body` + - `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools` + - both assert the unrelated string `worktree status, diff, and test results` + +Repository status: +- Child implementation worktree clean after commit. + +Residual risks / notes: +- Active workflow obligation extraction is intentionally conservative: it stores full snapshotted guidance as authority and derives bounded checkpoint labels from obligation-like lines. +- Completion/cancellation tool calls persist through normal history; compaction additionally writes updated typed snapshot into the compacted segment. + +--- From 3c674a70512ca31b5745d901959c04442c1695d0 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:52:19 +0900 Subject: [PATCH 07/28] docs: propose plugin package distribution --- docs/README.md | 7 +- docs/design/plugin-packages.md | 201 +++++++++++++++++++++++++++++++++ 2 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 docs/design/plugin-packages.md diff --git a/docs/README.md b/docs/README.md index 284fe0a4..337e3c9a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,9 +11,10 @@ It is not a dumping ground for external research, old plans, API inventories, or 3. [`design/pod-session-state.md`](design/pod-session-state.md) — Pod identity, replayable session logs, current metadata, and live process hints. 4. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources. 5. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. -6. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory, Knowledge, and audit records. -7. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. -8. [`development/validation.md`](development/validation.md) — how to check changes. +6. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries. +7. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory, Knowledge, and audit records. +8. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. +9. [`development/validation.md`](development/validation.md) — how to check changes. ## What belongs here diff --git a/docs/design/plugin-packages.md b/docs/design/plugin-packages.md new file mode 100644 index 00000000..fa5f68ca --- /dev/null +++ b/docs/design/plugin-packages.md @@ -0,0 +1,201 @@ +# Plugin packages and discovery + +Plugin packages are a distribution format, not an authority boundary. A package can be found on disk, inspected, validated, and cached without registering any Hook, exposing any Tool, starting any process, or initializing any WASM module. + +The initial goal is a durable `.yoi-plugin` package format that later Tickets can implement in independent layers: discovery, archive validation/cache materialization, manifest/profile enablement, Plugin permission policy, declarative hooks, WASM runtime support, and any future MCP bridge. + +## Package shape + +A `.yoi-plugin` file is a single-file archive. The initial archive format should be a constrained ZIP profile because it is easy to inspect without executing code and can carry text manifests, WASM modules, schemas, and license material. + +The archive root must contain `plugin.toml` directly at the root. Packages should not require a wrapping directory whose name must match the plugin id. + +Recommended root layout: + +```text +plugin.toml # required package manifest +module.wasm # optional; required when plugin.toml declares a WASM runtime +hooks/*.toml # optional declarative hook definitions +schemas/*.schema.json # optional JSON schemas for configuration or tool input/output +README.md # recommended human description +LICENSE* # recommended license text +assets/** # optional non-executable data assets +``` + +The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register `hooks/*.toml`, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution. + +## `plugin.toml` + +`plugin.toml` is the package authority for package identity and declared needs. It is not the authority for runtime grants. + +Illustrative manifest shape: + +```toml +schema_version = 1 +id = "example" +name = "Example Plugin" +version = "0.1.0" +description = "Demonstrates declarative hooks and an optional WASM module." + +[runtime] +kind = "wasm" # "declarative" or "wasm" for the initial plugin system +entry = "module.wasm" +abi = "yoi-plugin-wasm-1" + +[package] +readme = "README.md" +license = "LICENSE" + +[permissions] +tools = ["Bash"] +web = false +secrets = [] +filesystem = [] + +[[hooks]] +id = "summarize-ticket" +file = "hooks/summarize-ticket.toml" +``` + +Fields proposed for the first implementation pass: + +- `schema_version`: required integer; unsupported versions fail closed. +- `id`: required unqualified local id. It is scoped by the source that discovered the package; it is not globally unique by itself. +- `name`, `version`, `description`: human metadata used in listings and diagnostics. +- `runtime.kind`: required runtime family. Initial values should be `declarative` and `wasm`. +- `runtime.entry`: required for `wasm`, forbidden or ignored for purely declarative packages. +- `runtime.abi`: required for `wasm` so the host can reject incompatible modules before initialization. +- `hooks`, `schemas`, `package.readme`, `package.license`: package-relative paths that must pass the same normalized-path validation as archive entries. +- `permissions`: requested authority. These declarations are requests only; they do not grant access. + +The `source` is not read from `plugin.toml`. It is assigned by the store that discovered the package. + +## Stores, sources, and trust + +Discovery should scan explicit stores and attach a source kind to each package: + +- `builtin:`: packages shipped with Yoi or installed as part of the binary distribution. +- `user:`: packages discovered under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/`. +- `project:`: packages discovered under `/.yoi/plugins/`. + +Packages under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/` or `/.yoi/plugins/` are discovery only. Their presence is never permission to register Hooks or Tools, initialize WASM, start processes, open files, use network providers, read secrets, or launch MCP servers. + +Trust differs by source, but none of the sources is self-authorizing: + +- Builtin packages can be trusted as shipped code/data, but still require explicit enablement for a Pod/Profile when they affect runtime behavior. +- User packages are local user-installed artifacts and should be visible to workspaces, but they cannot bypass manifest/profile/tool/scope/secret policy. +- Project packages are repository-controlled artifacts and should be treated as untrusted until explicitly enabled by local policy. Cloning a repository must not be enough to execute a package. + +## Identity and selector rules + +Runtime identity is source-qualified: `builtin:`, `user:`, and `project:` are distinct plugins even when `` is the same string. + +Durable enablement records should use source-qualified ids. Ambiguous unqualified ids fail closed. The implementation may offer convenience listing or search by bare id, but any operation that enables a package, grants permission, pins a digest, or records restored runtime state should require the fully qualified id. + +Collision handling: + +- Two packages with the same source-qualified id in the same effective store set are a discovery diagnostic and neither candidate is enabled implicitly. +- A `user:example` package does not override `builtin:example` unless a future explicit override rule says so. +- A `project:example` package does not override `user:example` or `builtin:example` by name alone. + +## Discovery versus enablement + +Discovery is a read-only inventory operation. It may report package metadata, validation errors, source, canonical store path, and deterministic digest. It must not initialize any runtime contribution. + +Enablement is a resolved runtime plan. It should come from Profile/manifest configuration or another explicit local policy layer, then be recorded into the resolved Manifest/session metadata used to start the Pod. Restored Pods should use that resolved enabled-plugin plan instead of silently re-running fresh discovery and picking newer packages. Fresh discovery must not silently upgrade a restored Pod. + +A future enablement record can be shaped like this, but the exact schema belongs to the implementation Ticket: + +```toml +[[plugins.enabled]] +id = "user:example" +digest = "sha256:..." # optional pin in authoring, resolved in runtime metadata +config = { level = "concise" } +``` + +If no digest is pinned in authoring, fresh startup may resolve the newest acceptable discovered package according to explicit policy. Once a Pod is started, the resolved manifest/session metadata should record the exact source-qualified id and digest so restore is stable. + +## Permissions and grants + +Plugin permission declarations are requests, not grants. Effective grants are the result of Plugin-layer policy combined with existing Yoi authority layers: + +- resolved manifest/profile plugin enablement; +- Plugin policy for the source-qualified package id and deterministic digest; +- normal tool permission policy; +- filesystem scope checks; +- web provider enablement and network safety checks; +- secret references and secret-store policy; +- runtime limits for WASM or other execution engines. + +The Plugin package permission model must not reuse `pod::feature` HostAuthority or grant concepts. The feature layer is an API/contribution substrate; it is not a security boundary for untrusted plugin packages. Plugin grants need their own explicit policy that can fail closed before a Hook, Tool, WASM host function, provider bridge, or external runtime is exposed. + +When a package requests authority outside policy, diagnostics should explain the denied category and package identity without leaking raw secret values, environment contents, full private config, or large plugin-provided text. + +## Archive safety and materialization + +Archive handling should validate before runtime use: + +- Reject absolute paths, `..`, empty segments, Windows drive prefixes, NUL bytes, duplicate normalized paths, and paths that normalize outside the package root. +- Reject symlinks, hardlinks, device files, special files, and entries that are not regular files or directories. +- Enforce bounded extraction: maximum archive size, maximum expanded size, maximum entry count, maximum per-file size, and a compression-ratio limit. +- Validate every manifest-referenced path against the normalized entry set. +- Decode text manifests as UTF-8 and bound diagnostic excerpts. +- Ignore or normalize archive metadata such as mtimes, owners, groups, and executable bits; these should not affect runtime authority. + +After validation, compute a deterministic digest over the normalized materialized package, not over incidental ZIP ordering or timestamps. A stable digest input should include the format version, normalized relative path, file length, and file content hash for each regular file in sorted order. + +Runtime should materialize packages into a digest-keyed cache, for example: + +```text +/plugins/sha256-/ + plugin.toml + module.wasm + ... +``` + +Initialization should read from the digest-keyed cache, not directly from the mutable user/workspace store. This makes restore, diagnostics, and lock/pin behavior reproducible. + +Optional lock behavior can be added in a later Ticket: + +- an authoring-time pin in Profile/manifest configuration; +- a workspace lock file recording source-qualified id, version, source store, digest, and selected package path; +- restore metadata that records the actual digest used by the Pod. + +A lock or pin is selection authority, not execution authority. Enablement and grants are still required. + +## Diagnostics + +Diagnostics should be safe, bounded, and attributable: + +- Include source-qualified id when available, source kind, validation phase, and digest when computed. +- Prefer canonical store-relative paths or redacted absolute paths; avoid dumping large path lists. +- Never print raw secret values, provider tokens, environment dumps, or plugin-supplied opaque payloads. +- Treat package metadata and README text as untrusted content when showing it to an LLM or UI. +- Report discovery errors without disabling unrelated valid packages. + +## Runtime notes + +Declarative hooks are data contributions. Loading a declarative hook still requires explicit package enablement. Hook text should enter the system through the normal Hook/Worker paths, preserving the rule that model-affecting inputs are committed to history before they affect context when applicable. + +WASM packages should initialize only from the digest-keyed cache after enablement and grant resolution. The host should use a narrow ABI, bounded memory, fuel/time limits, bounded output, and explicit host functions. A WASM module must not inherit filesystem, network, tool, secret, process, or MCP authority from the package store path. + +Tool contributions from plugins should pass through the normal ToolRegistry and permission checks. Plugin-provided schemas can describe arguments, but schema presence is not permission to execute a tool. + +## MCP boundary + +MCP remains a separate feature-backed integration and is out of the initial Plugin package runtime. A `.yoi-plugin` package must not launch an MCP server or imply MCP enablement. + +A future MCP/plugin bridge would need its own Ticket covering external process authority, lifecycle, permission mapping, resource/prompt operations, diagnostics, and trust model. Until then, package metadata may mention compatibility for humans, but runtime packaging should ignore it. + +## Follow-up implementation cuts + +Good follow-up Tickets are intentionally separable: + +1. Manifest/Profile plugin enablement schema and resolved-session metadata, including restore behavior and digest pins. +2. Package discovery for builtin, user, and project stores with source-qualified identity and collision diagnostics. +3. `.yoi-plugin` archive validation, deterministic digest computation, and digest-keyed cache materialization. +4. Plugin-layer permission policy that combines package requests with existing tool/scope/web/secret/runtime allowlists without using `pod::feature` HostAuthority concepts. +5. Declarative hook package loading from enabled, materialized packages. +6. WASM package ABI, initialization limits, host-function grants, and Tool/Hook contribution plumbing. +7. Optional lock-file or pin update workflow for reproducible fresh startup. +8. Future MCP/plugin bridge, only if explicitly approved as a separate design and implementation effort. From 01e643719d6f14ad5c262e4a2403cc751e0e3511 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:53:19 +0900 Subject: [PATCH 08/28] ticket: record plugin package implementation --- .yoi/tickets/00001KT0Z4BK8/item.md | 2 +- .yoi/tickets/00001KT0Z4BK8/thread.md | 48 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KT0Z4BK8/item.md b/.yoi/tickets/00001KT0Z4BK8/item.md index 07f89afb..894f4369 100644 --- a/.yoi/tickets/00001KT0Z4BK8/item.md +++ b/.yoi/tickets/00001KT0Z4BK8/item.md @@ -2,7 +2,7 @@ title: 'Plugin distribution package format and discovery' state: 'inprogress' created_at: '2026-06-01T06:49:53Z' -updated_at: '2026-06-14T15:49:39Z' +updated_at: '2026-06-14T15:53:15Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:40:15Z' --- diff --git a/.yoi/tickets/00001KT0Z4BK8/thread.md b/.yoi/tickets/00001KT0Z4BK8/thread.md index e961cd3c..16fda617 100644 --- a/.yoi/tickets/00001KT0Z4BK8/thread.md +++ b/.yoi/tickets/00001KT0Z4BK8/thread.md @@ -239,3 +239,51 @@ Critical risks / reviewer focus: Routing decision と accepted implementation plan を記録済み。blocking relation / orchestration-plan blocker はなく、capacity waiting reason は解消した。implementation side effects の前に `queued -> inprogress` acceptance を記録する。 --- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KT0Z4BK8`: + +Commit: +- `3c674a70 docs: propose plugin package distribution` + +Changed files: +- `docs/design/plugin-packages.md` + - Added the `.yoi-plugin` package/discovery/enablement design proposal. +- `docs/README.md` + - Added the new design doc to the maintained documentation reading order. + +Design choices covered: +- `.yoi-plugin` as a constrained single-file archive with required root `plugin.toml`. +- Suggested package layout for `module.wasm`, declarative hooks, schemas, README/license, and assets. +- User/workspace/builtin plugin stores and source-qualified identity: `user:`, `project:`, `builtin:`. +- Explicit discovery-vs-enablement separation. +- Store presence under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/` or `/.yoi/plugins/` is discovery only, never registration/execution. +- Plugin permission declarations are requests, not grants. +- Plugin permission policy is explicitly separate from `pod::feature` HostAuthority/grant concepts. +- Archive validation, traversal rejection, bounded extraction, deterministic digest, digest-keyed cache materialization, and optional pin/lock behavior. +- Restore uses resolved enabled-plugin plan/digest metadata; fresh discovery must not silently upgrade a restored Pod. +- Diagnostics guidance without secret leakage. +- Runtime notes for declarative hooks and WASM. +- MCP is explicitly out of initial Plugin packaging/runtime and requires a future separate bridge Ticket. +- Follow-up implementation cuts are listed for enablement schema, discovery, validation/cache, permission policy, declarative hooks, WASM, locks, and future MCP bridge. + +Validation reported by coder: +- Passed: `git diff --check`. +- Link/path sanity review: + - Verified `docs/README.md` references existing `docs/design/plugin-packages.md`. + - Verified touched Markdown files have trailing newlines and no tab characters. +- Post-commit check: + - `git status --short --branch` showed clean branch `impl/00001KT0Z4BK8-plugin-package-discovery`. + - `git diff --check HEAD~1 HEAD` passed with no whitespace errors. +- No Rust/code changes were made, so `cargo fmt` / `cargo test` were not run. + +Repository status: +- Child implementation worktree clean after commit. + +Residual risks / blockers: +- This is intentionally a design proposal only. Exact manifest/Profile schema, lock-file format, archive limits, cache path, and WASM ABI details remain for follow-up implementation Tickets. + +--- From 48b0d34938cc71904fd8c24f432853bb4a4790df Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:54:40 +0900 Subject: [PATCH 09/28] ticket: record panel intake pod review --- .yoi/tickets/00001KV09WYC6/item.md | 2 +- .yoi/tickets/00001KV09WYC6/thread.md | 41 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV09WYC6/item.md b/.yoi/tickets/00001KV09WYC6/item.md index 7f94f1aa..c4303935 100644 --- a/.yoi/tickets/00001KV09WYC6/item.md +++ b/.yoi/tickets/00001KV09WYC6/item.md @@ -2,7 +2,7 @@ title: 'Workspace panel: show Ticket-associated Intake Pods adjacent to Ticket rows' state: 'inprogress' created_at: '2026-06-13T10:54:31Z' -updated_at: '2026-06-14T15:48:12Z' +updated_at: '2026-06-14T15:54:30Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel-ux', 'local-role-session-registry', 'pod-session-state'] diff --git a/.yoi/tickets/00001KV09WYC6/thread.md b/.yoi/tickets/00001KV09WYC6/thread.md index 9b765b5e..eef654ef 100644 --- a/.yoi/tickets/00001KV09WYC6/thread.md +++ b/.yoi/tickets/00001KV09WYC6/thread.md @@ -166,3 +166,44 @@ Residual risks / blockers: - Validation caveat: unrelated `multi_pod` filtered-suite failure noted above remains for reviewer/orchestrator assessment. --- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Worktree clean and HEAD at implementation commit `2664cdd9`. +- Ticket intent/body/thread reviewed from the child worktree. +- Diff `73d0a6a4..2664cdd9` reviewed. +- Changed files limited to: + - `crates/tui/src/workspace_panel.rs` + - `crates/tui/src/multi_pod.rs` +- Read-only whitespace validation: + - `git diff --check 73d0a6a4..2664cdd9` passed. + +Acceptance / invariant review: +- Ticket-associated Intake Pods are modeled as explicit `PanelRowKind::TicketIntakePod` rows with `PanelRowKey::TicketIntakePod { ticket_id, pod_name }`. +- Rows are inserted immediately after the owning Ticket row, satisfying adjacent/clearly-related display. +- Association comes from local role/session registry evidence: active local claim and Intake sessions with explicit `related_tickets`. +- Pod-name-contains-ticket-id heuristic was removed, reducing false association risk and preserving the pre-Ticket Intake invariant. +- Pre-Ticket Intake Pods without related Ticket evidence are not attached to Ticket rows; focused test covers this. +- Multiple Intake entries per Ticket are supported and bounded by `MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET`; no 1:1 assumption. +- one-active-claim-per-Ticket remains enforced by existing registry claim path; this change only reads/displays claims/sessions. +- No git-tracked Ticket metadata/frontmatter/thread storage was added for Pod assignment/status. +- No automatic polling or automatic Intake spawn was added. +- Arbitrary selected Pod direct-send UX was not revived; associated Intake row routes through existing open/attach behavior. +- live/restorable/stale status is surfaced via `TicketLocalClaimStatus::label()`. +- Focused coverage exists for adjacent Intake rows and multi-pod selection/open behavior. + +Validation not rerun: +- Cargo/fmt validation was not rerun because the review scope was read-only and those commands may write build artifacts. + +Reported `multi_pod` failure assessment: +- `tests::orchestrator_launch_uses_runtime_workspace_not_process_cwd` appears unrelated. The implementation changes Workspace Panel row modeling/rendering/selection/open routing only and does not modify orchestrator launch workspace/cwd construction. + +Conclusion: +- Approved. No blocker remains for integration. + +--- From b77ab0f4241140f01027b0f6b783f3b82f14f83a Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:55:41 +0900 Subject: [PATCH 10/28] ticket: complete panel intake pod rows --- .yoi/tickets/00001KV09WYC6/item.md | 4 +-- .yoi/tickets/00001KV09WYC6/thread.md | 38 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KV09WYC6/item.md b/.yoi/tickets/00001KV09WYC6/item.md index c4303935..5657f4bc 100644 --- a/.yoi/tickets/00001KV09WYC6/item.md +++ b/.yoi/tickets/00001KV09WYC6/item.md @@ -1,8 +1,8 @@ --- title: 'Workspace panel: show Ticket-associated Intake Pods adjacent to Ticket rows' -state: 'inprogress' +state: 'done' created_at: '2026-06-13T10:54:31Z' -updated_at: '2026-06-14T15:54:30Z' +updated_at: '2026-06-14T15:55:36Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel-ux', 'local-role-session-registry', 'pod-session-state'] diff --git a/.yoi/tickets/00001KV09WYC6/thread.md b/.yoi/tickets/00001KV09WYC6/thread.md index eef654ef..23acd578 100644 --- a/.yoi/tickets/00001KV09WYC6/thread.md +++ b/.yoi/tickets/00001KV09WYC6/thread.md @@ -207,3 +207,41 @@ Conclusion: - Approved. No blocker remains for integration. --- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KV09WYC6-panel-intake-pod-rows` +- implementation commit: `2664cdd9 feat: show ticket intake pods in panel` +- merge commit: `2fcbd6ae merge: panel intake pod rows` + +Reviewer result: +- `approve` recorded from `yoi-reviewer-00001KV09WYC6`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo test -p tui workspace_panel --lib` +- Passed: `cargo test -p tui role_session_registry --lib` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Notes: +- The coder-reported `cargo test -p tui multi_pod --lib` failure was reviewed and assessed unrelated to this Ticket. It was not used as a blocker. +- Orchestrator worktree is clean after validation. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved, implementation branch merged into the orchestration branch, focused validation passed in the Orchestrator worktree, and cleanup is ready. Marking Ticket done in the orchestration branch. + +--- From 8bcf833e2e1fbbe2f90fad7d598905efac46a2b7 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:56:29 +0900 Subject: [PATCH 11/28] ticket: record plugin package review --- .yoi/tickets/00001KT0Z4BK8/item.md | 2 +- .yoi/tickets/00001KT0Z4BK8/thread.md | 49 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KT0Z4BK8/item.md b/.yoi/tickets/00001KT0Z4BK8/item.md index 894f4369..52874b96 100644 --- a/.yoi/tickets/00001KT0Z4BK8/item.md +++ b/.yoi/tickets/00001KT0Z4BK8/item.md @@ -2,7 +2,7 @@ title: 'Plugin distribution package format and discovery' state: 'inprogress' created_at: '2026-06-01T06:49:53Z' -updated_at: '2026-06-14T15:53:15Z' +updated_at: '2026-06-14T15:56:22Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:40:15Z' --- diff --git a/.yoi/tickets/00001KT0Z4BK8/thread.md b/.yoi/tickets/00001KT0Z4BK8/thread.md index 16fda617..e3d97d9d 100644 --- a/.yoi/tickets/00001KT0Z4BK8/thread.md +++ b/.yoi/tickets/00001KT0Z4BK8/thread.md @@ -287,3 +287,52 @@ Residual risks / blockers: - This is intentionally a design proposal only. Exact manifest/Profile schema, lock-file format, archive limits, cache path, and WASM ABI details remain for follow-up implementation Tickets. --- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Child worktree/branch: + - `/home/hare/Projects/yoi/.worktree/00001KT0Z4BK8-plugin-package-discovery` + - `impl/00001KT0Z4BK8-plugin-package-discovery` +- HEAD: `3c674a70512ca31b5745d901959c04442c1695d0` +- Base merge point: `d73f748ee8d2e25217cafe3754eb9fa8870ddbed` +- Diff `d73f748e..HEAD` inspected: + - added `docs/design/plugin-packages.md` + - updated `docs/README.md` +- Ticket intent/acceptance context reviewed from the child worktree Ticket record. + +Acceptance criteria review: +- `.yoi-plugin` archive structure and required root `plugin.toml` are documented. +- Packaged assets/layout are covered, including optional WASM module, hooks, schemas, README/license, and `assets/**`. +- Stores and source/trust mapping are covered for `builtin:`, `user:`, and `project:`. +- Package presence in user/workspace stores is clearly discovery only, not execution/registration. +- Source-qualified identity, ambiguous-id fail-closed behavior, and collision handling are covered. +- Discovery vs enablement and restore/no-silent-upgrade behavior are explicit. +- Manifest/Profile enablement shape is illustrative and appropriately deferred. +- Plugin permission declarations are requests, not grants; effective grants are tied to Plugin-layer policy plus existing manifest/profile/tool/scope/web/secret/runtime authority layers. +- The document avoids using `pod::feature` HostAuthority/grant concepts as Plugin permission/security model. +- Archive safety covers traversal rejection, unsafe file types, bounded extraction, deterministic digest, digest-keyed cache, and manifest path validation. +- Diagnostics guidance covers attribution, bounded output, and no secret leakage. +- Runtime notes cover declarative hooks, WASM initialization from digest cache, host limits, and ToolRegistry/permission checks. +- MCP is explicitly separate and out of the initial Plugin package runtime. +- Follow-up implementation cuts are clear and separable. +- `docs/README.md` cross-reference is appropriate and remains Why/design-oriented. + +Validation performed: +- Passed: `git diff --check d73f748e..HEAD` +- Passed: `git diff --check HEAD~1 HEAD` +- README-listed relative doc target existence checked with shell commands. +- Manual Markdown/design boundary review completed. + +Validation not run: +- No cargo commands because the change is documentation-only. +- A Python-based link check could not run because `python3` is unavailable; shell existence checks were used instead. + +Conclusion: +- Approved. No blocking concern remains. + +--- From 81667a9acaf33f2e8341c899e097198bb0b3adce Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:56:49 +0900 Subject: [PATCH 12/28] ticket: complete plugin package design --- .yoi/tickets/00001KT0Z4BK8/item.md | 4 +-- .yoi/tickets/00001KT0Z4BK8/thread.md | 40 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KT0Z4BK8/item.md b/.yoi/tickets/00001KT0Z4BK8/item.md index 52874b96..c0dbd15e 100644 --- a/.yoi/tickets/00001KT0Z4BK8/item.md +++ b/.yoi/tickets/00001KT0Z4BK8/item.md @@ -1,8 +1,8 @@ --- title: 'Plugin distribution package format and discovery' -state: 'inprogress' +state: 'done' created_at: '2026-06-01T06:49:53Z' -updated_at: '2026-06-14T15:56:22Z' +updated_at: '2026-06-14T15:56:45Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:40:15Z' --- diff --git a/.yoi/tickets/00001KT0Z4BK8/thread.md b/.yoi/tickets/00001KT0Z4BK8/thread.md index e3d97d9d..a5f021e2 100644 --- a/.yoi/tickets/00001KT0Z4BK8/thread.md +++ b/.yoi/tickets/00001KT0Z4BK8/thread.md @@ -336,3 +336,43 @@ Conclusion: - Approved. No blocking concern remains. --- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KT0Z4BK8-plugin-package-discovery` +- implementation commit: `3c674a70 docs: propose plugin package distribution` +- merge commit: `2b9dae48 merge: plugin package design` + +Reviewer result: +- `approve` recorded from `yoi-reviewer-00001KT0Z4BK8`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `git diff --check` +- Passed: `test -f docs/design/plugin-packages.md` +- Passed: `grep -n 'plugin-packages.md' docs/README.md` + +Cargo validation: +- Not run because the merged change is documentation-only. + +Notes: +- The proposal is intentionally design-only. Exact manifest/Profile schema, lock-file format, archive limits, cache path, and WASM ABI remain follow-up implementation work. +- Orchestrator worktree is clean after validation. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved, documentation/design implementation branch merged into the orchestration branch, and documentation-focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + +--- From a1c8264bebf523afd7f302420821d03494385a56 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:57:54 +0900 Subject: [PATCH 13/28] ticket: accept invalid ticket panel work --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KV3A5CNH/item.md | 4 +- .yoi/tickets/00001KV3A5CNH/thread.md | 77 +++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KV3A5CNH/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KV3A5CNH/artifacts/orchestration-plan.jsonl index d8597276..df983f10 100644 --- a/.yoi/tickets/00001KV3A5CNH/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KV3A5CNH/artifacts/orchestration-plan.jsonl @@ -1,2 +1,3 @@ {"id":"orch-plan-20260614-153704-1","ticket_id":"00001KV3A5CNH","kind":"conflicts_with","related_ticket":"00001KV09WYC6","note":"同じ Workspace Panel Ticket row/model/action/diagnostic surface を変更する可能性が高いため、`00001KV09WYC6` の実装・review・integration 後に再 routing する。","author":"yoi-orchestrator","at":"2026-06-14T15:37:04Z"} {"id":"orch-plan-20260614-153704-2","ticket_id":"00001KV3A5CNH","kind":"waiting_capacity_note","note":"現在 `00001KTFY8V80` と `00001KV09WYC6` の2件が inprogress で Coder Pod running。`00001KV09WYC6` と source surface が重なるため、追加 spawn せず queued のまま待機。","author":"yoi-orchestrator","at":"2026-06-14T15:37:04Z"} +{"id":"orch-plan-20260614-155739-3","ticket_id":"00001KV3A5CNH","kind":"accepted_plan","accepted_plan":{"summary":"Accept invalid-Ticket partial failure Panel bugfix now that prior Panel Intake row work is integrated. Implement partial failure handling and focused tests without changing Ticket lifecycle authority.","branch":"impl/00001KV3A5CNH-panel-invalid-ticket-tolerance","worktree":"/home/hare/Projects/yoi/.worktree/00001KV3A5CNH-panel-invalid-ticket-tolerance","role_plan":"Orchestrator creates a dedicated implementation worktree and spawns a Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Prior conflict with `00001KV09WYC6` is resolved because that Ticket is merged/validated/done in orchestration branch."},"author":"yoi-orchestrator","at":"2026-06-14T15:57:39Z"} diff --git a/.yoi/tickets/00001KV3A5CNH/item.md b/.yoi/tickets/00001KV3A5CNH/item.md index 5ec48c07..36a16d17 100644 --- a/.yoi/tickets/00001KV3A5CNH/item.md +++ b/.yoi/tickets/00001KV3A5CNH/item.md @@ -1,8 +1,8 @@ --- title: 'Panel: invalid Ticket があっても Ticket 機能全体を無効化しない' -state: 'queued' +state: 'inprogress' created_at: '2026-06-14T14:56:51Z' -updated_at: '2026-06-14T15:37:04Z' +updated_at: '2026-06-14T15:57:47Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics'] diff --git a/.yoi/tickets/00001KV3A5CNH/thread.md b/.yoi/tickets/00001KV3A5CNH/thread.md index c4a0b02b..45ad4ec1 100644 --- a/.yoi/tickets/00001KV3A5CNH/thread.md +++ b/.yoi/tickets/00001KV3A5CNH/thread.md @@ -39,3 +39,80 @@ Next action: - planning return ではなく queued のまま waiting とする。 --- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- 以前の waiting reason は `00001KV09WYC6` と同じ Panel row/action surface の conflict risk だったが、`00001KV09WYC6` は reviewer approve、orchestration branch への merge、focused validation、Ticket `done` まで完了した。 +- Ticket body / thread / relations / orchestration plan / current Orchestrator workspace を再確認した。blocking relation はなく、planning に戻す concrete missing information はない。 +- 本 Ticket は invalid/corrupt/unreadable individual Ticket record に対する Panel partial failure handling として concrete であり、残る不確実性は backend/list/show error handling と Panel row/diagnostic 表現の実装戦術に閉じている。 + +Evidence checked: +- Ticket body/thread: Background, requirements, acceptance criteria, invariants, implementation latitude, escalation conditions, validation を確認。 +- Ticket relations: blocker なし。 +- OrchestrationPlan: `00001KV09WYC6` との prior conflict/waiting note を確認。先行 Ticket 完了により blocker は解消。 +- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`81667a9a` 上。 +- Active Pods: `00001KTFY8V80` reviewer running、coder idle。Panel implementation worker/reviewer for `00001KV09WYC6` は停止済み。 +- Current code map after prior Panel merge: `crates/tui/src/workspace_panel.rs`, `crates/tui/src/multi_pod.rs`, `crates/ticket/src/lib.rs`。 + +IntentPacket: + +Intent: +- Workspace Panel で個別 invalid/corrupt/unreadable Ticket record があっても、正常な Ticket rows と actions を表示・維持し、invalid record は bounded diagnostic/placeholder として見せる。 + +Binding decisions / invariants: +- invalid Ticket を理由に正常 Ticket の Panel 操作を巻き添えで止めない。 +- invalid Ticket record を Panel が自動修復・自動削除しない。 +- invalid Ticket には Queue / Close / planning return など lifecycle mutation action を出さない。 +- Ticket lifecycle authority / state schema は変更しない。 +- Ticket backend config 全体が unusable な場合と、個別 record の partial failure を区別する。 +- 正常 Ticket の lifecycle mutation は既存 typed Ticket backend / Panel action path を通す。 +- invalid record の content や secret-like content を UI/diagnostic に漏らさない。 + +Requirements / acceptance criteria: +- valid + invalid Ticket が混在しても valid rows は残る。 +- 正常 ready Ticket の Queue action、正常 planning Ticket の clarification/Intake 導線を維持する。 +- invalid Ticket は bounded diagnostic または disabled placeholder row として見える。 +- invalid Ticket に lifecycle mutation action を提示しない。 +- Panel header/diagnostics は全体 unavailable ではなく一部読み込み失敗を表す。 +- backend root/config unusable の既存 degraded behavior は壊さない。 +- Focused tests で partial failure、bounded invalid indication、valid action preservation、config unusable case を確認する。 + +Implementation latitude: +- 表示形式は header diagnostic / placeholder row / detail route のどれでもよい。 +- backend `list` を lossy にするか、Panel 側 per-Ticket load recovery にするかは実装判断。ただし typed boundary を保ち、Panel 専用 ad hoc parsing で schema authority を迂回しない。 +- `TicketDoctor` logic を再利用してよいが、Panel 起動ごとに重い full doctor を必須にしない。 + +Escalate if: +- `TicketBackend::list` public semantics の大幅変更が必要。 +- invalid path/id を安全に特定できない。 +- Panel action dispatch が valid Ticket と invalid placeholder を安全に分けられない。 +- TicketDoctor と Panel diagnostics の severity/wording が矛盾する。 +- invalid content を読まないと UI 表示できない設計になる。 + +Validation: +- `cargo test -p tui workspace_panel --lib` +- 必要に応じて `cargo test -p ticket` +- `cargo fmt --check` +- `git diff --check` + +Critical risks / reviewer focus: +- partial failure が全体 Ticket UI unavailable に戻らないこと。 +- invalid placeholder/action key が lifecycle mutation path に入らないこと。 +- Ticket backend config failure との区別。 +- diagnostics の boundedness と secret-like content 非露出。 +- prior `00001KV09WYC6` の Ticket-associated Intake row behavior との整合。 + +--- + + + +## State changed + +Routing decision と accepted implementation plan を記録済み。先行 `00001KV09WYC6` は merge/validation/done 済みで、prior conflict/waiting reason は解消。blocking relation / unresolved orchestration-plan blocker はないため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。 + +--- From 61473f64962170316b483a12d4878a1996019c68 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 00:58:53 +0900 Subject: [PATCH 14/28] ticket: request active workflow changes --- .yoi/tickets/00001KTFY8V80/item.md | 2 +- .yoi/tickets/00001KTFY8V80/thread.md | 59 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KTFY8V80/item.md b/.yoi/tickets/00001KTFY8V80/item.md index a82f61ee..654cb081 100644 --- a/.yoi/tickets/00001KTFY8V80/item.md +++ b/.yoi/tickets/00001KTFY8V80/item.md @@ -2,7 +2,7 @@ title: "Preserve active workflows across compaction" state: 'inprogress' created_at: "2026-06-07T02:23:28Z" -updated_at: '2026-06-14T15:50:38Z' +updated_at: '2026-06-14T15:58:49Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:23:07Z' --- diff --git a/.yoi/tickets/00001KTFY8V80/thread.md b/.yoi/tickets/00001KTFY8V80/thread.md index 0769a098..cbf2a0c8 100644 --- a/.yoi/tickets/00001KTFY8V80/thread.md +++ b/.yoi/tickets/00001KTFY8V80/thread.md @@ -200,3 +200,62 @@ Residual risks / notes: - Completion/cancellation tool calls persist through normal history; compaction additionally writes updated typed snapshot into the compacted segment. --- + + + +## Review: request changes + +Review result: request_changes + +Evidence checked: +- Child worktree/branch/head: + - `/home/hare/Projects/yoi/.worktree/00001KTFY8V80-active-workflows-compaction` + - `impl/00001KTFY8V80-active-workflows-compaction` + - HEAD `362fedfbe6689886f1e2e7c29da61e39b0ce1e38` + - merge base with requested base: `73d0a6a4` +- `git status --short` was clean. +- Diff `73d0a6a4..362fedfb` inspected. +- Read-only validation: + - Passed: `git diff --check 73d0a6a4..362fedfb` +- Cargo/fmt not rerun because review scope was read-only. + +What looks good: +- A typed active workflow snapshot was added with slug, status, invocation source/time, task scope, snapshot policy, snapshotted guidance, obligations/checkpoints, and completion metadata. +- Active workflow state is separated from advertised workflows; activation comes from invoked `SystemItem::Workflow` rather than resident workflow catalog. +- Snapshot-vs-latest behavior is explicit via `WorkflowBodySnapshotPolicy::SnapshottedAtInvocation`. +- Compaction passes active workflow state into compactor input and writes typed `LogEntry::Extension` into the compacted segment. +- Clear/cancel tools are exposed as `ActiveWorkflowComplete` / `ActiveWorkflowCancel`. + +Required changes: + +1. Stale active workflow guidance can remain in prompt history after typed state is invalid, completed, or cancelled. + +- The implementation writes active workflow rehydration guidance as an ordinary system message in compacted history (`pod.rs` around the compaction replacement history construction). +- Restore later uses `SegmentStart.history` as worker history. +- Corrupt/obsolete extension handling drops/diagnoses the typed state but does not remove the old `[Active workflow snapshot]` system message from compacted history. +- Therefore the model can still see stale workflow obligations even when the durable active-workflow extension is missing/corrupt/obsolete. +- The same leakage risk applies after completion/cancellation: old compacted system messages can remain until another compaction. + +Required fix: +- Ensure active workflow guidance shown to the model is gated by currently valid active workflow state, not immutable old compacted history. +- For example, regenerate guidance from validated typed state at context/compaction time, or sanitize/supersede old active workflow system messages when typed state is invalid/inactive. +- Add focused coverage for compacted history containing an active workflow message plus missing/corrupt/obsolete extension, and for completion/cancellation after compaction. + +2. Completion/cancellation durability is inferred from generic tool-call history and trusts bare `ToolCall`s. + +- `ActiveWorkflowComplete` / `ActiveWorkflowCancel` mutate only the in-memory store in the tool implementation. +- They do not append a new typed `LogEntry::Extension` on successful status change. +- Restore reconstructs completion/cancellation by scanning history. +- Replay marks workflows completed/cancelled based solely on `Item::ToolCall` names, without requiring a matching successful `ToolResult`. + +Required fix: +- Persist status changes as typed durable active workflow state when the status tool succeeds, or make replay validate a matching non-error tool result/event proving the tool executed successfully. +- Add focused tests for interrupted/unmatched/error status tool calls. + +Prompt-test failure assessment: +- The reported `cargo test -p pod --lib` failures about `worktree status, diff, and test results` appear unrelated. The diff changes `resources/prompts/internal/compact_system.md`, while the failing assertions are in pod orchestration prompt tests and reference a different prompt resource. + +Conclusion: +- Changes requested. Do not integrate until stale-guidance gating and completion/cancellation durability are fixed and covered. + +--- From ff446052c7a72f669a65832bb865387c11ad1c01 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:16:54 +0900 Subject: [PATCH 15/28] fix: gate active workflow rehydration state --- crates/pod/src/active_workflow.rs | 262 ++++++++++++++++++++++++------ crates/pod/src/ipc/interceptor.rs | 37 ++++- crates/pod/src/pod.rs | 64 ++++---- 3 files changed, 274 insertions(+), 89 deletions(-) diff --git a/crates/pod/src/active_workflow.rs b/crates/pod/src/active_workflow.rs index 07cda103..b682463c 100644 --- a/crates/pod/src/active_workflow.rs +++ b/crates/pod/src/active_workflow.rs @@ -17,8 +17,12 @@ use serde_json::json; use session_store::{LogEntry, SystemItem, segment_log}; pub const DOMAIN: &str = "pod.active_workflows"; +pub const REHYDRATION_MESSAGE_PREFIX: &str = "[Active workflow snapshot]"; +pub const INACTIVE_MESSAGE_PREFIX: &str = "[Active workflow state]"; const SCHEMA_VERSION: u32 = 1; +pub type LogEntryCommitter = Arc; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActiveWorkflowSnapshot { pub schema_version: u32, @@ -221,6 +225,16 @@ impl ActiveWorkflowStore { (!active.is_empty()).then(|| render_rehydration_message(&active)) } + pub fn sanitize_context(&self, context: &mut Vec) -> usize { + let removed = strip_rehydration_messages(context); + if let Some(message) = self.rehydration_message() { + context.push(Item::system_message(message)); + } else if removed > 0 || context.iter().any(has_active_workflow_hint) { + context.push(Item::system_message(inactive_workflow_message())); + } + removed + } + pub fn extension_entry(&self) -> LogEntry { LogEntry::Extension { ts: segment_log::now_millis(), @@ -232,14 +246,13 @@ impl ActiveWorkflowStore { pub fn restore_from_history_and_extensions( &self, - history: &[Item], + _history: &[Item], extensions: &[(String, serde_json::Value)], ) { - let (mut snapshot, diagnostics) = fold_extensions(extensions); + let (snapshot, diagnostics) = fold_extensions(extensions); for diagnostic in diagnostics { tracing::warn!(diagnostic, "failed to restore active workflow state"); } - replay_history_tools(&mut snapshot, history); self.replace_with(snapshot); } } @@ -271,49 +284,61 @@ pub fn fold_extensions( (latest.unwrap_or_default(), diagnostics) } -fn replay_history_tools(snapshot: &mut ActiveWorkflowSnapshot, history: &[Item]) { - for item in history { - let Item::ToolCall { - name, arguments, .. - } = item - else { - continue; - }; - let status = match name.as_str() { - "ActiveWorkflowComplete" => ActiveWorkflowStatus::Completed, - "ActiveWorkflowCancel" => ActiveWorkflowStatus::Cancelled, - _ => continue, - }; - if let Ok(params) = serde_json::from_str::(arguments) { - if let Some(record) = snapshot - .workflows - .iter_mut() - .find(|record| record.slug == params.slug) - { - let reason = params.reason.unwrap_or_else(|| status.to_string()); - record.status = status; - record.updated_at_ms = record.updated_at_ms.saturating_add(1); - record.completion = Some(WorkflowCompletionInfo { - completed_at_ms: record.updated_at_ms, - reason, - }); - for checkpoint in &mut record.checkpoints { - checkpoint.status = match status { - ActiveWorkflowStatus::Active => WorkflowCheckpointStatus::Open, - ActiveWorkflowStatus::Completed => WorkflowCheckpointStatus::Done, - ActiveWorkflowStatus::Cancelled => WorkflowCheckpointStatus::Cancelled, - }; - } - } - } +pub fn strip_rehydration_messages(items: &mut Vec) -> usize { + let before = items.len(); + items.retain(|item| !is_rehydration_message(item)); + before - items.len() +} + +pub fn is_rehydration_message(item: &Item) -> bool { + item_system_text(item) + .map(|text| text.trim_start().starts_with(REHYDRATION_MESSAGE_PREFIX)) + .unwrap_or(false) +} + +fn has_active_workflow_hint(item: &Item) -> bool { + item_system_text(item) + .map(|text| { + text.contains("Active Workflow Invocation State") + || text.contains("ActiveWorkflowStore:") + || text.contains(REHYDRATION_MESSAGE_PREFIX) + }) + .unwrap_or(false) +} + +fn item_system_text(item: &Item) -> Option { + match item { + Item::Message { role, content, .. } if *role == llm_worker::Role::System => Some( + content + .iter() + .map(|part| part.as_text()) + .collect::(), + ), + _ => None, } } -pub fn active_workflow_tools(store: ActiveWorkflowStore) -> Vec { +fn inactive_workflow_message() -> String { + format!( + "{INACTIVE_MESSAGE_PREFIX}\n\n\ + No currently valid active workflow invocation state is active. Ignore older compacted \ + history or summaries that appear to describe active workflow obligations; only validated \ + typed `{DOMAIN}` records with status `active` establish active workflow guidance." + ) +} + +pub fn active_workflow_tools( + store: ActiveWorkflowStore, + committer: Option, +) -> Vec { vec![ list_tool(store.clone()), - status_tool(store.clone(), ActiveWorkflowStatus::Completed), - status_tool(store, ActiveWorkflowStatus::Cancelled), + status_tool( + store.clone(), + ActiveWorkflowStatus::Completed, + committer.clone(), + ), + status_tool(store, ActiveWorkflowStatus::Cancelled, committer), ] } @@ -332,7 +357,11 @@ fn list_tool(store: ActiveWorkflowStore) -> ToolDefinition { }) } -fn status_tool(store: ActiveWorkflowStore, status: ActiveWorkflowStatus) -> ToolDefinition { +fn status_tool( + store: ActiveWorkflowStore, + status: ActiveWorkflowStatus, + committer: Option, +) -> ToolDefinition { let name = match status { ActiveWorkflowStatus::Completed => "ActiveWorkflowComplete", ActiveWorkflowStatus::Cancelled => "ActiveWorkflowCancel", @@ -348,6 +377,7 @@ fn status_tool(store: ActiveWorkflowStore, status: ActiveWorkflowStatus) -> Tool ActiveWorkflowStatus::Active => unreachable!("active status tool is not exposed"), }; let store_for_tool = store.clone(); + let committer_for_tool = committer.clone(); Arc::new(move || { ( ToolMeta::new(name) @@ -364,6 +394,7 @@ fn status_tool(store: ActiveWorkflowStore, status: ActiveWorkflowStatus) -> Tool Arc::new(ActiveWorkflowStatusTool { store: store_for_tool.clone(), status, + committer: committer_for_tool.clone(), }) as Arc, ) }) @@ -401,6 +432,7 @@ impl Tool for ActiveWorkflowListTool { struct ActiveWorkflowStatusTool { store: ActiveWorkflowStore, status: ActiveWorkflowStatus, + committer: Option, } #[async_trait] @@ -417,6 +449,9 @@ impl Tool for ActiveWorkflowStatusTool { .store .set_status(¶ms.slug, self.status, reason, segment_log::now_millis()) .map_err(ToolError::InvalidArgument)?; + if let Some(committer) = &self.committer { + committer(self.store.extension_entry()); + } let content = serde_json::to_string_pretty(&record) .map_err(|err| ToolError::Internal(err.to_string()))?; Ok(ToolOutput { @@ -490,12 +525,12 @@ fn render_snapshot_text(records: &[ActiveWorkflowRecord]) -> String { } fn render_rehydration_message(records: &[ActiveWorkflowRecord]) -> String { - let mut out = String::from( - "[Active workflow snapshot]\n\n\ + let mut out = format!( + "{REHYDRATION_MESSAGE_PREFIX}\n\n\ The following workflow invocation state is durable state carried across compaction. \ Continue to follow each active workflow's snapshotted guidance until the governed task \ is completed with ActiveWorkflowComplete or explicitly cancelled with ActiveWorkflowCancel. \ - Missing or obsolete workflow resources must not replace these invocation snapshots.\n", + Missing or obsolete workflow resources must not replace these invocation snapshots.\n" ); for record in records { out.push_str(&format!( @@ -547,19 +582,29 @@ fn truncate_chars(text: &str, max_chars: usize) -> String { mod tests { use super::*; - #[test] - fn active_workflow_guidance_carries_merge_close_obligations() { + fn store_with_active_workflow() -> ActiveWorkflowStore { let store = ActiveWorkflowStore::new(); - let items = vec![SystemItem::Workflow { - slug: "multi-agent-workflow".into(), - body: "# Multi-agent workflow\n- Delegate implementation to coder.\n- Require external review before merge.\n- Close the Ticket after merge and report evidence.\n".into(), - }]; - assert!(store.activate_from_system_items( - &items, + &[SystemItem::Workflow { + slug: "multi-agent-workflow".into(), + body: "# Multi-agent workflow\n- Delegate implementation to coder.\n- Require external review before merge.\n- Close the Ticket after merge and report evidence.\n".into(), + }], "/multi-agent-workflow implement ticket".into(), 42, )); + store + } + + fn active_extension(store: &ActiveWorkflowStore) -> (String, serde_json::Value) { + ( + DOMAIN.to_string(), + serde_json::to_value(store.snapshot()).expect("snapshot json"), + ) + } + + #[test] + fn active_workflow_guidance_carries_merge_close_obligations() { + let store = store_with_active_workflow(); let msg = store.rehydration_message().unwrap(); assert!(msg.contains("multi-agent-workflow")); @@ -568,6 +613,117 @@ mod tests { assert!(msg.contains("Snapshotted workflow guidance")); } + #[test] + fn compacted_rehydration_message_is_removed_when_typed_state_missing_or_invalid() { + for extensions in [ + Vec::new(), + vec![(DOMAIN.to_string(), json!({"schema_version":"bad"}))], + vec![( + DOMAIN.to_string(), + json!({"schema_version":999,"workflows":[]}), + )], + ] { + let original = store_with_active_workflow(); + let stale_message = original.rehydration_message().unwrap(); + let mut context = vec![ + Item::system_message(stale_message), + Item::user_message("continue"), + ]; + let restored = ActiveWorkflowStore::new(); + + restored.restore_from_history_and_extensions(&context, &extensions); + let removed = restored.sanitize_context(&mut context); + + assert_eq!(removed, 1); + assert!(restored.active_records().is_empty()); + assert!(!context.iter().any(is_rehydration_message)); + } + } + + #[test] + fn completion_or_cancellation_suppresses_old_compacted_guidance() { + for status in [ + ActiveWorkflowStatus::Completed, + ActiveWorkflowStatus::Cancelled, + ] { + let store = store_with_active_workflow(); + let stale_message = store.rehydration_message().unwrap(); + let mut context = vec![ + Item::system_message(stale_message), + Item::user_message("continue"), + ]; + + store + .set_status("multi-agent-workflow", status, status.to_string(), 84) + .expect("workflow exists"); + let removed = store.sanitize_context(&mut context); + + assert_eq!(removed, 1); + assert!(!context.iter().any(is_rehydration_message)); + } + } + + #[test] + fn unmatched_status_tool_calls_do_not_mutate_restored_state() { + let store = store_with_active_workflow(); + let extensions = vec![active_extension(&store)]; + let history = vec![ + Item::tool_call( + "call-1", + "ActiveWorkflowCancel", + json!({"slug":"multi-agent-workflow","reason":"not durable"}).to_string(), + ), + Item::tool_result_error("call-1", "error: failed"), + ]; + let restored = ActiveWorkflowStore::new(); + + restored.restore_from_history_and_extensions(&history, &extensions); + + assert_eq!(restored.active_records().len(), 1); + assert_eq!( + restored.snapshot().workflows[0].status, + ActiveWorkflowStatus::Active + ); + } + + #[tokio::test] + async fn status_tool_persists_typed_extension_on_success() { + let store = store_with_active_workflow(); + let committed = Arc::new(Mutex::new(Vec::::new())); + let committed_for_tool = committed.clone(); + let tools = active_workflow_tools( + store.clone(), + Some(Arc::new(move |entry| { + committed_for_tool + .lock() + .expect("committed entries mutex poisoned") + .push(entry); + })), + ); + let (_, tool) = tools[1](); + + tool.execute( + &json!({"slug":"multi-agent-workflow","reason":"review complete"}).to_string(), + ToolExecutionContext::default(), + ) + .await + .expect("status tool succeeds"); + + let committed = committed.lock().expect("committed entries mutex poisoned"); + let LogEntry::Extension { + domain, payload, .. + } = committed.last().expect("extension committed") + else { + panic!("expected typed active workflow extension"); + }; + assert_eq!(domain, DOMAIN); + let snapshot: ActiveWorkflowSnapshot = serde_json::from_value(payload.clone()).unwrap(); + assert_eq!( + snapshot.workflows[0].status, + ActiveWorkflowStatus::Completed + ); + } + #[test] fn corrupt_extension_fails_closed_with_diagnostic() { let entries = vec![(DOMAIN.to_string(), json!({"schema_version":"bad"}))]; diff --git a/crates/pod/src/ipc/interceptor.rs b/crates/pod/src/ipc/interceptor.rs index 41f36e61..d8a296a3 100644 --- a/crates/pod/src/ipc/interceptor.rs +++ b/crates/pod/src/ipc/interceptor.rs @@ -22,6 +22,7 @@ use llm_worker::tool::ToolOutput; use tracing::info; use tracing::warn; +use crate::active_workflow::ActiveWorkflowStore; use crate::compact::state::CompactState; use crate::compact::usage_tracker::UsageTracker; use session_store::SystemItem; @@ -71,6 +72,10 @@ pub(crate) struct PodInterceptor { /// worker. `None` in tests / `Pod::new` paths where no writer is /// attached. log_writer: Option>, + /// Active workflow state is durable typed Pod state. The interceptor + /// regenerates request-local workflow guidance from this store and strips + /// any stale compacted-history copies before each model request. + active_workflows: ActiveWorkflowStore, /// Next turn index assigned by `on_prompt_submit`. next_turn_index: AtomicUsize, /// Tool calls observed in the current turn (reset on each new prompt). @@ -86,6 +91,7 @@ impl PodInterceptor { pending_attachments: Arc>>, prompts: Arc, log_writer: Option>, + active_workflows: ActiveWorkflowStore, ) -> Self { Self { registry, @@ -96,6 +102,7 @@ impl PodInterceptor { pending_attachments, prompts, log_writer, + active_workflows, next_turn_index: AtomicUsize::new(0), tool_calls_this_turn: AtomicUsize::new(0), } @@ -234,6 +241,8 @@ impl Interceptor for PodInterceptor { } async fn pre_llm_request(&self, context: &mut Vec) -> PreRequestAction { + self.active_workflows.sanitize_context(context); + let initial_tokens = self.estimated_tokens(context); if self.request_threshold_exceeded(initial_tokens, context) { return PreRequestAction::Yield; @@ -449,11 +458,13 @@ mod tests { } impl SystemItemCommitter for RecordingSystemItemCommitter { - fn commit_system_item(&self, item: SystemItem) { - self.committed - .lock() - .expect("committed system-item list poisoned") - .push(item); + fn commit_log_entry(&self, entry: session_store::LogEntry) { + if let session_store::LogEntry::SystemItem { item, .. } = entry { + self.committed + .lock() + .expect("committed system-item list poisoned") + .push(item); + } } } @@ -525,6 +536,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let mut ctx = ctx_items; let action = interceptor.pre_llm_request(&mut ctx).await; @@ -557,6 +569,7 @@ mod tests { Some(Arc::new(RecordingSystemItemCommitter { committed: Arc::clone(&committed), })), + ActiveWorkflowStore::new(), ); let mut ctx = ctx_items; let action = interceptor.pre_llm_request(&mut ctx).await; @@ -593,6 +606,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ) .with_usage_tracker(usage_tracker); let mut ctx = ctx_items; @@ -618,6 +632,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let mut ctx = ctx_items; let action = interceptor.pre_llm_request(&mut ctx).await; @@ -659,6 +674,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let mut ctx = ctx_items; let action = interceptor.pre_llm_request(&mut ctx).await; @@ -686,6 +702,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let mut ctx = ctx_items; let action = interceptor.pre_llm_request(&mut ctx).await; @@ -707,6 +724,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let mut ctx: Vec = Vec::new(); let action = interceptor.pre_llm_request(&mut ctx).await; @@ -735,6 +753,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), Some(committer), + ActiveWorkflowStore::new(), ); let mut ctx: Vec = Vec::new(); @@ -782,6 +801,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let mut ctx: Vec = Vec::new(); @@ -839,6 +859,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let mut info = task_tool_call_info("TaskList", serde_json::json!({"scope": "all"})); @@ -886,6 +907,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let info = task_tool_call_info("TaskList", serde_json::json!({})); let mut result_info = ToolResultInfo { @@ -935,6 +957,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let history = vec![Item::user_message("hi"), Item::assistant_message("done")]; @@ -969,6 +992,7 @@ mod tests { Some(Arc::new(RecordingSystemItemCommitter { committed: Arc::clone(&committed), })), + ActiveWorkflowStore::new(), ) .with_usage_tracker(Arc::clone(&usage_tracker)); @@ -1028,6 +1052,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let items = interceptor.pending_history_appends().await; @@ -1065,6 +1090,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let mut ctx: Vec = vec![Item::user_message("hi")]; let action = interceptor.pre_llm_request(&mut ctx).await; @@ -1095,6 +1121,7 @@ mod tests { Arc::new(Mutex::new(Vec::new())), PromptCatalog::builtins_only().unwrap(), None, + ActiveWorkflowStore::new(), ); let mut ctx: Vec = Vec::new(); let action = interceptor.pre_llm_request(&mut ctx).await; diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index c2cee73a..524e573e 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -198,20 +198,23 @@ where /// interceptor commit `SystemItem`s without being generic over the /// concrete `Store` type. pub trait SystemItemCommitter: Send + Sync { - fn commit_system_item(&self, item: SystemItem); + fn commit_log_entry(&self, entry: LogEntry); + + fn commit_system_item(&self, item: SystemItem) { + self.commit_log_entry(LogEntry::SystemItem { + ts: segment_log::now_millis(), + item, + }); + } } impl SystemItemCommitter for LogWriterHandle where St: Store + Clone + Send + Sync + 'static, { - fn commit_system_item(&self, item: SystemItem) { - let entry = LogEntry::SystemItem { - ts: segment_log::now_millis(), - item, - }; + fn commit_log_entry(&self, entry: LogEntry) { if let Err(err) = self.append_entry(entry) { - warn!(error = %err, "system item commit failed; dropping"); + warn!(error = %err, "session log entry commit failed; dropping"); } } } @@ -822,8 +825,13 @@ impl Pod { ) -> FeatureRegistryInstallReport { let worker = self.worker.as_mut().expect("worker taken during run"); let report = registry.install_into_worker(worker, &mut self.hook_builder); + let active_workflow_committer = self.log_writer.clone().map(|writer| { + Arc::new(move |entry| writer.commit_log_entry(entry)) + as active_workflow::LogEntryCommitter + }); worker.register_tools(active_workflow::active_workflow_tools( self.active_workflows.clone(), + active_workflow_committer, )); report } @@ -890,7 +898,9 @@ impl Pod { self.task_feature.restore_from_history(&state.history); self.active_workflows .restore_from_history_and_extensions(&state.history, &state.extensions); - self.worker_mut().set_history(state.history); + let mut history = state.history; + active_workflow::strip_rehydration_messages(&mut history); + self.worker_mut().set_history(history); self.worker_mut().set_request_config(state.config); self.worker_mut().set_turn_count(state.turn_count); self.worker_mut() @@ -1256,6 +1266,7 @@ impl Pod { self.pending_attachments.clone(), self.prompts.clone(), self.log_writer.clone(), + self.active_workflows.clone(), ) .with_usage_tracker(self.usage_tracker.clone()); self.worker_mut().set_interceptor(interceptor); @@ -2391,8 +2402,10 @@ impl Pod { let worker = self.worker.as_ref().expect("worker taken during run"); let history = worker.history(); let retain_from = cut.index.min(history.len()); - let retained_items = history[retain_from..].to_vec(); - let items_to_summarise = history[..retain_from].to_vec(); + let mut retained_items = history[retain_from..].to_vec(); + let mut items_to_summarise = history[..retain_from].to_vec(); + active_workflow::strip_rehydration_messages(&mut retained_items); + active_workflow::strip_rehydration_messages(&mut items_to_summarise); // Compaction-related knobs. Fall through to manifest defaults when // `[compaction]` is omitted entirely. @@ -2634,31 +2647,24 @@ impl Pod { .filter(|i| i.is_user_message()) .count(); - // Build new history: [summary, ...auto-read, references, ...retained, active workflow snapshot, task snapshot, TaskList synthetic call/result]. - // The active workflow snapshot is inserted from durable typed state so - // workflow-governed tasks keep their procedural authority after the - // compacted segment starts. + // Build new history: [summary, ...auto-read, references, ...retained, task snapshot, TaskList synthetic call/result]. + // Active workflow guidance is intentionally not persisted as an ordinary + // compacted-history system message. It is regenerated request-locally + // from typed `pod.active_workflows` extension state so completed, + // cancelled, corrupt, or missing state cannot leak stale obligations. // The TaskStore snapshot trails the retained items so that, on resume, // `replay_history` walks any pre-compact Task* calls preserved verbatim // in retained_items first and the trailing snapshot's `replace_with` // is the final word — pre-compact `TaskCreate` calls cannot leak as // duplicate entries. - let active_workflow_message = self - .active_workflows - .rehydration_message() - .map(Item::system_message); let mut new_history = Vec::with_capacity( 1 + auto_read_messages.len() + 3 + reference_message.is_some() as usize - + active_workflow_message.is_some() as usize + retained_items.len(), ); - let mut compact_introduced_system_messages = Vec::with_capacity( - 2 + auto_read_messages.len() - + reference_message.is_some() as usize - + active_workflow_message.is_some() as usize, - ); + let mut compact_introduced_system_messages = + Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize); let summary_message = Item::system_message(format!("[Compacted context summary]\n\n{summary_text}")); compact_introduced_system_messages.push(summary_message.clone()); @@ -2671,9 +2677,6 @@ impl Pod { This is the complete session task list preserved across compaction. \ The following TaskList tool result presents the same state through the tool lane." )); - if let Some(msg) = active_workflow_message.as_ref() { - compact_introduced_system_messages.push(msg.clone()); - } compact_introduced_system_messages.push(task_snapshot_message.clone()); new_history.push(summary_message); @@ -2682,9 +2685,6 @@ impl Pod { new_history.push(msg); } new_history.extend(retained_items); - if let Some(msg) = active_workflow_message { - new_history.push(msg); - } new_history.push(task_snapshot_message); new_history.push(Item::tool_call("compact-tasklist", "TaskList", "{}")); new_history.push(Item::tool_result_with_content( @@ -4150,7 +4150,9 @@ where .. }) ); - worker.set_history(state.history.clone()); + let mut restored_history = state.history.clone(); + active_workflow::strip_rehydration_messages(&mut restored_history); + worker.set_history(restored_history); worker.set_request_config(state.config.clone()); worker.set_turn_count(state.turn_count); worker.set_last_run_interrupted(state.last_run_interrupted); From 30b22c1efca0d2e77e6c6c1daa294702b81058a9 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:17:51 +0900 Subject: [PATCH 16/28] ticket: record active workflow fixes --- .yoi/tickets/00001KTFY8V80/item.md | 2 +- .yoi/tickets/00001KTFY8V80/thread.md | 58 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KTFY8V80/item.md b/.yoi/tickets/00001KTFY8V80/item.md index 654cb081..b6200d95 100644 --- a/.yoi/tickets/00001KTFY8V80/item.md +++ b/.yoi/tickets/00001KTFY8V80/item.md @@ -2,7 +2,7 @@ title: "Preserve active workflows across compaction" state: 'inprogress' created_at: "2026-06-07T02:23:28Z" -updated_at: '2026-06-14T15:58:49Z' +updated_at: '2026-06-14T16:17:48Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:23:07Z' --- diff --git a/.yoi/tickets/00001KTFY8V80/thread.md b/.yoi/tickets/00001KTFY8V80/thread.md index cbf2a0c8..069f60a2 100644 --- a/.yoi/tickets/00001KTFY8V80/thread.md +++ b/.yoi/tickets/00001KTFY8V80/thread.md @@ -259,3 +259,61 @@ Conclusion: - Changes requested. Do not integrate until stale-guidance gating and completion/cancellation durability are fixed and covered. --- + + + +## Implementation report + +Follow-up implementation report after reviewer request_changes: + +New commit: +- `ff446052 fix: gate active workflow rehydration state` + +Changed files in follow-up: +- `crates/pod/src/active_workflow.rs` +- `crates/pod/src/ipc/interceptor.rs` +- `crates/pod/src/pod.rs` + +Review blocker 1 resolved: stale active workflow guidance in prompt history +- Active workflow rehydration guidance is no longer persisted into compacted `SegmentStart.history` as an ordinary system message. +- Compaction still carries active workflow state forward via typed `pod.active_workflows` extension state. +- Request context is sanitized before model calls: + - stale `[Active workflow snapshot]` system messages are stripped, + - current active workflow guidance is regenerated request-locally from validated typed state, + - when typed state is missing/corrupt/obsolete/inactive and stale workflow hints are present, a bounded inactive-state message supersedes old compacted references. +- Restore paths strip stale active workflow rehydration messages before installing worker history. +- Compaction source/retained histories strip old active workflow rehydration messages so old snapshots are not re-summarized as active authority. + +Review blocker 2 resolved: completion/cancellation durability +- `ActiveWorkflowComplete` / `ActiveWorkflowCancel` now persist successful status changes by committing a new typed `LogEntry::Extension` snapshot through the session log writer. +- Restore no longer infers completion/cancellation from bare `Item::ToolCall` history. +- Typed extension state is the authority, avoiding trust in unmatched/interrupted/error tool calls. + +Focused coverage added/updated: +- Compacted history containing active workflow rehydration message plus: + - missing extension, + - corrupt extension, + - obsolete extension. +- Completion/cancellation after compaction suppressing old compacted guidance. +- Successful status tool writes a typed durable extension. +- Unmatched/error status tool calls do not mutate restored active workflow state. +- Existing review/merge/close workflow obligation coverage still passes. + +Validation reported by coder: +- Passed: `cargo fmt --check` +- Passed: `git diff --check` +- Passed: `cargo test -p pod active_workflow --lib` + - 7 passed +- Passed: `cargo test -p pod interceptor --lib` + - 19 passed +- Ran: `cargo test -p pod --lib` + - Still fails only on the previously reported unrelated prompt assertion failures: + - `prompt::catalog::tests::pod_orchestration_guidance_section_renders_resource_body` + - `prompt::system::tests::pod_orchestration_guidance_is_included_for_pod_management_tools` + - both assert missing text: `worktree status, diff, and test results` + - result: `332 passed; 2 failed` + +Repository status: +- Child implementation worktree clean after follow-up commit. + +--- From b83b9e4e9e6f5ac40cfad82cfccf90e14b693625 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:21:04 +0900 Subject: [PATCH 17/28] fix: tolerate invalid ticket rows in panel --- crates/ticket/src/lib.rs | 266 +++++++++++++++++++++++++++--- crates/tui/src/multi_pod.rs | 30 +++- crates/tui/src/workspace_panel.rs | 241 +++++++++++++++++++++++++-- 3 files changed, 496 insertions(+), 41 deletions(-) diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 0cd9cf33..b9cc6d50 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -765,6 +765,24 @@ pub struct TicketSummary { pub updated_at: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketInvalidRecord { + pub label: String, + pub reason: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TicketPartialList { + pub tickets: Vec, + pub invalid_records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TicketPartial { + pub ticket: Ticket, + pub invalid_records: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TicketDocument { pub body: MarkdownText, @@ -932,6 +950,49 @@ impl LocalTicketBackend { } } + pub fn list_partial(&self, filter: TicketFilter) -> Result { + let mut output = TicketPartialList::default(); + let mut invalid_seen = BTreeSet::new(); + for dir in self.iter_ticket_dirs(TicketFilter::all())? { + let item = dir.join("item.md"); + if !item.exists() { + continue; + } + match read_item_file(&item) + .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) + { + Ok(meta) => { + if filter + .state + .is_some_and(|state| meta.workflow_state != state) + { + continue; + } + output.tickets.push(ticket_summary_from_meta(meta)); + } + Err(error) => push_invalid_ticket_record( + &mut output.invalid_records, + &mut invalid_seen, + &dir, + &error, + ), + } + } + Ok(output) + } + + pub fn show_partial(&self, id: TicketIdOrSlug) -> Result { + let dir = self.find_ticket_dir(&id)?; + let mut invalid_records = Vec::new(); + let mut invalid_seen = BTreeSet::new(); + let ticket = + self.ticket_from_dir_tolerant(&dir, &mut invalid_records, &mut invalid_seen)?; + Ok(TicketPartial { + ticket, + invalid_records, + }) + } + fn generated_heading(&self, default: &'static str, japanese: &'static str) -> &'static str { if is_japanese_record_language(self.record_language()) { japanese @@ -1045,6 +1106,27 @@ impl LocalTicketBackend { } fn ticket_from_dir(&self, dir: &Path) -> Result { + self.ticket_from_dir_with_relations(dir, |backend, meta| { + backend.relation_view_for_meta(meta) + }) + } + + fn ticket_from_dir_tolerant( + &self, + dir: &Path, + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + ) -> Result { + self.ticket_from_dir_with_relations(dir, |backend, meta| { + backend.relation_view_for_meta_tolerant(meta, invalid_records, invalid_seen) + }) + } + + fn ticket_from_dir_with_relations( + &self, + dir: &Path, + relation_view: impl FnOnce(&Self, &TicketMeta) -> Result, + ) -> Result { let item_path = dir.join("item.md"); let parsed = read_item_file(&item_path)?; let meta = ticket_meta_for_dir(dir, parsed.frontmatter.clone())?; @@ -1059,7 +1141,7 @@ impl LocalTicketBackend { Vec::new() }; let artifacts = collect_artifacts(&dir.join("artifacts"))?; - let relations = self.relation_view_for_meta(&meta)?; + let relations = relation_view(self, &meta)?; let resolution_path = dir.join("resolution.md"); let resolution = if resolution_path.exists() { Some(MarkdownText::new( @@ -1223,13 +1305,25 @@ impl LocalTicketBackend { for dir in self.iter_ticket_dirs(TicketFilter::all())? { relations.extend(self.read_ticket_relations_for_dir(&dir)?); } - relations.sort_by(|a, b| { - a.ticket_id - .cmp(&b.ticket_id) - .then_with(|| a.kind.cmp(&b.kind)) - .then_with(|| a.target.cmp(&b.target)) - .then_with(|| a.at.cmp(&b.at)) - }); + sort_ticket_relations(&mut relations); + Ok(relations) + } + + fn all_ticket_relation_records_tolerant( + &self, + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + ) -> Result> { + let mut relations = Vec::new(); + for dir in self.iter_ticket_dirs(TicketFilter::all())? { + match self.read_ticket_relations_for_dir(&dir) { + Ok(records) => relations.extend(records), + Err(error) => { + push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error) + } + } + } + sort_ticket_relations(&mut relations); Ok(relations) } @@ -1239,6 +1333,17 @@ impl LocalTicketBackend { Ok(relation_view_from_records(meta, &all, &states)) } + fn relation_view_for_meta_tolerant( + &self, + meta: &TicketMeta, + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + ) -> Result { + let states = self.ticket_state_index_tolerant(invalid_records, invalid_seen)?; + let all = self.all_ticket_relation_records_tolerant(invalid_records, invalid_seen)?; + Ok(relation_view_from_records(meta, &all, &states)) + } + fn ticket_state_index(&self) -> Result> { let mut states = HashMap::new(); for dir in self.iter_ticket_dirs(TicketFilter::all())? { @@ -1249,6 +1354,28 @@ impl LocalTicketBackend { Ok(states) } + fn ticket_state_index_tolerant( + &self, + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + ) -> Result> { + let mut states = HashMap::new(); + for dir in self.iter_ticket_dirs(TicketFilter::all())? { + let item = dir.join("item.md"); + match read_item_file(&item) + .and_then(|parsed| ticket_meta_for_dir(&dir, parsed.frontmatter)) + { + Ok(meta) => { + states.insert(meta.id, meta.workflow_state); + } + Err(error) => { + push_invalid_ticket_record(invalid_records, invalid_seen, &dir, &error) + } + } + } + Ok(states) + } + fn relation_blockers_for_meta(&self, meta: &TicketMeta) -> Result> { Ok(self.relation_view_for_meta(meta)?.blockers) } @@ -1274,21 +1401,7 @@ impl TicketBackend for LocalTicketBackend { } let parsed = read_item_file(&item)?; let meta = ticket_meta_for_dir(&dir, parsed.frontmatter)?; - tickets.push(TicketSummary { - id: meta.id, - slug: meta.slug, - title: meta.title, - status: meta.status, - kind: meta.kind, - priority: meta.priority, - labels: meta.labels, - readiness: meta.readiness, - workflow_state: meta.workflow_state, - workflow_state_explicit: meta.workflow_state_explicit, - queued_by: meta.queued_by, - queued_at: meta.queued_at, - updated_at: meta.updated_at, - }); + tickets.push(ticket_summary_from_meta(meta)); } Ok(tickets) } @@ -2224,6 +2337,72 @@ fn ticket_meta(frontmatter: TicketItemFrontmatter, id: String) -> TicketMeta { } } +fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary { + TicketSummary { + id: meta.id, + slug: meta.slug, + title: meta.title, + status: meta.status, + kind: meta.kind, + priority: meta.priority, + labels: meta.labels, + readiness: meta.readiness, + workflow_state: meta.workflow_state, + workflow_state_explicit: meta.workflow_state_explicit, + queued_by: meta.queued_by, + queued_at: meta.queued_at, + updated_at: meta.updated_at, + } +} + +fn sort_ticket_relations(relations: &mut [TicketRelation]) { + relations.sort_by(|a, b| { + a.ticket_id + .cmp(&b.ticket_id) + .then_with(|| a.kind.cmp(&b.kind)) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.at.cmp(&b.at)) + }); +} + +fn invalid_ticket_record_label(dir: &Path) -> String { + dir.file_name() + .and_then(|name| name.to_str()) + .filter(|name| validate_record_id(name).is_ok()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| "invalid ticket record".to_string()) +} + +fn invalid_ticket_record_reason(error: &TicketError) -> &'static str { + match error { + TicketError::Io { .. } => "could not read ticket record", + TicketError::Parse { .. } => "invalid ticket record schema", + TicketError::InvalidPathComponent(_) | TicketError::PathEscapesRoot { .. } => { + "invalid ticket record identity" + } + TicketError::Locked { .. } => "ticket backend is locked", + TicketError::NotFound(_) => "ticket record is missing", + TicketError::Ambiguous { .. } | TicketError::Conflict(_) => { + "invalid ticket record metadata" + } + } +} + +fn push_invalid_ticket_record( + invalid_records: &mut Vec, + invalid_seen: &mut BTreeSet, + dir: &Path, + error: &TicketError, +) { + let label = invalid_ticket_record_label(dir); + if invalid_seen.insert(label.clone()) { + invalid_records.push(TicketInvalidRecord { + label, + reason: invalid_ticket_record_reason(error).to_string(), + }); + } +} + fn trim_owned(value: String) -> String { value.trim().to_string() } @@ -3633,6 +3812,47 @@ state: planning assert!(report.is_ok(), "{:?}", report.diagnostics); } + #[test] + fn partial_list_and_show_keep_valid_tickets_when_peer_record_is_invalid() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let mut ready = NewTicket::new("Ready Valid"); + ready.workflow_state = Some(TicketWorkflowState::Ready); + let valid = backend.create(ready).unwrap(); + let invalid = backend + .create(NewTicket::new("Invalid Secret Title")) + .unwrap(); + fs::write( + backend.root().join(&invalid.id).join("item.md"), + "---\ntitle: Invalid Secret Title\nstate: super-secret-invalid\n---\nbody\n", + ) + .unwrap(); + + assert!(backend.list(TicketFilter::all()).is_err()); + + let partial = backend.list_partial(TicketFilter::all()).unwrap(); + assert_eq!(partial.tickets.len(), 1); + assert_eq!(partial.tickets[0].id, valid.id); + assert_eq!(partial.invalid_records.len(), 1); + assert_eq!(partial.invalid_records[0].label, invalid.id); + assert_eq!( + partial.invalid_records[0].reason, + "invalid ticket record schema" + ); + assert!( + !partial.invalid_records[0] + .reason + .contains("super-secret-invalid") + ); + + let detail = backend + .show_partial(TicketIdOrSlug::Id(valid.id.clone())) + .unwrap(); + assert_eq!(detail.ticket.meta.title, "Ready Valid"); + assert_eq!(detail.invalid_records.len(), 1); + assert_eq!(detail.invalid_records[0].label, invalid.id); + } + #[test] fn create_uses_configured_japanese_record_language_for_generated_defaults() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index bdce4d9e..63c0da1c 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -958,6 +958,10 @@ fn panel_e2e_row_key(key: &PanelRowKey) -> PanelE2eRowKey { kind: "ticket", id: id.clone(), }, + PanelRowKey::InvalidTicket(label) => PanelE2eRowKey { + kind: "invalid_ticket", + id: label.clone(), + }, PanelRowKey::TicketIntakePod { ticket_id, pod_name, @@ -1359,7 +1363,9 @@ impl MultiPodApp { ), None => match &hit.key { PanelRowKey::Pod(name) => (name.clone(), None, None), - PanelRowKey::Ticket(id) => (id.clone(), None, None), + PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => { + (id.clone(), None, None) + } PanelRowKey::TicketIntakePod { pod_name, .. } => { (pod_name.clone(), None, None) } @@ -1415,7 +1421,9 @@ impl MultiPodApp { } if let Some(key) = visible.iter().find(|key| match key { PanelRowKey::Pod(name) => Some(name.as_str()) != orchestrator_pod_name, - PanelRowKey::Ticket(_) | PanelRowKey::TicketIntakePod { .. } => true, + PanelRowKey::Ticket(_) + | PanelRowKey::InvalidTicket(_) + | PanelRowKey::TicketIntakePod { .. } => true, }) { self.select_panel_key(key.clone()); return; @@ -4693,6 +4701,11 @@ fn selected_ticket_notice(row: Option<&PanelRow>) -> String { .unwrap_or_else(|| { "Open/attach this Ticket's Intake Pod from the associated row.".to_string() }), + Some(row) if row.kind == PanelRowKind::InvalidTicket => row + .disabled_reason + .clone() + .or_else(|| row.key_hint.clone()) + .unwrap_or_else(|| "Invalid Ticket record placeholder has no actions.".to_string()), _ => "No Pod is selected.".to_string(), } } @@ -5262,6 +5275,14 @@ fn push_ticket_marker_span(spans: &mut Vec>, selected: bool, remai } fn panel_ticket_detail(row: &PanelRow) -> String { + if row.kind == PanelRowKind::InvalidTicket { + let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()]; + if let Some(reason) = panel_ticket_reason(row) { + parts.push(format!("Reason: {reason}")); + } + return parts.join(" · "); + } + if row.kind == PanelRowKind::TicketIntakePod { let mut parts = row .subtitle @@ -5320,6 +5341,9 @@ fn panel_ticket_reason(row: &PanelRow) -> Option<&str> { } fn ticket_detail_style(row: &PanelRow) -> Style { + if row.kind == PanelRowKind::InvalidTicket { + return Style::default().fg(Color::Yellow); + } if row .ticket .as_ref() @@ -5337,7 +5361,7 @@ fn panel_ticket_reference(row: &PanelRow) -> String { .as_ref() .map(|ticket| ticket.id.clone()) .unwrap_or_else(|| match &row.key { - PanelRowKey::Ticket(id) => id.clone(), + PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(), PanelRowKey::TicketIntakePod { ticket_id, .. } => ticket_id.clone(), PanelRowKey::Pod(name) => name.clone(), }) diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 58e11b0a..6597f81b 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -4,7 +4,7 @@ use protocol::PodStatus; use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; use ticket::{ LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, - TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState, + TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState, }; use crate::pod_list::{PodList, PodListEntry, StoredMetadataState}; @@ -182,6 +182,7 @@ impl OrchestratorPanelStatus { #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub(crate) enum PanelRowKey { Ticket(String), + InvalidTicket(String), TicketIntakePod { ticket_id: String, pod_name: String }, Pod(String), } @@ -190,7 +191,7 @@ impl PanelRowKey { pub(crate) fn pod_name(&self) -> Option<&str> { match self { Self::Pod(name) | Self::TicketIntakePod { pod_name: name, .. } => Some(name), - Self::Ticket(_) => None, + Self::Ticket(_) | Self::InvalidTicket(_) => None, } } } @@ -203,6 +204,7 @@ pub(crate) enum PanelRowKind { ActiveWork, TicketIntakePod, Pod, + InvalidTicket, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -324,12 +326,17 @@ impl PanelRow { } pub(crate) fn is_ticket_section_row(&self) -> bool { - self.is_ticket_action() || matches!(self.kind, PanelRowKind::TicketIntakePod) + self.is_ticket_action() + || matches!( + self.kind, + PanelRowKind::TicketIntakePod | PanelRowKind::InvalidTicket + ) } } const MAX_POD_NAME_CHARS: usize = 80; const MAX_ASSOCIATED_INTAKE_ROWS_PER_TICKET: usize = 3; +const MAX_INVALID_TICKET_PLACEHOLDER_ROWS: usize = 5; const ORCHESTRATOR_SUFFIX: &str = "-orchestrator"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -589,7 +596,10 @@ fn build_workspace_panel_with_registry_model( let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()) .with_record_language(config.ticket_record_language()); match build_ticket_rows(&backend, pods, registry) { - Ok(rows) => model.rows.extend(rows), + Ok(ticket_rows) => { + model.rows.extend(ticket_rows.rows); + model.header.diagnostics.extend(ticket_rows.diagnostics); + } Err(error) => { model .header @@ -663,24 +673,40 @@ fn ticket_summary_from_meta(meta: &TicketMeta) -> TicketSummary { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct TicketRowsBuild { + rows: Vec, + diagnostics: Vec, +} + fn build_ticket_rows( backend: &LocalTicketBackend, pods: &PodList, registry: &PanelRegistrySnapshot, -) -> ticket::Result> { +) -> ticket::Result { + let partial = backend.list_partial(TicketFilter::all())?; let mut ticket_rows = Vec::new(); - for summary in backend.list(TicketFilter::all())? { + let mut invalid_records = partial.invalid_records; + for summary in partial.tickets { if summary.workflow_state == TicketWorkflowState::Closed { continue; } - let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?; - ticket_rows.push(ticket_row( - summary, - &ticket.events, - &ticket.relations.blockers, - pods, - registry, - )); + match backend.show_partial(TicketIdOrSlug::Query(summary.id.clone())) { + Ok(ticket) => { + invalid_records.extend(ticket.invalid_records); + ticket_rows.push(ticket_row( + summary, + &ticket.ticket.events, + &ticket.ticket.relations.blockers, + pods, + registry, + )); + } + Err(_) => invalid_records.push(TicketInvalidRecord { + label: summary.id, + reason: "could not load ticket detail".to_string(), + }), + } } ticket_rows.sort_by(|a, b| { a.priority @@ -695,7 +721,72 @@ fn build_ticket_rows( rows.push(row); rows.extend(intake_rows); } - Ok(rows) + + let invalid_records = dedupe_invalid_ticket_records(invalid_records); + let diagnostics = invalid_ticket_diagnostics(invalid_records.len()); + rows.extend(invalid_ticket_rows(&invalid_records)); + + Ok(TicketRowsBuild { rows, diagnostics }) +} + +fn dedupe_invalid_ticket_records(records: Vec) -> Vec { + let mut deduped = Vec::new(); + for record in records { + if deduped + .iter() + .any(|existing: &TicketInvalidRecord| existing.label == record.label) + { + continue; + } + deduped.push(record); + } + deduped +} + +fn invalid_ticket_diagnostics(invalid_count: usize) -> Vec { + if invalid_count == 0 { + return Vec::new(); + } + let suffix = if invalid_count > MAX_INVALID_TICKET_PLACEHOLDER_ROWS { + format!( + "; showing first {} placeholder rows", + MAX_INVALID_TICKET_PLACEHOLDER_ROWS + ) + } else { + String::new() + }; + vec![bounded_panel_diagnostic(format!( + "Ticket records partially loaded: {invalid_count} invalid record(s) unavailable for actions{suffix}." + ))] +} + +fn invalid_ticket_rows(records: &[TicketInvalidRecord]) -> Vec { + records + .iter() + .take(MAX_INVALID_TICKET_PLACEHOLDER_ROWS) + .map(invalid_ticket_row) + .collect() +} + +fn invalid_ticket_row(record: &TicketInvalidRecord) -> PanelRow { + PanelRow { + key: PanelRowKey::InvalidTicket(record.label.clone()), + kind: PanelRowKind::InvalidTicket, + title: format!("Invalid Ticket record: {}", record.label), + subtitle: Some(record.reason.clone()), + status: "invalid".to_string(), + priority: ActionPriority::Background, + next_action: None, + ticket: None, + related_pods: Vec::new(), + disabled_reason: Some( + "Invalid Ticket record is diagnostics-only; lifecycle actions are disabled." + .to_string(), + ), + key_hint: Some( + "Actions unavailable until the Ticket record is repaired manually.".to_string(), + ), + } } fn ticket_row( @@ -1210,6 +1301,126 @@ mod tests { assert_eq!(row.next_action, Some(NextUserAction::Queue)); } + #[test] + fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let mut ready_input = NewTicket::new("Ready Still Queueable"); + ready_input.workflow_state = Some(TicketWorkflowState::Ready); + let ready = backend.create(ready_input).unwrap(); + backend + .create(NewTicket::new("Planning Still Clarifies")) + .unwrap(); + + for index in 0..6 { + let ticket = backend + .create(NewTicket::new(format!("Leaked Secret Invalid {index}"))) + .unwrap(); + fs::write( + temp.path() + .join(".yoi/tickets") + .join(&ticket.id) + .join("item.md"), + format!( + "---\ntitle: Leaked Secret Invalid {index}\nstate: super-secret-invalid-{index}\n---\nbody\n" + ), + ) + .unwrap(); + } + + let registry = PanelRegistryStore::from_root(temp.path().join("registry")); + registry + .claim_ticket(&ready.id, None, "ready-intake", "intake") + .unwrap(); + let model = build_workspace_panel_with_registry( + temp.path(), + &live_pods(&["ready-intake"]), + ®istry.snapshot().unwrap(), + ); + + let ready_index = model + .rows + .iter() + .position(|row| row.title == "Ready Still Queueable") + .unwrap(); + let ready_row = &model.rows[ready_index]; + assert_eq!(ready_row.next_action, Some(NextUserAction::Queue)); + assert!(ready_row.is_ticket_action()); + assert_eq!( + model.rows[ready_index + 1].key, + PanelRowKey::TicketIntakePod { + ticket_id: ready.id.clone(), + pod_name: "ready-intake".to_string(), + } + ); + + let planning = model + .rows + .iter() + .find(|row| row.title == "Planning Still Clarifies") + .unwrap(); + assert_eq!(planning.next_action, Some(NextUserAction::Clarify)); + assert!(planning.is_ticket_action()); + + let invalid_rows = model + .rows + .iter() + .filter(|row| row.kind == PanelRowKind::InvalidTicket) + .collect::>(); + assert_eq!(invalid_rows.len(), MAX_INVALID_TICKET_PLACEHOLDER_ROWS); + for row in invalid_rows { + assert_eq!(row.status, "invalid"); + assert!(row.ticket.is_none()); + assert_eq!(row.next_action, None); + assert!(!row.is_ticket_action()); + assert!(row.disabled_reason.as_deref().unwrap().contains("disabled")); + } + + let diagnostics = model.header.diagnostics.join("\n"); + assert!(diagnostics.contains("Ticket records partially loaded: 6 invalid record")); + assert!(diagnostics.contains("showing first 5")); + assert!(!diagnostics.contains("super-secret-invalid")); + assert!( + !model + .rows + .iter() + .any(|row| row.title.contains("Leaked Secret Invalid")) + ); + } + + #[test] + fn workspace_panel_keeps_backend_config_unusable_as_whole_ticket_degradation() { + let temp = TempDir::new().unwrap(); + let config_dir = temp.path().join(".yoi"); + fs::create_dir_all(&config_dir).unwrap(); + fs::write( + config_dir.join("ticket.config.toml"), + "[backend]\nprovider = \"unknown:provider\"\nroot = \".yoi/tickets\"\n", + ) + .unwrap(); + + let model = build_workspace_panel(temp.path(), &live_pods(&["idle"])); + + let diagnostics = model.header.diagnostics.join("\n"); + assert!(diagnostics.contains("Ticket config is unusable")); + assert!( + model + .rows + .iter() + .all(|row| row.kind != PanelRowKind::InvalidTicket) + ); + assert_eq!( + model.composer.available_targets, + vec![ComposerTarget::Companion] + ); + assert!( + model + .rows + .iter() + .any(|row| row.key == PanelRowKey::Pod("idle".to_string())) + ); + } #[test] fn workspace_panel_does_not_infer_workflow_state_from_readiness_or_title() { let temp = TempDir::new().unwrap(); From b2c08d80438987d29d9386985e24ba4abb439a2e Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:21:54 +0900 Subject: [PATCH 18/28] ticket: record invalid ticket panel implementation --- .yoi/tickets/00001KV3A5CNH/item.md | 2 +- .yoi/tickets/00001KV3A5CNH/thread.md | 41 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV3A5CNH/item.md b/.yoi/tickets/00001KV3A5CNH/item.md index 36a16d17..c5882372 100644 --- a/.yoi/tickets/00001KV3A5CNH/item.md +++ b/.yoi/tickets/00001KV3A5CNH/item.md @@ -2,7 +2,7 @@ title: 'Panel: invalid Ticket があっても Ticket 機能全体を無効化しない' state: 'inprogress' created_at: '2026-06-14T14:56:51Z' -updated_at: '2026-06-14T15:57:47Z' +updated_at: '2026-06-14T16:21:50Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics'] diff --git a/.yoi/tickets/00001KV3A5CNH/thread.md b/.yoi/tickets/00001KV3A5CNH/thread.md index 45ad4ec1..8622c5e9 100644 --- a/.yoi/tickets/00001KV3A5CNH/thread.md +++ b/.yoi/tickets/00001KV3A5CNH/thread.md @@ -116,3 +116,44 @@ Critical risks / reviewer focus: Routing decision と accepted implementation plan を記録済み。先行 `00001KV09WYC6` は merge/validation/done 済みで、prior conflict/waiting reason は解消。blocking relation / unresolved orchestration-plan blocker はないため、implementation side effects の前に `queued -> inprogress` acceptance を記録する。 --- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KV3A5CNH`: + +Commit: +- `b83b9e4e fix: tolerate invalid ticket rows in panel` + +Changed files: +- `crates/ticket/src/lib.rs` + - Added tolerant `LocalTicketBackend::list_partial(...)` and `show_partial(...)`. + - Added bounded/sanitized `TicketInvalidRecord` reporting. + - Preserved strict existing `TicketBackend::list/show` semantics. + - Added ticket backend test for valid records surviving peer invalid record failures. +- `crates/tui/src/workspace_panel.rs` + - Panel now uses partial ticket loading. + - Valid Ticket rows remain visible/actionable when sibling Ticket records are invalid. + - Invalid records render as disabled diagnostic placeholder rows. + - Invalid placeholder rows have no lifecycle actions and no `ticket` action identity. + - Header diagnostics indicate partial Ticket load failure with bounded placeholder count. + - Added focused Panel tests covering valid ready Queue action, valid planning Clarify/Intake path, associated Intake row adjacency, invalid row bounded/non-actionable behavior, secret-like content non-exposure, and backend config unusable behavior. +- `crates/tui/src/multi_pod.rs` + - Added rendering/selection support for invalid Ticket placeholder rows. + - Invalid placeholder rows are shown as ticket-section diagnostics but remain action-disabled. + +Validation reported by coder: +- Passed: `cargo test -p tui workspace_panel --lib` +- Passed: `cargo test -p ticket partial_list_and_show --lib` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Repository status: +- Child implementation worktree clean after commit. + +Residual risks / blockers: +- None known. +- Strict backend list/show behavior was preserved; new partial methods are opt-in for Panel tolerance. + +--- From 2f51cb62877171c8071b1405626c119f65e83f53 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:25:05 +0900 Subject: [PATCH 19/28] ticket: approve active workflow fixes --- .yoi/tickets/00001KTFY8V80/item.md | 2 +- .yoi/tickets/00001KTFY8V80/thread.md | 55 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KTFY8V80/item.md b/.yoi/tickets/00001KTFY8V80/item.md index b6200d95..9ffd9ff7 100644 --- a/.yoi/tickets/00001KTFY8V80/item.md +++ b/.yoi/tickets/00001KTFY8V80/item.md @@ -2,7 +2,7 @@ title: "Preserve active workflows across compaction" state: 'inprogress' created_at: "2026-06-07T02:23:28Z" -updated_at: '2026-06-14T16:17:48Z' +updated_at: '2026-06-14T16:25:00Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:23:07Z' --- diff --git a/.yoi/tickets/00001KTFY8V80/thread.md b/.yoi/tickets/00001KTFY8V80/thread.md index 069f60a2..89777e53 100644 --- a/.yoi/tickets/00001KTFY8V80/thread.md +++ b/.yoi/tickets/00001KTFY8V80/thread.md @@ -317,3 +317,58 @@ Repository status: - Child implementation worktree clean after follow-up commit. --- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Child worktree: + - `/home/hare/Projects/yoi/.worktree/00001KTFY8V80-active-workflows-compaction` +- HEAD: `ff446052 fix: gate active workflow rehydration state` +- Base checked against: `73d0a6a4` +- Original implementation checked where relevant: `362fedfb` + +Prior blocker 1: stale active workflow guidance after compaction +- Resolved. +- Active workflow rehydration is now derived from typed `LogEntry::Extension` state via `ActiveWorkflowStore`, not from ordinary prompt/tool-call history. +- Restore strips prior `[Active workflow snapshot]` system messages before installing history. +- Rehydration guidance is regenerated request-time from validated active typed state. +- Missing/corrupt/unsupported extension state fails closed: no active workflow restored, stale rehydration messages stripped, and bounded inactive diagnostic text tells the model not to treat older compacted history/summaries as active workflow authority. +- Completed/cancelled typed state does not regenerate active guidance. +- Compaction no longer stores active workflow guidance directly in `SegmentStart.history` as ordinary durable prompt authority; it carries typed extension entries. +- Focused coverage exists for stale active workflow message plus missing/corrupt/unsupported state and completion/cancellation after compaction. + +Prior blocker 2: completion/cancellation durability +- Resolved. +- `ActiveWorkflowComplete` / `ActiveWorkflowCancel` mutate store status and commit a fresh typed `LogEntry::Extension` snapshot through the active workflow log committer. +- Production controller wiring attaches the log writer before feature/tool registration, so status tools have durable commit plumbing. +- Restore no longer trusts bare unmatched `Item::ToolCall` entries or failed/error calls to infer completed/cancelled state. +- Focused tests cover unmatched/error status tool calls and explicit completed/cancelled typed extension suppression of active guidance. + +Overall acceptance: +- Durable typed active workflow representation exists. +- Compaction carries active workflow state forward through typed extension state. +- Rehydration restores guidance from durable validated state. +- Snapshot-vs-latest policy is explicit and fail-closed on missing/corrupt/unsupported latest state. +- No hidden context injection from non-durable transient data was found. +- Active vs advertised workflow separation is preserved. +- Clear/cancel/complete behavior is durable typed-state transition. + +Validation performed by reviewer: +- Passed: `git diff --check 73d0a6a4..HEAD` +- Passed: `cargo fmt --check` +- `git status --short` remained clean. + +Validation not rerun by reviewer: +- Cargo tests were not rerun because review scope was read-only and tests write build artifacts. Coder-reported focused test results were inspected as evidence. + +Full-suite prompt failure assessment: +- The remaining reported `cargo test -p pod --lib` prompt assertion failures involving `worktree status, diff, and test results` appear unrelated to the active workflow typed-state/compaction changes. + +Conclusion: +- Approved. No remaining blocker found. + +--- From 092fcd806de7de4756d8a51212a851a911bd3310 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:26:06 +0900 Subject: [PATCH 20/28] ticket: complete active workflow compaction --- .yoi/tickets/00001KTFY8V80/item.md | 4 +-- .yoi/tickets/00001KTFY8V80/thread.md | 43 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KTFY8V80/item.md b/.yoi/tickets/00001KTFY8V80/item.md index 9ffd9ff7..a2a5147f 100644 --- a/.yoi/tickets/00001KTFY8V80/item.md +++ b/.yoi/tickets/00001KTFY8V80/item.md @@ -1,8 +1,8 @@ --- title: "Preserve active workflows across compaction" -state: 'inprogress' +state: 'done' created_at: "2026-06-07T02:23:28Z" -updated_at: '2026-06-14T16:25:00Z' +updated_at: '2026-06-14T16:26:01Z' queued_by: 'workspace-panel' queued_at: '2026-06-14T15:23:07Z' --- diff --git a/.yoi/tickets/00001KTFY8V80/thread.md b/.yoi/tickets/00001KTFY8V80/thread.md index 89777e53..2c6b4bd7 100644 --- a/.yoi/tickets/00001KTFY8V80/thread.md +++ b/.yoi/tickets/00001KTFY8V80/thread.md @@ -372,3 +372,46 @@ Conclusion: - Approved. No remaining blocker found. --- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KTFY8V80-active-workflows-compaction` +- implementation commits: + - `362fedfb fix: preserve active workflows across compaction` + - `ff446052 fix: gate active workflow rehydration state` +- merge commit: `64d26f84 merge: preserve active workflows across compaction` + +Reviewer result: +- First review requested changes for stale prompt-history guidance and completion/cancellation durability. +- Second-pass review `approve` recorded from `yoi-reviewer-00001KTFY8V80-r2`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo test -p pod active_workflow --lib` + - 7 passed +- Passed: `cargo test -p pod interceptor --lib` + - 19 passed +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Notes: +- Full `cargo test -p pod --lib` was not rerun at integration because coder already reported only the known unrelated prompt assertion failures. Focused tests and reviewer inspection covered the changed active workflow/compaction/interceptor paths. +- Orchestrator worktree is clean after validation. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved after requested fixes, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + +--- From d81fced0518273f86f784ee0f8e75d7bc93b2d49 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:27:24 +0900 Subject: [PATCH 21/28] ticket: request invalid ticket panel changes --- .yoi/tickets/00001KV3A5CNH/item.md | 2 +- .yoi/tickets/00001KV3A5CNH/thread.md | 58 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV3A5CNH/item.md b/.yoi/tickets/00001KV3A5CNH/item.md index c5882372..6f710ebb 100644 --- a/.yoi/tickets/00001KV3A5CNH/item.md +++ b/.yoi/tickets/00001KV3A5CNH/item.md @@ -2,7 +2,7 @@ title: 'Panel: invalid Ticket があっても Ticket 機能全体を無効化しない' state: 'inprogress' created_at: '2026-06-14T14:56:51Z' -updated_at: '2026-06-14T16:21:50Z' +updated_at: '2026-06-14T16:27:19Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics'] diff --git a/.yoi/tickets/00001KV3A5CNH/thread.md b/.yoi/tickets/00001KV3A5CNH/thread.md index 8622c5e9..048406e9 100644 --- a/.yoi/tickets/00001KV3A5CNH/thread.md +++ b/.yoi/tickets/00001KV3A5CNH/thread.md @@ -157,3 +157,61 @@ Residual risks / blockers: - Strict backend list/show behavior was preserved; new partial methods are opt-in for Panel tolerance. --- + + + +## Review: request changes + +Review result: request_changes + +Blocker: +- A corrupt current-ticket relation artifact can still produce an actionable normal Ticket row. + +Details: +- New tolerant relation loading records relation-artifact failures as `TicketInvalidRecord`, but still returns an `Ok(Ticket)` with lossy/omitted relations. +- `LocalTicketBackend::show_partial` returns `ticket_from_dir_tolerant(...)` as successful partial details. +- `relation_view_for_meta_tolerant` builds a relation view from remaining records after `all_ticket_relation_records_tolerant` records relation read errors. +- Panel unconditionally emits a normal actionable `ticket_row(...)` whenever `show_partial` returns `Ok`, and only extends `invalid_records` for diagnostics. +- A ready row can therefore still receive `NextUserAction::Queue`. + +Consequence: +- If a ready Ticket's own `artifacts/relations.json` is corrupt/unreadable, Panel can show both: + 1. a normal ready Ticket row with `Queue`, and + 2. an invalid placeholder for the same Ticket id. +- This violates the invariant that invalid/corrupt Ticket records must not expose Queue/Close/planning-return/lifecycle mutation actions. +- It is also risky because malformed relations may hide dependency/blocker authority; Queue must not be exposed for that same corrupt Ticket record. + +Required changes: +- If partial detail loading reports an invalid record for the same Ticket id being rendered, do not also render that Ticket as an actionable normal row. +- Replace it with a disabled invalid placeholder, or otherwise guarantee no lifecycle mutation action is available for that id. +- Add focused coverage for a valid `item.md` with corrupt/unreadable current Ticket detail/artifact, preferably malformed `artifacts/relations.json`, asserting: + - Ticket id is visible as invalid diagnostic/placeholder. + - no Queue/Close/lifecycle action is available for that Ticket id. + - other valid Ticket rows/actions remain available. + +Evidence checked: +- Reviewed implementation diff `a1c8264b..b83b9e4e`. +- Inspected: + - `crates/ticket/src/lib.rs` + - `crates/tui/src/workspace_panel.rs` + - `crates/tui/src/multi_pod.rs` + - Ticket `00001KV3A5CNH` item. + +Positive findings: +- Strict `TicketBackend::list/show` behavior remains unchanged; partial behavior is opt-in. +- Mixed valid/invalid item frontmatter is covered by focused tests. +- Panel creates disabled invalid placeholder rows with no `next_action`. +- Backend config unusable remains a whole-ticket degraded case in tests. +- Existing associated Intake row behavior is covered for a valid ready Ticket. + +Validation performed by reviewer: +- Passed: `git diff --check a1c8264b..HEAD` +- Passed: `cargo fmt --check` + +Validation not run: +- `cargo test` was not rerun because review scope was read-only and Cargo writes build artifacts. + +Conclusion: +- Changes requested. Do not integrate until current-ticket detail/artifact corruption cannot expose lifecycle actions and focused coverage is added. + +--- From 456722c339161b12f2fd29b680b4aa0efa6d31a1 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:30:21 +0900 Subject: [PATCH 22/28] fix: disable tickets with invalid detail records --- crates/tui/src/workspace_panel.rs | 60 +++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index 6597f81b..0044e77e 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -693,7 +693,14 @@ fn build_ticket_rows( } match backend.show_partial(TicketIdOrSlug::Query(summary.id.clone())) { Ok(ticket) => { + let current_ticket_invalid = ticket + .invalid_records + .iter() + .any(|record| record.label == summary.id); invalid_records.extend(ticket.invalid_records); + if current_ticket_invalid { + continue; + } ticket_rows.push(ticket_row( summary, &ticket.ticket.events, @@ -1389,6 +1396,59 @@ mod tests { ); } + #[test] + fn workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let mut corrupt_input = NewTicket::new("Ready With Corrupt Relations"); + corrupt_input.workflow_state = Some(TicketWorkflowState::Ready); + let corrupt = backend.create(corrupt_input).unwrap(); + let mut other_input = NewTicket::new("Other Ready Still Queueable"); + other_input.workflow_state = Some(TicketWorkflowState::Ready); + let other = backend.create(other_input).unwrap(); + + let artifacts = temp + .path() + .join(".yoi/tickets") + .join(&corrupt.id) + .join("artifacts"); + fs::create_dir_all(&artifacts).unwrap(); + fs::write(artifacts.join("relations.json"), "{").unwrap(); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let corrupt_placeholders = model + .rows + .iter() + .filter(|row| row.key == PanelRowKey::InvalidTicket(corrupt.id.clone())) + .collect::>(); + assert_eq!(corrupt_placeholders.len(), 1); + let corrupt_placeholder = corrupt_placeholders[0]; + assert_eq!(corrupt_placeholder.kind, PanelRowKind::InvalidTicket); + assert_eq!(corrupt_placeholder.next_action, None); + assert!(corrupt_placeholder.ticket.is_none()); + assert!(!corrupt_placeholder.is_ticket_action()); + + assert!( + !model + .rows + .iter() + .any(|row| row.key == PanelRowKey::Ticket(corrupt.id.clone())) + ); + + let other_row = model + .rows + .iter() + .find(|row| row.key == PanelRowKey::Ticket(other.id.clone())) + .unwrap(); + assert_eq!(other_row.next_action, Some(NextUserAction::Queue)); + assert!(other_row.is_ticket_action()); + + let diagnostics = model.header.diagnostics.join("\n"); + assert!(diagnostics.contains("Ticket records partially loaded: 1 invalid record")); + } + #[test] fn workspace_panel_keeps_backend_config_unusable_as_whole_ticket_degradation() { let temp = TempDir::new().unwrap(); From 198d6193582b027d89de665797396e7ce1088df0 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:30:59 +0900 Subject: [PATCH 23/28] ticket: record invalid ticket panel fixes --- .yoi/tickets/00001KV3A5CNH/item.md | 2 +- .yoi/tickets/00001KV3A5CNH/thread.md | 45 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV3A5CNH/item.md b/.yoi/tickets/00001KV3A5CNH/item.md index 6f710ebb..0bdf7431 100644 --- a/.yoi/tickets/00001KV3A5CNH/item.md +++ b/.yoi/tickets/00001KV3A5CNH/item.md @@ -2,7 +2,7 @@ title: 'Panel: invalid Ticket があっても Ticket 機能全体を無効化しない' state: 'inprogress' created_at: '2026-06-14T14:56:51Z' -updated_at: '2026-06-14T16:27:19Z' +updated_at: '2026-06-14T16:30:55Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics'] diff --git a/.yoi/tickets/00001KV3A5CNH/thread.md b/.yoi/tickets/00001KV3A5CNH/thread.md index 048406e9..e691dc2d 100644 --- a/.yoi/tickets/00001KV3A5CNH/thread.md +++ b/.yoi/tickets/00001KV3A5CNH/thread.md @@ -215,3 +215,48 @@ Conclusion: - Changes requested. Do not integrate until current-ticket detail/artifact corruption cannot expose lifecycle actions and focused coverage is added. --- + + + +## Implementation report + +Follow-up implementation report after reviewer request_changes: + +New commit: +- `456722c3 fix: disable tickets with invalid detail records` + +Changed file in follow-up: +- `crates/tui/src/workspace_panel.rs` + +Blocker resolved: +- `build_ticket_rows(...)` now checks `show_partial(...)` diagnostics for an invalid record whose label matches the Ticket id currently being rendered. +- If the current Ticket id is reported invalid during partial detail loading, Panel no longer emits the normal actionable `ticket_row(...)`. +- The invalid record flows through the existing bounded invalid placeholder/diagnostic path, so the Ticket id remains visible but has no lifecycle action. + +Behavior after fix: +- A ready Ticket with valid `item.md` but malformed `artifacts/relations.json` is shown only as an invalid diagnostic placeholder. +- It cannot receive Queue, Close, clarification/planning-return, or other lifecycle mutation actions. +- Other valid Ticket rows continue to render normally and keep their actions. + +Coverage added: +- `workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid` +- Test scenario: + - one ready Ticket with malformed `artifacts/relations.json` + - one separate valid ready Ticket +- Assertions: + - corrupt Ticket id is visible as `PanelRowKey::InvalidTicket(...)` + - no normal `PanelRowKey::Ticket(...)` row exists for that corrupt id + - placeholder has no action and no Ticket action identity + - other valid ready Ticket still has `NextUserAction::Queue` + - header reports partial Ticket load failure + +Validation reported by coder: +- Passed: `cargo test -p tui workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid --lib` +- Passed: `cargo test -p tui workspace_panel --lib` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Repository status: +- Child implementation worktree clean after follow-up commit. + +--- From 8af11be0f0b4b9cdcebf126f747c9783d557b911 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:37:21 +0900 Subject: [PATCH 24/28] ticket: approve invalid ticket panel fixes --- .yoi/tickets/00001KV3A5CNH/item.md | 2 +- .yoi/tickets/00001KV3A5CNH/thread.md | 48 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV3A5CNH/item.md b/.yoi/tickets/00001KV3A5CNH/item.md index 0bdf7431..78a57a0e 100644 --- a/.yoi/tickets/00001KV3A5CNH/item.md +++ b/.yoi/tickets/00001KV3A5CNH/item.md @@ -2,7 +2,7 @@ title: 'Panel: invalid Ticket があっても Ticket 機能全体を無効化しない' state: 'inprogress' created_at: '2026-06-14T14:56:51Z' -updated_at: '2026-06-14T16:30:55Z' +updated_at: '2026-06-14T16:37:15Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics'] diff --git a/.yoi/tickets/00001KV3A5CNH/thread.md b/.yoi/tickets/00001KV3A5CNH/thread.md index e691dc2d..4d926e83 100644 --- a/.yoi/tickets/00001KV3A5CNH/thread.md +++ b/.yoi/tickets/00001KV3A5CNH/thread.md @@ -260,3 +260,51 @@ Repository status: - Child implementation worktree clean after follow-up commit. --- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Target worktree clean at `456722c3`. +- Reviewed diff `a1c8264b..456722c3` and second-pass fix diff `b83b9e4e..456722c3`. +- Confirmed backend partial/tolerant read paths: + - `LocalTicketBackend::list_partial` + - `LocalTicketBackend::show_partial` + - tolerant relation/state loading records invalid Ticket ids via `TicketInvalidRecord` instead of failing entire list/detail load. +- Confirmed Panel row construction: + - uses `list_partial` and `show_partial`; + - collects invalid records into bounded diagnostics/placeholders; + - detects when `show_partial` reports an invalid record whose label matches the summary id being rendered; + - skips normal actionable `PanelRowKey::Ticket(id)` for that id; + - emits `InvalidTicket(id)` placeholder instead. +- Confirmed invalid placeholder rows are diagnostics-only: + - `kind == PanelRowKind::InvalidTicket` + - `ticket == None` + - `next_action == None` + - `is_ticket_action()` excludes invalid rows + - lifecycle dispatch preparation only accepts `row.is_ticket_action()`. +- Confirmed regression test for prior blocker: + - `workspace_panel_disables_current_ticket_when_detail_artifact_is_invalid` + - malformed `artifacts/relations.json` ready Ticket has no normal actionable row; + - disabled `PanelRowKey::InvalidTicket(corrupt.id)` placeholder exists; + - another valid ready Ticket keeps `Queue`. +- Confirmed broader acceptance coverage remains: + - mixed valid/invalid records preserve valid ready/planning rows; + - invalid rows are capped and do not leak malformed state strings; + - backend config unusable remains separate whole-ticket degradation; + - valid Ticket-associated Intake rows remain adjacent to valid Ticket rows. + +Validation performed by reviewer: +- Passed: `git diff --check a1c8264b..HEAD` +- Passed: `cargo fmt --check` + +Validation not run: +- `cargo test` was not run by reviewer because read-only review scope prevents writing build artifacts. Coder-reported passing tests were inspected as evidence. + +Conclusion: +- Approved. No remaining blocker found. + +--- From 765e6e8ebcc45f8d2a6989c46eda6cad434468c3 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:38:06 +0900 Subject: [PATCH 25/28] ticket: complete invalid ticket panel tolerance --- .yoi/tickets/00001KV3A5CNH/item.md | 4 +-- .yoi/tickets/00001KV3A5CNH/thread.md | 44 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KV3A5CNH/item.md b/.yoi/tickets/00001KV3A5CNH/item.md index 78a57a0e..f704b494 100644 --- a/.yoi/tickets/00001KV3A5CNH/item.md +++ b/.yoi/tickets/00001KV3A5CNH/item.md @@ -1,8 +1,8 @@ --- title: 'Panel: invalid Ticket があっても Ticket 機能全体を無効化しない' -state: 'inprogress' +state: 'done' created_at: '2026-06-14T14:56:51Z' -updated_at: '2026-06-14T16:37:15Z' +updated_at: '2026-06-14T16:38:01Z' assignee: null readiness: 'implementation_ready' risk_flags: ['tui-panel', 'ticket-backend', 'partial-failure', 'diagnostics'] diff --git a/.yoi/tickets/00001KV3A5CNH/thread.md b/.yoi/tickets/00001KV3A5CNH/thread.md index 4d926e83..e098ed3c 100644 --- a/.yoi/tickets/00001KV3A5CNH/thread.md +++ b/.yoi/tickets/00001KV3A5CNH/thread.md @@ -308,3 +308,47 @@ Conclusion: - Approved. No remaining blocker found. --- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KV3A5CNH-panel-invalid-ticket-tolerance` +- implementation commits: + - `b83b9e4e fix: tolerate invalid ticket rows in panel` + - `456722c3 fix: disable tickets with invalid detail records` +- merge commit: `863b13b6 merge: tolerate invalid panel tickets` + +Reviewer result: +- First review requested changes for corrupt current-ticket relation/detail artifacts exposing lifecycle actions. +- Second-pass review `approve` recorded from `yoi-reviewer-00001KV3A5CNH-r2`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo test -p tui workspace_panel --lib` + - 16 passed +- Passed: `cargo test -p ticket partial_list_and_show --lib` + - 1 passed +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Notes: +- Valid Ticket rows/actions remain available when peer records are invalid. +- If a current Ticket's detail/artifact load reports invalidity, Panel renders only a disabled invalid placeholder for that id and no lifecycle action. +- Orchestrator worktree is clean after validation. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved after requested fixes, implementation branch merged into the orchestration branch, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + +--- From 5e81bc38e6e9277e9f8facc4cab2a2b6813170b2 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:39:23 +0900 Subject: [PATCH 26/28] ticket: accept panel e2e evidence work --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KV3BQ7Q3/item.md | 4 +- .yoi/tickets/00001KV3BQ7Q3/thread.md | 75 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KV3BQ7Q3/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KV3BQ7Q3/artifacts/orchestration-plan.jsonl index db4dcea5..17a17442 100644 --- a/.yoi/tickets/00001KV3BQ7Q3/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KV3BQ7Q3/artifacts/orchestration-plan.jsonl @@ -1,2 +1,3 @@ {"id":"orch-plan-20260614-153704-1","ticket_id":"00001KV3BQ7Q3","kind":"waiting_capacity_note","note":"現在2件の Coder Pod が running。さらに本 Ticket は現行 HEAD の Panel/TUI E2E evidence を扱うため、先行 Panel/TUI implementation branch の integration 後に、検証対象 HEAD を明確化してから acceptance する。","author":"yoi-orchestrator","at":"2026-06-14T15:37:04Z"} {"id":"orch-plan-20260614-153704-2","ticket_id":"00001KV3BQ7Q3","kind":"after","related_ticket":"00001KV09WYC6","note":"E2E evidence 対象の現行 HEAD を曖昧にしないため、少なくとも active な Panel display implementation (`00001KV09WYC6`) の outcome 確認後に開始する。","author":"yoi-orchestrator","at":"2026-06-14T15:37:04Z"} +{"id":"orch-plan-20260614-163914-3","ticket_id":"00001KV3BQ7Q3","kind":"accepted_plan","accepted_plan":{"summary":"Accept TUI/Panel E2E evidence Ticket after prior Panel implementation Tickets are integrated and done. Validate current orchestration HEAD behavior using existing/updated yoi-e2e scenarios and record pass/fail/gap evidence.","branch":"impl/00001KV3BQ7Q3-panel-e2e-evidence","worktree":"/home/hare/Projects/yoi/.worktree/00001KV3BQ7Q3-panel-e2e-evidence","role_plan":"Orchestrator creates a dedicated implementation/validation worktree and spawns a Coder with write scope limited to that worktree. Coder should run/add minimal E2E evidence and commit test/doc/evidence changes as needed. Reviewer will run read-only after implementation report."},"author":"yoi-orchestrator","at":"2026-06-14T16:39:14Z"} diff --git a/.yoi/tickets/00001KV3BQ7Q3/item.md b/.yoi/tickets/00001KV3BQ7Q3/item.md index 0ffac402..ef82c54a 100644 --- a/.yoi/tickets/00001KV3BQ7Q3/item.md +++ b/.yoi/tickets/00001KV3BQ7Q3/item.md @@ -1,8 +1,8 @@ --- title: '対象 TUI/Panel merge commit の挙動を現行 E2E で確認する' -state: 'queued' +state: 'inprogress' created_at: '2026-06-14T15:24:05Z' -updated_at: '2026-06-14T15:37:04Z' +updated_at: '2026-06-14T16:39:19Z' assignee: null readiness: 'implementation_ready' risk_flags: ['e2e', 'tui', 'panel', 'regression-evidence'] diff --git a/.yoi/tickets/00001KV3BQ7Q3/thread.md b/.yoi/tickets/00001KV3BQ7Q3/thread.md index 9c32c3f6..84fb8047 100644 --- a/.yoi/tickets/00001KV3BQ7Q3/thread.md +++ b/.yoi/tickets/00001KV3BQ7Q3/thread.md @@ -39,3 +39,78 @@ Next action: - planning return ではなく queued のまま waiting とする。 --- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Prior waiting reason は Panel/TUI surface の先行 implementation branch により E2E evidence 対象 HEAD が曖昧になることだった。 +- `00001KV09WYC6` と `00001KV3A5CNH` は reviewer approve、orchestration branch merge、focused validation、Ticket `done` まで完了した。 +- Ticket body / thread / relations / orchestration plan / current Orchestrator workspace を再確認した。blocking relation はなく、planning に戻す concrete missing information はない。 +- 本 Ticket は validation/evidence work item であり、現行 orchestration HEAD で対象 TUI/Panel behavior を E2E で pass/fail/coverage-gap として記録する作業に閉じている。 + +Evidence checked: +- Ticket body/thread: 対象 commit 3件、確認すべき user-visible behavior、acceptance criteria、binding decisions、escalation conditions、validation command を確認。 +- Ticket relations: blocker なし。 +- OrchestrationPlan: prior waiting note と `after 00001KV09WYC6` を確認。先行 Panel work は完了済み。 +- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`765e6e8e` 上。 +- Visible Pods: child Pod なし。 + +IntentPacket: + +Intent: +- 現行 orchestration HEAD / 現行 E2E infrastructure で、対象 TUI/Panel merge commit が意図した user-visible behavior を実プロセス PTY 経路で確認し、pass/fail/coverage-gap を明示する。 + +Binding decisions / invariants: +- focused unit test / code review だけで user-visible TUI/Panel behavior を確認済み扱いにしない。 +- E2E pass と manual/live user confirmation を混同しない。 +- 現行 E2E が確認していない behavior を pass と書かない。 +- historical merge decision を書き換えず、現行状態の evidence を追加する。 +- 主目的は validation/evidence 整理であり、対象 behavior の大きな再設計や unrelated fix はしない。 +- E2E 追加・更新時は fixture-local HOME/XDG/runtime/workspace isolation と no-provider/no-network 前提を維持する。 + +Requirements / acceptance criteria: +- `802fa1f0`, `02311883`, `db7bad7a` の3件それぞれについて、現行 E2E での確認結果を pass / fail / coverage gap として記録する。 +- pass の場合は E2E test 名、assertion、command、結果を記録する。 +- coverage gap の場合は何が確認できないか、追加 E2E か manual/live validation が必要かを記録する。 +- mouse selection は実 `yoi` binary + PTY 経路で user-visible observer を確認する。 +- quit latency は process exit だけでなく pending work / threshold / latency 観点で何を保証したか明示する。 +- rewind live refresh は restart/restore なしの live 表示更新が E2E で確認されるか、不足を明示する。 +- `cargo test -p yoi-e2e --features e2e` または同等の現行 E2E command を実行し結果を記録する。 + +Implementation latitude: +- 既存 `yoi-e2e` scenario の再利用、test name/assertion の明確化、最小限の scenario 追加・更新は Coder 判断。 +- 不足する observer/helper は production behavior に影響しない `e2e-test` feature gate 配下で追加可。 +- latency threshold は既存 E2E 基準を優先し、変更が必要なら理由を報告する。 +- flake の hardening と behavior fix を混同しない。 + +Escalate if: +- 現行 E2E infrastructure では原理的に確認不能で、実端末 manual validation や新 harness 設計が必要。 +- latency の測定が既存 observer / threshold では意味を持たない。 +- PTY SGR injection と実端末 mouse 操作に乖離疑いが残る。 +- actual regression が見つかり、validation Ticket の範囲を超える修正が必要。 + +Validation: +- `cargo test -p yoi-e2e --features e2e` または必要な narrow E2E command。 +- E2E 追加・更新時は `cargo test -p yoi-e2e --no-run`, `cargo fmt --check`, `git diff --check`。 +- 変更範囲に応じて `cargo check -p yoi-e2e -p yoi -p tui`。 + +Critical risks / reviewer focus: +- PTY / real binary 経路であること。 +- unit/focused test と E2E evidence の混同防止。 +- coverage gap を pass と偽らないこと。 +- E2E fixture isolation / no-provider / no-network 維持。 +- recently merged Panel changes (`00001KV09WYC6`, `00001KV3A5CNH`) 後の現行 HEAD に対する evidence であること。 + +--- + + + +## State changed + +Routing decision と accepted implementation/evidence plan を記録済み。先行 Panel/TUI implementation Tickets は merge/validation/done 済みで、prior waiting reason は解消。blocking relation / unresolved orchestration-plan blocker はないため、E2E validation side effects の前に `queued -> inprogress` acceptance を記録する。 + +--- From 1f07e57a2c084181162b8c33bfa62d41a9d5841d Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:46:43 +0900 Subject: [PATCH 27/28] test: record panel tui e2e evidence --- .../00001KV3BQ7Q3/artifacts/e2e-evidence.md | 27 ++++++++++ .yoi/tickets/00001KV3BQ7Q3/item.md | 2 +- .yoi/tickets/00001KV3BQ7Q3/thread.md | 35 +++++++++++++ crates/tui/src/single_pod.rs | 6 +-- tests/e2e/src/lib.rs | 52 +++++++++++++++++++ tests/e2e/tests/panel.rs | 14 +++-- tests/e2e/tests/rewind.rs | 8 ++- 7 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 .yoi/tickets/00001KV3BQ7Q3/artifacts/e2e-evidence.md diff --git a/.yoi/tickets/00001KV3BQ7Q3/artifacts/e2e-evidence.md b/.yoi/tickets/00001KV3BQ7Q3/artifacts/e2e-evidence.md new file mode 100644 index 00000000..74cea840 --- /dev/null +++ b/.yoi/tickets/00001KV3BQ7Q3/artifacts/e2e-evidence.md @@ -0,0 +1,27 @@ +# E2E evidence for Ticket 00001KV3BQ7Q3 + +Validation date: 2026-06-14 +Worktree: `/home/hare/Projects/yoi/.worktree/00001KV3BQ7Q3-panel-e2e-evidence` +Branch: `impl/00001KV3BQ7Q3-panel-e2e-evidence` + +## Summary + +| Target merge behavior | Status | E2E scenario / assertion | +| --- | --- | --- | +| `802fa1f00f8725fe35336e083cd05652fee1409e` / `merge: rewind live refresh` | Pass for current fixture PTY E2E | `single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter` spawns the real `yoi` binary under PTY, opens the rewind picker, applies the target without Esc/restart/restore, observes `rewind_applied` with restored composer text, and now also waits for the post-apply PTY stream to contain the unique live composer marker `rewind-live-refresh`. | +| `02311883f7cda116676d8e179a14ad0be9e7a244` / `merge: panel mouse selection` | Pass for current fixture PTY E2E | `panel_mouse_click_selects_row_without_dispatching_action` spawns the real `yoi panel` PTY path, injects SGR mouse click input, observes `selection_changed` for the clicked row, and asserts no `action_requested` event was emitted by click alone. | +| `db7bad7a64766c2039a4c10781801cb571027955` / `merge: panel quit latency` | Pass for bounded current fixture PTY E2E; original live-terminal latency remains outside this fixture | `panel_ctrl_c_exits_promptly_after_background_barrier` spawns the real `yoi panel` PTY path with a held `reload` background task, confirms that task is pending, sends Ctrl-C, and asserts clean process exit within `PanelHarness::default_exit_wait()` (1500 ms) plus `quit_requested` and `background_task_aborted { task: "reload" }` events. This guarantees that pending fixture background reload work is aborted and does not block quit past the threshold; it does not prove arbitrary live-terminal latency outside this fixture. | + +## Commands and results + +- `cargo fmt --check` — passed. +- `cargo test -p yoi-e2e --features e2e --no-run` — passed; built `yoi-e2e` unit/integration test executables. +- `cargo test -p yoi-e2e --features e2e` — passed: `yoi_e2e` unit test 1/1, `panel` integration tests 3/3, `rewind` integration test 1/1, doc-tests 0. +- `cargo check -p yoi-e2e -p yoi -p tui` — passed. +- `git diff --check` — passed. + +## Residual gaps / non-claims + +- These are automated fixture PTY confirmations only. They are not manual/live-terminal validation. +- The mouse path uses the harness's SGR mouse injection through a PTY. It confirms the real `yoi panel` process path receives and handles the encoded click as intended, but it is not a hardware/terminal-emulator compatibility matrix. +- The quit-latency assertion is bounded to the fixture's held `reload` task and 1500 ms threshold. It confirms pending fixture background work does not user-visibly block quit beyond that bound, but does not independently reproduce every historical live latency observation. diff --git a/.yoi/tickets/00001KV3BQ7Q3/item.md b/.yoi/tickets/00001KV3BQ7Q3/item.md index ef82c54a..b4dfd5da 100644 --- a/.yoi/tickets/00001KV3BQ7Q3/item.md +++ b/.yoi/tickets/00001KV3BQ7Q3/item.md @@ -2,7 +2,7 @@ title: '対象 TUI/Panel merge commit の挙動を現行 E2E で確認する' state: 'inprogress' created_at: '2026-06-14T15:24:05Z' -updated_at: '2026-06-14T16:39:19Z' +updated_at: '2026-06-14T16:45:57Z' assignee: null readiness: 'implementation_ready' risk_flags: ['e2e', 'tui', 'panel', 'regression-evidence'] diff --git a/.yoi/tickets/00001KV3BQ7Q3/thread.md b/.yoi/tickets/00001KV3BQ7Q3/thread.md index 84fb8047..44654a26 100644 --- a/.yoi/tickets/00001KV3BQ7Q3/thread.md +++ b/.yoi/tickets/00001KV3BQ7Q3/thread.md @@ -113,4 +113,39 @@ Critical risks / reviewer focus: Routing decision と accepted implementation/evidence plan を記録済み。先行 Panel/TUI implementation Tickets は merge/validation/done 済みで、prior waiting reason は解消。blocking relation / unresolved orchestration-plan blocker はないため、E2E validation side effects の前に `queued -> inprogress` acceptance を記録する。 +--- + + + +## Implementation report + +# E2E evidence for Ticket 00001KV3BQ7Q3 + +Validation date: 2026-06-14 +Worktree: `/home/hare/Projects/yoi/.worktree/00001KV3BQ7Q3-panel-e2e-evidence` +Branch: `impl/00001KV3BQ7Q3-panel-e2e-evidence` + +## Summary + +| Target merge behavior | Status | E2E scenario / assertion | +| --- | --- | --- | +| `802fa1f00f8725fe35336e083cd05652fee1409e` / `merge: rewind live refresh` | Pass for current fixture PTY E2E | `single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter` spawns the real `yoi` binary under PTY, opens the rewind picker, applies the target without Esc/restart/restore, observes `rewind_applied` with restored composer text, and now also waits for the post-apply PTY stream to contain the unique live composer marker `rewind-live-refresh`. | +| `02311883f7cda116676d8e179a14ad0be9e7a244` / `merge: panel mouse selection` | Pass for current fixture PTY E2E | `panel_mouse_click_selects_row_without_dispatching_action` spawns the real `yoi panel` PTY path, injects SGR mouse click input, observes `selection_changed` for the clicked row, and asserts no `action_requested` event was emitted by click alone. | +| `db7bad7a64766c2039a4c10781801cb571027955` / `merge: panel quit latency` | Pass for bounded current fixture PTY E2E; original live-terminal latency remains outside this fixture | `panel_ctrl_c_exits_promptly_after_background_barrier` spawns the real `yoi panel` PTY path with a held `reload` background task, confirms that task is pending, sends Ctrl-C, and asserts clean process exit within `PanelHarness::default_exit_wait()` (1500 ms) plus `quit_requested` and `background_task_aborted { task: "reload" }` events. This guarantees that pending fixture background reload work is aborted and does not block quit past the threshold; it does not prove arbitrary live-terminal latency outside this fixture. | + +## Commands and results + +- `cargo fmt --check` — passed. +- `cargo test -p yoi-e2e --features e2e --no-run` — passed; built `yoi-e2e` unit/integration test executables. +- `cargo test -p yoi-e2e --features e2e` — passed: `yoi_e2e` unit test 1/1, `panel` integration tests 3/3, `rewind` integration test 1/1, doc-tests 0. +- `cargo check -p yoi-e2e -p yoi -p tui` — passed. +- `git diff --check` — passed. + +## Residual gaps / non-claims + +- These are automated fixture PTY confirmations only. They are not manual/live-terminal validation. +- The mouse path uses the harness's SGR mouse injection through a PTY. It confirms the real `yoi panel` process path receives and handles the encoded click as intended, but it is not a hardware/terminal-emulator compatibility matrix. +- The quit-latency assertion is bounded to the fixture's held `reload` task and 1500 ms threshold. It confirms pending fixture background work does not user-visibly block quit beyond that bound, but does not independently reproduce every historical live latency observation. + + --- diff --git a/crates/tui/src/single_pod.rs b/crates/tui/src/single_pod.rs index 07e1b63b..8a423538 100644 --- a/crates/tui/src/single_pod.rs +++ b/crates/tui/src/single_pod.rs @@ -489,7 +489,7 @@ async fn run_e2e_rewind_fixture( truncate_entries: 1, turn_index: 1, timestamp_ms: Some(1), - preview: "revise the plan".to_string(), + preview: "candidate rewind target".to_string(), eligible: true, disabled_reason: None, warning: None, @@ -500,7 +500,7 @@ async fn run_e2e_rewind_fixture( "rewind_picker_opened", serde_json::json!({ "targets": 1, - "selected_preview": "revise the plan", + "selected_preview": "candidate rewind target", }), ); } @@ -545,7 +545,7 @@ async fn run_e2e_rewind_fixture( if submitted_at.elapsed() >= apply_delay { app.handle_pod_event(Event::RewindApplied { entries: Vec::new(), - input: vec![Segment::text("revise the plan")], + input: vec![Segment::text("rewind-live-refresh")], summary: RewindSummary { truncated_to_entries: 1, discarded_entries: 2, diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index b3dc4c35..9d469df9 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -638,6 +638,43 @@ impl PanelHarness { } } + pub fn output_len(&self) -> usize { + self.output.lock().map(|output| output.len()).unwrap_or(0) + } + + pub fn wait_for_output_contains_from( + &mut self, + start_offset: usize, + needle: &str, + timeout: Duration, + ) -> Result<()> { + let start = Instant::now(); + let needle = needle.as_bytes(); + loop { + if self.output_after(start_offset, needle) { + return Ok(()); + } + if let Some(status) = self.child.try_wait()? { + self.flush_output_artifact()?; + return Err(HarnessError::Protocol(format!( + "process exited with {status} before PTY output contained {:?}", + String::from_utf8_lossy(needle) + ))); + } + if start.elapsed() >= timeout { + self.flush_output_artifact()?; + return Err(HarnessError::Timeout { + what: format!( + "PTY output containing {:?} after offset {start_offset}", + String::from_utf8_lossy(needle) + ), + artifacts: self.artifacts.clone(), + }); + } + thread::sleep(Duration::from_millis(20)); + } + } + pub fn events(&mut self) -> Result> { let text = fs::read_to_string(&self.artifacts.events_jsonl)?; text.lines() @@ -684,6 +721,21 @@ impl PanelHarness { Ok(()) } + fn output_after(&self, start_offset: usize, needle: &[u8]) -> bool { + if needle.is_empty() { + return true; + } + self.output + .lock() + .map(|output| { + let start = start_offset.min(output.len()); + output[start..] + .windows(needle.len()) + .any(|window| window == needle) + }) + .unwrap_or(false) + } + fn mouse_capture_enabled(&self) -> bool { self.output .lock() diff --git a/tests/e2e/tests/panel.rs b/tests/e2e/tests/panel.rs index 5b094758..7a15208a 100644 --- a/tests/e2e/tests/panel.rs +++ b/tests/e2e/tests/panel.rs @@ -135,14 +135,20 @@ fn panel_ctrl_c_exits_promptly_after_background_barrier() -> yoi_e2e::Result<()> "quit latency {elapsed:?} exceeded threshold; artifacts at {}", panel.artifacts().dir.display() ); + let events = panel.events()?; assert!( - panel - .events()? - .iter() - .any(|event| event.event == "quit_requested"), + events.iter().any(|event| event.event == "quit_requested"), "quit_requested observability event missing; artifacts at {}", panel.artifacts().dir.display() ); + assert!( + events.iter().any(|event| { + event.event == "background_task_aborted" + && event.data.get("task").and_then(serde_json::Value::as_str) == Some("reload") + }), + "pending reload task should be aborted before quit completes; artifacts at {}", + panel.artifacts().dir.display() + ); drop(panel); assert_fixture_cleanup(fixture.cleanup()?); Ok(()) diff --git a/tests/e2e/tests/rewind.rs b/tests/e2e/tests/rewind.rs index 386bde93..a2c9f750 100644 --- a/tests/e2e/tests/rewind.rs +++ b/tests/e2e/tests/rewind.rs @@ -13,6 +13,7 @@ fn single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_ente tui.assert_no_full_drag_mouse_capture()?; tui.expect_event("rewind_fixture_ready", Duration::from_secs(5))?; + let before_rewind_output = tui.output_len(); tui.press(KeyPress::CtrlR)?; tui.expect_event("rewind_picker_opened", Duration::from_secs(5))?; @@ -34,10 +35,15 @@ fn single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_ente .data .get("composer_text") .and_then(serde_json::Value::as_str), - Some("revise the plan"), + Some("rewind-live-refresh"), "rewind should update the visible composer state without Esc/restart; artifacts at {}", tui.artifacts().dir.display() ); + tui.wait_for_output_contains_from( + before_rewind_output, + "rewind-live-refresh", + Duration::from_secs(5), + )?; assert_eq!( tui.count_events("rewind_submit_sent")?, submit_count, From 25a91d1cec88467a6e1aec6fad843925aa1511f4 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 15 Jun 2026 01:54:10 +0900 Subject: [PATCH 28/28] ticket: complete panel e2e evidence --- .yoi/tickets/00001KV3BQ7Q3/item.md | 4 +- .yoi/tickets/00001KV3BQ7Q3/thread.md | 87 ++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KV3BQ7Q3/item.md b/.yoi/tickets/00001KV3BQ7Q3/item.md index b4dfd5da..ca483872 100644 --- a/.yoi/tickets/00001KV3BQ7Q3/item.md +++ b/.yoi/tickets/00001KV3BQ7Q3/item.md @@ -1,8 +1,8 @@ --- title: '対象 TUI/Panel merge commit の挙動を現行 E2E で確認する' -state: 'inprogress' +state: 'done' created_at: '2026-06-14T15:24:05Z' -updated_at: '2026-06-14T16:45:57Z' +updated_at: '2026-06-14T16:54:05Z' assignee: null readiness: 'implementation_ready' risk_flags: ['e2e', 'tui', 'panel', 'regression-evidence'] diff --git a/.yoi/tickets/00001KV3BQ7Q3/thread.md b/.yoi/tickets/00001KV3BQ7Q3/thread.md index 44654a26..6750f2b8 100644 --- a/.yoi/tickets/00001KV3BQ7Q3/thread.md +++ b/.yoi/tickets/00001KV3BQ7Q3/thread.md @@ -148,4 +148,91 @@ Branch: `impl/00001KV3BQ7Q3-panel-e2e-evidence` - The quit-latency assertion is bounded to the fixture's held `reload` task and 1500 ms threshold. It confirms pending fixture background work does not user-visibly block quit beyond that bound, but does not independently reproduce every historical live latency observation. +--- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Worktree/branch/commit: + - `/home/hare/Projects/yoi/.worktree/00001KV3BQ7Q3-panel-e2e-evidence` + - `impl/00001KV3BQ7Q3-panel-e2e-evidence` + - HEAD `1f07e57a test: record panel tui e2e evidence` + - base/merge-base `5e81bc38` +- Ticket intent and acceptance criteria in `.yoi/tickets/00001KV3BQ7Q3/item.md`. +- Implementation/evidence changes in: + - `.yoi/tickets/00001KV3BQ7Q3/artifacts/e2e-evidence.md` + - `.yoi/tickets/00001KV3BQ7Q3/thread.md` + - `crates/tui/src/single_pod.rs` + - `tests/e2e/src/lib.rs` + - `tests/e2e/tests/panel.rs` + - `tests/e2e/tests/rewind.rs` + +Findings: +- `802fa1f0` / rewind live refresh: strengthened `single_pod_rewind_picker_applies_without_escape_and_suppresses_duplicate_enter` uses real `yoi` PTY fixture, applies rewind, observes `rewind_applied`, verifies restored composer text `rewind-live-refresh`, and waits for raw PTY output after the pre-rewind offset to contain that marker. This satisfies fixture PTY E2E evidence for restart/restore-free live display update. +- `02311883` / panel mouse selection: existing `panel_mouse_click_selects_row_without_dispatching_action` uses real `yoi panel` PTY, SGR click injection, observes selection change, and asserts no `action_requested` from click alone. This is not just unit/code review coverage. +- `db7bad7a` / panel quit latency: strengthened `panel_ctrl_c_exits_promptly_after_background_barrier` confirms pending `reload`, sends Ctrl-C, requires clean exit within `PanelHarness::default_exit_wait()` / 1500 ms, and asserts `background_task_aborted { task: "reload" }`. The evidence states the bounded guarantee precisely and does not overclaim arbitrary live-terminal latency. + +Residual gap representation: +- Correctly represented. The evidence distinguishes automated fixture PTY E2E from manual/live-terminal validation, describes mouse coverage as SGR PTY injection rather than terminal-emulator matrix, and keeps the original live-terminal latency gap outside the fixture guarantee. + +Validation performed by reviewer: +- Passed: `git diff --check 5e81bc38..HEAD` +- Passed: `cargo fmt --check` +- `git status --porcelain=v1` was clean. + +Validation not rerun by reviewer: +- E2E tests/checks were not rerun in reviewer because they write to build/artifact locations. Coder-reported passing results were reviewed as evidence. + +Conclusion: +- Approved. No blocker remains. + +--- + + + +## Implementation report + +Integration report: + +Merged implementation/evidence branch into orchestration branch: +- branch: `impl/00001KV3BQ7Q3-panel-e2e-evidence` +- implementation commit: `1f07e57a test: record panel tui e2e evidence` +- merge commit: `b5e7ca98 merge: panel e2e evidence` + +Reviewer result: +- `approve` recorded from `yoi-reviewer-00001KV3BQ7Q3`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo fmt --check` +- Passed: `cargo test -p yoi-e2e --features e2e --no-run` +- Passed: `cargo test -p yoi-e2e --features e2e` +- Passed: `cargo check -p yoi-e2e -p yoi -p tui` +- Passed: `git diff --check` + +Per-target evidence outcome: +- `802fa1f0` / rewind live refresh: pass for current fixture PTY E2E. Strengthened rewind E2E verifies post-apply PTY output contains the unique restored composer marker without restart/restore. +- `02311883` / panel mouse selection: pass for current fixture PTY E2E. Existing Panel mouse E2E covers real `yoi panel` PTY, SGR click selection change, and no action dispatch on click alone. +- `db7bad7a` / panel quit latency: pass for bounded fixture PTY E2E with residual live-terminal gap. Strengthened Panel quit E2E confirms pending reload background task is aborted and quit completes within the 1500 ms fixture threshold. + +Residual gaps: +- Evidence is automated fixture PTY E2E, not manual/live-terminal validation. +- Mouse coverage is SGR PTY injection through real process path, not a terminal-emulator compatibility matrix. +- Quit latency evidence is bounded to fixture-held reload work and threshold, not every historical live latency scenario. + +Cleanup planned: +- Stop related coder/reviewer Pods. +- Remove only child implementation worktree/branch for this Ticket. + +--- + + + +## State changed + +Reviewer approved, implementation/evidence branch merged into the orchestration branch, and E2E-focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + ---