agent-control-plane 0.1.9 → 0.1.13

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 (40) hide show
  1. package/hooks/heartbeat-hooks.sh +147 -8
  2. package/hooks/issue-reconcile-hooks.sh +46 -0
  3. package/npm/bin/agent-control-plane.js +89 -8
  4. package/package.json +8 -2
  5. package/references/commands.md +1 -0
  6. package/tools/bin/agent-project-cleanup-session +133 -0
  7. package/tools/bin/agent-project-publish-issue-pr +178 -62
  8. package/tools/bin/agent-project-reconcile-issue-session +171 -3
  9. package/tools/bin/agent-project-run-codex-resilient +121 -16
  10. package/tools/bin/agent-project-run-codex-session +118 -10
  11. package/tools/bin/agent-project-run-openclaw-session +82 -8
  12. package/tools/bin/branch-verification-guard.sh +15 -2
  13. package/tools/bin/cleanup-worktree.sh +4 -1
  14. package/tools/bin/dashboard-launchd-bootstrap.sh +16 -4
  15. package/tools/bin/ensure-runtime-sync.sh +182 -0
  16. package/tools/bin/flow-config-lib.sh +76 -30
  17. package/tools/bin/flow-resident-worker-lib.sh +28 -2
  18. package/tools/bin/flow-shell-lib.sh +15 -1
  19. package/tools/bin/heartbeat-safe-auto.sh +32 -0
  20. package/tools/bin/issue-publish-localization-guard.sh +142 -0
  21. package/tools/bin/project-launchd-bootstrap.sh +17 -4
  22. package/tools/bin/project-runtime-supervisor.sh +7 -1
  23. package/tools/bin/project-runtimectl.sh +78 -15
  24. package/tools/bin/reuse-issue-worktree.sh +46 -0
  25. package/tools/bin/start-issue-worker.sh +110 -30
  26. package/tools/bin/start-resident-issue-loop.sh +1 -0
  27. package/tools/bin/sync-shared-agent-home.sh +50 -10
  28. package/tools/bin/test-smoke.sh +6 -1
  29. package/tools/dashboard/app.js +71 -1
  30. package/tools/dashboard/dashboard_snapshot.py +74 -0
  31. package/tools/dashboard/styles.css +43 -0
  32. package/tools/templates/issue-prompt-template.md +20 -65
  33. package/tools/templates/legacy/issue-prompt-template-pre-slim.md +109 -0
  34. package/bin/audit-issue-routing.sh +0 -74
  35. package/tools/bin/audit-agent-worktrees.sh +0 -310
  36. package/tools/bin/audit-issue-routing.sh +0 -11
  37. package/tools/bin/audit-retained-layout.sh +0 -58
  38. package/tools/bin/audit-retained-overlap.sh +0 -135
  39. package/tools/bin/audit-retained-worktrees.sh +0 -228
  40. package/tools/bin/check-skill-contracts.sh +0 -324
@@ -15,15 +15,87 @@ 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
+ AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
19
+ DEFAULT_BRANCH="$(flow_resolve_default_branch "${CONFIG_YAML}")"
20
+ STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
21
+ PENDING_LAUNCH_DIR="${ACP_PENDING_LAUNCH_DIR:-${F_LOSNING_PENDING_LAUNCH_DIR:-${STATE_ROOT}/pending-launches}}"
18
22
  AGENT_PR_PREFIXES_JSON="$(flow_managed_pr_prefixes_json "${CONFIG_YAML}")"
19
23
  AGENT_PR_ISSUE_CAPTURE_REGEX="$(flow_managed_issue_branch_regex "${CONFIG_YAML}")"
20
24
  AGENT_PR_HANDOFF_LABEL="${AGENT_PR_HANDOFF_LABEL:-agent-handoff}"
21
25
  AGENT_EXCLUSIVE_LABEL="${AGENT_EXCLUSIVE_LABEL:-agent-exclusive}"
