@nanobpm/nano-workforce 0.123.1 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +9 -5
  3. package/app/deliveryGraphDeploy.test.ts +209 -0
  4. package/app/deliveryGraphDispatch.test.ts +143 -0
  5. package/app/deliveryGraphDispatch.ts +168 -0
  6. package/app/deliveryGraphProposals.test.ts +267 -0
  7. package/app/deliveryGraphProposals.ts +269 -0
  8. package/app/deliveryGraphRun.test.ts +6 -52
  9. package/app/deliveryGraphRun.ts +21 -76
  10. package/app/deliveryGraphText.ts +3 -3
  11. package/app/deliveryRunner.ts +4 -3
  12. package/app/featureReadModel.test.ts +80 -12
  13. package/app/github.test.ts +34 -0
  14. package/app/github.ts +12 -3
  15. package/app/maybeEnsureFreshHeadRun.test.ts +150 -0
  16. package/app/mergeEscalationQuestion.test.ts +33 -0
  17. package/app/mergeProtocol.test.ts +25 -0
  18. package/app/mergeProtocol.ts +10 -4
  19. package/app/pollUserTasks.test.ts +27 -0
  20. package/app/service.ts +92 -13
  21. package/app/stage.test.ts +21 -7
  22. package/app/stage.ts +18 -5
  23. package/db/migrations/075_delivery_graph_proposals.sql +48 -0
  24. package/db/migrations/075_feature_read_model_attention_from_user_tasks.sql +113 -0
  25. package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
  26. package/docs/adr/0006-delivery-units-one-representation.md +221 -0
  27. package/docs/agent-guide.md +50 -58
  28. package/e2e/convergence-escalation.e2e.ts +10 -0
  29. package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
  30. package/e2e/retire-escalation-subsystem.e2e.ts +13 -0
  31. package/openapi.yaml +118 -161
  32. package/operations/compileDeliveryGraph.test.ts +100 -37
  33. package/operations/compileDeliveryGraph.ts +64 -18
  34. package/operations/dispatchDeliveryGraph.test.ts +171 -152
  35. package/operations/dispatchDeliveryGraph.ts +79 -99
  36. package/operations/getAgentInstructions.test.ts +10 -6
  37. package/operations/previewDeliveryGraph.test.ts +90 -51
  38. package/operations/previewDeliveryGraph.ts +45 -18
  39. package/package.json +3 -3
  40. package/pages/cockpit/mount.js +19 -12
  41. package/pages/delivery-graphs/mount.js +37 -137
  42. package/pages/delivery-graphs.page.json +50 -3
  43. package/resources/processes/merge-loop.bpmn +1 -1
  44. package/scripts/check-migrations.test.ts +9 -0
  45. package/scripts/check-migrations.ts +11 -1
  46. package/test/cockpit-embed-endpoints.test.ts +59 -36
  47. package/test/delivery-graphs-embed.test.ts +36 -34
  48. package/e2e/delivery-graph-start.e2e.ts +0 -145
  49. package/operations/startDeliveryGraph.integration.test.ts +0 -316
  50. package/operations/startDeliveryGraph.ts +0 -222
@@ -1,23 +1,27 @@
1
- // app/deliveryGraphRun.ts — the `startDeliveryGraph` run AGGREGATE (ADR 0005 Decision 7, slice S5).
1
+ // app/deliveryGraphRun.ts — the delivery-graph run AGGREGATE (ADR 0005 Decision 7).
2
2
  //
3
- // The dispatch door (`operations/startDeliveryGraph.ts`) turns an agent-authored `DeliveryGraph` into
4
- // a RUNNING engine-native process (via the S4 runner) — but because these graphs merge PRs and publish
5
- // packages, dispatch is GATED on an approval of the rendered preview and must be idempotent. This
6
- // module is the durable aggregate that makes both properties true and gives the cockpit a row to show
7
- // WHERE a run is parked:
3
+ // The cockpit dispatch action (`app/deliveryGraphDispatch.ts`, invoked from `dispatchDeliveryGraph`)
4
+ // turns a staged, agent-authored `DeliveryGraph` into a RUNNING engine-native process (via the S4
5
+ // runner). Because these graphs merge PRs and publish packages, dispatch must be idempotent and
6
+ // at-most-once. This module is the durable aggregate that makes that true and gives the cockpit a row
7
+ // to show WHERE a run is:
8
8
  //
9
9
  // • the idempotency fence — a run is keyed by `run_key` (a caller `idempotencyKey`, else the graph's
10
- // content digest). A re-POST collapses onto the same row, so an in-flight run short-circuits
10
+ // content digest). A re-dispatch collapses onto the same row, so an in-flight run short-circuits
11
11
  // instead of double-launching (mirrors `plans`' `alreadyRunning`).
