agent-control-plane 0.1.16 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +93 -14
  2. package/bin/pr-risk.sh +28 -6
  3. package/hooks/heartbeat-hooks.sh +62 -22
  4. package/npm/bin/agent-control-plane.js +360 -10
  5. package/package.json +6 -3
  6. package/references/architecture.md +8 -0
  7. package/references/control-plane-map.md +6 -2
  8. package/references/release-checklist.md +0 -2
  9. package/tools/bin/agent-github-update-labels +6 -1
  10. package/tools/bin/agent-project-catch-up-issue-pr-links +118 -0
  11. package/tools/bin/agent-project-catch-up-merged-prs +78 -21
  12. package/tools/bin/agent-project-catch-up-scheduled-issue-retries +123 -0
  13. package/tools/bin/agent-project-cleanup-session +132 -4
  14. package/tools/bin/agent-project-heartbeat-loop +116 -1461
  15. package/tools/bin/agent-project-reconcile-issue-session +90 -117
  16. package/tools/bin/agent-project-reconcile-pr-session +76 -111
  17. package/tools/bin/agent-project-run-claude-session +12 -2
  18. package/tools/bin/agent-project-run-codex-resilient +86 -9
  19. package/tools/bin/agent-project-run-codex-session +16 -5
  20. package/tools/bin/agent-project-run-kilo-session +356 -14
  21. package/tools/bin/agent-project-run-ollama-session +658 -0
  22. package/tools/bin/agent-project-run-openclaw-session +37 -25
  23. package/tools/bin/agent-project-run-opencode-session +364 -14
  24. package/tools/bin/agent-project-run-pi-session +479 -0
  25. package/tools/bin/agent-project-worker-status +11 -8
  26. package/tools/bin/cleanup-worktree.sh +6 -1
  27. package/tools/bin/flow-config-lib.sh +196 -3
  28. package/tools/bin/flow-resident-worker-lib.sh +120 -2
  29. package/tools/bin/flow-shell-lib.sh +29 -2
  30. package/tools/bin/heartbeat-loop-cache-lib.sh +164 -0
  31. package/tools/bin/heartbeat-loop-counting-lib.sh +306 -0
  32. package/tools/bin/heartbeat-loop-pr-strategy-lib.sh +199 -0
  33. package/tools/bin/heartbeat-loop-scheduling-lib.sh +506 -0
  34. package/tools/bin/heartbeat-loop-worker-lib.sh +319 -0
  35. package/tools/bin/heartbeat-recovery-preflight.sh +13 -1
  36. package/tools/bin/heartbeat-safe-auto.sh +119 -20
  37. package/tools/bin/install-project-launchd.sh +19 -2
  38. package/tools/bin/prepare-worktree.sh +4 -4
  39. package/tools/bin/profile-activate.sh +2 -2
  40. package/tools/bin/profile-adopt.sh +2 -2
  41. package/tools/bin/project-init.sh +1 -1
  42. package/tools/bin/project-launchd-bootstrap.sh +11 -8
  43. package/tools/bin/project-runtimectl.sh +90 -7
  44. package/tools/bin/provider-cooldown-state.sh +14 -14
  45. package/tools/bin/reconcile-bootstrap-lib.sh +113 -0
  46. package/tools/bin/render-flow-config.sh +30 -33
  47. package/tools/bin/resident-issue-controller-lib.sh +448 -0
  48. package/tools/bin/resident-issue-queue-status.py +35 -0
  49. package/tools/bin/run-codex-task.sh +53 -4
  50. package/tools/bin/scaffold-profile.sh +18 -3
  51. package/tools/bin/start-issue-worker.sh +1 -1
  52. package/tools/bin/start-pr-fix-worker.sh +30 -0
  53. package/tools/bin/start-pr-review-worker.sh +31 -0
  54. package/tools/bin/start-resident-issue-loop.sh +27 -438
  55. package/tools/bin/sync-agent-repo.sh +2 -2
  56. package/tools/bin/sync-dependency-baseline.sh +3 -3
  57. package/tools/bin/sync-shared-agent-home.sh +4 -1
  58. package/tools/dashboard/app.js +7 -0
  59. package/tools/dashboard/dashboard_snapshot.py +13 -29
  60. package/tools/templates/pr-fix-template.md +3 -7
  61. package/tools/templates/pr-merge-repair-template.md +3 -7
  62. package/tools/templates/pr-review-template.md +2 -1
  63. package/SKILL.md +0 -149
