merge: integrate orchestrator panel queue sync

This commit is contained in:
Keisuke Hirata 2026-06-12 20:50:33 +09:00
commit edb736f4ce
No known key found for this signature in database
7 changed files with 915 additions and 38 deletions

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260612-091119-1","ticket_id":"00001KTVPS6K3","kind":"waiting_capacity_note","note":"Workspace Panel Queue notification was received, but this Orchestrator cannot safely accept implementation yet. The Orchestrator Ticket backend still reads the Ticket as `ready`, while the root workspace has unsynced/uncommitted queued Ticket changes for `00001KTVPS6K3`; root workspace is dirty (`.yoi/tickets/00001KTVPS6K3/*` plus `crates/tui/src/multi_pod.rs`). Current active work `00001KTWPE3KQ` is also in review and is specifically fixing the Panel Queue durable handoff/sync path. Re-route this Ticket after the Queue handoff is safely synced/committed and active review/merge state is clear.","author":"orchestrator","at":"2026-06-12T09:11:19Z"}

View File

@ -2,7 +2,7 @@
title: 'Ticket role launch inputを短縮し、role behaviorをInstruction/Workflowへ分離する'
state: 'ready'
created_at: '2026-06-11T16:03:28Z'
updated_at: '2026-06-11T16:03:33Z'
updated_at: '2026-06-12T09:11:30Z'
assignee: null
risk_flags: ['prompt-context', 'workflow-boundary', 'role-launch']
---

View File

@ -21,3 +21,30 @@ Ticket role launch の初回 user message を短縮し、情報所管を Instruc
要件・受け入れ条件・境界判断が揃ったため、Orchestrator routing 可能。実装はユーザーが panel で queue した後に開始する。
---
<!-- event: decision author: orchestrator at: 2026-06-12T09:11:30Z -->
## Decision
Routing decision: blocked_by_dependency_or_missing_authority
Reason:
- Workspace Panel Queue notification was received, but this Orchestrator backend still reads the Ticket as `ready`, not `queued`.
- The root workspace has unsynced/uncommitted queue-related changes for this Ticket and is dirty, including `.yoi/tickets/00001KTVPS6K3/*` and `crates/tui/src/multi_pod.rs`.
- Active in-progress work `00001KTWPE3KQ` is currently fixing the Panel Queue durable handoff/sync path and is in reviewer handoff; accepting this new Ticket before that path is reviewed/merged would require manual sync/queue recovery and could mix queue-side effects with unrelated dirty root changes.
Evidence checked:
- TicketShow `00001KTVPS6K3`: Orchestrator backend state is `ready`.
- TicketRelationQuery: no relation blockers.
- TicketOrchestrationPlanQuery: no prior plan records before this routing note.
- Root/orchestrator git state: Orchestrator branch has local routing record changes; root workspace is dirty with this Ticket's `.yoi` files and `crates/tui/src/multi_pod.rs`.
- Visible Pods: active reviewer `yoi-reviewer-panel-queue-sync` for `00001KTWPE3KQ`.
Next action:
- Leave this Ticket unaccepted for implementation in this Orchestrator pass.
- Re-route after the Panel Queue durable handoff work is resolved and the root/orchestration Ticket state is synchronized cleanly, or after a human explicitly instructs manual recovery for the queued root-side changes.
Escalate if:
- The queued root-side changes should be manually committed/synced despite the current dirty workspace and active Queue-handoff fix.
---

View File

@ -0,0 +1 @@
{"id":"orch-plan-20260612-084329-1","ticket_id":"00001KTWPE3KQ","kind":"accepted_plan","note":"Role Pods は今回起動しない。明示 follow-up まで queued のまま保持する。","accepted_plan":{"summary":"Routing では implementation_ready と判断した。ただし今回の launch instruction は role Pod spawn を explicit follow-up まで待つ指定のため、現時点では queued のまま保持し、queued -> inprogress、worktree 作成、coder/reviewer spawn、merge/close は行わない。実装開始時は side effect 前に TicketShow / relation / orchestration plan / git/worktree state を再確認し、問題なければ queued -> inprogress を記録してから進める。実装対象は Panel Queue action を root/dev 側 Ticket mutation + Queue commit + orchestration worktree ff-only sync + sync 確認後 Orchestrator notify/kick という durable handoff にすること。","branch":"ticket/panel-queue-orchestrator-sync","worktree":"/home/hare/Projects/yoi/.worktree/panel-queue-orchestrator-sync","role_plan":"次の明示 follow-up 後に Orchestrator が worktree-workflow で実装 worktree を作り、coder はその worktree に narrow write scope、reviewer は read-only scopeで sibling として起動する。Queue commit の対象差分限定、root/orchestration worktree identity checks、ff-only sync、dirty/divergent workspace block、workspace_root と cwd の分離維持を reviewer focus とする。"},"author":"orchestrator","at":"2026-06-12T08:43:29Z"}

View File

@ -1,8 +1,8 @@
---
title: 'Panel Queue時にdevとOrchestrator worktreeを同期する'
state: 'queued'
state: 'inprogress'
created_at: '2026-06-12T01:16:39Z'
updated_at: '2026-06-12T02:39:25Z'
updated_at: '2026-06-12T09:12:25Z'
assignee: null
queued_by: 'yoi ticket'
queued_at: '2026-06-12T02:39:25Z'

