@nanobpm/nano-workforce 0.115.0 → 0.117.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.
@@ -0,0 +1,323 @@
1
+ // app/deliveryGraphRun.ts — the `startDeliveryGraph` run AGGREGATE (ADR 0005 Decision 7, slice S5).
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:
8
+ //
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
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.
14
+ // • the derived parked-node phase — `phase`/`phase_node_id` is the display-only "where is it parked"
15
+ // projection `pollDeliveryGraphPhase` recomputes from engine truth (the running instance's open
16
+ // user tasks), generalising the `epic_phase` derived-phase machinery to a DYNAMIC compiled process.
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.
21
+
22
+ import type { DataLayer, ProcessInstanceState } from "@nanobpm/urban";
23
+ import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
24
+ import { isUniqueConstraintFence } from "./dbFence.ts";
25
+ import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
26
+
27
+ const now = () => new Date().toISOString();
28
+
29
+ /** One `startDeliveryGraph` run — the durable row. `side_effecting` is a SQLite boolean (0/1). */
30
+ export interface DeliveryGraphRun {
31
+ run_key: string;
32
+ process_key: string | null;
33
+ process_definition_id: string | null;
34
+ digest: string;
35
+ status: DeliveryGraphRunStatus;
36
+ side_effecting: number;
37
+ node_count: number;
38
+ human_node_count: number;
39
+ side_effect_count: number;
40
+ title: string | null;
41
+ phase: string | null;
42
+ phase_node_id: string | null;
43
+ human_labels: string | null;
44
+ created_at: string;
45
+ updated_at: string;
46
+ }
47
+
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. */
50
+ export const DELIVERY_GRAPH_RUN_STATUSES = [
51
+ "awaiting-approval",
52
+ "running",
53
+ "done",
54
+ "failed",
55
+ "abandoned",
56
+ ] as const;
57
+ export type DeliveryGraphRunStatus = typeof DELIVERY_GRAPH_RUN_STATUSES[number];
58
+
59
+ /** The ACTIVE statuses — a run in one of these is still in flight and shows in the cockpit's active
60
+ * grid (`pages/overview.page.json`'s "Active Delivery Graphs" filter). Note this is the DISPLAY set,
61
+ * broader than the instanceTracking binding: only `running` is backed by a live engine instance
62
+ * (non-null `process_key`), so ONLY `running` is instance-tracked (nano.app.json). A parked
63
+ * `awaiting-approval` run has no instance (`process_key` NULL) — it is shown here but not reconciled
64
+ * by the `process_key`-keyed reconciler. */
65
+ export const DELIVERY_GRAPH_ACTIVE_STATUSES: readonly DeliveryGraphRunStatus[] = [
66
+ "awaiting-approval",
67
+ "running",
68
+ ];
69
+
70
+ /** The terminal statuses — a run in one of these is done and drops out of the active grid. Mirrors
71
+ * `PLAN_TERMINAL_STATUSES`; the idempotency short-circuit only fires for a NON-terminal run. */
72
+ export const DELIVERY_GRAPH_TERMINAL_STATUSES: readonly DeliveryGraphRunStatus[] = [
73
+ "done",
74
+ "failed",
75
+ "abandoned",
76
+ ];
77
+
78
+ /** The display phases for a run's derived projection. `Parked on human node: <label>` is built at
79
+ * derivation time from the parked user task's label, so it is not a member here. */
80
+ export const DELIVERY_PHASE = {
81
+ AWAITING_APPROVAL: "Awaiting approval",
82
+ RUNNING: "Running",
83
+ COMPLETED: "Completed",
84
+ FAILED: "Failed",
85
+ } as const;
86
+
87
+ /** The `delivery_graph_runs` aggregate accessor — the durable run store keyed by `run_key`. */
88
+ export const deliveryGraphRuns = (data: DataLayer) =>
89
+ data.table<DeliveryGraphRun>("delivery_graph_runs", "run_key");
90
+
91
+ /** Atomically claim a run for LAUNCH — the at-most-once dispatch fence. Returns `true` iff THIS caller
92
+ * won the claim and must proceed to `runDeliveryGraph`; `false` iff a concurrent submit already claimed
93
+ * it (the caller must short-circuit as `alreadyRunning` instead of double-launching). Two fences, one
94
+ * per starting state, so dispatch is at-most-once from EITHER — the `run_key` PK guards a first launch
95
+ * and a compare-and-swap on `status` guards a relaunch off a persisted row:
96
+ * • no row yet (`existing` null) → INSERT the claim, fenced by the `run_key` PRIMARY KEY: a racing
97
+ * loser hits `UNIQUE constraint failed` and returns `false`.
98
+ * • a persisted NON-running row (a parked `awaiting-approval` row now being approved, or a terminal
99
+ * row being re-run) → a single guarded UPDATE (`SET status='running' … WHERE status <> 'running'`).
100
+ * It is ONE statement, so the check-and-flip is atomic even across the delegate's `await` points:
101
+ * of two concurrent approved re-submits that both read the same parked row, exactly one flips it
102
+ * (`changed === 1`) and the other matches zero rows (`changed === 0`). This closes the double-launch
103
+ * hole the PK fence alone left open — an `update`-on-existing path has no unique collision to lose,
104
+ * so without this guard both racers would `update` then both launch. The winner writes the run's
105
+ * full metadata afterwards (safe: it is now the sole caller past the fence).
106
+ *
107
+ * The CAS also CLEARS the instance-bound columns (`process_key`, `process_definition_id`, `phase`,
108
+ * `phase_node_id`) to the fresh claim's values IN THE SAME statement. A re-run off a terminal row (or
109
+ * any persisted row) still carries the PRIOR run's `process_key`; flipping `status` alone would make
110
+ * the row briefly visible as `running` while still pointing at the OLD instance key, so the
111
+ * `process_key`-keyed instance-tracking reconciler / poller could act on (and mis-reconcile against)
112
+ * the stale instance before the winner's follow-up metadata write lands. Clearing them atomically with
113
+ * the flip means a claimed `running` row can never be observed with a stale instance key. */
114
+ export async function claimRunForLaunch(
115
+ data: DataLayer,
116
+ existing: boolean,
117
+ claim: DeliveryGraphRun,
118
+ ): Promise<boolean> {
119
+ if (!existing) {
120
+ try {
121
+ await deliveryGraphRuns(data).insert(claim);
122
+ return true;
123
+ } catch (err) {
124
+ if (!isUniqueConstraintFence(err)) throw err;
125
+ return false;
126
+ }
127
+ }
128
+ const res = await data
129
+ .open()
130
+ .exec(
131
+ `UPDATE "delivery_graph_runs" SET "status" = ?, "process_key" = ?, "process_definition_id" = ?, "phase" = ?, "phase_node_id" = ?, "updated_at" = ? WHERE "run_key" = ? AND "status" <> 'running'`,
132
+ [
133
+ claim.status,
134
+ claim.process_key,
135
+ claim.process_definition_id,
136
+ claim.phase,
137
+ claim.phase_node_id,
138
+ claim.updated_at,
139
+ claim.run_key,
140
+ ],
141
+ );
142
+ return res.changed === 1;
143
+ }
144
+
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
+ /** 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
194
+ * explicit key) collapse onto one run, and a caller can force a fresh run with an explicit key. */
195
+ export function computeRunKey(idempotencyKey: string | undefined | null, digest: string): string {
196
+ const trimmed = typeof idempotencyKey === "string" ? idempotencyKey.trim() : "";
197
+ return trimmed || digest;
198
+ }
199
+
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
+ /** The compiled human-task element id for a node's compiled BPMN element (`delivery-human-task__n3`) —
214
+ * the exact id the S4 compiler inlines and the engine reports as a user task's `elementId`. */
215
+ export function humanTaskElementId(element: string): string {
216
+ return `${DELIVERY_HUMAN_ELEMENT}__${element}`;
217
+ }
218
+
219
+ /** The first non-blank line of a multi-line instruction, clamped, for a compact parked-node label. */
220
+ function firstLine(text: string | undefined | null): string {
221
+ if (typeof text !== "string") return "";
222
+ const line = text.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
223
+ return line.length > 80 ? `${line.slice(0, 77)}…` : line;
224
+ }
225
+
226
+ /** Map each human node's compiled user-task element id → a display label (its instruction's first
227
+ * line, else its author node id), from the S1 compile result. Stamped on the run row at dispatch so
228
+ * the poller renders the parked-node phase without recompiling the graph. */
229
+ export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<string, string> {
230
+ const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
231
+ const labels: Record<string, string> = {};
232
+ for (const stop of compiled.humanNodes) {
233
+ const element = elementByNodeId.get(stop.nodeId);
234
+ if (element === undefined) continue;
235
+ labels[humanTaskElementId(element)] = firstLine(stop.prompt) || stop.nodeId;
236
+ }
237
+ return labels;
238
+ }
239
+
240
+ /** Parse a run row's stored `human_labels` JSON back into a map, tolerating a null/blank/corrupt
241
+ * value (→ empty map) so a bad column can never crash the poller. */
242
+ export function parseHumanLabels(raw: string | null | undefined): Record<string, string> {
243
+ if (typeof raw !== "string" || raw.trim() === "") return {};
244
+ try {
245
+ const parsed: unknown = JSON.parse(raw);
246
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
247
+ const out: Record<string, string> = {};
248
+ for (const [k, v] of Object.entries(parsed)) {
249
+ if (typeof v === "string") out[k] = v;
250
+ }
251
+ return out;
252
+ } catch {
253
+ return {};
254
+ }
255
+ }
256
+
257
+ /** The derived (status, phase, parked-node) projection for a RUNNING run, from engine truth. PURE —
258
+ * the poller supplies the instance state + open user tasks, this maps them to the stored projection:
259
+ * • COMPLETED → `done` (instanceTracking does NOT reconcile COMPLETED — this poller owns it).
260
+ * • TERMINATED → `failed` (a safety net; the instanceTracking `onTerminated` edge also flips it).
261
+ * • ACTIVE parked on a human node → `running`, phase `Parked on human node: <label>`.
262
+ * • ACTIVE otherwise (watching a wait/agent node) → `running`, phase `Running`. */
263
+ export interface DeliveryPhaseProjection {
264
+ status: DeliveryGraphRunStatus;
265
+ phase: string;
266
+ phase_node_id: string | null;
267
+ }
268
+
269
+ export function deriveDeliveryPhase(
270
+ state: ProcessInstanceState | null | undefined,
271
+ openUserTasks: readonly { elementId?: string }[],
272
+ humanLabels: Record<string, string>,
273
+ ): DeliveryPhaseProjection {
274
+ if (state === "COMPLETED") return { status: "done", phase: DELIVERY_PHASE.COMPLETED, phase_node_id: null };
275
+ if (state === "TERMINATED") return { status: "failed", phase: DELIVERY_PHASE.FAILED, phase_node_id: null };
276
+ const parkedOn = openUserTasks
277
+ .map((t) => t.elementId)
278
+ .filter((id): id is string => typeof id === "string" && isDeliveryHumanElement(id))
279
+ .sort()[0];
280
+ if (parkedOn !== undefined) {
281
+ const label = humanLabels[parkedOn] ?? parkedOn;
282
+ return { status: "running", phase: `Parked on human node: ${label}`, phase_node_id: parkedOn };
283
+ }
284
+ return { status: "running", phase: DELIVERY_PHASE.RUNNING, phase_node_id: null };
285
+ }
286
+
287
+ /** Build the durable row for a run at a given lifecycle point — the SINGLE row-shape builder both the
288
+ * approval-park and the dispatch write go through, so the two can't drift on which columns a run
289
+ * carries. `createdAt` is preserved across an update (the door passes the existing row's value). */
290
+ export function buildDeliveryGraphRunRow(input: {
291
+ runKey: string;
292
+ digest: string;
293
+ status: DeliveryGraphRunStatus;
294
+ sideEffecting: boolean;
295
+ nodeCount: number;
296
+ humanNodeCount: number;
297
+ sideEffectCount: number;
298
+ title: string | null;
299
+ phase: string;
300
+ processKey?: string | null;
301
+ processDefinitionId?: string | null;
302
+ humanLabels?: Record<string, string>;
303
+ createdAt?: string;
304
+ }): DeliveryGraphRun {
305
+ const at = now();
306
+ return {
307
+ run_key: input.runKey,
308
+ process_key: input.processKey ?? null,
309
+ process_definition_id: input.processDefinitionId ?? null,
310
+ digest: input.digest,
311
+ status: input.status,
312
+ side_effecting: input.sideEffecting ? 1 : 0,
313
+ node_count: input.nodeCount,
314
+ human_node_count: input.humanNodeCount,
315
+ side_effect_count: input.sideEffectCount,
316
+ title: input.title,
317
+ phase: input.phase,
318
+ phase_node_id: null,
319
+ human_labels: input.humanLabels ? JSON.stringify(input.humanLabels) : null,
320
+ created_at: input.createdAt ?? at,
321
+ updated_at: at,
322
+ };
323
+ }
@@ -18,6 +18,14 @@ import type { EngineClient } from "@nanobpm/urban";
18
18
  import type { DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
19
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
20
 
21
+ /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
22
+ * content-addressed deploy id (`delivery-graph-<digest>`) AND the S5 dispatch door's approval token /
23
+ * default idempotency key. Both the runner (deploy id) and `operations/startDeliveryGraph` (approval +
24
+ * dedupe key) derive from THIS one function so the two can never drift on how a graph is addressed. */
25
+ export function deliveryGraphDigest(bpmn: string): string {
26
+ return createHash("sha256").update(bpmn).digest("hex").slice(0, 12);
27
+ }
28
+
21
29
  /** The bounded-timeout / SLA envelope every node inherits (Decision: bounded → escalate). ISO-8601
22
30
  * durations. Defaults are conservative; a caller (the S5 door) may tighten them per run. */
23
31
  export interface DeliveryRunTimeouts {
@@ -89,7 +97,7 @@ export function prepareDeliveryGraph(graph: DeliveryGraph, options: DeliveryRunO
89
97
  const compiled = compileDeliveryGraph(graph);
90
98
  if (!compiled.ok) return { ok: false, errors: compiled.errors };
91
99
 
92
- const digest = createHash("sha256").update(compiled.bpmn).digest("hex").slice(0, 12);
100
+ const digest = deliveryGraphDigest(compiled.bpmn);
93
101
  const processDefinitionId = `${DELIVERY_GRAPH_PROCESS_ID}-${digest}`;
94
102
  const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
95
103
 
@@ -12,6 +12,7 @@ import { TERMINAL_STATUSES } from "./delivery.ts";
12
12
  import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
13
13
  import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
14
14
  import { CONFORMANCE_REVIEWING_STATUS } from "./conformance.ts";
15
+ import { DELIVERY_GRAPH_TERMINAL_STATUSES } from "./deliveryGraphRun.ts";
15
16
 
16
17
  interface Binding {
17
18
  table: string;
@@ -141,3 +142,34 @@ test("instanceTracking: plan_conformance onTerminated status is not active", asy
141
142
  `onTerminated review_status "${String(settled)}" must not be listed active`,
142
143
  );
143
144
  });
145
+
146
+ // The delivery_graph_runs binding tracks ONLY the engine-instance-backed status. Unlike PR/plan/
147
+ // feature bindings, its active set is DELIBERATELY narrower than the code's DISPLAY active set
148
+ // (DELIVERY_GRAPH_ACTIVE_STATUSES = awaiting-approval + running): a parked `awaiting-approval` run
149
+ // has a null `process_key`, so the `process_key`-keyed reconciler cannot track it. Tie the manifest
150
+ // to that invariant so a future change can't silently (a) list a terminal status active — the
151
+ // reconciler would clobber a settled run — or (b) add `awaiting-approval`, which would make the
152
+ // reconciler flip every parked run to `failed` on its next pass (a null key never matches a live
153
+ // instance → "vanished" → onTerminated).
154
+ test("instanceTracking: delivery_graph_runs activeStatuses excludes every terminal status", async () => {
155
+ const b = bindingFor(await bindings(), "delivery_graph_runs");
156
+ for (const terminal of DELIVERY_GRAPH_TERMINAL_STATUSES) {
157
+ assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
158
+ }
159
+ });
160
+
161
+ test("instanceTracking: delivery_graph_runs activeStatuses is exactly the instance-backed status (running)", async () => {
162
+ const b = bindingFor(await bindings(), "delivery_graph_runs");
163
+ assertEquals([...(b.activeStatuses ?? [])].sort(), ["running"]);
164
+ // A parked run has no engine instance (process_key NULL) — it must NOT be reconciled by this binding.
165
+ assert(!b.activeStatuses?.includes("awaiting-approval"), "awaiting-approval (null process_key) must not be instance-tracked");
166
+ });
167
+
168
+ test("instanceTracking: delivery_graph_runs onTerminated status is not active", async () => {
169
+ const b = bindingFor(await bindings(), "delivery_graph_runs");
170
+ const settled = b.onTerminated.set.status;
171
+ assert(
172
+ typeof settled === "string" && !b.activeStatuses?.includes(settled),
173
+ `onTerminated status "${String(settled)}" must not be listed active`,
174
+ );
175
+ });
@@ -212,36 +212,3 @@ test("a control-flow arm with a blank question opens nothing so gw-escalated re-
212
212
  assertEquals(inserts.escalations.length, 0, "no dead escalation is fabricated");
213
213
  assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
214
214
  });
215
-
216
- // The scope-integrity arm (persist-escalation-blockedcomments) binds the escalation to the reviewed
217
- // commit (issue #395): it stamps `head_sha` and marks `scope_block` so the converge-gate can honour
218
- // a same-HEAD human answer as an override instead of re-deriving the block and re-escalating forever.
219
- test("persist-escalation binds a scope-integrity escalation to the reviewed HEAD (head_sha + scope_block)", async () => {
220
- const { app, inserts } = fakeApp();
221
- const job = {
222
- variables: {
223
- prKey: "o/r#5",
224
- round: 2,
225
- status: "blocked",
226
- question: "Scope integrity blocked: ...",
227
- recordRound: false,
228
- headSha: "HEAD1",
229
- scopeBlock: true,
230
- },
231
- };
232
- await handler(job as any, app as any);
233
- assertEquals(inserts.escalations.length, 1);
234
- assertEquals((inserts.escalations[0] as any).head_sha, "HEAD1", "the escalation carries the reviewed commit");
235
- assertEquals((inserts.escalations[0] as any).scope_block, 1, "flagged as a scope-integrity block");
236
- });
237
-
238
- // Every other escalation arm (agent verdict, no-progress, max-rounds, stalled) omits the scope
239
- // binding: head_sha stays absent and scope_block defaults to 0, so the override door opens ONLY for
240
- // the block a human can actually answer.
241
- test("persist-escalation: a non-scope escalation records no HEAD binding and scope_block 0", async () => {
242
- const { app, inserts } = fakeApp();
243
- const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds" } };
244
- await handler(job as any, app as any);
245
- assertEquals((inserts.escalations[0] as any).head_sha, undefined, "no reviewed HEAD to bind");
246
- assertEquals((inserts.escalations[0] as any).scope_block, 0, "not a scope-integrity block");
247
- });
package/app/service.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  } from "./conformance.ts";
29
29
  import { isUniqueConstraintFence } from "./dbFence.ts";
