agent-control-plane 0.1.8 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/bin/pr-risk.sh +54 -10
  2. package/hooks/heartbeat-hooks.sh +166 -13
  3. package/package.json +8 -2
  4. package/references/commands.md +1 -0
  5. package/tools/bin/agent-project-cleanup-session +143 -2
  6. package/tools/bin/agent-project-heartbeat-loop +29 -2
  7. package/tools/bin/agent-project-publish-issue-pr +178 -62
  8. package/tools/bin/agent-project-reconcile-issue-session +230 -5
  9. package/tools/bin/agent-project-reconcile-pr-session +104 -13
  10. package/tools/bin/agent-project-run-claude-session +19 -1
  11. package/tools/bin/agent-project-run-codex-resilient +121 -16
  12. package/tools/bin/agent-project-run-codex-session +61 -11
  13. package/tools/bin/agent-project-run-openclaw-session +274 -7
  14. package/tools/bin/agent-project-sync-anchor-repo +13 -2
  15. package/tools/bin/agent-project-worker-status +19 -14
  16. package/tools/bin/cleanup-worktree.sh +4 -1
  17. package/tools/bin/dashboard-launchd-bootstrap.sh +16 -4
  18. package/tools/bin/ensure-runtime-sync.sh +182 -0
  19. package/tools/bin/flow-config-lib.sh +76 -30
  20. package/tools/bin/flow-resident-worker-lib.sh +28 -2
  21. package/tools/bin/flow-shell-lib.sh +28 -8
  22. package/tools/bin/heartbeat-safe-auto.sh +32 -0
  23. package/tools/bin/issue-publish-localization-guard.sh +142 -0
  24. package/tools/bin/prepare-worktree.sh +3 -1
  25. package/tools/bin/project-launchd-bootstrap.sh +17 -4
  26. package/tools/bin/project-runtime-supervisor.sh +7 -1
  27. package/tools/bin/project-runtimectl.sh +78 -15
  28. package/tools/bin/provider-cooldown-state.sh +1 -1
  29. package/tools/bin/render-flow-config.sh +16 -1
  30. package/tools/bin/reuse-issue-worktree.sh +46 -0
  31. package/tools/bin/run-codex-task.sh +2 -2
  32. package/tools/bin/scaffold-profile.sh +2 -2
  33. package/tools/bin/start-issue-worker.sh +118 -16
  34. package/tools/bin/start-resident-issue-loop.sh +1 -0
  35. package/tools/bin/sync-shared-agent-home.sh +26 -0
  36. package/tools/bin/test-smoke.sh +6 -1
  37. package/tools/dashboard/app.js +91 -3
  38. package/tools/dashboard/dashboard_snapshot.py +119 -0
  39. package/tools/dashboard/styles.css +43 -0
  40. package/tools/templates/issue-prompt-template.md +18 -66
  41. package/tools/templates/legacy/issue-prompt-template-pre-slim.md +109 -0
  42. package/bin/audit-issue-routing.sh +0 -74
  43. package/tools/bin/audit-agent-worktrees.sh +0 -310
  44. package/tools/bin/audit-issue-routing.sh +0 -11
  45. package/tools/bin/audit-retained-layout.sh +0 -58
  46. package/tools/bin/audit-retained-overlap.sh +0 -135
  47. package/tools/bin/audit-retained-worktrees.sh +0 -228
  48. package/tools/bin/check-skill-contracts.sh +0 -324
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
 
@@ -15,15 +15,40 @@ FLOW_TOOLS_DIR="${FLOW_SKILL_DIR}/tools/bin"
15
15
  DETACHED_LAUNCH_BIN="${FLOW_TOOLS_DIR}/agent-project-detached-launch"
16
16
  RESIDENT_ISSUE_LOOP_BIN="${FLOW_TOOLS_DIR}/start-resident-issue-loop.sh"
17
17
  REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
18
+ STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
19
+ PENDING_LAUNCH_DIR="${ACP_PENDING_LAUNCH_DIR:-${F_LOSNING_PENDING_LAUNCH_DIR:-${STATE_ROOT}/pending-launches}}"
18
20
  AGENT_PR_PREFIXES_JSON="$(flow_managed_pr_prefixes_json "${CONFIG_YAML}")"
19
21
  AGENT_PR_ISSUE_CAPTURE_REGEX="$(flow_managed_issue_branch_regex "${CONFIG_YAML}")"
