@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.
- package/CHANGELOG.md +14 -0
- package/app/agentCompletion.test.ts +92 -6
- package/app/agentCompletion.ts +86 -58
- package/app/convergeGate.test.ts +8 -4
- package/app/convergenceEscalationGuard.test.ts +106 -0
- package/app/feature.ts +15 -166
- package/app/featureGateway.test.ts +6 -57
- package/app/persist-escalation.test.ts +32 -0
- package/app/pollUserTasks.test.ts +39 -13
- package/app/roundProgress.test.ts +7 -4
- package/app/service.ts +60 -132
- package/app/stage.test.ts +7 -53
- package/app/stage.ts +5 -37
- package/app/userTasks.test.ts +1 -1
- package/app/userTasks.ts +3 -3
- package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
- package/e2e/feature-run.e2e.ts +52 -41
- package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
- package/openapi.yaml +7 -108
- package/operations/agentCompleteEscalation.ts +2 -2
- package/operations/completeUserTask.test.ts +25 -6
- package/operations/completeUserTask.ts +12 -10
- package/package.json +2 -2
- package/pages/feature.page.json +1 -37
- package/pages/overview.page.json +1 -39
- package/pages/tasks.page.json +78 -93
- package/resources/forms/feature-escalation.form +3 -0
- package/resources/processes/convergence-loop.bpmn +114 -118
- package/workers/persist-escalation/worker.ts +7 -4
- package/workers/record-blocked-ack/worker.test.ts +1 -4
- package/workers/record-blocked-ack/worker.ts +0 -5
- package/workers/record-feature/worker.ts +0 -6
- package/workers/record-feature-escalation/worker.test.ts +14 -27
- package/workers/record-feature-escalation/worker.ts +16 -22
- package/app/featureBlocked.test.ts +0 -182
- package/app/featureEscalation.test.ts +0 -235
- package/operations/acknowledgeBlocked.test.ts +0 -111
- package/operations/acknowledgeBlocked.ts +0 -62
- 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,
|
|
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 (
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
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 —
|
|
1925
|
-
//
|
|
1926
|
-
//
|
|
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
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
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
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
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 {
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
assertEquals(deriveStage(base({ status: "
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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). */
|
package/app/userTasks.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
17
|
-
//
|
|
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;
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 —
|
|
218
|
-
|
|
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
|
|
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
|
|
294
|
-
//
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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
|
|
328
|
-
await
|
|
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");
|