agent-control-plane 0.1.16 → 0.3.0

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 (63) hide show
  1. package/README.md +93 -14
  2. package/bin/pr-risk.sh +28 -6
  3. package/hooks/heartbeat-hooks.sh +62 -22
  4. package/npm/bin/agent-control-plane.js +360 -10
  5. package/package.json +6 -3
  6. package/references/architecture.md +8 -0
  7. package/references/control-plane-map.md +6 -2
  8. package/references/release-checklist.md +0 -2
  9. package/tools/bin/agent-github-update-labels +6 -1
  10. package/tools/bin/agent-project-catch-up-issue-pr-links +118 -0
  11. package/tools/bin/agent-project-catch-up-merged-prs +78 -21
  12. package/tools/bin/agent-project-catch-up-scheduled-issue-retries +123 -0
  13. package/tools/bin/agent-project-cleanup-session +132 -4
  14. package/tools/bin/agent-project-heartbeat-loop +116 -1461
  15. package/tools/bin/agent-project-reconcile-issue-session +90 -117
  16. package/tools/bin/agent-project-reconcile-pr-session +76 -111
  17. package/tools/bin/agent-project-run-claude-session +12 -2
  18. package/tools/bin/agent-project-run-codex-resilient +86 -9
  19. package/tools/bin/agent-project-run-codex-session +16 -5
  20. package/tools/bin/agent-project-run-kilo-session +356 -14
  21. package/tools/bin/agent-project-run-ollama-session +658 -0
  22. package/tools/bin/agent-project-run-openclaw-session +37 -25
  23. package/tools/bin/agent-project-run-opencode-session +364 -14
  24. package/tools/bin/agent-project-run-pi-session +479 -0
  25. package/tools/bin/agent-project-worker-status +11 -8
  26. package/tools/bin/cleanup-worktree.sh +6 -1
  27. package/tools/bin/flow-config-lib.sh +196 -3
  28. package/tools/bin/flow-resident-worker-lib.sh +120 -2
  29. package/tools/bin/flow-shell-lib.sh +29 -2
  30. package/tools/bin/heartbeat-loop-cache-lib.sh +164 -0
  31. package/tools/bin/heartbeat-loop-counting-lib.sh +306 -0
  32. package/tools/bin/heartbeat-loop-pr-strategy-lib.sh +199 -0
  33. package/tools/bin/heartbeat-loop-scheduling-lib.sh +506 -0
  34. package/tools/bin/heartbeat-loop-worker-lib.sh +319 -0
  35. package/tools/bin/heartbeat-recovery-preflight.sh +13 -1
  36. package/tools/bin/heartbeat-safe-auto.sh +119 -20
  37. package/tools/bin/install-project-launchd.sh +19 -2
  38. package/tools/bin/prepare-worktree.sh +4 -4
  39. package/tools/bin/profile-activate.sh +2 -2
  40. package/tools/bin/profile-adopt.sh +2 -2
  41. package/tools/bin/project-init.sh +1 -1
  42. package/tools/bin/project-launchd-bootstrap.sh +11 -8
  43. package/tools/bin/project-runtimectl.sh +90 -7
  44. package/tools/bin/provider-cooldown-state.sh +14 -14
  45. package/tools/bin/reconcile-bootstrap-lib.sh +113 -0
  46. package/tools/bin/render-flow-config.sh +30 -33
  47. package/tools/bin/resident-issue-controller-lib.sh +448 -0
  48. package/tools/bin/resident-issue-queue-status.py +35 -0
  49. package/tools/bin/run-codex-task.sh +53 -4
  50. package/tools/bin/scaffold-profile.sh +18 -3
  51. package/tools/bin/start-issue-worker.sh +1 -1
  52. package/tools/bin/start-pr-fix-worker.sh +30 -0
  53. package/tools/bin/start-pr-review-worker.sh +31 -0
  54. package/tools/bin/start-resident-issue-loop.sh +27 -438
  55. package/tools/bin/sync-agent-repo.sh +2 -2
  56. package/tools/bin/sync-dependency-baseline.sh +3 -3
  57. package/tools/bin/sync-shared-agent-home.sh +4 -1
  58. package/tools/dashboard/app.js +7 -0
  59. package/tools/dashboard/dashboard_snapshot.py +13 -29
  60. package/tools/templates/pr-fix-template.md +3 -7
  61. package/tools/templates/pr-merge-repair-template.md +3 -7
  62. package/tools/templates/pr-review-template.md +2 -1
  63. package/SKILL.md +0 -149
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env bash
2
+ # heartbeat-loop-worker-lib.sh — tmux session queries, worker enumeration, and cache helpers
3
+
4
+ all_tmux_sessions() {
5
+ ensure_tmux_sessions_cache
6
+ printf '%s\n' "$tmux_sessions_cache"
7
+ }
8
+
9
+ session_matches_prefix() {
10
+ local session="${1:?session required}"
11
+ [[ "$session" == "${issue_prefix}"* || "$session" == "${pr_prefix}"* ]]
12
+ }
13
+
14
+ session_runner_state() {
15
+ local session="${1:?session required}"
16
+ local runner_state_file="${runs_root}/${session}/runner.env"
17
+ if [[ ! -f "$runner_state_file" ]]; then
18
+ return 1
19
+ fi
20
+ awk -F= '/^RUNNER_STATE=/{print $2; exit}' "$runner_state_file"
21
+ }
22
+
23
+ session_is_auth_waiting() {
24
+ local session="${1:?session required}"
25
+ local runner_state=""
26
+ runner_state="$(session_runner_state "$session" || true)"
27
+ [[ "$runner_state" == "waiting-auth-refresh" || "$runner_state" == "switching-account" ]]
28
+ }
29
+
30
+ all_running_workers() {
31
+ ensure_all_running_workers_cache
32
+ printf '%s\n' "$all_running_workers_cache"
33
+ }
34
+
35
+ running_issue_workers() {
36
+ ensure_running_issue_workers_cache
37
+ printf '%s\n' "$running_issue_workers_cache"
38
+ }
39
+
40
+ running_pr_workers() {
41
+ ensure_running_pr_workers_cache
42
+ printf '%s\n' "$running_pr_workers_cache"
43
+ }
44
+
45
+ auth_wait_workers() {
46
+ ensure_auth_wait_workers_cache
47
+ printf '%s\n' "$auth_wait_workers_cache"
48
+ }
49
+
50
+ pending_launch_pid() {
51
+ local kind="${1:?kind required}"
52
+ local item_id="${2:?item id required}"
53
+ local pending_file pid
54
+
55
+ pending_file="${pending_launch_dir}/${kind}-${item_id}.pid"
56
+ if [[ ! -f "$pending_file" ]]; then
57
+ return 1
58
+ fi
59
+
60
+ pid="$(tr -d '[:space:]' <"$pending_file" 2>/dev/null || true)"
61
+ if [[ -z "$pid" ]]; then
62
+ rm -f "$pending_file"
63
+ return 1
64
+ fi
65
+
66
+ if kill -0 "$pid" 2>/dev/null; then
67
+ printf '%s\n' "$pid"
68
+ return 0
69
+ fi
70
+
71
+ rm -f "$pending_file"
72
+ return 1
73
+ }
74
+
75
+ pending_issue_launch_active() {
76
+ local issue_id="${1:?issue id required}"
77
+ if tmux has-session -t "${issue_prefix}${issue_id}" 2>/dev/null; then
78
+ rm -f "${pending_launch_dir}/issue-${issue_id}.pid" 2>/dev/null || true
79
+ return 1
80
+ fi
81
+ pending_launch_pid issue "$issue_id" >/dev/null
82
+ }
83
+
84
+ pending_pr_launch_active() {
85
+ local pr_id="${1:?pr id required}"
86
+ if tmux has-session -t "${pr_prefix}${pr_id}" 2>/dev/null; then
87
+ rm -f "${pending_launch_dir}/pr-${pr_id}.pid" 2>/dev/null || true
88
+ return 1
89
+ fi
90
+ pending_launch_pid pr "$pr_id" >/dev/null
91
+ }
92
+
93
+ pending_issue_launch_counts_toward_capacity() {
94
+ local issue_id="${1:?issue id required}"
95
+ local controller_state=""
96
+
97
+ if ! pending_issue_launch_active "${issue_id}"; then
98
+ return 1
99
+ fi
100
+
101
+ controller_state="$(resident_issue_controller_state "${issue_id}" || true)"
102
+ if [[ -n "${controller_state}" ]]; then
103
+ case "${controller_state}" in
104
+ idle|sleeping|waiting-due|waiting-open-pr|waiting-provider)
105
+ return 1
106
+ ;;
107
+ esac
108
+ fi
109
+
110
+ return 0
111
+ }
112
+
113
+ resident_issue_controller_file() {
114
+ local issue_id="${1:?issue id required}"
115
+ printf '%s/resident-workers/issues/%s/controller.env\n' "${state_root}" "${issue_id}"
116
+ }
117
+
118
+ resident_issue_controller_state() {
119
+ local issue_id="${1:?issue id required}"
120
+ local controller_file state=""
121
+
122
+ controller_file="$(resident_issue_controller_file "$issue_id")"
123
+ [[ -f "${controller_file}" ]] || return 1
124
+
125
+ state="$(awk -F= '/^CONTROLLER_STATE=/{print $2; exit}' "${controller_file}" 2>/dev/null | tr -d '"' || true)"
126
+ [[ -n "${state}" ]] || return 1
127
+ printf '%s\n' "${state}"
128
+ }
129
+
130
+ issue_id_from_session() {
131
+ local session="${1:?session required}"
132
+ local issue_id=""
133
+ if [[ "$session" == "${issue_prefix}"* ]]; then
134
+ issue_id="${session#${issue_prefix}}"
135
+ fi
136
+ if [[ "$issue_id" =~ ^[0-9]+$ ]]; then
137
+ printf '%s\n' "$issue_id"
138
+ return 0
139
+ fi
140
+ return 1
141
+ }
142
+
143
+ pr_id_from_session() {
144
+ local session="${1:?session required}"
145
+ local pr_id=""
146
+ if [[ "$session" == "${pr_prefix}"* ]]; then
147
+ pr_id="${session#${pr_prefix}}"
148
+ fi
149
+ if [[ "$pr_id" =~ ^[0-9]+$ ]]; then
150
+ printf '%s\n' "$pr_id"
151
+ return 0
152
+ fi
153
+ return 1
154
+ }
155
+
156
+ worker_count() {
157
+ local workers="${1:-}"
158
+ if [[ -z "$workers" ]]; then
159
+ printf '0\n'
160
+ return
161
+ fi
162
+ printf '%s\n' "$workers" | sed '/^$/d' | wc -l | tr -d ' '
163
+ }
164
+
165
+ retry_ready() {
166
+ local kind="${1:?kind required}"
167
+ local item_id="${2:?item id required}"
168
+ local retry_out ready
169
+
170
+ retry_out="$(
171
+ "${shared_agent_home}/tools/bin/agent-project-retry-state" \
172
+ --state-root "$state_root" \
173
+ --kind "$kind" \
174
+ --item-id "$item_id" \
175
+ --action get
176
+ )"
177
+ ready="$(awk -F= '/^READY=/{print $2}' <<<"$retry_out")"
178
+ [[ "$ready" == "yes" ]]
179
+ }
180
+
181
+ provider_cooldown_state() {
182
+ "${shared_agent_home}/tools/bin/provider-cooldown-state.sh" get
183
+ }
184
+
185
+ completed_workers() {
186
+ ensure_completed_workers_cache
187
+ printf '%s\n' "$completed_workers_cache"
188
+ }
189
+
190
+ reconciled_marker_matches_run() {
191
+ local run_dir="${1:?run dir required}"
192
+ local marker_file="${run_dir}/reconciled.ok"
193
+ local run_env="${run_dir}/run.env"
194
+ local marker_started_at=""
195
+ local run_started_at=""
196
+
197
+ [[ -f "${marker_file}" && -f "${run_env}" ]] || return 1
198
+
199
+ marker_started_at="$(awk -F= '/^STARTED_AT=/{print $2}' "${marker_file}" 2>/dev/null | tr -d '"' | tail -n 1 || true)"
200
+ run_started_at="$(awk -F= '/^STARTED_AT=/{print $2}' "${run_env}" 2>/dev/null | tr -d '"' | tail -n 1 || true)"
201
+
202
+ [[ -n "${marker_started_at}" && -n "${run_started_at}" && "${marker_started_at}" == "${run_started_at}" ]]
203
+ }
204
+
205
+ ensure_tmux_sessions_cache() {
206
+ if [[ "$tmux_sessions_cache_loaded" != "yes" ]]; then
207
+ tmux_sessions_cache="$(tmux list-sessions -F '#S' 2>/dev/null || true)"
208
+ tmux_sessions_cache_loaded="yes"
209
+ fi
210
+ }
211
+
212
+ ensure_all_running_workers_cache() {
213
+ local session
214
+ if [[ "$all_running_workers_cache_loaded" == "yes" ]]; then
215
+ return 0
216
+ fi
217
+ ensure_tmux_sessions_cache
218
+ all_running_workers_cache=""
219
+ while IFS= read -r session; do
220
+ [[ -n "$session" ]] || continue
221
+ if session_matches_prefix "$session"; then
222
+ all_running_workers_cache+="${session}"$'\n'
223
+ fi
224
+ done <<<"$tmux_sessions_cache"
225
+ all_running_workers_cache="${all_running_workers_cache%$'\n'}"
226
+ all_running_workers_cache_loaded="yes"
227
+ }
228
+
229
+ ensure_auth_wait_workers_cache() {
230
+ local session
231
+ if [[ "$auth_wait_workers_cache_loaded" == "yes" ]]; then
232
+ return 0
233
+ fi
234
+ ensure_tmux_sessions_cache
235
+ auth_wait_workers_cache=""
236
+ while IFS= read -r session; do
237
+ [[ -n "$session" ]] || continue
238
+ session_matches_prefix "$session" || continue
239
+ if session_is_auth_waiting "$session"; then
240
+ auth_wait_workers_cache+="${session}"$'\n'
241
+ fi
242
+ done <<<"$tmux_sessions_cache"
243
+ auth_wait_workers_cache="${auth_wait_workers_cache%$'\n'}"
244
+ auth_wait_workers_cache_loaded="yes"
245
+ }
246
+
247
+ ensure_running_issue_workers_cache() {
248
+ local session
249
+ if [[ "$running_issue_workers_cache_loaded" == "yes" ]]; then
250
+ return 0
251
+ fi
252
+ ensure_tmux_sessions_cache
253
+ running_issue_workers_cache=""
254
+ while IFS= read -r session; do
255
+ [[ -n "$session" ]] || continue
256
+ if [[ "$session" == "${issue_prefix}"* ]]; then
257
+ if session_is_auth_waiting "$session"; then
258
+ continue
259
+ fi
260
+ running_issue_workers_cache+="${session}"$'\n'
261
+ fi
262
+ done <<<"$tmux_sessions_cache"
263
+ running_issue_workers_cache="${running_issue_workers_cache%$'\n'}"
264
+ running_issue_workers_cache_loaded="yes"
265
+ }
266
+
267
+ ensure_running_pr_workers_cache() {
268
+ local session
269
+ if [[ "$running_pr_workers_cache_loaded" == "yes" ]]; then
270
+ return 0
271
+ fi
272
+ ensure_tmux_sessions_cache
273
+ running_pr_workers_cache=""
274
+ while IFS= read -r session; do
275
+ [[ -n "$session" ]] || continue
276
+ if [[ "$session" == "${pr_prefix}"* ]]; then
277
+ if session_is_auth_waiting "$session"; then
278
+ continue
279
+ fi
280
+ running_pr_workers_cache+="${session}"$'\n'
281
+ fi
282
+ done <<<"$tmux_sessions_cache"
283
+ running_pr_workers_cache="${running_pr_workers_cache%$'\n'}"
284
+ running_pr_workers_cache_loaded="yes"
285
+ }
286
+
287
+ ensure_completed_workers_cache() {
288
+ local dir session issue_id status_line status
289
+ if [[ "$completed_workers_cache_loaded" == "yes" ]]; then
290
+ return 0
291
+ fi
292
+ completed_workers_cache=""
293
+ for dir in "$runs_root"/*; do
294
+ [[ -d "$dir" ]] || continue
295
+ session="${dir##*/}"
296
+ session_matches_prefix "$session" || continue
297
+ if reconciled_marker_matches_run "$dir"; then
298
+ continue
299
+ fi
300
+ if [[ "$session" == "${issue_prefix}"* ]]; then
301
+ issue_id="$(issue_id_from_session "$session" || true)"
302
+ if [[ -n "${issue_id}" ]] && pending_issue_launch_active "${issue_id}"; then
303
+ continue
304
+ fi
305
+ fi
306
+ status_line="$(
307
+ "${shared_agent_home}/tools/bin/agent-project-worker-status" \
308
+ --runs-root "$runs_root" \
309
+ --session "$session" \
310
+ | awk -F= '/^STATUS=/{print $2}' || true
311
+ )"
312
+ status="${status_line:-UNKNOWN}"
313
+ if [[ "$status" == "SUCCEEDED" || "$status" == "FAILED" ]]; then
314
+ completed_workers_cache+="${session}"$'\n'
315
+ fi
316
+ done
317
+ completed_workers_cache="${completed_workers_cache%$'\n'}"
318
+ completed_workers_cache_loaded="yes"
319
+ }
@@ -2,14 +2,25 @@
2
2
  set -euo pipefail
