@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/service.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  UnresolvableCapabilityRefError,
23
23
  } from "./capabilityNeed.ts";
24
24
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
25
- import { backfillFeatureStages, deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
25
+ import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
26
26
  import {
27
27
  classifyMergeability,
28
28
  coalesceTitle,
@@ -1779,83 +1779,6 @@ export async function pollFeatureDelivery(data: DataLayer) {
1779
1779
  }
1780
1780
  }
1781
1781
 
1782
- /** Reconcile each in-flight FEATURE run against its native `feature-escalation` user task (issue
1783
- * #210 — feature-run escalations were invisible in the nwf UI). When a feature run escalates it parks
1784
- * on the `feature-escalation` operator user task (an engine wait); no worker runs, so `feature_runs`
1785
- * — which the schema-driven pages read — stayed `running` with nothing to show. This is the
1786
- * `feature_runs` twin of `pollFeatureDelivery`: for each run that can be parked at (or resuming from)
1787
- * the escalation, read its open user tasks and project the parked task onto the row via the pure
1788
- * `deriveFeatureEscalationPatch` — flipping `status` to `escalated` and denormalising the escalation's
1789
- * completable `userTaskKey` so the pages can drive an answer, and flipping back to `running` (clearing
1790
- * the pointer) once it un-parks. It never writes `escalation_question` — that is persisted by the
1791
- * `record-feature-escalation` worker at escalation entry and cleared on the exit paths, so the poller
1792
- * can never clobber the source of truth for the question.
1793
- *
1794
- * Candidates are only the runs that could be parked here — `running` (may have just escalated) and
1795
- * `escalated` (may have just resumed) — queried via the `feature_runs(status)` index, so the pass
1796
- * stays O(in-flight), not O(total runs). Terminal-ward transitions THROUGH `record-feature` (answer
1797
- * → abandon, SLA auto-abandon, done) clear the pointer in that worker, so a run that has already left
1798
- * `escalated` never needs sweeping here. Best-effort + idempotent — per-run failures are isolated. */
1799
- export async function pollFeatureEscalations(data: DataLayer, engine: EngineClient) {
1800
- const seen = new Set<string>();
1801
- const candidates: FeatureRun[] = [];
1802
- for (const status of ["running", "escalated"] as const) {
1803
- for (const run of await featureRuns(data).find({ status })) {
1804
- if (seen.has(run.feature_key)) continue;
1805
- seen.add(run.feature_key);
1806
- candidates.push(run);
1807
- }
1808
- }
1809
- for (const run of candidates) {
1810
- if (!run.process_key) continue;
1811
- try {
1812
- const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1813
- const task = tasks.find((t) => t.elementId === FEATURE_ESCALATION_ELEMENT);
1814
- const parked = task ? { userTaskKey: task.userTaskKey } : null;
1815
- const patch = deriveFeatureEscalationPatch(run, parked);
1816
- if (patch) {
1817
- await featureRuns(data).update(run.feature_key, { ...patch, updated_at: now() });
1818
- }
1819
- } catch (err) {
1820
- console.error(`[poller] feature escalation ${run.feature_key}: ${err}`);
1821
- }
1822
- }
1823
- }
1824
-
1825
- /** Reconcile each BLOCKED FEATURE run against its native `feature-blocked` user task (issue #220 —
1826
- * a blocked run parked at `feature-blocked` had no completion affordance in nwf). When a feature run
1827
- * reaches a `blocked` outcome `record-feature` holds the row at the NON-terminal `awaiting_operator`
1828
- * status and it parks on the `feature-blocked` operator user task (an engine wait); no worker runs, so
1829
- * the schema-driven pages — which read `feature_runs` — had a status to show but NO pointer to drive a
1830
- * completion action, so the run sat parked forever unless completed out-of-band. This is the blocked
1831
- * twin of `pollFeatureEscalations`: for each run parked at (or resuming from) the blocked wait, read its
1832
- * open user tasks and project the parked task's completable `userTaskKey` onto the row via the pure
1833
- * `deriveFeatureBlockedPatch`, so the pages can drive an "Acknowledge blocked" action, and clear the
1834
- * pointer once it un-parks. It never touches `status` — `record-feature` owns the `awaiting_operator`
1835
- * flip and `record-blocked-ack` owns the terminal `blocked`, so the poller can never clobber either.
1836
- *
1837
- * Candidates are only the runs that could be parked here — `awaiting_operator` (parked at, or just
1838
- * un-parked from, the blocked wait) — queried via the `feature_runs(status)` index, so the pass stays
1839
- * O(in-flight), not O(total runs). The terminal-ward transition THROUGH `record-blocked-ack` (and the
1840
- * acknowledge operation) clears the pointer, so a run that has already settled to `blocked` never needs
1841
- * sweeping here. Best-effort + idempotent — per-run failures are isolated. */
1842
- export async function pollFeatureBlocked(data: DataLayer, engine: EngineClient) {
1843
- for (const run of await featureRuns(data).find({ status: "awaiting_operator" })) {
1844
- if (!run.process_key) continue;
1845
- try {
1846
- const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1847
- const task = tasks.find((t) => t.elementId === FEATURE_BLOCKED_ELEMENT);
1848
- const parked = task ? { userTaskKey: task.userTaskKey } : null;
1849
- const patch = deriveFeatureBlockedPatch(run, parked);
1850
- if (patch) {
1851
- await featureRuns(data).update(run.feature_key, { ...patch, updated_at: now() });
1852
- }
1853
- } catch (err) {
1854
- console.error(`[poller] feature blocked ${run.feature_key}: ${err}`);
1855
- }
1856
- }
1857
- }
1858
-
1859
1782
  /** The app manifest, read and parsed exactly ONCE at module load. `activeStatusesFor` is invoked
1860
1783
  * three times during module initialization (the PR/plan/feature constants below); parsing here keeps
1861
1784
  * that to a single synchronous `readFileSync` + `JSON.parse` instead of one per lookup. */
@@ -1904,16 +1827,14 @@ export const FEATURE_ACTIVE_STATUSES: readonly FeatureRunStatus[] =
1904
1827
 
1905
1828
  /** Reconcile the unified Tasks-inbox read-model (`user_tasks`) against the engine's currently-open
1906
1829
  * native user-task escalations (issue #236). The Tasks page lists EVERY open escalation awaiting a
1907
- * human decision — the feature kinds (already denormalised onto `feature_runs` by the two feature
1908
- * pollers, which run earlier in this pass) plus the epic/PR kinds (`plan-review-decision`,
1909
- * `trial-merge-decision`, `wait-answer`) that had no app-side pointer at all, so the pages could not
1910
- * drive their completion. This is the generalisation of `pollFeatureEscalations`/`pollFeatureBlocked`
1911
- * across all subjects: for each in-flight plan / PR it reads the instance's open user tasks and
1912
- * projects one `user_tasks` row per escalation, enriching the display `question` from the audit
1913
- * tables each kind already records. `reconcileUserTasks` then diffs the desired open set against the
1914
- * persisted rows so a completed task's row is deleted (answered here, via the task inbox, or
1915
- * out-of-band) and `showCount` reflects live pending work. Best-effort + idempotent — per-instance
1916
- * failures are isolated so one bad instance never stalls the pass. */
1830
+ * human decision — the feature kinds (`feature-escalation` / `feature-blocked`) plus the epic/PR kinds
1831
+ * (`plan-review-decision`, `trial-merge-decision`, `wait-answer`) that otherwise had no app-side
1832
+ * pointer, so the pages could not drive their completion. For each in-flight feature / plan / PR it
1833
+ * reads the instance's open user tasks and projects one `user_tasks` row per escalation, enriching the
1834
+ * display `question` from the audit tables each kind already records. `reconcileUserTasks` then diffs
1835
+ * the desired open set against the persisted rows so a completed task's row is deleted (answered here,
1836
+ * via the task inbox, or out-of-band) and `showCount` reflects live pending work. Best-effort +
1837
+ * idempotent per-instance failures are isolated so one bad instance never stalls the pass. */
1917
1838
  export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1918
