@nanobpm/nano-workforce 0.56.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 (90) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +10 -2
  5. package/SPEC.md +27 -24
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/agentGuide.ts +1 -1
  9. package/app/answer-escalation.test.ts +106 -0
  10. package/app/answerEscalation.test.ts +67 -0
  11. package/app/baseGuard.test.ts +9 -1
  12. package/app/baseGuard.ts +11 -0
  13. package/app/escalationSla.test.ts +39 -0
  14. package/app/escalationSla.ts +28 -0
  15. package/app/escalationTaxonomy.test.ts +115 -0
  16. package/app/escalationTaxonomy.ts +115 -0
  17. package/app/feature.test.ts +161 -0
  18. package/app/feature.ts +173 -0
  19. package/app/github.test.ts +179 -1
  20. package/app/github.ts +132 -0
  21. package/app/mergeProtocol.test.ts +15 -0
  22. package/app/mergeProtocol.ts +10 -0
  23. package/app/persist-escalation.test.ts +34 -36
  24. package/app/plan.test.ts +267 -313
  25. package/app/plan.ts +173 -231
  26. package/app/reviewWait.ts +12 -4
  27. package/app/roundResultDefault.test.ts +111 -2
  28. package/app/roundResultDefault.ts +35 -0
  29. package/app/service.test.ts +6 -7
  30. package/app/service.ts +52 -35
  31. package/db/migrations/026_agent_completion.sql +36 -0
  32. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  33. package/db/migrations/028_feature_runs.sql +28 -0
  34. package/e2e/agent-answerable.e2e.ts +185 -0
  35. package/e2e/convergence-escalation.e2e.ts +180 -0
  36. package/e2e/convergence-loop.e2e.ts +1 -1
  37. package/e2e/feature-run.e2e.ts +231 -0
  38. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  39. package/e2e/plan-fanout.e2e.ts +303 -0
  40. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  41. package/e2e/support/github-admit.ts +99 -0
  42. package/e2e/user-task-spine.e2e.ts +155 -0
  43. package/nano.app.json +41 -11
  44. package/openapi.yaml +237 -84
  45. package/operations/agentCompleteEscalation.ts +53 -0
  46. package/operations/listActivePrs.test.ts +39 -6
  47. package/operations/postMessage.ts +10 -41
  48. package/operations/revertEscalationCompletion.ts +44 -0
  49. package/operations/startAndMessage.test.ts +62 -60
  50. package/operations/startFeature.ts +127 -0
  51. package/operations/startPlanFanout.admission.integration.test.ts +263 -0
  52. package/operations/startPlanFanout.ts +70 -11
  53. package/package.json +4 -1
  54. package/pages/cockpit.page.json +1 -0
  55. package/pages/epic-detail.page.json +11 -37
  56. package/pages/epic.page.json +5 -2
  57. package/pages/feature.page.json +82 -0
  58. package/pages/home.page.json +6 -18
  59. package/resources/agent-guide.md +90 -26
  60. package/resources/forms/feature-escalation.form +27 -0
  61. package/resources/forms/plan-review-decision.form +27 -0
  62. package/resources/forms/pr-escalation.form +23 -0
  63. package/resources/forms/spine-demo.form +15 -0
  64. package/resources/forms/trial-merge-decision.form +25 -0
  65. package/resources/processes/convergence-loop.bpmn +127 -75
  66. package/resources/processes/feature.bpmn +240 -0
  67. package/resources/processes/plan-fanout.bpmn +322 -222
  68. package/resources/processes/spine-demo.bpmn +72 -0
  69. package/scripts/check-migrations.ts +68 -0
  70. package/workers/answer-escalation/worker.ts +78 -0
  71. package/workers/converge-feature/worker.ts +51 -0
  72. package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
  73. package/workers/ensure-base-branch/worker.test.ts +104 -0
  74. package/workers/ensure-base-branch/worker.ts +31 -0
  75. package/workers/finalize/worker.ts +0 -2
  76. package/workers/mark-merged/worker.ts +0 -2
  77. package/workers/merge/worker.ts +6 -5
  78. package/workers/persist-escalation/worker.ts +28 -32
  79. package/workers/record-feature/worker.ts +61 -0
  80. package/workers/record-plan-review/worker.test.ts +9 -10
  81. package/workers/record-plan-review/worker.ts +15 -5
  82. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  83. package/workers/resolve-trial-attention/worker.ts +43 -0
  84. package/operations/answerFeatureEscalation.test.ts +0 -112
  85. package/operations/answerFeatureEscalation.ts +0 -58
  86. package/operations/answerPlanEscalation.test.ts +0 -115
  87. package/operations/answerPlanEscalation.ts +0 -41
  88. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  89. package/workers/persist-plan-escalation/worker.ts +0 -73
  90. package/workers/persist-task-escalation/worker.ts +0 -120