30
30
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
31
+ import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
31
32
  import { fleetSupportsDurableResume } from "./durableResume.ts";
32
33
  import { backfillFeatureStages, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
33
34
  import {
@@ -2196,6 +2197,43 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
2196
2197
  * completed task's row is deleted (answered here, via the task inbox, or out-of-band) and `showCount`
2197
2198
  * reflects live pending work. Best-effort + idempotent — per-instance failures are isolated so one bad
2198
2199
  * instance never stalls the pass. */
2200
+ /** Poll pass (ADR 0005 slice S5): reconcile each RUNNING delivery-graph run's derived phase from
2201
+ * engine truth, and complete it when its instance ends. A delivery graph is a DYNAMIC compiled
2202
+ * process with no happy-path host worker, so — unlike `plans`/`feature_runs`, whose spine workers
2203
+ * write their own terminal row — this pass owns both the parked-node projection AND the COMPLETED→done
2204
+ * transition (instanceTracking's `onTerminated` edge reconciles only TERMINATED, never COMPLETED, so
2205
+ * a graph that ends normally would otherwise stay `running` forever). Generalises the `epic_phase`
2206
+ * derived-phase machinery to a graph whose element ids aren't known ahead of time: the parked-node
2207
+ * label is derived from the run row's stamped `human_labels` + the instance's OPEN user tasks. Scoped
2208
+ * to `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
2209
+ export async function pollDeliveryGraphPhase(
2210
+ data: DataLayer,
2211
+ engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks">,
2212
+ ) {
2213
+ for (const run of await deliveryGraphRuns(data).find({ status: "running" })) {
2214
+ if (!run.process_key) continue;
2215
+ const processKey = run.process_key;
2216
+ try {
2217
+ const [snapshots, tasks] = await Promise.all([
2218
+ engine.searchProcessInstances({ processInstanceKeys: [processKey] }),
2219
+ engine.searchUserTasks({ processInstanceKey: processKey, state: "CREATED" }),
2220
+ ]);
2221
+ const state = snapshots.find((s) => String(s.processInstanceKey) === processKey)?.state ?? null;
2222
+ const projection = deriveDeliveryPhase(state, tasks, parseHumanLabels(run.human_labels));
2223
+ if (run.status !== projection.status || run.phase !== projection.phase || run.phase_node_id !== projection.phase_node_id) {
2224
+ await deliveryGraphRuns(data).update(run.run_key, {
2225
+ status: projection.status,
2226
+ phase: projection.phase,
2227
+ phase_node_id: projection.phase_node_id,
2228
+ updated_at: now(),
2229
+ });
2230
+ }
2231
+ } catch (err) {
2232
+ console.error(`[poller] delivery graph ${run.run_key}: ${err}`);
2233
+ }
2234
+ }
2235
+ }
2236
+
2199
2237
  export async function pollUserTasks(
2200
2238
  data: DataLayer,
2201
2239
  engine: EngineClient,
@@ -2384,6 +2422,7 @@ export async function pollOnce(
2384
2422
  await pollLineage(data);
2385
2423
  await pollMergesPerDay(data);
2386
2424
  await pollUserTasks(data, engine, engineRest);
2425
+ await pollDeliveryGraphPhase(data, engine);
2387
2426
  if (engineRest) {
2388
2427
  const base = engineRest.restAddress.replace(/\/+$/, "");
2389
2428
  const headers: Record<string, string> = { "content-type": "application/json" };
@@ -0,0 +1,16 @@
1
+ -- Contract phase for the escalation scope-override columns (migration 056, issue #395).
2
+ --
3
+ -- Migration 056 added `escalations.head_sha` + `escalations.scope_block` to give the DETERMINISTIC
4
+ -- scope-integrity gate a human-override door: it bound a scope escalation to the reviewed HEAD so a
5
+ -- same-HEAD human answer could override the block instead of re-escalating forever. That whole
6
+ -- deterministic gate has since been replaced by the `senior:scope-classify` AGENT classifier, which
7
+ -- reads each closed issue's acceptance criteria and honours the recorded human `answer` directly —
8
+ -- so nothing writes or reads these two columns any more. They are dead schema (a drift surface with
9
+ -- no source of truth behind them), so drop them.
10
+ --
11
+ -- 056 is forward-only and immutable (its ledger row stays), so this is the standard expand→contract
12
+ -- follow-up rather than an edit to 056. Both drops are safe: the columns were nullable/defaulted and
13
+ -- have no remaining writer or reader. Numbered after the current highest prefix (056); the runner
14
+ -- wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
15
+ ALTER TABLE escalations DROP COLUMN scope_block;
16
+ ALTER TABLE escalations DROP COLUMN head_sha;
@@ -0,0 +1,58 @@
1
+ -- The `startDeliveryGraph` run aggregate (ADR 0005 Decision 7, slice S5). The dispatch door
2
+ -- (`operations/startDeliveryGraph.ts`, POST /app/api/actions/start/delivery-graph) turns an
3
+ -- agent-authored `DeliveryGraph` into a RUNNING engine-native process (via the S4 runner) — but
4
+ -- because these graphs merge PRs and publish packages, dispatch is GATED on an approval of the
5
+ -- rendered preview and must be idempotent. This table is the aggregate that makes both properties
6
+ -- durable and gives the cockpit a row to show WHERE a run is parked:
7
+ --
8
+ -- • run_key (PK) — the idempotency key: a caller-supplied `idempotencyKey` or, by default, the
9
+ -- graph's content digest (`sha256(compiled.bpmn)[:12]`). A re-POST of the same graph collapses
10
+ -- onto the same row, so an in-flight (`running`) run short-circuits instead of double-launching
11
+ -- (mirrors `startPlan`'s `alreadyRunning`). The UNIQUE PK is the durable at-most-once fence.
12
+ -- • status — the run lifecycle: `awaiting-approval` (a side-effecting graph parked at the approval
13
+ -- gate — no instance started yet), `running` (dispatched to the engine), `done` (the instance
14
+ -- COMPLETED), `failed` (the instance TERMINATED, or instance-tracking reconciled a vanished
15
+ -- running instance — nano.app.json's `delivery_graph_runs` binding maps `onTerminated` to
16
+ -- `failed`), `abandoned` (a reserved terminal status in the lifecycle union, not currently
17
+ -- produced by the reconciler). `awaiting-approval`/`running` are the ACTIVE (in-flight) statuses shown in
18
+ -- the cockpit's active grid; only `running` is engine-instance-backed and thus instance-tracked
19
+ -- (nano.app.json keys off process_key), while `awaiting-approval` has no instance (process_key
20
+ -- NULL) and is display-only. The terminal three drop out of the cockpit's active grid.
21
+ -- • digest — the content-addressed approval token: a side-effecting graph dispatches only when the
22
+ -- caller presents `approvalToken == digest`. Persisted so a resumed/second POST can re-derive and
23
+ -- re-check it without recompiling out of band.
24
+ -- • phase / phase_node_id — the derived, display-only "where is it parked" projection the poller
25
+ -- (`pollDeliveryGraphPhase`) recomputes from the running instance's open user tasks (generalising
26
+ -- the epic_phase derived-phase machinery): e.g. "Parked on human node: manual OTP publish" vs a
27
+ -- bare "Running". The door seeds `phase` at write time — `Awaiting approval` for a parked
28
+ -- (awaiting-approval) row, `Running` at dispatch — and the poller then refines it from engine truth
29
+ -- for a running instance; `phase_node_id` is NULL until the poller projects a parked human node.
30
+ --
31
+ -- process_key is the started instance key (NULL while `awaiting-approval`); the counts are the
32
+ -- compiled graph's shape (nodes / human stop-points / side effects), stamped at dispatch so the grid
33
+ -- and the approval gate need no recompile to render.
34
+ CREATE TABLE IF NOT EXISTS delivery_graph_runs (
35
+ run_key TEXT PRIMARY KEY,
36
+ process_key TEXT,
37
+ process_definition_id TEXT,
38
+ digest TEXT NOT NULL,
39
+ status TEXT NOT NULL,
40
+ side_effecting INTEGER NOT NULL DEFAULT 0,
41
+ node_count INTEGER NOT NULL DEFAULT 0,
42
+ human_node_count INTEGER NOT NULL DEFAULT 0,
43
+ side_effect_count INTEGER NOT NULL DEFAULT 0,
44
+ title TEXT,
45
+ phase TEXT,
46
+ phase_node_id TEXT,
47
+ -- JSON map of compiled human-task element id (`delivery-human-task__<element>`) → a display label
48
+ -- (the human node's instruction first line, else its author node id). Stamped at dispatch so
49
+ -- `pollDeliveryGraphPhase` can render "Parked on human node: <label>" from the run row + the open
50
+ -- user tasks alone — no recompile of the graph in the poller.
51
+ human_labels TEXT,
52
+ created_at TEXT NOT NULL,
53
+ updated_at TEXT NOT NULL
54
+ );
55
+
56
+ -- The instance-tracking reconciler and pollDeliveryGraphPhase both scan by process instance key.
57
+ CREATE INDEX IF NOT EXISTS ix_delivery_graph_runs_process_key
58
+ ON delivery_graph_runs (process_key);
@@ -87,6 +87,12 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
87
87
  capturedAnswer = (job.variables as Record<string, unknown>).answer;
88
88
  return { status: "converged", summary: "resolved after the human answer" };
89
89
  });
90
+ // `senior:scope-classify` is likewise an externalTaskType (no app worker): the converged round
91
+ // routes through it before finalizing. Stub it as "scope delivered" so the happy path reaches
92
+ // persist-converged rather than parking on an unserviced agent job.
93
+ await app.engine.registerWorker("senior:scope-classify", () => {
94
+ return { scopeBlocked: false, scopeBlockReason: "" };
95
+ });
90
96
  });
91
97
 
92
98
  after(async () => {