@nanobpm/nano-workforce 0.99.0 → 0.100.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/app/agentCompletion.test.ts +92 -6
  3. package/app/agentCompletion.ts +86 -58
  4. package/app/convergeGate.test.ts +8 -4
  5. package/app/convergenceEscalationGuard.test.ts +106 -0
  6. package/app/feature.ts +15 -166
  7. package/app/featureGateway.test.ts +6 -57
  8. package/app/persist-escalation.test.ts +32 -0
  9. package/app/pollUserTasks.test.ts +39 -13
  10. package/app/roundProgress.test.ts +7 -4
  11. package/app/service.ts +60 -132
  12. package/app/stage.test.ts +7 -53
  13. package/app/stage.ts +5 -37
  14. package/app/userTasks.test.ts +1 -1
  15. package/app/userTasks.ts +3 -3
  16. package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
  17. package/e2e/feature-run.e2e.ts +52 -41
  18. package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
  19. package/openapi.yaml +7 -108
  20. package/operations/agentCompleteEscalation.ts +2 -2
  21. package/operations/completeUserTask.test.ts +25 -6
  22. package/operations/completeUserTask.ts +12 -10
  23. package/package.json +2 -2
  24. package/pages/feature.page.json +1 -37
  25. package/pages/overview.page.json +1 -39
  26. package/pages/tasks.page.json +78 -93
  27. package/resources/forms/feature-escalation.form +3 -0
  28. package/resources/processes/convergence-loop.bpmn +114 -118
  29. package/workers/persist-escalation/worker.ts +7 -4
  30. package/workers/record-blocked-ack/worker.test.ts +1 -4
  31. package/workers/record-blocked-ack/worker.ts +0 -5
  32. package/workers/record-feature/worker.ts +0 -6
  33. package/workers/record-feature-escalation/worker.test.ts +14 -27
  34. package/workers/record-feature-escalation/worker.ts +16 -22
  35. package/app/featureBlocked.test.ts +0 -182
  36. package/app/featureEscalation.test.ts +0 -235
  37. package/operations/acknowledgeBlocked.test.ts +0 -111
  38. package/operations/acknowledgeBlocked.ts +0 -62
  39. package/operations/answerFeatureEscalation.ts +0 -68
package/app/feature.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
18
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
19
19
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
20
- import { deriveEscalationOpen, deriveListBucket, deriveStage } from "./stage.ts";
20
+ import { deriveListBucket, deriveStage } from "./stage.ts";
21
21
 
22
22
  /** The BPMN process this module drives (resources/processes/feature.bpmn). */
23
23
  export const FEATURE_PROCESS_ID = "feature";
