Skip to main content

agentmux_srv/server/
work_queue.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4//! Muxqueue HTTP surface — slice 2 of
5//! `docs/reports/REPORT_UNIVERSAL_AGENT_WORK_QUEUE_2026_09_01.md`.
6//!
7//!   POST   /agentmux/work                 — enqueue
8//!   POST   /agentmux/work/claim           — claim the next eligible item
9//!   POST   /agentmux/work/:id/heartbeat   — extend a lease
10//!   POST   /agentmux/work/:id/complete    — finish
11//!   POST   /agentmux/work/:id/release     — give back
12//!   GET    /agentmux/work                 — list
13//!   DELETE /agentmux/work/:id             — cancel
14//!
15//! Auth-gated by the same middleware as the cron and reactive routes.
16//!
17//! **Store choice:** every handler uses `state.identity_store` — the
18//! always-global store — NOT `state.shared_store`, which is what the cron
19//! handlers next door still use. That difference is deliberate, not an
20//! inconsistency to tidy up later: a per-channel queue would only ever mean
21//! "any agent in this channel can pick it up". Cron's own placement is a known
22//! unmigrated case (`SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md` step 1b).
23
24use axum::extract::{Path, Query, State};
25use axum::http::StatusCode;
26use axum::response::Json;
27use serde::Deserialize;
28use serde_json::{json, Value};
29use uuid::Uuid;
30
31use crate::backend::storage::work_queue::{ClaimFilter, HolderMatch, WorkItem};
32
33use super::AppState;
34
35/// Default lease granted by a claim, and the window a heartbeat extends by.
36/// Deliberately short relative to a long agent turn: a claimant is expected to
37/// heartbeat, and a crashed one should return to the pool in about a minute
38/// rather than blocking the item for an hour.
39const DEFAULT_LEASE_MS: i64 = 120_000;
40
41use agentmux_common::time::now_ms;
42
43pub(crate) fn publish_changed(state: &AppState) {
44    state.broker.publish_event::<crate::backend::mps_events::WorkQueueChanged>(&(), vec![], 0);
45}
46
47fn err(code: StatusCode, msg: impl Into<String>) -> (StatusCode, Json<Value>) {
48    (code, Json(json!({ "error": msg.into() })))
49}
50
51#[derive(Debug, Deserialize)]
52pub(super) struct EnqueueRequest {
53    pub title: String,
54    pub payload: String,
55    #[serde(default)]
56    pub kind: String,
57    #[serde(default)]
58    pub target_agent: String,
59    /// Identity M3: the target's UID, resolved at the MCP boundary (spec
60    /// §5) and CARRIED here. When present it is stored as-is; when absent
61    /// the server resolves `target_agent` itself as a counted fallback.
62    #[serde(default)]
63    pub target_agent_uid: String,
64    #[serde(default)]
65    pub target_group: String,
66    #[serde(default)]
67    pub priority: i64,
68    #[serde(default)]
69    pub created_by: String,
70    #[serde(default)]
71    pub max_attempts: Option<i64>,
72    /// ms epoch. Not claimable before this.
73    #[serde(default)]
74    pub not_before: Option<i64>,
75}
76
77pub(super) async fn handle_work_enqueue(
78    State(state): State<AppState>,
79    caller: Option<axum::Extension<super::caller::Caller>>,
80    Json(req): Json<EnqueueRequest>,
81) -> (StatusCode, Json<Value>) {
82    super::actor::check_actor(
83        &state,
84        caller.as_deref(),
85        super::actor::ActorSite::WorkEnqueue,
86        Some(&req.created_by),
87    );
88    if req.title.trim().is_empty() {
89        return err(StatusCode::BAD_REQUEST, "title is required");
90    }
91    if req.payload.trim().is_empty() {
92        return err(StatusCode::BAD_REQUEST, "payload is required");
93    }
94    // An item targeted at both a specific agent AND a group is ambiguous —
95    // reject rather than silently letting one win, which would look like the
96    // queue quietly ignored half the caller's intent.
97    if !req.target_agent.is_empty() && !req.target_group.is_empty() {
98        return err(
99            StatusCode::BAD_REQUEST,
100            "specify target_agent or target_group, not both",
101        );
102    }
103
104    // Identity M3: the target's UID is stored only if the caller CARRIED
105    // one — resolved at the boundary where the name was typed (the MCP, via
106    // `POST /agentmux/agents/resolve`, spec §5). This handler never turns a
107    // name into an identity itself (§2 rule 1): since M3 the UID decides who
108    // may claim the item, and a server-side guess — the host-wide slug
109    // registry, a template id — would bind it to whichever agent the guess
110    // found, in any channel (review on #3563). A named target with no
111    // carried UID keeps the name path until M5, counted (§9.2).
112    let target_agent_uid = req.target_agent_uid.trim().to_string();
113    if target_agent_uid.is_empty() && !req.target_agent.trim().is_empty() {
114        crate::backend::agent_resolve::record_uid_fallback("work_enqueue.uid_not_carried");
115    }
116
117    let now = now_ms();
118    let item = WorkItem {
119        id: Uuid::new_v4().to_string(),
120        title: req.title,
121        payload: req.payload,
122        kind: req.kind,
123        target_agent: req.target_agent,
124        target_group: req.target_group,
125        priority: req.priority,
126        state: String::new(), // set by the store
127        claimed_by: String::new(),
128        target_agent_uid,
129        claimed_by_uid: String::new(),
130        claim_expires: None,
131        attempts: 0,
132        max_attempts: req.max_attempts.unwrap_or(3),
133        created_by: req.created_by,
134        // Identity M4c-1 (§6.5.9): the enqueuer's UID is the request's
135        // `Caller` — its token — never a body field; `""` when Unattributed.
136        created_by_uid: super::caller::attributed_uid(caller.as_deref()),
137        created_at: now,
138        updated_at: now,
139        not_before: req.not_before,
140        result: String::new(),
141    };
142
143    match state.identity_store.work_queue_enqueue(&item) {
144        Ok(()) => {
145            publish_changed(&state);
146            (StatusCode::OK, Json(json!({ "id": item.id })))
147        }
148        Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("enqueue failed: {e}")),
149    }
150}
151
152#[derive(Debug, Deserialize)]
153pub(super) struct ClaimRequest {
154    /// The claiming agent's id.
155    pub agent_id: String,
156    /// The claiming agent's UID, carried from its own `AGENTMUX_AGENT_UID`
157    /// (identity M1a). Optional: a pre-M1a spawn, a continuation resume, an
158    /// App Server agent or a quick-launch pane has none. Since M3 it decides
159    /// eligibility for UID-addressed items, and is written to
160    /// `claimed_by_uid` — since M4c-1 only when the claim is Unattributed;
161    /// an attributed claim uses its token's UID instead. It is an assertion
162    /// like `agent_id` is — recorded, not trusted.
163    #[serde(default)]
164    pub agent_uid: String,
165    /// The claimer's block, carried from its `AGENTMUX_BLOCKID`. When no UID
166    /// was carried, the UID is taken from the row shown on this block — the
167    /// identity the block already has, never one derived from `agent_id`.
168    #[serde(default)]
169    pub block_id: String,
170    #[serde(default)]
171    pub kind: Option<String>,
172    /// Group ids this agent belongs to. Resolved by the CALLER — group
173    /// membership lives in the per-channel store, which the queue module
174    /// deliberately does not reach into.
175    #[serde(default)]
176    pub groups: Vec<String>,
177    #[serde(default)]
178    pub lease_ms: Option<i64>,
179}
180
181/// An Unattributed claimer's UID: carried, else its block's row, else `""`
182/// (name path only). An attributed claim uses its `Caller`'s UID instead —
183/// see `handle_work_claim`.
184async fn claimer_uid(state: &AppState, req: &ClaimRequest) -> String {
185    let carried = req.agent_uid.trim();
186    if !carried.is_empty() {
187        return carried.to_string();
188    }
189    crate::backend::agent_resolve::record_uid_fallback("work_claim.uid_not_carried");
190    let block_id = req.block_id.trim().to_string();
191    if block_id.is_empty() {
192        return String::new();
193    }
194    // A synchronous store read, so off the async worker (incident #1782).
195    let mstore = state.mstore.clone();
196    tokio::task::spawn_blocking(move || {
197        crate::backend::agent_resolve::uid_for_block(&mstore, &block_id)
198    })
199    .await
200    .ok()
201    .flatten()
202    .unwrap_or_default()
203}
204
205pub(super) async fn handle_work_claim(
206    State(state): State<AppState>,
207    caller: Option<axum::Extension<super::caller::Caller>>,
208    Json(req): Json<ClaimRequest>,
209) -> (StatusCode, Json<Value>) {
210    super::actor::check_actor(
211        &state,
212        caller.as_deref(),
213        super::actor::ActorSite::WorkClaim,
214        Some(&req.agent_id),
215    );
216    super::actor::check_carried_uid(caller.as_deref(), &req.agent_uid);
217    if req.agent_id.trim().is_empty() {
218        return err(StatusCode::BAD_REQUEST, "agent_id is required");
219    }
220    let now = now_ms();
221
222    // Reap BEFORE claiming, so an expired lease is recovered by the very next
223    // agent that asks for work. This is why there is no background reaper
224    // task: the only moment a stale claim actually matters is when someone
225    // wants the item, and reaping here makes that self-healing. The predicate
226    // is indexed (idx_ids_work_queue_claim_expires) and no-ops when nothing
227    // has expired.
228    if let Err(e) = state.identity_store.work_queue_reap(now) {
229        // Not fatal — a claim against a not-yet-reaped queue is still correct,
230        // just possibly emptier than it could be. Log and continue rather than
231        // failing the caller's request.
232        tracing::warn!(target: "workqueue", error = %e, "reap before claim failed");
233    }
234
235    // Identity M4c-1 (§6.5.9): an attributed claim's UID is the request's
236    // `Caller` — its token. Only an Unattributed claim falls back to M3's
237    // path: the UID it CARRIED, else the UID of the row on its own block
238    // (the presence path's resolution, §0.1) — never one derived from its
239    // name (§2 rule 1). Deriving it from the name reached the host-wide slug
240    // registry, so a claimer with no UID in its env could be locked out of
241    // its own UID-addressed work, or handed another channel's (review on
242    // #3563). Each fallback is counted (§9.2): a non-zero count names a
243    // launch path that does not carry its UID. This one UID is both the
244    // `claimed_by_uid` written and the M3 eligibility match
245    // (`target_agent_uid = ?5`) — the one reader change M4c-1 makes; the
246    // carried UID and the token are set together from one row, and a
247    // disagreement is counted just above (`check_carried_uid`).
248    let agent_uid = match caller.as_deref().and_then(super::caller::Caller::uid) {
249        Some(uid) => uid.to_string(),
250        None => claimer_uid(&state, &req).await,
251    };
252
253    let filter = ClaimFilter {
254        kind: req.kind.filter(|k| !k.is_empty()),
255        agent_id: req.agent_id.clone(),
256        agent_uid,
257        groups: req.groups.clone(),
258    };
259    let lease = req.lease_ms.filter(|&n| n > 0).unwrap_or(DEFAULT_LEASE_MS);
260
261    match state.identity_store.work_queue_claim(&filter, now, lease) {
262        Ok(Some(item)) => {
263            publish_changed(&state);
264            // `attempt` is echoed at the top level as well as inside `item`
265            // because every subsequent call (heartbeat/complete/release) MUST
266            // pass it back as a fence — see work_queue.rs's ABA note. Making
267            // it prominent here is the difference between a caller that
268            // fences correctly and one that discovers the requirement from a
269            // 404-shaped "not the holder" response.
270            (
271                StatusCode::OK,
272                Json(json!({ "claimed": true, "attempt": item.attempts, "item": item })),
273            )
274        }
275        Ok(None) => (StatusCode::OK, Json(json!({ "claimed": false }))),
276        Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("claim failed: {e}")),
277    }
278}
279
280#[derive(Debug, Deserialize)]
281pub(super) struct HolderRequest {
282    pub agent_id: String,
283    /// Fence token — the `attempt` returned by the claim this call belongs to.
284    pub attempt: i64,
285    #[serde(default)]
286    pub result: String,
287    #[serde(default)]
288    pub lease_ms: Option<i64>,
289}
290
291/// The caller's UID for a holder check (identity M4c-2, spec §6.5.9): the
292/// token's, or `""` when Unattributed — the store then matches by name.
293fn holder_uid(caller: Option<&super::caller::Caller>) -> &str {
294    caller.and_then(super::caller::Caller::uid).unwrap_or("")
295}
296
297/// Count a holder check that matched by name — the row or the caller had no
298/// UID. Each is a holder that M5, which drops the name path, would refuse.
299fn count_holder_match(matched: Option<HolderMatch>) {
300    if matched == Some(HolderMatch::Name) {
301        crate::backend::agent_resolve::record_uid_fallback("m4c.holder_by_name");
302    }
303}
304
305/// Shared shape for the three holder-only transitions. No match from the
306/// store means the caller is not the current holder OR its fence is stale — both are
307/// CONFLICT, not NOT_FOUND: the row usually still exists, it just moved on
308/// without this caller.
309fn holder_result(ok: bool, state: &AppState) -> (StatusCode, Json<Value>) {
310    if ok {
311        publish_changed(state);
312        (StatusCode::OK, Json(json!({ "ok": true })))
313    } else {
314        (
315            StatusCode::CONFLICT,
316            Json(json!({
317                "ok": false,
318                "error": "not the current holder, or this claim has been superseded \
319                          (expired and reclaimed) — re-claim before continuing"
320            })),
321        )
322    }
323}
324
325pub(super) async fn handle_work_heartbeat(
326    State(state): State<AppState>,
327    Path(id): Path<String>,
328    caller: Option<axum::Extension<super::caller::Caller>>,
329    Json(req): Json<HolderRequest>,
330) -> (StatusCode, Json<Value>) {
331    super::actor::check_actor(
332        &state,
333        caller.as_deref(),
334        super::actor::ActorSite::WorkHeartbeat,
335        Some(&req.agent_id),
336    );
337    let lease = req.lease_ms.filter(|&n| n > 0).unwrap_or(DEFAULT_LEASE_MS);
338    match state
339        .identity_store
340        .work_queue_heartbeat(&id, &req.agent_id, holder_uid(caller.as_deref()), req.attempt, now_ms(), lease)
341    {
342        Ok(matched) => {
343            count_holder_match(matched);
344            holder_result(matched.is_some(), &state)
345        }
346        Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("heartbeat failed: {e}")),
347    }
348}
349
350pub(super) async fn handle_work_complete(
351    State(state): State<AppState>,
352    Path(id): Path<String>,
353    caller: Option<axum::Extension<super::caller::Caller>>,
354    Json(req): Json<HolderRequest>,
355) -> (StatusCode, Json<Value>) {
356    super::actor::check_actor(
357        &state,
358        caller.as_deref(),
359        super::actor::ActorSite::WorkComplete,
360        Some(&req.agent_id),
361    );
362    match state
363        .identity_store
364        .work_queue_complete(&id, &req.agent_id, holder_uid(caller.as_deref()), req.attempt, &req.result, now_ms())
365    {
366        Ok(matched) => {
367            count_holder_match(matched);
368            holder_result(matched.is_some(), &state)
369        }
370        Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("complete failed: {e}")),
371    }
372}
373
374/// Returns the item's RESULTING state alongside `ok` (Codex P2 on PR #2902).
375/// A release on the item's final allowed attempt parks it as `failed` rather
376/// than reopening it, so a bare `{ok:true}` would let the caller report "handed
377/// back to the queue" for an item nobody can ever claim again.
378pub(super) async fn handle_work_release(
379    State(state): State<AppState>,
380    Path(id): Path<String>,
381    caller: Option<axum::Extension<super::caller::Caller>>,
382    Json(req): Json<HolderRequest>,
383) -> (StatusCode, Json<Value>) {
384    super::actor::check_actor(
385        &state,
386        caller.as_deref(),
387        super::actor::ActorSite::WorkRelease,
388        Some(&req.agent_id),
389    );
390    match state
391        .identity_store
392        .work_queue_release(&id, &req.agent_id, holder_uid(caller.as_deref()), req.attempt, &req.result, now_ms())
393    {
394        Ok(Some(matched)) => {
395            count_holder_match(Some(matched));
396            publish_changed(&state);
397            // Read back rather than infer: the store owns the
398            // attempts-vs-max_attempts decision, and duplicating that rule
399            // here is exactly how the two drift.
400            let resulting = state
401                .identity_store
402                .work_queue_get(&id)
403                .ok()
404                .flatten()
405                .map(|i| i.state)
406                .unwrap_or_default();
407            (StatusCode::OK, Json(json!({ "ok": true, "state": resulting })))
408        }
409        Ok(None) => holder_result(false, &state),
410        Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("release failed: {e}")),
411    }
412}
413
414#[derive(Debug, Deserialize)]
415pub(super) struct ListQuery {
416    #[serde(default)]
417    pub state: String,
418    #[serde(default)]
419    pub limit: Option<usize>,
420}
421
422pub(super) async fn handle_work_list(
423    State(state): State<AppState>,
424    Query(q): Query<ListQuery>,
425) -> (StatusCode, Json<Value>) {
426    let limit = q.limit.unwrap_or(50).clamp(1, 500);
427    match state.identity_store.work_queue_list(&q.state, limit) {
428        Ok(items) => (StatusCode::OK, Json(json!({ "items": items }))),
429        Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("list failed: {e}")),
430    }
431}
432
433#[derive(Debug, Default, Deserialize)]
434pub(super) struct CancelRequest {
435    #[serde(default)]
436    pub reason: String,
437}
438
439/// The body is OPTIONAL (reagent P1 on PR #2902). The sibling DELETE route in
440/// the same router — `DELETE /agentmux/cron/:id` — takes no body at all, so a
441/// caller following that adjacent, established convention would otherwise get
442/// a 400/415 from axum's `Json` extractor rather than the intended
443/// "cancel with an empty reason". `CancelRequest::reason` is already
444/// `#[serde(default)]`; making the whole body optional is what actually lets
445/// that default be reached.
446pub(super) async fn handle_work_cancel(
447    State(state): State<AppState>,
448    Path(id): Path<String>,
449    body: Option<Json<CancelRequest>>,
450) -> (StatusCode, Json<Value>) {
451    let req = body.map(|Json(r)| r).unwrap_or_default();
452    match state.identity_store.work_queue_cancel(&id, &req.reason, now_ms()) {
453        Ok(true) => {
454            publish_changed(&state);
455            (StatusCode::OK, Json(json!({ "ok": true })))
456        }
457        // Distinct from the holder-conflict case: cancel is deliberately NOT
458        // holder-gated (it is an operator action), so a `false` here means the
459        // item is already terminal or absent, not that the caller lacks a
460        // claim.
461        Ok(false) => (
462            StatusCode::CONFLICT,
463            Json(json!({ "ok": false, "error": "no open or claimed item with that id" })),
464        ),
465        Err(e) => err(StatusCode::INTERNAL_SERVER_ERROR, format!("cancel failed: {e}")),
466    }
467}