@nanobpm/nano-workforce 0.45.0 → 0.46.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/CHANGELOG.md +7 -0
- package/SPEC.md +40 -0
- package/app/plan.test.ts +80 -3
- package/app/plan.ts +118 -8
- package/db/migrations/020_plan_review_escalation.sql +51 -0
- package/nano.app.json +4 -0
- package/openapi.yaml +70 -3
- package/operations/answerPlanEscalation.test.ts +115 -0
- package/operations/answerPlanEscalation.ts +41 -0
- package/operations/postMessage.ts +25 -5
- package/operations/startAndMessage.test.ts +58 -0
- package/package.json +1 -1
- package/pages/epic.page.json +61 -1
- package/prompts/plan.md +11 -0
- package/resources/agent-guide.md +13 -3
- package/resources/processes/plan-fanout.bpmn +191 -109
- package/workers/persist-plan-escalation/worker.test.ts +80 -0
- package/workers/persist-plan-escalation/worker.ts +73 -0
- package/workers/record-plan-review/worker.test.ts +59 -39
- package/workers/record-plan-review/worker.ts +53 -29
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.46.0](https://github.com/nanobpm/nano-workforce/compare/v0.45.0...v0.46.0) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add plan review escalation ([#128](https://github.com/nanobpm/nano-workforce/issues/128)) ([b67cc9e](https://github.com/nanobpm/nano-workforce/commit/b67cc9ebd84ef251e38e1e0fc332f712f82438c2)), closes [owner/repo#N](https://github.com/owner/repo/issues/N)
|
|
7
|
+
|
|
1
8
|
# [0.45.0](https://github.com/nanobpm/nano-workforce/compare/v0.44.1...v0.45.0) (2026-08-12)
|
|
2
9
|
|
|
3
10
|
|
package/SPEC.md
CHANGED
|
@@ -511,6 +511,44 @@ the loop runs one parallel `implement` MI fan-out per wave:
|
|
|
511
511
|
directive in a sub-issue body, mapping each prerequisite `#M` to `issue-M` in the
|
|
512
512
|
adopted task's `dependsOn` — so a human-declared blocking order survives adoption.
|
|
513
513
|
|
|
514
|
+
### 13.2 Trial-merge integration gate (D3) — issue #69
|
|
515
|
+
|
|
516
|
+
Before a wave's still-open heads land, the fan-out runs a **D3 trial merge** to catch
|
|
517
|
+
**emergent** conflicts: heads that merge cleanly but whose *combination* breaks the
|
|
518
|
+
target repo's suite. `app/trialMerge.ts` classifies the result `clean | merge-conflict
|
|
519
|
+
| suite-failed`; only `suite-failed` escalates (`trialMergeDecision`). Textual
|
|
520
|
+
merge-conflicts are pass-through — D2/D6 own merge-exclusion and merge-train ordering.
|
|
521
|
+
It runs only for `headCount >= 2` on non-mergify repos (`shouldRunTrialMerge`).
|
|
522
|
+
|
|
523
|
+
Flow (`resources/processes/plan-fanout.bpmn`): `gw-trial-needed` → `trial-merge`
|
|
524
|
+
(`senior:trial-merge`) → `record-trial-merge` (audit row in `plan_trial_merges`) →
|
|
525
|
+
`gw-trial` (`trial red?`). On red it persists a plan-level escalation
|
|
526
|
+
(`pr.persist-task-escalation`, task id `trial-merge-wave-<N>`, corrKey
|
|
527
|
+
`<plan_key>:trial-merge-wave-<N>`) and parks at `wait-trial-answer`
|
|
528
|
+
(`feature-escalation-answered`). The operator answers exactly `proceed` to override and
|
|
529
|
+
continue, or anything else to **rerun** the trial after pushing a fix.
|
|
530
|
+
|
|
531
|
+
**Known gap — inherited vs emergent failures (issue #129, PLANNED).** As shipped, D3
|
|
532
|
+
escalates on *any* red combined suite, including a failure that was **already red on
|
|
533
|
+
each head individually** (e.g. a per-PR build defect, or a repo-wide workspace
|
|
534
|
+
build-ordering bug). That parks a human on something that is not an integration
|
|
535
|
+
decision. The target behaviour is an **autonomy ladder**:
|
|
536
|
+
|
|
537
|
+
1. **Shift-left** — a head whose *required* checks are red never enters the trial merge;
|
|
538
|
+
the convergence loop's `senior:fix-ci` path owns per-PR failures. D3 only sees
|
|
539
|
+
individually-green heads.
|
|
540
|
+
2. **Baseline-diff** — the `senior:trial-merge` agent reports, per failing check,
|
|
541
|
+
whether it was green on each head alone; D3 escalates **only** on checks that
|
|
542
|
+
*regress under combination* (green-per-head → red-combined) and attributes inherited
|
|
543
|
+
failures back to the owning head's loop.
|
|
544
|
+
3. **Auto-remediation** — for deterministic, agent-diagnosable classes (build ordering,
|
|
545
|
+
lockfile drift, renamed scripts) a `senior:integration-fix` agent pushes the fix and
|
|
546
|
+
reruns the trial before any human is parked (reusing the escalate→wait→rerun/proceed
|
|
547
|
+
branch from the plan-review escalation, PR #128).
|
|
548
|
+
|
|
549
|
+
A human escalation is then reserved for its one true case: **two slices that each pass
|
|
550
|
+
but encode incompatible decisions about a shared contract** — a genuine design call.
|
|
551
|
+
|
|
514
552
|
## 14. Open questions / future
|
|
515
553
|
|
|
516
554
|
- **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
|
|
@@ -524,6 +562,8 @@ the loop runs one parallel `implement` MI fan-out per wave:
|
|
|
524
562
|
- **Supervised vs external worker** — the agent runs as an external
|
|
525
563
|
`c8ctl nano work` daemon by default; a supervised in-server mode is possible
|
|
526
564
|
later (ADR 0041 decision).
|
|
565
|
+
- **Autonomous D3** — shift-left + baseline-diff + auto-remediation so the trial-merge
|
|
566
|
+
gate only escalates genuine cross-slice design conflicts (§13.2, issue #129).
|
|
527
567
|
- **Prompt versioning/hash** per PR for auditability.
|
|
528
568
|
- **Auth on the web UI** — the manifest `security` block (ADR 0028) if this is
|
|
529
569
|
exposed beyond localhost.
|
package/app/plan.test.ts
CHANGED
|
@@ -70,6 +70,10 @@ function memTable(rows: any[], key: string) {
|
|
|
70
70
|
rows.push(r);
|
|
71
71
|
return Promise.resolve(r);
|
|
72
72
|
},
|
|
73
|
+
count: (q: any) =>
|
|
74
|
+
Promise.resolve(
|
|
75
|
+
rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length,
|
|
76
|
+
),
|
|
73
77
|
update: (k: any, patch: any) => {
|
|
74
78
|
const r = rows.find((x) => x[key] === k);
|
|
75
79
|
if (r) Object.assign(r, patch);
|
|
@@ -97,8 +101,8 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
|
|
|
97
101
|
},
|
|
98
102
|
plan_reviews: {
|
|
99
103
|
rows: [
|
|
100
|
-
{ plan_key: PLAN_KEY, round: 0 },
|
|
101
|
-
{ plan_key: PLAN_KEY, round: 1 },
|
|
104
|
+
{ plan_key: PLAN_KEY, epoch: 0, round: 0 },
|
|
105
|
+
{ plan_key: PLAN_KEY, epoch: 0, round: 1 },
|
|
102
106
|
],
|
|
103
107
|
key: "plan_key",
|
|
104
108
|
},
|
|
@@ -195,7 +199,13 @@ test("re-plan of a finished issue clears stale open escalations and the denormal
|
|
|
195
199
|
// task row, and publishing the correlated resume message — that had no unit coverage. These
|
|
196
200
|
// drive both against the in-memory data layer above and assert the oldest-first surfacing,
|
|
197
201
|
// the answer mirroring, and the published message.
|
|
198
|
-
import {
|
|
202
|
+
import {
|
|
203
|
+
answerPlanEscalation,
|
|
204
|
+
answerTaskEscalation,
|
|
205
|
+
currentPlanReviewEpoch,
|
|
206
|
+
PLAN_ESCALATION_MESSAGE,
|
|
207
|
+
refreshOpenTaskEscalation,
|
|
208
|
+
} from "./plan.ts";
|
|
199
209
|
|
|
200
210
|
function escalationStores(rows: unknown[]): Record<string, { rows: unknown[]; key: string }> {
|
|
201
211
|
return {
|
|
@@ -293,6 +303,73 @@ test("answerTaskEscalation is a no-op when no open escalation matches the correl
|
|
|
293
303
|
assertEquals(r.ok, false);
|
|
294
304
|
});
|
|
295
305
|
|
|
306
|
+
test("currentPlanReviewEpoch counts answered plan-review escalations only", async () => {
|
|
307
|
+
const stores = {
|
|
308
|
+
plan_review_escalations: {
|
|
309
|
+
rows: [
|
|
310
|
+
{ id: 1, plan_key: "owner/repo#10", status: "answered" },
|
|
311
|
+
{ id: 2, plan_key: "owner/repo#10", status: "open" },
|
|
312
|
+
{ id: 3, plan_key: "owner/repo#other", status: "answered" },
|
|
313
|
+
],
|
|
314
|
+
key: "id",
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
assertEquals(await currentPlanReviewEpoch(memData(stores), "owner/repo#10"), 1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("answerPlanEscalation records directive, clears the plan pointer, and publishes the resume message", async () => {
|
|
321
|
+
const stores = {
|
|
322
|
+
plans: {
|
|
323
|
+
rows: [{
|
|
324
|
+
plan_key: "owner/repo#11",
|
|
325
|
+
open_plan_escalation_id: 7,
|
|
326
|
+
open_plan_findings: "reviewer findings",
|
|
327
|
+
open_plan_round: 2,
|
|
328
|
+
}],
|
|
329
|
+
key: "plan_key",
|
|
330
|
+
},
|
|
331
|
+
plan_review_escalations: {
|
|
332
|
+
rows: [{
|
|
333
|
+
id: 7,
|
|
334
|
+
plan_key: "owner/repo#11",
|
|
335
|
+
epoch: 0,
|
|
336
|
+
round: 2,
|
|
337
|
+
findings: "reviewer findings",
|
|
338
|
+
status: "open",
|
|
339
|
+
directive: null,
|
|
340
|
+
note: null,
|
|
341
|
+
}],
|
|
342
|
+
key: "id",
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
const published: any[] = [];
|
|
346
|
+
const engine = {
|
|
347
|
+
publishMessage: (m: any) => {
|
|
348
|
+
published.push(m);
|
|
349
|
+
return Promise.resolve();
|
|
350
|
+
},
|
|
351
|
+
} as any;
|
|
352
|
+
|
|
353
|
+
const r = await answerPlanEscalation(memData(stores), engine, "owner/repo#11", "revise", "Use issue-1 as seam.");
|
|
354
|
+
assertEquals(r.ok, true);
|
|
355
|
+
assertEquals(r.directive, "revise");
|
|
356
|
+
const esc = stores.plan_review_escalations.rows[0] as any;
|
|
357
|
+
assertEquals(esc.status, "answered");
|
|
358
|
+
assertEquals(esc.directive, "revise");
|
|
359
|
+
assertEquals(esc.note, "Use issue-1 as seam.");
|
|
360
|
+
const plan = stores.plans.rows[0] as any;
|
|
361
|
+
assertEquals(plan.open_plan_escalation_id, null);
|
|
362
|
+
assertEquals(plan.open_plan_findings, null);
|
|
363
|
+
assertEquals(plan.open_plan_round, null);
|
|
364
|
+
assertEquals(published[0].name, PLAN_ESCALATION_MESSAGE);
|
|
365
|
+
assertEquals(published[0].correlationKey, "owner/repo#11");
|
|
366
|
+
assertEquals(published[0].variables.planEscalationDirective, "revise");
|
|
367
|
+
assertEquals(
|
|
368
|
+
String(published[0].variables.planFindings).includes("Use issue-1 as seam."),
|
|
369
|
+
true,
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
296
373
|
// Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql).
|
|
297
374
|
//
|
|
298
375
|
// A plan may pin a base branch so the fleet branches off — and opens every PR against — a long-lived
|
package/app/plan.ts
CHANGED
|
@@ -44,6 +44,13 @@ export interface Plan {
|
|
|
44
44
|
open_task_question: string | null;
|
|
45
45
|
open_task_corr_key: string | null;
|
|
46
46
|
open_task_id: string | null;
|
|
47
|
+
// Denormalised "open plan-review escalation" pointer (# plan-review escalation): when the
|
|
48
|
+
// adversarial plan-review cap is reached without approval, the process parks for a human
|
|
49
|
+
// proceed/revise directive. These fields surface the newest open plan-level escalation on the
|
|
50
|
+
// plans page without overloading the implementation-phase `plan_escalations` table.
|
|
51
|
+
open_plan_escalation_id: number | null;
|
|
52
|
+
open_plan_findings: string | null;
|
|
53
|
+
open_plan_round: number | null;
|
|
47
54
|
// Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
|
|
48
55
|
// waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
|
|
49
56
|
gate_wave: number | null;
|
|
@@ -115,6 +122,10 @@ export const planEscalations = (data: DataLayer) =>
|
|
|
115
122
|
* subscription correlates on `<plan_key>:<task_id>` (see plan-fanout.bpmn). */
|
|
116
123
|
export const FEATURE_ESCALATION_MESSAGE = "feature-escalation-answered";
|
|
117
124
|
|
|
125
|
+
/** The message the plan-fanout process catches to resume a plan-review escalation; its
|
|
126
|
+
* subscription correlates on `<plan_key>` (see plan-fanout.bpmn). */
|
|
127
|
+
export const PLAN_ESCALATION_MESSAGE = "plan-escalation-answered";
|
|
128
|
+
|
|
118
129
|
/** Build the per-task message correlation key the process parks on. */
|
|
119
130
|
export const featureCorrKey = (planKey: string, taskId: string) => `${planKey}:${taskId}`;
|
|
120
131
|
|
|
@@ -136,6 +147,7 @@ export const planTaskDeps = (data: DataLayer) =>
|
|
|
136
147
|
* (crash/timeout after the insert) reuses its row instead of appending a duplicate round. */
|
|
137
148
|
export interface PlanReview {
|
|
138
149
|
plan_key: string;
|
|
150
|
+
epoch: number;
|
|
139
151
|
round: number;
|
|
140
152
|
approved: number;
|
|
141
153
|
findings: string | null;
|
|
@@ -144,6 +156,31 @@ export interface PlanReview {
|
|
|
144
156
|
}
|
|
145
157
|
export const planReviews = (data: DataLayer) => data.table<PlanReview>("plan_reviews", "plan_key");
|
|
146
158
|
|
|
159
|
+
export type PlanEscalationDirective = "proceed" | "revise";
|
|
160
|
+
|
|
161
|
+
export function parsePlanEscalationDirective(input: unknown): PlanEscalationDirective | null {
|
|
162
|
+
const s = typeof input === "string" ? input.trim().toLowerCase() : "";
|
|
163
|
+
return s === "proceed" || s === "revise" ? s : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One plan-review cap escalation. Kept in a dedicated table rather than overloading
|
|
167
|
+
* `plan_escalations`: the latter is task-scoped (`task_id`/`corr_key` are NOT NULL and mirrored
|
|
168
|
+
* onto `plan_tasks`), while this row is plan-scoped and drives the review epoch reset. */
|
|
169
|
+
export interface PlanReviewEscalation {
|
|
170
|
+
id: number;
|
|
171
|
+
plan_key: string;
|
|
172
|
+
epoch: number;
|
|
173
|
+
round: number;
|
|
174
|
+
findings: string | null;
|
|
175
|
+
status: string;
|
|
176
|
+
directive: PlanEscalationDirective | null;
|
|
177
|
+
note: string | null;
|
|
178
|
+
asked_at: string;
|
|
179
|
+
answered_at: string | null;
|
|
180
|
+
}
|
|
181
|
+
export const planReviewEscalations = (data: DataLayer) =>
|
|
182
|
+
data.table<PlanReviewEscalation>("plan_review_escalations", "id");
|
|
183
|
+
|
|
147
184
|
/** Read a positive-integer env override, falling back when unset/blank/invalid. A bad value
|
|
148
185
|
* (e.g. "", "abc", "0", "2.5") must NOT silently become `NaN`/`0` — that would make the round
|
|
149
186
|
* cap `round + 1 >= cap` always false and allow an unbounded revise loop. */
|
|
@@ -154,11 +191,17 @@ export function positiveIntEnv(name: string, fallback: number): number {
|
|
|
154
191
|
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
155
192
|
}
|
|
156
193
|
|
|
157
|
-
/** Max adversarial plan-review rounds. Reaching the cap WITHOUT approval
|
|
158
|
-
* fan-out
|
|
159
|
-
* #86).
|
|
194
|
+
/** Max adversarial plan-review rounds per epoch. Reaching the cap WITHOUT approval parks the
|
|
195
|
+
* fan-out on a human plan-review escalation rather than dispatching an un-approved plan (issue
|
|
196
|
+
* #86). A human `revise` answer starts a fresh epoch, so the next plan gets a full new budget. */
|
|
160
197
|
export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
|
|
161
198
|
|
|
199
|
+
/** The current review epoch is derived from the append-only escalation log: every answered
|
|
200
|
+
* plan-review escalation represents a human decision to leave the prior budget behind. */
|
|
201
|
+
export async function currentPlanReviewEpoch(data: DataLayer, planKey: string): Promise<number> {
|
|
202
|
+
return await planReviewEscalations(data).count({ plan_key: planKey, status: "answered" });
|
|
203
|
+
}
|
|
204
|
+
|
|
162
205
|
/** A plan is "done" in exactly these states; everything else (planning, dispatched)
|
|
163
206
|
* is in flight. The cancel guard and the active view key off this. */
|
|
164
207
|
export const PLAN_TERMINAL_STATUSES: readonly string[] = ["done", "failed", "abandoned"];
|
|
@@ -279,14 +322,18 @@ export async function startPlan(
|
|
|
279
322
|
// Clear them here — the table is keyed on `plan_key`, so one delete drops the
|
|
280
323
|
// whole set (mirrors how record-plan clears `plan_task_deps`).
|
|
281
324
|
await planReviews(data).delete(parsed.planKey);
|
|
282
|
-
// Same class of stale-row bug for
|
|
283
|
-
//
|
|
284
|
-
// the
|
|
285
|
-
//
|
|
286
|
-
// re-surfaces a question for a `task_id` we just deleted from `plan_tasks`.
|
|
325
|
+
// Same class of stale-row bug for escalation state: task escalations are keyed on `id` (not
|
|
326
|
+
// `plan_key`), so drop the prior run's rows one-by-one. Otherwise a still-"open" escalation
|
|
327
|
+
// from the previous run survives the re-plan and `refreshOpenTaskEscalation` re-surfaces a
|
|
328
|
+
// question for a `task_id` we just deleted from `plan_tasks`.
|
|
287
329
|
for (const e of await planEscalations(data).find({ plan_key: parsed.planKey })) {
|
|
288
330
|
await planEscalations(data).delete(e.id);
|
|
289
331
|
}
|
|
332
|
+
// Plan-review escalations are also keyed on `id` because they are an audit trail; clear them
|
|
333
|
+
// on a fresh submission so the epoch derived from answered escalations resets to 0.
|
|
334
|
+
for (const e of await planReviewEscalations(data).find({ plan_key: parsed.planKey })) {
|
|
335
|
+
await planReviewEscalations(data).delete(e.id);
|
|
336
|
+
}
|
|
290
337
|
// Same for the structured impl-change deltas (D5, #55): keyed on `id`, so drop the prior run's
|
|
291
338
|
// rows one-by-one, otherwise a stale delta lingers in the epic report for a task we just deleted.
|
|
292
339
|
await clearTaskDeltas(data, parsed.planKey);
|
|
@@ -303,6 +350,9 @@ export async function startPlan(
|
|
|
303
350
|
open_task_question: null,
|
|
304
351
|
open_task_corr_key: null,
|
|
305
352
|
open_task_id: null,
|
|
353
|
+
open_plan_escalation_id: null,
|
|
354
|
+
open_plan_findings: null,
|
|
355
|
+
open_plan_round: null,
|
|
306
356
|
blackboard_token: token,
|
|
307
357
|
base_branch: base,
|
|
308
358
|
updated_at: ts,
|
|
@@ -398,3 +448,63 @@ export async function answerTaskEscalation(
|
|
|
398
448
|
await refreshOpenTaskEscalation(data, open.plan_key);
|
|
399
449
|
return { ok: true, escalationId: open.id, planKey: open.plan_key, taskId: open.task_id };
|
|
400
450
|
}
|
|
451
|
+
|
|
452
|
+
export function normalizePlanEscalationDirective(input: unknown): PlanEscalationDirective {
|
|
453
|
+
return parsePlanEscalationDirective(input) ?? "revise";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderPlanEscalationFindings(open: PlanReviewEscalation, note: string): string {
|
|
457
|
+
const parts = [
|
|
458
|
+
`Plan review reached its round budget at epoch ${open.epoch}, round ${open.round}.`,
|
|
459
|
+
"",
|
|
460
|
+
"Reviewer findings:",
|
|
461
|
+
(open.findings ?? "").trim() || "(no reviewer findings were provided.)",
|
|
462
|
+
];
|
|
463
|
+
if (note) {
|
|
464
|
+
parts.push("", "Human guidance:", note);
|
|
465
|
+
} else {
|
|
466
|
+
parts.push("", "Human directive: revise the plan within the allowed task boundaries.");
|
|
467
|
+
}
|
|
468
|
+
return parts.join("\n");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Answer the newest open plan-review escalation. `proceed` is an explicit human override that lets
|
|
472
|
+
* the current (unapproved) plan continue to wave dispatch; `revise` (the default) folds the human
|
|
473
|
+
* note into `planFindings` and starts a fresh review epoch on the next planner pass. */
|
|
474
|
+
export async function answerPlanEscalation(
|
|
475
|
+
data: DataLayer,
|
|
476
|
+
engine: EngineClient,
|
|
477
|
+
planKey: string,
|
|
478
|
+
directiveInput: unknown,
|
|
479
|
+
noteInput: unknown,
|
|
480
|
+
) {
|
|
481
|
+
const open = (await planReviewEscalations(data).find({ plan_key: planKey, status: "open" }))
|
|
482
|
+
.sort((a, b) => b.id - a.id)[0];
|
|
483
|
+
if (!open) return { ok: false, reason: "no open plan escalation" };
|
|
484
|
+
|
|
485
|
+
const directive = normalizePlanEscalationDirective(directiveInput);
|
|
486
|
+
const note = typeof noteInput === "string" ? noteInput.trim() : "";
|
|
487
|
+
const ts = now();
|
|
488
|
+
await planReviewEscalations(data).update(open.id, {
|
|
489
|
+
directive,
|
|
490
|
+
note: note || null,
|
|
491
|
+
status: "answered",
|
|
492
|
+
answered_at: ts,
|
|
493
|
+
});
|
|
494
|
+
await plans(data).update(planKey, {
|
|
495
|
+
open_plan_escalation_id: null,
|
|
496
|
+
open_plan_findings: null,
|
|
497
|
+
open_plan_round: null,
|
|
498
|
+
updated_at: ts,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
await engine.publishMessage({
|
|
502
|
+
name: PLAN_ESCALATION_MESSAGE,
|
|
503
|
+
correlationKey: planKey,
|
|
504
|
+
variables: {
|
|
505
|
+
planEscalationDirective: directive,
|
|
506
|
+
planFindings: directive === "revise" ? renderPlanEscalationFindings(open, note) : "",
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
return { ok: true, escalationId: open.id, planKey, directive };
|
|
510
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- Plan-review cap escalation. When the adversarial review loop exhausts its per-epoch budget
|
|
2
|
+
-- without approval, the plan-fanout process parks for a human directive instead of raising an
|
|
3
|
+
-- unhandled PLAN_REJECTED incident. A `revise` answer starts a new review epoch; a `proceed`
|
|
4
|
+
-- answer explicitly dispatches the current plan as-is.
|
|
5
|
+
--
|
|
6
|
+
-- `plan_reviews.round` remains derived from the append-only review log, but now within the current
|
|
7
|
+
-- epoch. SQLite cannot alter the existing PRIMARY KEY (plan_key, round), so recreate the table with
|
|
8
|
+
-- (plan_key, epoch, round) and backfill existing rows into epoch 0.
|
|
9
|
+
|
|
10
|
+
ALTER TABLE plan_reviews ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0;
|
|
11
|
+
|
|
12
|
+
CREATE TABLE plan_reviews_new (
|
|
13
|
+
plan_key TEXT NOT NULL REFERENCES plans(plan_key),
|
|
14
|
+
epoch INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
round INTEGER NOT NULL,
|
|
16
|
+
approved INTEGER NOT NULL,
|
|
17
|
+
findings TEXT,
|
|
18
|
+
created_at TEXT NOT NULL,
|
|
19
|
+
job_key TEXT,
|
|
20
|
+
PRIMARY KEY (plan_key, epoch, round)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
INSERT INTO plan_reviews_new (plan_key, epoch, round, approved, findings, created_at, job_key)
|
|
24
|
+
SELECT plan_key, epoch, round, approved, findings, created_at, job_key
|
|
25
|
+
FROM plan_reviews;
|
|
26
|
+
|
|
27
|
+
DROP TABLE plan_reviews;
|
|
28
|
+
ALTER TABLE plan_reviews_new RENAME TO plan_reviews;
|
|
29
|
+
|
|
30
|
+
CREATE INDEX idx_plan_reviews_plan ON plan_reviews(plan_key);
|
|
31
|
+
CREATE UNIQUE INDEX idx_plan_reviews_job ON plan_reviews(plan_key, job_key);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE plan_review_escalations (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
plan_key TEXT NOT NULL REFERENCES plans(plan_key),
|
|
36
|
+
epoch INTEGER NOT NULL,
|
|
37
|
+
round INTEGER NOT NULL,
|
|
38
|
+
findings TEXT,
|
|
39
|
+
status TEXT NOT NULL, -- open | answered
|
|
40
|
+
directive TEXT, -- proceed | revise (answered rows only)
|
|
41
|
+
note TEXT,
|
|
42
|
+
asked_at TEXT NOT NULL,
|
|
43
|
+
answered_at TEXT
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX idx_plan_review_escalations_plan ON plan_review_escalations(plan_key);
|
|
47
|
+
CREATE INDEX idx_plan_review_escalations_open ON plan_review_escalations(plan_key, status);
|
|
48
|
+
|
|
49
|
+
ALTER TABLE plans ADD COLUMN open_plan_escalation_id INTEGER;
|
|
50
|
+
ALTER TABLE plans ADD COLUMN open_plan_findings TEXT;
|
|
51
|
+
ALTER TABLE plans ADD COLUMN open_plan_round INTEGER;
|
package/nano.app.json
CHANGED
|
@@ -119,6 +119,10 @@
|
|
|
119
119
|
"taskType": "pr.persist-task-escalation",
|
|
120
120
|
"handler": "workers/persist-task-escalation/worker.ts"
|
|
121
121
|
},
|
|
122
|
+
{
|
|
123
|
+
"taskType": "pr.persist-plan-escalation",
|
|
124
|
+
"handler": "workers/persist-plan-escalation/worker.ts"
|
|
125
|
+
},
|
|
122
126
|
{
|
|
123
127
|
"taskType": "pr.retro-gather",
|
|
124
128
|
"handler": "workers/retro-gather/worker.ts"
|
package/openapi.yaml
CHANGED
|
@@ -345,6 +345,27 @@ components:
|
|
|
345
345
|
type: string
|
|
346
346
|
minLength: 1
|
|
347
347
|
description: The operator's answer that resumes the parked implementation agent.
|
|
348
|
+
PlanAnswerRequest:
|
|
349
|
+
type: object
|
|
350
|
+
additionalProperties: false
|
|
351
|
+
required:
|
|
352
|
+
- plan
|
|
353
|
+
- directive
|
|
354
|
+
properties:
|
|
355
|
+
plan:
|
|
356
|
+
type: string
|
|
357
|
+
description: Plan reference (owner/repo#N), also the message correlation key.
|
|
358
|
+
directive:
|
|
359
|
+
type: string
|
|
360
|
+
description: >-
|
|
361
|
+
One of `proceed` or `revise` (case-insensitive; normalized to lowercase and trimmed
|
|
362
|
+
server-side, see `parsePlanEscalationDirective`).
|
|
363
|
+
`proceed` dispatches the current unapproved plan as an explicit human override;
|
|
364
|
+
`revise` loops back to the planner with the note folded into planFindings and a fresh
|
|
365
|
+
review budget.
|
|
366
|
+
note:
|
|
367
|
+
type: string
|
|
368
|
+
description: Human guidance for the planner (used for `revise`; optional for `proceed`).
|
|
348
369
|
BlackboardEntry:
|
|
349
370
|
type: object
|
|
350
371
|
additionalProperties: false
|
|
@@ -578,9 +599,9 @@ paths:
|
|
|
578
599
|
/actions/message:
|
|
579
600
|
post:
|
|
580
601
|
operationId: postMessage
|
|
581
|
-
summary: Publish a message / answer an escalation. For escalation-answered
|
|
582
|
-
feature-escalation-answered names, runs the corresponding
|
|
583
|
-
publishMessage.
|
|
602
|
+
summary: Publish a message / answer an escalation. For escalation-answered,
|
|
603
|
+
feature-escalation-answered, and plan-escalation-answered names, runs the corresponding
|
|
604
|
+
answer flow; otherwise a plain publishMessage.
|
|
584
605
|
requestBody:
|
|
585
606
|
required: true
|
|
586
607
|
content:
|
|
@@ -603,6 +624,13 @@ paths:
|
|
|
603
624
|
properties:
|
|
604
625
|
answer:
|
|
605
626
|
type: string
|
|
627
|
+
directive:
|
|
628
|
+
type: string
|
|
629
|
+
description: >-
|
|
630
|
+
One of `proceed` or `revise` (case-insensitive; normalized to lowercase and
|
|
631
|
+
trimmed server-side, see `parsePlanEscalationDirective`).
|
|
632
|
+
note:
|
|
633
|
+
type: string
|
|
606
634
|
responses:
|
|
607
635
|
"200":
|
|
608
636
|
description: The message was published (or the escalation answered).
|
|
@@ -661,6 +689,45 @@ paths:
|
|
|
661
689
|
application/json:
|
|
662
690
|
schema:
|
|
663
691
|
$ref: "#/components/schemas/MessageResult"
|
|
692
|
+
/hooks/plan-answer:
|
|
693
|
+
post:
|
|
694
|
+
operationId: answerPlanEscalation
|
|
695
|
+
summary: "Answer a plan-review cap escalation out of band. Optional shared-secret guard
|
|
696
|
+
(x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET is set."
|
|
697
|
+
security:
|
|
698
|
+
- hookSecret: []
|
|
699
|
+
- {}
|
|
700
|
+
requestBody:
|
|
701
|
+
required: true
|
|
702
|
+
content:
|
|
703
|
+
application/json:
|
|
704
|
+
schema:
|
|
705
|
+
$ref: "#/components/schemas/PlanAnswerRequest"
|
|
706
|
+
responses:
|
|
707
|
+
"200":
|
|
708
|
+
description: The escalation was answered and the parked plan resumed.
|
|
709
|
+
content:
|
|
710
|
+
application/json:
|
|
711
|
+
schema:
|
|
712
|
+
$ref: "#/components/schemas/MessageResult"
|
|
713
|
+
"400":
|
|
714
|
+
description: A required field was missing (plan or directive).
|
|
715
|
+
content:
|
|
716
|
+
application/json:
|
|
717
|
+
schema:
|
|
718
|
+
$ref: "#/components/schemas/MessageResult"
|
|
719
|
+
"401":
|
|
720
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
721
|
+
content:
|
|
722
|
+
application/json:
|
|
723
|
+
schema:
|
|
724
|
+
$ref: "#/components/schemas/MessageResult"
|
|
725
|
+
"404":
|
|
726
|
+
description: No matching open plan escalation for the plan key.
|
|
727
|
+
content:
|
|
728
|
+
application/json:
|
|
729
|
+
schema:
|
|
730
|
+
$ref: "#/components/schemas/MessageResult"
|
|
664
731
|
/hooks/blackboard:
|
|
665
732
|
get:
|
|
666
733
|
operationId: readBlackboard
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { assertEquals } from "#test-assert";
|
|
3
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
4
|
+
import { noopLog } from "../test/log.ts";
|
|
5
|
+
|
|
6
|
+
const hadSecret = Object.prototype.hasOwnProperty.call(process.env, "NANO_PR_WEBHOOK_SECRET");
|
|
7
|
+
const previousSecret = process.env.NANO_PR_WEBHOOK_SECRET;
|
|
8
|
+
let answerPlanEscalation: typeof import("./answerPlanEscalation.ts").default;
|
|
9
|
+
try {
|
|
10
|
+
process.env.NANO_PR_WEBHOOK_SECRET = " test-secret ";
|
|
11
|
+
answerPlanEscalation = (await import("./answerPlanEscalation.ts")).default;
|
|
12
|
+
} finally {
|
|
13
|
+
if (hadSecret && previousSecret !== undefined) process.env.NANO_PR_WEBHOOK_SECRET = previousSecret;
|
|
14
|
+
else delete process.env.NANO_PR_WEBHOOK_SECRET;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function memTable(rows: any[], key: string) {
|
|
18
|
+
return {
|
|
19
|
+
find: (where: Record<string, unknown>) =>
|
|
20
|
+
Promise.resolve(rows.filter((row) => Object.entries(where).every(([field, value]) => row[field] === value))),
|
|
21
|
+
update: (value: unknown, patch: Record<string, unknown>) => {
|
|
22
|
+
const row = rows.find((candidate) => candidate[key] === value);
|
|
23
|
+
if (row) Object.assign(row, patch);
|
|
24
|
+
return Promise.resolve(row);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function memApp(escalations: any[] = []) {
|
|
30
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
31
|
+
plans: { rows: [{ plan_key: "owner/repo#9", open_plan_escalation_id: 1 }], key: "plan_key" },
|
|
32
|
+
plan_review_escalations: { rows: escalations, key: "id" },
|
|
33
|
+
};
|
|
34
|
+
const published: Record<string, unknown>[] = [];
|
|
35
|
+
const app = {
|
|
36
|
+
data: {
|
|
37
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
38
|
+
},
|
|
39
|
+
engine: {
|
|
40
|
+
publishMessage: (message: Record<string, unknown>) => {
|
|
41
|
+
published.push(message);
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
log: noopLog(),
|
|
46
|
+
} as any as AppApi;
|
|
47
|
+
return { app, published, stores };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function input(body: Record<string, unknown>, secret?: string) {
|
|
51
|
+
const headers = new Headers();
|
|
52
|
+
if (secret !== undefined) headers.set("x-hook-secret", secret);
|
|
53
|
+
return {
|
|
54
|
+
req: {
|
|
55
|
+
method: "POST",
|
|
56
|
+
path: "/app/api/hooks/plan-answer",
|
|
57
|
+
query: new URLSearchParams(),
|
|
58
|
+
headers,
|
|
59
|
+
text: async () => "",
|
|
60
|
+
} as any,
|
|
61
|
+
params: {},
|
|
62
|
+
query: {},
|
|
63
|
+
body,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("rejects a request without the configured hook secret", async () => {
|
|
68
|
+
const { app } = memApp();
|
|
69
|
+
const result = await answerPlanEscalation(input({ plan: "owner/repo#9", directive: "revise" }), app) as any;
|
|
70
|
+
assertEquals(result.status, 401);
|
|
71
|
+
assertEquals(result.body, { ok: false, error: "unauthorized" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("answers an open plan escalation and publishes the plan correlation message", async () => {
|
|
75
|
+
const { app, published, stores } = memApp([{
|
|
76
|
+
id: 1,
|
|
77
|
+
plan_key: "owner/repo#9",
|
|
78
|
+
epoch: 0,
|
|
79
|
+
round: 2,
|
|
80
|
+
findings: "needs wave 0",
|
|
81
|
+
status: "open",
|
|
82
|
+
}]);
|
|
83
|
+
const result = await answerPlanEscalation(
|
|
84
|
+
input({ plan: "owner/repo#9", directive: "revise", note: " use issue-1 first " }, "test-secret"),
|
|
85
|
+
app,
|
|
86
|
+
) as any;
|
|
87
|
+
assertEquals(result.status, 200);
|
|
88
|
+
assertEquals(result.body.ok, true);
|
|
89
|
+
assertEquals(stores.plan_review_escalations.rows[0].status, "answered");
|
|
90
|
+
assertEquals(stores.plan_review_escalations.rows[0].directive, "revise");
|
|
91
|
+
assertEquals(stores.plan_review_escalations.rows[0].note, "use issue-1 first");
|
|
92
|
+
assertEquals(published[0]?.name, "plan-escalation-answered");
|
|
93
|
+
assertEquals(published[0]?.correlationKey, "owner/repo#9");
|
|
94
|
+
assertEquals((published[0]?.variables as Record<string, unknown>).planEscalationDirective, "revise");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("maps an unmatched plan to 404", async () => {
|
|
98
|
+
const { app } = memApp();
|
|
99
|
+
const result = await answerPlanEscalation(
|
|
100
|
+
input({ plan: "owner/repo#missing", directive: "revise" }, "test-secret"),
|
|
101
|
+
app,
|
|
102
|
+
) as any;
|
|
103
|
+
assertEquals(result.status, 404);
|
|
104
|
+
assertEquals(result.body.ok, false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("rejects an invalid directive with 400", async () => {
|
|
108
|
+
const { app } = memApp();
|
|
109
|
+
const result = await answerPlanEscalation(
|
|
110
|
+
input({ plan: "owner/repo#9", directive: "ship-it" }, "test-secret"),
|
|
111
|
+
app,
|
|
112
|
+
) as any;
|
|
113
|
+
assertEquals(result.status, 400);
|
|
114
|
+
assertEquals(result.body.ok, false);
|
|
115
|
+
});
|