@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
@@ -584,3 +584,30 @@ test("pollUserTasks (engine-first): a delivery-human task on an UNTRACKED run st
584
584
  assertEquals(byKey["35002"].subject_type, "delivery");
585
585
  assertEquals(byKey["35002"].subject_key, "dg-9"); // instance fallback — non-blank so it renders
586
586
  });
587
+
588
+ test("pollUserTasks (typed-seam fallback): projects an inlined delivery-human task on a RUNNING run, bucketed `delivery` (issue #442)", async () => {
589
+ // The reduced-capability host (no raw-REST surface) discovers open tasks by scanning each active
590
+ // subject's instance through the typed `openUserTasks` seam. A delivery-graph `human` node parks on its
591
+ // RUNNING run's instance, so that instance MUST be scanned here too — else the inlined
592
+ // `delivery-human-task__<node>` gate is dropped on this path even though its leak guard would accept it.
593
+ // Guards the OTHER discovery path the engine-first sweep tests don't reach.
594
+ const { data, stores } = memData({
595
+ delivery_graph_runs: [
596
+ { run_key: "delivery-graph-403eb22e", process_key: "dg-1", status: "running", title: "release runbook" },
597
+ { run_key: "delivery-graph-pending", process_key: null, status: "awaiting-approval", title: "not launched yet" },
598
+ ],
599
+ });
600
+ const engine = fakeEngine({
601
+ "dg-1": [{ userTaskKey: "35002", elementId: "delivery-human-task__n1" }],
602
+ });
603
+
604
+ await pollUserTasks(data, engine); // no engineRest → typed-seam fallback
605
+
606
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
607
+ assertEquals(Object.keys(byKey), ["35002"]);
608
+ assertEquals(byKey["35002"].element_id, "delivery-human-task__n1");
609
+ assertEquals(byKey["35002"].kind_label, "Delivery: human step");
610
+ assertEquals(byKey["35002"].subject_type, "delivery");
611
+ assertEquals(byKey["35002"].subject_key, "delivery-graph-403eb22e");
612
+ assertEquals(byKey["35002"].subject_title, "release runbook");
613
+ });
package/app/service.ts CHANGED
@@ -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 { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
31
32
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
32
33
  import { isDeliveryHumanElement } from "./deliveryHuman.ts";
33
34
  import { fleetSupportsDurableResume } from "./durableResume.ts";
@@ -51,7 +52,12 @@ import {
51
52
  } from "./github.ts";
52
53
  import { pollLineage } from "./lineage.ts";
53
54
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
54
- import { freshHeadRunAction, headRunPresenceCount, loadMergeProtocol } from "./mergeProtocol.ts";
55
+ import {
56
+ freshHeadRunAction,
57
+ headRunPresenceCount,
58
+ loadMergeProtocol,
59
+ type MergeProtocol,
60
+ } from "./mergeProtocol.ts";
55
61
  import { type PrLaneDecision, planPrLane, taskDependencyDepths } from "./mergeTrain.ts";
56
62
  import {
57
63
  capabilityGates,
@@ -178,7 +184,7 @@ export const MERGE_ADMIN = ["1", "true", "on", "yes"].includes(
178
184
 
179
185
  const now = () => new Date().toISOString();
180
186
 
181
- interface PullRequest {
187
+ export interface PullRequest {
182
188
  pr_key: string;
183
189
  repo: string;
184
190
  number: number;
@@ -1099,6 +1105,41 @@ async function advanceIfTerminalOutOfBand(
1099
1105
  return true;
1100
1106
  }
1101
1107
 
1108
+ /** Frugal-CI fresh-head-run self-heal, shared by the `"waiting"` and `"draft"` merge verdicts
1109
+ * (issue #454). Both verdicts feed the same {@link freshHeadRunAction} decision — when the repo's
1110
+ * merge protocol wants a fresh head run and this head has not been nudged yet, produce one
1111
+ * (mark-ready / reopen) and record the head so we fire at most once per landing attempt. Returns
1112
+ * `true` only when the self-heal was **actually applied** (the caller should re-poll); returns
1113
+ * `false` when no self-heal applies **or** the action was selected but failed (`ok === false`, e.g.
1114
+ * missing permission / repo policy) — so a caller that gates escalation on this (the `"draft"`
1115
+ * branch) falls through to the actionable escalation instead of `continue`-looping forever on a
1116
+ * self-heal that can never succeed. One implementation so the two verdicts can never drift (attempt
1117
+ * de-dupe, persistence, logging). `ensure` is injectable for tests; production uses the real
1118
+ * {@link ensureFreshHeadRun}. */
1119
+ export async function maybeEnsureFreshHeadRun(
1120
+ data: DataLayer,
1121
+ repo: string,
1122
+ number: number,
1123
+ prKey: string,
1124
+ protocol: MergeProtocol,
1125
+ verdict: "ready" | "waiting" | "conflict" | "blocked" | "draft",
1126
+ st: PrState,
1127
+ pr: PullRequest,
1128
+ ensure: typeof ensureFreshHeadRun = ensureFreshHeadRun,
1129
+ ): Promise<boolean> {
1130
+ const action = freshHeadRunAction(protocol, verdict, headRunPresenceCount(protocol, st), st.isDraft, {
1131
+ headRefOid: st.headRefOid,
1132
+ lastActionHeadRefOid: pr.fresh_head_run_head,
1133
+ });
1134
+ if (!action) return false;
1135
+ const ok = await ensure(repo, number, action).catch(() => false);
1136
+ if (ok && st.headRefOid) {
1137
+ await prs(data).update(prKey, { fresh_head_run_head: st.headRefOid, updated_at: now() });
1138
+ }
1139
+ console.log(`[poller] ${verdict} -> fresh head run (${action}) ${ok ? "requested" : "skipped"} -> ${prKey}`);
1140
+ return ok;
1141
+ }
1142
+
1102
1143
  /** Merge-stage poll pass (SPEC §11). Four durable waits, each keyed off the PR's `status`, are
1103
1144
  * advanced by correlating a message — mirroring the review-ready pattern so the process owns
1104
1145
  * the wait and this glue only signals when a GitHub condition is met:
@@ -1159,6 +1200,35 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1159
1200
  // the PR as UNSTABLE. The same handle is reused by the frugal-CI fresh-head-run branch below.
1160
1201
  const protocol = await loadMergeProtocol(repo, token).catch(() => null);
1161
1202
  const verdict = classifyMergeability(st, protocol ?? undefined);
1203
+ if (verdict === "draft") {
1204
+ // A draft PR is never landable — GitHub refuses the merge outright (issue #454). Two remedies,
1205
+ // in order: (1) self-heal — when the repo's merge protocol has a mark-ready capability
1206
+ // (`freshHeadRun: "ready"`/`"ready-or-reopen"`), mark the PR ready ourselves (the frugal-CI
1207
+ // path), which both un-drafts it and produces the required run, then re-poll; a `"reopen"`-only
1208
+ // protocol has NO mark-ready capability, so `freshHeadRunAction` returns null for a draft (a
1209
+ // reopen can't un-draft) and this falls straight through to (2). (2) otherwise — no self-heal
1210
+ // applies, OR the self-heal was attempted but could not be performed (e.g. missing permission /
1211
+ // repo policy) — escalate with an ACTIONABLE "mark it ready" message, instead of
1212
+ // `continue`-looping forever on a self-heal that can never succeed or surfacing GitHub's opaque
1213
+ // "blocked" refusal.
1214
+ if (protocol) {
1215
+ if (await maybeEnsureFreshHeadRun(data, repo, number, prKey, protocol, verdict, st, pr)) {
1216
+ continue; // re-poll: the mark-ready both un-drafts the PR and produces the required run
1217
+ }
1218
+ }
1219
+ // No applicable (or successful) protocol-driven self-heal → escalate so a human marks it ready.
1220
+ await flipToMergingThenPublish(data, engine, prKey, "waiting_merge", {
1221
+ name: "merge-ready",
1222
+ correlationKey: prKey,
1223
+ variables: {
1224
+ mergeState: verdict, // "draft" → gw-mergeable default → merge-esc-conflict (draft-aware FEEL)
1225
+ failingChecks: st.failingChecks,
1226
+ failingChecksList: st.failingCheckNames.join("\n"),
1227
+ },
1228
+ });
1229
+ console.log(`[poller] draft (no self-heal) -> escalate mark-ready -> ${prKey}`);
1230
+ continue;
1231
+ }
1162
1232
  if (verdict === "waiting") {
1163
1233
  // Frugal-CI remedy (#43): when the repo publishes a merge protocol that wants a fresh
1164
1234
  // head run and the PR has NO required head run yet, review has converged but the last push
@@ -1170,17 +1240,7 @@ export async function pollMerges(data: DataLayer, engine: EngineClient, token: s
1170
1240
  // `headRefOid`, so downstream merge-train PRs get a new nudge after every post-rebase
1171
1241
  // landing attempt.
1172
1242
  if (protocol) {
1173
- const action = freshHeadRunAction(protocol, verdict, headRunPresenceCount(protocol, st), st.isDraft, {
1174
- headRefOid: st.headRefOid,
1175
- lastActionHeadRefOid: pr.fresh_head_run_head,
1176
- });
1177
- if (action) {
1178
- const ok = await ensureFreshHeadRun(repo, number, action).catch(() => false);
1179
- if (ok && st.headRefOid) {
1180
- await prs(data).update(prKey, { fresh_head_run_head: st.headRefOid, updated_at: now() });
1181
- }
1182
- console.log(`[poller] fresh head run (${action}) ${ok ? "requested" : "skipped"} -> ${prKey}`);
1183
- }
1243
+ await maybeEnsureFreshHeadRun(data, repo, number, prKey, protocol, verdict, st, pr);
1184
1244
  }
1185
1245
  continue; // GitHub still computing / checks pending
1186
1246
  }
@@ -2245,6 +2305,19 @@ export async function pollDeliveryGraphPhase(
2245
2305
  }
2246
2306
  }
2247
2307
 
2308
+ /** Poll pass (ADR 0005 Decision 7): age out staged delivery-graph proposals whose TTL has elapsed by
2309
+ * flipping them to `expired`, so they drop out of the cockpit's staged grid rather than lingering there
2310
+ * only to fail dispatch. The grid filters purely on `status = 'staged'` (its datasource cannot express an
2311
+ * `expires_at > now` comparison), so this reconciliation sweep is what realises the proposal TTL. It is
2312
+ * data-only and idempotent — a proposal already terminal is left untouched. */
2313
+ export async function pollDeliveryProposals(data: DataLayer) {
2314
+ try {
2315
+ await sweepExpiredProposals(data);
2316
+ } catch (err) {
2317
+ console.error(`[poller] delivery graph proposals sweep: ${err}`);
2318
+ }
2319
+ }
2320
+
2248
2321
  export async function pollUserTasks(
2249
2322
  data: DataLayer,
2250
2323
  engine: EngineClient,
@@ -2381,6 +2454,11 @@ export async function pollUserTasks(
2381
2454
  for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
2382
2455
  for (const status of PR_ACTIVE_STATUSES) for (const pr of await prs(data).find({ status })) await scanInstance(pr.process_key);
2383
2456
  for (const review of await activeConformanceReviews(data)) await scanInstance(review.process_key);
2457
+ // A delivery-graph `human` node parks on its RUNNING run's engine instance (an awaiting-approval run
2458
+ // has no instance yet — mirrors `pollDeliveryGraphPhase`). Scan it too so the inlined
2459
+ // `delivery-human-task__<node>` gate surfaces on this reduced-capability path exactly as it does on
2460
+ // the engine-first sweep — otherwise the typed-seam host silently drops every delivery human gate.
2461
+ for (const run of await deliveryGraphRuns(data).find({ status: "running" })) await scanInstance(run.process_key);
2384
2462
  }
2385
2463
 
2386
2464
  const desired = [...desiredByKey.values()];
@@ -2415,6 +2493,7 @@ export async function pollOnce(
2415
2493
  await pollLineage(data);
2416
2494
  await pollUserTasks(data, engine, engineRest);
2417
2495
  await pollDeliveryGraphPhase(data, engine);
2496
+ await pollDeliveryProposals(data);
2418
2497
  if (engineRest) {
2419
2498
  const base = engineRest.restAddress.replace(/\/+$/, "");
2420
2499
  const headers: Record<string, string> = { "content-type": "application/json" };
package/app/stage.test.ts CHANGED
@@ -68,14 +68,28 @@ test("skipped: the three converge/auto_merge cases", () => {
68
68
  assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 1 })).skipped, "");
69
69
  });
70
70
 
71
- test("attention: derives from status alone (blocked, escalation, none)", () => {
72
- // Issue #332 dropped the denormalised escalation pointer/question columns; `attention` is now a pure
73
- // function of `status` — `awaiting_operator` (a parked blocked run) "blocked", `escalated` "⚠".
74
- assertEquals(deriveStage(base({ status: "awaiting_operator" })).attention, "blocked");
75
- assertEquals(deriveStage(base({ status: "escalated" })).attention, "");
71
+ test("attention: derives from OPEN user-task engine truth (blocked, escalation, none), NOT from status", () => {
72
+ // Issue #422: `attention` is a pure function of whether an OPEN native user task exists for the run
73
+ // (the `user_tasks` inbox the authoritative "who is waiting on a human" set), never of the sticky
74
+ // `status` variable. An open `feature-blocked` task → "blocked"; an open `feature-escalation` task → "".
75
+ assertEquals(deriveStage(base({ status: "awaiting_operator", hasOpenBlockedTask: true })).attention, "blocked");
76
+ assertEquals(deriveStage(base({ status: "escalated", hasOpenEscalationTask: true })).attention, "⚠");
76
77
  assertEquals(deriveStage(base({ status: "running" })).attention, null);
77
78
  });
78
79
 
80
+ test("attention #422: an ANSWERED escalation (status still 'escalated' but NO open task) shows NO badge", () => {
81
+ // The answer-loop returns the token to `implement-task` with no status reset, so `status` reads a
82
+ // stale "escalated" while the escalation user task is already gone. Sourcing the badge from engine
83
+ // truth (no open task) clears the ⚠ — the drift the old `status`-derived badge produced.
84
+ assertEquals(deriveStage(base({ status: "escalated" })).attention, null);
85
+ assertEquals(deriveStage(base({ status: "escalated", hasOpenEscalationTask: false })).attention, null);
86
+ // And an escalated run WHOSE task is genuinely open still shows ⚠.
87
+ assertEquals(deriveStage(base({ status: "escalated", hasOpenEscalationTask: true })).attention, "⚠");
88
+ // Symmetrically for the blocked/operator wait.
89
+ assertEquals(deriveStage(base({ status: "awaiting_operator" })).attention, null);
90
+ assertEquals(deriveStage(base({ status: "awaiting_operator", hasOpenBlockedTask: true })).attention, "blocked");
91
+ });
92
+
79
93
  // The three parked-status rows called out by the plan review.
80
94
  test("escalated WITH pr_key → PR open / null", () => {
81
95
  const d = deriveStage(base({ status: "escalated", pr_key: "o/r#5" }));
@@ -89,8 +103,8 @@ test("escalated WITHOUT pr_key → Implementing / null", () => {
89
103
  assertEquals(d.state, null);
90
104
  });
91
105
 
92
- test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when parked", () => {
93
- const d = deriveStage(base({ status: "awaiting_operator", pr_key: null }));
106
+ test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when its task is open", () => {
107
+ const d = deriveStage(base({ status: "awaiting_operator", pr_key: null, hasOpenBlockedTask: true }));
94
108
  assertEquals(d.stage, "Implementing");
95
109
  assertEquals(d.state, null);
96
110
  assertEquals(d.attention, "blocked");
package/app/stage.ts CHANGED
@@ -43,6 +43,14 @@ export interface StageInput {
43
43
  pr_key?: string | null;
44
44
  converge?: number | boolean | null;
45
45
  auto_merge?: number | boolean | null;
46
+ /** Engine truth for the `attention` badge (issue #422): whether an OPEN native user task of each
47
+ * human-wait kind currently exists for this run, from the `user_tasks` inbox (`pollUserTasks`, the
48
+ * authoritative "who is waiting on a human" set). `attention` derives from THESE, never from the
49
+ * drift-prone `status` variable — so once an escalation is answered (its `user_tasks` row deleted)
50
+ * the badge clears immediately even while `status` still reads a stale `"escalated"`. Omitted/false
51
+ * ⇒ no open task ⇒ no badge. `feature_read_model` (075) mirrors this with correlated EXISTS lookups. */
52
+ hasOpenBlockedTask?: boolean | null;
53
+ hasOpenEscalationTask?: boolean | null;
46
54
  }
47
55
 
48
56
  /** The derived pipeline projection for one run. `skipped` is a space-separated set of stage keys not
@@ -91,11 +99,16 @@ export function deriveStage(run: StageInput): DerivedStage {
91
99
 
92
100
  // `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
93
101
  // a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
94
- // Derived from `status` alone (issue #332): the parked-task pointers that used to source it were
95
- // dropped with the denormalised escalation surface, and the authoritative "who is waiting on a human"
96
- // list now lives on the `user_tasks` Tasks inbox. `awaiting_operator` (parked at `feature-blocked`)
97
- // shows the blocked glyph; `escalated` (parked at `feature-escalation`) shows the badge.
98
- const attention = status === "awaiting_operator" ? "blocked" : status === "escalated" ? "" : null;
102
+ // Derived from ENGINE TRUTH the presence of an OPEN native user task (issue #422), NOT from the
103
+ // `status` variable. `status` is worker-written imperatively and goes stale on the answer-loop back
104
+ // into `implement-task` (the process does not reset it), so a run whose escalation was already
105
+ // ANSWERED still reads `status="escalated"` until its next job completes; sourcing the badge from
106
+ // that value made the read model lie (a resolved run flaggedon Overview). The authoritative
107
+ // "who is waiting on a human" set is the `user_tasks` inbox (`pollUserTasks`), which holds a row
108
+ // IFF the task is open and deletes it the moment it is answered — so a run shows the blocked glyph
109
+ // IFF an open `feature-blocked` task exists, and ⚠ IFF an open `feature-escalation` task exists.
110
+ // Once answered, the row is gone and the badge clears regardless of the stale `status`.
111
+ const attention = truthy(run.hasOpenBlockedTask) ? "blocked" : truthy(run.hasOpenEscalationTask) ? "⚠" : null;
99
112
 
100
113
  return { stage, state, skipped: skippedKeys.join(" "), attention };
101
114
  }
@@ -0,0 +1,48 @@
1
+ -- The `staged` delivery-graph proposal store (ADR 0005 Decision 7, issue #460). This realises
2
+ -- `propose → preview → approve → dispatch` as intended: the agent-facing surface ends at
3
+ -- propose → compile → STAGE, and a HUMAN dispatches the staged proposal from the cockpit. The old
4
+ -- `approvalToken` was a REPLAYABLE content digest handed back to the same caller, so any holder of
5
+ -- the API credential self-approved. Removing the dispatch affordance from the agent surface (there is
6
+ -- no `start` endpoint) dissolves that hole: the compile door persists the compiled graph HERE as a
7
+ -- `staged` proposal and returns only a preview + a navigational `reviewUrl` — nothing that can trigger
8
+ -- a run. The cockpit lists these rows, renders the preview, and dispatches the one the operator picks.
9
+ --
10
+ -- • digest (PK) — the content address of the compiled graph (`sha256(compiled.bpmn)[:12]`), the
11
+ -- SAME digest the runner uses for the content-addressed deploy id. It NAMES the proposal so the
12
+ -- agent can hand the operator an unambiguous "dispatch <digest>" and the operator dispatches
13
+ -- EXACTLY the digest they previewed. A re-compile of the same bytes is idempotent (same PK).
14
+ -- • logical_key — the LOGICAL graph identity (the graph's `name`, else the digest) used to
15
+ -- SUPERSEDE: staging a changed graph (new digest) for the same logical key retires the prior
16
+ -- staged proposal, so the cockpit shows one live proposal per logical graph, not every recompile.
17
+ -- • graph — the original `DeliveryGraph` JSON, retained so the cockpit dispatch action can run the
18
+ -- runner for the previewed digest without the agent re-submitting anything.
19
+ -- • preview — the rendered preview JSON (`{ diagram, sideEffects, humanNodes }`) the cockpit shows,
20
+ -- stamped at stage time so the list renders without recompiling.
21
+ -- • status — `staged` (awaiting operator review), `superseded` (replaced by a newer digest for its
22
+ -- logical key), `dispatched` (the operator launched it), or `expired` (aged out of its TTL before
23
+ -- dispatch). Only `staged` rows show in the cockpit; the poller sweeps aged-out `staged` rows to
24
+ -- `expired` (the grid's datasource filter is equality-only, so expiry is realised by that status
25
+ -- flip, not an `expires_at > now` clause).
26
+ -- • expires_at — the TTL horizon. Staged proposals age out of the cockpit list so a stale entry an
27
+ -- operator never dispatched does not linger; the poller flips an aged-out `staged` row to `expired`.
28
+ CREATE TABLE IF NOT EXISTS delivery_graph_proposals (
29
+ digest TEXT PRIMARY KEY,
30
+ logical_key TEXT NOT NULL,
31
+ title TEXT,
32
+ graph TEXT NOT NULL,
33
+ preview TEXT NOT NULL,
34
+ node_count INTEGER NOT NULL DEFAULT 0,
35
+ human_node_count INTEGER NOT NULL DEFAULT 0,
36
+ side_effect_count INTEGER NOT NULL DEFAULT 0,
37
+ side_effecting INTEGER NOT NULL DEFAULT 0,
38
+ status TEXT NOT NULL DEFAULT 'staged',
39
+ created_at TEXT NOT NULL,
40
+ updated_at TEXT NOT NULL,
41
+ expires_at TEXT NOT NULL
42
+ );
43
+
44
+ -- Supersede scans by logical_key; the cockpit list filters by status + expiry.
45
+ CREATE INDEX IF NOT EXISTS ix_delivery_graph_proposals_logical
46
+ ON delivery_graph_proposals (logical_key);
47
+ CREATE INDEX IF NOT EXISTS ix_delivery_graph_proposals_status
48
+ ON delivery_graph_proposals (status);
@@ -0,0 +1,113 @@
1
+ -- Feature-run `attention` badge: derive it from engine truth (an OPEN native user task), not from the
2
+ -- drift-prone `status` variable (issue #422 — the L1 surface closure #439 named but did not ship).
3
+ --
4
+ -- 073_feature_read_model.sql retired the WRITE-TIME display projection (L2) into this VIEW, but it
5
+ -- still derived `attention` from the row's own `status` column:
6
+ -- WHEN fr.status = 'awaiting_operator' THEN 'blocked'
7
+ -- WHEN fr.status = 'escalated' THEN '⚠'
8
+ -- `status` is a process-scope variable set imperatively by the workers on the happy path. The `feature`
9
+ -- process loops the answer arm (`w_answerLoop`, resolution="answer") straight back into `implement-task`
10
+ -- with NO reset step, so after an escalation is ANSWERED the token is ACTIVE again at `implement-task`
11
+ -- while `status` still reads the previous iteration's `"escalated"` until the re-running agent job
12
+ -- completes and overwrites it (issue #422, observed live on merlin: feature instance 31779 showing ⚠
13
+ -- on Overview though its escalation was resolved and it was back implementing). Deriving the badge from
14
+ -- that sticky value makes the read model LIE — the exact "projected state maintained imperatively at
15
+ -- write time" defect class #439 set out to close ("derive it, don't maintain it — No Drift Surfaces").
16
+ --
17
+ -- The authoritative "who is waiting on a human" set is NOT `status` — it is the `user_tasks` inbox
18
+ -- (034_user_tasks_inbox.sql): `pollUserTasks` (app/service.ts) reconciles exactly one row per CURRENTLY
19
+ -- OPEN escalation user task from the engine and DELETES the row the moment the task closes (answered
20
+ -- here, via the Tasks inbox, or out-of-band). So a run is:
21
+ -- * awaiting an operator (blocked glyph) IFF an open `feature-blocked` user task exists for it, and
22
+ -- * escalated (⚠ badge) IFF an open `feature-escalation` user task exists for it.
23
+ -- Deriving `attention` from that presence (engine truth) instead of `status` closes the surface: once
24
+ -- the escalation is answered the `user_tasks` row is gone, so ⚠ clears immediately REGARDLESS of the
25
+ -- stale `status`. There is no stored column and no write path any writer can leave stale — the badge is
26
+ -- a pure function of the live open-task set, recomputed on every read. `deriveStage` (app/stage.ts)
27
+ -- remains the canonical TS oracle: it now takes the same open-task signals, and
28
+ -- app/featureReadModel.test.ts pins the VIEW to it in lockstep over the full status × open-task matrix
29
+ -- (including the #422 case: status='escalated' with NO open task → attention NULL).
30
+ --
31
+ -- pollUserTasks keys these rows `subject_type='feature'`, `subject_key=<feature_key>` (app/service.ts
32
+ -- DEFAULT_SUBJECT_TYPE / contextFor), so the correlated match is on `fr.feature_key`. `stage` is
33
+ -- deliberately UNCHANGED — an escalated/awaiting_operator run maps to `Implementing`, which is correct
34
+ -- whether or not the flag is stale (a run back at `implement-task` IS implementing); only the attention
35
+ -- badge was drifting, so only it moves to engine-truth derivation.
36
+ --
37
+ -- Forward-only, non-additive to `feature_runs` (a VIEW redefinition): `DROP VIEW` then `CREATE VIEW`,
38
+ -- plus one idempotent `CREATE INDEX IF NOT EXISTS` on `user_tasks` to front the correlated `attention`
39
+ -- lookups (see the trailing index comment).
40
+ -- 073 is a MERGED, IMMUTABLE migration — never edited; this is a NEW migration that supersedes its VIEW
41
+ -- definition. `user_tasks` (034) already exists earlier in the chain, so the correlated subquery
42
+ -- resolves. A single plain `CREATE VIEW … SELECT … FROM feature_runs fr` (the `user_tasks` lookups are
43
+ -- nested EXISTS subqueries at paren depth ≥ 1, so `feature_runs fr` stays the sole top-level FROM and
44
+ -- every output column stays aliased — the static pages↔schema contract guard, scripts/pages-contract.
45
+ -- test.ts, still parses the projection). Numbered after the current highest prefix on origin/main (074).
46
+ -- The runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
47
+
48
+ DROP VIEW IF EXISTS feature_read_model;
49
+
50
+ CREATE VIEW feature_read_model AS
51
+ SELECT
52
+ fr.feature_key AS feature_key,
53
+ fr.repo AS repo,
54
+ fr.issue_number AS issue_number,
55
+ fr.issue_url AS issue_url,
56
+ fr.title AS title,
57
+ fr.base_branch AS base_branch,
58
+ fr.status AS status,
59
+ fr.process_key AS process_key,
60
+ fr.pr_key AS pr_key,
61
+ fr.converge AS converge,
62
+ fr.auto_merge AS auto_merge,
63
+ fr.outcome AS outcome,
64
+ fr.delivery_label AS delivery_label,
65
+ fr.acknowledged_at AS acknowledged_at,
66
+ fr.created_at AS created_at,
67
+ fr.updated_at AS updated_at,
68
+ (CASE
69
+ WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') THEN 'Done'
70
+ WHEN fr.status = 'converging' THEN 'Converging'
71
+ WHEN (fr.pr_key IS NOT NULL AND fr.pr_key <> '') OR fr.status = 'opened' THEN 'PR open'
72
+ WHEN fr.status IN ('running', 'escalated', 'awaiting_operator') THEN 'Implementing'
73
+ ELSE 'Requested'
74
+ END) AS stage,
75
+ (CASE
76
+ WHEN fr.status IN ('merged', 'converged') THEN 'ok'
77
+ WHEN fr.status = 'blocked' THEN 'blocked'
78
+ WHEN fr.status IN ('failed', 'skipped', 'abandoned') THEN 'failed'
79
+ ELSE NULL
80
+ END) AS stage_state,
81
+ (CASE
82
+ WHEN NOT (fr.converge IS NOT NULL AND fr.converge <> 0) THEN 'Converging Merging'
83
+ WHEN NOT (fr.auto_merge IS NOT NULL AND fr.auto_merge <> 0) THEN 'Merging'
84
+ ELSE ''
85
+ END) AS stage_skipped,
86
+ (CASE
87
+ WHEN EXISTS (
88
+ SELECT 1 FROM user_tasks ut
89
+ WHERE ut.subject_type = 'feature' AND ut.subject_key = fr.feature_key
90
+ AND ut.element_id = 'feature-blocked'
91
+ ) THEN 'blocked'
92
+ WHEN EXISTS (
93
+ SELECT 1 FROM user_tasks ut
94
+ WHERE ut.subject_type = 'feature' AND ut.subject_key = fr.feature_key
95
+ AND ut.element_id = 'feature-escalation'
96
+ ) THEN '⚠'
97
+ ELSE NULL
98
+ END) AS attention,
99
+ (CASE
100
+ WHEN fr.status IN ('merged', 'converged', 'blocked', 'failed', 'skipped', 'abandoned') AND fr.acknowledged_at IS NOT NULL THEN 'history'
101
+ ELSE 'active'
102
+ END) AS list_bucket
103
+ FROM feature_runs fr;
104
+
105
+ -- Supporting index for the correlated `attention` EXISTS lookups above. Each row of
106
+ -- `feature_read_model` probes `user_tasks` by `(subject_type, subject_key, element_id)` (twice: once
107
+ -- for `feature-blocked`, once for `feature-escalation`); the only prior index (034) is on
108
+ -- `(element_id, updated_at)`, which does not front the equality on `subject_type`/`subject_key`, so a
109
+ -- page reading many `feature_runs` rows would repeat a `user_tasks` scan per row. A composite index on
110
+ -- the exact equality tuple turns each probe into an index seek. `IF NOT EXISTS` keeps the migration
111
+ -- idempotent on any DB that already carries the index.
112
+ CREATE INDEX IF NOT EXISTS idx_user_tasks_subject_element
113
+ ON user_tasks(subject_type, subject_key, element_id);
@@ -160,6 +160,11 @@ shared JSON contract means either can be swapped in later without touching the a
160
160
 
161
161
  ### 7. Submission is propose → preview → approve → dispatch, idempotent, over the self-describing endpoint
162
162
 
163
+ > **Superseded — see the *Amendment (issue #460)* at the end of this section.** The `POST
164
+ > /actions/start/delivery-graph` agent endpoint described in the following paragraph was **never
165
+ > shipped and has been removed**; the agent surface ends at propose → compile → stage and dispatch is
166
+ > operator-only. The paragraph below is retained as the original (Proposed) decision record.
167
+
163
168
  Graphs are submitted exactly as epics are today — via a **new (proposed)** `POST
164
169
  /actions/start/delivery-graph` endpoint (paths are relative to the agent guide's `__BASE__` prefix,
165
170
  matching the guide's style) with the JSON body, discovered via the agent guide (which already
@@ -174,6 +179,19 @@ at-least-once execution (mirroring the release workflow's `npx semantic-release`
174
179
  "skip already-published" discipline — `.github/workflows/release.yml`) so a
175
180
  resume cannot double-fire.
176
181
 
182
+ > **Amendment (issue #460): dispatch is operator-only.** As implemented, the "approve → dispatch"
183
+ > half of this decision is **not** an agent endpoint. The agent surface ends at **propose → compile →
184
+ > stage**: the `POST /actions/compile-delivery-graph` door validates + previews the graph and, on
185
+ > success, **stages** it as a proposal (a durable `delivery_graph_proposals` row, content-addressed by
186
+ > `digest`, superseded per logical graph + TTL-bounded), returning only a preview + a navigational
187
+ > `reviewUrl` — no run key, token, or PIK. A **human dispatches** the staged proposal from the cockpit's
188
+ > Delivery Graphs page (`POST /actions/delivery-graph/dispatch` by `digest`, an operator route). The
189
+ > originally-proposed agent `POST /actions/start/delivery-graph` door — where the same caller was handed
190
+ > a content-addressed `approvalToken` to re-submit with — was **removed**: that "approval" was a
191
+ > **replayable** digest returned to the approver, so any holder of the API credential self-approved.
192
+ > Dispatch-by-absence (there is no agent start door) closes that hole categorically; the idempotent
193
+ > at-most-once launch fence is retained on the operator dispatch path.
194
+
177
195
  ## Consequences
178
196
 
179
197
  - nwf gains a **generic delivery-graph runner** that composes its existing primitives; the motivating