@nanobpm/nano-workforce 0.60.0 → 0.61.1

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.61.1](https://github.com/nanobpm/nano-workforce/compare/v0.61.0...v0.61.1) (2026-08-13)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **feature:** reconcile Feature-history status + escalate blocked runs ([#204](https://github.com/nanobpm/nano-workforce/issues/204)) ([5ef9461](https://github.com/nanobpm/nano-workforce/commit/5ef94613f09b557806eb9254545df1984025077c)), closes [#171](https://github.com/nanobpm/nano-workforce/issues/171)
7
+
8
+ # [0.61.0](https://github.com/nanobpm/nano-workforce/compare/v0.60.0...v0.61.0) (2026-08-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * prompts as linked resources (bindingType: latest) — live mid-epic updates ([#198](https://github.com/nanobpm/nano-workforce/issues/198)) ([#203](https://github.com/nanobpm/nano-workforce/issues/203)) ([24a83e0](https://github.com/nanobpm/nano-workforce/commit/24a83e01f7e7b4f6e5296ce90c63fa44105aa058)), closes [#169](https://github.com/nanobpm/nano-workforce/issues/169) [#169](https://github.com/nanobpm/nano-workforce/issues/169)
14
+
1
15
  # [0.60.0](https://github.com/nanobpm/nano-workforce/compare/v0.59.0...v0.60.0) (2026-08-13)
2
16
 
3
17
 
package/README.md CHANGED
@@ -156,7 +156,8 @@ capability):
156
156
 