22
26
  CODING_WORKER="${ACP_CODING_WORKER:-${F_LOSNING_CODING_WORKER:-codex}}"
27
+ HEARTBEAT_ISSUE_JSON_CACHE_DIR="${TMPDIR:-/tmp}/heartbeat-issue-json.$$"
28
+
29
+ heartbeat_issue_retry_state_file() {
30
+ local issue_id="${1:?issue id required}"
31
+ printf '%s/retries/issues/%s.env\n' "${STATE_ROOT}" "${issue_id}"
32
+ }
33
+
34
+ heartbeat_reason_requires_baseline_change() {
35
+ local reason="${1:-}"
36
+ case "${reason}" in
37
+ verification-guard-blocked|no-publishable-commits|no-publishable-delta)
38
+ return 0
39
+ ;;
40
+ *)
41
+ return 1
42
+ ;;
43
+ esac
44
+ }
45
+
46
+ heartbeat_current_baseline_head_sha() {
47
+ local head_sha=""
48
+ if [[ -d "${AGENT_REPO_ROOT}" ]]; then
49
+ head_sha="$(git -C "${AGENT_REPO_ROOT}" rev-parse --verify --quiet "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
50
+ if [[ -z "${head_sha}" ]]; then
51
+ head_sha="$(git -C "${AGENT_REPO_ROOT}" rev-parse --verify --quiet "${DEFAULT_BRANCH}" 2>/dev/null || true)"
52
+ fi
53
+ fi
54
+ printf '%s\n' "${head_sha}"
55
+ }
56
+
57
+ heartbeat_retry_reason_is_baseline_blocked() {
58
+ local issue_id="${1:?issue id required}"
59
+ local reason="${2:-}"
60
+ local state_file baseline_head current_head
61
+
62
+ heartbeat_reason_requires_baseline_change "${reason}" || return 1
63
+ state_file="$(heartbeat_issue_retry_state_file "${issue_id}")"
64
+ [[ -f "${state_file}" ]] || return 1
65
+
66
+ baseline_head="$(awk -F= '/^BASELINE_HEAD_SHA=/{print substr($0, index($0, "=") + 1); exit}' "${state_file}" 2>/dev/null | tr -d '\r' || true)"
67
+ [[ -n "${baseline_head}" ]] || return 1
68
+ current_head="$(heartbeat_current_baseline_head_sha)"
69
+ [[ -n "${current_head}" ]] || return 1
70
+
71
+ [[ "${baseline_head}" == "${current_head}" ]]
72
+ }
73
+
74
+ heartbeat_issue_json_cached() {
75
+ local issue_id="${1:?issue id required}"
76
+ local cache_file=""
77
+ local issue_json=""
78
+
79
+ if [[ ! -d "${HEARTBEAT_ISSUE_JSON_CACHE_DIR}" ]]; then
80
+ mkdir -p "${HEARTBEAT_ISSUE_JSON_CACHE_DIR}"
81
+ fi
82
+
83
+ cache_file="${HEARTBEAT_ISSUE_JSON_CACHE_DIR}/${issue_id}.json"
84
+ if [[ -f "${cache_file}" ]]; then
85
+ cat "${cache_file}"
86
+ return 0
87
+ fi
88
+
89
+ issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
90
+ printf '%s' "${issue_json}" >"${cache_file}"
91
+ printf '%s\n' "${issue_json}"
92
+ }
23
93
 
24
94
  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}" '
95
+ local pr_issue_ids_json=""
96
+ pr_issue_ids_json="$(
97
+ flow_github_pr_list_json "$REPO_SLUG" open 100 \
98
+ | jq --argjson agentPrPrefixes "${AGENT_PR_PREFIXES_JSON}" --arg handoffLabel "${AGENT_PR_HANDOFF_LABEL}" --arg branchIssueRegex "${AGENT_PR_ISSUE_CAPTURE_REGEX}" '
27
99
  map(
28
100
  . as $pr
29
101
  | select(
@@ -48,6 +120,13 @@ heartbeat_open_agent_pr_issue_ids() {
48
120
  )
49
121
  | unique
50
122
  '
123
+ )"
124
+
125
+ if [[ -z "${pr_issue_ids_json:-}" ]]; then
126
+ printf '[]\n'
127
+ else
128
+ printf '%s\n' "${pr_issue_ids_json}"
129
+ fi
51
130
  }