View File

@ -30,4 +30,230 @@ Marked ready by `yoi ticket state`.
Ticket を `yoi ticket` が queued にしました。
---
<!-- event: decision author: orchestrator at: 2026-06-12T08:43:58Z -->
## Decision
Routing decision: implementation_ready
Reason:
- Ticket body は Panel Queue action の目的、binding invariants、失敗時の block 条件、非目標、受け入れ条件を observable に記録している。
- `ready -> queued` は既に記録済みで、relation blocker はない。
- 設計境界は「Ticket tools は cwd 基準」「Panel は root/dev 側の Queue commit を authority とし、Orchestrator kick 前に orchestration worktree を ff-only sync する」「自動 conflict resolution / stash / rebase はしない」として十分に固定されている。
- 残る不確実性は既存 Panel/Ticket/git helper 実装への bounded implementation investigation と local tactic selection に閉じている。
Evidence checked:
- Ticket body / thread / artifacts: `00001KTWPE3KQ` の本文、state/event、artifact なしを確認。
- TicketRelationQuery: outgoing/incoming relation なし、blocker なし。
- TicketOrchestrationPlanQuery: 既存 record なし。今回 `orch-plan-20260612-084329-1` として accepted plan を記録。
- Workspace state: Orchestrator worktree は `## ticket/orchestrator-progress-companion-notify`、root workspace は `## develop`、どちらも dirty 出力なし。
- Code map: `crates/tui/src/workspace_panel.rs``crates/tui/src/multi_pod.rs` 周辺に Panel Queue / workspace panel / orchestrator launch の実装面があることを確認。
- Visible Pods: 現在 visible Pod はこの Orchestrator のみで、coder/reviewer は未起動。
IntentPacket:
Intent:
- Panel Queue action を、root/dev 側の `ready -> queued` commit と orchestration worktree への ff-only sync を完了してから Orchestrator notify/kick する durable handoff にする。
Binding decisions / invariants:
- root workspace の canonical top-level、orchestration worktree の canonical top-level、共通 git dir、期待 branch、dirty 状態、対象 Ticket の `ready` 状態、orchestration_head が root_head の ancestor であることを mutation 前に検証する。
- Queue commit は root/dev 側で対象 Ticket record だけを stage/commit する。
- orchestration worktree への自動同期は `git -C <orchestration_worktree> merge --ff-only <queue_commit_sha>` のみに限定する。
- merge commit、rebase、stash、patch apply、conflict resolution、dirty cleanup は Panel Queue action では行わない。
- Orchestrator notify/restore/kick は orchestration worktree HEAD が Queue commit を含み、orchestration worktree 側 Ticket backend で対象 Ticket が `queued` として読めることを確認した後だけ行う。
- Ticket tools が cwd 基準で動く設計と、workspace_root / cwd / orchestration worktree / merge target の分離を崩さない。
Requirements / acceptance criteria:
- Queue 成功時、Panel は queued Ticket id、dev 側 Queue commit sha、orchestration worktree sync 結果、Orchestrator notify/kick の有無を表示する。
- root dirty、orchestration dirty、branch divergence、non-ff sync required、Ticket state mismatch、worktree identity mismatch では Queue を block し、失敗した check 名と対象 path / branch / Ticket id を表示する。
- `nix build .#yoi` が通る。
Implementation latitude:
- 既存 Panel action / Git helper / Ticket backend 呼び出しのどこへ checks と sync 処理を分割するかは coder が調査して選んでよい。
- 表示文言や内部 helper 名は、失敗条件が具体的に伝わり、既存 UX と整合する範囲で調整してよい。
- Focused tests / unit coverage の追加位置は既存の TUI/client/test 構造に合わせてよい。
Escalate if:
- Queue action が root/dev 側 Ticket commit 以外の変更を commit/stage する必要が出る。
- ff-only 以外の sync、stash/rebase/conflict resolution、dirty cleanup を自動化しないと成立しない。
- Ticket tools の cwd 基準設計、Pod workspace_root/cwd 分離、dedicated Orchestrator worktree の authority boundary を変える必要が出る。
- Panel が Orchestrator routing/acceptance を代行する必要が出る。
Validation:
- 変更に対応する focused test / existing relevant cargo test。
- `cargo fmt --check`
- `git diff --check`
- `target/debug/yoi ticket doctor`
- runtime resource / packaging / prompt へ触れる場合、または最終確認として `nix build .#yoi`
Current code map:
- 主な調査対象: `crates/tui/src/workspace_panel.rs`, `crates/tui/src/multi_pod.rs`
- Ticket backend / state mutation / CLI helper を変更する場合は `crates/ticket``crates/yoi` 側の既存 typed Ticket path に合わせる。
- Orchestrator/main `.yoi` project records、memory/local/runtime/secret-like `.yoi` paths は implementation worktree 側で勝手に生成・編集しない。
Critical risks / reviewer focus:
- root workspace と orchestration worktree を取り違えないこと。
- dirty/divergent 状態で mutation/commit/notify しないこと。
- Queue commit の差分が対象 Ticket record 以外へ広がらないこと。
- ff-only 限定を破らないこと。
- notify/kick が sync 完了前に走らないこと。
- cwd 基準 Ticket tools と workspace_root 分離を壊さないこと。
Next action:
- 今回の launch instruction は role Pod spawn を explicit follow-up まで待つ指定なので、ここでは `queued -> inprogress`、worktree 作成、coder/reviewer spawn、merge/close は行わない。
- 明示 follow-up があれば、side effect 前に TicketShow / relation / orchestration plan / git/worktree state を再確認し、問題がなければ `queued -> inprogress` を記録してから `multi-agent-workflow` に接続する。
---
<!-- event: state_changed author: orchestrator at: 2026-06-12T08:45:20Z from: queued to: inprogress reason: orchestrator_acceptance field: state -->
## State changed
Routing accepted for implementation. Ticket body/thread, relation blockers, orchestration plan, visible Pods, and root/orchestration git state were rechecked. No unresolved dependency/blocker or missing planning decision was found. Implementation side effects will start only after this accepted `queued -> inprogress` transition is recorded; worktree and sibling coder/reviewer routing will use the accepted plan `orch-plan-20260612-084329-1`.
---
<!-- event: plan author: orchestrator at: 2026-06-12T08:45:59Z -->
## Plan
Implementation worktree created for multi-agent handoff.
- Ticket: `00001KTWPE3KQ`
- Branch: `ticket/panel-queue-orchestrator-sync`
- Worktree: `/home/hare/Projects/yoi/.worktree/panel-queue-orchestrator-sync`
- Base: Orchestrator workspace HEAD `de0f533b` (`orchestration/yoi-orchestrator`), including the accepted routing record and `queued -> inprogress` transition.
- Next: spawn sibling coder with narrow write scope to the implementation worktree. Reviewer will be started after coder evidence is available.
---
<!-- event: plan author: orchestrator at: 2026-06-12T08:47:16Z -->
## Plan
Coder delegated.
- Coder Pod: `yoi-coder-panel-queue-sync`
- Worktree: `/home/hare/Projects/yoi/.worktree/panel-queue-orchestrator-sync`
- Branch: `ticket/panel-queue-orchestrator-sync`
- Scope: read `/home/hare/Projects/yoi`, write `/home/hare/Projects/yoi/.worktree/panel-queue-orchestrator-sync`
- Task: implement Panel Queue durable handoff with root/dev Queue commit, orchestration worktree ff-only sync, post-sync Ticket verification, and notify/kick ordering. Coder was instructed not to edit Orchestrator/main `.yoi` records or generated memory/runtime/secret-like paths.
---
<!-- event: implementation_report author: orchestrator at: 2026-06-12T09:05:32Z -->
## Implementation report
Coder completed initial implementation.
- Coder Pod: `yoi-coder-panel-queue-sync`
- Implementation branch: `ticket/panel-queue-orchestrator-sync`
- Implementation commit: `04a3c6e0` (`tui: make panel queue handoff durable`)
- Worktree status checked clean.
Orchestrator validation performed after coder handoff:
- `git diff --check HEAD^..HEAD` — pass
- `cargo test -p tui ticket_queue_action -- --nocapture` — pass
- `cargo fmt --check` — pass
- `cargo check -p tui --all-targets` — pass
- `nix build .#yoi` — pass
- `./result/bin/yoi ticket doctor``doctor: ok`
Next: delegate independent reviewer against the recorded Ticket intent, accepted plan, implementation commit, and validation evidence.
---
<!-- event: plan author: orchestrator at: 2026-06-12T09:06:03Z -->
## Plan
Reviewer delegated.
- Reviewer Pod: `yoi-reviewer-panel-queue-sync`
- Review target: `/home/hare/Projects/yoi/.worktree/panel-queue-orchestrator-sync`, branch `ticket/panel-queue-orchestrator-sync`, commit `04a3c6e0`
- Scope: read-only `/home/hare/Projects/yoi`
- Reviewer focus: durable Queue handoff ordering, root/orchestration worktree identity and cleanliness checks, ff-only-only sync, pathscope of Queue commit, no auto conflict/stash/rebase, notify only after sync + Ticket verification, and whether `--no-verify` is acceptable.
---
<!-- event: implementation_report author: orchestrator at: 2026-06-12T09:12:16Z -->
## Implementation report
Merge-ready dossier (human/parent merge authority required before merge/close/cleanup)
Ticket:
- `00001KTWPE3KQ` — Panel Queue時にdevとOrchestrator worktreeを同期する
Branch / worktree:
- Branch: `ticket/panel-queue-orchestrator-sync`
- Worktree: `/home/hare/Projects/yoi/.worktree/panel-queue-orchestrator-sync`
- Implementation commit: `04a3c6e0` (`tui: make panel queue handoff durable`)
Intent / invariant check:
- Panel Queue action is now implemented as root/dev-side Ticket `ready -> queued` mutation plus Queue commit, ff-only sync into the dedicated orchestration worktree, then Orchestrator notify/kick only after sync and Ticket-state verification.
- Automatic sync path is constrained to `git -C <orchestration_worktree> merge --ff-only <queue_commit_sha>`.
- No automatic merge commit, rebase, stash, patch apply, conflict resolution, or dirty cleanup path was found.
- Runtime workspace root / cwd / orchestration worktree / merge target separation remains path-explicit.
Implementation summary:
- `crates/tui/src/multi_pod.rs` changed.
- Added Panel Queue preflight for root/orchestration Git identity, branch state, shared common dir, clean worktrees, root Ticket `ready` state, and orchestration-head ancestry.
- Added root-side Queue commit creation scoped to the target Ticket record.
- Added ff-only orchestration worktree sync and post-sync verification that orchestration HEAD contains the Queue commit and orchestration Ticket backend reads the Ticket as `queued`.
- Added focused tests for successful Queue handoff, dirty root blocking, and orchestration branch divergence blocking.
Coder evidence:
- Coder Pod: `yoi-coder-panel-queue-sync`
- Commit: `04a3c6e0`
- Coder reported `cargo fmt --check`, `cargo check -p tui`, `cargo test -p tui ticket_queue_action -- --nocapture`, `git diff --check`, `nix build .#yoi`, and `./result/bin/yoi ticket doctor` passed. Full `cargo test -p tui` had unrelated existing failures reported by coder.
Orchestrator validation evidence:
- `git diff --check HEAD^..HEAD` — pass
- `cargo test -p tui ticket_queue_action -- --nocapture` — pass
- `cargo fmt --check` — pass
- `cargo check -p tui --all-targets` — pass
- `nix build .#yoi` — pass
- `./result/bin/yoi ticket doctor``doctor: ok`
Reviewer evidence:
- Reviewer Pod: `yoi-reviewer-panel-queue-sync`
- Verdict: approve.
- Reviewer reran `git diff --check HEAD^..HEAD` and `cargo test -p tui ticket_queue_action -- --nocapture`; both passed.
- Reviewer found pre-mutation checks, root-side mutation/commit, ff-only sync, notify-after-verify ordering, path-explicit workspace separation, and failure-message specificity acceptable.
- Reviewer did not treat `--no-verify` as a blocker for the automatic target-Ticket-record metadata commit; noted the tradeoff that local commit hooks do not run.
Blockers fixed / rejected findings:
- No reviewer blocker.
- `--no-verify` retained as non-blocking per reviewer rationale: hook-side effects are less acceptable for this narrow automatic Ticket metadata commit than skipping local hooks, with pathscope checks and post-validation covering the handoff.
Residual risks:
- If sync or verify fails after the root Queue commit is created, the root/dev side remains durably queued and committed; manual follow-up may be required.
- Clean-worktree checks are preflight checks, not a global lock against concurrent filesystem/git changes during the action.
- Current root workspace is dirty from separate Panel/user activity (`00001KTVPS6K3` queue attempt and `crates/tui/src/multi_pod.rs` local changes), so merge target safety must be rechecked before any merge.
Dirty state:
- Implementation worktree was checked clean after commit.
- Orchestrator worktree has only Ticket dossier changes before this record is committed.
- Root/merge-target workspace is dirty and must not be used for merge until understood/cleaned or explicitly authorized.
Decision needs:
- Explicit merge-completion authority is required before merging `ticket/panel-queue-orchestrator-sync` into the recorded merge target, marking final review/done/close, stopping cleanup, or deleting the worktree/branch.
- If authority is granted later, recheck branch/worktree/commit identity, independent approval, target dirty state, and rerun post-merge validation before Ticket completion.
---
<!-- event: implementation_report author: orchestrator at: 2026-06-12T09:12:25Z -->
## Implementation report
Coder/reviewer Pods stopped after merge-ready dossier was recorded.
- Stopped: `yoi-coder-panel-queue-sync`
- Stopped: `yoi-reviewer-panel-queue-sync`
- Implementation worktree/branch are retained for explicit merge-completion authority.
---