20
22
  AGENT_PR_HANDOFF_LABEL="${AGENT_PR_HANDOFF_LABEL:-agent-handoff}"
21
23
  AGENT_EXCLUSIVE_LABEL="${AGENT_EXCLUSIVE_LABEL:-agent-exclusive}"
22
24
  CODING_WORKER="${ACP_CODING_WORKER:-${F_LOSNING_CODING_WORKER:-codex}}"
25
+ HEARTBEAT_ISSUE_JSON_CACHE_DIR="${TMPDIR:-/tmp}/heartbeat-issue-json.$$"
26
+
27
+ heartbeat_issue_json_cached() {
28
+ local issue_id="${1:?issue id required}"
29
+ local cache_file=""
30
+ local issue_json=""
31
+
32
+ if [[ ! -d "${HEARTBEAT_ISSUE_JSON_CACHE_DIR}" ]]; then
33
+ mkdir -p "${HEARTBEAT_ISSUE_JSON_CACHE_DIR}"
34
+ fi
35
+
36
+ cache_file="${HEARTBEAT_ISSUE_JSON_CACHE_DIR}/${issue_id}.json"
37
+ if [[ -f "${cache_file}" ]]; then
38
+ cat "${cache_file}"
39
+ return 0
40
+ fi
41
+
42
+ issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
43
+ printf '%s' "${issue_json}" >"${cache_file}"
44
+ printf '%s\n' "${issue_json}"
45
+ }
23
46
 
