agent-control-plane 0.1.9 → 0.1.13

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.
Files changed (40) hide show
  1. package/hooks/heartbeat-hooks.sh +147 -8
  2. package/hooks/issue-reconcile-hooks.sh +46 -0
  3. package/npm/bin/agent-control-plane.js +89 -8
  4. package/package.json +8 -2
  5. package/references/commands.md +1 -0
  6. package/tools/bin/agent-project-cleanup-session +133 -0
  7. package/tools/bin/agent-project-publish-issue-pr +178 -62
  8. package/tools/bin/agent-project-reconcile-issue-session +171 -3
  9. package/tools/bin/agent-project-run-codex-resilient +121 -16
  10. package/tools/bin/agent-project-run-codex-session +118 -10
  11. package/tools/bin/agent-project-run-openclaw-session +82 -8
  12. package/tools/bin/branch-verification-guard.sh +15 -2
  13. package/tools/bin/cleanup-worktree.sh +4 -1
  14. package/tools/bin/dashboard-launchd-bootstrap.sh +16 -4
  15. package/tools/bin/ensure-runtime-sync.sh +182 -0
  16. package/tools/bin/flow-config-lib.sh +76 -30
  17. package/tools/bin/flow-resident-worker-lib.sh +28 -2
  18. package/tools/bin/flow-shell-lib.sh +15 -1
  19. package/tools/bin/heartbeat-safe-auto.sh +32 -0
  20. package/tools/bin/issue-publish-localization-guard.sh +142 -0
  21. package/tools/bin/project-launchd-bootstrap.sh +17 -4
  22. package/tools/bin/project-runtime-supervisor.sh +7 -1
  23. package/tools/bin/project-runtimectl.sh +78 -15
  24. package/tools/bin/reuse-issue-worktree.sh +46 -0
  25. package/tools/bin/start-issue-worker.sh +110 -30
  26. package/tools/bin/start-resident-issue-loop.sh +1 -0
  27. package/tools/bin/sync-shared-agent-home.sh +50 -10
  28. package/tools/bin/test-smoke.sh +6 -1
  29. package/tools/dashboard/app.js +71 -1
  30. package/tools/dashboard/dashboard_snapshot.py +74 -0
  31. package/tools/dashboard/styles.css +43 -0
  32. package/tools/templates/issue-prompt-template.md +20 -65
  33. package/tools/templates/legacy/issue-prompt-template-pre-slim.md +109 -0
  34. package/bin/audit-issue-routing.sh +0 -74
  35. package/tools/bin/audit-agent-worktrees.sh +0 -310
  36. package/tools/bin/audit-issue-routing.sh +0 -11
  37. package/tools/bin/audit-retained-layout.sh +0 -58
  38. package/tools/bin/audit-retained-overlap.sh +0 -135
  39. package/tools/bin/audit-retained-worktrees.sh +0 -228
  40. package/tools/bin/check-skill-contracts.sh +0 -324
@@ -153,6 +153,54 @@ reap_stale_run_dir() {
153
153
  mv "$RUN_DIR" "${HISTORY_ROOT}/${SESSION}-stale-$(date +%Y%m%d-%H%M%S)"
154
154
  }
155
155
 
156
+ find_archived_issue_session_dir() {
157
+ local root="${1:-}"
158
+ local target_session="${2:-}"
159
+ [[ -n "$root" && -d "$root" && -n "$target_session" ]] || return 1
160
+
161
+ find "$root" -mindepth 1 -maxdepth 1 -type d -name "${target_session}-*" ! -name "${target_session}-stale-*" 2>/dev/null \
162
+ | sort -r \
163
+ | head -n 1
164
+ }
165
+
166
+ issue_retry_state_value() {
167
+ local key="${1:?retry-state key required}"
168
+ awk -F= -v target_key="$key" '$1 == target_key { print substr($0, index($0, "=") + 1); exit }' <<<"${ISSUE_RETRY_STATE:-}"
169
+ }
170
+
171
+ issue_host_publish_replay_dir() {
172
+ local last_reason=""
173
+ local archived_dir=""
174
+ local runner_state=""
175
+ local result_outcome=""
176
+ local result_action=""
177
+
178
+ last_reason="$(issue_retry_state_value LAST_REASON)"
179
+ case "${last_reason}" in
180
+ host-publish-failed|issue-worker-blocked) ;;
181
+ *) return 1 ;;
182
+ esac
183
+
184
+ archived_dir="$(find_archived_issue_session_dir "$HISTORY_ROOT" "$SESSION" || true)"
185
+ [[ -n "${archived_dir}" && -f "${archived_dir}/run.env" && -f "${archived_dir}/runner.env" && -f "${archived_dir}/result.env" ]] || return 1
186
+
187
+ runner_state="$(awk -F= '/^RUNNER_STATE=/{print $2; exit}' "${archived_dir}/runner.env")"
188
+ result_outcome="$(awk -F= '/^OUTCOME=/{print $2; exit}' "${archived_dir}/result.env")"
189
+ result_action="$(awk -F= '/^ACTION=/{print $2; exit}' "${archived_dir}/result.env")"
190
+
191
+ [[ "${runner_state}" == "succeeded" ]] || return 1
192
+ [[ "${result_outcome}" == "implemented" ]] || return 1
193
+ [[ "${result_action}" == "host-publish-issue-pr" ]] || return 1
194
+
195
+ printf '%s\n' "${archived_dir}"
196
+ }
197
+
198
+ replay_issue_host_publish_retry() {
199
+ local archived_dir="${1:?archived dir required}"
200
+ printf 'ISSUE_HOST_PUBLISH_REPLAY=session=%s archived_run_dir=%s\n' "${SESSION}" "${archived_dir}" >&2
201
+ bash "${WORKSPACE_DIR}/bin/reconcile-issue-worker.sh" "${SESSION}"
202
+ }
203
+
156
204
  if tmux has-session -t "$SESSION" 2>/dev/null; then
