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 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)"
@@ -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
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 5 >/dev/null || true
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
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 2 >/dev/null || true
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
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 5 >/dev/null || true
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
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 5 >/dev/null || true
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
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 5 >/dev/null || true
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
- "${FLOW_TOOLS_DIR}/kick-scheduler.sh" 2 >/dev/null || true
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.7",
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 [[ "$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"
@@ -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
- argv = sys.argv[3:]
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
- 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=""
@@ -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
- 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"
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="issue-worker-blocked"
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