@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.
- package/CHANGELOG.md +22 -0
- package/README.md +9 -5
- package/app/deliveryGraphDeploy.test.ts +209 -0
- package/app/deliveryGraphDispatch.test.ts +143 -0
- package/app/deliveryGraphDispatch.ts +168 -0
- package/app/deliveryGraphProposals.test.ts +267 -0
- package/app/deliveryGraphProposals.ts +269 -0
- package/app/deliveryGraphRun.test.ts +6 -52
- package/app/deliveryGraphRun.ts +21 -76
- package/app/deliveryGraphText.ts +3 -3
- package/app/deliveryRunner.ts +4 -3
- 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 +92 -13
- package/app/stage.test.ts +21 -7
- package/app/stage.ts +18 -5
- package/db/migrations/075_delivery_graph_proposals.sql +48 -0
- package/db/migrations/075_feature_read_model_attention_from_user_tasks.sql +113 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +18 -0
- package/docs/adr/0006-delivery-units-one-representation.md +221 -0
- package/docs/agent-guide.md +50 -58
- package/e2e/convergence-escalation.e2e.ts +10 -0
- package/e2e/delivery-graph-dispatch.e2e.ts +155 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +13 -0
- package/openapi.yaml +118 -161
- package/operations/compileDeliveryGraph.test.ts +100 -37
- package/operations/compileDeliveryGraph.ts +64 -18
- package/operations/dispatchDeliveryGraph.test.ts +171 -152
- package/operations/dispatchDeliveryGraph.ts +79 -99
- package/operations/getAgentInstructions.test.ts +10 -6
- package/operations/previewDeliveryGraph.test.ts +90 -51
- package/operations/previewDeliveryGraph.ts +45 -18
- package/package.json +3 -3
- package/pages/cockpit/mount.js +19 -12
- package/pages/delivery-graphs/mount.js +37 -137
- package/pages/delivery-graphs.page.json +50 -3
- package/resources/processes/merge-loop.bpmn +1 -1
- package/scripts/check-migrations.test.ts +9 -0
- package/scripts/check-migrations.ts +11 -1
- package/test/cockpit-embed-endpoints.test.ts +59 -36
- package/test/delivery-graphs-embed.test.ts +36 -34
- package/e2e/delivery-graph-start.e2e.ts +0 -145
- package/operations/startDeliveryGraph.integration.test.ts +0 -316
- package/operations/startDeliveryGraph.ts +0 -222
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
|
-
// app/deliveryGraphRun.ts — the
|
|
1
|
+
// app/deliveryGraphRun.ts — the delivery-graph run AGGREGATE (ADR 0005 Decision 7).
|
|
2
2
|
//
|
|
3
|
-
// The dispatch
|
|
4
|
-
// a RUNNING engine-native process (via the S4
|
|
5
|
-
//
|
|
6
|
-
// module is the durable aggregate that makes
|
|
7
|
-
// WHERE a run is
|
|
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-
|
|
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
|
|
13
|
-
//
|
|
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`, `
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
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`
|
|
49
|
-
*
|
|
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
|
|
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 {
|
package/app/deliveryGraphText.ts
CHANGED
|
@@ -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
|
|
5
|
-
// the resulting object to the SAME pure `compileDeliveryGraph` compiler
|
|
6
|
-
//
|
|
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 —
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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:
|