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.
@@ -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
- alert = extract_github_rate_limit_alert(run_dir, item)
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
- <button id="refresh-button" type="button">Refresh now</button>
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, rgba(15, 118, 110, 0.08), transparent 34%),
26
- linear-gradient(180deg, #faf7ef 0%, var(--bg) 100%);
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: rgba(255, 253, 247, 0.92);
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(--ink);
85
- color: white;
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: #0f1720;
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: rgba(255, 253, 247, 0.94);
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: #0d5a54;
259
+ color: var(--accent);
187
260
  }
188
261
 
189
262
  .badge.warn {
190
263
  background: var(--warn-soft);
191
- color: #855500;
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: #e7c76d;
247
- background: #fff6dd;
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: #0d5a54;
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: #855500;
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: #dcfce7;
347
- color: #166534;
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: #dbeafe;
353
- color: #1d4ed8;
425
+ background: var(--reported-soft);
426
+ color: var(--reported-ink);
354
427
  }
355
428
 
356
429
  .status-pill.blocked {
357
- background: #fef3c7;
358
- color: #92400e;
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: #0d5a54;
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. Read `{REPO_ROOT}/openspec/AGENT_RULES.md`.
7
- 3. Read `{REPO_ROOT}/openspec/AGENTS.md`.
8
- 4. Read `{REPO_ROOT}/openspec/project.md`.
9
- 5. Read `{REPO_ROOT}/openspec/CONVENTIONS.md`.
10
- 6. Read `{REPO_ROOT}/docs/TESTING_AND_SEED_POLICY.md`.
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. Read `{REPO_ROOT}/openspec/AGENT_RULES.md`.
7
- 3. Read `{REPO_ROOT}/openspec/AGENTS.md`.
8
- 4. Read `{REPO_ROOT}/openspec/project.md`.
9
- 5. Read `{REPO_ROOT}/openspec/CONVENTIONS.md`.
10
- 6. Read `{REPO_ROOT}/docs/TESTING_AND_SEED_POLICY.md`.
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())