1use sqlx::{AssertSqlSafe, PgPool};
2use uuid::Uuid;
3
4use crate::{
5 auth::AuthCtx,
6 error::{BusError, BusResult},
7 model::{ClaimResult, TaskDetail, TaskEventInfo, TaskInfo, TaskList, ts, ts_opt},
8};
9
10const DEFAULT_LEASE_SECS: i64 = 900; const MAX_LEASE_SECS: i64 = 86_400;
12const MAX_LIMIT: i64 = 200;
13
14const MAX_TITLE_BYTES: usize = 512;
16const MAX_DESCRIPTION_BYTES: usize = 64 * 1024;
21const MAX_RESULT_BYTES: usize = 64 * 1024;
22const MAX_DEPENDENCIES: usize = 32;
25
26#[derive(sqlx::FromRow)]
27struct TaskRow {
28 key: String,
29 title: String,
30 description: Option<String>,
31 status: String,
32 depends_on: Vec<String>,
33 blocked: bool,
34 claimed_by: Option<String>,
35 claimed_session: Option<String>,
36 claimed_at: Option<chrono::DateTime<chrono::Utc>>,
37 lease_expires_at: Option<chrono::DateTime<chrono::Utc>>,
38 lease_expired: bool,
41 result: Option<String>,
42 metadata: serde_json::Value,
43 attachments: serde_json::Value,
44 created_by: Option<String>,
45 created_at: chrono::DateTime<chrono::Utc>,
46 updated_at: chrono::DateTime<chrono::Utc>,
47}
48
49impl From<TaskRow> for TaskInfo {
50 fn from(r: TaskRow) -> Self {
51 if r.lease_expired {
57 return TaskInfo {
58 key: r.key,
59 title: r.title,
60 description: r.description,
61 status: "open".to_owned(),
62 depends_on: r.depends_on,
63 blocked: r.blocked,
64 claimed_by: None,
65 claimed_session: None,
66 claimed_at: None,
67 lease_expires_at: None,
68 lease_expired: true,
69 lease_seconds_remaining: None,
70 lapsed_holder: r.claimed_by,
71 result: r.result,
72 metadata: r.metadata,
73 attachments: serde_json::from_value(r.attachments).unwrap_or_default(),
74 created_by: r.created_by,
75 created_at: ts(r.created_at),
76 updated_at: ts(r.updated_at),
77 };
78 }
79 let now = chrono::Utc::now();
80 let lease_seconds_remaining = r
84 .lease_expires_at
85 .filter(|_| r.status == "claimed")
86 .map(|e| (e - now).num_seconds().max(0));
87 TaskInfo {
88 key: r.key,
89 title: r.title,
90 description: r.description,
91 status: r.status,
92 depends_on: r.depends_on,
93 blocked: r.blocked,
94 claimed_by: r.claimed_by,
95 claimed_session: r.claimed_session.filter(|s| !s.is_empty()),
98 claimed_at: ts_opt(r.claimed_at),
99 lease_expires_at: ts_opt(r.lease_expires_at),
100 lease_expired: false,
101 lease_seconds_remaining,
102 lapsed_holder: None,
103 result: r.result,
104 metadata: r.metadata,
105 attachments: serde_json::from_value(r.attachments).unwrap_or_default(),
106 created_by: r.created_by,
107 created_at: ts(r.created_at),
108 updated_at: ts(r.updated_at),
109 }
110 }
111}
112
113const TASK_SELECT: &str = r#"
114 SELECT t.id,
115 t.key,
116 t.title,
117 t.description,
118 t.status,
119 COALESCE(
120 (SELECT array_agg(d.key ORDER BY d.key)
121 FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
122 WHERE td.task_id = t.id),
123 '{}'
124 ) AS depends_on,
125 EXISTS (
126 SELECT 1
127 FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
128 WHERE td.task_id = t.id AND d.status NOT IN ('done', 'cancelled')
129 ) AS blocked,
130 cb.name AS claimed_by,
131 t.claimed_session,
132 t.claimed_at,
133 t.lease_expires_at,
134 -- A claim with no expiry (a row from before leases had one) is a
135 -- live claim, not a lapsed one: NULL here would not decode.
136 COALESCE(t.status = 'claimed' AND t.lease_expires_at <= now(), false) AS lease_expired,
137 t.result,
138 t.metadata,
139 COALESCE(
140 (SELECT json_agg(json_build_object(
141 'id', a.id, 'filename', a.filename,
142 'content_type', a.content_type, 'size_bytes', a.size_bytes)
143 ORDER BY a.id)
144 FROM attachments a WHERE a.task_id = t.id),
145 '[]'::json
146 ) AS attachments,
147 crb.name AS created_by,
148 t.created_at,
149 t.updated_at
150 FROM tasks t
151 LEFT JOIN agents cb ON cb.id = t.claimed_by
152 LEFT JOIN agents crb ON crb.id = t.created_by
153"#;
154
155const EFFECTIVE_STATUS: &str =
158 "CASE WHEN t.status = 'claimed' AND t.lease_expires_at <= now() THEN 'open' ELSE t.status END";
159
160async fn log_event_tx(
164 conn: &mut sqlx::PgConnection,
165 task_id: Uuid,
166 agent_id: Uuid,
167 event: &str,
168 detail: Option<&str>,
169) -> BusResult<()> {
170 sqlx::query("INSERT INTO task_events (task_id, agent_id, event, detail) VALUES ($1,$2,$3,$4)")
171 .bind(task_id)
172 .bind(agent_id)
173 .bind(event)
174 .bind(detail)
175 .execute(conn)
176 .await?;
177 Ok(())
178}
179
180fn normalize_key(key: &str) -> BusResult<String> {
181 let key = key.trim();
182 if key.is_empty() {
183 return Err(BusError::invalid("task key cannot be empty"));
184 }
185 if key.len() > 128 {
186 return Err(BusError::invalid("task key is limited to 128 characters"));
187 }
188 Ok(key.to_owned())
189}
190
191pub struct CreateInput {
194 pub key: String,
195 pub title: String,
196 pub description: Option<String>,
197 pub metadata: Option<serde_json::Value>,
198 pub depends_on: Vec<String>,
201}
202
203pub async fn create_task(pool: &PgPool, auth: &AuthCtx, input: CreateInput) -> BusResult<TaskInfo> {
204 let key = normalize_key(&input.key)?;
205 let title = super::check_text("task title", &input.title, MAX_TITLE_BYTES)?;
206 if title.is_empty() {
207 return Err(BusError::invalid("task title cannot be empty"));
208 }
209 let description = match input.description.as_deref() {
210 Some(d) => Some(super::check_text(
211 "task description",
212 d,
213 MAX_DESCRIPTION_BYTES,
214 )?),
215 None => None,
216 };
217 let metadata_in = super::normalize_metadata(input.metadata);
218 super::check_metadata("task", metadata_in.as_ref())?;
219 let metadata = metadata_in.unwrap_or_else(|| serde_json::Value::Object(Default::default()));
220
221 if input.depends_on.len() > MAX_DEPENDENCIES {
222 return Err(BusError::invalid(format!(
223 "a task declares at most {MAX_DEPENDENCIES} dependencies; got {}. \
224 Group the upstream work into fewer tasks.",
225 input.depends_on.len()
226 )));
227 }
228 let mut dep_keys: Vec<String> = Vec::with_capacity(input.depends_on.len());
229 for dep_key in &input.depends_on {
230 let dep_key = normalize_key(dep_key)?;
231 if dep_key == key {
232 return Err(BusError::invalid("a task cannot depend on itself"));
233 }
234 dep_keys.push(dep_key);
235 }
236
237 let mut tx = pool.begin().await?;
244
245 let inserted: Option<(Uuid,)> = sqlx::query_as(
249 r#"
250 INSERT INTO tasks (team_id, key, title, description, metadata, created_by)
251 VALUES ($1, $2, $3, $4, $5, $6)
252 ON CONFLICT (team_id, key) DO NOTHING
253 RETURNING id
254 "#,
255 )
256 .bind(auth.team_id)
257 .bind(&key)
258 .bind(&title)
259 .bind(description.as_deref())
260 .bind(&metadata)
261 .bind(auth.agent_id)
262 .fetch_optional(&mut *tx)
263 .await?;
264 let Some((id,)) = inserted else {
265 return Err(BusError::conflict(format!(
266 "task '{key}' already exists; use get_task to inspect it"
267 )));
268 };
269
270 let found: Vec<(String, Uuid)> =
273 sqlx::query_as("SELECT key, id FROM tasks WHERE team_id = $1 AND key = ANY($2)")
274 .bind(auth.team_id)
275 .bind(&dep_keys)
276 .fetch_all(&mut *tx)
277 .await?;
278 let found: std::collections::HashMap<String, Uuid> = found.into_iter().collect();
279 let mut dep_ids: Vec<Uuid> = Vec::with_capacity(dep_keys.len());
280 for dep_key in &dep_keys {
281 match found.get(dep_key) {
282 Some(id) => dep_ids.push(*id),
283 None => {
284 return Err(BusError::not_found(format!(
285 "dependency '{dep_key}' does not exist; create it first"
286 )));
287 }
288 }
289 }
290 dep_ids.sort();
291 dep_ids.dedup();
292
293 if !dep_ids.is_empty() {
296 sqlx::query(
297 "INSERT INTO task_deps (task_id, blocked_by_task_id) SELECT $1, unnest($2::uuid[])",
298 )
299 .bind(id)
300 .bind(&dep_ids)
301 .execute(&mut *tx)
302 .await?;
303 }
304
305 log_event_tx(&mut tx, id, auth.agent_id, "created", Some(&title)).await?;
306 tx.commit().await?;
307 fetch_task(pool, auth, &key).await
308}
309
310async fn fetch_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskInfo> {
311 let row: Option<TaskRow> = sqlx::query_as(AssertSqlSafe(format!(
312 "{TASK_SELECT} WHERE t.team_id = $1 AND t.key = $2"
313 )))
314 .bind(auth.team_id)
315 .bind(key)
316 .fetch_optional(pool)
317 .await?;
318 row.map(Into::into)
319 .ok_or_else(|| BusError::not_found(format!("task '{key}'")))
320}
321
322pub async fn get_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskDetail> {
323 let task = fetch_task(pool, auth, key).await?;
324 let rows: Vec<(
325 String,
326 Option<String>,
327 Option<String>,
328 chrono::DateTime<chrono::Utc>,
329 )> = sqlx::query_as(
330 r#"
331 SELECT e.event, a.name, e.detail, e.created_at
332 FROM task_events e
333 LEFT JOIN agents a ON a.id = e.agent_id
334 JOIN tasks t ON t.id = e.task_id
335 WHERE t.team_id = $1 AND t.key = $2
336 ORDER BY e.id
337 "#,
338 )
339 .bind(auth.team_id)
340 .bind(key.trim())
341 .fetch_all(pool)
342 .await?;
343
344 Ok(TaskDetail {
345 task,
346 history: rows
347 .into_iter()
348 .map(|(event, agent, detail, created_at)| TaskEventInfo {
349 event,
350 agent,
351 detail,
352 created_at: ts(created_at),
353 })
354 .collect(),
355 })
356}
357
358pub async fn list_tasks(
361 pool: &PgPool,
362 auth: &AuthCtx,
363 status: Option<String>,
364 mine_only: bool,
365 limit: i64,
366) -> BusResult<TaskList> {
367 let limit = limit.clamp(1, MAX_LIMIT);
368 let status = status
369 .map(|s| s.trim().to_lowercase())
370 .filter(|s| s != "any");
371 if let Some(s) = &status
372 && !["open", "claimed", "done", "cancelled"].contains(&s.as_str())
373 {
374 return Err(BusError::invalid(
375 "status must be one of: open, claimed, done, cancelled, any",
376 ));
377 }
378
379 let rows: Vec<TaskRow> = sqlx::query_as(AssertSqlSafe(format!(
380 r#"{TASK_SELECT}
381 WHERE t.team_id = $1
382 AND ($2::text IS NULL OR ({EFFECTIVE_STATUS}) = $2)
383 -- "mine" means this session's live claim, matching whoami, renew
384 -- and release. Matching the agent alone would report a task your
385 -- core-manager window is holding as this window's own work,
386 -- which is the duplication the session check exists to stop; a
387 -- lapsed lease is nobody's.
388 AND (NOT $3::bool
389 OR (t.claimed_by = $4 AND COALESCE(t.claimed_session, '') = $6
390 AND COALESCE(t.lease_expires_at > now(), true)))
391 ORDER BY
392 CASE ({EFFECTIVE_STATUS}) WHEN 'claimed' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
393 t.updated_at DESC
394 LIMIT $5"#
395 )))
396 .bind(auth.team_id)
397 .bind(status.as_deref())
398 .bind(mine_only)
399 .bind(auth.agent_id)
400 .bind(limit)
401 .bind(&auth.session)
402 .fetch_all(pool)
403 .await?;
404
405 let (open, claimed): (i64, i64) = sqlx::query_as(AssertSqlSafe(format!(
406 r#"
407 SELECT count(*) FILTER (WHERE ({EFFECTIVE_STATUS}) = 'open'),
408 count(*) FILTER (WHERE ({EFFECTIVE_STATUS}) = 'claimed')
409 FROM tasks t WHERE t.team_id = $1
410 "#
411 )))
412 .bind(auth.team_id)
413 .fetch_one(pool)
414 .await?;
415
416 Ok(TaskList {
417 tasks: rows.into_iter().map(Into::into).collect(),
418 open,
419 claimed,
420 })
421}
422
423pub async fn claim_task(
428 pool: &PgPool,
429 auth: &AuthCtx,
430 key: &str,
431 lease_seconds: Option<i64>,
432) -> BusResult<ClaimResult> {
433 let key = normalize_key(key)?;
434 let lease = lease_seconds
435 .unwrap_or(DEFAULT_LEASE_SECS)
436 .clamp(30, MAX_LEASE_SECS);
437
438 let mut tx = pool.begin().await?;
441 super::sessions::guard(&mut tx, auth).await?;
442
443 let updated: Option<(Uuid,)> = sqlx::query_as(
444 r#"
445 UPDATE tasks
446 SET status = 'claimed',
447 claimed_by = $1,
448 claimed_session = $5,
449 claimed_at = now(),
450 lease_expires_at = now() + make_interval(secs => $2),
451 updated_at = now()
452 WHERE team_id = $3
453 AND key = $4
454 AND status IN ('open', 'claimed')
455 -- Re-claiming renews the lease, but only from the session that holds
456 -- it. Matching on the agent alone made the lease void between two
457 -- sessions of one person: both claimed, both were told they had it,
458 -- and both did the work. COALESCE so a claim taken before sessions
459 -- existed counts as the shared session, which is what it was.
460 AND (status = 'open'
461 OR (claimed_by = $1 AND COALESCE(claimed_session, '') = $5)
462 OR lease_expires_at IS NULL
463 OR lease_expires_at < now())
464 AND NOT EXISTS (
465 SELECT 1
466 FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
467 WHERE td.task_id = tasks.id AND d.status NOT IN ('done', 'cancelled')
468 )
469 RETURNING id
470 "#,
471 )
472 .bind(auth.agent_id)
473 .bind(lease as f64)
474 .bind(auth.team_id)
475 .bind(&key)
476 .bind(&auth.session)
477 .fetch_optional(&mut *tx)
478 .await?;
479 if let Some((id,)) = updated {
483 log_event_tx(&mut tx, id, auth.agent_id, "claimed", None).await?;
484 }
485 tx.commit().await?;
486
487 match updated {
488 Some(_) => Ok(ClaimResult {
489 claimed: true,
490 task: Some(fetch_task(pool, auth, &key).await?),
491 reason: None,
492 }),
493 None => {
494 let current = fetch_task(pool, auth, &key).await?;
496 let reason = if current.blocked {
497 format!(
498 "blocked by unfinished dependencies: {}",
499 current.depends_on.join(", ")
500 )
501 } else {
502 match current.status.as_str() {
503 "claimed" => holder_reason(auth, ¤t),
504 other => format!("task is {other}"),
505 }
506 };
507 Ok(ClaimResult {
508 claimed: false,
509 task: Some(current),
510 reason: Some(reason),
511 })
512 }
513 }
514}
515
516fn holder_reason(auth: &AuthCtx, current: &TaskInfo) -> String {
522 let holder = current.claimed_by.as_deref().unwrap_or("?");
523 let until = match current.lease_seconds_remaining {
524 Some(secs) => format!("the lease expires in {secs}s"),
525 None => "the lease expiry is unknown".to_owned(),
526 };
527 let mine = current.claimed_by.as_deref() == Some(auth.agent_name.as_str());
528 let same_session = current.claimed_session.as_deref().unwrap_or("") == auth.session;
529
530 if mine && !same_session {
531 let theirs = current
532 .claimed_session
533 .as_deref()
534 .map(|s| format!("'{s}'"))
535 .unwrap_or_else(|| "shared".to_owned());
536 format!(
537 "claimed by your own {theirs} session, and {until} — continue the \
538 work there, or wait for the lease to expire and claim it here"
539 )
540 } else {
541 let where_ = current
542 .claimed_session
543 .as_deref()
544 .map(|s| format!(" (session '{s}')"))
545 .unwrap_or_default();
546 format!("held by {holder}{where_}, {until}")
547 }
548}
549
550async fn no_claim_here(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusError {
555 match fetch_task(pool, auth, key).await {
556 Ok(current) if current.status == "claimed" => BusError::conflict(format!(
557 "you do not hold the claim on '{key}': it is {}",
558 holder_reason(auth, ¤t)
559 )),
560 Ok(current)
561 if current.lease_expired
562 && current.lapsed_holder.as_deref() == Some(auth.agent_name.as_str()) =>
563 {
564 BusError::conflict(format!(
565 "your lease on '{key}' lapsed, so the task has been open to everyone since. \
566 If you are still working on it, claim it again (and renew before the lease \
567 runs out next time)."
568 ))
569 }
570 Ok(current) => BusError::conflict(format!(
571 "you do not hold an active claim on '{key}' (it is {})",
572 current.status
573 )),
574 Err(_) => BusError::conflict(format!("you do not hold an active claim on '{key}'")),
577 }
578}
579
580pub async fn claim_next_task(
583 pool: &PgPool,
584 auth: &AuthCtx,
585 lease_seconds: Option<i64>,
586) -> BusResult<ClaimResult> {
587 let lease = lease_seconds
588 .unwrap_or(DEFAULT_LEASE_SECS)
589 .clamp(30, MAX_LEASE_SECS);
590
591 let mut tx = pool.begin().await?;
594 super::sessions::guard(&mut tx, auth).await?;
595
596 let picked: Option<(Uuid, String)> = sqlx::query_as(
597 r#"
598 WITH candidate AS (
599 SELECT id
600 FROM tasks
601 WHERE team_id = $1
602 AND (status = 'open'
603 OR (status = 'claimed' AND lease_expires_at < now()))
604 AND NOT EXISTS (
605 SELECT 1
606 FROM task_deps td JOIN tasks d ON d.id = td.blocked_by_task_id
607 WHERE td.task_id = tasks.id AND d.status NOT IN ('done', 'cancelled')
608 )
609 ORDER BY created_at
610 LIMIT 1
611 FOR UPDATE SKIP LOCKED
612 )
613 UPDATE tasks t
614 SET status = 'claimed',
615 claimed_by = $2,
616 claimed_session = $4,
617 claimed_at = now(),
618 lease_expires_at = now() + make_interval(secs => $3),
619 updated_at = now()
620 FROM candidate c
621 WHERE t.id = c.id
622 RETURNING t.id, t.key
623 "#,
624 )
625 .bind(auth.team_id)
626 .bind(auth.agent_id)
627 .bind(lease as f64)
628 .bind(&auth.session)
629 .fetch_optional(&mut *tx)
630 .await?;
631
632 match picked {
633 Some((id, key)) => {
634 log_event_tx(
635 &mut tx,
636 id,
637 auth.agent_id,
638 "claimed",
639 Some("via claim_next_task"),
640 )
641 .await?;
642 tx.commit().await?;
643 Ok(ClaimResult {
644 claimed: true,
645 task: Some(fetch_task(pool, auth, &key).await?),
646 reason: None,
647 })
648 }
649 None => {
650 tx.rollback().await?;
651 Ok(ClaimResult {
652 claimed: false,
653 task: None,
654 reason: Some("no unclaimed task available".into()),
655 })
656 }
657 }
658}
659
660pub async fn renew_lease(
661 pool: &PgPool,
662 auth: &AuthCtx,
663 key: &str,
664 lease_seconds: Option<i64>,
665) -> BusResult<TaskInfo> {
666 let key = normalize_key(key)?;
667 let lease = lease_seconds
668 .unwrap_or(DEFAULT_LEASE_SECS)
669 .clamp(30, MAX_LEASE_SECS);
670
671 let mut tx = pool.begin().await?;
674 super::sessions::guard(&mut tx, auth).await?;
675 let updated: Option<(Uuid,)> = sqlx::query_as(
676 r#"
677 UPDATE tasks
678 SET lease_expires_at = now() + make_interval(secs => $1),
679 updated_at = now()
680 WHERE team_id = $2 AND key = $3 AND claimed_by = $4 AND status = 'claimed'
681 AND COALESCE(claimed_session, '') = $5
682 -- A lease that lapsed is not renewed, it is claimed again: the task
683 -- has been open to everyone since, and ownership is re-established
684 -- through the same door as everyone else's.
685 AND lease_expires_at > now()
686 RETURNING id
687 "#,
688 )
689 .bind(lease as f64)
690 .bind(auth.team_id)
691 .bind(&key)
692 .bind(auth.agent_id)
693 .bind(&auth.session)
694 .fetch_optional(&mut *tx)
695 .await?;
696
697 if updated.is_none() {
698 tx.rollback().await?;
699 return Err(no_claim_here(pool, auth, &key).await);
700 }
701 tx.commit().await?;
702 fetch_task(pool, auth, &key).await
703}
704
705pub async fn release_task(pool: &PgPool, auth: &AuthCtx, key: &str) -> BusResult<TaskInfo> {
706 let key = normalize_key(key)?;
707 let mut tx = pool.begin().await?;
710 super::sessions::guard(&mut tx, auth).await?;
711 let updated: Option<(Uuid,)> = sqlx::query_as(
712 r#"
713 UPDATE tasks
714 SET status = 'open',
715 claimed_by = NULL,
716 -- Cleared with the holder it belongs to. Leaving it behind made a
717 -- released task report claimed_by null next to a session name,
718 -- which reads as an active holder that does not exist.
719 claimed_session = NULL,
720 claimed_at = NULL,
721 lease_expires_at = NULL,
722 updated_at = now()
723 WHERE team_id = $1 AND key = $2 AND claimed_by = $3 AND status = 'claimed'
724 AND COALESCE(claimed_session, '') = $4
725 RETURNING id
726 "#,
727 )
728 .bind(auth.team_id)
729 .bind(&key)
730 .bind(auth.agent_id)
731 .bind(&auth.session)
732 .fetch_optional(&mut *tx)
733 .await?;
734
735 match updated {
736 Some((id,)) => {
737 log_event_tx(&mut tx, id, auth.agent_id, "released", None).await?;
738 tx.commit().await?;
739 fetch_task(pool, auth, &key).await
740 }
741 None => {
742 tx.rollback().await?;
743 Err(no_claim_here(pool, auth, &key).await)
744 }
745 }
746}
747
748pub async fn complete_task(
749 pool: &PgPool,
750 auth: &AuthCtx,
751 key: &str,
752 result: Option<String>,
753) -> BusResult<TaskInfo> {
754 let key = normalize_key(key)?;
755 let result = match result.as_deref() {
756 Some(r) => Some(super::check_text("task result", r, MAX_RESULT_BYTES)?),
757 None => None,
758 };
759 let mut tx = pool.begin().await?;
765 super::sessions::guard(&mut tx, auth).await?;
766
767 let updated: Option<(Uuid,)> = sqlx::query_as(
776 r#"
777 UPDATE tasks
778 SET status = 'done',
779 result = $1,
780 lease_expires_at = NULL,
781 updated_at = now()
782 WHERE team_id = $2 AND key = $3 AND status IN ('open', 'claimed')
783 AND (
784 claimed_by IS NULL
785 OR lease_expires_at IS NULL
786 OR lease_expires_at <= now()
787 OR (claimed_by = $4 AND COALESCE(claimed_session, '') = $5)
788 )
789 RETURNING id
790 "#,
791 )
792 .bind(result.as_deref())
793 .bind(auth.team_id)
794 .bind(&key)
795 .bind(auth.agent_id)
796 .bind(&auth.session)
797 .fetch_optional(&mut *tx)
798 .await?;
799
800 match updated {
801 Some((id,)) => {
802 log_event_tx(&mut tx, id, auth.agent_id, "completed", result.as_deref()).await?;
803 tx.commit().await?;
804 fetch_task(pool, auth, &key).await
805 }
806 None => {
807 tx.rollback().await?;
808 let current = fetch_task(pool, auth, &key).await?;
809 if current.status == "claimed" {
810 return Err(BusError::conflict(format!(
813 "task '{key}' is {}. Completing it would end work somebody else is \
814 doing; ask them, or wait for the lease to expire.",
815 holder_reason(auth, ¤t)
816 )));
817 }
818 Err(BusError::conflict(format!(
819 "task '{key}' is already {}",
820 current.status
821 )))
822 }
823 }
824}