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
@@ -129,8 +129,23 @@ LABEL="${label_override:-${ACP_PROJECT_RUNTIME_LAUNCHD_LABEL:-ai.agent.project.$
129
129
  BASE_PATH="$(build_launchd_base_path)"
130
130
  CODING_WORKER_OVERRIDE="${ACP_PROJECT_RUNTIME_CODING_WORKER:-${ACP_CODING_WORKER:-}}"
131
131
  SYNC_SCRIPT="${ACP_PROJECT_RUNTIME_SYNC_SCRIPT:-${FLOW_SKILL_DIR}/tools/bin/sync-shared-agent-home.sh}"
132
- BOOTSTRAP_SCRIPT="${ACP_PROJECT_RUNTIME_BOOTSTRAP_SCRIPT:-${FLOW_SKILL_DIR}/tools/bin/project-launchd-bootstrap.sh}"
133
- SUPERVISOR_SCRIPT="${ACP_PROJECT_RUNTIME_SUPERVISOR_SCRIPT:-${FLOW_SKILL_DIR}/tools/bin/project-runtime-supervisor.sh}"
132
+ RUNTIME_SKILL_DIR="${RUNTIME_HOME}/skills/openclaw/agent-control-plane"
133
+ BOOTSTRAP_SCRIPT="${ACP_PROJECT_RUNTIME_BOOTSTRAP_SCRIPT:-}"
134
+ if [[ -z "${BOOTSTRAP_SCRIPT}" ]]; then
135
+ if [[ -x "${RUNTIME_SKILL_DIR}/tools/bin/project-launchd-bootstrap.sh" ]]; then
136
+ BOOTSTRAP_SCRIPT="${RUNTIME_SKILL_DIR}/tools/bin/project-launchd-bootstrap.sh"
137
+ else
138
+ BOOTSTRAP_SCRIPT="${FLOW_SKILL_DIR}/tools/bin/project-launchd-bootstrap.sh"
139
+ fi
140
+ fi
141
+ SUPERVISOR_SCRIPT="${ACP_PROJECT_RUNTIME_SUPERVISOR_SCRIPT:-}"
142
+ if [[ -z "${SUPERVISOR_SCRIPT}" ]]; then
143
+ if [[ -x "${RUNTIME_SKILL_DIR}/tools/bin/project-runtime-supervisor.sh" ]]; then
144
+ SUPERVISOR_SCRIPT="${RUNTIME_SKILL_DIR}/tools/bin/project-runtime-supervisor.sh"
145
+ else
146
+ SUPERVISOR_SCRIPT="${FLOW_SKILL_DIR}/tools/bin/project-runtime-supervisor.sh"
147
+ fi
148
+ fi
134
149
  STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
135
150
  SUPERVISOR_PID_FILE="${STATE_ROOT}/runtime-supervisor.pid"
136
151
  ENV_FILE="${ACP_PROJECT_RUNTIME_ENV_FILE:-${PROFILE_REGISTRY_ROOT}/${PROFILE_ID}/runtime.env}"
@@ -34,7 +34,12 @@ runtime copy.
34
34
 
35
35
  Common options:
36
36
  --profile-id <id> Profile id, e.g. billing-api
37
- --repo-slug <owner/repo> GitHub repo slug
37
+ --repo-slug <owner/repo> Forge repo slug
38
+ --forge-provider <github|gitea> Forge provider for this profile
39
+ --gitea-base-url <url> Base URL for a local/self-hosted Gitea instance
40
+ --gitea-token <token> Gitea API token written to profile runtime.env
41
+ --gitea-username <user> Gitea username written to profile runtime.env
42
+ --gitea-password <pass> Gitea password written to profile runtime.env
38
43
  --profile-home <path> Installed profile registry root
39
44
  --repo-root <path> Canonical repo root
40
45
  --agent-repo-root <path> Agent-owned anchor repo root
@@ -68,6 +73,11 @@ EOF
68
73
 
69
74
  profile_id=""
70
75
  repo_slug=""
76
+ forge_provider=""
77
+ gitea_base_url=""
78
+ gitea_token=""
79
+ gitea_username=""
80
+ gitea_password=""
71
81
  profile_home=""
72
82
  repo_root=""
73
83
  agent_repo_root=""
@@ -98,6 +108,11 @@ while [[ $# -gt 0 ]]; do
98
108
  case "$1" in
99
109
  --profile-id) profile_id="${2:-}"; shift 2 ;;
100
110
  --repo-slug) repo_slug="${2:-}"; shift 2 ;;
111
+ --forge-provider) forge_provider="${2:-}"; shift 2 ;;
112
+ --gitea-base-url) gitea_base_url="${2:-}"; shift 2 ;;
113
+ --gitea-token) gitea_token="${2:-}"; shift 2 ;;
114
+ --gitea-username) gitea_username="${2:-}"; shift 2 ;;
115
+ --gitea-password) gitea_password="${2:-}"; shift 2 ;;
101
116
  --profile-home) profile_home="${2:-}"; shift 2 ;;
102
117
  --repo-root) repo_root="${2:-}"; shift 2 ;;
103
118
  --agent-repo-root) agent_repo_root="${2:-}"; shift 2 ;;
