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/README.md CHANGED
@@ -33,7 +33,7 @@ however, and suddenly that "not smart enough" free model is grinding through
33
33
  your issue backlog like a junior developer who is weirdly enthusiastic about
34
34
  reading CI logs.
35
35
 
36
- That is what ACP does. It turns a GitHub repo into a managed runtime: a
36
+ That is what ACP does. It turns a forge-backed repo into a managed runtime: a
37
37
  repeatable setup, a stable home for state, a heartbeat that keeps agents
38
38
  scheduled and supervised, and a dashboard you can actually glance at without
39
39
  spelunking through temp folders, worktrees, or half-remembered `tmux` sessions.
@@ -73,7 +73,7 @@ monthly API budget in a long weekend, or enter a retry loop that only stops when
73
73
  the credit card does.
74
74
 
75
75
  ACP is the person standing next to the fuse. It enforces launch limits,
76
- reconciles outcomes before touching GitHub, validates before it publishes, and
76
+ reconciles outcomes before touching your forge, validates before it publishes, and
77
77
  respects cooldowns instead of hammering a provider at full throttle. The agent
78
78
  gets to be smart and fast. ACP makes sure "smart and fast" does not also mean
79
79
  "unattended and irreversible."
@@ -98,6 +98,27 @@ it had a supervisor."
98
98
  | Run reproducible agent research cheaply | Cost-controlled execution harness for studying agent behavior, output quality, or prompting strategies |
99
99
  | Enforce safety by architecture, not by hope | Launch limits, reconcile gates, and cooldowns that are built into the runtime, not left to chance |
100
100
 
101
+ ## Why Gitea Local-First
102
+
103
+ ACP started GitHub-first, but a local-first Gitea loop is often a better daily
104
+ working setup:
105
+
106
+ - It reduces dependence on GitHub API rate limits for routine issue and PR work.
107
+ - It lets agents collaborate against a local forge while you keep GitHub as the
108
+ public mirror or release boundary.
109
+ - It gives you a safer place to let ACP iterate quickly, because local Gitea is
110
+ cheaper to reset, inspect, and isolate than a live hosted repo.
111
+ - It matches how ACP now works best operationally: local runtime state,
112
+ local worktrees, local dashboard, and a forge that can live on the same
113
+ machine.
114
+
115
+ The intended model is:
116
+
117
+ - `Gitea main` is the working mainline ACP automates day to day.
118
+ - Your local source checkout auto-syncs from that forge mainline.
119
+ - GitHub becomes the publish/release mirror once the codebase is stable enough
120
+ to push outward.
121
+
101
122
  ## Use Cases
102
123
 
103
124
  Teams and solo builders usually reach for ACP when one of these starts to feel
@@ -211,7 +232,7 @@ flowchart LR
211
232
  Scheduler --> Workers["issue / PR worker launchers"]
212
233
  Workers --> Backends["codex / claude / openclaw / ollama / pi / opencode / kilo"]
213
234
  Backends --> Reconcile["reconcile issue / PR session"]
214
- Reconcile --> GitHub["issues / PRs / labels / comments"]
235
+ Reconcile --> Forge["issues / PRs / labels / comments"]
215
236
  Scheduler --> State["runs + state + history"]
216
237
  State --> Dashboard["dashboard snapshot + UI"]
217
238
  ```
@@ -227,7 +248,7 @@ sequenceDiagram
227
248
  participant Heartbeat
228
249
  participant Worker
229
250
  participant Reconcile
230
- participant GitHub
251
+ participant Forge
231
252
 
232
253
  Operator->>RuntimeCtl: runtime start --profile-id <id>
233
254
  RuntimeCtl->>Supervisor: keep runtime alive
@@ -236,7 +257,7 @@ sequenceDiagram
236
257
  Bootstrap->>Heartbeat: invoke published heartbeat
237
258
  Heartbeat->>Worker: launch eligible issue/PR flow
238
259
  Worker->>Reconcile: emit result artifacts
239
- Reconcile->>GitHub: labels, comments, PR actions
260
+ Reconcile->>Forge: labels, comments, PR actions
240
261
  end
241
262
  ```
242
263
 
