Skip to main content

ai_crew_sync/store/
tasks.rs

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; // 15 minutes
11const MAX_LEASE_SECS: i64 = 86_400;
12const MAX_LIMIT: i64 = 200;
13
14/// A title is a handle a human recognises in a list, not a description.
15const MAX_TITLE_BYTES: usize = 512;
16/// Descriptions and results carry context, but a task is an index entry, not
17/// a document: 64 KiB is generous for both and deliberately smaller than the
18/// 1 MiB a message body allows. A payload larger than this belongs in an
19/// attachment on the task.
20const MAX_DESCRIPTION_BYTES: usize = 64 * 1024;
21const MAX_RESULT_BYTES: usize = 64 * 1024;
22/// A pipeline with more upstream tasks than this wants restructuring, and an
23/// unbounded list is one INSERT per entry.
24const 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    /// Decided by the database clock, the same one the writers use, so a
39    /// read never disagrees with the claim that follows it.
40    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        // A lapsed lease is an open task. The writers have always treated it
52        // so (anyone may claim it); a read that still said "claimed by marta"
53        // sent the reader to wait for a holder that no longer exists, or to
54        // trust a list of open tasks that left this one out. Who let it lapse
55        // stays visible, in its own field.
56        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        // Seconds, not a timestamp: an error that says "expires in 240s" tells
81        // the caller whether waiting is an option; an RFC 3339 instant makes it
82        // do the arithmetic first.
83        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            // '' and NULL both mean the shared session: NULL is a claim taken
96            // before sessions existed, by a client that sent no header.
97            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
155/// The status a reader is told, which is the one the writers act on: a
156/// claim whose lease lapsed is open, whatever the row still says.
157const EFFECTIVE_STATUS: &str =
158    "CASE WHEN t.status = 'claimed' AND t.lease_expires_at <= now() THEN 'open' ELSE t.status END";
159
160/// A task's history entry, always on the transaction that made the change.
161/// One that commits separately can outlive a rolled-back mutation, or be
162/// lost by one that committed and then answered the caller with an error.
163async 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
191// ------------------------------------------------------------------ create --
192
193pub struct CreateInput {
194    pub key: String,
195    pub title: String,
196    pub description: Option<String>,
197    pub metadata: Option<serde_json::Value>,
198    /// Keys of existing tasks this one depends on. The task cannot be claimed
199    /// until every dependency is done or cancelled.
200    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    // The task, its dependencies and its `created` event commit together.
238    // Written one by one, the task was committed, open and dependency-free
239    // until its `task_deps` rows landed, so a claim in between took work
240    // whose upstream was unfinished, and a failure in between left it that
241    // way for good (#179). The NOTIFY a waiter wakes on fires at this commit
242    // too, so nobody is woken into the gap.
243    let mut tx = pool.begin().await?;
244
245    // The existence check is the insert itself: a concurrent create of the
246    // same key waits here for the other to commit and then gets the same
247    // conflict, not a unique violation.
248    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    // Resolved inside the same transaction, so a missing key rolls the task
271    // back with it: a typo fails the whole call and leaves nothing behind.
272    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    // Dependencies only point at pre-existing tasks and this task is brand
294    // new, so no cycle is possible by construction.
295    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
358// -------------------------------------------------------------------- list --
359
360pub 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
423// ------------------------------------------------------------------- claim --
424
425/// Claim a specific task. Succeeds when the task is open, when its lease has
426/// already expired, or when the caller already holds it (idempotent re-claim).
427pub 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    // Serialised with the mutation: a request already queued when its window
439    // was resumed must not commit into the session that replaced it.
440    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    // The history entry commits with the claim. Written after the commit, a
480    // failed write answered "database error" for a claim that stood, so the
481    // caller held a lease it had been told it did not get.
482    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            // Distinguish "does not exist" from "someone else holds it".
495            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, &current),
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
516/// Why a claim was refused, written for the model that has to act on it.
517///
518/// The case worth spelling out is your *own* other session: "held by joaquin"
519/// reads as a bug when you are joaquin, and the fix — go to that window, or
520/// wait for the lease — is not guessable from the name alone.
521fn 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
550/// Refusal for renew/release when this session does not hold the claim.
551///
552/// "You do not hold a claim" is true but useless when your other window does:
553/// look up who actually has it and say so.
554async 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, &current)
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        // The task is gone or not ours to see; the original message is still
575        // the honest answer.
576        Err(_) => BusError::conflict(format!("you do not hold an active claim on '{key}'")),
577    }
578}
579
580/// Claim the oldest available task. Uses `SKIP LOCKED` so several agents can
581/// call this concurrently without handing the same task to two of them.
582pub 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    // Fenced like the named claim: a replaced connection does not pick up
592    // work as the window that replaced it.
593    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    // Same fence as claiming and completing: a stale connection does not
672    // extend the replacement window's lease.
673    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    // Fenced like the rest: a connection that has been replaced does not
708    // put back the claim its replacement is holding.
709    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    // Serialised with the mutation, like claim_task: a request already
760    // queued when its window was resumed must not finish the work of the
761    // session that replaced it. The holder predicate below cannot catch
762    // that on its own — a resumed window has the same agent and the same
763    // label, and only the epoch tells them apart.
764    let mut tx = pool.begin().await?;
765    super::sessions::guard(&mut tx, auth).await?;
766
767    // A claim has to mean something on the way out too. Completing is the
768    // one write that ended someone else's work: anyone on the team could
769    // mark a task done while its holder was still doing it, and the holder
770    // found out when its next renew was refused with "it is done".
771    //
772    // The lease decides, exactly as it does for claiming. An unclaimed task
773    // is anyone's to finish; a claim whose lease has expired is fair game,
774    // which is what a lease is for; a live claim belongs to its holder.
775    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                // Somebody is working on it right now. Say who, and for how
811                // much longer, so the caller can do something about it.
812                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, &current)
816                )));
817            }
818            Err(BusError::conflict(format!(
819                "task '{key}' is already {}",
820                current.status
821            )))
822        }
823    }
824}