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, PlanRef, PlanStatus, PlannerError,
43 ReviseRequest, 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 resolve_plan(&self, plan: PlanRef) -> Result<PlanId, PlannerError> {
1409 match plan {
1410 PlanRef::Id(plan_id) => Ok(plan_id),
1411 PlanRef::Line { project, name } => self
1412 .store
1413 .read_tx(|tx| crate::portfolio::resolve_line(tx, &project, &name)),
1414 PlanRef::Active { project } => self.store.read_tx(|tx| {
1415 let lines = crate::portfolio::active_lines(tx, &project)?;
1416 match lines.as_slice() {
1417 [] => Err(PlannerError::NoActivePlan { project }),
1418 [(_, plan_id)] => Ok(plan_id.clone()),
1419 _ => Err(PlannerError::AmbiguousActivePlan {
1420 project,
1421 lines: lines.into_iter().map(|(name, _)| name).collect(),
1422 }),
1423 }
1424 }),
1425 }
1426 }
1427
1428 async fn list_active_plans(&self, project: &str) -> Result<Vec<PlanLineSummary>, PlannerError> {
1429 self.store.read_tx(|tx| {
1430 let names: HashSet<String> = crate::portfolio::active_lines(tx, project)?
1431 .into_iter()
1432 .map(|(name, _)| name)
1433 .collect();
1434 Ok(crate::portfolio::list(tx, project, false)?
1435 .into_iter()
1436 .filter(|line| names.contains(&line.name))
1437 .collect())
1438 })
1439 }
1440
1441 async fn revision_graph(
1442 &self,
1443 plan_id: &PlanId,
1444 revision: Option<u32>,
1445 ) -> Result<(u32, PlanGraph), PlannerError> {
1446 self.store
1447 .read_tx(|tx| crate::portfolio::revision_graph(tx, plan_id, revision))
1448 }
1449
1450 async fn revise_plan(&self, req: ReviseRequest) -> Result<(u32, RevisionDiff), PlannerError> {
1451 let ReviseRequest {
1452 plan_id,
1453 graph,
1454 force,
1455 } = req;
1456 let now = self.now();
1457 let revised = self.store.write_tx(|tx| {
1458 crate::portfolio::ensure_revisable(tx, &plan_id)?;
1459 crate::portfolio::revise(tx, &plan_id, graph, None, None, force, now)
1460 })?;
1461 self.flush_audit(revision_events(&plan_id, &revised, now))
1462 .await;
1463 Ok((revised.revision, revised.diff))
1464 }
1465
1466 async fn select_variant(
1467 &self,
1468 plan_id: &PlanId,
1469 force: bool,
1470 ) -> Result<SelectOutcome, PlannerError> {
1471 let now = self.now();
1472 let mut baseline_audit: Vec<AuditEvent> = Vec::new();
1473 let selected = self.store.write_tx(|tx| {
1478 let mut selected = crate::portfolio::select(tx, plan_id, force, now)?;
1479 if selected.outcome.changed {
1480 let state = crate::plan_store::load_plan_state(tx, plan_id)?.ok_or_else(|| {
1481 PlannerError::PlanNotFound {
1482 plan_id: plan_id.0.clone(),
1483 }
1484 })?;
1485 if ev::state_has_progress(&state) {
1486 selected.outcome.baseline_taken = self.ensure_execution_baseline(
1487 tx,
1488 plan_id,
1489 &state,
1490 now,
1491 &mut baseline_audit,
1492 )?;
1493 }
1494 }
1495 Ok(selected)
1496 })?;
1497 let mut events = selection_events(&selected, now);
1498 events.extend(baseline_audit);
1499 self.flush_audit(events).await;
1500 Ok(selected.outcome)
1501 }
1502
1503 async fn archive(
1504 &self,
1505 project: &str,
1506 name: &str,
1507 variant: Option<&str>,
1508 archived: bool,
1509 force: bool,
1510 ) -> Result<(), PlannerError> {
1511 let now = self.now();
1512 let outcome = self.store.write_tx(|tx| {
1513 crate::portfolio::archive(tx, project, name, variant, archived, force, now)
1514 })?;
1515 self.flush_audit(archive_events(
1516 project, name, variant, archived, &outcome, now,
1517 ))
1518 .await;
1519 Ok(())
1520 }
1521
1522 async fn acquire_cohort(&self, req: AcquireRequest) -> Result<Cohort, PlannerError> {
1523 let AcquireRequest {
1524 plan_id,
1525 caller_id,
1526 max_count,
1527 ids,
1528 metadata_filter,
1529 ttl,
1530 } = req;
1531 let now = self.now();
1532 let expires_at = now
1533 + chrono::Duration::from_std(self.effective_ttl(ttl))
1534 .expect("INVARIANT: planner TTL fits in chrono::Duration");
1535
1536 let mut audit_buf: Vec<AuditEvent> = Vec::new();
1540 let store = &self.store;
1541 let cohort = store.mutate_executable_plan_tx(&plan_id, |tx, state| {
1542 let reaped = state.reap_expired(now);
1544 for lock in &reaped {
1545 audit_buf.push(make_expired_event(lock, now));
1546 }
1547
1548 if let Some(req_ids) = &ids {
1553 for rid in req_ids {
1554 if !state.graph.deliverables.iter().any(|d| &d.id == rid) {
1555 return Err(PlannerError::DeliverableNotFound {
1556 plan_id: plan_id.0.clone(),
1557 deliverable_id: rid.clone(),
1558 });
1559 }
1560 }
1561 }
1562 let is_requested = |id: &str| ids.as_ref().is_some_and(|v| v.iter().any(|r| r == id));
1563 let is_manual =
1564 |d: &Deliverable| d.metadata.get("kind").and_then(|k| k.as_str()) == Some("manual");
1565 let matches_filter = |d: &Deliverable| {
1566 metadata_filter
1567 .as_ref()
1568 .is_none_or(|f| f.iter().all(|(k, v)| d.metadata.get(k) == Some(v)))
1569 };
1570 let in_scope =
1571 |d: &Deliverable| (ids.is_none() || is_requested(&d.id)) && matches_filter(d);
1572
1573 let poisoned: Vec<(String, u32)> = state
1593 .graph
1594 .deliverables
1595 .iter()
1596 .filter(|d| {
1597 matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1598 && lease_retry_decision(state.failure_count(&d.id)) == RetryDecision::Stop
1599 })
1600 .map(|d| (d.id.clone(), state.failure_count(&d.id)))
1601 .collect();
1602 for (id, failures) in poisoned {
1603 let reason = format!("circuit-break: exceeded {MAX_ATTEMPTS} failed attempts");
1606 audit_buf.push(make_circuit_break_event(
1607 &plan_id,
1608 &id,
1609 failures,
1610 FailureClass::Permanent,
1611 &reason,
1612 ));
1613 state
1614 .statuses
1615 .insert(id, DeliverableStatus::Failed { reason });
1616 }
1617
1618 let lapse_blocked: Vec<BlockedDeliverable> = state
1621 .graph
1622 .deliverables
1623 .iter()
1624 .filter(|d| {
1625 in_scope(d)
1626 && !is_manual(d)
1627 && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1628 && !state.locks.contains_key(&d.id)
1629 && state.lapse_count(&d.id) >= MAX_LAPSES
1630 })
1631 .map(|d| BlockedDeliverable {
1632 id: d.id.clone(),
1633 code: "LAPSE_LIMIT".to_string(),
1634 reason: format!(
1635 "lease lapsed {} times (limit {MAX_LAPSES}); clear with \
1636 plan.force_release {{reset_counters: true}}",
1637 state.lapse_count(&d.id)
1638 ),
1639 })
1640 .collect();
1641
1642 let sched_by_id: HashMap<&str, (f32, f32)> = state
1644 .cached_result
1645 .tasks
1646 .iter()
1647 .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
1648 .collect();
1649
1650 let mut blocked: Vec<BlockedDeliverable> = Vec::new();
1655 let mut ready: Vec<&Deliverable> = Vec::new();
1656 if let Some(req_ids) = &ids {
1657 let mut seen: HashSet<&str> = HashSet::new();
1658 for rid in req_ids {
1659 if !seen.insert(rid.as_str()) {
1660 continue;
1661 }
1662 let d = state
1663 .graph
1664 .deliverables
1665 .iter()
1666 .find(|d| &d.id == rid)
1667 .expect("INVARIANT: requested ids validated above");
1668 if !matches_filter(d) {
1669 continue;
1670 }
1671 let mut block = |code: &str, reason: String| {
1672 blocked.push(BlockedDeliverable {
1673 id: d.id.clone(),
1674 code: code.to_string(),
1675 reason,
1676 });
1677 };
1678 let status = state.statuses.get(&d.id);
1679 if is_manual(d) {
1680 block("MANUAL", "manual deliverable; never leased".to_string());
1681 } else if state.locks.contains_key(&d.id) {
1682 block("LOCKED", "held by an active lease".to_string());
1683 } else if !matches!(status, Some(DeliverableStatus::Ready)) {
1684 block(
1685 "NOT_READY",
1686 format!(
1687 "status is {}",
1688 status.map_or("unknown".to_string(), |s| format!("{s:?}"))
1689 ),
1690 );
1691 } else if let Some(lb) = lapse_blocked.iter().find(|b| b.id == d.id) {
1692 block("LAPSE_LIMIT", lb.reason.clone());
1693 } else {
1694 ready.push(d);
1695 }
1696 }
1697 } else {
1698 blocked = lapse_blocked.clone();
1699 ready.extend(state.graph.deliverables.iter().filter(|d| {
1700 in_scope(d)
1701 && !is_manual(d)
1702 && matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
1703 && !state.locks.contains_key(&d.id)
1704 && !lapse_blocked.iter().any(|b| b.id == d.id)
1705 }));
1706 }
1707 ready.sort_by_key(|d| priority_key(&d.id, &sched_by_id));
1708
1709 let mut selected: Vec<Deliverable> = Vec::new();
1712 let mut selected_files: HashMap<PathBuf, FileMode> = HashMap::new();
1713 for candidate in ready {
1714 if selected.len() == max_count {
1715 if ids.is_none() {
1716 break;
1717 }
1718 blocked.push(BlockedDeliverable {
1719 id: candidate.id.clone(),
1720 code: "MAX_COUNT".to_string(),
1721 reason: format!("cohort already holds max_count ({max_count})"),
1722 });
1723 continue;
1724 }
1725 let conflict = candidate.owned_files.iter().any(|f| {
1726 selected_files
1727 .get(f.path())
1728 .is_some_and(|m| modes_conflict(f.mode(), *m))
1729 || state
1730 .file_claims
1731 .get(f.path())
1732 .is_some_and(|c| c.conflicts_with(f.mode()))
1733 });
1734 if conflict {
1735 if ids.is_some() {
1736 blocked.push(BlockedDeliverable {
1737 id: candidate.id.clone(),
1738 code: "FILE_CONFLICT".to_string(),
1739 reason: "owned files overlap a held lock or an earlier pick"
1740 .to_string(),
1741 });
1742 }
1743 continue;
1744 }
1745 for f in &candidate.owned_files {
1746 let e = selected_files
1748 .entry(f.path().to_path_buf())
1749 .or_insert(f.mode());
1750 if f.mode() == FileMode::Exclusive {
1751 *e = FileMode::Exclusive;
1752 }
1753 }
1754 selected.push(candidate.clone());
1755 }
1756
1757 let mut rows: Vec<CohortRow> = Vec::with_capacity(selected.len());
1766 for d in selected {
1767 let lock = LockInfo {
1768 plan_id: plan_id.clone(),
1769 deliverable_id: d.id.clone(),
1770 caller_id: caller_id.clone(),
1771 acquired_at: now,
1772 expires_at,
1773 };
1774 state
1775 .statuses
1776 .insert(d.id.clone(), DeliverableStatus::InProgress);
1777 *state.attempt_counts.entry(d.id.clone()).or_insert(0) += 1;
1781 add_file_claims(&mut state.file_claims, &d.id, &d.owned_files);
1782 state.locks.insert(d.id.clone(), lock.clone());
1783 state.lockless.remove(&d.id);
1785 audit_buf.push(make_acquired_event(&lock, &d.owned_files));
1786 rows.push(CohortRow {
1787 deliverable: d,
1788 lock,
1789 });
1790 }
1791
1792 let mut shared_paths: Vec<PathBuf> = rows
1795 .iter()
1796 .flat_map(|r| r.deliverable.owned_files.iter())
1797 .filter(|f| f.mode() == FileMode::Append)
1798 .filter(|f| {
1799 matches!(state.file_claims.get(f.path()),
1800 Some(FileClaim::Append(set)) if set.len() >= 2)
1801 })
1802 .map(|f| f.path().to_path_buf())
1803 .collect();
1804 shared_paths.sort();
1805 shared_paths.dedup();
1806
1807 let baseline_taken = if rows.is_empty() {
1812 None
1813 } else {
1814 self.ensure_execution_baseline(tx, &plan_id, state, now, &mut audit_buf)?
1815 };
1816
1817 Ok(Cohort {
1818 plan_id: plan_id.clone(),
1819 rows,
1820 blocked,
1821 shared_paths,
1822 baseline_taken,
1823 })
1824 })?;
1825
1826 self.flush_audit(audit_buf).await;
1827 Ok(cohort)
1828 }
1829
1830 async fn mark_status(
1831 &self,
1832 req: MarkStatusRequest,
1833 ) -> Result<Option<BaselineTaken>, PlannerError> {
1834 let MarkStatusRequest {
1835 plan_id,
1836 deliverable_id,
1837 caller_id,
1838 status,
1839 earned_pct,
1840 actual_effort_hours,
1841 evidence,
1842 } = req;
1843 validate_actuals(
1844 &status,
1845 earned_pct.map(f64::from),
1846 actual_effort_hours.map(f64::from),
1847 evidence.as_deref(),
1848 )?;
1849 let report = crate::ev_store::ReportedActuals {
1850 earned_pct: earned_pct.filter(|_| status == DeliverableStatus::InProgress),
1852 actual_hours: actual_effort_hours,
1853 evidence,
1854 };
1855 let deliverable_id = deliverable_id.as_str();
1856 let caller_id = &caller_id;
1857 let now = self.now();
1858 let mut audit_buf: Vec<AuditEvent> = Vec::new();
1859 let store = &self.store;
1860 let baseline_taken = store.mutate_executable_plan_tx(&plan_id, |tx, state| {
1861 if !state
1863 .graph
1864 .deliverables
1865 .iter()
1866 .any(|d| d.id == deliverable_id)
1867 {
1868 return Err(PlannerError::DeliverableNotFound {
1869 plan_id: plan_id.0.clone(),
1870 deliverable_id: deliverable_id.to_string(),
1871 });
1872 }
1873
1874 if let Some(lock) = state.locks.get(deliverable_id)
1876 && lock.caller_id != *caller_id
1877 {
1878 return Err(PlannerError::LockNotHeld {
1879 caller_id: caller_id.0.clone(),
1880 deliverable_id: deliverable_id.to_string(),
1881 });
1882 }
1883
1884 let is_complete = matches!(status, DeliverableStatus::Complete);
1885 let previous_status = state
1886 .statuses
1887 .get(deliverable_id)
1888 .cloned()
1889 .unwrap_or(DeliverableStatus::Pending);
1890
1891 if !state.locks.contains_key(deliverable_id)
1895 && matches!(previous_status, DeliverableStatus::Complete)
1896 && !is_complete
1897 {
1898 return Err(PlannerError::LockNotHeld {
1899 caller_id: caller_id.0.clone(),
1900 deliverable_id: deliverable_id.to_string(),
1901 });
1902 }
1903
1904 let baseline_taken = if matches!(
1910 status,
1911 DeliverableStatus::InProgress | DeliverableStatus::Complete
1912 ) {
1913 self.ensure_execution_baseline(tx, &plan_id, state, now, &mut audit_buf)?
1914 } else {
1915 None
1916 };
1917 state.report_actuals(deliverable_id, report, now);
1918
1919 if is_complete && !state.locks.contains_key(deliverable_id) {
1922 if matches!(
1924 state.statuses.get(deliverable_id),
1925 Some(DeliverableStatus::Complete)
1926 ) {
1927 return Ok(baseline_taken);
1928 }
1929 let missing = incomplete_prerequisites(state, deliverable_id);
1930 if !missing.is_empty() {
1931 return Err(PlannerError::PrerequisitesIncomplete {
1932 plan_id: plan_id.0.clone(),
1933 deliverable_id: deliverable_id.to_string(),
1934 missing,
1935 });
1936 }
1937 audit_buf.push(make_completed_without_lease_event(
1938 &plan_id,
1939 deliverable_id,
1940 caller_id,
1941 &previous_status,
1942 ));
1943 } else if !is_complete && !state.locks.contains_key(deliverable_id) {
1944 if matches!(
1947 status,
1948 DeliverableStatus::Ready | DeliverableStatus::InProgress
1949 ) {
1950 let missing = incomplete_prerequisites(state, deliverable_id);
1951 if !missing.is_empty() {
1952 return Err(PlannerError::PrerequisitesIncomplete {
1953 plan_id: plan_id.0.clone(),
1954 deliverable_id: deliverable_id.to_string(),
1955 missing,
1956 });
1957 }
1958 }
1959 audit_buf.push(make_marked_without_lease_event(
1960 &plan_id,
1961 deliverable_id,
1962 caller_id,
1963 &status,
1964 &previous_status,
1965 ));
1966 }
1967
1968 if matches!(status, DeliverableStatus::Failed { .. })
1970 && let Some(lock) = state.end_lease(deliverable_id, now)
1971 {
1972 audit_buf.push(make_released_event(&lock, "failed"));
1973 }
1974
1975 if matches!(status, DeliverableStatus::Failed { .. })
1982 && !matches!(
1983 state.statuses.get(deliverable_id),
1984 Some(DeliverableStatus::Failed { .. })
1985 )
1986 {
1987 *state
1988 .failure_counts
1989 .entry(deliverable_id.to_string())
1990 .or_insert(0) += 1;
1991 }
1992
1993 if status == DeliverableStatus::InProgress && !state.locks.contains_key(deliverable_id)
1998 {
1999 state.lockless.insert(deliverable_id.to_string());
2000 } else {
2001 state.lockless.remove(deliverable_id);
2002 }
2003
2004 if is_complete {
2005 if complete_deliverable(state, deliverable_id, &mut audit_buf, "completed", now) {
2006 audit_buf.push(make_plan_completed_event(
2007 &plan_id,
2008 state.graph.deliverables.len(),
2009 ));
2010 }
2011 } else {
2012 state
2013 .statuses
2014 .insert(deliverable_id.to_string(), status.clone());
2015 }
2016
2017 Ok(baseline_taken)
2018 })?;
2019
2020 self.flush_audit(audit_buf).await;
2028 Ok(baseline_taken)
2029 }
2030
2031 async fn heartbeat(&self, req: HeartbeatRequest) -> Result<(), PlannerError> {
2032 let HeartbeatRequest {
2033 plan_id,
2034 deliverable_id,
2035 caller_id,
2036 ttl,
2037 } = req;
2038 let deliverable_id = deliverable_id.as_str();
2039 let caller_id = &caller_id;
2040 let now = self.now();
2041 let explicit_ttl = ttl.is_some();
2042 let expires_at = now
2043 + chrono::Duration::from_std(self.effective_ttl(ttl))
2044 .expect("INVARIANT: planner TTL fits in chrono::Duration");
2045
2046 self.store.mutate_executable_plan(&plan_id, |state| {
2047 let lock =
2048 state
2049 .locks
2050 .get_mut(deliverable_id)
2051 .ok_or_else(|| PlannerError::LockNotHeld {
2052 caller_id: caller_id.0.clone(),
2053 deliverable_id: deliverable_id.to_string(),
2054 })?;
2055
2056 if lock.caller_id != *caller_id {
2057 return Err(PlannerError::LockNotHeld {
2058 caller_id: caller_id.0.clone(),
2059 deliverable_id: deliverable_id.to_string(),
2060 });
2061 }
2062
2063 if lock.expires_at < now {
2066 return Err(PlannerError::LockExpired {
2067 deliverable_id: deliverable_id.to_string(),
2068 expired_at: lock.expires_at,
2069 });
2070 }
2071
2072 lock.expires_at = if explicit_ttl {
2076 expires_at
2077 } else {
2078 lock.expires_at.max(expires_at)
2079 };
2080 Ok(())
2081 })
2082 }
2083
2084 async fn get_plan(&self, plan_id: &PlanId) -> Result<PlanDefinition, PlannerError> {
2085 self.store.read_plan(plan_id, |state| PlanDefinition {
2086 plan_id: plan_id.clone(),
2087 graph: state.graph.clone(),
2088 })
2089 }
2090
2091 async fn status(&self, plan_id: &PlanId) -> Result<PlanStatus, PlannerError> {
2092 let (((mut status, head_hash), info), baseline) =
2093 self.store.read_plan_and_variant(plan_id, |state| {
2094 let deliverables: Vec<(String, DeliverableStatus, u32, u32, u32)> = state
2096 .graph
2097 .deliverables
2098 .iter()
2099 .map(|d| {
2100 let status = state
2101 .statuses
2102 .get(&d.id)
2103 .cloned()
2104 .unwrap_or(DeliverableStatus::Pending);
2105 (
2106 d.id.clone(),
2107 status,
2108 state.attempt_count(&d.id),
2109 state.failure_count(&d.id),
2110 state.lapse_count(&d.id),
2111 )
2112 })
2113 .collect();
2114
2115 let schedule = crate::schedule::schedule_rows(&state.graph, &state.cached_result);
2116 let mut ready_rows: Vec<&ScheduleRow> = schedule
2117 .iter()
2118 .filter(|r| {
2119 matches!(state.statuses.get(&r.id), Some(DeliverableStatus::Ready))
2120 && !state.locks.contains_key(&r.id)
2121 })
2122 .collect();
2123 let sched_by_id: HashMap<&str, (f32, f32)> = state
2128 .cached_result
2129 .tasks
2130 .iter()
2131 .map(|t| (t.id.as_str(), (t.latest_start, t.float)))
2132 .collect();
2133 ready_rows.sort_by_key(|r| ready_display_key(&r.id, &sched_by_id));
2134 let ready: Vec<String> = ready_rows.iter().map(|r| r.id.clone()).collect();
2135
2136 let milestones =
2137 crate::schedule::milestone_rows(&state.graph, &state.cached_result, |id| {
2138 matches!(state.statuses.get(id), Some(DeliverableStatus::Complete))
2139 });
2140
2141 let status = PlanStatus {
2142 plan_id: plan_id.clone(),
2143 milestones,
2144 critical_ids: state
2145 .cached_result
2146 .critical_ids
2147 .iter()
2148 .filter(|id| id.as_str() != START_ID && id.as_str() != FINISH_ID)
2149 .cloned()
2150 .collect(),
2151 plan_complete: all_complete(state),
2152 schedule,
2153 ready,
2154 deliverables,
2155 critical_path: state.cached_result.critical_path.clone(),
2156 critical_path_hours: state.cached_result.critical_path_duration,
2157 locks_held: state.locks.values().cloned().collect(),
2158 name: None,
2159 variant: None,
2160 selected: None,
2161 archived: None,
2162 active_plan: None,
2163 definition_drift: None,
2164 baseline: None,
2165 };
2166 (status, hash_graph(&state.graph))
2167 })?;
2168 status.baseline = baseline;
2169 if let Some(info) = info {
2170 status.definition_drift =
2172 definition_drift(self.project_root.as_ref(), &info, &head_hash);
2173 status.name = Some(info.name);
2174 status.variant = Some(info.variant);
2175 status.selected = Some(info.selected);
2176 status.archived = Some(info.archived);
2177 if info.archived {
2178 let project = info.project;
2180 status.active_plan = self
2181 .store
2182 .read_tx(|tx| crate::portfolio::active_line_name(tx, &project))?;
2183 }
2184 }
2185 Ok(status)
2186 }
2187
2188 async fn accept(&self, req: AcceptRequest) -> Result<(), PlannerError> {
2189 let mut audit_buf: Vec<AuditEvent> = Vec::new();
2190 let plan_id = req.plan_id.clone();
2191 let deliverable_id = req.deliverable_id.as_str();
2192 let now = self.now();
2193 self.store.mutate_executable_plan(&plan_id, |state| {
2194 if !state
2195 .graph
2196 .deliverables
2197 .iter()
2198 .any(|d| d.id == deliverable_id)
2199 {
2200 return Err(PlannerError::DeliverableNotFound {
2201 plan_id: plan_id.0.clone(),
2202 deliverable_id: deliverable_id.to_string(),
2203 });
2204 }
2205
2206 for lock in &state.reap_expired(now) {
2208 audit_buf.push(make_expired_event(lock, now));
2209 }
2210
2211 let previous_status = state
2213 .statuses
2214 .get(deliverable_id)
2215 .cloned()
2216 .unwrap_or(DeliverableStatus::Pending);
2217 if matches!(previous_status, DeliverableStatus::Complete) {
2218 return Ok(());
2219 }
2220
2221 let mut overrode: Option<String> = None;
2224 if let Some(lock) = state.locks.get(deliverable_id) {
2225 if !req.override_lock {
2226 return Err(PlannerError::LockHeld {
2227 plan_id: plan_id.0.clone(),
2228 deliverable_id: deliverable_id.to_string(),
2229 holder: lock.caller_id.0.clone(),
2230 });
2231 }
2232 overrode = Some(lock.caller_id.0.clone());
2233 }
2234
2235 let missing = incomplete_prerequisites(state, deliverable_id);
2236 if !missing.is_empty() {
2237 return Err(PlannerError::PrerequisitesIncomplete {
2238 plan_id: plan_id.0.clone(),
2239 deliverable_id: deliverable_id.to_string(),
2240 missing,
2241 });
2242 }
2243
2244 let plan_done =
2245 complete_deliverable(state, deliverable_id, &mut audit_buf, "accepted", now);
2246 audit_buf.push(make_accepted_event(
2247 &req,
2248 overrode.as_deref(),
2249 &previous_status,
2250 ));
2251 if plan_done {
2252 audit_buf.push(make_plan_completed_event(
2253 &plan_id,
2254 state.graph.deliverables.len(),
2255 ));
2256 }
2257 Ok(())
2258 })?;
2259
2260 self.flush_audit(audit_buf).await;
2261 Ok(())
2262 }
2263
2264 async fn force_release(&self, req: ForceReleaseRequest) -> Result<(), PlannerError> {
2265 let ForceReleaseRequest {
2266 plan_id,
2267 deliverable_id,
2268 reason,
2269 reset_counters,
2270 } = req;
2271 let deliverable_id = deliverable_id.as_str();
2272 let reason = reason.as_str();
2273 let now = self.now();
2274 let mut audit_buf: Vec<AuditEvent> = Vec::new();
2275 self.store.mutate_executable_plan(&plan_id, |state| {
2276 if !state
2277 .graph
2278 .deliverables
2279 .iter()
2280 .any(|d| d.id == deliverable_id)
2281 {
2282 return Err(PlannerError::DeliverableNotFound {
2283 plan_id: plan_id.0.clone(),
2284 deliverable_id: deliverable_id.to_string(),
2285 });
2286 }
2287
2288 if let Some(lock) = state.end_lease(deliverable_id, now) {
2289 state
2290 .statuses
2291 .insert(deliverable_id.to_string(), DeliverableStatus::Ready);
2292 audit_buf.push(make_force_released_event(&lock, reason));
2293 }
2294
2295 if reset_counters {
2296 let lapse_count = state.lapse_counts.remove(deliverable_id).unwrap_or(0);
2297 let failure_count = state.failure_counts.remove(deliverable_id).unwrap_or(0);
2298 if matches!(
2300 state.statuses.get(deliverable_id),
2301 Some(DeliverableStatus::Failed { .. })
2302 ) {
2303 let prereqs_complete = state
2304 .graph
2305 .deliverables
2306 .iter()
2307 .find(|d| d.id == deliverable_id)
2308 .is_some_and(|d| {
2309 crate::graph::prerequisite_ids(d).all(|p| {
2310 matches!(state.statuses.get(p), Some(DeliverableStatus::Complete))
2311 })
2312 });
2313 let revived = if prereqs_complete {
2314 DeliverableStatus::Ready
2315 } else {
2316 DeliverableStatus::Pending
2317 };
2318 state.statuses.insert(deliverable_id.to_string(), revived);
2319 }
2320 audit_buf.push(make_counters_reset_event(
2321 &plan_id,
2322 deliverable_id,
2323 reason,
2324 lapse_count,
2325 failure_count,
2326 ));
2327 }
2328
2329 Ok(())
2330 })?;
2331
2332 self.flush_audit(audit_buf).await;
2333 Ok(())
2334 }
2335}
2336
2337#[cfg(test)]
2338#[allow(clippy::float_cmp)]
2339mod tests {
2340 use super::*;
2341 use crate::estimator::EffortEstimator;
2342 use crate::schedule::deliverable_to_task;
2343 use crate::task::TaskKind;
2344
2345 fn deliverable(id: &str, effort: Option<f32>, metadata: serde_json::Value) -> Deliverable {
2346 Deliverable {
2347 id: id.to_string(),
2348 owned_files: Vec::new(),
2349 prerequisites: Vec::new(),
2350 estimated_effort_hours: effort,
2351 metadata,
2352 duration_hours: None,
2353 estimate: None,
2354 milestone: false,
2355 earning_rule: None,
2356 }
2357 }
2358
2359 #[test]
2360 fn explicit_default_earning_rule_hashes_like_absent() {
2361 let absent = deliverable("D1", Some(1.0), json!({}));
2362 let mut explicit = absent.clone();
2363 explicit.earning_rule = Some(crate::plan::EarningRule::ZeroHundred);
2364 let graph = |d: Deliverable| PlanGraph {
2365 deliverables: vec![d],
2366 max_chained_dispatch: None,
2367 };
2368 assert_eq!(hash_graph(&graph(explicit)), hash_graph(&graph(absent)));
2369 }
2370
2371 #[test]
2372 fn canonical_form_omits_an_absent_earning_rule() {
2373 let d = deliverable("D1", Some(1.0), json!({}));
2374 assert!(canonical_deliverable(&d).get("earning_rule").is_none());
2375 }
2376
2377 #[test]
2378 fn explicit_effort_wins_over_estimator() {
2379 let estimator = EffortEstimator::new();
2380 let d = deliverable("D1", Some(2.5), json!({}));
2381 let task = deliverable_to_task(&d, &estimator);
2382 assert_eq!(task.effort_hours, 2.5);
2383 }
2384
2385 #[test]
2386 fn missing_effort_uses_estimator_not_flat_default() {
2387 let estimator = EffortEstimator::new();
2390 let d = deliverable("D1", None, json!({}));
2391 let task = deliverable_to_task(&d, &estimator);
2392 let expected = estimator.estimate(
2393 &TaskKind::Custom {
2394 description: String::new(),
2395 },
2396 false,
2397 );
2398 assert_eq!(task.effort_hours, expected);
2399 assert_ne!(task.effort_hours, DEFAULT_EFFORT_HOURS);
2400 }
2401
2402 #[test]
2403 fn complexity_metadata_hint_raises_estimate() {
2404 let estimator = EffortEstimator::new();
2405 let simple = deliverable_to_task(&deliverable("S", None, json!({})), &estimator);
2406 let complex = deliverable_to_task(
2407 &deliverable("C", None, json!({ "complexity": true })),
2408 &estimator,
2409 );
2410 assert!(complex.effort_hours > simple.effort_hours);
2411 }
2412
2413 fn sched<'a>(entries: &[(&'a str, f32, f32)]) -> HashMap<&'a str, (f32, f32)> {
2414 entries
2415 .iter()
2416 .map(|&(id, latest_start, float)| (id, (latest_start, float)))
2417 .collect()
2418 }
2419
2420 #[test]
2421 fn priority_key_orders_lower_latest_start_first() {
2422 let s = sched(&[("A", 5.0, 0.0), ("B", 0.0, 9.0)]);
2423 assert!(priority_key("B", &s) < priority_key("A", &s));
2424 }
2425
2426 #[test]
2427 fn priority_key_breaks_latest_start_ties_by_float() {
2428 let s = sched(&[("X", 1.0, 3.0), ("Y", 1.0, 1.0)]);
2429 assert!(priority_key("Y", &s) < priority_key("X", &s));
2430 }
2431
2432 #[test]
2433 fn priority_key_breaks_float_ties_by_id() {
2434 let s = sched(&[("X", 1.0, 1.0), ("Y", 1.0, 1.0)]);
2435 assert!(priority_key("X", &s) < priority_key("Y", &s));
2436 }
2437
2438 #[test]
2439 #[should_panic(expected = "absent from the cached CPM schedule")]
2440 fn priority_key_missing_entry_is_invariant_breach() {
2441 let s: HashMap<&str, (f32, f32)> = HashMap::new();
2442 let _ = priority_key("ghost", &s);
2443 }
2444}