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(¤t)?;
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(¤t)?;
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(¤t.worktree.identity())?;
373 validate_receipt_session(&receipt.claim, ¤t.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(¤t.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(¤t)?;
445 receipt.validate_completion_readiness()?;
446 receipt.validate_completion_claim_subject(¤t.claim)?;
447 receipt.validate_worktree_identity(¤t.worktree.identity())?;
448 validate_receipt_session(&receipt.claim, ¤t.claim)?;
449 receipt.validate_completion_claim_match(¤t.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(¤t)?;
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(¤t) {
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(¤t_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}