ticket: simplify ticket identity fields

This commit is contained in:
2026-06-09 11:57:15 +09:00
parent 48efcc7dfb
commit 191a875f5a
747 changed files with 1311 additions and 2600 deletions
+39 -64
View File
@@ -24,7 +24,7 @@ use session_store::FsStore;
use ticket::config::TicketConfig;
use ticket::{
LocalTicketBackend, NewTicketEvent, TicketBackend, TicketEventKind, TicketIdOrSlug,
TicketStatus, TicketWorkflowState,
TicketWorkflowState,
};
use tokio::net::UnixStream;
use unicode_width::UnicodeWidthStr;
@@ -1127,15 +1127,11 @@ impl MultiPodApp {
return None;
};
let ticket_id = ticket.id.clone();
let ticket_slug = ticket.slug.clone();
let mut context =
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
context.ticket = Some(TicketRef {
id: Some(ticket_id.clone()),
slug: Some(ticket_slug.clone()),
});
context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(format!(
"Continue Intake for existing Ticket {ticket_id} ({ticket_slug}). Do not create a duplicate Ticket unless the user explicitly requests one."
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read TicketShow body/thread/artifacts before making routing or requirements decisions."
));
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
Ok(store) => store,
@@ -1176,7 +1172,7 @@ impl MultiPodApp {
self.sending = true;
self.notice = Some(format!(
"Launching Ticket Intake for {} as {}",
ticket_slug, planned.pod_name
ticket_id, planned.pod_name
));
Some(IntakeLaunchRequest {
context,
@@ -1185,7 +1181,7 @@ impl MultiPodApp {
registry_update: IntakeRegistryUpdate::ClaimTicket {
registry_root: store.root().to_path_buf(),
ticket_id,
ticket_slug: Some(ticket_slug),
ticket_slug: None,
pod_name,
},
})
@@ -1991,8 +1987,7 @@ async fn dispatch_ticket_action(
NextUserAction::Queue => {
if current_ticket.workflow_state != TicketWorkflowState::Ready {
return Err(TicketActionError::Stale(
"Queue is only valid while workflow_state is ready; reload and retry"
.to_string(),
"Queue is only valid while state is ready; reload and retry".to_string(),
));
}
backend
@@ -2006,7 +2001,7 @@ async fn dispatch_ticket_action(
Ok(TicketActionOutcome {
notice: format!(
"Queued Ticket {}; {}. Orchestrator routing is authorized; implementation side effects still require queued -> inprogress acceptance.",
current_ticket.slug,
current_ticket.id,
notification.sentence()
),
})
@@ -2017,31 +2012,13 @@ async fn dispatch_ticket_action(
&request.ticket_id,
panel_defer_body(current_ticket),
)?;
let mut moved = false;
if current_ticket
.status
.eq_ignore_ascii_case(TicketStatus::Open.as_str())
{
backend
.set_status(
TicketIdOrSlug::Id(request.ticket_id.clone()),
TicketStatus::Pending,
)
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
moved = true;
}
let notice = if moved {
format!(
"Recorded Panel Defer for Ticket {} and moved it to pending.",
current_ticket.slug
)
} else {
format!(
"Recorded Panel Defer for Ticket {}; status was already {}.",
current_ticket.slug, current_ticket.status
)
};
Ok(TicketActionOutcome { notice })
Ok(TicketActionOutcome {
notice: format!(
"Recorded Panel Defer for Ticket {}; state remains {}.",
current_ticket.id,
current_ticket.workflow_state.as_str()
),
})
}
NextUserAction::Close => unreachable!("Close action is handled before row dispatch"),
NextUserAction::Clarify
@@ -2075,41 +2052,40 @@ fn dispatch_panel_close(
Ok(TicketActionOutcome {
notice: format!(
"Closed Ticket {}; deterministic resolution recorded because workflow_state was already done.",
ticket.meta.slug
"Closed Ticket {}; deterministic resolution recorded because state was already done.",
ticket.meta.id
),
})
}
fn panel_close_blocker(ticket: &ticket::Ticket) -> Option<String> {
let slug = ticket.meta.slug.as_str();
if ticket.meta.status.as_local() != Some(TicketStatus::Open) {
let ticket_id = ticket.meta.id.as_str();
if ticket.meta.workflow_state == TicketWorkflowState::Closed {
return Some(format!(
"Close blocked for Ticket {slug}: local status is {}, expected open; no close was recorded.",
ticket.meta.status.as_str()
"Close blocked for Ticket {ticket_id}: state is already closed; no close was recorded."
));
}
if ticket.meta.workflow_state != TicketWorkflowState::Done {
return Some(format!(
"Close blocked for Ticket {slug}: workflow_state is {}, expected done; no close was recorded.",
"Close blocked for Ticket {ticket_id}: state is {}, expected done; no close was recorded.",
ticket.meta.workflow_state.as_str()
));
}
if let Some(reason) = non_empty_ticket_field(ticket.meta.attention_required.as_deref()) {
return Some(format!(
"Close blocked for Ticket {slug}: attention_required is set ({}); no close was recorded.",
"Close blocked for Ticket {ticket_id}: attention_required is set ({}); no close was recorded.",
bounded_panel_diagnostic(reason)
));
}
if let Some(reason) = non_empty_ticket_field(ticket.meta.action_required.as_deref()) {
return Some(format!(
"Close blocked for Ticket {slug}: action_required is set ({}); no close was recorded.",
"Close blocked for Ticket {ticket_id}: action_required is set ({}); no close was recorded.",
bounded_panel_diagnostic(reason)
));
}
if ticket.resolution.is_some() {
return Some(format!(
"Close blocked for Ticket {slug}: resolution.md already exists; no close was recorded."
"Close blocked for Ticket {ticket_id}: resolution.md already exists; no close was recorded."
));
}
None
@@ -2125,13 +2101,13 @@ fn panel_close_resolution(
) -> ticket::MarkdownText {
if is_japanese_ticket_record_language(record_language) {
ticket::MarkdownText::new(format!(
"Ticket `{}` (`{}`) はすでに `workflow_state: done` に到達していたため、workspace Panel から close しました。\n\nこの Close action によって、実装作業、workflow-state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。\n",
ticket.meta.slug, ticket.meta.id
"Ticket `{}` (`{}`) はすでに `state: done` に到達していたため、workspace Panel から close しました。\n\nこの Close action によって、実装作業、state 変更、Orchestrator/Companion launch、worker invocation は開始されていません。\n",
ticket.meta.id, ticket.meta.title
))
} else {
ticket::MarkdownText::new(format!(
"Closed from the workspace Panel because Ticket `{}` (`{}`) had already reached `workflow_state: done`.\n\nNo implementation work, workflow-state change, Orchestrator/Companion launch, or worker invocation was started by this Close action.\n",
ticket.meta.slug, ticket.meta.id
"Closed from the workspace Panel because Ticket `{}` (`{}`) had already reached `state: done`.\n\nNo implementation work, state change, Orchestrator/Companion launch, or worker invocation was started by this Close action.\n",
ticket.meta.id, ticket.meta.title
))
}
}
@@ -2171,7 +2147,7 @@ fn orchestrator_queue_notification_message(
) -> String {
let title = ticket.title.replace(['\r', '\n'], " ");
format!(
"Workspace panel Queue for Ticket `{}` (`{}`), title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current workspace state. If unblocked, record routing and transition workflow_state queued -> inprogress before any worktree/SpawnPod implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/<task-name>` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Pods (coder narrow child-worktree write scope, reviewer read-only by default) and stop at a merge-ready dossier without merge/close/final approval. If blocked, record a concise reason and leave the Ticket queued or explicitly defer it.",
"Workspace panel Queue for Ticket `{}` (`{}`), title `{}`: human authorized Orchestrator routing; this is not an unattended scheduler. Read the Ticket and inspect current workspace state. If unblocked, record routing and transition state queued -> inprogress before any worktree/SpawnPod implementation side effects. After inprogress acceptance, use worktree-workflow for `.worktree/<task-name>` creation with tracked `.yoi` project records visible and `.yoi/memory` plus local/runtime/log/lock/secret-like `.yoi` paths excluded, then use multi-agent-workflow to run sibling coder/reviewer Pods (coder narrow child-worktree write scope, reviewer read-only by default) and stop at a merge-ready dossier without merge/close/final approval. If blocked, record a concise reason and leave the Ticket queued or explicitly defer it.",
ticket.slug,
ticket.id,
title.trim()
@@ -3028,7 +3004,7 @@ mod tests {
fn ticket_workspace(
slug: &str,
workflow_state: TicketWorkflowState,
state: TicketWorkflowState,
configure: impl FnOnce(&mut NewTicket),
) -> (TempDir, String, LocalTicketBackend) {
let temp = TempDir::new().unwrap();
@@ -3050,7 +3026,7 @@ mod tests {
labels: Vec::new(),
readiness: None,
action_required: None,
workflow_state: Some(workflow_state),
state: Some(state),
attention_required: None,
queued_by: None,
queued_at: None,
@@ -3109,7 +3085,7 @@ mod tests {
.iter()
.find(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
&& event.workflow_state_field.as_deref() == Some("state")
&& event.from.as_deref() == Some("ready")
&& event.to.as_deref() == Some("queued")
})
@@ -3126,7 +3102,7 @@ mod tests {
.await
.unwrap_err();
assert!(error.to_string().contains("workflow_state is ready"));
assert!(error.to_string().contains("state is ready"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Open));
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Ready);
@@ -3149,7 +3125,7 @@ mod tests {
assert!(ticket.meta.queued_by.is_none());
assert!(!ticket.events.iter().any(|event| {
event.kind == TicketEventKind::StateChanged
&& event.state_field.as_deref() == Some("workflow_state")
&& event.workflow_state_field.as_deref() == Some("state")
}));
}
@@ -3184,7 +3160,7 @@ mod tests {
.unwrap();
assert!(outcome.notice.contains("Closed Ticket panel-close"));
assert!(outcome.notice.contains("workflow_state was already done"));
assert!(outcome.notice.contains("state was already done"));
let ticket = backend.show(TicketIdOrSlug::Id(ticket_id)).unwrap();
assert_eq!(ticket.meta.status.as_local(), Some(TicketStatus::Closed));
assert_eq!(ticket.meta.workflow_state, TicketWorkflowState::Done);
@@ -3193,9 +3169,9 @@ mod tests {
.as_ref()
.expect("Panel Close records resolution.md")
.as_str();
assert!(resolution.contains("workflow_state: done"));
assert!(resolution.contains("state: done"));
assert!(resolution.contains("No implementation work"));
assert!(resolution.contains("workflow-state change"));
assert!(resolution.contains("state change"));
assert!(resolution.contains("worker invocation"));
assert!(ticket.events.iter().any(|event| {
event.kind == TicketEventKind::Close && event.body.as_str().contains("workspace Panel")
@@ -3316,7 +3292,7 @@ mod tests {
assert!(message.contains("not an unattended scheduler"));
assert!(message.contains("Read the Ticket"));
assert!(message.contains("inspect current workspace state"));
assert!(message.contains("transition workflow_state queued -> inprogress"));
assert!(message.contains("transition state queued -> inprogress"));
assert!(message.contains("before any worktree/SpawnPod implementation side effects"));
assert!(message.contains("After inprogress acceptance"));
assert!(message.contains("worktree-workflow"));
@@ -4677,9 +4653,8 @@ mod tests {
kind: "task".to_string(),
priority: "P2".to_string(),
labels: Vec::new(),
workflow_state: TicketWorkflowState::parse(status)
.unwrap_or(TicketWorkflowState::Planning),
workflow_state_explicit: true,
state: TicketWorkflowState::parse(status).unwrap_or(TicketWorkflowState::Planning),
state_explicit: true,
attention_required: None,
next_action: Some(next_action),
updated_at: None,
+18 -15
View File
@@ -638,10 +638,10 @@ fn build_ticket_rows(
) -> ticket::Result<Vec<PanelRow>> {
let mut rows = Vec::new();
for summary in backend.list(TicketFilter::all())? {
if summary.status.as_local() == Some(TicketStatus::Closed) {
if summary.workflow_state == TicketWorkflowState::Closed {
continue;
}
let ticket = backend.show(TicketIdOrSlug::Query(summary.slug.clone()))?;
let ticket = backend.show(TicketIdOrSlug::Query(summary.id.clone()))?;
rows.push(ticket_row(summary, &ticket.events, pods, registry));
}
Ok(rows)
@@ -659,12 +659,12 @@ fn ticket_row(
let latest_event = events.last();
let entry = TicketPanelEntry {
id: summary.id.clone(),
slug: summary.slug.clone(),
slug: String::new(),
title: summary.title.clone(),
status: summary.status.as_str().to_string(),
kind: summary.kind.clone(),
status: summary.workflow_state.as_str().to_string(),
kind: String::new(),
priority: summary.priority.clone(),
labels: summary.labels.clone(),
labels: Vec::new(),
workflow_state: summary.workflow_state,
workflow_state_explicit: summary.workflow_state_explicit,
attention_required: summary.attention_required.clone(),
@@ -769,7 +769,7 @@ fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState {
priority: ActionPriority::Background,
action: Some(NextUserAction::Close),
disabled_reason: Some(
"workflow_state is done; close if a resolution is still missing.".to_string(),
"state is done; close if a resolution is still missing.".to_string(),
),
key_hint: None,
blocked_reason: None,
@@ -781,7 +781,15 @@ fn derive_ticket_state(summary: &TicketSummary) -> DerivedTicketState {
disabled_reason: Some(
"Ticket is still in planning; mark it ready before queueing.".to_string(),
),
key_hint: Some("Planning/Intake helpers can set workflow_state = ready".to_string()),
key_hint: Some("Planning/Intake helpers can set state = ready".to_string()),
blocked_reason: None,
},
TicketWorkflowState::Closed => DerivedTicketState {
kind: PanelRowKind::Review,
priority: ActionPriority::Background,
action: Some(NextUserAction::Wait),
disabled_reason: Some("Ticket is closed.".to_string()),
key_hint: None,
blocked_reason: None,
},
}
@@ -792,7 +800,6 @@ fn related_pods_for_ticket(
pods: &PodList,
registry: &PanelRegistrySnapshot,
) -> Vec<String> {
let slug = lowercase(&summary.slug);
let id = lowercase(&summary.id);
let mut names = Vec::new();
if let Some(claim) = registry.claim_for_ticket(&summary.id) {
@@ -800,7 +807,7 @@ fn related_pods_for_ticket(
}
for pod in pods.entries.iter().filter_map(|pod| {
let name = lowercase(&pod.name);
if (!slug.is_empty() && name.contains(&slug)) || (!id.is_empty() && name.contains(&id)) {
if !id.is_empty() && name.contains(&id) {
Some(pod.name.clone())
} else {
None
@@ -844,11 +851,7 @@ pub(crate) fn local_claim_status_for_pod(pod_name: &str, pods: &PodList) -> Tick
}
fn ticket_subtitle(entry: &TicketPanelEntry) -> Option<String> {
let mut parts = vec![format!(
"{} · {}",
entry.slug,
entry.workflow_state.as_str()
)];
let mut parts = vec![format!("{} · {}", entry.id, entry.workflow_state.as_str())];
if let Some(reason) = entry.attention_required.as_deref() {
parts.push(format!("attention: {reason}"));
}