package/app/plan.ts CHANGED
@@ -11,13 +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";
15
+ import { ensureBaseBranch, fetchDefaultBranch } from "./github.ts";
14
16
  import { clearExclusions } from "./mergeExclusion.ts";
15
17
  import { clearTaskDeltas } from "./taskDelta.ts";
16
- import { resolveTrialMergeAttention, trialMergeWaveFromTaskId } from "./trialMerge.ts";
17
18
 
18
19
  /** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
19
20
  export const PLAN_PROCESS_ID = "plan-fanout";
20
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
+
21
31
  const now = () => new Date().toISOString();
22
32
 
23
33
  // Agent prompts are no longer read by the host. The `senior:plan`, `senior:plan-review`, and
@@ -37,21 +47,6 @@ export interface Plan {
37
47
  task_count: number;
38
48
  process_key: string | null;
39
49
  outcome: string | null;
40
- // Denormalised "oldest open task escalation" pointer (issue #25): the plans page
41
- // detail has a single answer form per row, so the oldest still-open per-task
42
- // escalation is surfaced here; answering re-points these at the next one (or
43
- // clears them). See refreshOpenTaskEscalation.
44
- open_task_escalation_id: number | null;
45
- open_task_question: string | null;
46
- open_task_corr_key: string | null;
47
- open_task_id: string | null;
48
- // Denormalised "open plan-review escalation" pointer (# plan-review escalation): when the
49
- // adversarial plan-review cap is reached without approval, the process parks for a human
50
- // proceed/revise directive. These fields surface the newest open plan-level escalation on the
51
- // plans page without overloading the implementation-phase `plan_escalations` table.
52
- open_plan_escalation_id: number | null;
53
- open_plan_findings: string | null;
54
- open_plan_round: number | null;
55
50
  // Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
56
51
  // waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
57
52
  gate_wave: number | null;
@@ -71,9 +66,11 @@ export interface Plan {
71
66
  // Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
72
67
  // plans created before the blackboard shipped.
73
68
  blackboard_token: string | null;
74
- // Optional target base branch (019_plan_base_branch.sql): when set, the fleet branches off this
75
- // branch and opens every task PR against it instead of the repository's default branch, landing
76
- // the whole epic on a long-lived integration branch. NULL keeps the default-branch behaviour.
69
+ // Target base branch (019_plan_base_branch.sql; ADR 0003): the fleet branches off this branch and
70
+ // opens every task PR against it instead of the repository's default branch, landing the whole
71
+ // epic on a long-lived integration branch. New launches always set it (base is required at
72
+ // admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
73
+ // carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
77
74
  base_branch: string | null;
78
75
  created_at: string;
79
76
  updated_at: string;
@@ -111,36 +108,8 @@ export const PLAN_TASK_STATUSES = [
111
108
  ] as const;
112
109
  export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
113
110
 
114
- /** One implementation-phase escalation (issue #25) — the per-task analogue of the
115
- * review loop's `escalations` row. `status` is open | answered. */
116
- export interface PlanEscalation {
117
- id: number;
118
- plan_key: string;
119
- task_id: string;
120
- corr_key: string;
121
- question: string;
122
- answer: string | null;
123
- draft_pr_key: string | null;
124
- status: string;
125
- asked_at: string;
126
- answered_at: string | null;
127
- }
128
-
129
111
  export const plans = (data: DataLayer) => data.table<Plan>("plans", "plan_key");
