Skip to main content

driven/strategy/
orchestration.rs

1use crate::{DrivenError, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use super::artifacts::{
7    contained_state_dir, ensure_artifact_path_in_state, handoff_path as build_handoff_path,
8    read_handoff_artifact, read_receipt_artifact, receipt_path as build_receipt_path,
9    write_file_atomic, write_handoff_artifact, write_receipt_artifact,
10};
11use super::session::{read_or_create_session, validate_claim_session, validate_receipt_session};
12use super::state_lock::StateLock;
13use super::worktree::normalize_worktree_root;
14use super::{
15    ClaimId, ClaimStatus, LaneClaim, LaneId, PassNumber, PassOutcome, ProofReceipt, WorkerId,
16    WorktreeIsolationPlan, WorktreeMetadata, detect_worktree_metadata,
17};
18
19const COUNTER_FILE: &str = "lane-counter.json";
20const CLAIMS_FILE: &str = "lane-claims.json";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct LanePassConfig {
24    pub state_dir: PathBuf,
25    pub scope: String,
26    pub max_lanes: u8,
27    pub max_passes: u32,
28    pub cycle_lanes: bool,
29    pub project_root: Option<PathBuf>,
30    pub handoff_required_for_next: bool,
31}
32
33impl LanePassConfig {
34    pub fn new(state_dir: impl Into<PathBuf>, scope: impl Into<String>) -> Self {
35        Self {
36            state_dir: state_dir.into(),
37            scope: scope.into(),
38            max_lanes: super::MAX_LANES,
39            max_passes: 3,
40            cycle_lanes: false,
41            project_root: None,
42            handoff_required_for_next: false,
43        }
44    }
45
46    pub fn with_max_lanes(mut self, max_lanes: u8) -> Self {
47        self.max_lanes = max_lanes;
48        self
49    }
50
51    pub fn with_max_passes(mut self, max_passes: u32) -> Self {
52        self.max_passes = max_passes;
53        self
54    }
55
56    pub fn with_lane_cycling(mut self, cycle_lanes: bool) -> Self {
57        self.cycle_lanes = cycle_lanes;
58        self
59    }
60
61    pub fn with_project_root(mut self, project_root: impl Into<PathBuf>) -> Self {
62        let project_root = project_root.into();
63        self.project_root = Some(normalize_worktree_root(&project_root));
64        self
65    }
66
67    pub fn with_handoff_required_for_next(mut self, required: bool) -> Self {
68        self.handoff_required_for_next = required;
69        self
70    }
71
72    fn validate(&self) -> Result<()> {
73        if self.scope.trim().is_empty() {
74            return Err(DrivenError::Validation(
75                "lane/pass scope cannot be empty".to_string(),
76            ));
77        }
78        if self.max_lanes == 0 || self.max_lanes > super::MAX_LANES {
79            return Err(DrivenError::Validation(format!(
80                "max lanes must be between 1 and {}",
81                super::MAX_LANES
82            )));
83        }
84        if self.max_passes == 0 {
85            return Err(DrivenError::Validation(
86                "max passes must be at least 1".to_string(),
87            ));
88        }
89        Ok(())
90    }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct LanePassStatePaths {
95    pub state_dir: PathBuf,
96    pub counter_path: PathBuf,
97    pub claims_path: PathBuf,
98    pub worker_path: PathBuf,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum LanePassAssignmentStatus {
104    Peeked,
105    Claimed,
106    Advanced,
107    Completed,
108    Released,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct LanePassAssignment {
113    pub status: LanePassAssignmentStatus,
114    pub scope: String,
115    pub lane: LaneId,
116    pub pass: PassNumber,
117    pub worker_id: WorkerId,
118    pub claim: LaneClaim,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub receipt_id: Option<String>,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub receipt_path: Option<PathBuf>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub handoff_id: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub handoff_path: Option<PathBuf>,
127    pub paths: LanePassStatePaths,
128    pub worktree: WorktreeMetadata,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub worktree_plan: Option<WorktreeIsolationPlan>,
131}
132
133impl LanePassAssignment {
134    pub fn validate(&self) -> Result<()> {
135        self.claim.validate()?;
136        if self.scope.trim().is_empty() {
137            return Err(DrivenError::Validation(
138                "lane/pass assignment scope cannot be empty".to_string(),
139            ));
140        }
141        if self.claim.scope != self.scope {
142            return Err(DrivenError::Validation(
143                "assignment scope does not match claim scope".to_string(),
144            ));
145        }
146        if self.claim.lane != self.lane {
147            return Err(DrivenError::Validation(
148                "assignment lane does not match claim lane".to_string(),
149            ));
150        }
151        if self.claim.pass != self.pass {
152            return Err(DrivenError::Validation(
153                "assignment pass does not match claim pass".to_string(),
154            ));
155        }
156        if self.claim.worker_id != self.worker_id {
157            return Err(DrivenError::Validation(
158                "assignment worker does not match claim worker".to_string(),
159            ));
160        }
161        let expected_claim_status = match self.status {
162            LanePassAssignmentStatus::Peeked
163            | LanePassAssignmentStatus::Claimed
164            | LanePassAssignmentStatus::Advanced => ClaimStatus::Claimed,
165            LanePassAssignmentStatus::Completed => ClaimStatus::Completed,
166            LanePassAssignmentStatus::Released => ClaimStatus::Released,
167        };
168        if self.claim.status != expected_claim_status {
169            return Err(DrivenError::Validation(
170                "assignment status does not match claim status".to_string(),
171            ));
172        }
173        if let Some(receipt_id) = &self.receipt_id
174            && receipt_id.trim().is_empty()
175        {
176            return Err(DrivenError::Validation(
177                "assignment receipt id cannot be empty".to_string(),
178            ));
179        }
180        if let Some(receipt_path) = &self.receipt_path
181            && receipt_path.as_os_str().is_empty()
182        {
183            return Err(DrivenError::Validation(
184                "assignment receipt path cannot be empty".to_string(),
185            ));
186        }
187        if let Some(handoff_id) = &self.handoff_id
188            && handoff_id.trim().is_empty()
189        {
190            return Err(DrivenError::Validation(
191                "assignment handoff id cannot be empty".to_string(),
192            ));
193        }
194        if let Some(handoff_path) = &self.handoff_path
195            && handoff_path.as_os_str().is_empty()
196        {
197            return Err(DrivenError::Validation(
198                "assignment handoff path cannot be empty".to_string(),
199            ));
200        }
201        if self.receipt_id.is_some() != self.receipt_path.is_some() {
202            return Err(DrivenError::Validation(
203                "assignment receipt id and path must be recorded together".to_string(),
204            ));
205        }
206        if self.handoff_id.is_some() != self.handoff_path.is_some() {
207            return Err(DrivenError::Validation(
208                "assignment handoff id and path must be recorded together".to_string(),
209            ));
210        }
211        if let (Some(receipt_id), Some(handoff_id)) = (&self.receipt_id, &self.handoff_id)
212            && receipt_id != handoff_id
213        {
214            return Err(DrivenError::Validation(
215                "assignment receipt and handoff ids must match".to_string(),
216            ));
217        }
218        Ok(())
219    }
220}
221
222#[derive(Debug, Clone)]
223pub struct LanePassStore {
224    config: LanePassConfig,
225}
226
227impl LanePassStore {
228    pub fn new(config: LanePassConfig) -> Result<Self> {
229        config.validate()?;
230        Ok(Self { config })
231    }
232
233    pub fn peek_next_claim(&self) -> Result<LanePassAssignment> {
234        let lane = self.peek_next_lane()?;
235        let worker_id = WorkerId::new("unclaimed")?;
236        let execution_root = self.execution_root();
237        let worktree = detect_worktree_metadata(&execution_root);
238        let worktree_plan = WorktreeIsolationPlan::from_metadata(worktree.clone());
239        let claim = LaneClaim::new(
240            lane,
241            PassNumber::first(),
242            worker_id.clone(),
243            self.config.scope.clone(),
244        );
245
246        Ok(LanePassAssignment {
247            status: LanePassAssignmentStatus::Peeked,
248            scope: self.config.scope.clone(),
249            lane,
250            pass: PassNumber::first(),
251            worker_id,
252            claim,
253            receipt_id: None,
254            receipt_path: None,
255            handoff_id: None,
256            handoff_path: None,
257            paths: self.paths_for_worker("unclaimed")?,
258            worktree,
259            worktree_plan: Some(worktree_plan),
260        })
261    }
262
263    pub fn claim(&self, worker_id: WorkerId) -> Result<LanePassAssignment> {
264        let state_dir = contained_state_dir(&self.config.state_dir)?;
265        fs::create_dir_all(&state_dir).map_err(DrivenError::Io)?;
266        let _lock = StateLock::acquire(&state_dir)?;
267
268        if let Some(existing) = self.read_worker_assignment(&worker_id)?
269            && existing.claim.status == ClaimStatus::Claimed
270        {
271            self.validate_current_worktree_identity(&existing)?;
272            return Ok(existing);
273        }
274
275        let lane = self.peek_next_lane()?;
276        let pass = PassNumber::first();
277        let claim_id = self.next_claim_id()?;
278        let assignment = self.build_assignment(
279            LanePassAssignmentStatus::Claimed,
280            lane,
281            pass,
282            worker_id,
283            ClaimStatus::Claimed,
284            claim_id,
285        )?;
286        self.write_assignment(&assignment)?;
287        Ok(assignment)
288    }
289
290    pub fn worker_assignment(&self, worker_id: &WorkerId) -> Result<Option<LanePassAssignment>> {
291        let state_dir = contained_state_dir(&self.config.state_dir)?;
292        if !state_dir.exists() {
293            return Ok(None);
294        }
295        let _lock = StateLock::acquire(&state_dir)?;
296        self.read_worker_assignment(worker_id)
297    }
298
299    pub fn next_pass(&self, worker_id: &WorkerId) -> Result<LanePassAssignment> {
300        if self.config.handoff_required_for_next {
301            return Err(DrivenError::Validation(
302                "durable handoff is required for next-pass advancement; use next_pass_with_handoff"
303                    .to_string(),
304            ));
305        }
306        let state_dir = contained_state_dir(&self.config.state_dir)?;
307        fs::create_dir_all(&state_dir).map_err(DrivenError::Io)?;
308        let _lock = StateLock::acquire(&state_dir)?;
309
310        let current = self.read_worker_assignment(worker_id)?.ok_or_else(|| {
311            DrivenError::Validation(format!("worker {} has no lane claim", worker_id))
312        })?;
313        if current.claim.status != ClaimStatus::Claimed {
314            return Err(DrivenError::Validation(format!(
315                "worker {} lane claim is not active",
316                worker_id
317            )));
318        }
319        self.validate_current_worktree_identity(&current)?;
320        let next_pass = current.pass.next()?;
321        if next_pass.value() > self.config.max_passes {
322            return Err(DrivenError::Validation(format!(
323                "worker {} reached max passes ({})",
324                worker_id, self.config.max_passes
325            )));
326        }
327
328        let claim_id = self.next_claim_id()?;
329        let assignment = self.build_assignment(
330            LanePassAssignmentStatus::Advanced,
331            current.lane,
332            next_pass,
333            worker_id.clone(),
334            ClaimStatus::Claimed,
335            claim_id,
336        )?;
337        self.write_assignment(&assignment)?;
338        Ok(assignment)
339    }
340
341    pub fn next_pass_with_handoff(
342        &self,
343        worker_id: &WorkerId,
344        receipt: &ProofReceipt,
345        next_action: &str,
346    ) -> Result<LanePassAssignment> {
347        let state_dir = contained_state_dir(&self.config.state_dir)?;
348        fs::create_dir_all(&state_dir).map_err(DrivenError::Io)?;
349        let _lock = StateLock::acquire(&state_dir)?;
350
351        let current = self.read_worker_assignment(worker_id)?.ok_or_else(|| {
352            DrivenError::Validation(format!("worker {} has no lane claim", worker_id))
353        })?;
354        if current.claim.status != ClaimStatus::Claimed {
355            return Err(DrivenError::Validation(format!(
356                "worker {} lane claim is not active",
357                worker_id
358            )));
359        }
360        self.validate_current_worktree_identity(&current)?;
361
362        let mut receipt = receipt.clone();
363        if receipt.redacted {
364            return Err(DrivenError::Validation(
365                "next-pass handoff requires an unredacted proof receipt".to_string(),
366            ));
367        }
368        if receipt.receipt_id.trim().is_empty() {
369            receipt.receipt_id = receipt.receipt_id()?;
370        }
371        receipt.validate()?;
372        receipt.validate_worktree_identity(&current.worktree.identity())?;
373        validate_receipt_session(&receipt.claim, &current.claim)?;
374        let mut handoff = current
375            .claim
376            .next_pass_handoff(receipt.clone(), next_action.to_string())?;
377        handoff.worktree_identity = Some(current.worktree.identity());
378        handoff.validate_against(&current.claim)?;
379        if handoff.outcome == PassOutcome::Blocked {
380            return Err(DrivenError::Validation(
381                "blocked handoff cannot advance the next pass".to_string(),
382            ));
383        }
384        if handoff.next_pass.value() > self.config.max_passes {
385            return Err(DrivenError::Validation(format!(
386                "worker {} reached max passes ({})",
387                worker_id, self.config.max_passes
388            )));
389        }
390
391        let claim_id = self.next_claim_id()?;
392        let mut assignment = self.build_assignment(
393            LanePassAssignmentStatus::Advanced,
394            current.lane,
395            handoff.next_pass,
396            worker_id.clone(),
397            ClaimStatus::Claimed,
398            claim_id,
399        )?;
400        let receipt_path = build_receipt_path(&self.config.state_dir, &receipt.receipt_id)?;
401        let handoff_path = build_handoff_path(&self.config.state_dir, &handoff.receipt_id)?;
402        assignment.receipt_id = Some(receipt.receipt_id.clone());
403        assignment.receipt_path = Some(receipt_path.clone());
404        assignment.handoff_id = Some(handoff.receipt_id.clone());
405        assignment.handoff_path = Some(handoff_path.clone());
406        write_receipt_artifact(&receipt_path, &receipt)?;
407        write_handoff_artifact(&handoff_path, &handoff)?;
408        self.write_assignment(&assignment)?;
409        Ok(assignment)
410    }
411
412    pub fn complete_pass(&self, worker_id: &WorkerId) -> Result<LanePassAssignment> {
413        let _ = worker_id;
414        Err(DrivenError::Validation(
415            "completion requires a canonical proof receipt; use complete_pass_with_receipt"
416                .to_string(),
417        ))
418    }
419
420    pub fn complete_pass_with_receipt(
421        &self,
422        worker_id: &WorkerId,
423        receipt: &ProofReceipt,
424    ) -> Result<LanePassAssignment> {
425        let state_dir = contained_state_dir(&self.config.state_dir)?;
426        if !state_dir.exists() {
427            return Err(DrivenError::Validation(format!(
428                "worker {} has no lane claim",
429                worker_id
430            )));
431        }
432        fs::create_dir_all(&state_dir).map_err(DrivenError::Io)?;
433        let _lock = StateLock::acquire(&state_dir)?;
434
435        let current = self.read_worker_assignment(worker_id)?.ok_or_else(|| {
436            DrivenError::Validation(format!("worker {} has no lane claim", worker_id))
437        })?;
438        if current.claim.status != ClaimStatus::Claimed {
439            return Err(DrivenError::Validation(format!(
440                "worker {} lane claim is not active",
441                worker_id
442            )));
443        }
444        self.validate_current_worktree_identity(&current)?;
445        receipt.validate_completion_readiness()?;
446        receipt.validate_completion_claim_subject(&current.claim)?;
447        receipt.validate_worktree_identity(&current.worktree.identity())?;
448        validate_receipt_session(&receipt.claim, &current.claim)?;
449        receipt.validate_completion_claim_match(&current.claim)?;
450
451        let mut assignment = self.build_assignment(
452            LanePassAssignmentStatus::Completed,
453            current.lane,
454            current.pass,
455            worker_id.clone(),
456            ClaimStatus::Completed,
457            current.claim.claim_id.clone(),
458        )?;
459        let receipt_path = build_receipt_path(&self.config.state_dir, &receipt.receipt_id)?;
460        assignment.receipt_id = Some(receipt.receipt_id.clone());
461        assignment.receipt_path = Some(receipt_path.clone());
462        write_receipt_artifact(&receipt_path, receipt)?;
463        self.write_assignment(&assignment)?;
464        Ok(assignment)
465    }
466
467    pub fn release_lane(&self, worker_id: &WorkerId) -> Result<LanePassAssignment> {
468        self.update_claim_status(
469            worker_id,
470            LanePassAssignmentStatus::Released,
471            ClaimStatus::Released,
472        )
473    }
474
475    fn peek_next_lane(&self) -> Result<LaneId> {
476        let counter = self.read_counter()?.max(self.highest_recorded_lane()?);
477        let next = counter.saturating_add(1);
478        if next > self.config.max_lanes {
479            if self.config.cycle_lanes {
480                self.first_available_lane()
481            } else {
482                Err(DrivenError::Validation(format!(
483                    "max lanes ({}) reached",
484                    self.config.max_lanes
485                )))
486            }
487        } else {
488            LaneId::new(next)
489        }
490    }
491
492    fn build_assignment(
493        &self,
494        status: LanePassAssignmentStatus,
495        lane: LaneId,
496        pass: PassNumber,
497        worker_id: WorkerId,
498        claim_status: ClaimStatus,
499        claim_id: ClaimId,
500    ) -> Result<LanePassAssignment> {
501        let session = read_or_create_session(&self.config.state_dir, &self.config.scope)?;
502        let mut claim = LaneClaim::new(lane, pass, worker_id.clone(), self.config.scope.clone())
503            .with_claim_id(claim_id)
504            .with_state_session_id(session.state_session_id);
505        claim.status = claim_status;
506        let paths = self.paths_for_worker(worker_id.as_str())?;
507        let execution_root = self.execution_root();
508        let worktree = detect_worktree_metadata(&execution_root);
509        let worktree_plan = Some(WorktreeIsolationPlan::from_metadata(worktree.clone()));
510
511        Ok(LanePassAssignment {
512            status,
513            scope: self.config.scope.clone(),
514            lane,
515            pass,
516            worker_id,
517            claim,
518            receipt_id: None,
519            receipt_path: None,
520            handoff_id: None,
521            handoff_path: None,
522            paths,
523            worktree,
524            worktree_plan,
525        })
526    }
527
528    fn next_claim_id(&self) -> Result<ClaimId> {
529        let paths = self.paths_for_worker("claim-sequence")?;
530        let next = self
531            .read_claims(&paths.claims_path)?
532            .iter()
533            .filter_map(|assignment| assignment.claim.claim_id.sequence())
534            .max()
535            .unwrap_or(0)
536            .checked_add(1)
537            .ok_or_else(|| DrivenError::Validation("claim id sequence overflow".to_string()))?;
538        Ok(ClaimId::from_sequence(next))
539    }
540
541    fn paths_for_worker(&self, worker_id: &str) -> Result<LanePassStatePaths> {
542        let state_dir = contained_state_dir(&self.config.state_dir)?;
543        let worker_file = format!("worker-{}.json", safe_component(worker_id)?);
544        Ok(LanePassStatePaths {
545            counter_path: state_dir.join(COUNTER_FILE),
546            claims_path: state_dir.join(CLAIMS_FILE),
547            worker_path: state_dir.join("workers").join(worker_file),
548            state_dir,
549        })
550    }
551
552    fn read_worker_assignment(&self, worker_id: &WorkerId) -> Result<Option<LanePassAssignment>> {
553        let path = self.paths_for_worker(worker_id.as_str())?.worker_path;
554        if !path.exists() {
555            return Ok(None);
556        }
557        let content = fs::read_to_string(&path).map_err(DrivenError::Io)?;
558        let assignment: LanePassAssignment = serde_json::from_str(&content)
559            .map_err(|e| DrivenError::Parse(format!("failed to parse worker claim: {}", e)))?;
560        assignment.validate()?;
561        if &assignment.worker_id != worker_id {
562            return Err(DrivenError::Validation(format!(
563                "worker state file belongs to {}, not {}",
564                assignment.worker_id, worker_id
565            )));
566        }
567        if assignment.scope != self.config.scope {
568            return Err(DrivenError::Validation(format!(
569                "worker {} lane claim scope {} does not match store scope {}",
570                worker_id, assignment.scope, self.config.scope
571            )));
572        }
573        let session = read_or_create_session(&self.config.state_dir, &self.config.scope)?;
574        validate_claim_session(&assignment.claim, &session, "assignment")?;
575        self.validate_assignment_artifacts(&assignment)?;
576        Ok(Some(assignment))
577    }
578
579    fn update_claim_status(
580        &self,
581        worker_id: &WorkerId,
582        assignment_status: LanePassAssignmentStatus,
583        claim_status: ClaimStatus,
584    ) -> Result<LanePassAssignment> {
585        let state_dir = contained_state_dir(&self.config.state_dir)?;
586        fs::create_dir_all(&state_dir).map_err(DrivenError::Io)?;
587        let _lock = StateLock::acquire(&state_dir)?;
588
589        let current = self.read_worker_assignment(worker_id)?.ok_or_else(|| {
590            DrivenError::Validation(format!("worker {} has no lane claim", worker_id))
591        })?;
592        if current.claim.status != ClaimStatus::Claimed {
593            return Err(DrivenError::Validation(format!(
594                "worker {} lane claim is not active",
595                worker_id
596            )));
597        }
598        self.validate_current_worktree_identity(&current)?;
599
600        let assignment = self.build_assignment(
601            assignment_status,
602            current.lane,
603            current.pass,
604            worker_id.clone(),
605            claim_status,
606            current.claim.claim_id.clone(),
607        )?;
608        self.write_assignment(&assignment)?;
609        Ok(assignment)
610    }
611
612    fn write_assignment(&self, assignment: &LanePassAssignment) -> Result<()> {
613        let paths = &assignment.paths;
614        fs::create_dir_all(&paths.state_dir).map_err(DrivenError::Io)?;
615        if let Some(parent) = paths.worker_path.parent() {
616            fs::create_dir_all(parent).map_err(DrivenError::Io)?;
617        }
618
619        let worker_content = serde_json::to_string_pretty(assignment)
620            .map(|mut value| {
621                value.push('\n');
622                value
623            })
624            .map_err(|e| DrivenError::Format(format!("failed to render lane claim: {}", e)))?;
625
626        let mut claims = self.read_claims(&paths.claims_path)?;
627        claims.push(assignment.clone());
628        let claims_content = serde_json::to_string_pretty(&claims)
629            .map(|mut value| {
630                value.push('\n');
631                value
632            })
633            .map_err(|e| DrivenError::Format(format!("failed to render claims: {}", e)))?;
634        let counter_content = serde_json::to_string_pretty(&LaneCounter {
635            last_lane: assignment.lane.value(),
636        })
637        .map(|mut value| {
638            value.push('\n');
639            value
640        })
641        .map_err(|e| DrivenError::Format(format!("failed to render lane counter: {}", e)))?;
642
643        write_file_atomic(&paths.claims_path, claims_content.as_bytes())?;
644        write_file_atomic(&paths.worker_path, worker_content.as_bytes())?;
645        write_file_atomic(&paths.counter_path, counter_content.as_bytes())
646    }
647
648    fn validate_assignment_artifacts(&self, assignment: &LanePassAssignment) -> Result<()> {
649        if let (Some(receipt_id), Some(receipt_path)) =
650            (&assignment.receipt_id, &assignment.receipt_path)
651        {
652            ensure_artifact_path_in_state(&self.config.state_dir, receipt_path, "receipt")?;
653            let canonical_receipt_path = build_receipt_path(&self.config.state_dir, receipt_id)?;
654            if receipt_path != &canonical_receipt_path {
655                return Err(DrivenError::Validation(
656                    "assignment receipt path must match canonical receipt path".to_string(),
657                ));
658            }
659            let stored_receipt = read_receipt_artifact(receipt_path)?;
660            stored_receipt.validate()?;
661            if stored_receipt.receipt_id != *receipt_id {
662                return Err(DrivenError::Validation(
663                    "stored receipt id does not match assignment receipt id".to_string(),
664                ));
665            }
666            let expected_receipt_pass = if assignment.handoff_id.is_some() {
667                assignment
668                    .pass
669                    .value()
670                    .checked_sub(1)
671                    .filter(|value| *value > 0)
672                    .ok_or_else(|| {
673                        DrivenError::Validation(
674                            "handoff receipt cannot belong to pass zero".to_string(),
675                        )
676                    })
677                    .and_then(PassNumber::new)?
678            } else {
679                assignment.pass
680            };
681            if stored_receipt.claim.lane != assignment.lane
682                || stored_receipt.claim.pass != expected_receipt_pass
683                || stored_receipt.claim.worker_id != assignment.worker_id
684                || stored_receipt.claim.scope != assignment.scope
685            {
686                return Err(DrivenError::Validation(
687                    "stored receipt claim does not match assignment claim".to_string(),
688                ));
689            }
690            validate_receipt_session(&stored_receipt.claim, &assignment.claim)?;
691            stored_receipt.validate_worktree_identity(&assignment.worktree.identity())?;
692            if assignment.handoff_id.is_none()
693                && (stored_receipt.claim.claim_id != assignment.claim.claim_id
694                    || stored_receipt.claim.token != assignment.claim.token)
695            {
696                return Err(DrivenError::Validation(
697                    "stored receipt claim does not match assignment claim".to_string(),
698                ));
699            }
700        }
701
702        if let (Some(handoff_id), Some(handoff_path)) =
703            (&assignment.handoff_id, &assignment.handoff_path)
704        {
705            ensure_artifact_path_in_state(&self.config.state_dir, handoff_path, "handoff")?;
706            let canonical_handoff_path = build_handoff_path(&self.config.state_dir, handoff_id)?;
707            if handoff_path != &canonical_handoff_path {
708                return Err(DrivenError::Validation(
709                    "assignment handoff path must match canonical handoff path".to_string(),
710                ));
711            }
712            let stored_handoff = read_handoff_artifact(handoff_path)?;
713            if stored_handoff.worktree_identity.is_none() {
714                return Err(DrivenError::Validation(
715                    "stored handoff worktree identity is required".to_string(),
716                ));
717            }
718            if stored_handoff.receipt_id != *handoff_id {
719                return Err(DrivenError::Validation(
720                    "stored handoff receipt id does not match assignment handoff id".to_string(),
721                ));
722            }
723            if let Some(receipt_id) = &assignment.receipt_id
724                && stored_handoff.receipt_id != *receipt_id
725            {
726                return Err(DrivenError::Validation(
727                    "stored handoff receipt id does not match assignment receipt id".to_string(),
728                ));
729            }
730            if stored_handoff.lane != assignment.lane
731                || stored_handoff.worker_id != assignment.worker_id
732                || stored_handoff.next_pass != assignment.pass
733            {
734                return Err(DrivenError::Validation(
735                    "stored handoff does not match assignment lane/pass/worker".to_string(),
736                ));
737            }
738            let receipt_path = assignment.receipt_path.as_ref().ok_or_else(|| {
739                DrivenError::Validation(
740                    "stored handoff requires a receipt artifact for claim validation".to_string(),
741                )
742            })?;
743            let stored_receipt = read_receipt_artifact(receipt_path)?;
744            if stored_handoff.from_claim != stored_receipt.claim.token {
745                return Err(DrivenError::Validation(
746                    "stored handoff claim token does not match assignment claim".to_string(),
747                ));
748            }
749            let expected_handoff = stored_receipt
750                .claim
751                .next_pass_handoff(stored_receipt.clone(), stored_handoff.next_action.clone())?
752                .redacted_for_persistence();
753            if stored_handoff.summary != expected_handoff.summary {
754                return Err(DrivenError::Validation(
755                    "stored handoff summary does not match receipt summary".to_string(),
756                ));
757            }
758            if stored_handoff.outcome != expected_handoff.outcome {
759                return Err(DrivenError::Validation(
760                    "stored handoff outcome does not match receipt outcome".to_string(),
761                ));
762            }
763            if stored_handoff.blockers != expected_handoff.blockers {
764                return Err(DrivenError::Validation(
765                    "stored handoff blockers do not match receipt blockers".to_string(),
766                ));
767            }
768            let identity = stored_handoff.worktree_identity.as_ref().ok_or_else(|| {
769                DrivenError::Validation("stored handoff worktree identity is required".to_string())
770            })?;
771            if identity != &assignment.worktree.identity() {
772                return Err(DrivenError::Validation(
773                    "stored handoff worktree identity does not match assignment worktree"
774                        .to_string(),
775                ));
776            }
777            stored_handoff.validate_persisted()?;
778        }
779
780        Ok(())
781    }
782
783    pub(crate) fn validate_current_worktree_identity(
784        &self,
785        assignment: &LanePassAssignment,
786    ) -> Result<()> {
787        let current = detect_worktree_metadata(&self.execution_root());
788        if !assignment.worktree.has_same_identity(&current) {
789            return Err(DrivenError::Validation(
790                "worktree identity changed since the active lane claim".to_string(),
791            ));
792        }
793        Ok(())
794    }
795
796    fn read_claims(&self, path: &Path) -> Result<Vec<LanePassAssignment>> {
797        if !path.exists() {
798            return Ok(Vec::new());
799        }
800        let content = fs::read_to_string(path).map_err(DrivenError::Io)?;
801        let claims: Vec<LanePassAssignment> = serde_json::from_str(&content)
802            .map_err(|e| DrivenError::Parse(format!("failed to parse claims: {}", e)))?;
803        for claim in &claims {
804            claim.validate()?;
805        }
806        Ok(claims)
807    }
808
809    fn read_counter(&self) -> Result<u8> {
810        let path = contained_state_dir(&self.config.state_dir)?.join(COUNTER_FILE);
811        if !path.exists() {
812            return Ok(0);
813        }
814        let content = fs::read_to_string(&path).map_err(DrivenError::Io)?;
815        serde_json::from_str::<LaneCounter>(&content)
816            .map(|counter| counter.last_lane)
817            .map_err(|e| DrivenError::Parse(format!("failed to parse lane counter: {}", e)))
818    }
819
820    fn highest_recorded_lane(&self) -> Result<u8> {
821        let paths = self.paths_for_worker("scan")?;
822        Ok(self
823            .read_claims(&paths.claims_path)?
824            .iter()
825            .map(|assignment| assignment.lane.value())
826            .max()
827            .unwrap_or(0))
828    }
829
830    fn first_available_lane(&self) -> Result<LaneId> {
831        let paths = self.paths_for_worker("scan")?;
832        let claims = self.read_claims(&paths.claims_path)?;
833        let current_worktree = detect_worktree_metadata(&self.execution_root());
834        for lane in 1..=self.config.max_lanes {
835            let latest = claims
836                .iter()
837                .rev()
838                .find(|assignment| assignment.lane.value() == lane);
839            match latest {
840                None => return LaneId::new(lane),
841                Some(assignment) if assignment.claim.status == ClaimStatus::Claimed => {}
842                Some(assignment) if assignment.worktree.has_same_identity(&current_worktree) => {
843                    return LaneId::new(lane);
844                }
845                Some(_) => {}
846            }
847        }
848        Err(DrivenError::Validation(format!(
849            "max lanes ({}) reached and no released lane is available for the current worktree",
850            self.config.max_lanes
851        )))
852    }
853
854    fn execution_root(&self) -> PathBuf {
855        if let Some(project_root) = &self.config.project_root {
856            return project_root.clone();
857        }
858        if self.config.state_dir.is_relative() {
859            return PathBuf::from(".");
860        }
861        self.config
862            .state_dir
863            .parent()
864            .unwrap_or_else(|| Path::new("."))
865            .to_path_buf()
866    }
867}
868
869#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
870struct LaneCounter {
871    last_lane: u8,
872}
873
874fn safe_component(raw: &str) -> Result<String> {
875    let worker = WorkerId::new(raw)?;
876    Ok(worker.as_str().to_string())
877}