agent-control-plane 0.3.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 (43) 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 +256 -58
  9. package/package.json +7 -6
  10. package/tools/bin/agent-github-update-labels +36 -2
  11. package/tools/bin/agent-project-catch-up-merged-prs +3 -2
  12. package/tools/bin/agent-project-publish-issue-pr +6 -3
  13. package/tools/bin/agent-project-reconcile-issue-session +12 -1
  14. package/tools/bin/agent-project-reconcile-pr-session +90 -32
  15. package/tools/bin/agent-project-retry-state +18 -7
  16. package/tools/bin/agent-project-run-codex-resilient +13 -5
  17. package/tools/bin/agent-project-sync-source-repo-main +163 -0
  18. package/tools/bin/flow-config-lib.sh +1203 -60
  19. package/tools/bin/flow-shell-lib.sh +32 -0
  20. package/tools/bin/github-core-rate-limit-state.sh +77 -0
  21. package/tools/bin/github-write-outbox.sh +470 -0
  22. package/tools/bin/heartbeat-loop-scheduling-lib.sh +7 -7
  23. package/tools/bin/heartbeat-safe-auto.sh +42 -0
  24. package/tools/bin/install-project-launchd.sh +17 -2
  25. package/tools/bin/project-init.sh +21 -1
  26. package/tools/bin/project-launchd-bootstrap.sh +5 -1
  27. package/tools/bin/project-runtimectl.sh +46 -2
  28. package/tools/bin/resident-issue-controller-lib.sh +2 -2
  29. package/tools/bin/scaffold-profile.sh +61 -3
  30. package/tools/bin/start-pr-fix-worker.sh +47 -10
  31. package/tools/bin/start-resident-issue-loop.sh +2 -2
  32. package/tools/dashboard/app.js +30 -1
  33. package/tools/dashboard/dashboard_snapshot.py +55 -0
  34. package/tools/templates/pr-fix-template.md +3 -1
  35. package/tools/templates/pr-merge-repair-template.md +2 -1
  36. package/references/architecture.md +0 -217
  37. package/references/commands.md +0 -128
  38. package/references/control-plane-map.md +0 -124
  39. package/references/docs-map.md +0 -73
  40. package/references/release-checklist.md +0 -65
  41. package/references/repo-map.md +0 -36
  42. package/tools/bin/resident-issue-queue-status.py +0 -35
  43. package/tools/bin/split-retained-slice.sh +0 -124
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-control-plane",
3
- "version": "0.3.0",
3
+ "version": "0.4.9",
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": {
@@ -27,11 +27,12 @@
27
27
  "bin/sync-pr-labels.sh",
28
28
  "hooks",
29
29
  "npm/bin",
30
- "references",
31
30
  "tools/bin",
32
- "!tools/bin/audit-*.sh",
33
- "!tools/bin/check-skill-contracts.sh",
34
- "!tools/bin/render-dashboard-snapshot.py",
31
+ "!tools/bin/audit-*.sh",
32
+ "!tools/bin/check-skill-contracts.sh",
33
+ "!tools/bin/split-retained-slice.sh",
34
+ "!tools/bin/render-dashboard-snapshot.py",
35
+ "!tools/bin/resident-issue-queue-status.py",
35
36
  "tools/dashboard/app.js",
36
37
  "tools/dashboard/dashboard_snapshot.py",
37
38
  "tools/dashboard/index.html",