1839
  const at = now();
1919
1840
  const desired: UserTaskRow[] = [];
@@ -1921,54 +1842,63 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1921
1842
  if (row) desired.push(row);
1922
1843
  };
1923
1844
 
1924
- // Feature-run escalations — the completable keys are denormalised onto `feature_runs` by
1925
- // pollFeatureEscalations/pollFeatureBlocked (earlier in this pass), so no per-instance engine read
1926
- // is needed here.
1845
+ // Feature-run escalations / blocked runs read each in-flight feature run's open user tasks directly
1846
+ // from the engine (issue #332: the denormalised `feature_runs.escalation_user_task_key` /
1847
+ // `blocked_user_task_key` pointers were dropped in the contract phase, so this now reads the live
1848
+ // task exactly as the plan/PR scans below do — one canonical read, no denormalised mirror). Dedupe by
1849
+ // `feature_key` across the status queries so a run whose status transitions mid-pass is not processed
1850
+ // twice.
1927
1851
  const featureSeen = new Set<string>();
1928
1852
  for (const status of FEATURE_ACTIVE_STATUSES) {
1929
1853
  for (const run of await featureRuns(data).find({ status })) {
1854
+ if (!run.process_key) continue;
1930
1855
  if (featureSeen.has(run.feature_key)) continue;
1931
1856
  featureSeen.add(run.feature_key);
1932
- if (run.escalation_user_task_key) {
1933
- // Source the question from the canonical append-only `feature_escalations` audit log (issue
1934
- // #305) the surviving table `record-feature-escalation` writes — falling back to the legacy
1935
- // denormalised `feature_runs.escalation_question` while both coexist (expand phase). This is the
1936
- // feature analogue of the plan-review/trial-merge/PR-loop question enrichment below, and lets the
1937
- // denormalised column be dropped in the contract phase without the Tasks grid losing the text.
1938
- const question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: run.feature_key })) ??
1939
- run.escalation_question;
1940
- push(
1941
- buildUserTaskRow(
1942
- {
1943
- userTaskKey: run.escalation_user_task_key,
1944
- elementId: FEATURE_ESCALATION_ELEMENT,
1945
- subjectType: "feature",
1946
- subjectKey: run.feature_key,
1947
- subjectTitle: run.title,
1948
- subjectUrl: run.issue_url,
1949
- question,
1950
- processKey: run.process_key,
1951
- },
1952
- at,
1953
- ),
1954
- );
1857
+ let tasks: { userTaskKey: string; elementId?: string }[];
1858
+ try {
1859
+ tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1860
+ } catch (err) {
1861
+ console.error(`[poller] user tasks (feature ${run.feature_key}): ${err}`);
1862
+ continue;
1955
1863
  }