@@ -267,9 +288,9 @@ system.
267
288
  | --- | --- | --- | --- |
268
289
  | Node.js `>= 18` | yes | Runs the npm package entrypoint and `npx` wrapper. | CI runs on Node `22`. Node `20` or `22` both work fine. |
269
290
  | `bash` | yes | All runtime, profile, and worker orchestration scripts are Bash. | Your login shell can be `zsh`; `bash` just needs to be on `PATH`. |
270
- | `git` | yes | Manages worktrees, checks branch state, and coordinates repo automation. | Required even if you interact only through GitHub issues and PRs. |
271
- | `gh` | yes | GitHub CLI auth and API access for issues, PRs, labels, and metadata. | Run `gh auth login` before first use. |
272
- | `jq` | yes | Parses JSON from `gh` output and worker metadata throughout. | Missing `jq` will break GitHub-heavy and runtime flows. |
291
+ | `git` | yes | Manages worktrees, checks branch state, and coordinates repo automation. | Required even if you interact only through forge issues and PRs. |
292
+ | `gh` | for GitHub-first setups | GitHub CLI auth and API access for issues, PRs, labels, and metadata. | Run `gh auth login` before first use when `--forge-provider github`. |
293
+ | `jq` | yes | Parses JSON from `gh` output and worker metadata throughout. | Missing `jq` will break GitHub-heavy and Gitea-heavy runtime flows. |
273
294
  | `python3` | yes | Powers the dashboard server, snapshot renderer, and config helpers. | Required for both dashboard use and several internal scripts. |
274
295
  | `tmux` | yes | Runs long-lived worker sessions and captures their status. | Missing `tmux` means background worker workflows will not launch. |
275
296
  | Worker CLI (backend-specific) | depends on backend | The coding agent for a profile. Supported: `codex`, `claude`, `openclaw` (production); `ollama`, `pi`, `opencode`, `kilo` (experimental). | Install and authenticate your chosen backend before starting background runs. |
