agent-control-plane 0.1.8 → 0.1.9

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/bin/pr-risk.sh CHANGED
@@ -22,11 +22,32 @@ if [[ -f "${PR_LANE_OVERRIDE_FILE}" ]]; then
22
22
  source "${PR_LANE_OVERRIDE_FILE}" || true
23
23
  fi
24
24
 
25
- PR_JSON="$(gh pr view "$PR_NUMBER" -R "$REPO_SLUG" --json number,title,url,body,isDraft,headRefName,baseRefName,labels,files,mergeStateStatus,reviewDecision,reviewRequests,statusCheckRollup,comments)"
26
- PR_HEAD_SHA="$(gh api "repos/${REPO_SLUG}/pulls/${PR_NUMBER}" --jq .head.sha)"
27
- PR_HEAD_COMMITTED_AT="$(gh api "repos/${REPO_SLUG}/commits/${PR_HEAD_SHA}" --jq .commit.committer.date 2>/dev/null || true)"
28
- REVIEW_COMMENTS_JSON="$(gh api "repos/${REPO_SLUG}/pulls/${PR_NUMBER}/comments")"
29
- CHECK_RUNS_JSON="$(gh api "repos/${REPO_SLUG}/commits/${PR_HEAD_SHA}/check-runs" 2>/dev/null || printf '{"check_runs":[]}')"
25
+ gh_api_json_matching_or_fallback() {
26
+ local fallback="${1:?fallback required}"
27
+ local jq_filter="${2:?jq filter required}"
28
+ shift 2
29
+ local output=""
30
+
31
+ output="$(gh api "$@" 2>/dev/null || true)"
32
+ if jq -e "${jq_filter}" >/dev/null 2>&1 <<<"${output}"; then
33
+ printf '%s\n' "${output}"
34
+ return 0
35
+ fi
36
+
37
+ printf '%s\n' "${fallback}"
38
+ }
39
+
40
+ PR_JSON="$(gh pr view "$PR_NUMBER" -R "$REPO_SLUG" --json number,title,url,body,isDraft,headRefName,headRefOid,baseRefName,labels,files,mergeStateStatus,reviewDecision,reviewRequests,statusCheckRollup,comments)"
41
+ PR_HEAD_SHA="$(jq -r '.headRefOid // ""' <<<"$PR_JSON")"
42
+ PR_HEAD_COMMITTED_AT=""
43
+ if [[ -n "${PR_HEAD_SHA}" ]]; then
44
+ PR_HEAD_COMMITTED_AT="$(gh api "repos/${REPO_SLUG}/commits/${PR_HEAD_SHA}" --jq .commit.committer.date 2>/dev/null || true)"
45
+ fi
46
+ REVIEW_COMMENTS_JSON="$(gh_api_json_matching_or_fallback '[]' 'type == "array"' "repos/${REPO_SLUG}/pulls/${PR_NUMBER}/comments")"
47
+ CHECK_RUNS_JSON='{"check_runs":[]}'
48
+ if [[ -n "${PR_HEAD_SHA}" ]]; then
49
+ CHECK_RUNS_JSON="$(gh_api_json_matching_or_fallback '{"check_runs":[]}' 'type == "object" and ((.check_runs // []) | type == "array")' "repos/${REPO_SLUG}/commits/${PR_HEAD_SHA}/check-runs")"
50
+ fi
30
51
 
31
52
  PR_JSON="$PR_JSON" PR_HEAD_SHA="$PR_HEAD_SHA" PR_HEAD_COMMITTED_AT="$PR_HEAD_COMMITTED_AT" REVIEW_COMMENTS_JSON="$REVIEW_COMMENTS_JSON" CHECK_RUNS_JSON="$CHECK_RUNS_JSON" PR_LANE_OVERRIDE="${PR_LANE_OVERRIDE:-}" MANAGED_PR_PREFIXES_JSON="$MANAGED_PR_PREFIXES_JSON" MANAGED_PR_ISSUE_CAPTURE_REGEX="$MANAGED_PR_ISSUE_CAPTURE_REGEX" ALLOW_INFRA_CI_BYPASS="$ALLOW_INFRA_CI_BYPASS" LOCAL_FIRST_PR_POLICY="$LOCAL_FIRST_PR_POLICY" node <<'EOF'
32
53
  const { execFileSync } = require('node:child_process');
@@ -251,16 +272,39 @@ const riskReason =
251
272
  ? `paths-within-critical-app-allowlist:${criticalAppFiles.join(',')}`
252
273
  : `paths-outside-low-risk-allowlist:${disallowed.join(',')}`;
253
274
 
