agent-control-plane 0.2.0 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +69 -19
  2. package/assets/workflow-catalog.json +1 -1
  3. package/bin/pr-risk.sh +22 -7
  4. package/bin/sync-pr-labels.sh +1 -1
  5. package/hooks/heartbeat-hooks.sh +125 -12
  6. package/hooks/issue-reconcile-hooks.sh +1 -1
  7. package/hooks/pr-reconcile-hooks.sh +1 -1
  8. package/npm/bin/agent-control-plane.js +296 -61
  9. package/package.json +11 -7
  10. package/tools/bin/agent-github-update-labels +36 -2
  11. package/tools/bin/agent-project-catch-up-merged-prs +4 -2
  12. package/tools/bin/agent-project-cleanup-session +49 -5
  13. package/tools/bin/agent-project-heartbeat-loop +119 -1471
  14. package/tools/bin/agent-project-publish-issue-pr +6 -3
  15. package/tools/bin/agent-project-reconcile-issue-session +78 -106
  16. package/tools/bin/agent-project-reconcile-pr-session +166 -143
  17. package/tools/bin/agent-project-retry-state +18 -7
  18. package/tools/bin/agent-project-run-claude-session +10 -0
  19. package/tools/bin/agent-project-run-codex-resilient +99 -14
  20. package/tools/bin/agent-project-run-codex-session +16 -5
  21. package/tools/bin/agent-project-run-kilo-session +10 -0
  22. package/tools/bin/agent-project-run-openclaw-session +10 -0
  23. package/tools/bin/agent-project-run-opencode-session +10 -0
  24. package/tools/bin/agent-project-sync-source-repo-main +163 -0
  25. package/tools/bin/agent-project-worker-status +10 -7
  26. package/tools/bin/cleanup-worktree.sh +6 -1
  27. package/tools/bin/flow-config-lib.sh +1257 -34
  28. package/tools/bin/flow-resident-worker-lib.sh +119 -1
  29. package/tools/bin/flow-shell-lib.sh +56 -0
  30. package/tools/bin/github-core-rate-limit-state.sh +77 -0
  31. package/tools/bin/github-write-outbox.sh +470 -0
  32. package/tools/bin/heartbeat-loop-cache-lib.sh +164 -0
  33. package/tools/bin/heartbeat-loop-counting-lib.sh +306 -0
  34. package/tools/bin/heartbeat-loop-pr-strategy-lib.sh +199 -0
  35. package/tools/bin/heartbeat-loop-scheduling-lib.sh +506 -0
  36. package/tools/bin/heartbeat-loop-worker-lib.sh +319 -0
  37. package/tools/bin/heartbeat-recovery-preflight.sh +12 -1
  38. package/tools/bin/heartbeat-safe-auto.sh +56 -3
  39. package/tools/bin/install-project-launchd.sh +17 -2
  40. package/tools/bin/project-init.sh +21 -1
  41. package/tools/bin/project-launchd-bootstrap.sh +16 -9
  42. package/tools/bin/project-runtimectl.sh +46 -2
  43. package/tools/bin/reconcile-bootstrap-lib.sh +113 -0
  44. package/tools/bin/resident-issue-controller-lib.sh +448 -0
  45. package/tools/bin/scaffold-profile.sh +61 -3
  46. package/tools/bin/start-pr-fix-worker.sh +47 -10
  47. package/tools/bin/start-resident-issue-loop.sh +28 -439
  48. package/tools/dashboard/app.js +37 -1
  49. package/tools/dashboard/dashboard_snapshot.py +65 -26
  50. package/tools/templates/pr-fix-template.md +3 -1
  51. package/tools/templates/pr-merge-repair-template.md +2 -1
  52. package/SKILL.md +0 -149
  53. package/references/architecture.md +0 -217
  54. package/references/commands.md +0 -128
  55. package/references/control-plane-map.md +0 -124
  56. package/references/docs-map.md +0 -73
  57. package/references/release-checklist.md +0 -65
  58. package/references/repo-map.md +0 -36
  59. package/tools/bin/split-retained-slice.sh +0 -124
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ # shellcheck source=/dev/null
6
+ source "${SCRIPT_DIR}/flow-shell-lib.sh"
7
+
4
8
  usage() {
5
9
  cat <<'EOF'
6
10
  Usage:
7
- agent-project-retry-state --state-root <path> --kind issue|pr|provider --item-id <id> --action get|schedule|clear [--reason <text>] [--cooldowns <csv>]
11
+ agent-project-retry-state --state-root <path> --kind issue|pr|provider|github --item-id <id> --action get|schedule|clear [--reason <text>] [--cooldowns <csv>] [--next-at-epoch <unix-seconds>]
8
12
 
9
13
  Generic retry/cooldown state manager for project adapters.
10
14
 
@@ -12,6 +16,7 @@ Examples:
12
16
  agent-project-retry-state --state-root /tmp/state --kind issue --item-id 123 --action get
13
17
  agent-project-retry-state --state-root /tmp/state --kind pr --item-id 77 --action schedule --reason worker-exit-failed
14
18
  agent-project-retry-state --state-root /tmp/state --kind provider --item-id openclaw-stepfun-free --action schedule --reason provider-quota-limit
19
+ agent-project-retry-state --state-root /tmp/state --kind github --item-id core-api --action schedule --reason github-api-rate-limit --next-at-epoch 4102444800
15
20
  EOF
16
21
  }
