fix(ticket): separate retained responsibility from unfinished work
T-715: end assignment work identities atomically on done/close without deleting responsibility. Reopen never reactivates old authority. Schema 82 preserves legacy closed assignments, historical removal plans and Worker attribution; active work drives admission, auth/routing, retention and cleanup while independent lifecycle/attachment fences remain. Validation: root cargo check; cargo test -p yoi-workspace-server (630 lib + 11 bin), -p merge-request (20), -p worker (947); worker-runtime restore/removal filters (2); Deno ticket responsibility/runtime UI contracts (12); cargo fmt --all -- --check; git diff --check HEAD.
This commit is contained in:
@@ -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;
|
||||
@@ -146,7 +146,7 @@ impl merge_request::AssignmentSource for AuthorityMergeRequestSource {
|
||||
ticket_id: &str,
|
||||
) -> std::result::Result<Option<merge_request::CurrentAssignment>, String> {
|
||||
self.store
|
||||
.get_current_ticket_coder_assignment(workspace_id, ticket_id)
|
||||
.get_active_ticket_worker_assignment(workspace_id, ticket_id)
|
||||
.map(|assignment| {
|
||||
assignment.map(|assignment| merge_request::CurrentAssignment {
|
||||
assignment_id: assignment.assignment_id,
|
||||
@@ -831,10 +831,15 @@ impl SqliteWorkspaceAuthority {
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let has_orchestrator = role_assignments
|
||||
// Responsibility remains visible after work ends; action eligibility must
|
||||
// use only active unfinished work, including after a Ticket is reopened.
|
||||
let active_role_assignments = self
|
||||
.store
|
||||
.list_active_ticket_role_assignments(&self.workspace_id, id)?;
|
||||
let has_orchestrator = active_role_assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator);
|
||||
let has_coder = role_assignments
|
||||
let has_coder = active_role_assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
|
||||
let targets = ticket
|
||||
@@ -860,7 +865,7 @@ impl SqliteWorkspaceAuthority {
|
||||
for ticket_id in &dependency_check.queue_tickets {
|
||||
let assignments = self
|
||||
.store
|
||||
.list_current_ticket_role_assignments(&self.workspace_id, ticket_id)?;
|
||||
.list_active_ticket_role_assignments(&self.workspace_id, ticket_id)?;
|
||||
if !assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator)
|
||||
|
||||
@@ -384,9 +384,6 @@ CREATE TABLE ticket_current_worker_assignments (
|
||||
FOREIGN KEY (workspace_id, ticket_id)
|
||||
REFERENCES typed_tickets(workspace_id, ticket_id)
|
||||
ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, runtime_id, worker_id)
|
||||
REFERENCES worker_registry(workspace_id, runtime_id, worker_id)
|
||||
ON DELETE RESTRICT,
|
||||
CHECK(
|
||||
(principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL)
|
||||
OR
|
||||
@@ -1219,9 +1216,6 @@ CREATE INDEX ticket_current_principal_idx
|
||||
CREATE UNIQUE INDEX ticket_current_singleton_role_idx
|
||||
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
|
||||
WHERE role IN ('orchestrator', 'coder');
|
||||
CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
ON ticket_current_worker_assignments(workspace_id, role, runtime_id, worker_id)
|
||||
WHERE principal_kind = 'worker';
|
||||
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
||||
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
||||
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
|
||||
|
||||
@@ -69,7 +69,7 @@ pub struct WorkerRemovalPlanRequest {
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum WorkerRemovalBlocker {
|
||||
Hold,
|
||||
CurrentAssignment {
|
||||
UnfinishedWork {
|
||||
assignment_id: String,
|
||||
ticket_id: String,
|
||||
},
|
||||
@@ -229,8 +229,8 @@ impl SqliteWorkspaceStore {
|
||||
};
|
||||
let mut blockers=Vec::new();
|
||||
if worker.retention_state=="pinned" { blockers.push(WorkerRemovalBlocker::Hold); }
|
||||
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
|
||||
blockers.push(WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id});
|
||||
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
|
||||
blockers.push(WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id});
|
||||
}
|
||||
let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?;
|
||||
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
|
||||
@@ -276,10 +276,10 @@ impl SqliteWorkspaceStore {
|
||||
mark_stale(&tx,&plan,"hold added")?;tx.commit()?;
|
||||
return Err(stale_error(&plan,"hold added"));
|
||||
}
|
||||
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?;
|
||||
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?;
|
||||
if assigned{
|
||||
mark_stale(&tx,&plan,"current assignment added")?;tx.commit()?;
|
||||
return Err(stale_error(&plan,"current assignment added"));
|
||||
mark_stale(&tx,&plan,"unfinished work added")?;tx.commit()?;
|
||||
return Err(stale_error(&plan,"unfinished work added"));
|
||||
}
|
||||
let now=Utc::now().to_rfc3339();
|
||||
tx.execute("UPDATE worker_removal_operations SET state='executing',updated_at=?1 WHERE operation_id=?2",params![now,plan.operation_id])?;
|
||||
@@ -462,8 +462,8 @@ impl SqliteWorkspaceStore {
|
||||
let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?;
|
||||
if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));}
|
||||
if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); }
|
||||
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
|
||||
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:current assignment added",plan.plan_id))); }
|
||||
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
|
||||
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); }
|
||||
let now=Utc::now().to_rfc3339();
|
||||
if let Some(a)=&result.archive{
|
||||
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
|
||||
@@ -1427,29 +1427,60 @@ mod tests {
|
||||
).map_err(StoreError::from)).unwrap();
|
||||
assert_eq!(historical, 1);
|
||||
}
|
||||
fn install_current_assignment(
|
||||
s: &SqliteWorkspaceStore,
|
||||
assignment_id: &str,
|
||||
) -> crate::Result<()> {
|
||||
let assignment = TicketCoderAssignmentRecord {
|
||||
workspace_id: "w".into(),
|
||||
ticket_id: "ticket".into(),
|
||||
assignment_id: assignment_id.into(),
|
||||
worker: req().worker,
|
||||
assigned_by: "test".into(),
|
||||
assigned_at: "t".into(),
|
||||
};
|
||||
s.set_current_ticket_coder_assignment(
|
||||
&assignment,
|
||||
None,
|
||||
&format!("event-{assignment_id}"),
|
||||
&format!("operation-{assignment_id}"),
|
||||
false,
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn seed_current_assignment(s: &SqliteWorkspaceStore) {
|
||||
install_current_assignment(s, "assignment").unwrap();
|
||||
}
|
||||
|
||||
fn reopened_terminal_assignment_store(terminal_state: &str) -> SqliteWorkspaceStore {
|
||||
let store = setup();
|
||||
seed_current_assignment(&store);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state=?1
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[terminal_state],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state='planning'
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_and_orphan_are_authoritative() {
|
||||
let s = setup();
|
||||
s.with_conn(|c| {
|
||||
let stable_worker_id = worker_id().to_string();
|
||||
c.execute(
|
||||
"INSERT INTO ticket_worker_assignments(\
|
||||
workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at\
|
||||
) VALUES('w','ticket','assignment','r',?1,'test','t')",
|
||||
[&stable_worker_id],
|
||||
)?;
|
||||
c.execute(
|
||||
"INSERT INTO ticket_current_worker_assignments(\
|
||||
workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at\
|
||||
) VALUES('w','ticket','assignment','r',?1,'t')",
|
||||
[&stable_worker_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
seed_current_assignment(&s);
|
||||
let p = s.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
assert!(
|
||||
matches!(&p.blockers[..],[WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket")
|
||||
matches!(&p.blockers[..],[WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket")
|
||||
);
|
||||
let runtime_only = WorkerRetentionInventory {
|
||||
workspace_id: "w".into(),
|
||||
@@ -1501,6 +1532,355 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(unchanged, 2);
|
||||
}
|
||||
#[test]
|
||||
fn unfinished_work_blocker_uses_explicit_wire_kind() {
|
||||
let wire = serde_json::json!({
|
||||
"kind": "unfinished_work",
|
||||
"assignment_id": "assignment",
|
||||
"ticket_id": "ticket",
|
||||
});
|
||||
let blocker: WorkerRemovalBlocker = serde_json::from_value(wire.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(blocker).unwrap(), wire);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_retained_assignment_allows_worker_removal_even_after_reopen() {
|
||||
for (status, workflow_state) in [("closed", "closed"), ("open", "done")] {
|
||||
for reopen in [false, true] {
|
||||
let store = setup();
|
||||
seed_current_assignment(&store);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET status=?1, workflow_state=?2
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
params![status, workflow_state],
|
||||
)?;
|
||||
if reopen {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET status='open', workflow_state='planning'
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.update_worker_retention_policy(
|
||||
"w",
|
||||
1,
|
||||
&WorkerRetentionPolicyUpdate {
|
||||
policy_id: "purge".into(),
|
||||
session_disposition: SessionDisposition::Purge,
|
||||
metadata_disposition: MetadataDisposition::Tombstone,
|
||||
archive_retention: ArchiveRetention::Forever,
|
||||
diagnostics_disposition: DiagnosticsDisposition::Purge,
|
||||
diagnostics_retention_seconds: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
assert!(
|
||||
plan.blockers.is_empty(),
|
||||
"{status}/{workflow_state}, reopened={reopen}"
|
||||
);
|
||||
store
|
||||
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||
.unwrap();
|
||||
let result = WorkerRetentionExecutionResult {
|
||||
operation_id: plan.operation_id.clone(),
|
||||
input_fingerprint: plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
worker_id: worker_id(),
|
||||
session_disposition: plan.session_disposition,
|
||||
diagnostics_disposition: plan.diagnostics_disposition,
|
||||
archive: None,
|
||||
source_removed: true,
|
||||
diagnostics_retained: false,
|
||||
};
|
||||
let committed = store
|
||||
.commit_worker_removal(
|
||||
"w",
|
||||
&plan.operation_id,
|
||||
&plan.input_fingerprint,
|
||||
&result,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(committed.plan.state, WorkerRemovalPlanState::Succeeded);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry("w", &plan.worker)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_some());
|
||||
let (retained, historical): (i64, i64) = store
|
||||
.with_conn(|conn| {
|
||||
Ok((
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id='w' AND assignment_id='assignment'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?,
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ticket_worker_assignments
|
||||
WHERE workspace_id='w' AND assignment_id='assignment'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(retained, historical),
|
||||
(1, 1),
|
||||
"assignment history must survive removal"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfinished_work_added_after_planning_blocks_preparation() {
|
||||
let store = setup();
|
||||
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
seed_current_assignment(&store);
|
||||
let error = store
|
||||
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
|
||||
if reason == "unfinished work added")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry("w", &plan.worker)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_assignment_after_terminal_end_blocks_preparation() {
|
||||
for terminal_state in ["done", "closed"] {
|
||||
let store = reopened_terminal_assignment_store(terminal_state);
|
||||
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
assert!(plan.blockers.is_empty());
|
||||
install_current_assignment(&store, "new-assignment").unwrap();
|
||||
let blocked = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
assert_eq!(
|
||||
blocked.blockers,
|
||||
vec![WorkerRemovalBlocker::UnfinishedWork {
|
||||
assignment_id: "new-assignment".into(),
|
||||
ticket_id: "ticket".into(),
|
||||
}]
|
||||
);
|
||||
let replay = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
assert_eq!(replay.plan_id, blocked.plan_id);
|
||||
assert_eq!(replay.input_fingerprint, blocked.input_fingerprint);
|
||||
assert_ne!(blocked.plan_id, plan.plan_id);
|
||||
let error = store
|
||||
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
|
||||
if reason == "unfinished work added")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry("w", &plan.worker)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_removal_fences_distinct_assignment_after_terminal_end() {
|
||||
let store = reopened_terminal_assignment_store("done");
|
||||
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
store
|
||||
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||
.unwrap();
|
||||
let error = install_current_assignment(&store, "new-assignment").unwrap_err();
|
||||
assert!(
|
||||
matches!(error, StoreError::TicketAssignmentConflict(message)
|
||||
if message.contains("being retained or has been removed"))
|
||||
);
|
||||
let (current, history): (i64, i64) = store
|
||||
.with_conn(|conn| {
|
||||
Ok((
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?,
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ticket_worker_assignments
|
||||
WHERE workspace_id='w' AND assignment_id='new-assignment'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(current, 1, "the ended responsibility must remain retained");
|
||||
assert_eq!(history, 0, "the rejected new identity must roll back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_rechecks_distinct_assignment_independently_of_admission_fence() {
|
||||
let store = reopened_terminal_assignment_store("done");
|
||||
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
store
|
||||
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||
.unwrap();
|
||||
// Fault injection is confined to this in-memory authority fixture: use
|
||||
// direct SQLite writes to bypass the public API removal fence, and drop
|
||||
// both principal admission triggers to prove commit's independent guard.
|
||||
store
|
||||
.with_conn_mut(|conn| {
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute_batch(
|
||||
"DROP TRIGGER ticket_active_worker_principal_insert;
|
||||
DROP TRIGGER ticket_active_worker_principal_update;",
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO ticket_worker_assignments (
|
||||
workspace_id, ticket_id, assignment_id, runtime_id, worker_id,
|
||||
assigned_by, assigned_at
|
||||
) VALUES ('w', 'ticket', 'new-assignment', 'r', ?1, 'test', 'new')",
|
||||
[worker_id().to_string()],
|
||||
)?;
|
||||
let replaced = tx.execute(
|
||||
"UPDATE ticket_current_worker_assignments
|
||||
SET assignment_id='new-assignment', updated_at='new'
|
||||
WHERE workspace_id='w' AND ticket_id='ticket' AND role='coder'
|
||||
AND assignment_id='assignment'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM ticket_assignment_work_releases
|
||||
WHERE workspace_id='w' AND assignment_id='assignment'
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
if replaced != 1 {
|
||||
return Err(StoreError::InvalidInput(
|
||||
"fault injection requires one ended current assignment".into(),
|
||||
));
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let result = WorkerRetentionExecutionResult {
|
||||
operation_id: plan.operation_id.clone(),
|
||||
input_fingerprint: plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
worker_id: worker_id(),
|
||||
session_disposition: plan.session_disposition,
|
||||
diagnostics_disposition: plan.diagnostics_disposition,
|
||||
archive: None,
|
||||
source_removed: true,
|
||||
diagnostics_retained: false,
|
||||
};
|
||||
let error = store
|
||||
.commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
|
||||
if reason == "unfinished work added")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry("w", &plan.worker)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_none());
|
||||
let (state, active): (String, String) = store
|
||||
.with_conn(|conn| {
|
||||
Ok((
|
||||
conn.query_row(
|
||||
"SELECT state FROM worker_removal_operations WHERE operation_id=?1",
|
||||
[&plan.operation_id],
|
||||
|row| row.get(0),
|
||||
)?,
|
||||
conn.query_row(
|
||||
"SELECT assignment_id FROM ticket_active_worker_assignments
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
state, "executing",
|
||||
"rejected commit must remain recoverable"
|
||||
);
|
||||
assert_eq!(
|
||||
active, "new-assignment",
|
||||
"the ended identity must not reactivate"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_assignment_does_not_bypass_hold_added_before_commit() {
|
||||
let store = setup();
|
||||
seed_current_assignment(&store);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state='done'
|
||||
WHERE workspace_id='w' AND ticket_id='ticket'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
|
||||
store
|
||||
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
|
||||
.unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE worker_registry SET retention_state='pinned'
|
||||
WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1",
|
||||
[worker_id().to_string()],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let result = WorkerRetentionExecutionResult {
|
||||
operation_id: plan.operation_id.clone(),
|
||||
input_fingerprint: plan.input_fingerprint.clone(),
|
||||
expected_worker_revision: plan.worker_revision.clone(),
|
||||
worker_id: worker_id(),
|
||||
session_disposition: plan.session_disposition,
|
||||
diagnostics_disposition: plan.diagnostics_disposition,
|
||||
archive: None,
|
||||
source_removed: true,
|
||||
diagnostics_retained: false,
|
||||
};
|
||||
let error = store
|
||||
.commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
|
||||
if reason == "hold added")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_worker_registry("w", &plan.worker)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_plan_converges_and_purge_omits_tombstone() {
|
||||
let s = std::sync::Arc::new(setup());
|
||||
|
||||
@@ -5697,7 +5697,7 @@ fn ticket_target_capabilities_for_workdir(
|
||||
}
|
||||
let Some(assignment) = api
|
||||
.store
|
||||
.get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)?
|
||||
.get_active_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)?
|
||||
.filter(|assignment| assignment.role == TicketAssignmentRole::Coder)
|
||||
else {
|
||||
return Ok(source_capabilities);
|
||||
@@ -13177,7 +13177,7 @@ async fn scoped_set_ticket_assignment(
|
||||
}
|
||||
if api
|
||||
.store
|
||||
.get_current_ticket_role_assignment(
|
||||
.get_active_ticket_role_assignment(
|
||||
&workspace_id,
|
||||
&ticket.id,
|
||||
TicketAssignmentRole::Coder,
|
||||
@@ -13310,7 +13310,7 @@ async fn scoped_cancel_ticket_implementation(
|
||||
.into());
|
||||
}
|
||||
|
||||
let current = api.store.get_current_ticket_role_assignment(
|
||||
let current = api.store.get_active_ticket_role_assignment(
|
||||
&path.workspace_id,
|
||||
&ticket.id,
|
||||
TicketAssignmentRole::Coder,
|
||||
@@ -13479,7 +13479,7 @@ fn validate_ticket_assignment_spawn(
|
||||
|
||||
if let Some(current) = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&api.config.workspace_id, &assignment.ticket_id)?
|
||||
.get_active_ticket_worker_assignment(&api.config.workspace_id, &assignment.ticket_id)?
|
||||
{
|
||||
let replay_matches = api
|
||||
.store
|
||||
@@ -13563,7 +13563,7 @@ fn assign_ticket_worker_from_lifecycle(
|
||||
if operation.action == "assign"
|
||||
&& operation.ticket_id == assignment.ticket_id
|
||||
&& operation.worker.as_ref() == Some(&worker)
|
||||
&& let Some(current) = api.store.get_current_ticket_coder_assignment(
|
||||
&& let Some(current) = api.store.get_active_ticket_worker_assignment(
|
||||
&api.config.workspace_id,
|
||||
&assignment.ticket_id,
|
||||
)?
|
||||
@@ -14137,7 +14137,7 @@ async fn execute_ticket_rest_operation(
|
||||
for ticket_id in candidates {
|
||||
if api
|
||||
.store
|
||||
.get_current_ticket_role_assignment(
|
||||
.get_active_ticket_role_assignment(
|
||||
workspace_id,
|
||||
&ticket_id,
|
||||
TicketAssignmentRole::Coder,
|
||||
@@ -14626,7 +14626,7 @@ impl merge_request::AssignmentSource for MergeRequestAssignmentSource {
|
||||
ticket_id: &str,
|
||||
) -> std::result::Result<Option<merge_request::CurrentAssignment>, String> {
|
||||
self.store
|
||||
.get_current_ticket_coder_assignment(workspace_id, ticket_id)
|
||||
.get_active_ticket_worker_assignment(workspace_id, ticket_id)
|
||||
.map(|value| {
|
||||
value.map(|assignment| merge_request::CurrentAssignment {
|
||||
assignment_id: assignment.assignment_id,
|
||||
@@ -15328,7 +15328,7 @@ async fn scoped_list_merge_requests(
|
||||
) {
|
||||
(Some(selector), Some(ticket_id)) => match api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, ticket_id)?
|
||||
.get_active_ticket_worker_assignment(&workspace_id, ticket_id)?
|
||||
{
|
||||
Some(assignment) => match observe_published_source_ref(
|
||||
&api,
|
||||
@@ -15452,7 +15452,7 @@ async fn scoped_show_merge_request(
|
||||
})?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, ticket_id)?;
|
||||
.get_active_ticket_worker_assignment(&workspace_id, ticket_id)?;
|
||||
let source = match (mr.selector_from.as_deref(), assignment.as_ref()) {
|
||||
(Some(selector), Some(assignment)) => {
|
||||
match observe_published_source_ref(
|
||||
@@ -15526,7 +15526,7 @@ async fn scoped_merge_request_readiness(
|
||||
let ticket_id = merge_request_ticket_id(&mr)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?;
|
||||
.get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?;
|
||||
let (current_subject_ref, source_blocker) = match (mr.selector_from.as_deref(), assignment) {
|
||||
(Some(selector), Some(assignment)) => match observe_published_source_ref(
|
||||
&api,
|
||||
@@ -15586,9 +15586,9 @@ async fn scoped_open_merge_request(
|
||||
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
.get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||
Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into())
|
||||
})?;
|
||||
if assignment.worker.runtime_id != source.runtime_id
|
||||
|| assignment.worker.worker_id != source.worker_id
|
||||
@@ -15685,9 +15685,9 @@ async fn scoped_repair_merge_request_selector(
|
||||
let ticket_id = merge_request_ticket_id(&mr)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
.get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||
Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into())
|
||||
})?;
|
||||
let resolved_subject_ref = observe_published_source_ref(
|
||||
&api,
|
||||
@@ -15757,9 +15757,9 @@ async fn scoped_register_merge_request_review_capability(
|
||||
}
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
.get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||
Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into())
|
||||
})?;
|
||||
if assignment.worker.runtime_id != source.runtime_id
|
||||
|| assignment.worker.worker_id != source.worker_id
|
||||
@@ -15867,9 +15867,9 @@ async fn scoped_submit_merge_request_review(
|
||||
let mr = store.get_by_id(&workspace_id, &merge_request_id)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
.get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||
Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into())
|
||||
})?;
|
||||
let current_subject_ref = match mr.state {
|
||||
merge_request::MergeRequestState::Merged => merged_result_source_ref(&mr)?,
|
||||
@@ -15929,9 +15929,9 @@ async fn scoped_revoke_merge_request_review(
|
||||
let ticket_id = merge_request_ticket_id(&mr)?;
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
.get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||
Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into())
|
||||
})?;
|
||||
if assignment.worker.runtime_id != source.runtime_id
|
||||
|| assignment.worker.worker_id != source.worker_id
|
||||
@@ -15999,9 +15999,9 @@ async fn scoped_complete_merge_request(
|
||||
}
|
||||
let assignment = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
.get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||
.ok_or_else(|| {
|
||||
Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into())
|
||||
Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into())
|
||||
})?;
|
||||
let selector = mr
|
||||
.selector_from
|
||||
@@ -16065,9 +16065,12 @@ async fn scoped_complete_ticket(
|
||||
require_workspace_access(&workspace_id, &api)?;
|
||||
let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?;
|
||||
require_online_workspace_orchestrator_source(&api, &source)?;
|
||||
// No active assignment is expected after completion. The store checks an
|
||||
// exact recorded-operation replay before live authority, including its actor
|
||||
// and evidence fingerprint; an empty identity cannot authorize new completion.
|
||||
let assignment_id = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?
|
||||
.get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?
|
||||
.map(|assignment| assignment.assignment_id)
|
||||
.unwrap_or_default();
|
||||
let event = merge_request_store(&api, &workspace_id)?.complete_ticket(
|
||||
@@ -16409,7 +16412,7 @@ fn active_orchestrator_assignment(
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<TicketRoleAssignmentRecord>> {
|
||||
let assignment = api.store.get_current_ticket_role_assignment(
|
||||
let assignment = api.store.get_active_ticket_role_assignment(
|
||||
workspace_id,
|
||||
ticket_id,
|
||||
TicketAssignmentRole::Orchestrator,
|
||||
@@ -16452,7 +16455,7 @@ fn worker_ticket_source_context(
|
||||
) -> WorkerTicketSourceContext {
|
||||
let assignment = ticket.and_then(|ticket| {
|
||||
api.store
|
||||
.get_current_ticket_coder_assignment(workspace_id, &ticket.meta.id)
|
||||
.get_active_ticket_worker_assignment(workspace_id, &ticket.meta.id)
|
||||
.ok()
|
||||
.flatten()
|
||||
});
|
||||
@@ -16595,7 +16598,7 @@ fn notify_ticket_recipients(
|
||||
let mut recipients = Vec::new();
|
||||
if let Some(assignment) = api
|
||||
.store
|
||||
.get_current_ticket_coder_assignment(workspace_id, ticket_id)
|
||||
.get_active_ticket_worker_assignment(workspace_id, ticket_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
@@ -21178,7 +21181,7 @@ fn worker_retention_error_response(
|
||||
crate::retention::WorkerRetentionError::Blocked(_) => worker_remove_error_response(
|
||||
StatusCode::CONFLICT,
|
||||
"worker_removal_blocked",
|
||||
"Worker removal is blocked by current assignment, hold, or retention policy",
|
||||
"Worker removal is blocked by unfinished Ticket work, hold, or retention policy",
|
||||
),
|
||||
crate::retention::WorkerRetentionError::Invalid(_) => worker_remove_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -24300,15 +24303,15 @@ fn build_runtime_cleanup_plan(
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(&api.config.workspace_id, &record.worker)?;
|
||||
let current_assignment = api.store.get_current_ticket_role_assignment_for_worker(
|
||||
let active_assignment = api.store.get_active_ticket_role_assignment_for_worker(
|
||||
&api.config.workspace_id,
|
||||
&record.worker,
|
||||
)?;
|
||||
let is_running = live_running_worker_ids.contains(&record.worker);
|
||||
let pinned = record.retention_state == "pinned";
|
||||
let blocking_reason = if let Some(assignment) = current_assignment {
|
||||
let blocking_reason = if let Some(assignment) = active_assignment {
|
||||
Some(format!(
|
||||
"worker has current Ticket assignment `{}` (`{}`)",
|
||||
"worker has unfinished work for Ticket `{}` (`{}`)",
|
||||
assignment.ticket_id,
|
||||
assignment.role.as_str()
|
||||
))
|
||||
@@ -24524,13 +24527,13 @@ async fn execute_runtime_cleanup_with_context(
|
||||
);
|
||||
if let Some(assignment) = api
|
||||
.store
|
||||
.get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, &worker)?
|
||||
.get_active_ticket_role_assignment_for_worker(&api.config.workspace_id, &worker)?
|
||||
{
|
||||
return Err(cleanup_api_error(
|
||||
runtime_id,
|
||||
"workspace_cleanup_worker_assigned",
|
||||
&format!(
|
||||
"Worker is assigned to Ticket `{}` as `{}` and cannot be deleted",
|
||||
"Worker has unfinished work for Ticket `{}` as `{}` and cannot be deleted",
|
||||
assignment.ticket_id,
|
||||
assignment.role.as_str()
|
||||
),
|
||||
@@ -46949,6 +46952,444 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_live_test_coder_assignment(
|
||||
api: &WorkspaceApi,
|
||||
ticket_id: &str,
|
||||
assignment_id: &str,
|
||||
) -> RuntimeWorkerRef {
|
||||
let Json(created) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
display_name: "Assignment lifecycle Coder".to_string(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id);
|
||||
api.store
|
||||
.set_current_ticket_coder_assignment(
|
||||
&TicketCoderAssignmentRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
ticket_id: ticket_id.to_string(),
|
||||
assignment_id: assignment_id.to_string(),
|
||||
worker: worker.clone(),
|
||||
assigned_by: "test".to_string(),
|
||||
assigned_at: TEST_CREATED_AT.to_string(),
|
||||
},
|
||||
None,
|
||||
&format!("event-{assignment_id}"),
|
||||
&format!("operation-{assignment_id}"),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
worker
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_ticket_api_replays_done_operation_without_reviving_assignment_authority() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut input = ticket::NewTicket::new("Completed operation replay");
|
||||
input.workflow_state = Some(TicketWorkflowState::InProgress);
|
||||
let ticket = backend.create(input).unwrap();
|
||||
let coder = seed_live_test_coder_assignment(&api, &ticket.id, "completed-coder").await;
|
||||
let Json(started) = scoped_start_workspace_orchestrator(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let orchestrator = started.worker.unwrap();
|
||||
let orchestrator = RuntimeWorkerRef::new(orchestrator.runtime_id, orchestrator.worker_id);
|
||||
let recorded = merge_request::TicketCompletionEvent {
|
||||
operation_id: "recorded-ticket-completion".to_string(),
|
||||
ticket_id: ticket.id.clone(),
|
||||
item_revision: api.authority.ticket(&ticket.id).unwrap().item_revision,
|
||||
merge_request_ids: vec!["merged-result".to_string()],
|
||||
requirement_approval_event_id: "requirement-approval".to_string(),
|
||||
completed_by: merge_request::WorkerIdentity {
|
||||
runtime_id: orchestrator.runtime_id.clone(),
|
||||
worker_id: orchestrator.worker_id.clone(),
|
||||
},
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
// Seed the persisted completion boundary, not the review/integration flow:
|
||||
// this test proves API replay authorization against an already-recorded result.
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
crate::store::configure_sqlite(&conn).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state = 'done'
|
||||
WHERE workspace_id = ?1 AND ticket_id = ?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, ticket.id],
|
||||
)
|
||||
.unwrap();
|
||||
backend
|
||||
.with_event_attributes(BTreeMap::from([
|
||||
("operation_id".to_string(), recorded.operation_id.clone()),
|
||||
(
|
||||
"ticket_completion".to_string(),
|
||||
serde_json::to_string(&recorded).unwrap(),
|
||||
),
|
||||
]))
|
||||
.add_event(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
NewTicketEvent::new(TicketEventKind::StateChanged, "Recorded completion"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut former_actor_record = recorded.clone();
|
||||
former_actor_record.operation_id = "former-actor-completion".to_string();
|
||||
former_actor_record.completed_by = merge_request::WorkerIdentity {
|
||||
runtime_id: coder.runtime_id.clone(),
|
||||
worker_id: coder.worker_id.clone(),
|
||||
};
|
||||
browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.with_event_attributes(BTreeMap::from([
|
||||
(
|
||||
"operation_id".to_string(),
|
||||
former_actor_record.operation_id.clone(),
|
||||
),
|
||||
(
|
||||
"ticket_completion".to_string(),
|
||||
serde_json::to_string(&former_actor_record).unwrap(),
|
||||
),
|
||||
]))
|
||||
.add_event(
|
||||
TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
NewTicketEvent::new(TicketEventKind::StateChanged, "Former actor completion"),
|
||||
)
|
||||
.unwrap();
|
||||
let event_count = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.show(TicketIdOrSlug::Id(ticket.id.clone()))
|
||||
.unwrap()
|
||||
.events
|
||||
.len();
|
||||
let request = || server_api::CompleteTicketRequest {
|
||||
operation_id: recorded.operation_id.clone(),
|
||||
item_revision: recorded.item_revision.clone(),
|
||||
merge_request_ids: recorded.merge_request_ids.clone(),
|
||||
requirement_approval_event_id: recorded.requirement_approval_event_id.clone(),
|
||||
};
|
||||
let Json(replayed) = scoped_complete_ticket(
|
||||
State(api.clone()),
|
||||
ticket_check_headers(&orchestrator),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())),
|
||||
Json(request()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(replayed).unwrap(),
|
||||
serde_json::to_value(public_ticket_completion_event(recorded.clone())).unwrap()
|
||||
);
|
||||
for field in ["operation", "revision", "mr_set", "approval", "actor"] {
|
||||
let mut changed = request();
|
||||
match field {
|
||||
"operation" => changed.operation_id = "new-completion-operation".to_string(),
|
||||
"revision" => changed.item_revision = "different-revision".to_string(),
|
||||
"mr_set" => changed.merge_request_ids = vec!["different-result".to_string()],
|
||||
"approval" => {
|
||||
changed.requirement_approval_event_id = "different-approval".to_string();
|
||||
}
|
||||
"actor" => changed.operation_id = former_actor_record.operation_id.clone(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let response = scoped_complete_ticket(
|
||||
State(api.clone()),
|
||||
ticket_check_headers(&orchestrator),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())),
|
||||
Json(changed),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.into_response();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert_eq!(status, StatusCode::CONFLICT, "field={field}: {body}");
|
||||
let reason = if field == "operation" {
|
||||
"Ticket must be inprogress"
|
||||
} else {
|
||||
"Ticket completion operation fingerprint mismatch"
|
||||
};
|
||||
assert!(body.contains(reason), "field={field}: {body}");
|
||||
}
|
||||
assert!(
|
||||
api.store
|
||||
.get_active_ticket_role_assignment_for_worker(TEST_WORKSPACE_ID, &coder)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket.id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.assignment_id,
|
||||
"completed-coder"
|
||||
);
|
||||
let after = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.show(TicketIdOrSlug::Id(ticket.id))
|
||||
.unwrap();
|
||||
assert_eq!(after.meta.workflow_state, TicketWorkflowState::Done);
|
||||
assert_eq!(after.events.len(), event_count);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_and_reopen_api_preserves_responsibility_but_requires_fresh_routing_identity() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, execution) = test_api_with_recording_backend(dir.path()).await;
|
||||
let mut input = ticket::NewTicket::new("Close and reopen responsibility");
|
||||
input.workflow_state = Some(TicketWorkflowState::InProgress);
|
||||
set_test_ticket_target(&mut input, "test-repository", "develop");
|
||||
let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap();
|
||||
let old_coder = seed_live_test_coder_assignment(&api, &ticket.id, "old-coder").await;
|
||||
assign_test_orchestrator(&api, &ticket.id);
|
||||
let old_orchestrator_id = format!("orchestrator-{}", ticket.id);
|
||||
let Json(started) = scoped_start_workspace_orchestrator(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let orchestrator = started.worker.unwrap();
|
||||
let orchestrator = RuntimeWorkerRef::new(orchestrator.runtime_id, orchestrator.worker_id);
|
||||
execution.take_inputs();
|
||||
let status = scoped_close_ticket_record(
|
||||
State(api.clone()),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())),
|
||||
HeaderMap::new(),
|
||||
Json(MarkdownText::new("Normal close ends implementation work")),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
|
||||
for state in ["closed", "planning"] {
|
||||
if state == "planning" {
|
||||
// No public reopen operation exists. Seed a reopened authority
|
||||
// record after normal API close; the durable release must survive.
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
crate::store::configure_sqlite(&conn).unwrap();
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state = 'planning', status = 'open'
|
||||
WHERE workspace_id = ?1 AND ticket_id = ?2",
|
||||
rusqlite::params![TEST_WORKSPACE_ID, ticket.id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let path = || {
|
||||
AxumPath(ScopedRecordPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
id: ticket.id.clone(),
|
||||
})
|
||||
};
|
||||
let Json(detail) = scoped_get_ticket(State(api.clone()), path()).await.unwrap();
|
||||
assert_eq!(detail.state, state);
|
||||
assert_eq!(detail.current_coder.unwrap().assignment_id, "old-coder");
|
||||
assert!(
|
||||
detail
|
||||
.assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.assignment_id == old_orchestrator_id)
|
||||
);
|
||||
assert_eq!(
|
||||
detail.action_eligibility.can_assign_orchestrator,
|
||||
state == "planning"
|
||||
);
|
||||
let Json(assignments) = scoped_list_ticket_assignments(State(api.clone()), path())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
assignments
|
||||
.assignments
|
||||
.iter()
|
||||
.any(|assignment| assignment.assignment_id == "old-coder")
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.list_active_ticket_role_assignments(TEST_WORKSPACE_ID, &ticket.id)
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"state={state}"
|
||||
);
|
||||
let status = scoped_add_ticket_thread_event(
|
||||
State(api.clone()),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())),
|
||||
HeaderMap::new(),
|
||||
Json(NewTicketEvent::new(
|
||||
TicketEventKind::Comment,
|
||||
"Browser activity",
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
assert!(execution.take_inputs().is_empty(), "state={state}");
|
||||
scoped_add_ticket_thread_event(
|
||||
State(api.clone()),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())),
|
||||
ticket_check_headers(&old_coder),
|
||||
Json(NewTicketEvent::new(
|
||||
TicketEventKind::Comment,
|
||||
"Former Coder activity is ordinary Worker activity",
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let activity = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.show(TicketIdOrSlug::Id(ticket.id.clone()))
|
||||
.unwrap();
|
||||
let attributes = &activity.events.last().unwrap().attributes;
|
||||
assert_eq!(
|
||||
attributes.get("source_actor_role").map(String::as_str),
|
||||
Some("worker"),
|
||||
"state={state}"
|
||||
);
|
||||
assert!(
|
||||
!attributes.contains_key("source_assignment_id"),
|
||||
"state={state}"
|
||||
);
|
||||
assert!(execution.take_inputs().is_empty(), "state={state}");
|
||||
let response = scoped_open_merge_request(
|
||||
State(api.clone()),
|
||||
ticket_check_headers(&old_coder),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())),
|
||||
Json(server_api::OpenMergeRequestRequest {
|
||||
repository_key: "test-repository".to_string(),
|
||||
selector_from: "work/old-coder".to_string(),
|
||||
selector_to: "develop".to_string(),
|
||||
summary: "Old responsibility is not implementation authority".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.into_response();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert_eq!(status, StatusCode::CONFLICT, "state={state}: {body}");
|
||||
assert!(
|
||||
body.contains("Ticket has no active Coder work assignment"),
|
||||
"state={state}: {body}"
|
||||
);
|
||||
}
|
||||
execute_ticket_rest_operation(
|
||||
&api,
|
||||
TEST_WORKSPACE_ID,
|
||||
HeaderMap::new(),
|
||||
TicketBackendOperation::MarkReady {
|
||||
id: TicketIdOrSlug::Id(ticket.id.clone()),
|
||||
request: ticket::TicketMarkReady {
|
||||
operation_key: "reopened-ready".to_string(),
|
||||
reason: Some("Fresh implementation cycle".to_string()),
|
||||
author: None,
|
||||
intake_summary: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = scoped_queue_ticket_record(
|
||||
State(api.clone()),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())),
|
||||
HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.into_response();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let body = String::from_utf8_lossy(&bytes);
|
||||
assert_eq!(status, StatusCode::CONFLICT, "{body}");
|
||||
assert!(
|
||||
body.contains("Queue requires role=orchestrator assignment"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(execution.take_inputs().is_empty());
|
||||
let Json(assigned) = scoped_set_ticket_assignment(
|
||||
State(api.clone()),
|
||||
AxumPath((
|
||||
TEST_WORKSPACE_ID.to_string(),
|
||||
ticket.id.clone(),
|
||||
"orchestrator".to_string(),
|
||||
)),
|
||||
Json(server_api::SetTicketRoleAssignmentRequest {
|
||||
operation_id: "fresh-orchestrator-cycle".to_string(),
|
||||
principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent {
|
||||
agent_key: "workspace-orchestrator".to_string(),
|
||||
},
|
||||
expected_assignment_id: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let fresh_id = assigned.assignment.unwrap().assignment_id;
|
||||
assert_ne!(fresh_id, old_orchestrator_id);
|
||||
let Json(detail) = scoped_get_ticket(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedRecordPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
id: ticket.id.clone(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(detail.action_eligibility.can_queue);
|
||||
assert_eq!(detail.current_coder.unwrap().assignment_id, "old-coder");
|
||||
let _ = scoped_queue_ticket_record(
|
||||
State(api.clone()),
|
||||
AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())),
|
||||
HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let queued = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.show(TicketIdOrSlug::Id(ticket.id.clone()))
|
||||
.unwrap();
|
||||
assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued);
|
||||
assert_eq!(
|
||||
queued
|
||||
.events
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|event| event.attributes.get("orchestrator_assignment_id")),
|
||||
Some(&fresh_id)
|
||||
);
|
||||
let inputs = execution.take_inputs();
|
||||
assert_eq!(inputs.len(), 1);
|
||||
assert_eq!(inputs[0].0.worker_id.to_string(), orchestrator.worker_id);
|
||||
assert_eq!(
|
||||
inputs[0].1,
|
||||
ticket_notification_content(ticket.resource_key.as_deref().unwrap(), "ready", "queued")
|
||||
);
|
||||
assert!(
|
||||
api.store
|
||||
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
struct ManualCoderAssignmentFixture {
|
||||
_workspace: tempfile::TempDir,
|
||||
api: WorkspaceApi,
|
||||
@@ -53701,7 +54142,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_blocks_assigned_worker_before_runtime_deletion() {
|
||||
async fn cleanup_blocks_unfinished_work_before_runtime_deletion() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
@@ -53717,7 +54158,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
candidate.blocking_reason.as_deref(),
|
||||
Some("worker has current Ticket assignment `ticket-assigned` (`coder`)")
|
||||
Some("worker has unfinished work for Ticket `ticket-assigned` (`coder`)")
|
||||
);
|
||||
let request = ExecuteRuntimeCleanupRequest {
|
||||
expected_plan_revision: plan.revision.clone(),
|
||||
@@ -53750,6 +54191,93 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_preview_ignores_terminal_ticket_responsibility() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let api = test_api(workspace.path()).await;
|
||||
let worker_id = seed_cleanup_worker(&api, 3, "normal");
|
||||
let ticket_id = "terminal-responsibility";
|
||||
seed_cleanup_worker_assignment(&api, &worker_id, ticket_id);
|
||||
let worker = RuntimeWorkerRef::new("runtime-test", &worker_id);
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
crate::store::configure_sqlite(&conn).unwrap();
|
||||
|
||||
for (state, status) in [("done", "open"), ("closed", "closed")] {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state = ?1, status = ?2
|
||||
WHERE workspace_id = ?3 AND ticket_id = ?4",
|
||||
rusqlite::params![state, status, api.config.workspace_id, ticket_id],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
api.store
|
||||
.get_current_ticket_role_assignment_for_worker(
|
||||
&api.config.workspace_id,
|
||||
&worker,
|
||||
)
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"terminal responsibility must remain visible: {state}"
|
||||
);
|
||||
let plan = build_runtime_cleanup_plan(&api, "runtime-test")
|
||||
.unwrap_or_else(|err| panic!("cleanup plan: {}", err.error));
|
||||
let candidate = plan
|
||||
.workers
|
||||
.iter()
|
||||
.find(|candidate| candidate.worker_id == worker_id)
|
||||
.unwrap();
|
||||
assert_eq!(candidate.blocking_reason, None, "state={state}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merge_request_assignment_authority_rejects_terminal_ticket_responsibility() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let api = test_api(workspace.path()).await;
|
||||
let worker_id = seed_cleanup_worker(&api, 3, "normal");
|
||||
let ticket_id = "terminal-mr-responsibility";
|
||||
seed_cleanup_worker_assignment(&api, &worker_id, ticket_id);
|
||||
let source = MergeRequestAssignmentSource {
|
||||
store: api.store.clone(),
|
||||
};
|
||||
assert!(
|
||||
merge_request::AssignmentSource::current_assignment(
|
||||
&source,
|
||||
&api.config.workspace_id,
|
||||
ticket_id,
|
||||
)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
|
||||
crate::store::configure_sqlite(&conn).unwrap();
|
||||
for (state, status) in [("done", "open"), ("closed", "closed")] {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET workflow_state = ?1, status = ?2
|
||||
WHERE workspace_id = ?3 AND ticket_id = ?4",
|
||||
rusqlite::params![state, status, api.config.workspace_id, ticket_id],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
api.store
|
||||
.get_current_ticket_coder_assignment(&api.config.workspace_id, ticket_id)
|
||||
.unwrap()
|
||||
.is_some(),
|
||||
"terminal responsibility must remain visible: {state}"
|
||||
);
|
||||
assert!(
|
||||
merge_request::AssignmentSource::current_assignment(
|
||||
&source,
|
||||
&api.config.workspace_id,
|
||||
ticket_id,
|
||||
)
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"terminal responsibility must not authorize MR operations: {state}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_execution_retains_dirty_and_requires_fresh_provider_not_found() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 81;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 82;
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
@@ -283,6 +283,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Backend Job immutable resource serialization",
|
||||
apply: migrate_backend_job_resources_v80_to_v81,
|
||||
},
|
||||
Migration {
|
||||
version: 82,
|
||||
name: "Ticket responsibility and unfinished work separation",
|
||||
apply: migrate_assignment_work_v81_to_v82,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -1853,6 +1858,26 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
workspace_id: &str,
|
||||
operation_id: &str,
|
||||
) -> Result<()>;
|
||||
/// Operational authority/occupancy only. Terminal state releases these
|
||||
/// identities durably; reopening does not reactivate retained responsibility.
|
||||
fn list_active_ticket_role_assignments(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<TicketRoleAssignmentRecord>>;
|
||||
fn get_active_ticket_role_assignment_for_worker(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> Result<Option<TicketRoleAssignmentRecord>>;
|
||||
fn get_active_ticket_role_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
role: TicketAssignmentRole,
|
||||
) -> Result<Option<TicketRoleAssignmentRecord>>;
|
||||
/// Retained responsibility, including ended work and removed principals.
|
||||
/// Use the active variants for admission, routing, authorization or removal.
|
||||
fn list_current_ticket_role_assignments(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -1907,6 +1932,11 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
occurred_at: &str,
|
||||
reason: &str,
|
||||
) -> Result<bool>;
|
||||
fn get_active_ticket_worker_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<TicketCoderAssignmentRecord>>;
|
||||
fn get_current_ticket_coder_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -8026,6 +8056,69 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn list_active_ticket_role_assignments(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Vec<TicketRoleAssignmentRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let sql = active_ticket_role_assignment_select_sql(
|
||||
"WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 \
|
||||
ORDER BY current.role, a.assigned_at, a.assignment_id",
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(
|
||||
params![workspace_id, ticket_id],
|
||||
read_ticket_role_assignment_record,
|
||||
)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_active_ticket_role_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
role: TicketAssignmentRole,
|
||||
) -> Result<Option<TicketRoleAssignmentRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let sql = active_ticket_role_assignment_select_sql(
|
||||
"WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = ?3 \
|
||||
ORDER BY a.assigned_at, a.assignment_id LIMIT 1",
|
||||
);
|
||||
Ok(conn
|
||||
.query_row(
|
||||
&sql,
|
||||
params![workspace_id, ticket_id, role.as_str()],
|
||||
read_ticket_role_assignment_record,
|
||||
)
|
||||
.optional()?)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_active_ticket_role_assignment_for_worker(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> Result<Option<TicketRoleAssignmentRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let sql = active_ticket_role_assignment_select_sql(
|
||||
"WHERE current.workspace_id = ?1 \
|
||||
AND current.principal_kind = 'worker' \
|
||||
AND current.runtime_id = ?2 AND current.worker_id = ?3 \
|
||||
ORDER BY a.assigned_at, a.assignment_id LIMIT 1",
|
||||
);
|
||||
Ok(conn
|
||||
.query_row(
|
||||
&sql,
|
||||
params![workspace_id, worker.runtime_id, worker.worker_id],
|
||||
read_ticket_role_assignment_record,
|
||||
)
|
||||
.optional()?)
|
||||
})
|
||||
}
|
||||
|
||||
fn list_current_ticket_role_assignments(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -8203,12 +8296,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
}
|
||||
Some(expected.clone())
|
||||
} else if record.role.is_singleton() && !current.is_empty() {
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"Ticket `{}` already has an active `{}` assignment `{}`",
|
||||
record.ticket_id,
|
||||
record.role.as_str(),
|
||||
current[0].assignment_id
|
||||
)));
|
||||
let active: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2)",
|
||||
params![record.workspace_id, current[0].assignment_id], |row| row.get(0))?;
|
||||
if active {
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"Ticket {} already has unfinished {} work; use explicit reassign",
|
||||
record.ticket_id, record.role.as_str())));
|
||||
}
|
||||
Some(current[0].clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -8434,7 +8530,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
));
|
||||
}
|
||||
let conflicting: i64 = tx.query_row(
|
||||
"SELECT COUNT(*) FROM ticket_current_worker_assignments
|
||||
"SELECT COUNT(*) FROM ticket_active_worker_assignments
|
||||
WHERE workspace_id = ?1 AND ticket_id = ?2
|
||||
AND role IN ('orchestrator', 'coder')",
|
||||
params![record.workspace_id, record.ticket_id],
|
||||
@@ -8447,6 +8543,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
));
|
||||
}
|
||||
|
||||
let previous: Option<String> = tx.query_row(
|
||||
"SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder'",
|
||||
params![record.workspace_id, record.ticket_id], |row| row.get(0)).optional()?;
|
||||
tx.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder'",
|
||||
params![record.workspace_id, record.ticket_id])?;
|
||||
tx.execute(
|
||||
"INSERT INTO ticket_assignment_operations (
|
||||
workspace_id, operation_id, action, ticket_id, role, principal_kind,
|
||||
@@ -8478,10 +8579,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
tx.execute(
|
||||
"INSERT INTO ticket_worker_assignment_events (
|
||||
workspace_id, ticket_id, role, event_id, action, assignment_id,
|
||||
actor, created_at, operation_id
|
||||
) VALUES (?1, ?2, 'coder', ?3, 'assigned', ?4, ?5, ?6, ?7)",
|
||||
actor, created_at, operation_id, previous_assignment_id
|
||||
) VALUES (?1, ?2, 'coder', ?3, ?8, ?4, ?5, ?6, ?7, ?9)",
|
||||
params![record.workspace_id, record.ticket_id, event_id, record.assignment_id,
|
||||
record.assigned_by, record.assigned_at, operation_id],
|
||||
record.assigned_by, record.assigned_at, operation_id,
|
||||
if previous.is_some() { "reassigned" } else { "assigned" }, previous],
|
||||
)?;
|
||||
let event_index: i64 = tx.query_row(
|
||||
"SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events
|
||||
@@ -8649,6 +8751,27 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn get_active_ticket_worker_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
) -> Result<Option<TicketCoderAssignmentRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
current_ticket_worker_assignment_select_sql()
|
||||
.replace(
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_active_worker_assignments",
|
||||
)
|
||||
.as_str(),
|
||||
params![workspace_id, ticket_id],
|
||||
read_ticket_worker_assignment_record,
|
||||
)
|
||||
.optional()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_current_ticket_coder_assignment(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -8753,7 +8876,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
read_ticket_worker_assignment_record,
|
||||
)
|
||||
.optional()?;
|
||||
if previous.is_some() && !allow_reassign {
|
||||
let previous_active: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder')",
|
||||
params![record.workspace_id, record.ticket_id], |row| row.get(0))?;
|
||||
if previous_active && !allow_reassign {
|
||||
return Err(Error::TicketAssignmentConflict(format!(
|
||||
"Ticket {} is already assigned; use the explicit reassign operation",
|
||||
record.ticket_id
|
||||
@@ -8790,7 +8916,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
record.assigned_at,
|
||||
],
|
||||
)?;
|
||||
let current_write = if allow_reassign {
|
||||
let current_write = if previous.is_some() {
|
||||
tx.execute(
|
||||
r#"UPDATE ticket_current_worker_assignments
|
||||
SET assignment_id = ?3, runtime_id = ?4, worker_id = ?5, updated_at = ?6
|
||||
@@ -10633,7 +10759,7 @@ fn runtime_removal_preflight(
|
||||
),
|
||||
(
|
||||
"active_worker_assignment",
|
||||
"SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE runtime_id = ?1)",
|
||||
"SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE runtime_id = ?1)",
|
||||
),
|
||||
(
|
||||
"active_workdir",
|
||||
@@ -11743,6 +11869,13 @@ fn read_ticket_role_assignment_by_id(
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn active_ticket_role_assignment_select_sql(where_clause: &str) -> String {
|
||||
ticket_role_assignment_select_sql(where_clause).replace(
|
||||
"ticket_current_worker_assignments",
|
||||
"ticket_active_worker_assignments",
|
||||
)
|
||||
}
|
||||
|
||||
fn ticket_role_assignment_select_sql(where_clause: &str) -> String {
|
||||
format!(
|
||||
"SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.role, \
|
||||
@@ -11982,7 +12115,7 @@ fn read_assignment_operation(
|
||||
fn map_assignment_constraint(error: rusqlite::Error, ticket_id: &str, worker_id: &str) -> Error {
|
||||
if matches!(error, rusqlite::Error::SqliteFailure(_, _)) {
|
||||
Error::TicketAssignmentConflict(format!(
|
||||
"Ticket {ticket_id} or Worker {worker_id} already has a current assignment"
|
||||
"Ticket {ticket_id} has a responsibility conflict or Worker {worker_id} has unfinished work"
|
||||
))
|
||||
} else {
|
||||
Error::Sqlite(error)
|
||||
@@ -15128,6 +15261,99 @@ fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_assignment_work_v81_to_v82(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 81 {
|
||||
return Err(Error::Store(
|
||||
"Ticket work separation requires schema 81".into(),
|
||||
));
|
||||
}
|
||||
let foreign_keys =
|
||||
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
|
||||
let legacy =
|
||||
conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, bool>(0))?;
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?;
|
||||
let result = (|| {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
let dependents = {
|
||||
let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='ticket_current_worker_assignments' AND type IN ('index','trigger') AND sql IS NOT NULL AND name != 'ticket_current_worker_role_idx'")?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
let schema = include_str!("latest_schema.sql");
|
||||
let start = schema
|
||||
.find("CREATE TABLE ticket_current_worker_assignments (")
|
||||
.ok_or_else(|| Error::Store("Missing assignment schema".into()))?;
|
||||
let end = schema[start..]
|
||||
.find("CREATE TABLE ticket_worker_assignment_events")
|
||||
.ok_or_else(|| Error::Store("Missing assignment schema end".into()))?
|
||||
+ start;
|
||||
tx.execute_batch(&schema[start..end].replace(
|
||||
"CREATE TABLE ticket_current_worker_assignments",
|
||||
"CREATE TABLE ticket_current_worker_assignments_v82",
|
||||
))?;
|
||||
tx.execute_batch("INSERT INTO ticket_current_worker_assignments_v82 SELECT * FROM ticket_current_worker_assignments;
|
||||
DROP TABLE ticket_current_worker_assignments;
|
||||
ALTER TABLE ticket_current_worker_assignments_v82 RENAME TO ticket_current_worker_assignments;")?;
|
||||
for sql in dependents {
|
||||
tx.execute_batch(&sql)?;
|
||||
}
|
||||
tx.execute_batch(include_str!("assignment_work.sql"))?;
|
||||
// Existing done/closed + current-assignment data is preserved, and a
|
||||
// durable release prevents a later reopen from resurrecting old work.
|
||||
tx.execute_batch("INSERT OR IGNORE INTO ticket_assignment_work_releases
|
||||
SELECT current.workspace_id, current.assignment_id, COALESCE(ticket.updated_at, current.updated_at)
|
||||
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 IN ('done','closed');")?;
|
||||
let legacy_plans = {
|
||||
let mut statement =
|
||||
tx.prepare("SELECT operation_id, blockers_json FROM worker_removal_operations")?;
|
||||
let rows = statement.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (operation_id, encoded) in legacy_plans {
|
||||
let mut blockers: Vec<serde_json::Value> = serde_json::from_str(&encoded)
|
||||
.map_err(|error| Error::Store(format!("invalid removal blockers: {error}")))?;
|
||||
let mut changed = false;
|
||||
for blocker in &mut blockers {
|
||||
if blocker.get("kind").and_then(serde_json::Value::as_str)
|
||||
== Some("current_assignment")
|
||||
{
|
||||
blocker["kind"] = serde_json::Value::String("unfinished_work".into());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let encoded = serde_json::to_string(&blockers)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
// Historical blocked plans stay blocked; issued fingerprints
|
||||
// and identities are immutable. Fresh planning rereads active work.
|
||||
tx.execute(
|
||||
"UPDATE worker_removal_operations SET blockers_json=?2 WHERE operation_id=?1",
|
||||
params![operation_id, encoded],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
let broken: i64 =
|
||||
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
if broken != 0 {
|
||||
return Err(Error::Store(
|
||||
"Ticket work migration foreign-key verification failed".into(),
|
||||
));
|
||||
}
|
||||
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (82,'Ticket responsibility and unfinished work separation')", [])?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
conn.pragma_update(None, "legacy_alter_table", legacy)?;
|
||||
conn.pragma_update(None, "foreign_keys", foreign_keys)?;
|
||||
result
|
||||
}
|
||||
|
||||
fn migrate_backend_job_resources_v80_to_v81(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 80 {
|
||||
return Err(Error::Store(
|
||||
@@ -15371,6 +15597,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
));
|
||||
"#,
|
||||
)?;
|
||||
conn.execute_batch(include_str!("assignment_work.sql"))?;
|
||||
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||
Error::Store(format!(
|
||||
"fresh Workspace Ticket schema verification failed: {error}"
|
||||
@@ -15403,6 +15630,32 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> {
|
||||
)));
|
||||
}
|
||||
}
|
||||
if current_schema_version(conn)? >= 82 {
|
||||
for (kind, name) in [
|
||||
("table", "ticket_assignment_work_releases"),
|
||||
("view", "ticket_active_worker_assignments"),
|
||||
("trigger", "ticket_terminal_releases_work"),
|
||||
("trigger", "ticket_terminal_assignment_releases_work_insert"),
|
||||
("trigger", "ticket_terminal_assignment_releases_work_update"),
|
||||
("trigger", "ticket_active_worker_role_insert"),
|
||||
("trigger", "ticket_active_worker_role_update"),
|
||||
("trigger", "ticket_active_worker_principal_insert"),
|
||||
("trigger", "ticket_active_worker_principal_update"),
|
||||
("trigger", "ticket_unfinished_work_blocks_worker_delete"),
|
||||
("trigger", "ticket_unfinished_work_blocks_worker_move"),
|
||||
] {
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type=?1 AND name=?2)",
|
||||
params![kind, name],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Err(Error::Store(format!(
|
||||
"unfinished Ticket work constraint {name} is missing"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17212,6 +17465,9 @@ mod tests {
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?;
|
||||
downgrade_assignment_work_schema(conn);
|
||||
conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;")?;
|
||||
downgrade_schema_66_ticket_and_workdir_authority(conn)?;
|
||||
conn.execute_batch(
|
||||
"DROP TRIGGER runtime_binding_insert_blocked_by_removal; \
|
||||
@@ -17281,6 +17537,11 @@ mod tests {
|
||||
drop(SqliteWorkspaceStore::open(&path).unwrap());
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")
|
||||
.unwrap();
|
||||
downgrade_assignment_work_schema(&conn);
|
||||
conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;")
|
||||
.unwrap();
|
||||
downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE worker_removal_operations \
|
||||
@@ -17659,6 +17920,44 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn downgrade_assignment_work_schema(conn: &Connection) {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
DROP TRIGGER ticket_terminal_releases_work;
|
||||
DROP TRIGGER ticket_terminal_assignment_releases_work_insert;
|
||||
DROP TRIGGER ticket_terminal_assignment_releases_work_update;
|
||||
DROP TRIGGER ticket_active_worker_role_insert;
|
||||
DROP TRIGGER ticket_active_worker_role_update;
|
||||
DROP TRIGGER ticket_active_worker_principal_insert;
|
||||
DROP TRIGGER ticket_active_worker_principal_update;
|
||||
DROP TRIGGER ticket_unfinished_work_blocks_worker_delete;
|
||||
DROP TRIGGER ticket_unfinished_work_blocks_worker_move;
|
||||
DROP VIEW ticket_active_worker_assignments;
|
||||
DROP TABLE ticket_assignment_work_releases;
|
||||
CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
ON ticket_current_worker_assignments(workspace_id, role, runtime_id, worker_id)
|
||||
WHERE principal_kind='worker';
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
// Restore the actual old FK contract, not just the old unique index.
|
||||
let dependents = {
|
||||
let mut stmt = conn.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='ticket_current_worker_assignments' AND type IN ('index','trigger') AND sql IS NOT NULL").unwrap();
|
||||
stmt.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap()
|
||||
};
|
||||
let table: String = conn.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name='ticket_current_worker_assignments'", [], |row| row.get(0)).unwrap();
|
||||
let old_table = table.replace("CREATE TABLE ticket_current_worker_assignments", "CREATE TABLE current_assignments_old")
|
||||
.replace(" CHECK(\n", " FOREIGN KEY(workspace_id,runtime_id,worker_id) REFERENCES worker_registry(workspace_id,runtime_id,worker_id) ON DELETE RESTRICT,\n CHECK(\n");
|
||||
conn.execute_batch(&old_table).unwrap();
|
||||
conn.execute_batch("INSERT INTO current_assignments_old SELECT * FROM ticket_current_worker_assignments; DROP TABLE ticket_current_worker_assignments; ALTER TABLE current_assignments_old RENAME TO ticket_current_worker_assignments;").unwrap();
|
||||
for sql in dependents {
|
||||
conn.execute_batch(&sql).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_schema_v52(path: &Path) {
|
||||
let conn = Connection::open(path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
@@ -17667,6 +17966,7 @@ mod tests {
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")
|
||||
.unwrap();
|
||||
create_latest_workspace_schema(&conn).unwrap();
|
||||
downgrade_assignment_work_schema(&conn);
|
||||
downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap();
|
||||
conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;")
|
||||
.unwrap();
|
||||
@@ -17928,6 +18228,10 @@ mod tests {
|
||||
version: 81,
|
||||
name: "Backend Job immutable resource serialization".to_string()
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 82,
|
||||
name: "Ticket responsibility and unfinished work separation".to_string()
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -18019,6 +18323,7 @@ mod tests {
|
||||
(79, WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME.to_string()),
|
||||
(80, WORKER_RESTORE_INTENTS_MIGRATION_NAME.to_string()),
|
||||
(81, "Backend Job immutable resource serialization".to_string()),
|
||||
(82, "Ticket responsibility and unfinished work separation".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -18370,7 +18675,7 @@ mod tests {
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
||||
73, 74, 75, 76, 77, 78, 79, 80, 81
|
||||
73, 74, 75, 76, 77, 78, 79, 80, 81, 82
|
||||
]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
@@ -18379,7 +18684,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 32);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 33);
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||
@@ -20473,6 +20778,203 @@ mod tests {
|
||||
assert_eq!(pinned.definition.name, "coder-review");
|
||||
}
|
||||
|
||||
// Equivalent to the reported closed T-12 + retained Coder data. The
|
||||
// migration fixture is a real pre-cutover schema, not the live Workspace.
|
||||
#[test]
|
||||
fn schema_v81_terminal_responsibility_survives_migration_reopen_and_worker_deletion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("workspace.db");
|
||||
prepare_schema_v50(&path, Some("workspace-a"));
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
for migration in MIGRATIONS.iter().filter(|m| m.version <= 81) {
|
||||
(migration.apply)(&conn).unwrap();
|
||||
}
|
||||
conn.execute_batch(r#"
|
||||
INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at)
|
||||
VALUES('workspace-a','runtime-a','old-coder','Coder T-12','normal','1','1');
|
||||
INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,updated_at)
|
||||
VALUES('workspace-a','closed-ticket','closed-ticket','Closed Ticket','closed','task','normal','','closed',1,'2');
|
||||
INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at)
|
||||
VALUES('workspace-a','closed-ticket','old-assignment','runtime-a','old-coder','actor','1');
|
||||
INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at)
|
||||
VALUES('workspace-a','closed-ticket','old-assignment','runtime-a','old-coder','1');
|
||||
INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,event_id,action,assignment_id,actor,created_at)
|
||||
VALUES('workspace-a','closed-ticket','assigned-event','assigned','old-assignment','actor','1');
|
||||
"#).unwrap();
|
||||
let legacy_blockers = serde_json::json!([
|
||||
{"kind":"hold"}, {"kind":"current_assignment","assignment_id":"current_assignment","ticket_id":"closed-ticket"}
|
||||
]).to_string();
|
||||
conn.execute("INSERT INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,diagnostics_disposition,blockers_json,state,reason,created_at,updated_at)
|
||||
VALUES('old-op','old-plan','immutable-fingerprint','workspace-a','runtime-a','old-coder','1','workspace-default-conservative',1,'purge','tombstone','forever','purge',?1,'blocked','retain','1','1')", [&legacy_blockers]).unwrap();
|
||||
migrate_assignment_work_v81_to_v82(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 82);
|
||||
apply_migrations(&conn).unwrap(); // canonical retry is a no-op
|
||||
let (plan, fingerprint, state, encoded): (String,String,String,String) = conn.query_row(
|
||||
"SELECT plan_id,input_fingerprint,state,blockers_json FROM worker_removal_operations WHERE operation_id='old-op'", [],
|
||||
|row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap();
|
||||
assert_eq!(
|
||||
(plan.as_str(), fingerprint.as_str(), state.as_str()),
|
||||
("old-plan", "immutable-fingerprint", "blocked")
|
||||
);
|
||||
let blockers: Vec<crate::retention::WorkerRemovalBlocker> =
|
||||
serde_json::from_str(&encoded).unwrap();
|
||||
assert!(
|
||||
matches!(&blockers[..], [crate::retention::WorkerRemovalBlocker::Hold,crate::retention::WorkerRemovalBlocker::UnfinishedWork { assignment_id,ticket_id }] if assignment_id=="current_assignment" && ticket_id=="closed-ticket")
|
||||
);
|
||||
conn.execute_batch(
|
||||
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id='closed-ticket';
|
||||
DELETE FROM worker_registry WHERE worker_id='old-coder';",
|
||||
)
|
||||
.unwrap();
|
||||
let (responsibility, active, history, tombstone): (i64, i64, i64, i64) = conn
|
||||
.query_row(
|
||||
"SELECT (SELECT COUNT(*) FROM ticket_current_worker_assignments),
|
||||
(SELECT COUNT(*) FROM ticket_active_worker_assignments),
|
||||
(SELECT COUNT(*) FROM ticket_worker_assignment_events),
|
||||
(SELECT COUNT(*) FROM ticket_assignment_worker_tombstones)",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((responsibility, active, history, tombstone), (1, 0, 1, 1));
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE ticket_current_worker_assignments SET updated_at='3'",
|
||||
[]
|
||||
)
|
||||
.is_err(),
|
||||
"removed Worker cannot receive new authority"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_retains_responsibility_without_occupying_new_work_and_reopen_requires_new_identity()
|
||||
{
|
||||
use ticket::TicketBackend;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("workspace.db");
|
||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "w".into(),
|
||||
owner_account_id: "owner-account".into(),
|
||||
display_name: "W".into(),
|
||||
state: "active".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
store.with_conn(|conn| {
|
||||
conn.execute_batch("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','coder','Coder','normal','1','1');")?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap();
|
||||
let first = backend.create(ticket::NewTicket::new("First")).unwrap();
|
||||
let second = backend.create(ticket::NewTicket::new("Second")).unwrap();
|
||||
let assignment = TicketCoderAssignmentRecord {
|
||||
workspace_id: "w".into(),
|
||||
ticket_id: first.id.clone(),
|
||||
assignment_id: "a1".into(),
|
||||
worker: RuntimeWorkerRef::new("r", "coder"),
|
||||
assigned_by: "actor".into(),
|
||||
assigned_at: "1".into(),
|
||||
};
|
||||
store
|
||||
.set_current_ticket_coder_assignment(&assignment, None, "e1", "op1", false)
|
||||
.unwrap();
|
||||
let next = TicketCoderAssignmentRecord {
|
||||
ticket_id: second.id.clone(),
|
||||
assignment_id: "a2".into(),
|
||||
..assignment.clone()
|
||||
};
|
||||
assert!(
|
||||
store
|
||||
.set_current_ticket_coder_assignment(&next, None, "e2", "op2", false)
|
||||
.is_err()
|
||||
);
|
||||
backend
|
||||
.close(
|
||||
first.id.clone().into(),
|
||||
ticket::MarkdownText::new("Closed without integration proof"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.get_current_ticket_coder_assignment("w", &first.id)
|
||||
.unwrap(),
|
||||
Some(assignment.clone())
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_active_ticket_worker_assignment("w", &first.id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
store
|
||||
.set_current_ticket_coder_assignment(&next, None, "e2", "op2", false)
|
||||
.unwrap();
|
||||
// Generic Ticket tools do not expose terminal -> planning. Seed the
|
||||
// authority state boundary to prove future/reconciled reopen is inert.
|
||||
store.with_conn(|conn| { conn.execute("UPDATE typed_tickets SET workflow_state='planning' WHERE workspace_id='w' AND ticket_id=?1", [&first.id])?; Ok(()) }).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.get_active_ticket_worker_assignment("w", &first.id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
// Replay of the original operation reports its identity but does not revive work.
|
||||
store
|
||||
.set_current_ticket_coder_assignment(&assignment, None, "e1", "op1", false)
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.get_active_ticket_worker_assignment("w", &first.id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let restarted = TicketCoderAssignmentRecord {
|
||||
assignment_id: "a3".into(),
|
||||
..assignment.clone()
|
||||
};
|
||||
assert!(
|
||||
store
|
||||
.set_current_ticket_coder_assignment(&restarted, None, "e3", "op3", false)
|
||||
.is_err(),
|
||||
"another Ticket still occupies the Worker"
|
||||
);
|
||||
backend
|
||||
.close(second.id.clone().into(), ticket::MarkdownText::new("Ended"))
|
||||
.unwrap();
|
||||
store
|
||||
.set_current_ticket_coder_assignment(&restarted, None, "e3", "op3", false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.get_active_ticket_worker_assignment("w", &first.id)
|
||||
.unwrap(),
|
||||
Some(restarted)
|
||||
);
|
||||
let events = store
|
||||
.list_ticket_coder_assignment_events("w", &first.id, 10)
|
||||
.unwrap();
|
||||
assert_eq!(events[0].previous_assignment_id.as_deref(), Some("a1"));
|
||||
assert_eq!(events[0].action, "reassigned");
|
||||
assert!(
|
||||
store
|
||||
.delete_worker_registry("w", &assignment.worker)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -356,10 +356,10 @@ impl SqliteWorkspaceStore {
|
||||
params![operation.workspace_id, operation.working_directory_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let current_assignment: bool = conn.query_row(
|
||||
let unfinished_work: bool = conn.query_row(
|
||||
r#"SELECT EXISTS(
|
||||
SELECT 1 FROM worker_workdir_links AS link
|
||||
JOIN ticket_current_worker_assignments AS current
|
||||
JOIN ticket_active_worker_assignments AS current
|
||||
ON current.workspace_id=link.workspace_id
|
||||
JOIN ticket_worker_assignments AS assignment
|
||||
ON assignment.workspace_id=current.workspace_id
|
||||
@@ -404,10 +404,10 @@ impl SqliteWorkspaceStore {
|
||||
detail: "Workdir has a pending Worker attachment reservation",
|
||||
});
|
||||
}
|
||||
if current_assignment {
|
||||
if unfinished_work {
|
||||
guards.push(WorkdirRemovalGuard {
|
||||
category: "current_assignment",
|
||||
detail: "Workdir is bound to a Worker with a current Ticket assignment",
|
||||
category: "unfinished_work",
|
||||
detail: "Workdir is bound to a Worker with unfinished Ticket work",
|
||||
});
|
||||
}
|
||||
if retention_hold {
|
||||
@@ -1196,6 +1196,156 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_assignments_release_unfinished_work_guard_but_not_attachment_or_hold() {
|
||||
for (status, workflow_state) in [("closed", "closed"), ("open", "done")] {
|
||||
let (store, workdir) = seeded_store().await;
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO worker_registry (
|
||||
workspace_id, runtime_id, worker_id, display_name, retention_state,
|
||||
created_at, updated_at
|
||||
) VALUES ('workspace-a', 'runtime-a', 'worker-a', 'Worker A', 'pinned', '1', '1')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO worker_workdir_links (
|
||||
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at
|
||||
) VALUES ('workspace-a', 'runtime-a', 'worker-a', 'workdir-a', 'main', 'read_write', '1')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO typed_tickets (
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||
workflow_state, workflow_state_explicit
|
||||
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open',
|
||||
'task', 'normal', '', 'inprogress', 1)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_worker_assignments (
|
||||
workspace_id, ticket_id, assignment_id, runtime_id, worker_id,
|
||||
assigned_by, assigned_at
|
||||
) VALUES ('workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', 'worker-a', 'test', '1')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO ticket_current_worker_assignments (
|
||||
workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at
|
||||
) VALUES ('workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', 'worker-a', '1')",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let intent =
|
||||
workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap();
|
||||
let operation = store.reserve_workdir_removal_operation(&intent).unwrap();
|
||||
let categories = |guards: Vec<WorkdirRemovalGuard>| {
|
||||
guards
|
||||
.into_iter()
|
||||
.map(|guard| guard.category)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
categories(store.workdir_removal_guards(&operation).unwrap()),
|
||||
vec!["active_attachment", "unfinished_work", "retention_hold"]
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET status=?1, workflow_state=?2
|
||||
WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'",
|
||||
params![status, workflow_state],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
categories(store.workdir_removal_guards(&operation).unwrap()),
|
||||
vec!["active_attachment", "retention_hold"]
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE typed_tickets SET status='open', workflow_state='planning'
|
||||
WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
categories(store.workdir_removal_guards(&operation).unwrap()),
|
||||
vec!["active_attachment", "retention_hold"],
|
||||
"reopening must not restore unfinished work on the retained assignment"
|
||||
);
|
||||
assert!(matches!(
|
||||
store.commit_workdir_removal_removed(&operation),
|
||||
Err(Error::WorkdirAttachmentConflict(_))
|
||||
));
|
||||
assert!(
|
||||
store
|
||||
.get_workdir_registry("workspace-a", "workdir-a")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE worker_registry SET retention_state='normal'
|
||||
WHERE workspace_id='workspace-a' AND runtime_id='runtime-a' AND worker_id='worker-a'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
categories(store.workdir_removal_guards(&operation).unwrap()),
|
||||
vec!["active_attachment"]
|
||||
);
|
||||
assert!(matches!(
|
||||
store.commit_workdir_removal_removed(&operation),
|
||||
Err(Error::WorkdirAttachmentConflict(_))
|
||||
));
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE worker_workdir_links SET unlinked_at='2'
|
||||
WHERE workspace_id='workspace-a' AND workdir_id='workdir-a'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.workdir_removal_guards(&operation).unwrap().is_empty());
|
||||
let completed = store.commit_workdir_removal_removed(&operation).unwrap();
|
||||
assert_eq!(
|
||||
completed.disposition,
|
||||
Some(WorkdirRemovalDisposition::Removed)
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_workdir_registry("workspace-a", "workdir-a")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let retained: i64 = store
|
||||
.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id='workspace-a' AND assignment_id='assignment-a'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Error::from)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(retained, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_completion_keeps_registry_and_is_auditable() {
|
||||
let (store, workdir) = seeded_store().await;
|
||||
|
||||
@@ -45,6 +45,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
||||
"repository_ssh_host_trusts",
|
||||
"server_secret_versions",
|
||||
"ticket_assignment_operations",
|
||||
"ticket_assignment_work_releases",
|
||||
"ticket_assignment_ticket_tombstones",
|
||||
"ticket_assignment_worker_tombstones",
|
||||
"ticket_current_worker_assignments",
|
||||
@@ -686,10 +687,10 @@ fn workspace_database_blockers(
|
||||
|
||||
for (sql, kind, resource_kind, message) in [
|
||||
(
|
||||
"SELECT COUNT(*) FROM ticket_current_worker_assignments WHERE workspace_id = ?1",
|
||||
"SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE workspace_id = ?1",
|
||||
WorkspaceDeletionBlockerKind::WorkerRemovalBlocked,
|
||||
"ticket",
|
||||
"Remove current Ticket assignments before deleting the Workspace.",
|
||||
"End unfinished Ticket work before deleting the Workspace.",
|
||||
),
|
||||
(
|
||||
"SELECT COUNT(*)
|
||||
|
||||
Reference in New Issue
Block a user