feat(scheduler): incremental CPM recompute — changed_task_ids subgraph delta (ADR-0027)
ADR-0027 implementation. Currently the scheduler runs a full CPM pass on every trigger. For large projects (500+ tasks) this is unnecessarily expensive when only a small subgraph has changed.
Summary
Add changed_task_ids parameter to the CPM engine so it can extract the affected subgraph (ancestors + descendants) and recompute only that delta, falling back to a full pass when the change ratio exceeds a configurable threshold.
Scheduler changes (packages/scheduler)
def compute_cpm(
tasks: list[Task],
changed_task_ids: set[str] | None = None, # new
incremental_threshold: float = 0.3, # new — fall back if > 30% affected
) -> CPMResult: ...- Build the full dependency graph once; extract the minimal subgraph (ancestors ∪ descendants of
changed_task_ids) - If
len(subgraph) / len(tasks) > incremental_threshold→ fall back to full pass - Closure is computed once at project load and cached; only invalidated when dependency edges change
API / Celery changes (packages/api)
- Celery task
run_cpm.apply_asyncpasseschanged_task_idswhen the trigger is a partial mutation (task PATCH with date/duration/dependency fields changed) - Full triggers (project load, baseline operations, dependency bulk-edit) always pass
changed_task_ids=None
Configuration
# settings.py
SCHEDULER_INCREMENTAL_CHANGE_RATIO = 0.3 # fall back to full above this fraction
SCHEDULER_INCREMENTAL_CLOSURE_RATIO = 0.5 # fall back if closure > this fraction of graphAcceptance criteria
-
compute_cpmacceptschanged_task_idswith correct subgraph extraction - Incremental pass produces identical results to full pass (property-based test with hypothesis)
- Threshold fallback is logged at DEBUG level
- Celery
run_cpmtask passeschanged_task_idsfor single-task mutations - Benchmark: incremental pass on 5-task change in a 500-task project is ≥ 5× faster than full pass
- pytest coverage including edge cases: task at critical path root, disconnected subgraph, threshold fallback