Skip to main content

sloop/work_state/
mod.rs

1//! Authored ticket ingestion and daemon-owned work state.
2//!
3//! This module must never import the run-storage sibling; work-state
4//! transitions and run evidence are separate storage boundaries.
5
6use std::fmt;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use async_trait::async_trait;
11
12use crate::domain::work::{
13    Disposition, OwnerId, SourceVersion, TicketRef, WorkOutcome, WorkTicket,
14};
15
16use crate::frontmatter::Frontmatter;
17
18pub(crate) mod exec;
19pub mod local;
20pub(crate) mod markdown;
21pub mod trigger;
22
23use exec::ExecTicketSource;
24use markdown::MarkdownTicketSource;
25
26#[derive(Debug, Clone)]
27pub(crate) struct AuthoredTicket {
28    pub frontmatter: Frontmatter,
29    pub body: String,
30    pub source: String,
31    pub source_ref: String,
32    pub file_path: Option<PathBuf>,
33    pub original_content: Option<String>,
34    pub validation_error: Option<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub(crate) struct TicketFeedError(String);
39
40impl TicketFeedError {
41    pub(crate) fn new(message: impl Into<String>) -> Self {
42        Self(message.into())
43    }
44}
45
46impl fmt::Display for TicketFeedError {
47    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48        formatter.write_str(&self.0)
49    }
50}
51
52impl std::error::Error for TicketFeedError {}
53
54/// The configured authored-ticket feeder. The public extension seam is the
55/// exec wire protocol, so server wiring uses this concrete enum rather than a
56/// Rust implementation trait.
57#[derive(Clone)]
58pub(crate) enum TicketFeeder {
59    Markdown(MarkdownTicketSource),
60    Exec(ExecTicketSource),
61}
62
63impl TicketFeeder {
64    pub(crate) fn markdown(root: impl Into<PathBuf>, ticket_dir: impl Into<PathBuf>) -> Self {
65        Self::Markdown(MarkdownTicketSource::new(root, ticket_dir))
66    }
67
68    pub(crate) fn exec(root: impl Into<PathBuf>, argv: Vec<String>) -> Self {
69        Self::Exec(ExecTicketSource::new(root, argv))
70    }
71
72    pub(crate) fn pull(&self) -> Result<Vec<AuthoredTicket>, TicketFeedError> {
73        match self {
74            Self::Markdown(source) => source.pull(),
75            Self::Exec(source) => source.pull(),
76        }
77    }
78
79    pub(crate) fn supports_authoring(&self) -> bool {
80        matches!(self, Self::Markdown(_))
81    }
82
83    pub(crate) fn exec_reporter(&self) -> Option<ExecTicketSource> {
84        match self {
85            Self::Markdown(_) => None,
86            Self::Exec(source) => Some(source.clone()),
87        }
88    }
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum ClaimStrength {
93    Atomic,
94    Optimistic,
95    LocalOnly,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99#[allow(clippy::large_enum_variant)]
100pub enum ClaimResult {
101    Claimed { ticket: WorkTicket },
102    Lost { held_by: Option<OwnerId> },
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ActiveClaim {
107    pub ticket: TicketRef,
108    pub owner: OwnerId,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum SourceError {
113    Unavailable { retry_after: Option<Duration> },
114    Rejected { message: String },
115    Corrupt { message: String },
116}
117
118impl fmt::Display for SourceError {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        match self {
121            Self::Unavailable { retry_after } => match retry_after {
122                Some(retry_after) => write!(
123                    formatter,
124                    "work source is unavailable; retry after {} seconds",
125                    retry_after.as_secs()
126                ),
127                None => formatter.write_str("work source is unavailable"),
128            },
129            Self::Rejected { message } | Self::Corrupt { message } => formatter.write_str(message),
130        }
131    }
132}
133
134impl std::error::Error for SourceError {}
135
136/// The daemon-facing contract for reading and claiming work.
137///
138/// Implementations must uphold these invariants:
139///
140/// - Attempts increment only inside [`WorkState::claim`], never on release.
141/// - A claim is committed at the source before any run is recorded against it.
142/// - An outcome is durably recorded before [`WorkState::release`] or
143///   [`WorkState::push_outcome`] is called.
144#[async_trait]
145pub trait WorkState: Send + Sync {
146    /// What this source can promise about claims. The dispatcher never
147    /// branches on this; it exists for status display and for refusing
148    /// multi-writer setups on `LocalOnly` sources.
149    fn claim_strength(&self) -> ClaimStrength;
150
151    /// Refresh the daemon's read cache. Selection runs on the cache;
152    /// pull is periodic, never per-tick.
153    async fn pull_ready(&self) -> Result<Vec<WorkTicket>, SourceError>;
154
155    /// Claims visible at the source. Recovery compares these with recorded
156    /// runs and releases a claim whose second admission commit never landed.
157    async fn active_claims(&self) -> Result<Vec<ActiveClaim>, SourceError>;
158
159    /// The only authoritative check. Succeeds iff the ticket is still
160    /// ready at the source. Lost is a normal, silent outcome.
161    async fn claim(
162        &self,
163        ticket: &TicketRef,
164        owner: &OwnerId,
165        ttl: Duration,
166    ) -> Result<ClaimResult, SourceError>;
167
168    /// Heartbeat for long runs. An expired claim is reclaimable by peers.
169    async fn renew(&self, ticket: &TicketRef, owner: &OwnerId) -> Result<ClaimResult, SourceError>;
170
171    /// Give the ticket back, per policy. Repeating a completed release is an
172    /// idempotent success; it never consumes another attempt.
173    async fn release(
174        &self,
175        ticket: &TicketRef,
176        owner: &OwnerId,
177        disposition: Disposition,
178    ) -> Result<(), SourceError>;
179
180    /// Terminal push. Fire-and-forget from the dispatcher's view.
181    async fn push_outcome(&self, outcome: &WorkOutcome) -> Result<(), SourceError>;
182}
183
184#[async_trait]
185pub trait WorkStateAuthor: Send + Sync {
186    /// Create. The source assigns the ref; id authority lives behind
187    /// the seam.
188    async fn post(&self, ticket: &WorkTicket) -> Result<TicketRef, SourceError>;
189
190    /// Repost-in-place, compare-and-swap on the version. A conflicting
191    /// concurrent edit is a detected conflict, never a silent clobber.
192    async fn update(
193        &self,
194        ticket: &TicketRef,
195        content: &WorkTicket,
196        expected: &SourceVersion,
197    ) -> Result<SourceVersion, SourceError>;
198}
199
200#[cfg(test)]
201mod tests {
202    use std::sync::Mutex;
203
204    use crate::domain::work::{ExecutionHints, WorkTicketState};
205
206    use super::*;
207
208    struct FakeWorkState {
209        ticket: Mutex<WorkTicket>,
210        outcomes: Mutex<Vec<WorkOutcome>>,
211    }
212
213    #[async_trait]
214    impl WorkState for FakeWorkState {
215        fn claim_strength(&self) -> ClaimStrength {
216            ClaimStrength::Atomic
217        }
218
219        async fn pull_ready(&self) -> Result<Vec<WorkTicket>, SourceError> {
220            let ticket = self.ticket.lock().unwrap().clone();
221            Ok((ticket.state == WorkTicketState::Ready)
222                .then_some(ticket)
223                .into_iter()
224                .collect())
225        }
226
227        async fn active_claims(&self) -> Result<Vec<ActiveClaim>, SourceError> {
228            let ticket = self.ticket.lock().unwrap();
229            let WorkTicketState::Claimed { by } = &ticket.state else {
230                return Ok(Vec::new());
231            };
232            Ok(vec![ActiveClaim {
233                ticket: ticket_ref(&ticket),
234                owner: by.clone(),
235            }])
236        }
237
238        async fn claim(
239            &self,
240            ticket: &TicketRef,
241            owner: &OwnerId,
242            _ttl: Duration,
243        ) -> Result<ClaimResult, SourceError> {
244            let mut stored = self.ticket.lock().unwrap();
245            if stored.id != ticket.id || stored.state != WorkTicketState::Ready {
246                return Ok(ClaimResult::Lost { held_by: None });
247            }
248
249            stored.attempts += 1;
250            stored.state = WorkTicketState::Claimed { by: owner.clone() };
251            Ok(ClaimResult::Claimed {
252                ticket: stored.clone(),
253            })
254        }
255
256        async fn renew(
257            &self,
258            ticket: &TicketRef,
259            owner: &OwnerId,
260        ) -> Result<ClaimResult, SourceError> {
261            let stored = self.ticket.lock().unwrap();
262            if stored.id == ticket.id
263                && stored.state == (WorkTicketState::Claimed { by: owner.clone() })
264            {
265                return Ok(ClaimResult::Claimed {
266                    ticket: stored.clone(),
267                });
268            }
269
270            Ok(ClaimResult::Lost { held_by: None })
271        }
272
273        async fn release(
274            &self,
275            _ticket: &TicketRef,
276            _owner: &OwnerId,
277            disposition: Disposition,
278        ) -> Result<(), SourceError> {
279            let mut stored = self.ticket.lock().unwrap();
280            stored.state = match disposition {
281                Disposition::Complete => WorkTicketState::Done,
282                Disposition::Retry { .. } => WorkTicketState::Ready,
283                Disposition::Park { reason } => WorkTicketState::Held { reason },
284                Disposition::Abandon => WorkTicketState::Failed,
285            };
286            Ok(())
287        }
288
289        async fn push_outcome(&self, outcome: &WorkOutcome) -> Result<(), SourceError> {
290            self.outcomes.lock().unwrap().push(outcome.clone());
291            Ok(())
292        }
293    }
294
295    #[async_trait]
296    impl WorkStateAuthor for FakeWorkState {
297        async fn post(&self, ticket: &WorkTicket) -> Result<TicketRef, SourceError> {
298            *self.ticket.lock().unwrap() = ticket.clone();
299            Ok(ticket_ref(ticket))
300        }
301
302        async fn update(
303            &self,
304            ticket: &TicketRef,
305            content: &WorkTicket,
306            expected: &SourceVersion,
307        ) -> Result<SourceVersion, SourceError> {
308            let mut stored = self.ticket.lock().unwrap();
309            if stored.id != ticket.id || &stored.version != expected {
310                return Err(SourceError::Rejected {
311                    message: "version conflict".into(),
312                });
313            }
314
315            let version = SourceVersion(format!("{}-next", expected.0));
316            *stored = content.clone();
317            stored.version = version.clone();
318            Ok(version)
319        }
320    }
321
322    fn work_ticket() -> WorkTicket {
323        WorkTicket {
324            id: "T1".into(),
325            project_id: "P1".into(),
326            name: "test ticket".into(),
327            body: String::new(),
328            state: WorkTicketState::Ready,
329            blocked_by: Vec::new(),
330            attempts: 0,
331            hints: ExecutionHints {
332                worktree: None,
333                trigger_id: None,
334                target: None,
335                model: None,
336                effort: None,
337                flow: None,
338            },
339            version: SourceVersion("1".into()),
340        }
341    }
342
343    fn ticket_ref(ticket: &WorkTicket) -> TicketRef {
344        TicketRef {
345            id: ticket.id.clone(),
346            source: "memory".into(),
347            source_ref: None,
348        }
349    }
350
351    #[test]
352    fn work_state_is_object_safe() {
353        let _: Option<Box<dyn WorkState>> = None;
354    }
355
356    #[tokio::test]
357    async fn fake_implements_work_state_traits() {
358        let ticket = work_ticket();
359        let ticket_ref = ticket_ref(&ticket);
360        let fake = FakeWorkState {
361            ticket: Mutex::new(ticket.clone()),
362            outcomes: Mutex::new(Vec::new()),
363        };
364        let author: &dyn WorkStateAuthor = &fake;
365        let version = author
366            .update(&ticket_ref, &ticket, &ticket.version)
367            .await
368            .unwrap();
369        assert_eq!(version, SourceVersion("1-next".into()));
370
371        let source: Box<dyn WorkState> = Box::new(fake);
372
373        assert_eq!(source.claim_strength(), ClaimStrength::Atomic);
374        let result = source
375            .claim(
376                &ticket_ref,
377                &OwnerId("daemon-1".into()),
378                Duration::from_secs(60),
379            )
380            .await
381            .unwrap();
382
383        assert!(matches!(
384            result,
385            ClaimResult::Claimed {
386                ticket: WorkTicket { attempts: 1, .. }
387            }
388        ));
389    }
390}