@nanobpm/nano-workforce 0.45.0 → 0.46.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/SPEC.md +40 -0
- package/app/plan.test.ts +139 -4
- package/app/plan.ts +133 -8
- package/app/trialMerge.test.ts +94 -2
- package/app/trialMerge.ts +67 -12
- package/db/migrations/020_plan_review_escalation.sql +51 -0
- package/db/migrations/021_trial_merge_resolved.sql +44 -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 +69 -3
- package/prompts/plan.md +11 -0
- package/resources/agent-guide.md +13 -3
- package/resources/processes/plan-fanout.bpmn +192 -110
- package/workers/persist-plan-escalation/worker.test.ts +80 -0
- package/workers/persist-plan-escalation/worker.ts +73 -0
- package/workers/persist-task-escalation/worker.ts +9 -1
- 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,17 @@
|
|
|
1
|
+
## [0.46.1](https://github.com/nanobpm/nano-workforce/compare/v0.46.0...v0.46.1) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **trial-merge:** durable "needs attention" resolution + robust escalation key ([#131](https://github.com/nanobpm/nano-workforce/issues/131)) ([71200e8](https://github.com/nanobpm/nano-workforce/commit/71200e814a8c035a79f1eec37303781c70b6e6c0))
|
|
7
|
+
|
|
8
|
+
# [0.46.0](https://github.com/nanobpm/nano-workforce/compare/v0.45.0...v0.46.0) (2026-08-12)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* 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)
|
|
14
|
+
|
|
1
15
|
# [0.45.0](https://github.com/nanobpm/nano-workforce/compare/v0.44.1...v0.45.0) (2026-08-12)
|
|
2
16
|
|
|
3
17
|
|
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
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
|
|
6
6
|
// is not a positive integer, so the loop is always bounded.
|
|
7
7
|
import { test } from "node:test";
|
|
8
|
-
import { assertEquals, assertThrows } from "#test-assert";
|
|
8
|
+
import { assertEquals, assertRejects, assertThrows } from "#test-assert";
|
|
9
9
|
import { positiveIntEnv } from "./plan.ts";
|
|
10
10
|
|
|
11
11
|
const KEY = "NANO_PLAN_REVIEW_ROUNDS_TEST";
|
|
@@ -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,131 @@ test("answerTaskEscalation is a no-op when no open escalation matches the correl
|
|
|
293
303
|
assertEquals(r.ok, false);
|
|
294
304
|
});
|
|
295
305
|
|
|
306
|
+
// Red/green regression (PR #131 suppressed advisory, app/plan.ts:455).
|
|
307
|
+
//
|
|
308
|
+
// Clearing a trial-merge wave's "Needs attention" row (`resolveTrialMergeAttention`)
|
|
309
|
+
// is a best-effort cosmetic cleanup, but it must be RETRIABLE: if it ran only AFTER
|
|
310
|
+
// the escalation was committed as `answered` and the resume message was published,
|
|
311
|
+
// a transient DB error there would 500 the whole answer flow while the escalation is
|
|
312
|
+
// already answered/resumed — a retry then 404s (no open escalation) and the red row
|
|
313
|
+
// is pinned forever (the very failure the insert-first ordering elsewhere avoids).
|
|
314
|
+
// The fix runs the idempotent resolution BEFORE the commit/publish, so a failure
|
|
315
|
+
// leaves the escalation OPEN and nothing is orphaned — the caller can safely retry.
|
|
316
|
+
test("answerTaskEscalation stays retriable (escalation open, no orphaned resume) when clearing 'Needs attention' fails", async () => {
|
|
317
|
+
const stores = escalationStores([
|
|
318
|
+
{
|
|
319
|
+
id: 1,
|
|
320
|
+
plan_key: "owner/repo#9",
|
|
321
|
+
task_id: "trial-merge-wave-0",
|
|
322
|
+
corr_key: "owner/repo#9:trial-merge-wave-0",
|
|
323
|
+
question: "Q",
|
|
324
|
+
status: "open",
|
|
325
|
+
answer: null,
|
|
326
|
+
},
|
|
327
|
+
]);
|
|
328
|
+
stores.plan_trial_merges = {
|
|
329
|
+
rows: [{ id: 100, plan_key: "owner/repo#9", wave: 0, resolved: 0 }],
|
|
330
|
+
key: "id",
|
|
331
|
+
};
|
|
332
|
+
const base = memData(stores);
|
|
333
|
+
// Inject a transient failure in the trial-merge audit table's `update` only.
|
|
334
|
+
const data = {
|
|
335
|
+
table: (name: string, key: string) => {
|
|
336
|
+
const t = base.table(name, key);
|
|
337
|
+
if (name === "plan_trial_merges") {
|
|
338
|
+
return { ...t, update: () => Promise.reject(new Error("transient DB error")) };
|
|
339
|
+
}
|
|
340
|
+
return t;
|
|
341
|
+
},
|
|
342
|
+
} as any;
|
|
343
|
+
|
|
344
|
+
const published: any[] = [];
|
|
345
|
+
const engine = {
|
|
346
|
+
publishMessage: (m: any) => {
|
|
347
|
+
published.push(m);
|
|
348
|
+
return Promise.resolve();
|
|
349
|
+
},
|
|
350
|
+
} as any;
|
|
351
|
+
|
|
352
|
+
await assertRejects(() =>
|
|
353
|
+
answerTaskEscalation(data, engine, "owner/repo#9:trial-merge-wave-0", "proceed")
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
// Escalation must remain OPEN so a retry can recover (never committed as answered).
|
|
357
|
+
const esc = stores.plan_escalations.rows.find((x: any) => x.id === 1) as any;
|
|
358
|
+
assertEquals(esc.status, "open");
|
|
359
|
+
assertEquals(esc.answer, null);
|
|
360
|
+
// No orphaned resume message was published.
|
|
361
|
+
assertEquals(published.length, 0);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("currentPlanReviewEpoch counts answered plan-review escalations only", async () => {
|
|
365
|
+
const stores = {
|
|
366
|
+
plan_review_escalations: {
|
|
367
|
+
rows: [
|
|
368
|
+
{ id: 1, plan_key: "owner/repo#10", status: "answered" },
|
|
369
|
+
{ id: 2, plan_key: "owner/repo#10", status: "open" },
|
|
370
|
+
{ id: 3, plan_key: "owner/repo#other", status: "answered" },
|
|
371
|
+
],
|
|
372
|
+
key: "id",
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
assertEquals(await currentPlanReviewEpoch(memData(stores), "owner/repo#10"), 1);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("answerPlanEscalation records directive, clears the plan pointer, and publishes the resume message", async () => {
|
|
379
|
+
const stores = {
|
|
380
|
+
plans: {
|
|
381
|
+
rows: [{
|
|
382
|
+
plan_key: "owner/repo#11",
|
|
383
|
+
open_plan_escalation_id: 7,
|
|
384
|
+
open_plan_findings: "reviewer findings",
|
|
385
|
+
open_plan_round: 2,
|
|
386
|
+
}],
|
|
387
|
+
key: "plan_key",
|
|
388
|
+
},
|
|
389
|
+
plan_review_escalations: {
|
|
390
|
+
rows: [{
|
|
391
|
+
id: 7,
|
|
392
|
+
plan_key: "owner/repo#11",
|
|
393
|
+
epoch: 0,
|
|
394
|
+
round: 2,
|
|
395
|
+
findings: "reviewer findings",
|
|
396
|
+
status: "open",
|
|
397
|
+
directive: null,
|
|
398
|
+
note: null,
|
|
399
|
+
}],
|
|
400
|
+
key: "id",
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
const published: any[] = [];
|
|
404
|
+
const engine = {
|
|
405
|
+
publishMessage: (m: any) => {
|
|
406
|
+
published.push(m);
|
|
407
|
+
return Promise.resolve();
|
|
408
|
+
},
|
|
409
|
+
} as any;
|
|
410
|
+
|
|
411
|
+
const r = await answerPlanEscalation(memData(stores), engine, "owner/repo#11", "revise", "Use issue-1 as seam.");
|
|
412
|
+
assertEquals(r.ok, true);
|
|
413
|
+
assertEquals(r.directive, "revise");
|
|
414
|
+
const esc = stores.plan_review_escalations.rows[0] as any;
|
|
415
|
+
assertEquals(esc.status, "answered");
|
|
416
|
+
assertEquals(esc.directive, "revise");
|
|
417
|
+
assertEquals(esc.note, "Use issue-1 as seam.");
|
|
418
|
+
const plan = stores.plans.rows[0] as any;
|
|
419
|
+
assertEquals(plan.open_plan_escalation_id, null);
|
|
420
|
+
assertEquals(plan.open_plan_findings, null);
|
|
421
|
+
assertEquals(plan.open_plan_round, null);
|
|
422
|
+
assertEquals(published[0].name, PLAN_ESCALATION_MESSAGE);
|
|
423
|
+
assertEquals(published[0].correlationKey, "owner/repo#11");
|
|
424
|
+
assertEquals(published[0].variables.planEscalationDirective, "revise");
|
|
425
|
+
assertEquals(
|
|
426
|
+
String(published[0].variables.planFindings).includes("Use issue-1 as seam."),
|
|
427
|
+
true,
|
|
428
|
+
);
|
|
429
|
+
});
|
|
430
|
+
|
|
296
431
|
// Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql).
|
|
297
432
|
//
|
|
298
433
|
// 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
|
@@ -13,6 +13,7 @@ import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
|
13
13
|
import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
|
|
14
14
|
import { clearExclusions } from "./mergeExclusion.ts";
|
|
15
15
|
import { clearTaskDeltas } from "./taskDelta.ts";
|
|
16
|
+
import { resolveTrialMergeAttention, trialMergeWaveFromTaskId } from "./trialMerge.ts";
|
|
16
17
|
|
|
17
18
|
/** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
|
|
18
19
|
export const PLAN_PROCESS_ID = "plan-fanout";
|
|
@@ -44,6 +45,13 @@ export interface Plan {
|
|
|
44
45
|
open_task_question: string | null;
|
|
45
46
|
open_task_corr_key: string | null;
|
|
46
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;
|
|
47
55
|
// Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
|
|
48
56
|
// waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
|
|
49
57
|
gate_wave: number | null;
|
|
@@ -115,6 +123,10 @@ export const planEscalations = (data: DataLayer) =>
|
|
|
115
123
|
* subscription correlates on `<plan_key>:<task_id>` (see plan-fanout.bpmn). */
|
|
116
124
|
export const FEATURE_ESCALATION_MESSAGE = "feature-escalation-answered";
|
|
117
125
|
|
|
126
|
+
/** The message the plan-fanout process catches to resume a plan-review escalation; its
|
|
127
|
+
* subscription correlates on `<plan_key>` (see plan-fanout.bpmn). */
|
|
128
|
+
export const PLAN_ESCALATION_MESSAGE = "plan-escalation-answered";
|
|
129
|
+
|
|
118
130
|
/** Build the per-task message correlation key the process parks on. */
|
|
119
131
|
export const featureCorrKey = (planKey: string, taskId: string) => `${planKey}:${taskId}`;
|
|
120
132
|
|
|
@@ -136,6 +148,7 @@ export const planTaskDeps = (data: DataLayer) =>
|
|
|
136
148
|
* (crash/timeout after the insert) reuses its row instead of appending a duplicate round. */
|
|
137
149
|
export interface PlanReview {
|
|
138
150
|
plan_key: string;
|
|
151
|
+
epoch: number;
|
|
139
152
|
round: number;
|
|
140
153
|
approved: number;
|
|
141
154
|
findings: string | null;
|
|
@@ -144,6 +157,31 @@ export interface PlanReview {
|
|
|
144
157
|
}
|
|
145
158
|
export const planReviews = (data: DataLayer) => data.table<PlanReview>("plan_reviews", "plan_key");
|
|
146
159
|
|
|
160
|
+
export type PlanEscalationDirective = "proceed" | "revise";
|
|
161
|
+
|
|
162
|
+
export function parsePlanEscalationDirective(input: unknown): PlanEscalationDirective | null {
|
|
163
|
+
const s = typeof input === "string" ? input.trim().toLowerCase() : "";
|
|
164
|
+
return s === "proceed" || s === "revise" ? s : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** One plan-review cap escalation. Kept in a dedicated table rather than overloading
|
|
168
|
+
* `plan_escalations`: the latter is task-scoped (`task_id`/`corr_key` are NOT NULL and mirrored
|
|
169
|
+
* onto `plan_tasks`), while this row is plan-scoped and drives the review epoch reset. */
|
|
170
|
+
export interface PlanReviewEscalation {
|
|
171
|
+
id: number;
|
|
172
|
+
plan_key: string;
|
|
173
|
+
epoch: number;
|
|
174
|
+
round: number;
|
|
175
|
+
findings: string | null;
|
|
176
|
+
status: string;
|
|
177
|
+
directive: PlanEscalationDirective | null;
|
|
178
|
+
note: string | null;
|
|
179
|
+
asked_at: string;
|
|
180
|
+
answered_at: string | null;
|
|
181
|
+
}
|
|
182
|
+
export const planReviewEscalations = (data: DataLayer) =>
|
|
183
|
+
data.table<PlanReviewEscalation>("plan_review_escalations", "id");
|
|
184
|
+
|
|
147
185
|
/** Read a positive-integer env override, falling back when unset/blank/invalid. A bad value
|
|
148
186
|
* (e.g. "", "abc", "0", "2.5") must NOT silently become `NaN`/`0` — that would make the round
|
|
149
187
|
* cap `round + 1 >= cap` always false and allow an unbounded revise loop. */
|
|
@@ -154,11 +192,17 @@ export function positiveIntEnv(name: string, fallback: number): number {
|
|
|
154
192
|
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
155
193
|
}
|
|
156
194
|
|
|
157
|
-
/** Max adversarial plan-review rounds. Reaching the cap WITHOUT approval
|
|
158
|
-
* fan-out
|
|
159
|
-
* #86).
|
|
195
|
+
/** Max adversarial plan-review rounds per epoch. Reaching the cap WITHOUT approval parks the
|
|
196
|
+
* fan-out on a human plan-review escalation rather than dispatching an un-approved plan (issue
|
|
197
|
+
* #86). A human `revise` answer starts a fresh epoch, so the next plan gets a full new budget. */
|
|
160
198
|
export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
|
|
161
199
|
|
|
200
|
+
/** The current review epoch is derived from the append-only escalation log: every answered
|
|
201
|
+
* plan-review escalation represents a human decision to leave the prior budget behind. */
|
|
202
|
+
export async function currentPlanReviewEpoch(data: DataLayer, planKey: string): Promise<number> {
|
|
203
|
+
return await planReviewEscalations(data).count({ plan_key: planKey, status: "answered" });
|
|
204
|
+
}
|
|
205
|
+
|
|
162
206
|
/** A plan is "done" in exactly these states; everything else (planning, dispatched)
|
|
163
207
|
* is in flight. The cancel guard and the active view key off this. */
|
|
164
208
|
export const PLAN_TERMINAL_STATUSES: readonly string[] = ["done", "failed", "abandoned"];
|
|
@@ -279,14 +323,18 @@ export async function startPlan(
|
|
|
279
323
|
// Clear them here — the table is keyed on `plan_key`, so one delete drops the
|
|
280
324
|
// whole set (mirrors how record-plan clears `plan_task_deps`).
|
|
281
325
|
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`.
|
|
326
|
+
// Same class of stale-row bug for escalation state: task escalations are keyed on `id` (not
|
|
327
|
+
// `plan_key`), so drop the prior run's rows one-by-one. Otherwise a still-"open" escalation
|
|
328
|
+
// from the previous run survives the re-plan and `refreshOpenTaskEscalation` re-surfaces a
|
|
329
|
+
// question for a `task_id` we just deleted from `plan_tasks`.
|
|
287
330
|
for (const e of await planEscalations(data).find({ plan_key: parsed.planKey })) {
|
|
288
331
|
await planEscalations(data).delete(e.id);
|
|
289
332
|
}
|
|
333
|
+
// Plan-review escalations are also keyed on `id` because they are an audit trail; clear them
|
|
334
|
+
// on a fresh submission so the epoch derived from answered escalations resets to 0.
|
|
335
|
+
for (const e of await planReviewEscalations(data).find({ plan_key: parsed.planKey })) {
|
|
336
|
+
await planReviewEscalations(data).delete(e.id);
|
|
337
|
+
}
|
|
290
338
|
// Same for the structured impl-change deltas (D5, #55): keyed on `id`, so drop the prior run's
|
|
291
339
|
// rows one-by-one, otherwise a stale delta lingers in the epic report for a task we just deleted.
|
|
292
340
|
await clearTaskDeltas(data, parsed.planKey);
|
|
@@ -303,6 +351,9 @@ export async function startPlan(
|
|
|
303
351
|
open_task_question: null,
|
|
304
352
|
open_task_corr_key: null,
|
|
305
353
|
open_task_id: null,
|
|
354
|
+
open_plan_escalation_id: null,
|
|
355
|
+
open_plan_findings: null,
|
|
356
|
+
open_plan_round: null,
|
|
306
357
|
blackboard_token: token,
|
|
307
358
|
base_branch: base,
|
|
308
359
|
updated_at: ts,
|
|
@@ -383,6 +434,20 @@ export async function answerTaskEscalation(
|
|
|
383
434
|
.sort((a, b) => b.id - a.id)[0];
|
|
384
435
|
if (!open) return { ok: false, reason: "no open escalation" };
|
|
385
436
|
const ts = now();
|
|
437
|
+
// A trial-merge escalation (task_id `trial-merge-wave-<wave>`) leaves an
|
|
438
|
+
// append-only red audit row in `plan_trial_merges`. Answering it clears that
|
|
439
|
+
// row from the page's "Needs attention" tab — including a "proceed" override
|
|
440
|
+
// that records no re-run row (a re-run would supersede it, but a proceed would
|
|
441
|
+
// not, pinning the red row forever).
|
|
442
|
+
//
|
|
443
|
+
// Resolve it FIRST, before the escalation is committed as answered and the
|
|
444
|
+
// resume message is published. `resolveTrialMergeAttention` is idempotent, so
|
|
445
|
+
// if this throws (e.g. a transient DB error) the escalation is still open and
|
|
446
|
+
// the whole operation retries cleanly. Running it AFTER the commit/publish
|
|
447
|
+
// would make a failure here unrecoverable: the escalation is already answered,
|
|
448
|
+
// a retry 404s (no open escalation), and the red row is pinned forever.
|
|
449
|
+
const trialWave = trialMergeWaveFromTaskId(open.task_id);
|
|
450
|
+
if (trialWave != null) await resolveTrialMergeAttention(data, open.plan_key, trialWave);
|
|
386
451
|
await planEscalations(data).update(open.id, { answer, status: "answered", answered_at: ts });
|
|
387
452
|
// Mirror onto the task row so a re-dispatched agent (and the UI) sees the answer.
|
|
388
453
|
for (const t of await planTasks(data).find({ plan_key: open.plan_key, task_id: open.task_id })) {
|
|
@@ -398,3 +463,63 @@ export async function answerTaskEscalation(
|
|
|
398
463
|
await refreshOpenTaskEscalation(data, open.plan_key);
|
|
399
464
|
return { ok: true, escalationId: open.id, planKey: open.plan_key, taskId: open.task_id };
|
|
400
465
|
}
|
|
466
|
+
|
|
467
|
+
export function normalizePlanEscalationDirective(input: unknown): PlanEscalationDirective {
|
|
468
|
+
return parsePlanEscalationDirective(input) ?? "revise";
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function renderPlanEscalationFindings(open: PlanReviewEscalation, note: string): string {
|
|
472
|
+
const parts = [
|
|
473
|
+
`Plan review reached its round budget at epoch ${open.epoch}, round ${open.round}.`,
|
|
474
|
+
"",
|
|
475
|
+
"Reviewer findings:",
|
|
476
|
+
(open.findings ?? "").trim() || "(no reviewer findings were provided.)",
|
|
477
|
+
];
|
|
478
|
+
if (note) {
|
|
479
|
+
parts.push("", "Human guidance:", note);
|
|
480
|
+
} else {
|
|
481
|
+
parts.push("", "Human directive: revise the plan within the allowed task boundaries.");
|
|
482
|
+
}
|
|
483
|
+
return parts.join("\n");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Answer the newest open plan-review escalation. `proceed` is an explicit human override that lets
|
|
487
|
+
* the current (unapproved) plan continue to wave dispatch; `revise` (the default) folds the human
|
|
488
|
+
* note into `planFindings` and starts a fresh review epoch on the next planner pass. */
|
|
489
|
+
export async function answerPlanEscalation(
|
|
490
|
+
data: DataLayer,
|
|
491
|
+
engine: EngineClient,
|
|
492
|
+
planKey: string,
|
|
493
|
+
directiveInput: unknown,
|
|
494
|
+
noteInput: unknown,
|
|
495
|
+
) {
|
|
496
|
+
const open = (await planReviewEscalations(data).find({ plan_key: planKey, status: "open" }))
|
|
497
|
+
.sort((a, b) => b.id - a.id)[0];
|
|
498
|
+
if (!open) return { ok: false, reason: "no open plan escalation" };
|
|
499
|
+
|
|
500
|
+
const directive = normalizePlanEscalationDirective(directiveInput);
|
|
501
|
+
const note = typeof noteInput === "string" ? noteInput.trim() : "";
|
|
502
|
+
const ts = now();
|
|
503
|
+
await planReviewEscalations(data).update(open.id, {
|
|
504
|
+
directive,
|
|
505
|
+
note: note || null,
|
|
506
|
+
status: "answered",
|
|
507
|
+
answered_at: ts,
|
|
508
|
+
});
|
|
509
|
+
await plans(data).update(planKey, {
|
|
510
|
+
open_plan_escalation_id: null,
|
|
511
|
+
open_plan_findings: null,
|
|
512
|
+
open_plan_round: null,
|
|
513
|
+
updated_at: ts,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
await engine.publishMessage({
|
|
517
|
+
name: PLAN_ESCALATION_MESSAGE,
|
|
518
|
+
correlationKey: planKey,
|
|
519
|
+
variables: {
|
|
520
|
+
planEscalationDirective: directive,
|
|
521
|
+
planFindings: directive === "revise" ? renderPlanEscalationFindings(open, note) : "",
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
return { ok: true, escalationId: open.id, planKey, directive };
|
|
525
|
+
}
|
package/app/trialMerge.test.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
|
-
import { assertEquals } from "#test-assert";
|
|
3
|
-
import {
|
|
2
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
3
|
+
import {
|
|
4
|
+
recordTrialMergeAudit,
|
|
5
|
+
resolveTrialMergeAttention,
|
|
6
|
+
shouldRunTrialMerge,
|
|
7
|
+
trialMergeDecision,
|
|
8
|
+
trialMergeWaveFromTaskId,
|
|
9
|
+
} from "./trialMerge.ts";
|
|
4
10
|
|
|
5
11
|
test("trialMergeDecision only escalates clean-merge suite failures", () => {
|
|
6
12
|
assertEquals(trialMergeDecision("clean"), "proceed");
|
|
@@ -14,3 +20,89 @@ test("shouldRunTrialMerge skips lone heads and mergify queues", () => {
|
|
|
14
20
|
assertEquals(shouldRunTrialMerge(2, { land: { method: "mergify-queue" } }), false);
|
|
15
21
|
assertEquals(shouldRunTrialMerge(2, { land: { method: "gh-merge" } }), true);
|
|
16
22
|
});
|
|
23
|
+
|
|
24
|
+
// In-memory `plan_trial_merges` table backing the audit-resolution tests.
|
|
25
|
+
function memData() {
|
|
26
|
+
const rows: any[] = [];
|
|
27
|
+
let nextId = 1;
|
|
28
|
+
const table = {
|
|
29
|
+
async insert(row: any) {
|
|
30
|
+
const r = { id: nextId++, ...row };
|
|
31
|
+
rows.push(r);
|
|
32
|
+
return r.id;
|
|
33
|
+
},
|
|
34
|
+
async find(where: any = {}) {
|
|
35
|
+
return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v)).map((r) => ({ ...r }));
|
|
36
|
+
},
|
|
37
|
+
async update(id: any, patch: any) {
|
|
38
|
+
const r = rows.find((x) => x.id === id);
|
|
39
|
+
if (r) Object.assign(r, patch);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const data = { table: () => table } as any;
|
|
43
|
+
return { data, rows, table };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("recordTrialMergeAudit supersedes prior rows for the same wave", async () => {
|
|
47
|
+
const { data, rows } = memData();
|
|
48
|
+
// Wave 1 fails, then re-runs clean; wave 2 is independent.
|
|
49
|
+
const first = await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "suite-failed" });
|
|
50
|
+
await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 2, result: "suite-failed" });
|
|
51
|
+
const rerun = await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "clean" });
|
|
52
|
+
|
|
53
|
+
const byId = (id: number) => rows.find((r) => r.id === id);
|
|
54
|
+
assertEquals(byId(first).resolved, 1, "the superseded wave-1 red row is resolved");
|
|
55
|
+
assertEquals(byId(rerun).resolved, 0, "the fresh wave-1 row stays unresolved");
|
|
56
|
+
// The unrelated wave-2 row is untouched (still needs attention).
|
|
57
|
+
assertEquals(rows.filter((r) => r.wave === 2)[0].resolved, 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("recordTrialMergeAudit updates a re-reporting job in place without superseding", async () => {
|
|
61
|
+
const { data, rows } = memData();
|
|
62
|
+
const id = await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "suite-failed", jobKey: "j1" });
|
|
63
|
+
const again = await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "clean", jobKey: "j1" });
|
|
64
|
+
assertEquals(again, id, "the same job_key updates its row in place");
|
|
65
|
+
assertEquals(rows.length, 1, "no duplicate/supersede row is created");
|
|
66
|
+
assertEquals(rows[0].result, "clean");
|
|
67
|
+
assertEquals(rows[0].resolved, 0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("recordTrialMergeAudit keeps the wave flagged if the superseding insert fails", async () => {
|
|
71
|
+
const { data, rows, table } = memData();
|
|
72
|
+
await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "suite-failed" });
|
|
73
|
+
// Simulate a crash/failure on the superseding insert. The new row must be
|
|
74
|
+
// inserted BEFORE prior rows are resolved, so a failure here must not leave
|
|
75
|
+
// the wave with zero unresolved rows (which would silently clear "Needs
|
|
76
|
+
// attention").
|
|
77
|
+
table.insert = async () => {
|
|
78
|
+
throw new Error("insert failed");
|
|
79
|
+
};
|
|
80
|
+
await assertRejects(() => recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "clean" }));
|
|
81
|
+
const unresolved = rows.filter((r) => r.wave === 1 && r.resolved !== 1);
|
|
82
|
+
assertEquals(unresolved.length, 1, "the wave still has an unresolved row after the failed insert");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("resolveTrialMergeAttention clears every unresolved row for the wave", async () => {
|
|
86
|
+
const { data, rows } = memData();
|
|
87
|
+
await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 1, result: "suite-failed" });
|
|
88
|
+
await recordTrialMergeAudit(data, { planKey: "o/r#1", wave: 3, result: "suite-failed" });
|
|
89
|
+
const cleared = await resolveTrialMergeAttention(data, "o/r#1", 1);
|
|
90
|
+
assertEquals(cleared, 1);
|
|
91
|
+
assertEquals(rows.filter((r) => r.wave === 1)[0].resolved, 1);
|
|
92
|
+
assertEquals(rows.filter((r) => r.wave === 3)[0].resolved, 0, "another wave is untouched");
|
|
93
|
+
// Idempotent: a second call resolves nothing new.
|
|
94
|
+
assertEquals(await resolveTrialMergeAttention(data, "o/r#1", 1), 0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("trialMergeWaveFromTaskId parses only trial-merge task ids", () => {
|
|
98
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-2"), 2);
|
|
99
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-0"), 0);
|
|
100
|
+
assertEquals(trialMergeWaveFromTaskId("some-feature-task"), null);
|
|
101
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-x"), null);
|
|
102
|
+
// Empty suffix must not silently map to wave 0 (Number("") === 0).
|
|
103
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-"), null);
|
|
104
|
+
// Non-integer / signed / whitespace suffixes are rejected.
|
|
105
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-1.5"), null);
|
|
106
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-+2"), null);
|
|
107
|
+
assertEquals(trialMergeWaveFromTaskId("trial-merge-wave-12"), 12);
|
|
108
|
+
});
|
package/app/trialMerge.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface TrialMergeAuditRow {
|
|
|
28
28
|
failing: string | null;
|
|
29
29
|
summary: string | null;
|
|
30
30
|
job_key: string | null;
|
|
31
|
+
resolved: number;
|
|
31
32
|
created_at: string;
|
|
32
33
|
updated_at: string;
|
|
33
34
|
}
|
|
@@ -46,6 +47,17 @@ export function trialMergeTaskId(wave: number): string {
|
|
|
46
47
|
return `${TRIAL_MERGE_TASK_PREFIX}${Math.max(0, Math.trunc(wave))}`;
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
/** Inverse of {@link trialMergeTaskId}: the wave a trial-merge escalation
|
|
51
|
+
* `task_id` refers to, or `null` when `taskId` is not a trial-merge escalation
|
|
52
|
+
* (e.g. an ordinary feature escalation). */
|
|
53
|
+
export function trialMergeWaveFromTaskId(taskId: string): number | null {
|
|
54
|
+
if (!taskId.startsWith(TRIAL_MERGE_TASK_PREFIX)) return null;
|
|
55
|
+
const suffix = taskId.slice(TRIAL_MERGE_TASK_PREFIX.length);
|
|
56
|
+
if (!/^\d+$/.test(suffix)) return null;
|
|
57
|
+
const wave = Number(suffix);
|
|
58
|
+
return Number.isInteger(wave) && wave >= 0 ? wave : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
const auditTable = (data: DataLayer) => data.table<TrialMergeAuditRow>("plan_trial_merges", "id");
|
|
50
62
|
|
|
51
63
|
function jsonOrNull(v: unknown): string | null {
|
|
@@ -76,6 +88,8 @@ export async function recordTrialMergeAudit(
|
|
|
76
88
|
if (jobKey) {
|
|
77
89
|
const existing = (await table.find({ plan_key: row.planKey, job_key: jobKey })).sort((a, b) => b.id - a.id)[0];
|
|
78
90
|
if (existing) {
|
|
91
|
+
// Same job re-reporting (a retry before its wait subscription opened):
|
|
92
|
+
// update in place — it is the same logical attempt, not a supersede.
|
|
79
93
|
await table.update(existing.id, {
|
|
80
94
|
result: row.result,
|
|
81
95
|
heads: jsonOrNull(row.heads),
|
|
@@ -87,16 +101,57 @@ export async function recordTrialMergeAudit(
|
|
|
87
101
|
return existing.id;
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
// A fresh audit row for this wave supersedes every prior row for the same
|
|
105
|
+
// (plan_key, wave): those are now history, so mark them resolved. Without this
|
|
106
|
+
// the append-only log leaves an old red row in the page's "Needs attention"
|
|
107
|
+
// tab forever, even after the wave was re-run clean (issue: the tab never
|
|
108
|
+
// cleared). The newly-inserted row defaults `resolved = 0`, so a still-red
|
|
109
|
+
// latest attempt keeps showing until it too is superseded or answered.
|
|
110
|
+
//
|
|
111
|
+
// Insert the new (unresolved) row FIRST, then resolve the older rows — never
|
|
112
|
+
// the reverse. Resolving priors before the insert would leave the wave with
|
|
113
|
+
// zero unresolved rows if the insert (or the process) failed in between,
|
|
114
|
+
// silently clearing the "Needs attention" tab. Insert-first guarantees the
|
|
115
|
+
// wave always has at least one unresolved row through the transition.
|
|
116
|
+
const id = Number(
|
|
117
|
+
await table.insert({
|
|
118
|
+
plan_key: row.planKey,
|
|
119
|
+
wave: row.wave,
|
|
120
|
+
result: row.result,
|
|
121
|
+
heads: jsonOrNull(row.heads),
|
|
122
|
+
conflicts: jsonOrNull(row.conflicts),
|
|
123
|
+
failing: jsonOrNull(row.failing),
|
|
124
|
+
summary: row.summary ?? null,
|
|
125
|
+
job_key: jobKey,
|
|
126
|
+
resolved: 0,
|
|
127
|
+
created_at: ts,
|
|
128
|
+
updated_at: ts,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
for (const prior of await table.find({ plan_key: row.planKey, wave: row.wave })) {
|
|
132
|
+
if (prior.id !== id && prior.resolved !== 1) await table.update(prior.id, { resolved: 1, updated_at: ts });
|
|
133
|
+
}
|
|
134
|
+
return id;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Mark every trial-merge audit row for `(planKey, wave)` resolved, so the epic
|
|
138
|
+
* page's "Needs attention" tab stops surfacing it. Called when the wave's trial
|
|
139
|
+
* escalation is answered — including a "proceed" override that records no
|
|
140
|
+
* re-run row and so would otherwise leave the old red row pinned forever.
|
|
141
|
+
* Returns the number of rows newly resolved. */
|
|
142
|
+
export async function resolveTrialMergeAttention(
|
|
143
|
+
data: DataLayer,
|
|
144
|
+
planKey: string,
|
|
145
|
+
wave: number,
|
|
146
|
+
): Promise<number> {
|
|
147
|
+
const table = auditTable(data);
|
|
148
|
+
const ts = now();
|
|
149
|
+
let resolved = 0;
|
|
150
|
+
for (const r of await table.find({ plan_key: planKey, wave })) {
|
|
151
|
+
if (r.resolved !== 1) {
|
|
152
|
+
await table.update(r.id, { resolved: 1, updated_at: ts });
|
|
153
|
+
resolved++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return resolved;
|
|
102
157
|
}
|