@nanobpm/nano-workforce 0.123.1 → 0.123.2
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 +9 -0
- package/app/deliveryGraphDeploy.test.ts +209 -0
- package/app/featureReadModel.test.ts +80 -12
- package/app/github.test.ts +34 -0
- package/app/github.ts +12 -3
- package/app/maybeEnsureFreshHeadRun.test.ts +150 -0
- package/app/mergeEscalationQuestion.test.ts +33 -0
- package/app/mergeProtocol.test.ts +25 -0
- package/app/mergeProtocol.ts +10 -4
- package/app/pollUserTasks.test.ts +27 -0
- package/app/service.ts +77 -13
- package/app/stage.test.ts +21 -7
- package/app/stage.ts +18 -5
- package/db/migrations/075_feature_read_model_attention_from_user_tasks.sql +113 -0
- package/e2e/convergence-escalation.e2e.ts +10 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +13 -0
- package/package.json +3 -3
- package/resources/processes/merge-loop.bpmn +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## [0.123.2](https://github.com/nanobpm/nano-workforce/compare/v0.123.1...v0.123.2) (2026-08-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **merge:** make draft PRs a first-class not-landable verdict ([#454](https://github.com/nanobpm/nano-workforce/issues/454)) ([#455](https://github.com/nanobpm/nano-workforce/issues/455)) ([d684086](https://github.com/nanobpm/nano-workforce/commit/d684086748c840fe6a77fd7d86c5cdecbf8bfe2a))
|
|
7
|
+
* **read-model:** derive feature attention badge from open user tasks, not sticky status ([#458](https://github.com/nanobpm/nano-workforce/issues/458)) ([496971d](https://github.com/nanobpm/nano-workforce/commit/496971dba95f19857d659e5a9d7fa56085d932fa)), closes [#439](https://github.com/nanobpm/nano-workforce/issues/439) [#448](https://github.com/nanobpm/nano-workforce/issues/448) [#422](https://github.com/nanobpm/nano-workforce/issues/422) [#422](https://github.com/nanobpm/nano-workforce/issues/422)
|
|
8
|
+
* **tasks:** scan running delivery-graph runs in the typed-seam fallback so human gates surface without raw-REST ([#457](https://github.com/nanobpm/nano-workforce/issues/457)) ([9e82d37](https://github.com/nanobpm/nano-workforce/commit/9e82d37a101d532de447c6ca0bf64bd3d9a3bee7)), closes [#443](https://github.com/nanobpm/nano-workforce/issues/443) [#442](https://github.com/nanobpm/nano-workforce/issues/442)
|
|
9
|
+
|
|
1
10
|
## [0.123.1](https://github.com/nanobpm/nano-workforce/compare/v0.123.0...v0.123.1) (2026-08-22)
|
|
2
11
|
|
|
3
12
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// End-to-end coverage that the delivery-graph compiler (ADR 0005) emits BPMN that is BOTH
|
|
2
|
+
// EXECUTABLE and RENDERABLE — the two disjoint validity axes of the one BPMN in this system that is
|
|
3
|
+
// generated at RUNTIME by raw string concatenation rather than authored (issue #451).
|
|
4
|
+
//
|
|
5
|
+
// The pure compiler tests (`deliveryGraphCompiler.test.ts`) assert only on the XML STRING SHAPE
|
|
6
|
+
// (`includes(...)`, regex counts). A string-shape assert proves the text LOOKS right; it does NOT
|
|
7
|
+
// prove it DEPLOYS — a mis-wired boundary event, a flow to a dropped element, a bad `ioMapping`, or a
|
|
8
|
+
// `jobType` typo yields BPMN that passes every `includes()` and still fails `engine.deploy(xml)` with
|
|
9
|
+
// a misleading "unknown target element" at the flow (the AGENTS.md "it parsed but didn't execute"
|
|
10
|
+
// drift class). So here we DEPLOY the compiled graph through the real in-process WASM engine
|
|
11
|
+
// (`@nanobpm/urban-testkit`) via the SAME S4 path the runner uses (`runDeliveryGraph`) and ADVANCE a
|
|
12
|
+
// live instance to a terminal state — the exact deploy → instance → user-tasks → complete → terminal
|
|
13
|
+
// path that otherwise only gets hand-verified against a live node.
|
|
14
|
+
//
|
|
15
|
+
// Renderability and executability are DISJOINT (a graph can lay out perfectly and still fail deploy,
|
|
16
|
+
// and vice-versa), so `di coverage` guards the visual axis independently: every emitted flow node
|
|
17
|
+
// carries a `bpmndi:BPMNShape` and every sequence flow a `bpmndi:BPMNEdge`, so a future node kind
|
|
18
|
+
// cannot silently ship without a diagram (AGENTS.md: "BPMN Models need DI for rendering").
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import { createWasmEngineClient } from "@nanobpm/urban-testkit";
|
|
21
|
+
import { assert, assertEquals } from "#test-assert";
|
|
22
|
+
import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
|
|
23
|
+
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
24
|
+
import { runDeliveryGraph } from "./deliveryRunner.ts";
|
|
25
|
+
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
26
|
+
|
|
27
|
+
/** A graph exercising the full node-kind matrix: `agent` (a named `senior:*` job), `wait` (the
|
|
28
|
+
* `pr.readiness-probe` poll gate), `human` (a user task), and `connector` (the delivery-connector
|
|
29
|
+
* delegate). This is the ADR's motivating release runbook. */
|
|
30
|
+
const MATRIX_GRAPH: DeliveryGraph = {
|
|
31
|
+
name: "release runbook",
|
|
32
|
+
nodes: [
|
|
33
|
+
{ id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "un-draft + merge #B" } },
|
|
34
|
+
{ id: "watch", kind: "wait", wait: { kind: "pr", target: "owner/repo#42", match: { prState: "merged" } }, emits: [{ name: "mergedSha", type: "string" }] },
|
|
35
|
+
{ id: "publish", kind: "human", human: { prompt: "run the manual OTP publish" }, emits: [{ name: "resolvedArtifact", type: "artifact" }] },
|
|
36
|
+
{ id: "consume", kind: "connector", connector: { target: "npm:install", dedupeKey: "consume-1" } },
|
|
37
|
+
],
|
|
38
|
+
edges: [
|
|
39
|
+
{ from: "impl", to: "watch" },
|
|
40
|
+
{ from: "watch.mergedSha", to: "publish" },
|
|
41
|
+
{ from: "publish.resolvedArtifact", to: "consume" },
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The generic completion payload for a delivery user task. Satisfies BOTH the `human` node's output
|
|
46
|
+
* ioMapping (`value` → `humanEmitValue`, `resolvedArtifact` → `humanEmitArtifact`, `note` →
|
|
47
|
+
* `humanNote`) and the escalation task's generic form (`value` required, `note`). */
|
|
48
|
+
const HUMAN_PAYLOAD = { value: "done", note: "ok", resolvedArtifact: "@nanobpm/demo@1.0.0" };
|
|
49
|
+
|
|
50
|
+
/** Upper bound on drive rounds — a terminal graph settles in a handful; the cap turns a wiring bug
|
|
51
|
+
* (a node that never advances) into a loud failure instead of a hang. */
|
|
52
|
+
const MAX_ROUNDS = 16;
|
|
53
|
+
|
|
54
|
+
test("deploy+advance: a well-formed graph deploys through the real engine and every node kind advances to a COMPLETED instance", async () => {
|
|
55
|
+
const engine = await createWasmEngineClient();
|
|
56
|
+
try {
|
|
57
|
+
// Serve every service node's job so each node completes NORMALLY (no boundary timeout fires): the
|
|
58
|
+
// agent job, the readiness probe (return `ready: true` so the poll loop exits on its first pass),
|
|
59
|
+
// and the connector delegate.
|
|
60
|
+
await engine.registerWorker("senior:feature", async () => ({}));
|
|
61
|
+
await engine.registerWorker("pr.readiness-probe", async () => ({ ready: true, mergedSha: "deadbeefcafe" }));
|
|
62
|
+
await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => ({}));
|
|
63
|
+
|
|
64
|
+
const run = await runDeliveryGraph(engine, MATRIX_GRAPH);
|
|
65
|
+
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
66
|
+
const key = run.handle.processInstanceKey;
|
|
67
|
+
|
|
68
|
+
// Drive to terminal: serve jobs (drain), then complete any parked human user task, repeat. No
|
|
69
|
+
// virtual-clock advance — the happy path stalls ONLY on the human node, never on a timer.
|
|
70
|
+
const humanTasks: string[] = [];
|
|
71
|
+
let state = "?";
|
|
72
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
73
|
+
await engine.drain();
|
|
74
|
+
const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
|
|
75
|
+
assert(pi, `no process instance snapshot for ${key} — searchProcessInstances returned empty`);
|
|
76
|
+
state = pi.state ?? "?";
|
|
77
|
+
if (state === "COMPLETED" || state === "TERMINATED") break;
|
|
78
|
+
const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
|
|
79
|
+
assert(open.length > 0, `instance is ${state} with no open user task — a service node never advanced`);
|
|
80
|
+
for (const t of open) {
|
|
81
|
+
humanTasks.push(t.elementId ?? "?");
|
|
82
|
+
await engine.completeUserTask(t.userTaskKey, HUMAN_PAYLOAD);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
assertEquals(state, "COMPLETED", "the deployed delivery graph must run to a COMPLETED instance");
|
|
87
|
+
// The ONE stop on the happy path is the `publish` human node; its compiled user-task element id is
|
|
88
|
+
// `delivery-human-task__<element>`. Assert we actually surfaced (and completed) it — proof the
|
|
89
|
+
// human node's user task deployed and is completable, not just that the instance ended.
|
|
90
|
+
assertEquals(humanTasks.length, 1, `expected exactly one human user task, saw ${JSON.stringify(humanTasks)}`);
|
|
91
|
+
assert(
|
|
92
|
+
humanTasks[0].startsWith("delivery-human-task__") && !humanTasks[0].endsWith("__esc"),
|
|
93
|
+
`expected a human node task, saw ${humanTasks[0]}`,
|
|
94
|
+
);
|
|
95
|
+
} finally {
|
|
96
|
+
await engine.close();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("deploy+advance: a stalled service node escalates on its node-timeout boundary onto a human-completable task that advances the instance to COMPLETED", async () => {
|
|
101
|
+
const engine = await createWasmEngineClient();
|
|
102
|
+
try {
|
|
103
|
+
// A minimal agent → human graph. We deliberately register NO `senior:feature` worker, so the agent
|
|
104
|
+
// node stalls and MUST escalate on its `=nodeTimeout` boundary timer — the exact path a stuck node
|
|
105
|
+
// takes on a live fleet (and the one hand-verified against merlin).
|
|
106
|
+
const graph: DeliveryGraph = {
|
|
107
|
+
name: "escalation graph",
|
|
108
|
+
nodes: [
|
|
109
|
+
{ id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "do it" } },
|
|
110
|
+
{ id: "signoff", kind: "human", human: { prompt: "sign off" } },
|
|
111
|
+
],
|
|
112
|
+
edges: [{ from: "impl", to: "signoff" }],
|
|
113
|
+
};
|
|
114
|
+
// Short node timeout so the boundary fires within one virtual-clock advance; a long SLA so the
|
|
115
|
+
// human node's own escalation boundary never fires during the drive.
|
|
116
|
+
const run = await runDeliveryGraph(engine, graph, { nodeTimeout: "PT1M", escalationSlaTimeout: "PT1H" });
|
|
117
|
+
assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
|
|
118
|
+
const key = run.handle.processInstanceKey;
|
|
119
|
+
|
|
120
|
+
// The stalled agent has NOT escalated yet: no user task before the timeout.
|
|
121
|
+
await engine.drain();
|
|
122
|
+
let open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
|
|
123
|
+
assertEquals(open.length, 0, "the stalled agent must not surface a task before its node timeout");
|
|
124
|
+
|
|
125
|
+
// Fire the PT1M node-timeout boundary → the agent node escalates onto its `__esc` user task.
|
|
126
|
+
await engine.advanceTime(60_000);
|
|
127
|
+
open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
|
|
128
|
+
assertEquals(open.length, 1, "the node timeout must surface exactly one escalation user task");
|
|
129
|
+
assert(open[0].elementId?.endsWith("__esc"), `expected an __esc escalation task, saw ${open[0].elementId}`);
|
|
130
|
+
|
|
131
|
+
// Complete the escalation task → the agent node ends → the flow reaches the human node → complete
|
|
132
|
+
// that → terminal.
|
|
133
|
+
const completed: string[] = [];
|
|
134
|
+
let state = "?";
|
|
135
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
136
|
+
await engine.drain();
|
|
137
|
+
const [pi] = await engine.searchProcessInstances({ processInstanceKeys: [key] });
|
|
138
|
+
assert(pi, `no process instance snapshot for ${key} — searchProcessInstances returned empty`);
|
|
139
|
+
state = pi.state ?? "?";
|
|
140
|
+
if (state === "COMPLETED" || state === "TERMINATED") break;
|
|
141
|
+
const tasks = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
|
|
142
|
+
assert(tasks.length > 0, `instance is ${state} with no open task after escalation — a node never advanced`);
|
|
143
|
+
for (const t of tasks) {
|
|
144
|
+
completed.push(t.elementId ?? "?");
|
|
145
|
+
await engine.completeUserTask(t.userTaskKey, HUMAN_PAYLOAD);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
assertEquals(state, "COMPLETED", "completing the escalation + human task must run the graph to COMPLETED");
|
|
150
|
+
assert(
|
|
151
|
+
completed.some((id) => id.endsWith("__esc")),
|
|
152
|
+
`the escalation task must have been driven, saw ${JSON.stringify(completed)}`,
|
|
153
|
+
);
|
|
154
|
+
assert(
|
|
155
|
+
completed.some((id) => id.startsWith("delivery-human-task__") && !id.endsWith("__esc")),
|
|
156
|
+
`the downstream human task must have been driven, saw ${JSON.stringify(completed)}`,
|
|
157
|
+
);
|
|
158
|
+
} finally {
|
|
159
|
+
await engine.close();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/** Every BPMN flow-node tag that must carry a `bpmndi:BPMNShape` to be rendered by human tooling. */
|
|
164
|
+
const FLOW_NODE_TAGS = [
|
|
165
|
+
"startEvent",
|
|
166
|
+
"endEvent",
|
|
167
|
+
"task",
|
|
168
|
+
"serviceTask",
|
|
169
|
+
"userTask",
|
|
170
|
+
"subProcess",
|
|
171
|
+
"exclusiveGateway",
|
|
172
|
+
"parallelGateway",
|
|
173
|
+
"inclusiveGateway",
|
|
174
|
+
"boundaryEvent",
|
|
175
|
+
"intermediateCatchEvent",
|
|
176
|
+
"intermediateThrowEvent",
|
|
177
|
+
"callActivity",
|
|
178
|
+
].join("|");
|
|
179
|
+
|
|
180
|
+
/** Extract every `id="…"` for the given opening-tag alternation from the BPMN source. */
|
|
181
|
+
function idsForTags(bpmn: string, tagAlternation: string): string[] {
|
|
182
|
+
const re = new RegExp(`<bpmn:(?:${tagAlternation})\\b[^>]*\\bid="([^"]+)"`, "g");
|
|
183
|
+
return [...bpmn.matchAll(re)].map((m) => m[1]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
test("di coverage: every compiled flow node carries a BPMNShape and every sequence flow a BPMNEdge", async () => {
|
|
187
|
+
const r = await compileDeliveryGraph(MATRIX_GRAPH);
|
|
188
|
+
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
189
|
+
const bpmn = r.bpmn;
|
|
190
|
+
|
|
191
|
+
const flowNodeIds = idsForTags(bpmn, FLOW_NODE_TAGS);
|
|
192
|
+
assert(flowNodeIds.length > 0, "expected the compiled graph to contain flow nodes");
|
|
193
|
+
const shapeless = flowNodeIds.filter(
|
|
194
|
+
(id) => !new RegExp(`<bpmndi:BPMNShape[^>]*bpmnElement="${escapeRe(id)}"`).test(bpmn),
|
|
195
|
+
);
|
|
196
|
+
assertEquals(shapeless, [], `every flow node must have a BPMNShape; missing: ${JSON.stringify(shapeless)}`);
|
|
197
|
+
|
|
198
|
+
const sequenceFlowIds = idsForTags(bpmn, "sequenceFlow");
|
|
199
|
+
assert(sequenceFlowIds.length > 0, "expected the compiled graph to contain sequence flows");
|
|
200
|
+
const edgeless = sequenceFlowIds.filter(
|
|
201
|
+
(id) => !new RegExp(`<bpmndi:BPMNEdge[^>]*bpmnElement="${escapeRe(id)}"`).test(bpmn),
|
|
202
|
+
);
|
|
203
|
+
assertEquals(edgeless, [], `every sequence flow must have a BPMNEdge; missing: ${JSON.stringify(edgeless)}`);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/** Escape a BPMN element id for embedding in a RegExp (ids can contain `.` from fact-qualified names). */
|
|
207
|
+
function escapeRe(s: string): string {
|
|
208
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
209
|
+
}
|
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
// datasource on a terminated instance — bypassing the gateway and freezing the display columns.
|
|
11
11
|
// 073_feature_read_model.sql retires the write-time projection: the derived columns are now a VIEW
|
|
12
12
|
// over each row's own `status`/`pr_key`/`converge`/`auto_merge`/`acknowledged_at`, so there is no
|
|
13
|
-
// stored column and no write-path for any writer to leave stale.
|
|
13
|
+
// stored column and no write-path for any writer to leave stale. 075_feature_read_model_attention_
|
|
14
|
+
// from_user_tasks.sql then moves `attention` off the drift-prone `status` variable onto ENGINE TRUTH
|
|
15
|
+
// — an OPEN `feature-blocked`/`feature-escalation` row in the `user_tasks` inbox (issue #422).
|
|
14
16
|
//
|
|
15
|
-
// This exercises the REAL SQLite view (073 applied to an in-memory DB, mirroring
|
|
17
|
+
// This exercises the REAL SQLite view (073+075 applied to an in-memory DB, mirroring
|
|
16
18
|
// app/plansReadModel.test.ts / app/mergesPerDayView.test.ts) and pins that its CASE expressions
|
|
17
|
-
// reproduce `deriveStage` / `deriveListBucket` EXACTLY over the full status matrix — the
|
|
18
|
-
// helpers the acknowledge operations guard on — plus
|
|
19
|
-
// bypass
|
|
19
|
+
// reproduce `deriveStage` / `deriveListBucket` EXACTLY over the full status × open-task matrix — the
|
|
20
|
+
// SAME pure helpers the acknowledge operations guard on — plus RED/GREEN guards reproducing the
|
|
21
|
+
// reconciler bypass (a RAW-datasource `status` write must leave the projection correct) and the #422
|
|
22
|
+
// answered-escalation drift (a sticky `status='escalated'` with no open task must show no ⚠).
|
|
20
23
|
import { readFileSync } from "node:fs";
|
|
21
24
|
import { DatabaseSync } from "node:sqlite";
|
|
22
25
|
import { test } from "node:test";
|
|
@@ -40,10 +43,27 @@ function viewDb(): DatabaseSync {
|
|
|
40
43
|
outcome TEXT, delivery_label TEXT, acknowledged_at TEXT, created_at TEXT, updated_at TEXT,
|
|
41
44
|
stage TEXT, stage_state TEXT, stage_skipped TEXT, attention TEXT, list_bucket TEXT);`,
|
|
42
45
|
);
|
|
46
|
+
// The `user_tasks` inbox (034_user_tasks_inbox.sql) — the engine-truth source the 075 VIEW derives
|
|
47
|
+
// `attention` from (a row IFF an escalation user task is OPEN). Minimal shape: the three columns the
|
|
48
|
+
// correlated EXISTS lookups read, plus its PK.
|
|
49
|
+
db.exec(
|
|
50
|
+
`CREATE TABLE user_tasks (
|
|
51
|
+
user_task_key TEXT PRIMARY KEY, element_id TEXT NOT NULL, subject_type TEXT NOT NULL,
|
|
52
|
+
subject_key TEXT NOT NULL);`,
|
|
53
|
+
);
|
|
43
54
|
db.exec(MIG("073_feature_read_model.sql"));
|
|
55
|
+
db.exec(MIG("075_feature_read_model_attention_from_user_tasks.sql"));
|
|
44
56
|
return db;
|
|
45
57
|
}
|
|
46
58
|
|
|
59
|
+
// Simulate `pollUserTasks` opening one native user task for a feature run: the presence of this row is
|
|
60
|
+
// the engine truth the VIEW's `attention` derives from (its deletion = the task answered/closed).
|
|
61
|
+
function openUserTask(db: DatabaseSync, feature_key: string, element_id: "feature-escalation" | "feature-blocked"): void {
|
|
62
|
+
db.prepare(
|
|
63
|
+
"INSERT INTO user_tasks (user_task_key, element_id, subject_type, subject_key) VALUES (?, ?, 'feature', ?)",
|
|
64
|
+
).run(`${feature_key}:${element_id}`, element_id, feature_key);
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
interface SampleRun {
|
|
48
68
|
status: string;
|
|
49
69
|
pr_key?: string | null;
|
|
@@ -94,34 +114,51 @@ function projection(db: DatabaseSync, feature_key: string): Record<string, unkno
|
|
|
94
114
|
return { ...r };
|
|
95
115
|
}
|
|
96
116
|
|
|
97
|
-
test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key combination", () => {
|
|
117
|
+
test("feature_read_model derives stage/stage_state/stage_skipped/attention EXACTLY like deriveStage, over every status × converge/auto_merge/pr_key × open-task combination", () => {
|
|
98
118
|
const db = viewDb();
|
|
99
|
-
const cases: Array<{ key: string; run: SampleRun }> = [];
|
|
119
|
+
const cases: Array<{ key: string; run: SampleRun; hasOpenBlockedTask: boolean; hasOpenEscalationTask: boolean }> = [];
|
|
100
120
|
let i = 0;
|
|
101
121
|
for (const status of FEATURE_RUN_STATUSES) {
|
|
102
122
|
for (const converge of [0, 1]) {
|
|
103
123
|
for (const auto_merge of [0, 1]) {
|
|
104
124
|
for (const pr_key of [null, `o/r#pr${i}`]) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
125
|
+
// The open-task dimension only matters for the two human-wait statuses (escalated/
|
|
126
|
+
// awaiting_operator), whose derivation reads open tasks — for them exercise BOTH
|
|
127
|
+
// task-present and task-absent (the #422 drift case = the task already gone). Every other
|
|
128
|
+
// status ignores open tasks (`el` is null, so no task is ever created), so iterating the
|
|
129
|
+
// dimension there would only duplicate identical cases; iterate [false] alone.
|
|
130
|
+
const el = status === "escalated" ? "feature-escalation" : status === "awaiting_operator" ? "feature-blocked" : null;
|
|
131
|
+
for (const openTask of el !== null ? [false, true] : [false]) {
|
|
132
|
+
const key = `o/r#${i++}`;
|
|
133
|
+
const hasTask = openTask && el !== null;
|
|
134
|
+
cases.push({
|
|
135
|
+
key,
|
|
136
|
+
run: { status, converge, auto_merge, pr_key },
|
|
137
|
+
hasOpenBlockedTask: hasTask && el === "feature-blocked",
|
|
138
|
+
hasOpenEscalationTask: hasTask && el === "feature-escalation",
|
|
139
|
+
});
|
|
140
|
+
addRun(db, key, { status, converge, auto_merge, pr_key });
|
|
141
|
+
if (hasTask && el !== null) openUserTask(db, key, el);
|
|
142
|
+
}
|
|
108
143
|
}
|
|
109
144
|
}
|
|
110
145
|
}
|
|
111
146
|
}
|
|
112
147
|
|
|
113
|
-
for (const { key, run } of cases) {
|
|
148
|
+
for (const { key, run, hasOpenBlockedTask, hasOpenEscalationTask } of cases) {
|
|
114
149
|
const oracle = deriveStage({
|
|
115
150
|
status: run.status,
|
|
116
151
|
pr_key: run.pr_key ?? null,
|
|
117
152
|
converge: run.converge ?? 0,
|
|
118
153
|
auto_merge: run.auto_merge ?? 0,
|
|
154
|
+
hasOpenBlockedTask,
|
|
155
|
+
hasOpenEscalationTask,
|
|
119
156
|
});
|
|
120
157
|
const row = projection(db, key);
|
|
121
158
|
assertEquals(row.stage, oracle.stage, `${key} (status=${run.status}): stage`);
|
|
122
159
|
assertEquals(row.stage_state, oracle.state, `${key} (status=${run.status}): stage_state`);
|
|
123
160
|
assertEquals(row.stage_skipped, oracle.skipped, `${key} (status=${run.status}): stage_skipped`);
|
|
124
|
-
assertEquals(row.attention, oracle.attention, `${key} (status=${run.status}): attention`);
|
|
161
|
+
assertEquals(row.attention, oracle.attention, `${key} (status=${run.status}, openBlocked=${hasOpenBlockedTask}, openEsc=${hasOpenEscalationTask}): attention`);
|
|
125
162
|
}
|
|
126
163
|
});
|
|
127
164
|
|
|
@@ -162,6 +199,37 @@ test("feature_read_model IGNORES any stale STORED projection columns — it read
|
|
|
162
199
|
});
|
|
163
200
|
});
|
|
164
201
|
|
|
202
|
+
test("RED/GREEN GUARD #422: an ANSWERED escalation (status sticky 'escalated', no open user task) shows NO ⚠; the badge tracks the OPEN task, not status", () => {
|
|
203
|
+
// The `feature` process answer-loop returns the token to `implement-task` without resetting the
|
|
204
|
+
// `status` variable, so a run whose escalation was already answered still reads `status="escalated"`
|
|
205
|
+
// until its next agent job completes (observed live on merlin: feature instance 31779). The OLD VIEW
|
|
206
|
+
// derived `attention` from that value and rendered a stale ⚠ on Overview. The badge now derives from
|
|
207
|
+
// engine truth — the presence of an OPEN `feature-escalation` user task (`pollUserTasks` deletes the
|
|
208
|
+
// row the moment it is answered) — so it clears immediately regardless of the stale status.
|
|
209
|
+
const db = viewDb();
|
|
210
|
+
|
|
211
|
+
// Answered escalation: status STILL 'escalated' (stale) + a stored ⚠ that lied, but NO open task.
|
|
212
|
+
addRun(db, "o/r#answered", { status: "escalated", stored: { attention: "⚠", stage: "Implementing" } });
|
|
213
|
+
const answered = projection(db, "o/r#answered");
|
|
214
|
+
assertEquals(answered.attention, null, "the stale ⚠ is gone once the escalation task is closed");
|
|
215
|
+
assertEquals(answered.stage, "Implementing", "the run is back implementing (stage unchanged, correct either way)");
|
|
216
|
+
|
|
217
|
+
// Genuinely-parked escalation: the SAME status, but its `feature-escalation` user task is OPEN → ⚠.
|
|
218
|
+
addRun(db, "o/r#parked", { status: "escalated" });
|
|
219
|
+
openUserTask(db, "o/r#parked", "feature-escalation");
|
|
220
|
+
assertEquals(projection(db, "o/r#parked").attention, "⚠", "an OPEN escalation task shows ⚠");
|
|
221
|
+
|
|
222
|
+
// Answering it (deleting the row — what `pollUserTasks` does) clears the badge with status untouched.
|
|
223
|
+
db.prepare("DELETE FROM user_tasks WHERE subject_key = ?").run("o/r#parked");
|
|
224
|
+
assertEquals(projection(db, "o/r#parked").attention, null, "closing the task clears ⚠ though status is still 'escalated'");
|
|
225
|
+
|
|
226
|
+
// Symmetric operator/blocked wait: 'blocked' glyph IFF an open `feature-blocked` task exists.
|
|
227
|
+
addRun(db, "o/r#stuck", { status: "awaiting_operator", stored: { attention: "blocked" } });
|
|
228
|
+
assertEquals(projection(db, "o/r#stuck").attention, null, "no open feature-blocked task → no glyph despite awaiting_operator");
|
|
229
|
+
openUserTask(db, "o/r#stuck", "feature-blocked");
|
|
230
|
+
assertEquals(projection(db, "o/r#stuck").attention, "blocked", "an OPEN feature-blocked task shows the blocked glyph");
|
|
231
|
+
});
|
|
232
|
+
|
|
165
233
|
test("RED/GREEN GUARD: a RAW-datasource feature_runs.status write (the instanceTracking reconciler bypass) leaves the projection CORRECT (stage=Done, terminal stage_state, attention=null, Dismiss renderable, still Active)", () => {
|
|
166
234
|
// Reproduce the framework `instanceTracking` reconciler class of bug: on a terminated (cancelled)
|
|
167
235
|
// process instance it writes `{status:"abandoned"}` to `feature_runs` through the RAW datasource,
|
package/app/github.test.ts
CHANGED
|
@@ -663,6 +663,40 @@ for (const c of TOKEN_MODE) {
|
|
|
663
663
|
});
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
+
// ── Draft PRs are never landable (issue #454) ────────────────────────────────────────────────────
|
|
667
|
+
//
|
|
668
|
+
// A draft PR with green checks reports `mergeStateStatus: CLEAN`, so the old `mergeStateStatus`
|
|
669
|
+
// switch classified it `"ready"` → the poller attempted the merge → GitHub refused it (draft) → a
|
|
670
|
+
// misleading "the merge attempt did not land (result: blocked), investigate why GitHub refused"
|
|
671
|
+
// escalation. A draft is *categorically* not landable regardless of checks, and the remedy is
|
|
672
|
+
// always the same (mark it ready), so `isDraft` outranks every other signal and yields a
|
|
673
|
+
// first-class `"draft"` verdict the model can escalate with an actionable message.
|
|
674
|
+
test("classifyMergeability: a draft PR is never ready — even with green checks (issue #454)", () => {
|
|
675
|
+
// CLEAN + green rollup would be `"ready"` if `isDraft` were ignored.
|
|
676
|
+
assertEquals(classifyMergeability(mergePrState({ mergeStateStatus: "CLEAN", isDraft: true })), "draft");
|
|
677
|
+
assertEquals(
|
|
678
|
+
classifyMergeability(
|
|
679
|
+
mergePrState({ mergeStateStatus: "CLEAN", isDraft: true, rollup: [{ name: "build", conclusion: "SUCCESS" }] }),
|
|
680
|
+
protocolWith({ requiredChecks: reqChecks("build") }),
|
|
681
|
+
),
|
|
682
|
+
"draft",
|
|
683
|
+
);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("classifyMergeability: draft outranks every mergeStateStatus (issue #454)", () => {
|
|
687
|
+
for (const status of ["CLEAN", "HAS_HOOKS", "UNSTABLE", "BEHIND", "DIRTY", "BLOCKED", "UNKNOWN", ""]) {
|
|
688
|
+
assertEquals(
|
|
689
|
+
classifyMergeability(prState({ mergeStateStatus: status, isDraft: true })),
|
|
690
|
+
"draft",
|
|
691
|
+
`draft should outrank ${status || "''"}`,
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("classifyMergeability: a non-draft PR is unaffected (regression guard, issue #454)", () => {
|
|
697
|
+
assertEquals(classifyMergeability(prState({ mergeStateStatus: "CLEAN", isDraft: false })), "ready");
|
|
698
|
+
});
|
|
699
|
+
|
|
666
700
|
// `checkConclusions` must report a terminal conclusion per check but normalise a STILL-IN-FLIGHT run
|
|
667
701
|
// to "" for BOTH rollup shapes — a CheckRun whose `status` is not COMPLETED, and a legacy
|
|
668
702
|
// StatusContext whose `state` is PENDING/EXPECTED — so a caller never mistakes a pending
|
package/app/github.ts
CHANGED
|
@@ -954,9 +954,11 @@ export async function baseBranchLanded(
|
|
|
954
954
|
}
|
|
955
955
|
|
|
956
956
|
/** A settled landability verdict, or `waiting` when GitHub hasn't determined it yet (or is
|
|
957
|
-
* still running checks / awaiting review).
|
|
958
|
-
*
|
|
959
|
-
|
|
957
|
+
* still running checks / awaiting review). `draft` is a settled *not-landable* verdict: a draft PR
|
|
958
|
+
* can never be merged (GitHub refuses it outright), regardless of its checks — so it outranks every
|
|
959
|
+
* other signal and carries its own actionable remedy (mark it ready). The poller only advances the
|
|
960
|
+
* process on a settled verdict; `waiting` means re-poll later. */
|
|
961
|
+
export type Mergeability = "ready" | "waiting" | "conflict" | "blocked" | "draft";
|
|
960
962
|
|
|
961
963
|
/** Intersect a repo's declared `requiredChecks` against the head's actual per-check conclusions —
|
|
962
964
|
* an INDEPENDENT backstop that runs BEFORE the `mergeStateStatus` switch, so nwf never merges a red
|
|
@@ -1009,6 +1011,13 @@ function requiredChecksVerdict(s: PrState, protocol?: MergeProtocol): "blocked"
|
|
|
1009
1011
|
}
|
|
1010
1012
|
|
|
1011
1013
|
export function classifyMergeability(s: PrState, protocol?: MergeProtocol): Mergeability {
|
|
1014
|
+
// A draft PR is NEVER landable — GitHub refuses the merge outright, whatever its checks say — so
|
|
1015
|
+
// draft outranks every other signal (issue #454). Surface it as a first-class verdict rather than
|
|
1016
|
+
// letting a green draft read as `CLEAN` → `"ready"` → an attempted merge that GitHub blocks with an
|
|
1017
|
+
// opaque "the merge did not land (blocked)" escalation. The remedy is always the same: mark it
|
|
1018
|
+
// ready. The poller self-heals this (mark-ready) when the repo's protocol wants a fresh head run,
|
|
1019
|
+
// else escalates with an actionable message.
|
|
1020
|
+
if (s.isDraft) return "draft";
|
|
1012
1021
|
// Protocol-aware backstop FIRST (issue #392): honour the repo's declared `requiredChecks`
|
|
1013
1022
|
// against the actual head rollup, so an `UNSTABLE` PR with a red DECLARED-required
|
|
1014
1023
|
// check is no longer blindly `ready`. This never weakens GitHub branch protection (the switch
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Regression guard for the frugal-CI self-heal escalation fall-through (PR #455 review).
|
|
2
|
+
//
|
|
3
|
+
// `maybeEnsureFreshHeadRun` gates the `"draft"` merge branch: the poller `continue`s (re-polls)
|
|
4
|
+
// when it returns `true`, and falls through to the actionable "mark it ready" escalation when it
|
|
5
|
+
// returns `false`. The bug: it returned `true` whenever an action was *selected*, even if
|
|
6
|
+
// `ensureFreshHeadRun` FAILED (`ok === false`, e.g. missing permission / repo policy). A draft PR
|
|
7
|
+
// whose self-heal can never succeed would then `continue` forever, re-attempting `gh pr ready`/
|
|
8
|
+
// reopen every pass and never escalating to a human. The fix returns `ok`, so a persistently
|
|
9
|
+
// failing self-heal falls through to escalation. These tests pin: return value tracks `ok`;
|
|
10
|
+
// persistence (`fresh_head_run_head`) happens only on success; and no action → no attempt.
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { assert, assertEquals } from "#test-assert";
|
|
13
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
14
|
+
import type { PrState } from "./github.ts";
|
|
15
|
+
import { parseMergeProtocol } from "./mergeProtocol.ts";
|
|
16
|
+
import { maybeEnsureFreshHeadRun, type PullRequest } from "./service.ts";
|
|
17
|
+
|
|
18
|
+
const READY = parseMergeProtocol({ freshHeadRun: "ready", land: { method: "gh-merge" } });
|
|
19
|
+
|
|
20
|
+
function memData(seed: PullRequest): { data: DataLayer; row: () => PullRequest } {
|
|
21
|
+
const rows: PullRequest[] = [{ ...seed }];
|
|
22
|
+
const table = {
|
|
23
|
+
async update(id: string, patch: Partial<PullRequest>) {
|
|
24
|
+
const r = rows.find((x) => x.pr_key === id);
|
|
25
|
+
if (r) Object.assign(r, patch);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const data = { table: () => table } as unknown as DataLayer;
|
|
29
|
+
return { data, row: () => rows[0] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function draftState(overrides: Partial<PrState> = {}): PrState {
|
|
33
|
+
return {
|
|
34
|
+
merged: false,
|
|
35
|
+
state: "open",
|
|
36
|
+
mergeStateStatus: "DRAFT",
|
|
37
|
+
failingChecks: 0,
|
|
38
|
+
failingCheckNames: [],
|
|
39
|
+
totalChecks: 0, // no head run yet → frugal-CI stuck state
|
|
40
|
+
presentCheckNames: [],
|
|
41
|
+
pendingCheckNames: [],
|
|
42
|
+
checkConclusions: {},
|
|
43
|
+
isDraft: true,
|
|
44
|
+
headRefOid: "h1",
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function prRow(overrides: Partial<PullRequest> = {}): PullRequest {
|
|
50
|
+
return {
|
|
51
|
+
pr_key: "o/r#1",
|
|
52
|
+
repo: "o/r",
|
|
53
|
+
number: 1,
|
|
54
|
+
url: "https://github.com/o/r/pull/1",
|
|
55
|
+
title: "t",
|
|
56
|
+
status: "waiting_merge",
|
|
57
|
+
current_round: 0,
|
|
58
|
+
process_key: null,
|
|
59
|
+
waiting_since: null,
|
|
60
|
+
last_review_id: null,
|
|
61
|
+
outcome: null,
|
|
62
|
+
created_at: "t",
|
|
63
|
+
updated_at: "t",
|
|
64
|
+
converged_at: null,
|
|
65
|
+
merged_at: null,
|
|
66
|
+
active_worker: null,
|
|
67
|
+
lease_until: null,
|
|
68
|
+
last_nudge_at: null,
|
|
69
|
+
fresh_head_run_head: null,
|
|
70
|
+
abandon_token: null,
|
|
71
|
+
incident_key: null,
|
|
72
|
+
incident_message: null,
|
|
73
|
+
root_request_key: null,
|
|
74
|
+
...overrides,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test("maybeEnsureFreshHeadRun: successful self-heal returns true and records the head", async () => {
|
|
79
|
+
const { data, row } = memData(prRow());
|
|
80
|
+
const ret = await maybeEnsureFreshHeadRun(
|
|
81
|
+
data,
|
|
82
|
+
"o/r",
|
|
83
|
+
1,
|
|
84
|
+
"o/r#1",
|
|
85
|
+
READY,
|
|
86
|
+
"draft",
|
|
87
|
+
draftState(),
|
|
88
|
+
prRow(),
|
|
89
|
+
async () => true,
|
|
90
|
+
);
|
|
91
|
+
assertEquals(ret, true); // caller re-polls
|
|
92
|
+
assertEquals(row().fresh_head_run_head, "h1"); // one-shot de-dupe recorded
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("maybeEnsureFreshHeadRun: FAILED self-heal returns false so the draft branch escalates", async () => {
|
|
96
|
+
const { data, row } = memData(prRow());
|
|
97
|
+
const ret = await maybeEnsureFreshHeadRun(
|
|
98
|
+
data,
|
|
99
|
+
"o/r",
|
|
100
|
+
1,
|
|
101
|
+
"o/r#1",
|
|
102
|
+
READY,
|
|
103
|
+
"draft",
|
|
104
|
+
draftState(),
|
|
105
|
+
prRow(),
|
|
106
|
+
async () => false, // ensureFreshHeadRun could not perform the action (e.g. permission)
|
|
107
|
+
);
|
|
108
|
+
assertEquals(ret, false); // caller falls through to the actionable escalation
|
|
109
|
+
assertEquals(row().fresh_head_run_head, null); // not recorded → not a wasted one-shot
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("maybeEnsureFreshHeadRun: a throwing self-heal is caught and returns false", async () => {
|
|
113
|
+
const { data, row } = memData(prRow());
|
|
114
|
+
const ret = await maybeEnsureFreshHeadRun(
|
|
115
|
+
data,
|
|
116
|
+
"o/r",
|
|
117
|
+
1,
|
|
118
|
+
"o/r#1",
|
|
119
|
+
READY,
|
|
120
|
+
"draft",
|
|
121
|
+
draftState(),
|
|
122
|
+
prRow(),
|
|
123
|
+
async () => {
|
|
124
|
+
throw new Error("boom");
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
assertEquals(ret, false);
|
|
128
|
+
assertEquals(row().fresh_head_run_head, null);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("maybeEnsureFreshHeadRun: no applicable action never attempts the self-heal and returns false", async () => {
|
|
132
|
+
const { data } = memData(prRow());
|
|
133
|
+
let attempted = false;
|
|
134
|
+
const ret = await maybeEnsureFreshHeadRun(
|
|
135
|
+
data,
|
|
136
|
+
"o/r",
|
|
137
|
+
1,
|
|
138
|
+
"o/r#1",
|
|
139
|
+
READY,
|
|
140
|
+
"draft",
|
|
141
|
+
draftState({ totalChecks: 1 }), // required run already present → no action selected
|
|
142
|
+
prRow(),
|
|
143
|
+
async () => {
|
|
144
|
+
attempted = true;
|
|
145
|
+
return true;
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
assertEquals(ret, false);
|
|
149
|
+
assert(!attempted, "must not attempt a self-heal when no action applies");
|
|
150
|
+
});
|
|
@@ -155,3 +155,36 @@ test("regression: a question-less escalation can no longer park a dead wait-merg
|
|
|
155
155
|
"merge-esc-attempt must NOT flow directly into wait-merge-answer (the #329 dead-wait defect)",
|
|
156
156
|
);
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
// ── Draft PR escalation (issue #454) ─────────────────────────────────────────────────────────────
|
|
160
|
+
//
|
|
161
|
+
// A draft PR is never landable — `classifyMergeability` now yields a first-class `"draft"` verdict
|
|
162
|
+
// (app/github.ts) that the poller (app/service.ts) publishes as `mergeState = "draft"`. It routes
|
|
163
|
+
// through `gw-mergeable`'s default (`f_m_mBlocked → merge-esc-conflict`), so `merge-esc-conflict`'s
|
|
164
|
+
// question must recognise `draft` and give the ACTIONABLE remedy (mark it ready) instead of the
|
|
165
|
+
// generic "resolve the conflict or failing required check" text (which is the wrong remedy for a
|
|
166
|
+
// draft), and — before this fix — instead of `merge-esc-attempt`'s misleading "the merge attempt did
|
|
167
|
+
// not land (blocked), investigate why GitHub refused the merge".
|
|
168
|
+
const escConflictRaw = flat.match(/<bpmn:serviceTask\b[^>]*\bid="merge-esc-conflict"[\s\S]*?<\/bpmn:serviceTask>/);
|
|
169
|
+
const escConflict = escConflictRaw ? escConflictRaw[0].replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&") : null;
|
|
170
|
+
|
|
171
|
+
test("merge-esc-conflict gives a draft PR an actionable 'mark it ready' question (issue #454)", () => {
|
|
172
|
+
assert(escConflict, "merge-esc-conflict service task must exist");
|
|
173
|
+
const el = escConflict!;
|
|
174
|
+
// Branches on the draft verdict…
|
|
175
|
+
assertStringIncludes(el, 'mergeState = "draft"', "merge-esc-conflict must branch on the draft verdict");
|
|
176
|
+
// …with the actionable remedy (mark it ready), not the conflict/failing-check remedy.
|
|
177
|
+
assertStringIncludes(el, "draft and can't be merged", "the draft question must state the PR is in draft");
|
|
178
|
+
assertStringIncludes(el, "gh pr ready", "the draft question must tell the human to mark it ready");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("merge-esc-conflict keeps the non-draft not-mergeable branch intact (regression guard, issue #454)", () => {
|
|
182
|
+
assert(escConflict, "merge-esc-conflict service task must exist");
|
|
183
|
+
const el = escConflict!;
|
|
184
|
+
// The original conflict/failing-check message must still be reachable for non-draft states.
|
|
185
|
+
assertStringIncludes(el, "This PR is not mergeable (state:", "the non-draft not-mergeable message must remain");
|
|
186
|
+
// The draft branch must precede the generic message so it isn't shadowed.
|
|
187
|
+
const draftIdx = el.indexOf('mergeState = "draft"');
|
|
188
|
+
const genericIdx = el.indexOf("This PR is not mergeable (state:");
|
|
189
|
+
assert(draftIdx !== -1 && genericIdx !== -1 && draftIdx < genericIdx, "the draft branch must be evaluated before the generic not-mergeable message");
|
|
190
|
+
});
|
|
@@ -114,6 +114,31 @@ test("freshHeadRunAction: fires in the frugal-CI stuck state (no run + waiting)"
|
|
|
114
114
|
assertEquals(freshHeadRunAction(NANO, "waiting", 0, true), "ready");
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
test("freshHeadRunAction: a 'draft' verdict self-heals like waiting (issue #454)", () => {
|
|
118
|
+
// The merge poller now classifies a draft PR as the distinct "draft" verdict (not "waiting"),
|
|
119
|
+
// so guard that the fresh-head-run self-heal accepts it directly. A draft is always isDraft=true.
|
|
120
|
+
assertEquals(freshHeadRunAction(NANO, "draft", 0, true), "ready"); // no run yet → mark ready (un-drafts + runs)
|
|
121
|
+
assertEquals(freshHeadRunAction(NANO, "draft", 1, true), null); // required run already present → wait
|
|
122
|
+
assertEquals(freshHeadRunAction(NANO, "draft", -1, true), null); // token mode (unknown) → conservative
|
|
123
|
+
// fires once per landing-attempt head, then not again until the head changes (post-rebase)
|
|
124
|
+
assertEquals(
|
|
125
|
+
freshHeadRunAction(NANO, "draft", 0, true, { headRefOid: "h1", lastActionHeadRefOid: null }),
|
|
126
|
+
"ready",
|
|
127
|
+
);
|
|
128
|
+
assertEquals(
|
|
129
|
+
freshHeadRunAction(NANO, "draft", 0, true, { headRefOid: "h1", lastActionHeadRefOid: "h1" }),
|
|
130
|
+
null,
|
|
131
|
+
);
|
|
132
|
+
// reopen-only protocol: a draft yields NULL, not "reopen". Reopening (close+reopen) does NOT
|
|
133
|
+
// un-draft a PR, so a "reopen" self-heal can never resolve the draft-merge failure — it only
|
|
134
|
+
// emits noisy close/reopen events and delays the actionable escalation by a round (issue #454).
|
|
135
|
+
// With no mark-ready capability there is no valid draft self-heal, so escalate immediately.
|
|
136
|
+
const reopenOnly = parseMergeProtocol({ freshHeadRun: "reopen", land: { method: "gh-merge" } });
|
|
137
|
+
assertEquals(freshHeadRunAction(reopenOnly, "draft", 0, true), null);
|
|
138
|
+
// none protocol → no self-heal for a draft either
|
|
139
|
+
assertEquals(freshHeadRunAction(DEFAULT_MERGE_PROTOCOL, "draft", 0, true), null);
|
|
140
|
+
});
|
|
141
|
+
|
|
117
142
|
test("freshHeadRunAction: fires once per landing-attempt head, then re-fires after rebase", () => {
|
|
118
143
|
assertEquals(
|
|
119
144
|
freshHeadRunAction(NANO, "waiting", 0, false, { headRefOid: "h1", lastActionHeadRefOid: null }),
|
package/app/mergeProtocol.ts
CHANGED
|
@@ -241,23 +241,29 @@ export function headRunPresenceCount(
|
|
|
241
241
|
* same head already got its nudge, this returns `null`, so the poller never re-triggers inside one
|
|
242
242
|
* landing attempt. A rebase changes `headRefOid`, so the decision is re-derived and can fire again
|
|
243
243
|
* for the fresh post-rebase head. A genuinely-failing check (`blocked`) is left to the fix-ci arm,
|
|
244
|
-
* a conflict (`conflict`) to the rebase arm (#42).
|
|
244
|
+
* a conflict (`conflict`) to the rebase arm (#42). A `draft` verdict (issue #454) is treated like
|
|
245
|
+
* `waiting` here so the `freshHeadRun: "ready"`/`"ready-or-reopen"` self-heal still marks a draft
|
|
246
|
+
* ready (which both un-drafts it and produces the required run). But a `"reopen"` action is
|
|
247
|
+
* **never** returned for a draft: reopening (close+reopen) does not un-draft a PR, so it can never
|
|
248
|
+
* resolve the draft-merge failure — it only emits noisy close/reopen events and delays the poller's
|
|
249
|
+
* actionable escalation. When no mark-ready self-heal applies to a draft, this returns `null` and the
|
|
250
|
+
* poller escalates immediately. */
|
|
245
251
|
export function freshHeadRunAction(
|
|
246
252
|
protocol: MergeProtocol,
|
|
247
|
-
verdict: "ready" | "waiting" | "conflict" | "blocked",
|
|
253
|
+
verdict: "ready" | "waiting" | "conflict" | "blocked" | "draft",
|
|
248
254
|
headRunCount: number,
|
|
249
255
|
isDraft: boolean,
|
|
250
256
|
attempt: FreshHeadRunAttempt = {},
|
|
251
257
|
): "ready" | "reopen" | null {
|
|
252
258
|
if (protocol.freshHeadRun === "none") return null;
|
|
253
|
-
if (verdict !== "waiting") return null; // ready = go land; blocked/conflict = other arms
|
|
259
|
+
if (verdict !== "waiting" && verdict !== "draft") return null; // ready = go land; blocked/conflict = other arms
|
|
254
260
|
if (headRunCount !== 0) return null; // required run already present (or unknown in token mode) → wait
|
|
255
261
|
if (attempt.headRefOid && attempt.headRefOid === attempt.lastActionHeadRefOid) return null;
|
|
256
262
|
switch (protocol.freshHeadRun) {
|
|
257
263
|
case "ready":
|
|
258
264
|
return isDraft ? "ready" : null;
|
|
259
265
|
case "reopen":
|
|
260
|
-
return "reopen";
|
|
266
|
+
return isDraft ? null : "reopen";
|
|
261
267
|
case "ready-or-reopen":
|
|
262
268
|
return isDraft ? "ready" : "reopen";
|
|
263
269
|
default:
|
|
@@ -584,3 +584,30 @@ test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run st
|
|
|
584
584
|
assertEquals(byKey["35002"].subject_type, "delivery");
|
|
585
585
|
assertEquals(byKey["35002"].subject_key, "dg-9"); // instance fallback — non-blank so it renders
|
|
586
586
|
});
|
|
587
|
+
|
|
588
|
+
test("pollUserTasks (typed-seam fallback): projects an inlined delivery-human task on a RUNNING run, bucketed `delivery` (issue #442)", async () => {
|
|
589
|
+
// The reduced-capability host (no raw-REST surface) discovers open tasks by scanning each active
|
|
590
|
+
// subject's instance through the typed `openUserTasks` seam. A delivery-graph `human` node parks on its
|
|
591
|
+
// RUNNING run's instance, so that instance MUST be scanned here too — else the inlined
|
|
592
|
+
// `delivery-human-task__<node>` gate is dropped on this path even though its leak guard would accept it.
|
|
593
|
+
// Guards the OTHER discovery path the engine-first sweep tests don't reach.
|
|
594
|
+
const { data, stores } = memData({
|
|
595
|
+
delivery_graph_runs: [
|
|
596
|
+
{ run_key: "delivery-graph-403eb22e", process_key: "dg-1", status: "running", title: "release runbook" },
|
|
597
|
+
{ run_key: "delivery-graph-pending", process_key: null, status: "awaiting-approval", title: "not launched yet" },
|
|
598
|
+
],
|
|
599
|
+
});
|
|
600
|
+
const engine = fakeEngine({
|
|
601
|
+
"dg-1": [{ userTaskKey: "35002", elementId: "delivery-human-task__n1" }],
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
await pollUserTasks(data, engine); // no engineRest → typed-seam fallback
|
|
605
|
+
|
|
606
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
607
|
+
assertEquals(Object.keys(byKey), ["35002"]);
|
|
608
|
+
assertEquals(byKey["35002"].element_id, "delivery-human-task__n1");
|
|
609
|
+
assertEquals(byKey["35002"].kind_label, "Delivery: human step");
|
|
610
|
+
assertEquals(byKey["35002"].subject_type, "delivery");
|
|
611
|
+
assertEquals(byKey["35002"].subject_key, "delivery-graph-403eb22e");
|
|
612
|
+
assertEquals(byKey["35002"].subject_title, "release runbook");
|
|
613
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -51,7 +51,12 @@ import {
|
|
|
51
51
|
} from "./github.ts";
|
|
52
52
|
import { pollLineage } from "./lineage.ts";
|
|
53
53
|
import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
|
|
54
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
freshHeadRunAction,
|
|
56
|
+
headRunPresenceCount,
|
|
57
|
+
loadMergeProtocol,
|
|
58
|
+
type MergeProtocol,
|
|
59
|
+
} from "./mergeProtocol.ts";
|
|
55
60
|
import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
|
|
56
61
|
import {
|
|
57
62
|
capabilityGates,
|
|
@@ -178,7 +183,7 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
|
|
|
178
183
|
|
|
179
184
|
const now = () => new Date().toISOString();
|
|
180
185
|
|
|
181
|
-
interface PullRequest {
|
|
186
|
+
export interface PullRequest {
|
|
182
187
|
pr_key: string;
|
|
183
188
|
repo: string;
|
|
184
189
|
number: number;
|
|
@@ -1099,6 +1104,41 @@ async function advanceIfTerminalOutOfBand(
|
|
|
1099
1104
|
return true;
|
|
1100
1105
|
}
|
|
1101
1106
|
|
|
1107
|
+
/** Frugal-CI fresh-head-run self-heal, shared by the `"waiting"` and `"draft"` merge verdicts
|
|
1108
|
+
* (issue #454). Both verdicts feed the same {@link freshHeadRunAction} decision — when the repo's
|
|
1109
|
+
* merge protocol wants a fresh head run and this head has not been nudged yet, produce one
|
|
1110
|
+
* (mark-ready / reopen) and record the head so we fire at most once per landing attempt. Returns
|
|
1111
|
+
* `true` only when the self-heal was **actually applied** (the caller should re-poll); returns
|
|
1112
|
+
* `false` when no self-heal applies **or** the action was selected but failed (`ok === false`, e.g.
|
|
1113
|
+
* missing permission / repo policy) — so a caller that gates escalation on this (the `"draft"`
|
|
1114
|
+
* branch) falls through to the actionable escalation instead of `continue`-looping forever on a
|
|
1115
|
+
* self-heal that can never succeed. One implementation so the two verdicts can never drift (attempt
|
|
1116
|
+
* de-dupe, persistence, logging). `ensure` is injectable for tests; production uses the real
|
|
1117
|
+
* {@link ensureFreshHeadRun}. */
|
|
1118
|
+
export async function maybeEnsureFreshHeadRun(
|
|
1119
|
+
data: DataLayer,
|
|
1120
|
+
repo: string,
|
|
1121
|
+
number: number,
|
|
1122
|
+
prKey: string,
|
|
1123
|
+
protocol: MergeProtocol,
|
|
1124
|
+
verdict: "ready" | "waiting" | "conflict" | "blocked" | "draft",
|
|
1125
|
+
st: PrState,
|
|
1126
|
+
pr: PullRequest,
|
|
1127
|
+
ensure: typeof ensureFreshHeadRun = ensureFreshHeadRun,
|
|
1128
|
+
): Promise<boolean> {
|
|
1129
|
+
const action = freshHeadRunAction(protocol, verdict, headRunPresenceCount(protocol, st), st.isDraft, {
|
|
1130
|
+
headRefOid: st.headRefOid,
|
|
1131
|
+
lastActionHeadRefOid: pr.fresh_head_run_head,
|
|
1132
|
+
});
|
|
1133
|
+
if (!action) return false;
|
|
1134
|
+
const ok = await ensure(repo, number, action).catch(() => false);
|
|
1135
|
+
if (ok && st.headRefOid) {
|
|
1136
|
+
await prs(data).update(prKey, { fresh_head_run_head: st.headRefOid, updated_at: now() });
|
|
1137
|
+
}
|
|
1138
|
+
console.log(`[poller] ${verdict} -> fresh head run (${action}) ${ok ? "requested" : "skipped"} -> ${prKey}`);
|
|
1139
|
+
return ok;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1102
1142
|
/** Merge-stage poll pass (SPEC §11). Four durable waits, each keyed off the PR's `status`, are
|
|
1103
1143
|
* advanced by correlating a message — mirroring the review-ready pattern so the process owns
|
|
1104
1144
|
* the wait and this glue only signals when a GitHub condition is met:
|
|
@@ -1159,6 +1199,35 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1159
1199
|
// the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
|
|
1160
1200
|
const protocol = await loadMergeProtocol(repo, token).catch(() => null);
|
|
1161
1201
|
const verdict = classifyMergeability(st, protocol ?? undefined);
|
|
1202
|
+
if (verdict === "draft") {
|
|
1203
|
+
// A draft PR is never landable — GitHub refuses the merge outright (issue #454). Two remedies,
|
|
1204
|
+
// in order: (1) self-heal — when the repo's merge protocol has a mark-ready capability
|
|
1205
|
+
// (`freshHeadRun: "ready"`/`"ready-or-reopen"`), mark the PR ready ourselves (the frugal-CI
|
|
1206
|
+
// path), which both un-drafts it and produces the required run, then re-poll; a `"reopen"`-only
|
|
1207
|
+
// protocol has NO mark-ready capability, so `freshHeadRunAction` returns null for a draft (a
|
|
1208
|
+
// reopen can't un-draft) and this falls straight through to (2). (2) otherwise — no self-heal
|
|
1209
|
+
// applies, OR the self-heal was attempted but could not be performed (e.g. missing permission /
|
|
1210
|
+
// repo policy) — escalate with an ACTIONABLE "mark it ready" message, instead of
|
|
1211
|
+
// `continue`-looping forever on a self-heal that can never succeed or surfacing GitHub's opaque
|
|
1212
|
+
// "blocked" refusal.
|
|
1213
|
+
if (protocol) {
|
|
1214
|
+
if (await maybeEnsureFreshHeadRun(data, repo, number, prKey, protocol, verdict, st, pr)) {
|
|
1215
|
+
continue; // re-poll: the mark-ready both un-drafts the PR and produces the required run
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
// No applicable (or successful) protocol-driven self-heal → escalate so a human marks it ready.
|
|
1219
|
+
await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
|
|
1220
|
+
name: "merge-ready",
|
|
1221
|
+
correlationKey: prKey,
|
|
1222
|
+
variables: {
|
|
1223
|
+
mergeState: verdict, // "draft" → gw-mergeable default → merge-esc-conflict (draft-aware FEEL)
|
|
1224
|
+
failingChecks: st.failingChecks,
|
|
1225
|
+
failingChecksList: st.failingCheckNames.join("\n"),
|
|
1226
|
+
},
|
|
1227
|
+
});
|
|
1228
|
+
console.log(`[poller] draft (no self-heal) -> escalate mark-ready -> ${prKey}`);
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1162
1231
|
if (verdict === "waiting") {
|
|
1163
1232
|
// Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
|
|
1164
1233
|
// head run and the PR has NO required head run yet, review has converged but the last push
|
|
@@ -1170,17 +1239,7 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
|
|
|
1170
1239
|
// `headRefOid`, so downstream merge-train PRs get a new nudge after every post-rebase
|
|
1171
1240
|
// landing attempt.
|
|
1172
1241
|
if (protocol) {
|
|
1173
|
-
|
|
1174
|
-
headRefOid: st.headRefOid,
|
|
1175
|
-
lastActionHeadRefOid: pr.fresh_head_run_head,
|
|
1176
|
-
});
|
|
1177
|
-
if (action) {
|
|
1178
|
-
const ok = await ensureFreshHeadRun(repo, number, action).catch(() => false);
|
|
1179
|
-
if (ok && st.headRefOid) {
|
|
1180
|
-
await prs(data).update(prKey, { fresh_head_run_head: st.headRefOid, updated_at: now() });
|
|
1181
|
-
}
|
|
1182
|
-
console.log(`[poller] fresh head run (${action}) ${ok ? "requested" : "skipped"} -> ${prKey}`);
|
|
1183
|
-
}
|
|
1242
|
+
await maybeEnsureFreshHeadRun(data, repo, number, prKey, protocol, verdict, st, pr);
|
|
1184
1243
|
}
|
|
1185
1244
|
continue; // GitHub still computing / checks pending
|
|
1186
1245
|
}
|
|
@@ -2381,6 +2440,11 @@ export async function pollUserTasks(
|
|
|
2381
2440
|
for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
|
|
2382
2441
|
for (const status of PR_ACTIVE_STATUSES) for (const pr of await prs(data).find({ status })) await scanInstance(pr.process_key);
|
|
2383
2442
|
for (const review of await activeConformanceReviews(data)) await scanInstance(review.process_key);
|
|
2443
|
+
// A delivery-graph `human` node parks on its RUNNING run's engine instance (an awaiting-approval run
|
|
2444
|
+
// has no instance yet — mirrors `pollDeliveryGraphPhase`). Scan it too so the inlined
|
|
2445
|
+
// `delivery-human-task__<node>` gate surfaces on this reduced-capability path exactly as it does on
|
|
2446
|
+
// the engine-first sweep — otherwise the typed-seam host silently drops every delivery human gate.
|
|
2447
|
+
for (const run of await deliveryGraphRuns(data).find({ status: "running" })) await scanInstance(run.process_key);
|
|
2384
2448
|
}
|
|
2385
2449
|
|
|
2386
2450
|
const desired = [...desiredByKey.values()];
|
package/app/stage.test.ts
CHANGED
|
@@ -68,14 +68,28 @@ test("skipped: the three converge/auto_merge cases", () => {
|
|
|
68
68
|
assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 1 })).skipped, "");
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
test("attention: derives from
|
|
72
|
-
// Issue #
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
assertEquals(deriveStage(base({ status: "
|
|
71
|
+
test("attention: derives from OPEN user-task engine truth (blocked, escalation, none), NOT from status", () => {
|
|
72
|
+
// Issue #422: `attention` is a pure function of whether an OPEN native user task exists for the run
|
|
73
|
+
// (the `user_tasks` inbox — the authoritative "who is waiting on a human" set), never of the sticky
|
|
74
|
+
// `status` variable. An open `feature-blocked` task → "blocked"; an open `feature-escalation` task → "⚠".
|
|
75
|
+
assertEquals(deriveStage(base({ status: "awaiting_operator", hasOpenBlockedTask: true })).attention, "blocked");
|
|
76
|
+
assertEquals(deriveStage(base({ status: "escalated", hasOpenEscalationTask: true })).attention, "⚠");
|
|
76
77
|
assertEquals(deriveStage(base({ status: "running" })).attention, null);
|
|
77
78
|
});
|
|
78
79
|
|
|
80
|
+
test("attention #422: an ANSWERED escalation (status still 'escalated' but NO open task) shows NO badge", () => {
|
|
81
|
+
// The answer-loop returns the token to `implement-task` with no status reset, so `status` reads a
|
|
82
|
+
// stale "escalated" while the escalation user task is already gone. Sourcing the badge from engine
|
|
83
|
+
// truth (no open task) clears the ⚠ — the drift the old `status`-derived badge produced.
|
|
84
|
+
assertEquals(deriveStage(base({ status: "escalated" })).attention, null);
|
|
85
|
+
assertEquals(deriveStage(base({ status: "escalated", hasOpenEscalationTask: false })).attention, null);
|
|
86
|
+
// And an escalated run WHOSE task is genuinely open still shows ⚠.
|
|
87
|
+
assertEquals(deriveStage(base({ status: "escalated", hasOpenEscalationTask: true })).attention, "⚠");
|
|
88
|
+
// Symmetrically for the blocked/operator wait.
|
|
89
|
+
assertEquals(deriveStage(base({ status: "awaiting_operator" })).attention, null);
|
|
90
|
+
assertEquals(deriveStage(base({ status: "awaiting_operator", hasOpenBlockedTask: true })).attention, "blocked");
|
|
91
|
+
});
|
|
92
|
+
|
|
79
93
|
// The three parked-status rows called out by the plan review.
|
|
80
94
|
test("escalated WITH pr_key → PR open / null", () => {
|
|
81
95
|
const d = deriveStage(base({ status: "escalated", pr_key: "o/r#5" }));
|
|
@@ -89,8 +103,8 @@ test("escalated WITHOUT pr_key → Implementing / null", () => {
|
|
|
89
103
|
assertEquals(d.state, null);
|
|
90
104
|
});
|
|
91
105
|
|
|
92
|
-
test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when
|
|
93
|
-
const d = deriveStage(base({ status: "awaiting_operator", pr_key: null }));
|
|
106
|
+
test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when its task is open", () => {
|
|
107
|
+
const d = deriveStage(base({ status: "awaiting_operator", pr_key: null, hasOpenBlockedTask: true }));
|
|
94
108
|
assertEquals(d.stage, "Implementing");
|
|
95
109
|
assertEquals(d.state, null);
|
|
96
110
|
assertEquals(d.attention, "blocked");
|
package/app/stage.ts
CHANGED
|
@@ -43,6 +43,14 @@ export interface StageInput {
|
|
|
43
43
|
pr_key?: string | null;
|
|
44
44
|
converge?: number | boolean | null;
|
|
45
45
|
auto_merge?: number | boolean | null;
|
|
46
|
+
/** Engine truth for the `attention` badge (issue #422): whether an OPEN native user task of each
|
|
47
|
+
* human-wait kind currently exists for this run, from the `user_tasks` inbox (`pollUserTasks`, the
|
|
48
|
+
* authoritative "who is waiting on a human" set). `attention` derives from THESE, never from the
|
|
49
|
+
* drift-prone `status` variable — so once an escalation is answered (its `user_tasks` row deleted)
|
|
50
|
+
* the badge clears immediately even while `status` still reads a stale `"escalated"`. Omitted/false
|
|
51
|
+
* ⇒ no open task ⇒ no badge. `feature_read_model` (075) mirrors this with correlated EXISTS lookups. */
|
|
52
|
+
hasOpenBlockedTask?: boolean | null;
|
|
53
|
+
hasOpenEscalationTask?: boolean | null;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
/** The derived pipeline projection for one run. `skipped` is a space-separated set of stage keys not
|
|
@@ -91,11 +99,16 @@ export function deriveStage(run: StageInput): DerivedStage {
|
|
|
91
99
|
|
|
92
100
|
// `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
|
|
93
101
|
// a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
|
|
94
|
-
// Derived from
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
102
|
+
// Derived from ENGINE TRUTH — the presence of an OPEN native user task (issue #422), NOT from the
|
|
103
|
+
// `status` variable. `status` is worker-written imperatively and goes stale on the answer-loop back
|
|
104
|
+
// into `implement-task` (the process does not reset it), so a run whose escalation was already
|
|
105
|
+
// ANSWERED still reads `status="escalated"` until its next job completes; sourcing the badge from
|
|
106
|
+
// that value made the read model lie (a resolved run flagged ⚠ on Overview). The authoritative
|
|
107
|
+
// "who is waiting on a human" set is the `user_tasks` inbox (`pollUserTasks`), which holds a row
|
|
108
|
+
// IFF the task is open and deletes it the moment it is answered — so a run shows the blocked glyph
|
|
109
|
+
// IFF an open `feature-blocked` task exists, and ⚠ IFF an open `feature-escalation` task exists.
|
|
110
|
+
// Once answered, the row is gone and the badge clears regardless of the stale `status`.
|
|
111
|
+
const attention = truthy(run.hasOpenBlockedTask) ? "blocked" : truthy(run.hasOpenEscalationTask) ? "⚠" : null;
|
|
99
112
|
|
|
100
113
|
return { stage, state, skipped: skippedKeys.join(" "), attention };
|
|
101
114
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
-- Feature-run `attention` badge: derive it from engine truth (an OPEN native user task), not from the
|
|
2
|
+
-- drift-prone `status` variable (issue #422 — the L1 surface closure #439 named but did not ship).
|
|
3
|
+
--
|
|
4
|
+
-- 073_feature_read_model.sql retired the WRITE-TIME display projection (L2) into this VIEW, but it
|
|
5
|
+
-- still derived `attention` from the row's own `status` column:
|
|
6
|
+
-- WHEN fr.status = 'awaiting_operator' THEN 'blocked'
|
|
7
|
+
-- WHEN fr.status = 'escalated' THEN '⚠'
|
|
8
|
+
-- `status` is a process-scope variable set imperatively by the workers on the happy path. The `feature`
|
|
9
|
+
-- process loops the answer arm (`w_answerLoop`, resolution="answer") straight back into `implement-task`
|
|
10
|
+
-- with NO reset step, so after an escalation is ANSWERED the token is ACTIVE again at `implement-task`
|
|
11
|
+
-- while `status` still reads the previous iteration's `"escalated"` until the re-running agent job
|
|
12
|
+
-- completes and overwrites it (issue #422, observed live on merlin: feature instance 31779 showing ⚠
|
|
13
|
+
-- on Overview though its escalation was resolved and it was back implementing). Deriving the badge from
|
|
14
|
+
-- that sticky value makes the read model LIE — the exact "projected state maintained imperatively at
|
|
15
|
+
-- write time" defect class #439 set out to close ("derive it, don't maintain it — No Drift Surfaces").
|
|
16
|
+
--
|
|
17
|
+
-- The authoritative "who is waiting on a human" set is NOT `status` — it is the `user_tasks` inbox
|
|
18
|
+
-- (034_user_tasks_inbox.sql): `pollUserTasks` (app/service.ts) reconciles exactly one row per CURRENTLY
|
|
19
|
+
-- OPEN escalation user task from the engine and DELETES the row the moment the task closes (answered
|
|
20
|
+
-- here, via the Tasks inbox, or out-of-band). So a run is:
|
|
21
|
+
-- * awaiting an operator (blocked glyph) IFF an open `feature-blocked` user task exists for it, and
|
|
22
|
+
-- * escalated (⚠ badge) IFF an open `feature-escalation` user task exists for it.
|
|
23
|
+
-- Deriving `attention` from that presence (engine truth) instead of `status` closes the surface: once
|
|
24
|
+
-- the escalation is answered the `user_tasks` row is gone, so ⚠ clears immediately REGARDLESS of the
|
|
25
|
+
-- stale `status`. There is no stored column and no write path any writer can leave stale — the badge is
|
|
26
|
+
-- a pure function of the live open-task set, recomputed on every read. `deriveStage` (app/stage.ts)
|
|
27
|
+
-- remains the canonical TS oracle: it now takes the same open-task signals, and
|
|
28
|
+
-- app/featureReadModel.test.ts pins the VIEW to it in lockstep over the full status × open-task matrix
|
|
29
|
+
-- (including the #422 case: status='escalated' with NO open task → attention NULL).
|
|
30
|
+
--
|
|
31
|
+
-- pollUserTasks keys these rows `subject_type='feature'`, `subject_key=<feature_key>` (app/service.ts
|
|
32
|
+
-- DEFAULT_SUBJECT_TYPE / contextFor), so the correlated match is on `fr.feature_key`. `stage` is
|
|
33
|
+
-- deliberately UNCHANGED — an escalated/awaiting_operator run maps to `Implementing`, which is correct
|
|
34
|
+
-- whether or not the flag is stale (a run back at `implement-task` IS implementing); only the attention
|
|
35
|
+
-- badge was drifting, so only it moves to engine-truth derivation.
|
|
36
|
+
--
|
|
37
|
+
-- Forward-only, non-additive to `feature_runs` (a VIEW redefinition): `DROP VIEW` then `CREATE VIEW`,
|
|
38
|
+
-- plus one idempotent `CREATE INDEX IF NOT EXISTS` on `user_tasks` to front the correlated `attention`
|
|
39
|
+
-- lookups (see the trailing index comment).
|
|
40
|
+
-- 073 is a MERGED, IMMUTABLE migration — never edited; this is a NEW migration that supersedes its VIEW
|
|
41
|
+
-- definition. `user_tasks` (034) already exists earlier in the chain, so the correlated subquery
|
|
42
|
+
-- resolves. A single plain `CREATE VIEW … SELECT … FROM feature_runs fr` (the `user_tasks` lookups are
|
|
43
|
+
-- nested EXISTS subqueries at paren depth ≥ 1, so `feature_runs fr` stays the sole top-level FROM and
|
|
44
|
+
-- every output column stays aliased — the static pages↔schema contract guard, scripts/pages-contract.
|
|
45
|
+
-- test.ts, still parses the projection). Numbered after the current highest prefix on origin/main (074).
|
|
46
|
+
-- The runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
47
|
+
|
|
48
|
+
DROP VIEW IF EXISTS feature_read_model;
|
|
49
|
+
|
|
50
|
+
CREATE VIEW feature_read_model AS
|
|
51
|
+
SELECT
|
|
52
|
+
fr.feature_key AS feature_key,
|
|
53
|
+
fr.repo AS repo,
|
|
54
|
+
fr.issue_number AS issue_number,
|
|
55
|
+
fr.issue_url AS issue_url,
|
|
56
|
+
fr.title AS title,
|
|
57
|
+
fr.base_branch AS base_branch,
|
|
58
|
+
fr.status AS status,
|
|
59
|
+
fr.process_key AS process_key,
|
|
60
|
+
fr.pr_key AS pr_key,
|
|
61
|
+
fr.converge AS converge,
|
|
62
|
+
fr.auto_merge AS auto_merge,
|
|
63
|
+
fr.outcome AS outcome,
|
|
64
|
+
fr.delivery_label AS delivery_label,
|
|
65
|
+
fr.acknowledged_at AS acknowledged_at,
|
|
66
|
+
fr.created_at AS created_at,
|
|
67
|
+
fr.updated_at AS updated_at,
|
|
68
|
+
(CASE
|
|
69
|
+
WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') THEN 'Done'
|
|
70
|
+
WHEN fr.status = 'converging' THEN 'Converging'
|
|
71
|
+
WHEN (fr.pr_key IS NOT NULL AND fr.pr_key <> '') OR fr.status = 'opened' THEN 'PR open'
|
|
72
|
+
WHEN fr.status IN ('running', 'escalated', 'awaiting_operator') THEN 'Implementing'
|
|
73
|
+
ELSE 'Requested'
|
|
74
|
+
END) AS stage,
|
|
75
|
+
(CASE
|
|
76
|
+
WHEN fr.status IN ('merged', 'converged') THEN 'ok'
|
|
77
|
+
WHEN fr.status = 'blocked' THEN 'blocked'
|
|
78
|
+
WHEN fr.status IN ('failed', 'skipped', 'abandoned') THEN 'failed'
|
|
79
|
+
ELSE NULL
|
|
80
|
+
END) AS stage_state,
|
|
81
|
+
(CASE
|
|
82
|
+
WHEN NOT (fr.converge IS NOT NULL AND fr.converge <> 0) THEN 'Converging Merging'
|
|
83
|
+
WHEN NOT (fr.auto_merge IS NOT NULL AND fr.auto_merge <> 0) THEN 'Merging'
|
|
84
|
+
ELSE ''
|
|
85
|
+
END) AS stage_skipped,
|
|
86
|
+
(CASE
|
|
87
|
+
WHEN EXISTS (
|
|
88
|
+
SELECT 1 FROM user_tasks ut
|
|
89
|
+
WHERE ut.subject_type = 'feature' AND ut.subject_key = fr.feature_key
|
|
90
|
+
AND ut.element_id = 'feature-blocked'
|
|
91
|
+
) THEN 'blocked'
|
|
92
|
+
WHEN EXISTS (
|
|
93
|
+
SELECT 1 FROM user_tasks ut
|
|
94
|
+
WHERE ut.subject_type = 'feature' AND ut.subject_key = fr.feature_key
|
|
95
|
+
AND ut.element_id = 'feature-escalation'
|
|
96
|
+
) THEN '⚠'
|
|
97
|
+
ELSE NULL
|
|
98
|
+
END) AS attention,
|
|
99
|
+
(CASE
|
|
100
|
+
WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') AND fr.acknowledged_at IS NOT NULL THEN 'history'
|
|
101
|
+
ELSE 'active'
|
|
102
|
+
END) AS list_bucket
|
|
103
|
+
FROM feature_runs fr;
|
|
104
|
+
|
|
105
|
+
-- Supporting index for the correlated `attention` EXISTS lookups above. Each row of
|
|
106
|
+
-- `feature_read_model` probes `user_tasks` by `(subject_type, subject_key, element_id)` (twice: once
|
|
107
|
+
-- for `feature-blocked`, once for `feature-escalation`); the only prior index (034) is on
|
|
108
|
+
-- `(element_id, updated_at)`, which does not front the equality on `subject_type`/`subject_key`, so a
|
|
109
|
+
-- page reading many `feature_runs` rows would repeat a `user_tasks` scan per row. A composite index on
|
|
110
|
+
-- the exact equality tuple turns each probe into an index seek. `IF NOT EXISTS` keeps the migration
|
|
111
|
+
-- idempotent on any DB that already carries the index.
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_user_tasks_subject_element
|
|
113
|
+
ON user_tasks(subject_type, subject_key, element_id);
|
|
@@ -93,6 +93,16 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
|
|
|
93
93
|
await app.engine.registerWorker("senior:scope-classify", () => {
|
|
94
94
|
return { scopeBlocked: false, scopeBlockReason: "" };
|
|
95
95
|
});
|
|
96
|
+
// The converged round also runs the deterministic `pr.converge-gate` app worker, which FAILS
|
|
97
|
+
// CLOSED without a live GitHub transport (sealed here). This e2e isolates the review-escalation
|
|
98
|
+
// round-trip — the gate is exercised by `app/convergeGate.test.ts` + the worker integration
|
|
99
|
+
// test — so stub it as "converged cleanly" so the resumed round finalizes rather than parking
|
|
100
|
+
// on a second, converge-gate escalation. (Before engine-wasm 0.7.2, a blocked gate's escalation
|
|
101
|
+
// *question* silently blanked through ioMapping and was suppressed as a non-escalation, hiding
|
|
102
|
+
// this second escalation; the IO_MAPPING_ERROR fix now renders it, so it must be stubbed out.)
|
|
103
|
+
await app.engine.registerWorker("pr.converge-gate", () => {
|
|
104
|
+
return { convergeBlocked: false, convergeBlockReason: "" };
|
|
105
|
+
});
|
|
96
106
|
});
|
|
97
107
|
|
|
98
108
|
after(async () => {
|
|
@@ -88,6 +88,19 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
|
|
|
88
88
|
capturedAnswer = (job.variables as Record<string, unknown>).answer;
|
|
89
89
|
return { status: "converged", summary: "resolved after the human answer" };
|
|
90
90
|
});
|
|
91
|
+
// The converged round runs the deterministic `pr.converge-gate` app worker (and then
|
|
92
|
+
// `senior:scope-classify`), both of which FAIL CLOSED / park without a live transport (sealed
|
|
93
|
+
// here). This e2e isolates the escalation round-trip — the gate/classifier have their own tests
|
|
94
|
+
// — so stub both as "converged cleanly" so the resumed round finalizes rather than parking on a
|
|
95
|
+
// second escalation. (Before engine-wasm 0.7.2, a blocked gate's escalation *question* silently
|
|
96
|
+
// blanked through ioMapping and was suppressed as a non-escalation, hiding this; the
|
|
97
|
+
// IO_MAPPING_ERROR fix now renders it, so it must be stubbed out.)
|
|
98
|
+
await app.engine.registerWorker("pr.converge-gate", () => {
|
|
99
|
+
return { convergeBlocked: false, convergeBlockReason: "" };
|
|
100
|
+
});
|
|
101
|
+
await app.engine.registerWorker("senior:scope-classify", () => {
|
|
102
|
+
return { scopeBlocked: false, scopeBlockReason: "" };
|
|
103
|
+
});
|
|
91
104
|
});
|
|
92
105
|
|
|
93
106
|
after(async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.123.
|
|
3
|
+
"version": "0.123.2",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -59,12 +59,12 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"@nanobpm/agentic": "^0.1.0",
|
|
62
|
-
"@nanobpm/urban": "^0.
|
|
62
|
+
"@nanobpm/urban": "^0.77.1",
|
|
63
63
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@biomejs/biome": "^2.4.11",
|
|
67
|
-
"@nanobpm/urban-testkit": "^0.
|
|
67
|
+
"@nanobpm/urban-testkit": "^0.11.0",
|
|
68
68
|
"@nanobpm/workflow": "^0.14.0",
|
|
69
69
|
"@semantic-release/changelog": "^6.0.3",
|
|
70
70
|
"@semantic-release/git": "^10.0.1",
|
|
@@ -194,7 +194,7 @@
|
|
|
194
194
|
</zeebe:properties>
|
|
195
195
|
<zeebe:ioMapping>
|
|
196
196
|
<zeebe:input source="="blocked"" target="status" />
|
|
197
|
-
<zeebe:input source="
|
|
197
|
+
<zeebe:input source="=if mergeState = "draft" then "This PR is in draft and can't be merged. Mark it ready (gh pr ready), then reply to retry." else "This PR is not mergeable (state: " + mergeState + "). Resolve the conflict or failing required check on the branch, then reply to retry."" target="question" />
|
|
198
198
|
</zeebe:ioMapping>
|
|
199
199
|
</bpmn:extensionElements>
|
|
200
200
|
<bpmn:incoming>f_m_mBlocked</bpmn:incoming>
|