@matt82198/aesop 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -109,15 +109,32 @@ def _quote_arg(s: str) -> str:
109
109
  str: properly quoted argument safe for shell execution on this OS
110
110
  """
111
111
  if os.name == 'nt':
112
- # Windows (cmd.exe): use subprocess.list2cmdline semantics.
113
- # Double quotes are the safe quoting mechanism; embed quotes are escaped
114
- # with backslash, and backslashes before quotes are escaped.
115
- # For safety, we wrap in double quotes and escape embedded quotes/backslashes.
112
+ # Windows: forced-quote variant of the MS C runtime argv rules
113
+ # (subprocess.list2cmdline semantics). RS3-W N6: only backslashes
114
+ # that precede a double quote (or the closing quote) are doubled --
115
+ # doubling EVERY backslash turned `src\util.py` into `src\\util.py`
116
+ # and broke `git add` pathspecs for Windows-separator paths.
116
117
  if not s:
117
118
  return '""'
118
- # Escape backslashes before quotes, then escape quotes
119
- escaped = s.replace('\\', '\\\\').replace('"', '\\"')
120
- return f'"{escaped}"'
119
+ out = ['"']
120
+ pending_backslashes = 0
121
+ for ch in s:
122
+ if ch == '\\':
123
+ pending_backslashes += 1
124
+ elif ch == '"':
125
+ # Backslashes before a quote are doubled, then the quote
126
+ # itself is escaped.
127
+ out.append('\\' * (pending_backslashes * 2 + 1))
128
+ out.append('"')
129
+ pending_backslashes = 0
130
+ else:
131
+ out.append('\\' * pending_backslashes)
132
+ out.append(ch)
133
+ pending_backslashes = 0
134
+ # Backslashes before the CLOSING quote must be doubled.
135
+ out.append('\\' * (pending_backslashes * 2))
136
+ out.append('"')
137
+ return ''.join(out)
121
138
  else:
122
139
  # POSIX: shlex.quote handles all cases safely
123
140
  return shlex.quote(s)
@@ -277,6 +294,32 @@ def _journal_key_for_item(item: Dict[str, Any]) -> str:
277
294
  # Wave Recovery: Journal and Resume Support
278
295
  # ========================================================================
279
296
 
297
+ def _item_fingerprint(item: Dict[str, Any]) -> str:
298
+ """Stable identity fingerprint of a manifest item (RS3-W N10).
299
+
300
+ Journal entries persist across waves in state_dir. Without an identity
301
+ check, a NEW tracker item that reuses a prior wave's slug would inherit
302
+ that stale entry's verified=True and be silently skipped from build.
303
+ The fingerprint binds a journal entry to the item CONTENT (slug, prompt,
304
+ ownsFiles, testCmd), so resume-skip only applies to the same work.
305
+
306
+ Args:
307
+ item: manifest item dict.
308
+
309
+ Returns:
310
+ str: hex sha1 over the canonical identity fields.
311
+ """
312
+ basis = {
313
+ "slug": item.get("slug"),
314
+ "prompt": item.get("prompt"),
315
+ "ownsFiles": sorted(str(f) for f in (item.get("ownsFiles") or [])),
316
+ "testCmd": item.get("testCmd"),
317
+ }
318
+ return hashlib.sha1(
319
+ json.dumps(basis, sort_keys=True, default=str).encode("utf-8")
320
+ ).hexdigest()
321
+
322
+
280
323
  def _write_journal_entry(state_dir: str, slug: str, phase: str, data: Dict[str, Any], repo: str = None) -> None:
281
324
  """Write a journal entry for an item's progress.
282
325
 