1956
- if (run.blocked_user_task_key) {
1957
- push(
1958
- buildUserTaskRow(
1959
- {
1960
- userTaskKey: run.blocked_user_task_key,
1961
- elementId: FEATURE_BLOCKED_ELEMENT,
1962
- subjectType: "feature",
1963
- subjectKey: run.feature_key,
1964
- subjectTitle: run.title,
1965
- subjectUrl: run.issue_url,
1966
- question: run.delivery_label,
1967
- processKey: run.process_key,
1968
- },
1969
- at,
1970
- ),
1971
- );
1864
+ for (const t of tasks) {
1865
+ if (t.elementId === FEATURE_ESCALATION_ELEMENT) {
1866
+ // Source the question from the canonical append-only `feature_escalations` audit log (issue
1867
+ // #305) — the surviving table `record-feature-escalation` writes — the feature analogue of the
1868
+ // plan-review/trial-merge/PR-loop question enrichment below.
1869
+ const question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: run.feature_key }));
1870
+ push(
1871
+ buildUserTaskRow(
1872
+ {
1873
+ userTaskKey: t.userTaskKey,
1874
+ elementId: FEATURE_ESCALATION_ELEMENT,
1875
+ subjectType: "feature",
1876
+ subjectKey: run.feature_key,
1877
+ subjectTitle: run.title,
1878
+ subjectUrl: run.issue_url,
1879
+ question,
1880
+ processKey: run.process_key,
1881
+ },
1882
+ at,
1883
+ ),
1884
+ );
1885
+ } else if (t.elementId === FEATURE_BLOCKED_ELEMENT) {
1886
+ push(
1887
+ buildUserTaskRow(
1888
+ {
1889
+ userTaskKey: t.userTaskKey,
1890
+ elementId: FEATURE_BLOCKED_ELEMENT,
1891
+ subjectType: "feature",
1892
+ subjectKey: run.feature_key,
1893
+ subjectTitle: run.title,
1894
+ subjectUrl: run.issue_url,
1895
+ question: run.delivery_label,
1896
+ processKey: run.process_key,
1897
+ },
1898
+ at,
1899
+ ),
1900
+ );
1901
+ }
1972
1902
  }
1973
1903
  }
1974
1904
  }
@@ -2126,8 +2056,6 @@ export async function pollOnce(
2126
2056
  await pollPromotion(data, engine, token);
2127
2057
  await pollFeatureDelivery(data);
2128
2058
  await pollLineage(data);
2129
- await pollFeatureEscalations(data, engine);
2130
- await pollFeatureBlocked(data, engine);
2131
2059
  await pollUserTasks(data, engine);
2132
2060
  if (engineRest) {
2133
2061
  const base = engineRest.restAddress.replace(/\/+$/, "");
package/app/stage.test.ts CHANGED
@@ -5,15 +5,12 @@
5
5
  import { test } from "node:test";
6
6
  import { assert, assertEquals } from "#test-assert";
7
7
  import { FEATURE_RUN_STATUSES } from "./feature.ts";
8
- import { deriveEscalationOpen, deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
8
+ import { deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
9
9
 
10
10
  const base = (over: Partial<StageInput> & { status: string }): StageInput => ({
11
11
  pr_key: null,
12
12
  converge: 1,
13
13
  auto_merge: 1,
14
- escalation_question: null,
15
- escalation_user_task_key: null,
16
- blocked_user_task_key: null,
17
14
  ...over,
18
15
  });
19
16
 
@@ -71,10 +68,11 @@ test("skipped: the three converge/auto_merge cases", () => {
71
68
  assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 1 })).skipped, "");
72
69
  });
