@nanobpm/nano-workforce 0.202.0 → 0.203.1

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 (78) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +48 -0
  3. package/app/admittedStaging.test.ts +71 -0
  4. package/app/agentActivationLedger.ts +5 -0
  5. package/app/agentCompletion.test.ts +33 -0
  6. package/app/agentCompletion.ts +108 -2
  7. package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
  8. package/app/contracts.ts +23 -0
  9. package/app/deliveryGraphDispatch.ts +6 -0
  10. package/app/deliveryGraphReadModel.test.ts +2 -2
  11. package/app/deliveryGraphRun.ts +18 -0
  12. package/app/deliveryRunner.ts +9 -1
  13. package/app/escalationSteward.test.ts +452 -0
  14. package/app/escalationSteward.ts +537 -0
  15. package/app/escalationStewardPoll.test.ts +123 -0
  16. package/app/feature.test.ts +16 -0
  17. package/app/feature.ts +11 -0
  18. package/app/featureDelivery.test.ts +23 -0
  19. package/app/mcpToolSurface.ts +17 -1
  20. package/app/plan.ts +49 -6
  21. package/app/planLowering.test.ts +26 -1
  22. package/app/planLowering.ts +5 -0
  23. package/app/promotionPoll.test.ts +19 -0
  24. package/app/pullRequestReadModel.test.ts +1 -1
  25. package/app/service.test.ts +36 -0
  26. package/app/service.ts +139 -5
  27. package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
  28. package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
  29. package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
  30. package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
  31. package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
  32. package/docs/agent-guide.md +280 -814
  33. package/docs/mcp-runbook.md +2 -0
  34. package/e2e/escalation-steward.e2e.ts +211 -0
  35. package/nano.app.json +10 -1
  36. package/openapi.yaml +365 -0
  37. package/operations/dispatchDeliveryGraph.ts +6 -1
  38. package/operations/listActivePrs.test.ts +60 -1
  39. package/operations/listActivePrs.ts +3 -3
  40. package/operations/listAutoResolutions.ts +83 -0
  41. package/operations/reviewAutoResolution.test.ts +137 -0
  42. package/operations/reviewAutoResolution.ts +42 -0
  43. package/operations/startConvergenceLoop.ts +5 -1
  44. package/operations/startEpicSet.admission.integration.test.ts +2 -2
  45. package/operations/startEpicSet.ts +8 -2
  46. package/operations/startFeature.ts +5 -0
  47. package/operations/startPlanFanout.ts +4 -1
  48. package/package.json +1 -1
  49. package/pages/_nav.json +12 -0
  50. package/pages/auto-resolutions.page.json +191 -0
  51. package/pages/board.page.json +17 -0
  52. package/pages/cockpit.page.json +17 -0
  53. package/pages/delivery-graph-detail.page.json +17 -0
  54. package/pages/delivery-graphs/staged.mount.js +17 -5
  55. package/pages/delivery-graphs.page.json +17 -0
  56. package/pages/epic-detail.page.json +17 -0
  57. package/pages/epic.page.json +19 -1
  58. package/pages/feature.page.json +19 -1
  59. package/pages/home.page.json +22 -0
  60. package/pages/lineage.page.json +17 -0
  61. package/pages/mcp.page.json +17 -0
  62. package/pages/overview.page.json +17 -0
  63. package/pages/tasks.page.json +17 -0
  64. package/pages/velocity.page.json +17 -0
  65. package/resources/processes/escalation-steward.bpmn +162 -0
  66. package/resources/prompts/escalation-steward.md +87 -0
  67. package/skills/nano-workforce/SKILL.md +20 -65
  68. package/test/delivery-graphs-staged-embed.test.ts +13 -0
  69. package/test/derivation-parity/derivation-parity.test.ts +1 -0
  70. package/test/derivation-parity/flows.ts +12 -0
  71. package/workers/converge-feature/worker.ts +6 -1
  72. package/workers/delivery-connector/worker.test.ts +72 -1
  73. package/workers/delivery-connector/worker.ts +53 -2
  74. package/workers/escalation-steward-collect/worker.test.ts +180 -0
  75. package/workers/escalation-steward-collect/worker.ts +99 -0
  76. package/workers/escalation-steward-record/worker.test.ts +740 -0
  77. package/workers/escalation-steward-record/worker.ts +428 -0
  78. package/workers/record-wave/worker.ts +5 -1
