Skip to main content

agentmux_srv/backend/storage/
work_queue.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4//! Muxqueue — the universal agent work queue.
5//!
6//! One row = one unit of work that ANY agent may claim, as opposed to jekt,
7//! which is addressed push delivery to a named recipient right now. See
8//! `docs/reports/REPORT_UNIVERSAL_AGENT_WORK_QUEUE_2026_09_01.md` for why this
9//! is a new primitive rather than a feature of the Drone or Swarm panes.
10//!
11//! Shaped deliberately as `db_cron_jobs`' sibling: cron is this row with a
12//! TIME trigger, this is the same row with a READINESS trigger. `not_before`
13//! makes that relationship literal.
14//!
15//! **This lives in the always-global identity store, never the per-channel
16//! one** (`db_work_queue` in `run_identity_store_schema`). A per-channel queue
17//! would mean "any agent IN THIS CHANNEL can pick it up", which is close to
18//! useless when every local/dev/portable build is its own channel.
19//!
20//! ## Claiming is a lease, not a lock
21//!
22//! A claim carries `claim_expires`. A claimant that dies without completing
23//! loses the row back to the pool when [`Store::work_queue_reap`] runs. This is
24//! deliberate, and copies `db_background_tasks`' `last_seen_ms` heartbeat
25//! pattern rather than a bare `claimed` boolean — a bare flag reproduces the
26//! stuck-`running`-forever incident class (agentmux issue #2518), where a row
27//! whose owner vanished is indistinguishable from one being actively worked.
28//!
29//! `attempts`/`max_attempts` bound that recycling, so a poison item that
30//! crashes every taker is eventually parked in `failed` instead of cycling
31//! forever.
32
33use rusqlite::{params, OptionalExtension, Row};
34use serde::{Deserialize, Serialize};
35
36use super::store::Store;
37use super::StoreError;
38
39/// Lifecycle state of a queue item. Stored as TEXT.
40pub mod work_state {
41    /// Claimable.
42    pub const OPEN: &str = "open";
43    /// Held under an unexpired lease by `claimed_by`.
44    pub const CLAIMED: &str = "claimed";
45    /// Finished successfully.
46    pub const DONE: &str = "done";
47    /// Gave up — either an explicit failure or `max_attempts` exhausted.
48    pub const FAILED: &str = "failed";
49    /// Withdrawn by a human/agent before completion.
50    pub const CANCELLED: &str = "cancelled";
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54pub struct WorkItem {
55    pub id: String,
56    pub title: String,
57    /// The instruction injected into whichever agent claims this.
58    pub payload: String,
59    /// Free-form tag for filtering (`"review"`, `"repro"`, …). Empty = untyped.
60    #[serde(default)]
61    pub kind: String,
62    /// Empty = any agent may claim. Non-empty = only this agent id.
63    #[serde(default)]
64    pub target_agent: String,
65    /// Empty = no group restriction. Non-empty = a `db_agent_groups` id; any
66    /// member may claim. Group membership is resolved by the CALLER (which
67    /// holds the per-channel store where groups live), not here — this module
68    /// stays a pure queue and does not reach across stores.
69    #[serde(default)]
70    pub target_group: String,
71    /// Higher claims first. Ties broken by `created_at` ascending (FIFO).
72    #[serde(default)]
73    pub priority: i64,
74    pub state: String,
75    #[serde(default)]
76    pub claimed_by: String,
77    /// Identity M1b/M3 (SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md
78    /// §9.1): the UID (`db_agents.id`) of `target_agent`, resolved once at
79    /// the authoring boundary and carried in — spec §5.4, never at claim
80    /// time. Since M3, an item with a `target_agent_uid` is claimable by that
81    /// identity only. Empty = untargeted, or no UID was carried: the name
82    /// path applies.
83    #[serde(default)]
84    pub target_agent_uid: String,
85    /// The claimer's UID — its token's (M4c-1), else carried from its own
86    /// `AGENTMUX_AGENT_UID`, else its block's row. Empty when unknown. Since
87    /// M4c-2 the holder transitions match it against the caller's UID when
88    /// both are known, and `claimed_by` otherwise (`HolderMatch`).
89    #[serde(default)]
90    pub claimed_by_uid: String,
91    /// ms epoch; `None` unless `state == claimed`.
92    #[serde(default)]
93    pub claim_expires: Option<i64>,
94    #[serde(default)]
95    pub attempts: i64,
96    #[serde(default)]
97    pub max_attempts: i64,
98    #[serde(default)]
99    pub created_by: String,
100    /// Identity M4c-1 (SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md
101    /// §6.5.9): the enqueuer's UID, taken from the request's `Caller` — its
102    /// `X-Agent-Token` — never from a body field. Empty = the enqueue was
103    /// Unattributed; never guessed. Dual-written beside `created_by`;
104    /// nothing branches on it.
105    #[serde(default)]
106    pub created_by_uid: String,
107    pub created_at: i64,
108    pub updated_at: i64,
109    /// ms epoch; not claimable before this. `None` = claimable immediately.
110    #[serde(default)]
111    pub not_before: Option<i64>,
112    /// Completion note or failure reason.
113    #[serde(default)]
114    pub result: String,
115}
116
117/// What a caller may filter a claim by. All fields are AND-ed; `None` means
118/// "don't constrain on this".
119#[derive(Debug, Clone, Default)]
120pub struct ClaimFilter {
121    /// Only items with this `kind`.
122    pub kind: Option<String>,
123    /// The claiming agent's own id. Items targeted at a DIFFERENT agent are
124    /// excluded; untargeted items stay eligible.
125    pub agent_id: String,
126    /// The claiming agent's UID (identity M1b/M3). Written to
127    /// `claimed_by_uid` on a successful claim. Since M3 it is the ONLY way
128    /// into an item that carries a `target_agent_uid`; the `agent_id` name
129    /// match applies to items with no UID, until M5.
130    pub agent_uid: String,
131    /// Group ids this agent belongs to. An item with a `target_group` is
132    /// eligible only if its group is in this list. Resolved by the caller.
133    pub groups: Vec<String>,
134}
135
136fn row_to_item(row: &Row) -> rusqlite::Result<WorkItem> {
137    Ok(WorkItem {
138        id: row.get("id")?,
139        title: row.get("title")?,
140        payload: row.get("payload")?,
141        kind: row.get("kind")?,
142        target_agent: row.get("target_agent")?,
143        target_group: row.get("target_group")?,
144        priority: row.get("priority")?,
145        state: row.get("state")?,
146        claimed_by: row.get("claimed_by")?,
147        target_agent_uid: row.get("target_agent_uid")?,
148        claimed_by_uid: row.get("claimed_by_uid")?,
149        claim_expires: row.get("claim_expires")?,
150        attempts: row.get("attempts")?,
151        max_attempts: row.get("max_attempts")?,
152        created_by: row.get("created_by")?,
153        created_by_uid: row.get("created_by_uid")?,
154        created_at: row.get("created_at")?,
155        updated_at: row.get("updated_at")?,
156        not_before: row.get("not_before")?,
157        result: row.get("result")?,
158    })
159}
160
161/// How a holder-only transition (heartbeat, complete, release) matched the
162/// item's holder — identity M4c-2 (spec §6.5.9).
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum HolderMatch {
165    /// The row's `claimed_by_uid` is the caller's UID.
166    Uid,
167    /// The row's `claimed_by` is the request's name: the row or the caller
168    /// has no UID, as before M4c-2.
169    Name,
170}
171
172/// The holder predicates, tried in order. When the row's `claimed_by_uid`
173/// and the caller's UID (`?6`) are both known, only the UID decides — a name
174/// two agents share no longer lets either drive the other's item. Otherwise
175/// the name (`?2`) decides, as it always has: a tokenless holder keeps
176/// working. Each is its own conditional UPDATE, so each is a complete
177/// authorization on its own; the second runs only when the first matched
178/// nothing.
179const HOLDER_BY_UID: &str = "(claimed_by_uid <> '' AND claimed_by_uid = ?6)";
180const HOLDER_BY_NAME: &str = "((claimed_by_uid = '' OR ?6 = '') AND claimed_by = ?2)";
181
182/// Run a holder-only UPDATE (`sql` renders it around a holder predicate)
183/// with the UID predicate, then the name one. Every holder statement binds
184/// `?1` id, `?2` the request's name, `?3` the fence and `?6` the caller's UID
185/// (`''` when Unattributed).
186fn holder_update(
187    conn: &rusqlite::Connection,
188    sql: impl Fn(&str) -> String,
189    params: &[&dyn rusqlite::ToSql],
190) -> Result<Option<HolderMatch>, StoreError> {
191    for (holder, how) in [
192        (HOLDER_BY_UID, HolderMatch::Uid),
193        (HOLDER_BY_NAME, HolderMatch::Name),
194    ] {
195        if conn.execute(&sql(holder), params)? > 0 {
196            return Ok(Some(how));
197        }
198    }
199    Ok(None)
200}
201
202const COLS: &str = "id, title, payload, kind, target_agent, target_group, priority, state, \
203                    claimed_by, claim_expires, attempts, max_attempts, created_by, \
204                    created_at, updated_at, not_before, result, \
205                    target_agent_uid, claimed_by_uid, created_by_uid";
206
207impl Store {
208    /// Insert a new `open` item. `id` is caller-supplied so an enqueue can be
209    /// made idempotent by the caller (re-enqueueing the same id is a conflict,
210    /// not a duplicate).
211    pub fn work_queue_enqueue(&self, item: &WorkItem) -> Result<(), StoreError> {
212        let conn = self.conn().lock().unwrap();
213        conn.execute(
214            "INSERT INTO db_work_queue
215                (id, title, payload, kind, target_agent, target_group, priority, state,
216                 claimed_by, claim_expires, attempts, max_attempts, created_by,
217                 created_at, updated_at, not_before, result,
218                 target_agent_uid, claimed_by_uid, created_by_uid)
219             VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,'',?19)",
220            params![
221                item.id, item.title, item.payload, item.kind, item.target_agent,
222                item.target_group, item.priority, work_state::OPEN, "",
223                None::<i64>, 0i64, if item.max_attempts > 0 { item.max_attempts } else { 3 },
224                item.created_by, item.created_at, item.updated_at, item.not_before, "",
225                item.target_agent_uid, item.created_by_uid,
226            ],
227        )?;
228        Ok(())
229    }
230
231    /// Atomically claim the highest-priority eligible item, or `None` when
232    /// nothing is claimable.
233    ///
234    /// The entire claim is ONE conditional `UPDATE ... RETURNING`. That is what
235    /// makes concurrent claims safe: SQLite serializes writers, so exactly one
236    /// caller can observe a given row in `open` and transition it. Do NOT
237    /// refactor this into a SELECT-then-UPDATE — that reintroduces the
238    /// read-modify-write race this shape exists to avoid, and the race is
239    /// invisible in single-threaded tests.
240    ///
241    /// `now_ms` and `lease_ms` are injected rather than read from the clock
242    /// here so tests can drive expiry deterministically.
243    pub fn work_queue_claim(
244        &self,
245        filter: &ClaimFilter,
246        now_ms: i64,
247        lease_ms: i64,
248    ) -> Result<Option<WorkItem>, StoreError> {
249        let conn = self.conn().lock().unwrap();
250
251        // Group eligibility is an IN-list built from caller-resolved groups.
252        // Built as a bound-parameter list (never string-interpolated) so a
253        // group id can't inject SQL.
254        let group_placeholders: String = if filter.groups.is_empty() {
255            String::new()
256        } else {
257            let marks: Vec<String> = (0..filter.groups.len())
258                .map(|i| format!("?{}", i + 6))
259                .collect();
260            format!(" OR target_group IN ({})", marks.join(","))
261        };
262
263        let sql = format!(
264            "UPDATE db_work_queue
265                SET state = '{claimed}',
266                    claimed_by = ?1,
267                    claimed_by_uid = ?5,
268                    claim_expires = ?2,
269                    attempts = attempts + 1,
270                    updated_at = ?3
271              WHERE id = (
272                    SELECT id FROM db_work_queue
273                     WHERE state = '{open}'
274                       -- Defence in depth (Codex P2 on PR #2898): an item that
275                       -- has burned its attempts must never be handed out
276                       -- again, however it came to be `open`. release/reap
277                       -- both park exhausted rows as `failed` themselves; this
278                       -- makes a missed path fail closed rather than becoming
279                       -- an infinite claim/release loop.
280                       AND attempts < max_attempts
281                       AND (not_before IS NULL OR not_before <= ?3)
282                       -- Identity M3: an item that carries a target UID is
283                       -- claimable by that identity ONLY — never by a
284                       -- same-named other (muxreview P1 on PR #3563: the name
285                       -- branch must not short-circuit a UID-addressed item).
286                       -- Items with no UID (pre-M1b rows, names that did not
287                       -- resolve) keep the name path until M5, so a claimer
288                       -- with no UID is not locked out of name-targeted work.
289                       AND ((target_agent_uid <> '' AND target_agent_uid = ?5)
290                            OR (target_agent_uid = ''
291                                AND (target_agent = '' OR target_agent = ?1)))
292                       AND (target_group = ''{groups})
293                       AND (?4 = '' OR kind = ?4)
294                     ORDER BY priority DESC, created_at ASC
295                     LIMIT 1
296              )
297              RETURNING {cols}",
298            claimed = work_state::CLAIMED,
299            open = work_state::OPEN,
300            groups = group_placeholders,
301            cols = COLS,
302        );
303
304        let mut stmt = conn.prepare(&sql)?;
305        // Positional binds: ?1 agent_id, ?2 lease expiry, ?3 now, ?4 kind,
306        // ?5 agent_uid (SET, and since M3 the uid-target match in WHERE),
307        // ?6.. group ids. ?1 and ?3 are each referenced twice in the SQL above
308        // (SET + WHERE); numbered parameters make that reuse safe.
309        let expires = now_ms + lease_ms;
310        let kind_filter = filter.kind.clone().unwrap_or_default();
311        let mut binds: Vec<&dyn rusqlite::ToSql> = vec![
312            &filter.agent_id,
313            &expires,
314            &now_ms,
315            &kind_filter,
316            &filter.agent_uid,
317        ];
318        for g in &filter.groups {
319            binds.push(g);
320        }
321
322        let item = stmt
323            .query_row(binds.as_slice(), row_to_item)
324            .optional()?;
325        Ok(item)
326    }
327
328    /// Extend a live lease.
329    ///
330    /// `attempt` is the **fence token** — the `attempts` value returned by the
331    /// claim this call belongs to. Holder identity alone is NOT sufficient
332    /// (Codex P1 on PR #2898): `agent_id` is stable across claims, so after
333    /// `expire → reap → the SAME agent reclaims`, a delayed call left over
334    /// from the FIRST claim still satisfies a holder-and-state predicate and
335    /// would silently operate on the SECOND claim. That is a textbook ABA.
336    /// `attempts` increments on every claim, so comparing it fences each claim
337    /// instance apart with no extra column.
338    pub fn work_queue_heartbeat(
339        &self,
340        id: &str,
341        agent_id: &str,
342        caller_uid: &str,
343        attempt: i64,
344        now_ms: i64,
345        lease_ms: i64,
346    ) -> Result<Option<HolderMatch>, StoreError> {
347        let conn = self.conn().lock().unwrap();
348        holder_update(
349            &conn,
350            |holder| {
351                format!(
352                    "UPDATE db_work_queue
353                        SET claim_expires = ?4, updated_at = ?5
354                      WHERE id = ?1 AND {holder} AND attempts = ?3
355                        AND state = '{c}'",
356                    c = work_state::CLAIMED
357                )
358            },
359            params![id, agent_id, attempt, now_ms + lease_ms, now_ms, caller_uid],
360        )
361    }
362
363    /// Mark a claimed item finished. Same holder + fence guard as heartbeat —
364    /// a stale completion from a previous claim must not close out a newer
365    /// one with an old result.
366    pub fn work_queue_complete(
367        &self,
368        id: &str,
369        agent_id: &str,
370        caller_uid: &str,
371        attempt: i64,
372        result: &str,
373        now_ms: i64,
374    ) -> Result<Option<HolderMatch>, StoreError> {
375        let conn = self.conn().lock().unwrap();
376        holder_update(
377            &conn,
378            |holder| {
379                format!(
380                    "UPDATE db_work_queue
381                        SET state = '{done}', result = ?4, claim_expires = NULL, updated_at = ?5
382                      WHERE id = ?1 AND {holder} AND attempts = ?3
383                        AND state = '{c}'",
384                    done = work_state::DONE,
385                    c = work_state::CLAIMED
386                )
387            },
388            params![id, agent_id, attempt, result, now_ms, caller_uid],
389        )
390    }
391
392    /// Give a claim back voluntarily.
393    ///
394    /// `attempts` is NOT decremented — a release still consumed an attempt.
395    /// And once those attempts are exhausted the item goes to `failed`, not
396    /// back to `open` (Codex P2 on PR #2898): reopening unconditionally let a
397    /// hot-potato item cycle claim→release→claim forever, because the reaper
398    /// only parks rows that are still `claimed` and a released row isn't. That
399    /// silently defeated the documented attempt bound for exactly the case the
400    /// bound exists for.
401    ///
402    /// Fenced by `attempt` for the same ABA reason as `work_queue_heartbeat`.
403    pub fn work_queue_release(
404        &self,
405        id: &str,
406        agent_id: &str,
407        caller_uid: &str,
408        attempt: i64,
409        reason: &str,
410        now_ms: i64,
411    ) -> Result<Option<HolderMatch>, StoreError> {
412        let conn = self.conn().lock().unwrap();
413        holder_update(
414            &conn,
415            |holder| {
416                format!(
417                    "UPDATE db_work_queue
418                        SET state = CASE WHEN attempts >= max_attempts
419                                         THEN '{failed}' ELSE '{open}' END,
420                            claimed_by = '', claimed_by_uid = '', claim_expires = NULL,
421                            result = ?4, updated_at = ?5
422                      WHERE id = ?1 AND {holder} AND attempts = ?3
423                        AND state = '{c}'",
424                    failed = work_state::FAILED,
425                    open = work_state::OPEN,
426                    c = work_state::CLAIMED
427                )
428            },
429            params![id, agent_id, attempt, reason, now_ms, caller_uid],
430        )
431    }
432
433    /// Reclaim every item whose lease expired: back to `open`, or to `failed`
434    /// once `attempts >= max_attempts`.
435    ///
436    /// Returns `(reopened, failed)`. Safe to call on any interval; it is
437    /// idempotent because an already-reaped row is no longer `claimed`.
438    pub fn work_queue_reap(&self, now_ms: i64) -> Result<(usize, usize), StoreError> {
439        let conn = self.conn().lock().unwrap();
440        // Park the poison items FIRST — otherwise the reopen below would put a
441        // row that has already exhausted its attempts back into the pool for
442        // one more doomed round-trip.
443        let failed = conn.execute(
444            &format!(
445                "UPDATE db_work_queue
446                    SET state = '{failed}', claimed_by = '', claimed_by_uid = '', claim_expires = NULL,
447                        result = CASE WHEN result = '' THEN 'lease expired; max_attempts exhausted' ELSE result END,
448                        updated_at = ?1
449                  WHERE state = '{c}' AND claim_expires IS NOT NULL
450                    AND claim_expires <= ?1 AND attempts >= max_attempts",
451                failed = work_state::FAILED,
452                c = work_state::CLAIMED
453            ),
454            params![now_ms],
455        )?;
456        let reopened = conn.execute(
457            &format!(
458                "UPDATE db_work_queue
459                    SET state = '{open}', claimed_by = '', claimed_by_uid = '', claim_expires = NULL, updated_at = ?1
460                  WHERE state = '{c}' AND claim_expires IS NOT NULL
461                    AND claim_expires <= ?1",
462                open = work_state::OPEN,
463                c = work_state::CLAIMED
464            ),
465            params![now_ms],
466        )?;
467        Ok((reopened, failed))
468    }
469
470    /// Explicitly abandon an item regardless of holder (operator action).
471    pub fn work_queue_cancel(&self, id: &str, reason: &str, now_ms: i64) -> Result<bool, StoreError> {
472        let conn = self.conn().lock().unwrap();
473        let n = conn.execute(
474            &format!(
475                "UPDATE db_work_queue
476                    SET state = '{cancelled}', claimed_by = '', claimed_by_uid = '', claim_expires = NULL,
477                        result = ?2, updated_at = ?3
478                  WHERE id = ?1 AND state IN ('{open}','{c}')",
479                cancelled = work_state::CANCELLED,
480                open = work_state::OPEN,
481                c = work_state::CLAIMED
482            ),
483            params![id, reason, now_ms],
484        )?;
485        Ok(n > 0)
486    }
487
488    pub fn work_queue_get(&self, id: &str) -> Result<Option<WorkItem>, StoreError> {
489        let conn = self.conn().lock().unwrap();
490        let item = conn
491            .query_row(
492                &format!("SELECT {COLS} FROM db_work_queue WHERE id = ?1"),
493                params![id],
494                row_to_item,
495            )
496            .optional()?;
497        Ok(item)
498    }
499
500    /// List items, newest-updated first. `state` empty = all states.
501    /// Every item currently claimed by this agent — by name, or by UID when
502    /// one is given — with no row cap. A superset of the store's holder rule
503    /// (`HOLDER_BY_UID`); callers narrow it with the exact rule. Used when an
504    /// agent quits, to release its claims at once (SPEC_AGENT_SELF_QUIT §4.3):
505    /// a capped fleet-wide list could miss them on a busy queue.
506    pub fn work_queue_claimed_by(&self, agent_id: &str, uid: &str) -> Result<Vec<WorkItem>, StoreError> {
507        let conn = self.conn().lock().unwrap();
508        let mut stmt = conn.prepare(&format!(
509            "SELECT {COLS} FROM db_work_queue
510              WHERE state = ?1
511                AND ((?2 != '' AND claimed_by = ?2) OR (?3 != '' AND claimed_by_uid = ?3))"
512        ))?;
513        let rows = stmt.query_map(params![work_state::CLAIMED, agent_id, uid], row_to_item)?;
514        let mut out = Vec::new();
515        for r in rows {
516            out.push(r?);
517        }
518        Ok(out)
519    }
520
521    pub fn work_queue_list(&self, state: &str, limit: usize) -> Result<Vec<WorkItem>, StoreError> {
522        let conn = self.conn().lock().unwrap();
523        let mut stmt = conn.prepare(&format!(
524            "SELECT {COLS} FROM db_work_queue
525              WHERE (?1 = '' OR state = ?1)
526              ORDER BY updated_at DESC, created_at DESC
527              LIMIT ?2"
528        ))?;
529        let rows = stmt.query_map(params![state, limit as i64], row_to_item)?;
530        let mut out = Vec::new();
531        for r in rows {
532            out.push(r?);
533        }
534        Ok(out)
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    fn store() -> (Store, tempfile::TempDir) {
543        let dir = tempfile::tempdir().unwrap();
544        let s = Store::open_identity_store(&dir.path().join("identity-store.db")).unwrap();
545        (s, dir)
546    }
547
548    fn item(id: &str, title: &str) -> WorkItem {
549        WorkItem {
550            id: id.into(),
551            title: title.into(),
552            payload: "do the thing".into(),
553            kind: String::new(),
554            target_agent: String::new(),
555            target_group: String::new(),
556            priority: 0,
557            state: work_state::OPEN.into(),
558            claimed_by: String::new(),
559            target_agent_uid: String::new(),
560            claimed_by_uid: String::new(),
561            claim_expires: None,
562            attempts: 0,
563            max_attempts: 3,
564            created_by: "tester".into(),
565            created_by_uid: String::new(),
566            created_at: 1000,
567            updated_at: 1000,
568            not_before: None,
569            result: String::new(),
570        }
571    }
572
573    fn any(agent: &str) -> ClaimFilter {
574        ClaimFilter {
575            kind: None,
576            agent_id: agent.into(),
577            agent_uid: String::new(),
578            groups: vec![],
579        }
580    }
581
582    /// An agent that quits releases its claims at once (SPEC_AGENT_SELF_QUIT
583    /// §4.3). The lookup is scoped to it, with no cap: a fleet-wide list
584    /// capped at N could drop its claims on a busy queue (ReAgent P2, #3779).
585    #[test]
586    fn claimed_by_finds_all_of_one_agents_claims_and_nothing_else() {
587        let (s, _d) = store();
588        for i in 0..5 {
589            s.work_queue_enqueue(&item(&format!("w{i}"), "t")).unwrap();
590        }
591        let claim_as = |agent: &str, uid: &str| {
592            let f = ClaimFilter { kind: None, agent_id: agent.into(), agent_uid: uid.into(), groups: vec![] };
593            s.work_queue_claim(&f, 2000, 120_000).unwrap().expect("an open item");
594        };
595        claim_as("Lark", "uid-lark");
596        claim_as("Lark", "uid-lark");
597        claim_as("Korp", "uid-korp");
598        let lark = s.work_queue_claimed_by("Lark", "uid-lark").unwrap();
599        assert_eq!(lark.len(), 2);
600        assert!(lark.iter().all(|w| w.claimed_by == "Lark" && w.state == work_state::CLAIMED));
601        assert_eq!(s.work_queue_claimed_by("Nobody", "").unwrap().len(), 0);
602        assert_eq!(s.work_queue_claimed_by("", "").unwrap().len(), 0, "no identity: nothing");
603    }
604
605    // ---- identity M1b: the UID columns are written and round-trip --------
606
607    /// Positive case first: both UID columns round-trip. `target_agent_uid`
608    /// is what the enqueuer carried; `claimed_by_uid` is what the claimer
609    /// carried.
610    #[test]
611    fn uid_columns_are_written_at_enqueue_and_claim_and_round_trip() {
612        let (s, _d) = store();
613        let mut w = item("w-uid", "targeted");
614        w.target_agent = "agenty-2".into();
615        w.target_agent_uid = "4f3c-a91".into();
616        s.work_queue_enqueue(&w).unwrap();
617
618        let stored = s.work_queue_get("w-uid").unwrap().unwrap();
619        assert_eq!(stored.target_agent_uid, "4f3c-a91");
620        assert_eq!(stored.claimed_by_uid, "", "unclaimed: no claimer uid");
621
622        let filter = ClaimFilter {
623            kind: None,
624            agent_id: "agenty-2".into(),
625            agent_uid: "4f3c-a91".into(),
626            groups: vec![],
627        };
628        let claimed = s.work_queue_claim(&filter, 2000, 60_000).unwrap().unwrap();
629        assert_eq!(claimed.claimed_by, "agenty-2");
630        assert_eq!(claimed.claimed_by_uid, "4f3c-a91");
631        assert_eq!(claimed.target_agent_uid, "4f3c-a91");
632    }
633
634    /// Identity M4c-1: the enqueuer's UID is stored beside `created_by` and
635    /// read back by every read path (get, claim's RETURNING, list).
636    #[test]
637    fn created_by_uid_round_trips_through_enqueue_claim_and_list() {
638        let (s, _d) = store();
639        let mut w = item("w-cbu", "attributed");
640        w.created_by_uid = "uid-enqueuer".into();
641        s.work_queue_enqueue(&w).unwrap();
642        let mut later = item("w-cbu-none", "unattributed");
643        later.created_at = 1001;
644        s.work_queue_enqueue(&later).unwrap();
645
646        let stored = s.work_queue_get("w-cbu").unwrap().unwrap();
647        assert_eq!(stored.created_by, "tester");
648        assert_eq!(stored.created_by_uid, "uid-enqueuer");
649        assert_eq!(
650            s.work_queue_get("w-cbu-none")
651                .unwrap()
652                .unwrap()
653                .created_by_uid,
654            ""
655        );
656
657        let claimed = s
658            .work_queue_claim(&any("a1"), 2000, 60_000)
659            .unwrap()
660            .unwrap();
661        assert_eq!(claimed.id, "w-cbu");
662        assert_eq!(claimed.created_by_uid, "uid-enqueuer");
663        let listed = s.work_queue_list("", 10).unwrap();
664        assert!(listed
665            .iter()
666            .any(|i| i.id == "w-cbu" && i.created_by_uid == "uid-enqueuer"));
667    }
668
669    /// Identity M3: an item addressed by UID is claimable by the agent with
670    /// that UID even if its name differs from `target_agent` (a rename), and
671    /// NOT by a different agent that happens to carry the same name.
672    #[test]
673    fn a_uid_targeted_item_is_claimable_by_uid_and_not_by_a_same_named_other() {
674        let (s, _d) = store();
675        let mut w = item("w-by-uid", "for agenty-3 specifically");
676        w.target_agent = "AgentY".into();
677        w.target_agent_uid = "4f3c-a91".into();
678        s.work_queue_enqueue(&w).unwrap();
679
680        // The EXACT same display name, different identity: not eligible.
681        // The name branch must not short-circuit a UID-addressed item
682        // (ReAgent P1 on #3563 — an earlier version of this test used a
683        // different-case name, which dodged the case-sensitive `=`).
684        let impostor = ClaimFilter {
685            kind: None,
686            agent_id: "AgentY".into(),
687            agent_uid: "9b2e-7d4".into(),
688            groups: vec![],
689        };
690        assert!(s.work_queue_claim(&impostor, 2000, 60_000).unwrap().is_none());
691        // The exact name with NO uid at all is not enough either: a
692        // UID-addressed item is claimable by identity only. (A claimer that
693        // registered name-only before its row existed is locked out of its
694        // own UID-targeted work until it carries the UID — recorded.)
695        let nameless = ClaimFilter {
696            kind: None,
697            agent_id: "AgentY".into(),
698            agent_uid: String::new(),
699            groups: vec![],
700        };
701        assert!(s.work_queue_claim(&nameless, 2000, 60_000).unwrap().is_none());
702
703        // The right identity under a NEW display name still claims it.
704        let renamed = ClaimFilter {
705            kind: None,
706            agent_id: "Renamed".into(),
707            agent_uid: "4f3c-a91".into(),
708            groups: vec![],
709        };
710        let got = s.work_queue_claim(&renamed, 2000, 60_000).unwrap().unwrap();
711        assert_eq!(got.id, "w-by-uid");
712        assert_eq!(got.claimed_by_uid, "4f3c-a91");
713    }
714
715    /// No reader changes (spec §9.1 M1): a claimer that carries no UID — a
716    /// pre-M1a spawn, a quick-launch pane — is still eligible exactly as
717    /// before, and the column simply stays empty.
718    #[test]
719    fn a_claimer_without_a_uid_still_claims_and_leaves_the_column_empty() {
720        let (s, _d) = store();
721        s.work_queue_enqueue(&item("w-nouid", "open to all"))
722            .unwrap();
723        let claimed = s
724            .work_queue_claim(&any("agentx"), 2000, 60_000)
725            .unwrap()
726            .unwrap();
727        assert_eq!(claimed.claimed_by, "agentx");
728        assert_eq!(claimed.claimed_by_uid, "");
729    }
730
731    /// Every path that clears `claimed_by` clears `claimed_by_uid` with it —
732    /// release, reap, cancel. A UID left behind on an `open` row would be a
733    /// stale claim of a claimer that no longer holds it, which is exactly the
734    /// "derive state from what was previously stored" defect spec §10
735    /// constraint 2 forbids.
736    #[test]
737    fn release_reap_and_cancel_clear_the_claimer_uid_with_the_claimer() {
738        let (s, _d) = store();
739        let filter = ClaimFilter {
740            kind: None,
741            agent_id: "a1".into(),
742            agent_uid: "uid-a1".into(),
743            groups: vec![],
744        };
745
746        // release
747        s.work_queue_enqueue(&item("w-rel", "release me")).unwrap();
748        let c = s.work_queue_claim(&filter, 2000, 60_000).unwrap().unwrap();
749        assert_eq!(c.claimed_by_uid, "uid-a1");
750        assert!(s
751            .work_queue_release("w-rel", "a1", "", c.attempts, "later", 3000)
752            .unwrap()
753            .is_some());
754        assert_eq!(
755            s.work_queue_get("w-rel").unwrap().unwrap().claimed_by_uid,
756            ""
757        );
758        // Out of the pool, so the next section's claim cannot pick it up
759        // (released rows return to `open` with their original created_at).
760        assert!(s.work_queue_cancel("w-rel", "section done", 3100).unwrap());
761
762        // reap (lease expired)
763        s.work_queue_enqueue(&item("w-reap", "reap me")).unwrap();
764        let c = s.work_queue_claim(&filter, 4000, 10).unwrap();
765        assert_eq!(c.unwrap().id, "w-reap");
766        s.work_queue_reap(5000).unwrap();
767        let reaped = s.work_queue_get("w-reap").unwrap().unwrap();
768        assert_eq!(reaped.claimed_by, "");
769        assert_eq!(reaped.claimed_by_uid, "");
770        assert!(s.work_queue_cancel("w-reap", "section done", 5100).unwrap());
771
772        // cancel
773        s.work_queue_enqueue(&item("w-can", "cancel me")).unwrap();
774        s.work_queue_claim(&filter, 6000, 60_000).unwrap().unwrap();
775        assert!(s.work_queue_cancel("w-can", "operator", 7000).unwrap());
776        assert_eq!(
777            s.work_queue_get("w-can").unwrap().unwrap().claimed_by_uid,
778            ""
779        );
780    }
781
782    #[test]
783    fn claim_returns_none_on_an_empty_queue() {
784        let (s, _d) = store();
785        assert!(s.work_queue_claim(&any("a1"), 2000, 60_000).unwrap().is_none());
786    }
787
788    /// The core guarantee: two agents racing for one item must not both get
789    /// it. Single-threaded here, but it drives the same conditional-UPDATE
790    /// path concurrency relies on — a SELECT-then-UPDATE refactor fails this.
791    #[test]
792    fn one_item_can_only_be_claimed_once() {
793        let (s, _d) = store();
794        s.work_queue_enqueue(&item("w1", "only one")).unwrap();
795
796        let first = s.work_queue_claim(&any("a1"), 2000, 60_000).unwrap();
797        let second = s.work_queue_claim(&any("a2"), 2000, 60_000).unwrap();
798
799        assert_eq!(first.map(|i| i.id), Some("w1".to_string()));
800        assert!(second.is_none(), "a second claimant must not get the same item");
801    }
802
803    #[test]
804    fn claim_prefers_higher_priority_then_older_first() {
805        let (s, _d) = store();
806        let mut low = item("low", "low");
807        low.priority = 0;
808        low.created_at = 10;
809        let mut high = item("high", "high");
810        high.priority = 5;
811        high.created_at = 99;
812        let mut old = item("old", "old");
813        old.priority = 5;
814        old.created_at = 50;
815        s.work_queue_enqueue(&low).unwrap();
816        s.work_queue_enqueue(&high).unwrap();
817        s.work_queue_enqueue(&old).unwrap();
818
819        // priority 5 beats 0; within priority 5, created_at 50 beats 99.
820        assert_eq!(s.work_queue_claim(&any("a"), 2000, 60_000).unwrap().unwrap().id, "old");
821        assert_eq!(s.work_queue_claim(&any("a"), 2000, 60_000).unwrap().unwrap().id, "high");
822        assert_eq!(s.work_queue_claim(&any("a"), 2000, 60_000).unwrap().unwrap().id, "low");
823    }
824
825    #[test]
826    fn not_before_defers_an_item_until_its_time() {
827        let (s, _d) = store();
828        let mut later = item("later", "later");
829        later.not_before = Some(5_000);
830        s.work_queue_enqueue(&later).unwrap();
831
832        assert!(
833            s.work_queue_claim(&any("a"), 4_999, 60_000).unwrap().is_none(),
834            "must not be claimable before not_before"
835        );
836        assert!(
837            s.work_queue_claim(&any("a"), 5_000, 60_000).unwrap().is_some(),
838            "claimable exactly at not_before"
839        );
840    }
841
842    #[test]
843    fn a_targeted_item_is_only_claimable_by_its_target() {
844        let (s, _d) = store();
845        let mut t = item("t", "for a2");
846        t.target_agent = "a2".into();
847        s.work_queue_enqueue(&t).unwrap();
848
849        assert!(s.work_queue_claim(&any("a1"), 2000, 60_000).unwrap().is_none());
850        assert_eq!(s.work_queue_claim(&any("a2"), 2000, 60_000).unwrap().unwrap().id, "t");
851    }
852
853    #[test]
854    fn a_group_targeted_item_is_claimable_only_by_a_member() {
855        let (s, _d) = store();
856        let mut g = item("g", "for reviewers");
857        g.target_group = "reviewers".into();
858        s.work_queue_enqueue(&g).unwrap();
859
860        let outsider = ClaimFilter {
861            kind: None,
862            agent_id: "a1".into(),
863            agent_uid: String::new(),
864            groups: vec!["writers".into()],
865        };
866        let member = ClaimFilter {
867            kind: None,
868            agent_id: "a2".into(),
869            agent_uid: String::new(),
870            groups: vec!["reviewers".into()],
871        };
872
873        assert!(s.work_queue_claim(&outsider, 2000, 60_000).unwrap().is_none());
874        assert_eq!(s.work_queue_claim(&member, 2000, 60_000).unwrap().unwrap().id, "g");
875    }
876
877    /// An agent with NO groups must still claim untargeted work — the
878    /// empty-group path builds a different SQL string, so it needs its own
879    /// coverage rather than being assumed equivalent to the group path.
880    #[test]
881    fn an_agent_with_no_groups_can_still_claim_untargeted_work() {
882        let (s, _d) = store();
883        s.work_queue_enqueue(&item("free", "anyone")).unwrap();
884        assert_eq!(s.work_queue_claim(&any("a"), 2000, 60_000).unwrap().unwrap().id, "free");
885    }
886
887    #[test]
888    fn kind_filter_narrows_the_claim() {
889        let (s, _d) = store();
890        let mut review = item("r", "review");
891        review.kind = "review".into();
892        let mut repro = item("p", "repro");
893        repro.kind = "repro".into();
894        s.work_queue_enqueue(&review).unwrap();
895        s.work_queue_enqueue(&repro).unwrap();
896
897        let f = ClaimFilter {
898            kind: Some("repro".into()),
899            agent_id: "a".into(),
900            agent_uid: String::new(),
901            groups: vec![],
902        };
903        assert_eq!(s.work_queue_claim(&f, 2000, 60_000).unwrap().unwrap().id, "p");
904    }
905
906    #[test]
907    fn heartbeat_and_complete_are_holder_only() {
908        let (s, _d) = store();
909        s.work_queue_enqueue(&item("w", "held")).unwrap();
910        let c = s.work_queue_claim(&any("owner"), 2000, 60_000).unwrap().unwrap();
911
912        assert!(s.work_queue_heartbeat("w", "impostor", "", c.attempts, 3000, 60_000).unwrap().is_none());
913        assert!(s.work_queue_heartbeat("w", "owner", "", c.attempts, 3000, 60_000).unwrap().is_some());
914
915        assert!(s.work_queue_complete("w", "impostor", "", c.attempts, "nope", 4000).unwrap().is_none());
916        assert!(s.work_queue_complete("w", "owner", "", c.attempts, "shipped", 4000).unwrap().is_some());
917
918        let done = s.work_queue_get("w").unwrap().unwrap();
919        assert_eq!(done.state, work_state::DONE);
920        assert_eq!(done.result, "shipped");
921        assert!(done.claim_expires.is_none());
922    }
923
924    /// Identity M4c-2 (spec §6.5.9), the colliding-names fixture: two
925    /// agents both answer to `agenty`. When the row and the caller both have
926    /// a UID, only the UID decides; otherwise the name does, as before.
927    #[test]
928    fn a_holder_is_matched_by_uid_when_both_are_known() {
929        let (s, _d) = store();
930        s.work_queue_enqueue(&item("w", "held")).unwrap();
931        let mut by_y = any("agenty");
932        by_y.agent_uid = "uid-y".into();
933        let c = s.work_queue_claim(&by_y, 2000, 60_000).unwrap().unwrap();
934        let n = c.attempts;
935
936        // The other `agenty`, attributed: its name matches, its UID does not.
937        assert_eq!(s.work_queue_heartbeat("w", "agenty", "uid-y2", n, 3000, 60_000).unwrap(), None);
938        assert_eq!(s.work_queue_complete("w", "agenty", "uid-y2", n, "no", 3000).unwrap(), None);
939        assert_eq!(s.work_queue_release("w", "agenty", "uid-y2", n, "no", 3000).unwrap(), None);
940        // The holder's UID decides, whatever name the request carries.
941        assert_eq!(
942            s.work_queue_heartbeat("w", "AgentY", "uid-y", n, 3000, 60_000).unwrap(),
943            Some(HolderMatch::Uid)
944        );
945        // An Unattributed request keeps the name path.
946        assert_eq!(
947            s.work_queue_heartbeat("w", "agenty", "", n, 3000, 60_000).unwrap(),
948            Some(HolderMatch::Name)
949        );
950        assert_eq!(s.work_queue_heartbeat("w", "AgentY", "", n, 3000, 60_000).unwrap(), None);
951        assert_eq!(
952            s.work_queue_complete("w", "AgentY", "uid-y", n, "done", 4000).unwrap(),
953            Some(HolderMatch::Uid)
954        );
955        assert_eq!(s.work_queue_get("w").unwrap().unwrap().result, "done");
956
957        // A row claimed with no UID keeps the name path for an attributed
958        // caller too.
959        s.work_queue_enqueue(&item("w2", "held by name")).unwrap();
960        let c = s.work_queue_claim(&any("agenty"), 5000, 60_000).unwrap().unwrap();
961        assert_eq!(c.claimed_by_uid, "");
962        assert_eq!(
963            s.work_queue_release("w2", "agenty", "uid-y2", c.attempts, "back", 6000).unwrap(),
964            Some(HolderMatch::Name)
965        );
966        assert_eq!(s.work_queue_get("w2").unwrap().unwrap().state, work_state::OPEN);
967    }
968
969    /// Codex P1 on PR #2898 — the ABA case. `agent_id` is stable across
970    /// claims, so after expire → reap → *the same agent* reclaims, a delayed
971    /// call left over from the FIRST claim satisfies any holder-and-state
972    /// predicate and would operate on the SECOND claim. The `attempts` fence
973    /// is what separates the two claim instances.
974    #[test]
975    fn a_stale_call_from_a_previous_claim_cannot_touch_the_new_one() {
976        let (s, _d) = store();
977        s.work_queue_enqueue(&item("w", "reclaimed by the same agent")).unwrap();
978
979        let first = s.work_queue_claim(&any("a1"), 1_000, 1_000).unwrap().unwrap();
980        assert_eq!(first.attempts, 1);
981        assert_eq!(s.work_queue_reap(2_000).unwrap(), (1, 0));
982
983        // SAME agent id picks it up again — the whole point of the case.
984        let second = s.work_queue_claim(&any("a1"), 3_000, 60_000).unwrap().unwrap();
985        assert_eq!(second.attempts, 2, "the reclaim is a distinct claim instance");
986
987        // Everything below is the FIRST claim's fence arriving late.
988        assert!(
989            s.work_queue_complete("w", "a1", "", first.attempts, "stale result", 4_000).unwrap().is_none(),
990            "a stale completion must not close out the newer claim"
991        );
992        assert!(
993            s.work_queue_heartbeat("w", "a1", "", first.attempts, 4_000, 60_000).unwrap().is_none(),
994            "a stale heartbeat must not extend the newer claim"
995        );
996        assert!(
997            s.work_queue_release("w", "a1", "", first.attempts, "stale release", 4_000).unwrap().is_none(),
998            "a stale release must not reopen the newer claim"
999        );
1000
1001        let live = s.work_queue_get("w").unwrap().unwrap();
1002        assert_eq!(live.state, work_state::CLAIMED, "the second claim is untouched");
1003        assert_eq!(live.result, "");
1004
1005        // And the CURRENT fence still works.
1006        assert!(s.work_queue_complete("w", "a1", "", second.attempts, "real result", 5_000).unwrap().is_some());
1007        assert_eq!(s.work_queue_get("w").unwrap().unwrap().result, "real result");
1008    }
1009
1010    /// The issue-#2518 lesson: a claimant that dies must not hold the row
1011    /// forever.
1012    #[test]
1013    fn reap_returns_an_expired_lease_to_the_pool() {
1014        let (s, _d) = store();
1015        s.work_queue_enqueue(&item("w", "abandoned")).unwrap();
1016        s.work_queue_claim(&any("ghost"), 1_000, 5_000).unwrap().unwrap(); // expires 6000
1017
1018        assert_eq!(s.work_queue_reap(5_999).unwrap(), (0, 0), "not yet expired");
1019        assert_eq!(s.work_queue_reap(6_000).unwrap(), (1, 0), "expired at the boundary");
1020
1021        let back = s.work_queue_get("w").unwrap().unwrap();
1022        assert_eq!(back.state, work_state::OPEN);
1023        assert_eq!(back.claimed_by, "");
1024        assert_eq!(back.attempts, 1, "the dead attempt is still counted");
1025        assert!(s.work_queue_claim(&any("someone-else"), 7_000, 60_000).unwrap().is_some());
1026    }
1027
1028    /// A poison item that crashes every taker must eventually stop cycling.
1029    /// This also pins the ordering INSIDE `work_queue_reap`: if the reopen
1030    /// pass ran before the fail pass, an already-exhausted row would be handed
1031    /// out for one more doomed round-trip.
1032    #[test]
1033    fn a_poison_item_is_parked_as_failed_once_attempts_are_exhausted() {
1034        let (s, _d) = store();
1035        let mut poison = item("p", "kills takers");
1036        poison.max_attempts = 2;
1037        s.work_queue_enqueue(&poison).unwrap();
1038
1039        s.work_queue_claim(&any("a1"), 1_000, 1_000).unwrap().unwrap();
1040        assert_eq!(s.work_queue_reap(2_000).unwrap(), (1, 0));
1041
1042        s.work_queue_claim(&any("a2"), 3_000, 1_000).unwrap().unwrap();
1043        let (reopened, failed) = s.work_queue_reap(4_000).unwrap();
1044        assert_eq!((reopened, failed), (0, 1), "second expiry exhausts max_attempts");
1045
1046        let dead = s.work_queue_get("p").unwrap().unwrap();
1047        assert_eq!(dead.state, work_state::FAILED);
1048        assert!(
1049            s.work_queue_claim(&any("a3"), 5_000, 60_000).unwrap().is_none(),
1050            "a failed item must never be handed out again"
1051        );
1052    }
1053
1054    #[test]
1055    fn release_returns_the_item_but_keeps_the_attempt() {
1056        let (s, _d) = store();
1057        s.work_queue_enqueue(&item("w", "hot potato")).unwrap();
1058        let c = s.work_queue_claim(&any("a1"), 1_000, 60_000).unwrap().unwrap();
1059
1060        assert!(s.work_queue_release("w", "impostor", "", c.attempts, "not mine", 2_000).unwrap().is_none());
1061        assert!(s.work_queue_release("w", "a1", "", c.attempts, "cannot do this", 2_000).unwrap().is_some());
1062
1063        let back = s.work_queue_get("w").unwrap().unwrap();
1064        assert_eq!(back.state, work_state::OPEN);
1065        assert_eq!(back.attempts, 1, "a voluntary release still consumed an attempt");
1066        assert_eq!(s.work_queue_claim(&any("a2"), 3_000, 60_000).unwrap().unwrap().id, "w");
1067    }
1068
1069    /// Codex P2 on PR #2898: releasing on the FINAL allowed attempt used to
1070    /// return the row to `open` unconditionally, so a hot-potato item could
1071    /// cycle claim→release→claim forever — the reaper cannot park it, because
1072    /// a released row is no longer `claimed`. That silently defeated the
1073    /// attempt bound for precisely the case it exists to bound.
1074    #[test]
1075    fn releasing_on_the_final_attempt_fails_the_item_instead_of_reopening_it() {
1076        let (s, _d) = store();
1077        let mut hot = item("w", "nobody can do this");
1078        hot.max_attempts = 2;
1079        s.work_queue_enqueue(&hot).unwrap();
1080
1081        let c1 = s.work_queue_claim(&any("a1"), 1_000, 60_000).unwrap().unwrap();
1082        assert!(s.work_queue_release("w", "a1", "", c1.attempts, "not for me", 1_500).unwrap().is_some());
1083        assert_eq!(s.work_queue_get("w").unwrap().unwrap().state, work_state::OPEN,
1084                   "attempt 1 of 2 — still has a life left");
1085
1086        let c2 = s.work_queue_claim(&any("a2"), 2_000, 60_000).unwrap().unwrap();
1087        assert_eq!(c2.attempts, 2, "final allowed attempt");
1088        assert!(s.work_queue_release("w", "a2", "", c2.attempts, "nor me", 2_500).unwrap().is_some());
1089
1090        let dead = s.work_queue_get("w").unwrap().unwrap();
1091        assert_eq!(dead.state, work_state::FAILED, "exhausted release must park, not reopen");
1092        assert!(
1093            s.work_queue_claim(&any("a3"), 3_000, 60_000).unwrap().is_none(),
1094            "the cycle must be broken, not merely slowed"
1095        );
1096    }
1097
1098    /// Defence in depth for the same finding: even if a row somehow reaches
1099    /// `open` with its attempts already spent, the claim query itself must
1100    /// refuse it rather than trusting release/reap to have parked it.
1101    #[test]
1102    fn an_open_row_with_exhausted_attempts_is_not_claimable() {
1103        let (s, _d) = store();
1104        let mut spent = item("w", "already spent");
1105        spent.max_attempts = 1;
1106        s.work_queue_enqueue(&spent).unwrap();
1107
1108        // Force the state release/reap would normally never leave behind.
1109        s.work_queue_claim(&any("a1"), 1_000, 60_000).unwrap().unwrap();
1110        {
1111            let conn = s.conn().lock().unwrap();
1112            conn.execute(
1113                "UPDATE db_work_queue SET state = 'open', claimed_by = '' WHERE id = 'w'",
1114                [],
1115            )
1116            .unwrap();
1117        }
1118
1119        assert!(
1120            s.work_queue_claim(&any("a2"), 2_000, 60_000).unwrap().is_none(),
1121            "attempts >= max_attempts must fail closed at the claim query"
1122        );
1123    }
1124
1125    #[test]
1126    fn cancel_removes_an_item_from_circulation_from_either_state() {
1127        let (s, _d) = store();
1128        s.work_queue_enqueue(&item("o", "open one")).unwrap();
1129        s.work_queue_enqueue(&item("c", "claimed one")).unwrap();
1130        // Cancel "o" out of the way so the claim below deterministically takes "c".
1131        assert!(s.work_queue_cancel("o", "not needed", 1_500).unwrap());
1132        s.work_queue_claim(&any("a1"), 2_000, 60_000).unwrap().unwrap();
1133
1134        assert!(s.work_queue_cancel("c", "superseded", 2_500).unwrap());
1135        assert_eq!(s.work_queue_get("c").unwrap().unwrap().state, work_state::CANCELLED);
1136        assert!(s.work_queue_claim(&any("a2"), 3_000, 60_000).unwrap().is_none());
1137        // A second cancel is a no-op, not an error.
1138        assert!(!s.work_queue_cancel("c", "again", 3_000).unwrap());
1139    }
1140
1141    #[test]
1142    fn a_completed_item_is_never_reaped_or_reclaimed() {
1143        let (s, _d) = store();
1144        s.work_queue_enqueue(&item("w", "finished")).unwrap();
1145        let c = s.work_queue_claim(&any("a1"), 1_000, 1_000).unwrap().unwrap();
1146        assert!(s.work_queue_complete("w", "a1", "", c.attempts, "ok", 1_500).unwrap().is_some());
1147
1148        assert_eq!(s.work_queue_reap(9_999).unwrap(), (0, 0), "a done row has no live lease");
1149        assert!(s.work_queue_claim(&any("a2"), 9_999, 60_000).unwrap().is_none());
1150    }
1151
1152    #[test]
1153    fn list_filters_by_state_and_respects_the_limit() {
1154        let (s, _d) = store();
1155        s.work_queue_enqueue(&item("a", "a")).unwrap();
1156        s.work_queue_enqueue(&item("b", "b")).unwrap();
1157        s.work_queue_claim(&any("x"), 2_000, 60_000).unwrap().unwrap();
1158
1159        assert_eq!(s.work_queue_list("", 10).unwrap().len(), 2);
1160        assert_eq!(s.work_queue_list(work_state::CLAIMED, 10).unwrap().len(), 1);
1161        assert_eq!(s.work_queue_list(work_state::OPEN, 10).unwrap().len(), 1);
1162        assert_eq!(s.work_queue_list("", 1).unwrap().len(), 1);
1163    }
1164
1165    #[test]
1166    fn enqueue_rejects_a_duplicate_id_rather_than_silently_overwriting() {
1167        let (s, _d) = store();
1168        s.work_queue_enqueue(&item("dup", "first")).unwrap();
1169        assert!(s.work_queue_enqueue(&item("dup", "second")).is_err());
1170        assert_eq!(s.work_queue_get("dup").unwrap().unwrap().title, "first");
1171    }
1172}