73
70
 
74
- test("attention: blocked, escalation, none", () => {
75
- assertEquals(deriveStage(base({ status: "awaiting_operator", blocked_user_task_key: "ut-1" })).attention, "blocked");
76
- assertEquals(deriveStage(base({ status: "escalated", escalation_user_task_key: "ut-2" })).attention, "⚠");
77
- assertEquals(deriveStage(base({ status: "escalated", escalation_question: "which base?" })).attention, "");
71
+ test("attention: derives from status alone (blocked, escalation, none)", () => {
72
+ // Issue #332 dropped the denormalised escalation pointer/question columns; `attention` is now a pure
73
+ // function of `status` — `awaiting_operator` (a parked blocked run) → "blocked", `escalated` "⚠".
74
+ assertEquals(deriveStage(base({ status: "awaiting_operator" })).attention, "blocked");
75
+ assertEquals(deriveStage(base({ status: "escalated" })).attention, "⚠");
78
76
  assertEquals(deriveStage(base({ status: "running" })).attention, null);
79
77
  });
80
78
 
@@ -92,7 +90,7 @@ test("escalated WITHOUT pr_key → Implementing / null", () => {
92
90
  });
93
91
 
94
92
  test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when parked", () => {
95
- const d = deriveStage(base({ status: "awaiting_operator", pr_key: null, blocked_user_task_key: "ut-3" }));
93
+ const d = deriveStage(base({ status: "awaiting_operator", pr_key: null }));
96
94
  assertEquals(d.stage, "Implementing");
97
95
  assertEquals(d.state, null);
98
96
  assertEquals(d.attention, "blocked");
@@ -105,47 +103,3 @@ test("deriveListBucket: history iff terminal AND acknowledged, else active", ()
105
103
  assertEquals(deriveListBucket("running", "2024-01-01T00:00:00Z"), "active");
106
104
  assertEquals(deriveListBucket("blocked", "2024-01-01T00:00:00Z"), "history");
107
105
  });
108
-
109
- // deriveEscalationOpen (issue #272): the single fail-closed "open escalation" display signal. TRUE iff
110
- // all three independently-written escalation columns AGREE the run is parked at an answerable
111
- // escalation; any single missing/torn field yields FALSE so the pages render not-escalated.
112
- test("deriveEscalationOpen: true only when status, pointer AND question all present", () => {
113
- assertEquals(
114
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: "which base?" }),
115
- true,
116
- );
117
- });
118
-
119
- test("deriveEscalationOpen: torn tuple (pointer set, question blank) renders as NOT escalated", () => {
120
- // The mirror tear observed on nwf#270: status=escalated + live pointer + blank question.
121
- assertEquals(
122
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: null }),
123
- false,
124
- );
125
- assertEquals(
126
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: "ut-7", escalation_question: "" }),
127
- false,
128
- );
129
- });
130
-
131
- test("deriveEscalationOpen: torn tuple (question set, pointer null) renders as NOT escalated", () => {
132
- // The entry-window tear: record-feature-escalation persisted the question but the poller has not yet
133
- // denormalised the pointer.
134
- assertEquals(
135
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: null, escalation_question: "which base?" }),
136
- false,
137
- );
138
- });
139
-
140
- test("deriveEscalationOpen: a resumed run whose status lags behind a cleared tuple is NOT escalated", () => {
141
- // status still 'escalated' but the answer op already cleared pointer + question → fail closed.
142
- assertEquals(
143
- deriveEscalationOpen({ status: "escalated", escalation_user_task_key: null, escalation_question: null }),
144
- false,
145
- );
146
- // A non-escalated status can never be open regardless of stray column values.
147
- assertEquals(
148
- deriveEscalationOpen({ status: "running", escalation_user_task_key: "ut-7", escalation_question: "which base?" }),
149
- false,
150
- );
151
- });
package/app/stage.ts CHANGED
@@ -43,9 +43,6 @@ export interface StageInput {
43
43
  pr_key?: string | null;
44
44
  converge?: number | boolean | null;
45
45
  auto_merge?: number | boolean | null;
46
- escalation_question?: string | null;
47
- escalation_user_task_key?: string | null;
48
- blocked_user_task_key?: string | null;
49
46
  }
