@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.
- package/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +14 -0
- package/README.md +10 -2
- package/SPEC.md +27 -24
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/agentGuide.ts +1 -1
- package/app/answer-escalation.test.ts +106 -0
- package/app/answerEscalation.test.ts +67 -0
- package/app/baseGuard.test.ts +9 -1
- package/app/baseGuard.ts +11 -0
- package/app/escalationSla.test.ts +39 -0
- package/app/escalationSla.ts +28 -0
- package/app/escalationTaxonomy.test.ts +115 -0
- package/app/escalationTaxonomy.ts +115 -0
- package/app/feature.test.ts +161 -0
- package/app/feature.ts +173 -0
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +267 -313
- package/app/plan.ts +173 -231
- package/app/reviewWait.ts +12 -4
- package/app/roundResultDefault.test.ts +111 -2
- package/app/roundResultDefault.ts +35 -0
- package/app/service.test.ts +6 -7
- package/app/service.ts +52 -35
- package/db/migrations/026_agent_completion.sql +36 -0
- package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
- package/db/migrations/028_feature_runs.sql +28 -0
- package/e2e/agent-answerable.e2e.ts +185 -0
- package/e2e/convergence-escalation.e2e.ts +180 -0
- package/e2e/convergence-loop.e2e.ts +1 -1
- package/e2e/feature-run.e2e.ts +231 -0
- package/e2e/plan-fanout-sla.e2e.ts +238 -0
- package/e2e/plan-fanout.e2e.ts +303 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
- package/e2e/support/github-admit.ts +99 -0
- package/e2e/user-task-spine.e2e.ts +155 -0
- package/nano.app.json +41 -11
- package/openapi.yaml +237 -84
- package/operations/agentCompleteEscalation.ts +53 -0
- package/operations/listActivePrs.test.ts +39 -6
- package/operations/postMessage.ts +10 -41
- package/operations/revertEscalationCompletion.ts +44 -0
- package/operations/startAndMessage.test.ts +62 -60
- package/operations/startFeature.ts +127 -0
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +4 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +5 -2
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +90 -26
- package/resources/forms/feature-escalation.form +27 -0
- package/resources/forms/plan-review-decision.form +27 -0
- package/resources/forms/pr-escalation.form +23 -0
- package/resources/forms/spine-demo.form +15 -0
- package/resources/forms/trial-merge-decision.form +25 -0
- package/resources/processes/convergence-loop.bpmn +127 -75
- package/resources/processes/feature.bpmn +240 -0
- package/resources/processes/plan-fanout.bpmn +322 -222
- package/resources/processes/spine-demo.bpmn +72 -0
- package/scripts/check-migrations.ts +68 -0
- package/workers/answer-escalation/worker.ts +78 -0
- package/workers/converge-feature/worker.ts +51 -0
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -0
- package/workers/finalize/worker.ts +0 -2
- package/workers/mark-merged/worker.ts +0 -2
- package/workers/merge/worker.ts +6 -5
- package/workers/persist-escalation/worker.ts +28 -32
- package/workers/record-feature/worker.ts +61 -0
- package/workers/record-plan-review/worker.test.ts +9 -10
- package/workers/record-plan-review/worker.ts +15 -5
- package/workers/resolve-trial-attention/worker.test.ts +77 -0
- package/workers/resolve-trial-attention/worker.ts +43 -0
- package/operations/answerFeatureEscalation.test.ts +0 -112
- package/operations/answerFeatureEscalation.ts +0 -58
- package/operations/answerPlanEscalation.test.ts +0 -115
- package/operations/answerPlanEscalation.ts +0 -41
- package/workers/persist-plan-escalation/worker.test.ts +0 -80
- package/workers/persist-plan-escalation/worker.ts +0 -73
- 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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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
|
|
213
|
-
* plan-review
|
|
214
|
-
|
|
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,
|
|
274
|
-
*
|
|
275
|
-
* not a plausible git branch name is
|
|
276
|
-
*
|
|
277
|
-
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
403
|
-
// opens every PR against instead of the repo default. `baseBranchBrief` rides
|
|
404
|
-
// in the implement-task (like `blackboardBrief`)
|
|
405
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.
|