@matt82198/aesop 0.3.2 → 0.4.1
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 +47 -0
- package/README.md +24 -9
- package/aesop.config.example.json +16 -0
- package/bin/cli.js +107 -34
- package/daemons/run-watchdog.sh +6 -2
- package/docs/CONFIGURE.md +31 -20
- package/docs/FIRST-WAVE.md +29 -9
- package/docs/INSTALL.md +181 -26
- package/docs/MICROKERNEL.md +452 -0
- package/docs/PORTING.md +4 -0
- package/docs/README.md +7 -3
- package/docs/THE-AESOP-HYPOTHESIS.md +156 -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/doctor.js +124 -12
- package/tools/reproduce.js +11 -2
- package/tools/seated_shadow_adjudication.py +920 -0
- package/tools/self_stats.py +61 -6
- package/tools/shadow_adjudication.py +1024 -0
- package/tools/verify_test_suite_count.py +250 -0
- package/tools/verify_ui_trio_redaction_proof.py +292 -0
- package/tools/wave_manifest_lint.py +485 -0
- package/tools/wave_scorecard.py +430 -0
- package/ui/config.py +1 -1
- package/ui/cost.py +176 -3
- package/ui/web/dist/assets/{index-CP68RIh3.js → index-4rp6B_ID.js} +1 -1
- package/ui/web/dist/assets/{index-CNQxaiOW.css → index-B2lTtpsH.css} +1 -1
- package/ui/web/dist/index.html +2 -2
package/driver/wave_scheduler.py
CHANGED
|
@@ -10,6 +10,22 @@ WS3a pilot: deterministic one-cycle loop with CRITICAL GUARDRAILS:
|
|
|
10
10
|
6. Double-dispatch prevention: write "in_progress" status to tracker.json (atomic, conflict-detecting)
|
|
11
11
|
7. Bounded by: HALT file check (final gate before dispatch) + cost ceiling check
|
|
12
12
|
|
|
13
|
+
HS-2 BLOCK GATE (live orchestrator seat only; default Report shape unchanged):
|
|
14
|
+
- Items the seat BLOCKS get the TERMINAL tracker status "blocked" in the same
|
|
15
|
+
atomic write as shipped items' "in_progress" -> never re-selected/rebuilt
|
|
16
|
+
(the tracker state is the durable block record, not the journal entry).
|
|
17
|
+
- Report gains "blocked": [{slug, reason, quarantine}] (empty list when the
|
|
18
|
+
seat ran and blocked nothing). quarantine is "clean" | "errors" (+
|
|
19
|
+
quarantine_errors: the per-file error records -- refused code may still
|
|
20
|
+
be in the tree) | "skipped" (+ quarantine_skipped_reason) | "unknown"
|
|
21
|
+
(no build record). Also "orchestrator_gate": {seat, model, decisions,
|
|
22
|
+
verdict_counts{merge,block,escalate,undetermined,decision_failed},
|
|
23
|
+
blocked[], decision_failed[], seat_tokens_spent, status}. status is
|
|
24
|
+
"degraded" when the gate made zero successful decisions (all
|
|
25
|
+
DECISION_FAILED -> items still ship crash-only, but the outage is LOUD),
|
|
26
|
+
"no_decisions" when nothing was verified to review, else "active".
|
|
27
|
+
- success is False whenever any item was blocked (never silently True).
|
|
28
|
+
|
|
13
29
|
SINGLE-WRITER ASSUMPTION: This pilot assumes tracker.json is NOT edited concurrently by other
|
|
14
30
|
processes. Concurrent-writer safety checks detect conflicts and abort; full lock/StateAPI
|
|
15
31
|
integration is filed for next wave. Do NOT run multiple schedulers against the same tracker.
|
|
@@ -363,6 +379,7 @@ def build_wave_manifest(
|
|
|
363
379
|
def _write_tracker_status_atomic(
|
|
364
380
|
tracker_path: str, items_to_update: List[str], new_status: str, wave_id: str,
|
|
365
381
|
expected_hash: Optional[str] = None,
|
|
382
|
+
status_by_id: Optional[Dict[str, str]] = None,
|
|
366
383
|
) -> Tuple[bool, Optional[str]]:
|
|
367
384
|
"""Write item status updates to tracker.json atomically (P1-5, P6, HIGH).
|
|
368
385
|
|
|
@@ -375,6 +392,10 @@ def _write_tracker_status_atomic(
|
|
|
375
392
|
new_status: new status (should be "in_progress" for pilot)
|
|
376
393
|
wave_id: wave ID to record in notes
|
|
377
394
|
expected_hash: content hash from intake; if current content differs, abort with conflict
|
|
395
|
+
status_by_id: optional ADDITIONAL per-id statuses written in the same
|
|
396
|
+
atomic replace (HS-2 block gate: blocked items get the TERMINAL
|
|
397
|
+
"blocked" status alongside shipped items' "in_progress"). Entries
|
|
398
|
+
here override items_to_update on collision (terminal state wins).
|
|
378
399
|
|
|
379
400
|
Returns:
|
|
380
401
|
(success: bool, error_reason: str|None)
|
|
@@ -408,11 +429,14 @@ def _write_tracker_status_atomic(
|
|
|
408
429
|
else:
|
|
409
430
|
return False, "invalid_tracker_format"
|
|
410
431
|
|
|
411
|
-
# Update items
|
|
412
|
-
|
|
432
|
+
# Update items: base list gets new_status; status_by_id entries
|
|
433
|
+
# override (terminal states like "blocked" win over "in_progress").
|
|
434
|
+
status_map = {item_id: new_status for item_id in items_to_update}
|
|
435
|
+
if status_by_id:
|
|
436
|
+
status_map.update(status_by_id)
|
|
413
437
|
for item in items:
|
|
414
|
-
if item.get("id") in
|
|
415
|
-
item["status"] =
|
|
438
|
+
if item.get("id") in status_map:
|
|
439
|
+
item["status"] = status_map[item.get("id")]
|
|
416
440
|
notes = item.get("notes", "")
|
|
417
441
|
item["notes"] = f"{notes} [wave {wave_id[:8]}]".strip()
|
|
418
442
|
|
|
@@ -471,11 +495,28 @@ def emit_report(
|
|
|
471
495
|
tracker_unmapped_slugs: Optional[List[str]] = None,
|
|
472
496
|
success: bool = False,
|
|
473
497
|
merged: bool = False,
|
|
498
|
+
blocked: Optional[List[Dict[str, Any]]] = None,
|
|
499
|
+
orchestrator_gate: Optional[Dict[str, Any]] = None,
|
|
474
500
|
) -> Dict[str, Any]:
|
|
475
501
|
"""Emit a Report JSON structure (GATE-1 HANDOFF KIT).
|
|
476
502
|
|
|
477
503
|
Per-item observability: items_shipped includes full details {slug, backend, tier, verified, testExit}.
|
|
478
504
|
|
|
505
|
+
HS-2 block-gate fields (present ONLY when a live orchestrator seat ran;
|
|
506
|
+
the default no-seat Report shape is unchanged):
|
|
507
|
+
blocked: list of {slug, reason, quarantine} for items the seat refused
|
|
508
|
+
to ship (empty list when the seat ran and blocked nothing).
|
|
509
|
+
quarantine: "clean" | "errors" (+ quarantine_errors per-file
|
|
510
|
+
records: refused code may still be in the tree) | "skipped"
|
|
511
|
+
(+ quarantine_skipped_reason) | "unknown" (no build record).
|
|
512
|
+
orchestrator_gate: seat activity summary {seat, model, decisions,
|
|
513
|
+
verdict_counts {merge, block, escalate, undetermined,
|
|
514
|
+
decision_failed}, blocked [slugs], decision_failed [slugs],
|
|
515
|
+
seat_tokens_spent, status "active"|"degraded"|"no_decisions"}.
|
|
516
|
+
status "degraded" = the gate made ZERO successful decisions
|
|
517
|
+
(every one DECISION_FAILED): items still ship (crash-only),
|
|
518
|
+
but the outage is loud instead of invisible.
|
|
519
|
+
|
|
479
520
|
Returns:
|
|
480
521
|
report dict (ready to serialize)
|
|
481
522
|
"""
|
|
@@ -509,6 +550,13 @@ def emit_report(
|
|
|
509
550
|
report["tracker_update_attempted"] = True
|
|
510
551
|
if tracker_unmapped_slugs:
|
|
511
552
|
report["tracker_unmapped_slugs"] = tracker_unmapped_slugs
|
|
553
|
+
# HS-2 block gate: include the seat-observability lanes whenever a live
|
|
554
|
+
# seat ran (blocked may be an EMPTY list -- deterministic live-seat
|
|
555
|
+
# shape); both stay absent on the default no-seat path (no-op invariant).
|
|
556
|
+
if blocked is not None:
|
|
557
|
+
report["blocked"] = blocked
|
|
558
|
+
if orchestrator_gate is not None:
|
|
559
|
+
report["orchestrator_gate"] = orchestrator_gate
|
|
512
560
|
|
|
513
561
|
return report
|
|
514
562
|
|
|
@@ -523,6 +571,7 @@ def run_wave_scheduler(
|
|
|
523
571
|
dry_run: bool = False,
|
|
524
572
|
driver: Optional[AgentDriver] = None,
|
|
525
573
|
state_dir: Optional[Path] = None,
|
|
574
|
+
orchestrator_backend: Optional[Any] = None,
|
|
526
575
|
) -> Dict[str, Any]:
|
|
527
576
|
"""Run one complete wave cycle (intake -> manifest -> dispatch -> report).
|
|
528
577
|
|
|
@@ -532,6 +581,13 @@ def run_wave_scheduler(
|
|
|
532
581
|
dry_run: if True, print manifest without dispatch
|
|
533
582
|
driver: AgentDriver instance (defaults to FakeDriver for testing)
|
|
534
583
|
state_dir: state directory (defaults to ./state)
|
|
584
|
+
orchestrator_backend: optional OrchestratorBackend for the decision
|
|
585
|
+
seat (HS-2). None (default) keeps the live harness as the
|
|
586
|
+
orchestrator -- byte-identical to pre-HS-2, no key required.
|
|
587
|
+
A live backend (see resolve_orchestrator_backend) routes the
|
|
588
|
+
final_catch decision per verified item through run_wave's
|
|
589
|
+
Phase 6; verdict 'block' stops that item from shipping. The
|
|
590
|
+
Report JSON shape is IDENTICAL either way (swap transparency).
|
|
535
591
|
|
|
536
592
|
Returns:
|
|
537
593
|
Report dict (phase, wave_id, items_selected, items_shipped, etc.)
|
|
@@ -697,6 +753,7 @@ def run_wave_scheduler(
|
|
|
697
753
|
state_dir=state_dir,
|
|
698
754
|
git={"expectTopLevel": str(REPO)},
|
|
699
755
|
resume_journal=True,
|
|
756
|
+
orchestrator_backend=orchestrator_backend,
|
|
700
757
|
)
|
|
701
758
|
|
|
702
759
|
# P2c: verify no merged=True, record merged=false
|
|
@@ -725,14 +782,56 @@ def run_wave_scheduler(
|
|
|
725
782
|
"buildRecord": bool(b),
|
|
726
783
|
})
|
|
727
784
|
|
|
785
|
+
# HS-2 BLOCK GATE: extract the orchestrator seat's review (present
|
|
786
|
+
# ONLY when a live seat ran). Blocked items need a TERMINAL tracker
|
|
787
|
+
# state ("blocked", never left todo -> no rebuild loop) and a durable,
|
|
788
|
+
# observable lane in the Report.
|
|
789
|
+
review = wave_result.get("orchestrator_review")
|
|
790
|
+
blocked_slugs = []
|
|
791
|
+
blocked_lane = []
|
|
792
|
+
if isinstance(review, dict):
|
|
793
|
+
blocked_slugs = [s for s in (review.get("blocked") or [])]
|
|
794
|
+
reason_by_slug = {}
|
|
795
|
+
for d in review.get("blocked_detail") or []:
|
|
796
|
+
if isinstance(d, dict) and d.get("slug"):
|
|
797
|
+
reason_by_slug[d["slug"]] = d.get("reason")
|
|
798
|
+
built_by_slug = {
|
|
799
|
+
b_.get("slug"): b_
|
|
800
|
+
for b_ in (wave_result.get("built") or [])
|
|
801
|
+
if isinstance(b_, dict) and b_.get("slug")
|
|
802
|
+
}
|
|
803
|
+
for s in blocked_slugs:
|
|
804
|
+
entry = {
|
|
805
|
+
"slug": s,
|
|
806
|
+
"reason": reason_by_slug.get(s)
|
|
807
|
+
or "orchestrator final_catch verdict: block",
|
|
808
|
+
}
|
|
809
|
+
# QUARANTINE VISIBILITY: a failed quarantine (refused code
|
|
810
|
+
# still in the tree) must never look like a clean block.
|
|
811
|
+
q = (built_by_slug.get(s) or {}).get("quarantine")
|
|
812
|
+
if isinstance(q, dict):
|
|
813
|
+
if q.get("errors"):
|
|
814
|
+
entry["quarantine"] = "errors"
|
|
815
|
+
entry["quarantine_errors"] = q["errors"]
|
|
816
|
+
elif q.get("skipped_reason"):
|
|
817
|
+
entry["quarantine"] = "skipped"
|
|
818
|
+
entry["quarantine_skipped_reason"] = q["skipped_reason"]
|
|
819
|
+
else:
|
|
820
|
+
entry["quarantine"] = "clean"
|
|
821
|
+
else:
|
|
822
|
+
entry["quarantine"] = "unknown"
|
|
823
|
+
blocked_lane.append(entry)
|
|
824
|
+
|
|
728
825
|
# TRACKER UPDATE FIRST (live-pilot lesson: a crash in Report assembly
|
|
729
826
|
# must never leave shipped-but-unmarked items). Attempted as soon as
|
|
730
827
|
# shipped_slugs is known; outcome fields survive into ANY report,
|
|
731
|
-
# including the exception envelope below.
|
|
828
|
+
# including the exception envelope below. Blocked items are marked in
|
|
829
|
+
# the SAME atomic write: the tracker terminal state is the durable
|
|
830
|
+
# block record (the journal entry can be overwritten on resume).
|
|
732
831
|
tracker_update_attempted = False
|
|
733
832
|
tracker_update_error = None
|
|
734
833
|
tracker_unmapped = []
|
|
735
|
-
if shipped_slugs:
|
|
834
|
+
if shipped_slugs or blocked_slugs:
|
|
736
835
|
tracker_update_attempted = True
|
|
737
836
|
try:
|
|
738
837
|
slug_to_id = {it.get("slug"): it.get("id") for it in selected_items}
|
|
@@ -745,18 +844,28 @@ def run_wave_scheduler(
|
|
|
745
844
|
# LOUD, never silent: an unmapped shipped slug means the
|
|
746
845
|
# tracker cannot be marked -> double-dispatch risk.
|
|
747
846
|
tracker_unmapped.append(s_)
|
|
748
|
-
|
|
847
|
+
blocked_status_by_id = {}
|
|
848
|
+
for s_ in blocked_slugs:
|
|
849
|
+
id_ = slug_to_id.get(s_)
|
|
850
|
+
if id_:
|
|
851
|
+
blocked_status_by_id[id_] = "blocked"
|
|
852
|
+
else:
|
|
853
|
+
# Unmapped blocked slug = tracker stays todo = the
|
|
854
|
+
# rebuild-and-block loop this fix exists to stop.
|
|
855
|
+
tracker_unmapped.append(s_)
|
|
856
|
+
if shipped_item_ids or blocked_status_by_id:
|
|
749
857
|
success_update, update_error = _write_tracker_status_atomic(
|
|
750
858
|
tracker_path,
|
|
751
859
|
shipped_item_ids,
|
|
752
860
|
"in_progress",
|
|
753
861
|
wave_id,
|
|
754
862
|
expected_hash=intake_hash, # P6: conflict detection
|
|
863
|
+
status_by_id=blocked_status_by_id,
|
|
755
864
|
)
|
|
756
865
|
if not success_update:
|
|
757
866
|
tracker_update_error = update_error
|
|
758
867
|
if tracker_unmapped and tracker_update_error is None:
|
|
759
|
-
tracker_update_error = "
|
|
868
|
+
tracker_update_error = "unmapped_slugs"
|
|
760
869
|
except Exception as te:
|
|
761
870
|
tracker_update_error = f"tracker_update_exception: {te}"
|
|
762
871
|
|
|
@@ -765,9 +874,36 @@ def run_wave_scheduler(
|
|
|
765
874
|
sha = next((r.get("sha") for r in repo_results if isinstance(r, dict) and r.get("sha")), None)
|
|
766
875
|
branch = None # run_wave ships on the current branch; scheduler does not switch branches
|
|
767
876
|
|
|
877
|
+
# Orchestrator-gate summary for the Report (live seat only): the
|
|
878
|
+
# operator must be able to SEE the gate's real activity -- including
|
|
879
|
+
# a silently-neutered seat (every decision DECISION_FAILED).
|
|
880
|
+
orchestrator_gate = None
|
|
881
|
+
if isinstance(review, dict):
|
|
882
|
+
decisions = int(review.get("decisions") or 0)
|
|
883
|
+
failed_slugs = list(review.get("decision_failed") or [])
|
|
884
|
+
gate_status = review.get("gate_status")
|
|
885
|
+
if not gate_status:
|
|
886
|
+
if decisions == 0:
|
|
887
|
+
gate_status = "no_decisions"
|
|
888
|
+
elif len(failed_slugs) == decisions:
|
|
889
|
+
gate_status = "degraded"
|
|
890
|
+
else:
|
|
891
|
+
gate_status = "active"
|
|
892
|
+
orchestrator_gate = {
|
|
893
|
+
"seat": review.get("seat"),
|
|
894
|
+
"model": review.get("model"),
|
|
895
|
+
"decisions": decisions,
|
|
896
|
+
"verdict_counts": review.get("verdict_counts") or {},
|
|
897
|
+
"blocked": blocked_slugs,
|
|
898
|
+
"decision_failed": failed_slugs,
|
|
899
|
+
"seat_tokens_spent": review.get("seat_tokens_spent", 0),
|
|
900
|
+
"status": gate_status,
|
|
901
|
+
}
|
|
902
|
+
|
|
768
903
|
# run_wave has NO top-level "success" key: derive honestly. success
|
|
769
904
|
# additionally requires EVERY shipped item verified (contract: a
|
|
770
|
-
# shipped-but-unproven item is not a successful wave)
|
|
905
|
+
# shipped-but-unproven item is not a successful wave) and NO blocked
|
|
906
|
+
# items (a block is a refused ship, never a silent True).
|
|
771
907
|
wave_ok = bool(wave_result.get("preflight_ok")) and not wave_result.get("aborted")
|
|
772
908
|
all_verified = all(i.get("verified") for i in items_shipped) if items_shipped else True
|
|
773
909
|
return emit_report(
|
|
@@ -781,8 +917,15 @@ def run_wave_scheduler(
|
|
|
781
917
|
tracker_update_attempted=tracker_update_attempted,
|
|
782
918
|
tracker_update_error=tracker_update_error,
|
|
783
919
|
tracker_unmapped_slugs=tracker_unmapped if tracker_unmapped else None,
|
|
784
|
-
success=
|
|
920
|
+
success=(
|
|
921
|
+
wave_ok
|
|
922
|
+
and all_verified
|
|
923
|
+
and tracker_update_error is None
|
|
924
|
+
and not blocked_slugs
|
|
925
|
+
),
|
|
785
926
|
merged=False, # P2c: pilot stops before merge
|
|
927
|
+
blocked=blocked_lane if isinstance(review, dict) else None,
|
|
928
|
+
orchestrator_gate=orchestrator_gate,
|
|
786
929
|
)
|
|
787
930
|
|
|
788
931
|
except Exception as e:
|
|
@@ -804,8 +947,175 @@ def run_wave_scheduler(
|
|
|
804
947
|
# CLI
|
|
805
948
|
# ========================================================================
|
|
806
949
|
|
|
950
|
+
def resolve_worker_driver(
|
|
951
|
+
driver_override: Optional[str] = None,
|
|
952
|
+
config_path: Optional[str] = None,
|
|
953
|
+
execute: bool = False,
|
|
954
|
+
) -> Tuple[Optional[AgentDriver], Optional[str]]:
|
|
955
|
+
"""Resolve the worker-seat driver (HS-1): config-first, --driver overrides.
|
|
956
|
+
|
|
957
|
+
Resolution order:
|
|
958
|
+
1. driver_override 'claude'/'codex' -> that driver, exactly as the
|
|
959
|
+
legacy hardcoded CLI path behaved (including the codex+execute
|
|
960
|
+
OPENAI_API_KEY gate).
|
|
961
|
+
2. Otherwise: load aesop.config.json and activate a configured worker
|
|
962
|
+
ONLY when a seats.worker block is present (build_driver on the
|
|
963
|
+
validated seat). The seats block is the opt-in surface; it can
|
|
964
|
+
select ANY backend, including openai-compatible (previously
|
|
965
|
+
unreachable from this CLI).
|
|
966
|
+
3. No config file, no seats.worker -> ClaudeCodeDriver, byte-identical
|
|
967
|
+
to pre-0.4.0. A bare LEGACY FLAT backend block ({"backend": ...}
|
|
968
|
+
with no seats) stays INERT here: it was dead config before 0.4.0
|
|
969
|
+
(documented but consumed by nothing), and silently activating it
|
|
970
|
+
would change behavior on existing installs. Migrate it to
|
|
971
|
+
seats.worker to opt in. It still fails loud if malformed.
|
|
972
|
+
|
|
973
|
+
For hosted seats with execute=True, the seat's api_key_env (default
|
|
974
|
+
OPENAI_API_KEY) must be set; is_local seats need no key. Dry runs never
|
|
975
|
+
require a key (building a driver is offline-safe).
|
|
976
|
+
|
|
977
|
+
Returns:
|
|
978
|
+
(driver, None) on success; (None, error_message) on failure.
|
|
979
|
+
"""
|
|
980
|
+
key_env_default = "OPENAI" + "_" + "API" + "_" + "KEY"
|
|
981
|
+
|
|
982
|
+
if driver_override == "codex":
|
|
983
|
+
# CodexDriver requires OPENAI_API_KEY for execute; dry-run works without it
|
|
984
|
+
try:
|
|
985
|
+
from codex_driver import CodexDriver
|
|
986
|
+
except ImportError:
|
|
987
|
+
return None, "--driver codex requires codex_driver.py"
|
|
988
|
+
if execute and not os.environ.get(key_env_default):
|
|
989
|
+
return None, (
|
|
990
|
+
f"--driver codex --execute requires {key_env_default} "
|
|
991
|
+
f"environment variable"
|
|
992
|
+
)
|
|
993
|
+
return CodexDriver(), None
|
|
994
|
+
|
|
995
|
+
if driver_override == "claude":
|
|
996
|
+
try:
|
|
997
|
+
from claude_code_driver import ClaudeCodeDriver
|
|
998
|
+
except ImportError:
|
|
999
|
+
return None, "claude_code_driver.py not found"
|
|
1000
|
+
return ClaudeCodeDriver(), None
|
|
1001
|
+
|
|
1002
|
+
if driver_override is not None:
|
|
1003
|
+
return None, f"unknown --driver '{driver_override}' (claude|codex)"
|
|
1004
|
+
|
|
1005
|
+
# Default: read the config (seats.worker or legacy backend block).
|
|
1006
|
+
try:
|
|
1007
|
+
from backend_config import build_driver, load_backend_config
|
|
1008
|
+
except ImportError:
|
|
1009
|
+
return None, "backend_config.py not found"
|
|
1010
|
+
|
|
1011
|
+
try:
|
|
1012
|
+
config = load_backend_config(config_path)
|
|
1013
|
+
except (TypeError, ValueError) as exc:
|
|
1014
|
+
return None, f"invalid aesop.config.json: {exc}"
|
|
1015
|
+
|
|
1016
|
+
seats = config.get("seats")
|
|
1017
|
+
has_worker_seat = isinstance(seats, dict) and isinstance(
|
|
1018
|
+
seats.get("worker"), dict
|
|
1019
|
+
)
|
|
1020
|
+
if not has_worker_seat:
|
|
1021
|
+
# No seats.worker opt-in: byte-identical to pre-0.4.0 (Claude
|
|
1022
|
+
# worker), even when a legacy flat backend block is present -- that
|
|
1023
|
+
# block was dead config before 0.4.0 and stays inert here.
|
|
1024
|
+
try:
|
|
1025
|
+
from claude_code_driver import ClaudeCodeDriver
|
|
1026
|
+
except ImportError:
|
|
1027
|
+
return None, "claude_code_driver.py not found"
|
|
1028
|
+
return ClaudeCodeDriver(), None
|
|
1029
|
+
|
|
1030
|
+
backend_name = config.get("backend", "claude")
|
|
1031
|
+
if execute and backend_name in ("codex", "openai-compatible"):
|
|
1032
|
+
if backend_name == "codex":
|
|
1033
|
+
key_env = key_env_default
|
|
1034
|
+
is_local = False
|
|
1035
|
+
else:
|
|
1036
|
+
key_env = config.get("api_key_env") or key_env_default
|
|
1037
|
+
is_local = bool(config.get("is_local", False))
|
|
1038
|
+
if not is_local and not os.environ.get(key_env):
|
|
1039
|
+
return None, (
|
|
1040
|
+
f"configured backend '{backend_name}' with --execute requires "
|
|
1041
|
+
f"{key_env} environment variable"
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
try:
|
|
1045
|
+
return build_driver(config), None
|
|
1046
|
+
except (TypeError, ValueError, RuntimeError) as exc:
|
|
1047
|
+
return None, f"failed to build driver from config: {exc}"
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def resolve_orchestrator_backend(
|
|
1051
|
+
config_path: Optional[str] = None,
|
|
1052
|
+
execute: bool = False,
|
|
1053
|
+
) -> Tuple[Optional[Any], Optional[str]]:
|
|
1054
|
+
"""Resolve the orchestrator-seat backend (HS-2): config-first, opt-in.
|
|
1055
|
+
|
|
1056
|
+
Resolution:
|
|
1057
|
+
- No config file, no seats block, no seats.orchestrator, or backend
|
|
1058
|
+
'harness'/'claude' -> (None, None): the live harness stays the
|
|
1059
|
+
orchestrator. run_wave's Phase 6 is byte-identical to pre-HS-2 --
|
|
1060
|
+
no OpenAI backend constructed, no key required.
|
|
1061
|
+
- seats.orchestrator backend 'openai-compatible' -> a live
|
|
1062
|
+
OpenAICompatibleOrchestratorBackend built via
|
|
1063
|
+
build_orchestrator_backend(load_backend_config()). Construction is
|
|
1064
|
+
offline-safe (no key read until decide_call time); with execute=True
|
|
1065
|
+
a hosted (non-is_local) seat requires its api_key_env to be set,
|
|
1066
|
+
mirroring the worker-seat gate. Dry runs never require a key.
|
|
1067
|
+
- Malformed config -> (None, error_message): fail loud, never a
|
|
1068
|
+
silent fallback to the harness.
|
|
1069
|
+
|
|
1070
|
+
Returns:
|
|
1071
|
+
(backend_or_None, error_message_or_None).
|
|
1072
|
+
"""
|
|
1073
|
+
key_env_default = "OPENAI" + "_" + "API" + "_" + "KEY"
|
|
1074
|
+
|
|
1075
|
+
try:
|
|
1076
|
+
from backend_config import build_orchestrator_backend, load_backend_config
|
|
1077
|
+
except ImportError:
|
|
1078
|
+
return None, "backend_config.py not found"
|
|
1079
|
+
|
|
1080
|
+
try:
|
|
1081
|
+
config = load_backend_config(config_path)
|
|
1082
|
+
except (TypeError, ValueError) as exc:
|
|
1083
|
+
return None, f"invalid aesop.config.json: {exc}"
|
|
1084
|
+
|
|
1085
|
+
seats = config.get("seats")
|
|
1086
|
+
orch_seat = seats.get("orchestrator") if isinstance(seats, dict) else None
|
|
1087
|
+
if not isinstance(orch_seat, dict) or not orch_seat:
|
|
1088
|
+
return None, None
|
|
1089
|
+
if orch_seat.get("backend", "harness") in ("harness", "claude"):
|
|
1090
|
+
return None, None
|
|
1091
|
+
|
|
1092
|
+
try:
|
|
1093
|
+
backend = build_orchestrator_backend(config)
|
|
1094
|
+
except (TypeError, ValueError, RuntimeError) as exc:
|
|
1095
|
+
return None, f"failed to build orchestrator backend from config: {exc}"
|
|
1096
|
+
|
|
1097
|
+
# Defensive: the builder returns the null harness backend for any
|
|
1098
|
+
# residual default path -> that is the no-op seat, pass None through.
|
|
1099
|
+
try:
|
|
1100
|
+
from orchestrator_backend import HarnessOrchestratorBackend
|
|
1101
|
+
if isinstance(backend, HarnessOrchestratorBackend):
|
|
1102
|
+
return None, None
|
|
1103
|
+
except ImportError:
|
|
1104
|
+
pass
|
|
1105
|
+
|
|
1106
|
+
if execute and not bool(orch_seat.get("is_local", False)):
|
|
1107
|
+
key_env = orch_seat.get("api_key_env") or key_env_default
|
|
1108
|
+
if not os.environ.get(key_env):
|
|
1109
|
+
return None, (
|
|
1110
|
+
f"configured seats.orchestrator with --execute requires "
|
|
1111
|
+
f"{key_env} environment variable"
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
return backend, None
|
|
1115
|
+
|
|
1116
|
+
|
|
807
1117
|
def main():
|
|
808
|
-
"""CLI entry point (
|
|
1118
|
+
"""CLI entry point (HS-1: config-driven worker seat; --driver overrides)."""
|
|
809
1119
|
parser = argparse.ArgumentParser(
|
|
810
1120
|
description="Wave scheduler: intake -> manifest -> dispatch -> report"
|
|
811
1121
|
)
|
|
@@ -837,38 +1147,51 @@ def main():
|
|
|
837
1147
|
parser.add_argument(
|
|
838
1148
|
"--driver",
|
|
839
1149
|
choices=["claude", "codex"],
|
|
840
|
-
default=
|
|
841
|
-
help=
|
|
1150
|
+
default=None,
|
|
1151
|
+
help=(
|
|
1152
|
+
"OVERRIDE the configured worker seat (claude|codex). "
|
|
1153
|
+
"Default: read aesop.config.json seats.worker (no seats block "
|
|
1154
|
+
"-> claude; a legacy flat backend block stays inert -- migrate "
|
|
1155
|
+
"it to seats.worker); the config path also supports "
|
|
1156
|
+
"openai-compatible backends"
|
|
1157
|
+
),
|
|
1158
|
+
)
|
|
1159
|
+
parser.add_argument(
|
|
1160
|
+
"--config",
|
|
1161
|
+
default=None,
|
|
1162
|
+
help="Path to aesop.config.json (default: ./aesop.config.json)",
|
|
842
1163
|
)
|
|
843
1164
|
|
|
844
1165
|
args = parser.parse_args()
|
|
845
1166
|
|
|
846
1167
|
dry_run = not args.execute
|
|
847
1168
|
|
|
848
|
-
#
|
|
849
|
-
driver =
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1169
|
+
# HS-1: worker seat from config; --driver claude|codex remains an override.
|
|
1170
|
+
driver, driver_error = resolve_worker_driver(
|
|
1171
|
+
driver_override=args.driver,
|
|
1172
|
+
config_path=args.config,
|
|
1173
|
+
execute=args.execute,
|
|
1174
|
+
)
|
|
1175
|
+
if driver_error:
|
|
1176
|
+
print(f"ERROR: {driver_error}", file=sys.stderr)
|
|
1177
|
+
sys.exit(1)
|
|
1178
|
+
|
|
1179
|
+
# HS-2: orchestrator seat from config (seats.orchestrator). None = the
|
|
1180
|
+
# live harness stays the orchestrator (byte-identical default).
|
|
1181
|
+
orchestrator_backend, orch_error = resolve_orchestrator_backend(
|
|
1182
|
+
config_path=args.config,
|
|
1183
|
+
execute=args.execute,
|
|
1184
|
+
)
|
|
1185
|
+
if orch_error:
|
|
1186
|
+
print(f"ERROR: {orch_error}", file=sys.stderr)
|
|
1187
|
+
sys.exit(1)
|
|
1188
|
+
if orchestrator_backend is not None:
|
|
1189
|
+
print(
|
|
1190
|
+
"[wave_scheduler] orchestrator seat: "
|
|
1191
|
+
f"{type(orchestrator_backend).__name__} "
|
|
1192
|
+
f"(model={getattr(orchestrator_backend, 'model', None)})",
|
|
1193
|
+
file=sys.stderr,
|
|
1194
|
+
)
|
|
872
1195
|
|
|
873
1196
|
# Run scheduler
|
|
874
1197
|
report = run_wave_scheduler(
|
|
@@ -877,6 +1200,7 @@ def main():
|
|
|
877
1200
|
dry_run=dry_run,
|
|
878
1201
|
driver=driver,
|
|
879
1202
|
state_dir=Path(args.state_dir) if args.state_dir else None,
|
|
1203
|
+
orchestrator_backend=orchestrator_backend,
|
|
880
1204
|
)
|
|
881
1205
|
|
|
882
1206
|
# Output report as JSON
|
|
@@ -613,6 +613,72 @@ function detectIsolationViolations() {
|
|
|
613
613
|
return { violations, count: violations.length };
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
+
// 11b) Main CI status polling
|
|
617
|
+
// Calls `gh run list` to get latest main-full workflow run status; never throws
|
|
618
|
+
function checkMainCiStatus() {
|
|
619
|
+
try {
|
|
620
|
+
// Execute gh command to get latest main-full workflow run
|
|
621
|
+
const result = execSync(
|
|
622
|
+
'gh run list --workflow=main-full.yml --branch main --limit 1 --json status,conclusion,headSha,url',
|
|
623
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
|
|
624
|
+
).trim();
|
|
625
|
+
|
|
626
|
+
if (!result) {
|
|
627
|
+
// No runs found (workflow may not have run yet)
|
|
628
|
+
return {
|
|
629
|
+
state: 'unknown',
|
|
630
|
+
reason: 'No main-full workflow runs found',
|
|
631
|
+
checked_at: new Date(now).toISOString(),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const runs = JSON.parse(result);
|
|
636
|
+
if (!Array.isArray(runs) || runs.length === 0) {
|
|
637
|
+
return {
|
|
638
|
+
state: 'unknown',
|
|
639
|
+
reason: 'No main-full workflow runs found',
|
|
640
|
+
checked_at: new Date(now).toISOString(),
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const latestRun = runs[0];
|
|
645
|
+
const status = latestRun.status || '';
|
|
646
|
+
const conclusion = latestRun.conclusion || '';
|
|
647
|
+
const sha = latestRun.headSha || '';
|
|
648
|
+
const url = latestRun.url || '';
|
|
649
|
+
|
|
650
|
+
// Map status and conclusion to state
|
|
651
|
+
let state = 'unknown';
|
|
652
|
+
if (status === 'completed') {
|
|
653
|
+
if (conclusion === 'success') {
|
|
654
|
+
state = 'pass';
|
|
655
|
+
} else if (conclusion === 'failure') {
|
|
656
|
+
state = 'fail';
|
|
657
|
+
} else {
|
|
658
|
+
state = 'unknown';
|
|
659
|
+
}
|
|
660
|
+
} else if (status === 'in_progress') {
|
|
661
|
+
state = 'running';
|
|
662
|
+
} else {
|
|
663
|
+
state = 'unknown';
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
state,
|
|
668
|
+
sha,
|
|
669
|
+
url,
|
|
670
|
+
checked_at: new Date(now).toISOString(),
|
|
671
|
+
};
|
|
672
|
+
} catch (e) {
|
|
673
|
+
// gh command failed, missing, or unauthed — return unknown gracefully
|
|
674
|
+
return {
|
|
675
|
+
state: 'unknown',
|
|
676
|
+
reason: e.message || 'gh command unavailable or failed',
|
|
677
|
+
checked_at: new Date(now).toISOString(),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
616
682
|
// 12) Agent stall detection
|
|
617
683
|
// Calls tools/stall_check.py --json with bounded timeout; fails gracefully with NOT-AVAILABLE signal
|
|
618
684
|
function checkAgentStalls() {
|
|
@@ -891,6 +957,7 @@ const gitState = checkGitState();
|
|
|
891
957
|
const memory = checkMemoryFreshness();
|
|
892
958
|
const logFiles = checkLogFiles(securityAlertsContent);
|
|
893
959
|
const isolationViolations = detectIsolationViolations();
|
|
960
|
+
const mainCi = checkMainCiStatus();
|
|
894
961
|
const agentStalls = checkAgentStalls();
|
|
895
962
|
|
|
896
963
|
// Extended signal checks (5, 6, 8, 10) — skipped if extended_signals is OFF
|
|
@@ -941,6 +1008,7 @@ const signals = {
|
|
|
941
1008
|
costTick,
|
|
942
1009
|
unreviewedPrompts,
|
|
943
1010
|
isolationViolations,
|
|
1011
|
+
main_ci: mainCi,
|
|
944
1012
|
agentStalls,
|
|
945
1013
|
};
|
|
946
1014
|
|
|
@@ -1015,6 +1083,21 @@ if (!agentStalls.available) {
|
|
|
1015
1083
|
}
|
|
1016
1084
|
brief.push('');
|
|
1017
1085
|
|
|
1086
|
+
brief.push('## Main CI status (main-full workflow)');
|
|
1087
|
+
if (mainCi.state === 'pass') {
|
|
1088
|
+
brief.push('✓ Latest main-full run passed.');
|
|
1089
|
+
} else if (mainCi.state === 'fail') {
|
|
1090
|
+
brief.push(`🚨 **Latest main-full run FAILED:**`);
|
|
1091
|
+
if (mainCi.sha) brief.push(` SHA: ${mainCi.sha}`);
|
|
1092
|
+
if (mainCi.url) brief.push(` URL: ${mainCi.url}`);
|
|
1093
|
+
} else if (mainCi.state === 'running') {
|
|
1094
|
+
brief.push('⏳ Main-full workflow currently running...');
|
|
1095
|
+
if (mainCi.url) brief.push(` URL: ${mainCi.url}`);
|
|
1096
|
+
} else {
|
|
1097
|
+
brief.push(`⚠ Main-full status unknown: ${mainCi.reason || 'no data available'}`);
|
|
1098
|
+
}
|
|
1099
|
+
brief.push('');
|
|
1100
|
+
|
|
1018
1101
|
// Extended signals section (if disabled, just note they're off; if enabled, show details)
|
|
1019
1102
|
if (extendedSignals) {
|
|
1020
1103
|
brief.push('## Junk-script sprawl (temp/scratch)');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matt82198/aesop",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Multi-agent orchestration for AI coding agents, designed to survive failure — a plain-file brain, git as the only durable layer, cheap subagent fleets, and guardrails enforced in code.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"aesop": "bin/cli.js"
|