130
112
  export const planTasks = (data: DataLayer) => data.table<PlanTask>("plan_tasks", "id");
131
- export const planEscalations = (data: DataLayer) =>
132
- data.table<PlanEscalation>("plan_escalations", "id");
133
-
134
- /** The message the plan-fanout process catches to resume an escalated task; its
135
- * subscription correlates on `<plan_key>:<task_id>` (see plan-fanout.bpmn). */
136
- export const FEATURE_ESCALATION_MESSAGE = "feature-escalation-answered";
137
-
138
- /** The message the plan-fanout process catches to resume a plan-review escalation; its
139
- * subscription correlates on `<plan_key>` (see plan-fanout.bpmn). */
140
- export const PLAN_ESCALATION_MESSAGE = "plan-escalation-answered";
141
-
142
- /** Build the per-task message correlation key the process parks on. */
143
- export const featureCorrKey = (planKey: string, taskId: string) => `${planKey}:${taskId}`;
144
113
 
145
114
  /** One dependency edge in the plan DAG (issue #20): `task_id` waits for `depends_on_task_id`.
146
115
  * Keyed on `plan_key` so a single delete clears a plan's whole edge set (as pr_dependencies). */
@@ -169,31 +138,6 @@ export interface PlanReview {
169
138
  }
170
139
  export const planReviews = (data: DataLayer) => data.table<PlanReview>("plan_reviews", "plan_key");
171
140
 
172
- export type PlanEscalationDirective = "proceed" | "revise";
173
-
174
- export function parsePlanEscalationDirective(input: unknown): PlanEscalationDirective | null {
175
- const s = typeof input === "string" ? input.trim().toLowerCase() : "";
176
- return s === "proceed" || s === "revise" ? s : null;
177
- }
178
-
179
- /** One plan-review cap escalation. Kept in a dedicated table rather than overloading
180
- * `plan_escalations`: the latter is task-scoped (`task_id`/`corr_key` are NOT NULL and mirrored
181
- * onto `plan_tasks`), while this row is plan-scoped and drives the review epoch reset. */
182
- export interface PlanReviewEscalation {
183
- id: number;
184
- plan_key: string;
185
- epoch: number;
186
- round: number;
187
- findings: string | null;
188
- status: string;
189
- directive: PlanEscalationDirective | null;
190
- note: string | null;
191
- asked_at: string;
192
- answered_at: string | null;
193
- }
194
- export const planReviewEscalations = (data: DataLayer) =>
195
- data.table<PlanReviewEscalation>("plan_review_escalations", "id");
196
-
197
141
  /** Read a positive-integer env override, falling back when unset/blank/invalid. A bad value
198
142
  * (e.g. "", "abc", "0", "2.5") must NOT silently become `NaN`/`0` — that would make the round
199
143
  * cap `round + 1 >= cap` always false and allow an unbounded revise loop. */
@@ -209,11 +153,9 @@ export function positiveIntEnv(name: string, fallback: number): number {
209
153
  * #86). A human `revise` answer starts a fresh epoch, so the next plan gets a full new budget. */
210
154
  export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
211
155
 
212
- /** The current review epoch is derived from the append-only escalation log: every answered
213
- * plan-review escalation represents a human decision to leave the prior budget behind. */
214
- export async function currentPlanReviewEpoch(data: DataLayer, planKey: string): Promise<number> {
215
- return await planReviewEscalations(data).count({ plan_key: planKey, status: "answered" });
216
- }
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. */
217
159
 
218
160
  /** A plan is "done" in exactly these states; everything else (planning, dispatched)
219
161
  * is in flight. The cancel guard and the active view key off this. */
@@ -258,6 +200,16 @@ export class InvalidBaseBranchError extends Error {
258
200
  }
259
201
  }
