1use crate::error::{EngineError, Result};
9use crate::events::{Event, EventKind};
10use crate::types::*;
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14pub const ENGINE_RUN_ID: &str = "engine";
17
18pub fn fold(events: &[Event]) -> Result<MissionState> {
21 let first = events
22 .first()
23 .ok_or_else(|| EngineError::InvalidState("cannot fold an empty event log".to_string()))?;
24 let mut state = initial_state(first)?;
25 for event in &events[1..] {
26 apply(&mut state, event)?;
27 }
28 Ok(state)
29}
30
31pub fn apply(state: &mut MissionState, event: &Event) -> Result<()> {
35 if event.seq != state.last_seq + 1 {
36 return Err(EngineError::InvalidState(format!(
37 "non-contiguous apply: state at seq {}, event seq {}",
38 state.last_seq, event.seq
39 )));
40 }
41
42 match &event.kind {
43 EventKind::MissionCreated { .. } => {
44 return Err(EngineError::InvalidState(format!(
45 "mission.created at seq {} is only valid as the first event",
46 event.seq
47 )));
48 }
49
50 EventKind::PlanApproved { plan, base_sha } => {
51 if state.mission.status != MissionStatus::Planning {
61 tracing::warn!(
62 seq = event.seq,
63 status = ?state.mission.status,
64 "ignoring plan.approved outside Planning status"
65 );
66 state.last_seq = event.seq;
67 return Ok(());
68 }
69 crate::contract_controls::validate(&plan.validation_contract)?;
70 state.mission.base_sha = base_sha.clone();
71 state.mission.goal = plan.goal.clone();
72 state.mission.validation_contract = plan.validation_contract.clone();
73 state.mission.milestones = plan
74 .milestones
75 .iter()
76 .enumerate()
77 .map(|(mi, pm)| Milestone {
78 id: format!("ms-{}", mi + 1),
79 title: pm.title.clone(),
80 features: pm
81 .features
82 .iter()
83 .enumerate()
84 .map(|(fi, pf)| Feature {
85 id: format!("f-{}-{}", mi + 1, fi + 1),
86 title: pf.title.clone(),
87 spec: pf.spec.clone(),
88 validation_criteria: pf.validation_criteria.clone(),
89 origin: FeatureOrigin::Plan,
90 status: FeatureStatus::Pending,
91 worker_runs: Vec::new(),
92 commits: Vec::new(),
93 respawns: 0,
94 })
95 .collect(),
96 status: MilestoneStatus::Pending,
97 fix_cycles: 0,
98 start_sha: None,
99 validator_guidance: None,
100 })
101 .collect();
102 state.mission.command_grants = plan.command_grants.clone();
103 state.mission.touch_set = plan.touch_set.clone();
104 state.mission.standards_manifest = plan.standards_manifest.as_deref().cloned();
108 state.mission.reviewer_independence = plan.reviewer_independence;
109 state.mission.status = MissionStatus::Approved;
110 state.latest_plan_revision = 0;
111 state.pending_revision = None;
112 }
113
114 EventKind::PlanRevisionProposed {
115 revision,
116 plan,
117 instructions,
118 } => {
119 if *revision == 0 {
120 return Err(EngineError::InvalidState(
121 "plan.revision.proposed revision must be >= 1".to_string(),
122 ));
123 }
124 state.latest_plan_revision = state.latest_plan_revision.max(*revision);
125 state.pending_revision = Some(PendingRevision {
126 revision: *revision,
127 plan: plan.clone(),
128 instructions: instructions.clone(),
129 });
130 }
131
132 EventKind::PlanRevised { revision, plan } => {
133 if let Some(pending) = &state.pending_revision {
134 if pending.revision != *revision {
135 return Err(EngineError::InvalidState(format!(
136 "plan.revised revision {revision} does not match pending revision {}",
137 pending.revision
138 )));
139 }
140 }
141 apply_revised_plan(state, plan, *revision)?;
142 state.latest_plan_revision = state.latest_plan_revision.max(*revision);
143 state.pending_revision = None;
144 }
145
146 EventKind::PlanRevisionRejected { revision, .. } => {
147 if let Some(pending) = &state.pending_revision {
148 if pending.revision != *revision {
149 return Err(EngineError::InvalidState(format!(
150 "plan.revision.rejected revision {revision} does not match pending revision {}",
151 pending.revision
152 )));
153 }
154 }
155 state.latest_plan_revision = state.latest_plan_revision.max(*revision);
156 state.pending_revision = None;
157 }
158
159 EventKind::GrantRequested {
160 milestone_id,
161 kind,
162 command,
163 } => {
164 milestone_mut(state, milestone_id)?; if command.trim().is_empty() {
166 return Err(EngineError::InvalidState(
167 "grant.requested command must not be empty".to_string(),
168 ));
169 }
170 state.pending_grant_request = Some(PendingGrantRequest {
171 milestone_id: milestone_id.clone(),
172 kind: *kind,
173 command: command.clone(),
174 });
175 }
176
177 EventKind::GrantApproved { kind, command } => {
178 expect_pending_grant(state, *kind, command, "grant.approved")?;
183 let list = match kind {
186 GrantKind::Command => &mut state.mission.command_grants,
187 GrantKind::TouchPath => &mut state.mission.touch_set,
188 GrantKind::WorkerDeny => &mut state.mission.deny_exceptions,
189 GrantKind::Egress => &mut state.mission.egress_grants,
190 };
191 if !list.iter().any(|c| c == command) {
192 list.push(command.clone());
193 }
194 state.pending_grant_request = None;
195 }
196
197 EventKind::GrantDenied { kind, command, .. } => {
198 expect_pending_grant(state, *kind, command, "grant.denied")?;
199 state.pending_grant_request = None;
200 }
201
202 EventKind::MilestoneStarted {
203 milestone_id,
204 start_sha,
205 } => {
206 if state.executor_tier() == ExecutorTier::Local {
207 state.local_executor_milestones += 1;
208 }
209 let ms = milestone_mut(state, milestone_id)?;
210 ms.status = MilestoneStatus::Active;
211 ms.start_sha = Some(start_sha.clone());
212 if state.mission.status == MissionStatus::Approved {
213 state.mission.status = MissionStatus::Running;
214 }
215 }
216
217 EventKind::FeatureStarted { feature_id } => {
218 feature_mut(state, feature_id)?.status = FeatureStatus::Active;
219 }
220
221 EventKind::FeatureProgress {
222 feature_id,
223 base_sha,
224 commits,
225 } => {
226 if feature_mut(state, feature_id)?.status != FeatureStatus::Active {
227 return Err(EngineError::InvalidState(format!(
228 "feature.progress for inactive feature '{feature_id}'"
229 )));
230 }
231 if state
232 .feature_base_shas
233 .get(feature_id)
234 .is_some_and(|existing| existing != base_sha)
235 {
236 return Err(EngineError::InvalidState(format!(
237 "feature.progress changed the baseline for '{feature_id}'"
238 )));
239 }
240 state
241 .feature_base_shas
242 .insert(feature_id.clone(), base_sha.clone());
243 record_feature_commits(feature_mut(state, feature_id)?, commits);
244 }
245
246 EventKind::WorkerSpawned {
247 run_id,
248 role,
249 feature_id,
250 milestone_id,
251 candidate,
252 executor_route: _,
253 sdk_session_id,
254 model,
255 backend,
256 quant,
257 weight_hash,
258 prompt_hash,
259 transcript_path,
260 } => {
261 if state.runs.contains_key(run_id) {
262 return Err(EngineError::InvalidState(format!(
263 "duplicate worker.spawned for run '{run_id}'"
264 )));
265 }
266 if let Some(mid) = milestone_id {
267 milestone_mut(state, mid)?; }
269 if let Some(fid) = feature_id {
270 let feature = feature_mut(state, fid)?;
271 feature.worker_runs.push(run_id.clone());
272 if feature.worker_runs.len() > 1 && candidate.is_none() {
278 feature.respawns += 1;
279 }
280 }
281 state.runs.insert(
282 run_id.clone(),
283 WorkerRun {
284 backend: *backend,
285 id: run_id.clone(),
286 role: *role,
287 feature_id: feature_id.clone(),
288 milestone_id: milestone_id.clone(),
289 candidate: candidate.clone(),
290 sdk_session_id: sdk_session_id.clone(),
291 model: model.clone(),
292 quant: quant.clone(),
293 weight_hash: weight_hash.clone(),
294 started_at: event.ts,
295 ended_at: None,
296 tokens: TokenUsage::default(),
297 cost_usd: None,
298 transcript_path: transcript_path.clone(),
299 result: None,
300 report: None,
301 prompt_hash: prompt_hash.clone(),
302 },
303 );
304 if state.mission.status == MissionStatus::Approved {
305 state.mission.status = MissionStatus::Running;
306 }
307 }
308
309 EventKind::WorkerMessage { run_id, .. } => {
310 run_mut(state, run_id)?; }
312
313 EventKind::WorkerEgressDenied { run_id, .. } => {
314 run_mut(state, run_id)?;
320 }
321
322 EventKind::WorkerCompleted {
323 run_id,
324 result,
325 tokens,
326 cost_usd,
327 report,
328 } => {
329 let run = run_mut(state, run_id)?;
330 run.result = Some(*result);
331 run.tokens = tokens.clone();
332 run.cost_usd = *cost_usd;
333 run.report = report.clone();
334 run.ended_at = Some(event.ts);
335 state.totals.add(tokens);
336 state.total_cost_usd += cost_usd.unwrap_or(0.0);
337 }
338
339 EventKind::FeatureCompleted {
340 feature_id,
341 commits,
342 } => {
343 let cumulative = state.feature_base_shas.contains_key(feature_id);
344 let feature = feature_mut(state, feature_id)?;
345 feature.status = FeatureStatus::Complete;
346 if cumulative {
347 record_feature_commits(feature, commits);
348 } else {
349 feature.commits.extend(commits.iter().cloned());
350 }
351 }
352
353 EventKind::FeatureFailed {
354 feature_id,
355 commits,
356 ..
357 } => {
358 let cumulative = state.feature_base_shas.contains_key(feature_id);
359 let feature = feature_mut(state, feature_id)?;
360 feature.status = FeatureStatus::Failed;
361 if cumulative {
367 record_feature_commits(feature, commits);
368 } else {
369 feature.commits.extend(commits.iter().cloned());
370 }
371 }
372
373 EventKind::FeatureSkipped { feature_id, .. } => {
374 feature_mut(state, feature_id)?.status = FeatureStatus::Skipped;
375 }
376
377 EventKind::MilestoneValidating { milestone_id } => {
378 milestone_mut(state, milestone_id)?.status = MilestoneStatus::Validating;
379 }
380
381 EventKind::ValidationFinding {
382 milestone_id,
383 run_id,
384 ..
385 } => {
386 milestone_mut(state, milestone_id)?;
391 if run_id != ENGINE_RUN_ID {
392 run_mut(state, run_id)?;
393 }
394 }
395
396 EventKind::ValidatorTamper {
397 milestone_id,
398 run_id,
399 ..
400 } => {
401 milestone_mut(state, milestone_id)?;
405 if run_id != ENGINE_RUN_ID {
406 run_mut(state, run_id)?;
407 }
408 }
409
410 EventKind::ValidationSnapshot { milestone_id, .. } => {
411 milestone_mut(state, milestone_id)?;
416 }
417
418 EventKind::ValidationConfirm {
419 milestone_id,
420 local_run_id,
421 confirm_run_id,
422 ..
423 } => {
424 milestone_mut(state, milestone_id)?;
434 run_mut(state, local_run_id)?;
435 run_mut(state, confirm_run_id)?;
436 }
437
438 EventKind::ValidationPtyTranscript { milestone_id, .. } => {
439 milestone_mut(state, milestone_id)?;
446 }
447
448 EventKind::GateResult { .. } => {
449 }
459
460 EventKind::HookGateFired { run_id, .. } => {
461 run_mut(state, run_id)?;
470 }
471
472 EventKind::DivergenceNoted {
473 unit, candidates, ..
474 } => {
475 feature_mut(state, unit)?;
483 for candidate in candidates {
484 run_mut(state, &candidate.run_id)?;
485 }
486 }
487
488 EventKind::DivergenceResolved { unit, .. } => {
489 feature_mut(state, unit)?;
495 state.resolved_divergence_units.insert(unit.clone());
496 }
497
498 EventKind::FixFeatureCreated {
499 milestone_id,
500 feature,
501 } => {
502 let ms = milestone_mut(state, milestone_id)?;
503 if let Some(existing) = ms.features.iter().find(|f| f.id == feature.id) {
504 if existing.title == feature.title
511 && existing.spec == feature.spec
512 && existing.validation_criteria == feature.validation_criteria
513 {
514 } else {
516 let idx = ms
534 .features
535 .iter()
536 .position(|f| f.id == feature.id)
537 .expect("found above");
538 let existing = &ms.features[idx];
539 let prior_started = matches!(
540 existing.status,
541 FeatureStatus::Active | FeatureStatus::Complete | FeatureStatus::Skipped
542 ) || !existing.commits.is_empty();
543 if prior_started {
544 return Err(EngineError::InvalidState(format!(
545 "duplicate fixfeature.created for feature '{}' with a different payload",
546 feature.id
547 )));
548 }
549 tracing::info!(
550 feature_id = %feature.id,
551 "fixfeature re-proposed before any work: folding as implicit supersession"
552 );
553 if ms.status == MilestoneStatus::Validating {
554 ms.fix_cycles += 1;
555 ms.status = MilestoneStatus::Active;
556 }
557 let mut successor = feature.clone();
558 successor.status = FeatureStatus::Pending;
559 ms.features[idx] = successor;
560 state.feature_base_shas.remove(&feature.id);
561 }
562 } else {
563 if ms.status == MilestoneStatus::Validating {
568 ms.fix_cycles += 1;
569 ms.status = MilestoneStatus::Active;
570 }
571 ms.features.push(feature.clone());
572 }
573 }
574
575 EventKind::TierEscalated { milestone_id, .. } => {
576 state.config.worker.backend = None;
577 state.config.worker.base_url = None;
578 state.config.worker.context_budget = None;
579 state.config.worker.temperature = None;
580 state.escalated_milestones += 1;
581 let ms = milestone_mut(state, milestone_id)?;
582 ms.status = MilestoneStatus::Active;
583 ms.fix_cycles = 0;
584 }
585
586 EventKind::WorkerEscalated { run_id, .. } => {
587 run_mut(state, run_id)?;
600 }
601
602 EventKind::QuestionOpened {
603 question_id,
604 role,
605 text,
606 options,
607 run_id,
608 feature_id,
609 milestone_id,
610 } => {
611 if question_id.trim().is_empty() {
622 return Err(EngineError::InvalidState(
623 "question.opened question id must not be empty".to_string(),
624 ));
625 }
626 if text.trim().is_empty() {
627 return Err(EngineError::InvalidState(format!(
628 "question.opened {question_id} text must not be empty"
629 )));
630 }
631 if let Some(run_id) = run_id {
632 run_mut(state, run_id)?;
633 }
634 if let Some(feature_id) = feature_id {
635 feature_mut(state, feature_id)?;
636 }
637 if let Some(milestone_id) = milestone_id {
638 milestone_mut(state, milestone_id)?;
639 }
640 if let Some(existing) = state
641 .pending_questions
642 .iter()
643 .find(|q| q.question_id == *question_id)
644 {
645 let identical = existing.role == *role
646 && existing.text == *text
647 && existing.options == *options
648 && existing.run_id == *run_id
649 && existing.feature_id == *feature_id
650 && existing.milestone_id == *milestone_id;
651 if !identical {
652 return Err(EngineError::InvalidState(format!(
653 "duplicate question.opened for '{question_id}' with a different payload"
654 )));
655 }
656 } else {
658 state.question_count += 1;
659 state.pending_questions.push(PendingQuestion {
660 question_id: question_id.clone(),
661 role: *role,
662 text: text.clone(),
663 options: options.clone(),
664 run_id: run_id.clone(),
665 feature_id: feature_id.clone(),
666 milestone_id: milestone_id.clone(),
667 });
668 }
669 }
670
671 EventKind::QuestionAnswered {
672 question_id,
673 answer,
674 ..
675 } => {
676 let question = take_pending_question(state, question_id, "question.answered")?;
685 state.pending_user_messages.push(format!(
686 "answer to question {} (\"{}\"): {}",
687 question.question_id, question.text, answer
688 ));
689 }
690
691 EventKind::QuestionCleared { question_id, .. } => {
692 take_pending_question(state, question_id, "question.cleared")?;
696 }
697
698 EventKind::MilestoneBlocked { milestone_id, .. } => {
699 milestone_mut(state, milestone_id)?.status = MilestoneStatus::Blocked;
700 state.mission.status = MissionStatus::Blocked;
701 }
702
703 EventKind::MilestoneUnblocked {
704 milestone_id,
705 validator_guidance,
706 ..
707 } => {
708 let ms = milestone_mut(state, milestone_id)?;
709 ms.status = MilestoneStatus::Active;
710 ms.validator_guidance = validator_guidance.clone();
713 state.mission.status = MissionStatus::Running;
714 }
715
716 EventKind::MilestoneCompleted { milestone_id, .. } => {
717 let ms = milestone_mut(state, milestone_id)?;
718 ms.status = MilestoneStatus::Complete;
719 ms.validator_guidance = None;
722 }
723
724 EventKind::MissionValidating {} => {
725 state.mission.status = MissionStatus::Validating;
726 }
727
728 EventKind::MissionPaused {} => {
729 state.mission.status = MissionStatus::Paused;
730 }
731
732 EventKind::MissionResumed {} => {
733 state.mission.status = MissionStatus::Running;
734 }
735
736 EventKind::UserMessage { text, .. } => {
737 state.pending_user_messages.push(text.clone());
738 }
739
740 EventKind::OrchestratorDecision { summary, .. } => {
741 state.recent_decisions.push(summary.clone());
742 while state.recent_decisions.len() > MAX_RECENT_DECISIONS {
743 state.recent_decisions.remove(0);
744 }
745 state.pending_user_messages.clear();
747 }
748
749 EventKind::SecretRedacted { .. } => {
750 }
753
754 EventKind::ConfigChanged { patch } => {
755 let mut value = serde_json::to_value(&state.config)?;
756 deep_merge(&mut value, patch);
757 state.config = serde_json::from_value(value).map_err(|e| {
758 EngineError::Config(format!("config.changed patch produced invalid config: {e}"))
759 })?;
760 }
761
762 EventKind::MissionCompleted {} => {
763 state.mission.status = MissionStatus::Complete;
764 }
765
766 EventKind::MissionFailed { .. } => {
767 state.mission.status = MissionStatus::Failed;
768 }
769
770 EventKind::MissionAbandoned { .. } => {
771 state.mission.status = MissionStatus::Abandoned;
772 }
773
774 EventKind::WorkspaceProvisioned { provider, .. } => {
775 state.workspace_provider = Some(provider.clone());
778 }
779
780 EventKind::WorkspaceReadinessReport { .. } => {
781 }
784
785 EventKind::WorkspaceTeardown { state: outcome, .. } => {
786 if let Some(outcome) = outcome {
792 state.workspace_lifecycle = Some(WorkspaceLifecycle {
793 state: outcome.clone(),
794 ts: event.ts,
795 });
796 }
797 }
798
799 EventKind::WorkspaceProviderPinned {
800 provider,
801 template,
802 version,
803 } => {
804 state.workspace_pin = Some(WorkspacePin {
808 provider: provider.clone(),
809 template: template.clone(),
810 version: version.clone(),
811 });
812 }
813
814 EventKind::StandardsResolved { .. }
815 | EventKind::StandardsDrifted { .. }
816 | EventKind::StandardsWaiverApproved { .. }
817 | EventKind::StandardsAttestationApproved { .. } => {
818 }
824 }
825
826 state.last_seq = event.seq;
827 Ok(())
828}
829
830const MAX_RECENT_DECISIONS: usize = 10;
832
833fn record_feature_commits(feature: &mut Feature, commits: &[String]) {
834 for commit in commits {
835 if !feature
836 .commits
837 .iter()
838 .any(|existing| existing.split_whitespace().next() == commit.split_whitespace().next())
839 {
840 feature.commits.push(commit.clone());
841 }
842 }
843}
844
845fn initial_state(event: &Event) -> Result<MissionState> {
846 let EventKind::MissionCreated {
847 goal,
848 base_branch,
849 mission_branch,
850 config,
851 } = &event.kind
852 else {
853 return Err(EngineError::InvalidState(format!(
854 "first event must be mission.created, found '{}'",
855 event.kind.type_name()
856 )));
857 };
858 Ok(MissionState {
859 feature_base_shas: BTreeMap::new(),
860 mission: Mission {
861 id: event.mission_id.clone(),
862 goal: goal.clone(),
863 validation_contract: Vec::new(),
864 milestones: Vec::new(),
865 status: MissionStatus::Planning,
866 created_at: event.ts,
867 base_branch: base_branch.clone(),
868 base_sha: None,
869 mission_branch: mission_branch.clone(),
870 command_grants: Vec::new(),
871 touch_set: Vec::new(),
872 deny_exceptions: Vec::new(),
873 egress_grants: Vec::new(),
874 standards_manifest: None,
875 reviewer_independence: None,
876 executor_route: crate::routing::seed_executor_route(config, goal),
881 },
882 runs: BTreeMap::new(),
883 totals: TokenUsage::default(),
884 total_cost_usd: 0.0,
885 pending_user_messages: Vec::new(),
886 recent_decisions: Vec::new(),
887 config: config.clone(),
888 latest_plan_revision: 0,
889 pending_revision: None,
890 pending_grant_request: None,
891 pending_questions: Vec::new(),
892 question_count: 0,
893 last_seq: event.seq,
894 escalated_milestones: 0,
895 local_executor_milestones: 0,
896 workspace_provider: None,
897 workspace_pin: None,
898 workspace_lifecycle: None,
899 resolved_divergence_units: BTreeSet::new(),
900 })
901}
902
903fn expect_pending_grant(
910 state: &MissionState,
911 kind: GrantKind,
912 command: &str,
913 event: &str,
914) -> Result<()> {
915 match &state.pending_grant_request {
916 Some(pending) if pending.kind == kind && pending.command == command => Ok(()),
917 Some(pending) => Err(EngineError::InvalidState(format!(
918 "{event} {kind:?} {command:?} does not match pending grant {:?} {:?}",
919 pending.kind, pending.command
920 ))),
921 None => Err(EngineError::InvalidState(format!(
922 "{event} with no pending grant request"
923 ))),
924 }
925}
926
927fn take_pending_question(
933 state: &mut MissionState,
934 question_id: &str,
935 event: &str,
936) -> Result<PendingQuestion> {
937 let Some(index) = state
938 .pending_questions
939 .iter()
940 .position(|q| q.question_id == question_id)
941 else {
942 return Err(EngineError::InvalidState(format!(
943 "{event} for question '{question_id}' that is not open"
944 )));
945 };
946 Ok(state.pending_questions.remove(index))
947}
948
949fn apply_revised_plan(state: &mut MissionState, plan: &Plan, revision: u32) -> Result<()> {
950 crate::contract_controls::validate(&plan.validation_contract)?;
951 if plan.reviewer_independence != state.mission.reviewer_independence {
952 return Err(EngineError::Config(
953 "plan.revised cannot replace the approved reviewerIndependence policy".into(),
954 ));
955 }
956 ensure_contract_extends(
957 &state.mission.validation_contract,
958 &plan.validation_contract,
959 )?;
960 ensure_strings_extend(
961 "commandGrants",
962 &state.mission.command_grants,
963 &plan.command_grants,
964 )?;
965 ensure_strings_extend("touchSet", &state.mission.touch_set, &plan.touch_set)?;
966
967 let completed_prefix = state
968 .mission
969 .milestones
970 .iter()
971 .position(|m| m.status != MilestoneStatus::Complete)
972 .unwrap_or(state.mission.milestones.len());
973 if plan.milestones.len() < completed_prefix {
974 return Err(EngineError::InvalidState(
975 "plan.revised drops completed milestones".to_string(),
976 ));
977 }
978
979 let mut revised_milestones = Vec::new();
980 for (idx, existing) in state
981 .mission
982 .milestones
983 .iter()
984 .enumerate()
985 .take(completed_prefix)
986 {
987 let Some(plan_milestone) = plan.milestones.get(idx) else {
988 return Err(EngineError::InvalidState(format!(
989 "plan.revised drops completed milestone '{}'",
990 existing.title
991 )));
992 };
993 if !completed_milestone_matches(existing, plan_milestone) {
994 return Err(EngineError::InvalidState(format!(
995 "plan.revised alters completed milestone '{}'",
996 existing.title
997 )));
998 }
999 revised_milestones.push(existing.clone());
1000 }
1001
1002 for (idx, plan_milestone) in plan.milestones.iter().enumerate().skip(completed_prefix) {
1003 if let Some(existing) = state.mission.milestones.get(idx) {
1004 revised_milestones.push(merge_revised_milestone(existing, plan_milestone, revision));
1005 } else {
1006 revised_milestones.push(new_plan_milestone(idx, plan_milestone));
1007 }
1008 }
1009
1010 state.mission.goal = plan.goal.clone();
1011 state.mission.validation_contract = plan.validation_contract.clone();
1012 state.mission.command_grants = plan.command_grants.clone();
1013 state.mission.touch_set = plan.touch_set.clone();
1014 state.mission.milestones = revised_milestones;
1022 Ok(())
1023}
1024
1025pub fn dry_run_revised_plan(state: &MissionState, plan: &Plan, revision: u32) -> Result<()> {
1032 apply_revised_plan(&mut state.clone(), plan, revision)
1033}
1034
1035fn ensure_contract_extends(existing: &[Assertion], revised: &[Assertion]) -> Result<()> {
1036 for old in existing {
1037 let Some(new) = revised.iter().find(|a| a.id == old.id) else {
1038 return Err(EngineError::InvalidState(format!(
1039 "plan.revised removes validation assertion '{}'",
1040 old.id
1041 )));
1042 };
1043 if old.statement != new.statement
1044 || old.check != new.check
1045 || old.command != new.command
1046 || old.negative_control != new.negative_control
1047 {
1048 return Err(EngineError::InvalidState(format!(
1049 "plan.revised weakens or changes validation assertion '{}'",
1050 old.id
1051 )));
1052 }
1053 }
1054 Ok(())
1055}
1056
1057fn ensure_strings_extend(label: &str, existing: &[String], revised: &[String]) -> Result<()> {
1058 for old in existing {
1059 if !revised.iter().any(|new| new == old) {
1060 return Err(EngineError::InvalidState(format!(
1061 "plan.revised removes {label} entry '{old}'"
1062 )));
1063 }
1064 }
1065 Ok(())
1066}
1067
1068fn completed_milestone_matches(existing: &Milestone, revised: &PlanMilestone) -> bool {
1069 existing.title.trim() == revised.title.trim()
1070 && completed_features_match(&existing.features, &revised.features)
1071}
1072
1073pub(crate) fn completed_features_match(existing: &[Feature], revised: &[PlanFeature]) -> bool {
1084 existing.len() == revised.len()
1085 && existing.iter().zip(revised).all(|(a, b)| {
1086 a.title.trim() == b.title.trim()
1087 && a.spec.trim() == b.spec.trim()
1088 && a.validation_criteria.len() == b.validation_criteria.len()
1089 && a.validation_criteria
1090 .iter()
1091 .zip(&b.validation_criteria)
1092 .all(|(x, y)| x.trim() == y.trim())
1093 })
1094}
1095
1096fn merge_revised_milestone(
1097 existing: &Milestone,
1098 revised: &PlanMilestone,
1099 revision: u32,
1100) -> Milestone {
1101 let mut features = Vec::new();
1102 let mut new_count = 0usize;
1103 for feature in &existing.features {
1104 let matching = revised
1105 .features
1106 .iter()
1107 .find(|candidate| norm_title(&candidate.title) == norm_title(&feature.title));
1108 match (feature.status, matching) {
1109 (FeatureStatus::Pending, Some(plan_feature)) => {
1110 let mut updated = feature.clone();
1111 updated.title = plan_feature.title.clone();
1112 updated.spec = plan_feature.spec.clone();
1113 updated.validation_criteria = plan_feature.validation_criteria.clone();
1114 features.push(updated);
1115 }
1116 (FeatureStatus::Pending, None) => {
1117 let mut skipped = feature.clone();
1118 skipped.status = FeatureStatus::Skipped;
1119 features.push(skipped);
1120 }
1121 _ => features.push(feature.clone()),
1122 }
1123 }
1124
1125 for plan_feature in &revised.features {
1126 let already_present = existing
1127 .features
1128 .iter()
1129 .any(|feature| norm_title(&feature.title) == norm_title(&plan_feature.title));
1130 if !already_present {
1131 new_count += 1;
1132 features.push(Feature {
1133 id: format!("{}-rev-{revision}-{new_count}", existing.id),
1134 title: plan_feature.title.clone(),
1135 spec: plan_feature.spec.clone(),
1136 validation_criteria: plan_feature.validation_criteria.clone(),
1137 origin: FeatureOrigin::Plan,
1138 status: FeatureStatus::Pending,
1139 worker_runs: Vec::new(),
1140 commits: Vec::new(),
1141 respawns: 0,
1142 });
1143 }
1144 }
1145
1146 Milestone {
1147 id: existing.id.clone(),
1148 title: revised.title.clone(),
1149 features,
1150 status: existing.status,
1151 fix_cycles: existing.fix_cycles,
1152 start_sha: existing.start_sha.clone(),
1153 validator_guidance: existing.validator_guidance.clone(),
1156 }
1157}
1158
1159fn new_plan_milestone(idx: usize, plan_milestone: &PlanMilestone) -> Milestone {
1160 Milestone {
1161 id: format!("ms-{}", idx + 1),
1162 title: plan_milestone.title.clone(),
1163 features: plan_milestone
1164 .features
1165 .iter()
1166 .enumerate()
1167 .map(|(fi, feature)| Feature {
1168 id: format!("f-{}-{}", idx + 1, fi + 1),
1169 title: feature.title.clone(),
1170 spec: feature.spec.clone(),
1171 validation_criteria: feature.validation_criteria.clone(),
1172 origin: FeatureOrigin::Plan,
1173 status: FeatureStatus::Pending,
1174 worker_runs: Vec::new(),
1175 commits: Vec::new(),
1176 respawns: 0,
1177 })
1178 .collect(),
1179 status: MilestoneStatus::Pending,
1180 fix_cycles: 0,
1181 start_sha: None,
1182 validator_guidance: None,
1183 }
1184}
1185
1186fn norm_title(title: &str) -> String {
1187 title.trim().to_ascii_lowercase()
1188}
1189
1190fn milestone_mut<'a>(state: &'a mut MissionState, id: &str) -> Result<&'a mut Milestone> {
1191 state
1192 .mission
1193 .milestones
1194 .iter_mut()
1195 .find(|m| m.id == id)
1196 .ok_or_else(|| {
1197 EngineError::InvalidState(format!("event references unknown milestone '{id}'"))
1198 })
1199}
1200
1201fn feature_mut<'a>(state: &'a mut MissionState, id: &str) -> Result<&'a mut Feature> {
1202 state
1203 .mission
1204 .milestones
1205 .iter_mut()
1206 .flat_map(|m| m.features.iter_mut())
1207 .find(|f| f.id == id)
1208 .ok_or_else(|| {
1209 EngineError::InvalidState(format!("event references unknown feature '{id}'"))
1210 })
1211}
1212
1213fn run_mut<'a>(state: &'a mut MissionState, id: &str) -> Result<&'a mut WorkerRun> {
1214 state
1215 .runs
1216 .get_mut(id)
1217 .ok_or_else(|| EngineError::InvalidState(format!("event references unknown run '{id}'")))
1218}
1219
1220pub(crate) fn deep_merge(base: &mut serde_json::Value, patch: &serde_json::Value) {
1225 use serde_json::Value;
1226 match (base, patch) {
1227 (Value::Object(base_map), Value::Object(patch_map)) => {
1228 for (key, patch_value) in patch_map {
1229 deep_merge(
1230 base_map.entry(key.clone()).or_insert(Value::Null),
1231 patch_value,
1232 );
1233 }
1234 }
1235 (base_slot, patch_value) => *base_slot = patch_value.clone(),
1236 }
1237}
1238
1239pub fn write_snapshot(state: &MissionState, path: &Path) -> Result<()> {
1246 let file_name = path.file_name().ok_or_else(|| {
1247 EngineError::InvalidState(format!("snapshot path {} has no file name", path.display()))
1248 })?;
1249 let tmp_name = format!("{}.tmp", file_name.to_string_lossy());
1250 let (parent, pinned_name) = crate::paths::open_parent_nofollow(path)?;
1251
1252 for (name, display) in [
1256 (pinned_name.as_os_str(), path.to_path_buf()),
1257 (
1258 std::ffi::OsStr::new(&tmp_name),
1259 path.with_file_name(&tmp_name),
1260 ),
1261 ] {
1262 match parent.symlink_metadata(name) {
1263 Ok(metadata) if metadata.file_type().is_file() => {}
1264 Ok(_) => {
1265 return Err(EngineError::InvalidState(format!(
1266 "refusing snapshot write through non-regular path {}",
1267 display.display()
1268 )))
1269 }
1270 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1271 Err(error) => return Err(error.into()),
1272 }
1273 }
1274
1275 let json = serde_json::to_string_pretty(state)?;
1276 {
1277 use cap_fs_ext::OpenOptionsFollowExt as _;
1278 use cap_primitives::fs::FollowSymlinks;
1279 let mut options = cap_std::fs::OpenOptions::new();
1280 options
1281 .write(true)
1282 .create(true)
1283 .truncate(true)
1284 .follow(FollowSymlinks::No);
1285 let mut file = parent.open_with(&tmp_name, &options)?.into_std();
1286 std::io::Write::write_all(&mut file, json.as_bytes())?;
1287 file.sync_data()?;
1288 }
1289 parent.rename(&tmp_name, &parent, &pinned_name)?;
1290 Ok(())
1291}
1292
1293pub fn read_snapshot(path: &Path) -> Result<MissionState> {
1300 use std::io::Read;
1301 let mut content = String::new();
1302 crate::paths::open_read_nofollow(path)?.read_to_string(&mut content)?;
1303 Ok(serde_json::from_str(&content)?)
1304}
1305
1306#[cfg(test)]
1307mod negative_control_tests {
1308 use super::*;
1309
1310 fn event(seq: u64, kind: EventKind) -> Event {
1311 Event {
1312 seq,
1313 ts: chrono::Utc::now(),
1314 mission_id: "m-controls".into(),
1315 kind,
1316 }
1317 }
1318
1319 fn plan() -> Plan {
1320 serde_json::from_value(serde_json::json!({
1321 "goal": "control tests",
1322 "validationContract": [{
1323 "id": "a-1", "statement": "reject the defect", "check": "command", "command": "sh check.sh",
1324 "negativeControl": {
1325 "checkerFiles": [{"path": "check.sh", "content": "check"}],
1326 "validFiles": [{"path": "value.txt", "content": "valid"}],
1327 "defectiveFiles": [{"path": "value.txt", "content": "defect"}],
1328 "expectedFailure": "wrong-value"
1329 }
1330 }],
1331 "milestones": [{"title": "one", "features": [{"title": "change", "spec": "change", "validationCriteria": []}]}]
1332 })).unwrap()
1333 }
1334
1335 fn approved(plan: &Plan) -> Result<MissionState> {
1336 fold(&[
1337 event(
1338 1,
1339 EventKind::MissionCreated {
1340 goal: plan.goal.clone(),
1341 base_branch: "main".into(),
1342 mission_branch: "kranz/mission-m-controls".into(),
1343 config: MissionConfig::default(),
1344 },
1345 ),
1346 event(
1347 2,
1348 EventKind::PlanApproved {
1349 plan: plan.clone(),
1350 base_sha: Some("base".into()),
1351 },
1352 ),
1353 ])
1354 }
1355
1356 #[test]
1357 fn negative_control_replay_and_revision_validation_agree() {
1358 let plan = plan();
1359 let state = approved(&plan).unwrap();
1360 dry_run_revised_plan(&state, &plan, 1).unwrap();
1361 let mut added = plan.clone();
1362 let mut extra = added.validation_contract[0].clone();
1363 extra.id = "a-2".into();
1364 added.validation_contract.push(extra);
1365 crate::planning::validate_revised_plan_for_gate(&state.mission, &added).unwrap();
1366 dry_run_revised_plan(&state, &added, 1).unwrap();
1367 for mutation in ["removed", "changed", "invalid-new"] {
1368 let mut revised = plan.clone();
1369 match mutation {
1370 "removed" => revised.validation_contract[0].negative_control = None,
1371 "changed" => {
1372 revised.validation_contract[0]
1373 .negative_control
1374 .as_mut()
1375 .unwrap()
1376 .expected_failure = "other-defect".into()
1377 }
1378 _ => {
1379 revised = added.clone();
1380 revised.validation_contract[1]
1381 .negative_control
1382 .as_mut()
1383 .unwrap()
1384 .timeout_seconds = 0;
1385 }
1386 }
1387 assert!(
1388 crate::planning::validate_revised_plan_for_gate(&state.mission, &revised).is_err(),
1389 "pre-emit {mutation}"
1390 );
1391 assert!(
1392 dry_run_revised_plan(&state, &revised, 1).is_err(),
1393 "fold {mutation}"
1394 );
1395 }
1396 let mut malformed = plan;
1397 malformed.validation_contract[0]
1398 .negative_control
1399 .as_mut()
1400 .unwrap()
1401 .timeout_seconds = 0;
1402 assert!(
1403 approved(&malformed).is_err(),
1404 "new malformed control cannot enter via plan.approved replay"
1405 );
1406 }
1407
1408 #[test]
1409 fn negative_control_checks_preserve_legacy_pty_revision_replay() {
1410 let mut old = plan();
1411 old.validation_contract = serde_json::from_value(serde_json::json!([{
1412 "id": "pty", "statement": "legacy terminal check", "check": "pty-script",
1413 "ptyScript": {"command": "old", "steps": []}
1414 }]))
1415 .unwrap();
1416 let state = approved(&old).unwrap();
1417 assert!(state.mission.validation_contract[0]
1418 .negative_control
1419 .is_none());
1420 let mut revised = old;
1421 revised.validation_contract[0]
1422 .pty_script
1423 .as_mut()
1424 .unwrap()
1425 .command = "new".into();
1426 crate::planning::validate_revised_plan_for_gate(&state.mission, &revised).unwrap();
1427 dry_run_revised_plan(&state, &revised, 1).unwrap();
1428 }
1429}
1430
1431#[cfg(test)]
1432mod executor_tier_tests {
1433 use super::*;
1434 use crate::events::EventKind;
1435
1436 fn created_event(config: MissionConfig) -> Event {
1437 Event {
1438 seq: 1,
1439 ts: chrono::Utc::now(),
1440 mission_id: "m-test".to_string(),
1441 kind: EventKind::MissionCreated {
1442 goal: "ship the thing".to_string(),
1443 base_branch: "main".to_string(),
1444 mission_branch: "kranz/mission-m-test".to_string(),
1445 config,
1446 },
1447 }
1448 }
1449
1450 #[test]
1451 fn executor_routing_applies_local_worker_backend_folds_to_local_tier() {
1452 let mut config = MissionConfig::default();
1453 config.worker.backend = Some("local".to_string());
1454
1455 let state = fold(&[created_event(config)]).unwrap();
1456
1457 assert_eq!(state.executor_tier(), ExecutorTier::Local);
1458 }
1459
1460 #[test]
1461 fn executor_routing_applies_non_local_worker_backend_folds_to_frontier_tier() {
1462 let mut config = MissionConfig::default();
1463 config.worker.backend = Some("codex".to_string());
1464
1465 let state = fold(&[created_event(config)]).unwrap();
1466
1467 assert_eq!(state.executor_tier(), ExecutorTier::Frontier);
1468 }
1469
1470 #[test]
1471 fn validator_stays_frontier_regardless_of_worker_routing() {
1472 let mut config = MissionConfig::default();
1473 config.worker.backend = Some("local".to_string());
1474
1475 let state = fold(&[created_event(config)]).unwrap();
1476
1477 assert_eq!(
1478 state.config.backend_kind(Role::ValidatorScrutiny),
1479 BackendKind::Claude
1480 );
1481 assert_eq!(
1482 state.config.backend_kind(Role::ValidatorFunctional),
1483 BackendKind::Claude
1484 );
1485 }
1486}
1487
1488#[cfg(test)]
1489mod hook_gate_projection_tests {
1490 use super::*;
1491 use crate::events::EventKind;
1492
1493 fn event(seq: u64, kind: EventKind) -> Event {
1494 Event {
1495 seq,
1496 ts: chrono::Utc::now(),
1497 mission_id: "m-test".to_string(),
1498 kind,
1499 }
1500 }
1501
1502 fn spawned(seq: u64, run_id: &str) -> Event {
1503 event(
1504 seq,
1505 EventKind::WorkerSpawned {
1506 backend: None,
1507 run_id: run_id.to_string(),
1508 role: Role::Worker,
1509 feature_id: None,
1510 milestone_id: None,
1511 candidate: None,
1512 executor_route: None,
1513 sdk_session_id: "s-1".to_string(),
1514 model: "m".to_string(),
1515 quant: "n/a".to_string(),
1516 weight_hash: None,
1517 prompt_hash: "h".to_string(),
1518 transcript_path: "runs/r-1.jsonl".to_string(),
1519 },
1520 )
1521 }
1522
1523 #[test]
1528 fn hook_gate_projection_event_folds_record_only() {
1529 let created = event(
1530 1,
1531 EventKind::MissionCreated {
1532 goal: "g".to_string(),
1533 base_branch: "main".to_string(),
1534 mission_branch: "kranz/mission-m-test".to_string(),
1535 config: MissionConfig::default(),
1536 },
1537 );
1538 let fired = event(
1539 3,
1540 EventKind::HookGateFired {
1541 run_id: "r-1".to_string(),
1542 gate: "out-of-contract-write".to_string(),
1543 hook_event: "PreToolUse".to_string(),
1544 tool: "Write".to_string(),
1545 subject: "docs/oops.md".to_string(),
1546 verdict: "blocked".to_string(),
1547 detail: Some("outside the touch set".to_string()),
1548 },
1549 );
1550
1551 let with_hook = fold(&[created.clone(), spawned(2, "r-1"), fired]).unwrap();
1552 let without_hook = fold(&[created, spawned(2, "r-1")]).unwrap();
1553
1554 assert_eq!(with_hook.mission.status, without_hook.mission.status);
1557 assert_eq!(with_hook.runs.len(), 1);
1558 assert!(with_hook.runs["r-1"].result.is_none());
1559 assert_eq!(
1560 with_hook.mission.milestones.len(),
1561 without_hook.mission.milestones.len()
1562 );
1563 }
1564
1565 #[test]
1569 fn hook_gate_projection_event_with_unknown_run_is_refused() {
1570 let created = event(
1571 1,
1572 EventKind::MissionCreated {
1573 goal: "g".to_string(),
1574 base_branch: "main".to_string(),
1575 mission_branch: "kranz/mission-m-test".to_string(),
1576 config: MissionConfig::default(),
1577 },
1578 );
1579 let bogus = event(
1580 2,
1581 EventKind::HookGateFired {
1582 run_id: "no-such-run".to_string(),
1583 gate: "out-of-contract-write".to_string(),
1584 hook_event: "PreToolUse".to_string(),
1585 tool: "Write".to_string(),
1586 subject: "docs/oops.md".to_string(),
1587 verdict: "blocked".to_string(),
1588 detail: None,
1589 },
1590 );
1591 assert!(fold(&[created, bogus]).is_err());
1592 }
1593}
1594
1595#[cfg(test)]
1596mod routing_abstraction_tests {
1597 use super::*;
1598 use crate::events::EventKind;
1599
1600 fn event(seq: u64, kind: EventKind) -> Event {
1601 Event {
1602 seq,
1603 ts: chrono::Utc::now(),
1604 mission_id: "m-test".to_string(),
1605 kind,
1606 }
1607 }
1608
1609 fn created_with_local_worker() -> Event {
1610 let mut config = MissionConfig::default();
1611 config.worker.backend = Some("local".to_string());
1612 event(
1613 1,
1614 EventKind::MissionCreated {
1615 goal: "g".to_string(),
1616 base_branch: "main".to_string(),
1617 mission_branch: "kranz/mission-m-test".to_string(),
1618 config,
1619 },
1620 )
1621 }
1622
1623 fn spawned(seq: u64, run_id: &str) -> Event {
1624 event(
1625 seq,
1626 EventKind::WorkerSpawned {
1627 backend: None,
1628 run_id: run_id.to_string(),
1629 role: Role::Worker,
1630 feature_id: None,
1631 milestone_id: None,
1632 candidate: None,
1633 executor_route: None,
1634 sdk_session_id: "s-1".to_string(),
1635 model: "m".to_string(),
1636 quant: "n/a".to_string(),
1637 weight_hash: None,
1638 prompt_hash: "h".to_string(),
1639 transcript_path: "runs/r-1.jsonl".to_string(),
1640 },
1641 )
1642 }
1643
1644 #[test]
1652 fn routing_abstraction_escalation_folds_record_only_leaving_validators_untouched() {
1653 let escalated = event(
1654 3,
1655 EventKind::WorkerEscalated {
1656 run_id: "r-1".to_string(),
1657 feature_id: "f-1-1".to_string(),
1658 from: ExecutorTier::Local,
1659 to: ExecutorTier::Frontier,
1660 reason: "spec ambiguity beyond my confidence".to_string(),
1661 },
1662 );
1663
1664 let with = fold(&[created_with_local_worker(), spawned(2, "r-1"), escalated]).unwrap();
1665 let without = fold(&[created_with_local_worker(), spawned(2, "r-1")]).unwrap();
1666
1667 assert_eq!(with.config, without.config);
1671 assert_eq!(
1672 with.config.backend_kind(Role::ValidatorScrutiny),
1673 BackendKind::Claude
1674 );
1675 assert_eq!(
1676 with.config.backend_kind(Role::ValidatorFunctional),
1677 BackendKind::Claude
1678 );
1679 assert_eq!(with.executor_tier(), ExecutorTier::Local);
1682 assert_eq!(with.mission.status, without.mission.status);
1685 assert_eq!(with.runs.len(), without.runs.len());
1686 assert_eq!(with.escalated_milestones, without.escalated_milestones);
1687 assert_eq!(
1688 with.local_executor_milestones,
1689 without.local_executor_milestones
1690 );
1691 }
1692
1693 #[test]
1697 fn routing_abstraction_escalation_with_unknown_run_is_refused() {
1698 let bogus = event(
1699 2,
1700 EventKind::WorkerEscalated {
1701 run_id: "no-such-run".to_string(),
1702 feature_id: "f-1-1".to_string(),
1703 from: ExecutorTier::Local,
1704 to: ExecutorTier::Frontier,
1705 reason: "r".to_string(),
1706 },
1707 );
1708 let mut config = MissionConfig::default();
1709 config.worker.backend = Some("local".to_string());
1710 let created = event(
1711 1,
1712 EventKind::MissionCreated {
1713 goal: "g".to_string(),
1714 base_branch: "main".to_string(),
1715 mission_branch: "kranz/mission-m-test".to_string(),
1716 config,
1717 },
1718 );
1719 assert!(fold(&[created, bogus]).is_err());
1720 }
1721}