@@ -0,0 +1,229 @@
1
+ -- Issue #889: opt-in escalation steward. Two halves, both additive (expand-and-contract):
2
+ --
3
+ -- 1. `auto_resolve_escalations` (0|1, default 0) on each escalation-raising subject table —
4
+ -- `feature_runs`, `plans`, `pull_requests`, `delivery_graph_runs` — recording whether the work
5
+ -- OPTED IN to having its open escalations auto-resolved by the steward. The steward's
6
+ -- eligibility derivation (app/escalationSteward.ts) reads the flag off the SUBJECT row keyed by
7
+ -- the escalation's owning process instance, so an escalation from non-opted-in work is never
8
+ -- touched. Mirrored into the `delivery_units` aggregate (ADR 0006) — the sync triggers (089) and
9
+ -- compat VIEWs (091) are re-created verbatim plus the new column, exactly as migration 116 did
10
+ -- for `human_approval` (parity-tested in app/deliveryUnit.test.ts).
11
+ --
12
+ -- 2. `escalation_auto_resolutions` — the durable, reviewable record of every escalation the steward
13
+ -- ACTED ON or ABSTAINED from (one row each), backing the Auto-resolutions view and the
14
+ -- dismiss/intervene doors. `review_state` starts `pending`; an operator `dismiss`/`intervene`
15
+ -- folds it to `dismissed`/`intervened` with `reviewed_by`/`reviewed_at`/`review_note`.
16
+
17
+ ALTER TABLE feature_runs ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;
18
+ ALTER TABLE plans ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;
19
+ ALTER TABLE pull_requests ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;
20
+ ALTER TABLE delivery_graph_runs ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;
21
+
22
+ -- Mirror the new column through the `delivery_units` aggregate so the compat VIEWs stay row-for-row
23
+ -- equal to their base tables (ADR 0006, parity-tested in app/deliveryUnit.test.ts).
24
+ ALTER TABLE delivery_units ADD COLUMN auto_resolve_escalations INTEGER;
25
+ UPDATE delivery_units SET auto_resolve_escalations = 0 WHERE auto_resolve_escalations IS NULL;
26
+
27
+ DROP TRIGGER IF EXISTS feature_runs__du_ai;
28
+ CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
29
+ BEGIN
30
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
31
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
32
+ END;
33
+
34
+ DROP TRIGGER IF EXISTS feature_runs__du_au;
35
+ CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
36
+ BEGIN
37
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
38
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
39
+ END;
40
+
41
+ DROP TRIGGER IF EXISTS plans__du_ai;
42
+ CREATE TRIGGER plans__du_ai AFTER INSERT ON plans
43
+ BEGIN
44
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, auto_resolve_escalations, created_at, updated_at)
45
+ VALUES ('epic:' || NEW.plan_key, 'epic', NEW.plan_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'planning' THEN 'requested' WHEN NEW.status = 'dispatched' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('planning') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('dispatched') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.status, NEW.task_count, NEW.process_key, NEW.outcome, NEW.gate_wave, NEW.blackboard_token, NEW.retro_started_at, NEW.base_branch, NEW.epic_phase, NEW.promotion_pr, NEW.promotion_state, NEW.acknowledged_at, NEW.list_bucket, NEW.ack_open, NEW.wait_gate, NEW.wait_gate_label, NEW.bound_artifacts, NEW.auto_resolve_escalations, NEW.created_at, NEW.updated_at);
46
+ END;
47
+
48
+ DROP TRIGGER IF EXISTS plans__du_au;
49
+ CREATE TRIGGER plans__du_au AFTER UPDATE ON plans
50
+ BEGIN
51
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, auto_resolve_escalations, created_at, updated_at)
52
+ VALUES ('epic:' || NEW.plan_key, 'epic', NEW.plan_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'planning' THEN 'requested' WHEN NEW.status = 'dispatched' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('planning') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('dispatched') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.status, NEW.task_count, NEW.process_key, NEW.outcome, NEW.gate_wave, NEW.blackboard_token, NEW.retro_started_at, NEW.base_branch, NEW.epic_phase, NEW.promotion_pr, NEW.promotion_state, NEW.acknowledged_at, NEW.list_bucket, NEW.ack_open, NEW.wait_gate, NEW.wait_gate_label, NEW.bound_artifacts, NEW.auto_resolve_escalations, NEW.created_at, NEW.updated_at);
53
+ END;
54
+
55
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_ai;
56
+ CREATE TRIGGER delivery_graph_runs__du_ai AFTER INSERT ON delivery_graph_runs
57
+ BEGIN
58
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, auto_resolve_escalations, created_at, updated_at)
59
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.auto_resolve_escalations, NEW.created_at, NEW.updated_at);
60
+ END;
61
+
62
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_au;
63
+ CREATE TRIGGER delivery_graph_runs__du_au AFTER UPDATE ON delivery_graph_runs
64
+ BEGIN
65
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, auto_resolve_escalations, created_at, updated_at)
66
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.auto_resolve_escalations, NEW.created_at, NEW.updated_at);
67
+ END;
68
+
69
+ DROP VIEW IF EXISTS feature_runs__units;
70
+ CREATE VIEW feature_runs__units AS
71
+ SELECT
72
+ du.legacy_key AS feature_key,
73
+ du.repo AS repo,
74
+ du.issue_number AS issue_number,
75
+ du.issue_url AS issue_url,
76
+ du.base_branch AS base_branch,
77
+ du.status AS status,
78
+ du.process_key AS process_key,
79
+ du.pr_key AS pr_key,
80
+ du.converge AS converge,
81
+ du.auto_merge AS auto_merge,
82
+ du.human_approval AS human_approval,
83
+ du.auto_resolve_escalations AS auto_resolve_escalations,
84
+ du.outcome AS outcome,
85
+ du.created_at AS created_at,
86
+ du.updated_at AS updated_at,
87
+ du.delivery_label AS delivery_label,
88
+ du.title AS title,
89
+ du.acknowledged_at AS acknowledged_at,
90
+ du.stage AS stage,
91
+ du.stage_state AS stage_state,
92
+ du.stage_skipped AS stage_skipped,
93
+ du.attention AS attention,
94
+ du.list_bucket AS list_bucket
95
+ FROM delivery_units du
96
+ WHERE du.kind = 'feature';
97
+
98
+ DROP VIEW IF EXISTS plans__units;
99
+ CREATE VIEW plans__units AS
100
+ SELECT
101
+ du.legacy_key AS plan_key,
102
+ du.repo AS repo,
103
+ du.issue_number AS issue_number,
104
+ du.issue_url AS issue_url,
105
+ du.title AS title,
106
+ du.status AS status,
107
+ du.task_count AS task_count,
108
+ du.process_key AS process_key,
109
+ du.outcome AS outcome,
110
+ du.created_at AS created_at,
111
+ du.updated_at AS updated_at,
112
+ du.gate_wave AS gate_wave,
113
+ du.blackboard_token AS blackboard_token,
114
+ du.retro_started_at AS retro_started_at,
115
+ du.base_branch AS base_branch,
116
+ du.epic_phase AS epic_phase,
117
+ du.promotion_pr AS promotion_pr,
118
+ du.promotion_state AS promotion_state,
119
+ du.acknowledged_at AS acknowledged_at,
120
+ du.list_bucket AS list_bucket,
121
+ du.ack_open AS ack_open,
122
+ du.wait_gate AS wait_gate,
123
+ du.wait_gate_label AS wait_gate_label,
124
+ du.bound_artifacts AS bound_artifacts,
125
+ du.auto_resolve_escalations AS auto_resolve_escalations
126
+ FROM delivery_units du
127
+ WHERE du.kind = 'epic';
128
+
129
+ DROP VIEW IF EXISTS delivery_graph_runs__units;
130
+ CREATE VIEW delivery_graph_runs__units AS
131
+ SELECT
132
+ du.legacy_key AS run_key,
133
+ du.process_key AS process_key,
134
+ du.process_definition_id AS process_definition_id,
135
+ du.digest AS digest,
136
+ du.status AS status,
137
+ du.side_effecting AS side_effecting,
138
+ du.node_count AS node_count,
139
+ du.human_node_count AS human_node_count,
140
+ du.side_effect_count AS side_effect_count,
141
+ du.title AS title,
142
+ du.phase AS phase,
143
+ du.phase_node_id AS phase_node_id,
144
+ du.human_labels AS human_labels,
145
+ du.created_at AS created_at,
146
+ du.updated_at AS updated_at,
147
+ du.acknowledged_at AS acknowledged_at,
148
+ du.auto_resolve_escalations AS auto_resolve_escalations
149
+ FROM delivery_units du
150
+ WHERE du.kind = 'delivery-graph';
151
+
152
+ -- The durable, reviewable record of every escalation the steward acted on or abstained from.
153
+ CREATE TABLE escalation_auto_resolutions (
154
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
155
+ -- The escalation user task acted on (the engine `userTaskKey`), its BPMN element id, and the
156
+ -- escalation kind label at the time (denormalised so the record survives the task completing).
157
+ user_task_key TEXT NOT NULL,
158
+ escalation_kind TEXT,
159
+ element_id TEXT,
160
+ -- The subject the escalation belongs to (PR key / feature key / plan key / delivery run key).
161
+ subject_type TEXT,
162
+ subject_key TEXT,
163
+ subject_url TEXT,
164
+ process_instance_key TEXT,
165
+ -- Snapshots of what was asked and the choices on offer, so the record is reviewable after the fact.
166
+ question TEXT,
167
+ options TEXT, -- JSON array of the form's option values/labels
168
+ -- `resolved` (the steward submitted an answer) or `abstained` (it deliberately left it for a human).
169
+ outcome TEXT NOT NULL, -- resolved | abstained
170
+ submitted_variables TEXT, -- JSON of the variables submitted (NULL when abstained)
171
+ rationale TEXT, -- one-line why
172
+ evidence TEXT, -- JSON/markdown: what was checked, with links
173
+ -- Provenance of the steward run that produced this record.
174
+ steward_instance_key TEXT,
175
+ agent_job_key TEXT,
176
+ worker TEXT,
177
+ model TEXT,
178
+ created_at TEXT NOT NULL,
179
+ -- Operator review lifecycle: pending → dismissed | intervened.
180
+ review_state TEXT NOT NULL DEFAULT 'pending', -- pending | dismissed | intervened
181
+ reviewed_by TEXT,
182
+ reviewed_at TEXT,
183
+ review_note TEXT,
184
+ -- Claim/lease bookkeeping (not part of the review surface): which steward instance holds the open
185
+ -- claim and until when. NULL once the row is finalised (resolved/abstained recorded).
186
+ claimed_by TEXT,
187
+ claimed_until TEXT
188
+ );
189
+
190
+ CREATE INDEX idx_auto_resolutions_review_state ON escalation_auto_resolutions(review_state, created_at);
191
+ CREATE INDEX idx_auto_resolutions_subject ON escalation_auto_resolutions(subject_key);
192
+ -- The claim/lease fence: at most ONE open (unfinalised, `outcome='claimed'`) claim per user task, so
193
+ -- two overlapping steward ticks can never both act on the same escalation. Final rows
194
+ -- (`resolved`/`abstained`) are excluded so a re-escalation of the SAME task can be recorded afresh.
195
+ CREATE UNIQUE INDEX ux_auto_resolutions_open_claim ON escalation_auto_resolutions(user_task_key) WHERE outcome = 'claimed';
196
+ CREATE INDEX idx_auto_resolutions_task ON escalation_auto_resolutions(user_task_key);
197
+
198
+ -- The Auto-resolutions page read model (ADR 0042): projects each FINAL record (resolved/abstained —
199
+ -- never an in-flight `claimed` placeholder) with a derived `pending_review` flag (1 iff still
200
+ -- `pending`) for the row-action `showWhenField` gate, which reads JS truthiness. Derivation over
201
+ -- duplication: the grid binds this VIEW, never the base table.
202
+ CREATE VIEW escalation_auto_resolutions__read_model AS
203
+ SELECT
204
+ id,
205
+ user_task_key,
206
+ escalation_kind,
207
+ element_id,
208
+ subject_type,
209
+ subject_key,
210
+ subject_url,
211
+ process_instance_key,
212
+ question,
213
+ options,
214
+ outcome,
215
+ submitted_variables,
216
+ rationale,
217
+ evidence,
218
+ steward_instance_key,
219
+ agent_job_key,
220
+ worker,
221
+ model,
222
+ created_at,
223
+ review_state,
224
+ reviewed_by,
225
+ reviewed_at,
226
+ review_note,
227
+ CASE WHEN review_state = 'pending' THEN 1 ELSE 0 END AS pending_review
228
+ FROM escalation_auto_resolutions
229
+ WHERE outcome IN ('resolved', 'abstained');
@@ -0,0 +1,54 @@
1
+ -- Re-create the `delivery_graph_read_model` VIEW so it re-exports the base column added by
2
+ -- 120_escalation_auto_resolutions.sql (`auto_resolve_escalations`). The read-model VIEW must pass
3
+ -- through EVERY base `delivery_graph_runs` column (the static pages↔schema contract guard +
4
+ -- app/deliveryGraphReadModel.test.ts DRIFT GUARD assert it), so adding a base column obliges a fresh
5
+ -- VIEW definition — 087/096 are immutable and cannot be edited in place.
6
+ --
7
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
8
+ -- app/deliveryGraphReadModel.ts (`deliveryGraphReadModel.sqlSelectFor(col, { baseAlias: "dg" })`) —
9
+ -- the same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot
10
+ -- diverge. This file is a mechanical re-emission of 096 with the one new base pass-through added;
11
+ -- the derived `stage`/`stage_state`/`list_bucket`/`ack_open`/`park_label` expressions are unchanged.
12
+ -- The drift guard now points at THIS migration (the latest VIEW definition).
13
+ --
14
+ -- Forward-only VIEW definition (DROP then CREATE), sourced off the managed
15
+ -- `delivery_graph_runs__tracking` re-export of the base table so a terminated run classifies on
16
+ -- ENGINE TRUTH. The runner wraps each file in its own transaction, so this file must NOT contain
17
+ -- BEGIN/COMMIT. Numbered after 120.
18
+
19
+ DROP VIEW IF EXISTS delivery_graph_read_model;
20
+ DROP VIEW IF EXISTS delivery_graph_pr_counts;
21
+
22
+ CREATE VIEW IF NOT EXISTS "delivery_graph_pr_counts" AS
23
+ SELECT
24
+ "__urban_rollup_src"."root_request_key" AS "root_request_key",
25
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(("__urban_rollup_src"."root_request_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("__urban_rollup_src"."derived_status" = 'converged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'merged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight"
26
+ FROM "pull_requests__tracking" "__urban_rollup_src"
27
+ GROUP BY "__urban_rollup_src"."root_request_key";
28
+
29
+ CREATE VIEW delivery_graph_read_model AS
30
+ SELECT
31
+ dg.run_key AS run_key,
32
+ COALESCE(dg.derived_status, dg.status) AS status,
33
+ dg.process_key AS process_key,
34
+ dg.process_definition_id AS process_definition_id,
35
+ dg.digest AS digest,
36
+ dg.side_effecting AS side_effecting,
37
+ dg.node_count AS node_count,
38
+ dg.human_node_count AS human_node_count,
39
+ dg.side_effect_count AS side_effect_count,
40
+ dg.title AS title,
41
+ dg.phase AS phase,
42
+ dg.phase_node_id AS phase_node_id,
43
+ dg.human_labels AS human_labels,
44
+ dg.created_at AS created_at,
45
+ dg.updated_at AS updated_at,
46
+ dg.acknowledged_at AS acknowledged_at,
47
+ dg.auto_resolve_escalations AS auto_resolve_escalations,
48
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'Done' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("dg"."derived_status" = 'awaiting-approval'), 0) THEN 'Requested' WHEN COALESCE((COALESCE("pc"."prs_in_flight", 0) > 0), 0) THEN 'Converging' ELSE 'Implementing' END AS stage,
49
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'ok' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
50
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
51
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open,
52
+ CASE WHEN dg.phase_node_id IS NOT NULL THEN dg.phase ELSE NULL END AS park_label
53
+ FROM delivery_graph_runs__tracking dg
54
+ LEFT JOIN delivery_graph_pr_counts pc ON dg.run_key = pc.root_request_key;
@@ -0,0 +1,56 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the base column added by
2
+ -- 120_escalation_auto_resolutions.sql (`auto_resolve_escalations`). The read-model VIEW must pass
3
+ -- through EVERY base `pull_requests` column (the static pages↔schema contract guard +
4
+ -- app/pullRequestReadModel.test.ts DRIFT GUARD assert it), so adding a base column obliges a fresh
5
+ -- VIEW definition — 094/104/118 are immutable and cannot be edited in place.
6
+ --
7
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
8
+ -- app/pullRequestReadModel.ts (`pullRequestReadModel.sqlSelectFor(col, { baseAlias: "pr" })`) — the
9
+ -- same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot diverge.
10
+ -- This file is a mechanical re-emission of 118 with the one new base pass-through added; the
11
+ -- derived `list_bucket`/`ack_open` expressions are unchanged. The drift guard now points at THIS
12
+ -- migration (the latest VIEW definition).
13
+ --
14
+ -- Forward-only VIEW definition (DROP then CREATE), sourced off the managed `pull_requests__tracking`
15
+ -- re-export of the base table so a terminated PR classifies on ENGINE TRUTH. SQLite does not validate
16
+ -- a view body at CREATE time. The runner wraps each file in its own transaction, so this file must
17
+ -- NOT contain BEGIN/COMMIT. Numbered after 121.
18
+
19
+ DROP VIEW IF EXISTS pull_requests_read_model;
20
+
21
+ CREATE VIEW pull_requests_read_model AS
22
+ SELECT
23
+ pr.pr_key AS pr_key,
24
+ pr.repo AS repo,
25
+ pr.number AS number,
26
+ pr.url AS url,
27
+ pr.title AS title,
28
+ COALESCE(pr.derived_status, pr.status) AS status,
29
+ pr.current_round AS current_round,
30
+ pr.process_key AS process_key,
31
+ pr.waiting_since AS waiting_since,
32
+ pr.last_review_id AS last_review_id,
33
+ pr.outcome AS outcome,
34
+ pr.created_at AS created_at,
35
+ pr.updated_at AS updated_at,
36
+ pr.converged_at AS converged_at,
37
+ pr.merged_at AS merged_at,
38
+ pr.active_worker AS active_worker,
39
+ pr.lease_until AS lease_until,
40
+ pr.last_nudge_at AS last_nudge_at,
41
+ pr.fresh_head_run_head AS fresh_head_run_head,
42
+ pr.abandon_token AS abandon_token,
43
+ pr.incident_key AS incident_key,
44
+ pr.incident_message AS incident_message,
45
+ pr.last_round_head AS last_round_head,
46
+ pr.last_progress_job_key AS last_progress_job_key,
47
+ pr.last_progress_result AS last_progress_result,
48
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
49
+ pr.churn_escalated_round AS churn_escalated_round,
50
+ pr.root_request_key AS root_request_key,
51
+ pr.epic_phase_label AS epic_phase_label,
52
+ pr.acknowledged_at AS acknowledged_at,
53
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
54
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
55
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
56
+ FROM pull_requests__tracking pr;
@@ -0,0 +1,47 @@
1
+ -- Issue #889 review (Copilot "Previously missed"): the Auto-resolutions grid omitted the SELECTED
2
+ -- option, so an operator could not see whether the steward chose retry/continue/etc. without decoding
3
+ -- the raw submitted variables. Add a first-class `selected_option` column (the option value the steward
4
+ -- actually selected, derived host-side by the record worker from the decision's own form variables) and
5
+ -- re-create the read-model VIEW to expose it as a list column.
6
+ --
7
+ -- Expand-and-contract: the column is nullable (existing rows keep NULL — they predate the projection),
8
+ -- and the VIEW is re-created forward-only (DROP then CREATE) to add the new pass-through. Migration 120
9
+ -- is immutable and cannot be edited in place. Numbered after 122.
10
+
11
+ ALTER TABLE escalation_auto_resolutions ADD COLUMN selected_option TEXT;
12
+
13
+ DROP VIEW IF EXISTS escalation_auto_resolutions__read_model;
14
+ CREATE VIEW escalation_auto_resolutions__read_model AS
15
+ SELECT
16
+ id,
17
+ user_task_key,
18
+ escalation_kind,
19
+ element_id,
20
+ subject_type,
21
+ subject_key,
22
+ subject_url,
23
+ process_instance_key,
24
+ -- The cockpit "Agent" transcript link keys on `process_key` (the #833 invariant: every Agent →
25
+ -- cockpit link uses keyField `process_key`). The ANSWERING agent ran under `steward_instance_key`
26
+ -- (the steward process), NOT `process_instance_key` (the escalated SUBJECT's process), so project
27
+ -- the steward key under the canonical `process_key` name for that link (issue #889 review).
28
+ steward_instance_key AS process_key,
29
+ question,
30
+ options,
31
+ outcome,
32
+ submitted_variables,
33
+ selected_option,
34
+ rationale,
35
+ evidence,
36
+ steward_instance_key,
37
+ agent_job_key,
38
+ worker,
39
+ model,
40
+ created_at,
41
+ review_state,
42
+ reviewed_by,
43
+ reviewed_at,
44
+ review_note,
45
+ CASE WHEN review_state = 'pending' THEN 1 ELSE 0 END AS pending_review
46
+ FROM escalation_auto_resolutions
47
+ WHERE outcome IN ('resolved', 'abstained');
@@ -0,0 +1,21 @@
1
+ -- 124_admitted_epics_auto_resolve.sql — issue #889: carry the escalation-steward opt-in through the
2
+ -- set/batch admission door (`startEpicSet`).
3
+ --
4
+ -- The single-issue door (`startPlanFanout`) already seeds `plans.auto_resolve_escalations` (added by
5
+ -- 120) from the request body's `autoResolveEscalations`. The set/batch door dropped it: `startEpicSet`
6
+ -- stages each admitted epic into `admitted_epics` and `lowerAdmittedSet` later reads that staging to
7
+ -- call `startPlan` — but the staging row had no column for the flag, so every batch-admitted epic (and
8
+ -- every child convergence/merge loop it spawns) stayed opted OUT even when the caller opted in. That
9
+ -- contradicts the issue #889 contract that the opt-in reach "every initiation pathway".
10
+ --
11
+ -- Fix the class at the staging seam: persist the per-epic opt-in on the staged row so the lowering
12
+ -- reads it back and forwards it to `startPlan`. Additive (nullable-free `NOT NULL DEFAULT 0`), so an
13
+ -- existing install's already-staged rows read as opted-out (the safe default) and fresh admissions
14
+ -- record the caller's choice. No read-model view selects from `admitted_epics`, so no VIEW re-emission
15
+ -- is needed (unlike `pull_requests` — cf. 121/122).
16
+ --
17
+ -- Numbered 124: above this branch's highest committed prefix (123) and origin/main's (119), so a
18
+ -- fan-out sibling forking at either point cannot collide. The runner wraps each file in its own
19
+ -- transaction, so this file must NOT contain BEGIN/COMMIT.
20
+
21
+ ALTER TABLE admitted_epics ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;