157
205
  echo "worker session already exists: $SESSION" >&2
158
206
  exit 1
@@ -180,6 +228,9 @@ EOF
180
228
  ISSUE_REQUIRES_LOCAL_WORKSPACE_INSTALL="$(
181
229
  ISSUE_BODY="$ISSUE_BODY" bash "$LOCAL_INSTALL_POLICY_BIN"
182
230
  )"
231
+ ISSUE_RETRY_STATE="$(
232
+ bash "${WORKSPACE_DIR}/bin/retry-state.sh" issue "$ISSUE_ID" get 2>/dev/null || true
233
+ )"
183
234
  if [[ "${ISSUE_SCHEDULE_INTERVAL_SECONDS}" =~ ^[1-9][0-9]*$ ]]; then
184
235
  TEMPLATE_FILE="${SCHEDULED_TEMPLATE_FILE}"
185
236
  fi
@@ -227,6 +278,16 @@ if [[ -d "$RUN_DIR" ]]; then
227
278
  reap_stale_run_dir
228
279
  fi
229
280
 
281
+ ISSUE_HOST_PUBLISH_REPLAY_DIR="$(issue_host_publish_replay_dir || true)"
282
+ if [[ -n "${ISSUE_HOST_PUBLISH_REPLAY_DIR}" ]]; then
283
+ if ! replay_issue_host_publish_retry "${ISSUE_HOST_PUBLISH_REPLAY_DIR}"; then
284
+ echo "host publish replay failed for session ${SESSION}" >&2
285
+ exit 1
286
+ fi
287
+ launch_success="yes"
288
+ exit 0
289
+ fi
290
+
230
291
  block_if_recurring_checklist_complete
231
292
 
232
293
  mkdir -p "$RUN_DIR"
@@ -348,9 +409,6 @@ if (completedPrs.length > 0) {
348
409
  process.stdout.write(`${lines.join('\n')}\n`);
349
410
  EOF
350
411
  ISSUE_RECURRING_CONTEXT="$(cat "$ISSUE_RECURRING_CONTEXT_FILE")"
351
- ISSUE_RETRY_STATE="$(
352
- bash "${WORKSPACE_DIR}/bin/retry-state.sh" issue "$ISSUE_ID" get 2>/dev/null || true
353
- )"
354
412
  ISSUE_BLOCKER_CONTEXT="$(
355
413
  ISSUE_JSON="$ISSUE_JSON" ISSUE_RETRY_STATE="$ISSUE_RETRY_STATE" node <<'EOF'
356
414
  const issue = JSON.parse(process.env.ISSUE_JSON || '{}');
@@ -401,6 +459,9 @@ const inferCommentReason = (bodyText) => {
401
459
  if (/^# Blocker: Verification requirements were not satisfied$/im.test(body)) {
402
460
  return 'verification-guard-blocked';
403
461
  }
462
+ if (/^# Blocker: Localization requirements were not satisfied$/im.test(body)) {
463
+ return 'localization-guard-blocked';
464
+ }
404
465
  if (/^# Blocker: (All checklist items already completed|Worker produced no publishable delta)$/im.test(body)) {
405
466
  return 'no-publishable-commits';
406
467
  }
@@ -468,6 +529,8 @@ if (effectiveLastReason === 'scope-guard-blocked') {
468
529
  }
469
530
  } else if (effectiveLastReason === 'verification-guard-blocked') {
470
531
  lines.push('- Add the missing verification or shrink the touched surface before attempting another publish cycle.');
532
+ } else if (effectiveLastReason === 'localization-guard-blocked') {
533
+ lines.push('- Finish moving the remaining user-facing literals behind translation keys before attempting another publish cycle.');
471
534
  }
