@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.
- package/CHANGELOG.md +23 -0
- package/README.md +14 -6
- package/aesop.config.example.json +16 -0
- package/bin/cli.js +5 -2
- package/docs/INSTALL.md +104 -14
- package/docs/MICROKERNEL.md +452 -0
- package/docs/README.md +4 -0
- package/docs/THE-AESOP-HYPOTHESIS.md +140 -0
- package/driver/CLAUDE.md +123 -123
- package/driver/README.md +40 -2
- package/driver/adjudication_gate.py +367 -0
- package/driver/aesop.config.example.json +20 -4
- package/driver/backend_config.py +603 -12
- package/driver/claude_code_driver.py +9 -17
- package/driver/codex_driver.py +80 -25
- package/driver/context_pack.py +454 -0
- package/driver/openai_compatible_driver.py +53 -11
- package/driver/openai_transport.py +31 -10
- package/driver/orchestrator_backend.py +332 -0
- package/driver/orchestrator_driver.py +589 -0
- package/driver/proc_util.py +134 -0
- package/driver/wave_loop.py +801 -59
- package/driver/wave_scheduler.py +361 -37
- package/monitor/collect-signals.mjs +83 -0
- package/package.json +1 -1
- package/state_store/coordination.py +65 -5
- package/tools/ci_merge_wait.py +88 -42
- package/tools/ci_shard_runner.py +128 -0
- package/tools/seated_shadow_adjudication.py +920 -0
- package/tools/shadow_adjudication.py +1024 -0
- package/tools/verify_ui_trio_redaction_proof.py +292 -0
package/driver/wave_loop.py
CHANGED
|
@@ -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
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
+
resource = entry.get("slug")
|
|
467
|
+
if old_instance_id and resource:
|
|
408
468
|
try:
|
|
409
|
-
coordination.release(event_store, resource=
|
|
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
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
809
|
-
|
|
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
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
|
1669
|
+
# PHASE 6: Adversarial review / orchestrator final catch (HS-2)
|
|
1016
1670
|
# ========================================================================
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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
|
|
1255
|
-
|
|
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 = {
|