@matt82198/aesop 0.3.1 → 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 +34 -1
- package/README.md +26 -11
- package/aesop.config.example.json +16 -0
- package/bin/cli.js +5 -2
- package/daemons/install-tasks.ps1 +193 -0
- package/daemons/run-hidden.vbs +39 -0
- package/docs/INSTALL.md +148 -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 +3 -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
|
@@ -18,7 +18,38 @@ from __future__ import annotations
|
|
|
18
18
|
import time
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def
|
|
21
|
+
def _read_claim_events(store) -> list:
|
|
22
|
+
"""Read the claims stream from a StateAPI (.get) OR an EventStore (.read).
|
|
23
|
+
|
|
24
|
+
RS3-W: wave_loop passes a raw EventStore, which exposes read() but not
|
|
25
|
+
get(). try_claim called store.get() unconditionally, so EVERY claim
|
|
26
|
+
attempt raised AttributeError and fail-closed to False -- the claim gate
|
|
27
|
+
was dead code and items fell through to claim-less dispatch paths.
|
|
28
|
+
"""
|
|
29
|
+
getter = getattr(store, "get", None)
|
|
30
|
+
if callable(getter):
|
|
31
|
+
return getter("claims")
|
|
32
|
+
return store.read("claims")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _claim_expired(ev: dict, payload: dict, now: float) -> bool:
|
|
36
|
+
"""True when a claim_requested event is past its TTL (RS3-W N4).
|
|
37
|
+
|
|
38
|
+
A claim whose event timestamp plus payload ttl is in the past is treated
|
|
39
|
+
as released: a crashed holder's claim must not persist forever. Events
|
|
40
|
+
without a ttl or timestamp (legacy) never expire (backward compatible).
|
|
41
|
+
"""
|
|
42
|
+
ttl = payload.get("ttl")
|
|
43
|
+
ts = ev.get("ts")
|
|
44
|
+
if ttl is None or ts is None:
|
|
45
|
+
return False
|
|
46
|
+
try:
|
|
47
|
+
return float(now) > float(ts) + float(ttl)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fold_claims(events: list, now: float | None = None) -> dict[str, str]:
|
|
22
53
|
"""Fold a claims stream into the current state of who holds each resource.
|
|
23
54
|
|
|
24
55
|
Processes claim_requested and claim_released events to determine the winner
|
|
@@ -28,14 +59,23 @@ def fold_claims(events: list) -> dict[str, str]:
|
|
|
28
59
|
subsequent claim_released event. If an instance releases and then re-claims,
|
|
29
60
|
the re-claim is the new active claim for that instance.
|
|
30
61
|
|
|
62
|
+
TTL enforcement (RS3-W N4): a claim_requested event whose ts + payload
|
|
63
|
+
ttl is earlier than ``now`` is EXPIRED and ignored -- a crashed instance's
|
|
64
|
+
claim becomes claimable again after its TTL instead of persisting forever.
|
|
65
|
+
Legacy events without ts/ttl never expire.
|
|
66
|
+
|
|
31
67
|
Args:
|
|
32
68
|
events: list of event dicts from the claims stream
|
|
33
69
|
(as returned by EventStore.read('claims'))
|
|
70
|
+
now: reference time for TTL expiry (default: time.time())
|
|
34
71
|
|
|
35
72
|
Returns:
|
|
36
73
|
dict mapping resource_id -> holding_instance_id for all currently
|
|
37
74
|
held resources. Empty dict if no claims exist or all have been released.
|
|
38
75
|
"""
|
|
76
|
+
if now is None:
|
|
77
|
+
now = time.time()
|
|
78
|
+
|
|
39
79
|
# Track all claims by resource and instance (as list, to preserve order)
|
|
40
80
|
claims_by_resource = {} # resource -> {instance_id: [versions...]}
|
|
41
81
|
# Track all releases by resource and instance (as sorted list)
|
|
@@ -48,6 +88,9 @@ def fold_claims(events: list) -> dict[str, str]:
|
|
|
48
88
|
version = ev.get("version", 0)
|
|
49
89
|
|
|
50
90
|
if etype == "claim_requested":
|
|
91
|
+
if _claim_expired(ev, payload, now):
|
|
92
|
+
# Expired claim: releasable/ignored (never a live holder).
|
|
93
|
+
continue
|
|
51
94
|
resource = payload.get("resource")
|
|
52
95
|
instance_id = payload.get("instance_id")
|
|
53
96
|
if resource is not None and instance_id is not None:
|
|
@@ -119,7 +162,8 @@ def try_claim(store, resource: str, instance_id: str, ttl: float = 300.0) -> boo
|
|
|
119
162
|
in the stream could later become the winner if the true holder releases.
|
|
120
163
|
|
|
121
164
|
Args:
|
|
122
|
-
store: StateAPI or EventStore instance (must have append()
|
|
165
|
+
store: StateAPI or EventStore instance (must have append() plus
|
|
166
|
+
get() or read())
|
|
123
167
|
resource: the resource identifier to claim (e.g., "wave_123", "lane_0", ...)
|
|
124
168
|
instance_id: the instance identifier requesting the claim
|
|
125
169
|
ttl: time-to-live in seconds (default 300s = 5min); embedded in the payload
|
|
@@ -140,7 +184,7 @@ def try_claim(store, resource: str, instance_id: str, ttl: float = 300.0) -> boo
|
|
|
140
184
|
)
|
|
141
185
|
|
|
142
186
|
# Re-read claims stream and fold to see current state
|
|
143
|
-
events = store
|
|
187
|
+
events = _read_claim_events(store)
|
|
144
188
|
claims = fold_claims(events)
|
|
145
189
|
|
|
146
190
|
# Check if we won
|
|
@@ -162,7 +206,23 @@ def try_claim(store, resource: str, instance_id: str, ttl: float = 300.0) -> boo
|
|
|
162
206
|
|
|
163
207
|
return False
|
|
164
208
|
except Exception:
|
|
165
|
-
# Fail-closed: any exception means we don't hold the claim
|
|
209
|
+
# Fail-closed: any exception means we don't hold the claim.
|
|
210
|
+
# RS5 F2: our claim_requested may already be in the stream (append
|
|
211
|
+
# succeeded, then the re-read/fold raised -- e.g. SQLite lock retries
|
|
212
|
+
# exhausted). Left un-retracted it becomes a PHANTOM holder: it wins
|
|
213
|
+
# as soon as the true holder releases, blocking the resource for a
|
|
214
|
+
# full TTL while we believe we failed. Best-effort retract our own
|
|
215
|
+
# request before returning False; if the retract itself fails there
|
|
216
|
+
# is nothing more we can do (TTL expiry remains the backstop).
|
|
217
|
+
try:
|
|
218
|
+
store.append(
|
|
219
|
+
"claims",
|
|
220
|
+
"claim_released",
|
|
221
|
+
{"resource": resource, "instance_id": instance_id},
|
|
222
|
+
actor=instance_id,
|
|
223
|
+
)
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
166
226
|
return False
|
|
167
227
|
|
|
168
228
|
|
|
@@ -201,7 +261,7 @@ def current_holder(store, resource: str) -> str | None:
|
|
|
201
261
|
The instance_id of the current holder, or None if unclaimed.
|
|
202
262
|
"""
|
|
203
263
|
try:
|
|
204
|
-
events = store
|
|
264
|
+
events = _read_claim_events(store)
|
|
205
265
|
claims = fold_claims(events)
|
|
206
266
|
return claims.get(resource)
|
|
207
267
|
except Exception:
|
package/tools/ci_merge_wait.py
CHANGED
|
@@ -44,6 +44,7 @@ def run_gh_command(args):
|
|
|
44
44
|
"""
|
|
45
45
|
Run gh CLI command; return parsed JSON or None if gh missing/error.
|
|
46
46
|
Raises subprocess.CalledProcessError on non-zero exit.
|
|
47
|
+
N9 FIX: Raises TimeoutExpired on timeout (caller handles transient errors).
|
|
47
48
|
"""
|
|
48
49
|
try:
|
|
49
50
|
result = subprocess.run(
|
|
@@ -59,8 +60,8 @@ def run_gh_command(args):
|
|
|
59
60
|
result.check_returncode()
|
|
60
61
|
return json.loads(result.stdout) if result.stdout.strip() else None
|
|
61
62
|
except subprocess.TimeoutExpired:
|
|
62
|
-
|
|
63
|
-
|
|
63
|
+
# N9 FIX: Re-raise timeout so caller (poll loop) can retry
|
|
64
|
+
raise
|
|
64
65
|
except FileNotFoundError:
|
|
65
66
|
print("ERROR: gh CLI not found on PATH")
|
|
66
67
|
sys.exit(1)
|
|
@@ -69,12 +70,13 @@ def run_gh_command(args):
|
|
|
69
70
|
def get_pr_status(pr_number):
|
|
70
71
|
"""
|
|
71
72
|
Fetch PR status via gh pr view.
|
|
72
|
-
Returns dict with 'mergeable' and '
|
|
73
|
+
Returns dict with 'mergeable', 'statusCheckRollup', and 'headRefOid' keys.
|
|
73
74
|
Returns None if gh is missing.
|
|
75
|
+
F5 FIX: headRefOid is fetched to enable SHA-pinned merge via --match-head-commit.
|
|
74
76
|
"""
|
|
75
77
|
data = run_gh_command([
|
|
76
78
|
"gh", "pr", "view", str(pr_number),
|
|
77
|
-
"--json", "mergeable,statusCheckRollup"
|
|
79
|
+
"--json", "mergeable,statusCheckRollup,headRefOid"
|
|
78
80
|
])
|
|
79
81
|
return data
|
|
80
82
|
|
|
@@ -119,8 +121,13 @@ def check_ci_status(status_rollup, allow_no_checks=False, expected_checks=None):
|
|
|
119
121
|
Returns: ("pending", None), ("success", None), or ("failure", check_name)
|
|
120
122
|
"""
|
|
121
123
|
# Fail-closed: empty rollup defaults to PENDING unless explicitly allowed
|
|
124
|
+
# F2 FIX: expected_checks takes PRECEDENCE over allow_no_checks
|
|
125
|
+
# If expected_checks is non-empty and rollup is empty, return pending (missing required checks)
|
|
122
126
|
if not status_rollup:
|
|
123
|
-
if
|
|
127
|
+
if expected_checks:
|
|
128
|
+
# Expected checks are required but rollup is empty (they're missing)
|
|
129
|
+
return ("pending", None)
|
|
130
|
+
elif allow_no_checks:
|
|
124
131
|
return ("success", None)
|
|
125
132
|
else:
|
|
126
133
|
# Empty rollup: fail-closed to PENDING (window where checks vanished/haven't registered yet)
|
|
@@ -148,12 +155,13 @@ def check_ci_status(status_rollup, allow_no_checks=False, expected_checks=None):
|
|
|
148
155
|
elif not conclusion or conclusion == "":
|
|
149
156
|
# COMPLETED with null/empty conclusion = fail-closed to PENDING (API anomaly)
|
|
150
157
|
check_status = "pending"
|
|
151
|
-
elif conclusion.upper() in ("NEUTRAL", "SKIPPED"):
|
|
152
|
-
#
|
|
158
|
+
elif conclusion.upper() in ("SUCCESS", "NEUTRAL", "SKIPPED"):
|
|
159
|
+
# SUCCESS, NEUTRAL, SKIPPED are all passing/non-blocking
|
|
153
160
|
check_status = "success"
|
|
154
161
|
else:
|
|
155
|
-
#
|
|
156
|
-
|
|
162
|
+
# F6 FIX: Unknown conclusion values must fail-closed to PENDING (not success)
|
|
163
|
+
# Future GitHub API conclusion values should not auto-succeed
|
|
164
|
+
check_status = "pending"
|
|
157
165
|
elif status in ("QUEUED", "IN_PROGRESS"):
|
|
158
166
|
check_status = "pending"
|
|
159
167
|
else:
|
|
@@ -202,20 +210,16 @@ def check_ci_status(status_rollup, allow_no_checks=False, expected_checks=None):
|
|
|
202
210
|
# Expected check still pending
|
|
203
211
|
return ("pending", None)
|
|
204
212
|
|
|
205
|
-
# All expected checks passed
|
|
206
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
# All expected checks passed - merge is allowed even if non-expected checks are failing
|
|
217
|
-
# (pending non-expected checks are also acceptable)
|
|
218
|
-
return ("success", None)
|
|
213
|
+
# All expected checks passed. Now check if ANY check failed (expected or not).
|
|
214
|
+
# CRITICAL (BL4 P2 fix): Any check failure blocks merge, not just expected checks.
|
|
215
|
+
# The expected_checks parameter only REQUIRES certain checks to be present and pass,
|
|
216
|
+
# but doesn't exempt other checks from the failure rule (fail-closed semantics).
|
|
217
|
+
if failed_checks:
|
|
218
|
+
return ("failure", failed_checks[0])
|
|
219
|
+
elif pending_checks:
|
|
220
|
+
return ("pending", None)
|
|
221
|
+
else:
|
|
222
|
+
return ("success", None)
|
|
219
223
|
|
|
220
224
|
# Determine overall status (when expected_checks is not specified)
|
|
221
225
|
if failed_checks:
|
|
@@ -226,19 +230,32 @@ def check_ci_status(status_rollup, allow_no_checks=False, expected_checks=None):
|
|
|
226
230
|
return ("success", None)
|
|
227
231
|
|
|
228
232
|
|
|
229
|
-
def merge_pr(pr_number, merge_method, dry_run=False):
|
|
233
|
+
def merge_pr(pr_number, merge_method, dry_run=False, head_ref_oid=None):
|
|
230
234
|
"""
|
|
231
|
-
Merge the PR using gh pr merge.
|
|
235
|
+
Merge the PR using gh pr merge with SHA-pinned commit.
|
|
232
236
|
This call is STRUCTURALLY UNREACHABLE unless CI is SUCCESS.
|
|
237
|
+
F5 FIX: Uses --match-head-commit <sha> to ensure merge targets the exact commit
|
|
238
|
+
whose CI checks passed, preventing TOCTOU race where a push between CI check and
|
|
239
|
+
merge could sneak in an untested commit.
|
|
240
|
+
RS-B FIX: Requires headRefOid to be present (fail-closed). Does NOT merge unpinned.
|
|
233
241
|
If dry_run is True, report what would be done without actually merging.
|
|
234
242
|
Returns True on success, False on error.
|
|
235
243
|
"""
|
|
244
|
+
# RS-B FIX: Fail-closed if headRefOid is missing - require the SHA pin or abort
|
|
245
|
+
if not head_ref_oid:
|
|
246
|
+
print("ERROR: Cannot merge without SHA pin (headRefOid missing). Aborting merge to prevent TOCTOU race.")
|
|
247
|
+
return False
|
|
248
|
+
|
|
236
249
|
if dry_run:
|
|
237
|
-
print(f"[DRY-RUN] Would merge PR #{pr_number} with --{merge_method}")
|
|
250
|
+
print(f"[DRY-RUN] Would merge PR #{pr_number} with --{merge_method} --match-head-commit {head_ref_oid}")
|
|
238
251
|
return True
|
|
239
252
|
|
|
253
|
+
merge_cmd = ["gh", "pr", "merge", str(pr_number), f"--{merge_method}"]
|
|
254
|
+
# F5 FIX: Add --match-head-commit to pin merge to the SHA that passed CI
|
|
255
|
+
merge_cmd.extend(["--match-head-commit", head_ref_oid])
|
|
256
|
+
|
|
240
257
|
result = subprocess.run(
|
|
241
|
-
|
|
258
|
+
merge_cmd,
|
|
242
259
|
capture_output=True,
|
|
243
260
|
text=True,
|
|
244
261
|
timeout=30,
|
|
@@ -467,10 +484,10 @@ def run_self_test():
|
|
|
467
484
|
return False
|
|
468
485
|
print("[OK] Expected check failed: returns FAILURE")
|
|
469
486
|
|
|
470
|
-
# Test 20b: Expected checks all pass, but non-expected check FAILED (P2 audit bug fix)
|
|
471
|
-
# FIXED: When --expect-checks is given,
|
|
472
|
-
#
|
|
473
|
-
#
|
|
487
|
+
# Test 20b: Expected checks all pass, but non-expected check FAILED (P2 audit bug fix - BL4)
|
|
488
|
+
# FIXED: When --expect-checks is given, ANY check failure (expected or not) must block merge.
|
|
489
|
+
# The --expect-checks parameter only REQUIRES certain checks to be present and pass,
|
|
490
|
+
# but doesn't exempt other checks from the failure rule (fail-closed semantics).
|
|
474
491
|
rollup_expected_pass_noncxpected_fail = [
|
|
475
492
|
{"name": "unit-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
|
|
476
493
|
{"name": "integration-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
|
|
@@ -480,12 +497,12 @@ def run_self_test():
|
|
|
480
497
|
rollup_expected_pass_noncxpected_fail,
|
|
481
498
|
expected_checks={"unit-tests", "integration-tests"}
|
|
482
499
|
)
|
|
483
|
-
if ci_status != "
|
|
484
|
-
print(f"FAIL: Expected '
|
|
500
|
+
if ci_status != "failure" or failed_check != "lint":
|
|
501
|
+
print(f"FAIL: Expected 'failure' with non-expected check failed (BL4 P2 fix), got '{ci_status}' / '{failed_check}'")
|
|
485
502
|
return False
|
|
486
|
-
print("[OK] Non-expected check failed while expected pass: returns
|
|
503
|
+
print("[OK] Non-expected check failed while expected pass: returns FAILURE (BL4 P2 audit fix)")
|
|
487
504
|
|
|
488
|
-
# Test 20c: Expected checks all pass, non-expected pending
|
|
505
|
+
# Test 20c: Expected checks all pass, but non-expected pending (must wait for completion)
|
|
489
506
|
rollup_expected_pass_noncexpected_pending = [
|
|
490
507
|
{"name": "unit-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
|
|
491
508
|
{"name": "integration-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
|
|
@@ -495,10 +512,10 @@ def run_self_test():
|
|
|
495
512
|
rollup_expected_pass_noncexpected_pending,
|
|
496
513
|
expected_checks={"unit-tests", "integration-tests"}
|
|
497
514
|
)
|
|
498
|
-
if ci_status != "
|
|
499
|
-
print(f"FAIL: Expected '
|
|
515
|
+
if ci_status != "pending":
|
|
516
|
+
print(f"FAIL: Expected 'pending' when all expected pass + non-expected pending, got '{ci_status}'")
|
|
500
517
|
return False
|
|
501
|
-
print("[OK] Expected all pass, non-expected pending: returns
|
|
518
|
+
print("[OK] Expected all pass, non-expected pending: returns PENDING (wait for all checks)")
|
|
502
519
|
|
|
503
520
|
# Test 22: CheckRun STALE conclusion (invalidated check, counts as failure)
|
|
504
521
|
stale_check = [
|
|
@@ -659,7 +676,16 @@ def main():
|
|
|
659
676
|
print(f"TIMEOUT: CI did not conclude within {args.timeout}s")
|
|
660
677
|
sys.exit(3)
|
|
661
678
|
|
|
662
|
-
|
|
679
|
+
# N9 FIX: Catch transient gh errors (CalledProcessError, TimeoutExpired) and retry
|
|
680
|
+
try:
|
|
681
|
+
status = get_pr_status(args.pr_number)
|
|
682
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
683
|
+
print(f"TRANSIENT ERROR: gh command failed ({type(e).__name__}), retrying...")
|
|
684
|
+
# Treat as "status unknown this tick", continue polling
|
|
685
|
+
print(f"CI PENDING ({elapsed:.0f}s elapsed)... waiting {args.poll}s")
|
|
686
|
+
time.sleep(args.poll)
|
|
687
|
+
continue
|
|
688
|
+
|
|
663
689
|
if status is None:
|
|
664
690
|
print("ERROR: gh CLI check failed")
|
|
665
691
|
sys.exit(1)
|
|
@@ -677,7 +703,16 @@ def main():
|
|
|
677
703
|
elif ci_status == "success":
|
|
678
704
|
# SUCCESS: CI is green, re-check immediately before proceeding
|
|
679
705
|
print(f"CI GREEN: All checks passed. Re-checking status before merge...")
|
|
680
|
-
|
|
706
|
+
# N9 FIX: Also catch transient errors on final check
|
|
707
|
+
try:
|
|
708
|
+
final_check = get_pr_status(args.pr_number)
|
|
709
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
|
710
|
+
print(f"TRANSIENT ERROR: final gh check failed ({type(e).__name__}), retrying...")
|
|
711
|
+
# Treat as status change, go back to polling
|
|
712
|
+
print(f"CI PENDING ({elapsed:.0f}s elapsed)... waiting {args.poll}s")
|
|
713
|
+
time.sleep(args.poll)
|
|
714
|
+
continue
|
|
715
|
+
|
|
681
716
|
if final_check is None:
|
|
682
717
|
print("ERROR: final status check failed")
|
|
683
718
|
sys.exit(1)
|
|
@@ -695,19 +730,30 @@ def main():
|
|
|
695
730
|
|
|
696
731
|
# SUCCESS: CI is still green, proceed to merge
|
|
697
732
|
# This merge call is STRUCTURALLY UNREACHABLE unless ci_status == "success"
|
|
733
|
+
# F5 FIX: Capture headRefOid for SHA-pinned merge (prevents TOCTOU)
|
|
734
|
+
head_ref_oid = final_check.get("headRefOid")
|
|
698
735
|
if args.dry_run:
|
|
699
736
|
print(f"[DRY-RUN] PR #{args.pr_number} CI is green, would merge...")
|
|
700
737
|
else:
|
|
701
738
|
print(f"CI CONFIRMED GREEN. Merging PR #{args.pr_number}...")
|
|
702
739
|
|
|
703
|
-
|
|
740
|
+
# N9 FIX: Catch TimeoutExpired from merge subprocess and treat as transient error
|
|
741
|
+
try:
|
|
742
|
+
merge_result = merge_pr(args.pr_number, args.merge_method, dry_run=args.dry_run, head_ref_oid=head_ref_oid)
|
|
743
|
+
except subprocess.TimeoutExpired:
|
|
744
|
+
print(f"TRANSIENT ERROR: merge command timed out, retrying...")
|
|
745
|
+
print(f"CI PENDING ({elapsed:.0f}s elapsed)... waiting {args.poll}s")
|
|
746
|
+
time.sleep(args.poll)
|
|
747
|
+
continue
|
|
748
|
+
|
|
749
|
+
if merge_result:
|
|
704
750
|
if args.dry_run:
|
|
705
751
|
print(f"[DRY-RUN] PR #{args.pr_number} merge command would succeed")
|
|
706
752
|
else:
|
|
707
753
|
print(f"MERGED: PR #{args.pr_number} merged successfully")
|
|
708
754
|
sys.exit(0)
|
|
709
755
|
else:
|
|
710
|
-
print("ERROR: merge failed (PR state changed?)")
|
|
756
|
+
print("ERROR: merge failed (PR state changed? or missing SHA pin?)")
|
|
711
757
|
sys.exit(1)
|
|
712
758
|
|
|
713
759
|
# Still pending: wait and retry
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shard-aware Python test runner for CI.
|
|
4
|
+
|
|
5
|
+
Distributes tracked test files across N shards using round-robin assignment.
|
|
6
|
+
Shard ID and total shard count come from environment variables or CLI args.
|
|
7
|
+
|
|
8
|
+
Solves the multiprocessing spawn-loop problem on Windows: inline heredocs set
|
|
9
|
+
__main__ = "<string>", and spawn-mode children recursively re-import and re-execute
|
|
10
|
+
the script. A guarded __main__ block prevents re-execution in child processes.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
python tools/ci_shard_runner.py # Uses SHARD_ID, TOTAL_SHARDS env vars
|
|
14
|
+
python tools/ci_shard_runner.py 0 4 # Or pass shard_id and total_shards as args
|
|
15
|
+
|
|
16
|
+
Exit codes:
|
|
17
|
+
0: All tests passed
|
|
18
|
+
1: Test failures, import failures, distribution errors, or no tests collected
|
|
19
|
+
"""
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import unittest
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def distribute_shards(test_files, shard_id, total_shards):
|
|
28
|
+
"""Distribute test files across shards using round-robin.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
test_files: sorted list of test module names (stems, e.g., 'test_foo')
|
|
32
|
+
shard_id: integer 0..total_shards-1
|
|
33
|
+
total_shards: total number of shards
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
list of test module names assigned to this shard
|
|
37
|
+
"""
|
|
38
|
+
return [test_files[i] for i in range(len(test_files)) if i % total_shards == shard_id]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def main():
|
|
42
|
+
"""Run Python tests for the assigned shard."""
|
|
43
|
+
# Ensure repo root is in sys.path for test imports
|
|
44
|
+
script_dir = Path(__file__).resolve().parent
|
|
45
|
+
root = script_dir.parent
|
|
46
|
+
if str(root) not in sys.path:
|
|
47
|
+
sys.path.insert(0, str(root))
|
|
48
|
+
|
|
49
|
+
# Parse shard ID and total shards from env or CLI args
|
|
50
|
+
if len(sys.argv) >= 3:
|
|
51
|
+
try:
|
|
52
|
+
shard_id = int(sys.argv[1])
|
|
53
|
+
total_shards = int(sys.argv[2])
|
|
54
|
+
except (ValueError, IndexError):
|
|
55
|
+
print("ERROR: Usage: python ci_shard_runner.py [shard_id total_shards]")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
else:
|
|
58
|
+
try:
|
|
59
|
+
shard_id = int(os.environ.get("SHARD_ID", os.environ.get("MATRIX_PYTHON_SHARD", "0")))
|
|
60
|
+
total_shards = int(os.environ.get("TOTAL_SHARDS", "4"))
|
|
61
|
+
except ValueError:
|
|
62
|
+
print("ERROR: SHARD_ID and TOTAL_SHARDS must be integers")
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
# Get tracked test files only (exclude WIP untracked files)
|
|
66
|
+
try:
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["git", "ls-files", "tests/test_*.py"],
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
check=True,
|
|
72
|
+
)
|
|
73
|
+
tracked_files = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
|
74
|
+
# Extract just the stem for test discovery
|
|
75
|
+
test_files = sorted(set(Path(f).stem for f in tracked_files if f))
|
|
76
|
+
except Exception as e:
|
|
77
|
+
print(f"ERROR: Failed to get tracked test files: {e}", file=sys.stderr)
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
|
|
80
|
+
# Distribute files across shards (round-robin for balance)
|
|
81
|
+
shard_files = distribute_shards(test_files, shard_id, total_shards)
|
|
82
|
+
|
|
83
|
+
if not shard_files:
|
|
84
|
+
print(
|
|
85
|
+
f"ERROR: No tests assigned to shard {shard_id} (total test files: {len(test_files)})",
|
|
86
|
+
file=sys.stderr,
|
|
87
|
+
)
|
|
88
|
+
print(f"This indicates a configuration error in the shard distribution.", file=sys.stderr)
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
print(f"Shard {shard_id}: running {len(shard_files)} tests")
|
|
92
|
+
loader = unittest.TestLoader()
|
|
93
|
+
suite = unittest.TestSuite()
|
|
94
|
+
failed_imports = []
|
|
95
|
+
|
|
96
|
+
for test_name in shard_files:
|
|
97
|
+
try:
|
|
98
|
+
module = __import__(f"tests.{test_name}", fromlist=[test_name])
|
|
99
|
+
suite.addTests(loader.loadTestsFromModule(module))
|
|
100
|
+
except Exception as e:
|
|
101
|
+
failed_imports.append((test_name, str(e)))
|
|
102
|
+
print(f"ERROR: Failed to load {test_name}: {e}", file=sys.stderr)
|
|
103
|
+
|
|
104
|
+
# If there were import failures, exit immediately
|
|
105
|
+
if failed_imports:
|
|
106
|
+
print(
|
|
107
|
+
f"\n{len(failed_imports)} test module(s) failed to import:",
|
|
108
|
+
file=sys.stderr,
|
|
109
|
+
)
|
|
110
|
+
for name, error in failed_imports:
|
|
111
|
+
print(f" - {name}: {error}", file=sys.stderr)
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
# If no tests were collected, this is also an error
|
|
115
|
+
if suite.countTestCases() == 0:
|
|
116
|
+
print(
|
|
117
|
+
f"ERROR: No tests were collected for shard {shard_id}",
|
|
118
|
+
file=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
runner = unittest.TextTestRunner(verbosity=2)
|
|
123
|
+
result = runner.run(suite)
|
|
124
|
+
sys.exit(0 if result.wasSuccessful() else 1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
main()
|