12
- // • the approval token — `digest` is the content-address a side-effecting graph must present as its
13
- // `approvalToken` to dispatch. Persisted so a second POST re-derives + re-checks it.
12
+ // • the content digest — `digest` is the content-address of the compiled definition, persisted so
13
+ // the cockpit and reconcilers can relate a run to the proposal it came from.
14
14
  // • the derived parked-node phase — `phase`/`phase_node_id` is the display-only "where is it parked"
15
15
  // projection `pollDeliveryGraphPhase` recomputes from engine truth (the running instance's open
16
16
  // user tasks), generalising the `epic_phase` derived-phase machinery to a DYNAMIC compiled process.
17
17
  //
18
- // The pure helpers here (`computeRunKey`, `isDeliveryGraphApproved`, `buildHumanLabels`,
19
- // `deriveDeliveryPhase`) are engine/DB-free so they unit-test in isolation; the door and the poller
20
- // supply the I/O.
18
+ // The pure helpers here (`computeRunKey`, `buildHumanLabels`, `deriveDeliveryPhase`) are engine/DB-free
19
+ // so they unit-test in isolation; the dispatch action and the poller supply the I/O.
20
+ //
21
+ // NOTE (issue #460): the `awaiting-approval` status remains a RESERVED member of the lifecycle union
22
+ // (like `abandoned`) but is no longer produced — dispatch is now an operator action in the cockpit, so
23
+ // there is no agent-facing approval gate to park a run at. The old replayable `approvalToken` and the
24
+ // approval-park write were removed with the agent `start` door.
21
25
 
22
26
  import type { DataLayer, ProcessInstanceState } from "@nanobpm/urban";
23
27
  import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
@@ -26,7 +30,7 @@ import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.
26
30
 
27
31
  const now = () => new Date().toISOString();
28
32
 