275
+ const normalizeRollupCheck = (check) => {
276
+ const typename = String(check?.__typename || '');
277
+ if (typename === 'StatusContext') {
278
+ const name = String(check?.context || 'status-context');
279
+ const state = String(check?.state || '').toUpperCase();
280
+ return {
281
+ name,
282
+ status: state === 'SUCCESS' || state === 'FAILURE' || state === 'ERROR' ? 'COMPLETED' : state,
283
+ conclusion: state === 'SUCCESS' ? 'SUCCESS' : state === 'FAILURE' || state === 'ERROR' ? 'FAILURE' : '',
284
+ reasonField: 'state',
285
+ rawStatus: String(check?.state || '').toLowerCase() || 'unknown',
286
+ };
287
+ }
288
+
289
+ return {
290
+ name: String(check?.name || check?.workflowName || 'check-run'),
291
+ status: String(check?.status || '').toUpperCase(),
292
+ conclusion: String(check?.conclusion || '').toUpperCase(),
293
+ reasonField: 'status',
294
+ rawStatus: String(check?.status || '').toLowerCase() || 'unknown',
295
+ };
296
+ };
297
+
254
298
  const pendingChecks = [];
255
299
  const checkFailures = [];
256
- for (const check of checks) {
300
+ for (const rawCheck of checks) {
301
+ const check = normalizeRollupCheck(rawCheck);
257
302
  if (check.status !== 'COMPLETED') {
258
- pendingChecks.push(`${check.name}:status-${String(check.status || '').toLowerCase()}`);
303
+ pendingChecks.push(`${check.name}:${check.reasonField}-${check.rawStatus}`);
259
304
  continue;
260
305
  }
261
- const conclusion = String(check.conclusion || '').toUpperCase();
262
- if (conclusion !== 'SUCCESS' && conclusion !== 'SKIPPED') {
263
- checkFailures.push(`${check.name}:conclusion-${String(check.conclusion || '').toLowerCase()}`);
306
+ if (check.conclusion !== 'SUCCESS' && check.conclusion !== 'SKIPPED') {
307
+ checkFailures.push(`${check.name}:conclusion-${String(check.conclusion || '').toLowerCase() || 'unknown'}`);
264
308
  }
265
309
  }
266
310
 
@@ -52,7 +52,7 @@ heartbeat_open_agent_pr_issue_ids() {
52
52
 
53
53
  heartbeat_list_ready_issue_ids() {
54
54
  local open_agent_pr_issue_ids
55
- local ready_issue_rows issue_id is_blocked retry_out retry_reason
55
+ local ready_issue_rows issue_id is_blocked retry_reason
56
56
  open_agent_pr_issue_ids="$(heartbeat_open_agent_pr_issue_ids)"
57
57
 
58
58
  ready_issue_rows="$(
@@ -73,8 +73,7 @@ heartbeat_list_ready_issue_ids() {
73
73
  [[ -n "${issue_id:-}" ]] || continue
74
74
 
75
75
  if [[ "${is_blocked:-false}" == "true" ]]; then
76
- retry_out="$("${FLOW_TOOLS_DIR}/retry-state.sh" issue "$issue_id" get 2>/dev/null || true)"
77
- retry_reason="$(awk -F= '/^LAST_REASON=/{print $2}' <<<"${retry_out:-}")"
76
+ retry_reason="$(heartbeat_issue_blocked_recovery_reason "$issue_id")"
78
77
  if [[ -z "${retry_reason:-}" ]]; then
79
78
  continue
80
79
  fi
@@ -87,7 +86,7 @@ heartbeat_list_ready_issue_ids() {
87
86
 
88
87
  heartbeat_list_blocked_recovery_issue_ids() {
89
88
  local open_agent_pr_issue_ids
90
- local blocked_issue_rows issue_id retry_out retry_reason
89
+ local blocked_issue_rows issue_id retry_reason
91
90
  open_agent_pr_issue_ids="$(heartbeat_open_agent_pr_issue_ids)"
92
91
 
93
92
  blocked_issue_rows="$(
@@ -105,8 +104,7 @@ heartbeat_list_blocked_recovery_issue_ids() {
105
104
 
106
105
  while IFS= read -r issue_id; do
107
106
  [[ -n "${issue_id:-}" ]] || continue
108
- retry_out="$("${FLOW_TOOLS_DIR}/retry-state.sh" issue "$issue_id" get 2>/dev/null || true)"
109
- retry_reason="$(awk -F= '/^LAST_REASON=/{print $2}' <<<"${retry_out:-}")"
107
+ retry_reason="$(heartbeat_issue_blocked_recovery_reason "$issue_id")"
110
108
  if [[ -z "${retry_reason:-}" ]]; then
111
109
  continue
112
110
  fi
@@ -115,6 +113,72 @@ heartbeat_list_blocked_recovery_issue_ids() {
115
113
  done <<<"$blocked_issue_rows"
116
114
  }
117
115
 
116
+ heartbeat_issue_blocked_recovery_reason() {
117
+ local issue_id="${1:?issue id required}"
118
+ local retry_out retry_reason issue_json
119
+
120
+ retry_out="$("${FLOW_TOOLS_DIR}/retry-state.sh" issue "$issue_id" get 2>/dev/null || true)"
121
+ retry_reason="$(awk -F= '/^LAST_REASON=/{print $2}' <<<"${retry_out:-}")"
122
+ if [[ -n "${retry_reason:-}" && "${retry_reason}" != "issue-worker-blocked" ]]; then
123
+ printf '%s\n' "$retry_reason"
124
+ return 0
125
+ fi
126
+
127
+ issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
128
+ if [[ -z "${issue_json:-}" ]]; then
129
+ return 0
130
+ fi
131
+
132
+ ISSUE_JSON="${issue_json}" RETRY_REASON="${retry_reason:-}" node <<'EOF'
133
+ const issue = JSON.parse(process.env.ISSUE_JSON || '{}');
134
+ const labels = new Set((issue.labels || []).map((label) => label?.name).filter(Boolean));
135
+
136
+ if (!labels.has('agent-blocked')) {
137
+ process.exit(0);
138
+ }
139
+
140
+ const blockerComment = [...(issue.comments || [])]
141
+ .reverse()
142
+ .find((comment) =>
143
+ /Host-side publish blocked for session|Host-side publish failed for session|Blocked on missing referenced OpenSpec paths for issue|Superseded by focused follow-up issues:|Why it was blocked:|^# Blocker:/i.test(
144
+ comment?.body || '',
145
+ ),
146
+ );
147
+
148
+ if (!blockerComment || !blockerComment.body) {
149
+ process.exit(0);
150
+ }
151
+
152
+ const body = String(blockerComment.body);
153
+ let reason = '';
154
+
155
+ const explicitFailureReason = body.match(/Failure reason:\s*[\r\n]+-\s*`([^`]+)`/i);
156
+ if (explicitFailureReason) {
157
+ reason = explicitFailureReason[1];
158
+ } else if (/provider quota is currently exhausted|provider-side rate limit|quota window/i.test(body)) {
159
+ reason = 'provider-quota-limit';
160
+ } else if (/no publishable delta|no commits ahead of `?origin\/main`?/i.test(body)) {
161
+ reason = 'no-publishable-delta';
162
+ } else if (/scope guard/i.test(body)) {
163
+ reason = 'scope-guard-blocked';
164
+ } else if (/verification guard/i.test(body)) {
165
+ reason = 'verification-guard-blocked';
166
+ } else if (/missing referenced OpenSpec paths/i.test(body)) {
167
+ reason = 'missing-openspec-paths';
168
+ } else if (/superseded by focused follow-up issues/i.test(body)) {
169
+ reason = 'superseded-by-follow-ups';
170
+ } else if (/^# Blocker:/im.test(body)) {
171
+ reason = 'comment-blocked-recovery';
172
+ }
173
+
174
+ if (reason) {
175
+ process.stdout.write(`${reason}\n`);
176
+ } else if ((process.env.RETRY_REASON || '').trim()) {
177
+ process.stdout.write(`${String(process.env.RETRY_REASON).trim()}\n`);
178
+ }
179
+ EOF
180
+ }
181
+
118
182
  heartbeat_list_exclusive_issue_ids() {
119
183
  local open_agent_pr_issue_ids
120
184
  open_agent_pr_issue_ids="$(heartbeat_open_agent_pr_issue_ids)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Help a repo keep GitHub-driven coding agents running reliably without constant human babysitting",
5
5
  "homepage": "https://github.com/ducminhnguyen0319/agent-control-plane",
6
6
  "bugs": {
@@ -92,6 +92,7 @@ cleanup_status="0"
92
92
  cleanup_error=""
93
93
  cleanup_mode="noop"
94
94
  orphan_fallback_used="false"
95
+ active_tmux_session="false"
95
96
 
96
97
  if [[ -n "$session" ]]; then
97
98
  meta_file="${runs_root}/${session}/run.env"
@@ -112,6 +113,10 @@ if [[ -z "$remove_file" ]]; then
112
113
  remove_file="$result_file"
113
114
  fi
114
115
 
116
+ if [[ -n "$session" ]] && tmux has-session -t "$session" 2>/dev/null; then
117
+ active_tmux_session="true"
118
+ fi
119
+
115
120
  cleanup_tool="${shared_agent_home}/tools/bin/agent-cleanup-worktree"
116
121
  archive_tool="${shared_agent_home}/tools/bin/agent-project-archive-run"
117
122
 
@@ -178,7 +183,9 @@ cleanup_orphan_worktree_dir() {
178
183
  git -C "$repo_root" worktree prune >/dev/null 2>&1 || true
179
184
  }
180
185
 
181
- if [[ "$skip_worktree_cleanup" != "true" && -n "$branch_name" ]]; then
186
+ if [[ "$active_tmux_session" == "true" ]]; then
187
+ cleanup_mode="deferred-active-session"
188
+ elif [[ "$skip_worktree_cleanup" != "true" && -n "$branch_name" ]]; then
182
189
  if cleanup_output="$(cleanup_with_branch_tool yes 2>&1)"; then
183
190
  cleanup_mode="branch"
184
191
  else
@@ -205,7 +212,7 @@ elif [[ "$skip_worktree_cleanup" == "true" ]]; then
205
212
  fi
206
213
 
207
214
  archive_output=""
208
- if [[ -n "$session" ]]; then
215
+ if [[ -n "$session" && "$active_tmux_session" != "true" ]]; then
209
216
  archive_output="$(
210
217
  "$archive_tool" \
211
218
  --runs-root "$runs_root" \
@@ -227,6 +234,7 @@ printf 'BRANCH=%s\n' "$branch_name"
227
234
  printf 'KEEP_REMOTE=%s\n' "$keep_remote"
228
235
  printf 'ALLOW_UNMERGED=%s\n' "$allow_unmerged"
229
236
  printf 'SKIP_WORKTREE_CLEANUP=%s\n' "$skip_worktree_cleanup"
237
+ printf 'ACTIVE_TMUX_SESSION=%s\n' "$active_tmux_session"
230
238
  printf 'CLEANUP_MODE=%s\n' "$cleanup_mode"
231
239
  printf 'ORPHAN_FALLBACK_USED=%s\n' "$orphan_fallback_used"
232
240
  printf 'CLEANUP_STATUS=%s\n' "$cleanup_status"
@@ -929,6 +929,9 @@ running_recurring_issue_workers() {
929
929
  count=$((count + 1))
930
930
  fi
931
931
  done <<<"$running_issue_workers_cache"
932
+ # Also count pending recurring launches that are still in progress
933
+ # (prevents infinite respawning when workers die before creating tmux sessions)
934
+ count=$((count + $(pending_recurring_issue_launch_count)))
932
935
  printf '%s\n' "$count"
933
936
  }
934
937
 
@@ -1120,9 +1123,11 @@ build_ordered_ready_issue_ids_cache() {
1120
1123
 
1121
1124
  last_recurring_issue="$(last_launched_recurring_issue_id || true)"
1122
1125
  if [[ -n "$last_recurring_issue" ]]; then
1126
+ local emitted_after_last=0
1123
1127
  for issue_id in "${recurring_ids[@]}"; do
1124
1128
  if [[ "$seen_last" == "yes" ]]; then
1125
1129
  printf '%s\n' "$issue_id"
1130
+ emitted_after_last=$((emitted_after_last + 1))
1126
1131
  fi
1127
1132
  if [[ "$issue_id" == "$last_recurring_issue" ]]; then
1128
1133
  seen_last="yes"
@@ -1131,7 +1136,12 @@ build_ordered_ready_issue_ids_cache() {
1131
1136
  fi
1132
1137
 
1133
1138
  for issue_id in "${recurring_ids[@]}"; do
1134
- if [[ -n "$last_recurring_issue" && "$seen_last" == "yes" && "$issue_id" == "$last_recurring_issue" ]]; then
1139
+ # Stop the wrap-around once we reach the last-launched issue, but only
1140
+ # when the first loop already emitted at least one issue after it.
1141
+ # When there is exactly one recurring issue (or the last-launched issue
1142
+ # is the final element), emitted_after_last is 0, so we must still
1143
+ # include it here to avoid producing an empty list.
1144
+ if [[ -n "$last_recurring_issue" && "$seen_last" == "yes" && "$issue_id" == "$last_recurring_issue" && "$emitted_after_last" -gt 0 ]]; then
1135
1145
  break
1136
1146
  fi
1137
1147
  printf '%s\n' "$issue_id"
@@ -1143,6 +1153,21 @@ completed_workers() {
1143
1153
  printf '%s\n' "$completed_workers_cache"
1144
1154
  }
1145
1155
 
1156
+ reconciled_marker_matches_run() {
1157
+ local run_dir="${1:?run dir required}"
1158
+ local marker_file="${run_dir}/reconciled.ok"
1159
+ local run_env="${run_dir}/run.env"
1160
+ local marker_started_at=""
1161
+ local run_started_at=""
1162
+
1163
+ [[ -f "${marker_file}" && -f "${run_env}" ]] || return 1
1164
+
1165
+ marker_started_at="$(awk -F= '/^STARTED_AT=/{print $2}' "${marker_file}" 2>/dev/null | tr -d '"' | tail -n 1 || true)"
1166
+ run_started_at="$(awk -F= '/^STARTED_AT=/{print $2}' "${run_env}" 2>/dev/null | tr -d '"' | tail -n 1 || true)"
1167
+
1168
+ [[ -n "${marker_started_at}" && -n "${run_started_at}" && "${marker_started_at}" == "${run_started_at}" ]]
1169
+ }
1170
+
1146
1171
  ensure_completed_workers_cache() {
1147
1172
  local dir session issue_id status_line status
1148
1173
  if [[ "$completed_workers_cache_loaded" == "yes" ]]; then
@@ -1153,7 +1178,9 @@ ensure_completed_workers_cache() {
1153
1178
  [[ -d "$dir" ]] || continue
1154
1179
  session="${dir##*/}"
1155
1180
  session_matches_prefix "$session" || continue
1156
- [[ -f "$dir/reconciled.ok" ]] && continue
1181
+ if reconciled_marker_matches_run "$dir"; then
1182
+ continue
1183
+ fi
1157
1184
  if [[ "$session" == "${issue_prefix}"* ]]; then
1158
1185
  issue_id="$(issue_id_from_session "$session" || true)"
1159
1186
  if [[ -n "${issue_id}" ]] && pending_issue_launch_active "${issue_id}"; then
@@ -202,6 +202,8 @@ set +a
202
202
 
203
203
  result_outcome=""
204
204
  result_action=""
205
+ run_started_at="${STARTED_AT:-}"
206
+ expected_run_started_at="${ACP_EXPECTED_RUN_STARTED_AT:-${F_LOSNING_EXPECTED_RUN_STARTED_AT:-}}"
205
207
  result_file_candidate="${run_dir}/result.env"
206
208
  if [[ ! -f "$result_file_candidate" && -n "${RESULT_FILE:-}" && -f "${RESULT_FILE:-}" ]]; then
207
209
  result_file_candidate="${RESULT_FILE}"
@@ -215,6 +217,14 @@ if [[ -f "$result_file_candidate" ]]; then
215
217
  result_action="${ACTION:-}"
216
218
  fi
217
219
 
220
+ if [[ -n "${expected_run_started_at}" && "${expected_run_started_at}" != "${run_started_at}" ]]; then
221
+ printf 'STATUS=STALE-RUN-SKIPPED\n'
222
+ printf 'SESSION=%s\n' "$session"
223
+ printf 'EXPECTED_STARTED_AT=%s\n' "${expected_run_started_at}"
224
+ printf 'ACTUAL_STARTED_AT=%s\n' "${run_started_at}"
225
+ exit 0
226
+ fi
227
+
218
228
  issue_summary_outcome=""
219
229
  issue_summary_action=""
220
230
  issue_summary_failure_reason=""
@@ -829,6 +839,46 @@ Next step:
829
839
  EOF
830
840
  }
831
841
 
842
+ infer_issue_blocked_failure_reason() {
843
+ local comment_file="${run_dir}/issue-comment.md"
844
+ local current_reason="${1:-}"
845
+
846
+ if [[ -n "${current_reason:-}" && "${current_reason}" != "issue-worker-blocked" ]]; then
847
+ printf '%s\n' "${current_reason}"
848
+ return 0
849
+ fi
850
+
851
+ [[ -s "${comment_file}" ]] || {
852
+ printf 'issue-worker-blocked\n'
853
+ return 0
854
+ }
855
+
856
+ ISSUE_COMMENT_FILE="${comment_file}" node <<'EOF'
857
+ const fs = require('fs');
858
+
859
+ const path = process.env.ISSUE_COMMENT_FILE || '';
860
+ const body = path ? fs.readFileSync(path, 'utf8') : '';
861
+ let reason = '';
862
+
863
+ const explicitFailureReason = body.match(/Failure reason:\s*[\r\n]+-\s*`([^`]+)`/i);
864
+ if (explicitFailureReason) {
865
+ reason = explicitFailureReason[1];
866
+ } else if (/^# Blocker: Verification requirements were not satisfied$/im.test(body)) {
867
+ reason = 'verification-guard-blocked';
868
+ } else if (/^# Blocker: (All checklist items already completed|Worker produced no publishable delta)$/im.test(body)) {
869
+ reason = 'no-publishable-commits';
870
+ } else if (/^# Blocker: Change scope was too broad$/im.test(body)) {
871
+ reason = 'scope-guard-blocked';
872
+ } else if (/^# Blocker: Provider quota is currently exhausted$/im.test(body)) {
873
+ reason = 'provider-quota-limit';
874
+ } else if (/^# Blocker:/im.test(body)) {
875
+ reason = 'issue-worker-blocked';
876
+ }
877
+
878
+ process.stdout.write(`${reason || 'issue-worker-blocked'}\n`);
879
+ EOF
880
+ }
881
+
832
882
  extract_recovery_worktree_from_publish_output() {
833
883
  local publish_out="${1:-}"
834
884
  awk -F= '/^RECOVERY_WORKTREE=/{print $2}' <<<"$publish_out" | tail -n 1
@@ -844,8 +894,15 @@ require_transition() {
844
894
  }
845
895
 
846
896
  mark_reconciled() {
897
+ local reconciled_at tmp_file
847
898
  if [[ -d "$run_dir" ]]; then
848
- touch "${run_dir}/reconciled.ok"
899
+ reconciled_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
900
+ tmp_file="${run_dir}/reconciled.ok.tmp.$$"
901
+ {
902
+ printf 'STARTED_AT=%s\n' "${run_started_at}"
903
+ printf 'RECONCILED_AT=%s\n' "${reconciled_at}"
904
+ } >"${tmp_file}"
905
+ mv "${tmp_file}" "${run_dir}/reconciled.ok"
849
906
  fi
850
907
  }
851
908
 
@@ -984,7 +1041,7 @@ case "$status" in
984
1041
  printf 'ACTION=%s\n' "$result_action"
985
1042
  exit 0
986
1043
  fi
987
- failure_reason="issue-worker-blocked"
1044
+ failure_reason="$(infer_issue_blocked_failure_reason "${failure_reason:-}")"
988
1045
  normalize_issue_runner_state "succeeded" "0" ""
989
1046
  require_transition "issue_schedule_retry" issue_schedule_retry "$failure_reason"
990
1047
  require_transition "issue_mark_blocked" issue_mark_blocked
@@ -168,6 +168,9 @@ fi
168
168
  result_outcome=""
169
169
  result_action=""
170
170
  result_issue_id="${ISSUE_ID:-}"
171
+ result_detail=""
172
+ run_started_at="${STARTED_AT:-}"
173
+ expected_run_started_at="${ACP_EXPECTED_RUN_STARTED_AT:-${F_LOSNING_EXPECTED_RUN_STARTED_AT:-}}"
171
174
  host_blocker_file="${run_dir}/host-blocker.md"
172
175
  prompt_file="${run_dir}/prompt.md"
173
176
  pr_comment_file="${run_dir}/pr-comment.md"
@@ -184,9 +187,18 @@ if [[ -f "$result_file_candidate" ]]; then
184
187
  set +a
185
188
  result_outcome="${OUTCOME:-}"
186
189
  result_action="${ACTION:-}"
190
+ result_detail="${DETAIL:-}"
187
191
  result_issue_id="${ISSUE_ID:-${result_issue_id}}"
188
192
  fi
189
193
 
194
+ if [[ -n "${expected_run_started_at}" && "${expected_run_started_at}" != "${run_started_at}" ]]; then
195
+ printf 'STATUS=STALE-RUN-SKIPPED\n'
196
+ printf 'SESSION=%s\n' "$session"
197
+ printf 'EXPECTED_STARTED_AT=%s\n' "${expected_run_started_at}"
198
+ printf 'ACTUAL_STARTED_AT=%s\n' "${run_started_at}"
199
+ exit 0
200
+ fi
201
+
190
202
  pr_schedule_retry() { :; }
191
203
  pr_clear_retry() { :; }
192
204
  pr_cleanup_linked_issue_session() { :; }
@@ -225,6 +237,8 @@ clear_provider_quota_cooldown() {
225
237
  "${provider_cooldown_script}" clear >/dev/null || true
226
238
  }
227
239
 
240
+ blocked_runtime_reason=""
241
+
228
242
  owner="${repo_slug%%/*}"
229
243
  repo="${repo_slug#*/}"
230
244
  pr_view_json="$(flow_github_pr_view_json "$repo_slug" "$pr_number")"
@@ -339,6 +353,11 @@ normalize_pr_result_contract() {
339
353
  host-comment-pr-blocker)
340
354
  return 0
341
355
  ;;
356
+ host-comment-blocker)
357
+ result_action="host-comment-pr-blocker"
358
+ pr_result_contract_note="normalized-legacy-blocked-action"
359
+ return 0
360
+ ;;
342
361
  requested-changes-or-blocked)
343
362
  result_action="host-comment-pr-blocker"
344
363
  pr_result_contract_note="normalized-legacy-blocked-action"
@@ -358,8 +377,15 @@ normalize_pr_result_contract() {
358
377
  }
359
378
 
360
379
  mark_reconciled() {
380
+ local reconciled_at tmp_file
361
381
  if [[ -d "$run_dir" ]]; then
362
- touch "${run_dir}/reconciled.ok"
382
+ reconciled_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
383
+ tmp_file="${run_dir}/reconciled.ok.tmp.$$"
384
+ {
385
+ printf 'STARTED_AT=%s\n' "${run_started_at}"
386
+ printf 'RECONCILED_AT=%s\n' "${reconciled_at}"
387
+ } >"${tmp_file}"
388
+ mv "${tmp_file}" "${run_dir}/reconciled.ok"
363
389
  fi
364
390
  }
365
391
 
@@ -393,6 +419,20 @@ blocked_result_indicates_local_bind_failure() {
393
419
  return 1
394
420
  }
395
421
 
422
+ classify_pr_blocked_runtime_reason() {
423
+ if [[ "${result_detail:-}" == "worker-tool-exec-empty-command" ]]; then
424
+ printf 'worker-tool-exec-empty-command\n'
425
+ return 0
426
+ fi
427
+
428
+ if [[ -f "$session_log_file" ]] && grep -Fq '[tools] exec failed: Provide a command to start.' "$session_log_file"; then
429
+ printf 'worker-tool-exec-empty-command\n'
430
+ return 0
431
+ fi
432
+
433
+ return 1
434
+ }
435
+
396
436
  extract_preapproved_host_recovery_commands() {
397
437
  [[ -f "$prompt_file" ]] || return 0
398
438
  sed -n 's/^.*loopback retry command: `\(.*\)`$/\1/p' "$prompt_file"
@@ -700,18 +740,53 @@ merge_state_prepared() {
700
740
  git -C "$pr_worktree" rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1
701
741
  }
702
742
 
743
+ current_github_login() {
744
+ flow_export_github_cli_auth_env "${repo_slug}"
745
+ gh api user --jq '.login // ""' 2>/dev/null || true
746
+ }
747
+
748
+ pr_author_login() {
749
+ flow_export_github_cli_auth_env "${repo_slug}"
750
+ gh pr view "${pr_number}" -R "${repo_slug}" --json author --jq '.author.login // ""' 2>/dev/null || true
751
+ }
752
+
753
+ pr_is_self_authored_for_current_actor() {
754
+ local actor_login=""
755
+ local author_login=""
756
+
757
+ actor_login="$(current_github_login)"
758
+ author_login="$(pr_author_login)"
759
+ [[ -n "${actor_login}" && -n "${author_login}" && "${actor_login}" == "${author_login}" ]]
760
+ }
761
+
762
+ pr_remote_head_oid() {
763
+ flow_export_github_cli_auth_env "${repo_slug}"
764
+ gh pr view "${pr_number}" -R "${repo_slug}" --json headRefOid --jq '.headRefOid // ""' 2>/dev/null || true
765
+ }
766
+
767
+ pr_remote_already_has_final_head() {
768
+ local final_head="${FINAL_HEAD:-}"
769
+ local remote_head=""
770
+
771
+ [[ -n "${final_head}" ]] || return 1
772
+ remote_head="$(pr_remote_head_oid)"
773
+ [[ -n "${remote_head}" && "${remote_head}" == "${final_head}" ]]
774
+ }
775
+
703
776
  approve_and_merge() {
704
777
  local approve_output
705
- if ! approve_output="$(
706
- flow_github_api_repo "${repo_slug}" "pulls/${pr_number}/reviews" \
707
- --method POST \
708
- -f event=APPROVE \
709
- -f body="Automated final review passed. Safe low-risk scope, green checks, and host-side merge approved." \
710
- 2>&1
711
- )"; then
712
- if ! grep -q "Can not approve your own pull request" <<<"$approve_output"; then
713
- printf '%s\n' "$approve_output" >&2
714
- return 1
778
+ if ! pr_is_self_authored_for_current_actor; then
779
+ if ! approve_output="$(
780
+ flow_github_api_repo "${repo_slug}" "pulls/${pr_number}/reviews" \
781
+ --method POST \
782
+ -f event=APPROVE \
783
+ -f body="Automated final review passed. Safe low-risk scope, green checks, and host-side merge approved." \
784
+ 2>&1
785
+ )"; then
786
+ if ! grep -q "Can not approve your own pull request" <<<"$approve_output"; then
787
+ printf '%s\n' "$approve_output" >&2
788
+ return 1
789
+ fi
715
790
  fi
716
791
  fi
717
792
 
@@ -753,7 +828,14 @@ handle_linked_issue_merge_cleanup() {
753
828
 
754
829
  handle_updated_branch_result() {
755
830
  if [[ -z "$pr_worktree" || ! -d "$pr_worktree" ]]; then
756
- if pr_comment_already_posted; then
831
+ if pr_remote_already_has_final_head; then
832
+ post_pr_comment_if_present
833
+ require_transition "pr_clear_retry" pr_clear_retry
834
+ require_transition "pr_after_updated_branch" pr_after_updated_branch "$pr_number"
835
+ cleanup_pr_session
836
+ result_action="${result_action:-host-push-pr-branch}"
837
+ notify_pr_reconciled
838
+ elif pr_comment_already_posted; then
757
839
  require_transition "pr_clear_retry" pr_clear_retry
758
840
  require_transition "pr_after_updated_branch" pr_after_updated_branch "$pr_number"
759
841
  cleanup_pr_session
@@ -968,7 +1050,16 @@ elif [[ "$status" == "SUCCEEDED" && "$result_outcome" == "no-change-needed" ]];
968
1050
  fi
969
1051
  fi
970
1052
  elif [[ "$status" == "SUCCEEDED" && "$result_outcome" == "blocked" ]]; then
971
- if attempt_blocked_pr_host_verification_recovery; then
1053
+ blocked_runtime_reason="$(classify_pr_blocked_runtime_reason || true)"
1054
+ if [[ -n "${blocked_runtime_reason:-}" ]]; then
1055
+ status="FAILED"
1056
+ failure_reason="${blocked_runtime_reason}"
1057
+ require_transition "pr_schedule_retry" pr_schedule_retry "$failure_reason"
1058
+ require_transition "pr_after_failed" pr_after_failed "$pr_number"
1059
+ cleanup_pr_session
1060
+ result_action="queued-pr-retry"
1061
+ notify_pr_reconciled
1062
+ elif attempt_blocked_pr_host_verification_recovery; then
972
1063
  handle_updated_branch_result
973
1064
  else
974
1065
  post_pr_comment_if_present
@@ -61,6 +61,24 @@ resolve_claude_bin() {
61
61
  return 0
62
62
  fi
63
63
 
64
+ # Well-known install locations for Claude Code CLI.
65
+ # Detached supervisors and LaunchAgents run with a minimal PATH that
66
+ # does not include user-local directories, so command -v alone is not
67
+ # enough. Try the common locations explicitly.
68
+ local -a fallback_paths=(
69
+ "${HOME}/.local/bin/claude"
70
+ "${HOME}/.claude/local/bin/claude"
71
+ "/usr/local/bin/claude"
72
+ "/opt/homebrew/bin/claude"
73
+ )
74
+ local p
75
+ for p in "${fallback_paths[@]}"; do
76
+ if [[ -x "${p}" ]]; then
77
+ printf '%s\n' "${p}"
78
+ return 0
79
+ fi
80
+ done
81
+
64
82
  return 1
65
83
  }
66
84
 
@@ -348,7 +366,7 @@ fi
348
366
 
349
367
  reconcile_snippet=""
350
368
  if [[ -n "$reconcile_command" ]]; then
351
- printf -v delayed_reconcile_q '%q' "sleep 2; $reconcile_command"
369
+ printf -v delayed_reconcile_q '%q' "export ACP_EXPECTED_RUN_STARTED_AT=${started_at_q}; export F_LOSNING_EXPECTED_RUN_STARTED_AT=${started_at_q}; while tmux has-session -t ${session_q} 2>/dev/null; do sleep 1; done; sleep 2; $reconcile_command"
352
370
  reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
353
371
  fi
354
372
 
@@ -256,7 +256,7 @@ fi
256
256
 
257
257
  reconcile_snippet=""
258
258
  if [[ -n "$reconcile_command" ]]; then
259
- printf -v delayed_reconcile_q '%q' "sleep 2; $reconcile_command"
259
+ printf -v delayed_reconcile_q '%q' "export ACP_EXPECTED_RUN_STARTED_AT=${started_at_q}; export F_LOSNING_EXPECTED_RUN_STARTED_AT=${started_at_q}; while tmux has-session -t ${session_q} 2>/dev/null; do sleep 1; done; sleep 2; $reconcile_command"
260
260
  reconcile_snippet="nohup bash -lc ${delayed_reconcile_q} >> ${output_q} 2>&1 </dev/null &"
261
261
  fi
262
262