Skip to main content

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}