@@ -52,35 +52,6 @@ export interface FeatureRun {
52
52
  * outcome is written to `status` itself; this carries the sub-state / note (e.g. "merged",
53
53
  * "waiting_review", or "operator: <note>"). */
54
54
  delivery_label: string | null;
55
- /** The parked `feature-escalation` user task's `question`, persisted at escalation entry by the
56
- * `record-feature-escalation` worker (NOT by `pollFeatureEscalations` while parked, which
57
- * deliberately never writes it) so the pages can show what the agent asked. NULL whenever the run
58
- * is not parked at an escalation — cleared on the exit paths (`record-feature` / the answer
59
- * operation), and, as a self-heal, by `pollFeatureEscalations` when a previously-observed task is
60
- * completed out-of-band (see `deriveFeatureEscalationPatch`). */
61
- escalation_question: string | null;
62
- /** The completable native `feature-escalation` user-task key the answer affordance posts to
63
- * (`completeUserTaskAttributed`) and the pages gate the answer controls on (`showWhenField`). Set by
64
- * `pollFeatureEscalations` while parked; NULL otherwise. */
65
- escalation_user_task_key: string | null;
66
- /** Gateway projection (issue #272): the single fail-closed "open escalation" display signal, `1` iff
67
- * ALL THREE escalation columns agree the run is parked at an answerable escalation
68
- * (`status='escalated'` AND `escalation_user_task_key` non-NULL AND `escalation_question` non-NULL),
69
- * else `0`. Derived by `deriveEscalationOpen` at write time. The escalation tuple is spread across
70
- * three independently-written columns, so an interim read can see a TORN state (a live pointer with a
71
- * blank question, or a status lagging behind a cleared question); the pages gate the Abandon action
72
- * and the answer form on THIS conjunction instead of on `escalation_user_task_key` alone, so a torn
73
- * tuple renders as not-escalated rather than escalated-but-blank. NULL only on legacy rows before the
74
- * projection reached them (backfilled once at boot). */
75
- escalation_open: number | null;
76
- /** The completable native `feature-blocked` user-task key the "Acknowledge blocked" affordance posts
77
- * to (`completeUserTaskAttributed`) and the pages gate the acknowledge control on (`showWhenField`).
78
- * Kept DISTINCT from `escalation_user_task_key` so the two human tasks (an escalation answer vs a
79
- * blocked-run acknowledgement) are never conflated. Set by `pollFeatureBlocked` while a run is parked
80
- * at `feature-blocked` (status `awaiting_operator`); NULL otherwise — cleared on the exit paths
81
- * (`record-blocked-ack` / the acknowledge operation) and, as a self-heal, by `pollFeatureBlocked`
82
- * when a previously-observed task is completed out-of-band (see `deriveFeatureBlockedPatch`). */
83
- blocked_user_task_key: string | null;
84
55
  /** Timestamp an operator dismissed a TERMINAL run (§5, `acknowledge-done`); NULL until then. When
85
56
  * set on a terminal row, `list_bucket` flips from 'active' to 'history'. Projection surface. */
86
57
  acknowledged_at: string | null;
@@ -107,7 +78,7 @@ export interface FeatureRun {
107
78
  export const FEATURE_RUN_STATUSES = [
108
79
  "running", // the agent is implementing
109
80
  "escalated", // NON-terminal: the run is parked at the `feature-escalation` operator user task,
110
- // waiting on a human answer (denormalised from the parked user task by pollFeatureEscalations)
81
+ // waiting on a human answer (set by record-feature-escalation; surfaced on the Tasks inbox by pollUserTasks)
111
82
  "opened", // a PR was raised and the run ends here (converge was not requested)
112
83
  "converging", // the opened PR was handed to the convergence loop (live state via pr_key → pull_requests)
113
84
  "awaiting_operator", // NON-terminal: the run is blocked and parked at the feature-blocked operator user task
@@ -177,7 +148,8 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
177
148
  }
178
149
 
179
150
  /** The `feature-escalation` user-task element id (feature.bpmn) — the native operator wait a run
180
- * parks on when the agent escalates. `pollFeatureEscalations` reconciles it onto the read model. */
151
+ * parks on when the agent escalates. `pollUserTasks` reads the engine's open task for this element to
152
+ * project it onto the `user_tasks` Tasks inbox. */
181
153
  export const FEATURE_ESCALATION_ELEMENT = "feature-escalation";
182
154
 
183
155
  /** One append-only audit row per `feature-escalation` ENTRY (issue #305). Mirrors the surviving
@@ -223,111 +195,12 @@ export async function recordFeatureEscalation(
223
195
  });
224
196
  }
225
197
 
226
- /** The parked `feature-escalation` user task, as `pollFeatureEscalations` observes it via
227
- * `openUserTasks` (the open-task-scoped query — issue #294): the completable user-task key the pages
228
- * drive an attributed answer against. Scoping to `state:"CREATED"` is what keeps a looping run — which
229
- * holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task.
230
- *
231
- * The agent's `question` is NOT read from here — the WASM testkit engine does not surface a user
232
- * task's `zeebe:ioMapping`-mapped local variables through the user-task query, so relying on it would
233
- * make the question untestable. Instead the `record-feature-escalation` service task (feature.bpmn)
234
- * persists `question` onto the row at escalation entry — see `workers/record-feature-escalation`. */
235
- export interface FeatureEscalationParked {
236
- userTaskKey: string;
237
- }
238
-
239
- /** Pure source of truth for the escalation read-model reconcile (`pollFeatureEscalations`): given a
240
- * run and whether it is currently parked at `feature-escalation`, return the minimal `feature_runs`
241
- * patch reconciling the run's LIVENESS (status + completable-task pointer) with the observed park
242
- * state (or null when nothing changed, so the poller skips the write). Idempotent, and — crucially —
243
- * self-healing across the brief window between the `record-feature-escalation` service task and the
244
- * user task actually appearing: a premature "not parked" reset to `running` is re-flipped to
245
- * `escalated` on the next pass once the task is observed.
246
- *
247
- * - parked → flip `status` to `escalated` and denormalise the completable `userTaskKey` so the pages
248
- * can drive an attributed answer. It never writes `escalation_question` while parked — that is the
249
- * service task's to own (set) and the exit paths' to clear (record-feature / the answer operation),
250
- * so the poller can never clobber the persisted question during that self-healing window.
251
- * - un-parked → clear the completable-task pointer; a run still marked `escalated` has resumed
252
- * (answered / looped back to implement-task), so it returns to `running`. A run already advanced
253
- * past `escalated` by a downstream worker keeps that status — only the pointer is cleared. Once the
254
- * pointer was actually OBSERVED (non-NULL) and the task is now gone, `escalation_question` is also
255
- * cleared here, self-healing a question left populated when the task was completed out-of-band
256
- * (bypassing the answer operation). This is gated on the observed pointer precisely so it cannot
257
- * fire in the pre-observation self-healing window, where the pointer is still NULL. */
258
- export function deriveFeatureEscalationPatch(
259
- run: Pick<FeatureRun, "status" | "escalation_user_task_key">,
260
- parked: FeatureEscalationParked | null,
261
- ): Partial<FeatureRun> | null {
262
- const patch: Partial<FeatureRun> = {};
263
- if (parked) {
264
- if (run.status !== "escalated") patch.status = "escalated";
265
- if (run.escalation_user_task_key !== parked.userTaskKey) patch.escalation_user_task_key = parked.userTaskKey;
266
- } else {
267
- if (run.status === "escalated") patch.status = "running";
268
- // Un-park cleanup — fires ONLY once the poller has actually OBSERVED the task (pointer non-NULL)
269
- // and it is now gone. This self-heals a `question` left populated when the task was completed
270
- // out-of-band (e.g. an external task UI, bypassing the answer operation that normally clears it),
271
- // which would otherwise keep the UI showing an Escalation on a run that has resumed. Gating on
272
- // the pointer being non-NULL is what makes it safe: during the brief self-healing window between
273
- // `record-feature-escalation` (which persists the question but leaves the pointer NULL) and the
274
- // task appearing, the pointer is NULL, so this never clobbers the freshly-persisted question.
275
- if (run.escalation_user_task_key !== null) {
276
- patch.escalation_user_task_key = null;
277
- patch.escalation_question = null;
278
- }
279
- }
280
- return Object.keys(patch).length > 0 ? patch : null;
281
- }
282
-
283
198
  /** The `feature-blocked` user-task element id (feature.bpmn) — the native operator wait a run parks on
284
199
  * when the agent reports a `blocked` outcome (it gave up / the escalation was abandoned or timed out).
285
- * `pollFeatureBlocked` reconciles it onto the read model. */
200
+ * `pollUserTasks` reads the engine's open task for this element to project it onto the `user_tasks`
201
+ * Tasks inbox. */
286
202
  export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
287
203
 
288
- /** The parked `feature-blocked` user task, as `pollFeatureBlocked` observes it via `openUserTasks`
289
- * (the open-task-scoped query — issue #294): the completable user-task key the pages drive an
290
- * attributed acknowledgement against. Scoping to `state:"CREATED"` keeps a re-blocked run — which
291
- * holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task. */
292
- export interface FeatureBlockedParked {
293
- userTaskKey: string;
294
- }
295
-
296
- /** Pure source of truth for the blocked read-model reconcile (`pollFeatureBlocked`), the blocked twin
297
- * of `deriveFeatureEscalationPatch`: given a run and whether it is currently parked at `feature-blocked`,
298
- * return the minimal `feature_runs` patch reconciling the completable-task pointer with the observed park
299
- * state (or null when nothing changed, so the poller skips the write). Idempotent and self-healing.
300
- *
301
- * Unlike the escalation reconcile, the STATUS flip is NOT owned here: `record-feature` already persists
302
- * the row as `awaiting_operator` in the same token path before the `feature-blocked` user task is
303
- * created, and `record-blocked-ack` settles it to the terminal `blocked` on completion. So this only
304
- * reconciles the completable-task POINTER — never the status — so it can never overwrite the terminal
305
- * `blocked` the acknowledgement worker has already written.
306
- *
307
- * - parked → denormalise the completable `userTaskKey` so the pages can drive an attributed acknowledge.
308
- * - un-parked → clear the pointer ONLY once it was actually OBSERVED (non-NULL) and the task is now gone.
309
- * Gating on the observed pointer is what makes it safe across the brief self-healing window between
310
- * `record-feature` (which persists `awaiting_operator` but leaves the pointer NULL) and the user task
311
- * appearing: in that window the pointer is NULL, so this never fires, and the next pass fills it in once
312
- * the task is observable. Once observed and then gone (e.g. an out-of-band completion), the stale
313
- * pointer is cleared so the pages stop offering an acknowledge control for a task that no longer exists. */
314
- export function deriveFeatureBlockedPatch(
315
- run: Pick<FeatureRun, "blocked_user_task_key">,
316
- parked: FeatureBlockedParked | null,
317
- ): Partial<FeatureRun> | null {
318
- const patch: Partial<FeatureRun> = {};
319
- if (parked) {
320
- if (run.blocked_user_task_key !== parked.userTaskKey) patch.blocked_user_task_key = parked.userTaskKey;
321
- } else {
322
- // Un-park cleanup — fires ONLY once the poller has actually OBSERVED the task (pointer non-NULL) and
323
- // it is now gone. Gating on the pointer being non-NULL is what makes it safe: during the brief
324
- // self-healing window between `record-feature` (which persists `awaiting_operator` but leaves the
325
- // pointer NULL) and the task appearing, the pointer is NULL, so this never clears prematurely.
326
- if (run.blocked_user_task_key !== null) patch.blocked_user_task_key = null;
327
- }
328
- return Object.keys(patch).length > 0 ? patch : null;
329
- }
330
-
331
204
  /** The feature_runs fields the projection reads. A patch touching none of these cannot change the
332
205
  * derived `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`, so the gateway can skip the
333
206
  * read-back+reproject for it (see the `update` proxy). Kept adjacent to `projectFeatureRun` so the two
@@ -337,9 +210,6 @@ const PROJECTION_INPUT_KEYS: readonly (keyof FeatureRun)[] = [
337
210
  "pr_key",
338
211
  "converge",
339
212
  "auto_merge",
340
- "escalation_question",
341
- "escalation_user_task_key",
342
- "blocked_user_task_key",
343
213
  "acknowledged_at",
344
214
  ];
345
215
 
@@ -353,7 +223,6 @@ const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
353
223
  "stage_skipped",
354
224
  "attention",
355
225
  "list_bucket",
356
- "escalation_open",
357
226
  ];
358
227
 
359
228
  /** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
@@ -374,9 +243,6 @@ function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
374
243
  pr_key: row.pr_key ?? null,
375
244
  converge: row.converge ?? null,
376
245
  auto_merge: row.auto_merge ?? null,
377
- escalation_question: row.escalation_question ?? null,
378
- escalation_user_task_key: row.escalation_user_task_key ?? null,
379
- blocked_user_task_key: row.blocked_user_task_key ?? null,
380
246
  });
381
247
  return {
382
248
  stage,
@@ -384,13 +250,6 @@ function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
384
250
  stage_skipped: skipped,
385
251
  attention,
386
252
  list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
387
- escalation_open: deriveEscalationOpen({
388
- status: row.status,
389
- escalation_question: row.escalation_question ?? null,
390
- escalation_user_task_key: row.escalation_user_task_key ?? null,
391
- })
392
- ? 1
393
- : 0,
394
253
  };
395
254
  }
396
255
 
@@ -436,24 +295,20 @@ export const featureRuns = (data: DataLayer) => {
436
295
  };
437
296
 
438
297
  /** Re-project every feature_runs row through the gateway so rows missing any projection column get
439
- * correct `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`/`escalation_open` values.
440
- * Catches rows written before migration 039 (the pipeline columns) AND rows written before migration
441
- * 040 (whose `escalation_open` column is NULL, issue #272). Idempotent and safe to re-run: it
442
- * re-derives from each row's own stored fields, so a second pass is a no-op. Runs once at boot
443
- * (pollOnce) — the gateway keeps every future write fresh, so this only needs to catch legacy rows.
444
- * Reprojecting on a missing `escalation_open` matters for a run parked at a LIVE escalation when
445
- * migration 040 lands: `pollFeatureEscalations` writes nothing while it stays parked (no change), so
446
- * without this the fail-closed signal would stay NULL and hide a genuinely-open escalation. */
298
+ * correct `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` values. Catches rows written
299
+ * before migration 039 (the pipeline columns). Idempotent and safe to re-run: it re-derives from each
300
+ * row's own stored fields, so a second pass is a no-op. Runs once at boot (pollOnce) — the gateway keeps
301
+ * every future write fresh, so this only needs to catch legacy rows. */
447
302
  export async function backfillFeatureStages(data: DataLayer): Promise<number> {
448
303
  const table = featureRuns(data);
449
304
  const rows = await table.all();
450
305
  let stamped = 0;
451
306
  for (const row of rows) {
452
- // Only touch rows the projection has never fully reached — a legacy row whose `stage` (pre-039) or
453
- // `escalation_open` (pre-040) column is still NULL. The gateway keeps every write fresh, so a
454
- // fully-projected row needs no re-write; skipping them avoids a full-table rewrite on every boot and
455
- // keeps `stamped` an honest count of rows actually backfilled (not the total row count).
456
- if (row.stage != null && row.escalation_open != null) continue;
307
+ // Only touch rows the projection has never reached — a legacy row whose `stage` (pre-039) column is
308
+ // still NULL. The gateway keeps every write fresh, so a fully-projected row needs no re-write;
309
+ // skipping them avoids a full-table rewrite on every boot and keeps `stamped` an honest count of
310
+ // rows actually backfilled (not the total row count).
311
+ if (row.stage != null) continue;
457
312
  // Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
458
313
  // would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
459
314
  // so backfill projects explicitly rather than relying on an empty-patch reproject.)
@@ -522,9 +377,6 @@ export async function startFeature(
522
377
  // `deriveListBucket` flip the row to 'history' the moment it completes again, skipping the
523
378
  // intended operator dismissal. A fresh run must re-earn its tick-off.
524
379
  acknowledged_at: null,
525
- escalation_question: null,
526
- escalation_user_task_key: null,
527
- blocked_user_task_key: null,
528
380
  updated_at: ts,
529
381
  });
530
382
  } else {
@@ -542,9 +394,6 @@ export async function startFeature(
542
394
  auto_merge: autoMerge ? 1 : 0,
543
395
  outcome: null,
544
396
  delivery_label: null,
545
- escalation_question: null,
546
- escalation_user_task_key: null,
547
- blocked_user_task_key: null,
548
397
  created_at: ts,
549
398
  updated_at: ts,
550
399
  });
@@ -72,56 +72,6 @@ test("update {status:'escalated'} on a row with no pr_key projects Implementing
72
72
  assertEquals(rows[0].stage_state, null);
73
73
  });
74
74
 
75
- // issue #272: the fail-closed open-escalation projection. The escalation tuple is spread across three
76
- // independently-written columns; the gateway projects `escalation_open=1` ONLY when all three agree.
77
- test("escalation_open is 1 only when status=escalated AND pointer AND question all present", async () => {
78
- const { data, rows } = memData();
79
- rows.push({ feature_key: "o/r#esc", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
80
- // A complete, answerable escalation → 1.
81
- await featureRuns(data).update("o/r#esc", {
82
- status: "escalated",
83
- escalation_user_task_key: "ut-9",
84
- escalation_question: "which base branch?",
85
- });
86
- assertEquals(rows[0].escalation_open, 1);
87
- });
88
-
89
- test("a torn escalation tuple projects escalation_open=0 (renders not-escalated, fail closed)", async () => {
90
- const { data, rows } = memData();
91
- // Simulate the exit-path/poller race: the question was cleared while status + pointer still lag in the
92
- // escalated projection (the mirror tear observed on nwf#270). The page must NOT show an escalation.
93
- rows.push({
94
- feature_key: "o/r#torn",
95
- status: "escalated",
96
- pr_key: null,
97
- converge: 1,
98
- auto_merge: 1,
99
- escalation_user_task_key: "ut-9",
100
- escalation_question: "which base?",
101
- escalation_open: 1,
102
- });
103
- await featureRuns(data).update("o/r#torn", { escalation_question: null });
104
- assertEquals(rows[0].escalation_open, 0);
105
- });
106
-
107
- test("clearing the escalation tuple flips escalation_open to 0 without waiting a poll pass", async () => {
108
- const { data, rows } = memData();
109
- // The answer operation eagerly clears pointer + question; the gateway reprojects on that same write,
110
- // so the affordance disappears immediately (issue #272 acceptance: no poll-pass lag).
111
- rows.push({
112
- feature_key: "o/r#ans",
113
- status: "escalated",
114
- pr_key: null,
115
- converge: 1,
116
- auto_merge: 1,
117
- escalation_user_task_key: "ut-9",
118
- escalation_question: "which base?",
119
- escalation_open: 1,
120
- });
121
- await featureRuns(data).update("o/r#ans", { escalation_user_task_key: null, escalation_question: null });
122
- assertEquals(rows[0].escalation_open, 0);
123
- });
124
-
125
75
  test("a run with converge=false projects stage_skipped containing Converging and Merging", async () => {
126
76
  const { data, rows } = memData();
127
77
  rows.push({ feature_key: "o/r#5", status: "running", converge: 0, auto_merge: 0 });
@@ -212,9 +162,9 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
212
162
  // Legacy rows written before migration 039 → projection columns absent/NULL.
213
163
  rows.push({ feature_key: "o/r#8", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: "2024-01-01T00:00:00Z" });
214
164
  rows.push({ feature_key: "o/r#9", status: "running", pr_key: null, converge: 0, auto_merge: 0 });
215
- // A legacy row parked at a LIVE escalation when migration 040 lands: escalation_open is NULL and the
216
- // poller won't re-write it while it stays parked, so backfill MUST reproject it to 1 (issue #272).
217
- rows.push({ feature_key: "o/r#esc", status: "escalated", pr_key: null, converge: 1, auto_merge: 1, escalation_user_task_key: "ut-9", escalation_question: "which base?" });
165
+ // A legacy row parked at a LIVE escalation: its projection columns are absent/NULL, so backfill MUST
166
+ // reproject its pipeline stage (issue #254). Issue #332 dropped the escalation_* denormalisation.
167
+ rows.push({ feature_key: "o/r#esc", status: "escalated", pr_key: null, converge: 1, auto_merge: 1 });
218
168
 
219
169
  const stamped = await backfillFeatureStages(data);
220
170
  assertEquals(stamped, 3);
@@ -229,24 +179,23 @@ test("backfillFeatureStages stamps legacy terminal and live rows with helper-der
229
179
  assertEquals(live.stage_state, null);
230
180
  assertEquals(live.stage_skipped, "Converging Merging");
231
181
  assertEquals(live.list_bucket, "active");
232
- assertEquals(live.escalation_open, 0);
233
182
 
234
183
  const escalated = rows.find((r) => r.feature_key === "o/r#esc");
235
- assertEquals(escalated.escalation_open, 1);
184
+ assertEquals(escalated.stage, "Implementing");
185
+ assertEquals(escalated.attention, "⚠");
236
186
  });
237
187
 
238
188
  test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
239
189
  const { data, rows } = memData();
240
190
  // One legacy row (no projection) + one already-projected row (gateway kept it fresh).
241
191
  rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
242
- rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", escalation_open: 0, updated_at: "0" });
192
+ rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", updated_at: "0" });
243
193
 
244
194
  const stamped = await backfillFeatureStages(data);
245
195
  // Only the legacy row is stamped; the already-projected row is skipped.
246
196
  assertEquals(stamped, 1);
247
197
  const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
248
198
  assertEquals(legacy.stage, "Done");
249
- assertEquals(legacy.escalation_open, 0);
250
199
  // The already-projected row was not re-written (its sentinel updated_at is untouched).
251
200
  const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
252
201
  assertEquals(fresh.updated_at, "0");
@@ -180,3 +180,35 @@ test("persist-escalation heals from the prKey when repo/prNumber are absent", as
180
180
  "the running agent's abandon token is preserved from abandonUrl, not re-minted",
181
181
  );
182
182
  });
183
+
184
+ // #333 — the control-flow escalation arms (no-progress / review-stalled / unaddressed-comments /
185
+ // max-rounds) each set an explicit `status="blocked"` + a concrete `question` via `zeebe:input`
186
+ // (recordRound=false for the three that run after `persist-round`). They now route through
187
+ // `gw-escalated`, which branches on the worker's `escalated` output. This pins the contract that
188
+ // gateway depends on: a control-flow arm with a real question OPENS an escalation and returns
189
+ // `escalated:true` + the (trimmed) question, so gw-escalated parks a wait carrying that question —
190
+ // never a dead wait with a null question (the #333 defect).
191
+ test("a control-flow arm with a concrete question opens an escalation gw-escalated can park", async () => {
192
+ const { app, inserts } = fakeApp();
193
+ const question = "No review arrived within the review-wait timeout (PT20M). A human must decide how to proceed.";
194
+ const job = { variables: { prKey: "o/r#5", round: 2, status: "blocked", question, recordRound: false } };
195
+ const out = await handler(job as any, app as any);
196
+ assertEquals((out as any).escalated, true, "a real control-flow escalation reports escalated:true");
197
+ assertEquals((out as any).question, question, "the question is returned for the wait-answer form");
198
+ assertEquals(inserts.escalations.length, 1, "an escalation row is opened");
199
+ assertEquals(inserts.rounds.length, 0, "recordRound=false suppresses a duplicate round row");
200
+ });
201
+
202
+ // #333 — conversely, the `persist-escalation-blockedcomments` arm maps its question from the
203
+ // OPTIONAL `convergeBlockReason`. A blank reason is a NON-escalation: the worker opens nothing and
204
+ // returns `escalated:false`, so gw-escalated's default RE-ENTERS the loop instead of parking a dead
205
+ // `wait-answer` with a null question. This is the exact wedge the guard eliminates.
206
+ test("a control-flow arm with a blank question opens nothing so gw-escalated re-enters", async () => {
207
+ const { app, inserts, updates } = fakeApp();
208
+ const job = { variables: { prKey: "o/r#5", round: 2, status: "blocked", question: " ", recordRound: false } };
209
+ const out = await handler(job as any, app as any);
210
+ assertEquals((out as any).escalated, false, "a blank-reason arm reports escalated:false");
211
+ assertEquals((out as any).escalationId, null, "no escalation id is minted");
212
+ assertEquals(inserts.escalations.length, 0, "no dead escalation is fabricated");
213
+ assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
214
+ });
@@ -1,8 +1,9 @@
1
1
  // Integration test for `pollUserTasks` (issue #236) — the reconcile that projects the engine's
2
2
  // currently-open native user-task escalations onto the unified `user_tasks` read-model the Tasks page
3
- // reads. It generalises the two feature pollers across every subject: feature runs (denormalised
4
- // keys), in-flight plans (`plan-review-decision` / `trial-merge-decision`), and in-flight PRs
5
- // (`wait-answer`). A completed task's row is removed on the next pass so `showCount` tracks live work.
3
+ // reads. It reads each in-flight subject's open tasks from the engine directly: feature runs
4
+ // (`feature-escalation` / `feature-blocked`, issue #332), in-flight plans (`plan-review-decision` /
5
+ // `trial-merge-decision`), and in-flight PRs (`wait-answer`). A completed task's row is removed on the
6
+ // next pass so `showCount` tracks live work.
6
7
  import { test } from "node:test";
7
8
  import { assertEquals } from "#test-assert";
8
9
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
@@ -79,12 +80,12 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
79
80
  process_key: "fp-10",
80
81
  issue_url: "https://github.com/o/r/issues/10",
81
82
  title: "Add the framework selector",
82
- escalation_user_task_key: "ut-feat",
83
- escalation_question: "which framework?",
84
- blocked_user_task_key: null,
85
83
  delivery_label: null,
86
84
  },
87
85
  ],
