@nanobpm/nano-workforce 0.61.0 → 0.62.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.
- package/CHANGELOG.md +14 -0
- package/app/feature.test.ts +27 -1
- package/app/feature.ts +61 -2
- package/app/featureDelivery.test.ts +117 -0
- package/app/service.ts +35 -0
- package/db/migrations/030_feature_delivery.sql +21 -0
- package/e2e/feature-run.e2e.ts +31 -6
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/pages/feature.page.json +4 -3
- package/prompts/feature.md +39 -9
- package/resources/forms/feature-blocked.form +17 -0
- package/resources/processes/feature.bpmn +87 -19
- 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.62.0](https://github.com/nanobpm/nano-workforce/compare/v0.61.1...v0.62.0) (2026-08-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **feature-prompt:** claim the issue with a comment before starting work ([#207](https://github.com/nanobpm/nano-workforce/issues/207)) ([b1b23c9](https://github.com/nanobpm/nano-workforce/commit/b1b23c9928704616aa2173cfa056668a11d32e1c))
|
|
7
|
+
|
|
8
|
+
## [0.61.1](https://github.com/nanobpm/nano-workforce/compare/v0.61.0...v0.61.1) (2026-08-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **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)
|
|
14
|
+
|
|
1
15
|
# [0.61.0](https://github.com/nanobpm/nano-workforce/compare/v0.60.0...v0.61.0) (2026-08-13)
|
|
2
16
|
|
|
3
17
|
|
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 {
|
|
@@ -104,6 +104,8 @@ test("startFeature: seeds the single task slice + base-branch brief onto the ins
|
|
|
104
104
|
assertEquals(v.task.prompt.includes("owner/repo#42"), true);
|
|
105
105
|
assertEquals(v.converge, true);
|
|
106
106
|
assertEquals(v.autoMerge, false);
|
|
107
|
+
// A single-issue run owns its issue, so the agent is told it may claim it (epic slices never set this).
|
|
108
|
+
assertEquals(v.claimIssue, true);
|
|
107
109
|
assertEquals(v.baseBranch, "epic/x");
|
|
108
110
|
// The brief is the authoritative base-branch override the agent gets via appendPrompt.
|
|
109
111
|
assertEquals(v.baseBranchBrief.includes("epic/x"), true);
|
|
@@ -132,6 +134,30 @@ test("startFeature: an already-running run short-circuits (no new instance)", as
|
|
|
132
134
|
assertEquals(result.processKey, "PI-OLD");
|
|
133
135
|
});
|
|
134
136
|
|
|
137
|
+
test("startFeature: a run parked at the operator task (awaiting_operator) short-circuits a re-dispatch", async () => {
|
|
138
|
+
// A blocked run parked at the feature-blocked user task is NON-terminal, so re-dispatching the
|
|
139
|
+
// same issue must not spawn an orphaned parallel instance — it short-circuits until the operator
|
|
140
|
+
// acknowledges it (which settles it to terminal `blocked`).
|
|
141
|
+
assertEquals(FEATURE_TERMINAL_STATUSES.includes("awaiting_operator" as any), false);
|
|
142
|
+
const stores = {
|
|
143
|
+
feature_runs: {
|
|
144
|
+
rows: [{ feature_key: "owner/repo#42", status: "awaiting_operator", process_key: "PI-PARK" }],
|
|
145
|
+
key: "feature_key",
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
let created = 0;
|
|
149
|
+
const engine = {
|
|
150
|
+
createInstance: () => {
|
|
151
|
+
created += 1;
|
|
152
|
+
return Promise.resolve({ processInstanceKey: "PI-NEW" });
|
|
153
|
+
},
|
|
154
|
+
} as any;
|
|
155
|
+
const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
156
|
+
assertEquals(created, 0);
|
|
157
|
+
assertEquals("alreadyRunning" in result && (result as any).alreadyRunning, true);
|
|
158
|
+
assertEquals(result.processKey, "PI-PARK");
|
|
159
|
+
});
|
|
160
|
+
|
|
135
161
|
test("startFeature: a settled run is restarted in place (status reset, pr/outcome cleared)", async () => {
|
|
136
162
|
const stores = {
|
|
137
163
|
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
|
});
|
|
@@ -148,6 +202,11 @@ export async function startFeature(
|
|
|
148
202
|
// merge-loop. `converge=false` ⇒ merge is moot.
|
|
149
203
|
converge,
|
|
150
204
|
autoMerge,
|
|
205
|
+
// A single-issue feature run OWNS its issue (the whole issue is the slice), so the agent may
|
|
206
|
+
// claim it with a "starting work" comment on a first run (prompts/feature.md). Epic slices
|
|
207
|
+
// (plan-fanout) deliberately DO NOT set this — their `issue` is the shared parent epic, which
|
|
208
|
+
// must never be claimed per-slice.
|
|
209
|
+
claimIssue: true,
|
|
151
210
|
// Seed the agent-result variables so the escalation loop + record worker can reference them
|
|
152
211
|
// before the first `senior:feature` job completes (the harness merges the real values in).
|
|
153
212
|
answer: null,
|
|
@@ -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
|
+
});
|
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,
|
|
@@ -1249,6 +1250,39 @@ export async function pollDelivery(data: DataLayer) {
|
|
|
1249
1250
|
}
|
|
1250
1251
|
}
|
|
1251
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
|
+
|
|
1252
1286
|
/** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
|
|
1253
1287
|
* (when the engine REST endpoint is supplied) the job-activation visibility pass and the
|
|
1254
1288
|
* technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
|
|
@@ -1262,6 +1296,7 @@ export async function pollOnce(
|
|
|
1262
1296
|
await pollMerges(data, engine, token);
|
|
1263
1297
|
await pollWaveGates(data, engine, token);
|
|
1264
1298
|
await pollDelivery(data);
|
|
1299
|
+
await pollFeatureDelivery(data);
|
|
1265
1300
|
if (engineRest) {
|
|
1266
1301
|
await pollJobActivation(data, engineRest.restAddress, engineRest.token);
|
|
1267
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
|
});
|
package/nano.app.json
CHANGED
|
@@ -132,6 +132,10 @@
|
|
|
132
132
|
"taskType": "pr.record-feature",
|
|
133
133
|
"handler": "workers/record-feature/worker.ts"
|
|
134
134
|
},
|
|
135
|
+
{
|
|
136
|
+
"taskType": "pr.record-blocked-ack",
|
|
137
|
+
"handler": "workers/record-blocked-ack/worker.ts"
|
|
138
|
+
},
|
|
135
139
|
{
|
|
136
140
|
"taskType": "pr.converge-feature",
|
|
137
141
|
"handler": "workers/converge-feature/worker.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/feature.page.json
CHANGED
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
"source": "app",
|
|
60
60
|
"table": "feature_runs",
|
|
61
61
|
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
62
|
-
"filter": [{ "field": "status", "in": ["running"] }]
|
|
62
|
+
"filter": [{ "field": "status", "in": ["running", "awaiting_operator"] }]
|
|
63
63
|
},
|
|
64
64
|
"tabs": [
|
|
65
|
-
{ "label": "Active", "filter": [{ "field": "status", "in": ["running"] }] },
|
|
66
|
-
{ "label": "History", "filter": [{ "field": "status", "in": ["opened", "converging", "blocked", "skipped", "failed", "abandoned"] }] },
|
|
65
|
+
{ "label": "Active", "filter": [{ "field": "status", "in": ["running", "awaiting_operator"] }] },
|
|
66
|
+
{ "label": "History", "filter": [{ "field": "status", "in": ["opened", "converging", "merged", "converged", "blocked", "skipped", "failed", "abandoned"] }] },
|
|
67
67
|
{ "label": "All", "filter": [] }
|
|
68
68
|
],
|
|
69
69
|
"columns": [
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
72
72
|
{ "field": "base_branch", "header": "Base branch" },
|
|
73
73
|
{ "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
74
|
+
{ "field": "delivery_label", "header": "Delivery" },
|
|
74
75
|
{ "field": "converge", "header": "Converge" },
|
|
75
76
|
{ "field": "auto_merge", "header": "Auto-merge" },
|
|
76
77
|
{ "field": "outcome", "header": "Outcome" },
|
package/prompts/feature.md
CHANGED
|
@@ -22,16 +22,44 @@ You have `gh` / git authenticated for the target repository.
|
|
|
22
22
|
|
|
23
23
|
Always use the branch **`feat/<task.id>`**. Because a resumed run gets a fresh
|
|
24
24
|
process with no memory of your last run, the branch name MUST be derivable from
|
|
25
|
-
`task.id` alone. On start, check whether it already exists on the remote
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
`task.id` alone. On start, check whether it already exists on the remote with
|
|
26
|
+
`git ls-remote --heads origin feat/<task.id>` — a non-empty result means the
|
|
27
|
+
branch exists. Use this ref check as the authoritative first-run/resume signal:
|
|
28
|
+
a remote branch can exist without any PR, so `gh pr list --head feat/<task.id> --state all`
|
|
29
|
+
is reliable only as a *supplementary* PR lookup once `git ls-remote` has
|
|
30
|
+
established the branch exists, never as the existence check itself:
|
|
28
31
|
|
|
29
32
|
- **It does not exist** → this is a first run. Branch off the base branch (see
|
|
30
33
|
the note below — usually the repository default branch, but an epic may pin an
|
|
31
|
-
integration branch in your appended task context).
|
|
34
|
+
integration branch in your appended task context). **Claim the issue now** — see
|
|
35
|
+
below.
|
|
32
36
|
- **It exists** → this is a **resume**. `git fetch` and check it out, read its diff
|
|
33
37
|
and any open (draft) PR, and **continue from there** — do not restart from
|
|
34
|
-
scratch. Fold in `variables.answer` as the guidance you were waiting on.
|
|
38
|
+
scratch. Fold in `variables.answer` as the guidance you were waiting on. Do
|
|
39
|
+
**not** re-claim — you already announced this run on your first pass.
|
|
40
|
+
|
|
41
|
+
## Claim your issue on a first run (only when `variables.claimIssue` is true)
|
|
42
|
+
|
|
43
|
+
So humans and other agents can see the work has been picked up, announce yourself
|
|
44
|
+
on the issue **before implementing** — but ONLY on a first run (your
|
|
45
|
+
`feat/<task.id>` branch did not yet exist, per the check above) AND only when
|
|
46
|
+
**`variables.claimIssue` is true**:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
gh issue comment <variables.issue> --body-file - <<'BODY'
|
|
50
|
+
🤖 Starting work on this issue — branch `feat/<task.id>`.
|
|
51
|
+
BODY
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `variables.claimIssue` is set **only for single-issue feature runs**, where
|
|
55
|
+
`variables.issue` IS the issue you implement and close, so claiming it is
|
|
56
|
+
correct. **Epic slices leave it unset** — their `variables.issue` is the shared
|
|
57
|
+
*parent epic*, which must never be claimed per-slice; when `claimIssue` is not
|
|
58
|
+
true, **skip this step entirely**.
|
|
59
|
+
- The branch-existence check above is what makes this idempotent: a resumed run
|
|
60
|
+
(branch already exists) never re-claims, so the issue gets exactly one claim.
|
|
61
|
+
- It is a courtesy claim, **not a gate** — if the comment fails (e.g. a transient
|
|
62
|
+
`gh` error), carry on and implement anyway.
|
|
35
63
|
|
|
36
64
|
## Your base branch (default branch, unless the epic pins one)
|
|
37
65
|
|
|
@@ -44,14 +72,16 @@ against the wrong base will not be merged into the epic.
|
|
|
44
72
|
|
|
45
73
|
## What to do
|
|
46
74
|
|
|
47
|
-
1.
|
|
75
|
+
1. Claim your issue if `variables.claimIssue` is true and this is a first run (see
|
|
76
|
+
above).
|
|
77
|
+
2. Clone / check out your base branch (first run — the default branch, or the
|
|
48
78
|
pinned epic branch if your context names one) or your existing
|
|
49
79
|
`feat/<task.id>` branch (resume — see above).
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
3. Implement `task.prompt`. Keep the change scoped to this slice only.
|
|
81
|
+
4. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the
|
|
52
82
|
branch, and open a pull request with `gh pr create` describing the slice and
|
|
53
83
|
linking the parent issue (`Depends-on:`/`Closes` as appropriate).
|
|
54
|
-
|
|
84
|
+
5. Clean up any scratch clone/worktree you created outside the commit.
|
|
55
85
|
|
|
56
86
|
> **Do not request the Copilot review yourself.** When you open a *ready* PR the
|
|
57
87
|
> app enrolls it into the review-convergence loop and requests the initial
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "feature-blocked",
|
|
3
|
+
"schemaVersion": 18,
|
|
4
|
+
"type": "default",
|
|
5
|
+
"components": [
|
|
6
|
+
{
|
|
7
|
+
"type": "text",
|
|
8
|
+
"text": "This single-issue feature run is **blocked** — the implementation agent could not open a PR (it gave up, or the escalation was abandoned / timed out). It landed in your inbox so a blocked run is never a silent dead-end. Review the outcome, then acknowledge to close the run.",
|
|
9
|
+
"label": "Blocked feature run"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"type": "textarea",
|
|
13
|
+
"key": "note",
|
|
14
|
+
"label": "Disposition note (what you did / why — recorded on the run)"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -63,10 +63,35 @@
|
|
|
63
63
|
<bpmn:incoming>w_done</bpmn:incoming>
|
|
64
64
|
<bpmn:incoming>w_abandon</bpmn:incoming>
|
|
65
65
|
<bpmn:incoming>w_sla</bpmn:incoming>
|
|
66
|
-
<bpmn:outgoing>
|
|
66
|
+
<bpmn:outgoing>f_toBlockedGw</bpmn:outgoing>
|
|
67
|
+
</bpmn:serviceTask>
|
|
68
|
+
<bpmn:exclusiveGateway id="gw-blocked" name="blocked?" default="f_notBlocked">
|
|
69
|
+
<bpmn:incoming>f_toBlockedGw</bpmn:incoming>
|
|
70
|
+
<bpmn:outgoing>f_blocked</bpmn:outgoing>
|
|
71
|
+
<bpmn:outgoing>f_notBlocked</bpmn:outgoing>
|
|
72
|
+
</bpmn:exclusiveGateway>
|
|
73
|
+
<bpmn:userTask id="feature-blocked" name="Blocked run (human)">
|
|
74
|
+
<bpmn:extensionElements>
|
|
75
|
+
<zeebe:formDefinition formId="feature-blocked" />
|
|
76
|
+
<zeebe:userTask />
|
|
77
|
+
<zeebe:assignmentDefinition candidateGroups="operators" />
|
|
78
|
+
</bpmn:extensionElements>
|
|
79
|
+
<bpmn:incoming>f_blocked</bpmn:incoming>
|
|
80
|
+
<bpmn:outgoing>f_toBlockedAck</bpmn:outgoing>
|
|
81
|
+
</bpmn:userTask>
|
|
82
|
+
<bpmn:serviceTask id="record-blocked-ack" name="Record acknowledgement">
|
|
83
|
+
<bpmn:extensionElements>
|
|
84
|
+
<zeebe:taskDefinition type="pr.record-blocked-ack" />
|
|
85
|
+
<zeebe:ioMapping>
|
|
86
|
+
<zeebe:input source="=featureKey" target="featureKey" />
|
|
87
|
+
<zeebe:input source="=if (is defined(note)) then note else null" target="note" />
|
|
88
|
+
</zeebe:ioMapping>
|
|
89
|
+
</bpmn:extensionElements>
|
|
90
|
+
<bpmn:incoming>f_toBlockedAck</bpmn:incoming>
|
|
91
|
+
<bpmn:outgoing>f_blockedEnd</bpmn:outgoing>
|
|
67
92
|
</bpmn:serviceTask>
|
|
68
93
|
<bpmn:exclusiveGateway id="gw-converge" name="converge?" default="f_noConverge">
|
|
69
|
-
<bpmn:incoming>
|
|
94
|
+
<bpmn:incoming>f_notBlocked</bpmn:incoming>
|
|
70
95
|
<bpmn:outgoing>f_converge</bpmn:outgoing>
|
|
71
96
|
<bpmn:outgoing>f_noConverge</bpmn:outgoing>
|
|
72
97
|
</bpmn:exclusiveGateway>
|
|
@@ -80,6 +105,7 @@
|
|
|
80
105
|
<bpmn:endEvent id="End" name="Done">
|
|
81
106
|
<bpmn:incoming>f_noConverge</bpmn:incoming>
|
|
82
107
|
<bpmn:incoming>f_toEndConverged</bpmn:incoming>
|
|
108
|
+
<bpmn:incoming>f_blockedEnd</bpmn:incoming>
|
|
83
109
|
</bpmn:endEvent>
|
|
84
110
|
<bpmn:sequenceFlow id="f_toEnsureBase" sourceRef="Start" targetRef="ensure-base-branch" />
|
|
85
111
|
<bpmn:sequenceFlow id="f_toImplement" sourceRef="ensure-base-branch" targetRef="implement-task" />
|
|
@@ -94,7 +120,13 @@
|
|
|
94
120
|
</bpmn:sequenceFlow>
|
|
95
121
|
<bpmn:sequenceFlow id="w_abandon" name="abandon" sourceRef="w_gw_answer" targetRef="record-feature" />
|
|
96
122
|
<bpmn:sequenceFlow id="w_sla" name="SLA auto-abandon" sourceRef="be_feature_sla" targetRef="record-feature" />
|
|
97
|
-
<bpmn:sequenceFlow id="
|
|
123
|
+
<bpmn:sequenceFlow id="f_toBlockedGw" sourceRef="record-feature" targetRef="gw-blocked" />
|
|
124
|
+
<bpmn:sequenceFlow id="f_blocked" name="blocked" sourceRef="gw-blocked" targetRef="feature-blocked">
|
|
125
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=featureStatus = "blocked"</bpmn:conditionExpression>
|
|
126
|
+
</bpmn:sequenceFlow>
|
|
127
|
+
<bpmn:sequenceFlow id="f_notBlocked" name="not blocked" sourceRef="gw-blocked" targetRef="gw-converge" />
|
|
128
|
+
<bpmn:sequenceFlow id="f_toBlockedAck" sourceRef="feature-blocked" targetRef="record-blocked-ack" />
|
|
129
|
+
<bpmn:sequenceFlow id="f_blockedEnd" sourceRef="record-blocked-ack" targetRef="End" />
|
|
98
130
|
<bpmn:sequenceFlow id="f_converge" name="converge" sourceRef="gw-converge" targetRef="converge">
|
|
99
131
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=converge = true and featureStatus = "opened" and prKey != null</bpmn:conditionExpression>
|
|
100
132
|
</bpmn:sequenceFlow>
|
|
@@ -133,19 +165,31 @@
|
|
|
133
165
|
<bpmndi:BPMNShape id="BPMNShape_record-feature" bpmnElement="record-feature">
|
|
134
166
|
<dc:Bounds x="1116" y="80" width="100" height="80" />
|
|
135
167
|
</bpmndi:BPMNShape>
|
|
136
|
-
<bpmndi:BPMNShape id="BPMNShape_gw-
|
|
168
|
+
<bpmndi:BPMNShape id="BPMNShape_gw-blocked" bpmnElement="gw-blocked" isMarkerVisible="true">
|
|
137
169
|
<dc:Bounds x="1316" y="95" width="50" height="50" />
|
|
138
170
|
<bpmndi:BPMNLabel>
|
|
139
|
-
<dc:Bounds x="
|
|
171
|
+
<dc:Bounds x="1311" y="76" width="60" height="14" />
|
|
172
|
+
</bpmndi:BPMNLabel>
|
|
173
|
+
</bpmndi:BPMNShape>
|
|
174
|
+
<bpmndi:BPMNShape id="BPMNShape_feature-blocked" bpmnElement="feature-blocked">
|
|
175
|
+
<dc:Bounds x="1466" y="400" width="100" height="80" />
|
|
176
|
+
</bpmndi:BPMNShape>
|
|
177
|
+
<bpmndi:BPMNShape id="BPMNShape_record-blocked-ack" bpmnElement="record-blocked-ack">
|
|
178
|
+
<dc:Bounds x="1666" y="400" width="100" height="80" />
|
|
179
|
+
</bpmndi:BPMNShape>
|
|
180
|
+
<bpmndi:BPMNShape id="BPMNShape_gw-converge" bpmnElement="gw-converge" isMarkerVisible="true">
|
|
181
|
+
<dc:Bounds x="1491" y="95" width="50" height="50" />
|
|
182
|
+
<bpmndi:BPMNLabel>
|
|
183
|
+
<dc:Bounds x="1483" y="76" width="67" height="14" />
|
|
140
184
|
</bpmndi:BPMNLabel>
|
|
141
185
|
</bpmndi:BPMNShape>
|
|
142
186
|
<bpmndi:BPMNShape id="BPMNShape_converge" bpmnElement="converge">
|
|
143
|
-
<dc:Bounds x="
|
|
187
|
+
<dc:Bounds x="1666" y="240" width="100" height="80" />
|
|
144
188
|
</bpmndi:BPMNShape>
|
|
145
189
|
<bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
|
|
146
|
-
<dc:Bounds x="
|
|
190
|
+
<dc:Bounds x="1866" y="102" width="36" height="36" />
|
|
147
191
|
<bpmndi:BPMNLabel>
|
|
148
|
-
<dc:Bounds x="
|
|
192
|
+
<dc:Bounds x="1867" y="83" width="34" height="14" />
|
|
149
193
|
</bpmndi:BPMNLabel>
|
|
150
194
|
</bpmndi:BPMNShape>
|
|
151
195
|
<bpmndi:BPMNShape id="BPMNShape_be_feature_sla" bpmnElement="be_feature_sla">
|
|
@@ -173,15 +217,22 @@
|
|
|
173
217
|
<dc:Bounds x="875" y="98" width="32" height="14" />
|
|
174
218
|
</bpmndi:BPMNLabel>
|
|
175
219
|
</bpmndi:BPMNEdge>
|
|
176
|
-
<bpmndi:BPMNEdge id="
|
|
220
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toBlockedGw" bpmnElement="f_toBlockedGw">
|
|
177
221
|
<di:waypoint x="1216" y="120" />
|
|
178
222
|
<di:waypoint x="1316" y="120" />
|
|
179
223
|
</bpmndi:BPMNEdge>
|
|
180
|
-
<bpmndi:BPMNEdge id="
|
|
224
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_notBlocked" bpmnElement="f_notBlocked">
|
|
181
225
|
<di:waypoint x="1366" y="120" />
|
|
182
|
-
<di:waypoint x="
|
|
226
|
+
<di:waypoint x="1491" y="120" />
|
|
227
|
+
<bpmndi:BPMNLabel>
|
|
228
|
+
<dc:Bounds x="1390" y="98" width="78" height="14" />
|
|
229
|
+
</bpmndi:BPMNLabel>
|
|
230
|
+
</bpmndi:BPMNEdge>
|
|
231
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_noConverge" bpmnElement="f_noConverge">
|
|
232
|
+
<di:waypoint x="1541" y="120" />
|
|
233
|
+
<di:waypoint x="1866" y="120" />
|
|
183
234
|
<bpmndi:BPMNLabel>
|
|
184
|
-
<dc:Bounds x="
|
|
235
|
+
<dc:Bounds x="1668" y="98" width="71" height="14" />
|
|
185
236
|
</bpmndi:BPMNLabel>
|
|
186
237
|
</bpmndi:BPMNEdge>
|
|
187
238
|
<bpmndi:BPMNEdge id="BPMNEdge_w_abandon" bpmnElement="w_abandon">
|
|
@@ -200,22 +251,39 @@
|
|
|
200
251
|
<dc:Bounds x="646" y="206" width="67" height="14" />
|
|
201
252
|
</bpmndi:BPMNLabel>
|
|
202
253
|
</bpmndi:BPMNEdge>
|
|
203
|
-
<bpmndi:BPMNEdge id="
|
|
254
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_blocked" bpmnElement="f_blocked">
|
|
204
255
|
<di:waypoint x="1341" y="145" />
|
|
205
|
-
<di:waypoint x="1341" y="
|
|
206
|
-
<di:waypoint x="1466" y="
|
|
256
|
+
<di:waypoint x="1341" y="440" />
|
|
257
|
+
<di:waypoint x="1466" y="440" />
|
|
207
258
|
<bpmndi:BPMNLabel>
|
|
208
|
-
<dc:Bounds x="1346" y="
|
|
259
|
+
<dc:Bounds x="1346" y="286" width="53" height="14" />
|
|
260
|
+
</bpmndi:BPMNLabel>
|
|
261
|
+
</bpmndi:BPMNEdge>
|
|
262
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_converge" bpmnElement="f_converge">
|
|
263
|
+
<di:waypoint x="1516" y="145" />
|
|
264
|
+
<di:waypoint x="1516" y="280" />
|
|
265
|
+
<di:waypoint x="1666" y="280" />
|
|
266
|
+
<bpmndi:BPMNLabel>
|
|
267
|
+
<dc:Bounds x="1521" y="206" width="60" height="14" />
|
|
209
268
|
</bpmndi:BPMNLabel>
|
|
210
269
|
</bpmndi:BPMNEdge>
|
|
211
270
|
<bpmndi:BPMNEdge id="BPMNEdge_w_toAnswerGw" bpmnElement="w_toAnswerGw">
|
|
212
271
|
<di:waypoint x="866" y="280" />
|
|
213
272
|
<di:waypoint x="966" y="280" />
|
|
214
273
|
</bpmndi:BPMNEdge>
|
|
274
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toBlockedAck" bpmnElement="f_toBlockedAck">
|
|
275
|
+
<di:waypoint x="1566" y="440" />
|
|
276
|
+
<di:waypoint x="1666" y="440" />
|
|
277
|
+
</bpmndi:BPMNEdge>
|
|
278
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_blockedEnd" bpmnElement="f_blockedEnd">
|
|
279
|
+
<di:waypoint x="1766" y="440" />
|
|
280
|
+
<di:waypoint x="1884" y="440" />
|
|
281
|
+
<di:waypoint x="1884" y="138" />
|
|
282
|
+
</bpmndi:BPMNEdge>
|
|
215
283
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toEndConverged" bpmnElement="f_toEndConverged">
|
|
216
|
-
<di:waypoint x="
|
|
217
|
-
<di:waypoint x="
|
|
218
|
-
<di:waypoint x="
|
|
284
|
+
<di:waypoint x="1766" y="280" />
|
|
285
|
+
<di:waypoint x="1884" y="280" />
|
|
286
|
+
<di:waypoint x="1884" y="138" />
|
|
219
287
|
</bpmndi:BPMNEdge>
|
|
220
288
|
<bpmndi:BPMNEdge id="BPMNEdge_w_sla" bpmnElement="w_sla">
|
|
221
289
|
<di:waypoint x="816" y="338" />
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Unit coverage for pr.record-blocked-ack — the operator acknowledged a blocked feature run.
|
|
2
|
+
// It must settle the parked (non-terminal `awaiting_operator`) row at terminal `blocked` and record
|
|
3
|
+
// the operator's disposition note into `delivery_label` so the same issue can be re-dispatched.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
6
|
+
import { noopLog } from "../../test/log.ts";
|
|
7
|
+
import handler from "./worker.ts";
|
|
8
|
+
|
|
9
|
+
function fakeApp(rows: Record<string, unknown>[]) {
|
|
10
|
+
const stores: Record<string, Record<string, unknown>[]> = { feature_runs: rows };
|
|
11
|
+
return {
|
|
12
|
+
data: {
|
|
13
|
+
table(name: string, key: string) {
|
|
14
|
+
const store = (stores[name] ??= []);
|
|
15
|
+
return {
|
|
16
|
+
get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
17
|
+
find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
18
|
+
insert: (row: any) => {
|
|
19
|
+
store.push(row);
|
|
20
|
+
return Promise.resolve(store.length);
|
|
21
|
+
},
|
|
22
|
+
update: (k: any, patch: any) => {
|
|
23
|
+
const row = store.find((r) => r[key] === k);
|
|
24
|
+
if (row) Object.assign(row, patch);
|
|
25
|
+
return Promise.resolve(row);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
log: noopLog(),
|
|
31
|
+
} as any;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("record-blocked-ack: settles the parked run at terminal blocked and records the operator note", async () => {
|
|
35
|
+
const rows = [{ feature_key: "owner/repo#7", status: "awaiting_operator", delivery_label: null }];
|
|
36
|
+
const app = fakeApp(rows);
|
|
37
|
+
const out = await handler({ variables: { featureKey: "owner/repo#7", note: "reassigned to a human" } } as any, app);
|
|
38
|
+
assertEquals(out, {});
|
|
39
|
+
assertEquals(rows[0].status, "blocked");
|
|
40
|
+
assertEquals(rows[0].delivery_label, "operator: reassigned to a human");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("record-blocked-ack: a blank note falls back to an 'acknowledged' label", async () => {
|
|
44
|
+
const rows = [{ feature_key: "owner/repo#8", status: "awaiting_operator", delivery_label: null }];
|
|
45
|
+
const app = fakeApp(rows);
|
|
46
|
+
await handler({ variables: { featureKey: "owner/repo#8", note: " " } } as any, app);
|
|
47
|
+
assertEquals(rows[0].status, "blocked");
|
|
48
|
+
assertEquals(rows[0].delivery_label, "acknowledged");
|
|
49
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// pr.record-blocked-ack — an operator acknowledged a blocked single-issue feature run (issue #172).
|
|
2
|
+
//
|
|
3
|
+
// A `blocked` outcome from `record-feature` is routed to the `feature-blocked` operator user task,
|
|
4
|
+
// which keeps the instance alive (parked at the operators' inbox) and holds the row at the
|
|
5
|
+
// NON-terminal `awaiting_operator` status so a re-dispatch of the same issue short-circuits instead
|
|
6
|
+
// of spawning an orphaned parallel run. This worker fires once the operator completes that task: it
|
|
7
|
+
// settles the row at the TERMINAL `blocked` status and records the operator's disposition note, so
|
|
8
|
+
// the same issue can be re-dispatched afterwards. Persistence goes through the record gateway
|
|
9
|
+
// (`app.data`), never hand-written SQL — matching the other record-* workers.
|
|
10
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
11
|
+
import { featureRuns } from "../../app/feature.ts";
|
|
12
|
+
|
|
13
|
+
interface In extends Record<string, unknown> {
|
|
14
|
+
featureKey: string;
|
|
15
|
+
note?: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const str = (v: unknown): string | undefined =>
|
|
19
|
+
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
20
|
+
|
|
21
|
+
const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
|
|
22
|
+
const featureKey = job.variables.featureKey;
|
|
23
|
+
const note = str(job.variables.note);
|
|
24
|
+
const ts = new Date().toISOString();
|
|
25
|
+
|
|
26
|
+
await featureRuns(app.data).update(featureKey, {
|
|
27
|
+
status: "blocked",
|
|
28
|
+
delivery_label: note ? `operator: ${note}` : "acknowledged",
|
|
29
|
+
updated_at: ts,
|
|
30
|
+
});
|
|
31
|
+
app.log.info("record-blocked-ack", { featureKey, note: note ?? null });
|
|
32
|
+
|
|
33
|
+
return {};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default handler;
|
|
@@ -45,15 +45,21 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
45
45
|
// (the run is complete), just with no `pr_key` to converge.
|
|
46
46
|
const prKey = parsed?.prKey ?? null;
|
|
47
47
|
const featureStatus: FeatureRunStatus = status;
|
|
48
|
+
// A `blocked` outcome is routed (via the `blocked?` gateway) to the `feature-blocked` operator user
|
|
49
|
+
// task, which keeps the instance ALIVE until a human acknowledges it. Persist the row as the
|
|
50
|
+
// NON-terminal `awaiting_operator` for that parked window so a re-dispatch of the same issue
|
|
51
|
+
// short-circuits (no orphaned parallel run); `record-blocked-ack` writes the terminal `blocked`
|
|
52
|
+
// once the operator completes the task. `opened`/`skipped` are already terminal — persist as-is.
|
|
53
|
+
const rowStatus: FeatureRunStatus = status === "blocked" ? "awaiting_operator" : status;
|
|
48
54
|
const ts = new Date().toISOString();
|
|
49
55
|
|
|
50
56
|
await featureRuns(app.data).update(featureKey, {
|
|
51
|
-
status:
|
|
57
|
+
status: rowStatus,
|
|
52
58
|
pr_key: prKey,
|
|
53
59
|
outcome: summary ?? null,
|
|
54
60
|
updated_at: ts,
|
|
55
61
|
});
|
|
56
|
-
app.log.info("record-feature", { featureKey, featureStatus, prKey });
|
|
62
|
+
app.log.info("record-feature", { featureKey, featureStatus, rowStatus, prKey });
|
|
57
63
|
|
|
58
64
|
return { featureStatus, prKey };
|
|
59
65
|
};
|