29
- /** One `startDeliveryGraph` run — the durable row. `side_effecting` is a SQLite boolean (0/1). */
33
+ /** One delivery-graph run — the durable row. `side_effecting` is a SQLite boolean (0/1). */
30
34
  export interface DeliveryGraphRun {
31
35
  run_key: string;
32
36
  process_key: string | null;
@@ -45,8 +49,9 @@ export interface DeliveryGraphRun {
45
49
  updated_at: string;
46
50
  }
47
51
 
48
- /** The run lifecycle. `awaiting-approval` = a side-effecting graph parked at the approval gate (no
49
- * instance started); `running` = dispatched to the engine; `done`/`failed`/`abandoned` = terminal. */
52
+ /** The run lifecycle. `awaiting-approval` is RESERVED but no longer produced (issue #460 moved dispatch
53
+ * to an operator action, so runs are only ever created at launch) kept in the union to preserve the
54
+ * durable enum. `running` = dispatched to the engine; `done`/`failed`/`abandoned` = terminal. */
50
55
  export const DELIVERY_GRAPH_RUN_STATUSES = [
51
56
  "awaiting-approval",
52
57
  "running",
@@ -142,74 +147,14 @@ export async function claimRunForLaunch(
142
147
  return res.changed === 1;
143
148
  }
144
149
 
145
- /** Persist a PARKED (non-launch) run row — the approval-gate write for an unapproved side-effecting
146
- * graph — WITHOUT ever clobbering a concurrently-launched `running` claim. Two concurrent submits of
147
- * one graph (same `run_key`) can race an APPROVED launch (which inserts/flips a `running` claim via
148
- * `claimRunForLaunch`) against an UNAPPROVED park: a blind insert-or-update would let the park
149
- * overwrite that `running` claim back to `awaiting-approval` and null its `process_key`, breaking the
150
- * at-most-once dispatch fence and letting a later re-submit double-launch the graph's side effects.
151
- * So mirror the launch fence exactly — a first write is the `run_key` PK insert; on a unique
152
- * collision (a concurrent submit already wrote the row) OR when a row already exists, re-apply via a
153
- * single atomic guarded UPDATE `… WHERE status <> 'running'`. One statement, so the check-and-write
154
- * is atomic even across the delegate's `await` points: a racing `running` claim matches zero rows and
155
- * survives untouched, while a still-parked or terminal row is idempotently (re-)parked. */
156
- export async function parkRunFencedAgainstLaunch(
157
- data: DataLayer,
158
- existing: boolean,
159
- row: DeliveryGraphRun,
160
- ): Promise<void> {
161
- if (!existing) {
162
- try {
163
- await deliveryGraphRuns(data).insert(row);
164
- return;
165
- } catch (err) {
166
- if (!isUniqueConstraintFence(err)) throw err;
167
- }
168
- }
169
- await data
170
- .open()
171
- .exec(
172
- `UPDATE "delivery_graph_runs" SET "process_key" = ?, "process_definition_id" = ?, "digest" = ?, "status" = ?, "side_effecting" = ?, "node_count" = ?, "human_node_count" = ?, "side_effect_count" = ?, "title" = ?, "phase" = ?, "phase_node_id" = ?, "human_labels" = ?, "updated_at" = ? WHERE "run_key" = ? AND "status" <> 'running'`,
173
- [
174
- row.process_key,
175
- row.process_definition_id,
176
- row.digest,
177
- row.status,
178
- row.side_effecting,
179
- row.node_count,
180
- row.human_node_count,
181
- row.side_effect_count,
182
- row.title,
183
- row.phase,
184
- row.phase_node_id,
185
- row.human_labels,
186
- row.updated_at,
187
- row.run_key,
188
- ],
189
- );
190
- }
191
-
192
150
  /** The idempotency key for a submitted graph: a caller-supplied `idempotencyKey` (trimmed) when
193
- * present and non-blank, else the graph's content `digest`. So two POSTs of the SAME graph (no
151
+ * present and non-blank, else the graph's content `digest`. So two dispatches of the SAME graph (no
194
152
  * explicit key) collapse onto one run, and a caller can force a fresh run with an explicit key. */
195
153
  export function computeRunKey(idempotencyKey: string | undefined | null, digest: string): string {
196
154
  const trimmed = typeof idempotencyKey === "string" ? idempotencyKey.trim() : "";
197
155
  return trimmed || digest;
198
156
  }
199
157
 
200
- /** The approval decision (Decision 7). A graph with NO side-effecting nodes (only `wait`/`human`)
201
- * needs no approval and dispatches straight away. A SIDE-EFFECTING graph dispatches only when the
202
- * caller presents `approvalToken == digest` — an approval OF the rendered preview, content-addressed
203
- * so it cannot be replayed against a different graph. */
204
- export function isDeliveryGraphApproved(
205
- sideEffecting: boolean,
206
- approvalToken: string | undefined | null,
207
- digest: string,
208
- ): boolean {
209
- if (!sideEffecting) return true;
210
- return typeof approvalToken === "string" && approvalToken.trim() === digest;
211
- }
212
-
213
158
  /** The compiled human-task element id for a node's compiled BPMN element (`delivery-human-task__n3`) —
214
159
  * the exact id the S4 compiler inlines and the engine reports as a user task's `elementId`. */
215
160
  export function humanTaskElementId(element: string): string {
@@ -1,9 +1,9 @@
1
1
  // app/deliveryGraphText.ts — the shared PARSE step for the human-facing UI JSON-paste ingress
2
2
  // (issue #386, ADR 0005). The Delivery Graphs page (`pages/delivery-graphs.page.json`) submits the
3
3
  // operator's pasted delivery-graph as a raw JSON STRING (`graphJson`) — the page's text field cannot
4
- // submit a structured object — so the preview/dispatch ingress operations parse it here before handing
5
- // the resulting object to the SAME pure `compileDeliveryGraph` compiler / gated `startDeliveryGraph`
6
- // door the agent-facing paths use. This is a UI text adapter, NOT a parallel compile/dispatch path.
4
+ // submit a structured object — so the preview+stage ingress operation parses it here before handing
5
+ // the resulting object to the SAME pure `compileDeliveryGraph` compiler the agent-facing compile door
6
+ // uses. This is a UI text adapter, NOT a parallel compile path.
7
7
  //
8
8
  // PURE and I/O-free so it unit-tests in isolation. A blank field, non-JSON text, or a non-object JSON
9
9
  // value maps to a clean `{ ok:false, error }` the ingress surfaces as a 400 with a human banner —
@@ -20,9 +20,10 @@ import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./
20
20
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
21
21
 
22
22
  /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
23
- * content-addressed deploy id (`delivery-graph-<digest>`) AND the S5 dispatch door's approval token /
24
- * default idempotency key. Both the runner (deploy id) and `operations/startDeliveryGraph` (approval +
25
- * dedupe key) derive from THIS one function so the two can never drift on how a graph is addressed. */
23
+ * content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default idempotency
24
+ * key + the staged-proposal primary key. The runner (deploy id), `app/deliveryGraphDispatch` (dedupe
25
+ * key), and `app/deliveryGraphProposals` (proposal digest) all derive from THIS one function so they
26
+ * can never drift on how a graph is addressed. */
26
27
  export function deliveryGraphDigest(bpmn: string): string {
27
28
  return createHash("sha256").update(bpmn).digest("hex").slice(0, 12);
28
29
  }
@@ -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 SAME pure
18
- // helpers the acknowledge operations guard on — plus a RED/GREEN guard reproducing the reconciler
19
- // bypass: a RAW-datasource `status` write must leave the projection correct.
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
- const key = `o/r#${i++}`;
106
- cases.push({ key, run: { status, converge, auto_merge, pr_key } });
107
- addRun(db, key, { status, converge, auto_merge, pr_key });
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,
@@ -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). The poller only advances the process on a settled
958
- * verdict; `waiting` means re-poll later. */
959
- export type Mergeability = "ready" | "waiting" | "conflict" | "blocked";
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(/&#34;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/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 }),
@@ -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: