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
@@ -24,8 +24,10 @@ base_branch="main"
24
24
  remote_name="origin"
25
25
  dry_run="false"
26
26
  keep_open_label=""
27
+ meta_file_from_archive="no"
27
28
  shared_agent_home="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
28
29
  scope_guard_script="${shared_agent_home}/tools/bin/issue-publish-scope-guard.sh"
30
+ localization_guard_script="${shared_agent_home}/tools/bin/issue-publish-localization-guard.sh"
29
31
  verification_guard_script="${shared_agent_home}/tools/bin/branch-verification-guard.sh"
30
32
 
31
33
  while [[ $# -gt 0 ]]; do
@@ -51,11 +53,16 @@ fi
51
53
  find_archived_session_dir() {
52
54
  local root="${1:-}"
53
55
  local target_session="${2:-}"
56
+ local latest=""
54
57
  [[ -n "$root" && -d "$root" && -n "$target_session" ]] || return 1
55
58
 
56
- find "$root" -mindepth 1 -maxdepth 1 -type d -name "${target_session}-*" ! -name "${target_session}-stale-*" 2>/dev/null \
57
- | sort -r \
58
- | head -n 1
59
+ latest="$(
60
+ command find "$root" -mindepth 1 -maxdepth 1 -type d -name "${target_session}-*" ! -name "${target_session}-stale-*" 2>/dev/null \
61
+ | LC_ALL=C sort -r \
62
+ | sed -n '1p'
63
+ )"
64
+ [[ -n "$latest" ]] || return 1
65
+ printf '%s\n' "$latest"
59
66
  }
60
67
 
61
68
  find_existing_worktree_for_branch() {
@@ -88,6 +95,64 @@ find_existing_worktree_for_branch() {
88
95
  return 1
89
96
  }
90
97
 
98
+ worktree_is_git_repo() {
99
+ local worktree_path="${1:-}"
100
+ [[ -n "$worktree_path" && -d "$worktree_path" ]] || return 1
101
+ git -C "$worktree_path" rev-parse --is-inside-work-tree >/dev/null 2>&1
102
+ }
103
+
104
+ worktree_matches_publish_target() {
105
+ local worktree_path="${1:-}"
106
+ local current_branch=""
107
+ local current_head=""
108
+
109
+ worktree_is_git_repo "$worktree_path" || return 1
110
+
111
+ current_head="$(git -C "$worktree_path" rev-parse HEAD 2>/dev/null || true)"
112
+ current_branch="$(git -C "$worktree_path" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
113
+
114
+ if [[ -n "${FINAL_HEAD:-}" && -n "$current_head" && "$current_head" == "$FINAL_HEAD" ]]; then
115
+ return 0
116
+ fi
117
+
118
+ if [[ -n "${BRANCH:-}" && -n "$current_branch" && "$current_branch" == "$BRANCH" ]]; then
119
+ return 0
120
+ fi
121
+
122
+ return 1
123
+ }
124
+
125
+ recover_worktree_from_remote_branch() {
126
+ local repo_root="${1:-}"
127
+ local worktree_root="${2:-}"
128
+ local branch_name="${3:-}"
129
+ local remote="${4:-}"
130
+ local remote_ref=""
131
+ local fallback_worktree=""
132
+
133
+ [[ -n "$repo_root" && -d "$repo_root" && -n "$worktree_root" && -n "$branch_name" && -n "$remote" ]] || return 1
134
+
135
+ remote_ref="refs/remotes/${remote}/${branch_name}"
136
+ git -C "$repo_root" fetch "$remote" "$branch_name" >/dev/null 2>&1 || true
137
+ git -C "$repo_root" rev-parse --verify "$remote_ref" >/dev/null 2>&1 || return 1
138
+
139
+ fallback_worktree="$(mktemp -d "${worktree_root}/recovery-XXXXXX")"
140
+ git -C "$repo_root" worktree add --detach "$fallback_worktree" "$remote_ref" >/dev/null 2>&1 || {
141
+ rm -rf "$fallback_worktree" 2>/dev/null || true
142
+ return 1
143
+ }
144
+ worktree_is_git_repo "$fallback_worktree" || {
145
+ rm -rf "$fallback_worktree" 2>/dev/null || true
146
+ return 1
147
+ }
148
+ git -C "$fallback_worktree" checkout -B "$branch_name" "$remote_ref" >/dev/null 2>&1 || {
149
+ rm -rf "$fallback_worktree" 2>/dev/null || true
150
+ return 1
151
+ }
152
+ git -C "$fallback_worktree" branch --set-upstream-to "${remote}/${branch_name}" "$branch_name" >/dev/null 2>&1 || true
153
+ printf '%s\n' "$fallback_worktree"
154
+ }
155
+
91
156
  status_out="$(
92
157
  "${shared_agent_home}/tools/bin/agent-project-worker-status" \
93
158
  --runs-root "$runs_root" \
@@ -98,6 +163,7 @@ if [[ -z "$meta_file" || ! -f "$meta_file" ]]; then
98
163
  archived_run_dir="$(find_archived_session_dir "$history_root" "$session" || true)"
99
164
  if [[ -n "$archived_run_dir" && -f "${archived_run_dir}/run.env" ]]; then
100
165
  meta_file="${archived_run_dir}/run.env"
166
+ meta_file_from_archive="yes"
101
167
  fi
102
168
  fi
103
169
  if [[ -z "$meta_file" || ! -f "$meta_file" ]]; then
@@ -117,10 +183,34 @@ if [[ -z "${ISSUE_ID:-}" || -z "${BRANCH:-}" ]]; then
117
183
  exit 1
118
184
  fi
119
185
 
186
+ resolve_actor_login() {
187
+ local login=""
188
+
189
+ flow_export_github_cli_auth_env "${repo_slug}"
190
+ login="$(gh api user --jq .login 2>/dev/null || true)"
191
+ if [[ -z "${login}" ]]; then
192
+ login="$(
193
+ gh auth status 2>/dev/null \
194
+ | sed -n 's/^ ✓ Logged in to github.com account \([^ ]*\) (.*/\1/p' \
195
+ | head -n 1
196
+ )"
197
+ fi
198
+ if [[ -z "${login}" ]]; then
199
+ login="${USER:-}"
200
+ fi
201
+ printf '%s\n' "${login}"
202
+ }
203
+
120
204
  issue_json="$(flow_github_issue_view_json "$repo_slug" "$ISSUE_ID")"
121
205
  issue_title="$(jq -r '.title' <<<"$issue_json")"
122
206
  issue_url="$(jq -r '.url' <<<"$issue_json")"
123
- actor_login="${GITHUB_ACTOR:-$(gh api user --jq .login)}"
207
+ if [[ -z "${issue_title}" || "${issue_title}" == "null" ]]; then
208
+ issue_title="Issue #${ISSUE_ID}"
209
+ fi
210
+ if [[ -z "${issue_url}" || "${issue_url}" == "null" ]]; then
211
+ issue_url="${ISSUE_URL:-}"
212
+ fi
213
+ actor_login="${GITHUB_ACTOR:-$(resolve_actor_login)}"
124
214
  issue_keep_open="no"
125
215
  if [[ -n "$keep_open_label" ]] && jq -e --arg label "$keep_open_label" 'any(.labels[]?; .name == $label)' >/dev/null <<<"$issue_json"; then
126
216
  issue_keep_open="yes"
@@ -206,73 +296,93 @@ if [[ -n "$pr_json" ]]; then
206
296
  pr_number="$(jq -r '.number' <<<"$pr_json")"
207
297
  pr_url="$(jq -r '.url' <<<"$pr_json")"
208
298
  else
209
- if [[ -z "${WORKTREE:-}" || ! -d "${WORKTREE:-}" ]]; then
210
- # Worktree was cleaned up before publish — try branch fallback
211
- fallback_worktree=""
212
- if [[ -n "${BRANCH:-}" ]]; then
213
- # Resolve the baseline repo from the git common dir of the (now missing) worktree
214
- local_repo_root="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
215
- local_worktree_root="$(flow_resolve_worktree_root "${CONFIG_YAML}")"
216
-
217
- recover_branch_from_final_head() {
218
- [[ -n "${FINAL_HEAD:-}" ]] || return 1
219
- git -C "$local_repo_root" cat-file -e "${FINAL_HEAD}^{commit}" >/dev/null 2>&1 || return 1
220
- git -C "$local_repo_root" branch "$BRANCH" "$FINAL_HEAD" >/dev/null 2>&1 || return 1
299
+ local_repo_root="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
300
+ local_worktree_root="$(flow_resolve_worktree_root "${CONFIG_YAML}")"
301
+ remote_branch_ref="refs/remotes/${remote_name}/${BRANCH}"
302
+ existing_worktree=""
303
+ fallback_worktree=""
304
+ remote_head=""
305
+ current_head=""
306
+
307
+ if [[ "${meta_file_from_archive}" == "yes" && -n "${WORKTREE:-}" ]]; then
308
+ printf 'WORKTREE_RECOVERY=ignored-archived-pointer\n' >&2
309
+ printf 'RECOVERY_WORKTREE=%s\n' "${WORKTREE}" >&2
310
+ WORKTREE=""
311
+ fi
312
+
313
+ if [[ -n "${WORKTREE:-}" ]] && ! worktree_matches_publish_target "${WORKTREE}"; then
314
+ printf 'WORKTREE_RECOVERY=ignored-stale\n' >&2
315
+ printf 'RECOVERY_WORKTREE=%s\n' "${WORKTREE}" >&2
316
+ WORKTREE=""
317
+ fi
318
+
319
+ git -C "$local_repo_root" worktree prune >/dev/null 2>&1 || true
320
+
321
+ if [[ -z "${WORKTREE:-}" ]]; then
322
+ existing_worktree="$(find_existing_worktree_for_branch "$local_repo_root" "$BRANCH" || true)"
323
+ if [[ -n "$existing_worktree" ]] && worktree_matches_publish_target "$existing_worktree"; then
324
+ WORKTREE="$existing_worktree"
325
+ printf 'WORKTREE_RECOVERY=reused-existing\n' >&2
326
+ printf 'RECOVERY_WORKTREE=%s\n' "$existing_worktree" >&2
327
+ fi
328
+ fi
329
+
330
+ if [[ -z "${WORKTREE:-}" && "${meta_file_from_archive}" == "yes" ]]; then
331
+ fallback_worktree="$(recover_worktree_from_remote_branch "$local_repo_root" "$local_worktree_root" "$BRANCH" "$remote_name" || true)"
332
+ if [[ -n "$fallback_worktree" ]]; then
333
+ WORKTREE="$fallback_worktree"
334
+ printf 'WORKTREE_RECOVERY=from-remote\n' >&2
335
+ printf 'RECOVERY_WORKTREE=%s\n' "$fallback_worktree" >&2
336
+ fi
337
+ fi
338
+
339
+ if [[ -z "${WORKTREE:-}" ]]; then
340
+ if ! git -C "$local_repo_root" rev-parse --verify "${BRANCH}" >/dev/null 2>&1; then
341
+ if [[ -n "${FINAL_HEAD:-}" ]] && git -C "$local_repo_root" cat-file -e "${FINAL_HEAD}^{commit}" >/dev/null 2>&1; then
342
+ git -C "$local_repo_root" branch -f "$BRANCH" "$FINAL_HEAD" >/dev/null 2>&1 || true
221
343
  printf 'BRANCH_RECOVERY=from-final-head\n' >&2
222
344
  printf 'RECOVERY_HEAD=%s\n' "$FINAL_HEAD" >&2
223
- }
224
-
225
- # Check if the branch exists in the baseline repo
226
- if ! git -C "$local_repo_root" rev-parse --verify "${BRANCH}" >/dev/null 2>&1; then
227
- recover_branch_from_final_head || true
228
345
  fi
229
- if git -C "$local_repo_root" rev-parse --verify "${BRANCH}" >/dev/null 2>&1; then
230
- git -C "$local_repo_root" worktree prune >/dev/null 2>&1 || true
231
- existing_worktree="$(find_existing_worktree_for_branch "$local_repo_root" "$BRANCH" || true)"
232
- if [[ -n "$existing_worktree" && ( -f "$existing_worktree/.git" || -d "$existing_worktree/.git" ) ]]; then
233
- WORKTREE="$existing_worktree"
234
- fallback_worktree="$existing_worktree"
235
- printf 'WORKTREE_RECOVERY=reused-existing\n' >&2
236
- printf 'RECOVERY_WORKTREE=%s\n' "$existing_worktree" >&2
346
+ fi
347
+
348
+ existing_worktree="$(find_existing_worktree_for_branch "$local_repo_root" "$BRANCH" || true)"
349
+ if [[ -n "$existing_worktree" ]] && worktree_is_git_repo "$existing_worktree"; then
350
+ WORKTREE="$existing_worktree"
351
+ printf 'WORKTREE_RECOVERY=reused-existing\n' >&2
352
+ printf 'RECOVERY_WORKTREE=%s\n' "$existing_worktree" >&2
353
+ else
354
+ fallback_worktree="$(mktemp -d "${local_worktree_root}/recovery-XXXXXX")"
355
+ if git -C "$local_repo_root" worktree add "$fallback_worktree" "$BRANCH" >/dev/null 2>&1 \
356
+ || git -C "$local_repo_root" worktree add "$fallback_worktree" "$remote_branch_ref" >/dev/null 2>&1; then
357
+ if worktree_is_git_repo "$fallback_worktree"; then
358
+ WORKTREE="$fallback_worktree"
359
+ printf 'WORKTREE_RECOVERY=created\n' >&2
360
+ printf 'RECOVERY_WORKTREE=%s\n' "$fallback_worktree" >&2
237
361
  else
238
- fallback_worktree="$(mktemp -d "${local_worktree_root}/recovery-XXXXXX")"
239
- git -C "$local_repo_root" worktree add "$fallback_worktree" "$BRANCH" 2>/dev/null || true
240
- # Validate the worktree is a real git repo, not just an empty directory
241
- if [[ -f "$fallback_worktree/.git" || -d "$fallback_worktree/.git" ]]; then
242
- WORKTREE="$fallback_worktree"
243
- printf 'WORKTREE_RECOVERY=created\n' >&2
244
- printf 'RECOVERY_WORKTREE=%s\n' "$fallback_worktree" >&2
245
- else
246
- rm -rf "$fallback_worktree" 2>/dev/null || true
247
- fallback_worktree=""
248
- fi
249
- fi
250
- fi
251
- # Check if branch exists on remote
252
- if [[ -z "${fallback_worktree}" ]]; then
253
- git -C "$local_repo_root" fetch "$remote_name" 2>/dev/null || true
254
- if git -C "$local_repo_root" rev-parse --verify "refs/remotes/${remote_name}/${BRANCH}" >/dev/null 2>&1; then
255
- fallback_worktree="$(mktemp -d "${local_worktree_root}/recovery-XXXXXX")"
256
- git -C "$local_repo_root" worktree add "$fallback_worktree" "refs/remotes/${remote_name}/${BRANCH}" 2>/dev/null || true
257
- # Validate the worktree is a real git repo, not just an empty directory
258
- if [[ -f "$fallback_worktree/.git" || -d "$fallback_worktree/.git" ]]; then
259
- WORKTREE="$fallback_worktree"
260
- printf 'WORKTREE_RECOVERY=from-remote\n' >&2
261
- printf 'RECOVERY_WORKTREE=%s\n' "$fallback_worktree" >&2
262
- else
263
- rm -rf "$fallback_worktree" 2>/dev/null || true
264
- fallback_worktree=""
265
- fi
362
+ rm -rf "$fallback_worktree" 2>/dev/null || true
266
363
  fi
364
+ else
365
+ rm -rf "$fallback_worktree" 2>/dev/null || true
267
366
  fi
268
367
  fi
269
- if [[ -z "${WORKTREE:-}" || ! -d "${WORKTREE:-}" ]]; then
270
- echo "session $session is missing a publishable worktree (branch ${BRANCH:-unknown} not found locally or on remote ${remote_name})" >&2
271
- exit 1
272
- fi
273
368
  fi
274
369
 
275
- git -C "$WORKTREE" fetch "$remote_name" "$base_branch" --prune >/dev/null
370
+ if [[ -z "${WORKTREE:-}" || ! -d "${WORKTREE:-}" ]]; then
371
+ echo "session $session is missing a publishable worktree (branch ${BRANCH:-unknown} not found locally or on remote ${remote_name})" >&2
372
+ exit 1
373
+ fi
374
+
375
+ git -C "$WORKTREE" fetch "$remote_name" "$base_branch" "$BRANCH" --prune >/dev/null 2>&1 || git -C "$WORKTREE" fetch "$remote_name" "$base_branch" --prune >/dev/null 2>&1 || true
376
+ if git -C "$local_repo_root" rev-parse --verify "${remote_branch_ref}" >/dev/null 2>&1; then
377
+ remote_head="$(git -C "$local_repo_root" rev-parse "${remote_branch_ref}" 2>/dev/null || true)"
378
+ current_head="$(git -C "$WORKTREE" rev-parse HEAD 2>/dev/null || true)"
379
+ if [[ -n "${remote_head}" && "${current_head}" != "${remote_head}" ]]; then
380
+ git -C "$WORKTREE" checkout -B "$BRANCH" "${remote_branch_ref}" >/dev/null 2>&1 || true
381
+ git -C "$WORKTREE" branch --set-upstream-to "${remote_name}/${BRANCH}" "$BRANCH" >/dev/null 2>&1 || true
382
+ printf 'WORKTREE_RECOVERY=aligned-remote\n' >&2
383
+ printf 'RECOVERY_HEAD=%s\n' "${remote_head}" >&2
384
+ fi
385
+ fi
276
386
 
277
387
  head_sha="$(git -C "$WORKTREE" rev-parse HEAD)"
278
388
  ahead_count="$(git -C "$WORKTREE" rev-list --count "${remote_name}/${base_branch}"..HEAD)"
@@ -286,6 +396,12 @@ else
286
396
  --base-ref "${remote_name}/${base_branch}" \
287
397
  --issue-id "$ISSUE_ID"
288
398
 
399
+ if [[ -x "${localization_guard_script}" ]]; then
400
+ "${localization_guard_script}" \
401
+ --worktree "$WORKTREE" \
402
+ --base-ref "${remote_name}/${base_branch}"
403
+ fi
404
+
289
405
  "${verification_guard_script}" \
290
406
  --worktree "$WORKTREE" \
291
407
  --base-ref "${remote_name}/${base_branch}" \
@@ -305,7 +305,7 @@ provider_cooldown_script="${shared_tools_dir}/provider-cooldown-state.sh"
305
305
 
306
306
  schedule_provider_quota_cooldown() {
307
307
  local reason="${1:-provider-quota-limit}"
308
- [[ "${failure_reason:-}" == "provider-quota-limit" ]] || return 0
308
+ [[ "${reason}" == "provider-quota-limit" ]] || return 0
309
309
  [[ -x "${provider_cooldown_script}" ]] || return 0
310
310
 
311
311
  "${provider_cooldown_script}" schedule "${reason}" >/dev/null || true
@@ -317,6 +317,54 @@ clear_provider_quota_cooldown() {
317
317
  "${provider_cooldown_script}" clear >/dev/null || true
318
318
  }
319
319
 
320
+ normalize_issue_failure_reason() {
321
+ local current_reason="${1:-}"
322
+
323
+ case "${current_reason}" in
324
+ usage-limit|quota-switch-deferred|quota-switch-attempt-limit)
325
+ if [[ "${CODING_WORKER:-}" == "codex" ]]; then
326
+ printf 'provider-quota-limit\n'
327
+ return 0
328
+ fi
329
+ ;;
330
+ esac
331
+
332
+ printf '%s\n' "${current_reason}"
333
+ }
334
+
335
+ issue_runtime_log_file() {
336
+ if [[ -f "${run_dir}/${session}.log" ]]; then
337
+ printf '%s\n' "${run_dir}/${session}.log"
338
+ return 0
339
+ fi
340
+
341
+ find "${run_dir}" -maxdepth 1 -type f -name '*.log' 2>/dev/null | LC_ALL=C sort | tail -n 1
342
+ }
343
+
344
+ infer_issue_runtime_failure_from_log() {
345
+ local log_file=""
346
+
347
+ log_file="$(issue_runtime_log_file)"
348
+ [[ -n "${log_file}" && -f "${log_file}" ]] || return 1
349
+
350
+ if grep -Eiq 'stale-run no-codex-output-before-stall-threshold|no-codex-output-before-stall-threshold' "${log_file}" 2>/dev/null; then
351
+ printf 'no-codex-output-before-stall-threshold\n'
352
+ return 0
353
+ fi
354
+
355
+ if grep -Eiq 'stale-run no-codex-progress-before-stall-threshold|no-codex-progress-before-stall-threshold' "${log_file}" 2>/dev/null; then
356
+ printf 'no-codex-progress-before-stall-threshold\n'
357
+ return 0
358
+ fi
359
+
360
+ if grep -Eiq 'Ignoring invalid cwd .* No such file or directory|/tmp is absolute|Custom tool call output is missing' "${log_file}" 2>/dev/null; then
361
+ printf 'worker-environment-blocked\n'
362
+ return 0
363
+ fi
364
+
365
+ return 1
366
+ }
367
+
320
368
  normalize_issue_result_contract() {
321
369
  [[ "$status" == "SUCCEEDED" ]] || return 0
322
370
 
@@ -691,6 +739,11 @@ classify_issue_publish_blocker() {
691
739
  return 0
692
740
  fi
693
741
 
742
+ if grep -Fq 'Localization guard blocked branch publication.' <<<"$publish_out"; then
743
+ printf 'localization-guard-blocked\n'
744
+ return 0
745
+ fi
746
+
694
747
  if grep -Fq 'has no commits ahead of' <<<"$publish_out"; then
695
748
  printf 'no-publishable-commits\n'
696
749
  return 0
@@ -788,6 +841,23 @@ Why it was blocked:
788
841
  - the verification guard could not confirm the expected checks for this change
789
842
  - recurring issue publication should stop rather than open an unverifiable PR
790
843
 
844
+ \`\`\`text
845
+ ${publish_out}
846
+ \`\`\`
847
+ EOF
848
+ return 0
849
+ fi
850
+
851
+ if [[ "${blocker_reason}" == "localization-guard-blocked" ]]; then
852
+ cat <<EOF
853
+ # Blocker: Localization requirements were not satisfied
854
+
855
+ Host publication stopped this cycle because the branch updated locale resources but still left obvious hardcoded user-facing strings in the touched UI files.
856
+
857
+ Why it was blocked:
858
+ - the localization guard found remaining literals that should move behind translation keys
859
+ - recurring issue publication should stop rather than open a partially localized UI change
860
+
791
861
  \`\`\`text
792
862
  ${publish_out}
793
863
  \`\`\`
@@ -810,6 +880,21 @@ build_issue_runtime_blocker_comment() {
810
880
 
811
881
  case "${runtime_reason}" in
812
882
  provider-quota-limit)
883
+ if [[ "${worker_name}" == "codex" ]]; then
884
+ cat <<EOF
885
+ # Blocker: Provider quota is currently exhausted
886
+
887
+ This recurring run stopped before implementation because the configured ${worker_name} account hit a provider-side usage limit.
888
+
889
+ Why it was blocked:
890
+ - the worker reached the current Codex usage cap for the active account
891
+ - ACP records the quota hit, attempts safe account rotation when available, and then waits for the configured cooldown instead of looping indefinitely
892
+
893
+ Next step:
894
+ - wait for the current quota window to reset, or make another Codex account available to this profile
895
+ EOF
896
+ return 0
897
+ fi
813
898
  cat <<EOF
814
899
  # Blocker: Provider quota is currently exhausted
815
900
 
@@ -821,6 +906,21 @@ Why it was blocked:
821
906
 
822
907
  Next step:
823
908
  - wait for the current quota window to reset, or switch this profile to another available provider/account
909
+ EOF
910
+ return 0
911
+ ;;
912
+ worker-environment-blocked)
913
+ cat <<EOF
914
+ # Blocker: Worker environment failed before a valid result contract was written
915
+
916
+ This recurring run did not produce a usable ACP result file because the ${worker_name} execution environment failed mid-run.
917
+
918
+ Why it was blocked:
919
+ - the worker hit a sandbox/worktree runtime failure before it could write \`result.env\`
920
+ - ACP detected the runtime signature from the session log and converted the missing contract into a concrete blocker instead of retrying with a generic \`invalid-result-contract\`
921
+
922
+ Next step:
923
+ - refresh the worker runtime/worktree and rerun this cycle after the host-side environment issue is resolved
824
924
  EOF
825
925
  return 0
826
926
  ;;
@@ -865,12 +965,45 @@ if (explicitFailureReason) {
865
965
  reason = explicitFailureReason[1];
866
966
  } else if (/^# Blocker: Verification requirements were not satisfied$/im.test(body)) {
867
967
  reason = 'verification-guard-blocked';
968
+ } else if (/^# Blocker: Localization requirements were not satisfied$/im.test(body)) {
969
+ reason = 'localization-guard-blocked';
970
+ } else if (
971
+ /required (?:issue-contract )?verification does not currently pass/i.test(body) ||
972
+ /Because the required `pnpm typecheck` did not pass/i.test(body) ||
973
+ /- BLOCKED `pnpm typecheck`/i.test(body) ||
974
+ /pnpm typecheck(?:`)? fails in unrelated existing file/i.test(body) ||
975
+ /Blocked on required root verification/i.test(body) ||
976
+ /required root (?:verification command|`pnpm test`)/i.test(body) ||
977
+ /pnpm test` is currently failing outside this/i.test(body) ||
978
+ /The required root test command failed/i.test(body) ||
979
+ /did not commit because the issue contract requires verification to pass/i.test(body)
980
+ ) {
981
+ reason = 'verification-guard-blocked';
868
982
  } else if (/^# Blocker: (All checklist items already completed|Worker produced no publishable delta)$/im.test(body)) {
869
983
  reason = 'no-publishable-commits';
870
984
  } else if (/^# Blocker: Change scope was too broad$/im.test(body)) {
871
985
  reason = 'scope-guard-blocked';
872
986
  } else if (/^# Blocker: Provider quota is currently exhausted$/im.test(body)) {
873
987
  reason = 'provider-quota-limit';
988
+ } else if (
989
+ /blocked on external network access/i.test(body) ||
990
+ /could not perform a safe offline bump/i.test(body) ||
991
+ /failed to reach `api\.github\.com`/i.test(body) ||
992
+ /failed with `ENOTFOUND`/i.test(body)
993
+ ) {
994
+ reason = 'external-network-access-blocked';
995
+ } else if (
996
+ /I’m blocked on the environment, not the issue scope/i.test(body) ||
997
+ /Every local execution path I need for this cycle is failing immediately with `aborted`/i.test(body) ||
998
+ /outside this session['’]s writable sandbox/i.test(body) ||
999
+ /could not write to the host-required `\$ACP_RUN_DIR`/i.test(body) ||
1000
+ /cannot access local infrastructure from this sandbox/i.test(body) ||
1001
+ /sandbox socket connection errors/i.test(body) ||
1002
+ /connect EPERM 127\.0\.0\.1:6379/i.test(body) ||
1003
+ /local Postgres\/Redis services/i.test(body) ||
1004
+ /worker can(?:not|'t) connect to the local test Postgres and Redis services/i.test(body)
1005
+ ) {
1006
+ reason = 'worker-environment-blocked';
874
1007
  } else if (/^# Blocker:/im.test(body)) {
875
1008
  reason = 'issue-worker-blocked';
876
1009
  }
@@ -998,6 +1131,34 @@ case "$status" in
998
1131
  SUCCEEDED)
999
1132
  clear_provider_quota_cooldown
1000
1133
  if ! normalize_issue_result_contract; then
1134
+ inferred_failure_reason="$(infer_issue_runtime_failure_from_log || true)"
1135
+ if [[ -n "${inferred_failure_reason}" ]]; then
1136
+ status="FAILED"
1137
+ failure_reason="$(normalize_issue_failure_reason "${inferred_failure_reason}")"
1138
+ issue_result_contract_note="missing-worker-result-recovered-${failure_reason}"
1139
+ result_outcome="blocked"
1140
+ result_action="host-comment-blocker"
1141
+ normalize_issue_runner_state "failed" "${LAST_EXIT_CODE:-}" "${failure_reason}"
1142
+ if [[ ! -s "${run_dir}/issue-comment.md" ]]; then
1143
+ write_issue_comment_artifact "$(build_issue_runtime_blocker_comment "${failure_reason}")" || true
1144
+ fi
1145
+ post_issue_comment_if_present
1146
+ require_transition "issue_schedule_retry" issue_schedule_retry "$failure_reason"
1147
+ require_transition "issue_mark_ready" issue_mark_ready
1148
+ issue_set_reconcile_summary "$status" "$result_outcome" "$result_action" "$failure_reason"
1149
+ cleanup_issue_session
1150
+ notify_issue_reconciled
1151
+ mark_reconciled
1152
+ printf 'STATUS=%s\n' "$status"
1153
+ printf 'ISSUE_ID=%s\n' "$issue_id"
1154
+ printf 'PR_NUMBER=%s\n' "$pr_number"
1155
+ printf 'OUTCOME=%s\n' "$result_outcome"
1156
+ printf 'ACTION=%s\n' "$result_action"
1157
+ printf 'FAILURE_REASON=%s\n' "$failure_reason"
1158
+ printf 'RESULT_CONTRACT_NOTE=%s\n' "$issue_result_contract_note"
1159
+ exit 0
1160
+ fi
1161
+
1001
1162
  status="FAILED"
1002
1163
  failure_reason="invalid-result-contract"
1003
1164
  issue_result_contract_note="invalid-result-contract"
@@ -1195,10 +1356,17 @@ case "$status" in
1195
1356
  notify_issue_reconciled
1196
1357
  ;;
1197
1358
  FAILED)
1198
- failure_reason="${failure_reason:-worker-exit-failed}"
1359
+ failure_reason="$(normalize_issue_failure_reason "${failure_reason:-worker-exit-failed}")"
1199
1360
  schedule_provider_quota_cooldown "${failure_reason}"
1200
1361
  normalize_issue_runner_state "failed" "${LAST_EXIT_CODE:-}" "${failure_reason}"
1201
- if [[ "${result_outcome:-}" == "blocked" && "${result_action:-}" == "host-comment-blocker" ]]; then
1362
+ if [[ "${result_outcome:-}" == "blocked" && "${result_action:-}" == "host-comment-blocker" ]] \
1363
+ || [[ "${failure_reason}" == "provider-quota-limit" ]]; then
1364
+ if [[ -z "${result_outcome:-}" ]]; then
1365
+ result_outcome="blocked"
1366
+ fi
1367
+ if [[ -z "${result_action:-}" ]]; then
1368
+ result_action="host-comment-blocker"
1369
+ fi
1202
1370
  if [[ ! -s "${run_dir}/issue-comment.md" ]]; then
1203
1371
  write_issue_comment_artifact "$(build_issue_runtime_blocker_comment "${failure_reason}")" || true
1204
1372
  fi