feat: separate Ticket case state and immutable closure judgments

This commit is contained in:
2026-10-11 01:41:46 +09:00
parent 6a12b6cc34
commit ba3ebaa68c
65 changed files with 7625 additions and 6844 deletions
@@ -247,13 +247,13 @@ fn validate_relative_cwd(value: String) -> Result<String, ToolError> {
mod tests {
use std::sync::Mutex;
use ticket::{TicketError, TicketWorkflowState};
use ticket::{TicketError, TicketState};
use super::*;
use crate::feature::builtin::ticket::TicketHandoff;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
struct FixedTicketService(TicketWorkflowState);
struct FixedTicketService(TicketState);
impl TicketService for FixedTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
@@ -295,7 +295,7 @@ mod tests {
})
}
fn tool(service: Arc<RecordingService>, state: TicketWorkflowState) -> SpawnTicketWorkerTool {
fn tool(service: Arc<RecordingService>, state: TicketState) -> SpawnTicketWorkerTool {
SpawnTicketWorkerTool {
ticket_service: Arc::new(FixedTicketService(state)),
worker_service: service,
@@ -315,7 +315,7 @@ mod tests {
},
{"alias": "docs", "working_directory_id": "workdir-2"}
]);
let output = tool(service.clone(), TicketWorkflowState::Queued)
let output = tool(service.clone(), TicketState::Open)
.execute(
&input.to_string(),
ToolExecutionContext::new("call-7", "batch-1", 0),
@@ -372,7 +372,7 @@ mod tests {
input["workdir_attachments"] = serde_json::json!([]);
input["flow"] = serde_json::Value::Null;
}
tool(service.clone(), TicketWorkflowState::Queued)
tool(service.clone(), TicketState::Open)
.execute(
&input.to_string(),
ToolExecutionContext::new("call-empty", "batch-1", 0),
@@ -395,7 +395,7 @@ mod tests {
async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() {
let service = Arc::new(RecordingService::default());
let definition = definition(
Arc::new(FixedTicketService(TicketWorkflowState::Ready)),
Arc::new(FixedTicketService(TicketState::Open)),
service.clone(),
);
let (meta, tool) = definition();
@@ -434,33 +434,35 @@ mod tests {
}
#[tokio::test]
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
let service = Arc::new(RecordingService {
response: Some(WorkspaceResponse {
status: 409,
body: "Backend rejected Ticket claim".into(),
}),
..Default::default()
});
let error = tool(service.clone(), TicketWorkflowState::Planning)
.execute(
&input().to_string(),
ToolExecutionContext::new("call-claim", "batch-1", 0),
)
.await
.unwrap_err();
assert_eq!(service.requests.lock().unwrap().len(), 1);
assert!(
error
.to_string()
.contains("HTTP 409: Backend rejected Ticket claim")
);
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend_not_case_state() {
for state in [TicketState::Open, TicketState::Closed] {
let service = Arc::new(RecordingService {
response: Some(WorkspaceResponse {
status: 409,
body: "Backend rejected Ticket claim".into(),
}),
..Default::default()
});
let error = tool(service.clone(), state)
.execute(
&input().to_string(),
ToolExecutionContext::new("call-claim", "batch-1", 0),
)
.await
.unwrap_err();
assert_eq!(service.requests.lock().unwrap().len(), 1);
assert!(
error
.to_string()
.contains("HTTP 409: Backend rejected Ticket claim")
);
}
}
#[tokio::test]
async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() {
let service = Arc::new(RecordingService::default());
let tool = tool(service.clone(), TicketWorkflowState::Queued);
let tool = tool(service.clone(), TicketState::Open);
let cases = [
(
"profile",
@@ -603,7 +605,7 @@ mod tests {
#[test]
fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() {
let definition = definition(
Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
Arc::new(FixedTicketService(TicketState::Open)),
Arc::new(RecordingService::default()),
);
let (meta, _) = definition();
@@ -13,7 +13,10 @@ pub(super) struct ModelTicketQueryResponse {
struct ModelTicketQueryItem {
ticket: String,
title: String,
state: String,
state: ticket::TicketState,
closure_operation_key: Option<String>,
current_closure: Option<ticket::TicketClosureJudgment>,
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
@@ -38,11 +41,15 @@ pub(super) struct ModelTicketDetail {
content_digest: String,
title: String,
body: String,
state: String,
state: ticket::TicketState,
closure_operation_key: Option<String>,
current_closure: Option<ticket::TicketClosureJudgment>,
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
closure_history: Vec<ticket::TicketCaseHistoryEntry>,
thread: Vec<ModelTicketEvent>,
relations: ModelTicketRelations,
linked_objectives: Vec<ModelObjectiveSummary>,
@@ -216,6 +223,16 @@ struct ModelObjectiveEventPage {
has_more: bool,
}
fn typed_field<T: serde::de::DeserializeOwned>(
root: &Map<String, Value>,
key: &str,
) -> Result<T, String> {
let value = root
.get(key)
.ok_or_else(|| format!("Ticket projection requires {key}"))?;
serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}"))
}
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
let root = object(&value, "Ticket query response")?;
let page = object_field(root, "page")?;
@@ -235,7 +252,10 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
Ok(ModelTicketQueryItem {
ticket: resource_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?,
state: string_field(item, "state")?,
state: typed_field(item, "state")?,
closure_operation_key: typed_field(item, "closure_operation_key")?,
current_closure: typed_field(item, "current_closure")?,
legacy_handoff: typed_field(item, "legacy_handoff")?,
readiness: optional_string(item, "readiness")?,
priority: optional_string(item, "priority")?,
created_at: optional_string(item, "created_at")?,
@@ -286,7 +306,11 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
content_digest: string_field(root, "content_digest")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
state: typed_field(root, "state")?,
closure_operation_key: typed_field(root, "closure_operation_key")?,
current_closure: typed_field(root, "current_closure")?,
legacy_handoff: typed_field(root, "legacy_handoff")?,
closure_history: typed_field(root, "closure_history")?,
readiness: optional_string(root, "readiness")?,
priority: optional_string(root, "priority")?,
created_at: optional_string(root, "created_at")?,
@@ -694,16 +718,10 @@ mod tests {
#[test]
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
for state in ["open", "closed"] {
let projected = project_ticket_detail(json!({
"resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null,
@@ -724,6 +742,54 @@ mod tests {
}
}
#[test]
fn reopened_ticket_projection_preserves_closure_observation_history_and_legacy_handoff() {
let history = json!([
{
"operation_key": "close-1", "previous_closure_operation_key": null,
"action": {"action": "close", "reason": "not_planned"},
"explanation": "Not proceeding", "references": [{"kind": "ticket", "target": "T-12"}],
"author": "workspace-user:alice", "at": "2026-01-01T00:00:00Z"
},
{
"operation_key": "reopen-1", "previous_closure_operation_key": "close-1",
"action": {"action": "reopen"}, "explanation": "New information",
"references": [], "author": "workspace-user:bob", "at": "2026-01-02T00:00:00Z"
}
]);
let legacy = json!({
"workflow_state": "queued", "workflow_state_explicit": true, "status": "open",
"queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null
});
let response = json!({
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements",
"state": "open", "closure_operation_key": "close-1", "current_closure": null,
"closure_history": history, "legacy_handoff": legacy,
"events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null
});
let projected =
serde_json::to_value(project_ticket_detail(response.clone()).unwrap()).unwrap();
assert_eq!(projected["state"], "open");
assert_eq!(projected["closure_operation_key"], "close-1");
assert_eq!(projected["current_closure"], Value::Null);
assert_eq!(projected["closure_history"], history);
assert_eq!(projected["legacy_handoff"], legacy);
// Missing observations must not silently become null: the model must be
// able to distinguish an observed never-closed case from unknown data.
let mut missing = response;
missing
.as_object_mut()
.unwrap()
.remove("closure_operation_key");
assert!(
project_ticket_detail(missing)
.unwrap_err()
.contains("closure_operation_key")
);
}
#[test]
fn objective_projection_exposes_only_resource_references() {
let projected = project_objective_detail(json!({
@@ -771,7 +837,8 @@ mod tests {
"id": "00001TICKETINTERNAL",
"resource_key": "T-543",
"title": "Ticket",
"state": "inprogress",
"state": "open",
"closure_operation_key": null, "current_closure": null, "legacy_handoff": null,
"readiness": null,
"priority": "high",
"created_at": null,
@@ -842,9 +909,10 @@ mod tests {
"id": "internal-ticket",
"resource_key": "T-588",
"content_digest": "ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Queued Submit",
"body": "Body",
"state": "planning",
"state": "open",
"readiness": null,
"priority": "P2",
"created_at": "2026-09-03T00:00:00Z",
@@ -945,9 +1013,10 @@ mod tests {
let projected = project_ticket_detail(json!({
"resource_key": "T-716",
"content_digest": "current-ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Source evidence",
"body": "Body",
"state": if merged { "done" } else { "inprogress" },
"state": "open",
"events": [],
"linked_objectives": [],
"assignments": [],
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -796,7 +796,7 @@ mod tests {
"ReviewMergeRequest",
"CheckMergeRequestReadiness",
"CompleteMergeRequest",
"CompleteTicket",
"TicketCaseUpdate",
] {
assert!(combined.contains(operation), "missing {operation}");
}
+4 -4
View File
@@ -384,12 +384,12 @@ mod tests {
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
let complete = tools
.iter()
.find(|meta| meta.name == "CompleteTicket")
.find(|meta| meta.name == "TicketCaseUpdate")
.unwrap();
assert!(
complete
.description
.contains("Merge Requests and approvals are not prerequisites")
.contains("Repository, Merge Request and approval evidence are not prerequisites")
);
assert!(complete.description.contains("not real judgment"));
assert!(
@@ -461,12 +461,12 @@ mod tests {
);
let complete = tools
.iter()
.find(|meta| meta.name == "CompleteTicket")
.find(|meta| meta.name == "TicketCaseUpdate")
.unwrap();
assert!(
complete
.description
.contains("return without conclusion need not call this tool")
.contains("Return without conclusion need not call this tool")
);
}
}