返回 CodeWhale
registry.rs
根目录 / crates / lane / src / registry.rs
1 //! Durable lane registry under `$CODEWHALE_HOME/lanes/`.
2
3 use std::fs::{self, OpenOptions};
4 use std::path::{Path, PathBuf};
5
6 use anyhow::{Context, Result, bail};
7 use chrono::{SecondsFormat, Utc};
8 use serde::{Deserialize, Serialize};
9
10 use crate::runtime::RuntimeBackendKind;
11
12 const LANES_SUBDIR: &str = "lanes";
13 const LOGS_SUBDIR: &str = "logs";
14
15 /// Lifecycle status for a running workflow instance.
16 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17 #[serde(rename_all = "snake_case")]
18 pub enum LaneStatus {
19 Pending,
20 Running,
21 Stopped,
22 Failed,
23 Completed,
24 }
25
26 impl LaneStatus {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 Self::Pending => "pending",
30 Self::Running => "running",
31 Self::Stopped => "stopped",
32 Self::Failed => "failed",
33 Self::Completed => "completed",
34 }
35 }
36
37 pub fn is_active(self) -> bool {
38 matches!(self, Self::Pending | Self::Running)
39 }
40 }
41
42 /// Result of an attempted terminal transition.
43 ///
44 /// The caller needs all three cases distinguished to report a truthful
45 /// receipt: "I stopped it", "it was already terminal", and "it moved on since
46 /// you read it, so I refused". Collapsing them into a bool made a concurrent
47 /// stop by another process indistinguishable from our own transition.
48 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
49 pub enum TerminalTransition {
50 /// This call performed the active -> terminal transition.
51 Transitioned,
52 /// The record was already terminal; nothing changed.
53 AlreadyTerminal,
54 /// The caller pinned a lifecycle generation and the record has moved on.
55 /// Nothing was changed and no backend teardown ran.
56 FenceMismatch { observed: u64 },
57 }
58
59 impl TerminalTransition {
60 #[must_use]
61 pub const fn transitioned(self) -> bool {
62 matches!(self, Self::Transitioned)
63 }
64 }
65
66 /// One lane record: a running (or completed) workflow instance.
67 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68 pub struct LaneRecord {
69 pub id: String,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub workflow: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub fleet: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub issue: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub goal: Option<String>,
78 pub runtime: RuntimeBackendKind,
79 pub status: LaneStatus,
80 /// Monotonic durable lifecycle sequence used by Work Graph reconciliation.
81 #[serde(default)]
82 pub lifecycle_seq: u64,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub worktree_path: Option<PathBuf>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub branch: Option<String>,
87 /// tmux session name when `runtime == tmux`.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub tmux_session: Option<String>,
90 /// Explicit tmux server socket used for this Lane. Pinning the socket keeps
91 /// start/attach/stop/reconcile in the same server namespace even when
92 /// `TMUX_TMPDIR` or the caller environment changes between commands.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub tmux_socket: Option<PathBuf>,
95 /// Absolute path to the stream-json / NDJSON journal for this lane.
96 pub log_path: PathBuf,
97 pub started_at: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub stopped_at: Option<String>,
100 /// Optional human-readable attach target (e.g. `tmux attach -t …`).
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub attach_target: Option<String>,
103 /// Worktree cleanup TTL in seconds (None = no auto-cleanup).
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub worktree_ttl_secs: Option<u64>,
106 }
107
108 impl LaneRecord {
109 pub fn new_id() -> String {
110 let short = uuid::Uuid::new_v4().to_string();
111 format!("lane-{}", &short[..8])
112 }
113
114 pub fn now_rfc3339() -> String {
115 Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
116 }
117 }
118
119 /// Registry root: `$CODEWHALE_HOME/lanes`.
120 pub fn lanes_dir() -> Result<PathBuf> {
121 codewhale_config::ensure_state_dir(LANES_SUBDIR)
122 }
123
124 /// Where the Lane registry *would* live, without creating it.
125 ///
126 /// [`lanes_dir`] creates the directory as a side effect, which makes it
127 /// useless for answering "is there a durable Lane registry?" — a read-only
128 /// status surface must not conjure the store it is reporting on. Availability
129 /// probing goes through this instead (see [`crate::control::ControlContext`]).
130 pub fn lane_registry_root() -> Result<PathBuf> {
131 Ok(codewhale_config::codewhale_home()?.join(LANES_SUBDIR))
132 }
133
134 /// Persist and load lane records.
135 #[derive(Debug, Clone)]
136 pub struct LaneRegistry {
137 root: PathBuf,
138 }
139
140 impl LaneRegistry {
141 /// Open the default registry under `$CODEWHALE_HOME/lanes`.
142 pub fn open_default() -> Result<Self> {
143 Self::open(lanes_dir()?)
144 }
145
146 /// Open a registry at an explicit root (tests / custom homes).
147 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
148 let root = root.into();
149 fs::create_dir_all(&root)
150 .with_context(|| format!("create lane registry {}", root.display()))?;
151 fs::create_dir_all(root.join(LOGS_SUBDIR))
152 .with_context(|| format!("create lane logs under {}", root.display()))?;
153 Ok(Self { root })
154 }
155
156 pub fn root(&self) -> &Path {
157 &self.root
158 }
159
160 pub fn logs_dir(&self) -> PathBuf {
161 self.root.join(LOGS_SUBDIR)
162 }
163
164 pub fn record_path(&self, id: &str) -> PathBuf {
165 self.root.join(format!("{id}.json"))
166 }
167
168 pub fn log_path_for(&self, id: &str) -> PathBuf {
169 self.logs_dir().join(format!("{id}.ndjson"))
170 }
171
172 pub fn save(&self, record: &LaneRecord) -> Result<()> {
173 let path = self.record_path(&record.id);
174 let json = serde_json::to_string_pretty(record).context("serialize lane record")?;
175 let tmp = path.with_extension("json.tmp");
176 fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?;
177 fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?;
178 Ok(())
179 }
180
181 pub fn load(&self, id: &str) -> Result<LaneRecord> {
182 let path = self.record_path(id);
183 let text = fs::read_to_string(&path)
184 .with_context(|| format!("read lane record {}", path.display()))?;
185 serde_json::from_str(&text).with_context(|| format!("parse lane record {}", path.display()))
186 }
187
188 pub fn list(&self) -> Result<Vec<LaneRecord>> {
189 let mut records = Vec::new();
190 for entry in fs::read_dir(&self.root)
191 .with_context(|| format!("read lane registry {}", self.root.display()))?
192 {
193 let entry = entry?;
194 let path = entry.path();
195 if path.extension().and_then(|e| e.to_str()) != Some("json") {
196 continue;
197 }
198 let text =
199 fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
200 match serde_json::from_str::<LaneRecord>(&text) {
201 Ok(record) => records.push(record),
202 Err(err) => {
203 // Skip corrupt records rather than failing the whole list.
204 eprintln!(
205 "warning: skip corrupt lane record {}: {err}",
206 path.display()
207 );
208 }
209 }
210 }
211 records.sort_by(|a, b| b.started_at.cmp(&a.started_at));
212 Ok(records)
213 }
214
215 /// Create a pending lane with log file reserved.
216 pub fn create_pending(
217 &self,
218 workflow: Option<String>,
219 fleet: Option<String>,
220 issue: Option<String>,
221 goal: Option<String>,
222 runtime: RuntimeBackendKind,
223 worktree_ttl_secs: Option<u64>,
224 ) -> Result<LaneRecord> {
225 let id = LaneRecord::new_id();
226 let log_path = self.log_path_for(&id);
227 // Touch the log so `lane logs` works immediately.
228 fs::write(&log_path, "").with_context(|| format!("create log {}", log_path.display()))?;
229 let record = LaneRecord {
230 id,
231 workflow,
232 fleet,
233 issue,
234 goal,
235 runtime,
236 status: LaneStatus::Pending,
237 lifecycle_seq: 1,
238 worktree_path: None,
239 branch: None,
240 tmux_session: None,
241 tmux_socket: None,
242 log_path,
243 started_at: LaneRecord::now_rfc3339(),
244 stopped_at: None,
245 attach_target: None,
246 worktree_ttl_secs,
247 };
248 self.save(&record)?;
249 Ok(record)
250 }
251
252 /// Atomically promote a Pending Lane to Running.
253 ///
254 /// A concurrent `lane stop` is allowed to win while a backend is still
255 /// launching. In that case this returns `false`, reloads the terminal
256 /// record, and the backend must tear down any process it just created.
257 pub fn mark_running_if_pending(&self, record: &mut LaneRecord) -> Result<bool> {
258 self.mark_running_if_pending_with(record, || Ok(()), || Ok(()))
259 }
260
261 /// Atomically launch backend state and promote a Pending Lane to Running.
262 ///
263 /// The per-Lane lock spans the durable Pending check, `before_transition`,
264 /// and the Running save. A concurrent stop therefore either wins before
265 /// launch (and this returns `false` without calling the closure) or waits
266 /// until the Running record is visible. Backend metadata is first saved in
267 /// the Pending record so a failed final save still leaves enough data for
268 /// a later stop. `rollback` is attempted if that final save fails.
269 pub fn mark_running_if_pending_with<Start, Rollback>(
270 &self,
271 record: &mut LaneRecord,
272 before_transition: Start,
273 rollback: Rollback,
274 ) -> Result<bool>
275 where
276 Start: FnOnce() -> Result<()>,
277 Rollback: FnOnce() -> Result<()>,
278 {
279 let lock_path = self.root.join(format!("{}.lock", record.id));
280 let lock_file = OpenOptions::new()
281 .create(true)
282 .truncate(false)
283 .read(true)
284 .write(true)
285 .open(&lock_path)
286 .with_context(|| format!("open lane lock {}", lock_path.display()))?;
287 let mut lock = fd_lock::RwLock::new(lock_file);
288 let _guard = lock
289 .write()
290 .with_context(|| format!("lock lane record {}", record.id))?;
291
292 let current = self.load(&record.id)?;
293 if current.status != LaneStatus::Pending {
294 *record = current;
295 return Ok(false);
296 }
297 record.lifecycle_seq = current.lifecycle_seq.max(1);
298
299 // `record` carries backend metadata (tmux session, worktree, attach
300 // target) populated during launch. Persist it while still Pending so
301 // a failed launch/final save remains discoverable and stoppable.
302 record.status = LaneStatus::Pending;
303 record.stopped_at = None;
304 self.save(record)?;
305
306 before_transition()?;
307 record.status = LaneStatus::Running;
308 record.lifecycle_seq = record.lifecycle_seq.saturating_add(1);
309 record.stopped_at = None;
310 if let Err(save_error) = self.save(record) {
311 record.status = LaneStatus::Pending;
312 record.lifecycle_seq = current.lifecycle_seq.max(1);
313 if let Err(rollback_error) = rollback() {
314 return Err(save_error).context(format!(
315 "persist running Lane; backend rollback also failed: {rollback_error:#}"
316 ));
317 }
318 return Err(save_error).context("persist running Lane after backend launch");
319 }
320 Ok(true)
321 }
322
323 /// Atomically transition an active Lane to a terminal state.
324 ///
325 /// Detached tmux reconciliation, an explicit stop, and a second status
326 /// reader can race in separate CLI processes. Serialize those terminal
327 /// decisions on a per-Lane advisory lock, reload the live record under the
328 /// lock, and only let the first active -> terminal transition win.
329 pub fn mark_terminal_if_active(
330 &self,
331 record: &mut LaneRecord,
332 status: LaneStatus,
333 ) -> Result<bool> {
334 self.mark_terminal_if_active_with(record, status, |_| Ok(()))
335 }
336
337 /// Atomically perform backend teardown and transition an active Lane.
338 ///
339 /// `before_transition` runs while holding the per-Lane lifecycle lock.
340 /// If teardown fails, the record remains active. This keeps a failed tmux
341 /// kill from being persisted as Stopped and prevents cleanup racing a
342 /// concurrent reconciliation decision.
343 pub fn mark_terminal_if_active_with<F>(
344 &self,
345 record: &mut LaneRecord,
346 status: LaneStatus,
347 before_transition: F,
348 ) -> Result<bool>
349 where
350 F: FnOnce(&LaneRecord) -> Result<()>,
351 {
352 self.mark_terminal_if_active_fenced(record, status, None, before_transition)
353 .map(TerminalTransition::transitioned)
354 }
355
356 /// [`mark_terminal_if_active_with`] with an optional lifecycle fence.
357 ///
358 /// `expected_lifecycle_seq` is evaluated **after** the live record is
359 /// reloaded under the per-Lane advisory lock, not by the caller before it.
360 /// A pre-lock check is a TOCTOU: another process can transition the record
361 /// between the caller's read and this write, and the caller would then act
362 /// on a generation it never observed. Checking here means a stale fence
363 /// refuses without running `before_transition`, so no backend teardown
364 /// happens for a run the caller did not actually target.
365 ///
366 /// [`mark_terminal_if_active_with`]: Self::mark_terminal_if_active_with
367 pub fn mark_terminal_if_active_fenced<F>(
368 &self,
369 record: &mut LaneRecord,
370 status: LaneStatus,
371 expected_lifecycle_seq: Option<u64>,
372 before_transition: F,
373 ) -> Result<TerminalTransition>
374 where
375 F: FnOnce(&LaneRecord) -> Result<()>,
376 {
377 if status.is_active() {
378 bail!("terminal lane transition requires a terminal status");
379 }
380
381 let lock_path = self.root.join(format!("{}.lock", record.id));
382 let lock_file = OpenOptions::new()
383 .create(true)
384 .truncate(false)
385 .read(true)
386 .write(true)
387 .open(&lock_path)
388 .with_context(|| format!("open lane lock {}", lock_path.display()))?;
389 let mut lock = fd_lock::RwLock::new(lock_file);
390 let _guard = lock
391 .write()
392 .with_context(|| format!("lock lane record {}", record.id))?;
393
394 let mut current = self.load(&record.id)?;
395 // Fence first: a mismatched generation must not run backend teardown.
396 if let Some(expected) = expected_lifecycle_seq
397 && expected != current.lifecycle_seq
398 {
399 let observed = current.lifecycle_seq;
400 *record = current;
401 return Ok(TerminalTransition::FenceMismatch { observed });
402 }
403 if !current.status.is_active() {
404 *record = current;
405 return Ok(TerminalTransition::AlreadyTerminal);
406 }
407 before_transition(&current)?;
408 current.lifecycle_seq = current.lifecycle_seq.max(1).saturating_add(1);
409 current.status = status;
410 current.stopped_at = Some(LaneRecord::now_rfc3339());
411 current.attach_target = None;
412 self.save(&current)?;
413 *record = current;
414 Ok(TerminalTransition::Transitioned)
415 }
416 }
417
418 #[cfg(test)]
419 mod tests {
420 use super::*;
421 use std::sync::atomic::{AtomicUsize, Ordering};
422 use std::sync::{Arc, mpsc};
423 use tempfile::tempdir;
424
425 #[test]
426 fn registry_persists_across_open() {
427 let dir = tempdir().unwrap();
428 let reg = LaneRegistry::open(dir.path()).unwrap();
429 let record = reg
430 .create_pending(
431 Some("stopship".into()),
432 Some("stopship".into()),
433 Some("4375".into()),
434 None,
435 RuntimeBackendKind::Tmux,
436 Some(3600),
437 )
438 .unwrap();
439 let id = record.id.clone();
440
441 let reg2 = LaneRegistry::open(dir.path()).unwrap();
442 let loaded = reg2.load(&id).unwrap();
443 assert_eq!(loaded.workflow.as_deref(), Some("stopship"));
444 assert_eq!(loaded.fleet.as_deref(), Some("stopship"));
445 assert_eq!(loaded.issue.as_deref(), Some("4375"));
446 assert_eq!(loaded.runtime, RuntimeBackendKind::Tmux);
447 assert_eq!(loaded.status, LaneStatus::Pending);
448 assert_eq!(loaded.lifecycle_seq, 1);
449 assert!(loaded.log_path.is_file() || loaded.log_path.exists());
450
451 let listed = reg2.list().unwrap();
452 assert_eq!(listed.len(), 1);
453 assert_eq!(listed[0].id, id);
454 }
455
456 #[test]
457 fn terminal_lane_cannot_launch_after_stop_wins() {
458 let dir = tempdir().unwrap();
459 let reg = LaneRegistry::open(dir.path()).unwrap();
460 let mut record = reg
461 .create_pending(None, None, None, None, RuntimeBackendKind::Tmux, None)
462 .unwrap();
463 assert!(
464 reg.mark_terminal_if_active(&mut record, LaneStatus::Stopped)
465 .unwrap()
466 );
467 let starts = AtomicUsize::new(0);
468 assert!(
469 !reg.mark_running_if_pending_with(
470 &mut record,
471 || {
472 starts.fetch_add(1, Ordering::SeqCst);
473 Ok(())
474 },
475 || Ok(()),
476 )
477 .unwrap()
478 );
479 assert_eq!(starts.load(Ordering::SeqCst), 0);
480 assert_eq!(record.status, LaneStatus::Stopped);
481 assert_eq!(record.lifecycle_seq, 2);
482 let loaded = reg.load(&record.id).unwrap();
483 assert_eq!(loaded.status, LaneStatus::Stopped);
484 assert_eq!(loaded.lifecycle_seq, 2);
485 }
486
487 #[test]
488 fn start_and_stop_are_serialized_across_backend_launch() {
489 let dir = tempdir().unwrap();
490 let reg = LaneRegistry::open(dir.path()).unwrap();
491 let record = reg
492 .create_pending(None, None, None, None, RuntimeBackendKind::Tmux, None)
493 .unwrap();
494 let id = record.id.clone();
495 let starts = Arc::new(AtomicUsize::new(0));
496 let teardowns = Arc::new(AtomicUsize::new(0));
497 let (entered_tx, entered_rx) = mpsc::channel();
498 let (release_tx, release_rx) = mpsc::channel();
499
500 let start_reg = reg.clone();
501 let start_count = Arc::clone(&starts);
502 let start_thread = std::thread::spawn(move || {
503 let mut record = record;
504 let started = start_reg
505 .mark_running_if_pending_with(
506 &mut record,
507 || {
508 start_count.fetch_add(1, Ordering::SeqCst);
509 entered_tx.send(()).unwrap();
510 release_rx.recv().unwrap();
511 Ok(())
512 },
513 || Ok(()),
514 )
515 .unwrap();
516 assert!(started);
517 });
518 entered_rx.recv().unwrap();
519
520 let stop_reg = reg.clone();
521 let stop_id = id.clone();
522 let teardown_count = Arc::clone(&teardowns);
523 let (stopped_tx, stopped_rx) = mpsc::channel();
524 let stop_thread = std::thread::spawn(move || {
525 let mut record = stop_reg.load(&stop_id).unwrap();
526 let stopped = stop_reg
527 .mark_terminal_if_active_with(&mut record, LaneStatus::Stopped, |_| {
528 teardown_count.fetch_add(1, Ordering::SeqCst);
529 Ok(())
530 })
531 .unwrap();
532 stopped_tx.send(stopped).unwrap();
533 });
534 assert!(matches!(
535 stopped_rx.try_recv(),
536 Err(mpsc::TryRecvError::Empty)
537 ));
538 release_tx.send(()).unwrap();
539 start_thread.join().unwrap();
540 assert!(stopped_rx.recv().unwrap());
541 stop_thread.join().unwrap();
542
543 assert_eq!(starts.load(Ordering::SeqCst), 1);
544 assert_eq!(teardowns.load(Ordering::SeqCst), 1);
545 let loaded = reg.load(&id).unwrap();
546 assert_eq!(loaded.status, LaneStatus::Stopped);
547 assert_eq!(
548 loaded.lifecycle_seq, 3,
549 "pending, running, and stopped are three durable owner states"
550 );
551 }
552
553 #[test]
554 fn teardown_failure_keeps_durable_lane_active() {
555 let dir = tempdir().unwrap();
556 let reg = LaneRegistry::open(dir.path()).unwrap();
557 let mut record = reg
558 .create_pending(None, None, None, None, RuntimeBackendKind::Tmux, None)
559 .unwrap();
560 assert!(reg.mark_running_if_pending(&mut record).unwrap());
561 let error = reg
562 .mark_terminal_if_active_with(&mut record, LaneStatus::Stopped, |_| {
563 bail!("backend still alive")
564 })
565 .unwrap_err();
566 assert!(error.to_string().contains("backend still alive"));
567 assert_eq!(record.status, LaneStatus::Running);
568 assert_eq!(record.lifecycle_seq, 2);
569 let loaded = reg.load(&record.id).unwrap();
570 assert_eq!(loaded.status, LaneStatus::Running);
571 assert_eq!(loaded.lifecycle_seq, 2);
572 }
573 }
574
574 lines RUST