157
157
  - `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
158
158
  prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
159
- `job.variables`) to stdin; the relevant `prompts/*.md` template tells the agent how
159
+ `job.variables`) to stdin; the relevant `prompts/*.md` resource (linked into the task
160
+ and fetched by the harness at activation) tells the agent how
160
161
  to read it and where to write its result.
161
162
  - **`--allow-all-tools` is essential** for an unattended worker — without it Copilot
162
163
  pauses for permission before each tool call and the job stalls.
@@ -280,10 +281,18 @@ The `senior:*` tasks are serviced by **external** workers — they are **not** i
280
281
  manifest `workers[]`. Point a `c8ctl nano work` daemon (or any Zeebe-style worker) at
281
282
  the task types; each job carries its variables (e.g. `senior:pr-review` gets `{prUrl,
282
283
  repo, prNumber, round, answer?}` and returns `{status, summary, question?}`). An
283
- agent's base prompt is **not** in the job payload — it is delivered via a model
284
- **template header** (`{{review-round}}`, `{{plan}}`, `{{feature}}`, `{{fix-ci}}`, …)
285
- substituted at deploy time from `prompts/*.md` (`models.templates` in `nano.app.json`);
286
- per-instance context (e.g. a human's escalation answer) is appended by the harness.
284
+ agent's base prompt is **not** in the job payload — it is delivered as a
285
+ **linked resource** (likewise `plan.md`, `feature.md`, `fix-ci.md`, …):
286
+
287
+ ```xml
288
+ <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>
289
+ ```
290
+
291
+ that the engine resolves to the latest deployed `prompts/*.md` at job
292
+ activation (the prompts deploy as generic resources via a `models` glob in
293
+ `nano.app.json`); per-instance context (e.g. a human's escalation answer) is appended
294
+ by the harness. Because the binding is `latest`, redeploying one `prompts/*.md` updates
295
+ the prompt for the next activation in a **running** epic — see SPEC §9.
287
296
 
288
297
  ---
289
298
 
package/SPEC.md CHANGED
@@ -86,11 +86,11 @@ known at submit time, carried as a process variable and stored on the DB row.
86
86
 
87
87
 
88
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)
89
+ │ (base prompt delivered via the review-round.md
90
+ linked resource, not a process variable)
91
91
 
92
92
  ┌──▶ [Review round] (service task, taskType: senior:pr-review)
93
- │ in : prUrl, repo, prNumber, round, answer? (prompt via task header)
93
+ │ in : prUrl, repo, prNumber, round, answer? (prompt via linked resource)
94
94
  │ out: status, summary, question?
95
95
  │ │
96
96
  │ ▼
@@ -153,10 +153,11 @@ Notes:
153
153
  | `round` | int | 1-based round counter |
154
154
  | `answer` | string? | present only when resuming from an escalation |
155
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.
156
+ The base instructions are **not** a job variable: they are delivered as a
157
+ **linked resource** on the `senior:pr-review` task —
158
+ `<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>`,
159
+ which the engine resolves to the latest deployed `prompts/review-round.md` at job
160
+ activation.
160
161
 
161
162
  **Output** (job result variables):
162
163
  | var | type | notes |
@@ -292,24 +293,54 @@ tracked row to `abandoned` via the `instanceTracking` `onTerminated.set` patch.
292
293
  `deno task purge` wipes and re-migrates the app db (used
293
294
  when the engine data is purged, to keep app state and engine state consistent).
294
295
 
295
- ## 9. Prompt delivery — model-authored template headers
296
+ ## 9. Prompt delivery — linked resources (`bindingType: latest`)
296
297
 
297
- Each agent task's base prompt lives **only** in its `prompts/*.md` side-car and is
298
- authored **into the model** as a deploy-time `{{stem}}` template. `nano.app.json`
299
- declares `models.templates: ["prompts/*.md"]`, and each agent service task carries a
300
- `io.nanobpm.agentTask.task.prompt = {{stem}}` task header (`{{review-round}}`,
301
- `{{plan}}`, `{{plan-review}}`, `{{feature}}`, `{{fix-ci}}`). At deploy the template
302
- substitutes the file content into the header, so the host no longer reads prompt
303
- assets or carries them as process variables.
298
+ Each agent task's base prompt lives **only** in its `prompts/*.md` side-car, deployed
299
+ as a **generic resource** and **linked** not baked into the model (issue #169).
300
+ `nano.app.json` lists `prompts/*.md` in a `models` deploy glob, so `@nanobpm/urban`
301
+ deploys each file as an `application/octet-stream` resource whose deployed **name is
302
+ the file's basename** (`prompts/review-round.md` → resource `review-round.md`). Each
303
+ agent service task links it:
304
304
 
305
- Per-instance dynamic context rides **`appendPrompt`**: an ioMapping sets a job-local
306
- `appendPrompt` string (a plan's rejection findings, a feature task's brief, the
307
- failing-check list) which the agent harness concatenates **verbatim** onto the header
308
- base — the model owns any separator, and a null/empty append leaves the base
305
+ ```xml
306
+ <zeebe:linkedResources>
307
+ <zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt" />
308
+ </zeebe:linkedResources>
309
+ ```
310
+
311
+ At **job activation** the engine resolves the *latest deployed* key for that
312
+ `resourceId` and hands the content to the harness in the `linkedResources` activation
313
+ header; the harness fetches by key and uses it as the base prompt. Because the binding
314
+ is `latest`, **redeploying a single `prompts/*.md` changes the prompt for the next task
315
+ activation in a running epic** — no process redeploy, no in-flight epic restart. This
316
+ is the live-prompt debugging loop: edit one Markdown file, `urban deploy` (or restart
317
+ the app, which deploys on boot), and the next agent job of that type picks it up.
318
+
319
+ > **Latest-for-now, audited.** The engine currently keeps only the latest version per
320
+ > `resourceId` (`deployment`/`versionTag` bindings degrade to latest — no pinning yet).
321
+ > We accept `latest` (ideal for active debugging) and rely on the harness recording the
322
+ > resolved `resourceKey` per job for "which prompt did this run use?". True
323
+ > `deployment`-binding pinning for reproducible production epics is an engine follow-up,
324
+ > not part of #169.
325
+
326
+ > **The engine silently omits an unresolvable link.** A typo'd or undeployed
327
+ > `resourceId` is dropped from the activation header (no incident) — the agent would
328
+ > then run prompt-less. `scripts/check-agent-prompts.ts` (CI gate `check:prompts`)
329
+ > guards against this: every `linkName="prompt"` link's `resourceId` must match a
330
+ > prompt file the app actually deploys (a file in a `models` deploy glob), each linked
331
+ > prompt must be non-blank and teach the agent to emit a machine-readable result
332
+ > (`$AGENT_RESULT_FILE` / `::nano:result::`), and no task may still carry the retired
333
+ > baked `io.nanobpm.agentTask.task.prompt` header.
334
+
335
+ Per-instance dynamic context still rides **`appendPrompt`** (unchanged): an ioMapping
336
+ sets a job-local `appendPrompt` string (a plan's rejection findings, a feature task's
337
+ brief, the failing-check list) which the agent harness concatenates **verbatim** onto
338
+ the linked base — the model owns any separator, and a null/empty append leaves the base
309
339
  untouched. Base prompts can't be composed in FEEL (they are quote-heavy, and XML
310
340
  attribute escaping would corrupt a FEEL string literal), so composition happens via
311
- this append seam rather than inline in FEEL. Requires `@nanobpm/urban` with
312
- deploy-time template substitution.
341
+ this append seam rather than inline in FEEL. Requires an `@nanobpm/urban` deploy that
342
+ emits generic-resource deployments for `prompts/*.md` and a harness that consumes
343
+ `linkedResources` and fetches the resource by key.
313
344
 
314
345
  ## 10. Poller
315
346
 
@@ -365,7 +396,7 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
365
396
 
366
397
  - **CI auto-fix** — a `blocked` verdict means a **required check failed**
367
398
  (`classifyMergeability`). Rather than escalate immediately, the stage dispatches a
368
- `senior:fix-ci` agent (base prompt via the `{{fix-ci}}` template header; the failing
399
+ `senior:fix-ci` agent (base prompt via the `fix-ci.md` linked resource; the failing
369
400
  check names ride `appendPrompt`) to green the checks on the branch, then re-arms the
370
401
  poller. It repeats while `ciFixRound < ciFixMax`
371
402
  (`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3; `0` disables). Only when the budget is
@@ -432,7 +463,7 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
432
463
  ```
433
464
 
434
465
  - **`plan`** — service task, job type `senior:plan`. Its base prompt is delivered
435
- via the `{{plan}}` model template header (`prompts/plan.md`); when a prior review
466
+ via the `plan.md` linked resource (`bindingType: latest`); when a prior review
436
467
  rejected the plan, the rejection findings ride `appendPrompt` (an ioMapping over
437
468
  `planFindings`) rather than being concatenated in FEEL. The agent reads the issue
438
469
  via `gh` and emits `tasks: [{ id, title, prompt }]`.
@@ -442,8 +473,8 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
442
473
  iterates the canonical list.
443
474
  - **`implement`** — service task, job type `senior:feature`, **parallel
444
475
  multi-instance** over `=tasks` (`inputElement="task"`,
445
- `outputCollection="results"`). Its base prompt is delivered via the `{{feature}}`
446
- model template header (`prompts/feature.md`); each child's per-task brief
476
+ `outputCollection="results"`). Its base prompt is delivered via the `feature.md`
477
+ linked resource (`bindingType: latest`); each child's per-task brief
447
478
  (`"\n\n---\n\n" + task.prompt`) rides `appendPrompt` — an input mapping evaluated
448
479
  **per child** (Zeebe parity: the inner activity keeps its own `zeebe:ioMapping`,
449
480
  applied on each inner-instance activation with `task`/`loopCounter` bound). Each
@@ -7,7 +7,7 @@
7
7
  // process variables (the single `task` slice + the base-branch brief).
8
8
  import { test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
- import { FEATURE_PROCESS_ID, featureTaskId, startFeature } from "./feature.ts";
10
+ import { FEATURE_PROCESS_ID, FEATURE_TERMINAL_STATUSES, featureTaskId, startFeature } from "./feature.ts";
11
11
 
12
12
  function memTable(rows: any[], key: string) {
13
13
  return {
@@ -132,6 +132,30 @@ test("startFeature: an already-running run short-circuits (no new instance)", as
132
132
  assertEquals(result.processKey, "PI-OLD");
133
133
  });
134
134
 
135
+ test("startFeature: a run parked at the operator task (awaiting_operator) short-circuits a re-dispatch", async () => {
136
+ // A blocked run parked at the feature-blocked user task is NON-terminal, so re-dispatching the
137
+ // same issue must not spawn an orphaned parallel instance — it short-circuits until the operator
138
+ // acknowledges it (which settles it to terminal `blocked`).
139
+ assertEquals(FEATURE_TERMINAL_STATUSES.includes("awaiting_operator" as any), false);
140
+ const stores = {
141
+ feature_runs: {
142
+ rows: [{ feature_key: "owner/repo#42", status: "awaiting_operator", process_key: "PI-PARK" }],
143
+ key: "feature_key",
144
+ },
145
+ };
146
+ let created = 0;
147
+ const engine = {
148
+ createInstance: () => {
149
+ created += 1;
150
+ return Promise.resolve({ processInstanceKey: "PI-NEW" });
151
+ },
152
+ } as any;
153
+ const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
154
+ assertEquals(created, 0);
155
+ assertEquals("alreadyRunning" in result && (result as any).alreadyRunning, true);
156
+ assertEquals(result.processKey, "PI-PARK");
157
+ });
158
+
135
159
  test("startFeature: a settled run is restarted in place (status reset, pr/outcome cleared)", async () => {
136
160
  const stores = {
137
161
  feature_runs: {
package/app/feature.ts CHANGED
@@ -39,6 +39,12 @@ export interface FeatureRun {
39
39
  converge: number;
40
40
  auto_merge: number;
41
41
  outcome: string | null;
42
+ /** Human rollup detail. Projected by `pollFeatureDelivery` (fix: Feature history stuck at
43
+ * `converging`) and also written by `pr.record-blocked-ack` with the operator's disposition
44
+ * note when a blocked run is acknowledged. NULL until there is a signal. The reconciled TERMINAL
45
+ * outcome is written to `status` itself; this carries the sub-state / note (e.g. "merged",
46
+ * "waiting_review", or "operator: <note>"). */
47
+ delivery_label: string | null;
42
48
  created_at: string;
