@nanobpm/nano-workforce 0.26.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 (115) hide show
  1. package/.github/workflows/ci.yml +60 -0
  2. package/.github/workflows/release.yml +58 -0
  3. package/.releaserc.json +17 -0
  4. package/AGENTS.md +168 -0
  5. package/CHANGELOG.md +231 -0
  6. package/LICENSE +202 -0
  7. package/README.md +303 -0
  8. package/SPEC.md +492 -0
  9. package/actions/abandon.test.ts +93 -0
  10. package/actions/abandon.ts +23 -0
  11. package/actions/blackboard.test.ts +195 -0
  12. package/actions/blackboard.ts +76 -0
  13. package/actions/cancel.ts +29 -0
  14. package/actions/feature-answer-hook.ts +44 -0
  15. package/actions/message.ts +49 -0
  16. package/actions/plan-hook.ts +19 -0
  17. package/actions/plan-start.ts +17 -0
  18. package/actions/start.ts +19 -0
  19. package/actions/status.ts +22 -0
  20. package/actions/webhook-submit.ts +21 -0
  21. package/app/abandon.test.ts +97 -0
  22. package/app/abandon.ts +105 -0
  23. package/app/baseGuard.test.ts +35 -0
  24. package/app/baseGuard.ts +62 -0
  25. package/app/blackboard.test.ts +295 -0
  26. package/app/blackboard.ts +301 -0
  27. package/app/github.test.ts +59 -0
  28. package/app/github.ts +647 -0
  29. package/app/mergeExclusion.test.ts +168 -0
  30. package/app/mergeExclusion.ts +211 -0
  31. package/app/mergeProtocol.test.ts +124 -0
  32. package/app/mergeProtocol.ts +193 -0
  33. package/app/mergeRebaseArm.test.ts +72 -0
  34. package/app/mergeTrain.test.ts +91 -0
  35. package/app/mergeTrain.ts +117 -0
  36. package/app/persist-escalation.test.ts +119 -0
  37. package/app/persist-round.test.ts +65 -0
  38. package/app/plan.test.ts +317 -0
  39. package/app/plan.ts +321 -0
  40. package/app/record-plan-review.test.ts +38 -0
  41. package/app/reviewWait.test.ts +70 -0
  42. package/app/reviewWait.ts +59 -0
  43. package/app/rounds.test.ts +74 -0
  44. package/app/rounds.ts +48 -0
  45. package/app/service.test.ts +101 -0
  46. package/app/service.ts +895 -0
  47. package/app/taskDelta.test.ts +144 -0
  48. package/app/taskDelta.ts +175 -0
  49. package/app/trialMerge.test.ts +15 -0
  50. package/app/trialMerge.ts +102 -0
  51. package/app/waves.test.ts +128 -0
  52. package/app/waves.ts +116 -0
  53. package/assets/icon.svg +13 -0
  54. package/components/review-round.json +69 -0
  55. package/db/migrations/001_init.sql +46 -0
  56. package/db/migrations/002_transcript.sql +7 -0
  57. package/db/migrations/003_open_escalation.sql +8 -0
  58. package/db/migrations/004_merge.sql +36 -0
  59. package/db/migrations/004_planning.sql +37 -0
  60. package/db/migrations/005_job_activation.sql +15 -0
  61. package/db/migrations/005_plan_deps.sql +20 -0
  62. package/db/migrations/006_plan_review.sql +22 -0
  63. package/db/migrations/006_task_escalation.sql +52 -0
  64. package/db/migrations/007_plan_review_job_key.sql +14 -0
  65. package/db/migrations/007_wave_gate.sql +16 -0
  66. package/db/migrations/008_review_nudge.sql +9 -0
  67. package/db/migrations/009_plan_blackboard.sql +46 -0
  68. package/db/migrations/010_plan_task_deltas.sql +27 -0
  69. package/db/migrations/011_plan_merge_exclusions.sql +26 -0
  70. package/db/migrations/012_merge_protocol_attempt.sql +4 -0
  71. package/db/migrations/013_merge_train_waiting_lane.sql +6 -0
  72. package/db/migrations/014_plan_trial_merges.sql +21 -0
  73. package/db/migrations/015_pr_abandon_token.sql +9 -0
  74. package/deno.json +24 -0
  75. package/deno.lock +1776 -0
  76. package/main.ts +71 -0
  77. package/nano-ide.ext.json +7 -0
  78. package/nano.app.json +138 -0
  79. package/nanobpm.project.json +20 -0
  80. package/package.json +56 -0
  81. package/pages/epic.page.json +195 -0
  82. package/pages/home.page.json +296 -0
  83. package/prompts/feature.md +132 -0
  84. package/prompts/fix-ci.md +65 -0
  85. package/prompts/plan-review.md +69 -0
  86. package/prompts/plan.md +183 -0
  87. package/prompts/rebase.md +82 -0
  88. package/prompts/review-round.md +171 -0
  89. package/prompts/trial-merge.md +43 -0
  90. package/renovate.json +21 -0
  91. package/resources/processes/convergence-loop.bpmn +399 -0
  92. package/resources/processes/merge-loop.bpmn +585 -0
  93. package/resources/processes/plan-fanout.bpmn +546 -0
  94. package/scripts/check-agent-prompts.test.ts +84 -0
  95. package/scripts/check-agent-prompts.ts +143 -0
  96. package/scripts/layout-bpmn.ts +99 -0
  97. package/scripts/purge-db.ts +57 -0
  98. package/scripts/upgrade-from-pack.ts +334 -0
  99. package/tsconfig.json +51 -0
  100. package/workers/arm-merge/worker.ts +18 -0
  101. package/workers/finalize/worker.ts +89 -0
  102. package/workers/mark-merged/worker.ts +21 -0
  103. package/workers/merge/worker.ts +119 -0
  104. package/workers/persist-escalation/worker.ts +107 -0
  105. package/workers/persist-round/worker.ts +52 -0
  106. package/workers/persist-task-escalation/worker.ts +112 -0
  107. package/workers/record-plan/worker.ts +135 -0
  108. package/workers/record-plan-review/worker.ts +92 -0
  109. package/workers/record-results/worker.ts +30 -0
  110. package/workers/record-trial-merge/worker.test.ts +104 -0
  111. package/workers/record-trial-merge/worker.ts +88 -0
  112. package/workers/record-wave/worker.test.ts +221 -0
  113. package/workers/record-wave/worker.ts +308 -0
  114. package/workers/select-wave/worker.test.ts +130 -0
  115. package/workers/select-wave/worker.ts +84 -0