3
3
 
4
4
  FLOW_TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ FLOW_SHELL_LIB="${FLOW_TOOLS_DIR}/flow-shell-lib.sh"
5
6
  TEST_DIR="${FLOW_TOOLS_DIR%/bin}/tests"
6
7
  TEST_TIMEOUT_SECONDS="${F_LOSNING_HEARTBEAT_PREFLIGHT_TEST_TIMEOUT_SECONDS:-120}"
8
+ python_bin=""
9
+
10
+ # shellcheck source=/dev/null
11
+ source "${FLOW_SHELL_LIB}"
12
+
13
+ python_bin="$(flow_resolve_python_bin || true)"
14
+ if [[ -z "${python_bin}" || ! -x "${python_bin}" ]]; then
15
+ echo "unable to resolve a runnable python interpreter for heartbeat-recovery-preflight.sh" >&2
16
+ exit 1
17
+ fi
7
18
 
8
19
  run_with_timeout() {
9
20
  local timeout_seconds="${1:?timeout seconds required}"
10
21
  shift
11
22
 
12
- /opt/homebrew/bin/python3 - "$timeout_seconds" "$@" <<'PY'
23
+ "${python_bin}" - "$timeout_seconds" "$@" <<'PY'
13
24
  import os
14
25
  import signal
15
26
  import subprocess
@@ -80,6 +91,7 @@ run_preflight_test "heartbeat-no-tmux-sessions" "${TEST_DIR}/test-heartbeat-safe
80
91
  run_preflight_test "heartbeat-static-capacity-without-quota-cache" "${TEST_DIR}/test-heartbeat-safe-auto-static-capacity-without-quota-cache.sh"
81
92
  run_preflight_test "heartbeat-openclaw-skips-codex-quota" "${TEST_DIR}/test-heartbeat-safe-auto-openclaw-skips-codex-quota.sh"
82
93
  run_preflight_test "heartbeat-empty-schedule-label-sync" "${TEST_DIR}/test-heartbeat-sync-issue-labels-empty-schedule.sh"
94
+ run_preflight_test "heartbeat-open-issue-terminal-sync" "${TEST_DIR}/test-heartbeat-sync-open-agent-issues-terminal-clears-running.sh"
83
95
  run_preflight_test "heartbeat-open-pr-terminal-sync" "${TEST_DIR}/test-heartbeat-sync-open-agent-prs-terminal-clears-running.sh"
84
96
  run_preflight_test "heartbeat-pr-launch-dedup" "${TEST_DIR}/test-heartbeat-loop-pr-launch-dedup.sh"
85
97
  run_preflight_test "heartbeat-auth-wait-capacity" "${TEST_DIR}/test-heartbeat-loop-auth-wait-does-not-consume-capacity.sh"
@@ -17,6 +17,8 @@ flow_export_execution_env "${CONFIG_YAML}"
17
17
  AGENT_ROOT="$(flow_resolve_agent_root "${CONFIG_YAML}")"
18
18
  RUNS_ROOT="$(flow_resolve_runs_root "${CONFIG_YAML}")"
19
19
  STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
20
+ HISTORY_ROOT="$(flow_resolve_history_root "${CONFIG_YAML}")"
21
+ WORKTREE_ROOT="$(flow_resolve_worktree_root "${CONFIG_YAML}")"
20
22
  MEMORY_DIR="${ACP_MEMORY_DIR:-${F_LOSNING_MEMORY_DIR:-${AGENT_CONTROL_PLANE_WORKSPACE:-$HOME/.agent-runtime/control-plane/workspace}/memory}}"
21
23
  REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
22
24
  MAX_CONCURRENT_WORKERS="${ACP_MAX_CONCURRENT_WORKERS:-${F_LOSNING_MAX_CONCURRENT_WORKERS:-20}}"
@@ -29,13 +31,14 @@ MAX_CONCURRENT_BLOCKED_RECOVERY_ISSUE_WORKERS="${ACP_MAX_CONCURRENT_BLOCKED_RECO
29
31
  BLOCKED_RECOVERY_COOLDOWN_SECONDS="${ACP_BLOCKED_RECOVERY_COOLDOWN_SECONDS:-${F_LOSNING_BLOCKED_RECOVERY_COOLDOWN_SECONDS:-900}}"
30
32
  MAX_OPEN_AGENT_PRS_FOR_RECURRING="${ACP_MAX_OPEN_AGENT_PRS_FOR_RECURRING:-${F_LOSNING_MAX_OPEN_AGENT_PRS_FOR_RECURRING:-12}}"
31
33
  MAX_LAUNCHES_PER_HEARTBEAT="${ACP_MAX_LAUNCHES_PER_HEARTBEAT:-${F_LOSNING_MAX_LAUNCHES_PER_HEARTBEAT:-$MAX_CONCURRENT_WORKERS}}"
32
- CODING_WORKER="${ACP_CODING_WORKER:-${F_LOSNING_CODING_WORKER:-codex}}"
34
+ CODING_WORKER="${ACP_CODING_WORKER:-codex}"
33
35
  # The catchup and shared heartbeat passes can legitimately take a few minutes
34
36
  # once they reconcile stale sessions, sync labels, and launch multiple workers.
35
37
  CATCHUP_TIMEOUT_SECONDS="${ACP_CATCHUP_TIMEOUT_SECONDS:-${F_LOSNING_CATCHUP_TIMEOUT_SECONDS:-180}}"
36
38
  HEARTBEAT_LOOP_TIMEOUT_SECONDS="${ACP_HEARTBEAT_LOOP_TIMEOUT_SECONDS:-${F_LOSNING_HEARTBEAT_LOOP_TIMEOUT_SECONDS:-720}}"
37
39
  CONFIG_YAML="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
38
40
  SHARED_AGENT_HOME="$(resolve_shared_agent_home "${FLOW_SKILL_DIR}")"
41
+ RUNTIME_HOME_DIR="$(resolve_runtime_home)"
39
42
  FLOW_TOOLS_DIR="${FLOW_SKILL_DIR}/tools/bin"
40
43
  ISSUE_SESSION_PREFIX="$(flow_resolve_issue_session_prefix "${CONFIG_YAML}")"
41
44
  PR_SESSION_PREFIX="$(flow_resolve_pr_session_prefix "${CONFIG_YAML}")"
@@ -67,8 +70,17 @@ AGENT_WORKTREE_AUDIT_TIMEOUT_SECONDS="${ACP_AGENT_WORKTREE_AUDIT_TIMEOUT_SECONDS
67
70
  LOCK_DIR="${STATE_ROOT}/heartbeat-loop.lock"
68
71
  PID_FILE="${LOCK_DIR}/pid"
69
72
  SHARED_LOOP_PID_FILE="${STATE_ROOT}/shared-heartbeat-loop.pid"
73
+ SHARED_LOOP_STATUS_FILE="${STATE_ROOT}/shared-heartbeat-loop.env"
70
74
  QUOTA_LOCK_DIR="${STATE_ROOT}/quota-preflight.lock"
71
75
  QUOTA_PID_FILE="${QUOTA_LOCK_DIR}/pid"
76
+ python_bin="$(flow_resolve_python_bin || true)"
77
+
78
+ mkdir -p "${AGENT_ROOT}" "${RUNS_ROOT}" "${STATE_ROOT}" "${HISTORY_ROOT}" "${WORKTREE_ROOT}" "${MEMORY_DIR}"
79
+
80
+ if [[ -z "${python_bin}" || ! -x "${python_bin}" ]]; then
81
+ echo "unable to resolve a runnable python interpreter for heartbeat-safe-auto.sh" >&2
82
+ exit 1
83
+ fi
72
84
 
73
85
  acquire_lock() {
74
86
  mkdir -p "${STATE_ROOT}"
@@ -124,11 +136,34 @@ release_quota_lock() {
124
136
  rm -rf "${QUOTA_LOCK_DIR}"
125
137
  }
126
138
 
139
+ write_shared_loop_status() {
140
+ local state="${1:-}"
141
+ local status="${2:-}"
142
+ local timestamp=""
143
+ local tmp_file=""
144
+
145
+ timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
146
+ mkdir -p "${STATE_ROOT}"
147
+ tmp_file="$(mktemp)"
148
+ if [[ -f "${SHARED_LOOP_STATUS_FILE}" ]]; then
149
+ grep -Ev '^(STATE|STATUS|STARTED_AT|UPDATED_AT)=' "${SHARED_LOOP_STATUS_FILE}" >"${tmp_file}" || true
150
+ fi
151
+ printf 'STATE=%s\n' "${state}" >>"${tmp_file}"
152
+ if [[ -n "${status}" ]]; then
153
+ printf 'STATUS=%s\n' "${status}" >>"${tmp_file}"
154
+ fi
155
+ if [[ "${state}" == "running" ]]; then
156
+ printf 'STARTED_AT=%s\n' "${timestamp}" >>"${tmp_file}"
157
+ fi
158
+ printf 'UPDATED_AT=%s\n' "${timestamp}" >>"${tmp_file}"
159
+ mv "${tmp_file}" "${SHARED_LOOP_STATUS_FILE}"
160
+ }
161
+
127
162
  run_with_timeout() {
128
163
  local timeout_seconds="${1:?timeout seconds required}"
129
164
  shift
130
165
 
131
- /opt/homebrew/bin/python3 - "${timeout_seconds}" "$@" <<'PY'
166
+ "${python_bin}" - "${timeout_seconds}" "$@" <<'PY'
132
167
  import os
133
168
  from pathlib import Path
134
169
  import signal
@@ -302,7 +337,7 @@ EFFECTIVE_QUOTA_POOLS=""
302
337
 
303
338
  local quota_cache_age_seconds=""
304
339
  quota_cache_age_seconds="$(
305
- /opt/homebrew/bin/python3 - "${CODEX_QUOTA_FULL_CACHE_FILE}" <<'PY' 2>/dev/null || true
340
+ "${python_bin}" - "${CODEX_QUOTA_FULL_CACHE_FILE}" <<'PY' 2>/dev/null || true
306
341
  import os
307
342
  import sys
308
343
  import time
@@ -478,7 +513,11 @@ run_codex_quota_preflight
478
513
  # Sync skill files to runtime-home if source has changed since last sync.
479
514
  # This ensures start-issue-worker.sh and other scripts are always up to date.
480
515
  if [[ -x "${FLOW_TOOLS_DIR}/ensure-runtime-sync.sh" ]]; then
481
- "${FLOW_TOOLS_DIR}/ensure-runtime-sync.sh" --quiet 2>/dev/null || true
516
+ if [[ "${FLOW_SKILL_DIR}" == "${RUNTIME_HOME_DIR}"/* ]]; then
517
+ printf 'RUNTIME_SYNC_SKIPPED=active-runtime-home\n'
518
+ else
519
+ "${FLOW_TOOLS_DIR}/ensure-runtime-sync.sh" --quiet 2>/dev/null || true
520
+ fi
482
521
  fi
483
522
 
484
523
  acquire_lock
@@ -530,6 +569,7 @@ fi
530
569
  derive_dynamic_limits
531
570
 
532
571
  printf '[%s] shared heartbeat loop start\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
572
+ write_shared_loop_status "running" ""
533
573
  if ACP_TIMEOUT_CHILD_PID_FILE="${SHARED_LOOP_PID_FILE}" \
534
574
  F_LOSNING_TIMEOUT_CHILD_PID_FILE="${SHARED_LOOP_PID_FILE}" \
535
575
  run_with_timeout "${HEARTBEAT_LOOP_TIMEOUT_SECONDS}" \
@@ -563,9 +603,11 @@ printf '[%s] shared heartbeat loop start\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
563
603
  --heavy-running-label "E2E_ISSUE" \
564
604
  --heavy-deferred-key "E2E_DEFERRED" \
565
605
  --heavy-deferred-message "E2E-heavy issues remain queued until the single e2e slot is free."; then
606
+ write_shared_loop_status "idle" "0"
566
607
  printf '[%s] shared heartbeat loop end status=0\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
567
608
  else
568
609
  loop_status=$?
610
+ write_shared_loop_status "idle" "${loop_status}"
569
611
  if [[ "${loop_status}" -eq 124 ]]; then
570
612
  printf 'HEARTBEAT_LOOP_TIMEOUT=yes\n'
571
613
  fi
@@ -573,21 +615,78 @@ else
573
615
  exit "${loop_status}"
574
616
  fi
575
617
 
576
- printf '[%s] merged-pr catchup start\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
577
- if run_with_timeout "${CATCHUP_TIMEOUT_SECONDS}" \
578
- env \
579
- ACP_RUNS_ROOT="$RUNS_ROOT" \
580
- F_LOSNING_RUNS_ROOT="$RUNS_ROOT" \
581
- bash "${FLOW_TOOLS_DIR}/agent-project-catch-up-merged-prs" \
582
- --repo-slug "$REPO_SLUG" \
583
- --state-root "$STATE_ROOT" \
584
- --hook-file "$HOOK_FILE" \
585
- --limit 100; then
586
- printf '[%s] merged-pr catchup end status=0\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
587
- else
588
- catchup_status=$?
589
- if [[ "${catchup_status}" -eq 124 ]]; then
590
- printf 'CATCHUP_TIMEOUT=yes\n'
618
+ # ── Throttled catch-up passes ──────────────────────────────────────────────────
619
+ # These scripts fetch merged/closed PRs and linked issues which change rarely.
620
+ # Run them at most once every CATCHUP_INTERVAL_SECONDS (default 300 = 5 min)
621
+ # to avoid burning API quota on every heartbeat cycle.
622
+ CATCHUP_INTERVAL_SECONDS="${ACP_CATCHUP_INTERVAL_SECONDS:-${F_LOSNING_CATCHUP_INTERVAL_SECONDS:-300}}"
623
+ CATCHUP_STAMP_FILE="${STATE_ROOT}/last-catchup-timestamp"
624
+ _catchup_now="$(date +%s)"
625
+ _catchup_last="0"
626
+ if [[ -f "${CATCHUP_STAMP_FILE}" ]]; then
627
+ _catchup_last="$(cat "${CATCHUP_STAMP_FILE}" 2>/dev/null || echo 0)"
628
+ fi
629
+ _catchup_age=$(( _catchup_now - _catchup_last ))
630
+
631
+ if [[ "${_catchup_age}" -ge "${CATCHUP_INTERVAL_SECONDS}" ]]; then
632
+ printf '[%s] merged-pr catchup start\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
633
+ if run_with_timeout "${CATCHUP_TIMEOUT_SECONDS}" \
634
+ env \
635
+ ACP_RUNS_ROOT="$RUNS_ROOT" \
636
+ F_LOSNING_RUNS_ROOT="$RUNS_ROOT" \
637
+ bash "${FLOW_TOOLS_DIR}/agent-project-catch-up-merged-prs" \
638
+ --repo-slug "$REPO_SLUG" \
639
+ --state-root "$STATE_ROOT" \
640
+ --hook-file "$HOOK_FILE" \
641
+ --limit 100; then
642
+ printf '[%s] merged-pr catchup end status=0\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
643
+ else
644
+ catchup_status=$?
645
+ if [[ "${catchup_status}" -eq 124 ]]; then
646
+ printf 'CATCHUP_TIMEOUT=yes\n'
647
+ fi
648
+ printf '[%s] merged-pr catchup end status=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "${catchup_status}"
649
+ fi
650
+
651
+ printf '[%s] linked-pr issue catchup start\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
652
+ if run_with_timeout "${CATCHUP_TIMEOUT_SECONDS}" \
653
+ env \
654
+ ACP_RUNS_ROOT="$RUNS_ROOT" \
655
+ F_LOSNING_RUNS_ROOT="$RUNS_ROOT" \
656
+ bash "${FLOW_TOOLS_DIR}/agent-project-catch-up-issue-pr-links" \
657
+ --repo-slug "$REPO_SLUG" \
658
+ --state-root "$STATE_ROOT" \
659
+ --hook-file "$HOOK_FILE" \
660
+ --limit 100; then
661
+ printf '[%s] linked-pr issue catchup end status=0\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
662
+ else
663
+ linked_issue_catchup_status=$?
664
+ if [[ "${linked_issue_catchup_status}" -eq 124 ]]; then
665
+ printf 'LINKED_ISSUE_CATCHUP_TIMEOUT=yes\n'
666
+ fi
667
+ printf '[%s] linked-pr issue catchup end status=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "${linked_issue_catchup_status}"
668
+ fi
669
+
670
+ printf '[%s] scheduled-issue retry catchup start\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
671
+ if run_with_timeout "${CATCHUP_TIMEOUT_SECONDS}" \
672
+ env \
673
+ ACP_RUNS_ROOT="$RUNS_ROOT" \
674
+ F_LOSNING_RUNS_ROOT="$RUNS_ROOT" \
675
+ bash "${FLOW_TOOLS_DIR}/agent-project-catch-up-scheduled-issue-retries" \
676
+ --repo-slug "$REPO_SLUG" \
677
+ --state-root "$STATE_ROOT" \
678
+ --hook-file "$HOOK_FILE" \
679
+ --limit 100; then
680
+ printf '[%s] scheduled-issue retry catchup end status=0\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
681
+ else
682
+ scheduled_issue_catchup_status=$?
683
+ if [[ "${scheduled_issue_catchup_status}" -eq 124 ]]; then
684
+ printf 'SCHEDULED_ISSUE_CATCHUP_TIMEOUT=yes\n'
685
+ fi
686
+ printf '[%s] scheduled-issue retry catchup end status=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "${scheduled_issue_catchup_status}"
591
687
  fi
592
- printf '[%s] merged-pr catchup end status=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "${catchup_status}"
688
+
689
+ printf '%s' "${_catchup_now}" >"${CATCHUP_STAMP_FILE}"
690
+ else
691
+ printf '[%s] catchup skipped (age=%ss, interval=%ss)\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "${_catchup_age}" "${CATCHUP_INTERVAL_SECONDS}"
593
692
  fi
@@ -54,7 +54,7 @@ build_launchd_base_path() {
54
54
  local tool_name=""
55
55
  local tool_dir=""
56
56
 
57
- for tool_name in node gh git python3 openclaw codex claude; do
57
+ for tool_name in node gh git python3 openclaw codex claude ollama pi crush kilo; do
58
58
  tool_dir="$(resolved_tool_dir "${tool_name}" || true)"
59
59
  append_path_dir path_value "${tool_dir}"
60
60
  done
@@ -112,7 +112,14 @@ fi
112
112
  PROFILE_ID="$(flow_resolve_adapter_id "${CONFIG_YAML}")"
113
113
  profile_slug="$(printf '%s' "${PROFILE_ID}" | tr -c 'A-Za-z0-9._-' '-')"
114
114
  HOME_DIR="${ACP_PROJECT_RUNTIME_HOME_DIR:-${HOME:-}}"
115
- SOURCE_HOME="${ACP_PROJECT_RUNTIME_SOURCE_HOME:-$(cd "${FLOW_SKILL_DIR}/../../.." && pwd)}"
115
+ SOURCE_HOME="${ACP_PROJECT_RUNTIME_SOURCE_HOME:-}"
116
+ if [[ -z "${SOURCE_HOME}" ]]; then
117
+ if flow_is_skill_root "${FLOW_SKILL_DIR}"; then
118
+ SOURCE_HOME="${FLOW_SKILL_DIR}"
119
+ else
120
+ SOURCE_HOME="$(cd "${FLOW_SKILL_DIR}/../../.." && pwd)"
121
+ fi
122
+ fi
116
123
  RUNTIME_HOME="${ACP_PROJECT_RUNTIME_RUNTIME_HOME:-${HOME_DIR}/.agent-runtime/runtime-home}"
117
124
  WORKSPACE_DIR="${ACP_PROJECT_RUNTIME_WORKSPACE_DIR:-${HOME_DIR}/.agent-runtime/control-plane/workspace}"
118
125
  PROFILE_REGISTRY_ROOT="${ACP_PROJECT_RUNTIME_PROFILE_REGISTRY_ROOT:-${ACP_PROFILE_REGISTRY_ROOT:-${HOME_DIR}/.agent-runtime/control-plane/profiles}}"
@@ -120,6 +127,7 @@ LAUNCH_AGENTS_DIR="${ACP_PROJECT_RUNTIME_LAUNCH_AGENTS_DIR:-${HOME_DIR}/Library/
120
127
  LOG_DIR="${ACP_PROJECT_RUNTIME_LOG_DIR:-${HOME_DIR}/.agent-runtime/logs}"
121
128
  LABEL="${label_override:-${ACP_PROJECT_RUNTIME_LAUNCHD_LABEL:-ai.agent.project.${profile_slug}}}"
122
129
  BASE_PATH="$(build_launchd_base_path)"
130
+ CODING_WORKER_OVERRIDE="${ACP_PROJECT_RUNTIME_CODING_WORKER:-${ACP_CODING_WORKER:-}}"
123
131
  SYNC_SCRIPT="${ACP_PROJECT_RUNTIME_SYNC_SCRIPT:-${FLOW_SKILL_DIR}/tools/bin/sync-shared-agent-home.sh}"
124
132
  BOOTSTRAP_SCRIPT="${ACP_PROJECT_RUNTIME_BOOTSTRAP_SCRIPT:-${FLOW_SKILL_DIR}/tools/bin/project-launchd-bootstrap.sh}"
125
133
  SUPERVISOR_SCRIPT="${ACP_PROJECT_RUNTIME_SUPERVISOR_SCRIPT:-${FLOW_SKILL_DIR}/tools/bin/project-runtime-supervisor.sh}"
@@ -152,6 +160,15 @@ export AGENT_PROJECT_ID='${PROFILE_ID}'
152
160
  export ACP_PROJECT_RUNTIME_PATH='${BASE_PATH}'
153
161
  export ACP_PROJECT_RUNTIME_SYNC_SCRIPT='${SYNC_SCRIPT}'
154
162
  export ACP_PROFILE_REGISTRY_ROOT='${PROFILE_REGISTRY_ROOT}'
163
+ EOF
164
+
165
+ if [[ -n "${CODING_WORKER_OVERRIDE}" ]]; then
166
+ cat >>"${WRAPPER_PATH}" <<EOF
167
+ export ACP_CODING_WORKER='${CODING_WORKER_OVERRIDE}'
168
+ EOF
169
+ fi
170
+
171
+ cat >>"${WRAPPER_PATH}" <<EOF
155
172
  exec bash '${SUPERVISOR_SCRIPT}' --bootstrap-script '${BOOTSTRAP_SCRIPT}' --pid-file '${SUPERVISOR_PID_FILE}' --delay-seconds '${delay_seconds}' --interval-seconds '${interval_seconds}'
156
173
  EOF
157
174
  chmod +x "${WRAPPER_PATH}"
@@ -9,10 +9,10 @@ CONFIG_YAML="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
9
9
  AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
10
10
  CANONICAL_REPO_ROOT="$(flow_resolve_repo_root "${CONFIG_YAML}")"
11
11
  DEFAULT_DEPENDENCY_SOURCE_ROOT="$CANONICAL_REPO_ROOT"
12
- DEPENDENCY_SOURCE_ROOT="${ACP_DEPENDENCY_SOURCE_ROOT:-${F_LOSNING_DEPENDENCY_SOURCE_ROOT:-$DEFAULT_DEPENDENCY_SOURCE_ROOT}}"
13
- SYNC_DEPENDENCY_BASELINE_SCRIPT="${ACP_SYNC_DEPENDENCY_BASELINE_SCRIPT:-${F_LOSNING_SYNC_DEPENDENCY_BASELINE_SCRIPT:-${SCRIPT_DIR}/sync-dependency-baseline.sh}}"
14
- PACKAGE_MANAGER_BIN="${ACP_PACKAGE_MANAGER_BIN:-${F_LOSNING_PACKAGE_MANAGER_BIN:-pnpm}}"
15
- LOCAL_WORKSPACE_INSTALL="${ACP_WORKTREE_LOCAL_INSTALL:-${F_LOSNING_WORKTREE_LOCAL_INSTALL:-false}}"
12
+ DEPENDENCY_SOURCE_ROOT="${ACP_DEPENDENCY_SOURCE_ROOT:-$DEFAULT_DEPENDENCY_SOURCE_ROOT}"
13
+ SYNC_DEPENDENCY_BASELINE_SCRIPT="${ACP_SYNC_DEPENDENCY_BASELINE_SCRIPT:-${SCRIPT_DIR}/sync-dependency-baseline.sh}"
14
+ PACKAGE_MANAGER_BIN="${ACP_PACKAGE_MANAGER_BIN:-pnpm}"
15
+ LOCAL_WORKSPACE_INSTALL="${ACP_WORKTREE_LOCAL_INSTALL:-false}"
16
16
  WORKTREE="${1:?usage: prepare-worktree.sh WORKTREE}"
17
17
 
18
18
  realpath_safe() {
@@ -56,8 +56,8 @@ AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
56
56
  WORKTREE_ROOT="$(flow_resolve_worktree_root "${CONFIG_YAML}")"
57
57
  RUNS_ROOT="$(flow_resolve_runs_root "${CONFIG_YAML}")"
58
58
  STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
59
- CODING_WORKER="${ACP_CODING_WORKER:-${F_LOSNING_CODING_WORKER:-codex}}"
60
- ACTIVE_PROVIDER_POOL_NAME="${ACP_ACTIVE_PROVIDER_POOL_NAME:-${F_LOSNING_ACTIVE_PROVIDER_POOL_NAME:-}}"
59
+ CODING_WORKER="${ACP_CODING_WORKER:-codex}"
60
+ ACTIVE_PROVIDER_POOL_NAME="${ACP_ACTIVE_PROVIDER_POOL_NAME:-}"
61
61
 
62
62
  if [[ "${exports_only}" == "1" ]]; then
63
63
  printf 'export ACP_PROJECT_ID=%q
@@ -69,8 +69,8 @@ HISTORY_ROOT="$(flow_resolve_history_root "${CONFIG_YAML}")"
69
69
  WORKTREE_ROOT="$(flow_resolve_worktree_root "${CONFIG_YAML}")"
70
70
  RETAINED_REPO_ROOT="$(flow_resolve_retained_repo_root "${CONFIG_YAML}")"
71
71
  VSCODE_WORKSPACE_FILE="$(flow_resolve_vscode_workspace_file "${CONFIG_YAML}")"
72
- REMOTE_NAME="${ACP_REMOTE_NAME:-${F_LOSNING_REMOTE_NAME:-origin}}"
73
- SOURCE_REPO_ROOT="${source_repo_root_override:-${ACP_SOURCE_REPO_ROOT:-${F_LOSNING_SOURCE_REPO_ROOT:-${RETAINED_REPO_ROOT}}}}"
72
+ REMOTE_NAME="${ACP_REMOTE_NAME:-origin}"
73
+ SOURCE_REPO_ROOT="${source_repo_root_override:-${ACP_SOURCE_REPO_ROOT:-${RETAINED_REPO_ROOT}}}"
74
74
  PROFILE_LINK="${AGENT_ROOT}/control-plane.yaml"
75
75
  WORKSPACE_LINK="${AGENT_ROOT}/workspace.code-workspace"
76
76
  INSTALLED_PROFILE_DIR="$(dirname "${CONFIG_YAML}")"
@@ -42,7 +42,7 @@ Common options:
42
42
  --worktree-root <path> Worktree parent root
43
43
  --retained-repo-root <path> Retained/manual checkout root
44
44
  --vscode-workspace-file <path> VS Code workspace file
45
- --coding-worker <codex|openclaw|claude>
45
+ --coding-worker <codex|openclaw|claude|ollama|pi|opencode|kilo>
46
46
  --claude-model <model>
47
47
  --claude-permission-mode <mode>
48
48
  --claude-effort <level>