@nanobpm/nano-workforce 0.57.0 → 0.58.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 (82) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +1 -1
  5. package/SPEC.md +21 -22
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/answer-escalation.test.ts +106 -0
  9. package/app/answerEscalation.test.ts +67 -0
  10. package/app/baseGuard.test.ts +9 -1
  11. package/app/baseGuard.ts +11 -0
  12. package/app/escalationSla.test.ts +39 -0
  13. package/app/escalationSla.ts +28 -0
  14. package/app/escalationTaxonomy.test.ts +115 -0
  15. package/app/escalationTaxonomy.ts +115 -0
  16. package/app/feature.test.ts +161 -0
  17. package/app/feature.ts +173 -0
  18. package/app/mergeProtocol.test.ts +15 -0
  19. package/app/mergeProtocol.ts +10 -0
  20. package/app/persist-escalation.test.ts +34 -36
  21. package/app/plan.test.ts +0 -294
  22. package/app/plan.ts +26 -216
  23. package/app/reviewWait.ts +12 -4
  24. package/app/roundResultDefault.test.ts +111 -2
  25. package/app/roundResultDefault.ts +35 -0
  26. package/app/service.test.ts +6 -7
  27. package/app/service.ts +52 -35
  28. package/db/migrations/026_agent_completion.sql +36 -0
  29. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  30. package/db/migrations/028_feature_runs.sql +28 -0
  31. package/e2e/agent-answerable.e2e.ts +185 -0
  32. package/e2e/convergence-escalation.e2e.ts +180 -0
  33. package/e2e/convergence-loop.e2e.ts +1 -1
  34. package/e2e/feature-run.e2e.ts +231 -0
  35. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  36. package/e2e/plan-fanout.e2e.ts +303 -0
  37. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  38. package/e2e/support/github-admit.ts +99 -0
  39. package/e2e/user-task-spine.e2e.ts +155 -0
  40. package/nano.app.json +37 -11
  41. package/openapi.yaml +181 -73
  42. package/operations/agentCompleteEscalation.ts +53 -0
  43. package/operations/listActivePrs.test.ts +39 -6
  44. package/operations/postMessage.ts +10 -41
  45. package/operations/revertEscalationCompletion.ts +44 -0
  46. package/operations/startAndMessage.test.ts +0 -58
  47. package/operations/startFeature.ts +127 -0
  48. package/package.json +4 -1
  49. package/pages/cockpit.page.json +1 -0
  50. package/pages/epic-detail.page.json +11 -37
  51. package/pages/epic.page.json +1 -1
  52. package/pages/feature.page.json +82 -0
  53. package/pages/home.page.json +6 -18
  54. package/resources/agent-guide.md +52 -24
  55. package/resources/forms/feature-escalation.form +27 -0
  56. package/resources/forms/plan-review-decision.form +27 -0
  57. package/resources/forms/pr-escalation.form +23 -0
  58. package/resources/forms/spine-demo.form +15 -0
  59. package/resources/forms/trial-merge-decision.form +25 -0
  60. package/resources/processes/convergence-loop.bpmn +127 -75
  61. package/resources/processes/feature.bpmn +240 -0
  62. package/resources/processes/plan-fanout.bpmn +304 -223
  63. package/resources/processes/spine-demo.bpmn +72 -0
  64. package/scripts/check-migrations.ts +68 -0
  65. package/workers/answer-escalation/worker.ts +78 -0
  66. package/workers/converge-feature/worker.ts +51 -0
  67. package/workers/finalize/worker.ts +0 -2
  68. package/workers/mark-merged/worker.ts +0 -2
  69. package/workers/merge/worker.ts +6 -5
  70. package/workers/persist-escalation/worker.ts +28 -32
  71. package/workers/record-feature/worker.ts +61 -0
  72. package/workers/record-plan-review/worker.test.ts +9 -10
  73. package/workers/record-plan-review/worker.ts +15 -5
  74. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  75. package/workers/resolve-trial-attention/worker.ts +43 -0
  76. package/operations/answerFeatureEscalation.test.ts +0 -112
  77. package/operations/answerFeatureEscalation.ts +0 -58
  78. package/operations/answerPlanEscalation.test.ts +0 -115
  79. package/operations/answerPlanEscalation.ts +0 -41
  80. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  81. package/workers/persist-plan-escalation/worker.ts +0 -73
  82. package/workers/persist-task-escalation/worker.ts +0 -120
package/app/plan.test.ts CHANGED
@@ -127,94 +127,6 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
127
127
  assertEquals(stores.plan_tasks.rows.length, 0);
128
128
  });
129
129
 