@@ -313,11 +356,22 @@ def _write_journal_entry(state_dir: str, slug: str, phase: str, data: Dict[str,
313
356
  **data,
314
357
  }
315
358
 
359
+ tmp_file = journal_file.with_name(journal_file.name + ".tmp")
316
360
  try:
317
- journal_file.write_text(json.dumps(entry, default=str) + "\n")
361
+ # ATOMIC write (RS3-W N10): serialize first, write to a sibling temp
362
+ # file, then os.replace into place. A crash mid-write can no longer
363
+ # leave a torn/malformed entry at the journal path (a torn entry is
364
+ # silently skipped on load, losing the item's recovery state).
365
+ payload = json.dumps(entry, default=str) + "\n"
366
+ tmp_file.write_text(payload)
367
+ os.replace(str(tmp_file), str(journal_file))
318
368
  except Exception:
319
369
  # Fail-closed: if journal write fails, continue without journaling.
320
- pass
370
+ try:
371
+ if tmp_file.exists():
372
+ tmp_file.unlink()
373
+ except Exception:
374
+ pass
321
375
 
322
376
 
323
377
  def _load_journal_state(state_dir: str) -> Dict[str, Dict[str, Any]]:
@@ -402,11 +456,17 @@ def _release_stale_leases(state_dir: str, journal_state: Dict[str, Dict[str, Any
402
456
 
403
457
  event_store = store.EventStore(str(db_path))
404
458
 
405
- for slug, entry in journal_state.items():
459
+ # RS3-W N4: journal_state KEYS are (repo, slug) tuples, but claims
460
+ # are made with resource=<slug string> (see build_item's try_claim).
461
+ # Releasing with the tuple never matched a claim, so a crashed
462
+ # instance's lease persisted forever. Release with the SAME key
463
+ # shape claims use: the entry's slug string.
464
+ for entry in journal_state.values():
406
465
  old_instance_id = entry.get("instance_id")
407
- if old_instance_id:
466
+ resource = entry.get("slug")
467
+ if old_instance_id and resource:
408
468
  try:
409
- coordination.release(event_store, resource=slug, instance_id=old_instance_id)
469
+ coordination.release(event_store, resource=resource, instance_id=old_instance_id)
410
470
  except Exception:
411
471
  # Ignore release errors; fail-closed.
412
472
  pass
@@ -415,6 +475,448 @@ def _release_stale_leases(state_dir: str, journal_state: Dict[str, Dict[str, Any
415
475
  pass
416
476
 
417
477
 
478
+ # ========================================================================
479
+ # RS5: claim-gate lifecycle (ttl sized to the work, fence before repair/
480
+ # ship, hold across the full lifecycle, exactly-once release)
481
+ # ========================================================================
482
+
483
+ # F1a: the claim TTL must comfortably cover the WORK it protects (build +
484
+ # bounded repair + ship), not coordination's 300s default -- a single real
485
+ # build outlives 300s, letting a second instance reclaim a LIVE slug
486
+ # mid-build (double-dispatch). Sized from the driver's command timeout with
487
+ # a generous multiple and a sane floor; the fence (F1b) closes the window
488
+ # even when a build outruns this ttl.
489
+ _CLAIM_TTL_FLOOR_S = 3600.0 # never below 1h, even for fast drivers
490
+ _CLAIM_TTL_TIMEOUT_MULTIPLE = 10.0 # covers build + repair rounds + ship
491
+
492
+
493
+ def _claim_ttl_for_driver(driver: Any) -> float:
494
+ """Derive the per-item claim TTL from the driver's command timeout.
495
+
496
+ Checks the public and private timeout knobs used by the concrete
497
+ drivers (CodexDriver: command_timeout_s/_command_timeout_s;
498
+ ClaudeCodeDriver: _timeout_s). Unusable values fall back to the floor.
499
+ """
500
+ best = 0.0
501
+ for attr in (
502
+ "command_timeout_s", "_command_timeout_s", "timeout_s", "_timeout_s"
503
+ ):
504
+ try:
505
+ value = getattr(driver, attr, None)
506
+ if value is None:
507
+ continue
508
+ value = float(value)
509
+ except (TypeError, ValueError):
510
+ continue
511
+ if value > best:
512
+ best = value
513
+ return max(_CLAIM_TTL_FLOOR_S, best * _CLAIM_TTL_TIMEOUT_MULTIPLE)
514
+
515
+
516
+ class _ClaimContext:
517
+ """Per-wave claim registry: acquire in build, fence before repair/ship,
518
+ release exactly once at the true end of the lifecycle (RS5 F1/F3).
519
+
520
+ - acquire(slug): coordination.try_claim with the wave-level instance id
521
+ and the work-sized ttl. Exceptions propagate so build_item keeps its
522
+ fail-closed SKIP (RS3-W N5).
523
+ - fence_ok(slug): True unless WE claimed the slug and can no longer
524
+ prove we hold it (ttl lapsed / reclaimed / read error). Items this
525
+ wave never claimed (journal-resumed, no state_dir, no coordination
526
+ module) always pass: single-instance behavior is unchanged.
527
+ - release_all(): pops each held slug exactly once and best-effort
528
+ releases it; called from run_wave's finally on EVERY exit path
529
+ (success, abort, exception) -- never from build_item, so the claim
530
+ survives Phase 5 repair and Phase 7 ship.
531
+ """
532
+
533
+ def __init__(self, state_dir: Optional[str], instance_id: str, ttl: float):
534
+ self.state_dir = state_dir
535
+ self.instance_id = instance_id
536
+ self.ttl = ttl
537
+ self._held: Dict[str, bool] = {}
538
+ self._lock = threading.Lock()
539
+
540
+ def _enabled(self) -> bool:
541
+ return coordination is not None and self.state_dir is not None
542
+
543
+ def _event_store(self):
544
+ from state_store import store
545
+ return store.EventStore(str(Path(self.state_dir) / "state.db"))
546
+
547
+ def acquire(self, slug: str) -> bool:
548
+ """Claim slug for this wave; True only if the claim is held."""
549
+ if not self._enabled():
550
+ return False
551
+ won = coordination.try_claim(
552
+ self._event_store(),
553
+ resource=slug,
554
+ instance_id=self.instance_id,
555
+ ttl=self.ttl,
556
+ )
557
+ if won:
558
+ with self._lock:
559
+ self._held[slug] = True
560
+ return bool(won)
561
+
562
+ def holds(self, slug: str) -> bool:
563
+ with self._lock:
564
+ return slug in self._held
565
+
566
+ def fence_ok(self, slug: str) -> bool:
567
+ """FENCE (F1b): may this wave still ship/re-dispatch slug?
568
+
569
+ Fail-closed: if we claimed the slug but cannot prove we still hold
570
+ it, the answer is False -- never double-ship on a lapsed claim.
571
+ """
572
+ if not self._enabled() or not self.holds(slug):
573
+ return True
574
+ try:
575
+ return (
576
+ coordination.current_holder(self._event_store(), slug)
577
+ == self.instance_id
578
+ )
579
+ except Exception:
580
+ return False
581
+
582
+ def release_all(self) -> None:
583
+ """Release every held claim exactly once (idempotent by pop)."""
584
+ with self._lock:
585
+ slugs = list(self._held)
586
+ self._held.clear()
587
+ if coordination is None or self.state_dir is None:
588
+ return
589
+ for slug in slugs:
590
+ try:
591
+ coordination.release(
592
+ self._event_store(),
593
+ resource=slug,
594
+ instance_id=self.instance_id,
595
+ )
596
+ except Exception:
597
+ # Best-effort: TTL expiry is the backstop for a failed
598
+ # release (never raise out of run_wave's finally).
599
+ pass
600
+
601
+
602
+ def _is_live_orchestrator_backend(backend: Any) -> bool:
603
+ """True when a passed orchestrator backend is a REAL swapped seat.
604
+
605
+ None and the null HarnessOrchestratorBackend both mean "the live harness
606
+ is the orchestrator" (the no-op default): the wave engine must behave
607
+ byte-identically to pre-HS-2 in that case.
608
+ """
609
+ if backend is None:
610
+ return False
611
+ try:
612
+ from orchestrator_backend import HarnessOrchestratorBackend
613
+ except ImportError:
614
+ # Cannot classify; an explicitly passed backend is treated as live
615
+ # (fail loud downstream rather than silently ignoring the config).
616
+ return True
617
+ return not isinstance(backend, HarnessOrchestratorBackend)
618
+
619
+
620
+ def _seat_tokens_spent(backend: Any) -> int:
621
+ """Best-effort token spend of an orchestrator seat backend (0 if the
622
+ backend does not meter). Never raises; never fabricates."""
623
+ getter = getattr(backend, "get_tokens_spent", None)
624
+ if not callable(getter):
625
+ return 0
626
+ try:
627
+ return max(0, int(getter()))
628
+ except Exception:
629
+ return 0
630
+
631
+
632
+ def _quarantine_blocked_files(
633
+ driver: Any, item: Dict[str, Any], item_result: Dict[str, Any]
634
+ ) -> None:
635
+ """Restore a BLOCKED item's written files to their pre-build state.
636
+
637
+ Without this, Phase 4's writes stay in the working tree after a block and
638
+ a later `git add -A` (outside this loop) would ship the refused code.
639
+
640
+ Semantics (conservative, only the blocked item's own filesWritten):
641
+ - tracked file -> `git checkout -- <file>` (restore index version)
642
+ - untracked file -> did not exist pre-build; deleted (ONLY on a clean
643
+ exit-1 "untracked" determination; any other ls-files failure is
644
+ AMBIGUOUS -> error record, never delete: fail-safe, not fail-delete)
645
+ - not inside a git worktree -> SKIP with an honest record (we cannot
646
+ know pre-build state without git; never guess-delete)
647
+
648
+ PATHSPEC GUARD (re-attack-proven destructive defect): quarantine acts on
649
+ actual FILE paths ONLY. Empty strings, "."/"..", and directory entries
650
+ are REJECTED with an error record -- `git checkout -- subdir` (or ".")
651
+ would revert OTHER items' uncommitted verified work under that tree, not
652
+ just the blocked item's files. On Windows, backslash separators are
653
+ normalized to "/" first so a tracked file is never misclassified
654
+ untracked (and deleted) because git could not match the pathspec.
655
+
656
+ Records the outcome in item_result["quarantine"]:
657
+ {"attempted", "restored", "deleted", "errors", "skipped_reason"}.
658
+ Windows + POSIX safe: git args go through _quote_arg; deletion is
659
+ pathlib. Errors are per-file and never raise out of the gate.
660
+ """
661
+ files = list(item_result.get("filesWritten") or [])
662
+ outcome = {
663
+ "attempted": bool(files),
664
+ "restored": [],
665
+ "deleted": [],
666
+ "errors": [],
667
+ "skipped_reason": None,
668
+ }
669
+ item_result["quarantine"] = outcome
670
+ if not files:
671
+ return
672
+ if driver is None:
673
+ outcome["skipped_reason"] = "no_driver"
674
+ return
675
+
676
+ root = item.get("repo") or item.get("workDir") or "."
677
+ try:
678
+ probe = driver.run_command(
679
+ "git rev-parse --is-inside-work-tree", cwd=root
680
+ )
681
+ inside = probe.exit_code == 0 and "true" in (probe.stdout or "").lower()
682
+ except Exception:
683
+ inside = False
684
+ if not inside:
685
+ outcome["skipped_reason"] = "not_a_git_worktree"
686
+ return
687
+
688
+ for f in files:
689
+ raw = f if isinstance(f, str) else str(f)
690
+ # Windows: normalize separators so the git pathspec matches the
691
+ # index entry (backslash pathspecs silently match nothing -> a
692
+ # tracked file would be misclassified untracked and DELETED).
693
+ # POSIX: backslash is a legal filename character; leave it alone.
694
+ norm = raw.replace("\\", "/") if os.name == "nt" else raw
695
+ # PATHSPEC GUARD: reject empty/dot specs outright -- "." or ""
696
+ # as a checkout pathspec reverts the WHOLE repo, destroying other
697
+ # items' uncommitted verified work. Record, never act.
698
+ stripped = norm.strip().rstrip("/")
699
+ if stripped in ("", ".", ".."):
700
+ outcome["errors"].append(
701
+ {"file": raw, "error": "rejected pathspec: empty or dot "
702
+ "entry (would revert beyond the blocked item's own files)"}
703
+ )
704
+ continue
705
+ # Only touch paths that stay inside the item's root (never escape).
706
+ try:
707
+ _validate_file_path(norm, root)
708
+ except ValueError as exc:
709
+ outcome["errors"].append({"file": raw, "error": f"path validation: {exc}"})
710
+ continue
711
+ try:
712
+ target = Path(root) / norm
713
+ # PATHSPEC GUARD: directories are never quarantined -- a
714
+ # directory checkout reverts EVERY file under it, including
715
+ # other items' uncommitted verified work. Files only.
716
+ if target.is_dir():
717
+ outcome["errors"].append(
718
+ {"file": raw, "error": "rejected pathspec: resolves to "
719
+ "a directory (quarantine operates on file paths only; "
720
+ "a directory spec would revert other items' files "
721
+ "under it)"}
722
+ )
723
+ continue
724
+ # :(literal) pathspec magic: glob characters (*, ?, [) in an
725
+ # entry must never expand -- "*" would match (and checkout
726
+ # would revert) EVERY tracked file, not the blocked item's.
727
+ spec = _quote_arg(":(literal)" + norm)
728
+ tracked = driver.run_command(
729
+ "git ls-files --error-unmatch -- " + spec, cwd=root
730
+ )
731
+ if tracked.exit_code == 0:
732
+ restore = driver.run_command(
733
+ "git checkout -- " + spec, cwd=root
734
+ )
735
+ if restore.exit_code == 0:
736
+ outcome["restored"].append(f)
737
+ else:
738
+ outcome["errors"].append(
739
+ {"file": raw, "error": "git checkout failed: "
740
+ + (restore.stderr or "")[:200]}
741
+ )
742
+ elif tracked.exit_code == 1:
743
+ # Clean "untracked" determination (--error-unmatch exits 1
744
+ # on no-match): the file did not exist pre-build; remove it.
745
+ if target.exists() or target.is_symlink():
746
+ target.unlink()
747
+ outcome["deleted"].append(f)
748
+ else:
749
+ # AMBIGUOUS classification (exit 128, index lock, ...):
750
+ # fail-SAFE, never fail-delete. Tracked content is index-
751
+ # recoverable; an uncertain file must not be destroyed.
752
+ outcome["errors"].append(
753
+ {"file": raw, "error": "untracked classification "
754
+ "ambiguous (git ls-files exit "
755
+ f"{tracked.exit_code}): refusing to delete"}
756
+ )
757
+ except Exception as exc:
758
+ outcome["errors"].append({"file": raw, "error": str(exc)})
759
+
760
+
761
+ def _orchestrator_final_catch(
762
+ backend: Any,
763
+ items: List[Dict[str, Any]],
764
+ result: Dict[str, Any],
765
+ state_dir: Optional[str] = None,
766
+ driver: Any = None,
767
+ ) -> None:
768
+ """HS-2: route the pre-ship final-catch decision through a configured
769
+ orchestrator seat (Phase 6, replacing 'deferred' ONLY when a seat is
770
+ configured).
771
+
772
+ Semantics (conservative, incumbent-safe):
773
+ - Only test-VERIFIED items are reviewed (failed items already do not
774
+ ship; they consume no seat decisions).
775
+ - verdict 'merge' -> approved; item ships as today.
776
+ - verdict 'block' -> verified flipped False; item does NOT ship;
777
+ journal updated so a resume cannot skip-and-ship it.
778
+ - 'escalate' / 'undetermined' / DECISION_FAILED -> degrade to today's
779
+ behavior (ship to branch; merge stays manual downstream) with an
780
+ honest per-item record. A seat outage NEVER fabricates a verdict
781
+ and NEVER blocks a test-proven item (crash-only degradation).
782
+
783
+ Mutates result in place: per-item 'final_catch' + 'adversarial_review'
784
+ (+ 'quarantine' on block), plus a wave-level 'orchestrator_review'
785
+ summary block with verdict counts, blocked detail, seat token spend,
786
+ and a gate_status flag ("active" | "degraded" when every decision
787
+ failed | "no_decisions" when nothing was verified to review).
788
+ """
789
+ from context_pack import ContextPack
790
+ from orchestrator_driver import OrchestratorDriver
791
+
792
+ orch = OrchestratorDriver(backend, schema_dir=str(DRIVER_DIR))
793
+ review = {
794
+ "seat": type(backend).__name__,
795
+ "model": getattr(backend, "model", None),
796
+ "decisions": 0,
797
+ "blocked": [],
798
+ "blocked_detail": [],
799
+ "decision_failed": [],
800
+ "verdict_counts": {
801
+ "merge": 0,
802
+ "block": 0,
803
+ "escalate": 0,
804
+ "undetermined": 0,
805
+ "decision_failed": 0,
806
+ },
807
+ }
808
+ slug_to_item = {
809
+ item.get("slug", f"item-{i}"): item for i, item in enumerate(items)
810
+ }
811
+ result["adversarial_review"] = "orchestrator_final_catch"
812
+
813
+ for item_result in result["built"]:
814
+ if not item_result.get("verified", False):
815
+ item_result["adversarial_review"] = "skipped_not_verified"
816
+ continue
817
+
818
+ slug = item_result.get("slug", "unknown")
819
+ item = slug_to_item.get(slug, {})
820
+ evidence = {
821
+ "item": json.dumps(
822
+ {
823
+ "slug": slug,
824
+ "prompt_excerpt": str(item.get("prompt", ""))[:1000],
825
+ "ownsFiles": list(item.get("ownsFiles", [])),
826
+ "filesWritten": item_result.get("filesWritten", []),
827
+ "repairs": item_result.get("repairs", 0),
828
+ },
829
+ sort_keys=True,
830
+ ),
831
+ # NOTE (F6): this final_catch evidence is currently LOW-SIGNAL --
832
+ # test_passed is hardcoded True (only verified items reach this
833
+ # point) and no secret-scan/CI/branch-protection results are fed
834
+ # in yet. Future work should enrich it with the real gate outputs
835
+ # so the seat has something substantive to judge.
836
+ "verification_results": json.dumps(
837
+ {
838
+ "test_passed": True,
839
+ "test_exit_code": item_result.get("testExit"),
840
+ "spot_check_failed": bool(
841
+ item_result.get("spot_check_failed", False)
842
+ ),
843
+ },
844
+ sort_keys=True,
845
+ ),
846
+ }
847
+ pack = ContextPack(
848
+ decision_type="final_catch",
849
+ sources_requested=(),
850
+ evidence=evidence,
851
+ )
852
+ try:
853
+ decision = orch.decide("final_catch", pack)
854
+ except Exception as exc:
855
+ # decide() never raises by contract; belt and braces anyway.
856
+ decision = {
857
+ "verdict": "DECISION_FAILED",
858
+ "evidence": [f"decide() raised: {exc}"],
859
+ }
860
+ review["decisions"] += 1
861
+ verdict = str(decision.get("verdict", "DECISION_FAILED"))
862
+ item_result["final_catch"] = verdict
863
+
864
+ if verdict == "block":
865
+ review["verdict_counts"]["block"] += 1
866
+ item_result["verified"] = False
867
+ item_result["adversarial_review"] = "blocked_by_orchestrator"
868
+ item_result["error"] = "orchestrator final_catch verdict: block"
869
+ review["blocked"].append(slug)
870
+ # Persist WHY (hold_reason, else first evidence citation) so the
871
+ # Report's blocked lane is actionable, not just a slug.
872
+ reason = decision.get("hold_reason")
873
+ if not reason:
874
+ evidence_list = decision.get("evidence")
875
+ if isinstance(evidence_list, list) and evidence_list:
876
+ reason = str(evidence_list[0])
877
+ review["blocked_detail"].append(
878
+ {"slug": slug, "reason": reason or "final_catch verdict: block"}
879
+ )
880
+ if state_dir:
881
+ _write_journal_entry(
882
+ state_dir,
883
+ slug,
884
+ "final_catch_blocked",
885
+ {
886
+ "verified": False,
887
+ "testExit": item_result.get("testExit"),
888
+ "final_catch": "block",
889
+ },
890
+ repo=item.get("repo"),
891
+ )
892
+ # QUARANTINE: refused code must not linger in the working tree.
893
+ _quarantine_blocked_files(driver, item, item_result)
894
+ elif verdict == "merge":
895
+ review["verdict_counts"]["merge"] += 1
896
+ item_result["adversarial_review"] = "approved_by_orchestrator"
897
+ elif verdict in ("escalate", "undetermined"):
898
+ review["verdict_counts"][verdict] += 1
899
+ item_result["adversarial_review"] = verdict
900
+ else: # DECISION_FAILED (or anything unrecognized -> fail-safe path)
901
+ review["verdict_counts"]["decision_failed"] += 1
902
+ item_result["adversarial_review"] = "decision_failed_deferred"
903
+ review["decision_failed"].append(slug)
904
+
905
+ # Gate visibility: a 100%-failing seat must NOT look like an approving
906
+ # one. decisions>0 with every one DECISION_FAILED = the gate made zero
907
+ # successful decisions -> "degraded" (crash-only ship semantics are
908
+ # unchanged; this only makes the outage VISIBLE).
909
+ if review["decisions"] == 0:
910
+ review["gate_status"] = "no_decisions"
911
+ elif review["verdict_counts"]["decision_failed"] == review["decisions"]:
912
+ review["gate_status"] = "degraded"
913
+ else:
914
+ review["gate_status"] = "active"
915
+ review["seat_tokens_spent"] = _seat_tokens_spent(backend)
916
+
917
+ result["orchestrator_review"] = review
918
+
919
+
418
920
  def run_wave(
419
921
  driver: AgentDriver,
420
922
  manifest: Dict[str, Any],
@@ -422,6 +924,49 @@ def run_wave(
422
924
  state_dir: Optional[str] = None,
423
925
  git: Optional[Dict[str, str]] = None,
424
926
  resume_journal: bool = False,
927
+ orchestrator_backend: Any = None,
928
+ ) -> Dict[str, Any]:
929
+ """Run a full multi-item wave through an AgentDriver backend.
930
+
931
+ Public entry point: owns the wave's claim lifecycle (RS5). A single
932
+ wave-level instance id claims each dispatched item with a ttl sized to
933
+ the driver's command timeout (_claim_ttl_for_driver), HOLDS the claim
934
+ across build -> repair -> ship, fences repair/ship against lost claims,
935
+ and releases every claim exactly once here -- on every exit path,
936
+ including exceptions. All other semantics are documented on
937
+ _run_wave_inner (same signature plus claim_ctx).
938
+ """
939
+ claim_ctx = _ClaimContext(
940
+ state_dir=state_dir,
941
+ instance_id="wave-%s" % uuid.uuid4(),
942
+ ttl=_claim_ttl_for_driver(driver),
943
+ )
944
+ try:
945
+ return _run_wave_inner(
946
+ driver,
947
+ manifest,
948
+ state_dir=state_dir,
949
+ git=git,
950
+ resume_journal=resume_journal,
951
+ orchestrator_backend=orchestrator_backend,
952
+ claim_ctx=claim_ctx,
953
+ )
954
+ finally:
955
+ # RS5 F3: exactly-once release at the TRUE end of the lifecycle
956
+ # (build -> repair -> ship), success or terminal failure -- never
957
+ # in build_item's finally, which left Phase 5/7 claim-less.
958
+ claim_ctx.release_all()
959
+
960
+
961
+ def _run_wave_inner(
962
+ driver: AgentDriver,
963
+ manifest: Dict[str, Any],
964
+ *,
965
+ state_dir: Optional[str] = None,
966
+ git: Optional[Dict[str, str]] = None,
967
+ resume_journal: bool = False,
968
+ orchestrator_backend: Any = None,
969
+ claim_ctx: "_ClaimContext",
425
970
  ) -> Dict[str, Any]:
426
971
  """Run a full multi-item wave through an AgentDriver backend.
427
972
 
@@ -443,6 +988,15 @@ def run_wave(
443
988
  ship phase is skipped.
444
989
  resume_journal: if True and state_dir exists, load journal and skip items
445
990
  marked as verified (but re-run their tests for trust-but-verify).
991
+ orchestrator_backend: optional OrchestratorBackend for the DECISION seat
992
+ (HS-2). None or the null HarnessOrchestratorBackend means
993
+ the live harness stays the orchestrator: Phase 6 remains
994
+ 'deferred', byte-identical to pre-HS-2 (no key required,
995
+ no backend called). A live backend (e.g. from
996
+ build_orchestrator_backend(load_backend_config()) with a
997
+ seats.orchestrator block) routes a final_catch decision
998
+ per verified item through OrchestratorDriver.decide();
999
+ verdict 'block' stops that item from shipping.
446
1000
 
447
1001
  Returns:
448
1002
  dict with structure:
@@ -503,9 +1057,46 @@ def run_wave(
503
1057
  "resume_stats": None,
504
1058
  }
505
1059
 
1060
+ # Ensure the state directory exists up-front: claims (EventStore) and
1061
+ # the journal both live under it. Without this, a fresh state_dir made
1062
+ # EventStore construction raise inside the claim block -- which is a
1063
+ # fail-closed SKIP (RS3-W N5), not a license to dispatch claim-less.
1064
+ if state_dir:
1065
+ try:
1066
+ Path(state_dir).mkdir(parents=True, exist_ok=True)
1067
+ except Exception:
1068
+ # Downstream consumers (claims, journal, ceiling) each fail
1069
+ # closed on their own if the directory is truly unusable.
1070
+ pass
1071
+
506
1072
  # Extract items from manifest.
507
1073
  items = manifest.get("items", [])
508
1074
 
1075
+ # SLUG-UNIQUENESS PREFLIGHT (RS3-W N7): slugs are the identity key for
1076
+ # journal entries, coordination claims, and slug->item lookups. Duplicate
1077
+ # slugs silently collide in all three (last-writer-wins journal, claim
1078
+ # starvation, wrong item shipped). Reject loudly, before any dispatch.
1079
+ seen_slugs = {}
1080
+ duplicate_slugs = []
1081
+ for item in items:
1082
+ slug = item.get("slug")
1083
+ if slug is None:
1084
+ continue
1085
+ if slug in seen_slugs:
1086
+ duplicate_slugs.append(slug)
1087
+ else:
1088
+ seen_slugs[slug] = True
1089
+ if duplicate_slugs:
1090
+ result["aborted"] = True
1091
+ result["abort_reason"] = "duplicate_slugs"
1092
+ result["duplicate_slugs"] = sorted(set(duplicate_slugs))
1093
+ result["error"] = (
1094
+ "duplicate item slugs in manifest (slugs key the journal, "
1095
+ "coordination claims, and ship lookups): "
1096
+ + ", ".join(sorted(set(duplicate_slugs)))
1097
+ )
1098
+ return result
1099
+
509
1100
  # ========================================================================
510
1101
  # PHASE 0 (optional): Resume - Load journal state and release stale leases
511
1102
  # ========================================================================
@@ -692,11 +1283,27 @@ def run_wave(
692
1283
  journal_key = (repo, slug)
693
1284
  journal_entry = journal_state.get(journal_key)
694
1285
  skipped_from_journal = False
695
- if journal_entry and _should_skip_from_journal(journal_entry):
1286
+ # RS3-W N10: resume-skip requires the journal entry to MATCH the
1287
+ # current item's content fingerprint. Entries without a fingerprint
1288
+ # (older format) or with a different one (a NEW item reusing a prior
1289
+ # wave's slug) are rebuilt -- never silently inherited (fail-closed).
1290
+ if journal_entry and _should_skip_from_journal(journal_entry) and (
1291
+ journal_entry.get("fingerprint") == _item_fingerprint(item)
1292
+ ):
696
1293
  skipped_from_journal = True
697
1294
  with resume_stats_lock:
698
1295
  resume_stats["skipped_from_journal"] += 1
699
1296
 
1297
+ # RS3-W N3: restore the journaled filesWritten so a resumed
1298
+ # verified item can still SHIP (Phase 7) or be quarantined on a
1299
+ # block. Without this the resumed item verified green but never
1300
+ # produced a shipped record -> tracker stayed todo -> the item
1301
+ # was re-selected and re-verified every wave, forever.
1302
+ journal_files_written = [
1303
+ str(f) for f in (journal_entry.get("filesWritten") or [])
1304
+ if isinstance(f, str) and f
1305
+ ]
1306
+
700
1307
  # Trust-but-verify: re-run the test for the journaled item
701
1308
  test_cmd = item.get("testCmd", "")
702
1309
  if test_cmd:
@@ -713,7 +1320,7 @@ def run_wave(
713
1320
  "testExit": 0,
714
1321
  "repairs": 0,
715
1322
  "error": None,
716
- "filesWritten": [],
1323
+ "filesWritten": journal_files_written,
717
1324
  "skipped_from_journal": True,
718
1325
  "trust_verified": True,
719
1326
  },
@@ -778,19 +1385,14 @@ def run_wave(
778
1385
  resume_stats["rebuilt"] += 1
779
1386
 
780
1387
  # Try to claim the item if state_dir is given (fail-closed on claim failure).
781
- instance_id = f"wave-{uuid.uuid4()}"
782
- claim_held = False
1388
+ # RS5 F1/F3: the claim uses the WAVE-level instance id and a ttl
1389
+ # sized to the driver's command timeout (not the 300s default), and
1390
+ # is HELD across the item's full lifecycle (build -> repair -> ship).
1391
+ # Release happens exactly once in run_wave's finally, never here.
1392
+ instance_id = claim_ctx.instance_id
783
1393
  if coordination is not None and state_dir is not None:
784
1394
  try:
785
- from state_store import store
786
- db_path = Path(state_dir) / "state.db"
787
- event_store = store.EventStore(str(db_path))
788
- claim_held = coordination.try_claim(
789
- event_store,
790
- resource=slug,
791
- instance_id=instance_id,
792
- )
793
- if not claim_held:
1395
+ if not claim_ctx.acquire(slug):
794
1396
  # Item is claimed by another instance; skip it.
795
1397
  return (
796
1398
  item_index,
@@ -802,11 +1404,28 @@ def run_wave(
802
1404
  "repairs": 0,
803
1405
  "error": "resource claimed by another instance",
804
1406
  "filesWritten": [],
1407
+ "claim_skipped": True,
805
1408
  },
806
1409
  )
807
- except Exception:
808
- # On exception, fail-closed: skip the item.
809
- claim_held = False
1410
+ except Exception as claim_exc:
1411
+ # RS3-W N5: fail-CLOSED means SKIP, not dispatch. Falling
1412
+ # through here dispatched the item WITHOUT holding a claim
1413
+ # (two racing instances both hit the SQLite lock exception
1414
+ # and both dispatched -> double-dispatch). Skip the item and
1415
+ # record why; another instance (or the next wave) retries.
1416
+ return (
1417
+ item_index,
1418
+ {
1419
+ "slug": slug,
1420
+ "dispatched": False,
1421
+ "verified": False,
1422
+ "testExit": None,
1423
+ "repairs": 0,
1424
+ "error": f"claim check failed (fail-closed skip): {claim_exc}",
1425
+ "filesWritten": [],
1426
+ "claim_skipped": True,
1427
+ },
1428
+ )
810
1429
 
811
1430
  try:
812
1431
  # Build the manifest item with policy.
@@ -826,12 +1445,17 @@ def run_wave(
826
1445
  "workerId": dispatch_result.get("workerId"),
827
1446
  }
828
1447
 
829
- # Write journal entry for this item's outcome.
1448
+ # Write journal entry for this item's outcome. filesWritten is
1449
+ # persisted so a journal-resumed item can still ship/quarantine
1450
+ # (RS3-W N3); fingerprint binds the entry to this item's content
1451
+ # so a new item reusing the slug is never skipped (RS3-W N10).
830
1452
  if state_dir:
831
1453
  _write_journal_entry(state_dir, slug, "dispatched", {
832
1454
  "verified": item_result["verified"],
833
1455
  "testExit": item_result["testExit"],
834
1456
  "instance_id": instance_id,
1457
+ "filesWritten": item_result["filesWritten"],
1458
+ "fingerprint": _item_fingerprint(item),
835
1459
  }, repo=repo)
836
1460
 
837
1461
  return (item_index, item_result)
@@ -858,35 +1482,43 @@ def run_wave(
858
1482
  "filesWritten": [],
859
1483
  },
860
1484
  )
861
- finally:
862
- # Release the claim if held.
863
- if claim_held and coordination is not None and state_dir is not None:
864
- try:
865
- from state_store import store
866
- db_path = Path(state_dir) / "state.db"
867
- event_store = store.EventStore(str(db_path))
868
- coordination.release(event_store, resource=slug, instance_id=instance_id)
869
- except Exception:
870
- pass
1485
+ # NOTE (RS5 F3): no finally-release here. The claim protects the
1486
+ # WHOLE lifecycle -- Phase 5 repair and Phase 7 ship run under it --
1487
+ # and run_wave's finally releases it exactly once at the true end.
871
1488
 
872
1489
  # Run build in parallel.
873
1490
  max_workers = min(8, len(items)) if items else 1
874
1491
  with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
875
- futures = [
876
- executor.submit(build_item, i, item)
1492
+ future_to_item = {
1493
+ executor.submit(build_item, i, item): (i, item)
877
1494
  for i, item in enumerate(items)
878
- ]
879
- for future in concurrent.futures.as_completed(futures):
1495
+ }
1496
+ for future in concurrent.futures.as_completed(future_to_item):
1497
+ submitted_index, submitted_item = future_to_item[future]
880
1498
  try:
881
1499
  item_index, item_result = future.result()
882
- built_items.append((item_index, items[item_index], item_result))
1500
+ except Exception as exc:
1501
+ # RS3-W N7: build_item catches internally, but if the future
1502
+ # STILL raises the item must NEVER silently vanish from both
1503
+ # built and failed_items (it made green vacuously true).
1504
+ # Record an honest failed result for the submitted item.
1505
+ item_index = submitted_index
1506
+ item_result = {
1507
+ "slug": submitted_item.get("slug", f"item-{submitted_index}"),
1508
+ "dispatched": False,
1509
+ "verified": False,
1510
+ "testExit": None,
1511
+ "repairs": 0,
1512
+ "error": f"executor exception: {exc}",
1513
+ "filesWritten": [],
1514
+ }
1515
+ built_items.append((item_index, items[item_index], item_result))
883
1516
 
884
- # Track failed items for repair.
885
- if not item_result["verified"]:
886
- failed_items.append((item_index, items[item_index], item_result))
887
- except Exception:
888
- # Should not happen (build_item catches internally), but just in case.
889
- pass
1517
+ # Track failed items for repair. Claim-skipped items are NOT
1518
+ # repair candidates: repair would dispatch them WITHOUT a claim
1519
+ # (the double-dispatch the skip exists to prevent).
1520
+ if not item_result["verified"] and not item_result.get("claim_skipped"):
1521
+ failed_items.append((item_index, items[item_index], item_result))
890
1522
 
891
1523
  # Sort built_items by index to preserve order.
892
1524
  built_items.sort(key=lambda x: x[0])
@@ -918,6 +1550,25 @@ def run_wave(
918
1550
  workdir = item.get("workDir", ".")
919
1551
  test_cmd = item.get("testCmd", "")
920
1552
 
1553
+ # RS5 F1b FENCE: never repair-dispatch an item whose claim we no
1554
+ # longer hold (ttl lapsed and another instance may have reclaimed
1555
+ # it and be dispatching the same files right now). Terminal
1556
+ # honest abort -- recorded, never silently retried.
1557
+ if not claim_ctx.fence_ok(slug):
1558
+ item_result["error"] = (
1559
+ "claim lost before repair (fenced): not re-dispatched"
1560
+ )
1561
+ item_result["claim_lost"] = True
1562
+ if state_dir:
1563
+ _write_journal_entry(state_dir, slug, "claim_lost", {
1564
+ "verified": False,
1565
+ "testExit": item_result.get("testExit"),
1566
+ "repairs": item_result.get("repairs", 0),
1567
+ "instance_id": claim_ctx.instance_id,
1568
+ "error": "claim lost before repair",
1569
+ }, repo=item.get("repo"))
1570
+ continue
1571
+
921
1572
  # Build repair prompt: append test output to original prompt.
922
1573
  original_prompt = item.get("prompt", "")
923
1574
  test_output = f"\n\nTest failed with exit code {item_result['testExit']}.\n"
@@ -943,13 +1594,16 @@ def run_wave(
943
1594
  item_result["filesWritten"] = dispatch_result.get("filesWritten", [])
944
1595
  item_result["repairs"] += 1
945
1596
 
946
- # Write journal entry for repair outcome.
1597
+ # Write journal entry for repair outcome (filesWritten +
1598
+ # fingerprint: same resume contract as the dispatch entry).
947
1599
  if state_dir:
948
1600
  repo = item.get("repo")
949
1601
  _write_journal_entry(state_dir, slug, "repaired", {
950
1602
  "verified": item_result["verified"],
951
1603
  "testExit": item_result["testExit"],
952
1604
  "repairs": item_result["repairs"],
1605
+ "filesWritten": item_result["filesWritten"],
1606
+ "fingerprint": _item_fingerprint(item),
953
1607
  }, repo=repo)
954
1608
 
955
1609
  # If still failed, mark for next round.
@@ -1012,13 +1666,50 @@ def run_wave(
1012
1666
  item_result["spot_check_failed"] = True
1013
1667
 
1014
1668
  # ========================================================================
1015
- # PHASE 6: Adversarial review (deferred, not yet enforced)
1669
+ # PHASE 6: Adversarial review / orchestrator final catch (HS-2)
1016
1670
  # ========================================================================
1017
- # Adversarial review is not yet implemented; mark all as deferred.
1018
- # (TODO in a later increment: real adversarial review dispatch via driver)
1019
- result["adversarial_review"] = "deferred"
1020
- for item_result in result["built"]:
1021
- item_result["adversarial_review"] = "deferred"
1671
+ if _is_live_orchestrator_backend(orchestrator_backend):
1672
+ # A configured orchestrator seat is LIVE: route a final_catch
1673
+ # decision per verified item through the swapped backend.
1674
+ _orchestrator_final_catch(
1675
+ orchestrator_backend, items, result, state_dir=state_dir,
1676
+ driver=driver,
1677
+ )
1678
+ # HS-2 hardening: the seat's own spend (up to 3 calls/item) counts
1679
+ # against the ceiling too. Re-check AFTER decisions, BEFORE ship,
1680
+ # including metered seat tokens. Runs ONLY on the live-seat path
1681
+ # (no-op default keeps the pre-HS-2 check pattern byte-identical).
1682
+ if cost_ceiling is not None and state_dir is not None:
1683
+ # RS3-W N1: driver.get_tokens_spent() may be None BY CONTRACT
1684
+ # (ClaudeCodeDriver always; CodexDriver at zero spend). Adding
1685
+ # None + int crashed the flagship "Claude worker + swapped
1686
+ # orchestrator seat" wave AFTER seat decisions, BEFORE ship --
1687
+ # verified work never shipped and the item looped. With an
1688
+ # unmetered driver: count the metered seat spend when there is
1689
+ # any; otherwise pass None so cost_ceiling keeps its windowed
1690
+ # ledger fallback (same contract as the Phase 3 check).
1691
+ driver_spent = driver.get_tokens_spent()
1692
+ seat_spent = _seat_tokens_spent(orchestrator_backend)
1693
+ if driver_spent is None:
1694
+ spent_arg = seat_spent if seat_spent > 0 else None
1695
+ else:
1696
+ spent_arg = driver_spent + seat_spent
1697
+ ceiling_result = cost_ceiling.check(
1698
+ spent=spent_arg,
1699
+ trip=True,
1700
+ state_dir=state_dir,
1701
+ )
1702
+ result["ceiling"] = ceiling_result
1703
+ if ceiling_result.get("exceeded", False):
1704
+ result["aborted"] = True
1705
+ result["abort_reason"] = "cost_ceiling_exceeded_after_decisions"
1706
+ return result
1707
+ else:
1708
+ # No configured seat: the live harness IS the orchestrator; review
1709
+ # stays deferred to it. Byte-identical to pre-HS-2 behavior.
1710
+ result["adversarial_review"] = "deferred"
1711
+ for item_result in result["built"]:
1712
+ item_result["adversarial_review"] = "deferred"
1022
1713
 
1023
1714
  # ========================================================================
1024
1715
  # PHASE 7: Per-repo ship (git operations, if configured)
@@ -1041,6 +1732,17 @@ def run_wave(
1041
1732
  if item_result.get("verified", False):
1042
1733
  slug = item_result.get("slug")
1043
1734
  if slug in slug_to_item:
1735
+ # RS5 F1b FENCE: an item whose claim lapsed and was
1736
+ # reclaimed must NOT ship -- the reclaiming instance may
1737
+ # ship its own build of the same slug (double-ship).
1738
+ # Items this wave never claimed (journal-resumed,
1739
+ # claim-gate disabled) always pass the fence.
1740
+ if not claim_ctx.fence_ok(slug):
1741
+ item_result["ship_error"] = (
1742
+ "claim lost before ship (fenced): not shipped"
1743
+ )
1744
+ item_result["claim_lost"] = True
1745
+ continue
1044
1746
  item_index, original_item = slug_to_item[slug]
1045
1747
  verified_items.append((item_index, original_item, item_result))
1046
1748
 
@@ -1166,6 +1868,25 @@ def run_wave(
1166
1868
  commit_msg = f"Wave: {len(repo_items)} items verified"
1167
1869
  commit_cmd = f"git commit -m {_quote_arg(commit_msg)}"
1168
1870
  commit_result = driver.run_command(commit_cmd, cwd=repo_path)
1871
+ if commit_result.exit_code != 0 and (
1872
+ "nothing to commit"
1873
+ in ((commit_result.stdout or "") + (commit_result.stderr or "")).lower()
1874
+ ):
1875
+ # RS3-W N3: the staged content is ALREADY in HEAD
1876
+ # (e.g. a prior run committed then crashed before the
1877
+ # tracker was marked). The item is verified and its
1878
+ # work is committed: emit a terminal shipped record
1879
+ # instead of failing forever on re-commit.
1880
+ repo_ship_results.append({
1881
+ "repo": repo_path,
1882
+ "committed": False,
1883
+ "no_changes": True,
1884
+ "files_count": len(repo_items),
1885
+ })
1886
+ for _, _, item_result in repo_items:
1887
+ item_result["ship_no_changes"] = True
1888
+ shipped_items.append(item_result["slug"])
1889
+ continue
1169
1890
  if commit_result.exit_code != 0:
1170
1891
  # UNSTAGE P3: Commit failed; run git reset to unstage the files.
1171
1892
  # This prevents staged-files residue on partial failure.
@@ -1220,6 +1941,22 @@ def run_wave(
1220
1941
  # Mark items from this repo as shipped.
1221
1942
  for _, _, item_result in repo_items:
1222
1943
  shipped_items.append(item_result["slug"])
1944
+ else:
1945
+ # RS3-W N3: a VERIFIED item with no files to add must
1946
+ # still reach a TERMINAL shipped record -- otherwise the
1947
+ # scheduler never marks the tracker and the item is
1948
+ # re-selected, re-verified, and "succeeds" every wave,
1949
+ # forever (recovery livelock). Nothing to commit is an
1950
+ # honest no-op ship, not a silent drop.
1951
+ repo_ship_results.append({
1952
+ "repo": repo_path,
1953
+ "committed": False,
1954
+ "no_changes": True,
1955
+ "files_count": len(repo_items),
1956
+ })
1957
+ for _, _, item_result in repo_items:
1958
+ item_result["ship_no_changes"] = True
1959
+ shipped_items.append(item_result["slug"])
1223
1960
 
1224
1961
  # Record shipped items and per-repo results.
1225
1962
  if shipped_items:
@@ -1251,8 +1988,13 @@ def result_to_report(wave_result: Dict[str, Any]) -> Dict[str, Any]:
1251
1988
  repairs_used = sum(item.get("repairs", 0) for item in built_items)
1252
1989
 
1253
1990
  # Determine if wave was fully green (all items verified and not aborted).
1254
- green = not wave_result.get("aborted", False) and all(
1255
- item.get("verified", False) for item in built_items
1991
+ # RS3-W N7: green must be False when ZERO items ran -- all() over an
1992
+ # empty list is vacuously True, which turned a wave where every item
1993
+ # silently vanished into a green report.
1994
+ green = (
1995
+ not wave_result.get("aborted", False)
1996
+ and len(built_items) > 0
1997
+ and all(item.get("verified", False) for item in built_items)
1256
1998
  )
1257
1999
 
1258
2000
  report = {