52
131
 
53
132
  heartbeat_list_ready_issue_ids() {
@@ -120,11 +199,14 @@ heartbeat_issue_blocked_recovery_reason() {
120
199
  retry_out="$("${FLOW_TOOLS_DIR}/retry-state.sh" issue "$issue_id" get 2>/dev/null || true)"
121
200
  retry_reason="$(awk -F= '/^LAST_REASON=/{print $2}' <<<"${retry_out:-}")"
122
201
  if [[ -n "${retry_reason:-}" && "${retry_reason}" != "issue-worker-blocked" ]]; then
202
+ if heartbeat_retry_reason_is_baseline_blocked "${issue_id}" "${retry_reason}"; then
203
+ return 0
204
+ fi
123
205
  printf '%s\n' "$retry_reason"
124
206
  return 0
125
207
  fi
126
208
 
127
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
209
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
128
210
  if [[ -z "${issue_json:-}" ]]; then
129
211
  return 0
130
212
  fi
@@ -163,6 +245,8 @@ if (explicitFailureReason) {
163
245
  reason = 'scope-guard-blocked';
164
246
  } else if (/verification guard/i.test(body)) {
165
247
  reason = 'verification-guard-blocked';
248
+ } else if (/localization guard/i.test(body) || /^# Blocker: Localization requirements were not satisfied$/im.test(body)) {
249
+ reason = 'localization-guard-blocked';
166
250
  } else if (/missing referenced OpenSpec paths/i.test(body)) {
167
251
  reason = 'missing-openspec-paths';
168
252
  } else if (/superseded by focused follow-up issues/i.test(body)) {
@@ -248,7 +332,7 @@ heartbeat_issue_is_heavy() {
248
332
  heartbeat_issue_is_recurring() {
249
333
  local issue_id="${1:?issue id required}"
250
334
  local issue_json
251
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
335
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
252
336
  if [[ -n "$issue_json" ]] && jq -e 'any(.labels[]?; .name == "agent-keep-open")' >/dev/null <<<"$issue_json"; then
253
337
  printf 'yes\n'
254
338
  else
@@ -259,7 +343,7 @@ heartbeat_issue_is_recurring() {
259
343
  heartbeat_issue_schedule_interval_seconds() {
260
344
  local issue_id="${1:?issue id required}"
261
345
  local issue_json issue_body
262
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
346
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
263
347
  if [[ -z "$issue_json" ]]; then
264
348
  issue_json='{}'
265
349
  fi
@@ -282,7 +366,7 @@ EOF
282
366
  heartbeat_issue_schedule_token() {
283
367
  local issue_id="${1:?issue id required}"
284
368
  local issue_json issue_body
285
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
369
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
286
370
  if [[ -z "$issue_json" ]]; then
287
371
  issue_json='{}'
288
372
  fi
@@ -321,7 +405,7 @@ heartbeat_issue_is_scheduled() {
321
405
  heartbeat_issue_is_exclusive() {
322
406
  local issue_id="${1:?issue id required}"
323
407
  local issue_json
324
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
408
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
325
409
  if [[ -n "$issue_json" ]] && jq -e --arg exclusiveLabel "${AGENT_EXCLUSIVE_LABEL}" 'any(.labels[]?; .name == $exclusiveLabel)' >/dev/null <<<"$issue_json"; then
326
410
  printf 'yes\n'
327
411
  else
@@ -379,7 +463,7 @@ heartbeat_sync_issue_labels() {
379
463
  local -a add_args=()
380
464
  local -a update_args=()
381
465
 
382
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
466
+ issue_json="$(heartbeat_issue_json_cached "$issue_id")"
383
467
  if [[ -z "$issue_json" ]]; then
384
468
  return 0
385
469
  fi
@@ -501,6 +585,52 @@ heartbeat_issue_resident_worker_key() {
501
585
  flow_resident_issue_lane_key "${CODING_WORKER}" "safe" "${lane_kind}" "${lane_value}"
502
586
  }
503
587
 
588
+ heartbeat_pending_issue_launch_pid() {
589
+ local issue_id="${1:?issue id required}"
590
+ local pending_file pid=""
591
+
592
+ pending_file="${PENDING_LAUNCH_DIR}/issue-${issue_id}.pid"
593
+ [[ -f "${pending_file}" ]] || return 1
594
+
595
+ pid="$(tr -d '[:space:]' <"${pending_file}" 2>/dev/null || true)"
596
+ [[ "${pid}" =~ ^[0-9]+$ ]] || return 1
597
+ kill -0 "${pid}" 2>/dev/null || return 1
598
+
599
+ printf '%s\n' "${pid}"
600
+ }
601
+
602
+ heartbeat_pending_resident_lane_launch_issue_id() {
603
+ local issue_id="${1:?issue id required}"
604
+ local worker_key=""
605
+ local pending_file=""
606
+ local candidate_issue_id=""
607
+ local candidate_worker_key=""
608
+
609
+ worker_key="$(heartbeat_issue_resident_worker_key "${issue_id}")"
610
+ [[ -n "${worker_key}" ]] || return 1
611
+ [[ -d "${PENDING_LAUNCH_DIR}" ]] || return 1
612
+
613
+ for pending_file in "${PENDING_LAUNCH_DIR}"/issue-*.pid; do
614
+ [[ -f "${pending_file}" ]] || continue
615
+ candidate_issue_id="${pending_file##*/issue-}"
616
+ candidate_issue_id="${candidate_issue_id%.pid}"
617
+ [[ -n "${candidate_issue_id}" ]] || continue
618
+ if ! heartbeat_pending_issue_launch_pid "${candidate_issue_id}" >/dev/null 2>&1; then
619
+ rm -f "${pending_file}" 2>/dev/null || true
620
+ continue
621
+ fi
622
+ if [[ "$(heartbeat_issue_uses_resident_loop "${candidate_issue_id}")" != "yes" ]]; then
623
+ continue
624
+ fi
625
+ candidate_worker_key="$(heartbeat_issue_resident_worker_key "${candidate_issue_id}")"
626
+ [[ -n "${candidate_worker_key}" && "${candidate_worker_key}" == "${worker_key}" ]] || continue
627
+ printf '%s\n' "${candidate_issue_id}"
628
+ return 0
629
+ done
630
+
631
+ return 1
632
+ }
633
+
504
634
  heartbeat_live_issue_controller_for_lane() {
505
635
  local issue_id="${1:?issue id required}"
506
636
  local worker_key=""
@@ -548,11 +678,20 @@ heartbeat_enqueue_issue_for_resident_controller() {
548
678
 
549
679
  heartbeat_start_issue_worker() {
550
680
  local issue_id="${1:?issue id required}"
681
+ local pending_lane_issue_id=""
551
682
  if [[ "$(heartbeat_issue_uses_resident_loop "${issue_id}")" == "yes" ]]; then
552
683
  if heartbeat_enqueue_issue_for_live_resident_lane "${issue_id}"; then
553
684
  printf 'LAUNCH_MODE=resident-lease\n'
554
685
  return 0
555
686
  fi
687
+ pending_lane_issue_id="$(heartbeat_pending_resident_lane_launch_issue_id "${issue_id}" || true)"
688
+ if [[ -n "${pending_lane_issue_id}" ]]; then
689
+ if [[ "${pending_lane_issue_id}" != "${issue_id}" ]]; then
690
+ flow_resident_issue_enqueue "${CONFIG_YAML}" "${issue_id}" "heartbeat-pending-lane" >/dev/null
691
+ fi
692
+ printf 'LAUNCH_MODE=resident-pending-lane\n'
693
+ return 0
694
+ fi
556
695
  if heartbeat_enqueue_issue_for_resident_controller "${issue_id}"; then
557
696
  printf 'LAUNCH_MODE=resident-lease\n'
558
697
  return 0
@@ -12,7 +12,9 @@ ADAPTER_BIN_DIR="${FLOW_SKILL_DIR}/bin"
12
12
  FLOW_TOOLS_DIR="${FLOW_SKILL_DIR}/tools/bin"
13
13
  REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
14
14
  AGENT_ROOT="$(flow_resolve_agent_root "${CONFIG_YAML}")"
15
+ AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
15
16
  STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
17
+ DEFAULT_BRANCH="$(flow_resolve_default_branch "${CONFIG_YAML}")"
16
18
  RUNS_ROOT="$(flow_resolve_runs_root "${CONFIG_YAML}")"
17
19
  BLOCKED_RECOVERY_STATE_DIR="${STATE_ROOT}/blocked-recovery-issues"
18
20
 
@@ -26,6 +28,49 @@ issue_clear_blocked_recovery_state() {
26
28
  rm -f "${BLOCKED_RECOVERY_STATE_DIR}/${ISSUE_ID}.env" 2>/dev/null || true
27
29
  }
28
30
 
31
+ issue_retry_state_file() {
32
+ printf '%s/retries/issues/%s.env\n' "${STATE_ROOT}" "${ISSUE_ID}"
33
+ }
34
+
35
+ issue_reason_requires_baseline_change() {
36
+ local reason="${1:-}"
37
+ case "${reason}" in
38
+ verification-guard-blocked|no-publishable-commits|no-publishable-delta)
39
+ return 0
40
+ ;;
41
+ *)
42
+ return 1
43
+ ;;
44
+ esac
45
+ }
46
+
47
+ issue_current_baseline_head_sha() {
48
+ local head_sha=""
49
+ if [[ -d "${AGENT_REPO_ROOT}" ]]; then
50
+ head_sha="$(git -C "${AGENT_REPO_ROOT}" rev-parse --verify --quiet "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
51
+ if [[ -z "${head_sha}" ]]; then
52
+ head_sha="$(git -C "${AGENT_REPO_ROOT}" rev-parse --verify --quiet "${DEFAULT_BRANCH}" 2>/dev/null || true)"
53
+ fi
54
+ fi
55
+ printf '%s\n' "${head_sha}"
56
+ }
57
+
58
+ issue_record_retry_baseline_gate() {
59
+ local reason="${1:-}"
60
+ local state_file head_sha tmp_file
61
+
62
+ issue_reason_requires_baseline_change "${reason}" || return 0
63
+ state_file="$(issue_retry_state_file)"
64
+ [[ -f "${state_file}" ]] || return 0
65
+ head_sha="$(issue_current_baseline_head_sha)"
66
+ [[ -n "${head_sha}" ]] || return 0
67
+
68
+ tmp_file="$(mktemp)"
69
+ grep -v '^BASELINE_HEAD_SHA=' "${state_file}" >"${tmp_file}" || true
70
+ printf 'BASELINE_HEAD_SHA=%s\n' "${head_sha}" >>"${tmp_file}"
71
+ mv "${tmp_file}" "${state_file}"
72
+ }
73
+
29
74
  issue_has_schedule_cadence() {
30
75
  local issue_json issue_body
31
76
  issue_json="$(flow_github_issue_view_json "${REPO_SLUG}" "${ISSUE_ID}" 2>/dev/null || true)"
@@ -155,6 +200,7 @@ issue_schedule_retry() {
155
200
  return 0
156
201
  fi
157
202
  "${FLOW_TOOLS_DIR}/retry-state.sh" issue "$ISSUE_ID" schedule "$reason" >/dev/null || true
203
+ issue_record_retry_baseline_gate "${reason}"
158
204
  }
159
205
 
160
206
  issue_mark_ready() {
@@ -156,7 +156,7 @@ function createExecutionContext(stage) {
156
156
  }
157
157
 
158
158
  function runScriptWithContext(context, scriptRelativePath, forwardedArgs, options = {}) {
159
- const scriptPath = path.join(packageRoot, scriptRelativePath);
159
+ const scriptPath = options.scriptPath || path.join(packageRoot, scriptRelativePath);
160
160
  const stdio = options.stdio || "inherit";
161
161
  const result = spawnSync("bash", [scriptPath, ...forwardedArgs], {
162
162
  stdio,
@@ -175,11 +175,78 @@ function runScriptWithContext(context, scriptRelativePath, forwardedArgs, option
175
175
  };
176
176
  }
177
177
 
178
+ function resolvePersistentSourceHome(context) {
179
+ if (process.env.ACP_PROJECT_RUNTIME_SOURCE_HOME) {
180
+ return process.env.ACP_PROJECT_RUNTIME_SOURCE_HOME;
181
+ }
182
+ if (fs.existsSync(path.join(packageRoot, ".git"))) {
183
+ return packageRoot;
184
+ }
185
+ return context.runtimeHome;
186
+ }
187
+
188
+ function runtimeSkillRoot(context) {
189
+ return path.join(context.runtimeHome, "skills", "openclaw", skillName);
190
+ }
191
+
192
+ function createRuntimeExecutionContext(context) {
193
+ const stableSkillRoot = runtimeSkillRoot(context);
194
+ const persistentSourceHome = resolvePersistentSourceHome(context);
195
+ const runtimeScriptEnv = {
196
+ ACP_PROJECT_RUNTIME_SYNC_SCRIPT:
197
+ context.env.ACP_PROJECT_RUNTIME_SYNC_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "sync-shared-agent-home.sh"),
198
+ ACP_PROJECT_RUNTIME_ENSURE_SYNC_SCRIPT:
199
+ context.env.ACP_PROJECT_RUNTIME_ENSURE_SYNC_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "ensure-runtime-sync.sh"),
200
+ ACP_PROJECT_RUNTIME_BOOTSTRAP_SCRIPT:
201
+ context.env.ACP_PROJECT_RUNTIME_BOOTSTRAP_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "project-launchd-bootstrap.sh"),
202
+ ACP_PROJECT_RUNTIME_SUPERVISOR_SCRIPT:
203
+ context.env.ACP_PROJECT_RUNTIME_SUPERVISOR_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "project-runtime-supervisor.sh"),
204
+ ACP_PROJECT_RUNTIME_KICK_SCRIPT:
205
+ context.env.ACP_PROJECT_RUNTIME_KICK_SCRIPT || path.join(stableSkillRoot, "tools", "bin", "kick-scheduler.sh")
206
+ };
207
+ return {
208
+ ...context,
209
+ stableSkillRoot,
210
+ persistentSourceHome,
211
+ env: {
212
+ ...context.env,
213
+ SHARED_AGENT_HOME: context.runtimeHome,
214
+ AGENT_CONTROL_PLANE_ROOT: stableSkillRoot,
215
+ ACP_ROOT: stableSkillRoot,
216
+ AGENT_FLOW_SOURCE_ROOT: stableSkillRoot,
217
+ ACP_PROJECT_INIT_SOURCE_HOME: persistentSourceHome,
218
+ ACP_PROJECT_RUNTIME_SOURCE_HOME: persistentSourceHome,
219
+ ACP_DASHBOARD_SOURCE_HOME: persistentSourceHome,
220
+ ...runtimeScriptEnv
221
+ }
222
+ };
223
+ }
224
+
225
+ function syncRuntimeHome(context, options = {}) {
226
+ const result = runScriptWithContext(context, "tools/bin/sync-shared-agent-home.sh", [], {
227
+ stdio: options.stdio || "inherit"
228
+ });
229
+ if (result.status !== 0) {
230
+ throw new Error("failed to sync runtime home before command execution");
231
+ }
232
+ }
233
+
178
234
  function runCommand(scriptRelativePath, forwardedArgs) {
179
235
  const stage = stageSharedHome();
180
236
  const context = createExecutionContext(stage);
181
237
 
182
238
  try {
239
+ if (scriptRelativePath !== "tools/bin/sync-shared-agent-home.sh") {
240
+ syncRuntimeHome(context, { stdio: "inherit" });
241
+ const runtimeContext = createRuntimeExecutionContext(context);
242
+ const runtimeScriptPath = path.join(runtimeContext.stableSkillRoot, scriptRelativePath);
243
+ const result = runScriptWithContext(runtimeContext, scriptRelativePath, forwardedArgs, {
244
+ stdio: "inherit",
245
+ scriptPath: runtimeScriptPath
246
+ });
247
+ return result.status;
248
+ }
249
+
183
250
  const result = runScriptWithContext(context, scriptRelativePath, forwardedArgs, { stdio: "inherit" });
184
251
  return result.status;
185
252
  } finally {
@@ -1470,8 +1537,8 @@ async function maybeRunFinalSetupFixups(options, scopedContext, config, currentS
1470
1537
  runtimeStartReason = `doctor-${doctorKv.DOCTOR_STATUS || "not-ok"}`;
1471
1538
  } else {
1472
1539
  actions.push("runtime-start");
1473
- runSetupStep(scopedContext, "Start the runtime", "tools/bin/project-runtimectl.sh", ["start", "--profile-id", config.profileId]);
1474
- const runtimeStatusOutput = runSetupStep(scopedContext, "Read back runtime status", "tools/bin/project-runtimectl.sh", ["status", "--profile-id", config.profileId]);
1540
+ runSetupStep(scopedContext, "Start the runtime", "tools/bin/project-runtimectl.sh", ["start", "--profile-id", config.profileId], { useRuntimeCopy: true });
1541
+ const runtimeStatusOutput = runSetupStep(scopedContext, "Read back runtime status", "tools/bin/project-runtimectl.sh", ["status", "--profile-id", config.profileId], { useRuntimeCopy: true });
1475
1542
  runtimeStatusKv = parseKvOutput(runtimeStatusOutput);
1476
1543
  runtimeStartStatus = "ok";
1477
1544
  runtimeStartReason = "";
@@ -1480,7 +1547,7 @@ async function maybeRunFinalSetupFixups(options, scopedContext, config, currentS
1480
1547
 
1481
1548
  if (config.installLaunchd && process.platform === "darwin" && launchdInstallStatus !== "ok" && runtimeStartStatus === "ok") {
1482
1549
  actions.push("launchd-install");
1483
- runSetupStep(scopedContext, "Install macOS autostart", "tools/bin/install-project-launchd.sh", ["--profile-id", config.profileId]);
1550
+ runSetupStep(scopedContext, "Install macOS autostart", "tools/bin/install-project-launchd.sh", ["--profile-id", config.profileId], { useRuntimeCopy: true });
1484
1551
  launchdInstallStatus = "ok";
1485
1552
  launchdInstallReason = "";
1486
1553
  }
@@ -1603,7 +1670,21 @@ async function collectSetupConfig(options, context) {
1603
1670
 
1604
1671
  function runSetupStep(context, title, scriptRelativePath, args, options = {}) {
1605
1672
  console.log(`\n== ${title} ==`);
1606
- const result = runScriptWithContext(context, scriptRelativePath, args, { stdio: "pipe", env: options.env, cwd: options.cwd });
1673
+ let executionContext = context;
1674
+ let scriptPath = undefined;
1675
+
1676
+ if (options.useRuntimeCopy) {
1677
+ syncRuntimeHome(context, { stdio: "pipe" });
1678
+ executionContext = createRuntimeExecutionContext(context);
1679
+ scriptPath = path.join(executionContext.stableSkillRoot, scriptRelativePath);
1680
+ }
1681
+
1682
+ const result = runScriptWithContext(executionContext, scriptRelativePath, args, {
1683
+ stdio: "pipe",
1684
+ env: options.env,
1685
+ cwd: options.cwd,
1686
+ scriptPath
1687
+ });
1607
1688
  if (result.status !== 0) {
1608
1689
  printFailureDetails(result);
1609
1690
  throw new Error(`${title} failed`);
@@ -1868,8 +1949,8 @@ async function runSetupFlow(forwardedArgs) {
1868
1949
  runtimeStartReason = "gh-auth-not-ready";
1869
1950
  console.log("runtime start skipped: GitHub CLI is not authenticated yet. Run `gh auth login` and start the runtime afterwards.");
1870
1951
  } else {
1871
- runSetupStep(scopedContext, "Start the runtime", "tools/bin/project-runtimectl.sh", ["start", "--profile-id", config.profileId]);
1872
- const runtimeStatusOutput = runSetupStep(scopedContext, "Read back runtime status", "tools/bin/project-runtimectl.sh", ["status", "--profile-id", config.profileId]);
1952
+ runSetupStep(scopedContext, "Start the runtime", "tools/bin/project-runtimectl.sh", ["start", "--profile-id", config.profileId], { useRuntimeCopy: true });
1953
+ const runtimeStatusOutput = runSetupStep(scopedContext, "Read back runtime status", "tools/bin/project-runtimectl.sh", ["status", "--profile-id", config.profileId], { useRuntimeCopy: true });
1873
1954
  runtimeStatusKv = parseKvOutput(runtimeStatusOutput);
1874
1955
  runtimeStartStatus = "ok";
1875
1956
  runtimeStartReason = "";
@@ -1886,7 +1967,7 @@ async function runSetupFlow(forwardedArgs) {
1886
1967
  launchdInstallReason = "runtime-not-started";
1887
1968
  console.log("launchd install skipped: runtime was not started successfully in this setup run.");
1888
1969
  } else {
1889
- runSetupStep(scopedContext, "Install macOS autostart", "tools/bin/install-project-launchd.sh", ["--profile-id", config.profileId]);
1970
+ runSetupStep(scopedContext, "Install macOS autostart", "tools/bin/install-project-launchd.sh", ["--profile-id", config.profileId], { useRuntimeCopy: true });
1890
1971
  launchdInstallStatus = "ok";
1891
1972
  launchdInstallReason = "";
1892
1973
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane",
3
- "version": "0.1.9",
3
+ "version": "0.1.13",
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 ;;
@@ -141,6 +143,131 @@ path_is_within_root() {
141
143
  [[ "$target_canonical" == "$root_canonical" || "$target_canonical" == "$root_canonical"/* ]]
142
144
  }
143
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
+
144
271
  cleanup_with_branch_tool() {
145
272
  local include_path="${1:-yes}"
146
273
  local -a cleanup_args
@@ -185,6 +312,9 @@ cleanup_orphan_worktree_dir() {
185
312
 
186
313
  if [[ "$active_tmux_session" == "true" ]]; then
187
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"
188
318
  elif [[ "$skip_worktree_cleanup" != "true" && -n "$branch_name" ]]; then
189
319
  if cleanup_output="$(cleanup_with_branch_tool yes 2>&1)"; then
190
320
  cleanup_mode="branch"
@@ -207,6 +337,9 @@ elif [[ "$skip_worktree_cleanup" != "true" && -n "$worktree_path" ]] && git -C "
207
337
  git -C "$repo_root" worktree remove "$worktree_path" --force || true
208
338
  git -C "$repo_root" worktree prune
209
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"
210
343
  elif [[ "$skip_worktree_cleanup" == "true" ]]; then
211
344
  cleanup_mode="archived-only"
212
345
  fi