17
22
 
@@ -21,6 +26,7 @@ item_id=""
21
26
  action=""
22
27
  reason=""
23
28
  cooldowns_csv="${AGENT_PROJECT_RETRY_COOLDOWNS:-300,900,1800,3600}"
29
+ next_at_epoch_override=""
24
30
 
25
31
  while [[ $# -gt 0 ]]; do
26
32
  case "$1" in
@@ -30,6 +36,7 @@ while [[ $# -gt 0 ]]; do
30
36
  --action) action="${2:-}"; shift 2 ;;
31
37
  --reason) reason="${2:-}"; shift 2 ;;
32
38
  --cooldowns) cooldowns_csv="${2:-}"; shift 2 ;;
39
+ --next-at-epoch) next_at_epoch_override="${2:-}"; shift 2 ;;
33
40
  --help|-h) usage; exit 0 ;;
34
41
  *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
35
42
  esac
@@ -41,8 +48,8 @@ if [[ -z "$state_root" || -z "$kind" || -z "$item_id" || -z "$action" ]]; then
41
48
  fi
42
49
 
43
50
  case "$kind" in
44
- issue|pr|provider) ;;
45
- *) echo "--kind must be issue, pr, or provider" >&2; exit 1 ;;
51
+ issue|pr|provider|github) ;;
52
+ *) echo "--kind must be issue, pr, provider, or github" >&2; exit 1 ;;
46
53
  esac
47
54
 
48
55
  case "$action" in
@@ -98,7 +105,7 @@ write_state() {
98
105
 
99
106
  new_updated_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
100
107
  if [[ "$new_next_epoch" != "0" ]]; then
101
- new_next_at="$(date -u -r "$new_next_epoch" +"%Y-%m-%dT%H:%M:%SZ")"
108
+ new_next_at="$(flow_format_epoch_utc "$new_next_epoch")"
102
109
  fi
103
110
 
104
111
  cat >"$state_file" <<EOF
@@ -115,10 +122,14 @@ case "$action" in
115
122
  ;;
116
123
  schedule)
117
124
  attempts=$((attempts + 1))
118
- cooldown_seconds="$(cooldown_seconds_for_attempt "$attempts")"
119
- next_attempt_epoch=$((now_epoch + cooldown_seconds))
125
+ if [[ "${next_at_epoch_override}" =~ ^[0-9]+$ ]] && (( next_at_epoch_override > now_epoch )); then
126
+ next_attempt_epoch="${next_at_epoch_override}"
127
+ else
128
+ cooldown_seconds="$(cooldown_seconds_for_attempt "$attempts")"
129
+ next_attempt_epoch=$((now_epoch + cooldown_seconds))
130
+ fi
120
131
  write_state "$attempts" "$next_attempt_epoch" "$reason"
121
- next_attempt_at="$(date -u -r "$next_attempt_epoch" +"%Y-%m-%dT%H:%M:%SZ")"
132
+ next_attempt_at="$(flow_format_epoch_utc "$next_attempt_epoch")"
122
133
  last_reason="$reason"
123
134
  updated_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
124
135
  ;;
@@ -364,6 +364,16 @@ EOF
364
364
  done
365
365
  fi
366
366
 
367
+ # Always collect result.env from sandbox to artifact_dir
368
+ collect_copy_snippet+=$(
369
+ cat <<EOF
370
+ if [[ -f ${sandbox_run_dir_q}/result.env ]]; then
371
+ cp ${sandbox_run_dir_q}/result.env ${artifact_dir_q}/result.env
372
+ fi
373
+ EOF
374
+ )
375
+ collect_copy_snippet+=$'\n'
376
+
367
377
  reconcile_snippet=""
368
378
  if [[ -n "$reconcile_command" ]]; then
369
379
  printf -v delayed_reconcile_q '%q' "export ACP_EXPECTED_RUN_STARTED_AT=${started_at_q}; export F_LOSNING_EXPECTED_RUN_STARTED_AT=${started_at_q}; while tmux has-session -t ${session_q} 2>/dev/null; do sleep 1; done; sleep 2; $reconcile_command"
@@ -38,6 +38,23 @@ auth_refresh_poll_seconds="${ACP_CODEX_AUTH_REFRESH_POLL_SECONDS:-${F_LOSNING_CO
38
38
  max_quota_autoswitch_attempts="${ACP_CODEX_MAX_AUTOSWITCH_ATTEMPTS:-${F_LOSNING_CODEX_MAX_AUTOSWITCH_ATTEMPTS:-1}}"
39
39
  codex_progress_heartbeat_seconds="${ACP_CODEX_PROGRESS_HEARTBEAT_SECONDS:-${F_LOSNING_CODEX_PROGRESS_HEARTBEAT_SECONDS:-30}}"
