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.
- package/bin/pr-risk.sh +54 -10
- package/hooks/heartbeat-hooks.sh +166 -13
- package/package.json +8 -2
- package/references/commands.md +1 -0
- package/tools/bin/agent-project-cleanup-session +143 -2
- package/tools/bin/agent-project-heartbeat-loop +29 -2
- package/tools/bin/agent-project-publish-issue-pr +178 -62
- package/tools/bin/agent-project-reconcile-issue-session +230 -5
- package/tools/bin/agent-project-reconcile-pr-session +104 -13
- package/tools/bin/agent-project-run-claude-session +19 -1
- package/tools/bin/agent-project-run-codex-resilient +121 -16
- package/tools/bin/agent-project-run-codex-session +61 -11
- package/tools/bin/agent-project-run-openclaw-session +274 -7
- package/tools/bin/agent-project-sync-anchor-repo +13 -2
- package/tools/bin/agent-project-worker-status +19 -14
- package/tools/bin/cleanup-worktree.sh +4 -1
- package/tools/bin/dashboard-launchd-bootstrap.sh +16 -4
- package/tools/bin/ensure-runtime-sync.sh +182 -0
- package/tools/bin/flow-config-lib.sh +76 -30
- package/tools/bin/flow-resident-worker-lib.sh +28 -2
- package/tools/bin/flow-shell-lib.sh +28 -8
- package/tools/bin/heartbeat-safe-auto.sh +32 -0
- package/tools/bin/issue-publish-localization-guard.sh +142 -0
- package/tools/bin/prepare-worktree.sh +3 -1
- package/tools/bin/project-launchd-bootstrap.sh +17 -4
- package/tools/bin/project-runtime-supervisor.sh +7 -1
- package/tools/bin/project-runtimectl.sh +78 -15
- package/tools/bin/provider-cooldown-state.sh +1 -1
- package/tools/bin/render-flow-config.sh +16 -1
- package/tools/bin/reuse-issue-worktree.sh +46 -0
- package/tools/bin/run-codex-task.sh +2 -2
- package/tools/bin/scaffold-profile.sh +2 -2
- package/tools/bin/start-issue-worker.sh +118 -16
- package/tools/bin/start-resident-issue-loop.sh +1 -0
- package/tools/bin/sync-shared-agent-home.sh +26 -0
- package/tools/bin/test-smoke.sh +6 -1
- package/tools/dashboard/app.js +91 -3
- package/tools/dashboard/dashboard_snapshot.py +119 -0
- package/tools/dashboard/styles.css +43 -0
- package/tools/templates/issue-prompt-template.md +18 -66
- package/tools/templates/legacy/issue-prompt-template-pre-slim.md +109 -0
- package/bin/audit-issue-routing.sh +0 -74
- package/tools/bin/audit-agent-worktrees.sh +0 -310
- package/tools/bin/audit-issue-routing.sh +0 -11
- package/tools/bin/audit-retained-layout.sh +0 -58
- package/tools/bin/audit-retained-overlap.sh +0 -135
- package/tools/bin/audit-retained-worktrees.sh +0 -228
- 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
|
-
|
|
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
|
@@ -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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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="$(
|
|
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="$(
|
|
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="$(
|
|
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="$(
|
|
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="$(
|
|
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.
|
|
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",
|
package/references/commands.md
CHANGED
|
@@ -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 [[ "$
|
|
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
|
-
|
|
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
|