feat: compose Subject Ticket assignment with selected dependency contracts

Adapt T-778 source 6d15569 and selected T-465 plugin plans on T-803 51f0c7e0. Preserve frozen Workspace107 and append Subject108, current assignee/target checks, recipient-scoped notification recovery and public contract generation. This is a candidate composition, not target adoption.
This commit is contained in:
2026-10-30 03:16:00 +09:00
parent 771120778b
commit 1eecccc21e
124 changed files with 15444 additions and 1629 deletions
@@ -21,7 +21,7 @@ use crate::feature::{
const FEATURE_ID: &str = "orchestration";
const TOOL_NAME: &str = "SpawnTicketWorker";
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); repository/Workdir attachments are optional. For builtin:coder (or coder), omitted flow defaults to builtin:coder-review so the Coder owns the Reviewer SubWorker cycle and MR verdict. Explicit flow: null disables Flow; a string selects another Flow. Other profiles get no default Flow. Results and return choices follow user intent, not a universal approval or MR gate. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); repository/Workdir attachments are optional. For builtin:coder (or coder), omitted flow defaults to builtin:coder-review so the Coder owns the Reviewer SubWorker cycle and MR verdict. Explicit flow: null disables Flow; a string selects another Flow. Other profiles get no default Flow. Results and return choices follow user intent, not a universal approval or MR gate. Assigned Subject starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
#[derive(Debug, Default)]
pub struct OrchestrationFeature;
@@ -84,7 +84,7 @@ struct SpawnTicketWorkerWorkdirInput {
#[serde(deny_unknown_fields)]
struct SpawnTicketWorkerInput {
ticket_id: String,
/// Observed current delegation identity. Required by Backend for an Orchestrator start; manual callers omit it.
/// Observed current delegation identity. Required by Backend for an assigned Subject start; manual callers omit it.
#[serde(default)]
orchestrator_assignment_id: Option<String>,
runtime_id: String,
@@ -103,6 +103,10 @@ struct SpawnTicketWorkerInput {
/// Workdirs. Backend derives effective capabilities; callers cannot request them.
#[serde(default)]
workdir_attachments: Vec<SpawnTicketWorkerWorkdirInput>,
/// Existing general delegation contract. Backend validates the live Subject
/// ceiling and requested Profile; Ticket assignment grants no capabilities.
#[serde(default)]
execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
}
// Serde's ordinary Option would collapse explicit null and an absent field.
@@ -170,7 +174,7 @@ impl Tool for SpawnTicketWorkerTool {
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
display_name: format!("Worker · {}", ticket.resource_key),
initial_submit,
execution_grant: None,
execution_grant: input.execution_grant,
})
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
@@ -339,6 +343,12 @@ mod tests {
let mut input = input();
input["flow"] = serde_json::json!("project:investigation");
input["orchestrator_assignment_id"] = serde_json::json!("delegation-742");
input["execution_grant"] = serde_json::json!({
"permissions": [{"operation":"ticket.read","resource":{"kind":"ticket","workspace_id":"workspace-1","ticket_id":"T-719"}}],
"redelegation": []
});
let expected_grant: server_api::authorization::WorkerDelegationRequest =
serde_json::from_value(input["execution_grant"].clone()).unwrap();
input["workdir_attachments"] = serde_json::json!([
{
"alias": "checkout",
@@ -359,6 +369,7 @@ mod tests {
assert_eq!(requests.len(), 1);
let request = &requests[0];
assert_eq!(request.runtime_id, "runtime-1");
assert_eq!(request.execution_grant.as_ref(), Some(&expected_grant));
assert_eq!(
request.orchestrator_assignment_id.as_deref(),
Some("delegation-742")
@@ -145,13 +145,13 @@ impl PublicAssignmentKind {
pub(super) fn description(name: &str) -> &'static str {
match name {
"TicketOrchestratorAssign" => {
"Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
"Explicitly delegate or reissue this Ticket to a target Subject principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge."
}
"TicketOrchestratorClear" => {
"Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
"Release the observed current Subject delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model."
}
"TicketAssignmentsQuery" => {
"Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
"Read a bounded page of retained Subject assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation."
}
"TicketAssignmentDiagnostics" => {
"Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state."
@@ -343,19 +343,8 @@ async fn execute_value(
identity("operation_id", &input.operation_id)?;
optional_identity("expected_assignment_id", &input.expected_assignment_id)?;
match &input.principal {
server_api::TicketAssignmentPrincipal::User { account_id } => {
identity("account_id", account_id)?
}
server_api::TicketAssignmentPrincipal::Worker {
runtime_id,
worker_id,
} => {
identity("runtime_id", runtime_id)?;
identity("worker_id", worker_id)?;
}
server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => {
identity("agent_key", agent_key)?
}
server_api::TicketAssignmentPrincipal::Subject { subject_id } => identity("subject_id", subject_id)?,
_ => return Err(ToolError::InvalidArgument("New Ticket responsibility requires an explicit Subject principal; legacy principals are audit-only".into())),
}
WorkspaceServerOperation::TicketAssignmentSet {
ticket_id: input.ticket,
@@ -46,7 +46,7 @@ pub(super) fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
vec![
(
PublicAssignmentKind::Assign,
json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
json!({"ticket":"T-743", "principal":{"kind":"subject", "subject_id":"subject-a"}, "expected_assignment_id":null, "operation_id":"assign-1"}),
json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}),
),
(
@@ -164,6 +164,26 @@ async fn public_assignment_schemas_require_observations_and_exclude_model_actor_
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn new_assignment_rejects_retained_legacy_principals_before_transport() {
for principal in [
json!({"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}),
json!({"kind":"user", "account_id":"owner"}),
json!({"kind":"worker", "runtime_id":"r", "worker_id":"old-body"}),
] {
let client = transport(200, json!({}));
let (_, tool) = definition(client.clone(), PublicAssignmentKind::Assign)();
let input = json!({"ticket":"T-743", "principal":principal,
"expected_assignment_id":null, "operation_id":"legacy-is-not-new-authority"});
assert!(matches!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await,
Err(ToolError::InvalidArgument(_))
));
assert!(client.requests.lock().unwrap().is_empty());
}
}
#[tokio::test]
async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() {
for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| {
@@ -79,7 +79,7 @@ pub(super) fn description(name: &str) -> &'static str {
"Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request."
}
"TicketAssignmentTrackingUpdate" => {
"Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
"Record the current assigned Subject's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current Subject body; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations."
}
_ => unreachable!("unknown assignment tracking tool"),
}
@@ -99,6 +99,10 @@ fn principal() -> Shape {
union(
"kind",
vec![
case(
"subject",
Some(record(vec![p("subject_id", true, Shape::String)])),
),
case(
"user",
Some(record(vec![p("account_id", true, Shape::String)])),
@@ -117,6 +121,15 @@ fn principal() -> Shape {
],
)
}
fn assignment_principal() -> Shape {
union(
"kind",
vec![case(
"subject",
Some(record(vec![p("subject_id", true, Shape::String)])),
)],
)
}
fn reason() -> Shape {
// An externally tagged domain enum is encoded by this single native union definition.
Shape::ExternalUnion {
@@ -387,7 +400,7 @@ pub(super) fn contract(op: &str) -> Contract {
"orchestrator_assign",
"Delegate or explicitly reissue responsibility; no Worker starts. Observed null means unassigned, not unknown. Exact retries retain operation identity and full input.",
vec![
p("principal", true, principal()),
p("principal", true, assignment_principal()),
group(
"observed",
true,
@@ -198,12 +198,32 @@ fn native_entry_inputs_use_allocated_namespace_and_never_subject_argument_ids()
#[test]
fn assignment_principal_and_cas_are_declared_compounds_not_opaque_json() {
let value = json!({"principal":{"$case":"worker","value":{"runtime_id":"r","worker_id":"w"}},"observed":{"assignment_id":{"$case":"null"}},"operation_id":"assign-1"});
let value = json!({"principal":{"$case":"subject","value":{"subject_id":"subject-a"}},"observed":{"assignment_id":{"$case":"null"}},"operation_id":"assign-1"});
let command = converted("orchestrator_assign", value.clone(), Some("T-42"));
assert_eq!(
command,
json!({"ticket":"T-42","principal":{"kind":"worker","runtime_id":"r","worker_id":"w"},"expected_assignment_id":null,"operation_id":"assign-1"})
json!({"ticket":"T-42","principal":{"kind":"subject","subject_id":"subject-a"},"expected_assignment_id":null,"operation_id":"assign-1"})
);
for legacy in [
json!({"$case":"worker","value":{"runtime_id":"r","worker_id":"w"}}),
json!({"$case":"user","value":{"account_id":"a"}}),
json!({"$case":"workspace_agent","value":{"agent_key":"workspace-orchestrator"}}),
] {
let mut assignment = value.clone();
assignment["principal"] = legacy;
assert!(
input(
"orchestrator_assign",
&arguments(assignment),
Some("T-42"),
Some("ticket"),
"/cases",
"/changes",
"/goals"
)
.is_err()
);
}
for invalid in [
{
let mut v = value.clone();
+3 -3
View File
@@ -606,7 +606,7 @@ mod tests {
"only `open`",
"stable tag IDs, not display names",
"no ready step or separate Queue is required",
"Orchestrator's self-reported handling",
"assigned Subject's self-reported handling",
"Answers during waiting or ongoing handling continue the same assignment row",
"explicit reissue creates a new row/assignment_id",
"Respect an explicit user approval condition",
@@ -684,9 +684,9 @@ mod tests {
)));
for (policy, name, input) in [
(
"Use `TicketOrchestratorAssign` for explicit initial delegation or reissue",
"Use `TicketOrchestratorAssign` for explicit initial assignment, reassignment or reissue to the selected Subject principal",
"TicketOrchestratorAssign",
serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
serde_json::json!({"ticket":"T-743","principal":{"kind":"subject","subject_id":"subject-a"},"expected_assignment_id":null,"operation_id":"delegate-research"}),
),
(
"research returned to a human can be `open` + handling `done`",
+3
View File
@@ -1007,6 +1007,9 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
}),
feature: manifest.feature.clone().into(),
mcp: manifest.mcp.clone(),
// Reusable selection is not a plan or delegation authority. Backend launch
// must resolve it again within the child's own Workspace/delegation bound.
plugins: Some(manifest.plugins.clone()),
compaction: manifest
.compaction
.as_ref()