Skip to main content

forge_primitives/
workers.rs

1//! Worker-session shared types: tag constants, status snapshot,
2//! and the `worker_tag()` helper used by every crate that needs
3//! to format a worker's `{"type":"tag","tag":"forge:worker:<label>"}`
4//! JSONL row consistently.
5
6use std::time::SystemTime;
7
8use serde::{Deserialize, Serialize};
9
10use crate::runtime::SessionLifecycleState;
11
12/// Prefix shared by every worker session's tag. The full tag is
13/// formatted as `forge:worker:<label>` via [`worker_tag`].
14pub const FORGE_WORKER_TAG_PREFIX: &str = "forge:worker:";
15
16/// Reserved label for a project's own agent, addressed as the `label`
17/// of an `agents__send_message` target. `agents__spawn`
18/// rejects it so no live worker can shadow the keyword.
19pub const LEAD_LABEL: &str = "lead";
20
21/// Format a worker session's tag value: `forge:worker:<label>`.
22/// Used at spawn (writer side) and at scan (reader side).
23pub fn worker_tag(label: &str) -> String {
24    format!("{FORGE_WORKER_TAG_PREFIX}{label}")
25}
26
27/// Liveness of a worker session in the workspace's `live_workers`
28/// map. `Spawning` is the brief window between
29/// `Command::SpawnWorker` dispatch and the new session's
30/// `Connected` event. `Failed` is set when spawn / resume fails
31/// terminally - claude rejected the resume (e.g.,
32/// `No conversation found`), the subprocess exited non-zero, or
33/// the connection broke before `Connected` fired. The
34/// human-readable reason lives on the sibling `diagnostic` field
35/// of [`WorkerStatus`] / `WorkerEntry`; keeping it off the enum
36/// preserves the `Copy` bound that every match site relies on.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38pub enum WorkerLiveness {
39    Spawning,
40    Running,
41    Failed,
42    /// The worker's subprocess died while it was up and its supervisor is
43    /// bringing it back. Not live: the label is free for that resume.
44    Restarting,
45}
46
47/// Snapshot of one worker. Carried by `agents__list` and threaded
48/// through `SessionUpdate::WorkerStatusChanged` for a view to render.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct WorkerStatus {
51    pub label: String,
52    pub charter: String,
53    pub status: WorkerLiveness,
54    /// The session id the worker currently runs under.
55    pub session_id: String,
56    /// The slot the worker fills, which is what addresses it.
57    pub slot: crate::SessionSlot,
58    pub spawned_at: SystemTime,
59    /// The slot of the caller that issued the `agents__spawn` call.
60    /// In v1 this is always the project's lead; field exists
61    /// pre-baked for v2 worker-spawn-from-worker (currently gated).
62    pub spawned_by: crate::SessionSlot,
63    /// Human-readable failure reason when `status == Failed` (the
64    /// first line of claude's stderr, or the error variant name when
65    /// stderr was empty). `None` when the worker isn't in `Failed`
66    /// state or no diagnostic could be captured. The Projects pane
67    /// renders this as a dim sub-row beneath the worker label so the
68    /// user can tell at a glance whether the failure is recoverable
69    /// (e.g., "No conversation found" -> fresh spawn flow kicks in)
70    /// or terminal (e.g., spawn binary missing).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub diagnostic: Option<String>,
73    /// Which heal attempt is next when `status == Restarting`: 1 for the
74    /// first restart after the death. `None` for every other status.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub heal_attempt: Option<u32>,
77    /// When that attempt fires. `None` when no attempt is scheduled.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub heal_next_at: Option<SystemTime>,
80    /// What the worker's session is doing right now, on the axis
81    /// `status` does not answer: `status` is the spawn outcome and
82    /// stops moving once the worker connects, so a worker idle for an
83    /// hour still reads `Running` there.
84    ///
85    /// `None` means **this path does not derive activity**, not that
86    /// activity is unknown - do not project a state from it. The only
87    /// producer that populates it is `agents__list`;
88    /// `SessionUpdate::WorkerStatusChanged` leaves it `None` because
89    /// nothing on that path reads it.
90    ///
91    /// `LoggedOut` has no producer anywhere in the tree. `AuthRequired`
92    /// is derived, from the typed signals the CLI sends for an
93    /// authentication failure - an assistant message's error, or a retry
94    /// frame naming that class - with the `TurnError` envelope's text as
95    /// a fallback (busytools/forge#1201).
96    ///
97    /// Two limits inherited from the signals this reads, not from the
98    /// derivation: `Running` does not tell a live turn apart from a
99    /// worker that died mid-turn, because subprocess death reaches no
100    /// terminal path and so never clears `turn_pending`
101    /// (busytools/forge#643). And a turn queued behind another reads
102    /// `Idle` until its first token, because `turn_pending` is a bool
103    /// that the first `Result` clears (busytools/forge#671). Treat this
104    /// as "what forge last observed", never as proof of life.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub activity: Option<SessionLifecycleState>,
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn worker_tag_concatenates_prefix_and_label() {
115        assert_eq!(worker_tag("reviewer"), "forge:worker:reviewer");
116    }
117
118    #[test]
119    fn worker_tag_allows_unicode_labels() {
120        assert_eq!(worker_tag("résumé"), "forge:worker:résumé");
121    }
122
123    #[test]
124    fn worker_tag_empty_label_yields_bare_prefix() {
125        // Empty-label validation happens at the spawn tool gate, not
126        // at the format helper. The helper produces what it produces.
127        assert_eq!(worker_tag(""), "forge:worker:");
128    }
129
130    #[test]
131    fn worker_prefix_constant_value() {
132        assert_eq!(FORGE_WORKER_TAG_PREFIX, "forge:worker:");
133    }
134
135    #[test]
136    fn worker_liveness_failed_is_copy_unit_variant() {
137        // Failed stays a unit variant so the enum keeps `Copy`. The
138        // human-readable diagnostic lives on WorkerStatus's sibling
139        // field, not inside the variant. The Copy bound matters
140        // because every existing match site (and `matches!` invocation)
141        // moves the value into the match arm; converting to a tuple
142        // variant would ripple through ~10 sites.
143        fn requires_copy<T: Copy>(_: T) {}
144        requires_copy(WorkerLiveness::Failed);
145        assert!(matches!(WorkerLiveness::Failed, WorkerLiveness::Failed));
146    }
147
148    /// A restarting row carries the attempt and the instant the next one
149    /// fires, so a client can say "restarting, attempt 2 of 3" without
150    /// counting anything itself.
151    #[test]
152    fn a_restarting_status_carries_its_attempt_and_next_stamp() {
153        let next_at = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_790_000_000);
154        let status = WorkerStatus {
155            label: "reviewer".into(),
156            charter: "be sharp".into(),
157            status: WorkerLiveness::Restarting,
158            session_id: "uuid-1".into(),
159            slot: crate::SessionSlot::new("me", "forge", "reviewer"),
160            spawned_at: SystemTime::UNIX_EPOCH,
161            spawned_by: crate::SessionSlot::lead("me", "forge"),
162            diagnostic: None,
163            activity: None,
164            heal_attempt: Some(2),
165            heal_next_at: Some(next_at),
166        };
167        let json = serde_json::to_value(&status).expect("serialize");
168        assert_eq!(json["status"], serde_json::json!("Restarting"));
169        assert_eq!(json["heal_attempt"], serde_json::json!(2), "the attempt the row names");
170        assert_eq!(
171            json["heal_next_at"]["secs_since_epoch"],
172            serde_json::json!(1_790_000_000_u64),
173            "when the next attempt fires",
174        );
175    }
176
177    #[test]
178    fn worker_status_diagnostic_round_trips_through_serde() {
179        let status = WorkerStatus {
180            label: "reviewer".into(),
181            charter: "be sharp".into(),
182            status: WorkerLiveness::Failed,
183            session_id: "uuid-1".into(),
184            slot: crate::SessionSlot::new("me", "forge", "reviewer"),
185            spawned_at: SystemTime::UNIX_EPOCH,
186            spawned_by: crate::SessionSlot::lead("me", "forge"),
187            diagnostic: Some("No conversation found".into()),
188            activity: Some(SessionLifecycleState::Attention),
189            heal_attempt: None,
190            heal_next_at: None,
191        };
192        let json = serde_json::to_string(&status).expect("serialize");
193        let back: WorkerStatus = serde_json::from_str(&json).expect("deserialize");
194        assert_eq!(back.diagnostic.as_deref(), Some("No conversation found"));
195        assert_eq!(back.status, WorkerLiveness::Failed);
196        assert_eq!(back.activity, Some(SessionLifecycleState::Attention));
197    }
198}