Skip to main content

cpm_planner/
planner.rs

1//! `BasicCpmPlanner` — open-source [`Planner`] implementation.
2//!
3//! Bridges the wire model in [`crate::plan`] to the internal
4//! CPM kernel in [`crate::algorithm`], enforces the lock-aware semantics
5//! described on [`Planner`], and emits an audit lifecycle for every lock
6//! state transition.
7//!
8//! # Atomicity
9//!
10//! All mutating methods take the single top-level
11//! `tokio::sync::Mutex<HashMap<PlanId, PlanState>>`. Holding that mutex
12//! for the entirety of [`acquire_cohort`][Planner::acquire_cohort] is what
13//! makes "acquire N disjoint deliverables together" a single observable
14//! step: no other concurrent acquirer can witness a half-applied lock map.
15//!
16//! # Audit emission
17//!
18//! Audit events are buffered into a `Vec<AuditEvent>` while the mutex is
19//! held, then drained to the [`AuditSink`] AFTER the mutex is dropped.
20//! A slow sink therefore never holds up concurrent acquirers.
21
22use std::collections::{HashMap, HashSet};
23use std::path::PathBuf;
24use std::sync::Arc;
25use std::time::Duration;
26
27use crate::audit::{AuditEvent, AuditSink, NullAuditSink};
28use crate::plan::{
29    CallerId, Cohort, CohortRow, Deliverable, DeliverableStatus, LockInfo, PlanGraph, PlanId,
30    PlanStatus, PlannerError,
31};
32use crate::ports::Planner;
33use async_trait::async_trait;
34use chrono::{DateTime, Utc};
35use serde_json::json;
36use sha2::{Digest, Sha256};
37use tokio::sync::Mutex;
38
39use crate::algorithm::CpmAlgorithm;
40use crate::estimator::EffortEstimator;
41use crate::locks::PlanState;
42use crate::task::{Task, TaskKind};
43
44/// Default TTL applied to newly acquired locks. Five minutes is the
45/// open-source default called out in SPEC §33 PA3.
46pub const DEFAULT_TTL: Duration = Duration::from_secs(5 * 60);
47
48/// Legacy flat fallback for missing effort estimates.
49///
50/// As of CMP-016 the planner no longer uses this: when a deliverable omits
51/// `estimated_effort_hours`, [`deliverable_to_task`] asks an
52/// [`EffortEstimator`] for a kind-aware estimate instead of substituting a
53/// flat one hour. The constant is retained as a documented reference value
54/// for callers that want the historical default.
55pub const DEFAULT_EFFORT_HOURS: f32 = 1.0;
56
57/// Pluggable clock. Tests inject a closure backed by a shared instant so
58/// TTL expiry is deterministic without `std::thread::sleep`.
59pub type ClockFn = Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>;
60
61/// Open-source CPM planner with file-aware locking.
62pub struct BasicCpmPlanner {
63    plans: Arc<Mutex<HashMap<PlanId, PlanState>>>,
64    /// `graph_hash -> PlanId` map used by [`submit_plan`] to make
65    /// resubmissions idempotent without re-running CPM.
66    dedup: Arc<Mutex<HashMap<String, PlanId>>>,
67    audit: Arc<dyn AuditSink>,
68    ttl: Duration,
69    clock: ClockFn,
70}
71
72impl BasicCpmPlanner {
73    /// Construct a planner with a real-clock and the
74    /// [`DEFAULT_TTL`]. Audit events are dropped on the floor (use
75    /// [`Self::with_audit`] if you need them retained).
76    pub fn new() -> Self {
77        Self::with_audit(Arc::new(NullAuditSink))
78    }
79
80    /// Construct a planner with the supplied audit sink and the default
81    /// TTL. The real `Utc::now` is used as the clock.
82    pub fn with_audit(audit: Arc<dyn AuditSink>) -> Self {
83        Self::with_parts(audit, DEFAULT_TTL, Arc::new(Utc::now))
84    }
85
86    /// Override the lock TTL. Useful for short-lived integration tests.
87    pub fn with_ttl(mut self, ttl: Duration) -> Self {
88        self.ttl = ttl;
89        self
90    }
91
92    /// Override the clock. Intended for deterministic TTL tests; production
93    /// code should not call this.
94    pub fn with_clock(mut self, clock: ClockFn) -> Self {
95        self.clock = clock;
96        self
97    }
98
99    /// Full-parts constructor. Public for clients that want explicit
100    /// control over every field at once.
101    pub fn with_parts(audit: Arc<dyn AuditSink>, ttl: Duration, clock: ClockFn) -> Self {
102        Self {
103            plans: Arc::new(Mutex::new(HashMap::new())),
104            dedup: Arc::new(Mutex::new(HashMap::new())),
105            audit,
106            ttl,
107            clock,
108        }
109    }
110
111    fn now(&self) -> DateTime<Utc> {
112        (self.clock)()
113    }
114
115    /// Flush buffered audit events. Called after the mutex is dropped so a
116    /// slow sink never blocks concurrent planner callers.
117    async fn flush_audit(&self, events: Vec<AuditEvent>) {
118        for ev in events {
119            // Audit failures are intentionally swallowed at this layer:
120            // the planner's invariant is "lock state stays consistent
121            // even if observability fails". A `tracing::warn!` documents
122            // the loss without aborting the caller's operation.
123            if let Err(err) = self.audit.record(ev).await {
124                tracing::warn!(error = %err, "audit sink failed to record planner event");
125            }
126        }
127    }
128}
129
130impl Default for BasicCpmPlanner {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Graph validation + hashing
138// ---------------------------------------------------------------------------
139
140/// Deterministic content hash of a [`PlanGraph`]. Same logical graph -> same
141/// hash regardless of the order `deliverables` were submitted in. This is
142/// what lets `submit_plan` be idempotent.
143fn hash_graph(graph: &PlanGraph) -> String {
144    // Build a normalised JSON form: deliverables sorted by id; each
145    // deliverable's prerequisites + owned_files sorted; metadata kept
146    // as-is (callers are responsible for its determinism).
147    let mut deliverables: Vec<_> = graph
148        .deliverables
149        .iter()
150        .map(|d| {
151            let mut prereqs = d.prerequisites.clone();
152            prereqs.sort();
153            let mut files: Vec<String> = d
154                .owned_files
155                .iter()
156                .map(|p| p.to_string_lossy().into_owned())
157                .collect();
158            files.sort();
159            json!({
160                "id": d.id,
161                "owned_files": files,
162                "prerequisites": prereqs,
163                "estimated_effort_hours": d.estimated_effort_hours,
164                "metadata": d.metadata,
165            })
166        })
167        .collect();
168    deliverables.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
169
170    let payload = json!({
171        "deliverables": deliverables,
172        "max_chained_dispatch": graph.max_chained_dispatch,
173    });
174
175    // Invariant: `payload` was built from a JSON object literal whose
176    // leaves are all owned `String`, primitive, or already-validated
177    // `serde_json::Value` payloads. Serialisation cannot fail for these
178    // inputs; the `expect` documents the invariant and aborts loudly if
179    // a future refactor breaks it.
180    let serialised = serde_json::to_vec(&payload)
181        .expect("INVARIANT: plan-graph hash payload is JSON-serialisable");
182    let mut hasher = Sha256::new();
183    hasher.update(&serialised);
184    format!("{:x}", hasher.finalize())
185}
186
187/// Reject graphs that fail any structural invariant. Returns
188/// [`PlannerError::InvalidGraph`] with a precise `reason` on first failure.
189fn validate_graph(graph: &PlanGraph) -> Result<(), PlannerError> {
190    // Duplicate ids.
191    let mut seen_ids: HashSet<&str> = HashSet::new();
192    for d in &graph.deliverables {
193        if !seen_ids.insert(d.id.as_str()) {
194            return Err(PlannerError::InvalidGraph {
195                reason: format!("duplicate deliverable id '{}'", d.id),
196            });
197        }
198    }
199
200    // Prerequisite references resolve.
201    let id_set: HashSet<&str> = graph.deliverables.iter().map(|d| d.id.as_str()).collect();
202    for d in &graph.deliverables {
203        for p in &d.prerequisites {
204            if !id_set.contains(p.as_str()) {
205                return Err(PlannerError::InvalidGraph {
206                    reason: format!(
207                        "prerequisite '{p}' for deliverable '{}' does not exist",
208                        d.id
209                    ),
210                });
211            }
212        }
213    }
214
215    // Disjoint owned_files at graph level.
216    let mut file_owner: HashMap<&PathBuf, &str> = HashMap::new();
217    for d in &graph.deliverables {
218        for f in &d.owned_files {
219            if let Some(other) = file_owner.insert(f, d.id.as_str()) {
220                return Err(PlannerError::InvalidGraph {
221                    reason: format!(
222                        "file '{}' is owned by both '{}' and '{}'",
223                        f.display(),
224                        other,
225                        d.id
226                    ),
227                });
228            }
229        }
230    }
231
232    // Cycle detection via Kahn's algorithm on the prerequisite DAG.
233    let mut indeg: HashMap<&str, usize> = HashMap::new();
234    let mut succs: HashMap<&str, Vec<&str>> = HashMap::new();
235    for d in &graph.deliverables {
236        indeg.entry(d.id.as_str()).or_insert(0);
237        succs.entry(d.id.as_str()).or_default();
238    }
239    for d in &graph.deliverables {
240        for p in &d.prerequisites {
241            *indeg.entry(d.id.as_str()).or_insert(0) += 1;
242            succs.entry(p.as_str()).or_default().push(d.id.as_str());
243        }
244    }
245    let mut queue: Vec<&str> = indeg
246        .iter()
247        .filter_map(|(k, v)| if *v == 0 { Some(*k) } else { None })
248        .collect();
249    let mut popped = 0_usize;
250    while let Some(node) = queue.pop() {
251        popped += 1;
252        if let Some(s) = succs.get(node).cloned() {
253            for next in s {
254                if let Some(deg) = indeg.get_mut(next) {
255                    *deg -= 1;
256                    if *deg == 0 {
257                        queue.push(next);
258                    }
259                }
260            }
261        }
262    }
263    if popped < graph.deliverables.len() {
264        // SPEC §33 audit fixup (F6 STUB-007) — name the cycle members.
265        // After Kahn's terminates, any node with residual in-degree > 0
266        // is part of (or downstream of) at least one cycle. Listing
267        // them sorted gives operators a starting set to debug from
268        // instead of "somewhere in your 50-deliverable graph there's
269        // a cycle, good luck."
270        let mut cycle_members: Vec<&str> = indeg
271            .iter()
272            .filter_map(|(k, v)| if *v > 0 { Some(*k) } else { None })
273            .collect();
274        cycle_members.sort_unstable();
275        return Err(PlannerError::InvalidGraph {
276            reason: format!(
277                "cycle detected in prerequisite graph involving deliverables: [{}]",
278                cycle_members.join(", ")
279            ),
280        });
281    }
282
283    Ok(())
284}
285
286/// Convert each [`Deliverable`] into a [`Task`] for the CPM kernel.
287///
288/// Effort precedence: an explicit `estimated_effort_hours` on the
289/// deliverable always wins. When it is absent we ask `estimator` to derive
290/// a kind-aware estimate rather than falling back to the flat
291/// [`DEFAULT_EFFORT_HOURS`] placeholder. A `complexity` hint can be carried
292/// in `metadata` (boolean `complexity`/`is_complex`) to opt a deliverable
293/// into the configured complexity multiplier.
294fn deliverable_to_task(d: &Deliverable, estimator: &EffortEstimator) -> Task {
295    let description = d
296        .metadata
297        .get("description")
298        .and_then(|v| v.as_str())
299        .unwrap_or_default()
300        .to_string();
301    let kind = TaskKind::Custom { description };
302
303    let effort_hours = match d.estimated_effort_hours {
304        Some(explicit) => explicit,
305        None => {
306            // Coarse complexity hint from metadata; defaults to false.
307            let is_complex = d
308                .metadata
309                .get("complexity")
310                .or_else(|| d.metadata.get("is_complex"))
311                .and_then(serde_json::Value::as_bool)
312                .unwrap_or(false);
313            estimator.estimate(&kind, is_complex)
314        }
315    };
316
317    Task {
318        id: d.id.clone(),
319        name: d.id.clone(),
320        kind,
321        effort_hours,
322        dependencies: d.prerequisites.clone(),
323        ..Task::default()
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Audit helpers
329// ---------------------------------------------------------------------------
330
331fn make_acquired_event(lock: &LockInfo, owned_files: &[PathBuf]) -> AuditEvent {
332    AuditEvent::new("plan.lock.acquired")
333        .with_actor(lock.caller_id.as_str())
334        .with_payload(json!({
335            "plan_id": lock.plan_id.as_str(),
336            "deliverable_id": lock.deliverable_id,
337            "caller_id": lock.caller_id.as_str(),
338            "acquired_at": lock.acquired_at,
339            "expires_at": lock.expires_at,
340            "owned_files": owned_files,
341        }))
342}
343
344fn make_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
345    AuditEvent::new("plan.lock.released")
346        .with_actor(lock.caller_id.as_str())
347        .with_payload(json!({
348            "plan_id": lock.plan_id.as_str(),
349            "deliverable_id": lock.deliverable_id,
350            "caller_id": lock.caller_id.as_str(),
351            "reason": reason,
352        }))
353}
354
355fn make_expired_event(lock: &LockInfo, expired_at: DateTime<Utc>) -> AuditEvent {
356    AuditEvent::new("plan.lock.expired")
357        .with_actor(lock.caller_id.as_str())
358        .with_payload(json!({
359            "plan_id": lock.plan_id.as_str(),
360            "deliverable_id": lock.deliverable_id,
361            "last_caller_id": lock.caller_id.as_str(),
362            "expired_at": expired_at,
363        }))
364}
365
366fn make_force_released_event(lock: &LockInfo, reason: &str) -> AuditEvent {
367    AuditEvent::new("plan.lock.force_released")
368        .with_actor(lock.caller_id.as_str())
369        .with_payload(json!({
370            "plan_id": lock.plan_id.as_str(),
371            "deliverable_id": lock.deliverable_id,
372            "last_caller_id": lock.caller_id.as_str(),
373            "reason": reason,
374        }))
375}
376
377// ---------------------------------------------------------------------------
378// Priority ordering for cohort selection
379// ---------------------------------------------------------------------------
380
381/// Sort key for the ready-set priority pass. Critical-path tasks come first
382/// (in CP execution order), then non-critical tasks by ascending
383/// `earliest_start`. Ties broken by `deliverable_id` for determinism.
384fn priority_key(
385    deliverable_id: &str,
386    cp_positions: &HashMap<&str, usize>,
387    es_by_id: &HashMap<&str, f32>,
388) -> (u8, i64, String) {
389    if let Some(pos) = cp_positions.get(deliverable_id) {
390        // Tier 0 = critical path; position drives order.
391        (0, *pos as i64, deliverable_id.to_string())
392    } else {
393        // Tier 1 = non-critical; ES drives order (scaled to integer for Ord).
394        // Every deliverable in the ready set was turned into a Task and fed
395        // through CPM, so its id MUST be present in `es_by_id`. A miss means
396        // the ready set and the cached CPM result have diverged — an
397        // invariant breach, not a "default to time 0" situation, since
398        // defaulting would confidently mis-order the cohort.
399        let es = match es_by_id.get(deliverable_id) {
400            Some(&es) => es,
401            None => unreachable!(
402                "deliverable '{deliverable_id}' is in the ready set but absent from the cached \
403                 CPM earliest-start table — ready set and CPM result are out of sync"
404            ),
405        };
406        let es_scaled = (es * 1000.0).round() as i64;
407        (1, es_scaled, deliverable_id.to_string())
408    }
409}
410
411// ---------------------------------------------------------------------------
412// Planner impl
413// ---------------------------------------------------------------------------
414
415#[async_trait]
416impl Planner for BasicCpmPlanner {
417    async fn submit_plan(&self, graph: PlanGraph) -> Result<PlanId, PlannerError> {
418        validate_graph(&graph)?;
419        let graph_hash = hash_graph(&graph);
420
421        // Fast path: dedup hit -> return existing PlanId.
422        {
423            let dedup = self.dedup.lock().await;
424            if let Some(existing) = dedup.get(&graph_hash) {
425                return Ok(existing.clone());
426            }
427        }
428
429        // Build the CPM kernel input and run the algorithm. A single
430        // default-config estimator fills in effort for deliverables that
431        // omit an explicit `estimated_effort_hours`.
432        let estimator = EffortEstimator::new();
433        let mut tasks: Vec<Task> = graph
434            .deliverables
435            .iter()
436            .map(|d| deliverable_to_task(d, &estimator))
437            .collect();
438        let cached_result = CpmAlgorithm::calculate(&mut tasks);
439
440        // `validate_graph` above already rejected cyclic graphs, so the CPM
441        // kernel must have scheduled every task. If `unscheduled` is non-empty
442        // here, the two cycle detectors disagree — a correctness bug, not bad
443        // input. Surface it as an InvalidGraph rather than caching and serving
444        // a confidently-wrong plan.
445        if !cached_result.unscheduled.is_empty() {
446            return Err(PlannerError::InvalidGraph {
447                reason: format!(
448                    "internal CPM inconsistency: deliverables passed cycle validation but could \
449                     not be scheduled: [{}]",
450                    cached_result.unscheduled.join(", ")
451                ),
452            });
453        }
454
455        // Initialise per-deliverable status: zero-prereq -> Ready, else Pending.
456        let mut statuses: HashMap<String, DeliverableStatus> =
457            HashMap::with_capacity(graph.deliverables.len());
458        for d in &graph.deliverables {
459            let status = if d.prerequisites.is_empty() {
460                DeliverableStatus::Ready
461            } else {
462                DeliverableStatus::Pending
463            };
464            statuses.insert(d.id.clone(), status);
465        }
466
467        // Mint a fresh PlanId and insert. Re-check dedup under both locks to
468        // avoid a TOCTOU race between the read above and the insert below
469        // when two callers submit identical graphs concurrently.
470        let plan_id = PlanId(format!("plan_{}", uuid::Uuid::new_v4().simple()));
471        let state = PlanState::new(graph, statuses, cached_result);
472
473        let mut dedup = self.dedup.lock().await;
474        if let Some(existing) = dedup.get(&graph_hash) {
475            return Ok(existing.clone());
476        }
477        let mut plans = self.plans.lock().await;
478        dedup.insert(graph_hash, plan_id.clone());
479        plans.insert(plan_id.clone(), state);
480        Ok(plan_id)
481    }
482
483    async fn acquire_cohort(
484        &self,
485        plan_id: &PlanId,
486        caller_id: &CallerId,
487        max_count: usize,
488    ) -> Result<Cohort, PlannerError> {
489        let now = self.now();
490        let expires_at = now
491            + chrono::Duration::from_std(self.ttl)
492                .expect("INVARIANT: planner TTL fits in chrono::Duration");
493
494        // Whole acquire body runs under the top-level mutex — that's what
495        // gives us atomicity against concurrent acquirers.
496        let (cohort, audit_buf) = {
497            let mut plans = self.plans.lock().await;
498            let state = plans
499                .get_mut(plan_id)
500                .ok_or_else(|| PlannerError::PlanNotFound {
501                    plan_id: plan_id.0.clone(),
502                })?;
503
504            let mut audit_buf: Vec<AuditEvent> = Vec::new();
505
506            // 1. Reap expired locks, emitting expiry events.
507            let reaped = state.reap_expired(now);
508            for lock in &reaped {
509                audit_buf.push(make_expired_event(lock, now));
510            }
511
512            // 2. Build CP priority + ES lookup tables.
513            let cp_positions: HashMap<&str, usize> = state
514                .cached_result
515                .critical_path
516                .iter()
517                .enumerate()
518                .map(|(i, id)| (id.as_str(), i))
519                .collect();
520            let es_by_id: HashMap<&str, f32> = state
521                .cached_result
522                .tasks
523                .iter()
524                .map(|t| (t.id.as_str(), t.earliest_start))
525                .collect();
526
527            // 3. Build the ready set: status=Ready AND no lock currently held.
528            let mut ready: Vec<&Deliverable> = state
529                .graph
530                .deliverables
531                .iter()
532                .filter(|d| {
533                    matches!(state.statuses.get(&d.id), Some(DeliverableStatus::Ready))
534                        && !state.locks.contains_key(&d.id)
535                })
536                .collect();
537            ready.sort_by_key(|d| priority_key(&d.id, &cp_positions, &es_by_id));
538
539            // 4. Greedy fill with file-disjointness check.
540            let mut selected: Vec<Deliverable> = Vec::new();
541            let mut selected_files: HashSet<PathBuf> = HashSet::new();
542            for candidate in ready {
543                if selected.len() == max_count {
544                    break;
545                }
546                let conflict = candidate.owned_files.iter().any(|f| {
547                    selected_files.contains(f) || state.file_to_deliverable.contains_key(f)
548                });
549                if conflict {
550                    continue;
551                }
552                for f in &candidate.owned_files {
553                    selected_files.insert(f.clone());
554                }
555                selected.push(candidate.clone());
556            }
557
558            // 5. Atomically acquire: status -> InProgress, locks inserted,
559            //    file index updated, audit events buffered.
560            //
561            // F5 INTERFACE_GAP-001: build `Vec<CohortRow>` directly so
562            // the pairing invariant is type-enforced — pre-F5 we
563            // collected into `Vec<(Deliverable, LockInfo)>` then
564            // unzipped into two parallel vectors that were doc-only
565            // aligned.
566            let mut rows: Vec<CohortRow> = Vec::with_capacity(selected.len());
567            for d in selected {
568                let lock = LockInfo {
569                    plan_id: plan_id.clone(),
570                    deliverable_id: d.id.clone(),
571                    caller_id: caller_id.clone(),
572                    acquired_at: now,
573                    expires_at,
574                };
575                state
576                    .statuses
577                    .insert(d.id.clone(), DeliverableStatus::InProgress);
578                for f in &d.owned_files {
579                    state.file_to_deliverable.insert(f.clone(), d.id.clone());
580                }
581                state.locks.insert(d.id.clone(), lock.clone());
582                audit_buf.push(make_acquired_event(&lock, &d.owned_files));
583                rows.push(CohortRow {
584                    deliverable: d,
585                    lock,
586                });
587            }
588
589            let cohort = Cohort {
590                plan_id: plan_id.clone(),
591                rows,
592            };
593
594            (cohort, audit_buf)
595        };
596
597        self.flush_audit(audit_buf).await;
598        Ok(cohort)
599    }
600
601    async fn mark_status(
602        &self,
603        plan_id: &PlanId,
604        deliverable_id: &str,
605        caller_id: &CallerId,
606        status: DeliverableStatus,
607    ) -> Result<(), PlannerError> {
608        let audit_buf = {
609            let mut plans = self.plans.lock().await;
610            let state = plans
611                .get_mut(plan_id)
612                .ok_or_else(|| PlannerError::PlanNotFound {
613                    plan_id: plan_id.0.clone(),
614                })?;
615
616            // Deliverable existence.
617            if !state
618                .graph
619                .deliverables
620                .iter()
621                .any(|d| d.id == deliverable_id)
622            {
623                return Err(PlannerError::DeliverableNotFound {
624                    plan_id: plan_id.0.clone(),
625                    deliverable_id: deliverable_id.to_string(),
626                });
627            }
628
629            // If a lock exists it must belong to caller_id.
630            if let Some(lock) = state.locks.get(deliverable_id) {
631                if lock.caller_id != *caller_id {
632                    return Err(PlannerError::LockNotHeld {
633                        caller_id: caller_id.0.clone(),
634                        deliverable_id: deliverable_id.to_string(),
635                    });
636                }
637            }
638
639            let mut audit_buf: Vec<AuditEvent> = Vec::new();
640
641            // Lock release on terminal status.
642            let release_reason: Option<&'static str> = match &status {
643                DeliverableStatus::Complete => Some("completed"),
644                DeliverableStatus::Failed { .. } => Some("failed"),
645                _ => None,
646            };
647
648            if let Some(reason) = release_reason {
649                if let Some(lock) = state.locks.remove(deliverable_id) {
650                    // Deliverable existence was verified at the top of
651                    // `mark_status`; `.find()` is guaranteed to succeed.
652                    let owned_files: Vec<PathBuf> = match state
653                        .graph
654                        .deliverables
655                        .iter()
656                        .find(|d| d.id == deliverable_id)
657                    {
658                        Some(d) => d.owned_files.clone(),
659                        None => unreachable!(
660                            "deliverable {deliverable_id} present in locks but missing from \
661                             graph — invariant broken"
662                        ),
663                    };
664                    for f in &owned_files {
665                        state.file_to_deliverable.remove(f);
666                    }
667                    audit_buf.push(make_released_event(&lock, reason));
668                }
669            }
670
671            // Set status.
672            state
673                .statuses
674                .insert(deliverable_id.to_string(), status.clone());
675
676            // Advance dependents to Ready if all their prereqs are Complete.
677            if matches!(status, DeliverableStatus::Complete) {
678                let dependents: Vec<String> = state
679                    .graph
680                    .deliverables
681                    .iter()
682                    .filter(|d| d.prerequisites.iter().any(|p| p == deliverable_id))
683                    .map(|d| d.id.clone())
684                    .collect();
685                for dep_id in dependents {
686                    let dep = match state.graph.deliverables.iter().find(|d| d.id == dep_id) {
687                        Some(d) => d,
688                        None => {
689                            unreachable!("dependent id {dep_id} present in graph but not findable")
690                        }
691                    };
692                    let all_done = dep.prerequisites.iter().all(|p| {
693                        matches!(state.statuses.get(p), Some(DeliverableStatus::Complete))
694                    });
695                    let currently_pending = matches!(
696                        state.statuses.get(&dep_id),
697                        Some(DeliverableStatus::Pending)
698                    );
699                    if all_done && currently_pending {
700                        state.statuses.insert(dep_id, DeliverableStatus::Ready);
701                    }
702                }
703            }
704
705            audit_buf
706        };
707
708        // SPEC §33 audit fixup (F6 ORPHAN-001): the previous
709        // `let _ = status_recompute_needed;` extension marker was
710        // computed but never consumed. YAGNI — the CPM algorithm
711        // doesn't drift with status alone, and a future "filter on
712        // non-complete tasks" refactor can recompute the flag when
713        // it actually needs it.
714
715        self.flush_audit(audit_buf).await;
716        Ok(())
717    }
718
719    async fn heartbeat(
720        &self,
721        plan_id: &PlanId,
722        deliverable_id: &str,
723        caller_id: &CallerId,
724    ) -> Result<(), PlannerError> {
725        let now = self.now();
726        let expires_at = now
727            + chrono::Duration::from_std(self.ttl)
728                .expect("INVARIANT: planner TTL fits in chrono::Duration");
729
730        let mut plans = self.plans.lock().await;
731        let state = plans
732            .get_mut(plan_id)
733            .ok_or_else(|| PlannerError::PlanNotFound {
734                plan_id: plan_id.0.clone(),
735            })?;
736
737        let lock =
738            state
739                .locks
740                .get_mut(deliverable_id)
741                .ok_or_else(|| PlannerError::LockNotHeld {
742                    caller_id: caller_id.0.clone(),
743                    deliverable_id: deliverable_id.to_string(),
744                })?;
745
746        if lock.caller_id != *caller_id {
747            return Err(PlannerError::LockNotHeld {
748                caller_id: caller_id.0.clone(),
749                deliverable_id: deliverable_id.to_string(),
750            });
751        }
752
753        // TTL already lapsed at the moment of the heartbeat — surface it so
754        // the caller knows their work item may have been reclaimed.
755        if lock.expires_at < now {
756            return Err(PlannerError::LockExpired {
757                deliverable_id: deliverable_id.to_string(),
758                expired_at: lock.expires_at,
759            });
760        }
761
762        lock.expires_at = expires_at;
763        Ok(())
764    }
765
766    async fn status(&self, plan_id: &PlanId) -> Result<PlanStatus, PlannerError> {
767        let plans = self.plans.lock().await;
768        let state = plans
769            .get(plan_id)
770            .ok_or_else(|| PlannerError::PlanNotFound {
771                plan_id: plan_id.0.clone(),
772            })?;
773
774        // Preserve insertion order from the original graph for stable UI.
775        let deliverables: Vec<(String, DeliverableStatus)> = state
776            .graph
777            .deliverables
778            .iter()
779            .map(|d| {
780                let status = state
781                    .statuses
782                    .get(&d.id)
783                    .cloned()
784                    .unwrap_or(DeliverableStatus::Pending);
785                (d.id.clone(), status)
786            })
787            .collect();
788
789        Ok(PlanStatus {
790            plan_id: plan_id.clone(),
791            deliverables,
792            critical_path: state.cached_result.critical_path.clone(),
793            critical_path_hours: state.cached_result.critical_path_duration,
794            locks_held: state.locks.values().cloned().collect(),
795        })
796    }
797
798    async fn force_release(
799        &self,
800        plan_id: &PlanId,
801        deliverable_id: &str,
802        reason: &str,
803    ) -> Result<(), PlannerError> {
804        let audit_buf = {
805            let mut plans = self.plans.lock().await;
806            let state = plans
807                .get_mut(plan_id)
808                .ok_or_else(|| PlannerError::PlanNotFound {
809                    plan_id: plan_id.0.clone(),
810                })?;
811
812            if !state
813                .graph
814                .deliverables
815                .iter()
816                .any(|d| d.id == deliverable_id)
817            {
818                return Err(PlannerError::DeliverableNotFound {
819                    plan_id: plan_id.0.clone(),
820                    deliverable_id: deliverable_id.to_string(),
821                });
822            }
823
824            let mut audit_buf: Vec<AuditEvent> = Vec::new();
825            if let Some(lock) = state.locks.remove(deliverable_id) {
826                // Deliverable existence was verified above; the held lock
827                // implies the graph entry exists.
828                let owned_files: Vec<PathBuf> = match state
829                    .graph
830                    .deliverables
831                    .iter()
832                    .find(|d| d.id == deliverable_id)
833                {
834                    Some(d) => d.owned_files.clone(),
835                    None => unreachable!(
836                        "deliverable {deliverable_id} present in locks but missing from graph"
837                    ),
838                };
839                for f in &owned_files {
840                    state.file_to_deliverable.remove(f);
841                }
842                state
843                    .statuses
844                    .insert(deliverable_id.to_string(), DeliverableStatus::Ready);
845                audit_buf.push(make_force_released_event(&lock, reason));
846            }
847
848            audit_buf
849        };
850
851        self.flush_audit(audit_buf).await;
852        Ok(())
853    }
854}
855
856#[cfg(test)]
857#[allow(clippy::float_cmp)]
858mod tests {
859    use super::*;
860
861    fn deliverable(id: &str, effort: Option<f32>, metadata: serde_json::Value) -> Deliverable {
862        Deliverable {
863            id: id.to_string(),
864            owned_files: Vec::new(),
865            prerequisites: Vec::new(),
866            estimated_effort_hours: effort,
867            metadata,
868        }
869    }
870
871    #[test]
872    fn explicit_effort_wins_over_estimator() {
873        let estimator = EffortEstimator::new();
874        let d = deliverable("D1", Some(2.5), json!({}));
875        let task = deliverable_to_task(&d, &estimator);
876        assert_eq!(task.effort_hours, 2.5);
877    }
878
879    #[test]
880    fn missing_effort_uses_estimator_not_flat_default() {
881        // No explicit estimate -> estimator's Custom base (default 4.0),
882        // which must differ from the legacy flat DEFAULT_EFFORT_HOURS (1.0).
883        let estimator = EffortEstimator::new();
884        let d = deliverable("D1", None, json!({}));
885        let task = deliverable_to_task(&d, &estimator);
886        let expected = estimator.estimate(
887            &TaskKind::Custom {
888                description: String::new(),
889            },
890            false,
891        );
892        assert_eq!(task.effort_hours, expected);
893        assert_ne!(task.effort_hours, DEFAULT_EFFORT_HOURS);
894    }
895
896    #[test]
897    fn complexity_metadata_hint_raises_estimate() {
898        let estimator = EffortEstimator::new();
899        let simple = deliverable_to_task(&deliverable("S", None, json!({})), &estimator);
900        let complex = deliverable_to_task(
901            &deliverable("C", None, json!({ "complexity": true })),
902            &estimator,
903        );
904        assert!(complex.effort_hours > simple.effort_hours);
905    }
906
907    #[test]
908    fn priority_key_critical_tier_orders_by_position() {
909        let mut cp = HashMap::new();
910        cp.insert("A", 0usize);
911        cp.insert("B", 1usize);
912        let es: HashMap<&str, f32> = HashMap::new();
913        let ka = priority_key("A", &cp, &es);
914        let kb = priority_key("B", &cp, &es);
915        assert!(ka < kb);
916    }
917
918    #[test]
919    fn priority_key_noncritical_uses_es() {
920        let cp: HashMap<&str, usize> = HashMap::new();
921        let mut es = HashMap::new();
922        es.insert("X", 1.0_f32);
923        es.insert("Y", 3.0_f32);
924        let kx = priority_key("X", &cp, &es);
925        let ky = priority_key("Y", &cp, &es);
926        // Both tier 1, X has earlier ES so sorts first.
927        assert_eq!(kx.0, 1);
928        assert!(kx < ky);
929    }
930
931    #[test]
932    #[should_panic(expected = "absent from the cached CPM earliest-start table")]
933    fn priority_key_missing_es_is_invariant_breach() {
934        let cp: HashMap<&str, usize> = HashMap::new();
935        let es: HashMap<&str, f32> = HashMap::new();
936        // Non-critical deliverable with no ES entry must panic, not default.
937        let _ = priority_key("ghost", &cp, &es);
938    }
939}