43
49
  updated_at: string;
44
50
  }
@@ -47,24 +53,70 @@ export const FEATURE_RUN_STATUSES = [
47
53
  "running", // the agent is implementing (including while parked at an escalation user task)
48
54
  "opened", // a PR was raised and the run ends here (converge was not requested)
49
55
  "converging", // the opened PR was handed to the convergence loop (live state via pr_key → pull_requests)
56
+ "awaiting_operator", // NON-terminal: the run is blocked and parked at the feature-blocked operator user task
57
+ "merged", // reconciled: the handed-off PR MERGED (pollFeatureDelivery, from pull_requests.status)
58
+ "converged", // reconciled: the handed-off PR converged but did not merge (auto-merge off)
50
59
  "blocked", // the agent could not open a PR (gave up / escalation abandoned)
51
60
  "skipped", // nothing to do
52
61
  "failed", // an unexpected failure
53
- "abandoned", // the process instance was cancelled (set by instanceTracking.onTerminated)
62
+ "abandoned", // reconciled: the handed-off PR was abandoned (pollFeatureDelivery), or the process
63
+ // instance itself was cancelled (set by instanceTracking.onTerminated)
54
64
  ] as const;
55
65
  export type FeatureRunStatus = typeof FEATURE_RUN_STATUSES[number];