50
47
 
51
48
  /** The derived pipeline projection for one run. `skipped` is a space-separated set of stage keys not
@@ -94,44 +91,15 @@ export function deriveStage(run: StageInput): DerivedStage {
94
91
 
95
92
  // `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
96
93
  // a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
97
- const attention = run.blocked_user_task_key
98
- ? "blocked"
99
- : run.escalation_user_task_key || run.escalation_question
100
- ? ""
101
- : null;
94
+ // Derived from `status` alone (issue #332): the parked-task pointers that used to source it were
95
+ // dropped with the denormalised escalation surface, and the authoritative "who is waiting on a human"
96
+ // list now lives on the `user_tasks` Tasks inbox. `awaiting_operator` (parked at `feature-blocked`)
97
+ // shows the blocked glyph; `escalated` (parked at `feature-escalation`) shows the badge.
98
+ const attention = status === "awaiting_operator" ? "blocked" : status === "escalated" ? "⚠" : null;
102
99
 
103
100
  return { stage, state, skipped: skippedKeys.join(" "), attention };
104
101
  }
105
102
 
106
- /** Derive the single fail-closed "open escalation" display signal for one feature run (issue #272).
107
- *
108
- * The open-escalation condition is jointly encoded by THREE independently-written columns —
109
- * `status='escalated'`, `escalation_user_task_key` (the completable pointer), and `escalation_question`
110
- * — owned by different writers on different schedules (the `record-feature-escalation` service task
111
- * sets the question; `pollFeatureEscalations`/`deriveFeatureEscalationPatch` sets status + pointer; the
112
- * answer operation clears the tuple). Because they are not written as one atomic tuple, a reader can
113
- * observe a TORN interim state (e.g. `status=escalated` + pointer set + `question=null`) and render a
114
- * self-contradictory escalation — an "answer me" affordance with nothing to answer.
115
- *
116
- * Collapse that class at the consumer: the pages gate the escalation affordances (Abandon / answer
117
- * form) on this ONE derived conjunction rather than on any single column, so a torn tuple renders as
118
- * NOT escalated (fail closed) instead of escalated-but-blank. `true` iff ALL THREE fields agree the run
119
- * is parked at an answerable escalation; any missing field yields `false`. Maintained as a write-time
120
- * projection by the feature_runs gateway (like `stage`/`list_bucket`), so it stays fresh on every write
121
- * — including the answer operation's eager tuple-clear, which makes the affordance disappear WITHOUT
122
- * waiting a poll pass. Pure and read-only. */
123
- export function deriveEscalationOpen(run: {
124
- status: string;
125
- escalation_question?: string | null;
126
- escalation_user_task_key?: string | null;
127
- }): boolean {
128
- return (
129
- run.status === "escalated" &&
130
- (run.escalation_user_task_key ?? "") !== "" &&
131
- (run.escalation_question ?? "") !== ""
132
- );
133
- }
134
-
135
103
  /** The Active/History partition label (§5), maintained at write time so the flat-DSL page tabs filter
136
104
  * on a stored `list_bucket` column with only `in` clauses. `history` iff the row is in a truly-terminal
137
105
  * status AND acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). */
@@ -2,7 +2,7 @@
2
2
  // one resolved open escalation user task into its desired `user_tasks` row (or null for a
3
3
  // non-escalation / blank key), and `reconcileUserTasks` diffs the desired open set against the
4
4
  // persisted rows into the minimal insert/update/delete plan the `pollUserTasks` reconcile applies.
5
- // These are the pure source of truth the poller projects, mirroring `deriveFeatureEscalationPatch`.
5
+ // These are the pure source of truth the poller projects.
6
6
  import { test } from "node:test";
7
7
  import { assert, assertEquals } from "#test-assert";
8
8
  import type { PlanReview } from "./plan.ts";
package/app/userTasks.ts CHANGED
@@ -10,11 +10,11 @@
10
10
  // (app/service.ts) projects: `buildUserTaskRow` (one open task → a desired row) and
11
11
  // `reconcileUserTasks` (the desired set vs the persisted set → the minimal upserts + deletes). The
12
12
  // engine iteration + writes live in the poller; the decisions live here so they are unit-testable
13
- // without a host, mirroring `deriveFeatureEscalationPatch`.
13
+ // without a host.
14
14
  //