@@ -279,8 +300,10 @@ system.
279
300
  | `kilo` CLI | for `kilo` backend | TypeScript coding agent ([kilocode/cli](https://github.com/Kilo-Org/kilocode)). | Install via `npm i -g @kilocode/cli`. |
280
301
  | Bundled `codex-quota` + ACP quota manager | automatic for Codex | Quota-aware failover and health signals for Codex profiles. | Bundled by default. Override with `ACP_CODEX_QUOTA_BIN` only if you have a custom setup. |
281
302
 
282
- Make sure `gh` and your chosen worker backend are both authenticated for the
283
- same OS user before starting any background runtime.
303
+ Make sure your chosen worker backend is authenticated for the same OS user
304
+ before starting any background runtime. For GitHub-first setups, authenticate
305
+ `gh`. For Gitea local-first setups, provide `--gitea-base-url` plus a token or
306
+ username/password during setup so ACP can write issues, PR comments, and labels.
284
307
 
285
308
  ## Install
286
309
 
@@ -313,7 +336,7 @@ npx agent-control-plane@latest setup
313
336
  The wizard walks you through the full setup in one pass:
314
337
 
315
338
  1. Detects the current repo and suggests sane defaults
316
- 2. Installs missing dependencies and authenticates `gh`
339
+ 2. Captures forge mode (`github` or `gitea`) and the auth/settings that mode needs
317
340
  3. Checks backend readiness (API keys for openclaw/pi, local server for ollama)
318
341
  4. Scaffolds the profile, runs health checks, starts the runtime
319
342
  5. Launches the monitoring dashboard in the background
@@ -343,16 +366,41 @@ npx agent-control-plane@latest setup \
343
366
  With `--json`, ACP emits a single structured object on `stdout` and sends
344
367
  progress logs to `stderr`, which keeps parsing stable.
345
368
 
369
+ Example: local-first Gitea setup
370
+
371
+ ```bash
372
+ npx agent-control-plane@latest setup \
373
+ --forge-provider gitea \
374
+ --repo-slug acp-admin/my-repo \
375
+ --gitea-base-url http://127.0.0.1:3000 \
376
+ --gitea-token <token> \
377
+ --start-runtime \
378
+ --start-dashboard
379
+ ```
380
+
381
+ This writes the forge settings into the profile `runtime.env`, so later
382
+ heartbeat, reconcile, and publish steps keep talking to the same Gitea
383
+ instance without extra shell exports.
384
+
346
385
  ### Option B — Manual setup
347
386
 
348
387
  If you prefer explicit control over each step:
349
388
 
350
- **1. Authenticate GitHub**
389
+ **1. Authenticate the working forge**
351
390
 
352
391
  ```bash
353
392
  gh auth login
354
393
  ```
355
394
 
395
+ For Gitea local-first, skip `gh auth login` and pass Gitea settings directly to
396
+ `setup` or `init`:
397
+
398
+ ```bash
399
+ --forge-provider gitea \
400
+ --gitea-base-url http://127.0.0.1:3000 \
401
+ --gitea-token <token>
402
+ ```
403
+
356
404
  **2. Install the packaged runtime**
357
405
 
358
406
  ```bash
@@ -368,6 +416,7 @@ re-run after upgrades.
368
416
  npx agent-control-plane@latest init \
369
417
  --profile-id my-repo \
370
418
  --repo-slug owner/my-repo \
419
+ --forge-provider github \
371
420
  --repo-root ~/src/my-repo \
372
421
  --agent-root ~/.agent-runtime/projects/my-repo \
373
422
  --worktree-root ~/src/my-repo-worktrees \
@@ -377,7 +426,9 @@ npx agent-control-plane@latest init \
377
426
  | Flag | Purpose |
378
427
  | --- | --- |
379
428
  | `--profile-id` | Short name used in all ACP commands |
380
- | `--repo-slug` | GitHub repo ACP should track |
429
+ | `--repo-slug` | Forge repo ACP should track |
430
+ | `--forge-provider` | Which forge ACP should automate (`github` or `gitea`) |
431
+ | `--gitea-base-url` | Base URL when `--forge-provider gitea` |
381
432
  | `--repo-root` | Path to your local checkout |
382
433
  | `--agent-root` | Where ACP keeps per-project runtime state |
383
434
  | `--worktree-root` | Where ACP places repo worktrees |
@@ -407,9 +458,8 @@ grouped and inspectable without digging through scattered temp files.
407
458
  ## Starter Issues
408
459
 
409
460
  The setup wizard can create a set of recurring `agent-keep-open` issues on your
410
- repo so ACP starts working immediately after installation. Each issue carries the
411
- `agent-ready` and `agent-keep-open` labels, and ACP picks them up on its next
412
- heartbeat cycle.
461
+ repo so ACP starts working immediately after installation. ACP picks them up on
462
+ its next heartbeat cycle without requiring a separate readiness label.
413
463
 
414
464
  Built-in templates:
415
465
 
@@ -421,9 +471,9 @@ Built-in templates:
421
471
  | Dependency audit | Fix vulnerabilities and update safe patches |
422
472
  | Refactoring sweep | Reduce complexity and duplication |
423
473
 
424
- You can also create your own recurring issues just add the `agent-ready` and
425
- `agent-keep-open` labels to any GitHub issue and ACP will work on it
426
- continuously.
474
+ You can also create your own recurring issues by adding the
475
+ `agent-keep-open` label to any GitHub issue. ACP will keep revisiting that
476
+ issue continuously unless it is blocked or already claimed by an open agent PR.
427
477
 
428
478
  To skip this step during setup, pass `--no-create-starter-issues`.
429
479
 
@@ -7,7 +7,7 @@
7
7
  {
8
8
  "id": "issue-implementation",
9
9
  "kind": "issue",
10
- "trigger": "Open issue with agent-ready and without agent-running/agent-blocked",
10
+ "trigger": "Open issue without agent-running, without an open agent PR claim, and not blocked by retry policy",
11
11
  "entrypoint": "tools/bin/start-issue-worker.sh",
12
12
  "summary": "Primary implementation loop for focused issues that should end in a PR or a blocked report."
13
13
  },
package/bin/pr-risk.sh CHANGED
@@ -26,10 +26,10 @@ fi
26
26
  gh_api_json_matching_or_fallback() {
27
27
  local fallback="${1:?fallback required}"
28
28
  local jq_filter="${2:?jq filter required}"
29
- shift 2
29
+ local route="${3:?route required}"
30
30
  local output=""
31
31
 
32
- output="$(gh api "$@" 2>/dev/null || true)"
32
+ output="$(flow_github_api_repo "${REPO_SLUG}" "${route}" 2>/dev/null || true)"
33
33
  if jq -e "${jq_filter}" >/dev/null 2>&1 <<<"${output}"; then
34
34
  printf '%s\n' "${output}"
35
35
  return 0
@@ -38,17 +38,32 @@ gh_api_json_matching_or_fallback() {
38
38
  printf '%s\n' "${fallback}"
39
39
  }
40
40
 
41
- PR_JSON="$(gh pr view "$PR_NUMBER" -R "$REPO_SLUG" --json number,title,url,body,isDraft,headRefName,headRefOid,baseRefName,labels,files,mergeStateStatus,reviewDecision,reviewRequests,statusCheckRollup,comments 2>/dev/null)" \
42
- || { printf 'pr-risk: gh pr view failed for PR %s (repo: %s)\n' "$PR_NUMBER" "$REPO_SLUG" >&2; exit 1; }
41
+ PR_JSON="$(flow_github_pr_view_json "$REPO_SLUG" "$PR_NUMBER" 2>/dev/null || true)"
42
+ if ! jq -e '.number? != null' >/dev/null 2>&1 <<<"${PR_JSON:-}"; then
43
+ if flow_using_gitea; then
44
+ printf 'pr-risk: forge PR view failed for PR %s (repo: %s)\n' "$PR_NUMBER" "$REPO_SLUG" >&2
45
+ exit 1
46
+ fi
47
+ PR_JSON="$(gh pr view "$PR_NUMBER" -R "$REPO_SLUG" --json number,title,url,body,isDraft,headRefName,headRefOid,baseRefName,labels,files,mergeStateStatus,reviewDecision,reviewRequests,statusCheckRollup,comments 2>/dev/null)" \
48
+ || { printf 'pr-risk: forge PR view failed for PR %s (repo: %s)\n' "$PR_NUMBER" "$REPO_SLUG" >&2; exit 1; }
49
+ fi
43
50
  PR_HEAD_SHA="$(jq -r '.headRefOid // ""' <<<"$PR_JSON")"
44
51
  PR_HEAD_COMMITTED_AT=""
45
52
  if [[ -n "${PR_HEAD_SHA}" ]]; then
46
- PR_HEAD_COMMITTED_AT="$(gh api "repos/${REPO_SLUG}/commits/${PR_HEAD_SHA}" --jq .commit.committer.date 2>/dev/null || true)"
53
+ PR_HEAD_COMMITTED_AT="$(
54
+ flow_github_api_repo "${REPO_SLUG}" "commits/${PR_HEAD_SHA}" 2>/dev/null \
55
+ | jq -r '.commit.committer.date // .commit.author.date // .created // .timestamp // ""' 2>/dev/null \
56
+ || true
57
+ )"
58
+ fi
59
+ if flow_using_gitea; then
60
+ REVIEW_COMMENTS_JSON='[]'
61
+ else
62
+ REVIEW_COMMENTS_JSON="$(gh_api_json_matching_or_fallback '[]' 'type == "array"' "pulls/${PR_NUMBER}/comments")"
47
63
  fi
48
- REVIEW_COMMENTS_JSON="$(gh_api_json_matching_or_fallback '[]' 'type == "array"' "repos/${REPO_SLUG}/pulls/${PR_NUMBER}/comments")"
49
64
  CHECK_RUNS_JSON='{"check_runs":[]}'
50
65
  if [[ -n "${PR_HEAD_SHA}" ]]; then
51
- CHECK_RUNS_JSON="$(gh_api_json_matching_or_fallback '{"check_runs":[]}' 'type == "object" and ((.check_runs // []) | type == "array")' "repos/${REPO_SLUG}/commits/${PR_HEAD_SHA}/check-runs")"
66
+ CHECK_RUNS_JSON="$(gh_api_json_matching_or_fallback '{"check_runs":[]}' 'type == "object" and ((.check_runs // []) | type == "array")' "commits/${PR_HEAD_SHA}/check-runs")"
52
67
  fi
53
68
 
54
69
  PR_JSON="$PR_JSON" PR_HEAD_SHA="$PR_HEAD_SHA" PR_HEAD_COMMITTED_AT="$PR_HEAD_COMMITTED_AT" REVIEW_COMMENTS_JSON="$REVIEW_COMMENTS_JSON" CHECK_RUNS_JSON="$CHECK_RUNS_JSON" PR_LANE_OVERRIDE="${PR_LANE_OVERRIDE:-}" MANAGED_PR_PREFIXES_JSON="$MANAGED_PR_PREFIXES_JSON" MANAGED_PR_ISSUE_CAPTURE_REGEX="$MANAGED_PR_ISSUE_CAPTURE_REGEX" ALLOW_INFRA_CI_BYPASS="$ALLOW_INFRA_CI_BYPASS" LOCAL_FIRST_PR_POLICY="$LOCAL_FIRST_PR_POLICY" node <<'EOF'
@@ -100,7 +100,7 @@ fi
100
100
  bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "$REPO_SLUG" --number "$PR_NUMBER" "${args[@]}" >/dev/null
101
101
 
102
102
  if [[ -n "$linked_issue_id" ]]; then
103
- bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "$REPO_SLUG" --number "$linked_issue_id" --remove agent-ready --remove agent-running >/dev/null || true
103
+ bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "$REPO_SLUG" --number "$linked_issue_id" --remove agent-running >/dev/null || true
104
104
  fi
105
105
 
106
106
  printf 'PR_NUMBER=%s\n' "$PR_NUMBER"
@@ -36,13 +36,115 @@ HEARTBEAT_SNAPSHOT_CACHE_DIR="${TMPDIR:-/tmp}/heartbeat-snapshot.$$"
36
36
  # pipes), so in-memory variables would be lost immediately. We rely exclusively
37
37
  # on the PID-scoped disk cache under HEARTBEAT_SNAPSHOT_CACHE_DIR.
38
38
 
39
+ heartbeat_github_mirror_dir() {
40
+ [[ -n "${STATE_ROOT:-}" ]] || return 1
41
+ printf '%s/github-mirror/heartbeat\n' "${STATE_ROOT}"
42
+ }
43
+
44
+ heartbeat_github_mirror_file() {
45
+ local mirror_key="${1:?mirror key required}"
46
+ local mirror_dir=""
47
+ mirror_dir="$(heartbeat_github_mirror_dir)" || return 1
48
+ printf '%s/%s.json\n' "${mirror_dir}" "${mirror_key}"
49
+ }
50
+
51
+ heartbeat_json_matches_kind() {
52
+ local payload="${1:-}"
53
+ local expected_kind="${2:-}"
54
+
55
+ [[ -n "${payload}" && -n "${expected_kind}" ]] || return 1
56
+ jq -e --arg kind "${expected_kind}" 'type == $kind' >/dev/null <<<"${payload}"
57
+ }
58
+
59
+ heartbeat_write_json_mirror() {
60
+ local mirror_key="${1:?mirror key required}"
61
+ local payload="${2:-}"
62
+ local expected_kind="${3:?expected kind required}"
63
+ local source_mode="${4:-live}"
64
+ local mirror_file=""
65
+ local mirror_dir=""
66
+ local meta_file=""
67
+ local tmp_file=""
68
+ local meta_tmp_file=""
69
+ local updated_at=""
70
+
71
+ heartbeat_json_matches_kind "${payload}" "${expected_kind}" || return 1
72
+ mirror_dir="$(heartbeat_github_mirror_dir)" || return 1
73
+ mirror_file="${mirror_dir}/${mirror_key}.json"
74
+ meta_file="${mirror_dir}/${mirror_key}.env"
75
+ mkdir -p "${mirror_dir}"
76
+ updated_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
77
+ tmp_file="${mirror_file}.tmp.$$"
78
+ meta_tmp_file="${meta_file}.tmp.$$"
79
+ printf '%s' "${payload}" >"${tmp_file}"
80
+ {
81
+ printf 'MIRROR_KEY=%s\n' "${mirror_key}"
82
+ printf 'JSON_KIND=%s\n' "${expected_kind}"
83
+ printf 'SOURCE=%s\n' "${source_mode}"
84
+ printf 'UPDATED_AT=%s\n' "${updated_at}"
85
+ } >"${meta_tmp_file}"
86
+ mv "${tmp_file}" "${mirror_file}"
87
+ mv "${meta_tmp_file}" "${meta_file}"
88
+ }
89
+
90
+ heartbeat_read_json_mirror() {
91
+ local mirror_key="${1:?mirror key required}"
92
+ local expected_kind="${2:?expected kind required}"
93
+ local default_json="${3:-}"
94
+ local mirror_file=""
95
+ local payload=""
96
+
97
+ mirror_file="$(heartbeat_github_mirror_file "${mirror_key}" 2>/dev/null || true)"
98
+ if [[ -n "${mirror_file}" && -f "${mirror_file}" ]]; then
99
+ payload="$(cat "${mirror_file}" 2>/dev/null || true)"
100
+ if heartbeat_json_matches_kind "${payload}" "${expected_kind}"; then
101
+ printf '%s\n' "${payload}"
102
+ return 0
103
+ fi
104
+ fi
105
+
106
+ printf '%s\n' "${default_json}"
107
+ }
108
+
109
+ heartbeat_cached_json_with_local_mirror() {
110
+ local cache_file="${1:?cache file required}"
111
+ local mirror_key="${2:?mirror key required}"
112
+ local expected_kind="${3:?expected kind required}"
113
+ local default_json="${4:-}"
114
+ local live_fetch_fn="${5:?live fetch function required}"
115
+ local payload=""
116
+
117
+ shift 5
118
+
119
+ if [[ -f "${cache_file}" ]]; then
120
+ cat "${cache_file}"
121
+ return 0
122
+ fi
123
+
124
+ if payload="$("${live_fetch_fn}" "$@" 2>/dev/null)" && heartbeat_json_matches_kind "${payload}" "${expected_kind}"; then
125
+ heartbeat_write_json_mirror "${mirror_key}" "${payload}" "${expected_kind}" "live" || true
126
+ else
127
+ payload="$(heartbeat_read_json_mirror "${mirror_key}" "${expected_kind}" "${default_json}")"
128
+ fi
129
+
130
+ printf '%s' "${payload}" >"${cache_file}"
131
+ printf '%s\n' "${payload}"
132
+ }
133
+
39
134
  heartbeat_cached_issue_list_json() {
40
135
  mkdir -p "${HEARTBEAT_SNAPSHOT_CACHE_DIR}"
41
136
  local cache_file="${HEARTBEAT_SNAPSHOT_CACHE_DIR}/issues.json"
42
137
  if [[ ! -f "${cache_file}" ]]; then
43
- local snapshot
44
- snapshot="$(flow_github_issue_list_json "$REPO_SLUG" open 100 2>/dev/null || true)"
45
- printf '%s' "${snapshot}" >"${cache_file}"
138
+ heartbeat_cached_json_with_local_mirror \
139
+ "${cache_file}" \
140
+ "issues-open-100" \
141
+ "array" \
142
+ '[]' \
143
+ flow_github_issue_list_json_live \
144
+ "$REPO_SLUG" \
145
+ open \
146
+ 100
147
+ return 0
46
148
  fi
47
149
  cat "${cache_file}"
48
150
  }
@@ -51,9 +153,16 @@ heartbeat_cached_pr_list_json() {
51
153
  mkdir -p "${HEARTBEAT_SNAPSHOT_CACHE_DIR}"
52
154
  local cache_file="${HEARTBEAT_SNAPSHOT_CACHE_DIR}/prs.json"
53
155
  if [[ ! -f "${cache_file}" ]]; then
54
- local snapshot
55
- snapshot="$(flow_github_pr_list_json "$REPO_SLUG" open 100 2>/dev/null || true)"
56
- printf '%s' "${snapshot}" >"${cache_file}"
156
+ heartbeat_cached_json_with_local_mirror \
157
+ "${cache_file}" \
158
+ "prs-open-100" \
159
+ "array" \
160
+ '[]' \
161
+ flow_github_pr_list_json_live \
162
+ "$REPO_SLUG" \
163
+ open \
164
+ 100
165
+ return 0
57
166
  fi
58
167
  cat "${cache_file}"
59
168
  }
@@ -111,7 +220,6 @@ heartbeat_retry_reason_is_baseline_blocked() {
111
220
  heartbeat_issue_json_cached() {
112
221
  local issue_id="${1:?issue id required}"
113
222
  local cache_file=""
114
- local issue_json=""
115
223
 
116
224
  if [[ ! -d "${HEARTBEAT_ISSUE_JSON_CACHE_DIR}" ]]; then
117
225
  mkdir -p "${HEARTBEAT_ISSUE_JSON_CACHE_DIR}"
@@ -123,9 +231,14 @@ heartbeat_issue_json_cached() {
123
231
  return 0
124
232
  fi
125
233
 
126
- issue_json="$(flow_github_issue_view_json "$REPO_SLUG" "$issue_id" 2>/dev/null || true)"
127
- printf '%s' "${issue_json}" >"${cache_file}"
128
- printf '%s\n' "${issue_json}"
234
+ heartbeat_cached_json_with_local_mirror \
235
+ "${cache_file}" \
236
+ "issue-${issue_id}" \
237
+ "object" \
238
+ '{}' \
239
+ flow_github_issue_view_json_live \
240
+ "$REPO_SLUG" \
241
+ "$issue_id"
129
242
  }
130
243
 
131
244
  heartbeat_open_agent_pr_issue_ids() {
@@ -483,9 +596,9 @@ heartbeat_mark_issue_running() {
483
596
  local cached_json
484
597
  cached_json="$(heartbeat_issue_json_cached "$issue_id" 2>/dev/null || true)"
485
598
  if [[ "$is_heavy" == "yes" ]]; then
486
- ACP_CACHED_ISSUE_JSON="${cached_json}" bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "$REPO_SLUG" --number "$issue_id" --remove agent-ready --remove agent-blocked --add agent-running --add agent-e2e-heavy >/dev/null || true
599
+ ACP_CACHED_ISSUE_JSON="${cached_json}" bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "$REPO_SLUG" --number "$issue_id" --remove agent-blocked --add agent-running --add agent-e2e-heavy >/dev/null || true
487
600
  else
488
- ACP_CACHED_ISSUE_JSON="${cached_json}" bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "$REPO_SLUG" --number "$issue_id" --remove agent-ready --remove agent-blocked --add agent-running >/dev/null || true
601
+ ACP_CACHED_ISSUE_JSON="${cached_json}" bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "$REPO_SLUG" --number "$issue_id" --remove agent-blocked --add agent-running >/dev/null || true
489
602
  fi
490
603
  }
491
604
 
@@ -218,7 +218,7 @@ issue_publish_extra_args() {
218
218
  }
219
219
 
220
220
  issue_remove_running() {
221
- bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "${REPO_SLUG}" --number "$ISSUE_ID" --remove agent-ready --remove agent-running --remove agent-blocked >/dev/null || true
221
+ bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "${REPO_SLUG}" --number "$ISSUE_ID" --remove agent-running --remove agent-blocked >/dev/null || true
222
222
  }
223
223
 
224
224
  issue_mark_blocked() {
@@ -75,7 +75,7 @@ pr_cleanup_linked_issue_session() {
75
75
 
76
76
  local should_close
77
77
  should_close="$(pr_linked_issue_should_close "$issue_id")"
78
- update_args=(--remove agent-ready --remove agent-running --remove agent-blocked --remove agent-e2e-heavy --remove agent-automerge --remove agent-exclusive)
78
+ update_args=(--remove agent-running --remove agent-blocked --remove agent-e2e-heavy --remove agent-automerge --remove agent-exclusive)
79
79
  pr_best_effort_update_labels --repo-slug "${REPO_SLUG}" --number "$issue_id" "${update_args[@]}"
80
80
 
81
81
  local issue_session="${ISSUE_SESSION_PREFIX}${issue_id}"