View File

@ -2404,26 +2404,14 @@ async fn dispatch_ticket_action(
match request.action {
NextUserAction::Queue => {
if current_ticket.workflow_state != TicketWorkflowState::Ready {
return Err(TicketActionError::Stale(
"Queue is only valid while state is ready; reload and retry".to_string(),
));
}
backend
.queue_ready(
TicketIdOrSlug::Id(request.ticket_id.clone()),
"workspace-panel",
)
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
let notification =
notify_workspace_orchestrator(request.orchestrator, current_ticket).await;
Ok(TicketActionOutcome {
notice: format!(
"Queued Ticket {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
current_ticket.id,
notification.sentence()
),
})
dispatch_panel_queue(
&request.workspace_root,
&backend,
&request.ticket_id,
request.orchestrator,
current_ticket,
)
.await
}
NextUserAction::Close => unreachable!("Close action is handled before row dispatch"),
NextUserAction::Clarify
@ -2439,6 +2427,566 @@ async fn dispatch_ticket_action(
}
}
async fn dispatch_panel_queue(
workspace_root: &Path,
backend: &LocalTicketBackend,
ticket_id: &str,
orchestrator: Option<OrchestratorNotifyTarget>,
current_ticket: &crate::workspace_panel::TicketPanelEntry,
) -> Result<TicketActionOutcome, TicketActionError> {
if current_ticket.workflow_state != TicketWorkflowState::Ready {
return Err(TicketActionError::Stale(format!(
"Queue handoff check `root-ticket-state` failed for Ticket {ticket_id} at {}: state is {}, expected ready; reload and retry",
backend.root().display(),
current_ticket.workflow_state.as_str()
)));
}
let preflight = prepare_panel_queue_handoff(workspace_root, backend, ticket_id)?;
backend
.queue_ready(TicketIdOrSlug::Id(ticket_id.to_owned()), "workspace-panel")
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
let commit = commit_panel_queue_ticket_record(&preflight)?;
let sync = sync_panel_queue_to_orchestration(&preflight, &commit)?;
verify_panel_queue_synced(&preflight, &commit)?;
let notification = notify_workspace_orchestrator(orchestrator, current_ticket).await;
Ok(TicketActionOutcome {
notice: format!(
"Queued Ticket {}; root Queue commit {}; orchestration sync {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
ticket_id,
commit.sha,
sync.sentence(),
notification.sentence()
),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PanelQueueHandoffPreflight {
ticket_id: String,
root_top_level: PathBuf,
orchestration: OrchestrationWorktreeLayout,
ticket_record_dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PanelQueueCommit {
sha: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PanelQueueSync {
path: PathBuf,
branch: String,
head: String,
}
impl PanelQueueSync {
fn sentence(&self) -> String {
format!(
"ff-only synced {} ({}) to {}",
self.path.display(),
self.branch,
self.head
)
}
}
fn prepare_panel_queue_handoff(
workspace_root: &Path,
backend: &LocalTicketBackend,
ticket_id: &str,
) -> Result<PanelQueueHandoffPreflight, TicketActionError> {
let root_top_level = git_top_level(workspace_root).map_err(|message| {
queue_check_failed("root-worktree-identity", ticket_id, workspace_root, message)
})?;
let expected_root = workspace_root.canonicalize().map_err(|error| {
queue_check_failed(
"root-worktree-identity",
ticket_id,
workspace_root,
format!("could not canonicalize root workspace path: {error}"),
)
})?;
if root_top_level != expected_root {
return Err(queue_check_failed(
"root-worktree-identity",
ticket_id,
workspace_root,
format!(
"Git top-level is {}, expected root workspace {}",
root_top_level.display(),
expected_root.display()
),
));
}
let root_branch = git_current_branch(&root_top_level).map_err(|message| {
queue_check_failed("root-branch", ticket_id, &root_top_level, message)
})?;
let root_branch = root_branch.ok_or_else(|| {
queue_check_failed(
"root-branch",
ticket_id,
&root_top_level,
"root workspace is detached; expected merge target branch".to_string(),
)
})?;
ensure_git_effective_user(&root_top_level).map_err(|message| {
queue_check_failed("root-git-user", ticket_id, &root_top_level, message)
})?;
ensure_git_clean("root-clean", ticket_id, &root_top_level)?;
let orchestration = orchestration_worktree_layout(&root_top_level);
if !orchestration.path.exists() {
return Err(queue_check_failed(
"orchestration-worktree-identity",
ticket_id,
&orchestration.path,
"dedicated orchestration worktree is missing; open the Panel with Orchestrator support before Queue".to_string(),
));
}
validate_existing_orchestration_worktree(&root_top_level, &orchestration).map_err(
|message| {
queue_check_failed(
"orchestration-worktree-identity",
ticket_id,
&orchestration.path,
message,
)
},
)?;
ensure_git_clean("orchestration-clean", ticket_id, &orchestration.path)?;
let orchestration_branch = git_current_branch(&orchestration.path).map_err(|message| {
queue_check_failed(
"orchestration-branch",
ticket_id,
&orchestration.path,
message,
)
})?;
if orchestration_branch.as_deref() != Some(orchestration.branch.as_str()) {
return Err(queue_check_failed(
"orchestration-branch",
ticket_id,
&orchestration.path,
format!(
"orchestration branch is {:?}, expected {}",
orchestration_branch, orchestration.branch
),
));
}
let root_common = git_common_dir(&root_top_level).map_err(|message| {
queue_check_failed("shared-common-dir", ticket_id, &root_top_level, message)
})?;
let orchestration_common = git_common_dir(&orchestration.path).map_err(|message| {
queue_check_failed("shared-common-dir", ticket_id, &orchestration.path, message)
})?;
if root_common != orchestration_common {
return Err(queue_check_failed(
"shared-common-dir",
ticket_id,
&orchestration.path,
format!(
"orchestration common dir {} differs from root common dir {}",
orchestration_common.display(),
root_common.display()
),
));
}
ensure_ticket_state(
backend,
ticket_id,
TicketWorkflowState::Ready,
"root-ticket-state",
&root_top_level,
)?;
let orchestration_head = git_rev_parse(&orchestration.path, "HEAD").map_err(|message| {
queue_check_failed("branch-divergence", ticket_id, &orchestration.path, message)
})?;
let root_head = git_rev_parse(&root_top_level, "HEAD").map_err(|message| {
queue_check_failed("branch-divergence", ticket_id, &root_top_level, message)
})?;
ensure_git_ancestor(&root_top_level, &orchestration_head, &root_head).map_err(|message| {
queue_check_failed(
"branch-divergence",
ticket_id,
&orchestration.path,
format!(
"orchestration HEAD {orchestration_head} is not an ancestor of root branch {root_branch} HEAD {root_head}: {message}"
),
)
})?;
let ticket_record_dir = backend.root().join(ticket_id);
if !ticket_record_dir.join("item.md").is_file() {
return Err(queue_check_failed(
"target-ticket-record",
ticket_id,
&ticket_record_dir,
"target Ticket item.md is missing".to_string(),
));
}
Ok(PanelQueueHandoffPreflight {
ticket_id: ticket_id.to_string(),
root_top_level,
orchestration,
ticket_record_dir,
})
}
fn commit_panel_queue_ticket_record(
preflight: &PanelQueueHandoffPreflight,
) -> Result<PanelQueueCommit, TicketActionError> {
let ticket_rel = path_relative_to_root(
&preflight.root_top_level,
&preflight.ticket_record_dir,
"target-ticket-record",
&preflight.ticket_id,
)?;
let mut add = Command::new("git");
add.arg("-C")
.arg(&preflight.root_top_level)
.arg("add")
.arg("--")
.arg(&ticket_rel);
run_git_command(add, "stage Queue Ticket record").map_err(|message| {
queue_check_failed(
"queue-commit-stage",
&preflight.ticket_id,
&preflight.ticket_record_dir,
message,
)
})?;
let staged = git_capture(
&preflight.root_top_level,
&["diff", "--cached", "--name-only"],
"list staged files",
)
.map_err(|message| {
queue_check_failed(
"queue-commit-pathscope",
&preflight.ticket_id,
&preflight.root_top_level,
message,
)
})?;
let staged_paths = staged
.lines()
.filter(|line| !line.trim().is_empty())
.collect::<Vec<_>>();
if staged_paths.is_empty() {
return Err(queue_check_failed(
"queue-commit-pathscope",
&preflight.ticket_id,
&preflight.ticket_record_dir,
"Queue mutation produced no staged Ticket record changes".to_string(),
));
}
let ticket_rel_string = git_path_string(&ticket_rel);
let outside = staged_paths
.iter()
.find(|path| !git_status_path_is_inside(path, &ticket_rel_string));
if let Some(path) = outside {
return Err(queue_check_failed(
"queue-commit-pathscope",
&preflight.ticket_id,
&preflight.root_top_level,
format!(
"staged path {path} is outside target Ticket record {}",
ticket_rel.display()
),
));
}
let message = format!("ticket: queue {}", preflight.ticket_id);
let mut commit = Command::new("git");
commit
.arg("-C")
.arg(&preflight.root_top_level)
.arg("commit")
.arg("--no-verify")
.arg("-m")
.arg(message)
.arg("--")
.arg(&ticket_rel);
run_git_command(commit, "commit Queue Ticket record").map_err(|message| {
queue_check_failed(
"queue-commit-create",
&preflight.ticket_id,
&preflight.root_top_level,
message,
)
})?;
let sha = git_rev_parse(&preflight.root_top_level, "HEAD").map_err(|message| {
queue_check_failed(
"queue-commit-create",
&preflight.ticket_id,
&preflight.root_top_level,
message,
)
})?;
Ok(PanelQueueCommit { sha })
}
fn sync_panel_queue_to_orchestration(
preflight: &PanelQueueHandoffPreflight,
commit: &PanelQueueCommit,
) -> Result<PanelQueueSync, TicketActionError> {
ensure_git_clean(
"orchestration-clean-before-sync",
&preflight.ticket_id,
&preflight.orchestration.path,
)?;
let mut merge = Command::new("git");
merge
.arg("-C")
.arg(&preflight.orchestration.path)
.arg("merge")
.arg("--ff-only")
.arg(&commit.sha);
run_git_command(
merge,
"ff-only sync Queue commit into orchestration worktree",
)
.map_err(|message| {
queue_check_failed(
"orchestration-ff-only-sync",
&preflight.ticket_id,
&preflight.orchestration.path,
message,
)
})?;
let head = git_rev_parse(&preflight.orchestration.path, "HEAD").map_err(|message| {
queue_check_failed(
"orchestration-ff-only-sync",
&preflight.ticket_id,
&preflight.orchestration.path,
message,
)
})?;
Ok(PanelQueueSync {
path: preflight.orchestration.path.clone(),
branch: preflight.orchestration.branch.clone(),
head,
})
}
fn verify_panel_queue_synced(
preflight: &PanelQueueHandoffPreflight,
commit: &PanelQueueCommit,
) -> Result<(), TicketActionError> {
let head = git_rev_parse(&preflight.orchestration.path, "HEAD").map_err(|message| {
queue_check_failed(
"orchestration-sync-verify",
&preflight.ticket_id,
&preflight.orchestration.path,
message,
)
})?;
ensure_git_ancestor(&preflight.orchestration.path, &commit.sha, &head).map_err(|message| {
queue_check_failed(
"orchestration-sync-verify",
&preflight.ticket_id,
&preflight.orchestration.path,
format!(
"orchestration HEAD {head} does not contain Queue commit {}: {message}",
commit.sha
),
)
})?;
let config = TicketConfig::load_workspace(&preflight.orchestration.path).map_err(|error| {
queue_check_failed(
"orchestration-ticket-state",
&preflight.ticket_id,
&preflight.orchestration.path,
error.to_string(),
)
})?;
let backend = LocalTicketBackend::new(config.backend_root())
.with_record_language(config.ticket_record_language());
ensure_ticket_state(
&backend,
&preflight.ticket_id,
TicketWorkflowState::Queued,
"orchestration-ticket-state",
&preflight.orchestration.path,
)
}
fn ensure_ticket_state(
backend: &LocalTicketBackend,
ticket_id: &str,
expected: TicketWorkflowState,
check: &'static str,
path: &Path,
) -> Result<(), TicketActionError> {
let ticket = backend
.show(TicketIdOrSlug::Id(ticket_id.to_string()))
.map_err(|error| queue_check_failed(check, ticket_id, path, error.to_string()))?;
if ticket.meta.workflow_state != expected {
return Err(queue_check_failed(
check,
ticket_id,
path,
format!(
"state is {}, expected {}",
ticket.meta.workflow_state.as_str(),
expected.as_str()
),
));
}
Ok(())
}
fn ensure_git_clean(
check: &'static str,
ticket_id: &str,
path: &Path,
) -> Result<(), TicketActionError> {
let status = git_status_porcelain(path)
.map_err(|message| queue_check_failed(check, ticket_id, path, message))?;
if status.is_empty() {
return Ok(());
}
let detail = status.into_iter().take(6).collect::<Vec<_>>().join("; ");
Err(queue_check_failed(
check,
ticket_id,
path,
format!("worktree is dirty: {detail}"),
))
}
fn ensure_git_effective_user(path: &Path) -> Result<(), String> {
let name = git_capture(path, &["config", "user.name"], "read git user.name")?;
let email = git_capture(path, &["config", "user.email"], "read git user.email")?;
if name.trim().is_empty() || email.trim().is_empty() {
return Err("git user.name and user.email must be configured before the Panel creates a Queue commit".to_string());
}
Ok(())
}
fn git_rev_parse(path: &Path, rev: &str) -> Result<String, String> {
git_capture(path, &["rev-parse", rev], "resolve Git revision")
}
fn git_status_porcelain(path: &Path) -> Result<Vec<String>, String> {
let output = git_capture(
path,
&["status", "--porcelain", "--untracked-files=normal"],
"read Git status",
)?;
Ok(output.lines().map(|line| line.to_string()).collect())
}
fn ensure_git_ancestor(path: &Path, ancestor: &str, descendant: &str) -> Result<(), String> {
let status = Command::new("git")
.arg("-C")
.arg(path)
.arg("merge-base")
.arg("--is-ancestor")
.arg(ancestor)
.arg(descendant)
.status()
.map_err(|error| format!("could not run git merge-base --is-ancestor: {error}"))?;
if status.success() {
Ok(())
} else {
Err(format!(
"git merge-base --is-ancestor {ancestor} {descendant} exited with {status}"
))
}
}
fn git_capture(path: &Path, args: &[&str], action: &str) -> Result<String, String> {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.output()
.map_err(|error| {
format!(
"could not run git to {action} at {}: {error}",
path.display()
)
})?;
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if stderr.is_empty() { stdout } else { stderr };
Err(format!(
"git failed to {action} at {}: {detail}",
path.display()
))
}
fn path_relative_to_root(
root: &Path,
path: &Path,
check: &'static str,
ticket_id: &str,
) -> Result<PathBuf, TicketActionError> {
let canonical = path.canonicalize().map_err(|error| {
queue_check_failed(
check,
ticket_id,
path,
format!("could not canonicalize path: {error}"),
)
})?;
canonical
.strip_prefix(root)
.map(PathBuf::from)
.map_err(|_| {
queue_check_failed(
check,
ticket_id,
path,
format!(
"path {} is outside root Git top-level {}",
canonical.display(),
root.display()
),
)
})
}
fn git_path_string(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn git_status_path_is_inside(path: &str, parent: &str) -> bool {
path == parent
|| path
.strip_prefix(parent)
.is_some_and(|rest| rest.starts_with('/'))
}
fn queue_check_failed(
check: &'static str,
ticket_id: &str,
path: &Path,
message: impl Into<String>,
) -> TicketActionError {
TicketActionError::Stale(format!(
"Queue handoff check `{check}` failed for Ticket {ticket_id} at {}: {}",
path.display(),
message.into()
))
}
fn dispatch_panel_close(
backend: &LocalTicketBackend,
ticket_id: &str,
@ -3675,21 +4223,11 @@ mod tests {
fn init_test_repo(root: &Path) {
std::fs::create_dir_all(root).unwrap();
run_test_git(root, &["init"]).unwrap();
run_test_git(root, &["config", "user.email", "test@example.invalid"]).unwrap();
run_test_git(root, &["config", "user.name", "Yoi Test"]).unwrap();
std::fs::write(root.join("README.md"), "repo").unwrap();
run_test_git(root, &["add", "README.md"]).unwrap();
run_test_git(
root,
&[
"-c",
"user.email=test@example.invalid",
"-c",
"user.name=Yoi Test",
"commit",
"-m",
"init",
],
)
.unwrap();
run_test_git(root, &["commit", "-m", "init"]).unwrap();
}
#[test]
@ -3728,6 +4266,16 @@ mod tests {
) -> (TempDir, String, LocalTicketBackend) {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".yoi")).unwrap();
fs::write(
temp.path().join(".gitignore"),
".worktree/\n.yoi/tickets/.ticket-backend.lock\n",
)
.unwrap();
fs::write(
temp.path().join(".yoi/.gitignore"),
"tickets/.ticket-backend.lock\n",
)
.unwrap();
fs::write(
temp.path().join(".yoi/ticket.config.toml"),
"[backend]\nprovider = \"builtin:yoi_local\"\nroot = \".yoi/tickets\"\n",
@ -3746,6 +4294,21 @@ mod tests {
ticket_workspace(title, TicketWorkflowState::Ready, |_| {})
}
fn ready_ticket_git_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) {
let (temp, ticket_id, backend) = ready_ticket_workspace(title);
run_test_git(temp.path(), &["init"]).unwrap();
run_test_git(
temp.path(),
&["config", "user.email", "test@example.invalid"],
)
.unwrap();
run_test_git(temp.path(), &["config", "user.name", "Yoi Test"]).unwrap();
run_test_git(temp.path(), &["add", "."]).unwrap();
run_test_git(temp.path(), &["commit", "-m", "seed tickets"]).unwrap();
ensure_orchestration_worktree(temp.path()).unwrap();
(temp, ticket_id, backend)
}
fn done_ticket_workspace(title: &str) -> (TempDir, String, LocalTicketBackend) {
ticket_workspace(title, TicketWorkflowState::Done, |_| {})
}
@ -3765,14 +4328,23 @@ mod tests {
#[tokio::test]
async fn ticket_queue_action_transitions_ready_ticket_and_authorizes_orchestrator_routing() {
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-queue");
let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-queue");
let root_head_before = git_rev_parse(temp.path(), "HEAD").unwrap();
let outcome =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
.await
.unwrap();
let root_head_after = git_rev_parse(temp.path(), "HEAD").unwrap();
let layout = orchestration_worktree_layout(temp.path());
let orchestration_head = git_rev_parse(&layout.path, "HEAD").unwrap();
assert_ne!(root_head_after, root_head_before);
assert_eq!(orchestration_head, root_head_after);
assert!(outcome.notice.contains("Queued Ticket"));
assert!(outcome.notice.contains(&root_head_after));
assert!(outcome.notice.contains("root Queue commit"));
assert!(outcome.notice.contains("ff-only synced"));
assert!(
outcome
.notice
@ -3780,7 +4352,7 @@ mod tests {
);
assert!(outcome.notice.contains("queued -> inprogress acceptance"));
assert!(!outcome.notice.contains("No implementation was started"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap();
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Queued);
assert_eq!(ticket.meta.queued_by.as_deref(), Some("workspace-panel"));
assert!(ticket.meta.queued_at.is_some());
@ -3795,6 +4367,56 @@ mod tests {
})
.expect("queue state_changed event is recorded");
assert_eq!(state_change.author.as_deref(), Some("workspace-panel"));
let orchestration_backend = LocalTicketBackend::new(layout.path.join(".yoi/tickets"));
let orchestration_ticket = orchestration_backend
.show(TicketIdOrSlug::Id(ticket_id))
.unwrap();
assert_eq!(
orchestration_ticket.meta.workflow_state,
TicketWorkflowState::Queued
);
}
#[tokio::test]
async fn ticket_queue_action_blocks_dirty_root_without_mutation() {
let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-dirty-root");
fs::write(temp.path().join("dirty.txt"), "dirty").unwrap();
let error =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
.await
.unwrap_err();
let message = error.to_string();
assert!(message.contains("root-clean"));
assert!(message.contains(&ticket_id));
assert!(message.contains(&temp.path().display().to_string()));
assert!(message.contains("dirty.txt"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
assert!(ticket.meta.queued_by.is_none());
}
#[tokio::test]
async fn ticket_queue_action_blocks_orchestration_branch_divergence() {
let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-diverged");
let layout = orchestration_worktree_layout(temp.path());
fs::write(layout.path.join("orchestrator-only.txt"), "diverged").unwrap();
run_test_git(&layout.path, &["add", "orchestrator-only.txt"]).unwrap();
run_test_git(&layout.path, &["commit", "-m", "orchestrator-only"]).unwrap();
let error =
dispatch_ticket_action(request_for(&temp, ticket_id.clone(), NextUserAction::Queue))
.await
.unwrap_err();
let message = error.to_string();
assert!(message.contains("branch-divergence"));
assert!(message.contains(&ticket_id));
assert!(message.contains(&layout.path.display().to_string()));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
assert!(ticket.meta.queued_by.is_none());
}
#[tokio::test]