merge: integrate orchestration branch

This commit is contained in:
Keisuke Hirata 2026-06-15 22:48:46 +09:00
commit 65fa4c06ce
No known key found for this signature in database
5 changed files with 814 additions and 10 deletions

View File

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

View File

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

View File

@ -13,4 +13,239 @@ LocalTicketBackend によって作成されました。
Ticket を `workspace-panel` が queued にしました。
---
<!-- event: decision author: yoi-orchestrator at: 2026-06-15T12:40:38Z -->
## 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` closedorchestration branch config、`00001KV12W2RT` closedtwo-line Ticket row/gate display
- Current config: `.yoi/ticket.config.toml` を確認。現 checkout には `[orchestration]` section がないため、default `<workspace>/.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。
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T12:40:45Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
## State changed
Routing decision と accepted implementation plan を記録済み。blocking relation / unresolved OrchestrationPlan blocker はなく、Orchestrator workspace は clean。implementation side effects の前に `queued -> inprogress` acceptance を記録する。
---
<!-- event: implementation_report author: yoi-coder-00001KV5D7MG5 at: 2026-06-15T12:57:12Z -->
## 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.
---
<!-- event: review author: yoi-reviewer-00001KV5D7MG5 at: 2026-06-15T13:04:06Z status: approve -->
## 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.
---
<!-- event: implementation_report author: yoi-orchestrator at: 2026-06-15T13:05:20Z -->
## 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.
---
<!-- event: state_changed author: yoi-orchestrator at: 2026-06-15T13:05:20Z from: inprogress to: done reason: merged_validated field: state -->
## 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.
---

View File

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

View File

@ -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<TicketStateOverlay>,
pub(crate) next_action: Option<NextUserAction>,
pub(crate) updated_at: Option<String>,
pub(crate) latest_event_kind: Option<String>,
@ -252,6 +258,12 @@ pub(crate) struct TicketPanelEntry {
pub(crate) intake_pods: Vec<TicketAssociatedIntakeEntry>,
}
#[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<String, TicketStateOverlay>,
diagnostics: Vec<String>,
}
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<str>) -> 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<OrchestrationTicketOverlay, String> {
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<PathBuf, String> {
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<String, String> {
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,
&registry,
None,
))
}
@ -683,6 +879,7 @@ fn build_ticket_rows(
backend: &LocalTicketBackend,
pods: &PodList,
registry: &PanelRegistrySnapshot,
orchestration_overlay: &BTreeMap<String, TicketStateOverlay>,
) -> ticket::Result<TicketRowsBuild> {
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<String>,
}
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<String> {
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();