package/SPEC.md ADDED
@@ -0,0 +1,492 @@
1
+ # nano-workforce — specification (draft)
2
+
3
+ A Nano **Urban app** that drives GitHub pull requests to convergence against an
4
+ automated reviewer (e.g. GitHub Copilot's PR review), one durable, multi-round
5
+ loop per PR. The reviewer-agent is **decoupled**: from this app's point of view
6
+ it is just a BPMN service task with a `taskType` and a job payload. Whether a
7
+ Copilot instance (via `c8ctl nano hire`/`work`), a script, or anything else
8
+ services that job is entirely the worker's concern — this app never names it.
9
+
10
+ Status: **design draft** — decisions below are agreed; domain model + web
11
+ surface are proposed and open for adjustment.
12
+
13
+ ---
14
+
15
+ ## 1. Goal
16
+
17
+ - Submit a PR (web form or webhook) → the app runs a durable convergence loop:
18
+ address the reviewer's comments, push, re-request review, **wait** for the
19
+ next review, repeat until the latest review has nothing actionable.
20
+ - A web UI shows PRs **currently converging** (collapsible detail) and
21
+ **historical converged** PRs, with their round-by-round data.
22
+ - Persist everything in **SQLite**.
23
+ - Handle **escalation**: if the agent needs to ask a question mid-round, pause
24
+ and let a human answer, then resume.
25
+
26
+ ## 2. Architecture (decoupled)
27
+
28
+ ```
29
+ submit (form / webhook)
30
+
31
+
32
+ ┌───────────────────┐ review-ready (msg) ┌──────────┐
33
+ │ convergence-loop │◀───────────────────────────────│ poller │
34
+ │ (BPMN) │ escalation-answered (msg)└────┬─────┘
35
+ └─────────┬─────────┘◀───────────────┐ │ polls
36
+ │ senior:pr-review job │ answer POST │ GitHub
37
+ ▼ │ ▼
38
+ ┌───────────────────┐ ┌──────┴───────┐ ┌────────────┐
39
+ │ decoupled agent │ │ web UI + │ │ SQLite │
40
+ │ (c8ctl nano work) │ │ API routes │────▶│ (app.db) │
41
+ └───────────────────┘ └──────────────┘ └────────────┘
42
+ ```
43
+
44
+ - **Engine**: embedded Nano (the Urban app deploys its BPMN + runs the loop).
45
+ - **Agent**: external worker subscribed to `senior:pr-review`. Short jobs — one
46
+ round then return. It never blocks on the wait.
47
+ - **BPMN owns the durable wait** between rounds (message catch events), so
48
+ agent worker slots and job timeouts are never held hostage to Copilot's reply
49
+ latency.
50
+ - **Poller**: an in-app background loop that watches waiting PRs and publishes
51
+ the `review-ready` message when a new review lands (no GitHub webhook needed;
52
+ works behind NAT).
53
+
54
+ ## 3. Repository layout
55
+
56
+ ```
57
+ nano-workforce/
58
+ nano.app.json # manifest (ADR 0027): sqlite data, domain types, submit webhook trigger
59
+ main.ts # Deno entrypoint: deploy + start workers + Deno.serve (page runtime + action overrides + poller)
60
+ deno.json
61
+ pages/
62
+ home.page.json # the screen, authored declaratively (ADR 0042 Page Composer)
63
+ scripts/
64
+ purge-db.ts # `deno task purge`: wipe + re-migrate the app db
65
+ resources/
66
+ processes/
67
+ convergence-loop.bpmn # the durable convergence process
68
+ db/
69
+ migrations/
70
+ 001_init.sql # sqlite schema
71
+ prompts/
72
+ review-round.md # agent instructions asset (injected into job data)
73
+ components/
74
+ review-round.json # Zeebe element template for the senior:pr-review service task
75
+ SPEC.md # this document
76
+ README.md
77
+ ```
78
+
79
+ ## 4. Convergence process (`convergence-loop.bpmn`)
80
+
81
+ Correlation key for all messages: **`prKey = "<owner>/<repo>#<number>"`** — stable,
82
+ known at submit time, carried as a process variable and stored on the DB row.
83
+
84
+ ```
85
+ (start: pr-submitted) vars in: { repo, prNumber, prUrl, prKey }
86
+
87
+
88
+ [Register PR] (script/handler) → insert DB row; round = 1
89
+ │ (base prompt delivered via the {{review-round}} model
90
+ │ template header, not a process variable)
91
+
92
+ ┌──▶ [Review round] (service task, taskType: senior:pr-review)
93
+ │ in : prUrl, repo, prNumber, round, answer? (prompt via task header)
94
+ │ out: status, summary, question?
95
+ │ │
96
+ │ ▼
97
+ │ <gateway: status>
98
+ │ ├── converged → [Mark converged] → (end: converged)
99
+ │ │
100
+ │ ├── addressed → [Record round] → <event-based gateway: review ready or timeout?>
101
+ │ │ ├── review-ready (msg catch, key = prKey) → round++ ─────┐
102
+ │ │ └── =reviewWaitTimeout (timer catch) │
103
+ │ │ → [Escalate: review stalled] (blocked) │
104
+ │ │ → [Wait: escalation-answered] ──────────────────────┤
105
+ │ │ │
106
+ │ └── needs_input [Record escalation] │ │
107
+ │ or blocked → (kind = question | blocker) │ │
108
+ │ → [Wait: escalation-answered] (msg catch) │ │
109
+ │ → set answer ──────────────────────────────┤ │
110
+ │ │ │
111
+ └────────────────────────────────────────────────────────────────────┴───────────────┘
112
+
113
+ Both `needs_input` (the agent has a question) and `blocked` (the agent is stuck
114
+ on something external — auth, a failing push, a missing secret) route to the
115
+ **same escalation path**: record it, sleep at `escalation-answered`, then retry
116
+ the same round with the human's `answer`. They differ only by escalation `kind`,
117
+ which the UI uses to label the card. Neither ends the run — a human always gets
118
+ a chance to unblock and resume.
119
+
120
+ Guard: before each Review round, if round > MAX_ROUNDS → force an escalation
121
+ ("not converged after N rounds") so a human decides, rather than looping forever.
122
+ ```
123
+
124
+ Notes:
125
+ - On `addressed`, the loop parks at an **event-based gateway** that races a
126
+ `review-ready` message (correlated by the poller when a fresh review lands)
127
+ against a `=reviewWaitTimeout` timer (seeded at submit from
128
+ `NANO_PR_REVIEW_WAIT_TIMEOUT`, default `PT20M`). Whichever fires first
129
+ withdraws the other — the message arm advances `round`, the timer arm escalates
130
+ a **stalled review** (`blocked`) so a human decides rather than the instance
131
+ hanging forever. Because `persist-round` already recorded this `round` as
132
+ `addressed` before the gateway, the timer arm opens the escalation **without
133
+ re-recording the round** (it passes `recordRound=false`), so a single round is
134
+ never logged as both `addressed` and `blocked`. This replaced a bare
135
+ `review-ready` catch that could hang
136
+ indefinitely: Copilot won't re-review a round with no new commit and routinely
137
+ dismisses a re-request, so with no timeout a review that never arrives wedged
138
+ the loop (observed: three convergence processes stalled ~22h). The poller's
139
+ auto re-request (§10) is the primary liveness mechanism; this timer is the
140
+ backstop when even repeated nudges fail.
141
+ - On `needs_input`, the same `round` is retried after the answer (the answer is
142
+ added to the agent's context; the round number does not advance).
143
+
144
+
145
+ ## 5. Agent job contract (`senior:pr-review`)
146
+
147
+ **Input** (`job.variables`):
148
+ | var | type | notes |
149
+ |---|---|---|
150
+ | `prUrl` | string | canonical PR URL |
151
+ | `repo` | string | `owner/name` |
152
+ | `prNumber` | int | |
153
+ | `round` | int | 1-based round counter |
154
+ | `answer` | string? | present only when resuming from an escalation |
155
+
156
+ The base instructions are **not** a job variable: they are delivered as a model
157
+ **template header** on the `senior:pr-review` task — header key
158
+ `io.nanobpm.agentTask.task.prompt` with value `{{review-round}}`, substituted with
159
+ `prompts/review-round.md` at deploy time.
160
+
161
+ **Output** (job result variables):
162
+ | var | type | notes |
163
+ |---|---|---|
164
+ | `status` | enum | `converged` \| `addressed` \| `needs_input` \| `blocked` |
165
+ | `summary` | string | human-readable account of what the round did |
166
+ | `question` | string? | required when `status = needs_input` or `blocked` — the question/blocker text a human must resolve |
167
+
168
+ The agent is responsible, within a round, for: reading the latest review,
169
+ triaging, editing/replying/pushing, and (when `addressed`) re-requesting review.
170
+
171
+ ### Workspace isolation (host mode)
172
+
173
+ Workspace isolation is the **worker harness's** responsibility, not this app's and
174
+ not the prompt's. The `c8ctl nano work` host-git provisioning (frozen v1 envelope)
175
+ gives **each job its own `mkdtemp` run-dir + fresh clone**, runs the agent with
176
+ `cwd` set to it (`AGENT_WORKSPACE`/`REPO_URL`/`REPO_BRANCH`/`REPO_REF` env), and
177
+ **reaps that run-dir when the job ends**. So multiple agents on one host do **not**
178
+ collide even in host mode — the isolation lives below the agent.
179
+
180
+ Consequences the prompt (`prompts/review-round.md`) encodes:
181
+ - The agent works only inside its provided `cwd`; it must **not** re-clone or create
182
+ a separate `git worktree`, and must not touch global/host state.
183
+ - The agent **cleans up anything it creates outside the commit** before returning
184
+ (worktrees, scratch branches/clones, temp files), so host mode does not leak.
185
+ - The harness checks out the PR's **existing head branch** and pushes back to it
186
+ (no new branch/PR). The `c8ctl` integration provisions the repo and resolves the
187
+ head branch from `prNumber`/`prUrl` — the app does not pass a `headBranch` var.
188
+
189
+ ## 6. Signals
190
+
191
+ | message | correlationKey | published by | payload |
192
+ |---|---|---|---|
193
+ | `pr-submitted` | — (start) | submit route/webhook | `{repo, prNumber, prUrl, prKey}` |
194
+ | `review-ready` | `prKey` | **poller** | `{reviewId, reviewState, submittedAt}` |
195
+ | `escalation-answered` | `prKey` | UI answer route | `{answer, escalationId}` |
196
+ | `deps-cleared` | `prKey` | **poller** (merge) | — (all `Depends-on` PRs merged) |
197
+ | `merge-ready` | `prKey` | **poller** (merge) | `{mergeState}` (`ready` \| `conflict` \| `blocked`); when `blocked`, also `{failingChecks, failingChecksList}` for the `senior:fix-ci` branch |
198
+ | `merge-landed` | `prKey` | **poller** (merge) | — (queued PR merged, or merged out-of-band) |
199
+
200
+ Note `escalation-answered` is reused by both processes (`convergence-loop` and
201
+ `merge-loop`); only one is ever active for a given `prKey`, so correlation is
202
+ unambiguous. Each `.bpmn` gives it a distinct message **id** (and distinct
203
+ envelope shape ids) to avoid duplicate-id collisions when the manifest deploys
204
+ both files.
205
+
206
+ ## 7. Domain model (SQLite — `db/migrations/001_init.sql`) — PROPOSED
207
+
208
+ ```sql
209
+ CREATE TABLE pull_requests (
210
+ pr_key TEXT PRIMARY KEY, -- "<owner>/<repo>#<number>"
211
+ repo TEXT NOT NULL, -- "<owner>/<repo>"
212
+ number INTEGER NOT NULL,
213
+ url TEXT NOT NULL,
214
+ title TEXT, -- fetched from GitHub
215
+ status TEXT NOT NULL, -- review: converging | waiting_review | escalated | converged; merge: waiting_deps | waiting_merge | queued | merging | merged; abandoned
216
+ current_round INTEGER NOT NULL DEFAULT 0,
217
+ process_key TEXT, -- engine process-instance key
218
+ waiting_since TEXT, -- ISO ts we began waiting for a review (poller cursor)
219
+ last_review_id INTEGER, -- last GitHub review id we reacted to
220
+ outcome TEXT, -- final summary
221
+ created_at TEXT NOT NULL,
222
+ updated_at TEXT NOT NULL,
223
+ converged_at TEXT,
224
+ merged_at TEXT -- set by `pr.mark-merged` (migration 004)
225
+ );
226
+
227
+ CREATE TABLE rounds (
228
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
229
+ pr_key TEXT NOT NULL REFERENCES pull_requests(pr_key),
230
+ round_no INTEGER NOT NULL,
231
+ status TEXT, -- converged | addressed | needs_input | blocked
232
+ summary TEXT,
233
+ started_at TEXT NOT NULL,
234
+ ended_at TEXT
235
+ );
236
+
237
+ CREATE TABLE escalations (
238
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
239
+ pr_key TEXT NOT NULL REFERENCES pull_requests(pr_key),
240
+ round_no INTEGER NOT NULL,
241
+ kind TEXT NOT NULL, -- question | blocker
242
+ question TEXT NOT NULL,
243
+ answer TEXT,
244
+ status TEXT NOT NULL, -- open | answered
245
+ asked_at TEXT NOT NULL,
246
+ answered_at TEXT
247
+ );
248
+
249
+ CREATE INDEX idx_pr_status ON pull_requests(status);
250
+ CREATE INDEX idx_rounds_pr ON rounds(pr_key);
251
+ CREATE INDEX idx_esc_pr ON escalations(pr_key);
252
+ ```
253
+
254
+ ## 8. Screen + routes (`main.ts` + `pages/home.page.json`)
255
+
256
+ The UI is authored declaratively as `pages/home.page.json` and served by the
257
+ generic Urban **page runtime** (`@nanobpm/app`, ADR 0042) — no hand-written SPA.
258
+ The page defines status-filtered tabs (active vs. history), a submit form, a
259
+ per-row **Cancel** action, and an expandable detail with the round/escalation
260
+ child grids, a lazily-loaded transcript, and a conditional **answer** form shown
261
+ when the PR has an open escalation (`open_escalation_id`, denormalised onto the
262
+ row by migration `003`).
263
+
264
+ `main.ts` delegates to the runtime and intercepts only the three actions that
265
+ carry app-specific business logic:
266
+
267
+ | method | route | purpose |
268
+ |---|---|---|
269
+ | `POST` | `/app/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process |
270
+ | `POST` | `/app/actions/cancel` | cancel the engine instance + mark the PR `abandoned` |
271
+ | `POST` | `/app/actions/message` (`escalation-answered`) | answer an open escalation → publish `escalation-answered` |
272
+ | `POST` | `/hooks/submit` | webhook submit (shared-secret auth) → start the process |
273
+
274
+ Everything else (`GET /`, `GET /app/pages/*`, `GET /app/data/*`, the renderer) is
275
+ served by the runtime. `deno task purge` wipes and re-migrates the app db (used
276
+ when the engine data is purged, to keep app state and engine state consistent).
277
+
278
+ ## 9. Prompt delivery — model-authored template headers
279
+
280
+ Each agent task's base prompt lives **only** in its `prompts/*.md` side-car and is
281
+ authored **into the model** as a deploy-time `{{stem}}` template. `nano.app.json`
282
+ declares `models.templates: ["prompts/*.md"]`, and each agent service task carries a
283
+ `io.nanobpm.agentTask.task.prompt = {{stem}}` task header (`{{review-round}}`,
284
+ `{{plan}}`, `{{plan-review}}`, `{{feature}}`, `{{fix-ci}}`). At deploy the template
285
+ substitutes the file content into the header, so the host no longer reads prompt
286
+ assets or carries them as process variables.
287
+
288
+ Per-instance dynamic context rides **`appendPrompt`**: an ioMapping sets a job-local
289
+ `appendPrompt` string (a plan's rejection findings, a feature task's brief, the
290
+ failing-check list) which the agent harness concatenates **verbatim** onto the header
291
+ base — the model owns any separator, and a null/empty append leaves the base
292
+ untouched. Base prompts can't be composed in FEEL (they are quote-heavy, and XML
293
+ attribute escaping would corrupt a FEEL string literal), so composition happens via
294
+ this append seam rather than inline in FEEL. Requires `@nanobpm/urban` with
295
+ deploy-time template substitution.
296
+
297
+ ## 10. Poller
298
+
299
+ An in-app loop (interval `NANO_PR_POLL_MS`, default 60s):
300
+ 1. `SELECT pr_key, repo, number, waiting_since, last_review_id, last_nudge_at FROM pull_requests WHERE status = 'waiting_review'`.
301
+ 2. For each, GET the PR's reviews from GitHub; find the newest review submitted
302
+ after `waiting_since` with id > `last_review_id`.
303
+ 3. If found → publish `review-ready` (key = `pr_key`, `{reviewId, ...}`) and set
304
+ `last_review_id`.
305
+ 4. If **not** found → ensure a review is in flight: unless Copilot is already a
306
+ pending reviewer, **re-request** it (REST `requested_reviewers`, exact login
307
+ `copilot-pull-request-reviewer[bot]`) and record `last_nudge_at`. This is
308
+ throttled to one attempt per `NANO_PR_REVIEW_NUDGE_MINUTES` window (default 5m)
309
+ so a re-request Copilot dismisses is retried without hammering the API. A repo
310
+ where Copilot isn't an assignable reviewer (HTTP 422) is left to the process's
311
+ review-wait timer (§4). This closes the stall where Copilot won't spontaneously
312
+ re-review and silently dismisses a re-request, so no `review-ready` ever fires.
313
+
314
+ Requires a GitHub token (`GITHUB_TOKEN`) or the host `gh` CLI. One cheap API call
315
+ per waiting PR per interval (plus at most one reviewer-state check + re-request per
316
+ nudge window).
317
+
318
+ ## 11. Merge stage (`merge-loop.bpmn`)
319
+
320
+ With `NANO_PR_AUTO_MERGE` on (default), the `pr.finalize` worker does not stop at
321
+ `converged` — it starts a **second** durable process, `merge-loop`, keyed on the
322
+ same `prKey`, sharing the datasource and poller. It merges the PR, honouring
323
+ merge-queue branches and cross-PR dependencies, and reuses the review stage's
324
+ escalation machinery for anything it can't resolve autonomously.
325
+
326
+ Flow:
327
+
328
+ ```
329
+ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─ ready ─► merge ─┬─ merged ─► mark merged ─► end
330
+ (deps-cleared) (waiting_merge) (merge-ready)│ ├─ queued ─► wait: landed ─► mark merged
331
+ │ │ (merge-landed)
332
+ │ └─ blocked ──────────────► escalate ─┐
333
+ ├─ conflict ─────────────────────────────► escalate ─┤
334
+ └─ blocked (failing checks) ─► auto-fix CI? │
335
+ ├─ within budget ─► [senior:fix-ci] ─┐ │
336
+ └─ budget exhausted ─► escalate ─┐ │ │
337
+ (re-arm) ◄── fixed ─┬────────────────────────────────────────────│────┘ │
338
+ └─ could not fix ─► escalate ─┐ │ │
339
+ ▼ ▼ ▼
340
+ wait: answered ─► (re-arm) ◄──────── (all escalations)
341
+ (escalation-answered)
342
+ ```
343
+
344
+ - **CI auto-fix** — a `blocked` verdict means a **required check failed**
345
+ (`classifyMergeability`). Rather than escalate immediately, the stage dispatches a
346
+ `senior:fix-ci` agent (base prompt via the `{{fix-ci}}` template header; the failing
347
+ check names ride `appendPrompt`) to green the checks on the branch, then re-arms the
348
+ poller. It repeats while `ciFixRound < ciFixMax`
349
+ (`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3; `0` disables). Only when the budget is
350
+ exhausted, the agent reports `blocked`, or the branch is in `conflict` does it fall
351
+ through to the human escalation path.
352
+
353
+ - **Dependencies** — `pr_dependencies(pr_key, depends_on_key)` (migration 004).
354
+ Declared two ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
355
+ submit) and/or a `dependsOn` array on the submit request. `merge-loop` parks at
356
+ *wait: deps merged*; the poller checks each dependency (own tracked row first,
357
+ else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
358
+ - **Mergeability** — the poller classifies GitHub's `mergeStateStatus`:
359
+ `CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND` → `ready`; `DIRTY` → `conflict`;
360
+ `BLOCKED` → `blocked` if a required check is failing, else keep waiting;
361
+ `DRAFT`/`UNKNOWN`/empty → keep waiting. It publishes `merge-ready {mergeState}`.
362
+ - **Merge** — `pr.merge` attempts the merge (`NANO_PR_MERGE_METHOD`, default
363
+ `squash`). GitHub auto-enqueues on merge-queue-required branches → the process
364
+ waits for `merge-landed` (poller detects the landed PR). Every attempt is
365
+ recorded in the `merges` audit table.
366
+ - **Escalation** — a conflict or a failing gate raises the same
367
+ `pr.persist-escalation` worker / UI answer form as the review stage (status
368
+ `escalated`); answering re-arms and retries.
369
+ - **Terminal** — `merged` (with `merged_at`), or `converged` when
370
+ `NANO_PR_AUTO_MERGE=0` (review-only), or `abandoned` on cancel.
371
+
372
+ Poller status choreography mirrors the review stage: before publishing a
373
+ resuming message the poller flips the row to a **transient** status the scan
374
+ queries skip (`merging`), so a slow pass can't double-signal.
375
+
376
+ ## 12. Configuration (env, `${VAR:-default}` in the manifest)
377
+
378
+ | var | default | purpose |
379
+ |---|---|---|
380
+ | `PORT` | 8090 | app HTTP port |
381
+ | `NANO_APP_DB_URL` | `file:./app.db` | sqlite |
382
+ | `GITHUB_TOKEN` | — | GitHub API (poller + agent) |
383
+ | `NANO_PR_POLL_MS` | 60000 | poll interval |
384
+ | `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
385
+ | `NANO_PR_WEBHOOK_SECRET` | — | HMAC for `/hooks/submit` |
386
+ | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only) |
387
+ | `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
388
+ | `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
389
+ | `NANO_PR_REVIEW_WAIT_TIMEOUT` | PT20M | ISO-8601 wait before a stalled review escalates (timer arm of the `wait-review` event-based gateway); malformed → default |
390
+ | `NANO_PR_REVIEW_NUDGE_MINUTES` | 5 | cooldown between poller Copilot re-request nudges per PR (clamped 1–1440) |
391
+
392
+ ## 13. Planning fan-out (`plan-fanout.bpmn`) — issue #14
393
+
394
+ A second process turns a **GitHub issue** into a fleet of PRs. It is the "series
395
+ then parallel" flat form: plan once, then fan out over the tasks in parallel, then
396
+ hand every produced PR to the convergence loop of §4.
397
+
398
+ ```
399
+ Start(issue) → plan → record-plan → implement (parallel MI) → record-results → End
400
+ ```
401
+
402
+ - **`plan`** — service task, job type `senior:plan`. Its base prompt is delivered
403
+ via the `{{plan}}` model template header (`prompts/plan.md`); when a prior review
404
+ rejected the plan, the rejection findings ride `appendPrompt` (an ioMapping over
405
+ `planFindings`) rather than being concatenated in FEEL. The agent reads the issue
406
+ via `gh` and emits `tasks: [{ id, title, prompt }]`.
407
+ - **`record-plan`** — app worker `pr.record-plan`. Normalizes the tasks (assigns a
408
+ stable `id`/index), writes one `plan_tasks` row each, sets `plans.task_count` and
409
+ status `dispatched`, and **re-emits** the normalized `tasks` so the fan-out
410
+ iterates the canonical list.
411
+ - **`implement`** — service task, job type `senior:feature`, **parallel
412
+ multi-instance** over `=tasks` (`inputElement="task"`,
413
+ `outputCollection="results"`). Its base prompt is delivered via the `{{feature}}`
414
+ model template header (`prompts/feature.md`); each child's per-task brief
415
+ (`"\n\n---\n\n" + task.prompt`) rides `appendPrompt` — an input mapping evaluated
416
+ **per child** (Zeebe parity: the inner activity keeps its own `zeebe:ioMapping`,
417
+ applied on each inner-instance activation with `task`/`loopCounter` bound). Each
418
+ agent opens a PR and returns `{ status, summary, pr }`; `outputElement` collects
419
+ those into `results[i]`, index-aligned with `tasks[i]`.
420
+ - **`record-results`** — app worker `pr.record-results`. Zips `results` back onto
421
+ `plan_tasks` by index, and for each opened `pr` calls the same idempotent
422
+ `submitPr` as §4 — **the handoff**: every fleet-produced PR enrols into the
423
+ review-convergence loop. Sets `plans` status `done`.
424
+
425
+ **Payloads are untyped** (no `nano:shapes`/`io.nanobpm.dataEnvelope`): the vocab is
426
+ scalar-only and cannot express the `tasks`/`results` lists, so the workers self-type
427
+ `job.variables` inline (like `finalize`). `urban gen` still emits the four task types.
428
+
429
+ **Domain model** (`db/migrations/004_planning.sql`): `plans` (one row per issue) +
430
+ `plan_tasks` (one row per slice, tracking its `status`/`pr_key`/`summary`).
431
+
432
+ **Entry points**: the page's "Hand an issue to the fleet" form
433
+ (`startProcess plan-fanout` → `actions/plan-start.ts`), or `POST /hooks/plan`
434
+ (`{ issue | url }`, optional `X-Hook-Secret`).
435
+
436
+ **Visibility**: the home page adds a **Plans** grid (Active: planning/dispatched;
437
+ History: done/failed/abandoned) with a `plan_tasks` child grid showing each task's
438
+ status and the PR it produced (`pr_key` cross-references the Pull requests grid for
439
+ convergence status).
440
+
441
+ ### 13.1 Dependency waves + merge barrier (issues #20, #26, release-notes-concierge)
442
+
443
+ The flat `implement → record-results` shape above evolved into a **wave loop**. The
444
+ planner may emit `dependsOn` edges; `record-plan` levelizes them into ordered
445
+ **waves** (`app/waves.ts` `computeWaves`, `plan_tasks.wave` + `plan_task_deps`), and
446
+ the loop runs one parallel `implement` MI fan-out per wave:
447
+
448
+ ```
449
+ … → select-wave → implement (parallel MI) → record-wave → gw-more
450
+ ↑ │ more
451
+ └───────────── wait-wave-merged ←────────────────────┘
452
+ │ done
453
+
454
+ record-results
455
+ ```
456
+
457
+ - **`select-wave`** (`pr.select-wave`) emits the current wave's still-`pending`
458
+ tasks as `waveTasks`; a task whose dependency ended `blocked`/`skipped` is marked
459
+ `skipped` (the failure cascades) rather than dispatched.
460
+ - **`record-wave`** (`pr.record-wave`) records each slice's outcome, hands every
461
+ opened PR to the convergence loop via `submitPr` (declaring dependency PRs as
462
+ `dependsOn`), and advances `currentWave`.
463
+ - **Wave-merge barrier** (`wait-wave-merged`): when a wave has a successor,
464
+ `record-wave` sets `plans.gate_wave` to that wave's index and the process parks at
465
+ the `wait-wave-merged` catch event. The poller's `pollWaveGates` pass publishes the
466
+ `wave-merged` message (correlated on `planKey`) once **every opened PR in that wave
467
+ has merged** (`app/waves.ts` `waveMergeTargets` selects the PRs to wait on;
468
+ `blocked`/`skipped`/keyless tasks clear vacuously), then clears `gate_wave`
469
+ single-shot. So a `dependsOn` means the dependent wave is not **implemented** until
470
+ its prerequisites have **landed on the base branch** — not merely opened. This lets
471
+ a blocking prerequisite (e.g. app scaffolding) fully converge and merge before the
472
+ next wave builds on it. `gate_wave` lives in `db/migrations/007_wave_gate.sql`.
473
+ - **Adopting a decomposed epic** (`prompts/plan.md` Step 0): when adopting existing
474
+ sub-issues, the planner honours an explicit `Depends-on: #N` / `Blocked by #N`
475
+ directive in a sub-issue body, mapping each prerequisite `#M` to `issue-M` in the
476
+ adopted task's `dependsOn` — so a human-declared blocking order survives adoption.
477
+
478
+ ## 14. Open questions / future
479
+
480
+ - **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
481
+ integration provisions the repo and checks out the PR's head branch (it must
482
+ already give the worker repo access to work at all), resolving the branch from
483
+ `prNumber`/`prUrl`. The app does **not** pass a `headBranch` job variable; the
484
+ job stays engine-shaped and the worker stays a pure provisioner.
485
+ - **review-ready via GitHub webhook** — same message, swappable faster trigger,
486
+ when the app is publicly reachable. Deferred (poller-only for v1).
487
+ - **Supervised vs external worker** — the agent runs as an external
488
+ `c8ctl nano work` daemon by default; a supervised in-server mode is possible
489
+ later (ADR 0041 decision).
490
+ - **Prompt versioning/hash** per PR for auditability.
491
+ - **Auth on the web UI** — the manifest `security` block (ADR 0028) if this is
492
+ exposed beyond localhost.
@@ -0,0 +1,93 @@
1
+ // Tests for the GET /hooks/abandon endpoint (issue #76).
2
+ import { assertEquals } from "jsr:@std/assert@1";
3
+ import type { AppApi } from "@nanobpm/urban";
4
+ import handler from "./abandon.ts";
5
+
6
+ // deno-lint-ignore no-explicit-any
7
+ function memApp(): { app: AppApi } {
8
+ // deno-lint-ignore no-explicit-any
9
+ const stores: Record<string, any[]> = {};
10
+ function tbl(name: string) {
11
+ // deno-lint-ignore no-explicit-any
12
+ const rows = (stores[name] ??= [] as any[]);
13
+ return {
14
+ // deno-lint-ignore no-explicit-any require-await
15
+ async insert(row: any) {
16
+ rows.push({ ...row });
17
+ return row.pr_key;
18
+ },
19
+ // deno-lint-ignore no-explicit-any require-await
20
+ async findOne(where: any = {}) {
21
+ return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
22
+ },
23
+ };
24
+ }
25
+ // deno-lint-ignore no-explicit-any
26
+ const app = { data: { table: (n: string) => tbl(n) } } as any as AppApi;
27
+ return { app };
28
+ }
29
+
30
+ function req(method: string, query: Record<string, string>) {
31
+ return {
32
+ method,
33
+ path: "/hooks/abandon",
34
+ query: new URLSearchParams(query),
35
+ headers: new Headers(),
36
+ text: async () => "",
37
+ };
38
+ }
39
+
40
+ async function call(app: AppApi, method: string, query: Record<string, string>) {
41
+ // deno-lint-ignore no-explicit-any
42
+ const res = await handler({ req: req(method, query) as any, body: undefined }, app);
43
+ // deno-lint-ignore no-explicit-any
44
+ return res as any;
45
+ }
46
+
47
+ async function seedPr(app: AppApi, prKey: string, token: string, status: string) {
48
+ await app.data.table("pull_requests", "pr_key").insert({ pr_key: prKey, abandon_token: token, status });
49
+ }
50
+
51
+ Deno.test("missing token → 400", async () => {
52
+ const { app } = memApp();
53
+ assertEquals((await call(app, "GET", {})).status, 400);
54
+ });
55
+
56
+ Deno.test("unknown token → 404 (does not reveal PRs)", async () => {
57
+ const { app } = memApp();
58
+ await seedPr(app, "o/r#1", "good", "converging");
59
+ assertEquals((await call(app, "GET", { token: "bad" })).status, 404);
60
+ });
61
+
62
+ Deno.test("a running PR → { abandoned: false }", async () => {
63
+ const { app } = memApp();
64
+ await seedPr(app, "o/r#1", "tok", "converging");
65
+ const res = await call(app, "GET", { token: "tok" });
66
+ assertEquals(res.status, 200);
67
+ assertEquals(res.body, { prKey: "o/r#1", status: "converging", abandoned: false });
68
+ });
69
+
70
+ Deno.test("a cancelled PR → { abandoned: true }", async () => {
71
+ const { app } = memApp();
72
+ await seedPr(app, "o/r#1", "tok", "abandoned");
73
+ const res = await call(app, "GET", { token: "tok" });
74
+ assertEquals(res.status, 200);
75
+ assertEquals(res.body.abandoned, true);
76
+ });
77
+
78
+ Deno.test("token via header is accepted", async () => {
79
+ const { app } = memApp();
80
+ await seedPr(app, "o/r#1", "tok", "abandoned");
81
+ const r = req("GET", {});
82
+ r.headers.set("x-abandon-token", "tok");
83
+ // deno-lint-ignore no-explicit-any
84
+ const res = await handler({ req: r as any, body: undefined }, app) as any;
85
+ assertEquals(res.status, 200);
86
+ assertEquals(res.body.abandoned, true);
87
+ });
88
+
89
+ Deno.test("non-GET → 405", async () => {
90
+ const { app } = memApp();
91
+ await seedPr(app, "o/r#1", "tok", "converging");
92
+ assertEquals((await call(app, "POST", { token: "tok" })).status, 405);
93
+ });
@@ -0,0 +1,23 @@
1
+ // GET /hooks/abandon?token=<capabilityToken> — the cooperative abandon check (issue #76).
2
+ //
3
+ // A DIRECT side-channel for a running `senior:*` agent to learn whether its run was cancelled
4
+ // before it performs an irreversible side effect (push / open PR / request review / merge). The
5
+ // per-PR capability token (query string) IS the credential: it scopes the read to exactly one PR,
6
+ // so no shared secret is needed — the agent curls the exact URL it was handed in its prompt. An
7
+ // unknown token is a 404 (never leaks which PRs exist).
8
+ //
9
+ // GET → { prKey, status, abandoned } — `abandoned` is derived from `pull_requests.status`,
10
+ // which `cancelRun` sets to 'abandoned' on cancel. `true` ⇒ the agent must stop.
11
+ import type { ActionHandler } from "@nanobpm/urban";
12
+ import { abandonStatusForToken } from "../app/abandon.ts";
13
+
14
+ const handler: ActionHandler = async ({ req }, app) => {
15
+ if (req.method !== "GET") return { status: 405, body: { error: "method not allowed (use GET)" } };
16
+ const token = (req.query.get("token") ?? req.headers.get("x-abandon-token") ?? "").trim();
17
+ if (!token) return { status: 400, body: { error: "missing abandon token" } };
18
+ const state = await abandonStatusForToken(app.data, token);
19
+ if (!state) return { status: 404, body: { error: "unknown abandon token" } };
20
+ return { status: 200, body: state };
21
+ };
22
+
23
+ export default handler;