260
202
 
203
+ /** Raised when a caller supplies a blank/absent `baseBranch`. Every epic launch must name its base
204
+ * branch explicitly (ADR 0003): "land on the default branch" is a conscious, named, confirmed choice
205
+ * (the confirm-default gate), never a silent fallback. The operation edge maps this to a 400. */
206
+ export class MissingBaseBranchError extends Error {
207
+ constructor() {
208
+ super("base branch is required (blank/absent base branches are rejected)");
209
+ this.name = "MissingBaseBranchError";
210
+ }
211
+ }
212
+
261
213
  /** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
262
214
  * purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
263
215
  * no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
@@ -270,13 +222,14 @@ function isPlausibleBranchName(s: string): boolean {
270
222
  return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
271
223
  }
272
224
 
273
- /** Normalise a caller-supplied base branch: trim, and treat blank as "unset" (null) so the fleet
274
- * falls back to the repository's default branch the legacy behaviour. A non-blank value that is
275
- * not a plausible git branch name is rejected (`InvalidBaseBranchError`) rather than persisted or
276
- * rendered into the agent prompt; the operation edge maps that to a 400. */
277
- export function normalizeBaseBranch(input: string | null | undefined): string | null {
225
+ /** Normalise a caller-supplied base branch: trim, then require it. A blank/absent value is rejected
226
+ * (`MissingBaseBranchError`) ADR 0003 removed the implicit default-branch fallback, so every epic
227
+ * launch must name its base explicitly. A non-blank value that is not a plausible git branch name is
228
+ * rejected (`InvalidBaseBranchError`) rather than persisted or rendered into the agent prompt. The
229
+ * operation edge maps both to a 400. Always returns a non-null branch on success. */
230
+ export function normalizeBaseBranch(input: string | null | undefined): string {
278
231
  const s = (input ?? "").trim();
279
- if (s.length === 0) return null;
232
+ if (s.length === 0) throw new MissingBaseBranchError();
280
233
  if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
281
234
  return s;
282
235
  }
@@ -304,13 +257,131 @@ export function renderBaseBranchBrief(baseBranch: string): string {
304
257
  ].join("\n");
305
258
  }
306
259
 