@@ -47,7 +48,7 @@
47
48
  "scripts": {
48
49
  "doctor": "node ./npm/bin/agent-control-plane.js doctor",
49
50
  "smoke": "node ./npm/bin/agent-control-plane.js smoke",
50
- "test": "bash tools/tests/test-agent-control-plane-npm-cli.sh && bash tools/tests/test-agent-project-detached-launch-stable-cwd.sh && bash tools/tests/test-agent-project-claude-session-wrapper-reaps-child-on-term.sh && bash tools/tests/test-agent-project-claude-session-wrapper-does-not-retry-provider-quota.sh && bash tools/tests/test-agent-project-run-codex-resilient-uses-path-python-and-gnu-stat.sh && bash tools/tests/test-heartbeat-safe-auto-uses-path-python.sh && bash tools/tests/test-heartbeat-safe-auto-skips-self-sync.sh && bash tools/tests/test-agent-project-catch-up-terminal-prs-defaults-closed-hook.sh && bash tools/tests/test-agent-project-codex-session-wrapper-prefers-path-codex.sh && bash tools/tests/test-agent-project-codex-session-wrapper-recovers-var-tmp-logged-artifacts.sh && bash tools/tests/test-agent-project-cleanup-session-removes-registered-worktree-without-rg.sh && bash tools/tests/test-agent-project-cleanup-session-propagates-failure-with-session.sh && bash tools/tests/test-cleanup-worktree-syncs-workspace-after-cleanup-failure.sh && bash tools/tests/test-resident-issue-queue-status-contract.sh && bash tools/tests/test-agent-project-reconcile-issue-provider-quota-schedules-provider-cooldown.sh && bash tools/tests/test-agent-project-reconcile-issue-session-warns-on-cleanup-failure.sh && bash tools/tests/test-agent-project-reconcile-pr-session-warns-on-cleanup-failure.sh && bash tools/tests/test-pr-reconcile-hooks-refreshes-recurring-issue-checklist.sh && bash tools/tests/test-issue-reconcile-hooks-kick-scheduler-uses-profile.sh && bash tools/tests/test-profile-adopt-skip-anchor-sync-creates-agent-repo-root.sh && bash tools/tests/test-vendored-codex-quota-claude-oauth-only.sh && bash tools/tests/test-package-smoke-command.sh"
51
+ "test": "node -e \"const { spawnSync } = require('node:child_process'); const result = spawnSync('bash', ['tools/tests/run-all.sh'], { stdio: 'inherit' }); if (result.error) throw result.error; process.exit(result.status ?? 1);\""
51
52
  },
