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:
commit
7a6321d955
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user