merge: panel return planning

# Conflicts:
#	.yoi/tickets/00001KV09X0XC/item.md
#	.yoi/tickets/00001KV09X0XC/thread.md
#	crates/tui/src/multi_pod.rs
This commit is contained in:
Keisuke Hirata 2026-06-14 14:08:16 +09:00
commit 7a6321d955
No known key found for this signature in database
3 changed files with 743 additions and 13 deletions

View File

@ -2,7 +2,7 @@
title: 'Panel から ready Ticket を指示付きで planning に戻して Intake を再開できるようにする'
state: 'inprogress'
created_at: '2026-06-13T10:54:34Z'
updated_at: '2026-06-13T18:42:45Z'
updated_at: '2026-06-13T19:07:43Z'
assignee: null
readiness: 'implementation_ready'
risk_flags: ['panel-action', 'ticket-lifecycle', 'role-session', 'authority-boundary']

View File

@ -85,3 +85,62 @@ Implementation handoff started.
Next: Coder implementation_report / commit / validation evidence を確認後、Reviewer を起動する。
---
<!-- event: implementation_report author: hare at: 2026-06-13T19:01:36Z -->
## Implementation report
Commit: `f74146c6` (`tui: return ready tickets to planning from panel`)
Files changed:
- `crates/tui/src/multi_pod.rs`
What changed:
- Added a separate Panel path for selected `ready` Ticket rows when the composer target is Ticket Intake and the user submits non-empty refinement instructions.
- The action rereads the Ticket through the typed local Ticket backend and records a typed `ready -> planning` `state_changed` event with the user's refinement instruction in the Ticket thread.
- The action does not invoke Queue, `queued -> inprogress`, worktree creation, or implementation-role spawning.
- After the state change it attempts the Intake continuation path:
- launch and claim a new Intake Pod when no local Intake claim exists,
- notify a live claimed Intake Pod,
- open/restore a restorable claimed Intake Pod,
- or leave a visible diagnostic for stale claims without launching a duplicate.
- Added focused tests for stale-state rejection, state/event recording, action dispatch selection, launch-after-mutation followup, and role-session claim recording for post-launch Intake Pods.
Validation:
- `cargo test -p tui planning_return`
- `cargo test -p tui intake_registry`
- `cargo test -p tui workspace_panel`
- `cargo test -p ticket`
- `cargo fmt --check`
- `git diff --check`
Residual risks:
- No real terminal/PTTY Panel E2E was run; coverage is focused unit/action-path validation.
- The existing Panel composer model exposes this via Ticket Intake target + selected ready row + non-empty text, rather than adding a new visible button/menu surface.
---
<!-- event: review author: hare at: 2026-06-13T19:07:43Z status: approve -->
## Review: approve
Evidence reviewed:
- Inspected `git diff c4465a04..HEAD`; source changes are confined to `crates/tui/src/multi_pod.rs` plus Ticket record/report updates.
- Panel dispatch now has a separate `ReturnReadyTicketToPlanning` path for non-empty composer text when the composer target is Ticket Intake and the selected row action is `Queue`/ready.
- The mutation path reloads the Ticket from `LocalTicketBackend`, rejects non-`ready` current state, and records a typed `ready -> planning` `state_changed` event with author `workspace-panel`, reason `panel_return_to_planning`, and the user instruction in the thread body.
- The follow-up paths are Intake-only: launch when unclaimed, notify live claimed Intake, open/restorable claimed Intake, or diagnose stale claim without duplicate launch. I did not find calls to queue dispatch, `queued -> inprogress`, worktree creation, Orchestrator/Coder/Reviewer spawn in this path.
- Tests cover successful planning return, stale-state rejection, dispatch separation from Queue/generic Intake launch, state-before-launch follow-up, and launched-claim registry handling. Existing queue action coverage remains present.
Validation run:
- `cargo test -p tui planning_return` — pass (4 tests)
- `cargo test -p tui intake_registry` — pass (4 tests)
- `cargo test -p tui workspace_panel` — pass (12 tests)
- `cargo test -p ticket` — pass (68 tests + doctests)
- `cargo fmt --check` — pass
- `git diff --check c4465a04..HEAD` — pass
Residual notes:
- No real terminal/PTTY Panel E2E was run; this remains a unit/action-path review only.
- Discoverability depends on the existing composer target model: select a ready Ticket row, switch to Ticket Intake, type non-empty refinement instructions, then Enter. The implementation makes this visible in status/actionbar text; no separate button/menu was added.
---

