diff --git a/.yoi/tickets/00001KV5D7MG5/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KV5D7MG5/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..1edb5dce --- /dev/null +++ b/.yoi/tickets/00001KV5D7MG5/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260615-124038-1","ticket_id":"00001KV5D7MG5","kind":"accepted_plan","accepted_plan":{"summary":"Accept Panel orchestration Ticket state overlay work. Implement read-only, source-qualified overlay from configured orchestration worktree with path/branch/repository safety checks and duplicate action gating.","branch":"impl/00001KV5D7MG5-panel-orchestration-overlay","worktree":"/home/hare/Projects/yoi/.worktree/00001KV5D7MG5-panel-orchestration-overlay","role_plan":"Orchestrator creates dedicated implementation worktree and spawns Coder with write scope limited to that worktree. Reviewer will run read-only after implementation report. Work is isolated to Panel/Ticket config/worktree overlay code and current workspace has no active child implementation Pods."},"author":"yoi-orchestrator","at":"2026-06-15T12:40:38Z"} diff --git a/.yoi/tickets/00001KV5D7MG5/item.md b/.yoi/tickets/00001KV5D7MG5/item.md index e697ee3f..70de634a 100644 --- a/.yoi/tickets/00001KV5D7MG5/item.md +++ b/.yoi/tickets/00001KV5D7MG5/item.md @@ -1,8 +1,8 @@ --- title: 'Panel に orchestration worktree の Ticket state overlay を表示する' -state: 'queued' +state: 'done' created_at: '2026-06-15T10:29:00Z' -updated_at: '2026-06-15T12:39:21Z' +updated_at: '2026-06-15T13:05:20Z' assignee: null readiness: 'implementation_ready' risk_flags: ['panel', 'ticket-state', 'orchestration', 'worktree', 'git-branch', 'read-only-overlay'] diff --git a/.yoi/tickets/00001KV5D7MG5/thread.md b/.yoi/tickets/00001KV5D7MG5/thread.md index 2f0e7bf5..1db9ed0d 100644 --- a/.yoi/tickets/00001KV5D7MG5/thread.md +++ b/.yoi/tickets/00001KV5D7MG5/thread.md @@ -13,4 +13,239 @@ LocalTicketBackend によって作成されました。 Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Panel Queue により routing が明示的に許可され、Ticket は `queued`。 +- Ticket body / thread / relation / OrchestrationPlan / Orchestrator workspace state / related closed Ticket context を確認した。blocking relation はなく、planning に戻す concrete missing information はない。 +- `00001KV0X254D` で `.yoi/ticket.config.toml` の orchestration branch 設定が実装済みであり、`00001KV12W2RT` / `00001KV4ZPAD3` で Panel Ticket row / hierarchy 表示の前提が整っている。 +- Risk は panel / ticket-state / orchestration / worktree / git-branch / read-only-overlay だが、Ticket 本文に safety checks・read-only invariant・fallback・action gating が明記されているため、残る不確実性は implementation tactic に閉じている。 + +Evidence checked: +- Ticket body/thread: overlay source、safety checks、display guidance、action gating、fallback、acceptance criteria、non-goals を確認。 +- Ticket relations: blocker なし。 +- OrchestrationPlan: 既存 record なし。 +- Related context: `00001KV0X254D` closed(orchestration branch config)、`00001KV12W2RT` closed(two-line Ticket row/gate display)。 +- Current config: `.yoi/ticket.config.toml` を確認。現 checkout には `[orchestration]` section がないため、default `/.worktree/orchestration` / branch `orchestration` fallback を扱う必要がある。 +- Orchestrator workspace: `/home/hare/Projects/yoi/.worktree/orchestration` は clean、`318aa191` 上。 +- Visible Pods: implementation child Pod なし。 + +IntentPacket: + +Intent: +- Workspace Panel の canonical current-branch Ticket state を上書きせず、configured orchestration worktree / branch の Ticket state を read-only overlay として読み、source-qualified progress と action gating を表示する。 + +Binding decisions / invariants: +- Primary Ticket state authority は current workspace branch の `.yoi/tickets` のまま。 +- Overlay は read-only。overlay Ticket files を変更せず、current branch Ticket files に自動反映しない。 +- current branch `state` を overlay state で上書きしない。 +- Overlay source は expected path / branch / same repository / canonical top-level safety checks 通過時のみ使う。 +- 誤った worktree / unrelated repository / branch mismatch の Ticket state を表示しない。 +- Ticket identity join は canonical Ticket id。 +- Overlay progress is source-qualified(例: `orchestration: inprogress`, `local: queued · orchestration: done`)。 +- Overlay が current state より進んでいる場合、Queue/Start 等の duplicate action は safety 側で抑止する。 +- Runtime overlay は merge/review/close authority の代替ではない。 + +Requirements / acceptance criteria: +- Panel ViewModel が configured orchestration worktree `.yoi/tickets` を read-only overlay として読み込める。 +- `[orchestration]` config の `branch` / `worktree_dir` / `worktree_name` と defaults を使って expected path/branch を解決する。 +- current branch state は overlay state で変更されない。 +- local `queued` + overlay `inprogress` を Panel に overlay progress として表示する。 +- local `queued` + overlay `done` を `orchestration: done` / merge pending 相当として表示し、Queue/Start を再提示しない。 +- overlay unavailable / branch mismatch / missing worktree は panic せず current branch 表示へ fallback し、必要なら bounded diagnostic を出す。 +- Tests cover overlay id join、queued+inprogress、queued+done action gating、branch mismatch ignore、missing worktree fallback、current branch Ticket files non-mutation。 + +Implementation latitude: +- Overlay-only Ticket の表示有無は実装判断。ただし表示する場合は source を明確にする。 +- Exact wording / gate/action label は既存 Panel row hierarchy に合わせて調整してよい。 +- Safety check helper の配置は existing Ticket config / Panel orchestration worktree code に合わせる。 +- Heavy full doctor を毎 render 必須にしない。 + +Escalate if: +- Ticket config model に `worktree_dir` / `worktree_name` が存在せず、新 schema の exact authority が必要になる。 +- same-repository / git-common-dir validation が current abstractions で安全に書けない。 +- overlay state を読むために current workspace以外の arbitrary path authority が必要になる。 +- action gating が lifecycle authority と衝突する。 +- overlay-only Tickets の表示方針が product decision を要する。 + +Validation: +- `cargo test -p tui workspace_panel --lib` or focused overlay tests。 +- Relevant ticket config / orchestration worktree tests if config parsing changes。 +- `cargo check -p tui --all-targets`。 +- `cargo fmt --check`。 +- `git diff --check`。 + +Critical risks / reviewer focus: +- read-only overlay invariant。 +- same-repository / branch / canonical path safety checks。 +- source-qualified display; no state overwrite。 +- duplicate Queue/Start action suppression when overlay has progressed。 +- fallback diagnostics boundedness。 +- no accidental merge/close authority substitution。 +- row hierarchy and invalid Ticket tolerance regressions。 + +--- + + + +## State changed + +Routing decision と accepted implementation plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、Orchestrator workspace は clean。implementation side effects の前に `queued -> inprogress` acceptance を記録する。 + +--- + + + +## Implementation report + +Implementation report from Coder Pod `yoi-coder-00001KV5D7MG5`: + +Commit: +- `e0ddbed1 feat: show orchestration ticket overlay in panel` + +Changed files: +- `crates/tui/src/workspace_panel.rs` +- `crates/tui/src/multi_pod.rs` + +Implemented behavior: +- Panel ViewModel attempts to read a read-only orchestration worktree Ticket state overlay from configured/default `[orchestration]` settings. +- Overlay loading validates: + - expected worktree path + - expected branch + - same Git common-dir/repository + - canonical Git top-level equals expected worktree path +- Overlay state is joined by canonical Ticket id only. +- Local/current branch `.yoi/tickets` remains primary authority and is not mutated or overwritten. +- Rows with progressed overlay state show source-qualified state such as: + - `local: queued · orchestration: inprogress` + - `local: queued · orchestration: done` +- Progressed overlay state suppresses duplicate queue/start-like actions: + - overlay `inprogress` is treated as active work / read-only progress + - overlay `done` is treated as merge/review pending and does not expose queue/start again +- Orchestrator work-set derivation accounts for overlay in-progress/done guards to avoid duplicate start routing. + +Tests added/covered: +- overlay joins by Ticket id +- local `queued` + overlay `inprogress` display/action behavior +- local `queued` + overlay `done` merge-pending/action gating +- branch mismatch ignored +- missing worktree fallback +- local Ticket file not mutated by overlay reading + +Validation reported by coder: +- Passed: `cargo test -p tui workspace_panel --lib` +- Passed: `cargo check -p tui --all-targets` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Repository status: +- Child implementation worktree clean after commit. + +Residual blockers: +- None reported. + +--- + + + +## Review: approve + +Review result: approve + +Evidence checked: +- Inspected implementation diff `95abdc8d..e0ddbed1`. +- Changed files reviewed: + - `crates/tui/src/workspace_panel.rs` + - `crates/tui/src/multi_pod.rs` +- Worktree remained clean after read-only checks. + +Acceptance review: +- Current branch remains primary authority: + - local Ticket summaries are loaded from the current workspace backend first; + - overlay data is joined by canonical Ticket id and stored separately in `PanelTicketInfo.orchestration_overlay`; + - `workflow_state` remains local/current-branch state and is not overwritten by overlay. +- Overlay is read-only: + - overlay loading uses `LocalTicketBackend::list_partial()` against the orchestration worktree ticket root; + - no overlay writes or current-branch Ticket mutation path was found; + - test coverage includes local Ticket files not being mutated by overlay read. +- Safety checks are appropriately conservative: + - expected configured/default orchestration path; + - path existence/directory check; + - canonical top-level comparison; + - expected branch check; + - same git common-dir check against current workspace. +- Branch mismatch and missing worktree fall back safely. +- Default orchestration config behavior is covered when no `[orchestration]` section is present. +- Canonical Ticket id join is covered; unrelated overlay Tickets do not affect local rows. +- Source-qualified display is present, e.g. `local: queued · orchestration: inprogress/done`. +- Duplicate Queue/Start suppression is implemented for local `queued` with overlay `inprogress` or `done`. +- `multi_pod` queued-work attention also suppresses already-progressed overlay work. +- Overlay `done` / `closed` remains merge/review-pending information and does not grant close/merge/lifecycle authority. + +Validation performed by reviewer: +- Passed: `git diff --check 95abdc8d..e0ddbed1` +- Passed: `cargo fmt --check` +- Passed: `git status --short` clean + +Validation not run by reviewer: +- `cargo test -p tui workspace_panel --lib` +- `cargo check -p tui --all-targets` + +Reason: review scope was read-only and Cargo writes build artifacts. Coder-reported passing results for those commands were inspected as evidence. + +Risk assessment: +- Low residual risk. The main behavior is ViewModel-tested and safety checks are conservative. +- Minor UI risk: compact status column may truncate long source-qualified overlay text, but the full model value and disabled/detail text carry overlay progress context. + +Conclusion: +- Approved. No blocker found. + +--- + + + +## Implementation report + +Integration report: + +Merged implementation branch into orchestration branch: +- branch: `impl/00001KV5D7MG5-panel-orchestration-overlay` +- implementation commit: `e0ddbed1 feat: show orchestration ticket overlay in panel` +- merge commit: `eeb6986f merge: panel orchestration overlay` + +Reviewer result: +- `approve` recorded from `yoi-reviewer-00001KV5D7MG5`. + +Validation in Orchestrator worktree `/home/hare/Projects/yoi/.worktree/orchestration`: +- Passed: `cargo test -p tui workspace_panel --lib` + - 21 passed +- Passed: `cargo check -p tui --all-targets` +- Passed: `cargo fmt --check` +- Passed: `git diff --check` + +Notes: +- Overlay is read-only and source-qualified. +- Current branch Ticket state remains primary authority and is not overwritten. +- Missing worktree / branch mismatch / unrelated repo safety paths are covered by focused tests. +- Queue/Start-like duplicate actions are suppressed when overlay shows `inprogress` or `done`. +- 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, and focused validation passed in the Orchestrator worktree. Marking Ticket done in the orchestration branch. + --- diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index 52db77f8..6210dc00 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -3211,7 +3211,22 @@ fn derive_orchestrator_work_set( .iter() .filter_map(|row| { let ticket = row.ticket.as_ref()?; - if ticket.workflow_state == TicketWorkflowState::InProgress { + if let Some(overlay) = ticket + .orchestration_overlay + .as_ref() + .filter(|overlay| overlay.workflow_state == TicketWorkflowState::InProgress) + { + Some(OrchestratorActiveWorkItem { + id: ticket.id.clone(), + title: ticket.title.clone(), + status: format!( + "local: {} · {}: {}", + ticket.workflow_state.as_str(), + overlay.source, + overlay.workflow_state.as_str() + ), + }) + } else if ticket.workflow_state == TicketWorkflowState::InProgress { Some(OrchestratorActiveWorkItem { id: ticket.id.clone(), title: ticket.title.clone(), @@ -3297,6 +3312,13 @@ fn queued_duplicate_guard( guards.push(format!("related pod/worktree {pod}")); } } + if let Some(overlay) = ticket.orchestration_overlay.as_ref() { + guards.push(format!( + "{} worktree overlay shows state {}", + overlay.source, + overlay.workflow_state.as_str() + )); + } if guards.is_empty() { None } else { @@ -8893,6 +8915,7 @@ branch = "orchestration/custom-panel" workflow_state: TicketWorkflowState::parse(state) .unwrap_or(TicketWorkflowState::Planning), workflow_state_explicit: true, + orchestration_overlay: None, next_action: Some(next_action), updated_at: None, latest_event_kind: Some("implementation_report".to_string()), diff --git a/crates/tui/src/workspace_panel.rs b/crates/tui/src/workspace_panel.rs index aee3fc22..d5e9082c 100644 --- a/crates/tui/src/workspace_panel.rs +++ b/crates/tui/src/workspace_panel.rs @@ -1,7 +1,12 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; +use std::process::Command; use protocol::PodStatus; -use ticket::config::{TICKET_CONFIG_RELATIVE_PATH, TicketConfig}; +use ticket::config::{ + DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TICKET_CONFIG_RELATIVE_PATH, TicketConfig, + TicketOrchestrationConfig, +}; use ticket::{ LocalTicketBackend, TicketBackend, TicketError, TicketEvent, TicketFilter, TicketIdOrSlug, TicketInvalidRecord, TicketMeta, TicketRelationBlocker, TicketSummary, TicketWorkflowState, @@ -242,6 +247,7 @@ pub(crate) struct TicketPanelEntry { pub(crate) priority: String, pub(crate) workflow_state: TicketWorkflowState, pub(crate) workflow_state_explicit: bool, + pub(crate) orchestration_overlay: Option, pub(crate) next_action: Option, pub(crate) updated_at: Option, pub(crate) latest_event_kind: Option, @@ -252,6 +258,12 @@ pub(crate) struct TicketPanelEntry { pub(crate) intake_pods: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TicketStateOverlay { + pub(crate) source: String, + pub(crate) workflow_state: TicketWorkflowState, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TicketAssociatedIntakeEntry { pub(crate) ticket_id: String, @@ -379,6 +391,27 @@ pub(crate) enum OrchestratorLifecyclePlan { Unavailable(String), } +#[derive(Debug, Clone, PartialEq, Eq)] +struct OrchestrationTicketOverlay { + states: BTreeMap, + diagnostics: Vec, +} + +impl OrchestrationTicketOverlay { + fn empty() -> Self { + Self { + states: BTreeMap::new(), + diagnostics: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct OrchestrationWorktreeLayout { + path: PathBuf, + branch: String, +} + pub(crate) fn workspace_companion_pod_name(workspace_root: &Path) -> String { let seed = workspace_root .file_name() @@ -543,6 +576,161 @@ pub(crate) fn bounded_panel_diagnostic(message: impl AsRef) -> String { excerpt(&collapsed, 180).unwrap_or_else(|| "unknown diagnostic".to_string()) } +fn load_orchestration_ticket_overlay( + workspace_root: &Path, + config: &TicketConfig, +) -> OrchestrationTicketOverlay { + let layout = orchestration_worktree_layout(workspace_root, &config.orchestration); + if !layout.path.exists() { + return OrchestrationTicketOverlay::empty(); + } + match validate_orchestration_overlay_source(workspace_root, &layout) { + Ok(()) => { + load_orchestration_ticket_overlay_states(&layout.path, config.ticket_record_language()) + .unwrap_or_else(|message| OrchestrationTicketOverlay { + states: BTreeMap::new(), + diagnostics: vec![bounded_panel_diagnostic(format!( + "Orchestration Ticket overlay unavailable: {message}" + ))], + }) + } + Err(message) => OrchestrationTicketOverlay { + states: BTreeMap::new(), + diagnostics: vec![bounded_panel_diagnostic(format!( + "Orchestration Ticket overlay unavailable: {message}" + ))], + }, + } +} + +fn orchestration_worktree_layout( + workspace_root: &Path, + config: &TicketOrchestrationConfig, +) -> OrchestrationWorktreeLayout { + OrchestrationWorktreeLayout { + path: workspace_root + .join(config.worktree_dir()) + .join(config.worktree_name()), + branch: config.effective_branch_name().to_string(), + } +} + +fn load_orchestration_ticket_overlay_states( + worktree_root: &Path, + record_language: Option<&str>, +) -> Result { + let ticket_root = worktree_root.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH); + if !ticket_root.is_dir() { + return Ok(OrchestrationTicketOverlay { + states: BTreeMap::new(), + diagnostics: vec![bounded_panel_diagnostic(format!( + "Orchestration worktree has no {} directory", + DEFAULT_TICKET_BACKEND_RELATIVE_PATH + ))], + }); + } + let backend = LocalTicketBackend::new(ticket_root).with_record_language(record_language); + let partial = backend + .list_partial(TicketFilter::all()) + .map_err(|error| error.to_string())?; + let mut states = BTreeMap::new(); + for summary in partial.tickets { + states.insert( + summary.id, + TicketStateOverlay { + source: "orchestration".to_string(), + workflow_state: summary.workflow_state, + }, + ); + } + let diagnostics = invalid_ticket_diagnostics(partial.invalid_records.len()); + Ok(OrchestrationTicketOverlay { + states, + diagnostics, + }) +} + +fn validate_orchestration_overlay_source( + workspace_root: &Path, + layout: &OrchestrationWorktreeLayout, +) -> Result<(), String> { + if !layout.path.exists() { + return Err(format!( + "expected worktree {} does not exist", + layout.path.display() + )); + } + if !layout.path.is_dir() { + return Err(format!( + "expected worktree {} is not a directory", + layout.path.display() + )); + } + let expected_path = layout + .path + .canonicalize() + .map_err(|error| format!("could not canonicalize expected worktree: {error}"))?; + let overlay_top = git_output(&layout.path, &["rev-parse", "--show-toplevel"])?; + let overlay_top = PathBuf::from(overlay_top); + let overlay_top = overlay_top + .canonicalize() + .map_err(|error| format!("could not canonicalize overlay git top-level: {error}"))?; + if overlay_top != expected_path { + return Err(format!( + "overlay git top-level {} does not match expected path {}", + overlay_top.display(), + expected_path.display() + )); + } + + let current_common_dir = git_common_dir(workspace_root)?; + let overlay_common_dir = git_common_dir(&layout.path)?; + if current_common_dir != overlay_common_dir { + return Err("expected worktree is from a different git common-dir".to_string()); + } + + let overlay_branch = git_output(&layout.path, &["branch", "--show-current"])?; + if overlay_branch != layout.branch { + return Err(format!( + "expected branch {} but worktree is on {}", + layout.branch, overlay_branch + )); + } + + Ok(()) +} + +fn git_common_dir(worktree_root: &Path) -> Result { + let common_dir = git_output(worktree_root, &["rev-parse", "--git-common-dir"])?; + let common_dir_path = PathBuf::from(common_dir); + let absolute = if common_dir_path.is_absolute() { + common_dir_path + } else { + worktree_root.join(common_dir_path) + }; + absolute + .canonicalize() + .map_err(|error| format!("could not canonicalize git common-dir: {error}")) +} + +fn git_output(worktree_root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(worktree_root) + .output() + .map_err(|error| format!("could not run git {}: {error}", args.join(" ")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + format!("git {} exited with {}", args.join(" "), output.status) + } else { + format!("git {} failed: {stderr}", args.join(" ")) + }; + return Err(message); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + pub(crate) fn build_workspace_panel( workspace_root: &Path, pods: &PodList, @@ -595,10 +783,17 @@ fn build_workspace_panel_with_registry_model( model.header.ticket_root = config.backend_root().to_path_buf(); let backend = LocalTicketBackend::new(config.backend_root().to_path_buf()) .with_record_language(config.ticket_record_language()); - match build_ticket_rows(&backend, pods, registry) { + let orchestration_overlay = + load_orchestration_ticket_overlay(workspace_root, &config); + match build_ticket_rows(&backend, pods, registry, &orchestration_overlay.states) + { Ok(ticket_rows) => { model.rows.extend(ticket_rows.rows); model.header.diagnostics.extend(ticket_rows.diagnostics); + model + .header + .diagnostics + .extend(orchestration_overlay.diagnostics); } Err(error) => { model @@ -652,6 +847,7 @@ pub(crate) fn build_current_ticket_row( &ticket.relations.blockers, pods, ®istry, + None, )) } @@ -683,6 +879,7 @@ fn build_ticket_rows( backend: &LocalTicketBackend, pods: &PodList, registry: &PanelRegistrySnapshot, + orchestration_overlay: &BTreeMap, ) -> ticket::Result { let partial = backend.list_partial(TicketFilter::all())?; let mut ticket_rows = Vec::new(); @@ -701,12 +898,14 @@ fn build_ticket_rows( if current_ticket_invalid { continue; } + let overlay = orchestration_overlay.get(&summary.id); ticket_rows.push(ticket_row( summary, &ticket.ticket.events, &ticket.ticket.relations.blockers, pods, registry, + overlay, )); } Err(_) => invalid_records.push(TicketInvalidRecord { @@ -802,6 +1001,7 @@ fn ticket_row( relation_blockers: &[TicketRelationBlocker], pods: &PodList, registry: &PanelRegistrySnapshot, + orchestration_overlay: Option<&TicketStateOverlay>, ) -> PanelRow { let local_claim = local_claim_for_ticket(&summary, pods, registry); let intake_pods = @@ -815,14 +1015,24 @@ fn ticket_row( related_pods.push(pod_name); } } - let derived = derive_ticket_state(&summary, relation_blockers); + let visible_overlay = orchestration_overlay + .filter(|overlay| { + overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state) + }) + .cloned(); + let mut derived = derive_ticket_state(&summary, relation_blockers); + if let Some(overlay) = visible_overlay.as_ref() { + apply_orchestration_overlay_to_derived(&mut derived, summary.workflow_state, overlay); + } let latest_event = events.last(); + let state_display = ticket_state_display(summary.workflow_state, visible_overlay.as_ref()); let entry = TicketPanelEntry { id: summary.id.clone(), title: summary.title.clone(), priority: summary.priority.clone(), workflow_state: summary.workflow_state, workflow_state_explicit: summary.workflow_state_explicit, + orchestration_overlay: visible_overlay, next_action: derived.action, updated_at: summary.updated_at.clone(), latest_event_kind: latest_event.map(|event| event.kind.as_str().to_string()), @@ -838,7 +1048,7 @@ fn ticket_row( kind: derived.kind, title: summary.title, subtitle, - status: summary.workflow_state.as_str().to_string(), + status: state_display, priority: derived.priority, next_action: derived.action, ticket: Some(entry), @@ -858,6 +1068,76 @@ struct DerivedTicketState { blocked_reason: Option, } +fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 { + match state { + TicketWorkflowState::Planning => 0, + TicketWorkflowState::Ready => 1, + TicketWorkflowState::Queued => 2, + TicketWorkflowState::InProgress => 3, + TicketWorkflowState::Done => 4, + TicketWorkflowState::Closed => 5, + } +} + +fn overlay_state_has_progressed(local: TicketWorkflowState, overlay: TicketWorkflowState) -> bool { + workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local) +} + +fn ticket_state_display( + local: TicketWorkflowState, + overlay: Option<&TicketStateOverlay>, +) -> String { + match overlay { + Some(overlay) => format!( + "local: {} · {}: {}", + local.as_str(), + overlay.source, + overlay.workflow_state.as_str() + ), + None => local.as_str().to_string(), + } +} + +fn apply_orchestration_overlay_to_derived( + derived: &mut DerivedTicketState, + local: TicketWorkflowState, + overlay: &TicketStateOverlay, +) { + derived.action = Some(NextUserAction::Wait); + let overlay_state = overlay.workflow_state.as_str(); + match overlay.workflow_state { + TicketWorkflowState::Done | TicketWorkflowState::Closed => { + derived.kind = PanelRowKind::Review; + derived.priority = ActionPriority::Background; + derived.disabled_reason = Some(format!( + "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.", + overlay.source, + local.as_str() + )); + derived.key_hint = Some(format!( + "Merge pending: local: {} · {}: {overlay_state}", + local.as_str(), + overlay.source + )); + } + TicketWorkflowState::InProgress | TicketWorkflowState::Queued => { + derived.kind = PanelRowKind::ActiveWork; + derived.priority = ActionPriority::ActiveWork; + derived.disabled_reason = Some(format!( + "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.", + overlay.source, + local.as_str() + )); + derived.key_hint = Some(format!( + "Progress overlay: local: {} · {}: {overlay_state}", + local.as_str(), + overlay.source + )); + } + TicketWorkflowState::Planning | TicketWorkflowState::Ready => {} + } +} + fn derive_ticket_state( summary: &TicketSummary, relation_blockers: &[TicketRelationBlocker], @@ -1099,7 +1379,11 @@ pub(crate) fn local_claim_status_for_pod(pod_name: &str, pods: &PodList) -> Tick } fn ticket_subtitle(entry: &TicketPanelEntry) -> Option { - let mut parts = vec![format!("{} · {}", entry.id, entry.workflow_state.as_str())]; + let mut parts = vec![format!( + "{} · {}", + entry.id, + ticket_state_display(entry.workflow_state, entry.orchestration_overlay.as_ref()) + )]; if let Some(claim) = entry.local_claim.as_ref() { parts.push(format!( "claim: {} ({})", @@ -1212,7 +1496,10 @@ mod tests { use std::fs; use std::path::{Path, PathBuf}; use tempfile::TempDir; - use ticket::{NewTicket, NewTicketRelation, TicketRelationKind, TicketWorkflowState}; + use ticket::{ + NewTicket, NewTicketRelation, TicketIdOrSlug, TicketRelationKind, TicketStateChange, + TicketWorkflowState, + }; fn empty_pods() -> PodList { PodList::from_sources( @@ -1229,9 +1516,17 @@ mod tests { title: &str, configure: impl FnOnce(&mut NewTicket), ) { + create_ticket_with_id(backend, title, configure); + } + + fn create_ticket_with_id( + backend: &LocalTicketBackend, + title: &str, + configure: impl FnOnce(&mut NewTicket), + ) -> String { let mut input = NewTicket::new(title); configure(&mut input); - backend.create(input).unwrap(); + backend.create(input).unwrap().id } fn write_ticket_config(workspace_root: &Path) { @@ -1244,6 +1539,111 @@ mod tests { .unwrap(); } + fn run_git(workspace_root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(workspace_root) + .output() + .unwrap_or_else(|error| panic!("failed to run git {args:?}: {error}")); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn init_git_repo(workspace_root: &Path) { + run_git(workspace_root, &["init", "--initial-branch", "main"]); + run_git(workspace_root, &["config", "user.name", "Panel Test"]); + run_git( + workspace_root, + &["config", "user.email", "panel-test@example.invalid"], + ); + fs::write(workspace_root.join("README.md"), "panel test\n").unwrap(); + run_git(workspace_root, &["add", "README.md"]); + run_git(workspace_root, &["commit", "-m", "init"]); + } + + fn add_orchestration_worktree(workspace_root: &Path, branch: &str) -> PathBuf { + let orchestration_root = workspace_root.join(".worktree/orchestration"); + fs::create_dir_all(orchestration_root.parent().unwrap()).unwrap(); + run_git( + workspace_root, + &[ + "worktree", + "add", + "-b", + branch, + orchestration_root.to_str().unwrap(), + "HEAD", + ], + ); + orchestration_root + } + + fn copy_ticket_to_overlay(workspace_root: &Path, orchestration_root: &Path, id: &str) { + let local_ticket_dir = workspace_root.join(".yoi/tickets").join(id); + let overlay_ticket_dir = orchestration_root.join(".yoi/tickets").join(id); + fs::create_dir_all(overlay_ticket_dir.parent().unwrap()).unwrap(); + fs::create_dir_all(&overlay_ticket_dir).unwrap(); + fs::copy( + local_ticket_dir.join("item.md"), + overlay_ticket_dir.join("item.md"), + ) + .unwrap(); + let local_thread = local_ticket_dir.join("thread.md"); + if local_thread.exists() { + fs::copy(local_thread, overlay_ticket_dir.join("thread.md")).unwrap(); + } + } + + fn set_ticket_state(backend: &LocalTicketBackend, id: &str, state: TicketWorkflowState) { + loop { + let ticket = backend.show(TicketIdOrSlug::Id(id.to_string())).unwrap(); + if ticket.meta.workflow_state == state { + break; + } + let next = match (ticket.meta.workflow_state, state) { + (TicketWorkflowState::Queued, TicketWorkflowState::InProgress) + | (TicketWorkflowState::Queued, TicketWorkflowState::Done) => { + TicketWorkflowState::InProgress + } + (TicketWorkflowState::InProgress, TicketWorkflowState::Done) => { + TicketWorkflowState::Done + } + (from, to) => panic!("unsupported test transition {from} -> {to}"), + }; + backend + .set_workflow_state( + TicketIdOrSlug::Id(id.to_string()), + TicketStateChange::new( + ticket.meta.workflow_state.as_str(), + next.as_str(), + "test", + format!("test state -> {}", next.as_str()), + ), + ) + .unwrap(); + } + } + + fn ticket_row_by_title<'a>(model: &'a WorkspacePanelViewModel, title: &str) -> &'a PanelRow { + model + .rows + .iter() + .find(|row| row.title == title) + .unwrap_or_else(|| panic!("missing row for {title}")) + } + + fn status_contains(row: &PanelRow, needle: &str) { + assert!( + row.status.contains(needle), + "status {:?} did not contain {:?}", + row.status, + needle + ); + } + fn live_pods(names: &[&str]) -> PodList { PodList::from_sources( crate::pod_list::PodVisibilitySource::ResumePicker, @@ -1308,6 +1708,151 @@ mod tests { assert_eq!(row.next_action, Some(NextUserAction::Queue)); } + #[test] + fn workspace_panel_joins_orchestration_overlay_by_ticket_id() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()); + write_ticket_config(temp.path()); + let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let id = create_ticket_with_id(&backend, "Overlay Match", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + create_ticket(&backend, "Local Only", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); + let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); + set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress); + create_ticket(&overlay_backend, "Overlay Only", |input| { + input.workflow_state = Some(TicketWorkflowState::Done); + }); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let matched = ticket_row_by_title(&model, "Overlay Match"); + status_contains(matched, "local: queued"); + status_contains(matched, "orchestration: inprogress"); + assert_eq!( + matched.ticket.as_ref().unwrap().workflow_state, + TicketWorkflowState::Queued + ); + assert_eq!( + matched + .ticket + .as_ref() + .unwrap() + .orchestration_overlay + .as_ref() + .unwrap() + .workflow_state, + TicketWorkflowState::InProgress + ); + assert_eq!(ticket_row_by_title(&model, "Local Only").status, "queued"); + assert!(model.rows.iter().all(|row| row.title != "Overlay Only")); + } + + #[test] + fn workspace_panel_displays_queued_plus_orchestration_inprogress_without_mutating_local_ticket() + { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()); + write_ticket_config(temp.path()); + let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let id = create_ticket_with_id(&backend, "Overlay In Progress", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); + let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); + set_ticket_state(&overlay_backend, &id, TicketWorkflowState::InProgress); + let local_item = temp.path().join(".yoi/tickets").join(&id).join("item.md"); + let before = fs::read_to_string(&local_item).unwrap(); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let row = ticket_row_by_title(&model, "Overlay In Progress"); + status_contains(row, "local: queued"); + status_contains(row, "orchestration: inprogress"); + assert_eq!(row.next_action, Some(NextUserAction::Wait)); + assert_eq!(row.kind, PanelRowKind::ActiveWork); + assert_eq!(fs::read_to_string(&local_item).unwrap(), before); + let local_ticket = backend.show(TicketIdOrSlug::Id(id)).unwrap(); + assert_eq!( + local_ticket.meta.workflow_state, + TicketWorkflowState::Queued + ); + } + + #[test] + fn workspace_panel_displays_queued_plus_orchestration_done_as_merge_pending_without_queue() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()); + write_ticket_config(temp.path()); + let orchestration_root = add_orchestration_worktree(temp.path(), "orchestration"); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let id = create_ticket_with_id(&backend, "Overlay Done", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); + let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); + set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let row = ticket_row_by_title(&model, "Overlay Done"); + status_contains(row, "local: queued"); + status_contains(row, "orchestration: done"); + assert_eq!(row.kind, PanelRowKind::Review); + assert_eq!(row.next_action, Some(NextUserAction::Wait)); + assert_ne!(row.next_action, Some(NextUserAction::Queue)); + assert!( + row.disabled_reason + .as_deref() + .unwrap() + .contains("merge/review/close authority") + ); + } + + #[test] + fn workspace_panel_ignores_orchestration_overlay_on_branch_mismatch() { + let temp = TempDir::new().unwrap(); + init_git_repo(temp.path()); + write_ticket_config(temp.path()); + let orchestration_root = add_orchestration_worktree(temp.path(), "other-branch"); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + let id = create_ticket_with_id(&backend, "Branch Mismatch", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + copy_ticket_to_overlay(temp.path(), &orchestration_root, &id); + let overlay_backend = LocalTicketBackend::new(orchestration_root.join(".yoi/tickets")); + set_ticket_state(&overlay_backend, &id, TicketWorkflowState::Done); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let row = ticket_row_by_title(&model, "Branch Mismatch"); + assert_eq!(row.status, "queued"); + assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none()); + let diagnostics = model.header.diagnostics.join("\n"); + assert!(diagnostics.contains("expected branch orchestration")); + } + + #[test] + fn workspace_panel_falls_back_when_orchestration_worktree_is_missing() { + let temp = TempDir::new().unwrap(); + write_ticket_config(temp.path()); + let backend = LocalTicketBackend::new(temp.path().join(".yoi/tickets")); + create_ticket(&backend, "Missing Overlay", |input| { + input.workflow_state = Some(TicketWorkflowState::Queued); + }); + + let model = build_workspace_panel(temp.path(), &empty_pods()); + + let row = ticket_row_by_title(&model, "Missing Overlay"); + assert_eq!(row.status, "queued"); + assert!(row.ticket.as_ref().unwrap().orchestration_overlay.is_none()); + } + #[test] fn workspace_panel_keeps_valid_ticket_actions_with_invalid_records() { let temp = TempDir::new().unwrap();