@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 +14 -0
- package/README.md +14 -5
- package/SPEC.md +56 -25
- package/app/feature.test.ts +25 -1
- package/app/feature.ts +56 -2
- package/app/featureDelivery.test.ts +117 -0
- package/app/mergeRebaseArm.test.ts +2 -2
- package/app/plan.ts +6 -5
- package/app/retro.ts +1 -1
- package/app/service.ts +40 -4
- package/db/migrations/030_feature_delivery.sql +21 -0
- package/e2e/feature-run.e2e.ts +31 -6
- package/nano.app.json +6 -4
- package/package.json +1 -1
- package/pages/feature.page.json +4 -3
- package/resources/agent-guide.md +11 -3
- package/resources/forms/feature-blocked.form +17 -0
- package/resources/processes/convergence-loop.bpmn +3 -3
- package/resources/processes/feature.bpmn +90 -22
- package/resources/processes/merge-loop.bpmn +6 -6
- package/resources/processes/plan-fanout.bpmn +12 -12
- package/resources/processes/retro.bpmn +3 -3
- package/scripts/check-agent-prompts.test.ts +141 -35
- package/scripts/check-agent-prompts.ts +145 -93
- package/workers/record-blocked-ack/worker.test.ts +49 -0
- package/workers/record-blocked-ack/worker.ts +36 -0
- package/workers/record-feature/worker.ts +8 -2
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`
|
|
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
|
|
284
|
-
**
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
90
|
-
│
|
|
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
|
|
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
|
|
157
|
-
**
|
|
158
|
-
|
|
159
|
-
`prompts/review-round.md` at
|
|
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 —
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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`
|
|
312
|
-
|
|
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 `
|
|
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 `
|
|
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 `
|
|
446
|
-
|
|
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
|
package/app/feature.test.ts
CHANGED
|
@@ -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
|
|
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 < rebaseMax");
|
|
64
64
|
|
|
65
|
-
// The agent is the senior:rebase fleet task, carrying its base prompt via the
|
|
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, '
|
|
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
|
|
35
|
-
// `
|
|
36
|
-
//
|
|
37
|
-
// rejection findings, a task's brief) rides
|
|
38
|
-
// the
|
|
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 `
|
|
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
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// runtime PR identity + the
|
|
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;
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
});
|