472
535
 
473
536
  lines.push('', clippedBody);
@@ -579,10 +642,11 @@ open_or_reuse_issue_worktree() {
579
642
  RESIDENT_OPENCLAW_CONFIG_PATH="${current_resident_openclaw_config_path}"
580
643
  RESIDENT_TASK_COUNT="$(( ${TASK_COUNT:-0} + 1 ))"
581
644
  RESIDENT_WORKTREE_REUSED="yes"
582
- if [[ "${CODING_WORKER}" == "openclaw" && -n "${previous_issue_id}" && "${previous_issue_id}" != "${current_issue_id}" ]]; then
645
+ if [[ "${CODING_WORKER}" == "openclaw" ]]; then
583
646
  # Keep the resident lane's warm workspace/agent files, but rotate the
584
- # OpenClaw conversation thread when switching issues to reduce context drift.
585
- RESIDENT_OPENCLAW_SESSION_ID="$(flow_resident_issue_openclaw_session_id "${CONFIG_YAML}" "${current_issue_id}")"
647
+ # OpenClaw conversation thread every cycle so a new task does not inherit
648
+ # stale conversational context from the previous one.
649
+ RESIDENT_OPENCLAW_SESSION_ID="$(flow_resident_issue_openclaw_session_id "${CONFIG_YAML}" "${current_issue_id}" "${RESIDENT_TASK_COUNT}")"
586
650
  fi
587
651
  if reuse_output="$("${WORKSPACE_DIR}/bin/reuse-issue-worktree.sh" "${WORKTREE}" "${ISSUE_ID}" "${ISSUE_SLUG}" 2>&1)"; then
588
652
  WORKTREE_OUT="${reuse_output}"
@@ -590,6 +654,9 @@ open_or_reuse_issue_worktree() {
590
654
  printf 'RESIDENT_REUSE_FALLBACK=issue-%s reason=%s\n' "${ISSUE_ID}" "$(printf '%s' "${reuse_output}" | tr '\n' ' ' | sed 's/ */ /g')" >&2
591
655
  RESIDENT_TASK_COUNT="1"
592
656
  RESIDENT_WORKTREE_REUSED="no"
657
+ if [[ "${CODING_WORKER}" == "openclaw" ]]; then
658
+ RESIDENT_OPENCLAW_SESSION_ID="$(flow_resident_issue_openclaw_session_id "${CONFIG_YAML}" "${current_issue_id}" "${RESIDENT_TASK_COUNT}")"
659
+ fi
593
660
  if [[ "$ISSUE_REQUIRES_LOCAL_WORKSPACE_INSTALL" == "yes" ]]; then
594
661
  WORKTREE_OUT="$(ACP_WORKTREE_LOCAL_INSTALL=true F_LOSNING_WORKTREE_LOCAL_INSTALL=true "${WORKSPACE_DIR}/bin/new-worktree.sh" "$ISSUE_ID" "$ISSUE_SLUG")"
595
662
  else
@@ -599,6 +666,9 @@ open_or_reuse_issue_worktree() {
599
666
  else
600
667
  RESIDENT_TASK_COUNT="1"
601
668
  RESIDENT_WORKTREE_REUSED="no"
669
+ if [[ "${CODING_WORKER}" == "openclaw" ]]; then
670
+ RESIDENT_OPENCLAW_SESSION_ID="$(flow_resident_issue_openclaw_session_id "${CONFIG_YAML}" "${current_issue_id}" "${RESIDENT_TASK_COUNT}")"
671
+ fi
602
672
  if [[ "$ISSUE_REQUIRES_LOCAL_WORKSPACE_INSTALL" == "yes" ]]; then
603
673
  WORKTREE_OUT="$(ACP_WORKTREE_LOCAL_INSTALL=true F_LOSNING_WORKTREE_LOCAL_INSTALL=true "${WORKSPACE_DIR}/bin/new-worktree.sh" "$ISSUE_ID" "$ISSUE_SLUG")"
604
674
  else
@@ -657,38 +727,48 @@ for (const line of body.split(/\r?\n/).slice(0, 40)) {
657
727
  }
658
728
  }
659
729
 
660
- if (commands.length === 0 && repoRoot) {
661
- const packageJsonPath = path.join(repoRoot, 'package.json');
662
- if (fs.existsSync(packageJsonPath)) {
663
- try {
664
- const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
665
- if (packageJson?.scripts?.test) {
666
- if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) {
667
- addCommand('pnpm test');
668
- } else if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) {
669
- addCommand('yarn test');
670
- } else {
671
- addCommand('npm test');
730
+ if (commands.length === 0 && repoRoot) {
731
+ const packageJsonPath = path.join(repoRoot, 'package.json');
732
+ if (fs.existsSync(packageJsonPath)) {
733
+ try {
734
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
735
+ if (packageJson?.scripts?.smoke) {
736
+ addCommand('# If this cycle changes smoke runners, CLI entrypoints, or operator commands, also run the matching smoke command from the repo instructions.');
672
737
  }
738
+ } catch (_error) {
739
+ // Ignore parse errors and fall through to generic guidance.
673
740
  }
674
- } catch (_error) {
675
- // Ignore parse errors and fall through to generic guidance.
676
741
  }
677
742
  }
678
- }
679
743
 
680
- if (commands.length === 0) {
681
- addCommand('pnpm test');
682
- }
744
+ if (commands.length === 0) {
745
+ addCommand('# Pick the narrowest relevant local verification for the files you touch.');
746
+ addCommand('# Do not default to repo-wide pnpm test unless the issue body explicitly requires it.');
747
+ addCommand('# Examples:');
748
+ addCommand('# pnpm --filter @<repo>/api test -- --runInBand <target-spec>');
749
+ addCommand('# pnpm --filter @<repo>/api typecheck');
750
+ addCommand('# pnpm --filter @<repo>/web test -- --run <target-spec>');
751
+ addCommand('# pnpm --filter @<repo>/web typecheck');
752
+ addCommand('# pnpm --filter @<repo>/mobile test -- --runInBand <target-spec>');
753
+ addCommand('# pnpm --filter @<repo>/mobile typecheck');
754
+ addCommand('# pnpm --filter @<repo>/<package> test -- --run <target-spec>');
755
+ addCommand('# pnpm --filter @<repo>/<package> typecheck');
756
+ addCommand('# After each successful command, record it with record-verification.sh exactly as shown below.');
757
+ }
683
758
 
684
759
  const escapeDoubleQuotes = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
685
760
  const snippet = commands
686
- .map((command) =>
687
- command + '\n' +
688
- 'bash "$ACP_FLOW_TOOLS_DIR/record-verification.sh" --run-dir "$ACP_RUN_DIR" --status pass --command "' +
689
- escapeDoubleQuotes(command) +
690
- '"',
691
- )
761
+ .map((command) => {
762
+ if (command.startsWith('#')) {
763
+ return command;
764
+ }
765
+ return (
766
+ command + '\n' +
767
+ 'bash "$ACP_FLOW_TOOLS_DIR/record-verification.sh" --run-dir "$ACP_RUN_DIR" --status pass --command "' +
768
+ escapeDoubleQuotes(command) +
769
+ '"'
770
+ );
771
+ })
692
772
  .join('\n\n');
693
773
 
694
774
  process.stdout.write(snippet);
@@ -785,6 +785,7 @@ while true; do
785
785
  controller_refresh_execution_context
786
786
  controller_refresh_issue_lane_context "${is_scheduled}" "${schedule_interval_seconds}"
787
787
  controller_track_provider_selection "provider-selection"
788
+ controller_write_state "starting" ""
788
789
 
789
790
  if controller_yield_to_live_lane_peer; then
790
791
  break
@@ -32,12 +32,21 @@ if [[ ! -d "${FLOW_SKILL_SOURCE}" && -n "${COMPAT_FLOW_SKILL_ALIAS}" ]]; then
32
32
  fi
33
33
 
34
34
  FLOW_SKILL_SOURCE="$(cd "${FLOW_SKILL_SOURCE}" && pwd -P)"
35
+ SOURCE_HOME="$(cd "${SOURCE_HOME}" && pwd -P)"
36
+
37
+ remove_tree_force() {
38
+ local target="${1:-}"
39
+ [[ -n "${target}" ]] || return 0
40
+ [[ -e "${target}" || -L "${target}" ]] || return 0
41
+ chmod -R u+w "${target}" 2>/dev/null || true
42
+ rm -rf "${target}" 2>/dev/null || true
43
+ }
35
44
 
36
45
  sync_tree_copy_mode() {
37
46
  local source_dir="${1:?source dir required}"
38
47
  local target_dir="${2:?target dir required}"
39
48
  [[ -d "${source_dir}" ]] || return 0
40
- rm -rf "${target_dir}"
49
+ remove_tree_force "${target_dir}"
41
50
  mkdir -p "${target_dir}"
42
51
  (
43
52
  cd "${source_dir}"
@@ -57,18 +66,45 @@ sync_tree_into_target() {
57
66
  }
58
67
 
59
68
  sync_skill_copies() {
60
- sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_CANONICAL_ALIAS}"
61
- sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${FLOW_SKILL_TARGET}"
62
-
63
- if [[ -n "${SOURCE_FLOW_COMPAT_ALIAS}" ]]; then
64
- sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_COMPAT_ALIAS}"
69
+ if ! flow_is_skill_root "${SOURCE_HOME}"; then
70
+ sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_CANONICAL_ALIAS}"
71
+ if [[ -n "${SOURCE_FLOW_COMPAT_ALIAS}" ]]; then
72
+ sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${SOURCE_FLOW_COMPAT_ALIAS}"
73
+ fi
65
74
  fi
66
75
 
76
+ sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${FLOW_SKILL_TARGET}"
77
+
67
78
  if [[ -n "${TARGET_FLOW_COMPAT_ALIAS}" ]]; then
68
79
  sync_tree_into_target "${FLOW_SKILL_SOURCE}" "${TARGET_FLOW_COMPAT_ALIAS}"
69
80
  fi
70
81
  }
71
82
 
83
+ refresh_legacy_profile_templates() {
84
+ local profiles_root=""
85
+ local current_issue_template=""
86
+ local legacy_issue_template=""
87
+ local profile_dir=""
88
+ local profile_issue_template=""
89
+
90
+ profiles_root="$(resolve_flow_profile_registry_root)"
91
+ current_issue_template="${FLOW_SKILL_SOURCE}/tools/templates/issue-prompt-template.md"
92
+ legacy_issue_template="${FLOW_SKILL_SOURCE}/tools/templates/legacy/issue-prompt-template-pre-slim.md"
93
+
94
+ [[ -d "${profiles_root}" ]] || return 0
95
+ [[ -f "${current_issue_template}" ]] || return 0
96
+ [[ -f "${legacy_issue_template}" ]] || return 0
97
+
98
+ while IFS= read -r profile_dir; do
99
+ [[ -n "${profile_dir}" ]] || continue
100
+ profile_issue_template="${profile_dir}/templates/issue-prompt-template.md"
101
+ [[ -f "${profile_issue_template}" ]] || continue
102
+ if cmp -s "${profile_issue_template}" "${legacy_issue_template}"; then
103
+ cp "${current_issue_template}" "${profile_issue_template}"
104
+ fi
105
+ done < <(find "${profiles_root}" -mindepth 2 -maxdepth 2 -type f -name 'control-plane.yaml' -exec dirname {} \; 2>/dev/null | sort)
106
+ }
107
+
72
108
  remove_repo_local_profile_dirs() {
73
109
  local candidate=""
74
110
 
@@ -175,18 +211,21 @@ sync_tree_rsync() {
175
211
  }
176
212
 
177
213
  reset_runtime_skill_targets() {
178
- rm -rf "${FLOW_SKILL_TARGET}"
214
+ remove_tree_force "${FLOW_SKILL_TARGET}"
179
215
  if [[ -n "${TARGET_FLOW_COMPAT_ALIAS}" ]]; then
180
- rm -rf "${TARGET_FLOW_COMPAT_ALIAS}"
216
+ remove_tree_force "${TARGET_FLOW_COMPAT_ALIAS}"
181
217
  fi
182
218
  }
183
219
 
184
220
  reset_source_skill_targets() {
221
+ if flow_is_skill_root "${SOURCE_HOME}"; then
222
+ return 0
223
+ fi
185
224
  if [[ "${FLOW_SKILL_SOURCE}" != "${SOURCE_FLOW_CANONICAL_ALIAS}" ]]; then
186
- rm -rf "${SOURCE_FLOW_CANONICAL_ALIAS}"
225
+ remove_tree_force "${SOURCE_FLOW_CANONICAL_ALIAS}"
187
226
  fi
188
227
  if [[ -n "${SOURCE_FLOW_COMPAT_ALIAS}" && "${FLOW_SKILL_SOURCE}" != "${SOURCE_FLOW_COMPAT_ALIAS}" ]]; then
189
- rm -rf "${SOURCE_FLOW_COMPAT_ALIAS}"
228
+ remove_tree_force "${SOURCE_FLOW_COMPAT_ALIAS}"
190
229
  fi
191
230
  }
192
231
 
@@ -210,5 +249,6 @@ fi
210
249
  sync_skill_copies
211
250
  remove_repo_local_profile_dirs
212
251
  normalize_script_permissions
252
+ refresh_legacy_profile_templates
213
253
 
214
254
  printf 'SHARED_AGENT_HOME=%s\n' "${TARGET_HOME}"
@@ -58,7 +58,12 @@ run_step() {
58
58
  return "${status}"
59
59
  }
60
60
 
61
- run_step "check-skill-contracts" bash "${check_contracts_script}"
61
+ if [[ -f "${check_contracts_script}" ]]; then
62
+ run_step "check-skill-contracts" bash "${check_contracts_script}"
63
+ else
64
+ printf 'SMOKE_STEP=%s\n' "check-skill-contracts"
65
+ printf 'SMOKE_STEP_STATUS=%s\n' "skipped"
66
+ fi
62
67
 
63
68
  run_profile_smoke_fixture() (
64
69
  set -euo pipefail
@@ -2,6 +2,8 @@ const refreshButton = document.querySelector("#refresh-button");
2
2
  const generatedAtNode = document.querySelector("#generated-at");
3
3
  const overviewNode = document.querySelector("#overview");
4
4
  const profilesNode = document.querySelector("#profiles");
5
+ const seenAlertIds = new Set();
6
+ let notificationPermissionRequested = false;
5
7
 
6
8
  function relativeTime(input) {
7
9
  if (!input) return "n/a";
@@ -61,9 +63,10 @@ function renderOverview(snapshot) {
61
63
  acc.controllers += profile.counts.live_resident_controllers;
62
64
  acc.cooldowns += profile.counts.provider_cooldowns;
63
65
  acc.queue += profile.counts.queued_issues;
66
+ acc.alerts += profile.counts.alerts || 0;
64
67
  return acc;
65
68
  },
66
- { activeRuns: 0, runningRuns: 0, implementedRuns: 0, reportedRuns: 0, blockedRuns: 0, controllers: 0, cooldowns: 0, queue: 0 },
69
+ { activeRuns: 0, runningRuns: 0, implementedRuns: 0, reportedRuns: 0, blockedRuns: 0, controllers: 0, cooldowns: 0, queue: 0, alerts: 0 },
67
70
  );
68
71
 
69
72
  overviewNode.innerHTML = [
@@ -75,6 +78,7 @@ function renderOverview(snapshot) {
75
78
  ["Blocked", totals.blockedRuns],
76
79
  ["Live Controllers", totals.controllers],
77
80
  ["Provider Cooldowns", totals.cooldowns],
81
+ ["Alerts", totals.alerts],
78
82
  ["Queued Issues", totals.queue],
79
83
  ]
80
84
  .map(
@@ -104,6 +108,36 @@ function renderTable(columns, rows, emptyMessage = "No data right now.") {
104
108
  return `<div class="table-wrap"><table><thead><tr>${headers}</tr></thead><tbody>${body}</tbody></table></div>`;
105
109
  }
106
110
 
111
+ function renderAlerts(alerts) {
112
+ if (!alerts.length) {
113
+ return `<div class="empty-state">No active alerts for this profile.</div>`;
114
+ }
115
+ return `
116
+ <div class="alert-list">
117
+ ${alerts
118
+ .map(
119
+ (alert) => `
120
+ <article class="alert-card ${statusClass(alert.severity || "warn")}">
121
+ <div class="alert-header">
122
+ <div>
123
+ <h4>${alert.title}</h4>
124
+ <div class="muted mono">${alert.session || "n/a"} · ${alert.task_kind || "task"} ${alert.task_id || ""}</div>
125
+ </div>
126
+ <span class="badge warn">${alert.kind}</span>
127
+ </div>
128
+ <p>${alert.message}</p>
129
+ <div class="alert-meta">
130
+ <span>${alert.reset_at ? `Reset: ${alert.reset_at}` : "Reset: n/a"}</span>
131
+ <span>${alert.updated_at ? `${relativeTime(alert.updated_at)} · ${alert.updated_at}` : "updated n/a"}</span>
132
+ </div>
133
+ </article>
134
+ `,
135
+ )
136
+ .join("")}
137
+ </div>
138
+ `;
139
+ }
140
+
107
141
  function renderProfile(profile) {
108
142
  const providerBadges = [
109
143
  profile.coding_worker ? `<span class="badge good">${profile.coding_worker}</span>` : "",
@@ -125,6 +159,7 @@ function renderProfile(profile) {
125
159
  ["Live controllers", profile.counts.live_resident_controllers],
126
160
  ["Stale controllers", profile.counts.stale_resident_controllers],
127
161
  ["Provider cooldowns", profile.counts.provider_cooldowns],
162
+ ["Alerts", profile.counts.alerts || 0],
128
163
  ["Issue retries", profile.counts.active_retries],
129
164
  ["Queued issues", profile.counts.queued_issues],
130
165
  ["Scheduled", profile.counts.scheduled_issues],
@@ -240,6 +275,11 @@ function renderProfile(profile) {
240
275
  </header>
241
276
  <section class="overview">${summaryCards}</section>
242
277
  <section class="profile-grid">
278
+ <section class="panel">
279
+ <h3>Host Alerts</h3>
280
+ <p class="panel-subtitle">High-signal operational blockers surfaced from active run logs and comment artifacts.</p>
281
+ ${renderAlerts(profile.alerts || [])}
282
+ </section>
243
283
  <section class="panel">
244
284
  <h3>Active Runs</h3>
245
285
  <p class="panel-subtitle">Lifecycle shows technical session completion. Result shows what the run achieved: implemented, reported, or blocked.</p>
@@ -275,6 +315,35 @@ function renderProfile(profile) {
275
315
  `;
276
316
  }
277
317
 
318
+ async function maybeNotifyAlerts(snapshot) {
319
+ const alerts = (snapshot.alerts || []).filter((alert) => alert && alert.id);
320
+ if (!alerts.length || typeof window.Notification === "undefined") return;
321
+
322
+ if (window.Notification.permission === "default" && !notificationPermissionRequested) {
323
+ notificationPermissionRequested = true;
324
+ try {
325
+ await window.Notification.requestPermission();
326
+ } catch (_error) {
327
+ return;
328
+ }
329
+ }
330
+
331
+ if (window.Notification.permission !== "granted") return;
332
+
333
+ for (const alert of alerts) {
334
+ if (seenAlertIds.has(alert.id)) continue;
335
+ seenAlertIds.add(alert.id);
336
+ const bodyParts = [];
337
+ if (alert.session) bodyParts.push(alert.session);
338
+ if (alert.reset_at) bodyParts.push(`reset ${alert.reset_at}`);
339
+ if (alert.message) bodyParts.push(alert.message);
340
+ new window.Notification(alert.title || "ACP alert", {
341
+ body: bodyParts.join(" · ").slice(0, 240),
342
+ tag: alert.id,
343
+ });
344
+ }
345
+ }
346
+
278
347
  async function loadSnapshot() {
279
348
  refreshButton.disabled = true;
280
349
  try {
@@ -286,6 +355,7 @@ async function loadSnapshot() {
286
355
  generatedAtNode.textContent = `Snapshot: ${snapshot.generated_at}`;
287
356
  renderOverview(snapshot);
288
357
  profilesNode.innerHTML = snapshot.profiles.map(renderProfile).join("");
358
+ await maybeNotifyAlerts(snapshot);
289
359
  } catch (error) {
290
360
  generatedAtNode.textContent = `Snapshot load failed: ${error.message}`;
291
361
  profilesNode.innerHTML = `<article class="profile"><div class="empty-state">${error.message}</div></article>`;
@@ -143,6 +143,19 @@ 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_tail_text(path: Path, max_bytes: int = 65536) -> str:
147
+ if not path.is_file():
148
+ return ""
149
+ try:
150
+ with path.open("rb") as handle:
151
+ size = path.stat().st_size
152
+ if size > max_bytes:
153
+ handle.seek(size - max_bytes)
154
+ return handle.read().decode("utf-8", errors="replace")
155
+ except OSError:
156
+ return ""
157
+
158
+
146
159
  def classify_run_result(status: str, outcome: str, failure_reason: str) -> tuple[str, str]:
147
160
  normalized_status = (status or "").strip().upper()
148
161
  normalized_outcome = (outcome or "").strip()
@@ -167,6 +180,59 @@ def classify_run_result(status: str, outcome: str, failure_reason: str) -> tuple
167
180
  return ("unknown", normalized_status or "Unknown")
168
181
 
169
182
 
183
+ GITHUB_RATE_LIMIT_PATTERNS = [
184
+ re.compile(
185
+ r"GitHub core API[^\n]*?rate limit[^\n]*(?:reset(?:s| into)?(?: at)?\s+(?P<reset>[^.\n]+))?",
186
+ re.IGNORECASE,
187
+ ),
188
+ re.compile(
189
+ r"gh:\s*API rate limit exceeded[^\n]*(?:reset(?:s| into)?(?: at)?\s+(?P<reset>[^.\n]+))?",
190
+ re.IGNORECASE,
191
+ ),
192
+ ]
193
+
194
+
195
+ def summarize_whitespace(text: str) -> str:
196
+ return re.sub(r"\s+", " ", text).strip()
197
+
198
+
199
+ def extract_github_rate_limit_alert(run_dir: Path, run: dict[str, Any]) -> dict[str, Any] | None:
200
+ candidate_files = [
201
+ run_dir / "issue-comment.md",
202
+ run_dir / "pr-comment.md",
203
+ run_dir / f"{run['session']}.log",
204
+ ]
205
+ for path in candidate_files:
206
+ text = read_tail_text(path)
207
+ if not text:
208
+ continue
209
+ for pattern in GITHUB_RATE_LIMIT_PATTERNS:
210
+ match = pattern.search(text)
211
+ if not match:
212
+ continue
213
+ summary = summarize_whitespace(match.group(0))
214
+ reset_match = re.search(r"reset(?:s| into)?(?: at)?\s+([^.\n]+)", summary, re.IGNORECASE)
215
+ reset_at = summarize_whitespace((reset_match.group(1) if reset_match else "") or match.groupdict().get("reset") or "")
216
+ if not summary:
217
+ summary = "GitHub core API rate limit is blocking host-side actions."
218
+ if reset_at and reset_at not in summary:
219
+ summary = f"{summary} Reset: {reset_at}."
220
+ return {
221
+ "id": f"github-core-rate-limit:{run['session']}:{reset_at or path.name}",
222
+ "kind": "github-core-rate-limit",
223
+ "severity": "warn",
224
+ "title": "GitHub core API rate limit blocks host actions",
225
+ "message": summary,
226
+ "session": run.get("session", ""),
227
+ "task_kind": run.get("task_kind", ""),
228
+ "task_id": run.get("task_id", ""),
229
+ "reset_at": reset_at,
230
+ "updated_at": run.get("updated_at", "") or file_mtime_iso(path),
231
+ "source_file": str(path),
232
+ }
233
+ return None
234
+
235
+
170
236
  def collect_runs(runs_root: Path) -> list[dict[str, Any]]:
171
237
  if not runs_root.is_dir():
172
238
  return []
@@ -221,6 +287,8 @@ def collect_runs(runs_root: Path) -> list[dict[str, Any]]:
221
287
  "provider_pool_name": run_env.get("ACTIVE_PROVIDER_POOL_NAME", ""),
222
288
  "run_dir": str(run_dir),
223
289
  }
290
+ alert = extract_github_rate_limit_alert(run_dir, item)
291
+ item["alerts"] = [alert] if alert else []
224
292
  runs.append(item)
225
293
  return runs
226
294
 
@@ -430,6 +498,7 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
430
498
  scheduled = collect_scheduled_issues(state_root)
431
499
  retries = collect_issue_retries(state_root)
432
500
  queue = collect_issue_queue(state_root)
501
+ alerts = [alert for run in runs for alert in run.get("alerts", [])]
433
502
 
434
503
  return {
435
504
  "id": profile_id,
@@ -471,8 +540,10 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
471
540
  "provider_cooldowns": sum(1 for item in cooldowns if item["active"]),
472
541
  "active_retries": sum(1 for item in retries if not item.get("ready", True)),
473
542
  "scheduled_issues": len(scheduled),
543
+ "alerts": len(alerts),
474
544
  },
475
545
  "runs": runs,
546
+ "alerts": alerts,
476
547
  "resident_controllers": controllers,
477
548
  "resident_workers": resident_workers,
478
549
  "provider_cooldowns": cooldowns,
@@ -485,11 +556,14 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
485
556
  def build_snapshot() -> dict[str, Any]:
486
557
  registry_root = profile_registry_root()
487
558
  profiles = [build_profile_snapshot(profile_id, registry_root) for profile_id in list_profile_ids(registry_root)]
559
+ alerts = [alert for profile in profiles for alert in profile.get("alerts", [])]
488
560
  return {
489
561
  "generated_at": utc_now_iso(),
490
562
  "flow_skill_dir": str(ROOT_DIR),
491
563
  "profile_registry_root": str(registry_root),
492
564
  "profile_count": len(profiles),
565
+ "alert_count": len(alerts),
566
+ "alerts": alerts,
493
567
  "profiles": profiles,
494
568
  }
495
569
 
@@ -230,6 +230,49 @@ button:hover {
230
230
  font-size: 14px;
231
231
  }
232
232
 
233
+ .alert-list {
234
+ display: grid;
235
+ gap: 12px;
236
+ }
237
+
238
+ .alert-card {
239
+ padding: 14px 16px;
240
+ border-radius: 18px;
241
+ border: 1px solid var(--line);
242
+ background: var(--panel-strong);
243
+ }
244
+
245
+ .alert-card.warn {
246
+ border-color: #e7c76d;
247
+ background: #fff6dd;
248
+ }
249
+
250
+ .alert-card h4 {
251
+ margin: 0;
252
+ font-size: 17px;
253
+ }
254
+
255
+ .alert-card p {
256
+ margin: 10px 0 0;
257
+ line-height: 1.5;
258
+ }
259
+
260
+ .alert-header {
261
+ display: flex;
262
+ gap: 12px;
263
+ justify-content: space-between;
264
+ align-items: flex-start;
265
+ }
266
+
267
+ .alert-meta {
268
+ margin-top: 10px;
269
+ display: flex;
270
+ flex-wrap: wrap;
271
+ gap: 12px;
272
+ color: var(--muted);
273
+ font-size: 13px;
274
+ }
275
+
233
276
  .table-wrap {
234
277
  overflow-x: auto;
235
278
  }