@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 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
 
@@ -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 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
  });
@@ -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;
@@ -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
  });
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.61.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",
@@ -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" },
@@ -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
- (`git ls-remote --heads origin feat/<task.id>` or
27
- `gh pr list --head feat/<task.id> --state all`):
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. Clone / check out your base branch (first run the default branch, or the
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
- 2. Implement `task.prompt`. Keep the change scoped to this slice only.
51
- 3. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the
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
- 4. Clean up any scratch clone/worktree you created outside the commit.
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>f_toConvergeGw</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>f_toConvergeGw</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="f_toConvergeGw" sourceRef="record-feature" targetRef="gw-converge" />
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-converge" bpmnElement="gw-converge" isMarkerVisible="true">
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="1308" y="76" width="67" height="14" />
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="1466" y="240" width="100" height="80" />
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="1666" y="102" width="36" height="36" />
190
+ <dc:Bounds x="1866" y="102" width="36" height="36" />
147
191
  <bpmndi:BPMNLabel>
148
- <dc:Bounds x="1667" y="83" width="34" height="14" />
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="BPMNEdge_f_toConvergeGw" bpmnElement="f_toConvergeGw">
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="BPMNEdge_f_noConverge" bpmnElement="f_noConverge">
224
+ <bpmndi:BPMNEdge id="BPMNEdge_f_notBlocked" bpmnElement="f_notBlocked">
181
225
  <di:waypoint x="1366" y="120" />
182
- <di:waypoint x="1666" y="120" />
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="1481" y="98" width="71" height="14" />
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="BPMNEdge_f_converge" bpmnElement="f_converge">
254
+ <bpmndi:BPMNEdge id="BPMNEdge_f_blocked" bpmnElement="f_blocked">
204
255
  <di:waypoint x="1341" y="145" />
205
- <di:waypoint x="1341" y="280" />
206
- <di:waypoint x="1466" y="280" />
256
+ <di:waypoint x="1341" y="440" />
257
+ <di:waypoint x="1466" y="440" />
207
258
  <bpmndi:BPMNLabel>
208
- <dc:Bounds x="1346" y="206" width="60" height="14" />
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="1566" y="280" />
217
- <di:waypoint x="1684" y="280" />
218
- <di:waypoint x="1684" y="138" />
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: featureStatus,
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
  };