From 1c54689edba400f5b10ad648a32e0f390b03a4e4 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 14 Jun 2026 03:58:58 +0900 Subject: [PATCH 1/3] tui: configure orchestration branch --- crates/ticket/src/config.rs | 154 ++++++++++++++++++++++++++++++++++ crates/tui/src/multi_pod.rs | 161 ++++++++++++++++++++++++++++++++++-- 2 files changed, 307 insertions(+), 8 deletions(-) diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index fdb081c6..ad0ef355 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -35,6 +35,9 @@ pub fn ticket_config_scaffold() -> String { out.push_str( "\n# Optional durable Ticket record language. When unset, generated Ticket text keeps current defaults.\n# [ticket]\n# language = \"Japanese\"\n", ); + out.push_str( + "\n# Optional Panel Orchestrator worktree branch. When unset, Panel uses orchestration/.\n# [orchestration]\n# branch = \"orchestration/\"\n", + ); for role in TicketRole::ALL { out.push_str(&format!( "\n[roles.{role}]\nprofile = \"{}\"\nworkflow = \"{}\"\n", @@ -67,15 +70,110 @@ pub enum TicketConfigError { pub struct TicketConfig { pub backend: TicketBackendConfig, pub ticket: TicketRecordConfig, + pub orchestration: TicketOrchestrationConfig, pub roles: TicketRoleProfiles, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct TicketOrchestrationConfig { + pub branch: Option, +} + +impl TicketOrchestrationConfig { + pub fn branch_name(&self) -> Option<&str> { + self.branch.as_ref().map(GitBranchName::as_str) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +pub struct GitBranchName(String); + +impl GitBranchName { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let trimmed = value.trim(); + if trimmed != value { + return Err("git branch name must not have leading or trailing whitespace".to_string()); + } + validate_git_branch_name_value(trimmed)?; + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl<'de> Deserialize<'de> for GitBranchName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +impl fmt::Display for GitBranchName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +fn validate_git_branch_name_value(value: &str) -> Result<(), String> { + if value.is_empty() { + return Err("git branch name must not be empty".to_string()); + } + if value == "@" { + return Err("git branch name must not be `@`".to_string()); + } + if value.starts_with('-') { + return Err("git branch name must not start with `-`".to_string()); + } + if value.starts_with("refs/") { + return Err("git branch name must be a short branch name, not a full ref".to_string()); + } + if value.starts_with('/') || value.ends_with('/') || value.contains("//") { + return Err("git branch name must not contain empty path components".to_string()); + } + if value.contains("..") { + return Err("git branch name must not contain `..`".to_string()); + } + if value.contains("@{") { + return Err("git branch name must not contain `@{`".to_string()); + } + if value.ends_with('.') { + return Err("git branch name must not end with `.`".to_string()); + } + + for component in value.split('/') { + if component.starts_with('.') { + return Err("git branch name components must not start with `.`".to_string()); + } + if component.ends_with(".lock") { + return Err("git branch name components must not end with `.lock`".to_string()); + } + } + + for ch in value.chars() { + if ch.is_control() || matches!(ch, ' ' | '~' | '^' | ':' | '?' | '*' | '[' | '\\') { + return Err(format!( + "git branch name contains unsupported character `{}`", + ch.escape_default() + )); + } + } + + Ok(()) +} + impl TicketConfig { pub fn default_for_workspace(workspace_root: impl AsRef) -> Self { let workspace_root = workspace_root.as_ref(); Self { backend: TicketBackendConfig::default_for_workspace(workspace_root), ticket: TicketRecordConfig::default(), + orchestration: TicketOrchestrationConfig::default(), roles: TicketRoleProfiles::default(), } } @@ -528,9 +626,26 @@ struct RawTicketConfig { #[serde(default)] ticket: RawTicketRecordConfig, #[serde(default)] + orchestration: RawTicketOrchestrationConfig, + #[serde(default)] roles: BTreeMap, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawTicketOrchestrationConfig { + #[serde(default)] + branch: Option, +} + +impl RawTicketOrchestrationConfig { + fn resolve(self) -> TicketOrchestrationConfig { + TicketOrchestrationConfig { + branch: self.branch, + } + } +} + #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawTicketRecordConfig { @@ -576,6 +691,7 @@ impl RawTicketConfig { } })?, ticket: self.ticket.resolve(), + orchestration: self.orchestration.resolve(), roles, }) } @@ -680,6 +796,7 @@ mod tests { temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH) ); assert_eq!(config.ticket_record_language(), None); + assert_eq!(config.orchestration.branch_name(), None); for role in TicketRole::ALL { let role_config = config.role(role); assert_eq!(role_config.profile.as_str(), "inherit"); @@ -701,6 +818,9 @@ root = "custom-tickets" [ticket] language = "Japanese" +[orchestration] +branch = "orchestration/custom-panel" + [roles.intake] profile = "project:intake" launch_prompt = "$workspace/ticket/intake/launch" @@ -730,6 +850,10 @@ workflow = "multi-agent-workflow" ); assert_eq!(config.backend.root, temp.path().join("custom-tickets")); assert_eq!(config.ticket_record_language(), Some("Japanese")); + assert_eq!( + config.orchestration.branch_name(), + Some("orchestration/custom-panel") + ); assert_eq!( config.profile_for(TicketRole::Intake).as_str(), "project:intake" @@ -756,6 +880,9 @@ workflow = "multi-agent-workflow" assert!(scaffold.contains("provider = \"builtin:yoi_local\"")); assert!(scaffold.contains("root = \".yoi/tickets\"")); assert!(scaffold.contains("# [ticket]\n# language = \"Japanese\"")); + assert!(scaffold.contains( + "# [orchestration]\n# branch = \"orchestration/\"" + )); for role in TicketRole::ALL { assert!(scaffold.contains(&format!("[roles.{role}]"))); assert!(scaffold.contains(&format!( @@ -773,6 +900,7 @@ workflow = "multi-agent-workflow" ) .unwrap(); assert_eq!(config.backend_root(), temp.path().join(".yoi/tickets")); + assert_eq!(config.orchestration.branch_name(), None); for role in TicketRole::ALL { let role_config = config.role_launch_config(role).unwrap(); assert_eq!(role_config.profile.as_str(), role.default_profile()); @@ -851,6 +979,32 @@ profile = "builtin:default" ); } + #[test] + fn orchestration_branch_config_is_validated_as_git_branch_name() { + let temp = TempDir::new().unwrap(); + write_config( + temp.path(), + r#" +[orchestration] +branch = "orchestration/panel:bad" +"#, + ); + + let error = TicketConfig::load_workspace(temp.path()).unwrap_err(); + assert!(error.to_string().contains("git branch name")); + assert!(error.to_string().contains("unsupported character")); + } + + #[test] + fn orchestration_branch_rejects_full_refs_and_dash_prefixes() { + assert!(GitBranchName::new("refs/heads/orchestration/panel").is_err()); + assert!(GitBranchName::new("-orchestration-panel").is_err()); + assert_eq!( + GitBranchName::new("orchestration/panel").unwrap().as_str(), + "orchestration/panel" + ); + } + #[test] fn role_table_without_profile_is_not_role_launch_ready() { let temp = TempDir::new().unwrap(); diff --git a/crates/tui/src/multi_pod.rs b/crates/tui/src/multi_pod.rs index ba7a2315..9c4270f0 100644 --- a/crates/tui/src/multi_pod.rs +++ b/crates/tui/src/multi_pod.rs @@ -27,7 +27,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}; use serde::Serialize; use session_store::FsStore; -use ticket::config::TicketConfig; +use ticket::config::{GitBranchName, TicketConfig}; use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowState}; use tokio::net::UnixStream; use unicode_width::UnicodeWidthStr; @@ -2170,17 +2170,43 @@ struct OrchestrationWorktreeReady { status: OrchestrationWorktreeStatus, } -fn orchestration_worktree_layout(workspace_root: &Path) -> OrchestrationWorktreeLayout { +fn orchestration_worktree_layout_for_branch( + workspace_root: &Path, + branch: String, +) -> OrchestrationWorktreeLayout { let stem = workspace_orchestrator_pod_name(workspace_root); OrchestrationWorktreeLayout { path: workspace_root .join(".worktree") .join("orchestration") .join(&stem), - branch: format!("orchestration/{stem}"), + branch, } } +fn orchestration_worktree_layout(workspace_root: &Path) -> OrchestrationWorktreeLayout { + let stem = workspace_orchestrator_pod_name(workspace_root); + orchestration_worktree_layout_for_branch(workspace_root, format!("orchestration/{stem}")) +} + +fn resolved_orchestration_worktree_layout( + workspace_root: &Path, +) -> Result { + let config = TicketConfig::load_workspace(workspace_root) + .map_err(|err| format!("failed to load ticket config for orchestration branch: {err}"))?; + let branch = if let Some(branch) = config.orchestration.branch_name() { + branch.to_string() + } else { + orchestration_worktree_layout(workspace_root).branch + }; + GitBranchName::new(branch.clone()) + .map_err(|message| format!("invalid orchestration branch `{branch}`: {message}"))?; + Ok(orchestration_worktree_layout_for_branch( + workspace_root, + branch, + )) +} + fn build_orchestrator_launch_context( original_workspace_root: &Path, orchestration_workspace_root: &Path, @@ -2204,7 +2230,7 @@ fn build_orchestrator_launch_context( fn ensure_orchestration_worktree( workspace_root: &Path, ) -> Result { - let layout = orchestration_worktree_layout(workspace_root); + let layout = resolved_orchestration_worktree_layout(workspace_root)?; if layout.path.exists() { if !layout.path.is_dir() { return Err(format!( @@ -2267,7 +2293,7 @@ fn ensure_orchestration_worktree( fn prepare_orchestration_worktree_for_restore( workspace_root: &Path, ) -> Result { - let layout = orchestration_worktree_layout(workspace_root); + let layout = resolved_orchestration_worktree_layout(workspace_root)?; if !layout.path.exists() { return Err(format!( "orchestration worktree is missing; cannot restore existing Pod state: {}", @@ -2503,8 +2529,9 @@ async fn orchestrator_lifecycle( pod_name, OrchestratorPanelStatus::Restored, Some(format!( - "restored existing Pod state in orchestration worktree {}", - worktree.layout.path.display() + "restored existing Pod state in orchestration worktree {} on branch {}", + worktree.layout.path.display(), + worktree.layout.branch )), )) .mark_reload() @@ -3406,7 +3433,15 @@ fn prepare_panel_queue_handoff( queue_check_failed("root-git-user", ticket_id, &root_top_level, message) })?; - let orchestration = orchestration_worktree_layout(&root_top_level); + let orchestration = + resolved_orchestration_worktree_layout(&root_top_level).map_err(|message| { + queue_check_failed( + "orchestration-branch-config", + ticket_id, + &root_top_level, + message, + ) + })?; if !orchestration.path.exists() { return Err(queue_check_failed( "orchestration-worktree-identity", @@ -5219,6 +5254,94 @@ mod tests { assert!(created.layout.path.join("dirty.txt").exists()); } + #[test] + fn ensure_and_restore_use_configured_orchestration_branch() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + write_test_ticket_config( + &root, + r#" +[orchestration] +branch = "orchestration/custom-panel" +"#, + ); + run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); + run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); + + let resolved = resolved_orchestration_worktree_layout(&root).unwrap(); + assert_eq!(resolved.branch, "orchestration/custom-panel"); + assert!( + resolved + .path + .ends_with(".worktree/orchestration/repo-orchestrator") + ); + + let created = ensure_orchestration_worktree(&root).unwrap(); + assert_eq!(created.status, OrchestrationWorktreeStatus::Created); + assert_eq!(created.layout, resolved); + let branch = + run_test_git_output(&created.layout.path, &["branch", "--show-current"]).unwrap(); + assert_eq!(branch.trim(), "orchestration/custom-panel"); + + let restored = prepare_orchestration_worktree_for_restore(&root).unwrap(); + assert_eq!(restored.status, OrchestrationWorktreeStatus::Reused); + assert_eq!(restored.layout, created.layout); + } + + #[test] + fn invalid_configured_orchestration_branch_is_rejected_before_git_worktree_operations() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + std::fs::create_dir_all(&root).unwrap(); + write_test_ticket_config( + &root, + r#" +[orchestration] +branch = "orchestration/bad:branch" +"#, + ); + + let err = ensure_orchestration_worktree(&root).unwrap_err(); + assert!(err.contains("failed to load ticket config")); + assert!(err.contains("git branch name")); + assert!(!root.join(".worktree").exists()); + } + + #[test] + fn restore_rejects_mismatched_configured_orchestration_branch_without_checkout() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("repo"); + init_test_repo(&root); + write_test_ticket_config( + &root, + r#" +[orchestration] +branch = "orchestration/custom-panel" +"#, + ); + run_test_git(&root, &["add", ".yoi/ticket.config.toml"]).unwrap(); + run_test_git(&root, &["commit", "-m", "ticket config"]).unwrap(); + let layout = resolved_orchestration_worktree_layout(&root).unwrap(); + run_test_git( + &root, + &[ + "worktree", + "add", + &layout.path.display().to_string(), + "-b", + "orchestration/other-panel", + "HEAD", + ], + ) + .unwrap(); + + let err = prepare_orchestration_worktree_for_restore(&root).unwrap_err(); + assert!(err.contains("expected orchestration/custom-panel")); + let branch = run_test_git_output(&layout.path, &["branch", "--show-current"]).unwrap(); + assert_eq!(branch.trim(), "orchestration/other-panel"); + } + #[test] fn restore_uses_existing_orchestration_worktree_even_when_dirty() { let temp = TempDir::new().unwrap(); @@ -5309,6 +5432,12 @@ mod tests { assert!(layout.path.join("unrelated.txt").exists()); } + fn write_test_ticket_config(root: &Path, content: &str) { + let config_dir = root.join(".yoi"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("ticket.config.toml"), content).unwrap(); + } + fn init_test_repo(root: &Path) { std::fs::create_dir_all(root).unwrap(); run_test_git(root, &["init"]).unwrap(); @@ -5340,6 +5469,22 @@ mod tests { run_git_command(command, "run test git") } + fn run_test_git_output(root: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .map_err(|error| format!("could not run test git: {error}"))?; + if !output.status.success() { + return Err(format!( + "git failed to run test git: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + use crate::pod_list::{LivePodInfo, PodEntrySummary, StoredMetadataState, StoredPodInfo}; use std::fs; use tempfile::TempDir; From fc075bc69ec0620ec72390c282f8ec440601e638 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 14 Jun 2026 03:59:48 +0900 Subject: [PATCH 2/3] ticket: report orchestration branch config --- .yoi/tickets/00001KV0X254D/item.md | 2 +- .yoi/tickets/00001KV0X254D/thread.md | 39 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV0X254D/item.md b/.yoi/tickets/00001KV0X254D/item.md index cac42286..e56c51f7 100644 --- a/.yoi/tickets/00001KV0X254D/item.md +++ b/.yoi/tickets/00001KV0X254D/item.md @@ -2,7 +2,7 @@ title: 'Panel Orchestrator の orchestration branch 名を ticket.config.toml で設定可能にする' state: 'inprogress' created_at: '2026-06-13T16:29:25Z' -updated_at: '2026-06-13T18:41:25Z' +updated_at: '2026-06-13T18:59:21Z' assignee: null readiness: 'implementation_ready' risk_flags: ['config-schema', 'git-worktree', 'panel-orchestration'] diff --git a/.yoi/tickets/00001KV0X254D/thread.md b/.yoi/tickets/00001KV0X254D/thread.md index 136f9331..6e977161 100644 --- a/.yoi/tickets/00001KV0X254D/thread.md +++ b/.yoi/tickets/00001KV0X254D/thread.md @@ -69,4 +69,43 @@ Validation: Ticket evidence、relation records、orchestration plan、workspace state を確認した。Blocking relation はなく、implementation-ready。実装 side effect の前に inprogress acceptance を記録する。 +--- + + + +## Implementation report + +Implemented Ticket 00001KV0X254D. + +Files changed: +- `crates/ticket/src/config.rs` + - Added typed `[orchestration] branch = "..."` config support via `TicketOrchestrationConfig` and validated `GitBranchName`. + - Kept default behavior as no configured branch; documented the optional config in the generated scaffold. + - Added config validation tests for valid custom branches and invalid Git branch names. +- `crates/tui/src/multi_pod.rs` + - Resolved Panel Orchestrator worktree branch from typed ticket config, falling back to `orchestration/`. + - Reused the resolved branch for worktree creation, existing-worktree validation, restore preparation, queue handoff checks, and restore diagnostics. + - Preserved refusal behavior for dirty/unknown/mismatched worktrees; mismatches are diagnostic and do not checkout/delete/repair. + - Added TUI tests covering custom configured branch creation/reuse/restore, invalid branch rejection before worktree Git operations, and mismatched restore rejection without checkout. + +Validation: +- `cargo test -p ticket config --lib` — passed. +- `cargo test -p tui ensure_and_restore_use_configured_orchestration_branch --lib` — passed. +- `cargo test -p tui invalid_configured_orchestration_branch_is_rejected_before_git_worktree_operations --lib` — passed. +- `cargo test -p tui restore_rejects_mismatched_configured_orchestration_branch_without_checkout --lib` — passed. +- `cargo fmt --check` — passed. +- `git diff --check` — passed. +- `cargo build -p yoi` — passed. +- `target/debug/yoi ticket doctor` — passed. + +Additional note: +- I also tried broader `cargo test -p tui orchestration --lib`; it compiled but includes the existing `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` failure, in code not touched by this change. The focused new orchestration-branch tests listed above pass. + +Commit: +- `1c54689e tui: configure orchestration branch` + +Residual risks: +- Branch validation is implemented as an internal Git-ref compatible validator rather than invoking `git check-ref-format`, so it is intentionally conservative (for example, rejects spaces and full `refs/...` names) before any worktree Git operation. + + --- From 9ad87dda254e61dfac53fe626b5651dd7592c780 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 14 Jun 2026 14:03:44 +0900 Subject: [PATCH 3/3] ticket: approve orchestration branch config --- .yoi/tickets/00001KV0X254D/item.md | 2 +- .yoi/tickets/00001KV0X254D/thread.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KV0X254D/item.md b/.yoi/tickets/00001KV0X254D/item.md index e56c51f7..46c3a732 100644 --- a/.yoi/tickets/00001KV0X254D/item.md +++ b/.yoi/tickets/00001KV0X254D/item.md @@ -2,7 +2,7 @@ title: 'Panel Orchestrator の orchestration branch 名を ticket.config.toml で設定可能にする' state: 'inprogress' created_at: '2026-06-13T16:29:25Z' -updated_at: '2026-06-13T18:59:21Z' +updated_at: '2026-06-13T19:05:48Z' assignee: null readiness: 'implementation_ready' risk_flags: ['config-schema', 'git-worktree', 'panel-orchestration'] diff --git a/.yoi/tickets/00001KV0X254D/thread.md b/.yoi/tickets/00001KV0X254D/thread.md index 6e977161..b814830a 100644 --- a/.yoi/tickets/00001KV0X254D/thread.md +++ b/.yoi/tickets/00001KV0X254D/thread.md @@ -108,4 +108,32 @@ Residual risks: - Branch validation is implemented as an internal Git-ref compatible validator rather than invoking `git check-ref-format`, so it is intentionally conservative (for example, rejects spaces and full `refs/...` names) before any worktree Git operation. +--- + + + +## Review: approve + +Approve. + +Evidence: +- `crates/ticket/src/config.rs` adds typed `[orchestration] branch = "..."` via `TicketOrchestrationConfig` and `GitBranchName`, with `deny_unknown_fields` preserved and scaffold documentation limited to the Ticket config surface. Missing config leaves `orchestration.branch_name() == None`, preserving the existing default. +- `crates/tui/src/multi_pod.rs` resolves the Panel Orchestrator branch once through `resolved_orchestration_worktree_layout`, falling back to `orchestration/`, and uses that layout for create/reuse, restore preparation/validation/diagnostics, and Queue handoff preflight/sync notices. +- Invalid configured branches fail during Ticket config loading / branch resolution before `.worktree` creation; existing dirty, unknown, wrong-branch, or unrelated-repo paths remain diagnostic-only and are not checked out, deleted, reset, or repaired. +- No hidden prompt/Profile injection or unrelated config surface drift was found; the change stays in typed Ticket config plus Panel orchestration worktree handling. +- Tests cover default resolution, configured branch creation/restore, invalid branch rejection before worktree operations, mismatched branch diagnostics without checkout, and Queue paths using the resolved branch. + +Validation performed: +- `git diff --check c4465a04..HEAD` — passed. +- `cargo fmt --check` — passed. +- `cargo test -p ticket config --lib` — passed (23 tests). +- `cargo test -p tui orchestration_worktree --lib` — passed (3 tests). +- `cargo test -p tui configured_orchestration_branch --lib` — passed (3 tests). +- `cargo test -p tui ticket_queue_action --lib` — passed (5 tests). +- `cargo test -p tui orchestration --lib` — failed only on the known unrelated `multi_pod::tests::orchestrator_launch_context_uses_orchestration_root_for_runtime_workspace` stale assertion; the branch/config-focused cases in that run passed. + +Residual risk: +- Branch validation is an internal git-ref-compatible validator rather than an invocation of `git check-ref-format`; reviewed as acceptable and conservative for this Ticket. + + ---