40
40
  codex_stall_seconds="${ACP_CODEX_STALL_SECONDS:-${F_LOSNING_CODEX_STALL_SECONDS:-300}}"
41
+ python_bin=""
42
+
43
+ resolve_python_bin() {
44
+ if command -v python3 >/dev/null 2>&1; then
45
+ command -v python3
46
+ return 0
47
+ fi
48
+ if [[ -x /opt/homebrew/bin/python3 ]]; then
49
+ printf '%s\n' "/opt/homebrew/bin/python3"
50
+ return 0
51
+ fi
52
+ if command -v python >/dev/null 2>&1; then
53
+ command -v python
54
+ return 0
55
+ fi
56
+ return 1
57
+ }
41
58
 
42
59
  while [[ $# -gt 0 ]]; do
43
60
  case "$1" in
@@ -92,6 +109,12 @@ case "$codex_stall_seconds" in
92
109
  ''|*[!0-9]*) echo "ACP_CODEX_STALL_SECONDS must be numeric" >&2; exit 1 ;;
93
110
  esac
94
111
 
112
+ python_bin="$(resolve_python_bin || true)"
113
+ if [[ -z "$python_bin" || ! -x "$python_bin" ]]; then
114
+ echo "unable to resolve a runnable python interpreter for codex supervision" >&2
115
+ exit 1
116
+ fi
117
+
95
118
  FLOW_SKILL_DIR="$(resolve_flow_skill_dir "${BASH_SOURCE[0]}")"
96
119
  state_file="${host_run_dir}/runner.env"
97
120
  auth_file="${HOME}/.codex/auth.json"
@@ -113,6 +136,15 @@ config_yaml="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
113
136
  issue_session_prefix="$(flow_resolve_issue_session_prefix "${config_yaml}")"
114
137
  pr_session_prefix="$(flow_resolve_pr_session_prefix "${config_yaml}")"
115
138
 
139
+ # Keep npm-backed verification steps isolated from any broken user-global cache state.
140
+ npm_cache_dir="${NPM_CONFIG_CACHE:-${npm_config_cache:-}}"
141
+ if [[ -z "${npm_cache_dir}" ]]; then
142
+ npm_cache_dir="${ACP_NPM_CACHE_DIR:-${F_LOSNING_NPM_CACHE_DIR:-${HOME}/.agent-runtime/npm-cache}}"
143
+ fi
144
+ export NPM_CONFIG_CACHE="${npm_cache_dir}"
145
+ export npm_config_cache="${npm_cache_dir}"
146
+ mkdir -p "${npm_cache_dir}" 2>/dev/null || true
147
+
116
148
  thread_id=""
117
149
  attempt=0
118
150
  resume_count=0
@@ -177,7 +209,7 @@ run_with_timeout() {
177
209
  local timeout_seconds="${1:?timeout seconds required}"
178
210
  shift
179
211
 
180
- /opt/homebrew/bin/python3 - "$timeout_seconds" "$@" <<'PY'
212
+ "$python_bin" - "$timeout_seconds" "$@" <<'PY'
181
213
  import os
182
214
  import signal
183
215
  import subprocess
@@ -220,6 +252,60 @@ sys.exit(proc.returncode)
220
252
  PY
221
253
  }
222
254
 
255
+ stat_file_size() {
256
+ local path="${1:?path required}"
257
+ local value=""
258
+
259
+ value="$(stat -f %z "$path" 2>/dev/null || true)"
260
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
261
+ printf '%s\n' "$value"
262
+ return 0
263
+ fi
264
+
265
+ value="$(stat -c %s "$path" 2>/dev/null || true)"
266
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
267
+ printf '%s\n' "$value"
268
+ return 0
269
+ fi
270
+
271
+ "$python_bin" - "$path" <<'PY'
272
+ import os
273
+ import sys
274
+
275
+ try:
276
+ print(os.path.getsize(sys.argv[1]))
277
+ except OSError:
278
+ print("0")
279
+ PY
280
+ }
281
+
282
+ stat_file_mtime() {
283
+ local path="${1:?path required}"
284
+ local value=""
285
+
286
+ value="$(stat -f %m "$path" 2>/dev/null || true)"
287
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
288
+ printf '%s\n' "$value"
289
+ return 0
290
+ fi
291
+
292
+ value="$(stat -c %Y "$path" 2>/dev/null || true)"
293
+ if [[ "$value" =~ ^[0-9]+$ ]]; then
294
+ printf '%s\n' "$value"
295
+ return 0
296
+ fi
297
+
298
+ "$python_bin" - "$path" <<'PY'
299
+ import os
300
+ import sys
301
+
302
+ try:
303
+ print(int(os.path.getmtime(sys.argv[1])))
304
+ except OSError:
305
+ print("0")
306
+ PY
307
+ }
308
+
223
309
  auth_fingerprint() {
224
310
  if [[ ! -f "$auth_file" ]]; then
225
311
  printf 'missing\n'
@@ -227,8 +313,8 @@ auth_fingerprint() {
227
313
  fi
228
314
 
229
315
  local mtime size sha
230
- mtime="$(stat -f %m "$auth_file" 2>/dev/null || printf '0')"
231
- size="$(stat -f %z "$auth_file" 2>/dev/null || printf '0')"
316
+ mtime="$(stat_file_mtime "$auth_file" 2>/dev/null || printf '0')"
317
+ size="$(stat_file_size "$auth_file" 2>/dev/null || printf '0')"
232
318
  sha="$(shasum -a 256 "$auth_file" | awk '{print $1}')"
233
319
  printf '%s:%s:%s\n' "$mtime" "$size" "$sha"
234
320
  }
@@ -256,8 +342,8 @@ quota_switch_signature() {
256
342
  fi
257
343
 
258
344
  local mtime size sha
259
- mtime="$(stat -f %m "$quota_switch_state_file" 2>/dev/null || printf '0')"
260
- size="$(stat -f %z "$quota_switch_state_file" 2>/dev/null || printf '0')"
345
+ mtime="$(stat_file_mtime "$quota_switch_state_file" 2>/dev/null || printf '0')"
346
+ size="$(stat_file_size "$quota_switch_state_file" 2>/dev/null || printf '0')"
261
347
  sha="$(shasum -a 256 "$quota_switch_state_file" | awk '{print $1}')"
262
348
  printf '%s:%s:%s\n' "$mtime" "$size" "$sha"
263
349
  }
@@ -390,7 +476,7 @@ run_quota_autoswitch() {
390
476
  new_output_since() {
391
477
  local start_size="${1:?start size required}"
392
478
  local file_size
393
- file_size="$(stat -f %z "$output_file" 2>/dev/null || printf '0')"
479
+ file_size="$(stat_file_size "$output_file" 2>/dev/null || printf '0')"
394
480
  if (( file_size <= start_size )); then
395
481
  return 0
396
482
  fi
@@ -456,7 +542,7 @@ stream_codex_exec() {
456
542
  local progress_file=""
457
543
  local line=""
458
544
 
459
- last_attempt_start_size="$(stat -f %z "$output_file" 2>/dev/null || printf '0')"
545
+ last_attempt_start_size="$(stat_file_size "$output_file" 2>/dev/null || printf '0')"
460
546
  last_attempt_started_epoch="$(date +%s)"
461
547
  progress_file="${host_run_dir}/.codex-progress.$$"
462
548
  rm -f "$progress_file"
@@ -514,7 +600,7 @@ stream_codex_exec() {
514
600
  break
515
601
  fi
516
602
  else
517
- last_progress_epoch="$(stat -f %m "$progress_file" 2>/dev/null || printf '0')"
603
+ last_progress_epoch="$(stat_file_mtime "$progress_file" 2>/dev/null || printf '0')"
518
604
  if [[ -n "$last_progress_epoch" && "$last_progress_epoch" != "0" ]]; then
519
605
  idle_for=$((now - last_progress_epoch))
520
606
  if (( idle_for >= codex_stall_seconds )); then
@@ -546,17 +632,16 @@ stream_codex_exec() {
546
632
  rm -f "$stream_fifo"
547
633
  rm -f "$progress_file"
548
634
 
549
- if wait "$producer_pid"; then
550
- last_exit_code="0"
551
- else
552
- last_exit_code="$?"
553
- fi
635
+ set +e
636
+ wait "$producer_pid" 2>/dev/null
637
+ last_exit_code="$?"
638
+ set -e
554
639
 
555
640
  update_thread_id_from_output "$last_attempt_start_size"
556
641
  }
557
642
 
558
643
  extract_thread_id() {
559
- /opt/homebrew/bin/python3 -c '
644
+ "$python_bin" -c '
560
645
  import json
561
646
  import sys
562
647
 
@@ -39,10 +39,16 @@ declare -a context_items=()
39
39
  declare -a collect_files=()
40
40
 
41
41
  resolve_codex_bin() {
42
- local configured_bin="${CODEX_BIN:-}"
42
+ local configured_bin="${CODEX_BIN:-${ACP_CODEX_BIN:-${F_LOSNING_CODEX_BIN:-}}}"
43
43
  local best_version=""
44
44
  local best_bin=""
45
45
  local candidate version_line version
46
+ local -a fallback_paths=(
47
+ "${HOME}/.local/bin/codex"
48
+ "${HOME}/.codex/local/bin/codex"
49
+ "/usr/local/bin/codex"
50
+ "/opt/homebrew/bin/codex"
51
+ )
46
52
 
47
53
  if [[ -n "$configured_bin" && -x "$configured_bin" ]]; then
48
54
  printf '%s\n' "$configured_bin"
@@ -50,11 +56,16 @@ resolve_codex_bin() {
50
56
  fi
51
57
 
52
58
  if command -v codex >/dev/null 2>&1; then
53
- candidate="$(command -v codex)"
59
+ command -v codex
60
+ return 0
61
+ fi
62
+
63
+ for candidate in "${fallback_paths[@]}"; do
54
64
  if [[ -x "$candidate" ]]; then
55
- best_bin="$candidate"
65
+ printf '%s\n' "$candidate"
66
+ return 0
56
67
  fi
57
- fi
68
+ done
58
69
 
59
70
  if [[ -d "${HOME:-}/.nvm/versions/node" ]]; then
60
71
  while IFS= read -r candidate; do
@@ -347,7 +358,7 @@ find_logged_artifact_path() {
347
358
  if [[ "\$(basename "\${candidate}")" == "\${artifact_name}" && -f "\${candidate}" ]]; then
348
359
  printf '%s\n' "\${candidate}"
349
360
  fi
350
- done < <(grep -oE '/(Users|Volumes|tmp)/[^[:space:])"]+' ${output_q} 2>/dev/null || true)
361
+ done < <(grep -oE '/[^[:space:])"]+' ${output_q} 2>/dev/null || true)
351
362
  }
352
363
  recover_logged_artifact() {
353
364
  local artifact_name="\${1:?artifact name required}"
@@ -233,6 +233,16 @@ EOF
233
233
  done
234
234
  fi
235
235
 
236
+ # Always collect result.env from sandbox to artifact_dir
237
+ collect_copy_snippet+=$(
238
+ cat <<EOF
239
+ if [[ -f ${sandbox_run_dir_q}/result.env ]]; then
240
+ cp ${sandbox_run_dir_q}/result.env ${artifact_dir_q}/result.env
241
+ fi
242
+ EOF
243
+ )
244
+ collect_copy_snippet+=$'\n'
245
+
236
246
  reconcile_snippet=""
237
247
  if [[ -n "$reconcile_command" ]]; then
238
248
  printf -v delayed_reconcile_q '%q' "export ACP_EXPECTED_RUN_STARTED_AT=${started_at_q}; export F_LOSNING_EXPECTED_RUN_STARTED_AT=${started_at_q}; while tmux has-session -t ${session_q} 2>/dev/null; do sleep 1; done; sleep 2; $reconcile_command"
@@ -283,6 +283,16 @@ EOF
283
283
  done
284
284
  fi
285
285
 
286
+ # Always collect result.env from sandbox to artifact_dir
287
+ collect_copy_snippet+=$(
288
+ cat <<EOF
289
+ if [[ -f ${sandbox_run_dir_q}/result.env ]]; then
290
+ cp ${sandbox_run_dir_q}/result.env ${artifact_dir_q}/result.env
291
+ fi
292
+ EOF
293
+ )
294
+ collect_copy_snippet+=$'\n'
295
+
286
296
  reconcile_snippet=""
287
297
  if [[ -n "$reconcile_command" ]]; then
288
298
  printf -v delayed_reconcile_q '%q' "export ACP_EXPECTED_RUN_STARTED_AT=${started_at_q}; export F_LOSNING_EXPECTED_RUN_STARTED_AT=${started_at_q}; while tmux has-session -t ${session_q} 2>/dev/null; do sleep 1; done; sleep 2; $reconcile_command"
@@ -240,6 +240,16 @@ EOF
240
240
  done
241
241
  fi
242
242
 
243
+ # Always collect result.env from sandbox to artifact_dir
244
+ collect_copy_snippet+=$(
245
+ cat <<EOF
246
+ if [[ -f ${sandbox_run_dir_q}/result.env ]]; then
247
+ cp ${sandbox_run_dir_q}/result.env ${artifact_dir_q}/result.env
248
+ fi
249
+ EOF
250
+ )
251
+ collect_copy_snippet+=$'\n'
252
+
243
253
  reconcile_snippet=""
244
254
  if [[ -n "$reconcile_command" ]]; then
245
255
  printf -v delayed_reconcile_q '%q' "export ACP_EXPECTED_RUN_STARTED_AT=${started_at_q}; export F_LOSNING_EXPECTED_RUN_STARTED_AT=${started_at_q}; while tmux has-session -t ${session_q} 2>/dev/null; do sleep 1; done; sleep 2; $reconcile_command"
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ # shellcheck source=/dev/null
6
+ source "${SCRIPT_DIR}/flow-config-lib.sh"
7
+
8
+ CONFIG_YAML="${ACP_SOURCE_REPO_SYNC_CONFIG_YAML:-$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")}"
9
+ REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
10
+ SOURCE_REPO_ROOT="$(flow_resolve_source_repo_root "${CONFIG_YAML}")"
11
+ DEFAULT_BRANCH="$(flow_resolve_default_branch "${CONFIG_YAML}")"
12
+ STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
13
+ SYNC_STATE_FILE="${STATE_ROOT}/source-repo-main-sync.env"
14
+ FORGE_PROVIDER="$(flow_forge_provider)"
15
+ REMOTE_OVERRIDE="${ACP_SOURCE_SYNC_REMOTE:-${F_LOSNING_SOURCE_SYNC_REMOTE:-}}"
16
+
17
+ write_state() {
18
+ local status="${1:-}"
19
+ local remote_name="${2:-}"
20
+ local remote_sha="${3:-}"
21
+ local local_sha="${4:-}"
22
+ local detail="${5:-}"
23
+
24
+ mkdir -p "$(dirname "${SYNC_STATE_FILE}")"
25
+ {
26
+ printf 'STATUS=%s\n' "${status}"
27
+ printf 'UPDATED_AT=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
28
+ printf 'SOURCE_REPO_ROOT=%s\n' "${SOURCE_REPO_ROOT}"
29
+ printf 'DEFAULT_BRANCH=%s\n' "${DEFAULT_BRANCH}"
30
+ printf 'REMOTE_NAME=%s\n' "${remote_name}"
31
+ printf 'REMOTE_SHA=%s\n' "${remote_sha}"
32
+ printf 'LOCAL_SHA=%s\n' "${local_sha}"
33
+ printf 'DETAIL=%s\n' "${detail}"
34
+ } >"${SYNC_STATE_FILE}"
35
+ }
36
+
37
+ git_ref_sha() {
38
+ local repo_root="${1:?repo root required}"
39
+ local ref_name="${2:?ref required}"
40
+ git -C "${repo_root}" rev-parse --verify --quiet "${ref_name}" 2>/dev/null || true
41
+ }
42
+
43
+ git_has_remote() {
44
+ local repo_root="${1:?repo root required}"
45
+ local remote_name="${2:?remote required}"
46
+ git -C "${repo_root}" remote get-url "${remote_name}" >/dev/null 2>&1
47
+ }
48
+
49
+ discover_remote_name() {
50
+ local remote_name=""
51
+
52
+ if [[ -n "${REMOTE_OVERRIDE}" ]] && git_has_remote "${SOURCE_REPO_ROOT}" "${REMOTE_OVERRIDE}"; then
53
+ printf '%s\n' "${REMOTE_OVERRIDE}"
54
+ return 0
55
+ fi
56
+
57
+ case "${FORGE_PROVIDER}" in
58
+ gitea)
59
+ if git_has_remote "${SOURCE_REPO_ROOT}" "gitea"; then
60
+ printf 'gitea\n'
61
+ return 0
62
+ fi
63
+ ;;
64
+ github)
65
+ if git_has_remote "${SOURCE_REPO_ROOT}" "origin"; then
66
+ printf 'origin\n'
67
+ return 0
68
+ fi
69
+ ;;
70
+ esac
71
+
72
+ while IFS= read -r remote_name; do
73
+ [[ -n "${remote_name}" ]] || continue
74
+ if [[ "$(flow_git_remote_repo_slug "${SOURCE_REPO_ROOT}" "${remote_name}" 2>/dev/null || true)" == "${REPO_SLUG}" ]]; then
75
+ printf '%s\n' "${remote_name}"
76
+ return 0
77
+ fi
78
+ done < <(git -C "${SOURCE_REPO_ROOT}" remote)
79
+
80
+ return 1
81
+ }
82
+
83
+ if [[ -z "${SOURCE_REPO_ROOT}" ]]; then
84
+ write_state "skipped" "" "" "" "source-repo-root-unset"
85
+ printf 'SOURCE_REPO_SYNC_STATUS=skipped\nSOURCE_REPO_SYNC_REASON=source-repo-root-unset\n'
86
+ exit 0
87
+ fi
88
+
89
+ if [[ ! -d "${SOURCE_REPO_ROOT}/.git" && ! -f "${SOURCE_REPO_ROOT}/.git" ]]; then
90
+ write_state "skipped" "" "" "" "source-repo-not-git"
91
+ printf 'SOURCE_REPO_SYNC_STATUS=skipped\nSOURCE_REPO_SYNC_REASON=source-repo-not-git\nSOURCE_REPO_ROOT=%s\n' "${SOURCE_REPO_ROOT}"
92
+ exit 0
93
+ fi
94
+
95
+ REMOTE_NAME="$(discover_remote_name || true)"
96
+ if [[ -z "${REMOTE_NAME}" ]]; then
97
+ write_state "skipped" "" "" "" "remote-not-found"
98
+ printf 'SOURCE_REPO_SYNC_STATUS=skipped\nSOURCE_REPO_SYNC_REASON=remote-not-found\nSOURCE_REPO_ROOT=%s\n' "${SOURCE_REPO_ROOT}"
99
+ exit 0
100
+ fi
101
+
102
+ if ! git -C "${SOURCE_REPO_ROOT}" fetch "${REMOTE_NAME}" "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/${REMOTE_NAME}/${DEFAULT_BRANCH}" --prune >/dev/null 2>&1; then
103
+ write_state "failed" "${REMOTE_NAME}" "" "" "fetch-failed"
104
+ printf 'SOURCE_REPO_SYNC_STATUS=failed\nSOURCE_REPO_SYNC_REASON=fetch-failed\nSOURCE_REPO_ROOT=%s\nREMOTE_NAME=%s\n' "${SOURCE_REPO_ROOT}" "${REMOTE_NAME}"
105
+ exit 1
106
+ fi
107
+
108
+ remote_sha="$(git_ref_sha "${SOURCE_REPO_ROOT}" "refs/remotes/${REMOTE_NAME}/${DEFAULT_BRANCH}")"
109
+ local_branch_sha="$(git_ref_sha "${SOURCE_REPO_ROOT}" "refs/heads/${DEFAULT_BRANCH}")"
110
+ current_branch="$(git -C "${SOURCE_REPO_ROOT}" symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
111
+
112
+ if [[ -z "${remote_sha}" || -z "${local_branch_sha}" ]]; then
113
+ write_state "failed" "${REMOTE_NAME}" "${remote_sha}" "${local_branch_sha}" "missing-branch-ref"
114
+ printf 'SOURCE_REPO_SYNC_STATUS=failed\nSOURCE_REPO_SYNC_REASON=missing-branch-ref\nSOURCE_REPO_ROOT=%s\nREMOTE_NAME=%s\n' "${SOURCE_REPO_ROOT}" "${REMOTE_NAME}"
115
+ exit 1
116
+ fi
117
+
118
+ if [[ "${remote_sha}" == "${local_branch_sha}" ]]; then
119
+ write_state "unchanged" "${REMOTE_NAME}" "${remote_sha}" "${local_branch_sha}" "already-current"
120
+ printf 'SOURCE_REPO_SYNC_STATUS=unchanged\nSOURCE_REPO_ROOT=%s\nREMOTE_NAME=%s\nSOURCE_REPO_SYNC_SHA=%s\n' "${SOURCE_REPO_ROOT}" "${REMOTE_NAME}" "${remote_sha}"
121
+ exit 0
122
+ fi
123
+
124
+ if [[ "${current_branch}" == "${DEFAULT_BRANCH}" ]]; then
125
+ if [[ -n "$(git -C "${SOURCE_REPO_ROOT}" status --porcelain 2>/dev/null || true)" ]]; then
126
+ write_state "blocked" "${REMOTE_NAME}" "${remote_sha}" "${local_branch_sha}" "working-tree-dirty"
127
+ printf 'SOURCE_REPO_SYNC_STATUS=blocked\nSOURCE_REPO_SYNC_REASON=working-tree-dirty\nSOURCE_REPO_ROOT=%s\nREMOTE_NAME=%s\nLOCAL_SHA=%s\nREMOTE_SHA=%s\n' \
128
+ "${SOURCE_REPO_ROOT}" "${REMOTE_NAME}" "${local_branch_sha}" "${remote_sha}"
129
+ exit 0
130
+ fi
131
+
132
+ if git -C "${SOURCE_REPO_ROOT}" merge-base --is-ancestor "${local_branch_sha}" "${remote_sha}" >/dev/null 2>&1; then
133
+ git -C "${SOURCE_REPO_ROOT}" merge --ff-only "refs/remotes/${REMOTE_NAME}/${DEFAULT_BRANCH}" >/dev/null
134
+ updated_sha="$(git_ref_sha "${SOURCE_REPO_ROOT}" "refs/heads/${DEFAULT_BRANCH}")"
135
+ write_state "updated" "${REMOTE_NAME}" "${remote_sha}" "${updated_sha}" "fast-forward-checked-out-branch"
136
+ printf 'SOURCE_REPO_SYNC_STATUS=updated\nSOURCE_REPO_ROOT=%s\nREMOTE_NAME=%s\nSOURCE_REPO_SYNC_SHA=%s\n' "${SOURCE_REPO_ROOT}" "${REMOTE_NAME}" "${updated_sha}"
137
+ exit 0
138
+ fi
139
+
140
+ if git -C "${SOURCE_REPO_ROOT}" merge --no-edit "refs/remotes/${REMOTE_NAME}/${DEFAULT_BRANCH}" >/dev/null 2>&1; then
141
+ updated_sha="$(git_ref_sha "${SOURCE_REPO_ROOT}" "refs/heads/${DEFAULT_BRANCH}")"
142
+ write_state "updated" "${REMOTE_NAME}" "${remote_sha}" "${updated_sha}" "merge-checked-out-branch"
143
+ printf 'SOURCE_REPO_SYNC_STATUS=updated\nSOURCE_REPO_ROOT=%s\nREMOTE_NAME=%s\nSOURCE_REPO_SYNC_SHA=%s\n' "${SOURCE_REPO_ROOT}" "${REMOTE_NAME}" "${updated_sha}"
144
+ exit 0
145
+ fi
146
+
147
+ git -C "${SOURCE_REPO_ROOT}" merge --abort >/dev/null 2>&1 || true
148
+ write_state "blocked" "${REMOTE_NAME}" "${remote_sha}" "${local_branch_sha}" "merge-conflict"
149
+ printf 'SOURCE_REPO_SYNC_STATUS=blocked\nSOURCE_REPO_SYNC_REASON=merge-conflict\nSOURCE_REPO_ROOT=%s\nREMOTE_NAME=%s\nLOCAL_SHA=%s\nREMOTE_SHA=%s\n' \
150
+ "${SOURCE_REPO_ROOT}" "${REMOTE_NAME}" "${local_branch_sha}" "${remote_sha}"
151
+ exit 0
152
+ fi
153
+
154
+ if ! git -C "${SOURCE_REPO_ROOT}" merge-base --is-ancestor "${local_branch_sha}" "${remote_sha}" >/dev/null 2>&1; then
155
+ write_state "blocked" "${REMOTE_NAME}" "${remote_sha}" "${local_branch_sha}" "local-main-diverged"
156
+ printf 'SOURCE_REPO_SYNC_STATUS=blocked\nSOURCE_REPO_SYNC_REASON=local-main-diverged\nSOURCE_REPO_ROOT=%s\nREMOTE_NAME=%s\nLOCAL_SHA=%s\nREMOTE_SHA=%s\n' \
157
+ "${SOURCE_REPO_ROOT}" "${REMOTE_NAME}" "${local_branch_sha}" "${remote_sha}"
158
+ exit 0
159
+ fi
160
+
161
+ git -C "${SOURCE_REPO_ROOT}" update-ref "refs/heads/${DEFAULT_BRANCH}" "${remote_sha}" "${local_branch_sha}"
162
+ write_state "updated" "${REMOTE_NAME}" "${remote_sha}" "${remote_sha}" "fast-forward-local-ref"
163
+ printf 'SOURCE_REPO_SYNC_STATUS=updated\nSOURCE_REPO_ROOT=%s\nREMOTE_NAME=%s\nSOURCE_REPO_SYNC_SHA=%s\n' "${SOURCE_REPO_ROOT}" "${REMOTE_NAME}" "${remote_sha}"
@@ -117,13 +117,6 @@ if [[ "$status" == "UNKNOWN" && -f "$output_file" ]]; then
117
117
  fi
118
118
  fi
119
119
 
120
- if [[ "$status" == "UNKNOWN" && -z "$failure_reason" ]]; then
121
- failure_reason="$(failure_reason_from_output || true)"
122
- if [[ -n "$failure_reason" ]]; then
123
- status="FAILED"
124
- fi
125
- fi
126
-
127
120
  if [[ "$status" == "UNKNOWN" && -n "$runner_state" ]]; then
128
121
  case "$runner_state" in
129
122
  running|waiting-auth-refresh|switching-account)
@@ -133,6 +126,7 @@ if [[ "$status" == "UNKNOWN" && -n "$runner_state" ]]; then
133
126
  # Check BEFORE stale result.env to avoid false SUCCEEDED when a prior
134
127
  # cycle's result.env happens to exist.
135
128
  status="FAILED"
129
+ failure_reason="$(failure_reason_from_output || true)"
136
130
  if [[ -z "$failure_reason" ]]; then
137
131
  failure_reason="runner-aborted-before-completion"
138
132
  fi
@@ -146,10 +140,19 @@ fi
146
140
  if [[ "$status" == "UNKNOWN" && -f "$result_file" ]]; then
147
141
  # A worker that managed to persist result.env already completed its contract,
148
142
  # even if the tmux session disappeared before the exit marker was flushed.
143
+ # Check BEFORE failure_reason_from_output so that a completed result.env
144
+ # is not overridden by transient failure text in the log.
149
145
  status="SUCCEEDED"
150
146
  result_only_completion="yes"
151
147
  fi
152
148
 
149
+ if [[ "$status" == "UNKNOWN" && -z "$failure_reason" ]]; then
150
+ failure_reason="$(failure_reason_from_output || true)"
151
+ if [[ -n "$failure_reason" ]]; then
152
+ status="FAILED"
153
+ fi
154
+ fi
155
+
153
156
  if [[ "$status" == "UNKNOWN" && -f "$output_file" ]]; then
154
157
  if rg -qi "You've hit your usage limit|You have reached your Codex usage limits|visit https://chatgpt.com/codex/settings/usage|Upgrade to Pro|rate limit exceeded|quota exceeded|usage cap (reached|exceeded)|usage quota (reached|exceeded)" "$output_file"; then
155
158
  status="FAILED"
@@ -37,11 +37,16 @@ if [[ -n "$SESSION" ]]; then
37
37
  ARGS+=(--session "$SESSION")
38
38
  fi
39
39
 
40
+ cleanup_exit=0
40
41
  AGENT_PROJECT_WORKTREE_ROOT="$WORKTREE_ROOT" \
41
42
  F_LOSNING_WORKTREE_ROOT="$WORKTREE_ROOT" \
42
- bash "${FLOW_TOOLS_DIR}/agent-project-cleanup-session" "${ARGS[@]}" >/dev/null
43
+ bash "${FLOW_TOOLS_DIR}/agent-project-cleanup-session" "${ARGS[@]}" >/dev/null || cleanup_exit=$?
43
44
 
44
45
  F_LOSNING_AGENT_REPO_ROOT="$AGENT_REPO_ROOT" \
45
46
  F_LOSNING_RETAINED_REPO_ROOT="$RETAINED_REPO_ROOT" \
46
47
  F_LOSNING_VSCODE_WORKSPACE_FILE="$VSCODE_WORKSPACE_FILE" \
47
48
  "${FLOW_TOOLS_DIR}/sync-vscode-workspace.sh" >/dev/null 2>&1 || true
49
+
50
+ if [[ "$cleanup_exit" -ne 0 ]]; then
51
+ exit "$cleanup_exit"
52
+ fi