agent-control-plane 0.1.13 → 0.1.16
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/README.md +250 -355
- package/SKILL.md +1 -1
- package/hooks/heartbeat-hooks.sh +16 -9
- package/npm/bin/agent-control-plane.js +117 -8
- package/package.json +3 -1
- package/references/commands.md +2 -2
- package/references/control-plane-map.md +1 -1
- package/tools/bin/agent-project-reconcile-issue-session +23 -0
- package/tools/bin/agent-project-reconcile-pr-session +191 -22
- package/tools/bin/agent-project-run-codex-resilient +57 -2
- package/tools/bin/agent-project-run-openclaw-session +46 -0
- package/tools/bin/agent-project-worker-status +37 -0
- package/tools/bin/flow-config-lib.sh +7 -0
- package/tools/bin/flow-shell-lib.sh +2 -0
- package/tools/bin/heartbeat-safe-auto.sh +20 -10
- package/tools/bin/project-runtimectl.sh +1 -1
- package/tools/bin/provider-cooldown-state.sh +39 -1
- package/tools/bin/start-issue-worker.sh +35 -0
- package/tools/bin/start-pr-fix-worker.sh +3 -0
- package/tools/bin/start-pr-review-worker.sh +3 -0
- package/tools/bin/start-resident-issue-loop.sh +1 -0
- package/tools/dashboard/app.js +136 -0
- package/tools/dashboard/dashboard_snapshot.py +253 -3
- package/tools/dashboard/index.html +5 -1
- package/tools/dashboard/styles.css +97 -20
- package/tools/templates/pr-fix-template.md +6 -6
- package/tools/templates/pr-merge-repair-template.md +6 -6
- package/tools/vendor/codex-quota-manager/scripts/auto-switch.sh +8 -6
- package/tools/bin/render-dashboard-snapshot.py +0 -16
- package/tools/templates/legacy/issue-prompt-template-pre-slim.md +0 -109
|
@@ -143,6 +143,15 @@ def file_mtime_iso(path: Path) -> str:
|
|
|
143
143
|
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
144
144
|
|
|
145
145
|
|
|
146
|
+
def read_json_file(path: Path) -> dict[str, Any]:
|
|
147
|
+
if not path.is_file():
|
|
148
|
+
return {}
|
|
149
|
+
try:
|
|
150
|
+
return json.loads(path.read_text(encoding="utf-8", errors="replace"))
|
|
151
|
+
except Exception:
|
|
152
|
+
return {}
|
|
153
|
+
|
|
154
|
+
|
|
146
155
|
def read_tail_text(path: Path, max_bytes: int = 65536) -> str:
|
|
147
156
|
if not path.is_file():
|
|
148
157
|
return ""
|
|
@@ -191,6 +200,11 @@ GITHUB_RATE_LIMIT_PATTERNS = [
|
|
|
191
200
|
),
|
|
192
201
|
]
|
|
193
202
|
|
|
203
|
+
WORKER_PREFLIGHT_NETWORK_BLOCKED_PATTERN = re.compile(
|
|
204
|
+
r"Blocked on external network access.*?What I ran:\s*-\s*`(?P<command>[^`]+)`.*?Exact failure:\s*`(?P<failure>[^`]+)`",
|
|
205
|
+
re.IGNORECASE | re.DOTALL,
|
|
206
|
+
)
|
|
207
|
+
|
|
194
208
|
|
|
195
209
|
def summarize_whitespace(text: str) -> str:
|
|
196
210
|
return re.sub(r"\s+", " ", text).strip()
|
|
@@ -233,6 +247,52 @@ def extract_github_rate_limit_alert(run_dir: Path, run: dict[str, Any]) -> dict[
|
|
|
233
247
|
return None
|
|
234
248
|
|
|
235
249
|
|
|
250
|
+
def extract_worker_preflight_network_blocked_alert(run_dir: Path, run: dict[str, Any]) -> dict[str, Any] | None:
|
|
251
|
+
candidate_files = [
|
|
252
|
+
run_dir / "issue-comment.md",
|
|
253
|
+
run_dir / "pr-comment.md",
|
|
254
|
+
]
|
|
255
|
+
for path in candidate_files:
|
|
256
|
+
text = read_tail_text(path)
|
|
257
|
+
if not text:
|
|
258
|
+
continue
|
|
259
|
+
match = WORKER_PREFLIGHT_NETWORK_BLOCKED_PATTERN.search(text)
|
|
260
|
+
if not match:
|
|
261
|
+
continue
|
|
262
|
+
command = summarize_whitespace(match.group("command"))
|
|
263
|
+
failure = summarize_whitespace(match.group("failure"))
|
|
264
|
+
message = f"Worker preflight `{command or 'unknown command'}` failed before implementation."
|
|
265
|
+
if failure:
|
|
266
|
+
message = f"{message} {failure}"
|
|
267
|
+
message = f"{message} Verify from the host if the same command succeeds; worker and host environment can diverge."
|
|
268
|
+
return {
|
|
269
|
+
"id": f"worker-preflight-network-blocked:{run.get('session', '')}:{command}:{failure}",
|
|
270
|
+
"kind": "worker-preflight-network-blocked",
|
|
271
|
+
"severity": "warn",
|
|
272
|
+
"title": "Worker preflight blocked by network",
|
|
273
|
+
"message": message,
|
|
274
|
+
"session": run.get("session", ""),
|
|
275
|
+
"task_kind": run.get("task_kind", ""),
|
|
276
|
+
"task_id": run.get("task_id", ""),
|
|
277
|
+
"reset_at": "",
|
|
278
|
+
"updated_at": run.get("updated_at", "") or file_mtime_iso(path),
|
|
279
|
+
"source_file": str(path),
|
|
280
|
+
}
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def extract_run_alerts(run_dir: Path, run: dict[str, Any]) -> list[dict[str, Any]]:
|
|
285
|
+
alerts: list[dict[str, Any]] = []
|
|
286
|
+
for extractor in (
|
|
287
|
+
extract_github_rate_limit_alert,
|
|
288
|
+
extract_worker_preflight_network_blocked_alert,
|
|
289
|
+
):
|
|
290
|
+
alert = extractor(run_dir, run)
|
|
291
|
+
if alert:
|
|
292
|
+
alerts.append(alert)
|
|
293
|
+
return alerts
|
|
294
|
+
|
|
295
|
+
|
|
236
296
|
def collect_runs(runs_root: Path) -> list[dict[str, Any]]:
|
|
237
297
|
if not runs_root.is_dir():
|
|
238
298
|
return []
|
|
@@ -287,12 +347,68 @@ def collect_runs(runs_root: Path) -> list[dict[str, Any]]:
|
|
|
287
347
|
"provider_pool_name": run_env.get("ACTIVE_PROVIDER_POOL_NAME", ""),
|
|
288
348
|
"run_dir": str(run_dir),
|
|
289
349
|
}
|
|
290
|
-
|
|
291
|
-
item["alerts"] = [alert] if alert else []
|
|
350
|
+
item["alerts"] = extract_run_alerts(run_dir, item)
|
|
292
351
|
runs.append(item)
|
|
293
352
|
return runs
|
|
294
353
|
|
|
295
354
|
|
|
355
|
+
def collect_recent_history(history_root: Path, limit: int = 8) -> list[dict[str, Any]]:
|
|
356
|
+
if not history_root.is_dir():
|
|
357
|
+
return []
|
|
358
|
+
|
|
359
|
+
items: list[dict[str, Any]] = []
|
|
360
|
+
seen_sessions: set[str] = set()
|
|
361
|
+
for run_dir in sorted(
|
|
362
|
+
[entry for entry in history_root.iterdir() if entry.is_dir()],
|
|
363
|
+
key=lambda item: item.stat().st_mtime,
|
|
364
|
+
reverse=True,
|
|
365
|
+
):
|
|
366
|
+
run_env = read_env_file(run_dir / "run.env")
|
|
367
|
+
runner_env = read_env_file(run_dir / "runner.env")
|
|
368
|
+
result_env = read_env_file(run_dir / "result.env")
|
|
369
|
+
session = run_env.get("SESSION", "")
|
|
370
|
+
if not session:
|
|
371
|
+
name = run_dir.name
|
|
372
|
+
parts = name.split("-")
|
|
373
|
+
session = "-".join(parts[:-2]) if len(parts) > 2 else name
|
|
374
|
+
if session in seen_sessions:
|
|
375
|
+
continue
|
|
376
|
+
lifecycle_status = (runner_env.get("RUNNER_STATE", "") or "").strip().upper()
|
|
377
|
+
if lifecycle_status == "SUCCEEDED":
|
|
378
|
+
lifecycle_status = "SUCCEEDED"
|
|
379
|
+
elif lifecycle_status == "FAILED":
|
|
380
|
+
lifecycle_status = "FAILED"
|
|
381
|
+
elif lifecycle_status:
|
|
382
|
+
lifecycle_status = lifecycle_status.upper()
|
|
383
|
+
else:
|
|
384
|
+
lifecycle_status = "UNKNOWN"
|
|
385
|
+
outcome = result_env.get("OUTCOME", "")
|
|
386
|
+
failure_reason = runner_env.get("LAST_FAILURE_REASON", "")
|
|
387
|
+
result_kind, result_label = classify_run_result(lifecycle_status, outcome, failure_reason)
|
|
388
|
+
item = {
|
|
389
|
+
"session": session,
|
|
390
|
+
"task_kind": run_env.get("TASK_KIND", ""),
|
|
391
|
+
"task_id": run_env.get("TASK_ID", ""),
|
|
392
|
+
"status": lifecycle_status,
|
|
393
|
+
"lifecycle_status": lifecycle_status,
|
|
394
|
+
"updated_at": result_env.get("UPDATED_AT", "") or runner_env.get("UPDATED_AT", "") or file_mtime_iso(run_dir),
|
|
395
|
+
"coding_worker": run_env.get("CODING_WORKER", ""),
|
|
396
|
+
"failure_reason": failure_reason,
|
|
397
|
+
"outcome": outcome,
|
|
398
|
+
"action": result_env.get("ACTION", ""),
|
|
399
|
+
"result_kind": result_kind,
|
|
400
|
+
"result_label": result_label,
|
|
401
|
+
"run_dir": str(run_dir),
|
|
402
|
+
"archived": True,
|
|
403
|
+
}
|
|
404
|
+
item["alerts"] = extract_run_alerts(run_dir, item)
|
|
405
|
+
items.append(item)
|
|
406
|
+
seen_sessions.add(session)
|
|
407
|
+
if len(items) >= limit:
|
|
408
|
+
break
|
|
409
|
+
return items
|
|
410
|
+
|
|
411
|
+
|
|
296
412
|
def controller_is_stale(env: dict[str, str], controller_path: Path) -> bool:
|
|
297
413
|
"""A controller is stale if it claims to be running but its PID is dead or its
|
|
298
414
|
UPDATED_AT file mtime is older than 10 minutes."""
|
|
@@ -408,6 +524,94 @@ def collect_provider_cooldowns(state_root: Path) -> list[dict[str, Any]]:
|
|
|
408
524
|
return items
|
|
409
525
|
|
|
410
526
|
|
|
527
|
+
def collect_codex_rotation(profile: dict[str, str]) -> dict[str, Any]:
|
|
528
|
+
coding_worker = profile.get("EFFECTIVE_CODING_WORKER", "")
|
|
529
|
+
if coding_worker != "codex":
|
|
530
|
+
return {}
|
|
531
|
+
|
|
532
|
+
cache_root = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "codex-quota-manager"
|
|
533
|
+
state_file = cache_root / "rotation-state.json"
|
|
534
|
+
switch_file = cache_root / "last-switch.env"
|
|
535
|
+
state_json = read_json_file(state_file)
|
|
536
|
+
state_accounts = state_json.get("accounts", {}) if isinstance(state_json, dict) else {}
|
|
537
|
+
now_epoch = int(datetime.now(timezone.utc).timestamp())
|
|
538
|
+
|
|
539
|
+
active_label = ""
|
|
540
|
+
candidate_labels: list[str] = []
|
|
541
|
+
list_json: dict[str, Any] = {}
|
|
542
|
+
quota_bin_override = os.environ.get("CODEX_QUOTA_BIN", "").strip()
|
|
543
|
+
quota_bin = Path(quota_bin_override) if quota_bin_override else TOOLS_BIN_DIR / "codex-quota"
|
|
544
|
+
if quota_bin.is_file():
|
|
545
|
+
try:
|
|
546
|
+
raw = subprocess.check_output(
|
|
547
|
+
[str(quota_bin), "codex", "list", "--json"],
|
|
548
|
+
cwd=str(ROOT_DIR),
|
|
549
|
+
env=os.environ.copy(),
|
|
550
|
+
text=True,
|
|
551
|
+
stderr=subprocess.DEVNULL,
|
|
552
|
+
timeout=20,
|
|
553
|
+
)
|
|
554
|
+
list_json = json.loads(raw)
|
|
555
|
+
except Exception:
|
|
556
|
+
list_json = {}
|
|
557
|
+
|
|
558
|
+
if isinstance(list_json, dict):
|
|
559
|
+
active_info = list_json.get("activeInfo", {}) or {}
|
|
560
|
+
active_label = str(active_info.get("trackedLabel") or active_info.get("activeLabel") or "")
|
|
561
|
+
seen: set[str] = set()
|
|
562
|
+
for account in list_json.get("accounts", []) or []:
|
|
563
|
+
label = str(account.get("label") or "").strip()
|
|
564
|
+
if not label or label == active_label or label in seen:
|
|
565
|
+
continue
|
|
566
|
+
candidate_labels.append(label)
|
|
567
|
+
seen.add(label)
|
|
568
|
+
|
|
569
|
+
next_retry_label = ""
|
|
570
|
+
next_retry_epoch = 0
|
|
571
|
+
for label in candidate_labels:
|
|
572
|
+
entry = state_accounts.get(label, {}) if isinstance(state_accounts, dict) else {}
|
|
573
|
+
retry_epoch = safe_int(str(entry.get("next_retry_at", "")))
|
|
574
|
+
removed = bool(entry.get("removed", False))
|
|
575
|
+
if removed or not retry_epoch or retry_epoch <= now_epoch:
|
|
576
|
+
continue
|
|
577
|
+
if next_retry_epoch == 0 or retry_epoch < next_retry_epoch:
|
|
578
|
+
next_retry_epoch = retry_epoch
|
|
579
|
+
next_retry_label = label
|
|
580
|
+
|
|
581
|
+
ready_candidates = []
|
|
582
|
+
for label in candidate_labels:
|
|
583
|
+
entry = state_accounts.get(label, {}) if isinstance(state_accounts, dict) else {}
|
|
584
|
+
retry_epoch = safe_int(str(entry.get("next_retry_at", ""))) or 0
|
|
585
|
+
removed = bool(entry.get("removed", False))
|
|
586
|
+
if not removed and retry_epoch <= now_epoch:
|
|
587
|
+
ready_candidates.append(label)
|
|
588
|
+
|
|
589
|
+
last_switch = read_env_file(switch_file)
|
|
590
|
+
switch_decision = "unknown"
|
|
591
|
+
if ready_candidates:
|
|
592
|
+
switch_decision = "ready-candidate"
|
|
593
|
+
elif next_retry_label:
|
|
594
|
+
switch_decision = "deferred"
|
|
595
|
+
elif last_switch.get("LAST_SWITCH_LABEL"):
|
|
596
|
+
switch_decision = "switched"
|
|
597
|
+
elif candidate_labels:
|
|
598
|
+
switch_decision = "failed"
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
"active_label": active_label,
|
|
602
|
+
"candidate_labels": candidate_labels,
|
|
603
|
+
"ready_candidates": ready_candidates,
|
|
604
|
+
"next_retry_label": next_retry_label,
|
|
605
|
+
"next_retry_epoch": next_retry_epoch,
|
|
606
|
+
"next_retry_at": datetime.fromtimestamp(next_retry_epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if next_retry_epoch else "",
|
|
607
|
+
"switch_decision": switch_decision,
|
|
608
|
+
"last_switch_label": last_switch.get("LAST_SWITCH_LABEL", ""),
|
|
609
|
+
"last_switch_reason": last_switch.get("LAST_SWITCH_REASON", ""),
|
|
610
|
+
"last_switch_epoch": safe_int(last_switch.get("LAST_SWITCH_EPOCH")),
|
|
611
|
+
"state_file": str(state_file),
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
411
615
|
def collect_scheduled_issues(state_root: Path) -> list[dict[str, Any]]:
|
|
412
616
|
scheduled_root = state_root / "scheduled-issues"
|
|
413
617
|
if not scheduled_root.is_dir():
|
|
@@ -455,6 +659,43 @@ def collect_issue_retries(state_root: Path) -> list[dict[str, Any]]:
|
|
|
455
659
|
return items
|
|
456
660
|
|
|
457
661
|
|
|
662
|
+
def collect_pr_retries(state_root: Path) -> list[dict[str, Any]]:
|
|
663
|
+
retries_root = state_root / "retries" / "prs"
|
|
664
|
+
if not retries_root.is_dir():
|
|
665
|
+
return []
|
|
666
|
+
|
|
667
|
+
now_epoch = int(datetime.now(timezone.utc).timestamp())
|
|
668
|
+
items: list[dict[str, Any]] = []
|
|
669
|
+
for path in sorted(retries_root.glob("*.env"), key=lambda item: item.stat().st_mtime, reverse=True):
|
|
670
|
+
env = read_env_file(path)
|
|
671
|
+
next_attempt_epoch = safe_int(env.get("NEXT_ATTEMPT_EPOCH"))
|
|
672
|
+
items.append(
|
|
673
|
+
{
|
|
674
|
+
"pr_number": path.stem,
|
|
675
|
+
"attempts": safe_int(env.get("ATTEMPTS")) or 0,
|
|
676
|
+
"next_attempt_epoch": next_attempt_epoch,
|
|
677
|
+
"next_attempt_at": env.get("NEXT_ATTEMPT_AT", ""),
|
|
678
|
+
"last_reason": env.get("LAST_REASON", ""),
|
|
679
|
+
"updated_at": env.get("UPDATED_AT", "") or file_mtime_iso(path),
|
|
680
|
+
"ready": not bool(next_attempt_epoch and next_attempt_epoch > now_epoch),
|
|
681
|
+
"state_file": str(path),
|
|
682
|
+
}
|
|
683
|
+
)
|
|
684
|
+
return items
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def resolve_history_root(render_env: dict[str, str], yaml_env: dict[str, str], runs_root: Path) -> Path:
|
|
688
|
+
configured = (
|
|
689
|
+
render_env.get("EFFECTIVE_HISTORY_ROOT", "").strip()
|
|
690
|
+
or yaml_env.get("runtime.history_root", "").strip()
|
|
691
|
+
)
|
|
692
|
+
if configured and configured != ".":
|
|
693
|
+
return Path(configured)
|
|
694
|
+
if runs_root.name == "runs":
|
|
695
|
+
return runs_root.parent / "history"
|
|
696
|
+
return Path(".")
|
|
697
|
+
|
|
698
|
+
|
|
458
699
|
def collect_issue_queue(state_root: Path) -> dict[str, list[dict[str, Any]]]:
|
|
459
700
|
queue_root = state_root / "resident-workers" / "issue-queue"
|
|
460
701
|
pending_root = queue_root / "pending"
|
|
@@ -491,14 +732,18 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
|
|
|
491
732
|
|
|
492
733
|
runs_root = Path(render_env.get("EFFECTIVE_RUNS_ROOT", ""))
|
|
493
734
|
state_root = Path(render_env.get("EFFECTIVE_STATE_ROOT", ""))
|
|
735
|
+
history_root = resolve_history_root(render_env, yaml_env, runs_root)
|
|
494
736
|
runs = collect_runs(runs_root)
|
|
737
|
+
recent_history = collect_recent_history(history_root)
|
|
495
738
|
controllers = collect_resident_controllers(state_root)
|
|
496
739
|
resident_workers = collect_resident_workers(state_root)
|
|
497
740
|
cooldowns = collect_provider_cooldowns(state_root)
|
|
498
741
|
scheduled = collect_scheduled_issues(state_root)
|
|
499
742
|
retries = collect_issue_retries(state_root)
|
|
743
|
+
pr_retries = collect_pr_retries(state_root)
|
|
500
744
|
queue = collect_issue_queue(state_root)
|
|
501
|
-
alerts = [alert for run in runs for alert in run.get("alerts", [])]
|
|
745
|
+
alerts = [alert for run in (runs + recent_history) for alert in run.get("alerts", [])]
|
|
746
|
+
codex_rotation = collect_codex_rotation(render_env)
|
|
502
747
|
|
|
503
748
|
return {
|
|
504
749
|
"id": profile_id,
|
|
@@ -506,6 +751,7 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
|
|
|
506
751
|
"repo_root": render_env.get("EFFECTIVE_REPO_ROOT", ""),
|
|
507
752
|
"runs_root": str(runs_root),
|
|
508
753
|
"state_root": str(state_root),
|
|
754
|
+
"history_root": str(history_root),
|
|
509
755
|
"issue_prefix": yaml_env.get("session_naming.issue_prefix", ""),
|
|
510
756
|
"pr_prefix": yaml_env.get("session_naming.pr_prefix", ""),
|
|
511
757
|
"coding_worker": render_env.get("EFFECTIVE_CODING_WORKER", ""),
|
|
@@ -520,6 +766,7 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
|
|
|
520
766
|
"last_reason": render_env.get("EFFECTIVE_PROVIDER_POOL_LAST_REASON", ""),
|
|
521
767
|
"pools_exhausted": render_env.get("EFFECTIVE_PROVIDER_POOLS_EXHAUSTED", ""),
|
|
522
768
|
},
|
|
769
|
+
"codex_rotation": codex_rotation,
|
|
523
770
|
"counts": {
|
|
524
771
|
"active_runs": len(runs),
|
|
525
772
|
"running_runs": sum(1 for item in runs if item["status"] == "RUNNING"),
|
|
@@ -531,6 +778,7 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
|
|
|
531
778
|
"completed_runs": sum(
|
|
532
779
|
1 for item in runs if item["status"] == "SUCCEEDED" and item["result_kind"] not in {"implemented", "reported", "blocked"}
|
|
533
780
|
),
|
|
781
|
+
"recent_history_runs": len(recent_history),
|
|
534
782
|
"resident_controllers": len(controllers),
|
|
535
783
|
"live_resident_controllers": sum(1 for item in controllers if item["state"] != "stopped" and item["controller_live"]),
|
|
536
784
|
"stale_resident_controllers": sum(1 for item in controllers if item.get("controller_stale", False)),
|
|
@@ -543,12 +791,14 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
|
|
|
543
791
|
"alerts": len(alerts),
|
|
544
792
|
},
|
|
545
793
|
"runs": runs,
|
|
794
|
+
"recent_history": recent_history,
|
|
546
795
|
"alerts": alerts,
|
|
547
796
|
"resident_controllers": controllers,
|
|
548
797
|
"resident_workers": resident_workers,
|
|
549
798
|
"provider_cooldowns": cooldowns,
|
|
550
799
|
"scheduled_issues": scheduled,
|
|
551
800
|
"issue_retries": retries,
|
|
801
|
+
"pr_retries": pr_retries,
|
|
552
802
|
"issue_queue": queue,
|
|
553
803
|
}
|
|
554
804
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
6
7
|
<title>ACP Worker Dashboard</title>
|
|
7
8
|
<link rel="stylesheet" href="./styles.css" />
|
|
8
9
|
</head>
|
|
@@ -20,7 +21,10 @@
|
|
|
20
21
|
</p>
|
|
21
22
|
</div>
|
|
22
23
|
<div class="hero-actions">
|
|
23
|
-
<
|
|
24
|
+
<div class="hero-controls">
|
|
25
|
+
<button id="theme-toggle" type="button" aria-label="Toggle dark mode">Dark mode</button>
|
|
26
|
+
<button id="refresh-button" type="button">Refresh now</button>
|
|
27
|
+
</div>
|
|
24
28
|
<div class="meta">
|
|
25
29
|
<div>Auto refresh: <strong>5s</strong></div>
|
|
26
30
|
<div id="generated-at">Loading snapshot...</div>
|
|
@@ -12,6 +12,56 @@
|
|
|
12
12
|
--danger: #b42318;
|
|
13
13
|
--danger-soft: #fdd8d2;
|
|
14
14
|
--shadow: 0 18px 50px rgba(25, 33, 38, 0.08);
|
|
15
|
+
--button-bg: var(--ink);
|
|
16
|
+
--button-ink: #ffffff;
|
|
17
|
+
--button-hover: #0f1720;
|
|
18
|
+
--hero-bg: rgba(255, 253, 247, 0.92);
|
|
19
|
+
--profile-bg: rgba(255, 253, 247, 0.94);
|
|
20
|
+
--body-gradient-top: rgba(15, 118, 110, 0.08);
|
|
21
|
+
--body-gradient-bottom: #faf7ef;
|
|
22
|
+
--theme-toggle-bg: var(--panel);
|
|
23
|
+
--theme-toggle-ink: var(--ink);
|
|
24
|
+
--theme-toggle-line: var(--line);
|
|
25
|
+
--theme-toggle-hover: var(--panel-strong);
|
|
26
|
+
--reported-soft: #dbeafe;
|
|
27
|
+
--reported-ink: #1d4ed8;
|
|
28
|
+
--implemented-soft: #dcfce7;
|
|
29
|
+
--implemented-ink: #166534;
|
|
30
|
+
--blocked-soft: #fef3c7;
|
|
31
|
+
--blocked-ink: #92400e;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
:root[data-theme="dark"] {
|
|
35
|
+
--bg: #0d1418;
|
|
36
|
+
--panel: #142026;
|
|
37
|
+
--panel-strong: #1a2a31;
|
|
38
|
+
--ink: #ebf1f3;
|
|
39
|
+
--muted: #9ab0bb;
|
|
40
|
+
--line: #2a3c44;
|
|
41
|
+
--accent: #5ad4c7;
|
|
42
|
+
--accent-soft: #183d3a;
|
|
43
|
+
--warn: #f4c35f;
|
|
44
|
+
--warn-soft: #4d3a12;
|
|
45
|
+
--danger: #ff8a80;
|
|
46
|
+
--danger-soft: #4a2220;
|
|
47
|
+
--shadow: 0 24px 60px rgba(0, 0, 0, 0.32);
|
|
48
|
+
--button-bg: #ebf1f3;
|
|
49
|
+
--button-ink: #0d1418;
|
|
50
|
+
--button-hover: #d7e2e6;
|
|
51
|
+
--hero-bg: rgba(20, 32, 38, 0.92);
|
|
52
|
+
--profile-bg: rgba(20, 32, 38, 0.94);
|
|
53
|
+
--body-gradient-top: rgba(90, 212, 199, 0.12);
|
|
54
|
+
--body-gradient-bottom: #10181d;
|
|
55
|
+
--theme-toggle-bg: #1d2d35;
|
|
56
|
+
--theme-toggle-ink: #ebf1f3;
|
|
57
|
+
--theme-toggle-line: #35505b;
|
|
58
|
+
--theme-toggle-hover: #23363f;
|
|
59
|
+
--reported-soft: #1b3552;
|
|
60
|
+
--reported-ink: #9fc8ff;
|
|
61
|
+
--implemented-soft: #173a2b;
|
|
62
|
+
--implemented-ink: #85ddb1;
|
|
63
|
+
--blocked-soft: #4a3711;
|
|
64
|
+
--blocked-ink: #f4c35f;
|
|
15
65
|
}
|
|
16
66
|
|
|
17
67
|
* {
|
|
@@ -22,8 +72,8 @@ body {
|
|
|
22
72
|
margin: 0;
|
|
23
73
|
font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
|
|
24
74
|
background:
|
|
25
|
-
radial-gradient(circle at top right,
|
|
26
|
-
linear-gradient(180deg,
|
|
75
|
+
radial-gradient(circle at top right, var(--body-gradient-top), transparent 34%),
|
|
76
|
+
linear-gradient(180deg, var(--body-gradient-bottom) 0%, var(--bg) 100%);
|
|
27
77
|
color: var(--ink);
|
|
28
78
|
}
|
|
29
79
|
|
|
@@ -41,7 +91,7 @@ body {
|
|
|
41
91
|
padding: 24px;
|
|
42
92
|
border: 1px solid var(--line);
|
|
43
93
|
border-radius: 28px;
|
|
44
|
-
background:
|
|
94
|
+
background: var(--hero-bg);
|
|
45
95
|
box-shadow: var(--shadow);
|
|
46
96
|
}
|
|
47
97
|
|
|
@@ -75,19 +125,42 @@ body {
|
|
|
75
125
|
align-items: flex-end;
|
|
76
126
|
}
|
|
77
127
|
|
|
128
|
+
.hero-controls {
|
|
129
|
+
display: flex;
|
|
130
|
+
gap: 10px;
|
|
131
|
+
align-items: center;
|
|
132
|
+
flex-wrap: wrap;
|
|
133
|
+
justify-content: flex-end;
|
|
134
|
+
}
|
|
135
|
+
|
|
78
136
|
button {
|
|
79
137
|
appearance: none;
|
|
80
138
|
border: 0;
|
|
81
139
|
border-radius: 999px;
|
|
82
140
|
padding: 12px 18px;
|
|
83
141
|
font: inherit;
|
|
84
|
-
background: var(--
|
|
85
|
-
color:
|
|
142
|
+
background: var(--button-bg);
|
|
143
|
+
color: var(--button-ink);
|
|
86
144
|
cursor: pointer;
|
|
145
|
+
transition: background 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
|
87
146
|
}
|
|
88
147
|
|
|
89
148
|
button:hover {
|
|
90
|
-
background:
|
|
149
|
+
background: var(--button-hover);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
button:active {
|
|
153
|
+
transform: translateY(1px);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#theme-toggle {
|
|
157
|
+
background: var(--theme-toggle-bg);
|
|
158
|
+
color: var(--theme-toggle-ink);
|
|
159
|
+
border: 1px solid var(--theme-toggle-line);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#theme-toggle:hover {
|
|
163
|
+
background: var(--theme-toggle-hover);
|
|
91
164
|
}
|
|
92
165
|
|
|
93
166
|
.meta {
|
|
@@ -135,7 +208,7 @@ button:hover {
|
|
|
135
208
|
padding: 22px;
|
|
136
209
|
border-radius: 28px;
|
|
137
210
|
border: 1px solid var(--line);
|
|
138
|
-
background:
|
|
211
|
+
background: var(--profile-bg);
|
|
139
212
|
box-shadow: var(--shadow);
|
|
140
213
|
}
|
|
141
214
|
|
|
@@ -183,12 +256,12 @@ button:hover {
|
|
|
183
256
|
|
|
184
257
|
.badge.good {
|
|
185
258
|
background: var(--accent-soft);
|
|
186
|
-
color:
|
|
259
|
+
color: var(--accent);
|
|
187
260
|
}
|
|
188
261
|
|
|
189
262
|
.badge.warn {
|
|
190
263
|
background: var(--warn-soft);
|
|
191
|
-
color:
|
|
264
|
+
color: var(--warn);
|
|
192
265
|
}
|
|
193
266
|
|
|
194
267
|
.badge.danger {
|
|
@@ -243,8 +316,8 @@ button:hover {
|
|
|
243
316
|
}
|
|
244
317
|
|
|
245
318
|
.alert-card.warn {
|
|
246
|
-
border-color:
|
|
247
|
-
background:
|
|
319
|
+
border-color: color-mix(in srgb, var(--warn) 45%, var(--line));
|
|
320
|
+
background: color-mix(in srgb, var(--warn-soft) 82%, var(--panel) 18%);
|
|
248
321
|
}
|
|
249
322
|
|
|
250
323
|
.alert-card h4 {
|
|
@@ -323,7 +396,7 @@ th {
|
|
|
323
396
|
.status-pill.launching,
|
|
324
397
|
.status-pill.reconciling {
|
|
325
398
|
background: var(--accent-soft);
|
|
326
|
-
color:
|
|
399
|
+
color: var(--accent);
|
|
327
400
|
}
|
|
328
401
|
|
|
329
402
|
.status-pill.waiting-provider,
|
|
@@ -332,7 +405,7 @@ th {
|
|
|
332
405
|
.status-pill.idle,
|
|
333
406
|
.status-pill.sleeping {
|
|
334
407
|
background: var(--warn-soft);
|
|
335
|
-
color:
|
|
408
|
+
color: var(--warn);
|
|
336
409
|
}
|
|
337
410
|
|
|
338
411
|
.status-pill.FAILED,
|
|
@@ -343,19 +416,19 @@ th {
|
|
|
343
416
|
}
|
|
344
417
|
|
|
345
418
|
.status-pill.implemented {
|
|
346
|
-
background:
|
|
347
|
-
color:
|
|
419
|
+
background: var(--implemented-soft);
|
|
420
|
+
color: var(--implemented-ink);
|
|
348
421
|
}
|
|
349
422
|
|
|
350
423
|
.status-pill.reported,
|
|
351
424
|
.status-pill.completed {
|
|
352
|
-
background:
|
|
353
|
-
color:
|
|
425
|
+
background: var(--reported-soft);
|
|
426
|
+
color: var(--reported-ink);
|
|
354
427
|
}
|
|
355
428
|
|
|
356
429
|
.status-pill.blocked {
|
|
357
|
-
background:
|
|
358
|
-
color:
|
|
430
|
+
background: var(--blocked-soft);
|
|
431
|
+
color: var(--blocked-ink);
|
|
359
432
|
}
|
|
360
433
|
|
|
361
434
|
.status-pill.failed,
|
|
@@ -366,7 +439,7 @@ th {
|
|
|
366
439
|
|
|
367
440
|
.status-pill.running {
|
|
368
441
|
background: var(--accent-soft);
|
|
369
|
-
color:
|
|
442
|
+
color: var(--accent);
|
|
370
443
|
}
|
|
371
444
|
|
|
372
445
|
.empty-state {
|
|
@@ -387,6 +460,10 @@ th {
|
|
|
387
460
|
text-align: left;
|
|
388
461
|
}
|
|
389
462
|
|
|
463
|
+
.hero-controls {
|
|
464
|
+
justify-content: flex-start;
|
|
465
|
+
}
|
|
466
|
+
|
|
390
467
|
.panel.half,
|
|
391
468
|
.panel.third {
|
|
392
469
|
grid-column: span 12;
|
|
@@ -3,11 +3,11 @@ You are the PR repair worker for `{REPO_SLUG}`.
|
|
|
3
3
|
Before making any change:
|
|
4
4
|
|
|
5
5
|
1. Read `{REPO_ROOT}/AGENTS.md`.
|
|
6
|
-
2.
|
|
7
|
-
3.
|
|
8
|
-
4.
|
|
9
|
-
5.
|
|
10
|
-
6.
|
|
6
|
+
2. If present, read `{REPO_ROOT}/openspec/AGENT_RULES.md`.
|
|
7
|
+
3. If present, read `{REPO_ROOT}/openspec/AGENTS.md`.
|
|
8
|
+
4. If present, read `{REPO_ROOT}/openspec/project.md`.
|
|
9
|
+
5. If present, read `{REPO_ROOT}/openspec/CONVENTIONS.md`.
|
|
10
|
+
6. If present, read `{REPO_ROOT}/docs/TESTING_AND_SEED_POLICY.md`.
|
|
11
11
|
7. Stay on this PR branch worktree. Do not push or mutate GitHub from inside the worker.
|
|
12
12
|
|
|
13
13
|
PR metadata:
|
|
@@ -58,7 +58,7 @@ PR body:
|
|
|
58
58
|
Required flow:
|
|
59
59
|
|
|
60
60
|
1. Inspect the current diff and the failing/pending CI signals first:
|
|
61
|
-
- `openspec list`
|
|
61
|
+
- `openspec list` if the repo uses OpenSpec
|
|
62
62
|
- `git diff --stat origin/main...HEAD`
|
|
63
63
|
- `git status --short`
|
|
64
64
|
- if `Merge state` is not `CLEAN` or `Mergeable` is `FALSE`, treat branch drift/conflicts as the concrete blocker first
|
|
@@ -3,11 +3,11 @@ You are the PR merge-repair worker for `{REPO_SLUG}`.
|
|
|
3
3
|
Before making any change:
|
|
4
4
|
|
|
5
5
|
1. Read `{REPO_ROOT}/AGENTS.md`.
|
|
6
|
-
2.
|
|
7
|
-
3.
|
|
8
|
-
4.
|
|
9
|
-
5.
|
|
10
|
-
6.
|
|
6
|
+
2. If present, read `{REPO_ROOT}/openspec/AGENT_RULES.md`.
|
|
7
|
+
3. If present, read `{REPO_ROOT}/openspec/AGENTS.md`.
|
|
8
|
+
4. If present, read `{REPO_ROOT}/openspec/project.md`.
|
|
9
|
+
5. If present, read `{REPO_ROOT}/openspec/CONVENTIONS.md`.
|
|
10
|
+
6. If present, read `{REPO_ROOT}/docs/TESTING_AND_SEED_POLICY.md`.
|
|
11
11
|
7. Stay on this PR branch worktree. Do not push or mutate GitHub from inside the worker.
|
|
12
12
|
|
|
13
13
|
PR metadata:
|
|
@@ -53,7 +53,7 @@ Required flow:
|
|
|
53
53
|
- do not run `git fetch`, `git pull`, `git merge`, `git rebase`, `git commit`, `git push`, or any command that writes Git metadata
|
|
54
54
|
- do not abort or restart the prepared merge state
|
|
55
55
|
3. Inspect only the concrete branch-repair state you were given:
|
|
56
|
-
- `openspec list`
|
|
56
|
+
- `openspec list` if the repo uses OpenSpec
|
|
57
57
|
- `git status --short`
|
|
58
58
|
- `git diff --check`
|
|
59
59
|
- `git diff --name-only --diff-filter=U`
|
|
@@ -410,12 +410,6 @@ for label in "${CANDIDATE_LABELS[@]}"; do
|
|
|
410
410
|
continue
|
|
411
411
|
fi
|
|
412
412
|
|
|
413
|
-
retry_at="$(state_next_retry_at "$label")"
|
|
414
|
-
if [[ "$retry_at" =~ ^[0-9]+$ ]] && (( retry_at > now_epoch )); then
|
|
415
|
-
note_candidate_retry "$label" "$retry_at"
|
|
416
|
-
continue
|
|
417
|
-
fi
|
|
418
|
-
|
|
419
413
|
quota_output="$(load_account_quota_json "$label" 2>&1 || true)"
|
|
420
414
|
if ! jq -e 'type == "array" and length > 0' >/dev/null 2>&1 <<<"$quota_output"; then
|
|
421
415
|
if is_auth_401_output "$quota_output"; then
|
|
@@ -436,6 +430,14 @@ for label in "${CANDIDATE_LABELS[@]}"; do
|
|
|
436
430
|
continue
|
|
437
431
|
fi
|
|
438
432
|
|
|
433
|
+
retry_at="$(state_next_retry_at "$label")"
|
|
434
|
+
if [[ "$retry_at" =~ ^[0-9]+$ ]] && (( retry_at > now_epoch )) && account_is_eligible "$label" "$quota_output"; then
|
|
435
|
+
state_mark_ready "$label" "quota-revalidated" "$now_epoch"
|
|
436
|
+
elif [[ "$retry_at" =~ ^[0-9]+$ ]] && (( retry_at > now_epoch )); then
|
|
437
|
+
note_candidate_retry "$label" "$retry_at"
|
|
438
|
+
continue
|
|
439
|
+
fi
|
|
440
|
+
|
|
439
441
|
if ! account_is_eligible "$label" "$quota_output"; then
|
|
440
442
|
retry_at="$(account_retry_epoch "$label" "$quota_output")"
|
|
441
443
|
if [[ "$retry_at" =~ ^[0-9]+$ ]] && (( retry_at > now_epoch )); then
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import pathlib
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
ROOT = pathlib.Path(__file__).resolve().parents[1] / "dashboard"
|
|
9
|
-
if str(ROOT) not in sys.path:
|
|
10
|
-
sys.path.insert(0, str(ROOT))
|
|
11
|
-
|
|
12
|
-
from dashboard_snapshot import main
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if __name__ == "__main__":
|
|
16
|
-
raise SystemExit(main())
|