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