1use rusqlite::{params, OptionalExtension, Row};
34use serde::{Deserialize, Serialize};
35
36use super::store::Store;
37use super::StoreError;
38
39pub mod work_state {
41 pub const OPEN: &str = "open";
43 pub const CLAIMED: &str = "claimed";
45 pub const DONE: &str = "done";
47 pub const FAILED: &str = "failed";
49 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 pub payload: String,
59 #[serde(default)]
61 pub kind: String,
62 #[serde(default)]
64 pub target_agent: String,
65 #[serde(default)]
70 pub target_group: String,
71 #[serde(default)]
73 pub priority: i64,
74 pub state: String,
75 #[serde(default)]
76 pub claimed_by: String,
77 #[serde(default)]
84 pub target_agent_uid: String,
85 #[serde(default)]
90 pub claimed_by_uid: String,
91 #[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 #[serde(default)]
106 pub created_by_uid: String,
107 pub created_at: i64,
108 pub updated_at: i64,
109 #[serde(default)]
111 pub not_before: Option<i64>,
112 #[serde(default)]
114 pub result: String,
115}
116
117#[derive(Debug, Clone, Default)]
120pub struct ClaimFilter {
121 pub kind: Option<String>,
123 pub agent_id: String,
126 pub agent_uid: String,
131 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum HolderMatch {
165 Uid,
167 Name,
170}
171
172const 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
182fn 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 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 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 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 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 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 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 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 pub fn work_queue_reap(&self, now_ms: i64) -> Result<(usize, usize), StoreError> {
439 let conn = self.conn().lock().unwrap();
440 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 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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 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 assert!(s.work_queue_cancel("w-rel", "section done", 3100).unwrap());
761
762 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 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 #[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 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 #[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 #[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 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 assert_eq!(
942 s.work_queue_heartbeat("w", "AgentY", "uid-y", n, 3000, 60_000).unwrap(),
943 Some(HolderMatch::Uid)
944 );
945 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 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 #[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 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 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 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 #[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(); 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 #[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 #[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 #[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 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 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 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}