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::earned_value::{BaselineMode, BaselineTaken};
38use crate::plan::{
39 AcceptRequest, AcquireRequest, BlockedDeliverable, CallerId, Cohort, CohortRow,
40 ComparePlansRequest, Deliverable, DeliverableStatus, FINISH_ID, FileMode, ForceReleaseRequest,
41 ForkRequest, HeartbeatRequest, LockInfo, MAX_DELIVERABLES, MarkStatusRequest, OwnedFile,
42 PlanDefinition, PlanGraph, PlanId, PlanLineSummary, PlanStatus, PlannerError, ReviseRequest,
43 START_ID, ScheduleRow, SelectOutcome, SyncOutcome, SyncRequest,
44};
45use crate::plan_store::SqlitePlanStore;
46use crate::portfolio::{Archived, Revised, Selected, Synced, VariantInfo};
47use crate::ports::Planner;
48use crate::project::ProjectRoot;
49use crate::revise::RevisionDiff;
50use async_trait::async_trait;
51use chrono::{DateTime, Utc};
52use execution_policy::classify::{FailureClass, RetryDecision};
53use serde_json::json;
54
55use crate::locks::{FileClaim, PlanState, add_file_claims, modes_conflict};
56
57mod ev;
58
59pub const DEFAULT_TTL: Duration = Duration::from_secs(5 * 60);
62
63pub const DEFAULT_MAX_TTL: Duration = Duration::from_secs(8 * 60 * 60);
68
69pub const MAX_TTL_CEILING: Duration = Duration::from_secs(30 * 24 * 60 * 60);
71
72pub const MAX_ATTEMPTS: u32 = 3;
88
89pub const MAX_LAPSES: u32 = 10;
99
100fn lease_retry_decision(failure_count: u32) -> RetryDecision {
105 if failure_count >= MAX_ATTEMPTS {
106 RetryDecision::Stop
107 } else {
108 RetryDecision::Retry
109 }
110}
111
112pub const DEFAULT_EFFORT_HOURS: f32 = 1.0;
120
121pub type ClockFn = Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>;
124
125pub struct BasicCpmPlanner {
127 store: SqlitePlanStore,
130 audit: Arc<dyn AuditSink>,
131 ttl: Duration,
132 max_ttl: Duration,
133 clock: ClockFn,
134 project_root: Option<ProjectRoot>,
137 baseline_mode: BaselineMode,
139}
140
141impl BasicCpmPlanner {
142 pub fn new() -> Self {
146 Self::with_audit(Arc::new(NullAuditSink))
147 }
148
149 pub fn with_audit(audit: Arc<dyn AuditSink>) -> Self {
152 Self::with_parts(audit, DEFAULT_TTL, Arc::new(Utc::now))
153 }
154
155 pub async fn simulate_plan(
158 &self,
159 plan_id: &PlanId,
160 req: &crate::simulate::SimulateRequest,
161 ) -> Result<crate::simulate::SimulationResult, PlannerError> {
162 let graph = self.get_plan(plan_id).await?.graph;
163 crate::simulate::simulate(&graph, req)
164 }
165
166 pub fn with_ttl(mut self, ttl: Duration) -> Self {
168 self.ttl = ttl;
169 self
170 }
171
172 pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
176 self.max_ttl = max_ttl.min(MAX_TTL_CEILING);
177 self
178 }
179
180 pub fn with_clock(mut self, clock: ClockFn) -> Self {
183 self.clock = clock;
184 self
185 }
186
187 pub fn with_parts(audit: Arc<dyn AuditSink>, ttl: Duration, clock: ClockFn) -> Self {
190 let store = SqlitePlanStore::open_in_memory()
191 .expect("INVARIANT: opening a private in-memory sqlite database cannot fail");
192 Self::with_store_parts(store, audit, ttl, clock)
193 }
194
195 pub fn with_store(store: SqlitePlanStore, audit: Arc<dyn AuditSink>) -> Self {
199 Self::with_store_parts(store, audit, DEFAULT_TTL, Arc::new(Utc::now))
200 }
201
202 pub fn with_store_parts(
204 store: SqlitePlanStore,
205 audit: Arc<dyn AuditSink>,
206 ttl: Duration,
207 clock: ClockFn,
208 ) -> Self {
209 Self {
210 store,
211 audit,
212 ttl,
213 max_ttl: DEFAULT_MAX_TTL,
214 clock,
215 project_root: None,
216 baseline_mode: BaselineMode::Auto,
217 }
218 }
219
220 pub fn with_baseline_mode(mut self, mode: BaselineMode) -> Self {
225 self.baseline_mode = mode;
226 self
227 }
228
229 pub fn with_project_root(mut self, root: ProjectRoot) -> Self {
234 self.project_root = Some(root);
235 self
236 }
237
238 #[cfg(test)]
240 pub(crate) fn store(&self) -> &SqlitePlanStore {
241 &self.store
242 }
243
244 pub fn project_root(&self) -> Option<&ProjectRoot> {
247 self.project_root.as_ref()
248 }
249
250 pub fn compare_inputs(
258 &self,
259 req: &ComparePlansRequest,
260 ) -> Result<Vec<(PlanId, String, PlanGraph)>, PlannerError> {
261 if let Some(ids) = &req.plan_ids {
262 let distinct: HashSet<&str> = ids.iter().map(|id| id.0.as_str()).collect();
263 if distinct.len() != ids.len() {
264 return Err(PlannerError::InvalidGraph {
265 reason: "plan_ids must be distinct".to_string(),
266 });
267 }
268 crate::compare::check_variant_count(ids.len())?;
269 }
270 self.store.read_tx(|tx| {
271 let targets: Vec<(PlanId, Option<String>)> = match (&req.plan_ids, &req.plan) {
272 (Some(ids), None) => ids.iter().map(|id| (id.clone(), None)).collect(),
273 (None, Some((project, name))) => {
274 crate::portfolio::live_variants(tx, project, name)?
275 .into_iter()
276 .map(|(variant, id)| (id, Some(variant)))
277 .collect()
278 }
279 _ => {
280 return Err(PlannerError::InvalidGraph {
281 reason: "compare takes exactly one of plan_ids or plan".to_string(),
282 });
283 }
284 };
285 crate::compare::check_variant_count(targets.len())?;
286 targets
287 .into_iter()
288 .map(|(id, label)| {
289 let (_, graph) = crate::portfolio::revision_graph(tx, &id, None)?;
290 let label = match label {
291 Some(variant) => variant,
292 None => crate::portfolio::variant_info(tx, &id)?
293 .map_or_else(|| id.0.clone(), |info| info.variant),
294 };
295 Ok((id, label, graph))
296 })
297 .collect::<Result<Vec<_>, PlannerError>>()
298 })
299 }
300
301 fn check_export_target(
306 &self,
307 plan_id: &PlanId,
308 root: &ProjectRoot,
309 file: &crate::project::PlanFileRef,
310 ) -> Result<(), PlannerError> {
311 let project = root.project_key();
312 let trackers = self.store.read_tx(|tx| {
313 crate::portfolio::variants_tracking(tx, &project, &file.rel_path)?
314 .into_iter()
315 .map(|id| {
316 let info = crate::portfolio::variant_info(tx, &id)?;
317 let (_, head) = crate::portfolio::revision_graph(tx, &id, None)?;
318 let recorded = crate::portfolio::revision_content_hashes(tx, &id)?;
319 Ok((id, info, hash_graph(&head), recorded))
320 })
321 .collect::<Result<Vec<_>, PlannerError>>()
322 })?;
323 for (id, info, head_hash, mut recorded) in trackers {
324 let Some(info) = info else { continue };
325 if id != *plan_id {
326 return Err(PlannerError::InvalidPath {
327 reason: format!(
328 "{} is the tracked plan file of variant '{}' of '{}'; pass force to \
329 overwrite it",
330 file.rel_path, info.variant, info.name
331 ),
332 });
333 }
334 if definition_drift(Some(root), &info, &head_hash) != Some(true) {
335 continue;
336 }
337 recorded.extend(info.content_hash.clone());
341 recorded.insert(head_hash);
342 if has_local_edits(root, file, &recorded) {
343 return Err(PlannerError::InvalidPath {
344 reason: format!(
345 "{} has local edits that were never synced (definition drift); sync it \
346 or pass force to overwrite it",
347 file.rel_path
348 ),
349 });
350 }
351 }
352 Ok(())
353 }
354
355 fn now(&self) -> DateTime<Utc> {
356 (self.clock)()
357 }
358
359 fn effective_ttl(&self, requested: Option<Duration>) -> Duration {
362 requested.unwrap_or(self.ttl).min(self.max_ttl)
363 }
364
365 pub async fn record_audit(&self, event: AuditEvent) {
368 self.flush_audit(vec![event]).await;
369 }
370
371 async fn flush_audit(&self, events: Vec<AuditEvent>) {
374 for ev in events {
375 if let Err(err) = self.audit.record(ev).await {
380 tracing::warn!(error = %err, "audit sink failed to record planner event");
381 }
382 }
383 }
384}
385
386impl Default for BasicCpmPlanner {
387 fn default() -> Self {
388 Self::new()
389 }
390}
391
392fn definition_drift(
402 root: Option<&ProjectRoot>,
403 info: &VariantInfo,
404 head_hash: &str,
405) -> Option<bool> {
406 let root = root?;
407 if root.project_key() != info.project {
408 return None;
409 }
410 let rel = info.source_path.as_deref()?;
411 let file = root.resolve_plan_file(rel).ok()?;
412 let bytes = root.read_bytes(&file).ok()?;
413 if info.content_hash.as_deref() == Some(crate::project::content_hash(&bytes).as_str()) {
414 return Some(false);
415 }
416 match serde_json::from_slice::<PlanGraph>(&bytes) {
417 Ok(graph) => Some(hash_graph(&graph) != head_hash),
418 Err(_) => Some(true),
419 }
420}
421
422fn has_local_edits(
425 root: &ProjectRoot,
426 file: &crate::project::PlanFileRef,
427 recorded: &HashSet<String>,
428) -> bool {
429 let Ok(bytes) = root.read_bytes(file) else {
430 return false;
431 };
432 if recorded.contains(&crate::project::content_hash(&bytes)) {
433 return false;
434 }
435 match serde_json::from_slice::<PlanGraph>(&bytes) {
436 Ok(graph) => !recorded.contains(&hash_graph(&graph)),
437 Err(_) => true,
438 }
439}
440
441pub(crate) fn canonical_deliverable(d: &Deliverable) -> serde_json::Value {
450 let mut prereqs: Vec<serde_json::Value> = d
451 .prerequisites
452 .iter()
453 .map(|p| {
454 json!({
455 "id": p.id(),
456 "consumes": p.consumes(),
457 "kind": p.kind(),
458 "lag_hours": p.lag_hours() + 0.0,
459 })
460 })
461 .collect();
462 prereqs.sort_by_cached_key(ToString::to_string);
465 let mut files: Vec<serde_json::Value> = d
466 .owned_files
467 .iter()
468 .map(|f| json!({ "path": f.path().to_string_lossy(), "mode": f.mode() }))
469 .collect();
470 files.sort_by_cached_key(ToString::to_string);
471 let mut canonical = json!({
472 "id": d.id,
473 "owned_files": files,
474 "prerequisites": prereqs,
475 "estimated_effort_hours": d.estimated_effort_hours,
476 "duration_hours": d.duration_hours,
477 "estimate": d.estimate,
478 "metadata": d.metadata,
479 "milestone": d.milestone,
480 });
481 if let Some(rule) = d
485 .earning_rule
486 .filter(|r| *r != crate::plan::EarningRule::ZeroHundred)
487 {
488 canonical["earning_rule"] = json!(rule);
489 }
490 canonical
491}
492
493pub(crate) fn hash_graph(graph: &PlanGraph) -> String {
498 let mut deliverables: Vec<_> = graph
502 .deliverables
503 .iter()
504 .map(canonical_deliverable)
505 .collect();
506 deliverables.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
507
508 let payload = json!({
509 "deliverables": deliverables,
510 "max_chained_dispatch": graph.max_chained_dispatch,
511 });
512
513 let serialised = serde_json::to_vec(&payload)
519 .expect("INVARIANT: plan-graph hash payload is JSON-serialisable");
520 crate::skills::sha256_hex(&serialised)
521}
522
523pub(crate) fn validate_graph(graph: &PlanGraph) -> Result<(), PlannerError> {
528 if graph.deliverables.len() > MAX_DELIVERABLES {
529 return Err(PlannerError::InvalidGraph {
530 reason: format!(
531 "plan has {} deliverables; maximum is {MAX_DELIVERABLES}",
532 graph.deliverables.len()
533 ),
534 });
535 }
536 for d in &graph.deliverables {
537 if d.id == START_ID || d.id == FINISH_ID {
538 return Err(PlannerError::InvalidGraph {
539 reason: format!("deliverable id '{}' is reserved", d.id),
540 });
541 }
542 }
543
544 let mut seen_ids: HashSet<&str> = HashSet::new();
546 for d in &graph.deliverables {
547 if !seen_ids.insert(d.id.as_str()) {
548 return Err(PlannerError::InvalidGraph {
549 reason: format!("duplicate deliverable id '{}'", d.id),
550 });
551 }
552 }
553
554 if let Some(problem) = crate::graph::value_problems(graph).into_iter().next() {
557 return Err(PlannerError::InvalidGraph {
558 reason: problem.message,
559 });
560 }
561
562 let id_set: HashSet<&str> = graph.deliverables.iter().map(|d| d.id.as_str()).collect();
564 for d in &graph.deliverables {
565 for p in crate::graph::prerequisite_ids(d) {
566 if !id_set.contains(p) {
567 return Err(PlannerError::InvalidGraph {
568 reason: format!(
569 "prerequisite '{p}' for deliverable '{}' does not exist",
570 d.id
571 ),
572 });
573 }
574 }
575 }
576
577 let mut reach: Option<HashMap<String, HashSet<String>>> = None;
581 let mut claimants: HashMap<&Path, Vec<(&str, FileMode)>> = HashMap::new();
582 let mut path_order: Vec<&Path> = Vec::new();
583 for d in &graph.deliverables {
584 for f in &d.owned_files {
585 let entry = claimants.entry(f.path()).or_insert_with(|| {
586 path_order.push(f.path());
587 Vec::new()
588 });
589 entry.push((d.id.as_str(), f.mode()));
590 }
591 }
592 for path in path_order {
593 let list = &claimants[path];
594 for (i, &(x, xm)) in list.iter().enumerate() {
595 for &(y, ym) in &list[i + 1..] {
596 if x == y || (xm == FileMode::Append && ym == FileMode::Append) {
597 continue;
598 }
599 let reach = reach.get_or_insert_with(|| crate::graph::reachability(graph));
600 let ordered = reach.get(x).is_some_and(|r| r.contains(y))
601 || reach.get(y).is_some_and(|r| r.contains(x));
602 if !ordered {
603 return Err(PlannerError::InvalidGraph {
604 reason: format!(
605 "file '{}' is claimed by '{x}' and '{y}', which are not ordered by prerequisites (one could run while the other holds it)",
606 path.display(),
607 ),
608 });
609 }
610 }
611 }
612 }
613
614 let mut indeg: HashMap<&str, usize> = HashMap::new();
616 let mut succs: HashMap<&str, Vec<&str>> = HashMap::new();
617 for d in &graph.deliverables {
618 indeg.entry(d.id.as_str()).or_insert(0);
619 succs.entry(d.id.as_str()).or_default();
620 }
621 for d in &graph.deliverables {
622 for p in crate::graph::prerequisite_ids(d) {
623 *indeg.entry(d.id.as_str()).or_insert(0) += 1;
624 succs.entry(p).or_default().push(d.id.as_str());
625 }
626 }
627 let mut queue: Vec<&str> = indeg
628 .iter()
629 .filter_map(|(k, v)| if *v == 0 { Some(*k) } else { None })
630 .collect();
631 let mut popped = 0_usize;
632 while let Some(node) = queue.pop() {
633 popped += 1;
634 if let Some(s) = succs.get(node).cloned() {
635 for next in s {
636 if let Some(deg) = indeg.get_mut(next) {
637 *deg -= 1;
638 if *deg == 0 {
639 queue.push(next);
640 }
641 }
642 }
643 }
644 }
645 if popped < graph.deliverables.len() {
646 let mut cycle_members: Vec<&str> = indeg
653 .iter()
654 .filter_map(|(k, v)| if *v > 0 { Some(*k) } else { None })
655 .collect();
656 cycle_members.sort_unstable();
657 return Err(PlannerError::InvalidGraph {
658 reason: format!(
659 "cycle detected in prerequisite graph involving deliverables: [{}]",
660 cycle_members.join(", ")
661 ),
662 });
663 }
664
665 Ok(())
666}
667
668fn initial_plan(graph: PlanGraph) -> Result<(PlanId, PlanState), PlannerError> {
671 let cached_result = crate::schedule::compute_cpm(&graph)?;
672 let mut statuses: HashMap<String, DeliverableStatus> =
673 HashMap::with_capacity(graph.deliverables.len());
674 for d in &graph.deliverables {
675 let status = if d.prerequisites.is_empty() {
676 DeliverableStatus::Ready
677 } else {
678 DeliverableStatus::Pending
679 };
680 statuses.insert(d.id.clone(), status);
681 }
682 let plan_id = PlanId(format!("plan_{}", uuid::Uuid::new_v4().simple()));
683 Ok((plan_id, PlanState::new(graph, statuses, cached_result)))
684}
685
686fn make_portfolio_created_event(
691 outcome: &SyncOutcome,
692 project: &str,
693 selected: bool,
694) -> AuditEvent {
695 AuditEvent::new("plan.portfolio.created").with_payload(json!({
696 "plan_id": outcome.plan_id.as_str(),
697 "project": project,
698 "name": outcome.name,
699 "variant": outcome.variant,
700 "revision": outcome.revision,
701 "selected": selected,
702 }))
703}
704
705fn make_portfolio_revised_event(
706 plan_id: &PlanId,
707 revision: u32,
708 diff: &RevisionDiff,
709) -> AuditEvent {
710 AuditEvent::new("plan.portfolio.revised").with_payload(json!({
711 "plan_id": plan_id.as_str(),
712 "revision": revision,
713 "diff": diff,
714 }))
715}
716
717fn revision_events(plan_id: &PlanId, revised: &Revised, now: DateTime<Utc>) -> Vec<AuditEvent> {
720 if revised.no_op {
721 return Vec::new();
722 }
723 let mut events: Vec<AuditEvent> = revised
724 .reaped
725 .iter()
726 .map(|lock| make_expired_event(lock, now))
727 .collect();
728 events.extend(
729 revised
730 .released
731 .iter()
732 .map(|lock| make_released_event(lock, "revision")),
733 );
734 events.push(make_portfolio_revised_event(
735 plan_id,
736 revised.revision,
737 &revised.diff,
738 ));
739 if let Some(count) = revised.completed {
740 events.push(make_plan_completed_event(plan_id, count));
741 }
742 events
743}
744
745fn make_portfolio_selected_event(outcome: &SelectOutcome) -> AuditEvent {
746 AuditEvent::new("plan.portfolio.selected").with_payload(json!({
747 "plan_id": outcome.plan_id.as_str(),
748 "project": outcome.project,
749 "name": outcome.name,
750 "from": outcome.previous,
751 "to": outcome.variant,
752 "carried": outcome.carried,
753 "released": outcome.released_locks,
754 }))
755}
756
757fn selection_events(selected: &Selected, now: DateTime<Utc>) -> Vec<AuditEvent> {
762 if !selected.outcome.changed {
763 return Vec::new();
764 }
765 let mut events: Vec<AuditEvent> = selected
766 .reaped
767 .iter()
768 .map(|lock| make_expired_event(lock, now))
769 .collect();
770 events.extend(
771 selected
772 .released
773 .iter()
774 .map(|lock| make_released_event(lock, "variant deselected")),
775 );
776 events.push(make_portfolio_selected_event(&selected.outcome));
777 if let Some(count) = selected.completed {
778 events.push(make_plan_completed_event(&selected.outcome.plan_id, count));
779 }
780 events
781}
782
783fn archive_events(
788 project: &str,
789 name: &str,
790 variant: Option<&str>,
791 archived: bool,
792 outcome: &Archived,
793 now: DateTime<Utc>,
794) -> Vec<AuditEvent> {
795 if !outcome.changed() {
796 return Vec::new();
797 }
798 let mut events: Vec<AuditEvent> = outcome
799 .reaped
800 .iter()
801 .map(|lock| make_expired_event(lock, now))
802 .collect();
803 events.extend(
804 outcome
805 .released
806 .iter()
807 .map(|lock| make_released_event(lock, "line archived")),
808 );
809 let variants: Vec<serde_json::Value> = outcome
810 .variants
811 .iter()
812 .map(|(v, plan_id)| json!({ "variant": v, "plan_id": plan_id.as_str() }))
813 .collect();
814 let event_type = if archived {
815 "plan.portfolio.archived"
816 } else {
817 "plan.portfolio.unarchived"
818 };
819 events.push(AuditEvent::new(event_type).with_payload(json!({
820 "project": project,
821 "name": name,
822 "variant": variant,
823 "line": outcome.line,
824 "variants": variants,
825 "released": outcome
826 .released
827 .iter()
828 .map(|l| l.deliverable_id.as_str())
829 .collect::<Vec<_>>(),
830 })));
831 events
832}
833
834fn sync_outcome(
836 synced: Synced,
837 project: &str,
838 name: String,
839 variant: String,
840 now: DateTime<Utc>,
841) -> (SyncOutcome, Vec<AuditEvent>) {
842 let outcome = |plan_id, revision, created, changed, diff| SyncOutcome {
843 plan_id,
844 name: name.clone(),
845 variant: variant.clone(),
846 revision,
847 created,
848 changed,
849 diff,
850 };
851 match synced {
852 Synced::Created { plan_id, selected } => {
853 let out = outcome(plan_id, 1, true, true, None);
854 let event = make_portfolio_created_event(&out, project, selected);
855 (out, vec![event])
856 }
857 Synced::Unchanged { plan_id, revision } => {
858 (outcome(plan_id, revision, false, false, None), Vec::new())
859 }
860 Synced::Revised { plan_id, revised } => {
861 let events = revision_events(&plan_id, &revised, now);
862 let out = outcome(plan_id, revised.revision, false, true, Some(revised.diff));
863 (out, events)
864 }
865 }
866}
867
868const MAX_PROJECT_LEN: usize = 512;
871
872fn is_disallowed_project_char(c: char) -> bool {
877 c.is_control()
878 || matches!(
879 c,
880 '\u{200B}'..='\u{200F}'
881 | '\u{202A}'..='\u{202E}'
882 | '\u{2060}'..='\u{206F}'
883 | '\u{FEFF}'
884 | '\u{2028}'
885 | '\u{2029}'
886 | '\u{FFF9}'..='\u{FFFB}'
887 )
888}
889
890fn validate_variant_key(req: &SyncRequest) -> Result<(), PlannerError> {
894 if req.project.is_empty() || req.project.chars().count() > MAX_PROJECT_LEN {
895 return Err(PlannerError::InvalidPath {
896 reason: format!("project must be 1..={MAX_PROJECT_LEN} characters"),
897 });
898 }
899 if req.project.chars().any(is_disallowed_project_char) {
900 return Err(PlannerError::InvalidPath {
901 reason: "project must not contain control or invisible format characters".to_string(),
902 });
903 }
904 crate::project::validate_slug("name", &req.name)?;
905 crate::project::validate_slug("variant", &req.variant)
906}
907
908fn make_acquired_event(lock: &LockInfo, owned_files: &[OwnedFile]) -> AuditEvent {
909 AuditEvent::new("plan.lock.acquired")
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 "acquired_at": lock.acquired_at,
916 "expires_at": lock.expires_at,
917 "owned_files": owned_files,
918 }))
919}
920
921fn make_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
922 AuditEvent::new("plan.lock.released")
923 .with_actor(lock.caller_id.as_str())
924 .with_payload(json!({
925 "plan_id": lock.plan_id.as_str(),
926 "deliverable_id": lock.deliverable_id,
927 "caller_id": lock.caller_id.as_str(),
928 "reason": reason,
929 }))
930}
931
932fn make_expired_event(lock: &LockInfo, expired_at: DateTime<Utc>) -> AuditEvent {
933 AuditEvent::new("plan.lock.expired")
934 .with_actor(lock.caller_id.as_str())
935 .with_payload(json!({
936 "plan_id": lock.plan_id.as_str(),
937 "deliverable_id": lock.deliverable_id,
938 "last_caller_id": lock.caller_id.as_str(),
939 "expired_at": expired_at,
940 }))
941}
942
943fn make_circuit_break_event(
944 plan_id: &PlanId,
945 deliverable_id: &str,
946 failure_count: u32,
947 class: FailureClass,
948 reason: &str,
949) -> AuditEvent {
950 AuditEvent::new("plan.deliverable.circuit_broken").with_payload(json!({
951 "plan_id": plan_id.as_str(),
952 "deliverable_id": deliverable_id,
953 "failure_count": failure_count,
954 "max_attempts": MAX_ATTEMPTS,
955 "failure_class": format!("{class:?}"),
956 "retry_decision": format!("{:?}", RetryDecision::Stop),
957 "reason": reason,
958 }))
959}
960
961pub(crate) fn validate_actuals(
965 status: &DeliverableStatus,
966 earned_pct: Option<f64>,
967 actual_effort_hours: Option<f64>,
968 evidence: Option<&str>,
969) -> Result<(), PlannerError> {
970 let bad = |reason: String| Err(PlannerError::InvalidActuals { reason });
971 if let Some(pct) = earned_pct {
972 if !(pct.fract() == 0.0 && (0.0..=100.0).contains(&pct)) {
973 return bad(format!("earned_pct must be an integer 0..=100, got {pct}"));
974 }
975 if !matches!(
976 status,
977 DeliverableStatus::InProgress | DeliverableStatus::Complete
978 ) {
979 return bad(
980 "earned_pct is only accepted with status in_progress (or complete, where it is \
981 ignored)"
982 .to_string(),
983 );
984 }
985 }
986 if let Some(h) = actual_effort_hours
987 && !(h.is_finite() && (0.0..=f64::from(crate::plan::MAX_HOURS)).contains(&h))
988 {
989 return bad(format!(
990 "actual_effort_hours must be a finite number between 0 and 1000000, got {h}"
991 ));
992 }
993 if let Some(e) = evidence {
994 let chars = e.chars().count();
995 if chars > crate::plan::MAX_EVIDENCE_CHARS {
996 return bad(format!(
997 "evidence must be at most {} characters, got {chars}",
998 crate::plan::MAX_EVIDENCE_CHARS
999 ));
1000 }
1001 }
1002 Ok(())
1003}
1004
1005fn incomplete_prerequisites(state: &PlanState, deliverable_id: &str) -> Vec<String> {
1007 state
1008 .graph
1009 .deliverables
1010 .iter()
1011 .find(|d| d.id == deliverable_id)
1012 .map(|d| {
1013 crate::graph::prerequisite_ids(d)
1014 .filter(|p| !matches!(state.statuses.get(*p), Some(DeliverableStatus::Complete)))
1015 .map(str::to_string)
1016 .collect()
1017 })
1018 .unwrap_or_default()
1019}
1020
1021fn complete_deliverable(
1029 state: &mut PlanState,
1030 deliverable_id: &str,
1031 audit_buf: &mut Vec<AuditEvent>,
1032 release_reason: &str,
1033 now: DateTime<Utc>,
1034) -> bool {
1035 let was_complete = all_complete(state);
1036 if let Some(lock) = state.end_lease(deliverable_id, now) {
1037 audit_buf.push(make_released_event(&lock, release_reason));
1038 }
1039
1040 state
1041 .statuses
1042 .insert(deliverable_id.to_string(), DeliverableStatus::Complete);
1043
1044 let dependents: Vec<String> = state
1045 .graph
1046 .deliverables
1047 .iter()
1048 .filter(|d| crate::graph::prerequisite_ids(d).any(|p| p == deliverable_id))
1049 .map(|d| d.id.clone())
1050 .collect();
1051 for dep_id in dependents {
1052 let dep = match state.graph.deliverables.iter().find(|d| d.id == dep_id) {
1053 Some(d) => d,
1054 None => unreachable!("dependent id {dep_id} present in graph but not findable"),
1055 };
1056 let all_done = crate::graph::prerequisite_ids(dep)
1057 .all(|p| matches!(state.statuses.get(p), Some(DeliverableStatus::Complete)));
1058 let currently_pending = matches!(
1059 state.statuses.get(&dep_id),
1060 Some(DeliverableStatus::Pending)
1061 );
1062 if all_done && currently_pending {
1063 state.statuses.insert(dep_id, DeliverableStatus::Ready);
1064 }
1065 }
1066 !was_complete && all_complete(state)
1067}
1068
1069pub(crate) fn all_complete(state: &PlanState) -> bool {
1071 state
1072 .graph
1073 .deliverables
1074 .iter()
1075 .all(|d| matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Complete)))
1076}
1077
1078fn make_plan_completed_event(plan_id: &PlanId, deliverable_count: usize) -> AuditEvent {
1079 AuditEvent::new("plan.completed").with_payload(json!({
1080 "plan_id": plan_id.as_str(),
1081 "deliverable_count": deliverable_count,
1082 }))
1083}
1084
1085fn make_accepted_event(
1086 req: &AcceptRequest,
1087 overrode_lock_of: Option<&str>,
1088 previous_status: &DeliverableStatus,
1089) -> AuditEvent {
1090 AuditEvent::new("plan.deliverable.accepted")
1091 .with_actor(req.accepted_by.as_str())
1092 .with_payload(json!({
1093 "plan_id": req.plan_id.as_str(),
1094 "deliverable_id": req.deliverable_id,
1095 "accepted_by": req.accepted_by,
1096 "evidence": req.evidence,
1097 "overrode_lock_of": overrode_lock_of,
1098 "previous_status": previous_status,
1099 }))
1100}
1101
1102fn make_completed_without_lease_event(
1103 plan_id: &PlanId,
1104 deliverable_id: &str,
1105 caller_id: &CallerId,
1106 previous_status: &DeliverableStatus,
1107) -> AuditEvent {
1108 AuditEvent::new("plan.deliverable.completed_without_lease")
1109 .with_actor(caller_id.as_str())
1110 .with_payload(json!({
1111 "plan_id": plan_id.as_str(),
1112 "deliverable_id": deliverable_id,
1113 "caller_id": caller_id.as_str(),
1114 "previous_status": previous_status,
1115 }))
1116}
1117
1118fn make_marked_without_lease_event(
1119 plan_id: &PlanId,
1120 deliverable_id: &str,
1121 caller_id: &CallerId,
1122 status: &DeliverableStatus,
1123 previous_status: &DeliverableStatus,
1124) -> AuditEvent {
1125 AuditEvent::new("plan.deliverable.marked_without_lease")
1126 .with_actor(caller_id.as_str())
1127 .with_payload(json!({
1128 "plan_id": plan_id.as_str(),
1129 "deliverable_id": deliverable_id,
1130 "caller_id": caller_id.as_str(),
1131 "status": status,
1132 "previous_status": previous_status,
1133 }))
1134}
1135
1136fn make_force_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
1137 AuditEvent::new("plan.lock.force_released")
1138 .with_actor(lock.caller_id.as_str())
1139 .with_payload(json!({
1140 "plan_id": lock.plan_id.as_str(),
1141 "deliverable_id": lock.deliverable_id,
1142 "last_caller_id": lock.caller_id.as_str(),
1143 "reason": reason,
1144 }))
1145}
1146
1147fn make_counters_reset_event(
1148 plan_id: &PlanId,
1149 deliverable_id: &str,
1150 reason: &str,
1151 lapse_count: u32,
1152 failure_count: u32,
1153) -> AuditEvent {
1154 AuditEvent::new("plan.deliverable.counters_reset")
1155 .with_actor("operator")
1156 .with_payload(json!({
1157 "plan_id": plan_id.as_str(),
1158 "deliverable_id": deliverable_id,
1159 "reason": reason,
1160 "previous": { "lapse_count": lapse_count, "failure_count": failure_count },
1161 }))
1162}
1163
1164fn priority_key(
1172 deliverable_id: &str,
1173 sched_by_id: &HashMap<&str, (f32, f32)>,
1174) -> (i64, i64, String) {
1175 let (latest_start, float) = match sched_by_id.get(deliverable_id) {
1176 Some(&v) => v,
1177 None => unreachable!(
1178 "deliverable '{deliverable_id}' is in the ready set but absent from the cached \
1179 CPM schedule — ready set and CPM result are out of sync"
1180 ),
1181 };
1182 let scale = |h: f32| (h * 1000.0).round() as i64;
1183 (
1184 scale(latest_start),
1185 scale(float),
1186 deliverable_id.to_string(),
1187 )
1188}
1189
1190fn ready_display_key(
1195 deliverable_id: &str,
1196 sched_by_id: &HashMap<&str, (f32, f32)>,
1197) -> (i64, i64, String) {
1198 let (latest_start, float) = match sched_by_id.get(deliverable_id) {
1199 Some(&v) => v,
1200 None => unreachable!(
1201 "deliverable '{deliverable_id}' is in the ready set but absent from the cached \
1202 CPM schedule — ready set and CPM result are out of sync"
1203 ),
1204 };
1205 let scale = |h: f32| (h * 1000.0).round() as i64;
1206 (
1207 scale(float),
1208 scale(latest_start),
1209 deliverable_id.to_string(),
1210 )
1211}
1212
1213#[async_trait]
1218impl Planner for BasicCpmPlanner {
1219 async fn submit_plan(&self, graph: PlanGraph) -> Result<PlanId, PlannerError> {
1220 validate_graph(&graph)?;
1221 let graph_hash = hash_graph(&graph);
1222
1223 self.store
1228 .submit_or_get(&graph_hash, move || initial_plan(graph))
1229 }
1230
1231 async fn sync_plan(&self, req: SyncRequest) -> Result<SyncOutcome, PlannerError> {
1232 validate_variant_key(&req)?;
1233 validate_graph(&req.graph)?;
1234 let graph_hash = hash_graph(&req.graph);
1235 let now = self.now();
1236 let (project, name, variant) = (req.project.clone(), req.name.clone(), req.variant.clone());
1237 let synced = self
1240 .store
1241 .write_tx(|tx| crate::portfolio::sync(tx, req, &graph_hash, now, initial_plan))?;
1242 let (outcome, events) = sync_outcome(synced, &project, name, variant, now);
1243 self.flush_audit(events).await;
1244 Ok(outcome)
1245 }
1246
1247 async fn fork_plan(&self, req: ForkRequest) -> Result<SyncOutcome, PlannerError> {
1248 let ForkRequest {
1249 plan_id,
1250 variant,
1251 edits,
1252 project_root,
1253 } = req;
1254 crate::project::validate_slug("variant", &variant)?;
1255 let (head, info, exists, line_archived) = self.store.read_tx(|tx| {
1256 let (_, head) = crate::portfolio::revision_graph(tx, &plan_id, None)?;
1257 let info = crate::portfolio::variant_info(tx, &plan_id)?.ok_or_else(|| {
1258 PlannerError::InvalidPath {
1259 reason: "fork requires a named plan".to_string(),
1260 }
1261 })?;
1262 let exists = crate::portfolio::has_variant(tx, &info.project, &info.name, &variant)?;
1263 let line_archived = crate::portfolio::line_is_archived(tx, &info.project, &info.name)?;
1264 Ok((head, info, exists, line_archived))
1265 })?;
1266 if line_archived {
1271 return Err(crate::portfolio::line_archived(&info.name));
1272 }
1273 if exists {
1274 return Err(crate::portfolio::variant_exists(&variant, &info.name));
1275 }
1276 let graph = crate::edits::apply_edits(&head, &edits)?;
1277 let root = match project_root {
1278 Some(root) if root.project_key() != info.project => {
1279 return Err(PlannerError::InvalidPath {
1280 reason: format!(
1281 "the project root belongs to a different project than plan {}",
1282 plan_id.0
1283 ),
1284 });
1285 }
1286 Some(root) => Some(root),
1287 None => self
1288 .project_root
1289 .clone()
1290 .filter(|root| root.project_key() == info.project),
1291 };
1292 let mut sync_req = SyncRequest::new(
1293 info.project.clone(),
1294 info.name.clone(),
1295 variant.clone(),
1296 graph,
1297 );
1298 let mut written = None;
1299 if let Some(root) = &root {
1300 let file = root.plan_file(&info.name, &variant)?;
1301 let hash = root.write_new_graph(&file, &sync_req.graph)?;
1302 sync_req = sync_req
1303 .with_source_path(file.rel_path.clone())
1304 .with_content_hash(hash.clone());
1305 written = Some((root, file, hash));
1306 }
1307 let graph_hash = hash_graph(&sync_req.graph);
1308 let now = self.now();
1309 let synced = self
1310 .store
1311 .write_tx(|tx| crate::portfolio::sync_new(tx, sync_req, &graph_hash, now, initial_plan))
1312 .inspect_err(|_| {
1313 if let Some((root, file, hash)) = &written {
1315 root.remove_plan_file_if_unchanged(file, hash);
1316 }
1317 })?;
1318 let (outcome, events) = sync_outcome(synced, &info.project, info.name, variant, now);
1319 self.flush_audit(events).await;
1320 Ok(outcome)
1321 }
1322
1323 async fn compare_plans(
1324 &self,
1325 req: ComparePlansRequest,
1326 ) -> Result<crate::compare::Comparison, PlannerError> {
1327 let inputs = self.compare_inputs(&req)?;
1328 crate::compare::compare(&inputs, &req.request)
1329 }
1330
1331 async fn export_plan(
1332 &self,
1333 plan_id: &PlanId,
1334 root: &ProjectRoot,
1335 path: Option<&str>,
1336 force: bool,
1337 ) -> Result<String, PlannerError> {
1338 let (graph, info) = self.store.read_tx(|tx| {
1339 let (_, graph) = crate::portfolio::revision_graph(tx, plan_id, None)?;
1340 Ok((graph, crate::portfolio::variant_info(tx, plan_id)?))
1341 })?;
1342 let file = match (path, &info) {
1343 (Some(path), _) => root.resolve_plan_file(path)?,
1344 (None, Some(info)) if info.project == root.project_key() => {
1345 root.plan_file(&info.name, &info.variant)?
1346 }
1347 (None, Some(_)) => {
1348 return Err(PlannerError::InvalidPath {
1349 reason: format!(
1350 "plan {} belongs to a different project; give a path",
1351 plan_id.0
1352 ),
1353 });
1354 }
1355 (None, None) => {
1356 return Err(PlannerError::InvalidPath {
1357 reason: format!("plan {} is unnamed; export requires a path", plan_id.0),
1358 });
1359 }
1360 };
1361 if !force {
1362 self.check_export_target(plan_id, root, &file)?;
1363 }
1364 let hash = root.write_graph(&file, &graph)?;
1365 if let Some(info) = &info
1368 && info.project == root.project_key()
1369 && info.source_path.as_deref() == Some(file.rel_path.as_str())
1370 {
1371 self.store
1372 .write_tx(|tx| crate::portfolio::set_content_hash(tx, plan_id, &hash))?;
1373 }
1374 Ok(file.rel_path)
1375 }
1376
1377 async fn baseline(
1378 &self,
1379 req: crate::earned_value::BaselineRequest,
1380 ) -> Result<crate::earned_value::BaselineOutcome, PlannerError> {
1381 self.take_baseline(req).await
1382 }
1383
1384 async fn ev(
1385 &self,
1386 plan_id: &PlanId,
1387 as_of: Option<DateTime<Utc>>,
1388 ) -> Result<crate::earned_value::EvReport, PlannerError> {
1389 self.ev_report(plan_id, as_of)
1390 }
1391
1392 async fn snapshot(
1393 &self,
1394 req: crate::earned_value::SnapshotRequest,
1395 ) -> Result<crate::earned_value::SnapshotOutcome, PlannerError> {
1396 self.take_snapshot(req)
1397 }
1398
1399 async fn list_plans(
1400 &self,
1401 project: &str,
1402 include_archived: bool,
1403 ) -> Result<Vec<PlanLineSummary>, PlannerError> {
1404 self.store
1405 .read_tx(|tx| crate::portfolio::list(tx, project, include_archived))
1406 }
1407
1408 async fn revision_graph(
1409 &self,
1410 plan_id: &PlanId,
1411 revision: Option<u32>,
1412 ) -> Result<(u32, PlanGraph), PlannerError> {
1413 self.store
1414 .read_tx(|tx| crate::portfolio::revision_graph(tx, plan_id, revision))
1415 }
1416
1417 async fn revise_plan(&self, req: ReviseRequest) -> Result<(u32, RevisionDiff), PlannerError> {
1418 let ReviseRequest {
1419 plan_id,
1420 graph,
1421 force,
1422 } = req;
1423 let now = self.now();
1424 let revised = self.store.write_tx(|tx| {
1425 crate::portfolio::ensure_revisable(tx, &plan_id)?;
1426 crate::portfolio::revise(tx, &plan_id, graph, None, None, force, now)
1427 })?;
1428 self.flush_audit(revision_events(&plan_id, &revised, now))
1429 .await;
1430 Ok((revised.revision, revised.diff))
1431 }
1432
1433 async fn select_variant(
1434 &self,
1435 plan_id: &PlanId,
1436 force: bool,
1437 ) -> Result<SelectOutcome, PlannerError> {
1438 let now = self.now();
1439 let mut baseline_audit: Vec<AuditEvent> = Vec::new();
1440 let selected = self.store.write_tx(|tx| {
1445 let mut selected = crate::portfolio::select(tx, plan_id, force, now)?;
1446 if selected.outcome.changed {
1447 let state = crate::plan_store::load_plan_state(tx, plan_id)?.ok_or_else(|| {
1448 PlannerError::PlanNotFound {
1449 plan_id: plan_id.0.clone(),
1450 }
1451 })?;
1452 if ev::state_has_progress(&state) {
1453 selected.outcome.baseline_taken = self.ensure_execution_baseline(
1454 tx,
1455 plan_id,
1456 &state,
1457 now,
1458 &mut baseline_audit,
1459 )?;
1460 }
1461 }
1462 Ok(selected)
1463 })?;
1464 let mut events = selection_events(&selected, now);
1465 events.extend(baseline_audit);
1466 self.flush_audit(events).await;
1467 Ok(selected.outcome)
1468 }
1469
1470 async fn archive(
1471 &self,
1472 project: &str,
1473 name: &str,
1474 variant: Option<&str>,
1475 archived: bool,
1476 force: bool,
1477 ) -> Result<(), PlannerError> {
1478 let now = self.now();
1479 let outcome = self.store.write_tx(|tx| {
1480 crate::portfolio::archive(tx, project, name, variant, archived, force, now)
1481 })?;
1482 self.flush_audit(archive_events(
1483 project, name, variant, archived, &outcome, now,
1484 ))
1485 .await;
1486 Ok(())
1487 }
1488
1489 async fn acquire_cohort(&self, req: AcquireRequest) -> Result<Cohort, PlannerError> {
1490 let AcquireRequest {
1491 plan_id,
1492 caller_id,
1493 max_count,
1494 ids,
1495 metadata_filter,
1496 ttl,
1497 } = req;
1498 let now = self.now();
1499 let expires_at = now
1500 + chrono::Duration::from_std(self.effective_ttl(ttl))
1501 .expect("INVARIANT: planner TTL fits in chrono::Duration");
1502
1503 let mut audit_buf: Vec<AuditEvent> = Vec::new();
1507 let store = &self.store;
1508 let cohort = store.mutate_executable_plan_tx(&plan_id, |tx, state| {
1509 let reaped = state.reap_expired(now);
1511 for lock in &reaped {
1512 audit_buf.push(make_expired_event(lock, now));
1513 }
1514
1515 if let Some(req_ids) = &ids {
1520 for rid in req_ids {
1521 if !state.graph.deliverables.iter().any(|d| &d.id == rid) {
1522 return Err(PlannerError::DeliverableNotFound {
1523 plan_id: plan_id.0.clone(),
1524 deliverable_id: rid.clone(),
1525 });
1526 }
1527 }
1528 }
1529 let is_requested = |id: &str| ids.as_ref().is_some_and(|v| v.iter().any(|r| r == id));
1530 let is_manual =
1531 |d: &Deliverable| d.metadata.get("kind").and_then(|k| k.as_str()) == Some("manual");
1532 let matches_filter = |d: &Deliverable| {
1533 metadata_filter
1534 .as_ref()
1535 .is_none_or(|f| f.iter().all(|(k, v)| d.metadata.get(k) == Some(v)))
1536 };
1537 let in_scope =
1538 |d: &Deliverable| (ids.is_none() || is_requested(&d.id)) && matches_filter(d);
1539
1540 let poisoned: Vec<(String, u32)> = state
1560 .graph
1561 .deliverables
1562 .iter()
1563 .filter(|d| {
1564 matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1565 && lease_retry_decision(state.failure_count(&d.id)) == RetryDecision::Stop
1566 })
1567 .map(|d| (d.id.clone(), state.failure_count(&d.id)))
1568 .collect();
1569 for (id, failures) in poisoned {
1570 let reason = format!("circuit-break: exceeded {MAX_ATTEMPTS} failed attempts");
1573 audit_buf.push(make_circuit_break_event(
1574 &plan_id,
1575 &id,
1576 failures,
1577 FailureClass::Permanent,
1578 &reason,
1579 ));
1580 state
1581 .statuses
1582 .insert(id, DeliverableStatus::Failed { reason });
1583 }
1584
1585 let lapse_blocked: Vec<BlockedDeliverable> = state
1588 .graph
1589 .deliverables
1590 .iter()
1591 .filter(|d| {
1592 in_scope(d)
1593 && !is_manual(d)
1594 && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1595 && !state.locks.contains_key(&d.id)
1596 && state.lapse_count(&d.id) >= MAX_LAPSES
1597 })
1598 .map(|d| BlockedDeliverable {
1599 id: d.id.clone(),
1600 code: "LAPSE_LIMIT".to_string(),
1601 reason: format!(
1602 "lease lapsed {} times (limit {MAX_LAPSES}); clear with \
1603 plan.force_release {{reset_counters: true}}",
1604 state.lapse_count(&d.id)
1605 ),
1606 })
1607 .collect();
1608
1609 let sched_by_id: HashMap<&str, (f32, f32)> = state
1611 .cached_result
1612 .tasks
1613 .iter()
1614 .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
1615 .collect();
1616
1617 let mut blocked: Vec<BlockedDeliverable> = Vec::new();
1622 let mut ready: Vec<&Deliverable> = Vec::new();
1623 if let Some(req_ids) = &ids {
1624 let mut seen: HashSet<&str> = HashSet::new();
1625 for rid in req_ids {
1626 if !seen.insert(rid.as_str()) {
1627 continue;
1628 }
1629 let d = state
1630 .graph
1631 .deliverables
1632 .iter()
1633 .find(|d| &d.id == rid)
1634 .expect("INVARIANT: requested ids validated above");
1635 if !matches_filter(d) {
1636 continue;
1637 }
1638 let mut block = |code: &str, reason: String| {
1639 blocked.push(BlockedDeliverable {
1640 id: d.id.clone(),
1641 code: code.to_string(),
1642 reason,
1643 });
1644 };
1645 let status = state.statuses.get(&d.id);
1646 if is_manual(d) {
1647 block("MANUAL", "manual deliverable; never leased".to_string());
1648 } else if state.locks.contains_key(&d.id) {
1649 block("LOCKED", "held by an active lease".to_string());
1650 } else if !matches!(status, Some(DeliverableStatus::Ready)) {
1651 block(
1652 "NOT_READY",
1653 format!(
1654 "status is {}",
1655 status.map_or("unknown".to_string(), |s| format!("{s:?}"))
1656 ),
1657 );
1658 } else if let Some(lb) = lapse_blocked.iter().find(|b| b.id == d.id) {
1659 block("LAPSE_LIMIT", lb.reason.clone());
1660 } else {
1661 ready.push(d);
1662 }
1663 }
1664 } else {
1665 blocked = lapse_blocked.clone();
1666 ready.extend(state.graph.deliverables.iter().filter(|d| {
1667 in_scope(d)
1668 && !is_manual(d)
1669 && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1670 && !state.locks.contains_key(&d.id)
1671 && !lapse_blocked.iter().any(|b| b.id == d.id)
1672 }));
1673 }
1674 ready.sort_by_key(|d| priority_key(&d.id, &sched_by_id));
1675
1676 let mut selected: Vec<Deliverable> = Vec::new();
1679 let mut selected_files: HashMap<PathBuf, FileMode> = HashMap::new();
1680 for candidate in ready {
1681 if selected.len() == max_count {
1682 if ids.is_none() {
1683 break;
1684 }
1685 blocked.push(BlockedDeliverable {
1686 id: candidate.id.clone(),
1687 code: "MAX_COUNT".to_string(),
1688 reason: format!("cohort already holds max_count ({max_count})"),
1689 });
1690 continue;
1691 }
1692 let conflict = candidate.owned_files.iter().any(|f| {
1693 selected_files
1694 .get(f.path())
1695 .is_some_and(|m| modes_conflict(f.mode(), *m))
1696 || state
1697 .file_claims
1698 .get(f.path())
1699 .is_some_and(|c| c.conflicts_with(f.mode()))
1700 });
1701 if conflict {
1702 if ids.is_some() {
1703 blocked.push(BlockedDeliverable {
1704 id: candidate.id.clone(),
1705 code: "FILE_CONFLICT".to_string(),
1706 reason: "owned files overlap a held lock or an earlier pick"
1707 .to_string(),
1708 });
1709 }
1710 continue;
1711 }
1712 for f in &candidate.owned_files {
1713 let e = selected_files
1715 .entry(f.path().to_path_buf())
1716 .or_insert(f.mode());
1717 if f.mode() == FileMode::Exclusive {
1718 *e = FileMode::Exclusive;
1719 }
1720 }
1721 selected.push(candidate.clone());
1722 }
1723
1724 let mut rows: Vec<CohortRow> = Vec::with_capacity(selected.len());
1733 for d in selected {
1734 let lock = LockInfo {
1735 plan_id: plan_id.clone(),
1736 deliverable_id: d.id.clone(),
1737 caller_id: caller_id.clone(),
1738 acquired_at: now,
1739 expires_at,
1740 };
1741 state
1742 .statuses
1743 .insert(d.id.clone(), DeliverableStatus::InProgress);
1744 *state.attempt_counts.entry(d.id.clone()).or_insert(0) += 1;
1748 add_file_claims(&mut state.file_claims, &d.id, &d.owned_files);
1749 state.locks.insert(d.id.clone(), lock.clone());
1750 state.lockless.remove(&d.id);
1752 audit_buf.push(make_acquired_event(&lock, &d.owned_files));
1753 rows.push(CohortRow {
1754 deliverable: d,
1755 lock,
1756 });
1757 }
1758
1759 let mut shared_paths: Vec<PathBuf> = rows
1762 .iter()
1763 .flat_map(|r| r.deliverable.owned_files.iter())
1764 .filter(|f| f.mode() == FileMode::Append)
1765 .filter(|f| {
1766 matches!(state.file_claims.get(f.path()),
1767 Some(FileClaim::Append(set)) if set.len() >= 2)
1768 })
1769 .map(|f| f.path().to_path_buf())
1770 .collect();
1771 shared_paths.sort();
1772 shared_paths.dedup();
1773
1774 let baseline_taken = if rows.is_empty() {
1779 None
1780 } else {
1781 self.ensure_execution_baseline(tx, &plan_id, state, now, &mut audit_buf)?
1782 };
1783
1784 Ok(Cohort {
1785 plan_id: plan_id.clone(),
1786 rows,
1787 blocked,
1788 shared_paths,
1789 baseline_taken,
1790 })
1791 })?;
1792
1793 self.flush_audit(audit_buf).await;
1794 Ok(cohort)
1795 }
1796
1797 async fn mark_status(
1798 &self,
1799 req: MarkStatusRequest,
1800 ) -> Result<Option<BaselineTaken>, PlannerError> {
1801 let MarkStatusRequest {
1802 plan_id,
1803 deliverable_id,
1804 caller_id,
1805 status,
1806 earned_pct,
1807 actual_effort_hours,
1808 evidence,
1809 } = req;
1810 validate_actuals(
1811 &status,
1812 earned_pct.map(f64::from),
1813 actual_effort_hours.map(f64::from),
1814 evidence.as_deref(),
1815 )?;
1816 let report = crate::ev_store::ReportedActuals {
1817 earned_pct: earned_pct.filter(|_| status == DeliverableStatus::InProgress),
1819 actual_hours: actual_effort_hours,
1820 evidence,
1821 };
1822 let deliverable_id = deliverable_id.as_str();
1823 let caller_id = &caller_id;
1824 let now = self.now();
1825 let mut audit_buf: Vec<AuditEvent> = Vec::new();
1826 let store = &self.store;
1827 let baseline_taken = store.mutate_executable_plan_tx(&plan_id, |tx, state| {
1828 if !state
1830 .graph
1831 .deliverables
1832 .iter()
1833 .any(|d| d.id == deliverable_id)
1834 {
1835 return Err(PlannerError::DeliverableNotFound {
1836 plan_id: plan_id.0.clone(),
1837 deliverable_id: deliverable_id.to_string(),
1838 });
1839 }
1840
1841 if let Some(lock) = state.locks.get(deliverable_id)
1843 && lock.caller_id != *caller_id
1844 {
1845 return Err(PlannerError::LockNotHeld {
1846 caller_id: caller_id.0.clone(),
1847 deliverable_id: deliverable_id.to_string(),
1848 });
1849 }
1850
1851 let is_complete = matches!(status, DeliverableStatus::Complete);
1852 let previous_status = state
1853 .statuses
1854 .get(deliverable_id)
1855 .cloned()
1856 .unwrap_or(DeliverableStatus::Pending);
1857
1858 if !state.locks.contains_key(deliverable_id)
1862 && matches!(previous_status, DeliverableStatus::Complete)
1863 && !is_complete
1864 {
1865 return Err(PlannerError::LockNotHeld {
1866 caller_id: caller_id.0.clone(),
1867 deliverable_id: deliverable_id.to_string(),
1868 });
1869 }
1870
1871 let baseline_taken = if matches!(
1877 status,
1878 DeliverableStatus::InProgress | DeliverableStatus::Complete
1879 ) {
1880 self.ensure_execution_baseline(tx, &plan_id, state, now, &mut audit_buf)?
1881 } else {
1882 None
1883 };
1884 state.report_actuals(deliverable_id, report, now);
1885
1886 if is_complete && !state.locks.contains_key(deliverable_id) {
1889 if matches!(
1891 state.statuses.get(deliverable_id),
1892 Some(DeliverableStatus::Complete)
1893 ) {
1894 return Ok(baseline_taken);
1895 }
1896 let missing = incomplete_prerequisites(state, deliverable_id);
1897 if !missing.is_empty() {
1898 return Err(PlannerError::PrerequisitesIncomplete {
1899 plan_id: plan_id.0.clone(),
1900 deliverable_id: deliverable_id.to_string(),
1901 missing,
1902 });
1903 }
1904 audit_buf.push(make_completed_without_lease_event(
1905 &plan_id,
1906 deliverable_id,
1907 caller_id,
1908 &previous_status,
1909 ));
1910 } else if !is_complete && !state.locks.contains_key(deliverable_id) {
1911 if matches!(
1914 status,
1915 DeliverableStatus::Ready | DeliverableStatus::InProgress
1916 ) {
1917 let missing = incomplete_prerequisites(state, deliverable_id);
1918 if !missing.is_empty() {
1919 return Err(PlannerError::PrerequisitesIncomplete {
1920 plan_id: plan_id.0.clone(),
1921 deliverable_id: deliverable_id.to_string(),
1922 missing,
1923 });
1924 }
1925 }
1926 audit_buf.push(make_marked_without_lease_event(
1927 &plan_id,
1928 deliverable_id,
1929 caller_id,
1930 &status,
1931 &previous_status,
1932 ));
1933 }
1934
1935 if matches!(status, DeliverableStatus::Failed { .. })
1937 && let Some(lock) = state.end_lease(deliverable_id, now)
1938 {
1939 audit_buf.push(make_released_event(&lock, "failed"));
1940 }
1941
1942 if matches!(status, DeliverableStatus::Failed { .. })
1949 && !matches!(
1950 state.statuses.get(deliverable_id),
1951 Some(DeliverableStatus::Failed { .. })
1952 )
1953 {
1954 *state
1955 .failure_counts
1956 .entry(deliverable_id.to_string())
1957 .or_insert(0) += 1;
1958 }
1959
1960 if status == DeliverableStatus::InProgress && !state.locks.contains_key(deliverable_id)
1965 {
1966 state.lockless.insert(deliverable_id.to_string());
1967 } else {
1968 state.lockless.remove(deliverable_id);
1969 }
1970
1971 if is_complete {
1972 if complete_deliverable(state, deliverable_id, &mut audit_buf, "completed", now) {
1973 audit_buf.push(make_plan_completed_event(
1974 &plan_id,
1975 state.graph.deliverables.len(),
1976 ));
1977 }
1978 } else {
1979 state
1980 .statuses
1981 .insert(deliverable_id.to_string(), status.clone());
1982 }
1983
1984 Ok(baseline_taken)
1985 })?;
1986
1987 self.flush_audit(audit_buf).await;
1995 Ok(baseline_taken)
1996 }
1997
1998 async fn heartbeat(&self, req: HeartbeatRequest) -> Result<(), PlannerError> {
1999 let HeartbeatRequest {
2000 plan_id,
2001 deliverable_id,
2002 caller_id,
2003 ttl,
2004 } = req;
2005 let deliverable_id = deliverable_id.as_str();
2006 let caller_id = &caller_id;
2007 let now = self.now();
2008 let explicit_ttl = ttl.is_some();
2009 let expires_at = now
2010 + chrono::Duration::from_std(self.effective_ttl(ttl))
2011 .expect("INVARIANT: planner TTL fits in chrono::Duration");
2012
2013 self.store.mutate_executable_plan(&plan_id, |state| {
2014 let lock =
2015 state
2016 .locks
2017 .get_mut(deliverable_id)
2018 .ok_or_else(|| PlannerError::LockNotHeld {
2019 caller_id: caller_id.0.clone(),
2020 deliverable_id: deliverable_id.to_string(),
2021 })?;
2022
2023 if lock.caller_id != *caller_id {
2024 return Err(PlannerError::LockNotHeld {
2025 caller_id: caller_id.0.clone(),
2026 deliverable_id: deliverable_id.to_string(),
2027 });
2028 }
2029
2030 if lock.expires_at < now {
2033 return Err(PlannerError::LockExpired {
2034 deliverable_id: deliverable_id.to_string(),
2035 expired_at: lock.expires_at,
2036 });
2037 }
2038
2039 lock.expires_at = if explicit_ttl {
2043 expires_at
2044 } else {
2045 lock.expires_at.max(expires_at)
2046 };
2047 Ok(())
2048 })
2049 }
2050
2051 async fn get_plan(&self, plan_id: &PlanId) -> Result<PlanDefinition, PlannerError> {
2052 self.store.read_plan(plan_id, |state| PlanDefinition {
2053 plan_id: plan_id.clone(),
2054 graph: state.graph.clone(),
2055 })
2056 }
2057
2058 async fn status(&self, plan_id: &PlanId) -> Result<PlanStatus, PlannerError> {
2059 let (((mut status, head_hash), info), baseline) =
2060 self.store.read_plan_and_variant(plan_id, |state| {
2061 let deliverables: Vec<(String, DeliverableStatus, u32, u32, u32)> = state
2063 .graph
2064 .deliverables
2065 .iter()
2066 .map(|d| {
2067 let status = state
2068 .statuses
2069 .get(&d.id)
2070 .cloned()
2071 .unwrap_or(DeliverableStatus::Pending);
2072 (
2073 d.id.clone(),
2074 status,
2075 state.attempt_count(&d.id),
2076 state.failure_count(&d.id),
2077 state.lapse_count(&d.id),
2078 )
2079 })
2080 .collect();
2081
2082 let schedule = crate::schedule::schedule_rows(&state.graph, &state.cached_result);
2083 let mut ready_rows: Vec<&ScheduleRow> = schedule
2084 .iter()
2085 .filter(|r| {
2086 matches!(state.statuses.get(&r.id), Some(DeliverableStatus::Ready))
2087 && !state.locks.contains_key(&r.id)
2088 })
2089 .collect();
2090 let sched_by_id: HashMap<&str, (f32, f32)> = state
2095 .cached_result
2096 .tasks
2097 .iter()
2098 .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
2099 .collect();
2100 ready_rows.sort_by_key(|r| ready_display_key(&r.id, &sched_by_id));
2101 let ready: Vec<String> = ready_rows.iter().map(|r| r.id.clone()).collect();
2102
2103 let milestones =
2104 crate::schedule::milestone_rows(&state.graph, &state.cached_result, |id| {
2105 matches!(state.statuses.get(id), Some(DeliverableStatus::Complete))
2106 });
2107
2108 let status = PlanStatus {
2109 plan_id: plan_id.clone(),
2110 milestones,
2111 critical_ids: state
2112 .cached_result
2113 .critical_ids
2114 .iter()
2115 .filter(|id| id.as_str() != START_ID && id.as_str() != FINISH_ID)
2116 .cloned()
2117 .collect(),
2118 plan_complete: all_complete(state),
2119 schedule,
2120 ready,
2121 deliverables,
2122 critical_path: state.cached_result.critical_path.clone(),
2123 critical_path_hours: state.cached_result.critical_path_duration,
2124 locks_held: state.locks.values().cloned().collect(),
2125 name: None,
2126 variant: None,
2127 selected: None,
2128 definition_drift: None,
2129 baseline: None,
2130 };
2131 (status, hash_graph(&state.graph))
2132 })?;
2133 status.baseline = baseline;
2134 if let Some(info) = info {
2135 status.definition_drift =
2137 definition_drift(self.project_root.as_ref(), &info, &head_hash);
2138 status.name = Some(info.name);
2139 status.variant = Some(info.variant);
2140 status.selected = Some(info.selected);
2141 }
2142 Ok(status)
2143 }
2144
2145 async fn accept(&self, req: AcceptRequest) -> Result<(), PlannerError> {
2146 let mut audit_buf: Vec<AuditEvent> = Vec::new();
2147 let plan_id = req.plan_id.clone();
2148 let deliverable_id = req.deliverable_id.as_str();
2149 let now = self.now();
2150 self.store.mutate_executable_plan(&plan_id, |state| {
2151 if !state
2152 .graph
2153 .deliverables
2154 .iter()
2155 .any(|d| d.id == deliverable_id)
2156 {
2157 return Err(PlannerError::DeliverableNotFound {
2158 plan_id: plan_id.0.clone(),
2159 deliverable_id: deliverable_id.to_string(),
2160 });
2161 }
2162
2163 for lock in &state.reap_expired(now) {
2165 audit_buf.push(make_expired_event(lock, now));
2166 }
2167
2168 let previous_status = state
2170 .statuses
2171 .get(deliverable_id)
2172 .cloned()
2173 .unwrap_or(DeliverableStatus::Pending);
2174 if matches!(previous_status, DeliverableStatus::Complete) {
2175 return Ok(());
2176 }
2177
2178 let mut overrode: Option<String> = None;
2181 if let Some(lock) = state.locks.get(deliverable_id) {
2182 if !req.override_lock {
2183 return Err(PlannerError::LockHeld {
2184 plan_id: plan_id.0.clone(),
2185 deliverable_id: deliverable_id.to_string(),
2186 holder: lock.caller_id.0.clone(),
2187 });
2188 }
2189 overrode = Some(lock.caller_id.0.clone());
2190 }
2191
2192 let missing = incomplete_prerequisites(state, deliverable_id);
2193 if !missing.is_empty() {
2194 return Err(PlannerError::PrerequisitesIncomplete {
2195 plan_id: plan_id.0.clone(),
2196 deliverable_id: deliverable_id.to_string(),
2197 missing,
2198 });
2199 }
2200
2201 let plan_done =
2202 complete_deliverable(state, deliverable_id, &mut audit_buf, "accepted", now);
2203 audit_buf.push(make_accepted_event(
2204 &req,
2205 overrode.as_deref(),
2206 &previous_status,
2207 ));
2208 if plan_done {
2209 audit_buf.push(make_plan_completed_event(
2210 &plan_id,
2211 state.graph.deliverables.len(),
2212 ));
2213 }
2214 Ok(())
2215 })?;
2216
2217 self.flush_audit(audit_buf).await;
2218 Ok(())
2219 }
2220
2221 async fn force_release(&self, req: ForceReleaseRequest) -> Result<(), PlannerError> {
2222 let ForceReleaseRequest {
2223 plan_id,
2224 deliverable_id,
2225 reason,
2226 reset_counters,
2227 } = req;
2228 let deliverable_id = deliverable_id.as_str();
2229 let reason = reason.as_str();
2230 let now = self.now();
2231 let mut audit_buf: Vec<AuditEvent> = Vec::new();
2232 self.store.mutate_executable_plan(&plan_id, |state| {
2233 if !state
2234 .graph
2235 .deliverables
2236 .iter()
2237 .any(|d| d.id == deliverable_id)
2238 {
2239 return Err(PlannerError::DeliverableNotFound {
2240 plan_id: plan_id.0.clone(),
2241 deliverable_id: deliverable_id.to_string(),
2242 });
2243 }
2244
2245 if let Some(lock) = state.end_lease(deliverable_id, now) {
2246 state
2247 .statuses
2248 .insert(deliverable_id.to_string(), DeliverableStatus::Ready);
2249 audit_buf.push(make_force_released_event(&lock, reason));
2250 }
2251
2252 if reset_counters {
2253 let lapse_count = state.lapse_counts.remove(deliverable_id).unwrap_or(0);
2254 let failure_count = state.failure_counts.remove(deliverable_id).unwrap_or(0);
2255 if matches!(
2257 state.statuses.get(deliverable_id),
2258 Some(DeliverableStatus::Failed { .. })
2259 ) {
2260 let prereqs_complete = state
2261 .graph
2262 .deliverables
2263 .iter()
2264 .find(|d| d.id == deliverable_id)
2265 .is_some_and(|d| {
2266 crate::graph::prerequisite_ids(d).all(|p| {
2267 matches!(state.statuses.get(p), Some(DeliverableStatus::Complete))
2268 })
2269 });
2270 let revived = if prereqs_complete {
2271 DeliverableStatus::Ready
2272 } else {
2273 DeliverableStatus::Pending
2274 };
2275 state.statuses.insert(deliverable_id.to_string(), revived);
2276 }
2277 audit_buf.push(make_counters_reset_event(
2278 &plan_id,
2279 deliverable_id,
2280 reason,
2281 lapse_count,
2282 failure_count,
2283 ));
2284 }
2285
2286 Ok(())
2287 })?;
2288
2289 self.flush_audit(audit_buf).await;
2290 Ok(())
2291 }
2292}
2293
2294#[cfg(test)]
2295#[allow(clippy::float_cmp)]
2296mod tests {
2297 use super::*;
2298 use crate::estimator::EffortEstimator;
2299 use crate::schedule::deliverable_to_task;
2300 use crate::task::TaskKind;
2301
2302 fn deliverable(id: &str, effort: Option<f32>, metadata: serde_json::Value) -> Deliverable {
2303 Deliverable {
2304 id: id.to_string(),
2305 owned_files: Vec::new(),
2306 prerequisites: Vec::new(),
2307 estimated_effort_hours: effort,
2308 metadata,
2309 duration_hours: None,
2310 estimate: None,
2311 milestone: false,
2312 earning_rule: None,
2313 }
2314 }
2315
2316 #[test]
2317 fn explicit_default_earning_rule_hashes_like_absent() {
2318 let absent = deliverable("D1", Some(1.0), json!({}));
2319 let mut explicit = absent.clone();
2320 explicit.earning_rule = Some(crate::plan::EarningRule::ZeroHundred);
2321 let graph = |d: Deliverable| PlanGraph {
2322 deliverables: vec![d],
2323 max_chained_dispatch: None,
2324 };
2325 assert_eq!(hash_graph(&graph(explicit)), hash_graph(&graph(absent)));
2326 }
2327
2328 #[test]
2329 fn canonical_form_omits_an_absent_earning_rule() {
2330 let d = deliverable("D1", Some(1.0), json!({}));
2331 assert!(canonical_deliverable(&d).get("earning_rule").is_none());
2332 }
2333
2334 #[test]
2335 fn explicit_effort_wins_over_estimator() {
2336 let estimator = EffortEstimator::new();
2337 let d = deliverable("D1", Some(2.5), json!({}));
2338 let task = deliverable_to_task(&d, &estimator);
2339 assert_eq!(task.effort_hours, 2.5);
2340 }
2341
2342 #[test]
2343 fn missing_effort_uses_estimator_not_flat_default() {
2344 let estimator = EffortEstimator::new();
2347 let d = deliverable("D1", None, json!({}));
2348 let task = deliverable_to_task(&d, &estimator);
2349 let expected = estimator.estimate(
2350 &TaskKind::Custom {
2351 description: String::new(),
2352 },
2353 false,
2354 );
2355 assert_eq!(task.effort_hours, expected);
2356 assert_ne!(task.effort_hours, DEFAULT_EFFORT_HOURS);
2357 }
2358
2359 #[test]
2360 fn complexity_metadata_hint_raises_estimate() {
2361 let estimator = EffortEstimator::new();
2362 let simple = deliverable_to_task(&deliverable("S", None, json!({})), &estimator);
2363 let complex = deliverable_to_task(
2364 &deliverable("C", None, json!({ "complexity": true })),
2365 &estimator,
2366 );
2367 assert!(complex.effort_hours > simple.effort_hours);
2368 }
2369
2370 fn sched<'a>(entries: &[(&'a str, f32, f32)]) -> HashMap<&'a str, (f32, f32)> {
2371 entries
2372 .iter()
2373 .map(|&(id, latest_start, float)| (id, (latest_start, float)))
2374 .collect()
2375 }
2376
2377 #[test]
2378 fn priority_key_orders_lower_latest_start_first() {
2379 let s = sched(&[("A", 5.0, 0.0), ("B", 0.0, 9.0)]);
2380 assert!(priority_key("B", &s) < priority_key("A", &s));
2381 }
2382
2383 #[test]
2384 fn priority_key_breaks_latest_start_ties_by_float() {
2385 let s = sched(&[("X", 1.0, 3.0), ("Y", 1.0, 1.0)]);
2386 assert!(priority_key("Y", &s) < priority_key("X", &s));
2387 }
2388
2389 #[test]
2390 fn priority_key_breaks_float_ties_by_id() {
2391 let s = sched(&[("X", 1.0, 1.0), ("Y", 1.0, 1.0)]);
2392 assert!(priority_key("X", &s) < priority_key("Y", &s));
2393 }
2394
2395 #[test]
2396 #[should_panic(expected = "absent from the cached CPM schedule")]
2397 fn priority_key_missing_entry_is_invariant_breach() {
2398 let s: HashMap<&str, (f32, f32)> = HashMap::new();
2399 let _ = priority_key("ghost", &s);
2400 }
2401}