52
53
  "publishConfig": {
53
54
  "access": "public",
@@ -20,6 +20,7 @@ number=""
20
20
  add_file="$(mktemp)"
21
21
  remove_file="$(mktemp)"
22
22
  trap 'rm -f "$add_file" "$remove_file"' EXIT
23
+ github_outbox_script="${SCRIPT_DIR}/github-write-outbox.sh"
23
24
 
24
25
  while [[ $# -gt 0 ]]; do
25
26
  case "$1" in
@@ -47,12 +48,40 @@ if [[ -z "$repo_slug" || -z "$number" ]]; then
47
48
  exit 1
48
49
  fi
49
50
 
51
+ enqueue_label_update() {
52
+ local -a args=()
53
+ local label=""
54
+
55
+ [[ "${ACP_GITHUB_OUTBOX_DISABLE_ENQUEUE:-0}" != "1" ]] || return 1
56
+ [[ -x "${github_outbox_script}" ]] || return 1
57
+
58
+ args=(enqueue-labels --repo-slug "${repo_slug}" --number "${number}")
59
+ while IFS= read -r label; do
60
+ [[ -n "${label}" ]] || continue
61
+ args+=(--add "${label}")
62
+ done <"${add_file}"
63
+ while IFS= read -r label; do
64
+ [[ -n "${label}" ]] || continue
65
+ args+=(--remove "${label}")
66
+ done <"${remove_file}"
67
+
68
+ "${github_outbox_script}" "${args[@]}" >/dev/null
69
+ }
70
+
50
71
  resource="issues/${number}"
72
+ if flow_github_core_rate_limit_active; then
73
+ enqueue_label_update && exit 0
74
+ exit 1
75
+ fi
76
+
51
77
  # Use caller-provided cached JSON if available to skip the GET call
52
78
  if [[ -n "${ACP_CACHED_ISSUE_JSON:-}" ]]; then
53
79
  current_json="${ACP_CACHED_ISSUE_JSON}"
54
80
  else
55
- current_json="$(flow_github_api_repo "${repo_slug}" "${resource}")"
81
+ if ! current_json="$(flow_github_api_repo "${repo_slug}" "${resource}")"; then
82
+ enqueue_label_update && exit 0
83
+ exit 1
84
+ fi
56
85
  fi
57
86
  add_json="$(jq -R . <"$add_file" | jq -s .)"
58
87
  remove_json="$(jq -R . <"$remove_file" | jq -s .)"
@@ -68,4 +97,9 @@ process.stdout.write(JSON.stringify({ labels: Array.from(labels).sort() }));
68
97
  EOF
69
98
  )"
70
99
 
71
- printf '%s' "$payload" | flow_github_api_repo "${repo_slug}" "${resource}" --method PATCH --input - >/dev/null
100
+ if printf '%s' "$payload" | flow_github_api_repo "${repo_slug}" "${resource}" --method PATCH --input - >/dev/null; then
101
+ exit 0
102
+ fi
103
+
104
+ enqueue_label_update && exit 0
105
+ exit 1
@@ -55,8 +55,9 @@ for hook_name in "${optional_hooks[@]}"; do
55
55
  fi
56
56
  done
57
57
 
58
- merged_ledger_dir="${state_root}/merged-pr-catchup"
59
- closed_ledger_dir="${state_root}/closed-pr-catchup"
58
+ forge_scope="$(printf '%s' "${ACP_FORGE_PROVIDER:-${F_LOSNING_FORGE_PROVIDER:-github}}" | tr -c '[:alnum:]._-' '-')"
59
+ merged_ledger_dir="${state_root}/merged-pr-catchup-${forge_scope}"
60
+ closed_ledger_dir="${state_root}/closed-pr-catchup-${forge_scope}"
60
61
  mkdir -p "$merged_ledger_dir" "$closed_ledger_dir"
61
62
 
62
63
  get_pr_risk_json() {
@@ -186,9 +186,12 @@ fi
186
186
  resolve_actor_login() {
187
187
  local login=""
188
188
 
189
- flow_export_github_cli_auth_env "${repo_slug}"
190
- login="$(gh api user --jq .login 2>/dev/null || true)"
191
- if [[ -z "${login}" ]]; then
189
+ login="$(flow_github_current_login 2>/dev/null || true)"
190
+ if [[ -z "${login}" && ! "$(flow_forge_provider)" =~ ^gitea$ ]]; then
191
+ flow_export_github_cli_auth_env "${repo_slug}"
192
+ login="$(gh api user --jq .login 2>/dev/null || true)"
193
+ fi
194
+ if [[ -z "${login}" && ! "$(flow_forge_provider)" =~ ^gitea$ ]]; then
192
195
  login="$(
193
196
  gh auth status 2>/dev/null \
194
197
  | sed -n 's/^ ✓ Logged in to github.com account \([^ ]*\) (.*/\1/p' \
@@ -231,6 +231,7 @@ if [[ -n "$hook_file" && -f "$hook_file" ]]; then
231
231
  fi
232
232
 
233
233
  provider_cooldown_script="${shared_tools_dir}/provider-cooldown-state.sh"
234
+ github_write_outbox_script="${shared_tools_dir}/github-write-outbox.sh"
234
235
 
235
236
  schedule_provider_quota_cooldown() {
236
237
  local reason="${1:-provider-quota-limit}"
@@ -335,7 +336,17 @@ post_issue_comment_if_present() {
335
336
  if issue_latest_comment_matches_artifact; then
336
337
  return 0
337
338
  fi
338
- flow_github_api_repo "${repo_slug}" "issues/${issue_id}/comments" --method POST -f body="$(cat "$comment_file")" >/dev/null || true
339
+ if flow_github_api_repo "${repo_slug}" "issues/${issue_id}/comments" --method POST -f body="$(cat "$comment_file")" >/dev/null 2>&1; then
340
+ return 0
341
+ fi
342
+ if [[ -x "${github_write_outbox_script}" ]]; then
343
+ "${github_write_outbox_script}" enqueue-comment \
344
+ --repo-slug "${repo_slug}" \
345
+ --number "${issue_id}" \
346
+ --kind issue \
347
+ --body-file "${comment_file}" >/dev/null 2>&1 || true
348
+ fi
349
+ return 0
339
350
  }
340
351
 
341
352
  issue_latest_comment_matches_artifact() {
@@ -151,6 +151,9 @@ if [[ -n "$hook_file" && -f "$hook_file" ]]; then
151
151
  fi
152
152
 
153
153
  provider_cooldown_script="${shared_tools_dir}/provider-cooldown-state.sh"
154
+ github_core_rate_limit_script="${shared_tools_dir}/github-core-rate-limit-state.sh"
155
+ github_write_outbox_script="${shared_tools_dir}/github-write-outbox.sh"
156
+ automated_pr_approval_body="Automated final review passed. Safe low-risk scope, green checks, and host-side merge approved."
154
157
 
155
158
  schedule_provider_quota_cooldown() {
156
159
  local reason="${1:-provider-quota-limit}"
@@ -172,6 +175,26 @@ blocked_runtime_reason=""
172
175
  host_github_rate_limited="no"
173
176
  host_github_rate_limit_detail=""
174
177
 
178
+ host_github_rate_limit_state_active() {
179
+ local state_out=""
180
+ local ready=""
181
+ local next_attempt_at=""
182
+
183
+ [[ -x "${github_core_rate_limit_script}" ]] || return 1
184
+ state_out="$("${github_core_rate_limit_script}" get 2>/dev/null || true)"
185
+ ready="$(awk -F= '/^READY=/{print $2; exit}' <<<"${state_out}")"
186
+ [[ "${ready}" == "no" ]] || return 1
187
+
188
+ next_attempt_at="$(awk -F= '/^NEXT_ATTEMPT_AT=/{print $2; exit}' <<<"${state_out}")"
189
+ host_github_rate_limited="yes"
190
+ if [[ -n "${next_attempt_at}" ]]; then
191
+ host_github_rate_limit_detail="GitHub core API rate limit cooldown is active and resets at ${next_attempt_at}."
192
+ else
193
+ host_github_rate_limit_detail="GitHub core API rate limit cooldown is active."
194
+ fi
195
+ return 0
196
+ }
197
+
175
198
  owner="${repo_slug%%/*}"
176
199
  repo="${repo_slug#*/}"
177
200
  pr_view_json="$(flow_github_pr_view_json "$repo_slug" "$pr_number")"
@@ -365,9 +388,27 @@ post_pr_comment_if_present() {
365
388
  if pr_comment_already_posted; then
366
389
  return 0
367
390
  fi
368
- if ! host_github_post_issue_comment "${pr_number}" "$(cat "$comment_file")"; then
369
- return 1
391
+ if host_github_post_issue_comment "${pr_number}" "$(cat "$comment_file")"; then
392
+ return 0
393
+ fi
394
+ if [[ -x "${github_write_outbox_script}" ]]; then
395
+ if "${github_write_outbox_script}" enqueue-comment \
396
+ --repo-slug "${repo_slug}" \
397
+ --number "${pr_number}" \
398
+ --kind pr \
399
+ --body-file "${comment_file}" >/dev/null 2>&1; then
400
+ return 0
401
+ fi
370
402
  fi
403
+ return 1
404
+ }
405
+
406
+ enqueue_pr_approval_intent() {
407
+ [[ -x "${github_write_outbox_script}" ]] || return 1
408
+ "${github_write_outbox_script}" enqueue-approval \
409
+ --repo-slug "${repo_slug}" \
410
+ --number "${pr_number}" \
411
+ --body "${automated_pr_approval_body}" >/dev/null 2>&1
371
412
  }
372
413
 
373
414
  pr_comment_already_posted() {
@@ -390,6 +431,9 @@ record_host_github_rate_limit() {
390
431
  host_github_rate_limited="yes"
391
432
  host_github_rate_limit_detail="${output}"
392
433
  printf '%s\n' "${output}" >"${detail_file}"
434
+ if [[ -x "${github_core_rate_limit_script}" ]]; then
435
+ "${github_core_rate_limit_script}" schedule "github-api-rate-limit" >/dev/null 2>&1 || true
436
+ fi
393
437
  }
394
438
 
395
439
  host_github_post_issue_comment() {
@@ -397,9 +441,13 @@ host_github_post_issue_comment() {
397
441
  local body="${2:-}"
398
442
  local output=""
399
443
 
444
+ if host_github_rate_limit_state_active; then
445
+ return 1
446
+ fi
447
+
400
448
  flow_export_github_cli_auth_env "${repo_slug}"
401
449
  if output="$(
402
- gh api "repos/${repo_slug}/issues/${issue_number}/comments" \
450
+ flow_github_api_repo "${repo_slug}" "issues/${issue_number}/comments" \
403
451
  --method POST \
404
452
  -f body="${body}" 2>&1
405
453
  )"; then
@@ -418,23 +466,25 @@ host_github_post_issue_comment() {
418
466
  host_github_submit_pr_approval() {
419
467
  local output=""
420
468
 
469
+ if host_github_rate_limit_state_active; then
470
+ enqueue_pr_approval_intent || true
471
+ return 1
472
+ fi
473
+
421
474
  flow_export_github_cli_auth_env "${repo_slug}"
422
475
  if output="$(
423
- gh api "repos/${repo_slug}/pulls/${pr_number}/reviews" \
424
- --method POST \
425
- -f event=APPROVE \
426
- -f body="Automated final review passed. Safe low-risk scope, green checks, and host-side merge approved." \
427
- 2>&1
476
+ flow_github_pr_review_approve "${repo_slug}" "${pr_number}" "${automated_pr_approval_body}" 2>&1
428
477
  )"; then
429
478
  return 0
430
479
  fi
431
480
 
432
- if grep -q "Can not approve your own pull request" <<<"${output}"; then
481
+ if grep -q "Can not approve your own pull request" <<<"${output}" || grep -q "approve your own pull is not allowed" <<<"${output}"; then
433
482
  return 0
434
483
  fi
435
484
 
436
485
  if host_github_output_indicates_rate_limit "${output}"; then
437
486
  record_host_github_rate_limit "${output}"
487
+ enqueue_pr_approval_intent || true
438
488
  return 1
439
489
  fi
440
490
 
@@ -843,13 +893,21 @@ merge_state_prepared() {
843
893
  }
844
894
 
845
895
  current_github_login() {
896
+ if host_github_rate_limit_state_active; then
897
+ printf '\n'
898
+ return 0
899
+ fi
846
900
  flow_export_github_cli_auth_env "${repo_slug}"
847
- gh api user --jq '.login // ""' 2>/dev/null || true
901
+ flow_github_current_login
848
902
  }
849
903
 
850
904
  pr_author_login() {
905
+ if host_github_rate_limit_state_active; then
906
+ printf '\n'
907
+ return 0
908
+ fi
851
909
  flow_export_github_cli_auth_env "${repo_slug}"
852
- gh pr view "${pr_number}" -R "${repo_slug}" --json author --jq '.author.login // ""' 2>/dev/null || true
910
+ flow_github_pr_author_login "${repo_slug}" "${pr_number}"
853
911
  }
854
912
 
855
913
  pr_is_self_authored_for_current_actor() {
@@ -862,8 +920,12 @@ pr_is_self_authored_for_current_actor() {
862
920
  }
863
921
 
864
922
  pr_remote_head_oid() {
923
+ if host_github_rate_limit_state_active; then
924
+ printf '\n'
925
+ return 0
926
+ fi
865
927
  flow_export_github_cli_auth_env "${repo_slug}"
866
- gh pr view "${pr_number}" -R "${repo_slug}" --json headRefOid --jq '.headRefOid // ""' 2>/dev/null || true
928
+ flow_github_pr_head_oid "${repo_slug}" "${pr_number}"
867
929
  }
868
930
 
869
931
  pr_remote_already_has_final_head() {
@@ -885,29 +947,25 @@ approve_and_merge() {
885
947
  fi
886
948
  fi
887
949
 
950
+ if host_github_rate_limit_state_active; then
951
+ return 2
952
+ fi
953
+
888
954
  flow_export_github_cli_auth_env "${repo_slug}"
889
- if ! gh pr merge "${pr_number}" -R "${repo_slug}" --squash --delete-branch --admin >"${run_dir}/host-github-merge.out" 2>"${run_dir}/host-github-merge.err"; then
890
- local merge_output=""
891
- merge_output="$(cat "${run_dir}/host-github-merge.err" 2>/dev/null || true)"
892
- if host_github_output_indicates_rate_limit "${merge_output}"; then
893
- record_host_github_rate_limit "${merge_output}"
894
- return 2
895
- fi
896
- if flow_github_pr_merge "$repo_slug" "$pr_number" "squash" "yes" 2>"${run_dir}/host-github-merge.err"; then
897
- return 0
898
- fi
899
- merge_output="$(cat "${run_dir}/host-github-merge.err" 2>/dev/null || true)"
900
- if host_github_output_indicates_rate_limit "${merge_output}"; then
901
- record_host_github_rate_limit "${merge_output}"
902
- return 2
903
- fi
904
- if [[ -n "${merge_output}" ]]; then
905
- printf '%s\n' "${merge_output}" >&2
906
- fi
907
- return 1
955
+ if flow_github_pr_merge "$repo_slug" "$pr_number" "squash" "yes" >"${run_dir}/host-github-merge.out" 2>"${run_dir}/host-github-merge.err"; then
956
+ return 0
908
957
  fi
909
958
 
910
- return 0
959
+ local merge_output=""
960
+ merge_output="$(cat "${run_dir}/host-github-merge.err" 2>/dev/null || true)"
961
+ if host_github_output_indicates_rate_limit "${merge_output}"; then
962
+ record_host_github_rate_limit "${merge_output}"
963
+ return 2
964
+ fi
965
+ if [[ -n "${merge_output}" ]]; then
966
+ printf '%s\n' "${merge_output}" >&2
967
+ fi
968
+ return 1
911
969
  }
912
970
 
913
971
  cleanup_pr_session() {
@@ -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
  ;;
@@ -136,6 +136,15 @@ config_yaml="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
136
136
  issue_session_prefix="$(flow_resolve_issue_session_prefix "${config_yaml}")"
137
137
  pr_session_prefix="$(flow_resolve_pr_session_prefix "${config_yaml}")"
138
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
+
139
148
  thread_id=""
140
149
  attempt=0
141
150
  resume_count=0
@@ -623,11 +632,10 @@ stream_codex_exec() {
623
632
  rm -f "$stream_fifo"
624
633
  rm -f "$progress_file"
625
634
 
626
- if wait "$producer_pid"; then
627
- last_exit_code="0"
628
- else
629
- last_exit_code="$?"
630
- fi
635
+ set +e
636
+ wait "$producer_pid" 2>/dev/null
637
+ last_exit_code="$?"
638
+ set -e
631
639
 
632
640
  update_thread_id_from_output "$last_attempt_start_size"
633
641
  }
@@ -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}"