|
| 1 | +<!-- markdownlint-disable-next-line MD041 --> |
| 2 | +# External Agent Runtime |
| 3 | + |
| 4 | +This document defines the persistent supervisor needed to make the agent behave |
| 5 | +continuously outside a single chat/API turn. |
| 6 | + |
| 7 | +The model does not "run forever". The runtime does. It does that by repeatedly |
| 8 | +loading fresh state, giving the model a bounded task packet, executing the |
| 9 | +checked-in helper surface, persisting evidence, and immediately scheduling the |
| 10 | +next turn until one of two stop conditions is true: |
| 11 | + |
| 12 | +- a human explicitly stops the runtime |
| 13 | +- no open upstream epics remain in |
| 14 | + `LabVIEW-Community-CI-CD/compare-vi-cli-action` |
| 15 | + |
| 16 | +## Goals |
| 17 | + |
| 18 | +- Drain upstream epic backlog without relying on manual "continue" prompts. |
| 19 | +- Keep upstream as the only ship/no-ship authority. |
| 20 | +- Reuse repo-native helpers instead of inventing a parallel mutation surface. |
| 21 | +- Preserve single-writer safety for files, branches, and GitHub mutations. |
| 22 | +- Make every turn restartable from durable state instead of transcript memory. |
| 23 | +- Advance a different lane while CI, review, or merge queue waits. |
| 24 | + |
| 25 | +## Non-Goals |
| 26 | + |
| 27 | +- Replacing the checked-in `tools/priority/*` helper layer. |
| 28 | +- Bypassing GitHub protections, required checks, or review policy. |
| 29 | +- Letting the model hold an unbounded transcript or unmanaged background job. |
| 30 | +- Using a single shared mutable checkout for all fork lanes. |
| 31 | + |
| 32 | +## Runtime Topology |
| 33 | + |
| 34 | +| Component | Responsibility | Existing repo surface | |
| 35 | +| --- | --- | --- | |
| 36 | +| Supervisor | Owns the event loop, stop conditions, ranking, and lease renewal. | `tools/priority/queue-supervisor.mjs` | |
| 37 | +| State mirror | Refreshes live GitHub epics, child issues, PRs, checks, and project metadata. | `gh`, `priority:project:portfolio:check` | |
| 38 | +| Lease broker | Prevents overlapping writers in the same repo scope. | `tools/priority/agent-writer-lease.mjs` | |
| 39 | +| Worker pool | Maintains isolated worktrees per lane/fork. | git worktrees + `priority:develop:sync` | |
| 40 | +| Command broker | Executes repo-native helpers and records fallback evidence. | `bootstrap.ps1`, `priority:pr`, `priority:issue:mirror` | |
| 41 | +| CI watcher | Watches hosted/self-hosted checks and wakes waiting lanes. | `ci:watch:rest`, `ci:watch:safe` | |
| 42 | +| Evidence sink | Writes runtime state, events, and lane checkpoints. | `tests/results/_agent/` | |
| 43 | + |
| 44 | +## Core Idea |
| 45 | + |
| 46 | +The runtime is an external supervisor process, not a longer prompt. It runs a |
| 47 | +bounded loop: |
| 48 | + |
| 49 | +1. Acquire the writer lease for the repo and lane scopes. |
| 50 | +2. Refresh live GitHub state from upstream and both fork planes. |
| 51 | +3. Recompute open epics, child issues, PRs, blockers, and free fork lanes. |
| 52 | +4. Select exactly one highest-value next action. |
| 53 | +5. Materialize a dedicated worker checkout for that lane. |
| 54 | +6. Call the model with a compact task packet for that one action. |
| 55 | +7. Execute the resulting helper/tool calls. |
| 56 | +8. Persist evidence and update runtime state. |
| 57 | +9. Re-enter the scheduler immediately. |
| 58 | + |
| 59 | +The model stays stateless between turns. The runtime owns continuity. |
| 60 | + |
| 61 | +## Durable State |
| 62 | + |
| 63 | +The runtime should keep its own durable state outside the model transcript and |
| 64 | +mirror a compact copy into the repository evidence tree. |
| 65 | + |
| 66 | +Recommended persistent store: |
| 67 | + |
| 68 | +- SQLite for scheduler state and event indexes |
| 69 | +- NDJSON append log for operator replay |
| 70 | +- repo evidence files under `tests/results/_agent/runtime/` |
| 71 | + |
| 72 | +Recommended repo-visible artifacts: |
| 73 | + |
| 74 | +- `tests/results/_agent/runtime/runtime-state.json` |
| 75 | +- `tests/results/_agent/runtime/runtime-events.ndjson` |
| 76 | +- `tests/results/_agent/runtime/lanes/<lane-id>.json` |
| 77 | +- `tests/results/_agent/runtime/turns/<timestamp>-<lane-id>.json` |
| 78 | +- `tests/results/_agent/runtime/last-blocker.json` |
| 79 | + |
| 80 | +Each lane record should include: |
| 81 | + |
| 82 | +- upstream issue number |
| 83 | +- parent epic number |
| 84 | +- fork remote (`origin`, `personal`, or `upstream`) |
| 85 | +- branch name |
| 86 | +- PR URL if present |
| 87 | +- current blocker class |
| 88 | +- worker path |
| 89 | +- last heartbeat time |
| 90 | +- last meaningful change |
| 91 | + |
| 92 | +## Lease Model |
| 93 | + |
| 94 | +`tools/priority/agent-writer-lease.mjs` already provides the base lock |
| 95 | +primitive. The runtime should layer named scopes on top of it instead of adding |
| 96 | +new locking rules. |
| 97 | + |
| 98 | +Required logical scopes: |
| 99 | + |
| 100 | +- `workspace` for repo-wide bootstrap and develop-sync activity |
| 101 | +- `fork/<remote>` for one active PR lane per fork |
| 102 | +- `issue/<number>` for one active lane per child issue |
| 103 | +- `upstream-promotion` for the single upstream merge/admission lane |
| 104 | + |
| 105 | +Lease rules: |
| 106 | + |
| 107 | +- renew heartbeat every 30 to 60 seconds |
| 108 | +- treat stale leases as recoverable after the configured timeout |
| 109 | +- on lease loss, stop writes, persist a checkpoint, and reschedule |
| 110 | +- never let two workers mutate the same fork lane or issue lane |
| 111 | + |
| 112 | +## Worker Model |
| 113 | + |
| 114 | +Each active lane gets its own checkout rooted under a runtime-managed working |
| 115 | +directory. A worker should never share a mutable checkout with another lane. |
| 116 | + |
| 117 | +Recommended layout: |
| 118 | + |
| 119 | +```text |
| 120 | +.runtime/ |
| 121 | + bare/ |
| 122 | + worktrees/ |
| 123 | + origin-977/ |
| 124 | + personal-978/ |
| 125 | + upstream-981/ |
| 126 | +``` |
| 127 | + |
| 128 | +Worker bootstrap sequence: |
| 129 | + |
| 130 | +1. fetch `upstream`, `origin`, and `personal` |
| 131 | +2. sync `develop` with |
| 132 | + `node tools/npm/run-script.mjs priority:develop:sync -- --fork-remote <remote>` |
| 133 | +3. check out or reattach the lane branch |
| 134 | +4. run `pwsh -NoLogo -NoProfile -File tools/priority/bootstrap.ps1` |
| 135 | +5. refresh local handoff and standing-priority artifacts |
| 136 | + |
| 137 | +## Model Turn Contract |
| 138 | + |
| 139 | +The runtime should never pass the full historical conversation back to the |
| 140 | +model. It should compile a bounded task packet from durable state. |
| 141 | + |
| 142 | +Each task packet should contain: |
| 143 | + |
| 144 | +- repo identity and active lane |
| 145 | +- exact objective for this turn |
| 146 | +- current branch, PR, and check status |
| 147 | +- relevant file paths and prior evidence artifacts |
| 148 | +- the last small set of runtime events for this lane |
| 149 | +- allowed helper surfaces and known fallbacks |
| 150 | + |
| 151 | +Each turn should have a hard budget, for example: |
| 152 | + |
| 153 | +- one lane |
| 154 | +- one primary objective |
| 155 | +- one promotion attempt or one blocker fix |
| 156 | +- a bounded tool-call count |
| 157 | +- a bounded wall-clock timeout |
| 158 | + |
| 159 | +When the budget expires, the runtime persists the result and schedules the next |
| 160 | +turn. That is how it behaves continuously without pretending a single reply is |
| 161 | +infinite. |
| 162 | + |
| 163 | +## Scheduling Policy |
| 164 | + |
| 165 | +The scheduler should implement the repo's backlog policy directly. |
| 166 | + |
| 167 | +Refresh phase: |
| 168 | + |
| 169 | +1. run bootstrap and standing-priority sync |
| 170 | +2. read live upstream issues and PRs |
| 171 | +3. repair missing epic or child links before feature work |
| 172 | +4. refresh fork mirror issues and fork PR state |
| 173 | + |
| 174 | +Selection phase: |
| 175 | + |
| 176 | +1. candidate epics are all open upstream epics |
| 177 | +2. candidate children are all open non-epic issues attached to open epics |
| 178 | +3. rank epics and children using the repo's selection algorithm |
| 179 | +4. choose one action type in this order: |
| 180 | + - fix blocker on an existing upstream PR |
| 181 | + - advance a different child issue on a free fork while upstream waits |
| 182 | + - finish a fork PR that is one iteration from promotion |
| 183 | + - split or repair an open epic with no executable children |
| 184 | + - start the highest-ranked unblocked child issue |
| 185 | + |
| 186 | +Execution phase: |
| 187 | + |
| 188 | +1. acquire required scopes |
| 189 | +2. run the bounded model turn |
| 190 | +3. watch CI asynchronously |
| 191 | +4. on any meaningful state change, reschedule immediately |
| 192 | + |
| 193 | +## Command Surface |
| 194 | + |
| 195 | +The runtime should treat repo helpers as the default mutation API. |
| 196 | + |
| 197 | +Preferred commands: |
| 198 | + |
| 199 | +- `pwsh -NoLogo -NoProfile -File tools/priority/bootstrap.ps1` |
| 200 | +- `node tools/npm/run-script.mjs priority:develop:sync` |
| 201 | +- `node tools/npm/run-script.mjs priority:project:portfolio:apply` |
| 202 | +- `node tools/npm/run-script.mjs priority:github:metadata:apply` |
| 203 | +- `node tools/npm/run-script.mjs priority:issue:mirror` |
| 204 | +- `node tools/npm/run-script.mjs priority:pr` |
| 205 | +- `node tools/npm/run-script.mjs priority:queue:supervisor` |
| 206 | +- `node tools/npm/run-script.mjs ci:watch:rest` |
| 207 | +- `node tools/npm/run-script.mjs ci:watch:safe` |
| 208 | + |
| 209 | +Fallback rule: |
| 210 | + |
| 211 | +- raw `gh` is allowed only when no checked-in helper can perform the action, or |
| 212 | + when reproducing a helper bug with explicit evidence written to the runtime |
| 213 | + event log |
| 214 | + |
| 215 | +## Event Sources |
| 216 | + |
| 217 | +The supervisor should combine polling with GitHub webhooks. |
| 218 | + |
| 219 | +Wake-up sources: |
| 220 | + |
| 221 | +- issue opened, edited, labeled, or relinked |
| 222 | +- PR opened, synchronized, reviewed, merged, or closed |
| 223 | +- check suite and workflow run state changes |
| 224 | +- queue-supervisor readiness changes |
| 225 | +- lease stale or worker crash events |
| 226 | +- explicit human stop or priority override |
| 227 | + |
| 228 | +Polling is still required as the recovery path when webhooks are missed. |
| 229 | + |
| 230 | +## Failure Handling |
| 231 | + |
| 232 | +Expected failure classes: |
| 233 | + |
| 234 | +- merge blocker |
| 235 | +- review blocker |
| 236 | +- CI blocker |
| 237 | +- scope blocker |
| 238 | +- helper/runtime bug |
| 239 | +- auth or policy drift |
| 240 | + |
| 241 | +Runtime response: |
| 242 | + |
| 243 | +- classify the blocker explicitly |
| 244 | +- attach it to the correct epic if it reveals new scope |
| 245 | +- persist evidence paths and API error details |
| 246 | +- start the next-best unblocked lane immediately |
| 247 | + |
| 248 | +If a helper bug is discovered, the runtime should create a focused upstream |
| 249 | +issue, parent it under the correct epic, and keep moving. |
| 250 | + |
| 251 | +## Human Control Surface |
| 252 | + |
| 253 | +The external runtime needs explicit controls outside the model: |
| 254 | + |
| 255 | +- `start` with repo, model, token source, and workspace root |
| 256 | +- `stop` for graceful drain and checkpoint |
| 257 | +- `pause` to stop new mutations but keep observing |
| 258 | +- `resume` to continue from durable state |
| 259 | +- `status` to print current lane, blockers, leases, and recent events |
| 260 | + |
| 261 | +Recommended stop file or command channel: |
| 262 | + |
| 263 | +- `tests/results/_agent/runtime/stop-request.json` |
| 264 | + |
| 265 | +The runtime should check that file before starting a new turn. |
| 266 | + |
| 267 | +## Deployment Shape |
| 268 | + |
| 269 | +Recommended first deployment: |
| 270 | + |
| 271 | +- one always-on supervisor process for the canonical repo |
| 272 | +- one host with git, `gh`, Node.js, PowerShell, and token access |
| 273 | +- one bare mirror plus per-lane worktrees |
| 274 | +- one durable state directory outside the repo checkout |
| 275 | + |
| 276 | +Suitable hosts: |
| 277 | + |
| 278 | +- a Windows service on the existing self-hosted runner |
| 279 | +- a long-lived VM with the repo mounted locally |
| 280 | +- a containerized worker host if LabVIEW-specific tasks stay on separate runners |
| 281 | + |
| 282 | +## Incremental Rollout |
| 283 | + |
| 284 | +Phase 1: |
| 285 | + |
| 286 | +- single supervisor |
| 287 | +- one active fork lane at a time |
| 288 | +- polling only |
| 289 | +- JSON evidence + SQLite state |
| 290 | +- bounded model auto-resume |
| 291 | + |
| 292 | +Phase 2: |
| 293 | + |
| 294 | +- parallel fork lanes with per-fork mutexes |
| 295 | +- upstream-promotion mutex |
| 296 | +- CI watcher wake-ups |
| 297 | +- blocker issue auto-creation |
| 298 | + |
| 299 | +Phase 3: |
| 300 | + |
| 301 | +- webhook ingestion |
| 302 | +- adaptive scheduling from queue and SLO artifacts |
| 303 | +- richer operator dashboard |
| 304 | + |
| 305 | +## Minimal Pseudocode |
| 306 | + |
| 307 | +```text |
| 308 | +while not stopRequested(): |
| 309 | + refreshState() |
| 310 | + repairMetadata() |
| 311 | + lane = selectNextLane() |
| 312 | + if lane is None: |
| 313 | + if noOpenUpstreamEpics(): |
| 314 | + exit("no-open-epics") |
| 315 | + sleep(shortInterval) |
| 316 | + continue |
| 317 | + with acquireScopes(lane): |
| 318 | + task = compileTaskPacket(lane) |
| 319 | + result = runBoundedModelTurn(task) |
| 320 | + persist(result) |
| 321 | + if result.startedCiWatch: |
| 322 | + armWatcher(lane) |
| 323 | +``` |
| 324 | + |
| 325 | +## Design Decision |
| 326 | + |
| 327 | +The runtime should be built as a thin external supervisor around the existing |
| 328 | +repo contracts, not as a new agent framework embedded in prompts. The repository |
| 329 | +already has the right mutation helpers, watcher outputs, lease primitive, and |
| 330 | +handoff artifacts. The missing piece is the always-on scheduler that keeps |
| 331 | +calling them. |
| 332 | + |
| 333 | +Initial extraction note: |
| 334 | + |
| 335 | +- the portable core now starts in `packages/runtime-harness/` |
| 336 | +- the worker and observer loop seams now live in |
| 337 | + `packages/runtime-harness/worker.mjs` and |
| 338 | + `packages/runtime-harness/observer.mjs` |
| 339 | +- the observer now calls an adapter scheduler hook before each worker turn and |
| 340 | + persists `scheduler-decision.json` plus per-cycle |
| 341 | + `scheduler-decisions/*.json` artifacts under the runtime directory |
| 342 | +- the compare-vi repository wrapper remains at |
| 343 | + `tools/priority/runtime-supervisor.mjs` |
| 344 | +- that wrapper now includes the first compare-vi scheduler cut: when no manual |
| 345 | + lane is supplied, it plans from the bootstrapped standing-priority cache or |
| 346 | + router artifacts instead of acting as a heartbeat-only shell |
| 347 | +- the compare-vi Linux-only daemon wrapper lives at |
| 348 | + `tools/priority/runtime-daemon.mjs` |
| 349 | +- the Docker Desktop Linux launcher now starts at |
| 350 | + `tools/priority/Start-RuntimeDaemonInDocker.ps1` |
| 351 | +- the Docker Desktop lifecycle controller now lives at |
| 352 | + `tools/priority/Manage-RuntimeDaemonInDocker.ps1` |
| 353 | + and now acquires the Linux Docker Desktop context automatically under a |
| 354 | + host-wide engine lock before running lifecycle commands |
| 355 | +- the Docker manager also now classifies detached daemon health from |
| 356 | + `observer-heartbeat.json`, persists a health artifact, and restarts stale or |
| 357 | + wedged running containers deterministically on `start` |
| 358 | +- that same manager now exposes `reconcile`, which scans persisted lane state, |
| 359 | + reapplies `start` as the repair primitive per lane, and writes a shared |
| 360 | + `docker-daemon-reconcile.json` artifact for operator-free recovery |
0 commit comments