1use std::collections::{HashMap, HashSet};
32use std::path::{Path, PathBuf};
33use std::sync::Arc;
34use std::time::Duration;
35
36use crate::audit::{AuditEvent, AuditSink, NullAuditSink};
37use crate::plan::{
38 AcceptRequest, AcquireRequest, BlockedDeliverable, CallerId, Cohort, CohortRow,
39 ComparePlansRequest, Deliverable, DeliverableStatus, FINISH_ID, FileMode, ForceReleaseRequest,
40 ForkRequest, HeartbeatRequest, LockInfo, MAX_DELIVERABLES, MarkStatusRequest, OwnedFile,
41 PlanDefinition, PlanGraph, PlanId, PlanLineSummary, PlanStatus, PlannerError, ReviseRequest,
42 START_ID, ScheduleRow, SelectOutcome, SyncOutcome, SyncRequest,
43};
44use crate::plan_store::SqlitePlanStore;
45use crate::portfolio::{Archived, Revised, Selected, Synced, VariantInfo};
46use crate::ports::Planner;
47use crate::project::ProjectRoot;
48use crate::revise::RevisionDiff;
49use async_trait::async_trait;
50use chrono::{DateTime, Utc};
51use execution_policy::classify::{FailureClass, RetryDecision};
52use serde_json::json;
53
54use crate::locks::{FileClaim, PlanState, add_file_claims, modes_conflict};
55
56mod ev;
57
58pub const DEFAULT_TTL: Duration = Duration::from_secs(5 * 60);
61
62pub const DEFAULT_MAX_TTL: Duration = Duration::from_secs(8 * 60 * 60);
67
68pub const MAX_TTL_CEILING: Duration = Duration::from_secs(30 * 24 * 60 * 60);
70
71pub const MAX_ATTEMPTS: u32 = 3;
87
88pub const MAX_LAPSES: u32 = 10;
98
99fn lease_retry_decision(failure_count: u32) -> RetryDecision {
104 if failure_count >= MAX_ATTEMPTS {
105 RetryDecision::Stop
106 } else {
107 RetryDecision::Retry
108 }
109}
110
111pub const DEFAULT_EFFORT_HOURS: f32 = 1.0;
119
120pub type ClockFn = Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>;
123
124pub struct BasicCpmPlanner {
126 store: SqlitePlanStore,
129 audit: Arc<dyn AuditSink>,
130 ttl: Duration,
131 max_ttl: Duration,
132 clock: ClockFn,
133 project_root: Option<ProjectRoot>,
136}
137
138impl BasicCpmPlanner {
139 pub fn new() -> Self {
143 Self::with_audit(Arc::new(NullAuditSink))
144 }
145
146 pub fn with_audit(audit: Arc<dyn AuditSink>) -> Self {
149 Self::with_parts(audit, DEFAULT_TTL, Arc::new(Utc::now))
150 }
151
152 pub async fn simulate_plan(
155 &self,
156 plan_id: &PlanId,
157 req: &crate::simulate::SimulateRequest,
158 ) -> Result<crate::simulate::SimulationResult, PlannerError> {
159 let graph = self.get_plan(plan_id).await?.graph;
160 crate::simulate::simulate(&graph, req)
161 }
162
163 pub fn with_ttl(mut self, ttl: Duration) -> Self {
165 self.ttl = ttl;
166 self
167 }
168
169 pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
173 self.max_ttl = max_ttl.min(MAX_TTL_CEILING);
174 self
175 }
176
177 pub fn with_clock(mut self, clock: ClockFn) -> Self {
180 self.clock = clock;
181 self
182 }
183
184 pub fn with_parts(audit: Arc<dyn AuditSink>, ttl: Duration, clock: ClockFn) -> Self {
187 let store = SqlitePlanStore::open_in_memory()
188 .expect("INVARIANT: opening a private in-memory sqlite database cannot fail");
189 Self::with_store_parts(store, audit, ttl, clock)
190 }
191
192 pub fn with_store(store: SqlitePlanStore, audit: Arc<dyn AuditSink>) -> Self {
196 Self::with_store_parts(store, audit, DEFAULT_TTL, Arc::new(Utc::now))
197 }
198
199 pub fn with_store_parts(
201 store: SqlitePlanStore,
202 audit: Arc<dyn AuditSink>,
203 ttl: Duration,
204 clock: ClockFn,
205 ) -> Self {
206 Self {
207 store,
208 audit,
209 ttl,
210 max_ttl: DEFAULT_MAX_TTL,
211 clock,
212 project_root: None,
213 }
214 }
215
216 pub fn with_project_root(mut self, root: ProjectRoot) -> Self {
221 self.project_root = Some(root);
222 self
223 }
224
225 #[cfg(test)]
227 pub(crate) fn store(&self) -> &SqlitePlanStore {
228 &self.store
229 }
230
231 pub fn project_root(&self) -> Option<&ProjectRoot> {
234 self.project_root.as_ref()
235 }
236
237 pub fn compare_inputs(
245 &self,
246 req: &ComparePlansRequest,
247 ) -> Result<Vec<(PlanId, String, PlanGraph)>, PlannerError> {
248 if let Some(ids) = &req.plan_ids {
249 let distinct: HashSet<&str> = ids.iter().map(|id| id.0.as_str()).collect();
250 if distinct.len() != ids.len() {
251 return Err(PlannerError::InvalidGraph {
252 reason: "plan_ids must be distinct".to_string(),
253 });
254 }
255 crate::compare::check_variant_count(ids.len())?;
256 }
257 self.store.read_tx(|tx| {
258 let targets: Vec<(PlanId, Option<String>)> = match (&req.plan_ids, &req.plan) {
259 (Some(ids), None) => ids.iter().map(|id| (id.clone(), None)).collect(),
260 (None, Some((project, name))) => {
261 crate::portfolio::live_variants(tx, project, name)?
262 .into_iter()
263 .map(|(variant, id)| (id, Some(variant)))
264 .collect()
265 }
266 _ => {
267 return Err(PlannerError::InvalidGraph {
268 reason: "compare takes exactly one of plan_ids or plan".to_string(),
269 });
270 }
271 };
272 crate::compare::check_variant_count(targets.len())?;
273 targets
274 .into_iter()
275 .map(|(id, label)| {
276 let (_, graph) = crate::portfolio::revision_graph(tx, &id, None)?;
277 let label = match label {
278 Some(variant) => variant,
279 None => crate::portfolio::variant_info(tx, &id)?
280 .map_or_else(|| id.0.clone(), |info| info.variant),
281 };
282 Ok((id, label, graph))
283 })
284 .collect::<Result<Vec<_>, PlannerError>>()
285 })
286 }
287
288 fn check_export_target(
293 &self,
294 plan_id: &PlanId,
295 root: &ProjectRoot,
296 file: &crate::project::PlanFileRef,
297 ) -> Result<(), PlannerError> {
298 let project = root.project_key();
299 let trackers = self.store.read_tx(|tx| {
300 crate::portfolio::variants_tracking(tx, &project, &file.rel_path)?
301 .into_iter()
302 .map(|id| {
303 let info = crate::portfolio::variant_info(tx, &id)?;
304 let (_, head) = crate::portfolio::revision_graph(tx, &id, None)?;
305 let recorded = crate::portfolio::revision_content_hashes(tx, &id)?;
306 Ok((id, info, hash_graph(&head), recorded))
307 })
308 .collect::<Result<Vec<_>, PlannerError>>()
309 })?;
310 for (id, info, head_hash, mut recorded) in trackers {
311 let Some(info) = info else { continue };
312 if id != *plan_id {
313 return Err(PlannerError::InvalidPath {
314 reason: format!(
315 "{} is the tracked plan file of variant '{}' of '{}'; pass force to \
316 overwrite it",
317 file.rel_path, info.variant, info.name
318 ),
319 });
320 }
321 if definition_drift(Some(root), &info, &head_hash) != Some(true) {
322 continue;
323 }
324 recorded.extend(info.content_hash.clone());
328 recorded.insert(head_hash);
329 if has_local_edits(root, file, &recorded) {
330 return Err(PlannerError::InvalidPath {
331 reason: format!(
332 "{} has local edits that were never synced (definition drift); sync it \
333 or pass force to overwrite it",
334 file.rel_path
335 ),
336 });
337 }
338 }
339 Ok(())
340 }
341
342 fn now(&self) -> DateTime<Utc> {
343 (self.clock)()
344 }
345
346 fn effective_ttl(&self, requested: Option<Duration>) -> Duration {
349 requested.unwrap_or(self.ttl).min(self.max_ttl)
350 }
351
352 pub async fn record_audit(&self, event: AuditEvent) {
355 self.flush_audit(vec![event]).await;
356 }
357
358 async fn flush_audit(&self, events: Vec<AuditEvent>) {
361 for ev in events {
362 if let Err(err) = self.audit.record(ev).await {
367 tracing::warn!(error = %err, "audit sink failed to record planner event");
368 }
369 }
370 }
371}
372
373impl Default for BasicCpmPlanner {
374 fn default() -> Self {
375 Self::new()
376 }
377}
378
379fn definition_drift(
389 root: Option<&ProjectRoot>,
390 info: &VariantInfo,
391 head_hash: &str,
392) -> Option<bool> {
393 let root = root?;
394 if root.project_key() != info.project {
395 return None;
396 }
397 let rel = info.source_path.as_deref()?;
398 let file = root.resolve_plan_file(rel).ok()?;
399 let bytes = root.read_bytes(&file).ok()?;
400 if info.content_hash.as_deref() == Some(crate::project::content_hash(&bytes).as_str()) {
401 return Some(false);
402 }
403 match serde_json::from_slice::<PlanGraph>(&bytes) {
404 Ok(graph) => Some(hash_graph(&graph) != head_hash),
405 Err(_) => Some(true),
406 }
407}
408
409fn has_local_edits(
412 root: &ProjectRoot,
413 file: &crate::project::PlanFileRef,
414 recorded: &HashSet<String>,
415) -> bool {
416 let Ok(bytes) = root.read_bytes(file) else {
417 return false;
418 };
419 if recorded.contains(&crate::project::content_hash(&bytes)) {
420 return false;
421 }
422 match serde_json::from_slice::<PlanGraph>(&bytes) {
423 Ok(graph) => !recorded.contains(&hash_graph(&graph)),
424 Err(_) => true,
425 }
426}
427
428pub(crate) fn canonical_deliverable(d: &Deliverable) -> serde_json::Value {
437 let mut prereqs: Vec<serde_json::Value> = d
438 .prerequisites
439 .iter()
440 .map(|p| {
441 json!({
442 "id": p.id(),
443 "consumes": p.consumes(),
444 "kind": p.kind(),
445 "lag_hours": p.lag_hours() + 0.0,
446 })
447 })
448 .collect();
449 prereqs.sort_by_cached_key(ToString::to_string);
452 let mut files: Vec<serde_json::Value> = d
453 .owned_files
454 .iter()
455 .map(|f| json!({ "path": f.path().to_string_lossy(), "mode": f.mode() }))
456 .collect();
457 files.sort_by_cached_key(ToString::to_string);
458 let mut canonical = json!({
459 "id": d.id,
460 "owned_files": files,
461 "prerequisites": prereqs,
462 "estimated_effort_hours": d.estimated_effort_hours,
463 "duration_hours": d.duration_hours,
464 "estimate": d.estimate,
465 "metadata": d.metadata,
466 "milestone": d.milestone,
467 });
468 if let Some(rule) = d
472 .earning_rule
473 .filter(|r| *r != crate::plan::EarningRule::ZeroHundred)
474 {
475 canonical["earning_rule"] = json!(rule);
476 }
477 canonical
478}
479
480pub(crate) fn hash_graph(graph: &PlanGraph) -> String {
485 let mut deliverables: Vec<_> = graph
489 .deliverables
490 .iter()
491 .map(canonical_deliverable)
492 .collect();
493 deliverables.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
494
495 let payload = json!({
496 "deliverables": deliverables,
497 "max_chained_dispatch": graph.max_chained_dispatch,
498 });
499
500 let serialised = serde_json::to_vec(&payload)
506 .expect("INVARIANT: plan-graph hash payload is JSON-serialisable");
507 crate::skills::sha256_hex(&serialised)
508}
509
510pub(crate) fn validate_graph(graph: &PlanGraph) -> Result<(), PlannerError> {
515 if graph.deliverables.len() > MAX_DELIVERABLES {
516 return Err(PlannerError::InvalidGraph {
517 reason: format!(
518 "plan has {} deliverables; maximum is {MAX_DELIVERABLES}",
519 graph.deliverables.len()
520 ),
521 });
522 }
523 for d in &graph.deliverables {
524 if d.id == START_ID || d.id == FINISH_ID {
525 return Err(PlannerError::InvalidGraph {
526 reason: format!("deliverable id '{}' is reserved", d.id),
527 });
528 }
529 }
530
531 let mut seen_ids: HashSet<&str> = HashSet::new();
533 for d in &graph.deliverables {
534 if !seen_ids.insert(d.id.as_str()) {
535 return Err(PlannerError::InvalidGraph {
536 reason: format!("duplicate deliverable id '{}'", d.id),
537 });
538 }
539 }
540
541 if let Some(problem) = crate::graph::value_problems(graph).into_iter().next() {
544 return Err(PlannerError::InvalidGraph {
545 reason: problem.message,
546 });
547 }
548
549 let id_set: HashSet<&str> = graph.deliverables.iter().map(|d| d.id.as_str()).collect();
551 for d in &graph.deliverables {
552 for p in crate::graph::prerequisite_ids(d) {
553 if !id_set.contains(p) {
554 return Err(PlannerError::InvalidGraph {
555 reason: format!(
556 "prerequisite '{p}' for deliverable '{}' does not exist",
557 d.id
558 ),
559 });
560 }
561 }
562 }
563
564 let mut reach: Option<HashMap<String, HashSet<String>>> = None;
568 let mut claimants: HashMap<&Path, Vec<(&str, FileMode)>> = HashMap::new();
569 let mut path_order: Vec<&Path> = Vec::new();
570 for d in &graph.deliverables {
571 for f in &d.owned_files {
572 let entry = claimants.entry(f.path()).or_insert_with(|| {
573 path_order.push(f.path());
574 Vec::new()
575 });
576 entry.push((d.id.as_str(), f.mode()));
577 }
578 }
579 for path in path_order {
580 let list = &claimants[path];
581 for (i, &(x, xm)) in list.iter().enumerate() {
582 for &(y, ym) in &list[i + 1..] {
583 if x == y || (xm == FileMode::Append && ym == FileMode::Append) {
584 continue;
585 }
586 let reach = reach.get_or_insert_with(|| crate::graph::reachability(graph));
587 let ordered = reach.get(x).is_some_and(|r| r.contains(y))
588 || reach.get(y).is_some_and(|r| r.contains(x));
589 if !ordered {
590 return Err(PlannerError::InvalidGraph {
591 reason: format!(
592 "file '{}' is claimed by '{x}' and '{y}', which are not ordered by prerequisites (one could run while the other holds it)",
593 path.display(),
594 ),
595 });
596 }
597 }
598 }
599 }
600
601 let mut indeg: HashMap<&str, usize> = HashMap::new();
603 let mut succs: HashMap<&str, Vec<&str>> = HashMap::new();
604 for d in &graph.deliverables {
605 indeg.entry(d.id.as_str()).or_insert(0);
606 succs.entry(d.id.as_str()).or_default();
607 }
608 for d in &graph.deliverables {
609 for p in crate::graph::prerequisite_ids(d) {
610 *indeg.entry(d.id.as_str()).or_insert(0) += 1;
611 succs.entry(p).or_default().push(d.id.as_str());
612 }
613 }
614 let mut queue: Vec<&str> = indeg
615 .iter()
616 .filter_map(|(k, v)| if *v == 0 { Some(*k) } else { None })
617 .collect();
618 let mut popped = 0_usize;
619 while let Some(node) = queue.pop() {
620 popped += 1;
621 if let Some(s) = succs.get(node).cloned() {
622 for next in s {
623 if let Some(deg) = indeg.get_mut(next) {
624 *deg -= 1;
625 if *deg == 0 {
626 queue.push(next);
627 }
628 }
629 }
630 }
631 }
632 if popped < graph.deliverables.len() {
633 let mut cycle_members: Vec<&str> = indeg
640 .iter()
641 .filter_map(|(k, v)| if *v > 0 { Some(*k) } else { None })
642 .collect();
643 cycle_members.sort_unstable();
644 return Err(PlannerError::InvalidGraph {
645 reason: format!(
646 "cycle detected in prerequisite graph involving deliverables: [{}]",
647 cycle_members.join(", ")
648 ),
649 });
650 }
651
652 Ok(())
653}
654
655fn initial_plan(graph: PlanGraph) -> Result<(PlanId, PlanState), PlannerError> {
658 let cached_result = crate::schedule::compute_cpm(&graph)?;
659 let mut statuses: HashMap<String, DeliverableStatus> =
660 HashMap::with_capacity(graph.deliverables.len());
661 for d in &graph.deliverables {
662 let status = if d.prerequisites.is_empty() {
663 DeliverableStatus::Ready
664 } else {
665 DeliverableStatus::Pending
666 };
667 statuses.insert(d.id.clone(), status);
668 }
669 let plan_id = PlanId(format!("plan_{}", uuid::Uuid::new_v4().simple()));
670 Ok((plan_id, PlanState::new(graph, statuses, cached_result)))
671}
672
673fn make_portfolio_created_event(
678 outcome: &SyncOutcome,
679 project: &str,
680 selected: bool,
681) -> AuditEvent {
682 AuditEvent::new("plan.portfolio.created").with_payload(json!({
683 "plan_id": outcome.plan_id.as_str(),
684 "project": project,
685 "name": outcome.name,
686 "variant": outcome.variant,
687 "revision": outcome.revision,
688 "selected": selected,
689 }))
690}
691
692fn make_portfolio_revised_event(
693 plan_id: &PlanId,
694 revision: u32,
695 diff: &RevisionDiff,
696) -> AuditEvent {
697 AuditEvent::new("plan.portfolio.revised").with_payload(json!({
698 "plan_id": plan_id.as_str(),
699 "revision": revision,
700 "diff": diff,
701 }))
702}
703
704fn revision_events(plan_id: &PlanId, revised: &Revised, now: DateTime<Utc>) -> Vec<AuditEvent> {
707 if revised.no_op {
708 return Vec::new();
709 }
710 let mut events: Vec<AuditEvent> = revised
711 .reaped
712 .iter()
713 .map(|lock| make_expired_event(lock, now))
714 .collect();
715 events.extend(
716 revised
717 .released
718 .iter()
719 .map(|lock| make_released_event(lock, "revision")),
720 );
721 events.push(make_portfolio_revised_event(
722 plan_id,
723 revised.revision,
724 &revised.diff,
725 ));
726 if let Some(count) = revised.completed {
727 events.push(make_plan_completed_event(plan_id, count));
728 }
729 events
730}
731
732fn make_portfolio_selected_event(outcome: &SelectOutcome) -> AuditEvent {
733 AuditEvent::new("plan.portfolio.selected").with_payload(json!({
734 "plan_id": outcome.plan_id.as_str(),
735 "project": outcome.project,
736 "name": outcome.name,
737 "from": outcome.previous,
738 "to": outcome.variant,
739 "carried": outcome.carried,
740 "released": outcome.released_locks,
741 }))
742}
743
744fn selection_events(selected: &Selected, now: DateTime<Utc>) -> Vec<AuditEvent> {
749 if !selected.outcome.changed {
750 return Vec::new();
751 }
752 let mut events: Vec<AuditEvent> = selected
753 .reaped
754 .iter()
755 .map(|lock| make_expired_event(lock, now))
756 .collect();
757 events.extend(
758 selected
759 .released
760 .iter()
761 .map(|lock| make_released_event(lock, "variant deselected")),
762 );
763 events.push(make_portfolio_selected_event(&selected.outcome));
764 if let Some(count) = selected.completed {
765 events.push(make_plan_completed_event(&selected.outcome.plan_id, count));
766 }
767 events
768}
769
770fn archive_events(
775 project: &str,
776 name: &str,
777 variant: Option<&str>,
778 archived: bool,
779 outcome: &Archived,
780 now: DateTime<Utc>,
781) -> Vec<AuditEvent> {
782 if !outcome.changed() {
783 return Vec::new();
784 }
785 let mut events: Vec<AuditEvent> = outcome
786 .reaped
787 .iter()
788 .map(|lock| make_expired_event(lock, now))
789 .collect();
790 events.extend(
791 outcome
792 .released
793 .iter()
794 .map(|lock| make_released_event(lock, "line archived")),
795 );
796 let variants: Vec<serde_json::Value> = outcome
797 .variants
798 .iter()
799 .map(|(v, plan_id)| json!({ "variant": v, "plan_id": plan_id.as_str() }))
800 .collect();
801 let event_type = if archived {
802 "plan.portfolio.archived"
803 } else {
804 "plan.portfolio.unarchived"
805 };
806 events.push(AuditEvent::new(event_type).with_payload(json!({
807 "project": project,
808 "name": name,
809 "variant": variant,
810 "line": outcome.line,
811 "variants": variants,
812 "released": outcome
813 .released
814 .iter()
815 .map(|l| l.deliverable_id.as_str())
816 .collect::<Vec<_>>(),
817 })));
818 events
819}
820
821fn sync_outcome(
823 synced: Synced,
824 project: &str,
825 name: String,
826 variant: String,
827 now: DateTime<Utc>,
828) -> (SyncOutcome, Vec<AuditEvent>) {
829 let outcome = |plan_id, revision, created, changed, diff| SyncOutcome {
830 plan_id,
831 name: name.clone(),
832 variant: variant.clone(),
833 revision,
834 created,
835 changed,
836 diff,
837 };
838 match synced {
839 Synced::Created { plan_id, selected } => {
840 let out = outcome(plan_id, 1, true, true, None);
841 let event = make_portfolio_created_event(&out, project, selected);
842 (out, vec![event])
843 }
844 Synced::Unchanged { plan_id, revision } => {
845 (outcome(plan_id, revision, false, false, None), Vec::new())
846 }
847 Synced::Revised { plan_id, revised } => {
848 let events = revision_events(&plan_id, &revised, now);
849 let out = outcome(plan_id, revised.revision, false, true, Some(revised.diff));
850 (out, events)
851 }
852 }
853}
854
855const MAX_PROJECT_LEN: usize = 512;
858
859fn is_disallowed_project_char(c: char) -> bool {
864 c.is_control()
865 || matches!(
866 c,
867 '\u{200B}'..='\u{200F}'
868 | '\u{202A}'..='\u{202E}'
869 | '\u{2060}'..='\u{206F}'
870 | '\u{FEFF}'
871 | '\u{2028}'
872 | '\u{2029}'
873 | '\u{FFF9}'..='\u{FFFB}'
874 )
875}
876
877fn validate_variant_key(req: &SyncRequest) -> Result<(), PlannerError> {
881 if req.project.is_empty() || req.project.chars().count() > MAX_PROJECT_LEN {
882 return Err(PlannerError::InvalidPath {
883 reason: format!("project must be 1..={MAX_PROJECT_LEN} characters"),
884 });
885 }
886 if req.project.chars().any(is_disallowed_project_char) {
887 return Err(PlannerError::InvalidPath {
888 reason: "project must not contain control or invisible format characters".to_string(),
889 });
890 }
891 crate::project::validate_slug("name", &req.name)?;
892 crate::project::validate_slug("variant", &req.variant)
893}
894
895fn make_acquired_event(lock: &LockInfo, owned_files: &[OwnedFile]) -> AuditEvent {
896 AuditEvent::new("plan.lock.acquired")
897 .with_actor(lock.caller_id.as_str())
898 .with_payload(json!({
899 "plan_id": lock.plan_id.as_str(),
900 "deliverable_id": lock.deliverable_id,
901 "caller_id": lock.caller_id.as_str(),
902 "acquired_at": lock.acquired_at,
903 "expires_at": lock.expires_at,
904 "owned_files": owned_files,
905 }))
906}
907
908fn make_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
909 AuditEvent::new("plan.lock.released")
910 .with_actor(lock.caller_id.as_str())
911 .with_payload(json!({
912 "plan_id": lock.plan_id.as_str(),
913 "deliverable_id": lock.deliverable_id,
914 "caller_id": lock.caller_id.as_str(),
915 "reason": reason,
916 }))
917}
918
919fn make_expired_event(lock: &LockInfo, expired_at: DateTime<Utc>) -> AuditEvent {
920 AuditEvent::new("plan.lock.expired")
921 .with_actor(lock.caller_id.as_str())
922 .with_payload(json!({
923 "plan_id": lock.plan_id.as_str(),
924 "deliverable_id": lock.deliverable_id,
925 "last_caller_id": lock.caller_id.as_str(),
926 "expired_at": expired_at,
927 }))
928}
929
930fn make_circuit_break_event(
931 plan_id: &PlanId,
932 deliverable_id: &str,
933 failure_count: u32,
934 class: FailureClass,
935 reason: &str,
936) -> AuditEvent {
937 AuditEvent::new("plan.deliverable.circuit_broken").with_payload(json!({
938 "plan_id": plan_id.as_str(),
939 "deliverable_id": deliverable_id,
940 "failure_count": failure_count,
941 "max_attempts": MAX_ATTEMPTS,
942 "failure_class": format!("{class:?}"),
943 "retry_decision": format!("{:?}", RetryDecision::Stop),
944 "reason": reason,
945 }))
946}
947
948pub(crate) fn validate_actuals(
952 status: &DeliverableStatus,
953 earned_pct: Option<f64>,
954 actual_effort_hours: Option<f64>,
955 evidence: Option<&str>,
956) -> Result<(), PlannerError> {
957 let bad = |reason: String| Err(PlannerError::InvalidActuals { reason });
958 if let Some(pct) = earned_pct {
959 if !(pct.fract() == 0.0 && (0.0..=100.0).contains(&pct)) {
960 return bad(format!("earned_pct must be an integer 0..=100, got {pct}"));
961 }
962 if !matches!(
963 status,
964 DeliverableStatus::InProgress | DeliverableStatus::Complete
965 ) {
966 return bad(
967 "earned_pct is only accepted with status in_progress (or complete, where it is \
968 ignored)"
969 .to_string(),
970 );
971 }
972 }
973 if let Some(h) = actual_effort_hours
974 && !(h.is_finite() && (0.0..=f64::from(crate::plan::MAX_HOURS)).contains(&h))
975 {
976 return bad(format!(
977 "actual_effort_hours must be a finite number between 0 and 1000000, got {h}"
978 ));
979 }
980 if let Some(e) = evidence {
981 let chars = e.chars().count();
982 if chars > crate::plan::MAX_EVIDENCE_CHARS {
983 return bad(format!(
984 "evidence must be at most {} characters, got {chars}",
985 crate::plan::MAX_EVIDENCE_CHARS
986 ));
987 }
988 }
989 Ok(())
990}
991
992fn incomplete_prerequisites(state: &PlanState, deliverable_id: &str) -> Vec<String> {
994 state
995 .graph
996 .deliverables
997 .iter()
998 .find(|d| d.id == deliverable_id)
999 .map(|d| {
1000 crate::graph::prerequisite_ids(d)
1001 .filter(|p| !matches!(state.statuses.get(*p), Some(DeliverableStatus::Complete)))
1002 .map(str::to_string)
1003 .collect()
1004 })
1005 .unwrap_or_default()
1006}
1007
1008fn complete_deliverable(
1016 state: &mut PlanState,
1017 deliverable_id: &str,
1018 audit_buf: &mut Vec<AuditEvent>,
1019 release_reason: &str,
1020 now: DateTime<Utc>,
1021) -> bool {
1022 let was_complete = all_complete(state);
1023 if let Some(lock) = state.end_lease(deliverable_id, now) {
1024 audit_buf.push(make_released_event(&lock, release_reason));
1025 }
1026
1027 state
1028 .statuses
1029 .insert(deliverable_id.to_string(), DeliverableStatus::Complete);
1030
1031 let dependents: Vec<String> = state
1032 .graph
1033 .deliverables
1034 .iter()
1035 .filter(|d| crate::graph::prerequisite_ids(d).any(|p| p == deliverable_id))
1036 .map(|d| d.id.clone())
1037 .collect();
1038 for dep_id in dependents {
1039 let dep = match state.graph.deliverables.iter().find(|d| d.id == dep_id) {
1040 Some(d) => d,
1041 None => unreachable!("dependent id {dep_id} present in graph but not findable"),
1042 };
1043 let all_done = crate::graph::prerequisite_ids(dep)
1044 .all(|p| matches!(state.statuses.get(p), Some(DeliverableStatus::Complete)));
1045 let currently_pending = matches!(
1046 state.statuses.get(&dep_id),
1047 Some(DeliverableStatus::Pending)
1048 );
1049 if all_done && currently_pending {
1050 state.statuses.insert(dep_id, DeliverableStatus::Ready);
1051 }
1052 }
1053 !was_complete && all_complete(state)
1054}
1055
1056pub(crate) fn all_complete(state: &PlanState) -> bool {
1058 state
1059 .graph
1060 .deliverables
1061 .iter()
1062 .all(|d| matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Complete)))
1063}
1064
1065fn make_plan_completed_event(plan_id: &PlanId, deliverable_count: usize) -> AuditEvent {
1066 AuditEvent::new("plan.completed").with_payload(json!({
1067 "plan_id": plan_id.as_str(),
1068 "deliverable_count": deliverable_count,
1069 }))
1070}
1071
1072fn make_accepted_event(
1073 req: &AcceptRequest,
1074 overrode_lock_of: Option<&str>,
1075 previous_status: &DeliverableStatus,
1076) -> AuditEvent {
1077 AuditEvent::new("plan.deliverable.accepted")
1078 .with_actor(req.accepted_by.as_str())
1079 .with_payload(json!({
1080 "plan_id": req.plan_id.as_str(),
1081 "deliverable_id": req.deliverable_id,
1082 "accepted_by": req.accepted_by,
1083 "evidence": req.evidence,
1084 "overrode_lock_of": overrode_lock_of,
1085 "previous_status": previous_status,
1086 }))
1087}
1088
1089fn make_completed_without_lease_event(
1090 plan_id: &PlanId,
1091 deliverable_id: &str,
1092 caller_id: &CallerId,
1093 previous_status: &DeliverableStatus,
1094) -> AuditEvent {
1095 AuditEvent::new("plan.deliverable.completed_without_lease")
1096 .with_actor(caller_id.as_str())
1097 .with_payload(json!({
1098 "plan_id": plan_id.as_str(),
1099 "deliverable_id": deliverable_id,
1100 "caller_id": caller_id.as_str(),
1101 "previous_status": previous_status,
1102 }))
1103}
1104
1105fn make_marked_without_lease_event(
1106 plan_id: &PlanId,
1107 deliverable_id: &str,
1108 caller_id: &CallerId,
1109 status: &DeliverableStatus,
1110 previous_status: &DeliverableStatus,
1111) -> AuditEvent {
1112 AuditEvent::new("plan.deliverable.marked_without_lease")
1113 .with_actor(caller_id.as_str())
1114 .with_payload(json!({
1115 "plan_id": plan_id.as_str(),
1116 "deliverable_id": deliverable_id,
1117 "caller_id": caller_id.as_str(),
1118 "status": status,
1119 "previous_status": previous_status,
1120 }))
1121}
1122
1123fn make_force_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
1124 AuditEvent::new("plan.lock.force_released")
1125 .with_actor(lock.caller_id.as_str())
1126 .with_payload(json!({
1127 "plan_id": lock.plan_id.as_str(),
1128 "deliverable_id": lock.deliverable_id,
1129 "last_caller_id": lock.caller_id.as_str(),
1130 "reason": reason,
1131 }))
1132}
1133
1134fn make_counters_reset_event(
1135 plan_id: &PlanId,
1136 deliverable_id: &str,
1137 reason: &str,
1138 lapse_count: u32,
1139 failure_count: u32,
1140) -> AuditEvent {
1141 AuditEvent::new("plan.deliverable.counters_reset")
1142 .with_actor("operator")
1143 .with_payload(json!({
1144 "plan_id": plan_id.as_str(),
1145 "deliverable_id": deliverable_id,
1146 "reason": reason,
1147 "previous": { "lapse_count": lapse_count, "failure_count": failure_count },
1148 }))
1149}
1150
1151fn priority_key(
1159 deliverable_id: &str,
1160 sched_by_id: &HashMap<&str, (f32, f32)>,
1161) -> (i64, i64, String) {
1162 let (latest_start, float) = match sched_by_id.get(deliverable_id) {
1163 Some(&v) => v,
1164 None => unreachable!(
1165 "deliverable '{deliverable_id}' is in the ready set but absent from the cached \
1166 CPM schedule — ready set and CPM result are out of sync"
1167 ),
1168 };
1169 let scale = |h: f32| (h * 1000.0).round() as i64;
1170 (
1171 scale(latest_start),
1172 scale(float),
1173 deliverable_id.to_string(),
1174 )
1175}
1176
1177#[async_trait]
1182impl Planner for BasicCpmPlanner {
1183 async fn submit_plan(&self, graph: PlanGraph) -> Result<PlanId, PlannerError> {
1184 validate_graph(&graph)?;
1185 let graph_hash = hash_graph(&graph);
1186
1187 self.store
1192 .submit_or_get(&graph_hash, move || initial_plan(graph))
1193 }
1194
1195 async fn sync_plan(&self, req: SyncRequest) -> Result<SyncOutcome, PlannerError> {
1196 validate_variant_key(&req)?;
1197 validate_graph(&req.graph)?;
1198 let graph_hash = hash_graph(&req.graph);
1199 let now = self.now();
1200 let (project, name, variant) = (req.project.clone(), req.name.clone(), req.variant.clone());
1201 let synced = self
1204 .store
1205 .write_tx(|tx| crate::portfolio::sync(tx, req, &graph_hash, now, initial_plan))?;
1206 let (outcome, events) = sync_outcome(synced, &project, name, variant, now);
1207 self.flush_audit(events).await;
1208 Ok(outcome)
1209 }
1210
1211 async fn fork_plan(&self, req: ForkRequest) -> Result<SyncOutcome, PlannerError> {
1212 let ForkRequest {
1213 plan_id,
1214 variant,
1215 edits,
1216 project_root,
1217 } = req;
1218 crate::project::validate_slug("variant", &variant)?;
1219 let (head, info, exists, line_archived) = self.store.read_tx(|tx| {
1220 let (_, head) = crate::portfolio::revision_graph(tx, &plan_id, None)?;
1221 let info = crate::portfolio::variant_info(tx, &plan_id)?.ok_or_else(|| {
1222 PlannerError::InvalidPath {
1223 reason: "fork requires a named plan".to_string(),
1224 }
1225 })?;
1226 let exists = crate::portfolio::has_variant(tx, &info.project, &info.name, &variant)?;
1227 let line_archived = crate::portfolio::line_is_archived(tx, &info.project, &info.name)?;
1228 Ok((head, info, exists, line_archived))
1229 })?;
1230 if line_archived {
1235 return Err(crate::portfolio::line_archived(&info.name));
1236 }
1237 if exists {
1238 return Err(crate::portfolio::variant_exists(&variant, &info.name));
1239 }
1240 let graph = crate::edits::apply_edits(&head, &edits)?;
1241 let root = match project_root {
1242 Some(root) if root.project_key() != info.project => {
1243 return Err(PlannerError::InvalidPath {
1244 reason: format!(
1245 "the project root belongs to a different project than plan {}",
1246 plan_id.0
1247 ),
1248 });
1249 }
1250 Some(root) => Some(root),
1251 None => self
1252 .project_root
1253 .clone()
1254 .filter(|root| root.project_key() == info.project),
1255 };
1256 let mut sync_req = SyncRequest::new(
1257 info.project.clone(),
1258 info.name.clone(),
1259 variant.clone(),
1260 graph,
1261 );
1262 let mut written = None;
1263 if let Some(root) = &root {
1264 let file = root.plan_file(&info.name, &variant)?;
1265 let hash = root.write_new_graph(&file, &sync_req.graph)?;
1266 sync_req = sync_req
1267 .with_source_path(file.rel_path.clone())
1268 .with_content_hash(hash.clone());
1269 written = Some((root, file, hash));
1270 }
1271 let graph_hash = hash_graph(&sync_req.graph);
1272 let now = self.now();
1273 let synced = self
1274 .store
1275 .write_tx(|tx| crate::portfolio::sync_new(tx, sync_req, &graph_hash, now, initial_plan))
1276 .inspect_err(|_| {
1277 if let Some((root, file, hash)) = &written {
1279 root.remove_plan_file_if_unchanged(file, hash);
1280 }
1281 })?;
1282 let (outcome, events) = sync_outcome(synced, &info.project, info.name, variant, now);
1283 self.flush_audit(events).await;
1284 Ok(outcome)
1285 }
1286
1287 async fn compare_plans(
1288 &self,
1289 req: ComparePlansRequest,
1290 ) -> Result<crate::compare::Comparison, PlannerError> {
1291 let inputs = self.compare_inputs(&req)?;
1292 crate::compare::compare(&inputs, &req.request)
1293 }
1294
1295 async fn export_plan(
1296 &self,
1297 plan_id: &PlanId,
1298 root: &ProjectRoot,
1299 path: Option<&str>,
1300 force: bool,
1301 ) -> Result<String, PlannerError> {
1302 let (graph, info) = self.store.read_tx(|tx| {
1303 let (_, graph) = crate::portfolio::revision_graph(tx, plan_id, None)?;
1304 Ok((graph, crate::portfolio::variant_info(tx, plan_id)?))
1305 })?;
1306 let file = match (path, &info) {
1307 (Some(path), _) => root.resolve_plan_file(path)?,
1308 (None, Some(info)) if info.project == root.project_key() => {
1309 root.plan_file(&info.name, &info.variant)?
1310 }
1311 (None, Some(_)) => {
1312 return Err(PlannerError::InvalidPath {
1313 reason: format!(
1314 "plan {} belongs to a different project; give a path",
1315 plan_id.0
1316 ),
1317 });
1318 }
1319 (None, None) => {
1320 return Err(PlannerError::InvalidPath {
1321 reason: format!("plan {} is unnamed; export requires a path", plan_id.0),
1322 });
1323 }
1324 };
1325 if !force {
1326 self.check_export_target(plan_id, root, &file)?;
1327 }
1328 let hash = root.write_graph(&file, &graph)?;
1329 if let Some(info) = &info
1332 && info.project == root.project_key()
1333 && info.source_path.as_deref() == Some(file.rel_path.as_str())
1334 {
1335 self.store
1336 .write_tx(|tx| crate::portfolio::set_content_hash(tx, plan_id, &hash))?;
1337 }
1338 Ok(file.rel_path)
1339 }
1340
1341 async fn baseline(
1342 &self,
1343 req: crate::earned_value::BaselineRequest,
1344 ) -> Result<crate::earned_value::BaselineOutcome, PlannerError> {
1345 self.take_baseline(req).await
1346 }
1347
1348 async fn ev(
1349 &self,
1350 plan_id: &PlanId,
1351 as_of: Option<DateTime<Utc>>,
1352 ) -> Result<crate::earned_value::EvReport, PlannerError> {
1353 self.ev_report(plan_id, as_of)
1354 }
1355
1356 async fn snapshot(
1357 &self,
1358 req: crate::earned_value::SnapshotRequest,
1359 ) -> Result<crate::earned_value::SnapshotOutcome, PlannerError> {
1360 self.take_snapshot(req)
1361 }
1362
1363 async fn list_plans(
1364 &self,
1365 project: &str,
1366 include_archived: bool,
1367 ) -> Result<Vec<PlanLineSummary>, PlannerError> {
1368 self.store
1369 .read_tx(|tx| crate::portfolio::list(tx, project, include_archived))
1370 }
1371
1372 async fn revision_graph(
1373 &self,
1374 plan_id: &PlanId,
1375 revision: Option<u32>,
1376 ) -> Result<(u32, PlanGraph), PlannerError> {
1377 self.store
1378 .read_tx(|tx| crate::portfolio::revision_graph(tx, plan_id, revision))
1379 }
1380
1381 async fn revise_plan(&self, req: ReviseRequest) -> Result<(u32, RevisionDiff), PlannerError> {
1382 let ReviseRequest {
1383 plan_id,
1384 graph,
1385 force,
1386 } = req;
1387 let now = self.now();
1388 let revised = self.store.write_tx(|tx| {
1389 crate::portfolio::ensure_revisable(tx, &plan_id)?;
1390 crate::portfolio::revise(tx, &plan_id, graph, None, None, force, now)
1391 })?;
1392 self.flush_audit(revision_events(&plan_id, &revised, now))
1393 .await;
1394 Ok((revised.revision, revised.diff))
1395 }
1396
1397 async fn select_variant(
1398 &self,
1399 plan_id: &PlanId,
1400 force: bool,
1401 ) -> Result<SelectOutcome, PlannerError> {
1402 let now = self.now();
1403 let selected = self
1404 .store
1405 .write_tx(|tx| crate::portfolio::select(tx, plan_id, force, now))?;
1406 self.flush_audit(selection_events(&selected, now)).await;
1407 Ok(selected.outcome)
1408 }
1409
1410 async fn archive(
1411 &self,
1412 project: &str,
1413 name: &str,
1414 variant: Option<&str>,
1415 archived: bool,
1416 force: bool,
1417 ) -> Result<(), PlannerError> {
1418 let now = self.now();
1419 let outcome = self.store.write_tx(|tx| {
1420 crate::portfolio::archive(tx, project, name, variant, archived, force, now)
1421 })?;
1422 self.flush_audit(archive_events(
1423 project, name, variant, archived, &outcome, now,
1424 ))
1425 .await;
1426 Ok(())
1427 }
1428
1429 async fn acquire_cohort(&self, req: AcquireRequest) -> Result<Cohort, PlannerError> {
1430 let AcquireRequest {
1431 plan_id,
1432 caller_id,
1433 max_count,
1434 ids,
1435 metadata_filter,
1436 ttl,
1437 } = req;
1438 let now = self.now();
1439 let expires_at = now
1440 + chrono::Duration::from_std(self.effective_ttl(ttl))
1441 .expect("INVARIANT: planner TTL fits in chrono::Duration");
1442
1443 let mut audit_buf: Vec<AuditEvent> = Vec::new();
1447 let cohort = self.store.mutate_executable_plan(&plan_id, |state| {
1448 let reaped = state.reap_expired(now);
1450 for lock in &reaped {
1451 audit_buf.push(make_expired_event(lock, now));
1452 }
1453
1454 if let Some(req_ids) = &ids {
1459 for rid in req_ids {
1460 if !state.graph.deliverables.iter().any(|d| &d.id == rid) {
1461 return Err(PlannerError::DeliverableNotFound {
1462 plan_id: plan_id.0.clone(),
1463 deliverable_id: rid.clone(),
1464 });
1465 }
1466 }
1467 }
1468 let is_requested = |id: &str| ids.as_ref().is_some_and(|v| v.iter().any(|r| r == id));
1469 let is_manual =
1470 |d: &Deliverable| d.metadata.get("kind").and_then(|k| k.as_str()) == Some("manual");
1471 let matches_filter = |d: &Deliverable| {
1472 metadata_filter
1473 .as_ref()
1474 .is_none_or(|f| f.iter().all(|(k, v)| d.metadata.get(k) == Some(v)))
1475 };
1476 let in_scope =
1477 |d: &Deliverable| (ids.is_none() || is_requested(&d.id)) && matches_filter(d);
1478
1479 let poisoned: Vec<(String, u32)> = state
1499 .graph
1500 .deliverables
1501 .iter()
1502 .filter(|d| {
1503 matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1504 && lease_retry_decision(state.failure_count(&d.id)) == RetryDecision::Stop
1505 })
1506 .map(|d| (d.id.clone(), state.failure_count(&d.id)))
1507 .collect();
1508 for (id, failures) in poisoned {
1509 let reason = format!("circuit-break: exceeded {MAX_ATTEMPTS} failed attempts");
1512 audit_buf.push(make_circuit_break_event(
1513 &plan_id,
1514 &id,
1515 failures,
1516 FailureClass::Permanent,
1517 &reason,
1518 ));
1519 state
1520 .statuses
1521 .insert(id, DeliverableStatus::Failed { reason });
1522 }
1523
1524 let lapse_blocked: Vec<BlockedDeliverable> = state
1527 .graph
1528 .deliverables
1529 .iter()
1530 .filter(|d| {
1531 in_scope(d)
1532 && !is_manual(d)
1533 && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1534 && !state.locks.contains_key(&d.id)
1535 && state.lapse_count(&d.id) >= MAX_LAPSES
1536 })
1537 .map(|d| BlockedDeliverable {
1538 id: d.id.clone(),
1539 code: "LAPSE_LIMIT".to_string(),
1540 reason: format!(
1541 "lease lapsed {} times (limit {MAX_LAPSES}); clear with \
1542 plan.force_release {{reset_counters: true}}",
1543 state.lapse_count(&d.id)
1544 ),
1545 })
1546 .collect();
1547
1548 let sched_by_id: HashMap<&str, (f32, f32)> = state
1550 .cached_result
1551 .tasks
1552 .iter()
1553 .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
1554 .collect();
1555
1556 let mut blocked: Vec<BlockedDeliverable> = Vec::new();
1561 let mut ready: Vec<&Deliverable> = Vec::new();
1562 if let Some(req_ids) = &ids {
1563 let mut seen: HashSet<&str> = HashSet::new();
1564 for rid in req_ids {
1565 if !seen.insert(rid.as_str()) {
1566 continue;
1567 }
1568 let d = state
1569 .graph
1570 .deliverables
1571 .iter()
1572 .find(|d| &d.id == rid)
1573 .expect("INVARIANT: requested ids validated above");
1574 if !matches_filter(d) {
1575 continue;
1576 }
1577 let mut block = |code: &str, reason: String| {
1578 blocked.push(BlockedDeliverable {
1579 id: d.id.clone(),
1580 code: code.to_string(),
1581 reason,
1582 });
1583 };
1584 let status = state.statuses.get(&d.id);
1585 if is_manual(d) {
1586 block("MANUAL", "manual deliverable; never leased".to_string());
1587 } else if state.locks.contains_key(&d.id) {
1588 block("LOCKED", "held by an active lease".to_string());
1589 } else if !matches!(status, Some(DeliverableStatus::Ready)) {
1590 block(
1591 "NOT_READY",
1592 format!(
1593 "status is {}",
1594 status.map_or("unknown".to_string(), |s| format!("{s:?}"))
1595 ),
1596 );
1597 } else if let Some(lb) = lapse_blocked.iter().find(|b| b.id == d.id) {
1598 block("LAPSE_LIMIT", lb.reason.clone());
1599 } else {
1600 ready.push(d);
1601 }
1602 }
1603 } else {
1604 blocked = lapse_blocked.clone();
1605 ready.extend(state.graph.deliverables.iter().filter(|d| {
1606 in_scope(d)
1607 && !is_manual(d)
1608 && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1609 && !state.locks.contains_key(&d.id)
1610 && !lapse_blocked.iter().any(|b| b.id == d.id)
1611 }));
1612 }
1613 ready.sort_by_key(|d| priority_key(&d.id, &sched_by_id));
1614
1615 let mut selected: Vec<Deliverable> = Vec::new();
1618 let mut selected_files: HashMap<PathBuf, FileMode> = HashMap::new();
1619 for candidate in ready {
1620 if selected.len() == max_count {
1621 if ids.is_none() {
1622 break;
1623 }
1624 blocked.push(BlockedDeliverable {
1625 id: candidate.id.clone(),
1626 code: "MAX_COUNT".to_string(),
1627 reason: format!("cohort already holds max_count ({max_count})"),
1628 });
1629 continue;
1630 }
1631 let conflict = candidate.owned_files.iter().any(|f| {
1632 selected_files
1633 .get(f.path())
1634 .is_some_and(|m| modes_conflict(f.mode(), *m))
1635 || state
1636 .file_claims
1637 .get(f.path())
1638 .is_some_and(|c| c.conflicts_with(f.mode()))
1639 });
1640 if conflict {
1641 if ids.is_some() {
1642 blocked.push(BlockedDeliverable {
1643 id: candidate.id.clone(),
1644 code: "FILE_CONFLICT".to_string(),
1645 reason: "owned files overlap a held lock or an earlier pick"
1646 .to_string(),
1647 });
1648 }
1649 continue;
1650 }
1651 for f in &candidate.owned_files {
1652 let e = selected_files
1654 .entry(f.path().to_path_buf())
1655 .or_insert(f.mode());
1656 if f.mode() == FileMode::Exclusive {
1657 *e = FileMode::Exclusive;
1658 }
1659 }
1660 selected.push(candidate.clone());
1661 }
1662
1663 let mut rows: Vec<CohortRow> = Vec::with_capacity(selected.len());
1672 for d in selected {
1673 let lock = LockInfo {
1674 plan_id: plan_id.clone(),
1675 deliverable_id: d.id.clone(),
1676 caller_id: caller_id.clone(),
1677 acquired_at: now,
1678 expires_at,
1679 };
1680 state
1681 .statuses
1682 .insert(d.id.clone(), DeliverableStatus::InProgress);
1683 *state.attempt_counts.entry(d.id.clone()).or_insert(0) += 1;
1687 add_file_claims(&mut state.file_claims, &d.id, &d.owned_files);
1688 state.locks.insert(d.id.clone(), lock.clone());
1689 state.lockless.remove(&d.id);
1691 audit_buf.push(make_acquired_event(&lock, &d.owned_files));
1692 rows.push(CohortRow {
1693 deliverable: d,
1694 lock,
1695 });
1696 }
1697
1698 let mut shared_paths: Vec<PathBuf> = rows
1701 .iter()
1702 .flat_map(|r| r.deliverable.owned_files.iter())
1703 .filter(|f| f.mode() == FileMode::Append)
1704 .filter(|f| {
1705 matches!(state.file_claims.get(f.path()),
1706 Some(FileClaim::Append(set)) if set.len() >= 2)
1707 })
1708 .map(|f| f.path().to_path_buf())
1709 .collect();
1710 shared_paths.sort();
1711 shared_paths.dedup();
1712
1713 Ok(Cohort {
1714 plan_id: plan_id.clone(),
1715 rows,
1716 blocked,
1717 shared_paths,
1718 })
1719 })?;
1720
1721 self.flush_audit(audit_buf).await;
1722 Ok(cohort)
1723 }
1724
1725 async fn mark_status(&self, req: MarkStatusRequest) -> Result<(), PlannerError> {
1726 let MarkStatusRequest {
1727 plan_id,
1728 deliverable_id,
1729 caller_id,
1730 status,
1731 earned_pct,
1732 actual_effort_hours,
1733 evidence,
1734 } = req;
1735 validate_actuals(
1736 &status,
1737 earned_pct.map(f64::from),
1738 actual_effort_hours.map(f64::from),
1739 evidence.as_deref(),
1740 )?;
1741 let report = crate::ev_store::ReportedActuals {
1742 earned_pct: earned_pct.filter(|_| status == DeliverableStatus::InProgress),
1744 actual_hours: actual_effort_hours,
1745 evidence,
1746 };
1747 let deliverable_id = deliverable_id.as_str();
1748 let caller_id = &caller_id;
1749 let now = self.now();
1750 let mut audit_buf: Vec<AuditEvent> = Vec::new();
1751 self.store.mutate_executable_plan(&plan_id, |state| {
1752 if !state
1754 .graph
1755 .deliverables
1756 .iter()
1757 .any(|d| d.id == deliverable_id)
1758 {
1759 return Err(PlannerError::DeliverableNotFound {
1760 plan_id: plan_id.0.clone(),
1761 deliverable_id: deliverable_id.to_string(),
1762 });
1763 }
1764
1765 if let Some(lock) = state.locks.get(deliverable_id)
1767 && lock.caller_id != *caller_id
1768 {
1769 return Err(PlannerError::LockNotHeld {
1770 caller_id: caller_id.0.clone(),
1771 deliverable_id: deliverable_id.to_string(),
1772 });
1773 }
1774
1775 let is_complete = matches!(status, DeliverableStatus::Complete);
1776 let previous_status = state
1777 .statuses
1778 .get(deliverable_id)
1779 .cloned()
1780 .unwrap_or(DeliverableStatus::Pending);
1781
1782 if !state.locks.contains_key(deliverable_id)
1786 && matches!(previous_status, DeliverableStatus::Complete)
1787 && !is_complete
1788 {
1789 return Err(PlannerError::LockNotHeld {
1790 caller_id: caller_id.0.clone(),
1791 deliverable_id: deliverable_id.to_string(),
1792 });
1793 }
1794
1795 state.report_actuals(deliverable_id, report, now);
1798
1799 if is_complete && !state.locks.contains_key(deliverable_id) {
1802 if matches!(
1804 state.statuses.get(deliverable_id),
1805 Some(DeliverableStatus::Complete)
1806 ) {
1807 return Ok(());
1808 }
1809 let missing = incomplete_prerequisites(state, deliverable_id);
1810 if !missing.is_empty() {
1811 return Err(PlannerError::PrerequisitesIncomplete {
1812 plan_id: plan_id.0.clone(),
1813 deliverable_id: deliverable_id.to_string(),
1814 missing,
1815 });
1816 }
1817 audit_buf.push(make_completed_without_lease_event(
1818 &plan_id,
1819 deliverable_id,
1820 caller_id,
1821 &previous_status,
1822 ));
1823 } else if !is_complete && !state.locks.contains_key(deliverable_id) {
1824 if matches!(
1827 status,
1828 DeliverableStatus::Ready | DeliverableStatus::InProgress
1829 ) {
1830 let missing = incomplete_prerequisites(state, deliverable_id);
1831 if !missing.is_empty() {
1832 return Err(PlannerError::PrerequisitesIncomplete {
1833 plan_id: plan_id.0.clone(),
1834 deliverable_id: deliverable_id.to_string(),
1835 missing,
1836 });
1837 }
1838 }
1839 audit_buf.push(make_marked_without_lease_event(
1840 &plan_id,
1841 deliverable_id,
1842 caller_id,
1843 &status,
1844 &previous_status,
1845 ));
1846 }
1847
1848 if matches!(status, DeliverableStatus::Failed { .. })
1850 && let Some(lock) = state.end_lease(deliverable_id, now)
1851 {
1852 audit_buf.push(make_released_event(&lock, "failed"));
1853 }
1854
1855 if matches!(status, DeliverableStatus::Failed { .. })
1862 && !matches!(
1863 state.statuses.get(deliverable_id),
1864 Some(DeliverableStatus::Failed { .. })
1865 )
1866 {
1867 *state
1868 .failure_counts
1869 .entry(deliverable_id.to_string())
1870 .or_insert(0) += 1;
1871 }
1872
1873 if status == DeliverableStatus::InProgress && !state.locks.contains_key(deliverable_id)
1878 {
1879 state.lockless.insert(deliverable_id.to_string());
1880 } else {
1881 state.lockless.remove(deliverable_id);
1882 }
1883
1884 if is_complete {
1885 if complete_deliverable(state, deliverable_id, &mut audit_buf, "completed", now) {
1886 audit_buf.push(make_plan_completed_event(
1887 &plan_id,
1888 state.graph.deliverables.len(),
1889 ));
1890 }
1891 } else {
1892 state
1893 .statuses
1894 .insert(deliverable_id.to_string(), status.clone());
1895 }
1896
1897 Ok(())
1898 })?;
1899
1900 self.flush_audit(audit_buf).await;
1908 Ok(())
1909 }
1910
1911 async fn heartbeat(&self, req: HeartbeatRequest) -> Result<(), PlannerError> {
1912 let HeartbeatRequest {
1913 plan_id,
1914 deliverable_id,
1915 caller_id,
1916 ttl,
1917 } = req;
1918 let deliverable_id = deliverable_id.as_str();
1919 let caller_id = &caller_id;
1920 let now = self.now();
1921 let explicit_ttl = ttl.is_some();
1922 let expires_at = now
1923 + chrono::Duration::from_std(self.effective_ttl(ttl))
1924 .expect("INVARIANT: planner TTL fits in chrono::Duration");
1925
1926 self.store.mutate_executable_plan(&plan_id, |state| {
1927 let lock =
1928 state
1929 .locks
1930 .get_mut(deliverable_id)
1931 .ok_or_else(|| PlannerError::LockNotHeld {
1932 caller_id: caller_id.0.clone(),
1933 deliverable_id: deliverable_id.to_string(),
1934 })?;
1935
1936 if lock.caller_id != *caller_id {
1937 return Err(PlannerError::LockNotHeld {
1938 caller_id: caller_id.0.clone(),
1939 deliverable_id: deliverable_id.to_string(),
1940 });
1941 }
1942
1943 if lock.expires_at < now {
1946 return Err(PlannerError::LockExpired {
1947 deliverable_id: deliverable_id.to_string(),
1948 expired_at: lock.expires_at,
1949 });
1950 }
1951
1952 lock.expires_at = if explicit_ttl {
1956 expires_at
1957 } else {
1958 lock.expires_at.max(expires_at)
1959 };
1960 Ok(())
1961 })
1962 }
1963
1964 async fn get_plan(&self, plan_id: &PlanId) -> Result<PlanDefinition, PlannerError> {
1965 self.store.read_plan(plan_id, |state| PlanDefinition {
1966 plan_id: plan_id.clone(),
1967 graph: state.graph.clone(),
1968 })
1969 }
1970
1971 async fn status(&self, plan_id: &PlanId) -> Result<PlanStatus, PlannerError> {
1972 let ((mut status, head_hash), info) =
1973 self.store.read_plan_and_variant(plan_id, |state| {
1974 let deliverables: Vec<(String, DeliverableStatus, u32, u32, u32)> = state
1976 .graph
1977 .deliverables
1978 .iter()
1979 .map(|d| {
1980 let status = state
1981 .statuses
1982 .get(&d.id)
1983 .cloned()
1984 .unwrap_or(DeliverableStatus::Pending);
1985 (
1986 d.id.clone(),
1987 status,
1988 state.attempt_count(&d.id),
1989 state.failure_count(&d.id),
1990 state.lapse_count(&d.id),
1991 )
1992 })
1993 .collect();
1994
1995 let schedule = crate::schedule::schedule_rows(&state.graph, &state.cached_result);
1996 let mut ready_rows: Vec<&ScheduleRow> = schedule
1997 .iter()
1998 .filter(|r| {
1999 matches!(state.statuses.get(&r.id), Some(DeliverableStatus::Ready))
2000 && !state.locks.contains_key(&r.id)
2001 })
2002 .collect();
2003 let sched_by_id: HashMap<&str, (f32, f32)> = state
2007 .cached_result
2008 .tasks
2009 .iter()
2010 .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
2011 .collect();
2012 ready_rows.sort_by_key(|r| priority_key(&r.id, &sched_by_id));
2013 let ready: Vec<String> = ready_rows.iter().map(|r| r.id.clone()).collect();
2014
2015 let milestones =
2016 crate::schedule::milestone_rows(&state.graph, &state.cached_result, |id| {
2017 matches!(state.statuses.get(id), Some(DeliverableStatus::Complete))
2018 });
2019
2020 let status = PlanStatus {
2021 plan_id: plan_id.clone(),
2022 milestones,
2023 critical_ids: state
2024 .cached_result
2025 .critical_ids
2026 .iter()
2027 .filter(|id| id.as_str() != START_ID && id.as_str() != FINISH_ID)
2028 .cloned()
2029 .collect(),
2030 plan_complete: all_complete(state),
2031 schedule,
2032 ready,
2033 deliverables,
2034 critical_path: state.cached_result.critical_path.clone(),
2035 critical_path_hours: state.cached_result.critical_path_duration,
2036 locks_held: state.locks.values().cloned().collect(),
2037 name: None,
2038 variant: None,
2039 selected: None,
2040 definition_drift: None,
2041 };
2042 (status, hash_graph(&state.graph))
2043 })?;
2044 if let Some(info) = info {
2045 status.definition_drift =
2047 definition_drift(self.project_root.as_ref(), &info, &head_hash);
2048 status.name = Some(info.name);
2049 status.variant = Some(info.variant);
2050 status.selected = Some(info.selected);
2051 }
2052 Ok(status)
2053 }
2054
2055 async fn accept(&self, req: AcceptRequest) -> Result<(), PlannerError> {
2056 let mut audit_buf: Vec<AuditEvent> = Vec::new();
2057 let plan_id = req.plan_id.clone();
2058 let deliverable_id = req.deliverable_id.as_str();
2059 let now = self.now();
2060 self.store.mutate_executable_plan(&plan_id, |state| {
2061 if !state
2062 .graph
2063 .deliverables
2064 .iter()
2065 .any(|d| d.id == deliverable_id)
2066 {
2067 return Err(PlannerError::DeliverableNotFound {
2068 plan_id: plan_id.0.clone(),
2069 deliverable_id: deliverable_id.to_string(),
2070 });
2071 }
2072
2073 for lock in &state.reap_expired(now) {
2075 audit_buf.push(make_expired_event(lock, now));
2076 }
2077
2078 let previous_status = state
2080 .statuses
2081 .get(deliverable_id)
2082 .cloned()
2083 .unwrap_or(DeliverableStatus::Pending);
2084 if matches!(previous_status, DeliverableStatus::Complete) {
2085 return Ok(());
2086 }
2087
2088 let mut overrode: Option<String> = None;
2091 if let Some(lock) = state.locks.get(deliverable_id) {
2092 if !req.override_lock {
2093 return Err(PlannerError::LockHeld {
2094 plan_id: plan_id.0.clone(),
2095 deliverable_id: deliverable_id.to_string(),
2096 holder: lock.caller_id.0.clone(),
2097 });
2098 }
2099 overrode = Some(lock.caller_id.0.clone());
2100 }
2101
2102 let missing = incomplete_prerequisites(state, deliverable_id);
2103 if !missing.is_empty() {
2104 return Err(PlannerError::PrerequisitesIncomplete {
2105 plan_id: plan_id.0.clone(),
2106 deliverable_id: deliverable_id.to_string(),
2107 missing,
2108 });
2109 }
2110
2111 let plan_done =
2112 complete_deliverable(state, deliverable_id, &mut audit_buf, "accepted", now);
2113 audit_buf.push(make_accepted_event(
2114 &req,
2115 overrode.as_deref(),
2116 &previous_status,
2117 ));
2118 if plan_done {
2119 audit_buf.push(make_plan_completed_event(
2120 &plan_id,
2121 state.graph.deliverables.len(),
2122 ));
2123 }
2124 Ok(())
2125 })?;
2126
2127 self.flush_audit(audit_buf).await;
2128 Ok(())
2129 }
2130
2131 async fn force_release(&self, req: ForceReleaseRequest) -> Result<(), PlannerError> {
2132 let ForceReleaseRequest {
2133 plan_id,
2134 deliverable_id,
2135 reason,
2136 reset_counters,
2137 } = req;
2138 let deliverable_id = deliverable_id.as_str();
2139 let reason = reason.as_str();
2140 let now = self.now();
2141 let mut audit_buf: Vec<AuditEvent> = Vec::new();
2142 self.store.mutate_executable_plan(&plan_id, |state| {
2143 if !state
2144 .graph
2145 .deliverables
2146 .iter()
2147 .any(|d| d.id == deliverable_id)
2148 {
2149 return Err(PlannerError::DeliverableNotFound {
2150 plan_id: plan_id.0.clone(),
2151 deliverable_id: deliverable_id.to_string(),
2152 });
2153 }
2154
2155 if let Some(lock) = state.end_lease(deliverable_id, now) {
2156 state
2157 .statuses
2158 .insert(deliverable_id.to_string(), DeliverableStatus::Ready);
2159 audit_buf.push(make_force_released_event(&lock, reason));
2160 }
2161
2162 if reset_counters {
2163 let lapse_count = state.lapse_counts.remove(deliverable_id).unwrap_or(0);
2164 let failure_count = state.failure_counts.remove(deliverable_id).unwrap_or(0);
2165 if matches!(
2167 state.statuses.get(deliverable_id),
2168 Some(DeliverableStatus::Failed { .. })
2169 ) {
2170 let prereqs_complete = state
2171 .graph
2172 .deliverables
2173 .iter()
2174 .find(|d| d.id == deliverable_id)
2175 .is_some_and(|d| {
2176 crate::graph::prerequisite_ids(d).all(|p| {
2177 matches!(state.statuses.get(p), Some(DeliverableStatus::Complete))
2178 })
2179 });
2180 let revived = if prereqs_complete {
2181 DeliverableStatus::Ready
2182 } else {
2183 DeliverableStatus::Pending
2184 };
2185 state.statuses.insert(deliverable_id.to_string(), revived);
2186 }
2187 audit_buf.push(make_counters_reset_event(
2188 &plan_id,
2189 deliverable_id,
2190 reason,
2191 lapse_count,
2192 failure_count,
2193 ));
2194 }
2195
2196 Ok(())
2197 })?;
2198
2199 self.flush_audit(audit_buf).await;
2200 Ok(())
2201 }
2202}
2203
2204#[cfg(test)]
2205#[allow(clippy::float_cmp)]
2206mod tests {
2207 use super::*;
2208 use crate::estimator::EffortEstimator;
2209 use crate::schedule::deliverable_to_task;
2210 use crate::task::TaskKind;
2211
2212 fn deliverable(id: &str, effort: Option<f32>, metadata: serde_json::Value) -> Deliverable {
2213 Deliverable {
2214 id: id.to_string(),
2215 owned_files: Vec::new(),
2216 prerequisites: Vec::new(),
2217 estimated_effort_hours: effort,
2218 metadata,
2219 duration_hours: None,
2220 estimate: None,
2221 milestone: false,
2222 earning_rule: None,
2223 }
2224 }
2225
2226 #[test]
2227 fn explicit_default_earning_rule_hashes_like_absent() {
2228 let absent = deliverable("D1", Some(1.0), json!({}));
2229 let mut explicit = absent.clone();
2230 explicit.earning_rule = Some(crate::plan::EarningRule::ZeroHundred);
2231 let graph = |d: Deliverable| PlanGraph {
2232 deliverables: vec![d],
2233 max_chained_dispatch: None,
2234 };
2235 assert_eq!(hash_graph(&graph(explicit)), hash_graph(&graph(absent)));
2236 }
2237
2238 #[test]
2239 fn canonical_form_omits_an_absent_earning_rule() {
2240 let d = deliverable("D1", Some(1.0), json!({}));
2241 assert!(canonical_deliverable(&d).get("earning_rule").is_none());
2242 }
2243
2244 #[test]
2245 fn explicit_effort_wins_over_estimator() {
2246 let estimator = EffortEstimator::new();
2247 let d = deliverable("D1", Some(2.5), json!({}));
2248 let task = deliverable_to_task(&d, &estimator);
2249 assert_eq!(task.effort_hours, 2.5);
2250 }
2251
2252 #[test]
2253 fn missing_effort_uses_estimator_not_flat_default() {
2254 let estimator = EffortEstimator::new();
2257 let d = deliverable("D1", None, json!({}));
2258 let task = deliverable_to_task(&d, &estimator);
2259 let expected = estimator.estimate(
2260 &TaskKind::Custom {
2261 description: String::new(),
2262 },
2263 false,
2264 );
2265 assert_eq!(task.effort_hours, expected);
2266 assert_ne!(task.effort_hours, DEFAULT_EFFORT_HOURS);
2267 }
2268
2269 #[test]
2270 fn complexity_metadata_hint_raises_estimate() {
2271 let estimator = EffortEstimator::new();
2272 let simple = deliverable_to_task(&deliverable("S", None, json!({})), &estimator);
2273 let complex = deliverable_to_task(
2274 &deliverable("C", None, json!({ "complexity": true })),
2275 &estimator,
2276 );
2277 assert!(complex.effort_hours > simple.effort_hours);
2278 }
2279
2280 fn sched<'a>(entries: &[(&'a str, f32, f32)]) -> HashMap<&'a str, (f32, f32)> {
2281 entries
2282 .iter()
2283 .map(|&(id, latest_start, float)| (id, (latest_start, float)))
2284 .collect()
2285 }
2286
2287 #[test]
2288 fn priority_key_orders_lower_latest_start_first() {
2289 let s = sched(&[("A", 5.0, 0.0), ("B", 0.0, 9.0)]);
2290 assert!(priority_key("B", &s) < priority_key("A", &s));
2291 }
2292
2293 #[test]
2294 fn priority_key_breaks_latest_start_ties_by_float() {
2295 let s = sched(&[("X", 1.0, 3.0), ("Y", 1.0, 1.0)]);
2296 assert!(priority_key("Y", &s) < priority_key("X", &s));
2297 }
2298
2299 #[test]
2300 fn priority_key_breaks_float_ties_by_id() {
2301 let s = sched(&[("X", 1.0, 1.0), ("Y", 1.0, 1.0)]);
2302 assert!(priority_key("X", &s) < priority_key("Y", &s));
2303 }
2304
2305 #[test]
2306 #[should_panic(expected = "absent from the cached CPM schedule")]
2307 fn priority_key_missing_entry_is_invariant_breach() {
2308 let s: HashMap<&str, (f32, f32)> = HashMap::new();
2309 let _ = priority_key("ghost", &s);
2310 }
2311}