Skip to main content

agentmux_mcp/tools/
work.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The shared work queue (Work*).
5
6use super::*;
7
8pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
9    let ToolCtx { local_url, auth_key, block_id, client, .. } = *cx;
10    match name {
11        // ── Muxqueue ────────────────────────────────────────────────────
12        // All six post/get to /agentmux/work* on the local srv, same auth
13        // header as the cron arms below.
14        "WorkEnqueue" => {
15            require_agent_env(local_url, auth_key, block_id)?;
16            let title = arguments.get("title").and_then(|v| v.as_str()).filter(|s| !s.is_empty())
17                .ok_or_else(|| anyhow::anyhow!("missing required parameter: title"))?;
18            let payload = arguments.get("payload").and_then(|v| v.as_str()).filter(|s| !s.is_empty())
19                .ok_or_else(|| anyhow::anyhow!("missing required parameter: payload"))?;
20            let self_id = std::env::var("AGENTMUX_AGENT_ID").ok().filter(|s| !s.is_empty()).unwrap_or_default();
21            let target_agent = arguments.get("target_agent").and_then(|v| v.as_str()).unwrap_or("");
22            // Identity M3: resolve the typed target NOW, at the boundary, and
23            // carry its uid; an ambiguous name is handed back with candidates.
24            let target_agent_uid = if target_agent.is_empty() {
25                None
26            } else {
27                resolve_agent_name_at_boundary(&client, local_url, auth_key, target_agent).await?
28            };
29
30            let url = format!("{}/agentmux/work", local_url.trim_end_matches('/'));
31            let body = serde_json::json!({
32                "title": title,
33                "payload": payload,
34                "kind": arguments.get("kind").and_then(|v| v.as_str()).unwrap_or(""),
35                "target_agent": target_agent,
36                "target_agent_uid": target_agent_uid.unwrap_or_default(),
37                "target_group": arguments.get("target_group").and_then(|v| v.as_str()).unwrap_or(""),
38                "priority": arguments.get("priority").and_then(|v| v.as_i64()).unwrap_or(0),
39                "not_before": arguments.get("not_before").and_then(|v| v.as_i64()),
40                "max_attempts": arguments.get("max_attempts").and_then(|v| v.as_i64()).filter(|&n| n > 0),
41                "created_by": self_id,
42            });
43            let resp = client.post(&url).header(AUTH_KEY_HEADER, auth_key).json(&body).send().await
44                .map_err(|e| anyhow::anyhow!("work enqueue request failed: {e}"))?;
45            let status = resp.status();
46            let text = resp.text().await.unwrap_or_default();
47            if !status.is_success() {
48                anyhow::bail!("WorkEnqueue failed: HTTP {status} — {text}");
49            }
50            let id = serde_json::from_str::<Value>(&text).ok()
51                .and_then(|v| v.get("id").and_then(|x| x.as_str()).map(|s| s.to_string()))
52                .unwrap_or_default();
53            Ok(format!("Enqueued work item {id}: {title}"))
54        }
55        "WorkClaim" => {
56            require_agent_env(local_url, auth_key, block_id)?;
57            let self_id = std::env::var("AGENTMUX_AGENT_ID").ok().filter(|s| !s.is_empty())
58                .ok_or_else(|| anyhow::anyhow!("AGENTMUX_AGENT_ID is not set — cannot claim work without an agent identity"))?;
59
60            // Identity M1b/M3: carry this agent's UID (AGENTMUX_AGENT_UID, set
61            // by srv at spawn since M1a). It is the only way into a
62            // UID-addressed item. Empty when absent (pre-M1a spawn,
63            // continuation resume, quick-launch pane) — srv then takes the
64            // UID from this block's row, and counts the fallback.
65            let self_uid = std::env::var("AGENTMUX_AGENT_UID").ok().filter(|s| !s.is_empty()).unwrap_or_default();
66
67            let url = format!("{}/agentmux/work/claim", local_url.trim_end_matches('/'));
68            let body = serde_json::json!({
69                "agent_id": self_id,
70                "agent_uid": self_uid,
71                // Identity M3: when no UID is carried, srv takes it from the
72                // row on this block rather than deriving one from the name.
73                "block_id": block_id,
74                "kind": arguments.get("kind").and_then(|v| v.as_str()),
75                "lease_ms": arguments.get("lease_ms").and_then(|v| v.as_i64()).filter(|&n| n > 0),
76                // Group membership is resolved server-side-of-this-call by the
77                // caller in the general design; the MCP path has no cheap way
78                // to know this agent's groups yet, so it claims only untargeted
79                // and self-targeted work. Group-targeted claiming arrives with
80                // the group lookup, not before — better to under-claim than to
81                // silently ignore a group restriction.
82                "groups": [],
83            });
84            let resp = client.post(&url).header(AUTH_KEY_HEADER, auth_key).json(&body).send().await
85                .map_err(|e| anyhow::anyhow!("work claim request failed: {e}"))?;
86            let status = resp.status();
87            let text = resp.text().await.unwrap_or_default();
88            if !status.is_success() {
89                anyhow::bail!("WorkClaim failed: HTTP {status} — {text}");
90            }
91            let v: Value = serde_json::from_str(&text).unwrap_or(serde_json::json!({}));
92            if !v.get("claimed").and_then(|x| x.as_bool()).unwrap_or(false) {
93                return Ok("No work available on the queue right now.".to_string());
94            }
95            let attempt = v.get("attempt").and_then(|x| x.as_i64()).unwrap_or(0);
96            let item = v.get("item").cloned().unwrap_or(serde_json::json!({}));
97            let id = item.get("id").and_then(|x| x.as_str()).unwrap_or("");
98            let title = item.get("title").and_then(|x| x.as_str()).unwrap_or("");
99            let payload = item.get("payload").and_then(|x| x.as_str()).unwrap_or("");
100            // Carry forward why a PREVIOUS holder handed this back (Codex P2 on
101            // PR #2902). WorkRelease's description promises the next claimant
102            // sees the reason; dropping it here broke that promise and let
103            // agents re-hit a known blocker with no warning. `attempt > 1` is
104            // exactly "someone has held this before me".
105            let prior = item.get("result").and_then(|x| x.as_str()).unwrap_or("");
106            let handback = if attempt > 1 && !prior.is_empty() {
107                format!(
108                    "\n\nNOTE — a previous agent held this and handed it back \
109                     (attempt {} of this item). Their reason: {prior}\n\
110                     Read that before repeating their approach.",
111                    attempt - 1
112                )
113            } else {
114                String::new()
115            };
116            Ok(format!(
117                "Claimed work item {id} (attempt {attempt}) — pass attempt={attempt} to \
118                 WorkHeartbeat/WorkComplete/WorkRelease for this item.\n\n\
119                 Title: {title}\n\n{payload}{handback}"
120            ))
121        }
122        "WorkHeartbeat" | "WorkComplete" | "WorkRelease" => {
123            require_agent_env(local_url, auth_key, block_id)?;
124            let self_id = std::env::var("AGENTMUX_AGENT_ID").ok().filter(|s| !s.is_empty())
125                .ok_or_else(|| anyhow::anyhow!("AGENTMUX_AGENT_ID is not set"))?;
126            let id = arguments.get("id").and_then(|v| v.as_str()).filter(|s| !s.is_empty())
127                .ok_or_else(|| anyhow::anyhow!("missing required parameter: id"))?;
128            let attempt = arguments.get("attempt").and_then(|v| v.as_i64())
129                .ok_or_else(|| anyhow::anyhow!("missing required parameter: attempt (from the WorkClaim response)"))?;
130
131            let (segment, result_text) = match name {
132                "WorkHeartbeat" => ("heartbeat", String::new()),
133                "WorkComplete" => (
134                    "complete",
135                    // Enforced at runtime as well as in the schema (Codex P2 on
136                    // PR #2902): completing with no result marks the item done
137                    // forever while destroying the only record that the work
138                    // happened. Better to reject the call than to accept a
139                    // silent hole in the audit trail.
140                    arguments
141                        .get("result")
142                        .and_then(|v| v.as_str())
143                        .map(str::trim)
144                        .filter(|s| !s.is_empty())
145                        .ok_or_else(|| {
146                            anyhow::anyhow!(
147                                "WorkComplete requires a non-empty 'result' — it is the only \
148                                 record of what this item accomplished once it is marked done"
149                            )
150                        })?
151                        .to_string(),
152                ),
153                _ => (
154                    "release",
155                    arguments.get("reason").and_then(|v| v.as_str()).unwrap_or("").to_string(),
156                ),
157            };
158
159            let url = format!("{}/agentmux/work/{}/{}", local_url.trim_end_matches('/'), id, segment);
160            let body = serde_json::json!({
161                "agent_id": self_id,
162                "attempt": attempt,
163                "result": result_text,
164                "lease_ms": arguments.get("lease_ms").and_then(|v| v.as_i64()).filter(|&n| n > 0),
165            });
166            let resp = client.post(&url).header(AUTH_KEY_HEADER, auth_key).json(&body).send().await
167                .map_err(|e| anyhow::anyhow!("work {segment} request failed: {e}"))?;
168            let status = resp.status();
169            let text = resp.text().await.unwrap_or_default();
170            if status == reqwest::StatusCode::CONFLICT {
171                // The fence rejected this call. Say so in the terms the agent
172                // can act on, rather than surfacing a raw 409 — the recovery is
173                // always the same: claim again.
174                anyhow::bail!(
175                    "This claim is no longer yours — the lease expired and the item was \
176                     reclaimed, or another agent holds it now. Call WorkClaim again if you \
177                     still want work; do NOT retry with the old attempt number."
178                );
179            }
180            if !status.is_success() {
181                anyhow::bail!("{name} failed: HTTP {status} — {text}");
182            }
183            Ok(match segment {
184                "heartbeat" => format!("Lease extended on {id}."),
185                "complete" => format!("Completed {id}."),
186                _ => {
187                    // A release on the FINAL allowed attempt parks the item as
188                    // `failed` rather than reopening it, so reporting "back to
189                    // the queue" unconditionally would tell the caller another
190                    // agent can pick it up when nobody ever will (Codex P2 on
191                    // PR #2902). The server reports the resulting state; trust
192                    // it rather than re-deriving the attempts rule here.
193                    let resulting = serde_json::from_str::<Value>(&text)
194                        .ok()
195                        .and_then(|v| v.get("state").and_then(|s| s.as_str()).map(str::to_string))
196                        .unwrap_or_default();
197                    if resulting == "failed" {
198                        format!(
199                            "Released {id}, and it has now used its final attempt — the item is \
200                             parked as FAILED and will not be offered to any agent again. If it \
201                             still needs doing, enqueue a fresh item (ideally with what you \
202                             learned about why it kept failing)."
203                        )
204                    } else {
205                        format!("Released {id} back to the queue for another agent.")
206                    }
207                }
208            })
209        }
210        "WorkList" => {
211            require_agent_env(local_url, auth_key, block_id)?;
212            let url = format!("{}/agentmux/work", local_url.trim_end_matches('/'));
213            let state = arguments.get("state").and_then(|v| v.as_str()).unwrap_or("");
214            let limit = arguments.get("limit").and_then(|v| v.as_i64()).unwrap_or(50);
215            // Built via reqwest's own query serializer, NOT string
216            // concatenation (reagent P2 on PR #2902): `state` is a free-form
217            // string in this tool's schema, not a validated enum, so a value
218            // containing `&`, `#`, or `%` would otherwise corrupt the query
219            // rather than being sent as the literal the caller intended.
220            let resp = client
221                .get(&url)
222                .query(&[("state", state), ("limit", &limit.to_string())])
223                .header(AUTH_KEY_HEADER, auth_key)
224                .send()
225                .await
226                .map_err(|e| anyhow::anyhow!("work list request failed: {e}"))?;
227            let status = resp.status();
228            let text = resp.text().await.unwrap_or_default();
229            if !status.is_success() {
230                anyhow::bail!("WorkList failed: HTTP {status} — {text}");
231            }
232            let v: Value = serde_json::from_str(&text).unwrap_or(serde_json::json!({}));
233            let items = v.get("items").and_then(|x| x.as_array()).cloned().unwrap_or_default();
234            if items.is_empty() {
235                return Ok("Queue is empty.".to_string());
236            }
237            let mut out = format!("{} work item(s):\n", items.len());
238            for it in &items {
239                let id = it.get("id").and_then(|x| x.as_str()).unwrap_or("");
240                let st = it.get("state").and_then(|x| x.as_str()).unwrap_or("");
241                let title = it.get("title").and_then(|x| x.as_str()).unwrap_or("");
242                let holder = it.get("claimed_by").and_then(|x| x.as_str()).unwrap_or("");
243                let who = if holder.is_empty() { String::new() } else { format!(" [{holder}]") };
244                out.push_str(&format!("  {id}  {st}{who}  {title}\n"));
245                // `result` is the completion trace for a done item, and the
246                // reason for a failed/released one — the very thing
247                // WorkComplete calls "the only record". Omitting it here left
248                // that record unreachable through the only agent-facing read
249                // tool (Codex P2 on PR #2902).
250                let result = it.get("result").and_then(|x| x.as_str()).unwrap_or("");
251                if !result.is_empty() {
252                    out.push_str(&format!("      -> {result}\n"));
253                }
254            }
255            Ok(out)
256        }
257        _ => Err(not_in_family()),
258    }
259}