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 +54 -10
- package/hooks/heartbeat-hooks.sh +70 -6
- package/package.json +1 -1
- package/tools/bin/agent-project-cleanup-session +10 -2
- package/tools/bin/agent-project-heartbeat-loop +29 -2
- package/tools/bin/agent-project-reconcile-issue-session +59 -2
- package/tools/bin/agent-project-reconcile-pr-session +104 -13
- package/tools/bin/agent-project-run-claude-session +19 -1
- package/tools/bin/agent-project-run-codex-session +1 -1
- package/tools/bin/agent-project-run-openclaw-session +200 -7
- package/tools/bin/agent-project-sync-anchor-repo +13 -2
- package/tools/bin/agent-project-worker-status +19 -14
- package/tools/bin/flow-shell-lib.sh +13 -7
- package/tools/bin/prepare-worktree.sh +3 -1
- package/tools/bin/provider-cooldown-state.sh +1 -1
- package/tools/bin/render-flow-config.sh +16 -1
- package/tools/bin/run-codex-task.sh +2 -2
- package/tools/bin/scaffold-profile.sh +2 -2
- package/tools/bin/start-issue-worker.sh +42 -10
- package/tools/dashboard/app.js +20 -2
- package/tools/dashboard/dashboard_snapshot.py +45 -0
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
300
|
+
for (const rawCheck of checks) {
|
|
301
|
+
const check = normalizeRollupCheck(rawCheck);
|
|
257
302
|
if (check.status !== 'COMPLETED') {
|
|
258
|
-
pendingChecks.push(`${check.name}
|
|
303
|
+
pendingChecks.push(`${check.name}:${check.reasonField}-${check.rawStatus}`);
|
|
259
304
|
continue;
|
|
260
305
|
}
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
package/hooks/heartbeat-hooks.sh
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 [[ "$
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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 !
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|