package/README.md CHANGED
@@ -94,7 +94,7 @@ it had a supervisor."
94
94
  | Get real value out of free-tier models | Quota cooldowns, stall detection, provider failover, and retry backoff that free-tier models need to be actually useful |
95
95
  | Manage multiple repos cleanly | One profile per repo with isolated runtime state, each with its own identity and status |
96
96
  | Observe what is happening without digging through files | Dashboard and `runtime status` that show the real state without spelunking through `tmux` or temp folders |
97
- | Compare worker backends on real workloads | Swap between `codex`, `claude`, and `openclaw` without rebuilding your runtime habits |
97
+ | Compare worker backends on real workloads | Swap between `codex`, `claude`, `openclaw`, `ollama`, `pi`, `opencode`, and `kilo` without rebuilding your runtime habits |
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
 
@@ -130,19 +130,61 @@ Windows.
130
130
  | `codex` | Production-ready | Full ACP workflow support today. |
131
131
  | `claude` | Production-ready | Full ACP workflow support today. |
132
132
  | `openclaw` | Production-ready | Full ACP workflow support, including resident-style runs. |
133
- | `opencode` | Scaffolded | Routing and docs in place; live execution not yet implemented. |
134
- | `kilo` | Scaffolded | Routing and docs in place; live execution not yet implemented. |
133
+ | `ollama` | Experimental | Working adapter with Node.js agentic loop. Runs any model served by a local Ollama instance. Output quality depends on model size — 7–9B models handle exploration well but struggle with complex multi-step tasks. |
134
+ | `pi` | Experimental | Working adapter using the [pi CLI](https://github.com/mariozechner/pi) in `--print --no-session` mode. Connects to any OpenRouter-compatible model. Useful as a lightweight alternative to openclaw for free-tier model testing. |
135
+ | `opencode` | Experimental | Working adapter for [Crush](https://github.com/charmbracelet/crush) (formerly opencode). Non-interactive `crush run` with full tool execution. |
136
+ | `kilo` | Experimental | Working adapter for [Kilo Code](https://github.com/Kilo-Org/kilocode). Non-interactive `kilo run --auto` with JSON event stream. |
135
137
  | `gemini-cli` | Roadmap | Strong future candidate; not wired into ACP yet. |
136
- | `ollama` | Research | Candidate local-model substrate for future ACP integrations. |
137
138
  | `nanoclaw` | Exploratory | Ecosystem reference for containerized and WSL2-friendly workflows. |
138
139
  | `picoclaw` | Exploratory | Ecosystem reference for lightweight Linux and edge-style runtimes. |
139
140
 
140
141
  If you are trying ACP on a real repo right now, start with `codex`, `claude`,
141
- or `openclaw`. The other entries show the direction of travel, not finished
142
- support.
142
+ or `openclaw`. Use `ollama` to run local models useful for research, offline
143
+ workflows, or comparing local model output against cloud backends without
144
+ incurring API costs. Use `pi` to experiment with OpenRouter-hosted free-tier
145
+ models via the pi CLI. The remaining entries show the direction of travel, not
146
+ finished support.
143
147
 
144
148
  See [ROADMAP.md](./ROADMAP.md) for the fuller public roadmap.
145
149
 
150
+ ### Using Ollama (local models)
151
+
152
+ To run ACP with a local model via [Ollama](https://ollama.com):
153
+
154
+ ```bash
155
+ # 1. Install Ollama and pull a model
156
+ ollama pull qwen3.5:9b
157
+
158
+ # 2. Init a profile with ollama backend
159
+ npx agent-control-plane@latest init \
160
+ --profile-id my-repo \
161
+ --repo-slug owner/my-repo \
162
+ --repo-root ~/src/my-repo \
163
+ --agent-root ~/.agent-runtime/projects/my-repo \
164
+ --worktree-root ~/src/my-repo-worktrees \
165
+ --coding-worker ollama
166
+
167
+ # 3. Configure the model in your profile YAML
168
+ # ~/.agent-runtime/control-plane/profiles/my-repo/control-plane.yaml
169
+ #
170
+ # execution:
171
+ # coding_worker: "ollama"
172
+ # ollama:
173
+ # model: "qwen3.5:9b"
174
+ # base_url: "http://localhost:11434"
175
+ # timeout_seconds: 900
176
+ ```
177
+
178
+ The Ollama adapter runs a Node.js agentic loop that calls the Ollama API with
179
+ tool-use support. It handles both native tool-call responses and models that
180
+ return tool calls as JSON text in the content field.
181
+
182
+ **Model guidance:** Models in the 7–14B range can explore codebases and run
183
+ commands, but may struggle with complex multi-step repair tasks. Larger models
184
+ (27B+) produce significantly better results if your hardware supports them.
185
+ Thinking mode is disabled by default (`think: false`) and context is set to
186
+ 32K tokens to balance speed and capability.
187
+
146
188
  ## See It Running
147
189
 
148
190
  The dashboard gives you a single view across all active profiles — running
@@ -167,7 +209,7 @@ flowchart LR
167
209
  Supervisor --> Heartbeat["heartbeat-safe-auto.sh"]
168
210
  Heartbeat --> Scheduler["agent-project-heartbeat-loop"]
169
211
  Scheduler --> Workers["issue / PR worker launchers"]
170
- Workers --> Backends["codex / claude / openclaw"]
212
+ Workers --> Backends["codex / claude / openclaw / ollama / pi / opencode / kilo"]
171
213
  Backends --> Reconcile["reconcile issue / PR session"]
172
214
  Reconcile --> GitHub["issues / PRs / labels / comments"]
173
215
  Scheduler --> State["runs + state + history"]
@@ -230,7 +272,11 @@ system.
230
272
  | `jq` | yes | Parses JSON from `gh` output and worker metadata throughout. | Missing `jq` will break GitHub-heavy and runtime flows. |
231
273
  | `python3` | yes | Powers the dashboard server, snapshot renderer, and config helpers. | Required for both dashboard use and several internal scripts. |
232
274
  | `tmux` | yes | Runs long-lived worker sessions and captures their status. | Missing `tmux` means background worker workflows will not launch. |
233
- | Worker CLI (`codex`, `claude`, or `openclaw`) | depends on backend | The actual coding agent for a profile. | Install and authenticate the backend you plan to use before starting background runs. |
275
+ | 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. |
276
+ | `ollama` | for `ollama` backend | Serves local models via OpenAI-compatible API at `http://localhost:11434`. | Install from [ollama.com](https://ollama.com) and pull a model (e.g. `ollama pull qwen3.5:9b`) before use. |
277
+ | `pi` CLI | for `pi` backend | Lightweight coding agent using OpenRouter-compatible APIs. | Install via `npm i -g @mariozechner/pi-coding-agent`. Set `OPENROUTER_API_KEY` before use. |
278
+ | `crush` (opencode) | for `opencode` backend | Go-based coding agent by Charm ([charmbracelet/crush](https://github.com/charmbracelet/crush)). | Install via `brew install charmbracelet/tap/crush`. |
279
+ | `kilo` CLI | for `kilo` backend | TypeScript coding agent ([kilocode/cli](https://github.com/Kilo-Org/kilocode)). | Install via `npm i -g @kilocode/cli`. |
234
280
  | 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. |
235
281
 
236
282
  Make sure `gh` and your chosen worker backend are both authenticated for the
@@ -264,9 +310,17 @@ The fastest path is the interactive wizard:
264
310
  npx agent-control-plane@latest setup
265
311
  ```
266
312
 
267
- The wizard auto-detects the current repo when possible, offers to install
268
- missing dependencies, prompts for `gh auth login`, scaffolds the profile, runs
269
- health checks, and optionally starts the runtime all in one pass.
313
+ The wizard walks you through the full setup in one pass:
314
+
315
+ 1. Detects the current repo and suggests sane defaults
316
+ 2. Installs missing dependencies and authenticates `gh`
317
+ 3. Checks backend readiness (API keys for openclaw/pi, local server for ollama)
318
+ 4. Scaffolds the profile, runs health checks, starts the runtime
319
+ 5. Launches the monitoring dashboard in the background
320
+ 6. Offers to create recurring starter issues so ACP starts working immediately
321
+
322
+ After the wizard finishes, your repo has a running agent, a live dashboard,
323
+ and a set of `agent-keep-open` issues that ACP will continuously work through.
270
324
 
271
325
  To preview exactly what it would do before touching anything:
272
326
 
@@ -281,6 +335,8 @@ npx agent-control-plane@latest setup \
281
335
  --non-interactive \
282
336
  --install-missing-deps \
283
337
  --start-runtime \
338
+ --start-dashboard \
339
+ --create-starter-issues \
284
340
  --json
285
341
  ```
286
342
 
@@ -325,7 +381,7 @@ npx agent-control-plane@latest init \
325
381
  | `--repo-root` | Path to your local checkout |
326
382
  | `--agent-root` | Where ACP keeps per-project runtime state |
327
383
  | `--worktree-root` | Where ACP places repo worktrees |
328
- | `--coding-worker` | Backend to orchestrate (`codex`, `claude`, or `openclaw`) |
384
+ | `--coding-worker` | Backend to orchestrate (`codex`, `claude`, `openclaw`, `ollama`, `pi`, `opencode`, or `kilo`) |
329
385
 
330
386
  **4. Validate before trusting it**
331
387
 
@@ -348,6 +404,29 @@ Once `runtime status` returns clean output, ACP is actively managing the
348
404
  runtime for that profile. Per-profile state lives under `~/.agent-runtime`,
349
405
  grouped and inspectable without digging through scattered temp files.
350
406
 
407
+ ## Starter Issues
408
+
409
+ 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.
413
+
414
+ Built-in templates:
415
+
416
+ | Issue | What ACP does |
417
+ | --- | --- |
418
+ | Code quality sweep | Fix lint warnings, type errors, and dead code |
419
+ | Test coverage improvement | Add tests for critical untested modules |
420
+ | Documentation refresh | Keep README and inline docs accurate |
421
+ | Dependency audit | Fix vulnerabilities and update safe patches |
422
+ | Refactoring sweep | Reduce complexity and duplication |
423
+
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.
427
+
428
+ To skip this step during setup, pass `--no-create-starter-issues`.
429
+
351
430
  ## Everyday Usage
352
431
 
353
432
  ```bash
@@ -427,7 +506,7 @@ Use `--purge-paths` only when you want ACP-managed directories deleted too.
427
506
  | `explicit profile selection required` | Pass `--profile-id <id>` to `runtime`, `launchd-install`, `launchd-uninstall`, and `remove`. |
428
507
  | `gh` cannot access the repo | Re-run `gh auth login` and confirm the repo slug in the profile is correct. |
429
508
  | Setup deferred anchor repo sync | ACP could not reach the repo remote. Fix Git access or the remote URL, then re-run `setup` or `init` without `--skip-anchor-sync`. |
430
- | Backend auth failures (`codex`, `claude`, `openclaw`) | Authenticate the backend before starting ACP in the background. |
509
+ | Backend auth failures | Authenticate the backend before starting ACP. For `openclaw`/`pi`, set `OPENROUTER_API_KEY`. For `ollama`, ensure the server is running. For `opencode`/`kilo`, install and authenticate the CLI. |
431
510
  | Node older than `18` | Upgrade Node first. ACP's minimum version is `18+`. |
432
511
  | Missing `jq` | Install `jq`, then retry the failed command. |
433
512
  | Runtime or source drift after an update | Run `sync`, then `doctor`. |
@@ -440,7 +519,7 @@ Use `--purge-paths` only when you want ACP-managed directories deleted too.
440
519
  | --- | --- |
441
520
  | `help` | Show the full CLI surface. Good first command on a new machine. |
442
521
  | `version` | Print the running package version. |
443
- | `setup [--dry-run] [--json]` | Guided bootstrap wizard. Detects repo, installs deps, scaffolds profile, starts runtime. `--dry-run` previews without writing anything. `--json` emits a structured result. |
522
+ | `setup [--dry-run] [--json]` | Guided bootstrap wizard. Detects repo, installs deps, scaffolds profile, starts runtime and dashboard, creates starter issues. `--dry-run` previews. `--json` emits structured output. |
444
523
  | `sync` / `install` | Stage or refresh the packaged runtime into `~/.agent-runtime/runtime-home`. Run after install or upgrade. |
445
524
  | `init ...` | Scaffold one repo profile manually. Requires `--profile-id`, `--repo-slug`, `--repo-root`, `--agent-root`, `--worktree-root`, `--coding-worker`. |
446
525
  | `doctor` | Inspect runtime and source installation health. |
package/bin/pr-risk.sh CHANGED
@@ -6,14 +6,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
6
  source "${SCRIPT_DIR}/../tools/bin/flow-config-lib.sh"
7
7
 
8
8
  PR_NUMBER="${1:?usage: pr-risk.sh PR_NUMBER}"
9
+ [[ "${PR_NUMBER}" =~ ^[1-9][0-9]*$ ]] || { printf 'pr-risk: PR_NUMBER must be a positive integer, got: %s\n' "${PR_NUMBER}" >&2; exit 1; }
9
10
  CONFIG_YAML="$(resolve_flow_config_yaml "${BASH_SOURCE[0]}")"
10
11
  MANAGED_PR_PREFIXES_JSON="$(flow_managed_pr_prefixes_json "${CONFIG_YAML}")"
11
12
  MANAGED_PR_ISSUE_CAPTURE_REGEX="$(flow_managed_issue_branch_regex "${CONFIG_YAML}")"
12
13
  REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
13
14
  AGENT_ROOT="$(flow_resolve_agent_root "${CONFIG_YAML}")"
14
15
  STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
15
- ALLOW_INFRA_CI_BYPASS="${ACP_ALLOW_INFRA_CI_BYPASS:-${F_LOSNING_ALLOW_INFRA_CI_BYPASS:-1}}"
16
- LOCAL_FIRST_PR_POLICY="${ACP_LOCAL_FIRST_PR_POLICY:-${F_LOSNING_LOCAL_FIRST_PR_POLICY:-1}}"
16
+ ALLOW_INFRA_CI_BYPASS="${ACP_ALLOW_INFRA_CI_BYPASS:-1}"
17
+ LOCAL_FIRST_PR_POLICY="${ACP_LOCAL_FIRST_PR_POLICY:-1}"
17
18
  PR_LANE_OVERRIDE_FILE="${STATE_ROOT}/pr-lane-overrides/${PR_NUMBER}.env"
18
19
  PR_LANE_OVERRIDE=""
19
20
 
@@ -37,7 +38,8 @@ gh_api_json_matching_or_fallback() {
37
38
  printf '%s\n' "${fallback}"
38
39
  }
39
40
 
40
- 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)"
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
43
  PR_HEAD_SHA="$(jq -r '.headRefOid // ""' <<<"$PR_JSON")"
42
44
  PR_HEAD_COMMITTED_AT=""
43
45
  if [[ -n "${PR_HEAD_SHA}" ]]; then
@@ -51,9 +53,24 @@ fi
51
53
 
52
54
  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'
53
55
  const { execFileSync } = require('node:child_process');
54
- const data = JSON.parse(process.env.PR_JSON);
55
- const reviewComments = JSON.parse(process.env.REVIEW_COMMENTS_JSON || '[]');
56
- const checkRunsPayload = JSON.parse(process.env.CHECK_RUNS_JSON || '{"check_runs":[]}');
56
+ let data;
57
+ try {
58
+ data = JSON.parse(process.env.PR_JSON);
59
+ } catch (err) {
60
+ process.stderr.write(`pr-risk: failed to parse PR_JSON: ${err.message}\n`);
61
+ process.stdout.write(JSON.stringify({ agentLane: 'ignore', error: `parse-error: ${err.message}` }));
62
+ process.exit(0);
63
+ }
64
+ let reviewComments, checkRunsPayload;
65
+ try {
66
+ reviewComments = JSON.parse(process.env.REVIEW_COMMENTS_JSON || '[]');
67
+ checkRunsPayload = JSON.parse(process.env.CHECK_RUNS_JSON || '{"check_runs":[]}');
68
+ } catch (err) {
69
+ process.stderr.write(`pr-risk: failed to parse auxiliary JSON env: ${err.message}\n`);
70
+ reviewComments = [];
71
+ checkRunsPayload = { check_runs: [] };
72
+ }
73
+ try {
57
74
  const checkRuns = checkRunsPayload.check_runs || [];
58
75
  const files = (data.files || []).map((file) => file.path);
59
76
  const labelNames = (data.labels || []).map((label) => label.name);
@@ -573,4 +590,9 @@ const result = {
573
590
  };
574
591
 
575
592
  process.stdout.write(JSON.stringify(result));
593
+ } catch (err) {
594
+ process.stderr.write(`pr-risk: unexpected error: ${err.message}\n${err.stack || ''}\n`);
595
+ process.stdout.write(JSON.stringify({ agentLane: 'ignore', error: `runtime-error: ${err.message}` }));
596
+ process.exit(1);
597
+ }
576
598
  EOF
@@ -18,14 +18,51 @@ REPO_SLUG="$(flow_resolve_repo_slug "${CONFIG_YAML}")"
18
18
  AGENT_REPO_ROOT="$(flow_resolve_agent_repo_root "${CONFIG_YAML}")"
19
19
  DEFAULT_BRANCH="$(flow_resolve_default_branch "${CONFIG_YAML}")"
20
20
  STATE_ROOT="$(flow_resolve_state_root "${CONFIG_YAML}")"
21
- PENDING_LAUNCH_DIR="${ACP_PENDING_LAUNCH_DIR:-${F_LOSNING_PENDING_LAUNCH_DIR:-${STATE_ROOT}/pending-launches}}"
21
+ PENDING_LAUNCH_DIR="${ACP_PENDING_LAUNCH_DIR:-${STATE_ROOT}/pending-launches}"
22
22
  AGENT_PR_PREFIXES_JSON="$(flow_managed_pr_prefixes_json "${CONFIG_YAML}")"
23
23
  AGENT_PR_ISSUE_CAPTURE_REGEX="$(flow_managed_issue_branch_regex "${CONFIG_YAML}")"
24
24
  AGENT_PR_HANDOFF_LABEL="${AGENT_PR_HANDOFF_LABEL:-agent-handoff}"
25
25
  AGENT_EXCLUSIVE_LABEL="${AGENT_EXCLUSIVE_LABEL:-agent-exclusive}"
26
- CODING_WORKER="${ACP_CODING_WORKER:-${F_LOSNING_CODING_WORKER:-codex}}"
26
+ CODING_WORKER="${ACP_CODING_WORKER:-codex}"
27
27
  HEARTBEAT_ISSUE_JSON_CACHE_DIR="${TMPDIR:-/tmp}/heartbeat-issue-json.$$"
28
28
 
29
+ # ── Per-heartbeat snapshot cache ──────────────────────────────────────────────
30
+ # Fetch open issues and open PRs once per heartbeat cycle and reuse the
31
+ # snapshot for every list query. This eliminates 4x issue_list + 3x pr_list
32
+ # redundant GitHub API calls per cycle.
33
+ HEARTBEAT_SNAPSHOT_CACHE_DIR="${TMPDIR:-/tmp}/heartbeat-snapshot.$$"
34
+
35
+ # Snapshot functions are always called from subshells (command substitution or
36
+ # pipes), so in-memory variables would be lost immediately. We rely exclusively
37
+ # on the PID-scoped disk cache under HEARTBEAT_SNAPSHOT_CACHE_DIR.
38
+
39
+ heartbeat_cached_issue_list_json() {
40
+ mkdir -p "${HEARTBEAT_SNAPSHOT_CACHE_DIR}"
41
+ local cache_file="${HEARTBEAT_SNAPSHOT_CACHE_DIR}/issues.json"
42
+ 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}"
46
+ fi
47
+ cat "${cache_file}"
48
+ }
49
+
50
+ heartbeat_cached_pr_list_json() {
51
+ mkdir -p "${HEARTBEAT_SNAPSHOT_CACHE_DIR}"
52
+ local cache_file="${HEARTBEAT_SNAPSHOT_CACHE_DIR}/prs.json"
53
+ 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}"
57
+ fi
58
+ cat "${cache_file}"
59
+ }
60
+
61
+ heartbeat_invalidate_snapshot_cache() {
62
+ rm -rf "${HEARTBEAT_SNAPSHOT_CACHE_DIR}" 2>/dev/null || true
63
+ rm -rf "${HEARTBEAT_ISSUE_JSON_CACHE_DIR}" 2>/dev/null || true
64
+ }
65
+
29
66
  heartbeat_issue_retry_state_file() {
30
67
  local issue_id="${1:?issue id required}"
31
68
  printf '%s/retries/issues/%s.env\n' "${STATE_ROOT}" "${issue_id}"
@@ -92,10 +129,15 @@ heartbeat_issue_json_cached() {
92
129
  }
93
130
 
94
131
  heartbeat_open_agent_pr_issue_ids() {
132
+ mkdir -p "${HEARTBEAT_SNAPSHOT_CACHE_DIR}"
133
+ local cache_file="${HEARTBEAT_SNAPSHOT_CACHE_DIR}/pr_issue_ids.json"
134
+ if [[ -f "${cache_file}" ]]; then
135
+ cat "${cache_file}"
136
+ return 0
137
+ fi
95
138
  local pr_issue_ids_json=""
96
139
  pr_issue_ids_json="$(
97
- flow_github_pr_list_json "$REPO_SLUG" open 100 \
98
- 2>/dev/null \
140
+ heartbeat_cached_pr_list_json \
99
141
  | jq --argjson agentPrPrefixes "${AGENT_PR_PREFIXES_JSON}" --arg handoffLabel "${AGENT_PR_HANDOFF_LABEL}" --arg branchIssueRegex "${AGENT_PR_ISSUE_CAPTURE_REGEX}" '
100
142
  map(
101
143
  . as $pr
@@ -124,10 +166,10 @@ heartbeat_open_agent_pr_issue_ids() {
124
166
  )"
125
167
 
126
168
  if [[ -z "${pr_issue_ids_json:-}" ]]; then
127
- printf '[]\n'
128
- else
129
- printf '%s\n' "${pr_issue_ids_json}"
169
+ pr_issue_ids_json="[]"
130
170
  fi
171
+ printf '%s' "${pr_issue_ids_json}" >"${cache_file}"
172
+ printf '%s\n' "${pr_issue_ids_json}"
131
173
  }
132
174
 
133
175
  heartbeat_list_ready_issue_ids() {
@@ -136,8 +178,7 @@ heartbeat_list_ready_issue_ids() {
136
178
  open_agent_pr_issue_ids="$(heartbeat_open_agent_pr_issue_ids)"
137
179
 
138
180
  ready_issue_rows="$(
139
- flow_github_issue_list_json "$REPO_SLUG" open 100 \
140
- 2>/dev/null \
181
+ heartbeat_cached_issue_list_json \
141
182
  | jq -r --argjson openAgentPrIssueIds "${open_agent_pr_issue_ids}" '
142
183
  map(select(
143
184
  (any(.labels[]?; .name == "agent-running") | not)
@@ -171,8 +212,7 @@ heartbeat_list_blocked_recovery_issue_ids() {
171
212
  open_agent_pr_issue_ids="$(heartbeat_open_agent_pr_issue_ids)"
172
213
 
173
214
  blocked_issue_rows="$(
174
- flow_github_issue_list_json "$REPO_SLUG" open 100 \
175
- 2>/dev/null \
215
+ heartbeat_cached_issue_list_json \
176
216
  | jq -r --argjson openAgentPrIssueIds "${open_agent_pr_issue_ids}" '
177
217
  map(select(
178
218
  any(.labels[]?; .name == "agent-blocked")
@@ -270,8 +310,7 @@ heartbeat_list_exclusive_issue_ids() {
270
310
  local open_agent_pr_issue_ids
271
311
  open_agent_pr_issue_ids="$(heartbeat_open_agent_pr_issue_ids)"
272
312
 
273
- flow_github_issue_list_json "$REPO_SLUG" open 100 \
274
- 2>/dev/null \
313
+ heartbeat_cached_issue_list_json \
275
314
  | jq -r --arg exclusiveLabel "${AGENT_EXCLUSIVE_LABEL}" --argjson openAgentPrIssueIds "${open_agent_pr_issue_ids}" '
276
315
  map(select(
277
316
  any(.labels[]?; .name == $exclusiveLabel)
@@ -285,8 +324,7 @@ heartbeat_list_exclusive_issue_ids() {
285
324
  }
286
325
 
287
326
  heartbeat_list_running_issue_ids() {
288
- flow_github_issue_list_json "$REPO_SLUG" open 100 \
289
- 2>/dev/null \
327
+ heartbeat_cached_issue_list_json \
290
328
  | jq -r '
291
329
  map(select(any(.labels[]?; .name == "agent-running")))
292
330
  | sort_by(.createdAt, .number)
@@ -295,8 +333,7 @@ heartbeat_list_running_issue_ids() {
295
333
  }
296
334
 
297
335
  heartbeat_list_open_agent_pr_ids() {
298
- flow_github_pr_list_json "$REPO_SLUG" open 100 \
299
- 2>/dev/null \
336
+ heartbeat_cached_pr_list_json \
300
337
  | jq -r --argjson agentPrPrefixes "${AGENT_PR_PREFIXES_JSON}" --arg handoffLabel "${AGENT_PR_HANDOFF_LABEL}" '
301
338
  map(select(
302
339
  . as $pr
@@ -312,8 +349,7 @@ heartbeat_list_open_agent_pr_ids() {
312
349
  }
313
350
 
314
351
  heartbeat_list_exclusive_pr_ids() {
315
- flow_github_pr_list_json "$REPO_SLUG" open 100 \
316
- 2>/dev/null \
352
+ heartbeat_cached_pr_list_json \
317
353
  | jq -r --argjson agentPrPrefixes "${AGENT_PR_PREFIXES_JSON}" --arg handoffLabel "${AGENT_PR_HANDOFF_LABEL}" --arg exclusiveLabel "${AGENT_EXCLUSIVE_LABEL}" '
318
354
  map(select(
319
355
  . as $pr
@@ -444,16 +480,20 @@ heartbeat_pr_risk_json() {
444
480
  heartbeat_mark_issue_running() {
445
481
  local issue_id="${1:?issue id required}"
446
482
  local is_heavy="${2:-no}"
483
+ local cached_json
484
+ cached_json="$(heartbeat_issue_json_cached "$issue_id" 2>/dev/null || true)"
447
485
  if [[ "$is_heavy" == "yes" ]]; then
448
- 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
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
449
487
  else
450
- 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
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
451
489
  fi
452
490
  }
453
491
 
454
492
  heartbeat_issue_launch_failed() {
455
493
  local issue_id="${1:?issue id required}"
456
- bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "$REPO_SLUG" --number "$issue_id" --remove agent-running >/dev/null || true
494
+ local cached_json
495
+ cached_json="$(heartbeat_issue_json_cached "$issue_id" 2>/dev/null || true)"
496
+ ACP_CACHED_ISSUE_JSON="${cached_json}" bash "${FLOW_TOOLS_DIR}/agent-github-update-labels" --repo-slug "$REPO_SLUG" --number "$issue_id" --remove agent-running >/dev/null || true
457
497
  }
458
498
 
459
499
  heartbeat_ensure_issue_label_exists() {