56
66
 
57
67
  /** A feature run is finished once it leaves `running`. Mirrors PLAN_TERMINAL_STATUSES: a
58
- * re-dispatch of the same issue restarts only when the prior run has settled. */
68
+ * re-dispatch of the same issue restarts only when the prior run has settled. `converging` stays
69
+ * terminal-for-redispatch even though `pollFeatureDelivery` may later advance it to
70
+ * `merged`/`converged`/`abandoned` — those are equally terminal, so redispatch gating is unaffected.
71
+ * `awaiting_operator` is deliberately EXCLUDED (non-terminal): while a blocked run is parked at the
72
+ * feature-blocked operator user task its instance is still alive, so a re-dispatch of the same issue
73
+ * must short-circuit (no orphaned parallel instance) until the operator acknowledges it. */
59
74
  export const FEATURE_TERMINAL_STATUSES: readonly FeatureRunStatus[] = [
60
75
  "opened",
61
76
  "converging",
77
+ "merged",
78
+ "converged",
62
79
  "blocked",
63
80
  "skipped",
64
81
  "failed",
65
82
  "abandoned",
66
83
  ];
67
84
 
85
+ /** Reconciled delivery outcome for a single feature run, derived from its handed-off PR's
86
+ * `pull_requests.status`. Pure and read-only — the source of truth for the denormalised
87
+ * `feature_runs.status` transition + `feature_runs.delivery_label` that `pollFeatureDelivery`
88
+ * projects. Only meaningful for a run currently `converging` with a `pr_key`. */
89
+ export interface FeatureDeliveryRollup {
90
+ /** The reconciled `feature_runs.status`. Stays `converging` while the PR is still in flight
91
+ * (or its row is missing); advances to the matching terminal outcome once the PR settles. */
92
+ status: FeatureRunStatus;
93
+ /** Human rollup detail for the row (`delivery_label`). */
94
+ label: string;
95
+ }
96
+
97
+ /** Map a handed-off PR's `pull_requests.status` to the feature run's reconciled outcome.
98
+ *
99
+ * - `merged` → `merged` (the win). `converged` → `converged` (review done, not merged — auto-merge
100
+ * was off). `abandoned` → `abandoned`.
101
+ * - in-flight PR statuses (`converging`/`waiting_review`/`escalated`) keep the run `converging`,
102
+ * surfacing the live sub-state as the label so the grid stops looking frozen.
103
+ * - `null` (the `pull_requests` row is missing — DB desync) keeps the run `converging` and labels
104
+ * it so the desync is visible, never a false-positive terminal. */
105
+ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryRollup {
106
+ switch (prStatus) {
107
+ case "merged":
108
+ return { status: "merged", label: "merged" };
109
+ case "converged":
110
+ return { status: "converged", label: "converged (not merged)" };
111
+ case "abandoned":
112
+ return { status: "abandoned", label: "PR abandoned" };
113
+ case null:
114
+ return { status: "converging", label: "PR record missing" };
115
+ default:
116
+ return { status: "converging", label: prStatus };
117
+ }
118
+ }
119
+
68
120
  export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
