Skip to main content

agentmux_srv/backend/work_facts/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Work facts: per running agent, what srv can already see or cheaply derive
5//! about what it is working on, so agents can find out who else is working
6//! on the same file, branch or topic
7//! (docs/specs/SPEC_AGENT_OVERLAP_AWARENESS_2026_10_10.md ยง3.1).
8//!
9//! | Fact | Source |
10//! |---|---|
11//! | goal | block meta `term:ambient_summary`, else `term:last_prompt` |
12//! | todos, current tool, recent files | the progress watcher (`reactive::progress_watcher`) |
13//! | working folder | bashwrap's cwd state file, else the start folder (`cmd:cwd`) |
14//! | repo, branch, uncommitted files | git in that folder, cached ([`git`]) |
15//!
16//! Files are compared across clones as (repository, repository-relative
17//! path), since every agent has its own clone. Nothing here blocks anyone:
18//! the facts only inform, through `WhoIsWorkingOn` ([`matching`]) and the
19//! overlap notes an agent gets when it edits a file another agent is also
20//! changing ([`overlap`], [`overlap_notes`]).
21//! What agents say they are working on (`ClaimWork`, [`claims`]) joins both.
22
23pub mod claims;
24pub mod cross_channel;
25pub mod git;
26pub mod matching;
27pub mod overlap;
28pub mod overlap_notes;
29pub mod paths;
30
31use std::path::Path;
32
33use serde::{Deserialize, Serialize};
34
35use crate::backend::blockcontroller::{cmd_env_of, get_block_controller_status, META_KEY_CMD_CWD, STATUS_RUNNING};
36use crate::backend::obj::{self, Block, MetaMapType};
37use crate::backend::reactive::progress_watcher::{watched_work, TodoItem};
38use crate::backend::reactive::types::AgentRegistration;
39use crate::backend::storage::store::Store;
40
41/// The block meta key the agent pane writes the latest prompt to
42/// (`frontend/app/store/swarm-line.ts`, `META_LAST_PROMPT`).
43const META_LAST_PROMPT: &str = "term:last_prompt";
44
45/// One file an agent changed recently.
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
47pub struct FactFile {
48    /// Normalized absolute path, as the tool call named it.
49    pub path: String,
50    /// Relative to the agent's repository, when the file is inside it.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub rel: Option<String>,
53    pub tool: String,
54    pub ts_ms: u64,
55}
56
57/// Everything known about one agent's current work.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct WorkFacts {
60    pub agent: String,
61    pub block_id: String,
62    /// The AgentMux channel the agent runs in (`stable`, `dev`, ...).
63    pub channel: String,
64    /// `busy` (a turn is running), `idle`, or `stopped`.
65    pub status: String,
66    pub goal: Option<String>,
67    pub working_folder: Option<String>,
68    pub repo: Option<String>,
69    pub repo_root: Option<String>,
70    pub branch: Option<String>,
71    /// Repository-relative, at most a few hundred; `dirty_count` is the total.
72    pub dirty_files: Vec<String>,
73    pub dirty_count: usize,
74    /// Newest first.
75    pub recent_files: Vec<FactFile>,
76    pub todos: Vec<TodoItem>,
77    pub current_tool: Option<String>,
78    /// When git was last asked; `None` before the first probe or outside a
79    /// repository.
80    pub git_checked_ms: Option<u64>,
81}
82
83impl WorkFacts {
84    /// The key two agents share when they work in the same repository: the
85    /// `owner/repo` of its origin, else (no remote) the clone's own folder.
86    pub fn repo_key(&self) -> Option<String> {
87        self.repo.clone().or_else(|| self.repo_root.as_ref().map(|r| format!("local:{r}")))
88    }
89}
90
91/// The agent's goal: its generated title, else its last prompt.
92pub fn goal_of(meta: &MetaMapType) -> Option<String> {
93    let title = obj::meta_get_string(meta, crate::ambient::title::META_TITLE, "");
94    if crate::ambient::validate::is_usable_title(&title) {
95        return Some(title.trim().to_string());
96    }
97    let prompt = obj::meta_get_string(meta, META_LAST_PROMPT, "");
98    let prompt = prompt.trim();
99    (!prompt.is_empty()).then(|| prompt.to_string())
100}
101
102/// The agent's working folder (see [`paths::working_folder`]), from its
103/// block row. Reads the store: call off the async workers.
104pub fn folder_of(store: &Store, home: Option<&Path>, reg: &AgentRegistration) -> Option<String> {
105    let block = store.get::<Block>(&reg.block_id).ok()??;
106    folder_of_meta(&block.meta, home, &reg.agent_id)
107}
108
109fn folder_of_meta(meta: &MetaMapType, home: Option<&Path>, agent_id: &str) -> Option<String> {
110    let env = cmd_env_of(meta);
111    let start = obj::meta_get_string(meta, META_KEY_CMD_CWD, "");
112    paths::working_folder(home, &env, agent_id, &start)
113}
114
115fn status_of(block_id: &str) -> &'static str {
116    match get_block_controller_status(block_id) {
117        Some(s) if s.shellprocstatus == STATUS_RUNNING => {
118            if s.turn_active {
119                "busy"
120            } else {
121                "idle"
122            }
123        }
124        _ => "stopped",
125    }
126}
127
128/// The facts for every agent registered on this srv. Reads the store and
129/// the caches only, never git: call off the async workers.
130pub fn collect_local(store: &Store, regs: &[AgentRegistration], channel: &str) -> Vec<WorkFacts> {
131    let home = dirs::home_dir();
132    regs.iter()
133        .map(|reg| {
134            let meta = store.get::<Block>(&reg.block_id).ok().flatten().map(|b| b.meta).unwrap_or_default();
135            let folder = folder_of_meta(&meta, home.as_deref(), &reg.agent_id);
136            let git = folder.as_deref().and_then(|d| git::cached(&reg.block_id, d));
137            let watched = watched_work(&reg.block_id).unwrap_or_default();
138            build(
139                reg,
140                channel,
141                status_of(&reg.block_id),
142                goal_of(&meta),
143                folder,
144                git,
145                watched,
146            )
147        })
148        .collect()
149}
150
151/// Assemble one agent's facts from what each source said.
152pub fn build(
153    reg: &AgentRegistration,
154    channel: &str,
155    status: &str,
156    goal: Option<String>,
157    working_folder: Option<String>,
158    git: Option<git::GitFacts>,
159    watched: crate::backend::reactive::progress_watcher::WatchedWork,
160) -> WorkFacts {
161    let root = git.as_ref().map(|g| g.root.clone());
162    let recent_files = watched
163        .recent_files
164        .into_iter()
165        .map(|f| {
166            let path = paths::normalize_path(&f.path);
167            let rel = root.as_deref().and_then(|r| paths::relative_to(&path, r)).filter(|r| !r.is_empty());
168            FactFile { path, rel, tool: f.tool, ts_ms: f.ts_ms }
169        })
170        .collect();
171    let git = git.unwrap_or_default();
172    WorkFacts {
173        agent: reg.agent_id.clone(),
174        block_id: reg.block_id.clone(),
175        channel: channel.to_string(),
176        status: status.to_string(),
177        goal,
178        working_folder,
179        repo: git.repo,
180        repo_root: root,
181        branch: git.branch,
182        dirty_files: git.dirty_files,
183        dirty_count: git.dirty_count,
184        recent_files,
185        todos: watched.progress.todos,
186        current_tool: watched.progress.current_tool,
187        git_checked_ms: (git.checked_ms > 0).then_some(git.checked_ms),
188    }
189}
190
191/// How many uncommitted and recent files a `ListConversations` entry shows.
192const SUMMARY_DIRTY: usize = 10;
193const SUMMARY_RECENT: usize = 5;
194
195/// The facts as a `ListConversations` entry carries them (its `work` field):
196/// the first few files of each list and a checklist summary.
197pub fn conversation_summary(f: &WorkFacts) -> serde_json::Value {
198    let done = f.todos.iter().filter(|t| t.status == "completed").count();
199    let in_progress: Vec<&str> =
200        f.todos.iter().filter(|t| t.status == "in_progress").map(|t| t.text.as_str()).take(3).collect();
201    serde_json::json!({
202        "goal": f.goal,
203        "working_folder": f.working_folder,
204        "repo": f.repo,
205        "branch": f.branch,
206        "dirty_count": f.dirty_count,
207        "dirty_files": f.dirty_files.iter().take(SUMMARY_DIRTY).collect::<Vec<_>>(),
208        "recent_files": f.recent_files.iter().take(SUMMARY_RECENT).map(|r| serde_json::json!({
209            "path": r.rel.as_deref().unwrap_or(&r.path),
210            "tool": r.tool,
211            "ts_ms": r.ts_ms,
212        })).collect::<Vec<_>>(),
213        "todos": { "total": f.todos.len(), "completed": done, "in_progress": in_progress },
214        "current_tool": f.current_tool,
215        "git_checked_ms": f.git_checked_ms,
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::backend::reactive::progress_watcher::{AgentProgress, RecentFile, WatchedWork};
223
224    fn reg(agent: &str, block: &str) -> AgentRegistration {
225        AgentRegistration {
226            agent_id: agent.to_string(),
227            block_id: block.to_string(),
228            tab_id: None,
229            uid: None,
230            registered_at: 0,
231            last_seen: 0,
232            registration_nonce: 0,
233        }
234    }
235
236    fn meta(pairs: &[(&str, &str)]) -> MetaMapType {
237        pairs.iter().map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string()))).collect()
238    }
239
240    #[test]
241    fn the_goal_is_the_title_else_the_last_prompt() {
242        assert_eq!(
243            goal_of(&meta(&[("term:ambient_summary", "Fix the login race"), ("term:last_prompt", "hi")])).as_deref(),
244            Some("Fix the login race")
245        );
246        assert_eq!(
247            goal_of(&meta(&[("term:ambient_summary", ""), ("term:last_prompt", " add the allowlist ")])).as_deref(),
248            Some("add the allowlist")
249        );
250        assert_eq!(goal_of(&meta(&[])), None);
251    }
252
253    #[test]
254    fn build_maps_recent_files_into_the_repository_and_keeps_watcher_state() {
255        let git = git::GitFacts {
256            root: "C:/w/clone".into(),
257            repo: Some("o/r".into()),
258            branch: Some("feat".into()),
259            dirty_files: vec!["a.rs".into()],
260            dirty_count: 1,
261            error: None,
262            checked_ms: 7,
263        };
264        let watched = WatchedWork {
265            progress: AgentProgress {
266                todos: vec![TodoItem { text: "Ship".into(), status: "in_progress".into() }],
267                current_tool: Some("Edit".into()),
268                ..Default::default()
269            },
270            recent_files: vec![
271                RecentFile { path: r"C:\w\clone\src\b.rs".into(), tool: "Edit".into(), ts_ms: 5 },
272                RecentFile { path: "D:/elsewhere/c.rs".into(), tool: "Write".into(), ts_ms: 4 },
273            ],
274        };
275        let f = build(&reg("A", "b1"), "stable", "idle", None, Some("C:/w/clone".into()), Some(git), watched);
276        assert_eq!(f.repo.as_deref(), Some("o/r"));
277        assert_eq!(f.recent_files[0].rel.as_deref(), Some("src/b.rs"));
278        assert_eq!(f.recent_files[0].path, "C:/w/clone/src/b.rs");
279        assert_eq!(f.recent_files[1].rel, None, "outside the repository");
280        assert_eq!(f.current_tool.as_deref(), Some("Edit"));
281        assert_eq!(f.git_checked_ms, Some(7));
282        assert_eq!(f.repo_key().as_deref(), Some("o/r"));
283
284        let summary = conversation_summary(&f);
285        assert_eq!(summary["dirty_count"], 1);
286        assert_eq!(summary["recent_files"][0]["path"], "src/b.rs");
287        assert_eq!(summary["todos"]["in_progress"][0], "Ship");
288    }
289
290    #[test]
291    fn a_repository_without_a_remote_is_keyed_by_its_folder() {
292        let f = WorkFacts { repo_root: Some("C:/w/x".into()), ..Default::default() };
293        assert_eq!(f.repo_key().as_deref(), Some("local:C:/w/x"));
294        assert_eq!(WorkFacts::default().repo_key(), None);
295    }
296
297    #[test]
298    fn facts_round_trip_for_the_cross_channel_endpoint() {
299        let f = WorkFacts {
300            agent: "A".into(),
301            todos: vec![TodoItem { text: "t".into(), status: "pending".into() }],
302            recent_files: vec![FactFile { path: "/x".into(), rel: None, tool: "Edit".into(), ts_ms: 1 }],
303            ..Default::default()
304        };
305        let back: WorkFacts = serde_json::from_value(serde_json::to_value(&f).unwrap()).unwrap();
306        assert_eq!(back, f);
307    }
308}