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}