24
47
  heartbeat_open_agent_pr_issue_ids() {
25
- flow_github_pr_list_json "$REPO_SLUG" open 100 \
26
- | jq --argjson agentPrPrefixes "${AGENT_PR_PREFIXES_JSON}" --arg handoffLabel "${AGENT_PR_HANDOFF_LABEL}" --arg branchIssueRegex "${AGENT_PR_ISSUE_CAPTURE_REGEX}" '
48
+ local pr_issue_ids_json=""
49
+ pr_issue_ids_json="$(
50
+ flow_github_pr_list_json "$REPO_SLUG" open 100 \
51
+ | jq --argjson agentPrPrefixes "${AGENT_PR_PREFIXES_JSON}" --arg handoffLabel "${AGENT_PR_HANDOFF_LABEL}" --arg branchIssueRegex "${AGENT_PR_ISSUE_CAPTURE_REGEX}" '
27
52
  map(
28
53
  . as $pr
29
54
  | select(
@@ -48,11 +73,18 @@ heartbeat_open_agent_pr_issue_ids() {
48
73
  )
49
74
  | unique
50
75
  '
76
+ )"
77
+
78
+ if [[ -z "${pr_issue_ids_json:-}" ]]; then
79
+ printf '[]\n'
80
+ else
81
+ printf '%s\n' "${pr_issue_ids_json}"
82
+ fi
51
83
  }
52
84
 
53
85
  heartbeat_list_ready_issue_ids() {
54
86
  local open_agent_pr_issue_ids
55
- local ready_issue_rows issue_id is_blocked retry_out retry_reason
87
+ local ready_issue_rows issue_id is_blocked retry_reason
56
88
  open_agent_pr_issue_ids="$(heartbeat_open_agent_pr_issue_ids)"
57
89
 
58
90
  ready_issue_rows="$(
@@ -73,8 +105,7 @@ heartbeat_list_ready_issue_ids() {
73
105
  [[ -n "${issue_id:-}" ]] || continue
74
106
 
75
107
  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:-}")"
108
+ retry_reason="$(heartbeat_issue_blocked_recovery_reason "$issue_id")"
78
109
  if [[ -z "${retry_reason:-}" ]]; then
79
110
  continue
80
111
  fi
@@ -87,7 +118,7 @@ heartbeat_list_ready_issue_ids() {
87
118
 
88
119
  heartbeat_list_blocked_recovery_issue_ids() {
89
120
  local open_agent_pr_issue_ids
90
- local blocked_issue_rows issue_id retry_out retry_reason
121
+ local blocked_issue_rows issue_id retry_reason
91
122
  open_agent_pr_issue_ids="$(heartbeat_open_agent_pr_issue_ids)"
92
123
 
93
124
  blocked_issue_rows="$(
@@ -105,8 +136,7 @@ heartbeat_list_blocked_recovery_issue_ids() {
105
136
 
106
137
  while IFS= read -r issue_id; do
107
138
  [[ -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:-}")"
139
+ retry_reason="$(heartbeat_issue_blocked_recovery_reason "$issue_id")"
110
140
  if [[ -z "${retry_reason:-}" ]]; then
111
141
  continue
112
142
  fi
@@ -115,6 +145,74 @@ heartbeat_list_blocked_recovery_issue_ids() {
115
145
  done <<<"$blocked_issue_rows"
116
146
  }
117
147
 
148
+ heartbeat_issue_blocked_recovery_reason() {
149
+ local issue_id="${1:?issue id required}"
150
+ local retry_out retry_reason issue_json
151
+
152
+ retry_out="$("${FLOW_TOOLS_DIR}/retry-state.sh" issue "$issue_id" get 2>/dev/null || true)"
153
+ retry_reason="$(awk -F= '/^LAST_REASON=/{print $2}' <<<"${retry_out:-}")"
154
+ if [[ -n "${retry_reason:-}" && "${retry_reason}" != "issue-worker-blocked" ]]; then
155
+ printf '%s\n' "$retry_reason"
156
+ return 0
157
+ fi
158
+
159
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
160
+ if [[ -z "${issue_json:-}" ]]; then
161
+ return 0
162
+ fi
163
+
164
+ ISSUE_JSON="${issue_json}" RETRY_REASON="${retry_reason:-}" node <<'EOF'
165
+ const issue = JSON.parse(process.env.ISSUE_JSON || '{}');
166
+ const labels = new Set((issue.labels || []).map((label) => label?.name).filter(Boolean));
167
+
168
+ if (!labels.has('agent-blocked')) {
169
+ process.exit(0);
170
+ }
171
+
172
+ const blockerComment = [...(issue.comments || [])]
173
+ .reverse()
174
+ .find((comment) =>
175
+ /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(
176
+ comment?.body || '',
177
+ ),
178
+ );
179
+
180
+ if (!blockerComment || !blockerComment.body) {
181
+ process.exit(0);
182
+ }
183
+
184
+ const body = String(blockerComment.body);
185
+ let reason = '';
186
+
187
+ const explicitFailureReason = body.match(/Failure reason:\s*[\r\n]+-\s*`([^`]+)`/i);
188
+ if (explicitFailureReason) {
189
+ reason = explicitFailureReason[1];
190
+ } else if (/provider quota is currently exhausted|provider-side rate limit|quota window/i.test(body)) {
191
+ reason = 'provider-quota-limit';
192
+ } else if (/no publishable delta|no commits ahead of `?origin\/main`?/i.test(body)) {
193
+ reason = 'no-publishable-delta';
194
+ } else if (/scope guard/i.test(body)) {
195
+ reason = 'scope-guard-blocked';
196
+ } else if (/verification guard/i.test(body)) {
197
+ reason = 'verification-guard-blocked';
198
+ } else if (/localization guard/i.test(body) || /^# Blocker: Localization requirements were not satisfied$/im.test(body)) {
199
+ reason = 'localization-guard-blocked';
200
+ } else if (/missing referenced OpenSpec paths/i.test(body)) {
201
+ reason = 'missing-openspec-paths';
202
+ } else if (/superseded by focused follow-up issues/i.test(body)) {
203
+ reason = 'superseded-by-follow-ups';
204
+ } else if (/^# Blocker:/im.test(body)) {
205
+ reason = 'comment-blocked-recovery';
206
+ }
207
+
208
+ if (reason) {
209
+ process.stdout.write(`${reason}\n`);
210
+ } else if ((process.env.RETRY_REASON || '').trim()) {
211
+ process.stdout.write(`${String(process.env.RETRY_REASON).trim()}\n`);
212
+ }
213
+ EOF
214
+ }
215
+
118
216
  heartbeat_list_exclusive_issue_ids() {
119
217
  local open_agent_pr_issue_ids
120
218
  open_agent_pr_issue_ids="$(heartbeat_open_agent_pr_issue_ids)"
@@ -184,7 +282,7 @@ heartbeat_issue_is_heavy() {
184
282
  heartbeat_issue_is_recurring() {
185
283
  local issue_id="${1:?issue id required}"
186
284
  local issue_json
187
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
285
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
188
286
  if [[ -n "$issue_json" ]] && jq -e 'any(.labels[]?; .name == "agent-keep-open")' >/dev/null <<<"$issue_json"; then
189
287
  printf 'yes\n'
190
288
  else
@@ -195,7 +293,7 @@ heartbeat_issue_is_recurring() {
195
293
  heartbeat_issue_schedule_interval_seconds() {
196
294
  local issue_id="${1:?issue id required}"
197
295
  local issue_json issue_body
198
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
296
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
199
297
  if [[ -z "$issue_json" ]]; then
200
298
  issue_json='{}'
201
299
  fi
@@ -218,7 +316,7 @@ EOF
218
316
  heartbeat_issue_schedule_token() {
219
317
  local issue_id="${1:?issue id required}"
220
318
  local issue_json issue_body
221
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
319
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
222
320
  if [[ -z "$issue_json" ]]; then
223
321
  issue_json='{}'
224
322
  fi
@@ -257,7 +355,7 @@ heartbeat_issue_is_scheduled() {
257
355
  heartbeat_issue_is_exclusive() {
258
356
  local issue_id="${1:?issue id required}"
259
357
  local issue_json
260
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
358
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
261
359
  if [[ -n "$issue_json" ]] && jq -e --arg exclusiveLabel "${AGENT_EXCLUSIVE_LABEL}" 'any(.labels[]?; .name == $exclusiveLabel)' >/dev/null <<<"$issue_json"; then
262
360
  printf 'yes\n'
263
361
  else
@@ -315,7 +413,7 @@ heartbeat_sync_issue_labels() {
315
413
  local -a add_args=()
316
414
  local -a update_args=()
317
415
 
318
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
416
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
319
417
  if [[ -z "$issue_json" ]]; then
320
418
  return 0
321
419
  fi
@@ -437,6 +535,52 @@ heartbeat_issue_resident_worker_key() {
437
535
  flow_resident_issue_lane_key "${CODING_WORKER}" "safe" "${lane_kind}" "${lane_value}"
438
536
  }
439
537
 
538
+ heartbeat_pending_issue_launch_pid() {
539
+ local issue_id="${1:?issue id required}"
540
+ local pending_file pid=""
541
+
542
+ pending_file="${PENDING_LAUNCH_DIR}/issue-${issue_id}.pid"
543
+ [[ -f "${pending_file}" ]] || return 1
544
+
545
+ pid="$(tr -d '[:space:]' <"${pending_file}" 2>/dev/null || true)"
546
+ [[ "${pid}" =~ ^[0-9]+$ ]] || return 1
547
+ kill -0 "${pid}" 2>/dev/null || return 1
548
+
549
+ printf '%s\n' "${pid}"
550
+ }
551
+
552
+ heartbeat_pending_resident_lane_launch_issue_id() {
553
+ local issue_id="${1:?issue id required}"
554
+ local worker_key=""
555
+ local pending_file=""
556
+ local candidate_issue_id=""
557
+ local candidate_worker_key=""
558
+
559
+ worker_key="$(heartbeat_issue_resident_worker_key "${issue_id}")"
560
+ [[ -n "${worker_key}" ]] || return 1
561
+ [[ -d "${PENDING_LAUNCH_DIR}" ]] || return 1
562
+
563
+ for pending_file in "${PENDING_LAUNCH_DIR}"/issue-*.pid; do
564
+ [[ -f "${pending_file}" ]] || continue
565
+ candidate_issue_id="${pending_file##*/issue-}"
566
+ candidate_issue_id="${candidate_issue_id%.pid}"
567
+ [[ -n "${candidate_issue_id}" ]] || continue
568
+ if ! heartbeat_pending_issue_launch_pid "${candidate_issue_id}" >/dev/null 2>&1; then
569
+ rm -f "${pending_file}" 2>/dev/null || true
570
+ continue
571
+ fi
572
+ if [[ "$(heartbeat_issue_uses_resident_loop "${candidate_issue_id}")" != "yes" ]]; then
573
+ continue
574
+ fi
575
+ candidate_worker_key="$(heartbeat_issue_resident_worker_key "${candidate_issue_id}")"
576
+ [[ -n "${candidate_worker_key}" && "${candidate_worker_key}" == "${worker_key}" ]] || continue
577
+ printf '%s\n' "${candidate_issue_id}"
578
+ return 0
579
+ done
580
+
581
+ return 1
582
+ }
583
+
440
584
  heartbeat_live_issue_controller_for_lane() {
441
585
  local issue_id="${1:?issue id required}"
442
586
  local worker_key=""
@@ -484,11 +628,20 @@ heartbeat_enqueue_issue_for_resident_controller() {
484
628
 
485
629
  heartbeat_start_issue_worker() {
486
630
  local issue_id="${1:?issue id required}"
631
+ local pending_lane_issue_id=""
487
632
  if [[ "$(heartbeat_issue_uses_resident_loop "${issue_id}")" == "yes" ]]; then
488
633
  if heartbeat_enqueue_issue_for_live_resident_lane "${issue_id}"; then
489
634
  printf 'LAUNCH_MODE=resident-lease\n'
490
635
  return 0
491
636
  fi
637
+ pending_lane_issue_id="$(heartbeat_pending_resident_lane_launch_issue_id "${issue_id}" || true)"
638
+ if [[ -n "${pending_lane_issue_id}" ]]; then
639
+ if [[ "${pending_lane_issue_id}" != "${issue_id}" ]]; then
640
+ flow_resident_issue_enqueue "${CONFIG_YAML}" "${issue_id}" "heartbeat-pending-lane" >/dev/null
641
+ fi
642
+ printf 'LAUNCH_MODE=resident-pending-lane\n'
643
+ return 0
644
+ fi
492
645
  if heartbeat_enqueue_issue_for_resident_controller "${issue_id}"; then
493
646
  printf 'LAUNCH_MODE=resident-lease\n'
494
647
  return 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane",
3
- "version": "0.1.8",
3
+ "version": "0.1.12",
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": {
@@ -21,11 +21,17 @@
21
21
  "README.md",
22
22
  "SKILL.md",
23
23
  "assets/workflow-catalog.json",
24
- "bin",
24
+ "bin/agent-control-plane",
25
+ "bin/issue-resource-class.sh",
26
+ "bin/label-follow-up-issues.sh",
27
+ "bin/pr-risk.sh",
28
+ "bin/sync-pr-labels.sh",
25
29
  "hooks",
26
30
  "npm/bin",
27
31
  "references",
28
32
  "tools/bin",
33
+ "!tools/bin/audit-*.sh",
34
+ "!tools/bin/check-skill-contracts.sh",
29
35
  "tools/dashboard/app.js",
30
36
  "tools/dashboard/dashboard_snapshot.py",
31
37
  "tools/dashboard/index.html",
@@ -76,6 +76,7 @@ tools/bin/profile-smoke.sh
76
76
  tools/bin/test-smoke.sh
77
77
  tools/bin/profile-adopt.sh --profile-id <id>
78
78
  tools/bin/project-runtimectl.sh status --profile-id <id>
79
+ tools/bin/project-runtimectl.sh sync --profile-id <id>
79
80
  tools/bin/project-runtimectl.sh stop --profile-id <id>
80
81
  tools/bin/project-runtimectl.sh start --profile-id <id>
81
82
  tools/bin/project-runtimectl.sh restart --profile-id <id>
@@ -15,6 +15,7 @@ shared_agent_home="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
15
15
  repo_root="${AGENT_PROJECT_REPO_ROOT:-}"
16
16
  runs_root="${AGENT_PROJECT_RUNS_ROOT:-}"
17
17
  history_root="${AGENT_PROJECT_HISTORY_ROOT:-}"
18
+ state_root="${AGENT_PROJECT_STATE_ROOT:-${F_LOSNING_STATE_ROOT:-}}"
18
19
  session=""
19
20
  worktree_path=""
20
21
  mode="generic"
@@ -30,6 +31,7 @@ while [[ $# -gt 0 ]]; do
30
31
  --repo-root) repo_root="${2:-}"; shift 2 ;;
31
32
  --runs-root) runs_root="${2:-}"; shift 2 ;;
32
33
  --history-root) history_root="${2:-}"; shift 2 ;;
34
+ --state-root) state_root="${2:-}"; shift 2 ;;
33
35
  --session) session="${2:-}"; shift 2 ;;
34
36
  --worktree) worktree_path="${2:-}"; shift 2 ;;
35
37
  --mode) mode="${2:-}"; shift 2 ;;
@@ -92,6 +94,7 @@ cleanup_status="0"
92
94
  cleanup_error=""
93
95
  cleanup_mode="noop"
94
96
  orphan_fallback_used="false"
97
+ active_tmux_session="false"
95
98
 
96
99
  if [[ -n "$session" ]]; then
97
100
  meta_file="${runs_root}/${session}/run.env"
@@ -112,6 +115,10 @@ if [[ -z "$remove_file" ]]; then
112
115
  remove_file="$result_file"
113
116
  fi
114
117
 
118
+ if [[ -n "$session" ]] && tmux has-session -t "$session" 2>/dev/null; then
119
+ active_tmux_session="true"
120
+ fi
121
+
115
122
  cleanup_tool="${shared_agent_home}/tools/bin/agent-cleanup-worktree"
116
123
  archive_tool="${shared_agent_home}/tools/bin/agent-project-archive-run"
117
124
 
@@ -136,6 +143,131 @@ path_is_within_root() {
136
143
  [[ "$target_canonical" == "$root_canonical" || "$target_canonical" == "$root_canonical"/* ]]
137
144
  }
138
145
 
146
+ derive_state_root() {
147
+ if [[ -n "${state_root}" && -d "${state_root}" ]]; then
148
+ printf '%s\n' "${state_root}"
149
+ return 0
150
+ fi
151
+
152
+ if [[ -n "${runs_root}" && -d "${runs_root}" ]]; then
153
+ local candidate_root=""
154
+ candidate_root="$(cd "${runs_root}/.." 2>/dev/null && pwd -P || true)"
155
+ if [[ -n "${candidate_root}" && -d "${candidate_root}/state" ]]; then
156
+ printf '%s/state\n' "${candidate_root}"
157
+ return 0
158
+ fi
159
+ fi
160
+
161
+ return 1
162
+ }
163
+
164
+ resident_worktree_protected() {
165
+ local candidate_path="${1:-}"
166
+ local resolved_candidate=""
167
+ local resolved_state_root=""
168
+ local metadata_file=""
169
+ local resident_worktree=""
170
+ local resident_realpath=""
171
+ local resolved_resident=""
172
+
173
+ [[ -n "${candidate_path}" && -d "${candidate_path}" ]] || return 1
174
+ resolved_candidate="$(canonicalize_existing_dir "${candidate_path}" || true)"
175
+ [[ -n "${resolved_candidate}" ]] || return 1
176
+
177
+ resolved_state_root="$(derive_state_root || true)"
178
+ [[ -n "${resolved_state_root}" && -d "${resolved_state_root}/resident-workers/issues" ]] || return 1
179
+
180
+ for metadata_file in "${resolved_state_root}"/resident-workers/issues/*/metadata.env; do
181
+ [[ -f "${metadata_file}" ]] || continue
182
+ resident_worktree=""
183
+ resident_realpath=""
184
+ set +u
185
+ set -a
186
+ # shellcheck source=/dev/null
187
+ source "${metadata_file}"
188
+ set +a
189
+ set -u
190
+ resident_worktree="${WORKTREE_REALPATH:-${WORKTREE:-}}"
191
+ [[ -n "${resident_worktree}" && -d "${resident_worktree}" ]] || continue
192
+ resolved_resident="$(canonicalize_existing_dir "${resident_worktree}" || true)"
193
+ [[ -n "${resolved_resident}" ]] || continue
194
+ if [[ "${resolved_candidate}" == "${resolved_resident}" ]]; then
195
+ return 0
196
+ fi
197
+ done
198
+
199
+ return 1
200
+ }
201
+
202
+ active_resident_run_worktree_protected() {
203
+ local candidate_path="${1:-}"
204
+ local resolved_candidate=""
205
+ local run_meta=""
206
+ local run_dir=""
207
+ local session_name=""
208
+ local resident_enabled=""
209
+ local resident_worktree=""
210
+ local runner_state_file=""
211
+ local runner_state=""
212
+ local active_session="false"
213
+ local resolved_resident=""
214
+
215
+ [[ -n "${candidate_path}" && -d "${candidate_path}" ]] || return 1
216
+ [[ -n "${runs_root}" && -d "${runs_root}" ]] || return 1
217
+
218
+ resolved_candidate="$(canonicalize_existing_dir "${candidate_path}" || true)"
219
+ [[ -n "${resolved_candidate}" ]] || return 1
220
+
221
+ for run_meta in "${runs_root}"/*/run.env; do
222
+ [[ -f "${run_meta}" ]] || continue
223
+
224
+ session_name=""
225
+ resident_enabled=""
226
+ resident_worktree=""
227
+ runner_state=""
228
+ active_session="false"
229
+
230
+ set +u
231
+ set -a
232
+ # shellcheck source=/dev/null
233
+ source "${run_meta}"
234
+ set +a
235
+ set -u
236
+
237
+ resident_enabled="${RESIDENT_WORKER_ENABLED:-no}"
238
+ [[ "${resident_enabled}" == "yes" ]] || continue
239
+
240
+ resident_worktree="${WORKTREE_REALPATH:-${WORKTREE:-}}"
241
+ [[ -n "${resident_worktree}" && -d "${resident_worktree}" ]] || continue
242
+ resolved_resident="$(canonicalize_existing_dir "${resident_worktree}" || true)"
243
+ [[ -n "${resolved_resident}" ]] || continue
244
+ [[ "${resolved_candidate}" == "${resolved_resident}" ]] || continue
245
+
246
+ run_dir="$(dirname "${run_meta}")"
247
+ runner_state_file="${run_dir}/runner.env"
248
+ if [[ -f "${runner_state_file}" ]]; then
249
+ set +u
250
+ set -a
251
+ # shellcheck source=/dev/null
252
+ source "${runner_state_file}"
253
+ set +a
254
+ set -u
255
+ runner_state="${RUNNER_STATE:-}"
256
+ fi
257
+
258
+ session_name="${SESSION:-}"
259
+ if [[ -n "${session_name}" ]] && tmux has-session -t "${session_name}" 2>/dev/null; then
260
+ active_session="true"
261
+ fi
262
+
263
+ if [[ "${runner_state}" == "running" || "${active_session}" == "true" ]]; then
264
+ return 0
265
+ fi
266
+ done
267
+
268
+ return 1
269
+ }
270
+
139
271
  cleanup_with_branch_tool() {
140
272
  local include_path="${1:-yes}"
141
273
  local -a cleanup_args
@@ -178,7 +310,12 @@ cleanup_orphan_worktree_dir() {
178
310
  git -C "$repo_root" worktree prune >/dev/null 2>&1 || true
179
311
  }
180
312
 
181
- if [[ "$skip_worktree_cleanup" != "true" && -n "$branch_name" ]]; then
313
+ if [[ "$active_tmux_session" == "true" ]]; then
314
+ cleanup_mode="deferred-active-session"
315
+ elif [[ "$skip_worktree_cleanup" != "true" && -n "${worktree_path}" ]] \
316
+ && { resident_worktree_protected "${worktree_path}" || active_resident_run_worktree_protected "${worktree_path}"; }; then
317
+ cleanup_mode="protected-resident-worktree"
318
+ elif [[ "$skip_worktree_cleanup" != "true" && -n "$branch_name" ]]; then
182
319
  if cleanup_output="$(cleanup_with_branch_tool yes 2>&1)"; then
183
320
  cleanup_mode="branch"
184
321
  else
@@ -200,12 +337,15 @@ elif [[ "$skip_worktree_cleanup" != "true" && -n "$worktree_path" ]] && git -C "
200
337
  git -C "$repo_root" worktree remove "$worktree_path" --force || true
201
338
  git -C "$repo_root" worktree prune
202
339
  cleanup_mode="worktree"
340
+ elif [[ "$skip_worktree_cleanup" != "true" ]] && cleanup_orphan_worktree_dir; then
341
+ orphan_fallback_used="true"
342
+ cleanup_mode="orphan-worktree"
203
343
  elif [[ "$skip_worktree_cleanup" == "true" ]]; then
204
344
  cleanup_mode="archived-only"
205
345
  fi
206
346
 
207
347
  archive_output=""
208
- if [[ -n "$session" ]]; then
348
+ if [[ -n "$session" && "$active_tmux_session" != "true" ]]; then
209
349
  archive_output="$(
210
350
  "$archive_tool" \
211
351
  --runs-root "$runs_root" \
@@ -227,6 +367,7 @@ printf 'BRANCH=%s\n' "$branch_name"
227
367
  printf 'KEEP_REMOTE=%s\n' "$keep_remote"
228
368
  printf 'ALLOW_UNMERGED=%s\n' "$allow_unmerged"
229
369
  printf 'SKIP_WORKTREE_CLEANUP=%s\n' "$skip_worktree_cleanup"
370
+ printf 'ACTIVE_TMUX_SESSION=%s\n' "$active_tmux_session"
230
371
  printf 'CLEANUP_MODE=%s\n' "$cleanup_mode"
231
372
  printf 'ORPHAN_FALLBACK_USED=%s\n' "$orphan_fallback_used"
232
373
  printf 'CLEANUP_STATUS=%s\n' "$cleanup_status"
@@ -929,6 +929,9 @@ running_recurring_issue_workers() {
929
929
  count=$((count + 1))
930
930
  fi
931
931
  done <<<"$running_issue_workers_cache"
932
+ # Also count pending recurring launches that are still in progress
933
+ # (prevents infinite respawning when workers die before creating tmux sessions)
934
+ count=$((count + $(pending_recurring_issue_launch_count)))
932
935
  printf '%s\n' "$count"
933
936
  }
934
937
 
@@ -1120,9 +1123,11 @@ build_ordered_ready_issue_ids_cache() {
1120
1123
 
1121
1124
  last_recurring_issue="$(last_launched_recurring_issue_id || true)"
1122
1125
  if [[ -n "$last_recurring_issue" ]]; then
1126
+ local emitted_after_last=0
1123
1127
  for issue_id in "${recurring_ids[@]}"; do
1124
1128
  if [[ "$seen_last" == "yes" ]]; then
1125
1129
  printf '%s\n' "$issue_id"
1130
+ emitted_after_last=$((emitted_after_last + 1))
1126
1131
  fi
1127
1132
  if [[ "$issue_id" == "$last_recurring_issue" ]]; then
1128
1133
  seen_last="yes"
@@ -1131,7 +1136,12 @@ build_ordered_ready_issue_ids_cache() {
1131
1136
  fi
1132
1137
 
1133
1138
  for issue_id in "${recurring_ids[@]}"; do
1134
- if [[ -n "$last_recurring_issue" && "$seen_last" == "yes" && "$issue_id" == "$last_recurring_issue" ]]; then
1139
+ # Stop the wrap-around once we reach the last-launched issue, but only
1140
+ # when the first loop already emitted at least one issue after it.
1141
+ # When there is exactly one recurring issue (or the last-launched issue
1142
+ # is the final element), emitted_after_last is 0, so we must still
1143
+ # include it here to avoid producing an empty list.
1144
+ if [[ -n "$last_recurring_issue" && "$seen_last" == "yes" && "$issue_id" == "$last_recurring_issue" && "$emitted_after_last" -gt 0 ]]; then
1135
1145
  break
1136
1146
  fi
1137
1147
  printf '%s\n' "$issue_id"
@@ -1143,6 +1153,21 @@ completed_workers() {
1143
1153
  printf '%s\n' "$completed_workers_cache"
1144
1154
  }
1145
1155
 
1156
+ reconciled_marker_matches_run() {
1157
+ local run_dir="${1:?run dir required}"
1158
+ local marker_file="${run_dir}/reconciled.ok"
1159
+ local run_env="${run_dir}/run.env"
1160
+ local marker_started_at=""
1161
+ local run_started_at=""
1162
+
1163
+ [[ -f "${marker_file}" && -f "${run_env}" ]] || return 1
1164
+
1165
+ marker_started_at="$(awk -F= '/^STARTED_AT=/{print $2}' "${marker_file}" 2>/dev/null | tr -d '"' | tail -n 1 || true)"
1166
+ run_started_at="$(awk -F= '/^STARTED_AT=/{print $2}' "${run_env}" 2>/dev/null | tr -d '"' | tail -n 1 || true)"
1167
+
1168
+ [[ -n "${marker_started_at}" && -n "${run_started_at}" && "${marker_started_at}" == "${run_started_at}" ]]
1169
+ }
1170
+
1146
1171
  ensure_completed_workers_cache() {
1147
1172
  local dir session issue_id status_line status
1148
1173
  if [[ "$completed_workers_cache_loaded" == "yes" ]]; then
@@ -1153,7 +1178,9 @@ ensure_completed_workers_cache() {
1153
1178
  [[ -d "$dir" ]] || continue
1154
1179
  session="${dir##*/}"
1155
1180
  session_matches_prefix "$session" || continue
1156
- [[ -f "$dir/reconciled.ok" ]] && continue
1181
+ if reconciled_marker_matches_run "$dir"; then
1182
+ continue
1183
+ fi
1157
1184
  if [[ "$session" == "${issue_prefix}"* ]]; then
1158
1185
  issue_id="$(issue_id_from_session "$session" || true)"
1159
1186
  if [[ -n "${issue_id}" ]] && pending_issue_launch_active "${issue_id}"; then