15
15
  // Completion is NOT owned here — the page posts the typed form variables to the ONE canonical human
16
- // completer (`completeEscalationAsHuman`, app/agentCompletion.ts) / the existing feature answer &
17
- // acknowledge operations, the exact resume path the task inbox uses. This module only makes the open
16
+ // completer (`completeEscalationAsHuman`, app/agentCompletion.ts) via the `complete-user-task` door,
17
+ // the exact resume path the task inbox uses. This module only makes the open
18
18
  // tasks visible; a completed task's row is removed on the next pass when the engine no longer reports
19
19
  // it open.
20
20
  import type { DataLayer } from "@nanobpm/urban";
@@ -0,0 +1,25 @@
1
+ -- 049_drop_feature_escalation_surface.sql — issue #332: the destructive CONTRACT phase of #305,
2
+ -- whose EXPAND half landed in #310 (the append-only `feature_escalations` audit table + dual-write +
3
+ -- the native `user_tasks` read). Now that escalation/blocked state is projected onto `user_tasks`
4
+ -- from the engine's open user tasks (`pollUserTasks` reads the engine directly, like the plan/PR
5
+ -- kinds), and the question text survives on `feature_escalations`, the denormalised
6
+ -- `feature_runs.escalation_*` / `blocked_user_task_key` surface is dead — nothing reads or writes it.
7
+ -- Drop it.
8
+ --
9
+ -- These four columns were the interim mirror the retired `pollFeatureEscalations` / `pollFeatureBlocked`
10
+ -- pollers and the bespoke `answer-escalation` / `acknowledge-blocked` doors used to drive the Feature /
11
+ -- Overview pages before the Tasks inbox owned every human decision:
12
+ -- • escalation_question (031) — now sourced from the `feature_escalations` audit log.
13
+ -- • escalation_user_task_key (031) — now read live from the engine's open `feature-escalation` task.
14
+ -- • blocked_user_task_key (032) — now read live from the engine's open `feature-blocked` task.
15
+ -- • escalation_open (040) — the fail-closed torn-tuple display signal, moot once the tuple
16
+ -- is gone; the Feature page no longer gates any affordance on it.
17
+ --
18
+ -- Forward-only and numbered after the current highest prefix on origin/main (048); migrations apply in
19
+ -- order and are auto-applied on boot. The runner wraps each file in its own transaction, so this file
20
+ -- must NOT contain BEGIN/COMMIT. No index references these columns (none was ever created), so a plain
21
+ -- `ALTER TABLE … DROP COLUMN` suffices (mirrors 027's `pull_requests`/`plans` pointer drops).
22
+ ALTER TABLE feature_runs DROP COLUMN escalation_question;
23
+ ALTER TABLE feature_runs DROP COLUMN escalation_user_task_key;
24
+ ALTER TABLE feature_runs DROP COLUMN blocked_user_task_key;
25
+ ALTER TABLE feature_runs DROP COLUMN escalation_open;
@@ -23,7 +23,7 @@ import type { EngineJob } from "@nanobpm/urban/runtime";
23
23
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
24
24
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
25
  import { asEngineClient } from "./support/engine-client.ts";
26
- import { pollFeatureBlocked, pollFeatureEscalations } from "../app/service.ts";
26
+ import { pollUserTasks } from "../app/service.ts";
27
27
 
28
28
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
29
 
@@ -58,9 +58,6 @@ interface FeatureRow {
58
58
  status: string;
59
59
  pr_key: string | null;
60
60
  delivery_label: string | null;
61
- escalation_question: string | null;
62
- escalation_user_task_key: string | null;
63
- blocked_user_task_key: string | null;
64
61
  }
65
62
  interface PrRow {
66
63
  pr_key: string;
@@ -191,18 +188,23 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
191
188
  const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({});
192
189
  assert.equal(prs.length, 0, "a blocked run never enrolled a PR into the convergence loop");
193
190
 
194
- // The poller fills in the completable user-task key (which no service task can know the task
195
- // doesn't exist yet when record-feature runs) so the pages can drive an attributed acknowledge.
196
- await pollFeatureBlocked(app.db, asEngineClient(app.engine));
197
- const denorm = await featureRow(app, featureKey);
198
- assert.ok(denorm.blocked_user_task_key, "the poller denormalised the completable blocked user-task key");
199
- assert.equal(denorm.status, "awaiting_operator", "the run stays awaiting_operator while parked");
200
-
201
- // Acknowledge through the app's OWN operation (the nwf UI's affordance) — the attributed
202
- // completer resumes the SAME record-blocked-ack path a human would from the task inbox, with NO
203
- // out-of-band /v2/user-tasks/{key}/completion call.
204
- const acked = await app.api?.call("acknowledgeBlocked", {
205
- body: { userTaskKey: denorm.blocked_user_task_key, note: "reassigned to a human" },
191
+ // The escalation state now lives on the native user task + the Tasks inbox `user_tasks`
192
+ // read-model (issue #332 dropped the denormalised `feature_runs.blocked_user_task_key` pointer),
193
+ // so `pollUserTasks` projects the parked `feature-blocked` task onto `user_tasks` by reading the
194
+ // engine directly no per-run column write.
195
+ await pollUserTasks(app.db, asEngineClient(app.engine));
196
+ const inboxRow = await app.db
197
+ .table<{ user_task_key: string; element_id: string }>("user_tasks", "user_task_key")
198
+ .findOne({ user_task_key: task!.userTaskKey });
199
+ assert.ok(inboxRow, "the poller projected the blocked task onto the Tasks inbox read-model");
200
+ assert.equal(inboxRow!.element_id, "feature-blocked", "the projected row is a feature-blocked task");
201
+ assert.equal(parked.status, "awaiting_operator", "the run stays awaiting_operator while parked");
202
+
203
+ // Acknowledge through the ONE canonical `complete-user-task` door (issue #332 retired the bespoke
204
+ // `acknowledge-blocked` operation) — the attributed human completer resumes the SAME
205
+ // record-blocked-ack path from the task inbox, with NO out-of-band completion call.
206
+ const acked = await app.api?.call("completeUserTask", {
207
+ body: { userTaskKey: task!.userTaskKey, variables: { note: "reassigned to a human" } },
206
208
  });
207
209
  assert.equal(acked?.status, 200, "the operator acknowledgement completed the blocked task");
208
210
  await app.settle();
@@ -212,10 +214,10 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
212
214
  const settled = await featureRow(app, featureKey);
213
215
  assert.equal(settled.status, "blocked", "the acknowledged run settles at terminal blocked");
214
216
  assert.equal(settled.delivery_label, "operator: reassigned to a human", "the operator note is recorded");
215
- assert.equal(settled.blocked_user_task_key, null, "the completable-task pointer was cleared on ack");
216
217
 
217
- // A further poll pass is an idempotent no-op — a terminal run is not a candidate.
218
- await pollFeatureBlocked(app.db, asEngineClient(app.engine));
218
+ // A further poll pass is an idempotent no-op — the task is completed, so the read-model row is
219
+ // reconciled away and a terminal run is not a candidate.
220
+ await pollUserTasks(app.db, asEngineClient(app.engine));
219
221
  assert.equal((await featureRow(app, featureKey)).status, "blocked");
220
222
  },
221
223
  );
@@ -276,7 +278,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
276
278
  );
277
279
  });
