harn_vm/agent_events/worker.rs
1use serde::{Deserialize, Serialize};
2
3use super::lifecycle::{AgentLifecycleEvent, AgentLifecycleState};
4
5/// Structured terminal outcome for one delegated sub-agent run.
6#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum SubagentTerminalStatus {
9 Success,
10 Failure,
11 Cancellation,
12 Timeout,
13}
14
15/// One agent run and the session whose transcript owns it.
16///
17/// Session and run identifiers are deliberately separate: a session may host
18/// multiple runs, while lifecycle correlation must name the exact run.
19#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
20pub struct AgentRunRef {
21 pub session_id: String,
22 pub run_id: String,
23}
24
25/// Authoritative parent/child identity for one delegated run.
26#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
27pub struct DelegatedRunLineage {
28 pub parent: AgentRunRef,
29 pub child: AgentRunRef,
30}
31
32/// The measurable boundaries of one parent/child join, in one place.
33///
34/// Three intervals fall out of these four points, and a run report that
35/// carries only `completed_at_ms` and `joined_at_ms` cannot separate them:
36///
37/// - scheduler wait: `joined_at_ms - wait_started_at_ms`;
38/// - collection lag: `joined_at_ms - completed_at_ms`;
39/// - result processing: `result_processing_completed_at_ms -
40/// result_processing_started_at_ms`.
41///
42/// The optional boundaries stay `Option` rather than defaulting to the join
43/// instant, because a report that cannot distinguish "the parent never waited"
44/// from "the parent waited zero milliseconds" is worse than one that says it
45/// does not know. A path that never waited (`agent_start` without
46/// `wait_for_terminal`) and a path that collected without collapsing a result
47/// both project explicit nulls.
48#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
49pub struct DelegatedJoinBoundaries {
50 /// When the parent began waiting on this child, if it ever did.
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub wait_started_at_ms: Option<i64>,
53 /// When the parent began collapsing the child's result, if it did.
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub result_processing_started_at_ms: Option<i64>,
56 /// When that collapse finished. Recorded even when it failed, so a failed
57 /// collapse is a measured interval rather than a missing one.
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub result_processing_completed_at_ms: Option<i64>,
60}
61
62impl DelegatedJoinBoundaries {
63 /// How long the parent was blocked, from the moment it began waiting to the
64 /// moment it observed the child terminal.
65 ///
66 /// `None` when the parent never waited. Also `None` when the clock ran
67 /// backwards between the two reads, because a negative duration is a
68 /// broken measurement, not a fast one.
69 #[must_use]
70 pub fn wait_ms(&self, joined_at_ms: i64) -> Option<u64> {
71 u64::try_from(joined_at_ms.checked_sub(self.wait_started_at_ms?)?).ok()
72 }
73
74 /// How long the parent spent collapsing the child's result. `None` unless
75 /// both boundaries were recorded.
76 #[must_use]
77 pub fn result_processing_ms(&self) -> Option<u64> {
78 u64::try_from(
79 self.result_processing_completed_at_ms?
80 .checked_sub(self.result_processing_started_at_ms?)?,
81 )
82 .ok()
83 }
84}
85
86/// One coalesced filesystem notification from a hostlib `fs_watch`
87/// subscription.
88#[derive(Clone, Debug, Serialize, Deserialize)]
89pub struct FsWatchEvent {
90 pub kind: String,
91 pub paths: Vec<String>,
92 pub relative_paths: Vec<String>,
93 pub raw_kind: String,
94 pub error: Option<String>,
95}
96
97/// Typed worker lifecycle events emitted by delegated/background agent
98/// execution. Bridge-facing worker updates still derive a string status
99/// from these variants, but the runtime no longer passes raw status
100/// strings around internally.
101///
102/// `Spawned`/`Completed`/`Failed`/`Stopped`/`Cancelled` are the terminal-or-start
103/// states. `Progressed` is fired on intermediate milestones (e.g. a
104/// retriggerable worker resuming from `awaiting_input`, or a workflow
105/// stage completing without ending the worker). `WaitingForInput` covers
106/// retriggerable workers that finish a cycle but stay alive pending the
107/// next host-supplied trigger payload. `Suspended`/`Resumed` cover
108/// cooperative mid-loop pause and warm resume (harn#1836); the
109/// `agent_loop` honors the pause signal at the next turn boundary,
110/// distinct from a graceful `Stopped` handoff or hard `Cancelled` interrupt.
111#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
112pub enum WorkerEvent {
113 WorkerSpawned,
114 WorkerProgressed,
115 WorkerWaitingForInput,
116 WorkerSuspended,
117 WorkerResumed,
118 WorkerCompleted,
119 WorkerFailed,
120 WorkerStopped,
121 WorkerCancelled,
122}
123
124impl WorkerEvent {
125 /// The full set of `WorkerEvent` variants in canonical order. Mirrors
126 /// the pattern used by `ToolCallStatus::ALL` so the protocol-artifact
127 /// dumper can enumerate worker status wire values without
128 /// special-casing each lifecycle event.
129 pub const ALL: [Self; 9] = [
130 Self::WorkerSpawned,
131 Self::WorkerProgressed,
132 Self::WorkerWaitingForInput,
133 Self::WorkerSuspended,
134 Self::WorkerResumed,
135 Self::WorkerCompleted,
136 Self::WorkerFailed,
137 Self::WorkerStopped,
138 Self::WorkerCancelled,
139 ];
140
141 /// Map onto the shared agent/run lifecycle event owner.
142 pub const fn lifecycle_event(self) -> AgentLifecycleEvent {
143 match self {
144 Self::WorkerSpawned => AgentLifecycleEvent::Spawned,
145 Self::WorkerProgressed => AgentLifecycleEvent::Progressed,
146 Self::WorkerWaitingForInput => AgentLifecycleEvent::WaitingForInput,
147 Self::WorkerSuspended => AgentLifecycleEvent::Suspended,
148 Self::WorkerResumed => AgentLifecycleEvent::Resumed,
149 Self::WorkerCompleted => AgentLifecycleEvent::Completed,
150 Self::WorkerFailed => AgentLifecycleEvent::Failed,
151 Self::WorkerStopped => AgentLifecycleEvent::Stopped,
152 Self::WorkerCancelled => AgentLifecycleEvent::Cancelled,
153 }
154 }
155
156 /// Wire-level status string used by bridge `worker_update` payloads
157 /// and ACP `worker_update` session updates. Derived from
158 /// [`AgentLifecycleState`] so adapter dumps cannot drift from the
159 /// shared registry.
160 pub fn as_status(self) -> &'static str {
161 self.lifecycle_event()
162 .target_state()
163 .expect("worker events always target a lifecycle state")
164 .wire_name()
165 }
166
167 pub fn as_str(self) -> &'static str {
168 match self {
169 Self::WorkerSpawned => "WorkerSpawned",
170 Self::WorkerProgressed => "WorkerProgressed",
171 Self::WorkerWaitingForInput => "WorkerWaitingForInput",
172 Self::WorkerSuspended => "WorkerSuspended",
173 Self::WorkerResumed => "WorkerResumed",
174 Self::WorkerCompleted => "WorkerCompleted",
175 Self::WorkerFailed => "WorkerFailed",
176 Self::WorkerStopped => "WorkerStopped",
177 Self::WorkerCancelled => "WorkerCancelled",
178 }
179 }
180
181 /// True for lifecycle events that mean the worker has reached a
182 /// final, non-resumable state. Retriggerable awaiting, progressed,
183 /// and cooperative suspend/resume milestones are *not* terminal —
184 /// the worker keeps running, is waiting for a trigger, or is parked
185 /// awaiting an external resume.
186 pub fn is_terminal(self) -> bool {
187 self.lifecycle_event()
188 .target_state()
189 .is_some_and(AgentLifecycleState::is_terminal)
190 }
191
192 /// Interpret a persisted worker status through the lifecycle owner.
193 /// `running` is represented by the spawn variant because both spawn and
194 /// resume intentionally project to the same non-terminal wire state.
195 /// Compatibility aliases (`awaiting`, `canceled`, …) are accepted but
196 /// never become distinct canonical states.
197 pub fn from_status(status: &str) -> Option<Self> {
198 match AgentLifecycleState::from_wire(status)? {
199 AgentLifecycleState::Running => Some(Self::WorkerSpawned),
200 AgentLifecycleState::Progressed => Some(Self::WorkerProgressed),
201 AgentLifecycleState::AwaitingInput => Some(Self::WorkerWaitingForInput),
202 AgentLifecycleState::Suspended => Some(Self::WorkerSuspended),
203 AgentLifecycleState::Completed => Some(Self::WorkerCompleted),
204 AgentLifecycleState::Failed => Some(Self::WorkerFailed),
205 AgentLifecycleState::Stopped => Some(Self::WorkerStopped),
206 AgentLifecycleState::Cancelled => Some(Self::WorkerCancelled),
207 }
208 }
209
210 pub fn status_is_terminal(status: &str) -> bool {
211 AgentLifecycleState::status_is_terminal(status)
212 }
213}