@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// pr.persist-plan-escalation — the adversarial plan-review loop reached its per-epoch budget
|
|
2
|
+
// without approval. Record a plan-level human escalation and park the process until an operator
|
|
3
|
+
// answers with either `revise` (default: loop back to the planner with guidance and a fresh epoch)
|
|
4
|
+
// or `proceed` (explicit override: dispatch the current plan as-is).
|
|
5
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
6
|
+
import { planReviewEscalations, plans } from "../../app/plan.ts";
|
|
7
|
+
|
|
8
|
+
interface In extends Record<string, unknown> {
|
|
9
|
+
planKey: string;
|
|
10
|
+
planFindings?: unknown;
|
|
11
|
+
planReviewEpoch?: unknown;
|
|
12
|
+
planReviewRound?: unknown;
|
|
13
|
+
}
|
|
14
|
+
interface Out extends Record<string, unknown> {
|
|
15
|
+
planEscalationId: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Accept only genuine strings (trimmed); anything else is treated as missing so
|
|
19
|
+
// the explicit planKey guard fires instead of silently coercing (e.g. 123 -> "123")
|
|
20
|
+
// an escalation that BPMN correlation ("owner/repo#N") could never resume.
|
|
21
|
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
22
|
+
const int = (v: unknown): number => {
|
|
23
|
+
const n = Math.trunc(Number(v));
|
|
24
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
28
|
+
const planKey = str(job.variables.planKey);
|
|
29
|
+
if (!planKey) {
|
|
30
|
+
// No plan binding means we cannot correlate a resume — fail loudly rather
|
|
31
|
+
// than silently parking a token that can never be answered.
|
|
32
|
+
throw new Error("persist-plan-escalation: missing planKey in process scope");
|
|
33
|
+
}
|
|
34
|
+
const epoch = int(job.variables.planReviewEpoch);
|
|
35
|
+
const round = int(job.variables.planReviewRound);
|
|
36
|
+
const findings = str(job.variables.planFindings) || null;
|
|
37
|
+
const ts = new Date().toISOString();
|
|
38
|
+
|
|
39
|
+
const escTable = planReviewEscalations(app.data);
|
|
40
|
+
const existing = (await escTable.find({ plan_key: planKey, status: "open" }))
|
|
41
|
+
.sort((a, b) => b.id - a.id)[0];
|
|
42
|
+
let escalationId: number;
|
|
43
|
+
if (existing) {
|
|
44
|
+
await escTable.update(existing.id, { epoch, round, findings });
|
|
45
|
+
escalationId = existing.id;
|
|
46
|
+
} else {
|
|
47
|
+
escalationId = Number(
|
|
48
|
+
await escTable.insert({
|
|
49
|
+
plan_key: planKey,
|
|
50
|
+
epoch,
|
|
51
|
+
round,
|
|
52
|
+
findings,
|
|
53
|
+
status: "open",
|
|
54
|
+
directive: null,
|
|
55
|
+
note: null,
|
|
56
|
+
asked_at: ts,
|
|
57
|
+
answered_at: null,
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await plans(app.data).update(planKey, {
|
|
63
|
+
status: "planning",
|
|
64
|
+
open_plan_escalation_id: escalationId,
|
|
65
|
+
open_plan_findings: findings,
|
|
66
|
+
open_plan_round: round,
|
|
67
|
+
updated_at: ts,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { planEscalationId: escalationId };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export default handler;
|
|
@@ -36,6 +36,7 @@ interface In extends Record<string, unknown> {
|
|
|
36
36
|
}
|
|
37
37
|
interface Out extends Record<string, unknown> {
|
|
38
38
|
escalationId: number;
|
|
39
|
+
escalationCorrKey: string;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
// A non-blank trimmed string, else undefined. A blank question/PR must not reach
|
|
@@ -106,7 +107,14 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
await refreshOpenTaskEscalation(app.data, planKey);
|
|
109
|
-
|
|
110
|
+
// Emit the correlation key as a single scalar so the downstream
|
|
111
|
+
// `feature-escalation-answered` catch subscribes on `=escalationCorrKey`
|
|
112
|
+
// (freshly set, in scope) rather than re-deriving `=planKey + ":" + task.id`.
|
|
113
|
+
// The concatenation form errored to an empty, unmatchable key whenever `task`
|
|
114
|
+
// was not a Map at subscription-open, parking the token forever (see the
|
|
115
|
+
// engine incident fix). The value is identical to `featureCorrKey(planKey,
|
|
116
|
+
// taskId)`, so the app's answer-publish path still matches.
|
|
117
|
+
return { escalationId, escalationCorrKey: corrKey };
|
|
110
118
|
};
|
|
111
119
|
|
|
112
120
|
export default handler;
|
|
@@ -1,80 +1,100 @@
|
|
|
1
1
|
// Red/green for the plan-review gate (issue #86).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// GREEN having done nothing (instance 21). We now HARD-FAIL: the terminal, unapproved round raises
|
|
7
|
-
// a non-retryable `PLAN_REJECTED` BpmnError (→ incident), so an un-approved plan never dispatches.
|
|
3
|
+
// The fan-out must never dispatch an unapproved plan automatically. When the per-epoch review cap
|
|
4
|
+
// is reached without approval, record-plan-review now emits `planEscalated` so BPMN parks for a
|
|
5
|
+
// human directive instead of throwing an unhandled incident or proceeding silently.
|
|
8
6
|
import { test } from "node:test";
|
|
9
|
-
import { assertEquals
|
|
10
|
-
import { BpmnError } from "@nanobpm/urban";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
11
8
|
import { noopLog } from "../../test/log.ts";
|
|
12
|
-
import handler from "./worker.ts";
|
|
13
9
|
import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview } from "../../app/plan.ts";
|
|
10
|
+
import handler from "./worker.ts";
|
|
14
11
|
|
|
15
|
-
function fakeApp(existing: PlanReview[] = []) {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
12
|
+
function fakeApp(existing: PlanReview[] = [], reviewEscalations: any[] = []) {
|
|
13
|
+
const reviewRows: PlanReview[] = [...existing];
|
|
14
|
+
const escalationRows = [...reviewEscalations];
|
|
15
|
+
const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
|
|
16
|
+
Object.entries(q).every(([f, v]) => r[f] === v);
|
|
17
|
+
const table = (rows: any[]) => ({
|
|
18
|
+
find: (q: any) => Promise.resolve(rows.filter((r) => match(r, q))),
|
|
19
|
+
findOne: (q: any) => Promise.resolve(rows.find((r) => match(r, q)) ?? null),
|
|
20
|
+
count: (q: any) => Promise.resolve(rows.filter((r) => match(r, q)).length),
|
|
21
|
+
insert: (row: any) => {
|
|
22
|
+
rows.push(row);
|
|
23
|
+
return Promise.resolve(row);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
19
26
|
return {
|
|
20
27
|
data: {
|
|
21
|
-
table() {
|
|
22
|
-
return
|
|
23
|
-
|
|
24
|
-
count: (q: any) => Promise.resolve(rows.filter((r) => match(r, q)).length),
|
|
25
|
-
insert: (row: PlanReview) => {
|
|
26
|
-
rows.push(row);
|
|
27
|
-
return Promise.resolve(row);
|
|
28
|
-
},
|
|
29
|
-
};
|
|
28
|
+
table(name: string) {
|
|
29
|
+
if (name === "plan_review_escalations") return table(escalationRows);
|
|
30
|
+
return table(reviewRows);
|
|
30
31
|
},
|
|
31
32
|
},
|
|
32
33
|
log: noopLog(),
|
|
33
|
-
_rows:
|
|
34
|
+
_rows: reviewRows,
|
|
35
|
+
_escalations: escalationRows,
|
|
34
36
|
} as any;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
// Seed `n` prior recorded rounds for a plan so the next job lands on round `n` (0-based).
|
|
38
|
-
function priorRounds(planKey: string, n: number): PlanReview[] {
|
|
39
|
+
// Seed `n` prior recorded rounds for a plan/epoch so the next job lands on round `n` (0-based).
|
|
40
|
+
function priorRounds(planKey: string, n: number, epoch = 0): PlanReview[] {
|
|
39
41
|
return Array.from({ length: n }, (_, i) => ({
|
|
40
42
|
plan_key: planKey,
|
|
43
|
+
epoch,
|
|
41
44
|
round: i,
|
|
42
45
|
approved: 0,
|
|
43
46
|
findings: null,
|
|
44
47
|
created_at: "2026-01-01T00:00:00.000Z",
|
|
45
|
-
job_key: `prior-${i}`,
|
|
48
|
+
job_key: `prior-${epoch}-${i}`,
|
|
46
49
|
}));
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
const call = async (app: unknown, vars: Record<string, unknown>, jobKey = "j-new") =>
|
|
50
53
|
await handler({ variables: vars, jobKey } as any, app as any);
|
|
51
54
|
|
|
52
|
-
test("approved round proceeds (planApproved=true, no
|
|
55
|
+
test("approved round proceeds (planApproved=true, no escalation)", async () => {
|
|
53
56
|
const app = fakeApp(priorRounds("o/r#1", 0));
|
|
54
57
|
const out = await call(app, { planKey: "o/r#1", approved: true });
|
|
55
|
-
assertEquals((out as
|
|
58
|
+
assertEquals((out as any).planApproved, true);
|
|
59
|
+
assertEquals((out as any).planEscalated, false);
|
|
56
60
|
});
|
|
57
61
|
|
|
58
|
-
test("unapproved, non-final round revises (planApproved=false, no
|
|
62
|
+
test("unapproved, non-final round revises (planApproved=false, no escalation)", async () => {
|
|
59
63
|
// First round of a 3-round cap: not final, so revise.
|
|
60
64
|
const app = fakeApp(priorRounds("o/r#2", 0));
|
|
61
65
|
const out = await call(app, { planKey: "o/r#2", approved: false, findings: "fix X" });
|
|
62
|
-
assertEquals((out as
|
|
63
|
-
assertEquals((out as
|
|
66
|
+
assertEquals((out as any).planApproved, false);
|
|
67
|
+
assertEquals((out as any).planEscalated, false);
|
|
68
|
+
assertEquals((out as any).planFindings, "fix X");
|
|
64
69
|
});
|
|
65
70
|
|
|
66
|
-
test("unapproved FINAL round
|
|
67
|
-
// Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒
|
|
71
|
+
test("unapproved FINAL round escalates instead of throwing or proceeding", async () => {
|
|
72
|
+
// Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒ human escalation.
|
|
68
73
|
const app = fakeApp(priorRounds("o/r#3", MAX_PLAN_REVIEW_ROUNDS - 1));
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
);
|
|
73
|
-
assertEquals((
|
|
74
|
+
const out = await call(app, { planKey: "o/r#3", approved: false, findings: "still wrong" });
|
|
75
|
+
assertEquals((out as any).planApproved, false);
|
|
76
|
+
assertEquals((out as any).planEscalated, true);
|
|
77
|
+
assertEquals((out as any).planReviewRound, MAX_PLAN_REVIEW_ROUNDS - 1);
|
|
78
|
+
assertEquals((out as any).planFindings, "still wrong");
|
|
74
79
|
});
|
|
75
80
|
|
|
76
|
-
test("approved on the FINAL round still proceeds (no
|
|
81
|
+
test("approved on the FINAL round still proceeds (no escalation)", async () => {
|
|
77
82
|
const app = fakeApp(priorRounds("o/r#4", MAX_PLAN_REVIEW_ROUNDS - 1));
|
|
78
83
|
const out = await call(app, { planKey: "o/r#4", approved: true });
|
|
79
|
-
assertEquals((out as
|
|
84
|
+
assertEquals((out as any).planApproved, true);
|
|
85
|
+
assertEquals((out as any).planEscalated, false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("answered plan-review escalation starts a fresh epoch and round budget", async () => {
|
|
89
|
+
const app = fakeApp(
|
|
90
|
+
priorRounds("o/r#5", MAX_PLAN_REVIEW_ROUNDS, 0),
|
|
91
|
+
[{ id: 1, plan_key: "o/r#5", status: "answered" }],
|
|
92
|
+
);
|
|
93
|
+
const out = await call(app, { planKey: "o/r#5", approved: false, findings: "new epoch finding" });
|
|
94
|
+
assertEquals((out as any).planApproved, false);
|
|
95
|
+
assertEquals((out as any).planEscalated, false);
|
|
96
|
+
assertEquals((out as any).planReviewEpoch, 1);
|
|
97
|
+
assertEquals((out as any).planReviewRound, 0);
|
|
98
|
+
assertEquals(app._rows.at(-1).epoch, 1);
|
|
99
|
+
assertEquals(app._rows.at(-1).round, 0);
|
|
80
100
|
});
|
|
@@ -3,23 +3,27 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The `senior:plan-review` agent critiqued the levelized plan and emitted `{ approved, findings }`.
|
|
5
5
|
// This worker:
|
|
6
|
-
// • derives the current
|
|
7
|
-
//
|
|
6
|
+
// • derives the current epoch from answered plan-review escalations and the current round from
|
|
7
|
+
// the append-only `plan_reviews` log for that epoch (no counter variable), using the engine
|
|
8
|
+
// jobKey as an idempotency guard so a retried job reuses its row,
|
|
8
9
|
// • records this round's verdict + findings,
|
|
9
10
|
// • decides the loop: emits `planApproved` (reviewer said yes → the BPMN gateway proceeds to
|
|
10
11
|
// `select-wave`) or, when unapproved, re-emits the findings as `planFindings` so a revise
|
|
11
12
|
// round feeds the planner and loops back to `plan`.
|
|
12
13
|
//
|
|
13
|
-
// When the review-round cap is reached WITHOUT approval, this worker
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// the whole epic complete GREEN having done nothing.
|
|
17
|
-
//
|
|
18
|
-
// approved (revise until the cap).
|
|
14
|
+
// When the review-round cap is reached WITHOUT approval, this worker emits `planEscalated` so the
|
|
15
|
+
// BPMN parks on a human plan-review escalation rather than proceeding regardless or raising an
|
|
16
|
+
// unhandled incident. Proceeding used to dispatch an un-vetted plan and — when the plan was empty —
|
|
17
|
+
// let the whole epic complete GREEN having done nothing. A missing/ambiguous `approved` is treated
|
|
18
|
+
// as NOT approved (revise until the cap, then escalate).
|
|
19
19
|
|
|
20
20
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
21
|
-
import {
|
|
22
|
-
|
|
21
|
+
import {
|
|
22
|
+
currentPlanReviewEpoch,
|
|
23
|
+
MAX_PLAN_REVIEW_ROUNDS,
|
|
24
|
+
type PlanReview,
|
|
25
|
+
planReviews,
|
|
26
|
+
} from "../../app/plan.ts";
|
|
23
27
|
|
|
24
28
|
interface In extends Record<string, unknown> {
|
|
25
29
|
planKey: string;
|
|
@@ -28,7 +32,10 @@ interface In extends Record<string, unknown> {
|
|
|
28
32
|
}
|
|
29
33
|
interface Out extends Record<string, unknown> {
|
|
30
34
|
planApproved: boolean;
|
|
35
|
+
planEscalated: boolean;
|
|
31
36
|
planFindings: string;
|
|
37
|
+
planReviewEpoch: number;
|
|
38
|
+
planReviewRound: number;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
// Only an explicit boolean-true (or the string "true") approves; anything else — including a
|
|
@@ -56,17 +63,20 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
56
63
|
const jobKey = job.jobKey;
|
|
57
64
|
|
|
58
65
|
const reviews = planReviews(app.data);
|
|
66
|
+
const epoch = await currentPlanReviewEpoch(app.data, planKey);
|
|
59
67
|
|
|
60
|
-
// Idempotency guard: deriving the round from count(plan_reviews) is not retry-safe on its
|
|
61
|
-
// A job retried after the insert (crash/timeout post-write) re-runs with the SAME jobKey —
|
|
62
|
-
// this job already recorded a row, reuse it rather than appending a duplicate, which would
|
|
68
|
+
// Idempotency guard: deriving the epoch/round from count(plan_reviews) is not retry-safe on its
|
|
69
|
+
// own. A job retried after the insert (crash/timeout post-write) re-runs with the SAME jobKey —
|
|
70
|
+
// if this job already recorded a row, reuse it rather than appending a duplicate, which would
|
|
63
71
|
// inflate the count and reach the review-round cap early. Otherwise this is the first attempt:
|
|
64
|
-
// derive the 0-based next round from the append-only log and record it
|
|
72
|
+
// derive the 0-based next round from the append-only log for the current epoch and record it
|
|
73
|
+
// under this jobKey.
|
|
65
74
|
const recorded: PlanReview = (await reviews.findOne({ plan_key: planKey, job_key: jobKey })) ??
|
|
66
75
|
await (async () => {
|
|
67
|
-
const round = await reviews.count({ plan_key: planKey }); // 0-based: next round index
|
|
76
|
+
const round = await reviews.count({ plan_key: planKey, epoch }); // 0-based: next round index
|
|
68
77
|
const row: PlanReview = {
|
|
69
78
|
plan_key: planKey,
|
|
79
|
+
epoch,
|
|
70
80
|
round,
|
|
71
81
|
approved: approved ? 1 : 0,
|
|
72
82
|
findings: findings || null,
|
|
@@ -78,32 +88,46 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
78
88
|
})();
|
|
79
89
|
|
|
80
90
|
const round = recorded.round;
|
|
91
|
+
const recordedEpoch = recorded.epoch;
|
|
81
92
|
const roundApproved = recorded.approved === 1;
|
|
82
93
|
const roundFindings = recorded.findings ?? "";
|
|
83
94
|
|
|
84
95
|
if (roundApproved) {
|
|
85
|
-
return {
|
|
96
|
+
return {
|
|
97
|
+
planApproved: true,
|
|
98
|
+
planEscalated: false,
|
|
99
|
+
planFindings: roundFindings,
|
|
100
|
+
planReviewEpoch: recordedEpoch,
|
|
101
|
+
planReviewRound: round,
|
|
102
|
+
};
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
// Not approved this round.
|
|
89
|
-
// fan-out PROCEEDED regardless
|
|
90
|
-
//
|
|
91
|
-
// done nothing (instance 21). Instead raise a non-retryable BpmnError: no boundary catches
|
|
92
|
-
// `PLAN_REJECTED`, so the engine parks the instance on an incident rather than dispatching an
|
|
93
|
-
// un-approved plan. The round is 0-based, so `round + 1 >= cap` is the last permitted round.
|
|
105
|
+
// Not approved this round. Escalate once the per-epoch round cap is reached (issue #86):
|
|
106
|
+
// previously the fan-out PROCEEDED regardless, dispatching an un-vetted plan. The round is
|
|
107
|
+
// 0-based, so `round + 1 >= cap` is the last permitted round.
|
|
94
108
|
if (round + 1 >= MAX_PLAN_REVIEW_ROUNDS) {
|
|
95
|
-
app.log.
|
|
109
|
+
app.log.warn(`record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
|
|
110
|
+
epoch: recordedEpoch,
|
|
96
111
|
round,
|
|
97
112
|
});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
return {
|
|
114
|
+
planApproved: false,
|
|
115
|
+
planEscalated: true,
|
|
116
|
+
planFindings: roundFindings,
|
|
117
|
+
planReviewEpoch: recordedEpoch,
|
|
118
|
+
planReviewRound: round,
|
|
119
|
+
};
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
// Otherwise loop: the planner revises against this round's findings.
|
|
105
|
-
app.log.info(`record-plan-review: ${planKey} round ${round} — revise`, { approved: false });
|
|
106
|
-
return {
|
|
123
|
+
app.log.info(`record-plan-review: ${planKey} epoch ${recordedEpoch} round ${round} — revise`, { approved: false });
|
|
124
|
+
return {
|
|
125
|
+
planApproved: false,
|
|
126
|
+
planEscalated: false,
|
|
127
|
+
planFindings: roundFindings,
|
|
128
|
+
planReviewEpoch: recordedEpoch,
|
|
129
|
+
planReviewRound: round,
|
|
130
|
+
};
|
|
107
131
|
};
|
|
108
132
|
|
|
109
133
|
export default handler;
|