@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.
@@ -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
- return { escalationId };
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
- // Previously the fan-out PROCEEDED when the review-round cap was reached without approval
4
- // ("proceed regardless rather than dead-lock"). That dispatched an un-vetted plan and when the
5
- // plan was empty (e.g. the planner agent couldn't persist its result) — completed the whole epic
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, assertRejects } from "#test-assert";
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 rows: PlanReview[] = [...existing];
17
- const match = (r: PlanReview, q: Record<string, unknown>) =>
18
- Object.entries(q).every(([f, v]) => (r as unknown as Record<string, unknown>)[f] === v);
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
- findOne: (q: any) => Promise.resolve(rows.find((r) => match(r, q)) ?? null),
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: 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 throw)", async () => {
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 { planApproved: boolean }).planApproved, true);
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 throw)", async () => {
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 { planApproved: boolean; planFindings: string }).planApproved, false);
63
- assertEquals((out as { planFindings: string }).planFindings, "fix X");
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 hard-fails with PLAN_REJECTED incident", async () => {
67
- // Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒ must throw.
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 err = await assertRejects(
70
- () => call(app, { planKey: "o/r#3", approved: false, findings: "still wrong" }),
71
- BpmnError,
72
- );
73
- assertEquals((err as BpmnError).errorCode, "PLAN_REJECTED");
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 throw)", async () => {
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 { planApproved: boolean }).planApproved, true);
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 round from the append-only `plan_reviews` log (no counter variable),
7
- // using the engine jobKey as an idempotency guard so a retried job reuses its row,
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 HARD-FAILS: it throws a
14
- // non-retryable `PLAN_REJECTED` BpmnError (→ incident) rather than proceeding regardless
15
- // (issue #86). Proceeding used to dispatch an un-vetted plan and — when the plan was empty — let
16
- // the whole epic complete GREEN having done nothing. The cap still bounds the loop; it now bounds
17
- // it into an incident, not a silent proceed. A missing/ambiguous `approved` is treated as NOT
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 { BpmnError } from "@nanobpm/urban";
22
- import { MAX_PLAN_REVIEW_ROUNDS, type PlanReview, planReviews } from "../../app/plan.ts";
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 own.
61
- // A job retried after the insert (crash/timeout post-write) re-runs with the SAME jobKey — if
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 under this jobKey.
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 { planApproved: true, planFindings: roundFindings };
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. Hard-fail once the round cap is reached (issue #86): previously the
89
- // fan-out PROCEEDED regardless ("don't dead-lock on a reviewer that never approves"), which
90
- // dispatched an un-vetted plan and when the plan was empty — completed the epic GREEN having
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.error(`record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
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
- throw new BpmnError(
99
- "PLAN_REJECTED",
100
- `${planKey}: plan not approved after ${MAX_PLAN_REVIEW_ROUNDS} review round(s)`,
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 { planApproved: false, planFindings: roundFindings };
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;