130
- // Red/green regression for re-plan clearing stale open escalations (issue #25).
131
- //
132
- // `plan_escalations` is written by the implementation-phase escalation loop and denormalised onto
133
- // the plan row (`open_task_*`). When `startPlan` re-plans a finished issue it deletes the prior
134
- // `plan_tasks`, so any still-"open" escalation from that run points at a task that no longer
135
- // exists. If those rows (and the plan's denormalised pointer) survive the re-plan,
136
- // `refreshOpenTaskEscalation` re-surfaces a dead question in the answer form — the same
137
- // stale-row class as `plan_reviews` above. This drives `startPlan` against the in-memory data
138
- // layer and asserts both the escalation rows and the denormalised pointer are cleared.
139
- test("re-plan of a finished issue clears stale open escalations and the denormalised open_task_* pointer", async () => {
140
- const PLAN_KEY = "owner/repo#8";
141
- const stores: Record<string, { rows: unknown[]; key: string }> = {
142
- plans: {
143
- rows: [{
144
- plan_key: PLAN_KEY,
145
- status: "done",
146
- task_count: 1,
147
- open_task_escalation_id: 5,
148
- open_task_question: "stale question from prior run?",
149
- open_task_corr_key: `${PLAN_KEY}:task-1`,
150
- open_task_id: "task-1",
151
- }],
152
- key: "plan_key",
153
- },
154
- plan_tasks: { rows: [{ id: 1, plan_key: PLAN_KEY, task_id: "task-1" }], key: "id" },
155
- plan_reviews: { rows: [], key: "plan_key" },
156
- plan_escalations: {
157
- rows: [{
158
- id: 5,
159
- plan_key: PLAN_KEY,
160
- task_id: "task-1",
161
- corr_key: `${PLAN_KEY}:task-1`,
162
- question: "stale question from prior run?",
163
- status: "open",
164
- }],
165
- key: "id",
166
- },
167
- plan_task_deps: { rows: [], key: "plan_key" },
168
- };
169
- const data = {
170
- table: (name: string, key: string) =>
171
- memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
172
- } as any;
173
- const engine = {
174
- createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }),
175
- } as any;
176
-
177
- await startPlan(data, engine, {
178
- repo: "owner/repo",
179
- number: 8,
180
- url: "https://github.com/owner/repo/issues/8",
181
- planKey: PLAN_KEY,
182
- }, "epic/agent-protocol");
183
-
184
- // Stale escalation rows from the prior run must not survive a re-plan …
185
- assertEquals(stores.plan_escalations.rows.length, 0);
186
- // … and the plan's denormalised "surfaced escalation" pointer must be reset,
187
- // so `refreshOpenTaskEscalation` can't re-surface a question for a deleted task.
188
- const plan = stores.plans.rows[0] as Record<string, unknown>;
189
- assertEquals(plan.open_task_escalation_id, null);
190
- assertEquals(plan.open_task_question, null);
191
- assertEquals(plan.open_task_corr_key, null);
192
- assertEquals(plan.open_task_id, null);
193
- });
194
-
195
- // Red/green coverage for the implementation-phase escalation lifecycle (issue #25).
196
- //
197
- // `refreshOpenTaskEscalation` and `answerTaskEscalation` (issue #25) drive new stateful
198
- // behaviour — denormalising the plan's "surfaced" escalation, mirroring the answer onto the
199
- // task row, and publishing the correlated resume message — that had no unit coverage. These
200
- // drive both against the in-memory data layer above and assert the oldest-first surfacing,
201
- // the answer mirroring, and the published message.
202
- import {
203
- answerPlanEscalation,
204
- answerTaskEscalation,
205
- currentPlanReviewEpoch,
206
- PLAN_ESCALATION_MESSAGE,
207
- refreshOpenTaskEscalation,
208
- } from "./plan.ts";
209
-
210
- function escalationStores(rows: unknown[]): Record<string, { rows: unknown[]; key: string }> {
211
- return {
212
- plans: { rows: [{ plan_key: "owner/repo#9" }], key: "plan_key" },
213
- plan_escalations: { rows, key: "id" },
214
- plan_tasks: { rows: [], key: "id" },
215
- };
216
- }
217
-
218
130
  function memData(stores: Record<string, { rows: any[]; key: string }>) {
219
131
  return {
220
132
  table: (name: string, key: string) =>
@@ -222,212 +134,6 @@ function memData(stores: Record<string, { rows: any[]; key: string }>) {
222
134
  } as any;
223
135
  }
224
136
 
225
- test("refreshOpenTaskEscalation surfaces the OLDEST open escalation, then clears when none remain", async () => {
226
- const stores = escalationStores([
227
- { id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open" },
228
- { id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open" },
229
- ]);
230
- const data = memData(stores);
231
-
232
- await refreshOpenTaskEscalation(data, "owner/repo#9");
233
- let plan = stores.plans.rows[0] as any;
234
- assertEquals(plan.open_task_escalation_id, 1);
235
- assertEquals(plan.open_task_question, "Q-a");
236
- assertEquals(plan.open_task_corr_key, "owner/repo#9:a");
237
- assertEquals(plan.open_task_id, "a");
238
-
239
- // Once the oldest is answered, the next-oldest is surfaced.
240
- (stores.plan_escalations.rows.find((r: any) => r.id === 1) as any).status = "answered";
241
- await refreshOpenTaskEscalation(data, "owner/repo#9");
242
- plan = stores.plans.rows[0] as any;
243
- assertEquals(plan.open_task_escalation_id, 2);
244
- assertEquals(plan.open_task_id, "b");
245
-
246
- // With nothing open the denormalised fields clear.
247
- (stores.plan_escalations.rows.find((r: any) => r.id === 2) as any).status = "answered";
248
- await refreshOpenTaskEscalation(data, "owner/repo#9");
249
- plan = stores.plans.rows[0] as any;
250
- assertEquals(plan.open_task_escalation_id, null);
251
- assertEquals(plan.open_task_question, null);
252
- assertEquals(plan.open_task_corr_key, null);
253
- assertEquals(plan.open_task_id, null);
254
- });
255
-
256
- test("answerTaskEscalation records the answer, mirrors it onto the task, publishes the resume message, and re-surfaces the next escalation", async () => {
257
- const stores = escalationStores([
258
- { id: 1, plan_key: "owner/repo#9", task_id: "a", corr_key: "owner/repo#9:a", question: "Q-a", status: "open", answer: null },
259
- { id: 2, plan_key: "owner/repo#9", task_id: "b", corr_key: "owner/repo#9:b", question: "Q-b", status: "open", answer: null },
260
- ]);
261
- stores.plan_tasks.rows.push({ id: 10, plan_key: "owner/repo#9", task_id: "a", answer: null });
262
- const data = memData(stores);
263
-
264
- const published: any[] = [];
265
- const engine = {
266
- publishMessage: (m: any) => {
267
- published.push(m);
268
- return Promise.resolve();
269
- },
270
- } as any;
271
-
272
- const r = await answerTaskEscalation(data, engine, "owner/repo#9:a", "do it");
273
- assertEquals(r.ok, true);
274
- assertEquals(r.escalationId, 1);
275
- assertEquals(r.planKey, "owner/repo#9");
276
- assertEquals(r.taskId, "a");
277
-
278
- // Escalation row marked answered with the recorded answer.
279
- const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
280
- assertEquals(esc.status, "answered");
281
- assertEquals(esc.answer, "do it");
282
-
283
- // Answer mirrored onto the task row.
284
- assertEquals((stores.plan_tasks.rows[0] as any).answer, "do it");
285
-
286
- // Correlated resume message published on the shared constant channel.
287
- assertEquals(published.length, 1);
288
- assertEquals(published[0].name, "feature-escalation-answered");
289
- assertEquals(published[0].correlationKey, "owner/repo#9:a");
290
- assertEquals(published[0].variables.answer, "do it");
291
-
292
- // Next-oldest open escalation re-surfaced on the plan row.
293
- assertEquals((stores.plans.rows[0] as any).open_task_escalation_id, 2);
294
- });
295
-
296
- test("answerTaskEscalation is a no-op when no open escalation matches the correlation key", async () => {
297
- const stores = escalationStores([]);
298
- const data = memData(stores);
299
- const engine = {
300
- publishMessage: () => Promise.reject(new Error("should not publish")),
301
- } as any;
302
- const r = await answerTaskEscalation(data, engine, "owner/repo#9:missing", "x");
303
- assertEquals(r.ok, false);
304
- });
305
-
306
- // Red/green regression (PR #131 suppressed advisory, app/plan.ts:455).
307
- //
308
- // Clearing a trial-merge wave's "Needs attention" row (`resolveTrialMergeAttention`)
309
- // is a best-effort cosmetic cleanup, but it must be RETRIABLE: if it ran only AFTER
310
- // the escalation was committed as `answered` and the resume message was published,
311
- // a transient DB error there would 500 the whole answer flow while the escalation is
312
- // already answered/resumed — a retry then 404s (no open escalation) and the red row
313
- // is pinned forever (the very failure the insert-first ordering elsewhere avoids).
314
- // The fix runs the idempotent resolution BEFORE the commit/publish, so a failure
315
- // leaves the escalation OPEN and nothing is orphaned — the caller can safely retry.
316
- test("answerTaskEscalation stays retriable (escalation open, no orphaned resume) when clearing 'Needs attention' fails", async () => {
317
- const stores = escalationStores([
318
- {
319
- id: 1,
320
- plan_key: "owner/repo#9",
321
- task_id: "trial-merge-wave-0",
322
- corr_key: "owner/repo#9:trial-merge-wave-0",
323
- question: "Q",
324
- status: "open",
325
- answer: null,
326
- },
327
- ]);
328
- stores.plan_trial_merges = {
329
- rows: [{ id: 100, plan_key: "owner/repo#9", wave: 0, resolved: 0 }],
330
- key: "id",
331
- };
332
- const base = memData(stores);
333
- // Inject a transient failure in the trial-merge audit table's `update` only.
334
- const data = {
335
- table: (name: string, key: string) => {
336
- const t = base.table(name, key);
337
- if (name === "plan_trial_merges") {
338
- return { ...t, update: () => Promise.reject(new Error("transient DB error")) };
339
- }
340
- return t;
341
- },
342
- } as any;
343
-
344
- const published: any[] = [];
345
- const engine = {
346
- publishMessage: (m: any) => {
347
- published.push(m);
348
- return Promise.resolve();
349
- },
350
- } as any;
351
-
352
- await assertRejects(() =>
353
- answerTaskEscalation(data, engine, "owner/repo#9:trial-merge-wave-0", "proceed")
354
- );
355
-
356
- // Escalation must remain OPEN so a retry can recover (never committed as answered).
357
- const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
358
- assertEquals(esc.status, "open");
359
- assertEquals(esc.answer, null);
360
- // No orphaned resume message was published.
361
- assertEquals(published.length, 0);
362
- });
363
-
364
- test("currentPlanReviewEpoch counts answered plan-review escalations only", async () => {
365
- const stores = {
366
- plan_review_escalations: {
367
- rows: [
368
- { id: 1, plan_key: "owner/repo#10", status: "answered" },
369
- { id: 2, plan_key: "owner/repo#10", status: "open" },
370
- { id: 3, plan_key: "owner/repo#other", status: "answered" },
371
- ],
372
- key: "id",
373
- },
374
- };
375
- assertEquals(await currentPlanReviewEpoch(memData(stores), "owner/repo#10"), 1);
376
- });
377
-
378
- test("answerPlanEscalation records directive, clears the plan pointer, and publishes the resume message", async () => {
379
- const stores = {
380
- plans: {
381
- rows: [{
382
- plan_key: "owner/repo#11",
383
- open_plan_escalation_id: 7,
384
- open_plan_findings: "reviewer findings",
385
- open_plan_round: 2,
386
- }],
387
- key: "plan_key",
388
- },
389
- plan_review_escalations: {
390
- rows: [{
391
- id: 7,
392
- plan_key: "owner/repo#11",
393
- epoch: 0,
394
- round: 2,
395
- findings: "reviewer findings",
396
- status: "open",
397
- directive: null,
398
- note: null,
399
- }],
400
- key: "id",
401
- },
402
- };
403
- const published: any[] = [];
404
- const engine = {
405
- publishMessage: (m: any) => {
406
- published.push(m);
407
- return Promise.resolve();
408
- },
409
- } as any;
410
-
411
- const r = await answerPlanEscalation(memData(stores), engine, "owner/repo#11", "revise", "Use issue-1 as seam.");
412
- assertEquals(r.ok, true);
413
- assertEquals(r.directive, "revise");
414
- const esc = stores.plan_review_escalations.rows[0] as any;
415
- assertEquals(esc.status, "answered");
416
- assertEquals(esc.directive, "revise");
417
- assertEquals(esc.note, "Use issue-1 as seam.");
418
- const plan = stores.plans.rows[0] as any;
419
- assertEquals(plan.open_plan_escalation_id, null);
420
- assertEquals(plan.open_plan_findings, null);
421
- assertEquals(plan.open_plan_round, null);
422
- assertEquals(published[0].name, PLAN_ESCALATION_MESSAGE);
423
- assertEquals(published[0].correlationKey, "owner/repo#11");
424
- assertEquals(published[0].variables.planEscalationDirective, "revise");
425
- assertEquals(
426
- String(published[0].variables.planFindings).includes("Use issue-1 as seam."),
427
- true,
428
- );
429
- });
430
-
431
137
  // Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql; ADR 0003).
432
138
  //
433
139
  // Every plan must pin a base branch so the fleet branches off — and opens every PR against — a
package/app/plan.ts CHANGED
@@ -11,14 +11,23 @@
11
11
  // hand-written SQL — matching app/service.ts.
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
13
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
+ import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
14
15
  import { ensureBaseBranch, fetchDefaultBranch } from "./github.ts";
15
16
  import { clearExclusions } from "./mergeExclusion.ts";
16
17
  import { clearTaskDeltas } from "./taskDelta.ts";
17
- import { resolveTrialMergeAttention, trialMergeWaveFromTaskId } from "./trialMerge.ts";
18
18
 
19
19
  /** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
20
20
  export const PLAN_PROCESS_ID = "plan-fanout";
21
21
 
22
+ /** The fleet-wide escalation SLA (ISO-8601 duration) seeded onto every plan-fanout instance as the
23
+ * `escalationSlaTimeout` process variable and evaluated by each escalation user task's interrupting
24
+ * timer boundary. An operator sets `NANO_ESCALATION_SLA_TIMEOUT`; a malformed value falls back to
25
+ * {@link DEFAULT_ESCALATION_SLA_TIMEOUT} so a bad env can never deploy an uninterpretable timer. */
26
+ export const ESCALATION_SLA_TIMEOUT = escalationSlaTimeout(
27
+ process.env.NANO_ESCALATION_SLA_TIMEOUT,
28
+ DEFAULT_ESCALATION_SLA_TIMEOUT,
29
+ );
30
+
22
31
  const now = () => new Date().toISOString();
23
32
 
24
33
  // Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
@@ -38,21 +47,6 @@ export interface Plan {
38
47
  task_count: number;
39
48
  process_key: string | null;
40
49
  outcome: string | null;
41
- // Denormalised "oldest open task escalation" pointer (issue #25): the plans page
42
- // detail has a single answer form per row, so the oldest still-open per-task
43
- // escalation is surfaced here; answering re-points these at the next one (or
44
- // clears them). See refreshOpenTaskEscalation.
45
- open_task_escalation_id: number | null;
46
- open_task_question: string | null;
47
- open_task_corr_key: string | null;
48
- open_task_id: string | null;
49
- // Denormalised "open plan-review escalation" pointer (# plan-review escalation): when the
50
- // adversarial plan-review cap is reached without approval, the process parks for a human
51
- // proceed/revise directive. These fields surface the newest open plan-level escalation on the
52
- // plans page without overloading the implementation-phase `plan_escalations` table.
53
- open_plan_escalation_id: number | null;
54
- open_plan_findings: string | null;
55
- open_plan_round: number | null;
56
50
  // Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
57
51
  // waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
58
52
  gate_wave: number | null;
@@ -114,36 +108,8 @@ export const PLAN_TASK_STATUSES = [
114
108
  ] as const;
115
109
  export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
116
110
 
117
- /** One implementation-phase escalation (issue #25) — the per-task analogue of the
118
- * review loop's `escalations` row. `status` is open | answered. */
119
- export interface PlanEscalation {
120
- id: number;
121
- plan_key: string;
122
- task_id: string;
123
- corr_key: string;
124
- question: string;
125
- answer: string | null;
126
- draft_pr_key: string | null;
127
- status: string;
128
- asked_at: string;
129
- answered_at: string | null;
130
- }
131
-
132
111
  export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
133
112
  export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
134
- export const planEscalations = (data: DataLayer) =>
135
- data.table<PlanEscalation>("plan_escalations", "id");
136
-
137
- /** The message the plan-fanout process catches to resume an escalated task; its
138
- * subscription correlates on `<plan_key>:<task_id>` (see plan-fanout.bpmn). */
139
- export const FEATURE_ESCALATION_MESSAGE = "feature-escalation-answered";
140
-
141
- /** The message the plan-fanout process catches to resume a plan-review escalation; its
142
- * subscription correlates on `<plan_key>` (see plan-fanout.bpmn). */
143
- export const PLAN_ESCALATION_MESSAGE = "plan-escalation-answered";
144
-
145
- /** Build the per-task message correlation key the process parks on. */
146
- export const featureCorrKey = (planKey: string, taskId: string) => `${planKey}:${taskId}`;
147
113
 
148
114
  /** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
149
115
  * Keyed on `plan_key` so a single delete clears a plan's whole edge set (as pr_dependencies). */
@@ -172,31 +138,6 @@ export interface PlanReview {
172
138
  }
173
139
  export const planReviews = (data: DataLayer) => data.table<PlanReview>("plan_reviews", "plan_key");
174
140
 
175
- export type PlanEscalationDirective = "proceed" | "revise";
176
-
177
- export function parsePlanEscalationDirective(input: unknown): PlanEscalationDirective | null {
178
- const s = typeof input === "string" ? input.trim().toLowerCase() : "";
179
- return s === "proceed" || s === "revise" ? s : null;
180
- }
181
-
182
- /** One plan-review cap escalation. Kept in a dedicated table rather than overloading
183
- * `plan_escalations`: the latter is task-scoped (`task_id`/`corr_key` are NOT NULL and mirrored
184
- * onto `plan_tasks`), while this row is plan-scoped and drives the review epoch reset. */
185
- export interface PlanReviewEscalation {
186
- id: number;
187
- plan_key: string;
188
- epoch: number;
189
- round: number;
190
- findings: string | null;
191
- status: string;
192
- directive: PlanEscalationDirective | null;
193
- note: string | null;
194
- asked_at: string;
195
- answered_at: string | null;
196
- }
197
- export const planReviewEscalations = (data: DataLayer) =>
198
- data.table<PlanReviewEscalation>("plan_review_escalations", "id");
199
-
200
141
  /** Read a positive-integer env override, falling back when unset/blank/invalid. A bad value
201
142
  * (e.g. "", "abc", "0", "2.5") must NOT silently become `NaN`/`0` — that would make the round
202
143
  * cap `round + 1 >= cap` always false and allow an unbounded revise loop. */
@@ -212,11 +153,9 @@ export function positiveIntEnv(name: string, fallback: number): number {
212
153
  * #86). A human `revise` answer starts a fresh epoch, so the next plan gets a full new budget. */
213
154
  export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
214
155
 
215
- /** The current review epoch is derived from the append-only escalation log: every answered
216
- * plan-review escalation represents a human decision to leave the prior budget behind. */
217
- export async function currentPlanReviewEpoch(data: DataLayer, planKey: string): Promise<number> {
218
- return await planReviewEscalations(data).count({ plan_key: planKey, status: "answered" });
219
- }
156
+ /** The current review epoch is a durable process variable (`planReviewEpoch`) bumped by the
157
+ * `plan-review-decision` user task each time a human answers a plan-review escalation. It is read
158
+ * back by `record-plan-review` to reset the round budget — there is no derived counter here. */
220
159
 
221
160
  /** A plan is "done" in exactly these states; everything else (planning, dispatched)
222
161
  * is in flight. The cancel guard and the active view key off this. */
@@ -467,18 +406,6 @@ export async function startPlan(
467
406
  // Clear them here — the table is keyed on `plan_key`, so one delete drops the
468
407
  // whole set (mirrors how record-plan clears `plan_task_deps`).
469
408
  await planReviews(data).delete(parsed.planKey);
470
- // Same class of stale-row bug for escalation state: task escalations are keyed on `id` (not
471
- // `plan_key`), so drop the prior run's rows one-by-one. Otherwise a still-"open" escalation
472
- // from the previous run survives the re-plan and `refreshOpenTaskEscalation` re-surfaces a
473
- // question for a `task_id` we just deleted from `plan_tasks`.
474
- for (const e of await planEscalations(data).find({ plan_key: parsed.planKey })) {
475
- await planEscalations(data).delete(e.id);
476
- }
477
- // Plan-review escalations are also keyed on `id` because they are an audit trail; clear them
478
- // on a fresh submission so the epoch derived from answered escalations resets to 0.
479
- for (const e of await planReviewEscalations(data).find({ plan_key: parsed.planKey })) {
480
- await planReviewEscalations(data).delete(e.id);
481
- }
482
409
  // Same for the structured impl-change deltas (D5, #55): keyed on `id`, so drop the prior run's
483
410
  // rows one-by-one, otherwise a stale delta lingers in the epic report for a task we just deleted.
484
411
  await clearTaskDeltas(data, parsed.planKey);
@@ -489,15 +416,6 @@ export async function startPlan(
489
416
  task_count: 0,
490
417
  issue_url: parsed.url,
491
418
  outcome: null,
492
- // Reset the denormalised "surfaced escalation" pointer so nothing from the
493
- // prior run lingers on the plan row (would otherwise show a dead answer form).
494
- open_task_escalation_id: null,
495
- open_task_question: null,
496
- open_task_corr_key: null,
497
- open_task_id: null,
498
- open_plan_escalation_id: null,
499
- open_plan_findings: null,
500
- open_plan_round: null,
501
419
  blackboard_token: token,
502
420
  base_branch: base,
503
421
  updated_at: ts,
@@ -525,6 +443,19 @@ export async function startPlan(
525
443
  issueNumber: parsed.number,
526
444
  issueUrl: parsed.url,
527
445
  planFindings: null,
446
+ // The plan-review epoch is a durable process variable, bumped by the `plan-review-decision`
447
+ // user task each time a human answers a plan-review escalation. `record-plan-review` reads it
448
+ // to reset the per-epoch round budget; it starts at 0 for the first review round.
449
+ planReviewEpoch: 0,
450
+ // Escalation-of-the-escalation SLA (U5, #156): the validated ISO-8601 duration seeded onto the
451
+ // instance and read by each escalation user task's interrupting timer boundary
452
+ // (`<bpmn:timeDuration>=escalationSlaTimeout`). If a human never answers, the boundary fires and
453
+ // the process auto-proceeds down the gateway's safe-default arm — durable in-process liveness,
454
+ // not a poller-side watchdog. `escalationAssignee` is the optional named assignee the escalation
455
+ // user tasks' `zeebe:assignmentDefinition` resolves (null = unassigned, routed via the
456
+ // `operators` candidate group); an operator/agent can claim/reassign via the task inbox.
457
+ escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
458
+ escalationAssignee: null,
528
459
  // Coordination blackboard (#51): the capability URL + the protocol brief that each
529
460
  // implementer agent gets appended to its prompt (composed into `appendPrompt` in
530
461
  // plan-fanout.bpmn's implement-task). Advisory shared state, delivered in-band, used
@@ -546,124 +477,3 @@ export async function startPlan(
546
477
  return { planKey: parsed.planKey, processKey };
547
478
  }
548
479
 
549
- /** Re-point a plan's denormalised "open task escalation" fields at its OLDEST
550
- * still-open `plan_escalations` row (or clear them when none remain). The page
551
- * runtime binds a single answer form per plan row, so parallel escalations are
552
- * surfaced one at a time, oldest-first; this is called after opening an
553
- * escalation and after answering one. */
554
- export async function refreshOpenTaskEscalation(data: DataLayer, planKey: string) {
555
- const open = (await planEscalations(data).find({ plan_key: planKey, status: "open" }))
556
- .sort((a, b) => a.id - b.id)[0];
557
- await plans(data).update(planKey, {
558
- open_task_escalation_id: open ? open.id : null,
559
- open_task_question: open ? open.question : null,
560
- open_task_corr_key: open ? open.corr_key : null,
561
- open_task_id: open ? open.task_id : null,
562
- updated_at: now(),
563
- });
564
- }
565
-
566
- /** Answer an open implementation-phase escalation → record it, resume the parked
567
- * task via the correlated `feature-escalation-answered` message, and re-surface
568
- * the next-oldest open escalation (if any). Keyed by the correlation key
569
- * (`<plan_key>:<task_id>`) so an external webhook and the page share one path.
570
- * Idempotent-ish: a corr_key with no open escalation is a 404-style no-op. */
571
- export async function answerTaskEscalation(
572
- data: DataLayer,
573
- engine: EngineClient,
574
- corrKey: string,
575
- answer: string,
576
- ) {
577
- const open = (await planEscalations(data).find({ corr_key: corrKey, status: "open" }))
578
- .sort((a, b) => b.id - a.id)[0];
579
- if (!open) return { ok: false, reason: "no open escalation" };
580
- const ts = now();
581
- // A trial-merge escalation (task_id `trial-merge-wave-<wave>`) leaves an
582
- // append-only red audit row in `plan_trial_merges`. Answering it clears that
583
- // row from the page's "Needs attention" tab — including a "proceed" override
584
- // that records no re-run row (a re-run would supersede it, but a proceed would
585
- // not, pinning the red row forever).
586
- //
587
- // Resolve it FIRST, before the escalation is committed as answered and the
588
- // resume message is published. `resolveTrialMergeAttention` is idempotent, so
589
- // if this throws (e.g. a transient DB error) the escalation is still open and
590
- // the whole operation retries cleanly. Running it AFTER the commit/publish
591
- // would make a failure here unrecoverable: the escalation is already answered,
592
- // a retry 404s (no open escalation), and the red row is pinned forever.
593
- const trialWave = trialMergeWaveFromTaskId(open.task_id);
594
- if (trialWave != null) await resolveTrialMergeAttention(data, open.plan_key, trialWave);
595
- await planEscalations(data).update(open.id, { answer, status: "answered", answered_at: ts });
596
- // Mirror onto the task row so a re-dispatched agent (and the UI) sees the answer.
597
- for (const t of await planTasks(data).find({ plan_key: open.plan_key, task_id: open.task_id })) {
598
- await planTasks(data).update(t.id, { answer, updated_at: ts });
599
- }
600
- // Resume the parked child: the process merges `answer` into the child scope and
601
- // loops back to re-dispatch the SAME task on its existing branch.
602
- await engine.publishMessage({
603
- name: FEATURE_ESCALATION_MESSAGE,
604
- correlationKey: corrKey,
605
- variables: { answer },
606
- });
607
- await refreshOpenTaskEscalation(data, open.plan_key);
608
- return { ok: true, escalationId: open.id, planKey: open.plan_key, taskId: open.task_id };
609
- }
610
-
611
- export function normalizePlanEscalationDirective(input: unknown): PlanEscalationDirective {
612
- return parsePlanEscalationDirective(input) ?? "revise";
613
- }
614
-
615
- function renderPlanEscalationFindings(open: PlanReviewEscalation, note: string): string {
616
- const parts = [
617
- `Plan review reached its round budget at epoch ${open.epoch}, round ${open.round}.`,
618
- "",
619
- "Reviewer findings:",
620
- (open.findings ?? "").trim() || "(no reviewer findings were provided.)",
621
- ];
622
- if (note) {
623
- parts.push("", "Human guidance:", note);
624
- } else {
625
- parts.push("", "Human directive: revise the plan within the allowed task boundaries.");
626
- }
627
- return parts.join("\n");
628
- }
629
-
630
- /** Answer the newest open plan-review escalation. `proceed` is an explicit human override that lets
631
- * the current (unapproved) plan continue to wave dispatch; `revise` (the default) folds the human
632
- * note into `planFindings` and starts a fresh review epoch on the next planner pass. */
633
- export async function answerPlanEscalation(
634
- data: DataLayer,
635
- engine: EngineClient,
636
- planKey: string,
637
- directiveInput: unknown,
638
- noteInput: unknown,
639
- ) {
640
- const open = (await planReviewEscalations(data).find({ plan_key: planKey, status: "open" }))
641
- .sort((a, b) => b.id - a.id)[0];
642
- if (!open) return { ok: false, reason: "no open plan escalation" };
643
-
644
- const directive = normalizePlanEscalationDirective(directiveInput);
645
- const note = typeof noteInput === "string" ? noteInput.trim() : "";
646
- const ts = now();
647
- await planReviewEscalations(data).update(open.id, {
648
- directive,
649
- note: note || null,
650
- status: "answered",
651
- answered_at: ts,
652
- });
653
- await plans(data).update(planKey, {
654
- open_plan_escalation_id: null,
655
- open_plan_findings: null,
656
- open_plan_round: null,
657
- updated_at: ts,
658
- });
659
-
660
- await engine.publishMessage({
661
- name: PLAN_ESCALATION_MESSAGE,
662
- correlationKey: planKey,
663
- variables: {
664
- planEscalationDirective: directive,
665
- planFindings: directive === "revise" ? renderPlanEscalationFindings(open, note) : "",
666
- },
667
- });
668
- return { ok: true, escalationId: open.id, planKey, directive };
669
- }
package/app/reviewWait.ts CHANGED
@@ -20,15 +20,23 @@ export const DEFAULT_REVIEW_WAIT_TIMEOUT = "PT20M";
20
20
  // would fail to interpret; not a full grammar (we don't need fractional seconds here).
21
21
  const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?$/;
22
22
 
23
+ /** Validate an ISO-8601 duration string for a BPMN timer's `<bpmn:timeDuration>`, falling back to
24
+ * `def` when the value is absent, blank, or malformed — a bad env value must never deploy an
25
+ * uninterpretable timer expression into a process. Normalises to upper case (`pt20m` → `PT20M`).
26
+ * This is the single canonical duration validator; per-timer policies (review-wait, escalation
27
+ * SLA) derive their env-driven value from it rather than re-implementing the grammar. */
28
+ export function isoDuration(raw: string | undefined, def: string): string {
29
+ const s = (raw ?? "").trim().toUpperCase();
30
+ return s !== "" && ISO_DURATION.test(s) ? s : def;
31
+ }
32
+
23
33
  /** Validate an ISO-8601 duration for the review-wait timer, falling back to `def` when the value
24
- * is absent, blank, or malformed a bad env value must never deploy an uninterpretable timer
25
- * expression into the process. Normalises to upper case (`pt20m` → `PT20M`). */
34
+ * is absent, blank, or malformed. Thin wrapper over {@link isoDuration}. */
26
35
  export function reviewWaitTimeout(
27
36
  raw: string | undefined,
28
37
  def: string = DEFAULT_REVIEW_WAIT_TIMEOUT,
29
38
  ): string {
30
- const s = (raw ?? "").trim().toUpperCase();
31
- return s !== "" && ISO_DURATION.test(s) ? s : def;
39
+ return isoDuration(raw, def);
32
40
  }
33
41
 
34
42
  /** Default cooldown (minutes) between automatic Copilot re-request nudges for one waiting PR.