@@ -144,6 +159,11 @@ SOURCE_HOME="${source_home:-${ACP_PROJECT_INIT_SOURCE_HOME:-$(cd "${FLOW_SKILL_D
144
159
  RUNTIME_HOME="${runtime_home:-${ACP_PROJECT_INIT_RUNTIME_HOME:-${HOME}/.agent-runtime/runtime-home}}"
145
160
 
146
161
  scaffold_cmd=(bash "${SCAFFOLD_SCRIPT}" --profile-id "${profile_id}" --repo-slug "${repo_slug}")
162
+ [[ -n "${forge_provider}" ]] && scaffold_cmd+=(--forge-provider "${forge_provider}")
163
+ [[ -n "${gitea_base_url}" ]] && scaffold_cmd+=(--gitea-base-url "${gitea_base_url}")
164
+ [[ -n "${gitea_token}" ]] && scaffold_cmd+=(--gitea-token "${gitea_token}")
165
+ [[ -n "${gitea_username}" ]] && scaffold_cmd+=(--gitea-username "${gitea_username}")
166
+ [[ -n "${gitea_password}" ]] && scaffold_cmd+=(--gitea-password "${gitea_password}")
147
167
  [[ -n "${profile_home}" ]] && scaffold_cmd+=(--profile-home "${profile_home}")
148
168
  [[ -n "${repo_root}" ]] && scaffold_cmd+=(--repo-root "${repo_root}")
149
169
  [[ -n "${agent_repo_root}" ]] && scaffold_cmd+=(--agent-repo-root "${agent_repo_root}")
@@ -54,7 +54,11 @@ if [[ -x "${ENSURE_SYNC_SCRIPT}" ]]; then
54
54
  if [[ "${ALWAYS_SYNC}" == "1" ]]; then
55
55
  ensure_args=(--force "${ensure_args[@]}")
56
56
  fi
57
- bash "${ENSURE_SYNC_SCRIPT}" "${ensure_args[@]}"
57
+ if [[ "${FLOW_SKILL_DIR}" == "${RUNTIME_HOME}"/* ]]; then
58
+ printf 'RUNTIME_SYNC_SKIPPED=active-runtime-home\n'
59
+ else
60
+ bash "${ENSURE_SYNC_SCRIPT}" "${ensure_args[@]}"
61
+ fi
58
62
  elif [[ "${ALWAYS_SYNC}" == "1" || ! -x "${RUNTIME_HEARTBEAT_SCRIPT}" ]]; then
59
63
  if [[ -z "${SOURCE_HOME}" ]]; then
60
64
  SOURCE_HOME="${FLOW_SKILL_DIR}"
@@ -110,6 +110,7 @@ LAUNCHD_PLIST="${ACP_PROJECT_RUNTIME_LAUNCHD_PLIST:-${LAUNCH_AGENTS_DIR}/${LAUNC
110
110
  SOURCE_HOME="${ACP_PROJECT_RUNTIME_SOURCE_HOME:-}"
111
111
  RUNTIME_HOME="${ACP_PROJECT_RUNTIME_RUNTIME_HOME:-$(resolve_runtime_home)}"
112
112
  SYNC_STAMP_FILE="${RUNTIME_HOME}/.agent-control-plane-runtime-sync.env"
113
+ SOURCE_REPO_SYNC_STATE_FILE="${STATE_ROOT}/source-repo-main-sync.env"
113
114
 
114
115
  case "${delay_seconds}" in
115
116
  ''|*[!0-9]*) echo "--delay-seconds must be numeric" >&2; exit 64 ;;
@@ -199,6 +200,13 @@ sync_stamp_value() {
199
200
  | sed -e "s/^'//" -e "s/'$//"
200
201
  }
201
202
 
203
+ source_repo_sync_value() {
204
+ local key="${1:?key required}"
205
+ [[ -f "${SOURCE_REPO_SYNC_STATE_FILE}" ]] || return 1
206
+ awk -F= -v target="${key}" '$1 == target {print $2; exit}' "${SOURCE_REPO_SYNC_STATE_FILE}" 2>/dev/null \
207
+ | sed -e "s/^'//" -e "s/'$//"
208
+ }
209
+
202
210
  shared_loop_status_value() {
203
211
  local key="${1:?key required}"
204
212
  local file="${STATE_ROOT}/shared-heartbeat-loop.env"
@@ -399,6 +407,15 @@ print_status() {
399
407
  local shared_loop_last_status=""
400
408
  local shared_loop_started_at=""
401
409
  local shared_loop_updated_at=""
410
+ local source_repo_sync_status=""
411
+ local source_repo_sync_updated_at=""
412
+ local source_repo_sync_root=""
413
+ local source_repo_sync_branch=""
414
+ local source_repo_sync_remote=""
415
+ local source_repo_sync_remote_sha=""
416
+ local source_repo_sync_local_sha=""
417
+ local source_repo_sync_detail=""
418
+ local source_repo_sync_aligned="unknown"
402
419
 
403
420
  heartbeat="$(heartbeat_pid)"
404
421
  shared_loop="$(shared_loop_pid)"
@@ -429,6 +446,23 @@ print_status() {
429
446
  shared_loop_last_status="$(shared_loop_status_value "STATUS" || true)"
430
447
  shared_loop_started_at="$(shared_loop_status_value "STARTED_AT" || true)"
431
448
  shared_loop_updated_at="$(shared_loop_status_value "UPDATED_AT" || true)"
449
+ source_repo_sync_status="$(source_repo_sync_value "STATUS" || true)"
450
+ source_repo_sync_updated_at="$(source_repo_sync_value "UPDATED_AT" || true)"
451
+ source_repo_sync_root="$(source_repo_sync_value "SOURCE_REPO_ROOT" || true)"
452
+ source_repo_sync_branch="$(source_repo_sync_value "DEFAULT_BRANCH" || true)"
453
+ source_repo_sync_remote="$(source_repo_sync_value "REMOTE_NAME" || true)"
454
+ source_repo_sync_remote_sha="$(source_repo_sync_value "REMOTE_SHA" || true)"
455
+ source_repo_sync_local_sha="$(source_repo_sync_value "LOCAL_SHA" || true)"
456
+ source_repo_sync_detail="$(source_repo_sync_value "DETAIL" || true)"
457
+ if [[ -n "${source_repo_sync_status}" ]]; then
458
+ if [[ -n "${source_repo_sync_remote_sha}" && -n "${source_repo_sync_local_sha}" && "${source_repo_sync_remote_sha}" == "${source_repo_sync_local_sha}" ]]; then
459
+ source_repo_sync_aligned="yes"
460
+ elif [[ "${source_repo_sync_status}" == "blocked" || "${source_repo_sync_status}" == "failed" ]]; then
461
+ source_repo_sync_aligned="no"
462
+ else
463
+ source_repo_sync_aligned="unknown"
464
+ fi
465
+ fi
432
466
 
433
467
  printf 'PROFILE_ID=%s\n' "${PROFILE_ID}"
434
468
  printf 'CONFIG_YAML=%s\n' "${CONFIG_YAML}"
@@ -459,6 +493,16 @@ print_status() {
459
493
  printf 'RUNTIME_SYNC_STATUS=%s\n' "${runtime_sync_status}"
460
494
  printf 'RUNTIME_SYNC_UPDATED_AT=%s\n' "${runtime_sync_updated_at}"
461
495
  printf 'RUNTIME_SYNC_FINGERPRINT=%s\n' "${runtime_sync_fingerprint}"
496
+ printf 'SOURCE_REPO_SYNC_STATE_FILE=%s\n' "${SOURCE_REPO_SYNC_STATE_FILE}"
497
+ printf 'SOURCE_REPO_SYNC_STATUS=%s\n' "${source_repo_sync_status}"
498
+ printf 'SOURCE_REPO_SYNC_UPDATED_AT=%s\n' "${source_repo_sync_updated_at}"
499
+ printf 'SOURCE_REPO_SYNC_ROOT=%s\n' "${source_repo_sync_root}"
500
+ printf 'SOURCE_REPO_SYNC_BRANCH=%s\n' "${source_repo_sync_branch}"
501
+ printf 'SOURCE_REPO_SYNC_REMOTE=%s\n' "${source_repo_sync_remote}"
502
+ printf 'SOURCE_REPO_SYNC_REMOTE_SHA=%s\n' "${source_repo_sync_remote_sha}"
503
+ printf 'SOURCE_REPO_SYNC_LOCAL_SHA=%s\n' "${source_repo_sync_local_sha}"
504
+ printf 'SOURCE_REPO_SYNC_DETAIL=%s\n' "${source_repo_sync_detail}"
505
+ printf 'SOURCE_REPO_SYNC_ALIGNED=%s\n' "${source_repo_sync_aligned}"
462
506
  }
463
507
 
464
508
  terminate_pid_list() {
@@ -518,7 +562,7 @@ clear_running_labels_after_stop() {
518
562
  fi
519
563
 
520
564
  issue_json="$(flow_github_issue_list_json "${REPO_SLUG}" open 100 2>/dev/null || printf '[]\n')"
521
- if [[ "${issue_json}" == "[]" ]]; then
565
+ if [[ "${issue_json}" == "[]" ]] && ! flow_using_gitea; then
522
566
  issue_json="$(gh issue list -R "${REPO_SLUG}" --state open --limit 100 --json number,labels 2>/dev/null || printf '[]\n')"
523
567
  fi
524
568
  while IFS= read -r number; do
@@ -527,7 +571,7 @@ clear_running_labels_after_stop() {
527
571
  done < <(jq -r '.[] | select(any(.labels[]?; .name == "agent-running")) | .number' <<<"${issue_json}" 2>/dev/null || true)
528
572
 
529
573
  pr_json="$(flow_github_pr_list_json "${REPO_SLUG}" open 100 2>/dev/null || printf '[]\n')"
530
- if [[ "${pr_json}" == "[]" ]]; then
574
+ if [[ "${pr_json}" == "[]" ]] && ! flow_using_gitea; then
531
575
  pr_json="$(gh pr list -R "${REPO_SLUG}" --state open --limit 100 --json number,labels 2>/dev/null || printf '[]\n')"
532
576
  fi
533
577
  while IFS= read -r number; do
@@ -401,7 +401,7 @@ controller_wait_for_provider_capacity() {
401
401
  if (( wait_completed_epoch >= wait_started_epoch )); then
402
402
  PROVIDER_LAST_WAIT_SECONDS=$((wait_completed_epoch - wait_started_epoch))
403
403
  PROVIDER_WAIT_TOTAL_SECONDS=$((PROVIDER_WAIT_TOTAL_SECONDS + PROVIDER_LAST_WAIT_SECONDS))
404
- PROVIDER_LAST_WAIT_COMPLETED_AT="$(date -u -r "${wait_completed_epoch}" +"%Y-%m-%dT%H:%M:%SZ")"
404
+ PROVIDER_LAST_WAIT_COMPLETED_AT="$(flow_format_epoch_utc "${wait_completed_epoch}")"
405
405
  fi
406
406
  fi
407
407
  NEXT_WAKE_EPOCH=""
@@ -418,7 +418,7 @@ controller_wait_for_provider_capacity() {
418
418
  if [[ -z "${wait_started_epoch}" ]]; then
419
419
  wait_started_epoch="$(date +%s)"
420
420
  PROVIDER_WAIT_COUNT=$((PROVIDER_WAIT_COUNT + 1))
421
- PROVIDER_LAST_WAIT_STARTED_AT="$(date -u -r "${wait_started_epoch}" +"%Y-%m-%dT%H:%M:%SZ")"
421
+ PROVIDER_LAST_WAIT_STARTED_AT="$(flow_format_epoch_utc "${wait_started_epoch}")"
422
422
  fi
423
423
 
424
424
  PROVIDER_WAITED="yes"
@@ -14,7 +14,12 @@ Create a new installed project profile, profile templates, and profile notes.
14
14
 
15
15
  Options:
16
16
  --profile-id <id> Profile id, e.g. billing-api
17
- --repo-slug <owner/repo> GitHub repo slug
17
+ --repo-slug <owner/repo> Forge repo slug
18
+ --forge-provider <github|gitea> Forge provider (default: github)
19
+ --gitea-base-url <url> Base URL for a local/self-hosted Gitea instance
20
+ --gitea-token <token> Gitea API token written to profile runtime.env
21
+ --gitea-username <user> Gitea username written to profile runtime.env
22
+ --gitea-password <pass> Gitea password written to profile runtime.env
18
23
  --profile-home <path> Profile registry root (default: ~/.agent-runtime/control-plane/profiles)
19
24
  --repo-root <path> Canonical repo root
20
25
  --agent-repo-root <path> Agent-owned anchor repo root (defaults to repo root)
@@ -40,6 +45,11 @@ EOF
40
45
 
41
46
  profile_id=""
42
47
  repo_slug=""
48
+ forge_provider="github"
49
+ gitea_base_url=""
50
+ gitea_token=""
51
+ gitea_username=""
52
+ gitea_password=""
43
53
  profile_home=""
44
54
  repo_root=""
45
55
  agent_repo_root=""
@@ -63,6 +73,11 @@ while [[ $# -gt 0 ]]; do
63
73
  case "$1" in
64
74
  --profile-id) profile_id="${2:-}"; shift 2 ;;
65
75
  --repo-slug) repo_slug="${2:-}"; shift 2 ;;
76
+ --forge-provider) forge_provider="${2:-}"; shift 2 ;;
77
+ --gitea-base-url) gitea_base_url="${2:-}"; shift 2 ;;
78
+ --gitea-token) gitea_token="${2:-}"; shift 2 ;;
79
+ --gitea-username) gitea_username="${2:-}"; shift 2 ;;
80
+ --gitea-password) gitea_password="${2:-}"; shift 2 ;;
66
81
  --profile-home) profile_home="${2:-}"; shift 2 ;;
67
82
  --repo-root) repo_root="${2:-}"; shift 2 ;;
68
83
  --agent-repo-root) agent_repo_root="${2:-}"; shift 2 ;;
@@ -104,6 +119,14 @@ case "$coding_worker" in
104
119
  ;;
105
120
  esac
106
121
 
122
+ case "$forge_provider" in
123
+ github|gitea) ;;
124
+ *)
125
+ echo "--forge-provider must be github or gitea" >&2
126
+ exit 1
127
+ ;;
128
+ esac
129
+
107
130
  case "$claude_effort" in
108
131
  low|medium|high|max) ;;
109
132
  *)
@@ -133,6 +156,7 @@ profile_home="${profile_home:-$(resolve_flow_profile_registry_root)}"
133
156
  profiles_dir="${profile_home}"
134
157
  profile_dir="${profiles_dir}/${profile_id}"
135
158
  profile_yaml="${profile_dir}/control-plane.yaml"
159
+ profile_runtime_env="${profile_dir}/runtime.env"
136
160
  profile_templates_dir="${profile_dir}/templates"
137
161
  profile_readme="${profile_dir}/README.md"
138
162
 
@@ -228,9 +252,9 @@ session_naming:
228
252
  pr_worktree_branch_prefix: "${pr_worktree_branch_prefix}"
229
253
  managed_pr_branch_globs: "${managed_pr_branch_globs}"
230
254
  queue:
231
- source: "github"
255
+ source: "${forge_provider}"
232
256
  issue_labels:
233
- ready: "agent-ready"
257
+ ready: ""
234
258
  running: "agent-running"
235
259
  blocked: "agent-blocked"
236
260
  heavy: "agent-e2e-heavy"
@@ -366,7 +390,38 @@ policies:
366
390
  EOF
367
391
  }
368
392
 
393
+ write_profile_runtime_env() {
394
+ local target_file="${1:?target file required}"
395
+
396
+ : >"$target_file"
397
+ {
398
+ printf 'ACP_FORGE_PROVIDER=%s\n' "${forge_provider}"
399
+ printf 'F_LOSNING_FORGE_PROVIDER=%s\n' "${forge_provider}"
400
+ if [[ "${forge_provider}" == "gitea" ]]; then
401
+ if [[ -n "${gitea_base_url}" ]]; then
402
+ printf 'ACP_GITEA_BASE_URL=%s\n' "${gitea_base_url}"
403
+ printf 'GITEA_BASE_URL=%s\n' "${gitea_base_url}"
404
+ fi
405
+ if [[ -n "${gitea_token}" ]]; then
406
+ printf 'ACP_GITEA_TOKEN=%s\n' "${gitea_token}"
407
+ printf 'GITEA_TOKEN=%s\n' "${gitea_token}"
408
+ fi
409
+ if [[ -n "${gitea_username}" ]]; then
410
+ printf 'ACP_GITEA_USERNAME=%s\n' "${gitea_username}"
411
+ printf 'GITEA_USERNAME=%s\n' "${gitea_username}"
412
+ fi
413
+ if [[ -n "${gitea_password}" ]]; then
414
+ printf 'ACP_GITEA_PASSWORD=%s\n' "${gitea_password}"
415
+ printf 'GITEA_PASSWORD=%s\n' "${gitea_password}"
416
+ fi
417
+ printf 'ACP_SOURCE_SYNC_REMOTE=gitea\n'
418
+ printf 'F_LOSNING_SOURCE_SYNC_REMOTE=gitea\n'
419
+ fi
420
+ } >"$target_file"
421
+ }
422
+
369
423
  write_profile_yaml "$profile_yaml"
424
+ write_profile_runtime_env "$profile_runtime_env"
370
425
  write_profile_readme "$profile_readme"
371
426
 
372
427
  if compgen -G "${flow_skill_dir}/tools/templates/*.md" >/dev/null; then
@@ -375,15 +430,18 @@ fi
375
430
 
376
431
  profile_home_real="$(mkdir -p "$profile_home" && cd "$profile_home" && pwd -P)"
377
432
  profile_yaml_real="$(cd "$(dirname "$profile_yaml")" && pwd -P)/$(basename "$profile_yaml")"
433
+ profile_runtime_env_real="$(cd "$(dirname "$profile_runtime_env")" && pwd -P)/$(basename "$profile_runtime_env")"
378
434
  profile_templates_dir_real="$(cd "$profile_templates_dir" && pwd -P)"
379
435
  profile_readme_real="$(cd "$(dirname "$profile_readme")" && pwd -P)/$(basename "$profile_readme")"
380
436
 
381
437
  printf 'PROFILE_ID=%s\n' "$profile_id"
382
438
  printf 'PROFILE_HOME=%s\n' "$profile_home_real"
383
439
  printf 'PROFILE_YAML=%s\n' "$profile_yaml_real"
440
+ printf 'PROFILE_RUNTIME_ENV=%s\n' "$profile_runtime_env_real"
384
441
  printf 'PROFILE_TEMPLATE_DIR=%s\n' "$profile_templates_dir_real"
385
442
  printf 'PROFILE_README=%s\n' "$profile_readme_real"
386
443
  printf 'REPO_SLUG=%s\n' "$repo_slug"
444
+ printf 'FORGE_PROVIDER=%s\n' "$forge_provider"
387
445
  printf 'CODING_WORKER=%s\n' "$coding_worker"
388
446
  printf 'NEXT_STEP=ACP_PROJECT_ID=%s bash %s/tools/bin/render-flow-config.sh\n' "$profile_id" "$flow_skill_dir"
389
447
  printf 'NEXT_STEP=bash %s/tools/bin/sync-shared-agent-home.sh\n' "$flow_skill_dir"
@@ -169,11 +169,41 @@ PR_MISSING_REASONS_TEXT="$(jq -r '.missingReasons[]? | "- " + .' <<<"$RISK_JSON"
169
169
  if [[ -z "$PR_MISSING_REASONS_TEXT" ]]; then
170
170
  PR_MISSING_REASONS_TEXT="- none"
171
171
  fi
172
- PR_PULL_JSON="$(flow_github_api_repo "${REPO_SLUG}" "pulls/${PR_NUMBER}")"
173
- PR_HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_PULL_JSON")"
174
- PR_MERGEABLE_STATUS="$(jq -r 'if .mergeable == null then "UNKNOWN" else (.mergeable | tostring | ascii_upcase) end' <<<"$PR_PULL_JSON")"
172
+ PR_PULL_JSON="$(flow_github_api_repo "${REPO_SLUG}" "pulls/${PR_NUMBER}" 2>/dev/null || printf '{}\n')"
173
+ PR_HEAD_SHA="$(jq -r '.head.sha // .headRefOid // ""' <<<"$PR_PULL_JSON")"
174
+ PR_MERGEABLE_STATUS="$(jq -r 'if .mergeable == null then "UNKNOWN" else (.mergeable | tostring | ascii_upcase) end' <<<"$PR_PULL_JSON" 2>/dev/null || printf 'UNKNOWN\n')"
175
+
176
+ pr_comments_json() {
177
+ local review_route="pulls/${PR_NUMBER}/comments"
178
+ local issue_route="issues/${PR_NUMBER}/comments"
179
+ local payload=""
180
+
181
+ if flow_using_gitea; then
182
+ payload="$(flow_github_api_repo "${REPO_SLUG}" "${issue_route}" 2>/dev/null || true)"
183
+ else
184
+ payload="$(flow_github_api_repo "${REPO_SLUG}" "${review_route}" 2>/dev/null || true)"
185
+ fi
186
+
187
+ if jq -e 'type == "array"' >/dev/null 2>&1 <<<"${payload}"; then
188
+ printf '%s\n' "${payload}"
189
+ return 0
190
+ fi
191
+
192
+ printf '[]\n'
193
+ }
194
+
195
+ pr_issue_comments_json() {
196
+ local payload=""
197
+ payload="$(flow_github_api_repo "${REPO_SLUG}" "issues/${PR_NUMBER}/comments" 2>/dev/null || true)"
198
+ if jq -e 'type == "array"' >/dev/null 2>&1 <<<"${payload}"; then
199
+ printf '%s\n' "${payload}"
200
+ return 0
201
+ fi
202
+ printf '[]\n'
203
+ }
204
+
175
205
  PR_REVIEW_FINDINGS_TEXT="$(
176
- flow_github_api_repo "${REPO_SLUG}" "pulls/${PR_NUMBER}/comments" \
206
+ pr_comments_json \
177
207
  | jq -r --arg head_sha "$PR_HEAD_SHA" '
178
208
  map(select(
179
209
  (.user.login == "chatgpt-codex-connector[bot]")
@@ -195,7 +225,7 @@ PR_REVIEW_FINDINGS_TEXT="$(
195
225
  '
196
226
  )"
197
227
  PR_BLOCKER_SUMMARY_TEXT="$(
198
- flow_github_api_repo "${REPO_SLUG}" "issues/${PR_NUMBER}/comments" \
228
+ pr_issue_comments_json \
199
229
  | jq -r '
200
230
  map(select((.body // "") | startswith("## PR final review blocker")))
201
231
  | if length == 0 then
@@ -225,12 +255,17 @@ PR_LOCAL_HOST_BLOCKER_SUMMARY_TEXT="$(latest_history_artifact_content "host-bloc
225
255
 
226
256
  WORKTREE_OUT="$("${WORKSPACE_DIR}/bin/new-pr-worktree.sh" "$PR_NUMBER" "$PR_HEAD_REF")"
227
257
  WORKTREE="$(awk -F= '/^WORKTREE=/{print $2}' <<<"$WORKTREE_OUT")"
258
+ PR_BASE_REMOTE="$(flow_resolve_forge_primary_remote "${WORKTREE}" "${REPO_SLUG}" 2>/dev/null || true)"
259
+ if [[ -z "${PR_BASE_REMOTE}" ]]; then
260
+ PR_BASE_REMOTE="origin"
261
+ fi
262
+ PR_BASE_TRACKING_REF="${PR_BASE_REMOTE}/${PR_BASE_REF}"
228
263
  PR_HOST_MERGE_STATUS="not-applicable"
229
264
  PR_HOST_MERGE_SUMMARY_TEXT="- not-applicable"
230
265
 
231
266
  materialize_host_merge_repair() {
232
267
  local merge_output=""
233
- if merge_output="$(git -C "$WORKTREE" merge --no-commit --no-ff "origin/${PR_BASE_REF}" 2>&1)"; then
268
+ if merge_output="$(git -C "$WORKTREE" merge --no-commit --no-ff "${PR_BASE_TRACKING_REF}" 2>&1)"; then
234
269
  PR_HOST_MERGE_STATUS="clean"
235
270
  if [[ -n "$merge_output" ]]; then
236
271
  PR_HOST_MERGE_SUMMARY_TEXT="$(printf '%s\n' "$merge_output")"
@@ -271,14 +306,14 @@ else
271
306
  PR_CONFLICT_PATHS_TEXT="$(
272
307
  (
273
308
  cd "$WORKTREE"
274
- base_sha="$(git merge-base HEAD "origin/${PR_BASE_REF}" 2>/dev/null || true)"
309
+ base_sha="$(git merge-base HEAD "${PR_BASE_TRACKING_REF}" 2>/dev/null || true)"
275
310
  if [[ -z "$base_sha" ]]; then
276
311
  printf '%s\n' "- unable to compute merge-base"
277
312
  exit 0
278
313
  fi
279
314
 
280
315
  conflict_paths="$(
281
- git merge-tree "$base_sha" HEAD "origin/${PR_BASE_REF}" \
316
+ git merge-tree "$base_sha" HEAD "${PR_BASE_TRACKING_REF}" \
282
317
  | awk '
283
318
  /^changed in both$/ { capture=1; next }
284
319
  capture && /^( base| our| their) / {
@@ -333,6 +368,7 @@ PR_HOST_MERGE_SUMMARY_TEXT="$PR_HOST_MERGE_SUMMARY_TEXT" \
333
368
  PR_REPO_ROOT="$PR_REPO_ROOT" \
334
369
  PR_DEPENDENCY_SOURCE_ROOT="$PR_DEPENDENCY_SOURCE_ROOT" \
335
370
  PR_WORKTREE="$WORKTREE" \
371
+ PR_BASE_TRACKING_REF="$PR_BASE_TRACKING_REF" \
336
372
  PR_WEB_PLAYWRIGHT_COMMAND="$WEB_PLAYWRIGHT_COMMAND" \
337
373
  REPO_SLUG="$REPO_SLUG" \
338
374
  TEMPLATE_FILE="$TEMPLATE_FILE" \
@@ -359,11 +395,11 @@ let requiredTargetedVerificationText = '- none';
359
395
  let preApprovedVerificationFallbacksText = '- none';
360
396
  try {
361
397
  const worktree = process.env.PR_WORKTREE || '';
362
- const baseRef = process.env.PR_BASE_REF || 'main';
398
+ const baseTrackingRef = process.env.PR_BASE_TRACKING_REF || `origin/${process.env.PR_BASE_REF || 'main'}`;
363
399
  if (worktree) {
364
400
  const changedFiles = execFileSync(
365
401
  'git',
366
- ['-C', worktree, 'diff', '--name-only', '--diff-filter=ACMR', `origin/${baseRef}...HEAD`],
402
+ ['-C', worktree, 'diff', '--name-only', '--diff-filter=ACMR', `${baseTrackingRef}...HEAD`],
367
403
  { encoding: 'utf8' },
368
404
  )
369
405
  .split('\n')
@@ -423,6 +459,7 @@ const replacements = {
423
459
  '{PR_URL}': process.env.PR_URL || '',
424
460
  '{PR_HEAD_REF}': process.env.PR_HEAD_REF || '',
425
461
  '{PR_BASE_REF}': process.env.PR_BASE_REF || '',
462
+ '{PR_BASE_TRACKING_REF}': process.env.PR_BASE_TRACKING_REF || '',
426
463
  '{PR_BODY}': process.env.PR_BODY || '',
427
464
  '{REPO_SLUG}': process.env.REPO_SLUG || '',
428
465
  '{PR_RISK}': process.env.PR_RISK || '',
@@ -243,12 +243,12 @@ record_scheduled_next_due() {
243
243
  now_epoch="$(date +%s)"
244
244
  next_due_epoch=$((now_epoch + interval_seconds))
245
245
  NEXT_WAKE_EPOCH="${next_due_epoch}"
246
- NEXT_WAKE_AT="$(date -u -r "${next_due_epoch}" +"%Y-%m-%dT%H:%M:%SZ")"
246
+ NEXT_WAKE_AT="$(flow_format_epoch_utc "${next_due_epoch}")"
247
247
  state_file="${SCHEDULED_STATE_DIR}/${ISSUE_ID}.env"
248
248
  cat >"${state_file}" <<EOF
249
249
  INTERVAL_SECONDS=${interval_seconds}
250
250
  LAST_STARTED_EPOCH=${now_epoch}
251
- LAST_STARTED_AT=$(date -u -r "${now_epoch}" +"%Y-%m-%dT%H:%M:%SZ")
251
+ LAST_STARTED_AT=$(flow_format_epoch_utc "${now_epoch}")
252
252
  NEXT_DUE_EPOCH=${next_due_epoch}
253
253
  NEXT_DUE_AT=${NEXT_WAKE_AT}
254
254
  UPDATED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
@@ -111,9 +111,10 @@ function renderOverview(snapshot) {
111
111
  acc.cooldowns += profile.counts.provider_cooldowns;
112
112
  acc.queue += profile.counts.queued_issues;
113
113
  acc.alerts += profile.counts.alerts || 0;
114
+ acc.pendingGithubWrites += profile.counts.pending_github_writes || 0;
114
115
  return acc;
115
116
  },
116
- { activeRuns: 0, runningRuns: 0, implementedRuns: 0, reportedRuns: 0, blockedRuns: 0, controllers: 0, cooldowns: 0, queue: 0, alerts: 0 },
117
+ { activeRuns: 0, runningRuns: 0, implementedRuns: 0, reportedRuns: 0, blockedRuns: 0, controllers: 0, cooldowns: 0, queue: 0, alerts: 0, pendingGithubWrites: 0 },
117
118
  );
118
119
 
119
120
  overviewNode.innerHTML = [
@@ -125,6 +126,7 @@ function renderOverview(snapshot) {
125
126
  ["Blocked", totals.blockedRuns],
126
127
  ["Live Controllers", totals.controllers],
127
128
  ["Provider Cooldowns", totals.cooldowns],
129
+ ["Pending GitHub Writes", totals.pendingGithubWrites],
128
130
  ["Alerts", totals.alerts],
129
131
  ["Queued Issues", totals.queue],
130
132
  ]
@@ -237,6 +239,8 @@ function renderProfile(profile) {
237
239
  ["Live controllers", profile.counts.live_resident_controllers],
238
240
  ["Stale controllers", profile.counts.stale_resident_controllers],
239
241
  ["Provider cooldowns", profile.counts.provider_cooldowns],
242
+ ["Pending GitHub writes", profile.counts.pending_github_writes || 0],
243
+ ["Failed GitHub writes", profile.counts.failed_github_writes || 0],
240
244
  ["Alerts", profile.counts.alerts || 0],
241
245
  ["Issue retries", profile.counts.active_retries],
242
246
  ["Queued issues", profile.counts.queued_issues],
@@ -378,6 +382,26 @@ function renderProfile(profile) {
378
382
  "No claimed issues.",
379
383
  );
380
384
 
385
+ const githubOutbox = profile.github_outbox || { counts: {}, pending: [] };
386
+ const githubOutboxTable = renderTable(
387
+ [
388
+ { label: "Type", render: (row) => row.type || "n/a" },
389
+ { label: "Target", render: (row) => `${row.kind || row.type || "write"} #${row.number || "?"}` },
390
+ {
391
+ label: "Payload",
392
+ render: (row) => {
393
+ if (row.type === "labels") {
394
+ return `+${row.add_count || 0} / -${row.remove_count || 0}`;
395
+ }
396
+ return row.body_preview || "n/a";
397
+ },
398
+ },
399
+ { label: "Created", render: (row) => row.created_at ? `${relativeTime(row.created_at)}<div class="muted">${row.created_at}</div>` : "n/a" },
400
+ ],
401
+ githubOutbox.pending || [],
402
+ "No pending GitHub write intents.",
403
+ );
404
+
381
405
  const codexRotationPanel =
382
406
  profile.coding_worker === "codex"
383
407
  ? `
@@ -452,6 +476,11 @@ function renderProfile(profile) {
452
476
  <h3>Claimed Issues</h3>
453
477
  ${claimsTable}
454
478
  </section>
479
+ <section class="panel">
480
+ <h3>GitHub Outbox</h3>
481
+ <p class="panel-subtitle">Local write intents queued while ACP defers or retries GitHub sync. Pending ${githubOutbox.counts?.pending || 0}, sent ${githubOutbox.counts?.sent || 0}, failed ${githubOutbox.counts?.failed || 0}.</p>
482
+ ${githubOutboxTable}
483
+ </section>
455
484
  </section>
456
485
  </article>
457
486
  `;
@@ -697,6 +697,57 @@ def collect_pr_retries(state_root: Path) -> list[dict[str, Any]]:
697
697
  return items
698
698
 
699
699
 
700
+ def collect_github_outbox(state_root: Path) -> dict[str, Any]:
701
+ outbox_root = state_root / "github-outbox"
702
+ pending_root = outbox_root / "pending"
703
+ sent_root = outbox_root / "sent"
704
+ failed_root = outbox_root / "failed"
705
+
706
+ def list_items(root: Path, limit: int | None = None) -> list[dict[str, Any]]:
707
+ if not root.is_dir():
708
+ return []
709
+
710
+ items: list[dict[str, Any]] = []
711
+ for path in sorted(root.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True):
712
+ payload = read_json_file(path)
713
+ items.append(
714
+ {
715
+ "type": str(payload.get("type", "")),
716
+ "repo_slug": str(payload.get("repo_slug", "")),
717
+ "number": str(payload.get("number", "")),
718
+ "kind": str(payload.get("kind", "")),
719
+ "created_at": str(payload.get("created_at", "")),
720
+ "updated_at": file_mtime_iso(path),
721
+ "file": str(path),
722
+ "add_count": len(payload.get("add", []) or []),
723
+ "remove_count": len(payload.get("remove", []) or []),
724
+ "body_preview": summarize_whitespace(str(payload.get("body", "")))[:120],
725
+ }
726
+ )
727
+ if limit is not None and len(items) >= limit:
728
+ break
729
+ return items
730
+
731
+ all_pending_items = list_items(pending_root)
732
+ pending_items = all_pending_items[:20]
733
+ sent_items = list_items(sent_root, limit=5)
734
+ failed_items = list_items(failed_root, limit=5)
735
+
736
+ return {
737
+ "pending": pending_items,
738
+ "sent_recent": sent_items,
739
+ "failed_recent": failed_items,
740
+ "counts": {
741
+ "pending": len(all_pending_items),
742
+ "sent": len(list(sent_root.glob("*.json"))) if sent_root.is_dir() else 0,
743
+ "failed": len(list(failed_root.glob("*.json"))) if failed_root.is_dir() else 0,
744
+ "pending_comments": sum(1 for item in all_pending_items if item["type"] == "comment"),
745
+ "pending_approvals": sum(1 for item in all_pending_items if item["type"] == "approval"),
746
+ "pending_label_updates": sum(1 for item in all_pending_items if item["type"] == "labels"),
747
+ },
748
+ }
749
+
750
+
700
751
  def resolve_history_root(render_env: dict[str, str], yaml_env: dict[str, str], runs_root: Path) -> Path:
701
752
  configured = (
702
753
  render_env.get("EFFECTIVE_HISTORY_ROOT", "").strip()
@@ -726,6 +777,7 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
726
777
  retries = collect_issue_retries(state_root)
727
778
  pr_retries = collect_pr_retries(state_root)
728
779
  queue = collect_issue_queue(state_root)
780
+ github_outbox = collect_github_outbox(state_root)
729
781
  alerts = [alert for run in (runs + recent_history) for alert in run.get("alerts", [])]
730
782
  codex_rotation = collect_codex_rotation(render_env)
731
783
 
@@ -773,6 +825,8 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
773
825
  "active_retries": sum(1 for item in retries if not item.get("ready", True)),
774
826
  "scheduled_issues": len(scheduled),
775
827
  "alerts": len(alerts),
828
+ "pending_github_writes": github_outbox["counts"]["pending"],
829
+ "failed_github_writes": github_outbox["counts"]["failed"],
776
830
  },
777
831
  "runs": runs,
778
832
  "recent_history": recent_history,
@@ -784,6 +838,7 @@ def build_profile_snapshot(profile_id: str, registry_root: Path) -> dict[str, An
784
838
  "issue_retries": retries,
785
839
  "pr_retries": pr_retries,
786
840
  "issue_queue": queue,
841
+ "github_outbox": github_outbox,
787
842
  }
788
843
 
789
844
 
@@ -11,6 +11,7 @@ PR metadata:
11
11
  - PR: {PR_NUMBER} - {PR_TITLE}
12
12
  - URL: {PR_URL}
13
13
  - Base branch: {PR_BASE_REF}
14
+ - Base tracking ref: {PR_BASE_TRACKING_REF}
14
15
  - Head branch: {PR_HEAD_REF}
15
16
  - Linked issue: {PR_LINKED_ISSUE_ID}
16
17
  - Risk classification: {PR_RISK}
@@ -55,7 +56,7 @@ Required flow:
55
56
 
56
57
  1. Inspect the current diff and the failing/pending CI signals first:
57
58
  - `openspec list` if the repo uses OpenSpec
58
- - `git diff --stat origin/main...HEAD`
59
+ - `git diff --stat {PR_BASE_TRACKING_REF}...HEAD`
59
60
  - `git status --short`
60
61
  - if `Merge state` is not `CLEAN` or `Mergeable` is `FALSE`, treat branch drift/conflicts as the concrete blocker first
61
62
  - if `Actionable current-head review findings` is not `- none`, treat those findings as the concrete blockers to address first
@@ -68,6 +69,7 @@ Required flow:
68
69
  - do not run `git fetch`, `git merge`, `git rebase`, `git commit`, `git push`, or other Git metadata-writing commands from inside this worker; host-side wrappers own those steps
69
70
  3. If the blocker is branch drift or a merge conflict, use the already-prepared local refs and make the smallest branch-local source update needed to restore mergeability on this PR branch. Keep the resolution scoped to the PR intent; do not rewrite unrelated code.
70
71
  - Treat `Current local merge-conflict paths` as the authoritative conflict list to clear.
72
+ - Treat `{PR_BASE_TRACKING_REF}` as the authoritative base ref for any read-only diff or merge-base inspection.
71
73
  - Do not stop after fixing only one file if other conflict paths remain.
72
74
  - Before you declare success, rerun local merge simulation and confirm there are no remaining conflict paths for this branch against `{PR_BASE_REF}`.
73
75
  4. Make the smallest change that fixes the concrete PR blockers on this existing branch.