278
280
 
279
- test("escalate → the poller surfaces it on the read model, and the operator answer resolves it (issue #210)", async () => {
281
+ test("escalate → the poller surfaces it on the Tasks inbox, and the operator answer resolves it (issue #210/#332)", async () => {
280
282
  let calls = 0;
281
283
  await withApp(
282
284
  {
@@ -288,25 +290,32 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
288
290
  },
289
291
  },
290
292
  { baseBranch: "epic/e2e" },
291
- async ({ app, featureKey }) => {
293
+ async ({ app, featureKey, processKey }) => {
292
294
  // The `record-feature-escalation` service task runs on the escalated arm (before the user task),
293
- // so the row already carries the flipped status + the agent's question when the run parks — the
294
- // read model the pages read is no longer blind to the native user-task wait (the #210 bug).
295
+ // so the row already carries the flipped status when the run parks, and the agent's question is
296
+ // recorded in the `feature_escalations` audit log the poller reads (issue #332 dropped the
297
+ // denormalised `feature_runs.escalation_question` column).
295
298
  const parked = await featureRow(app, featureKey);
296
299
  assert.equal(parked.status, "escalated", "the escalated status is surfaced on the read model");
297
- assert.equal(parked.escalation_question, "Which API should I use?", "the agent's question is surfaced");
298
-
299
- // The poller fills in the completable user-task key (which the service task can't know — the
300
- // task doesn't exist yet when it runs) so the UI can drive an attributed answer.
301
- await pollFeatureEscalations(app.db, asEngineClient(app.engine));
302
- const escalated = await featureRow(app, featureKey);
303
- assert.ok(escalated.escalation_user_task_key, "the poller denormalised the completable user-task key");
304
- assert.equal(escalated.status, "escalated", "the run stays escalated while parked");
305
-
306
- // Answer through the app's OWN operation (the nwf UI's answer affordance) — the attributed
307
- // completer resumes the SAME implement task a human would from the task inbox.
308
- const answered = await app.api?.call("answerFeatureEscalation", {
309
- body: { userTaskKey: escalated.escalation_user_task_key, resolution: "answer", answer: "use v2" },
300
+
301
+ const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
302
+ const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
303
+ assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
304
+
305
+ // The poller projects the parked task onto the Tasks inbox `user_tasks` read-model by reading
306
+ // the engine directly, sourcing the question from the `feature_escalations` audit log.
307
+ await pollUserTasks(app.db, asEngineClient(app.engine));
308
+ const inboxRow = await app.db
309
+ .table<{ user_task_key: string; element_id: string; question: string | null }>("user_tasks", "user_task_key")
310
+ .findOne({ user_task_key: task!.userTaskKey });
311
+ assert.ok(inboxRow, "the poller projected the escalation onto the Tasks inbox read-model");
312
+ assert.equal(inboxRow!.question, "Which API should I use?", "the agent's question is surfaced from the audit log");
313
+
314
+ // Answer through the ONE canonical `complete-user-task` door (issue #332 retired the bespoke
315
+ // `answer-escalation` operation) — the attributed human completer resumes the SAME implement task
316
+ // a human would from the task inbox.
317
+ const answered = await app.api?.call("completeUserTask", {
318
+ body: { userTaskKey: task!.userTaskKey, variables: { resolution: "answer", answer: "use v2" } },
310
319
  });
311
320
  assert.equal(answered?.status, 200, "the operator answer completed the escalation task");
312
321
  await app.settle();
@@ -318,14 +327,16 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
318
327
  );
319
328
  assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
320
329
 
321
- // The run opened its PR; the escalation pointer + question were cleared once resolved.
330
+ // The run opened its PR.
322
331
  const settled = await featureRow(app, featureKey);
323
332
  assert.equal(settled.status, "opened", "the resumed run opened its PR");
324
- assert.equal(settled.escalation_user_task_key, null, "the escalation pointer was cleared once resolved");
325
- assert.equal(settled.escalation_question, null, "the surfaced question was cleared once resolved");
326
333
 
327
- // A further poll pass is an idempotent no-op a terminal run is not a candidate.
328
- await pollFeatureEscalations(app.db, asEngineClient(app.engine));
334
+ // A further poll pass reconciles the completed task's read-model row away.
335
+ await pollUserTasks(app.db, asEngineClient(app.engine));
336
+ const gone = await app.db
337
+ .table<{ user_task_key: string }>("user_tasks", "user_task_key")
338
+ .findOne({ user_task_key: task!.userTaskKey });
339
+ assert.equal(gone, undefined, "the completed escalation's inbox row was reconciled away");
329
340
  assert.equal((await featureRow(app, featureKey)).status, "opened");
330
341
  },
331
342
  );
@@ -155,6 +155,32 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
155
155
  }
156
156
  });
157
157
 
158
+ test("issue #332 contract phase: feature_runs escalation columns + bespoke feature doors are gone", async () => {
159
+ // The destructive contract phase of #305 dropped the denormalised feature-run escalation surface now
160
+ // that escalation state lives on the native `user_tasks` inbox + the `feature_escalations` audit log.
161
+ const featureCols = await columnNames(app, "feature_runs");
162
+ assert.ok(featureCols.length > 0, "feature_runs table still exists");
163
+ for (const dropped of [
164
+ "escalation_question",
165
+ "escalation_user_task_key",
166
+ "blocked_user_task_key",
167
+ "escalation_open",
168
+ ]) {
169
+ assert.ok(!featureCols.includes(dropped), `feature_runs.${dropped} column dropped (#332)`);
170
+ }
171
+
172
+ // The bespoke feature-run answer doors are gone — the ONE canonical `/actions/complete-user-task`
173
+ // door now completes `feature-escalation` / `feature-blocked` alongside the epic/PR kinds.
174
+ for (const path of ["/app/api/actions/answer-escalation", "/app/api/actions/acknowledge-blocked"]) {
175
+ const res = await app.callRoute({
176
+ method: "POST",
177
+ path,
178
+ body: JSON.stringify({ userTaskKey: "x" }),
179
+ });
180
+ assert.equal(res.status, 404, `retired feature door ${path} is unmounted (404)`);
181
+ }
182
+ });
183
+
158
184
  test("an escalation still round-trips via userTask + inbox with no denormalised pointer or dead form", async () => {
159
185
  const api = app.api;
160
186
  assert.ok(api, "the OpenAPI driver is available");