agent-control-plane 0.1.7 → 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/hooks/issue-reconcile-hooks.sh +9 -2
- package/hooks/pr-reconcile-hooks.sh +11 -4
- package/package.json +2 -2
- package/tools/bin/agent-project-cleanup-session +10 -2
- package/tools/bin/agent-project-detached-launch +22 -2
- package/tools/bin/agent-project-heartbeat-loop +29 -2
- package/tools/bin/agent-project-reconcile-issue-session +109 -3
- package/tools/bin/agent-project-reconcile-pr-session +104 -13
- package/tools/bin/agent-project-run-claude-session +193 -55
- 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 +3 -3
- package/tools/bin/scaffold-profile.sh +4 -4
- 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)"
|
|
@@ -7,6 +7,7 @@ source "${HOOK_SCRIPT_DIR}/../tools/bin/flow-config-lib.sh"
|
|
|
7
7
|
|
|
8
8
|
FLOW_SKILL_DIR="$(cd "${HOOK_SCRIPT_DIR}/.." && pwd)"
|
|
9
9
|
CONFIG_YAML="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
|
|
10
|
+
PROFILE_ID="$(flow_resolve_adapter_id "${CONFIG_YAML}")"
|
|
10
11
|
ADAPTER_BIN_DIR="${FLOW_SKILL_DIR}/bin"
|
|
11
12
|
FLOW_TOOLS_DIR="${FLOW_SKILL_DIR}/tools/bin"
|
|
12
13
|
REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
|
|
@@ -15,6 +16,12 @@ STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
|
|
|
15
16
|
RUNS_ROOT="$(flow_resolve_runs_root "${CONFIG_YAML}")"
|
|
16
17
|
BLOCKED_RECOVERY_STATE_DIR="${STATE_ROOT}/blocked-recovery-issues"
|
|
17
18
|
|
|
19
|
+
issue_kick_scheduler() {
|
|
20
|
+
ACP_PROJECT_ID="${PROFILE_ID}" \
|
|
21
|
+
AGENT_PROJECT_ID="${PROFILE_ID}" \
|
|
22
|
+
"${FLOW_TOOLS_DIR}/kick-scheduler.sh" "${1:-2}" >/dev/null || true
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
issue_clear_blocked_recovery_state() {
|
|
19
26
|
rm -f "${BLOCKED_RECOVERY_STATE_DIR}/${ISSUE_ID}.env" 2>/dev/null || true
|
|
20
27
|
}
|
|
@@ -194,7 +201,7 @@ issue_after_pr_created() {
|
|
|
194
201
|
if [[ "$(jq -r '.eligibleForAutoMerge' <<<"$risk_json")" == "true" ]]; then
|
|
195
202
|
bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "${REPO_SLUG}" --number "$pr_number" --add agent-automerge >/dev/null || true
|
|
196
203
|
fi
|
|
197
|
-
|
|
204
|
+
issue_kick_scheduler 5
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
issue_after_reconciled() {
|
|
@@ -213,5 +220,5 @@ issue_after_reconciled() {
|
|
|
213
220
|
esac
|
|
214
221
|
fi
|
|
215
222
|
|
|
216
|
-
|
|
223
|
+
issue_kick_scheduler 2
|
|
217
224
|
}
|
|
@@ -7,6 +7,7 @@ source "${HOOK_SCRIPT_DIR}/../tools/bin/flow-config-lib.sh"
|
|
|
7
7
|
|
|
8
8
|
FLOW_SKILL_DIR="$(cd "${HOOK_SCRIPT_DIR}/.." && pwd)"
|
|
9
9
|
CONFIG_YAML="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
|
|
10
|
+
PROFILE_ID="$(flow_resolve_adapter_id "${CONFIG_YAML}")"
|
|
10
11
|
ADAPTER_BIN_DIR="${FLOW_SKILL_DIR}/bin"
|
|
11
12
|
FLOW_TOOLS_DIR="${FLOW_SKILL_DIR}/tools/bin"
|
|
12
13
|
RUNS_ROOT="$(flow_resolve_runs_root "${CONFIG_YAML}")"
|
|
@@ -19,6 +20,12 @@ ISSUE_SESSION_PREFIX="$(flow_resolve_issue_session_prefix "${CONFIG_YAML}")"
|
|
|
19
20
|
PR_WORKTREE_BRANCH_PREFIX="$(flow_resolve_pr_worktree_branch_prefix "${CONFIG_YAML}")"
|
|
20
21
|
PR_LANE_OVERRIDE_DIR="${STATE_ROOT}/pr-lane-overrides"
|
|
21
22
|
|
|
23
|
+
pr_kick_scheduler() {
|
|
24
|
+
ACP_PROJECT_ID="${PROFILE_ID}" \
|
|
25
|
+
AGENT_PROJECT_ID="${PROFILE_ID}" \
|
|
26
|
+
"${FLOW_TOOLS_DIR}/kick-scheduler.sh" "${1:-2}" >/dev/null || true
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
pr_best_effort_update_labels() {
|
|
23
30
|
bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" "$@" >/dev/null 2>&1 || true
|
|
24
31
|
}
|
|
@@ -131,14 +138,14 @@ pr_after_merged() {
|
|
|
131
138
|
pr_clear_lane_override "$pr_number"
|
|
132
139
|
pr_best_effort_update_labels --repo-slug "${REPO_SLUG}" --number "$pr_number" --remove agent-running --remove agent-automerge --remove agent-repair-queued --remove agent-fix-needed --remove agent-manual-fix-override --remove agent-ci-refresh --remove agent-ci-bypassed --remove agent-double-check-1/2 --remove agent-double-check-2/2 --remove agent-human-review --remove agent-human-approved --remove agent-blocked --remove agent-handoff --remove agent-exclusive
|
|
133
140
|
pr_refresh_linked_issue_checklist "$pr_number"
|
|
134
|
-
|
|
141
|
+
pr_kick_scheduler 5
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
pr_after_closed() {
|
|
138
145
|
local pr_number="${1:?pr number required}"
|
|
139
146
|
pr_clear_lane_override "$pr_number"
|
|
140
147
|
pr_best_effort_update_labels --repo-slug "${REPO_SLUG}" --number "$pr_number" --remove agent-running --remove agent-automerge --remove agent-repair-queued --remove agent-fix-needed --remove agent-manual-fix-override --remove agent-ci-refresh --remove agent-ci-bypassed --remove agent-double-check-1/2 --remove agent-double-check-2/2 --remove agent-human-review --remove agent-human-approved --remove agent-blocked --remove agent-handoff --remove agent-exclusive
|
|
141
|
-
|
|
148
|
+
pr_kick_scheduler 5
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
pr_automerge_allowed() {
|
|
@@ -189,7 +196,7 @@ pr_after_double_check_advanced() {
|
|
|
189
196
|
pr_set_lane_override "$pr_number" "double-check-${next_stage}"
|
|
190
197
|
pr_best_effort_update_labels --repo-slug "${REPO_SLUG}" --number "$pr_number" --remove agent-running --remove agent-automerge --remove agent-repair-queued --remove agent-fix-needed --remove agent-manual-fix-override --remove agent-ci-refresh --remove agent-human-review --remove agent-human-approved --remove agent-double-check-1/2 --remove agent-double-check-2/2 --add "$next_label"
|
|
191
198
|
pr_best_effort_sync_pr_labels "$pr_number"
|
|
192
|
-
|
|
199
|
+
pr_kick_scheduler 5
|
|
193
200
|
}
|
|
194
201
|
|
|
195
202
|
pr_after_updated_branch() {
|
|
@@ -221,5 +228,5 @@ pr_after_failed() {
|
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
pr_after_reconciled() {
|
|
224
|
-
|
|
231
|
+
pr_kick_scheduler 2
|
|
225
232
|
}
|
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": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"doctor": "node ./npm/bin/agent-control-plane.js doctor",
|
|
42
42
|
"smoke": "node ./npm/bin/agent-control-plane.js smoke",
|
|
43
|
-
"test": "bash tools/tests/test-agent-control-plane-npm-cli.sh && bash tools/tests/test-profile-adopt-skip-anchor-sync-creates-agent-repo-root.sh && bash tools/tests/test-vendored-codex-quota-claude-oauth-only.sh && bash tools/tests/test-package-smoke-command.sh"
|
|
43
|
+
"test": "bash tools/tests/test-agent-control-plane-npm-cli.sh && bash tools/tests/test-agent-project-detached-launch-stable-cwd.sh && bash tools/tests/test-agent-project-claude-session-wrapper-reaps-child-on-term.sh && bash tools/tests/test-agent-project-claude-session-wrapper-does-not-retry-provider-quota.sh && bash tools/tests/test-agent-project-reconcile-issue-provider-quota-schedules-provider-cooldown.sh && bash tools/tests/test-pr-reconcile-hooks-refreshes-recurring-issue-checklist.sh && bash tools/tests/test-issue-reconcile-hooks-kick-scheduler-uses-profile.sh && bash tools/tests/test-profile-adopt-skip-anchor-sync-creates-agent-repo-root.sh && bash tools/tests/test-vendored-codex-quota-claude-oauth-only.sh && bash tools/tests/test-package-smoke-command.sh"
|
|
44
44
|
},
|
|
45
45
|
"keywords": [
|
|
46
46
|
"agents",
|
|
@@ -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"
|
|
@@ -6,6 +6,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
6
6
|
source "${SCRIPT_DIR}/flow-config-lib.sh"
|
|
7
7
|
CONFIG_YAML="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
|
|
8
8
|
STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
|
|
9
|
+
AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
|
|
10
|
+
REPO_ROOT="$(flow_resolve_repo_root "${CONFIG_YAML}")"
|
|
9
11
|
|
|
10
12
|
usage() {
|
|
11
13
|
cat <<'EOF'
|
|
@@ -66,6 +68,21 @@ if [[ -n "${pending_key}" ]]; then
|
|
|
66
68
|
pending_file="${pending_dir}/${pending_key}.pid"
|
|
67
69
|
fi
|
|
68
70
|
|
|
71
|
+
launch_cwd="${ACP_LAUNCH_CWD:-${F_LOSNING_LAUNCH_CWD:-}}"
|
|
72
|
+
if [[ -z "${launch_cwd}" ]]; then
|
|
73
|
+
for candidate in "${AGENT_REPO_ROOT}" "${REPO_ROOT}" "${STATE_ROOT}" "${HOME:-}" "/"; do
|
|
74
|
+
if [[ -n "${candidate}" && -d "${candidate}" ]]; then
|
|
75
|
+
launch_cwd="${candidate}"
|
|
76
|
+
break
|
|
77
|
+
fi
|
|
78
|
+
done
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
if [[ -z "${launch_cwd}" || ! -d "${launch_cwd}" ]]; then
|
|
82
|
+
echo "could not determine a stable working directory for detached launch" >&2
|
|
83
|
+
exit 1
|
|
84
|
+
fi
|
|
85
|
+
|
|
69
86
|
python_bin="${PYTHON_BIN:-$(command -v python3 || true)}"
|
|
70
87
|
if [[ -z "${python_bin}" ]]; then
|
|
71
88
|
echo "python3 is required for detached launch" >&2
|
|
@@ -73,14 +90,15 @@ if [[ -z "${python_bin}" ]]; then
|
|
|
73
90
|
fi
|
|
74
91
|
|
|
75
92
|
launch_pid="$(
|
|
76
|
-
"${python_bin}" - "${log_file}" "${pending_file}" "$@" <<'PY'
|
|
93
|
+
"${python_bin}" - "${log_file}" "${pending_file}" "${launch_cwd}" "$@" <<'PY'
|
|
77
94
|
import subprocess
|
|
78
95
|
import sys
|
|
79
96
|
from pathlib import Path
|
|
80
97
|
|
|
81
98
|
log_file = Path(sys.argv[1])
|
|
82
99
|
pending_file = sys.argv[2]
|
|
83
|
-
|
|
100
|
+
launch_cwd = sys.argv[3]
|
|
101
|
+
argv = sys.argv[4:]
|
|
84
102
|
|
|
85
103
|
with log_file.open("ab", buffering=0) as log_handle:
|
|
86
104
|
proc = subprocess.Popen(
|
|
@@ -88,6 +106,7 @@ with log_file.open("ab", buffering=0) as log_handle:
|
|
|
88
106
|
stdin=subprocess.DEVNULL,
|
|
89
107
|
stdout=log_handle,
|
|
90
108
|
stderr=subprocess.STDOUT,
|
|
109
|
+
cwd=launch_cwd,
|
|
91
110
|
start_new_session=True,
|
|
92
111
|
)
|
|
93
112
|
|
|
@@ -102,6 +121,7 @@ printf 'LAUNCH_MODE=detached\n'
|
|
|
102
121
|
printf 'LAUNCH_NAME=%s\n' "${launch_name}"
|
|
103
122
|
printf 'LAUNCH_PID=%s\n' "${launch_pid}"
|
|
104
123
|
printf 'LAUNCH_LOG=%s\n' "${log_file}"
|
|
124
|
+
printf 'LAUNCH_CWD=%s\n' "${launch_cwd}"
|
|
105
125
|
if [[ -n "${pending_file}" ]]; then
|
|
106
126
|
printf 'LAUNCH_PENDING_FILE=%s\n' "${pending_file}"
|
|
107
127
|
fi
|
|
@@ -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=""
|
|
@@ -794,6 +804,81 @@ ${publish_out}
|
|
|
794
804
|
EOF
|
|
795
805
|
}
|
|
796
806
|
|
|
807
|
+
build_issue_runtime_blocker_comment() {
|
|
808
|
+
local runtime_reason="${1:-worker-exit-failed}"
|
|
809
|
+
local worker_name="${CODING_WORKER:-worker}"
|
|
810
|
+
|
|
811
|
+
case "${runtime_reason}" in
|
|
812
|
+
provider-quota-limit)
|
|
813
|
+
cat <<EOF
|
|
814
|
+
# Blocker: Provider quota is currently exhausted
|
|
815
|
+
|
|
816
|
+
This recurring run stopped before implementation because the configured ${worker_name} account hit a provider-side rate limit.
|
|
817
|
+
|
|
818
|
+
Why it was blocked:
|
|
819
|
+
- the worker reached Anthropic's current request limit for this account
|
|
820
|
+
- ACP recorded the quota hit and will retry after the configured cooldown instead of looping indefinitely
|
|
821
|
+
|
|
822
|
+
Next step:
|
|
823
|
+
- wait for the current quota window to reset, or switch this profile to another available provider/account
|
|
824
|
+
EOF
|
|
825
|
+
return 0
|
|
826
|
+
;;
|
|
827
|
+
esac
|
|
828
|
+
|
|
829
|
+
cat <<EOF
|
|
830
|
+
# Blocker: Worker session failed before publish
|
|
831
|
+
|
|
832
|
+
The worker exited before ACP could publish or reconcile a result for this cycle.
|
|
833
|
+
|
|
834
|
+
Failure reason:
|
|
835
|
+
- \`${runtime_reason}\`
|
|
836
|
+
|
|
837
|
+
Next step:
|
|
838
|
+
- inspect the run logs for this session and re-queue once the underlying worker issue is resolved
|
|
839
|
+
EOF
|
|
840
|
+
}
|
|
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
|
+
|
|
797
882
|
extract_recovery_worktree_from_publish_output() {
|
|
798
883
|
local publish_out="${1:-}"
|
|
799
884
|
awk -F= '/^RECOVERY_WORKTREE=/{print $2}' <<<"$publish_out" | tail -n 1
|
|
@@ -809,8 +894,15 @@ require_transition() {
|
|
|
809
894
|
}
|
|
810
895
|
|
|
811
896
|
mark_reconciled() {
|
|
897
|
+
local reconciled_at tmp_file
|
|
812
898
|
if [[ -d "$run_dir" ]]; then
|
|
813
|
-
|
|
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"
|
|
814
906
|
fi
|
|
815
907
|
}
|
|
816
908
|
|
|
@@ -949,7 +1041,7 @@ case "$status" in
|
|
|
949
1041
|
printf 'ACTION=%s\n' "$result_action"
|
|
950
1042
|
exit 0
|
|
951
1043
|
fi
|
|
952
|
-
failure_reason="
|
|
1044
|
+
failure_reason="$(infer_issue_blocked_failure_reason "${failure_reason:-}")"
|
|
953
1045
|
normalize_issue_runner_state "succeeded" "0" ""
|
|
954
1046
|
require_transition "issue_schedule_retry" issue_schedule_retry "$failure_reason"
|
|
955
1047
|
require_transition "issue_mark_blocked" issue_mark_blocked
|
|
@@ -1106,9 +1198,17 @@ case "$status" in
|
|
|
1106
1198
|
failure_reason="${failure_reason:-worker-exit-failed}"
|
|
1107
1199
|
schedule_provider_quota_cooldown "${failure_reason}"
|
|
1108
1200
|
normalize_issue_runner_state "failed" "${LAST_EXIT_CODE:-}" "${failure_reason}"
|
|
1201
|
+
if [[ "${result_outcome:-}" == "blocked" && "${result_action:-}" == "host-comment-blocker" ]]; then
|
|
1202
|
+
if [[ ! -s "${run_dir}/issue-comment.md" ]]; then
|
|
1203
|
+
write_issue_comment_artifact "$(build_issue_runtime_blocker_comment "${failure_reason}")" || true
|
|
1204
|
+
fi
|
|
1205
|
+
post_issue_comment_if_present
|
|
1206
|
+
issue_set_reconcile_summary "$status" "$result_outcome" "$result_action" "$failure_reason"
|
|
1207
|
+
else
|
|
1208
|
+
issue_set_reconcile_summary "$status" "" "" "$failure_reason"
|
|
1209
|
+
fi
|
|
1109
1210
|
require_transition "issue_schedule_retry" issue_schedule_retry "${failure_reason}"
|
|
1110
1211
|
require_transition "issue_mark_ready" issue_mark_ready
|
|
1111
|
-
issue_set_reconcile_summary "$status" "" "" "$failure_reason"
|
|
1112
1212
|
cleanup_issue_session
|
|
1113
1213
|
notify_issue_reconciled
|
|
1114
1214
|
;;
|
|
@@ -1120,6 +1220,12 @@ mark_reconciled
|
|
|
1120
1220
|
printf 'STATUS=%s\n' "$status"
|
|
1121
1221
|
printf 'ISSUE_ID=%s\n' "$issue_id"
|
|
1122
1222
|
printf 'PR_NUMBER=%s\n' "$pr_number"
|
|
1223
|
+
if [[ -n "${issue_summary_outcome:-}" ]]; then
|
|
1224
|
+
printf 'OUTCOME=%s\n' "${issue_summary_outcome}"
|
|
1225
|
+
fi
|
|
1226
|
+
if [[ -n "${issue_summary_action:-}" ]]; then
|
|
1227
|
+
printf 'ACTION=%s\n' "${issue_summary_action}"
|
|
1228
|
+
fi
|
|
1123
1229
|
if [[ -n "$failure_reason" ]]; then
|
|
1124
1230
|
printf 'FAILURE_REASON=%s\n' "$failure_reason"
|
|
1125
1231
|
fi
|