260
+ /** Raised when the explicit base branch IS the repository default branch but the caller did not
261
+ * acknowledge the consequence with `confirmDefaultBase: true` (ADR 0003 rule 3). Naming the default
262
+ * is the one dangerous explicit value: every task lands directly on it with no integration buffer,
263
+ * and any merge-to-default side effect fires per task. The operation edge maps this to a 400. */
264
+ export class DefaultBaseNotConfirmedError extends Error {
265
+ readonly branch: string;
266
+ constructor(branch: string) {
267
+ super(
268
+ `base branch "${branch}" is the repository default branch: every task would land directly ` +
269
+ `on "${branch}" with NO integration branch, and any merge-to-default side effect (e.g. ` +
270
+ `auto-publish) would fire per task. Re-submit with confirmDefaultBase: true to acknowledge ` +
271
+ `and proceed, or name an epic/* integration branch instead.`,
272
+ );
273
+ this.name = "DefaultBaseNotConfirmedError";
274
+ this.branch = branch;
275
+ }
276
+ }
277
+
278
+ /** Raised when another ACTIVE plan (status ∉ PLAN_TERMINAL_STATUSES) already targets the same repo
279
+ * + same custom base branch, and the caller did not pass `allowSharedBase: true` (ADR 0003 rule 4).
280
+ * Two in-flight epics sharing one integration branch interleave commits and poison each other's
281
+ * base. The default branch is EXEMPT (many epics target it concurrently without colliding — each
282
+ * task PR is independent). The operation edge maps this to a 409. */
283
+ export class SharedBaseError extends Error {
284
+ readonly repo: string;
285
+ readonly branch: string;
286
+ constructor(repo: string, branch: string) {
287
+ super(
288
+ `base branch "${branch}" on ${repo} is already in use by another active epic. Sharing one ` +
289
+ `integration branch across epics interleaves their commits and poisons the base. Re-submit ` +
290
+ `with allowSharedBase: true only if you intend to stack on it, or name a distinct epic/* ` +
291
+ `branch.`,
292
+ );
293
+ this.name = "SharedBaseError";
294
+ this.repo = repo;
295
+ this.branch = branch;
296
+ }
297
+ }
298
+
299
+ /** Find plans on `repo` targeting `base` whose status is NOT terminal (i.e. still active). Used by
300
+ * the shared-base admission guard to detect a second epic reaching for the same integration branch.
301
+ * Grandfathered `base_branch = null` rows never match a non-null `base`, so they are ignored. */
302
+ export async function findActivePlansByBase(
303
+ data: DataLayer,
304
+ repo: string,
305
+ base: string,
306
+ ): Promise<Plan[]> {
307
+ const rows = await plans(data).find({ repo, base_branch: base });
308
+ return rows.filter((p) => !PLAN_TERMINAL_STATUSES.includes(p.status));
309
+ }
310
+
311
+ /** Options gating the confirm-default (rule 3) and shared-base (rule 4) admission rules. Both
312
+ * default to `false` — a "warn you can't skip": the operator must consciously opt in. */
313
+ export interface AdmitPlanOptions {
314
+ allowSharedBase?: boolean;
315
+ confirmDefaultBase?: boolean;
316
+ /** The `plan_key` of the launch being admitted. When set, the shared-base guard (rule 4)
317
+ * EXCLUDES this plan's own active row, so an idempotent re-submit of the same issue does not
318
+ * trip `SharedBaseError` against itself — `startPlan` is idempotent on `plan_key` and returns
319
+ * `alreadyRunning` for an active plan, so the retry must reach it, not 409 on rule 4. */
320
+ selfPlanKey?: string;
321
+ }
322
+
323
+ /** Fail-fast admission gate for an epic launch (ADR 0003 §Decision). Composes the four ordered
324
+ * admission rules BEFORE any task fans out and returns the normalized base branch on success. The
325
+ * ORDER is load-bearing — the cheapest / most fundamental reject (missing or typo'd base) fires
326
+ * first, so it is NOT reordered:
327
+ *
328
+ * 1. Required + explicit — `normalizeBaseBranch` (blank/absent → `MissingBaseBranchError`;
329
+ * implausible → `InvalidBaseBranchError`).
330
+ * 2. Create-if-missing (epic/* guard), synchronously — `ensureBaseBranch`: a missing non-`epic/*`
331
+ * base throws `BaseBranchMustExistError` HERE (so a typo is a clean edge 400, not a late
332
+ * per-task failure); a missing `epic/*` base is created off default HEAD before fan-out; an
333
+ * existing base is a no-op. It is idempotent, so the durable `ensure-base-branch` head task
334
+ * re-runs it as belt-and-suspenders.
335
+ * 3. Confirm-default — if the base equals the repo default branch and `confirmDefaultBase` is not
336
+ * `true`, throw `DefaultBaseNotConfirmedError`. The default branch is then EXEMPT from rule 4.
337
+ * 4. Shared-base — if a DIFFERENT active plan already targets this same custom base and
338
+ * `allowSharedBase` is not `true`, throw `SharedBaseError`. The launch's own active row is
339
+ * excluded (via `options.selfPlanKey`) so an idempotent same-issue re-submit is not a 409.
340
+ */
341
+ export async function admitPlan(
342
+ data: DataLayer,
343
+ repo: string,
344
+ baseBranch: string | null | undefined,
345
+ token: string,
346
+ options: AdmitPlanOptions = {},
347
+ ): Promise<string> {
348
+ // Rule 1 — required + explicit.
349
+ const base = normalizeBaseBranch(baseBranch);
350
+
351
+ // Rule 2 — create-if-missing (epic/* guard), synchronously at admission. A missing non-epic/*
352
+ // base throws BaseBranchMustExistError → clean edge 400; a missing epic/* base is created off
353
+ // default HEAD; an existing base is a no-op. Idempotent, so the head task safely re-runs it.
354
+ await ensureBaseBranch(repo, base, token);
355
+
356
+ // Rule 3 — confirm-default. Naming the repo default branch is deliberate and requires an explicit
357
+ // acknowledgement. When the base IS the default, it is exempt from the shared-base guard (rule 4),
358
+ // so return here on a confirmed default.
359
+ const defaultBranch = await fetchDefaultBranch(repo, token);
360
+ if (defaultBranch !== null && base === defaultBranch) {
361
+ if (options.confirmDefaultBase !== true) throw new DefaultBaseNotConfirmedError(base);
362
+ return base;
363
+ }
364
+
365
+ // Rule 4 — shared-base guard on a custom integration branch. Exclude this launch's OWN active row
366
+ // (when `selfPlanKey` is given) so an idempotent same-issue re-submit reaches `startPlan`'s
367
+ // `alreadyRunning` short-circuit instead of tripping a 409 against itself.
368
+ if (options.allowSharedBase !== true) {
369
+ const active = (await findActivePlansByBase(data, repo, base)).filter(
370
+ (p) => p.plan_key !== options.selfPlanKey,
371
+ );
372
+ if (active.length > 0) throw new SharedBaseError(repo, base);
373
+ }
374
+
375
+ return base;
376
+ }
377
+
307
378
  /** Register a plan row (if new) and start the plan-fanout process. Idempotent on
308
379
  * planKey: a plan already in flight is not restarted. */