69
121
 
70
122
  /** The deterministic task id for a single-issue run — the implementation agent branches
@@ -104,6 +156,7 @@ export async function startFeature(
104
156
  converge: converge ? 1 : 0,
105
157
  auto_merge: autoMerge ? 1 : 0,
106
158
  outcome: null,
159
+ delivery_label: null,
107
160
  updated_at: ts,
108
161
  });
109
162
  } else {
@@ -119,6 +172,7 @@ export async function startFeature(
119
172
  converge: converge ? 1 : 0,
120
173
  auto_merge: autoMerge ? 1 : 0,
121
174
  outcome: null,
175
+ delivery_label: null,
122
176
  created_at: ts,
123
177
  updated_at: ts,
124
178
  });
@@ -0,0 +1,117 @@
1
+ // Read-model derivation test for the FEATURE-run delivery reconcile (fix: Feature history stuck at
2
+ // `converging`). A single-issue feature run hands its opened PR to the convergence loop and ENDS with
3
+ // `feature_runs.status = 'converging'`; the PR's live outcome then lives only on `pull_requests`
4
+ // (keyed by `pr_key`). `deriveFeatureDelivery` is the pure source of truth for the status transition
5
+ // + `delivery_label` that `pollFeatureDelivery` projects onto the row so the grid stops looking frozen.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import type { DataLayer } from "@nanobpm/urban";
9
+ import { deriveFeatureDelivery } from "./feature.ts";
10
+ import { pollFeatureDelivery } from "./service.ts";
11
+
12
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
13
+ const stores: Record<string, any[]> = {};
14
+ function tbl(name: string, pk = "id") {
15
+ const rows = (stores[name] ??= [] as any[]);
16
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
17
+ return {
18
+ async all() {
19
+ return rows.slice();
20
+ },
21
+ async get(id: any) {
22
+ return rows.find((r) => r[pk] === id);
23
+ },
24
+ async find(where: any = {}) {
25
+ return rows.filter((r) => match(r, where));
26
+ },
27
+ async insert(row: any) {
28
+ rows.push({ ...row });
29
+ return row[pk];
30
+ },
31
+ async update(id: any, patch: any) {
32
+ const r = rows.find((row) => row[pk] === id);
33
+ if (r) Object.assign(r, patch);
34
+ },
35
+ };
36
+ }
37
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
38
+ return { data, stores };
39
+ }
40
+
41
+ test("deriveFeatureDelivery: merged PR advances the run to merged", () => {
42
+ assertEquals(deriveFeatureDelivery("merged"), { status: "merged", label: "merged" });
43
+ });
44
+
45
+ test("deriveFeatureDelivery: converged (review-only, unmerged) PR advances to converged", () => {
46
+ assertEquals(deriveFeatureDelivery("converged"), { status: "converged", label: "converged (not merged)" });
47
+ });
48
+
49
+ test("deriveFeatureDelivery: abandoned PR advances to abandoned", () => {
50
+ assertEquals(deriveFeatureDelivery("abandoned"), { status: "abandoned", label: "PR abandoned" });
51
+ });
52
+
53
+ test("deriveFeatureDelivery: an in-flight PR keeps the run converging, surfacing the sub-state", () => {
54
+ for (const s of ["converging", "waiting_review", "escalated"]) {
55
+ assertEquals(deriveFeatureDelivery(s), { status: "converging", label: s }, `status ${s}`);
56
+ }
57
+ });
58
+
59
+ test("deriveFeatureDelivery: a missing PR row keeps converging, never a false-positive terminal", () => {
60
+ assertEquals(deriveFeatureDelivery(null), { status: "converging", label: "PR record missing" });
61
+ });
62
+
63
+ test("pollFeatureDelivery: a converging run whose PR merged is reconciled to merged", async () => {
64
+ const { data, stores } = memData();
65
+ stores.feature_runs = [
66
+ { feature_key: "o/r#1", status: "converging", pr_key: "o/r#5", delivery_label: null },
67
+ ];
68
+ stores.pull_requests = [{ pr_key: "o/r#5", status: "merged" }];
69
+
70
+ await pollFeatureDelivery(data);
71
+
72
+ assertEquals(stores.feature_runs[0].status, "merged");
73
+ assertEquals(stores.feature_runs[0].delivery_label, "merged");
74
+ });
75
+
76
+ test("pollFeatureDelivery: a run with a still-in-flight PR stays converging with a live label", async () => {
77
+ const { data, stores } = memData();
78
+ stores.feature_runs = [
79
+ { feature_key: "o/r#2", status: "converging", pr_key: "o/r#6", delivery_label: null },
80
+ ];
81
+ stores.pull_requests = [{ pr_key: "o/r#6", status: "waiting_review" }];
82
+
83
+ await pollFeatureDelivery(data);
84
+
85
+ assertEquals(stores.feature_runs[0].status, "converging");
86
+ assertEquals(stores.feature_runs[0].delivery_label, "waiting_review");
87
+ });
88
+
89
+ test("pollFeatureDelivery: only touches converging runs with a pr_key", async () => {
90
+ const { data, stores } = memData();
91
+ stores.feature_runs = [
92
+ { feature_key: "o/r#3", status: "opened", pr_key: null, delivery_label: null }, // not converging
93
+ { feature_key: "o/r#4", status: "converging", pr_key: null, delivery_label: null }, // no PR to read
94
+ { feature_key: "o/r#5", status: "blocked", pr_key: "o/r#9", delivery_label: null }, // terminal, not converging
95
+ ];
96
+ stores.pull_requests = [{ pr_key: "o/r#9", status: "merged" }];
97
+
98
+ await pollFeatureDelivery(data);
99
+
100
+ assertEquals(stores.feature_runs[0].status, "opened");
101
+ assertEquals(stores.feature_runs[1].status, "converging");
102
+ assertEquals(stores.feature_runs[1].delivery_label, null);
103
+ assertEquals(stores.feature_runs[2].status, "blocked");
104
+ });
105
+
106
+ test("pollFeatureDelivery: a dangling pr_key (missing PR row) stays converging, never a false terminal", async () => {
107
+ const { data, stores } = memData();
108
+ stores.feature_runs = [
109
+ { feature_key: "o/r#6", status: "converging", pr_key: "o/r#404", delivery_label: null },
110
+ ];
111
+ stores.pull_requests = [];
112
+
113
+ await pollFeatureDelivery(data);
114
+
115
+ assertEquals(stores.feature_runs[0].status, "converging");
116
+ assertEquals(stores.feature_runs[0].delivery_label, "PR record missing");
117
+ });
@@ -62,9 +62,9 @@ test("rebase arm mirrors the fix-ci arm: budget gate → agent → result gate",
62
62
  assert(hasFlow("gw-rebase", "merge-esc-conflict"), "gw-rebase → merge-esc-conflict (budget exhausted) missing");
63
63
  assertStringIncludes(flat, "rebaseRound &lt; rebaseMax");
64
64
 
65
- // The agent is the senior:rebase fleet task, carrying its base prompt via the {{rebase}} header.
65
+ // The agent is the senior:rebase fleet task, carrying its base prompt via the rebase.md linked resource.
66
66
  assertStringIncludes(flat, 'type="senior:rebase"');
67
- assertStringIncludes(flat, 'value="{{rebase}}"');
67
+ assertStringIncludes(flat, 'resourceId="rebase.md"');
68
68
 
69
69
  // Agent → result gate; the round counter advances so the budget can actually be exhausted.
70
70
  assert(hasFlow("rebase", "gw-rebase-result"), "rebase → gw-rebase-result missing");
package/app/plan.ts CHANGED
@@ -31,11 +31,12 @@ export const ESCALATION_SLA_TIMEOUT = escalationSlaTimeout(
31
31
  const now = () => new Date().toISOString();
32
32
 
33
33
  // Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
34
- // `senior:feature` prompts are authored in the model as `{{plan}}` / `{{plan-review}}` /
35
- // `{{feature}}` deploy-time templates (see `models.templates` in nano.app.json) substituted into
36
- // each task's `io.nanobpm.agentTask.task.prompt` header. Per-instance dynamic context (a plan's
37
- // rejection findings, a task's brief) rides `appendPrompt`, which the harness concatenates onto
38
- // the header base. The host only carries runtime identity + `planFindings`.
34
+ // `senior:feature` prompts are generic resources (`prompts/plan.md` / `prompts/plan-review.md` /
35
+ // `prompts/feature.md`, deployed via a `models` glob in nano.app.json) linked into each task as
36
+ // `<zeebe:linkedResource bindingType="latest" linkName="prompt"/>` and resolved by the engine at
37
+ // job activation. Per-instance dynamic context (a plan's rejection findings, a task's brief) rides
38
+ // `appendPrompt`, which the harness concatenates onto the linked base. The host only carries
39
+ // runtime identity + `planFindings`.
39
40
 
40
41
  export interface Plan {
41
42
  plan_key: string;
package/app/retro.ts CHANGED
@@ -161,7 +161,7 @@ export async function gatherRetro(data: DataLayer, planKey: string): Promise<Ret
161
161
  }
162
162
 
163
163
  /** Render the digest as the compact markdown brief handed to the retro agent (rides `appendPrompt`,
164
- * concatenated after the base `{{retro}}` prompt — so it owns its own leading separator). */
164
+ * concatenated after the base `retro.md` linked-resource prompt — so it owns its own leading separator). */
165
165
  export function renderRetroBrief(d: RetroDigest): string {
166
166
  const lines: string[] = [
167
167
  "",
package/app/service.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  // `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
10
10
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
11
11
  import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
12
+ import { deriveFeatureDelivery, featureRuns } from "./feature.ts";
12
13
  import {
13
14
  classifyMergeability,
14
15
  ensureFreshHeadRun,
@@ -85,10 +86,11 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
85
86
  (process.env.NANO_PR_MERGE_ADMIN ?? "0").trim().toLowerCase(),
86
87
  );
87
88
 
88
- // The `senior:pr-review` agent prompt is no longer read by the host: it is authored in the
89
- // model as a `{{review-round}}` deploy-time template (see `models.templates` in nano.app.json)
90
- // substituted into the task's `io.nanobpm.agentTask.task.prompt` header. The host only carries
91
- // runtime PR identity + the round counter now.
89
+ // The `senior:pr-review` agent prompt is no longer read by the host: it is a generic resource
90
+ // (`prompts/review-round.md`, deployed via a `models` glob in nano.app.json) linked into the task
91
+ // as `<zeebe:linkedResource resourceId="review-round.md" bindingType="latest" linkName="prompt"/>`
92
+ // and resolved by the engine at job activation. The host only carries runtime PR identity + the
93
+ // round counter now.
92
94
 
93
95
  const now = () => new Date().toISOString();
94
96
 
@@ -1248,6 +1250,39 @@ export async function pollDelivery(data: DataLayer) {
1248
1250
  }
1249
1251
  }
1250
1252
 
1253
+ /** Reconcile each in-flight FEATURE run against its handed-off PR (fix: Feature history stuck at
1254
+ * `converging`). A feature run ends its own process with `status = converging` and its PR's live
1255
+ * outcome (merged / converged / abandoned) thereafter lives only on the `pull_requests` row keyed
1256
+ * by `pr_key` — so the Feature history grid, which reads `feature_runs`, showed `converging` forever.
1257
+ * This is the `feature_runs` twin of `pollDelivery` (which does the same for epic `plans`): for each
1258
+ * run currently `converging` with a `pr_key`, project the PR's status onto `feature_runs.status`
1259
+ * (advancing it to the matching terminal outcome once the PR settles) + a human `delivery_label`.
1260
+ * Never touches a run that isn't `converging` — additive/derived only, idempotent, best-effort. */
1261
+ export async function pollFeatureDelivery(data: DataLayer) {
1262
+ // Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
1263
+ const statusByPrKey = new Map<string, string>();
1264
+ for (const pr of await prs(data).all()) statusByPrKey.set(pr.pr_key, pr.status);
1265
+ // Only `converging` runs are ever reconciled — query them via the `feature_runs(status)` index
1266
+ // (db/migrations/028) instead of scanning all history, so this pass stays O(in-flight), not
1267
+ // O(total runs), as the table grows.
1268
+ for (const run of await featureRuns(data).find({ status: "converging" })) {
1269
+ if (!run.pr_key) continue;
1270
+ try {
1271
+ const prStatus = statusByPrKey.get(run.pr_key) ?? null;
1272
+ const { status, label } = deriveFeatureDelivery(prStatus);
1273
+ if (run.status !== status || run.delivery_label !== label) {
1274
+ await featureRuns(data).update(run.feature_key, {
1275
+ status,
1276
+ delivery_label: label,
1277
+ updated_at: now(),
1278
+ });
1279
+ }
1280
+ } catch (err) {
1281
+ console.error(`[poller] feature delivery ${run.feature_key}: ${err}`);
1282
+ }
1283
+ }
1284
+ }
1285
+
1251
1286
  /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
1252
1287
  * (when the engine REST endpoint is supplied) the job-activation visibility pass and the
1253
1288
  * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
@@ -1261,6 +1296,7 @@ export async function pollOnce(
1261
1296
  await pollMerges(data, engine, token);
1262
1297
  await pollWaveGates(data, engine, token);
1263
1298
  await pollDelivery(data);
1299
+ await pollFeatureDelivery(data);
1264
1300
  if (engineRest) {
1265
1301
  await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1266
1302
  await pollIncidents(data, engineRest.restAddress, engineRest.token);
@@ -0,0 +1,21 @@
1
+ -- Feature-run delivery reconcile (fix: Feature history stuck at `converging`).
2
+ --
3
+ -- A single-issue feature run hands its opened PR to the convergence loop and then
4
+ -- ENDS with `feature_runs.status = 'converging'` (terminal for the run's own
5
+ -- process). The PR's live outcome (merged / converged / abandoned) thereafter
6
+ -- lives only on the `pull_requests` row keyed by `pr_key`, so the Feature history
7
+ -- grid — which reads `feature_runs` — showed `converging` forever even after the
8
+ -- PR merged. The epic side already solves the identical gap for `plans` via
9
+ -- `pollDelivery` → `plans.delivery` (issue #171); this brings the same reconcile
10
+ -- to `feature_runs`.
11
+ --
12
+ -- `delivery_label` is the human rollup detail projected onto the row by
13
+ -- `pollFeatureDelivery` (e.g. "merged", "converged (not merged)", "PR abandoned",
14
+ -- or an in-flight sub-state like "waiting_review"). It is also written by
15
+ -- `pr.record-blocked-ack` with the operator's disposition note (e.g. "operator:
16
+ -- <note>" / "acknowledged") when a blocked run is acknowledged. NULL until there
17
+ -- is a signal (no `pr_key`, or the run never reached `converging` and was not
18
+ -- acknowledged). The reconciled terminal
19
+ -- outcome is written to `status` itself (converging → merged | converged |
20
+ -- abandoned) so the existing Status column becomes accurate.
21
+ ALTER TABLE feature_runs ADD COLUMN delivery_label TEXT;
@@ -55,6 +55,7 @@ interface FeatureRow {
55
55
  process_key: string | null;
56
56
  status: string;
57
57
  pr_key: string | null;
58
+ delivery_label: string | null;
58
59
  }
59
60
  interface PrRow {
60
61
  pr_key: string;
@@ -160,16 +161,40 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
160
161
  );
161
162
  });
162
163
 
163
- test("blocked: a non-opened result never enrolls a PR and settles blocked", async () => {
164
+ test("blocked: parks at the operators' user task (non-terminal), never enrolls a PR, settles on ack", async () => {
164
165
  await withApp(
165
166
  { "senior:feature": () => ({ status: "blocked", summary: "could not proceed" }) },
166
167
  { baseBranch: "epic/e2e", converge: true },
167
- async ({ app, featureKey }) => {
168
+ async ({ app, featureKey, processKey }) => {
168
169
  const flows = takenFlows(app);
169
- assert.ok(flows.includes("gw-converge->End"), "a blocked run takes the raise-only branch even with converge=true");
170
- const run = await featureRow(app, featureKey);
171
- assert.equal(run.status, "blocked");
172
- assert.equal(run.pr_key, null);
170
+ assert.ok(
171
+ flows.includes("gw-blocked->feature-blocked"),
172
+ `a blocked run routes to the operators' user task, not the converge gateway (flows: ${flows.join(", ")})`,
173
+ );
174
+ assert.ok(!flows.includes("gw-blocked->gw-converge"), "a blocked run never reaches the converge gateway");
175
+
176
+ // The instance stays alive parked at a completable native user task (operators inbox) — a
177
+ // blocked run is never a silent dead-end — and the row is NON-terminal `awaiting_operator`
178
+ // so a re-dispatch of the same issue short-circuits (no orphaned parallel run).
179
+ const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
180
+ const task = tasks.find((t) => t.elementId === "feature-blocked") as InboxTask | undefined;
181
+ assert.ok(task?.userTaskKey, "the blocked outcome parked a completable native user task");
182
+
183
+ const parked = await featureRow(app, featureKey);
184
+ assert.equal(parked.status, "awaiting_operator", "while parked the run is non-terminal awaiting_operator");
185
+ assert.equal(parked.pr_key, null);
186
+ const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({});
187
+ assert.equal(prs.length, 0, "a blocked run never enrolled a PR into the convergence loop");
188
+
189
+ // Acknowledging the blocked run records the note, settles it terminal `blocked`, and ends.
190
+ await app.engine.completeUserTask(task!.userTaskKey, { note: "reassigned to a human" });
191
+ await app.settle();
192
+ const flows2 = takenFlows(app);
193
+ assert.ok(flows2.includes("feature-blocked->record-blocked-ack"), "ack routes through record-blocked-ack");
194
+ assert.ok(flows2.includes("record-blocked-ack->End"), "the acknowledged run ends");
195
+ const settled = await featureRow(app, featureKey);
196
+ assert.equal(settled.status, "blocked", "the acknowledged run settles at terminal blocked");
197
+ assert.equal(settled.delivery_label, "operator: reassigned to a human", "the operator note is recorded");
173
198
  },
174
199
  );
175
200
  });