diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs
index 78a40bbd5..3afda0d9a 100644
--- a/crates/merge-request/src/lib.rs
+++ b/crates/merge-request/src/lib.rs
@@ -979,7 +979,7 @@ impl MergeRequestStore {
}
let assignment_is_current: bool = transaction.query_row(
"SELECT EXISTS(
- SELECT 1 FROM ticket_current_worker_assignments
+ SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
)",
params![i.auth.workspace_id, i.ticket_id, i.auth.assignment_id],
@@ -1174,7 +1174,7 @@ impl MergeRequestStore {
}
let assignment_is_current: bool = transaction.query_row(
"SELECT EXISTS(
- SELECT 1 FROM ticket_current_worker_assignments
+ SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
)",
params![
@@ -1291,20 +1291,8 @@ impl MergeRequestStore {
"Ticket state changed during completion".into(),
));
}
- let released = transaction.execute(
- "DELETE FROM ticket_current_worker_assignments
- WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3",
- params![
- input.auth.workspace_id,
- input.ticket_id,
- input.auth.assignment_id
- ],
- )?;
- if released != 1 {
- return Err(MergeRequestError::Unauthorized(
- "Ticket completion assignment changed while releasing it".into(),
- ));
- }
+ // Terminal state ends unfinished work through the Workspace authority's
+ // lifecycle trigger. Keep the responsibility identity and its history.
let event = TicketCompletionEvent {
operation_id: input.operation_id,
ticket_id: input.ticket_id.clone(),
diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs
index b73f656eb..183e7c677 100644
--- a/crates/merge-request/tests/store.rs
+++ b/crates/merge-request/tests/store.rs
@@ -39,7 +39,7 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("db");
let c = Connection::open(&p).unwrap();
- c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap();
+ c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
drop(c);
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
assignment_id: "A".into(),
@@ -250,7 +250,9 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
)
.unwrap();
assert_eq!(state, "done");
- assert!(!assignment_exists);
+ assert!(assignment_exists);
+ let active: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T')", [], |row| row.get(0)).unwrap();
+ assert!(!active);
let replayed_completion = s
.complete_ticket(CompleteTicket {
ticket_id: "T".into(),
diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs
index 789229581..b88574510 100644
--- a/crates/worker/src/feature/builtin/manage_worker.rs
+++ b/crates/worker/src/feature/builtin/manage_worker.rs
@@ -875,7 +875,7 @@ impl WorkerOperation {
"Restore a stopped Backend/Runtime Worker session in the current Workspace. Supply expected_observation_token from WorkerList; a conflict requires refreshing and deliberate new intent. New requests default to the durable Tool call ID. For explicit recovery of an unknown/pending outcome, supply the reported request_id and the original token unchanged; never create a new intent or automatically retry."
}
Self::Remove => {
- "Remove an eligible stopped, unassigned, non-internal Worker. Supply a bounded reason; Backend validation and retention are authoritative."
+ "Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Supply a bounded reason; Backend validation and retention are authoritative."
}
}
}
diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs
index 88460b2d7..b1d1e786c 100644
--- a/crates/worker/src/feature/builtin/merge_request.rs
+++ b/crates/worker/src/feature/builtin/merge_request.rs
@@ -1149,7 +1149,7 @@ pub fn description(n: &str) -> Option<&'static str> {
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
),
"CompleteTicket" => Some(
- "Complete the Ticket only after reviewing its current item revision and exact linked Merge Request result set; this releases the current assignment atomically.",
+ "Complete the Ticket only after reviewing its current item revision and exact linked Merge Request result set; this ends unfinished work atomically while retaining responsibility and assignment history.",
),
"ReviewMergeRequest" => Some(
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs
index 1b72b8415..6ef4aa501 100644
--- a/crates/worker/src/prompt/catalog.rs
+++ b/crates/worker/src/prompt/catalog.rs
@@ -1027,7 +1027,7 @@ mod tests {
}
#[test]
- fn builtin_orchestrator_cleanup_policy_renders_with_common_includes() {
+ fn builtin_orchestrator_cleanup_retains_terminal_responsibility_and_blocks_unfinished_work() {
let rendered = PromptCatalog::builtins_only()
.unwrap()
.render_name("role.orchestrator", Value::UNDEFINED)
@@ -1040,7 +1040,21 @@ mod tests {
assert!(rendered.contains("call `WorkerStop`"));
assert!(rendered.contains("call `WorkerRemove`"));
assert!(rendered.contains("only then call `WorkdirDelete`"));
- assert!(rendered.contains("`CurrentAssignment` means unassign and reread"));
+ assert!(rendered.contains("must have no unfinished Ticket work"));
+ assert!(rendered.contains("does not require unassignment before cleanup"));
+ assert!(rendered.contains("`UnfinishedWork` (`unfinished_work` on the wire)"));
+ assert!(rendered.contains("requires explicit work end or reassignment"));
+ assert!(rendered.contains("idle or stopped status does not end unfinished work"));
+ for stale_policy in [
+ "no current Ticket assignment",
+ "unassign it through the available orchestration authority",
+ "`CurrentAssignment`",
+ ] {
+ assert!(
+ !rendered.contains(stale_policy),
+ "stale cleanup policy: {stale_policy}"
+ );
+ }
assert!(!rendered.contains("{% include"));
}
}
diff --git a/crates/workspace-server/src/assignment_work.sql b/crates/workspace-server/src/assignment_work.sql
new file mode 100644
index 000000000..72de3169c
--- /dev/null
+++ b/crates/workspace-server/src/assignment_work.sql
@@ -0,0 +1,100 @@
+-- Responsibility is durable. A release only ends the operational authority of
+-- this assignment identity; reopening a Ticket must not revive that authority.
+CREATE TABLE ticket_assignment_work_releases (
+ workspace_id TEXT NOT NULL,
+ assignment_id TEXT NOT NULL,
+ released_at TEXT NOT NULL,
+ PRIMARY KEY (workspace_id, assignment_id),
+ FOREIGN KEY (workspace_id, assignment_id)
+ REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE
+);
+CREATE VIEW ticket_active_worker_assignments AS
+ SELECT current.* FROM ticket_current_worker_assignments AS current
+ JOIN typed_tickets AS ticket
+ ON ticket.workspace_id = current.workspace_id AND ticket.ticket_id = current.ticket_id
+ WHERE ticket.workflow_state NOT IN ('done', 'closed')
+ AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases AS released
+ WHERE released.workspace_id = current.workspace_id
+ AND released.assignment_id = current.assignment_id);
+CREATE TRIGGER ticket_terminal_releases_work
+AFTER UPDATE OF workflow_state ON typed_tickets
+WHEN NEW.workflow_state IN ('done', 'closed')
+BEGIN
+ INSERT OR IGNORE INTO ticket_assignment_work_releases
+ SELECT workspace_id, assignment_id, COALESCE(NEW.updated_at, updated_at)
+ FROM ticket_current_worker_assignments
+ WHERE workspace_id = NEW.workspace_id AND ticket_id = NEW.ticket_id;
+END;
+CREATE TRIGGER ticket_terminal_assignment_releases_work_insert
+AFTER INSERT ON ticket_current_worker_assignments
+WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
+ AND ticket_id = NEW.ticket_id AND workflow_state IN ('done', 'closed'))
+BEGIN
+ INSERT OR IGNORE INTO ticket_assignment_work_releases
+ VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at);
+END;
+CREATE TRIGGER ticket_terminal_assignment_releases_work_update
+AFTER UPDATE ON ticket_current_worker_assignments
+WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
+ AND ticket_id = NEW.ticket_id AND workflow_state IN ('done', 'closed'))
+BEGIN
+ INSERT OR IGNORE INTO ticket_assignment_work_releases
+ VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at);
+END;
+CREATE TRIGGER ticket_active_worker_role_insert
+BEFORE INSERT ON ticket_current_worker_assignments
+WHEN NEW.principal_kind = 'worker'
+ AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
+ AND ticket_id = NEW.ticket_id AND workflow_state NOT IN ('done', 'closed'))
+ AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases
+ WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id)
+ AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments
+ WHERE workspace_id = NEW.workspace_id AND role = NEW.role
+ AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
+ AND assignment_id != NEW.assignment_id)
+BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END;
+CREATE TRIGGER ticket_active_worker_role_update
+BEFORE UPDATE ON ticket_current_worker_assignments
+WHEN NEW.principal_kind = 'worker'
+ AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
+ AND ticket_id = NEW.ticket_id AND workflow_state NOT IN ('done', 'closed'))
+ AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases
+ WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id)
+ AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments
+ WHERE workspace_id = NEW.workspace_id AND role = NEW.role
+ AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
+ AND assignment_id != OLD.assignment_id)
+BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END;
+-- Keep archived responsibilities valid after Worker removal, but never allow
+-- newly installed identities to refer to a removed / removal-reserved Worker.
+CREATE TRIGGER ticket_active_worker_principal_insert
+BEFORE INSERT ON ticket_current_worker_assignments
+WHEN NEW.principal_kind = 'worker' AND (
+ NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id
+ AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id)
+ OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id
+ AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
+ AND state IN ('executing', 'failed', 'succeeded')))
+BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END;
+CREATE TRIGGER ticket_active_worker_principal_update
+BEFORE UPDATE ON ticket_current_worker_assignments
+WHEN NEW.principal_kind = 'worker' AND (
+ NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id
+ AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id)
+ OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id
+ AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
+ AND state IN ('executing', 'failed', 'succeeded')))
+BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END;
+CREATE TRIGGER ticket_unfinished_work_blocks_worker_delete
+BEFORE DELETE ON worker_registry
+WHEN EXISTS (SELECT 1 FROM ticket_active_worker_assignments
+ WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id
+ AND worker_id = OLD.worker_id)
+BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END;
+CREATE TRIGGER ticket_unfinished_work_blocks_worker_move
+BEFORE UPDATE OF runtime_id, worker_id, workspace_id ON worker_registry
+WHEN (OLD.runtime_id != NEW.runtime_id OR OLD.worker_id != NEW.worker_id OR OLD.workspace_id != NEW.workspace_id)
+ AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments
+ WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id
+ AND worker_id = OLD.worker_id)
+BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END;
diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs
index 8a60ffe40..2ef3830e2 100644
--- a/crates/workspace-server/src/authority.rs
+++ b/crates/workspace-server/src/authority.rs
@@ -146,7 +146,7 @@ impl merge_request::AssignmentSource for AuthorityMergeRequestSource {
ticket_id: &str,
) -> std::result::Result