merge: panel orchestration overlay

This commit is contained in:
Keisuke Hirata 2026-06-15 22:04:30 +09:00
commit eeb6986f16
No known key found for this signature in database
2 changed files with 576 additions and 8 deletions

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