86
+ feature_escalations: [
87
+ { id: 1, feature_key: "o/r#10", question: "which framework?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
88
+ ],
88
89
  plans: [
89
90
  { plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20", title: "Broaden the epic scope" },
90
91
  { plan_key: "o/r#21", status: "done", process_key: "pp-21", issue_url: "https://github.com/o/r/issues/21" },
@@ -102,6 +103,7 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
102
103
  escalations: [{ id: 1, pr_key: "o/r#30", status: "open", question: "conflicting reviews" }],
103
104
  });
104
105
  const engine = fakeEngine({
106
+ "fp-10": [{ userTaskKey: "ut-feat", elementId: "feature-escalation" }],
105
107
  "pp-20": [
106
108
  { userTaskKey: "ut-plan", elementId: "plan-review-decision" },
107
109
  { userTaskKey: "ut-trial", elementId: "trial-merge-decision" },
@@ -152,10 +154,10 @@ test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into use
152
154
  assertEquals(byKey["ut-merge"].question, "not mergeable — resolve the conflict");
153
155
  });
154
156
 
155
- test("pollUserTasks: sources the feature-escalation question from the feature_escalations audit log (issue #305)", async () => {
156
- // The canonical source is the append-only `feature_escalations` log (what `record-feature-escalation`
157
- // writes); the denormalised `feature_runs.escalation_question` is a legacy fallback during the expand
158
- // phase. When both exist the newest audit row wins, so a re-escalation shows the latest question.
157
+ test("pollUserTasks: sources the feature-escalation question from the feature_escalations audit log (issue #305/#332)", async () => {
158
+ // The canonical (and now sole) source is the append-only `feature_escalations` log what
159
+ // `record-feature-escalation` writes; issue #332 dropped the denormalised `feature_runs.escalation_question`
160
+ // column. When several audit rows exist the newest wins, so a re-escalation shows the latest question.
159
161
  const { data, stores } = memData({
160
162
  feature_runs: [
161
163
  {
@@ -164,9 +166,6 @@ test("pollUserTasks: sources the feature-escalation question from the feature_es
164
166
  process_key: "fp-42",
165
167
  issue_url: "https://github.com/o/r/issues/42",
166
168
  title: "Wire the audit log",
167
- escalation_user_task_key: "ut-feat",
168
- escalation_question: "stale legacy question",
169
- blocked_user_task_key: null,
170
169
  delivery_label: null,
171
170
  },
172
171
  ],
@@ -183,6 +182,33 @@ test("pollUserTasks: sources the feature-escalation question from the feature_es
183
182
  assertEquals(byKey["ut-feat"].question, "latest ask");
184
183
  });
185
184
 
185
+ test("pollUserTasks: projects a blocked feature run (feature-blocked) with the delivery_label as its question (issue #332)", async () => {
186
+ // A blocked run parks on the native `feature-blocked` operator task at the non-terminal
187
+ // `awaiting_operator` status. The poller reads it from the engine directly (no denormalised pointer)
188
+ // and projects it onto the Tasks inbox, sourcing the display text from the run's `delivery_label`.
189
+ const { data, stores } = memData({
190
+ feature_runs: [
191
+ {
192
+ feature_key: "o/r#60",
193
+ status: "awaiting_operator",
194
+ process_key: "fp-60",
195
+ issue_url: "https://github.com/o/r/issues/60",
196
+ title: "Blocked slice",
197
+ delivery_label: "agent gave up: no PR",
198
+ },
199
+ ],
200
+ });
201
+ const engine = fakeEngine({ "fp-60": [{ userTaskKey: "ut-blocked", elementId: "feature-blocked" }] });
202
+
203
+ await pollUserTasks(data, engine);
204
+
205
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
206
+ assertEquals(Object.keys(byKey), ["ut-blocked"]);
207
+ assertEquals(byKey["ut-blocked"].element_id, "feature-blocked");
208
+ assertEquals(byKey["ut-blocked"].subject_type, "feature");
209
+ assertEquals(byKey["ut-blocked"].question, "agent gave up: no PR");
210
+ });
211
+
186
212
  test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
187
213
  const { data, stores } = memData({
188
214
  user_tasks: [
@@ -210,11 +210,14 @@ test("gw-progress default arm re-enters the review wait with no condition", () =
210
210
  assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
211
211
  });
212
212
 
213
- test("the no-progress escalation lands on the human wait-answer task", () => {
214
- const f = flowElement("f_noprogressWait");
215
- assert(f, "f_noprogressWait flow missing");
213
+ test("the no-progress escalation routes through gw-escalated toward the human wait-answer task", () => {
214
+ // #333: the arm no longer flows UNCONDITIONALLY into wait-answer — it routes through the
215
+ // gw-escalated guard, which parks wait-answer only when the worker opened a real escalation
216
+ // (escalated=true) and otherwise re-enters the loop (never a dead wait with a null question).
217
+ const f = flowElement("f_noprogressGate");
218
+ assert(f, "f_noprogressGate flow missing");
216
219
  assertStringIncludes(f, 'sourceRef="persist-escalation-noprogress"');
217
- assertStringIncludes(f, 'targetRef="wait-answer"');
220
+ assertStringIncludes(f, 'targetRef="gw-escalated"');
218
221
  // It opens a real, answerable escalation (blocked status + a concrete question) so it is never a
219
222
  // blank-question non-escalation that would wedge the token on the wait.
220
223
  const task = flat.match(