309
380
  export async function startPlan(
310
381
  data: DataLayer,
311
382
  engine: EngineClient,
312
383
  parsed: ParsedIssue,
313
- baseBranch: string | null = null,
384
+ baseBranch: string,
314
385
  ) {
315
386
  const table = plans(data);
316
387
  const existing = await table.get(parsed.planKey);
@@ -335,18 +406,6 @@ export async function startPlan(
335
406
  // Clear them here — the table is keyed on `plan_key`, so one delete drops the
336
407
  // whole set (mirrors how record-plan clears `plan_task_deps`).
337
408
  await planReviews(data).delete(parsed.planKey);
338
- // Same class of stale-row bug for escalation state: task escalations are keyed on `id` (not
339
- // `plan_key`), so drop the prior run's rows one-by-one. Otherwise a still-"open" escalation
340
- // from the previous run survives the re-plan and `refreshOpenTaskEscalation` re-surfaces a
341
- // question for a `task_id` we just deleted from `plan_tasks`.
342
- for (const e of await planEscalations(data).find({ plan_key: parsed.planKey })) {
343
- await planEscalations(data).delete(e.id);
344
- }
345
- // Plan-review escalations are also keyed on `id` because they are an audit trail; clear them
346
- // on a fresh submission so the epoch derived from answered escalations resets to 0.
347
- for (const e of await planReviewEscalations(data).find({ plan_key: parsed.planKey })) {
348
- await planReviewEscalations(data).delete(e.id);
349
- }
350
409
  // Same for the structured impl-change deltas (D5, #55): keyed on `id`, so drop the prior run's
351
410
  // rows one-by-one, otherwise a stale delta lingers in the epic report for a task we just deleted.
352
411
  await clearTaskDeltas(data, parsed.planKey);
@@ -357,15 +416,6 @@ export async function startPlan(
357
416
  task_count: 0,
358
417
  issue_url: parsed.url,
359
418
  outcome: null,
360
- // Reset the denormalised "surfaced escalation" pointer so nothing from the
361
- // prior run lingers on the plan row (would otherwise show a dead answer form).
362
- open_task_escalation_id: null,
363
- open_task_question: null,
364
- open_task_corr_key: null,
365
- open_task_id: null,
366
- open_plan_escalation_id: null,
367
- open_plan_findings: null,
368
- open_plan_round: null,
369
419
  blackboard_token: token,
370
420
  base_branch: base,
371
421
  updated_at: ts,
@@ -393,18 +443,31 @@ export async function startPlan(
393
443
  issueNumber: parsed.number,
394
444
  issueUrl: parsed.url,
395
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,
396
459
  // Coordination blackboard (#51): the capability URL + the protocol brief that each
397
460
  // implementer agent gets appended to its prompt (composed into `appendPrompt` in
398
461
  // plan-fanout.bpmn's implement-task). Advisory shared state, delivered in-band, used
399
462
  // out-of-band.
400
463
  blackboardUrl: bbUrl,
401
464
  blackboardBrief: renderCoordinationBrief(bbUrl),
402
- // Optional epic base branch (019_plan_base_branch.sql): the branch the fleet branches off and
403
- // opens every PR against instead of the repo default. `baseBranchBrief` rides `appendPrompt`
404
- // in the implement-task (like `blackboardBrief`); both are null when no base branch is pinned,
405
- // so the agent keeps the default-branch behaviour from prompts/feature.md.
465
+ // Epic base branch (019_plan_base_branch.sql; ADR 0003): the branch the fleet branches off
466
+ // and opens every PR against instead of the repo default. `baseBranchBrief` rides
467
+ // `appendPrompt` in the implement-task (like `blackboardBrief`). Base is now always explicit
468
+ // (normalizeBaseBranch rejects blank), so the brief is always rendered.
406
469
  baseBranch: base,
407
- baseBranchBrief: base == null ? null : renderBaseBranchBrief(base),
470
+ baseBranchBrief: renderBaseBranchBrief(base),
408
471
  },
409
472
  });
410
473
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -414,124 +477,3 @@ export async function startPlan(
414
477
  return { planKey: parsed.planKey, processKey };
415
478
  }
416
479
 
417
- /** Re-point a plan's denormalised "open task escalation" fields at its OLDEST
418
- * still-open `plan_escalations` row (or clear them when none remain). The page
419
- * runtime binds a single answer form per plan row, so parallel escalations are
420
- * surfaced one at a time, oldest-first; this is called after opening an
421
- * escalation and after answering one. */
422
- export async function refreshOpenTaskEscalation(data: DataLayer, planKey: string) {
423
- const open = (await planEscalations(data).find({ plan_key: planKey, status: "open" }))
424
- .sort((a, b) => a.id - b.id)[0];
425
- await plans(data).update(planKey, {
426
- open_task_escalation_id: open ? open.id : null,
427
- open_task_question: open ? open.question : null,
428
- open_task_corr_key: open ? open.corr_key : null,
429
- open_task_id: open ? open.task_id : null,
430
- updated_at: now(),
431
- });
432
- }
433
-
434
- /** Answer an open implementation-phase escalation → record it, resume the parked
435
- * task via the correlated `feature-escalation-answered` message, and re-surface
436
- * the next-oldest open escalation (if any). Keyed by the correlation key
437
- * (`<plan_key>:<task_id>`) so an external webhook and the page share one path.
438
- * Idempotent-ish: a corr_key with no open escalation is a 404-style no-op. */
439
- export async function answerTaskEscalation(
440
- data: DataLayer,
441
- engine: EngineClient,
442
- corrKey: string,
443
- answer: string,
444
- ) {
445
- const open = (await planEscalations(data).find({ corr_key: corrKey, status: "open" }))
446
- .sort((a, b) => b.id - a.id)[0];
447
- if (!open) return { ok: false, reason: "no open escalation" };
448
- const ts = now();
449
- // A trial-merge escalation (task_id `trial-merge-wave-<wave>`) leaves an
450
- // append-only red audit row in `plan_trial_merges`. Answering it clears that
451
- // row from the page's "Needs attention" tab — including a "proceed" override
452
- // that records no re-run row (a re-run would supersede it, but a proceed would
453
- // not, pinning the red row forever).
454
- //
455
- // Resolve it FIRST, before the escalation is committed as answered and the
456
- // resume message is published. `resolveTrialMergeAttention` is idempotent, so
457
- // if this throws (e.g. a transient DB error) the escalation is still open and
458
- // the whole operation retries cleanly. Running it AFTER the commit/publish
459
- // would make a failure here unrecoverable: the escalation is already answered,
460
- // a retry 404s (no open escalation), and the red row is pinned forever.
461
- const trialWave = trialMergeWaveFromTaskId(open.task_id);
462
- if (trialWave != null) await resolveTrialMergeAttention(data, open.plan_key, trialWave);
463
- await planEscalations(data).update(open.id, { answer, status: "answered", answered_at: ts });
464
- // Mirror onto the task row so a re-dispatched agent (and the UI) sees the answer.
465
- for (const t of await planTasks(data).find({ plan_key: open.plan_key, task_id: open.task_id })) {
466
- await planTasks(data).update(t.id, { answer, updated_at: ts });
467
- }
468
- // Resume the parked child: the process merges `answer` into the child scope and
469
- // loops back to re-dispatch the SAME task on its existing branch.
470
- await engine.publishMessage({
471
- name: FEATURE_ESCALATION_MESSAGE,
472
- correlationKey: corrKey,
473
- variables: { answer },
474
- });
475
- await refreshOpenTaskEscalation(data, open.plan_key);
476
- return { ok: true, escalationId: open.id, planKey: open.plan_key, taskId: open.task_id };
477
- }
478
-
479
- export function normalizePlanEscalationDirective(input: unknown): PlanEscalationDirective {
480
- return parsePlanEscalationDirective(input) ?? "revise";
481
- }
482
-
483
- function renderPlanEscalationFindings(open: PlanReviewEscalation, note: string): string {
484
- const parts = [
485
- `Plan review reached its round budget at epoch ${open.epoch}, round ${open.round}.`,
486
- "",
487
- "Reviewer findings:",
488
- (open.findings ?? "").trim() || "(no reviewer findings were provided.)",
489
- ];
490
- if (note) {
491
- parts.push("", "Human guidance:", note);
492
- } else {
493
- parts.push("", "Human directive: revise the plan within the allowed task boundaries.");
494
- }
495
- return parts.join("\n");
496
- }
497
-
498
- /** Answer the newest open plan-review escalation. `proceed` is an explicit human override that lets
499
- * the current (unapproved) plan continue to wave dispatch; `revise` (the default) folds the human
500
- * note into `planFindings` and starts a fresh review epoch on the next planner pass. */
501
- export async function answerPlanEscalation(
502
- data: DataLayer,
503
- engine: EngineClient,
504
- planKey: string,
505
- directiveInput: unknown,
506
- noteInput: unknown,
507
- ) {
508
- const open = (await planReviewEscalations(data).find({ plan_key: planKey, status: "open" }))
509
- .sort((a, b) => b.id - a.id)[0];
510
- if (!open) return { ok: false, reason: "no open plan escalation" };
511
-
512
- const directive = normalizePlanEscalationDirective(directiveInput);
513
- const note = typeof noteInput === "string" ? noteInput.trim() : "";
514
- const ts = now();
515
- await planReviewEscalations(data).update(open.id, {
516
- directive,
517
- note: note || null,
518
- status: "answered",
519
- answered_at: ts,
520
- });
521
- await plans(data).update(planKey, {
522
- open_plan_escalation_id: null,
523
- open_plan_findings: null,
524
- open_plan_round: null,
525
- updated_at: ts,
526
- });
527
-
528
- await engine.publishMessage({
529
- name: PLAN_ESCALATION_MESSAGE,
530
- correlationKey: planKey,
531
- variables: {
532
- planEscalationDirective: directive,
533
- planFindings: directive === "revise" ? renderPlanEscalationFindings(open, note) : "",
534
- },
535
- });
536
- return { ok: true, escalationId: open.id, planKey, directive };
537
- }
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.