View File

@ -28,7 +28,10 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
use serde::Serialize;
use session_store::FsStore;
use ticket::config::{GitBranchName, TicketConfig};
use ticket::{LocalTicketBackend, TicketBackend, TicketIdOrSlug, TicketWorkflowState};
use ticket::{
LocalTicketBackend, MarkdownText, TicketBackend, TicketIdOrSlug, TicketStateChange,
TicketWorkflowState,
};
use tokio::net::UnixStream;
use unicode_width::UnicodeWidthStr;
@ -62,6 +65,7 @@ const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400;
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
const MULTI_POD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
const PANEL_READY_REFINEMENT_MAX_INSTRUCTION_CHARS: usize = 4_000;
#[derive(Debug)]
pub(crate) enum MultiPodError {
@ -210,6 +214,42 @@ pub(crate) async fn run(
}
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
}
MultiPodAction::ReturnReadyTicketToPlanning(request) => {
#[cfg(feature = "e2e-test")]
crate::e2e_observer::emit(
"panel",
"action_requested",
serde_json::json!({ "action": "return_ready_ticket_to_planning" }),
);
pending_reload.abort();
pending_queue_attention_notice.abort();
terminal.draw(|f| draw(f, app))?;
match dispatch_ready_ticket_planning_return(request).await {
Ok(outcome) => {
match app.finish_ready_ticket_planning_return_success(outcome) {
ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request) => {
terminal.draw(|f| draw(f, app))?;
let planning_notice = app.notice.clone().unwrap_or_default();
let result = launch_intake_with_handoff(request).await;
app.finish_ready_ticket_planning_return_with_intake_launch(
planning_notice,
result,
);
}
ReadyTicketPlanningReturnAfterMutation::OpenClaim(request) => {
terminal.draw(|f| draw(f, app))?;
return Ok(MultiPodOutcome::Open(request));
}
ReadyTicketPlanningReturnAfterMutation::None => {}
}
}
Err(error) => app.finish_ready_ticket_planning_return_error(error),
}
if pending_reload.start(OrchestratorLifecycleMode::Observe) {
app.refreshing = true;
}
next_poll = Instant::now() + MULTI_POD_POLL_INTERVAL;
}
MultiPodAction::LaunchIntake(request) => {
#[cfg(feature = "e2e-test")]
crate::e2e_observer::emit(
@ -456,6 +496,45 @@ pub(crate) enum IntakeRegistryUpdate {
ticket_slug: Option<String>,
pod_name: String,
},
ClaimLaunchedTicket {
registry_root: PathBuf,
ticket_id: String,
ticket_slug: Option<String>,
},
}
#[derive(Debug)]
pub(crate) struct ReadyTicketPlanningReturnRequest {
workspace_root: PathBuf,
ticket_id: String,
user_instruction: String,
followup: ReadyTicketPlanningReturnFollowup,
}
#[derive(Debug)]
pub(crate) enum ReadyTicketPlanningReturnFollowup {
LaunchIntake(IntakeLaunchRequest),
NotifyLiveClaimedIntake {
pod_name: String,
socket_path: PathBuf,
},
OpenRestorableClaimedIntake(OpenPodRequest),
BlockedByStaleClaim {
pod_name: String,
},
}
#[derive(Debug)]
struct ReadyTicketPlanningReturnOutcome {
notice: String,
followup: ReadyTicketPlanningReturnAfterMutation,
}
#[derive(Debug)]
enum ReadyTicketPlanningReturnAfterMutation {
LaunchIntake(IntakeLaunchRequest),
OpenClaim(OpenPodRequest),
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -608,7 +687,8 @@ async fn launch_intake_with_handoff(request: IntakeLaunchRequest) -> IntakeLaunc
options,
)
.await?;
let registry_warning = commit_intake_registry_update(request.registry_update);
let registry_warning =
commit_intake_registry_update(request.registry_update, Some(&launch.plan.pod_name));
let peer_registration = match (orchestrator_pod, skip_warning) {
(_, Some(warning)) => warning,
(Some(orchestrator_pod), None) if launch.pre_run_warnings.is_empty() => {
@ -633,7 +713,10 @@ async fn launch_intake_with_handoff(request: IntakeLaunchRequest) -> IntakeLaunc
})
}
fn commit_intake_registry_update(update: IntakeRegistryUpdate) -> Option<String> {
fn commit_intake_registry_update(
update: IntakeRegistryUpdate,
launched_pod_name: Option<&str>,
) -> Option<String> {
match update {
IntakeRegistryUpdate::RecordSession {
registry_root,
@ -670,6 +753,29 @@ fn commit_intake_registry_update(update: IntakeRegistryUpdate) -> Option<String>
"local Ticket Intake claim could not be committed after launch acceptance: {error}"
))),
},
IntakeRegistryUpdate::ClaimLaunchedTicket {
registry_root,
ticket_id,
ticket_slug,
} => {
let Some(pod_name) = launched_pod_name else {
return Some(
"local Ticket Intake claim could not be committed after launch acceptance: missing launched Pod name"
.to_string(),
);
};
match PanelRegistryStore::from_root(registry_root).claim_ticket(
&ticket_id,
ticket_slug.as_deref(),
pod_name,
TicketRole::Intake.as_str(),
) {
Ok(TicketClaimResult::Claimed) | Ok(TicketClaimResult::AlreadyOwned(_)) => None,
Err(error) => Some(bounded_panel_diagnostic(format!(
"local Ticket Intake claim could not be committed after launch acceptance: {error}"
))),
}
}
}
}
@ -1715,6 +1821,209 @@ impl MultiPodApp {
})
}
pub(crate) fn prepare_ready_ticket_planning_return(
&mut self,
) -> Option<ReadyTicketPlanningReturnRequest> {
if self.sending {
self.notice = Some(
"Ticket refinement return is already in progress; wait for it to finish before retrying."
.to_string(),
);
return None;
}
if !self
.panel
.composer
.is_available(ComposerTarget::TicketIntake)
{
self.composer_target = ComposerTarget::Companion;
self.notice = Some(
"Ticket Intake target is unavailable without usable Ticket config.".to_string(),
);
return None;
}
let body = Segment::flatten_to_text(&self.input.submit_segments());
let user_instruction = bounded_refinement_instruction(body.trim());
if user_instruction.is_empty() {
self.notice = Some(
"Type refinement instructions with the Ticket Planning target before returning a ready Ticket to planning."
.to_string(),
);
return None;
}
let row = match self.selected_panel_row() {
Some(row) if row.is_ticket_action() => row,
Some(row) if row.ticket.is_some() => {
self.notice =
Some("Selected Ticket row has no refinement return action.".to_string());
return None;
}
_ => {
self.notice =
Some("Select a ready Ticket row before returning it to planning.".to_string());
return None;
}
};
if row.next_action != Some(NextUserAction::Queue) {
self.notice = Some(
"Only ready Ticket rows can be returned to planning from the Ticket Planning target."
.to_string(),
);
return None;
}
let Some(ticket) = row.ticket.as_ref() else {
self.notice =
Some("Select a ready Ticket row before returning it to planning.".to_string());
return None;
};
let ticket_id = ticket.id.clone();
if ticket.workflow_state != TicketWorkflowState::Ready {
self.notice = Some(format!(
"Ticket {} is {}; expected ready before returning to planning.",
ticket_id,
ticket.workflow_state.as_str()
));
return None;
}
let workspace_root = current_workspace_root();
let store = match PanelRegistryStore::default_for_workspace(&workspace_root) {
Ok(store) => store,
Err(error) => {
self.notice = Some(format!("Ticket Intake registry unavailable: {error}"));
return None;
}
};
let followup = match store.claim_for_ticket(&ticket_id) {
Ok(Some(claim)) => match local_claim_status_for_pod(&claim.pod_name, &self.list) {
TicketLocalClaimStatus::Live => match self
.list
.entries
.iter()
.find(|entry| entry.name == claim.pod_name)
.and_then(PodListEntry::attach_socket_path)
{
Some(socket_path) => {
ReadyTicketPlanningReturnFollowup::NotifyLiveClaimedIntake {
pod_name: claim.pod_name,
socket_path: socket_path.to_path_buf(),
}
}
None => ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
pod_name: claim.pod_name,
},
},
TicketLocalClaimStatus::Restorable => {
ReadyTicketPlanningReturnFollowup::OpenRestorableClaimedIntake(OpenPodRequest {
pod_name: claim.pod_name,
socket_override: None,
})
}
TicketLocalClaimStatus::Stale => {
ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
pod_name: claim.pod_name,
}
}
},
Ok(None) => {
let mut context =
TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction(
&ticket_id,
&user_instruction,
));
let peer_registration = self.prepare_intake_peer_registration(&mut context);
ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
context,
runtime_command: self.runtime_command.clone(),
peer_registration,
registry_update: IntakeRegistryUpdate::ClaimLaunchedTicket {
registry_root: store.root().to_path_buf(),
ticket_id: ticket_id.clone(),
ticket_slug: None,
},
})
}
Err(error) => {
self.notice = Some(format!("Ticket claim diagnostic required: {error}"));
return None;
}
};
self.sending = true;
self.notice = Some(format!(
"Returning ready Ticket {} to planning for refinement…",
ticket_id
));
Some(ReadyTicketPlanningReturnRequest {
workspace_root,
ticket_id,
user_instruction,
followup,
})
}
fn finish_ready_ticket_planning_return_error(&mut self, error: TicketActionError) {
self.sending = false;
self.notice = Some(match error {
TicketActionError::Stale(message) => {
self.set_panel_diagnostic("Ticket planning return rejected", message)
}
TicketActionError::BackendConfig(error) | TicketActionError::Ticket(error) => {
self.set_panel_diagnostic("Ticket planning return failed", error)
}
});
}
fn finish_ready_ticket_planning_return_success(
&mut self,
outcome: ReadyTicketPlanningReturnOutcome,
) -> ReadyTicketPlanningReturnAfterMutation {
self.sending = false;
self.input.clear();
self.notice = Some(outcome.notice);
outcome.followup
}
fn finish_ready_ticket_planning_return_with_intake_launch(
&mut self,
planning_notice: String,
result: IntakeLaunchResult,
) {
self.sending = false;
self.input.clear();
match result {
Ok(result) => {
let pod_name = result.launch.plan.pod_name;
let peer_notice = match result.peer_registration {
IntakePeerRegistrationStatus::Registered { orchestrator_pod } => {
format!(" Handoff peer registered with {orchestrator_pod}.")
}
IntakePeerRegistrationStatus::Warning { message } => {
format!(" Handoff warning: {message}")
}
};
let registry_notice = result
.registry_warning
.map(|warning| format!(" Registry warning: {warning}"))
.unwrap_or_default();
self.notice = Some(bounded_panel_diagnostic(format!(
"{planning_notice} Launched Ticket Intake Pod {pod_name}.{peer_notice}{registry_notice}"
)));
}
Err(error) => {
self.notice = Some(self.set_panel_diagnostic(
"Ticket Intake launch failed after planning return",
format!(
"{planning_notice} Intake launch/restore failed after Ticket was returned to planning; instruction was recorded in the Ticket thread. {}",
error
),
));
}
}
}
pub(crate) fn finish_intake_launch(&mut self, result: IntakeLaunchResult) {
self.sending = false;
match result {
@ -1828,6 +2137,14 @@ impl MultiPodApp {
.unwrap_or(MultiPodAction::None)
}
KeyCode::Enter if self.composer_is_blank() => MultiPodAction::Open,
KeyCode::Enter
if self.composer_target == ComposerTarget::TicketIntake
&& self.selected_ticket_action() == Some(NextUserAction::Queue) =>
{
self.prepare_ready_ticket_planning_return()
.map(MultiPodAction::ReturnReadyTicketToPlanning)
.unwrap_or(MultiPodAction::None)
}
KeyCode::Enter if self.composer_target == ComposerTarget::TicketIntake => self
.prepare_intake_launch()
.map(MultiPodAction::LaunchIntake)
@ -1850,6 +2167,7 @@ enum MultiPodAction {
Quit,
Open,
DispatchTicketAction(TicketActionRequest),
ReturnReadyTicketToPlanning(ReadyTicketPlanningReturnRequest),
LaunchIntake(IntakeLaunchRequest),
SendCompanion(CompanionSendRequest),
}
@ -3152,6 +3470,132 @@ async fn dispatch_orchestrator_queue_attention_notice(
}
}
fn bounded_refinement_instruction(input: &str) -> String {
bounded_progress_text(input, PANEL_READY_REFINEMENT_MAX_INSTRUCTION_CHARS)
.trim()
.to_string()
}
fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str) -> String {
format!(
"Panel returned ready Ticket {ticket_id} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
)
}
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String {
format!(
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read TicketShow body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
)
}
fn build_ready_ticket_refinement_notify(ticket_id: &str, instruction: &str) -> String {
format!(
"Ticket {ticket_id} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
)
}
async fn dispatch_ready_ticket_planning_return(
request: ReadyTicketPlanningReturnRequest,
) -> Result<ReadyTicketPlanningReturnOutcome, TicketActionError> {
match ticket_config_availability(&request.workspace_root) {
TicketConfigAvailability::Usable => {}
TicketConfigAvailability::Absent => {
return Err(TicketActionError::Stale(
"Ticket config is absent; workspace panel no longer exposes Ticket actions"
.to_string(),
));
}
TicketConfigAvailability::Unusable(message) => {
return Err(TicketActionError::Stale(format!(
"Ticket config is unusable; workspace panel no longer exposes Ticket actions: {message}"
)));
}
}
let config = TicketConfig::load_workspace(&request.workspace_root)
.map_err(|error| TicketActionError::BackendConfig(error.to_string()))?;
let backend = LocalTicketBackend::new(config.backend_root())
.with_record_language(config.ticket_record_language());
let id = TicketIdOrSlug::Id(request.ticket_id.clone());
let ticket = backend
.show(id.clone())
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
if ticket.meta.workflow_state != TicketWorkflowState::Ready {
return Err(TicketActionError::Stale(format!(
"Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.",
ticket.meta.id,
ticket.meta.workflow_state.as_str()
)));
}
let mut change = TicketStateChange::new(
TicketWorkflowState::Ready.as_str(),
TicketWorkflowState::Planning.as_str(),
"panel_return_to_planning",
MarkdownText::from(build_ready_ticket_refinement_thread_body(
&ticket.meta.id,
&request.user_instruction,
)),
);
change.author = Some("workspace-panel".to_string());
backend
.set_workflow_state(id, change)
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
let notice = match request.followup {
ReadyTicketPlanningReturnFollowup::LaunchIntake(request) => {
ReadyTicketPlanningReturnOutcome {
notice: format!(
"Ticket {} returned to planning for refinement; launching Ticket Intake…",
ticket.meta.id
),
followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request),
}
}
ReadyTicketPlanningReturnFollowup::NotifyLiveClaimedIntake {
pod_name,
socket_path,
} => {
let message =
build_ready_ticket_refinement_notify(&ticket.meta.id, &request.user_instruction);
match send_notify_only(&socket_path, message, true).await {
Ok(()) => ReadyTicketPlanningReturnOutcome {
notice: format!(
"Ticket {} returned to planning for refinement; notified live Intake Pod {}.",
ticket.meta.id, pod_name
),
followup: ReadyTicketPlanningReturnAfterMutation::None,
},
Err(error) => ReadyTicketPlanningReturnOutcome {
notice: bounded_panel_diagnostic(format!(
"Ticket {} returned to planning and instruction was recorded, but notifying Intake Pod {} failed: {}",
ticket.meta.id, pod_name, error
)),
followup: ReadyTicketPlanningReturnAfterMutation::None,
},
}
}
ReadyTicketPlanningReturnFollowup::OpenRestorableClaimedIntake(request) => {
let pod_name = request.pod_name.clone();
ReadyTicketPlanningReturnOutcome {
notice: format!(
"Ticket {} returned to planning for refinement; opening/restoring claimed Intake Pod {}…",
ticket.meta.id, pod_name
),
followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request),
}
}
ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { pod_name } => {
ReadyTicketPlanningReturnOutcome {
notice: bounded_panel_diagnostic(format!(
"Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Pod.",
ticket.meta.id, pod_name
)),
followup: ReadyTicketPlanningReturnAfterMutation::None,
}
}
};
Ok(notice)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TicketActionOutcome {
notice: String,
@ -4973,6 +5417,11 @@ fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: R
fn composer_enter_status_text(app: &MultiPodApp) -> String {
match app.composer_target() {
ComposerTarget::Companion => companion_enter_status_text(app),
ComposerTarget::TicketIntake
if app.selected_ticket_action() == Some(NextUserAction::Queue) =>
{
"return selected ready Ticket to planning".to_string()
}
ComposerTarget::TicketIntake => "launch Intake with composer text".to_string(),
}
}
@ -4980,6 +5429,9 @@ fn composer_enter_status_text(app: &MultiPodApp) -> String {
fn composer_enter_actionbar_text(app: &MultiPodApp) -> String {
match app.composer_target() {
ComposerTarget::Companion => companion_enter_actionbar_text(app),
ComposerTarget::TicketIntake if app.selected_ticket_action() == Some(NextUserAction::Queue) => {
"Ticket Intake target: Enter records instructions and returns selected ready Ticket to planning".to_string()
}
ComposerTarget::TicketIntake => {
"Ticket Intake target: Enter launches Intake with composer text".to_string()
}
@ -5085,7 +5537,11 @@ fn actionbar_left_text(app: &MultiPodApp) -> String {
.to_string()
}
ComposerTarget::TicketIntake => {
"Composer target: Ticket Intake; type a request, then Enter launches Intake".to_string()
if app.selected_ticket_action() == Some(NextUserAction::Queue) {
"Composer target: Ticket Intake; text + Enter returns selected ready Ticket to planning".to_string()
} else {
"Composer target: Ticket Intake; type a request, then Enter launches Intake".to_string()
}
}
}
}
@ -5560,6 +6016,168 @@ branch = "orchestration/custom-panel"
}
}
fn planning_return_request(
temp: &TempDir,
ticket_id: String,
instruction: &str,
) -> ReadyTicketPlanningReturnRequest {
ReadyTicketPlanningReturnRequest {
workspace_root: temp.path().to_path_buf(),
ticket_id,
user_instruction: instruction.to_string(),
followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
pod_name: "stale-intake".to_string(),
},
}
}
#[tokio::test]
async fn ready_ticket_planning_return_records_instruction_and_returns_to_planning_without_queueing()
{
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-ready");
let outcome = dispatch_ready_ticket_planning_return(planning_return_request(
&temp,
ticket_id.clone(),
"please add acceptance detail before queueing",
))
.await
.unwrap();
assert!(outcome.notice.contains("returned to planning"));
assert!(outcome.notice.contains("instruction was recorded"));
assert!(matches!(
outcome.followup,
ReadyTicketPlanningReturnAfterMutation::None
));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id.clone())).unwrap();
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning);
assert!(ticket.meta.queued_by.is_none());
assert!(ticket.meta.queued_at.is_none());
let state_change = ticket
.events
.iter()
.find(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("state")
&& event.from.as_deref() == Some("ready")
&& event.to.as_deref() == Some("planning")
})
.expect("ready -> planning state_changed event is recorded");
assert_eq!(state_change.author.as_deref(), Some("workspace-panel"));
assert!(
state_change
.body
.as_str()
.contains("please add acceptance detail")
);
assert!(state_change.body.as_str().contains("not Queue routing"));
assert!(
state_change
.body
.as_str()
.contains("must not start implementation")
);
}
#[tokio::test]
async fn ready_ticket_planning_return_rejects_stale_non_ready_ticket() {
let (temp, ticket_id, backend) =
ticket_workspace("panel-refine-stale", TicketWorkflowState::Planning, |_| {});
let error = dispatch_ready_ticket_planning_return(planning_return_request(
&temp,
ticket_id.clone(),
"refine please",
))
.await
.unwrap_err();
assert!(error.to_string().contains("expected ready"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning);
assert!(
ticket
.events
.iter()
.all(|event| !(event.kind == TicketEventKind::StateChanged
&& event.from.as_deref() == Some("ready")
&& event.to.as_deref() == Some("planning")))
);
}
#[test]
fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launch() {
let mut panel = WorkspacePanelViewModel::empty(Path::new("test"));
panel.composer = crate::workspace_panel::WorkspacePanelComposer::ticket_enabled();
panel.rows.push(panel_test_ticket_row(
"20260608-000123-ready",
"Ready Ticket",
ActionPriority::ReadyForQueue,
NextUserAction::Queue,
"ready",
));
let mut app = app_with_panel(empty_test_list(), panel);
app.cycle_composer_target();
app.input.insert_str("clarify expected behavior");
let request = match app.handle_key(key(KeyCode::Enter)) {
MultiPodAction::ReturnReadyTicketToPlanning(request) => request,
_ => panic!("ready Ticket row with Ticket Intake text should return to planning"),
};
assert_eq!(request.ticket_id, "20260608-000123-ready");
assert_eq!(request.user_instruction, "clarify expected behavior");
assert!(matches!(
request.followup,
ReadyTicketPlanningReturnFollowup::LaunchIntake(_)
));
assert!(app.sending);
assert!(
app.notice
.as_deref()
.unwrap()
.contains("Returning ready Ticket")
);
assert_eq!(input_text(&app), "clarify expected behavior");
}
#[tokio::test]
async fn planning_return_with_launch_followup_changes_state_before_launch_followup() {
let (temp, ticket_id, backend) = ready_ticket_workspace("panel-refine-launch");
let request = ReadyTicketPlanningReturnRequest {
workspace_root: temp.path().to_path_buf(),
ticket_id: ticket_id.clone(),
user_instruction: "launch intake after state change".to_string(),
followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
context: TicketRoleLaunchContext::new(
temp.path().to_path_buf(),
TicketRole::Intake,
),
runtime_command: PodRuntimeCommand::for_executable("/tmp/yoi"),
peer_registration: IntakePeerRegistrationRequest::Skip {
reason: "test".to_string(),
},
registry_update: IntakeRegistryUpdate::ClaimLaunchedTicket {
registry_root: temp.path().join(".yoi/local-role-sessions"),
ticket_id: ticket_id.clone(),
ticket_slug: None,
},
}),
};
let outcome = dispatch_ready_ticket_planning_return(request)
.await
.unwrap();
assert!(matches!(
outcome.followup,
ReadyTicketPlanningReturnAfterMutation::LaunchIntake(_)
));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Planning);
}
#[tokio::test]
async fn ticket_queue_action_transitions_ready_ticket_and_authorizes_orchestrator_routing() {
let (temp, ticket_id, backend) = ready_ticket_git_workspace("panel-queue");
@ -7408,7 +8026,7 @@ branch = "orchestration/custom-panel"
"holding a pending Intake registry update must not persist a Ticket claim"
);
assert!(commit_intake_registry_update(update.clone()).is_none());
assert!(commit_intake_registry_update(update.clone(), None).is_none());
assert!(
store
.claim_for_ticket("20260608-000000-existing")
@ -7417,7 +8035,7 @@ branch = "orchestration/custom-panel"
"the claim is persisted only by the post-acceptance commit step"
);
assert!(commit_intake_registry_update(update).is_none());
assert!(commit_intake_registry_update(update, None).is_none());
let snapshot = store.snapshot().unwrap();
assert_eq!(snapshot.claims.len(), 1);
assert_eq!(snapshot.sessions.len(), 1);
@ -7430,6 +8048,56 @@ branch = "orchestration/custom-panel"
);
}
#[test]
fn intake_registry_claims_launched_ticket_with_accepted_pod_name() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("registry");
let store = PanelRegistryStore::from_root(root.clone());
let update = IntakeRegistryUpdate::ClaimLaunchedTicket {
registry_root: root,
ticket_id: "20260608-000000-ready".to_string(),
ticket_slug: None,
};
assert!(commit_intake_registry_update(update, Some("launched-intake")).is_none());
let claim = store
.claim_for_ticket("20260608-000000-ready")
.unwrap()
.expect("launched Intake Pod is claimed after accepted launch");
assert_eq!(claim.pod_name, "launched-intake");
let snapshot = store.snapshot().unwrap();
assert_eq!(snapshot.claims.len(), 1);
assert_eq!(snapshot.sessions.len(), 1);
assert_eq!(snapshot.sessions[0].origin, RoleSessionOrigin::TicketClaim);
assert_eq!(snapshot.sessions[0].pod_name, "launched-intake");
}
#[test]
fn intake_registry_launched_ticket_claim_without_pod_name_is_diagnostic() {
let temp = TempDir::new().unwrap();
let root = temp.path().join("registry");
let store = PanelRegistryStore::from_root(root.clone());
let warning = commit_intake_registry_update(
IntakeRegistryUpdate::ClaimLaunchedTicket {
registry_root: root,
ticket_id: "20260608-000000-ready".to_string(),
ticket_slug: None,
},
None,
)
.expect("missing launched Pod name should be diagnostic");
assert!(warning.contains("missing launched Pod name"));
assert!(
store
.claim_for_ticket("20260608-000000-ready")
.unwrap()
.is_none()
);
}
#[test]
fn intake_registry_update_claim_conflict_is_diagnostic_not_overwrite() {
let temp = TempDir::new().unwrap();
@ -7444,12 +8112,15 @@ branch = "orchestration/custom-panel"
)
.unwrap();
let warning = commit_intake_registry_update(IntakeRegistryUpdate::ClaimTicket {
registry_root: root,
ticket_id: "20260608-000001-existing".to_string(),
ticket_slug: Some("existing".to_string()),
pod_name: "second-intake".to_string(),
})
let warning = commit_intake_registry_update(
IntakeRegistryUpdate::ClaimTicket {
registry_root: root,
ticket_id: "20260608-000001-existing".to_string(),
ticket_slug: Some("existing".to_string()),
pod_name: "second-intake".to_string(),
},
None,
)
.expect("conflicting post-success claim should be reported");
assert!(warning.contains("could not be committed"));