@nanobpm/nano-workforce 0.162.0 → 0.162.2
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/AGENTS.md +12 -6
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/SPEC.md +8 -6
- package/app/convergeGate.test.ts +1 -1
- package/app/service.ts +1 -1
- package/docs/agent-guide.md +1 -1
- package/e2e/plan-fanout.e2e.ts +37 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +2 -2
- package/resources/processes/feature.bpmn +1 -1
- package/resources/processes/implement-cell.bpmn +1 -1
- package/resources/processes/merge-cell.bpmn +1 -1
- package/resources/processes/merge-loop.bpmn +2 -2
- package/resources/processes/plan-fanout.bpmn +250 -211
- package/resources/processes/retro.bpmn +2 -2
- package/scripts/check-agent-prompts.test.ts +21 -2
- package/scripts/check-agent-prompts.ts +46 -27
- package/test/derivation-parity/flows.ts +2 -2
- package/workers/record-plan/worker.test.ts +8 -2
- package/workers/record-plan/worker.ts +8 -2
- package/workers/record-plan-review/worker.test.ts +18 -0
- package/workers/record-plan-review/worker.ts +27 -3
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
<bpmn:extensionElements>
|
|
45
45
|
<zeebe:taskDefinition type="senior:conformance" />
|
|
46
46
|
<zeebe:linkedResources>
|
|
47
|
-
<zeebe:linkedResource resourceId="conformance.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
47
|
+
<zeebe:linkedResource resourceId="prompts/conformance.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
48
48
|
</zeebe:linkedResources>
|
|
49
49
|
<zeebe:ioMapping>
|
|
50
50
|
<zeebe:input source="=conformanceDigest" target="appendPrompt" />
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
<bpmn:extensionElements>
|
|
93
93
|
<zeebe:taskDefinition type="senior:retro" />
|
|
94
94
|
<zeebe:linkedResources>
|
|
95
|
-
<zeebe:linkedResource resourceId="retro.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
95
|
+
<zeebe:linkedResource resourceId="prompts/retro.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
96
96
|
</zeebe:linkedResources>
|
|
97
97
|
<zeebe:ioMapping>
|
|
98
98
|
<zeebe:input source="=retroDigest" target="appendPrompt" />
|
|
@@ -60,7 +60,9 @@ test("passes when every prompt link resolves to a deployed, non-blank, result-em
|
|
|
60
60
|
test("discovers prompts by the resources/ convention when the manifest declares no models", () => {
|
|
61
61
|
const root = fixture({
|
|
62
62
|
"nano.app.json": MANIFEST_CONVENTION,
|
|
63
|
-
|
|
63
|
+
// Under ADR 0062 deploy-by-convention the resource id is the path relative to resources/, so the
|
|
64
|
+
// link must reference `prompts/review-round.md` — the exact id urban deploys and the engine resolves.
|
|
65
|
+
"resources/processes/loop.bpmn": serviceTask(link("prompts/review-round.md")),
|
|
64
66
|
"resources/prompts/review-round.md": "# Round\nDo the thing, then write `$AGENT_RESULT_FILE`.",
|
|
65
67
|
// Docs live OUTSIDE resources/ and must never be swept into the deploy set.
|
|
66
68
|
"docs/agent-guide.md": "# Guide\nnot a deployable",
|
|
@@ -71,6 +73,23 @@ test("discovers prompts by the resources/ convention when the manifest declares
|
|
|
71
73
|
assertEquals(res.resolved, ["review-round"]);
|
|
72
74
|
});
|
|
73
75
|
|
|
76
|
+
test("convention: a bare basename resourceId (pre-ADR-0062) fails — the id must be resources/-relative", () => {
|
|
77
|
+
// Regression guard for the #241 migration miss: the prompt WAS moved under resources/prompts/, so it
|
|
78
|
+
// deploys as `prompts/review-round.md`, but the link still names the bare `review-round.md`. The
|
|
79
|
+
// engine resolves ids exactly, omits the unresolvable link, and the agent runs prompt-less. The gate
|
|
80
|
+
// MUST catch this, not silently pass as it did before.
|
|
81
|
+
const root = fixture({
|
|
82
|
+
"nano.app.json": MANIFEST_CONVENTION,
|
|
83
|
+
"resources/processes/loop.bpmn": serviceTask(link("review-round.md")),
|
|
84
|
+
"resources/prompts/review-round.md": "# Round\nWrite `$AGENT_RESULT_FILE`.",
|
|
85
|
+
});
|
|
86
|
+
const res = checkAgentPrompts(root);
|
|
87
|
+
assert(!res.ok);
|
|
88
|
+
assert(
|
|
89
|
+
res.errors.some((e) => e.includes('resourceId="review-round.md"') && e.includes("no deployed resource")),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
74
93
|
test("convention walk flags a prompt link that has no deployed resource under resources/", () => {
|
|
75
94
|
const root = fixture({
|
|
76
95
|
"nano.app.json": MANIFEST_CONVENTION,
|
|
@@ -236,7 +255,7 @@ test("fails when two deploy globs match files sharing a basename (ambiguous reso
|
|
|
236
255
|
});
|
|
237
256
|
const res = checkAgentPrompts(root);
|
|
238
257
|
assert(!res.ok);
|
|
239
|
-
assert(res.errors.some((e) => e.includes("duplicate deployed resource
|
|
258
|
+
assert(res.errors.some((e) => e.includes("duplicate deployed resource id") && e.includes("review-round.md")));
|
|
240
259
|
});
|
|
241
260
|
|
|
242
261
|
test("checks the real repo: all committed agent prompts link to deployed resources", () => {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// the deploy no longer substitutes `{{token}}` templates, so such a header would ship a literal
|
|
30
30
|
// `{{token}}` (or stale frozen text) as the prompt.
|
|
31
31
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
32
|
-
import { basename, join } from "node:path";
|
|
32
|
+
import { basename, join, relative, sep } from "node:path";
|
|
33
33
|
|
|
34
34
|
// The retired header that used to carry an agent's baked base prompt. Its continued presence is a
|
|
35
35
|
// migration regression (the deploy no longer substitutes templates), so we flag it.
|
|
@@ -69,33 +69,49 @@ function expandGlob(root: string, pattern: string): string[] {
|
|
|
69
69
|
.map((f) => join(dir, f));
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
// The convention directory (ADR 0062): deploy-only, walked
|
|
73
|
-
// no `models`. Must stay in lock-step with urban's
|
|
72
|
+
// The convention directory (ADR 0062): deploy-only, walked recursively (every file at any depth)
|
|
73
|
+
// when the manifest declares no `models`. Must stay in lock-step with urban's
|
|
74
|
+
// `RESOURCES_DIR`/`deployModels`.
|
|
74
75
|
const RESOURCES_DIR = "resources";
|
|
75
76
|
|
|
76
77
|
// Mirror urban's deploy-by-convention walk (ADR 0062): when the manifest declares no `models`, the
|
|
77
|
-
// deployables are every file
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// two discovery modes are interchangeable downstream.
|
|
78
|
+
// deployables are every file under `resources/` at ANY depth (urban's deploy.js walks it
|
|
79
|
+
// recursively). A convention resource is keyed by its path relative to `resources/`, so files
|
|
80
|
+
// sharing a basename in different sub-directories deploy as distinct resources — no collision — and
|
|
81
|
+
// a deep walk is safe. Paths come back repo-relative (with `/`), matching `expandGlob`'s output so
|
|
82
|
+
// the two discovery modes are interchangeable downstream.
|
|
82
83
|
function discoverResources(root: string): string[] {
|
|
83
84
|
const base = join(root, RESOURCES_DIR);
|
|
84
85
|
if (!existsSync(base)) return [];
|
|
85
86
|
const out: string[] = [];
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
const walk = (dir: string): void => {
|
|
88
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
89
|
+
const abs = join(dir, entry.name);
|
|
90
|
+
if (entry.isFile()) {
|
|
91
|
+
out.push(join(RESOURCES_DIR, relative(base, abs)));
|
|
92
|
+
} else if (entry.isDirectory()) {
|
|
93
|
+
walk(abs);
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
|
-
}
|
|
96
|
+
};
|
|
97
|
+
walk(base);
|
|
96
98
|
return out.sort();
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
// The deployed `resourceId` a `linkName="prompt"` link must reference. Kept in lock-step with
|
|
102
|
+
// urban's deploy.js: a CONVENTION resource (no `models` block) is keyed by its path relative to
|
|
103
|
+
// `resources/` (POSIX) — `resources/prompts/plan.md` → `prompts/plan.md`; a `models` OVERRIDE
|
|
104
|
+
// resource is keyed by its basename. This is the exact string the engine matches a linkedResource
|
|
105
|
+
// `resourceId` against at activation, so the gate must reason about it (not the bare basename) —
|
|
106
|
+
// otherwise a prompt moved into a sub-directory deploys as `prompts/plan.md` while a stale bare
|
|
107
|
+
// `resourceId="plan.md"` link resolves to nothing and the agent runs prompt-less.
|
|
108
|
+
function deployResourceId(rel: string, byConvention: boolean): string {
|
|
109
|
+
if (!byConvention) return basename(rel);
|
|
110
|
+
const posix = rel.split(sep).join("/");
|
|
111
|
+
const prefix = `${RESOURCES_DIR}/`;
|
|
112
|
+
return posix.startsWith(prefix) ? posix.slice(prefix.length) : basename(rel);
|
|
113
|
+
}
|
|
114
|
+
|
|
99
115
|
interface PromptLink {
|
|
100
116
|
resourceId: string;
|
|
101
117
|
bindingType: string | null;
|
|
@@ -162,14 +178,16 @@ export function checkAgentPrompts(root: string): CheckResult {
|
|
|
162
178
|
// The resources the app actually DEPLOYS. Under ADR 0062 deploy-by-convention this is derived the
|
|
163
179
|
// SAME way urban's deployModels derives it, so this gate reasons about exactly the file set that
|
|
164
180
|
// ships to the engine:
|
|
165
|
-
// • no `models` block → discover by convention: every file under `resources/`
|
|
166
|
-
//
|
|
181
|
+
// • no `models` block → discover by convention: every file under `resources/` at any depth
|
|
182
|
+
// (recursive, mirroring urban). This is nwf's blessed layout — prompts live at
|
|
183
|
+
// `resources/prompts/*.md`.
|
|
167
184
|
// • `models` globs present → explicit override, used verbatim (the escape hatch for a
|
|
168
185
|
// non-convention layout). A declared-but-empty `models` is still an override, NOT a fallback
|
|
169
186
|
// to the convention walk — mirror deployModels, which keys convention off the block's absence.
|
|
170
|
-
// Either way a deployed resource's
|
|
171
|
-
//
|
|
172
|
-
//
|
|
187
|
+
// Either way a deployed resource's id is what a `linkedResource resourceId` must reference — for a
|
|
188
|
+
// convention resource its path relative to `resources/` (`prompts/plan.md`), for a `models`
|
|
189
|
+
// override its basename (see deployResourceId) — which is what catches a prompt that exists on
|
|
190
|
+
// disk but is not actually deployed under the id the link names (so the link resolves to nothing).
|
|
173
191
|
const byConvention = manifest.models === undefined;
|
|
174
192
|
const deployedRels = byConvention
|
|
175
193
|
? discoverResources(root)
|
|
@@ -179,19 +197,20 @@ export function checkAgentPrompts(root: string): CheckResult {
|
|
|
179
197
|
...(manifest.models?.forms ?? []),
|
|
180
198
|
].flatMap((p) => expandGlob(root, p));
|
|
181
199
|
|
|
182
|
-
// Keyed by
|
|
183
|
-
//
|
|
200
|
+
// Keyed by the DEPLOYED resourceId a link references: the path relative to `resources/` for a
|
|
201
|
+
// convention resource, or the basename for a `models` override (see deployResourceId). Two
|
|
202
|
+
// deployables that would deploy under the same id clobber each other at the engine, so a
|
|
184
203
|
// `resourceId` lookup could resolve to the wrong file (or mask a misconfiguration). Fail fast on
|
|
185
204
|
// the collision so the lookup stays unambiguous.
|
|
186
205
|
const deployedFiles = new Map<string, string>();
|
|
187
206
|
for (const rel of deployedRels) {
|
|
188
|
-
const name =
|
|
207
|
+
const name = deployResourceId(rel, byConvention);
|
|
189
208
|
const prior = deployedFiles.get(name);
|
|
190
209
|
if (prior != null && prior !== rel) {
|
|
191
210
|
errors.push(
|
|
192
|
-
`duplicate deployed resource
|
|
193
|
-
`same
|
|
194
|
-
`rename one so deployed resource
|
|
211
|
+
`duplicate deployed resource id "${name}": both "${prior}" and "${rel}" deploy under the ` +
|
|
212
|
+
`same id, so a linkName="prompt" resourceId="${name}" would resolve ambiguously — ` +
|
|
213
|
+
`rename one so deployed resource ids stay unique`,
|
|
195
214
|
);
|
|
196
215
|
continue;
|
|
197
216
|
}
|
|
@@ -108,7 +108,7 @@ const retroFlow: DeclarativeFlow = defineFlow(
|
|
|
108
108
|
w.task("gather", { jobType: "pr.retro-gather" });
|
|
109
109
|
w.task("conformance", {
|
|
110
110
|
jobType: "senior:conformance",
|
|
111
|
-
prompt: { resourceId: "conformance.md", bindingType: "latest", append: "=conformanceDigest" },
|
|
111
|
+
prompt: { resourceId: "prompts/conformance.md", bindingType: "latest", append: "=conformanceDigest" },
|
|
112
112
|
});
|
|
113
113
|
w.task("record-conformance", { jobType: "pr.conformance-record" });
|
|
114
114
|
w.branch("hasDeviations = true", {
|
|
@@ -130,7 +130,7 @@ const retroFlow: DeclarativeFlow = defineFlow(
|
|
|
130
130
|
});
|
|
131
131
|
w.task("synthesize", {
|
|
132
132
|
jobType: "senior:retro",
|
|
133
|
-
prompt: { resourceId: "retro.md", bindingType: "latest", append: "=retroDigest" },
|
|
133
|
+
prompt: { resourceId: "prompts/retro.md", bindingType: "latest", append: "=retroDigest" },
|
|
134
134
|
});
|
|
135
135
|
w.task("record", { jobType: "pr.retro-record" });
|
|
136
136
|
},
|
|
@@ -61,7 +61,7 @@ function fakeApp() {
|
|
|
61
61
|
|
|
62
62
|
test("record-plan dispatches a taskful plan and levelizes its tasks (wave progress is now VIEW-derived)", async () => {
|
|
63
63
|
const { app, plans } = fakeApp();
|
|
64
|
-
await handler(
|
|
64
|
+
const out = await handler(
|
|
65
65
|
{
|
|
66
66
|
variables: {
|
|
67
67
|
planKey: "owner/repo#137",
|
|
@@ -74,6 +74,8 @@ test("record-plan dispatches a taskful plan and levelizes its tasks (wave progre
|
|
|
74
74
|
app,
|
|
75
75
|
);
|
|
76
76
|
assertEquals(plans[0].status, "dispatched");
|
|
77
|
+
// taskCount drives the plan-fanout gateway (`gw-plan-empty`): non-zero ⇒ proceed to review (#623).
|
|
78
|
+
assertEquals((out as any).taskCount, 2);
|
|
77
79
|
// Wave progress (wave_count/current_wave/wave_label) was retired as a stored projection (epic
|
|
78
80
|
// #412) — it is derived from `plan_tasks` by the plan_wave_label/plan_read_model VIEWs — so
|
|
79
81
|
// record-plan no longer writes it onto the plans row.
|
|
@@ -84,11 +86,15 @@ test("record-plan dispatches a taskful plan and levelizes its tasks (wave progre
|
|
|
84
86
|
|
|
85
87
|
test("record-plan marks a taskless plan done (no wave-progress columns written)", async () => {
|
|
86
88
|
const { app, plans } = fakeApp();
|
|
87
|
-
await handler(
|
|
89
|
+
const out = await handler(
|
|
88
90
|
{ variables: { planKey: "owner/repo#137", tasks: [], note: "planner emitted no tasks" } } as any,
|
|
89
91
|
app,
|
|
90
92
|
);
|
|
91
93
|
assertEquals(plans[0].status, "done");
|
|
94
|
+
// taskCount 0 routes the plan-fanout gateway (`gw-plan-empty`) to the terminal taskless-done arm,
|
|
95
|
+
// short-circuiting the adversarial plan-review loop that would otherwise livelock (issue #623).
|
|
96
|
+
assertEquals((out as any).taskCount, 0);
|
|
97
|
+
assertEquals(plans[0].outcome, "planner emitted no tasks");
|
|
92
98
|
assertEquals(plans[0].wave_count, undefined);
|
|
93
99
|
assertEquals(plans[0].current_wave, undefined);
|
|
94
100
|
assertEquals(plans[0].wave_label, undefined);
|
|
@@ -38,6 +38,11 @@ interface NormalTask {
|
|
|
38
38
|
interface Out extends Record<string, unknown> {
|
|
39
39
|
currentWave: number;
|
|
40
40
|
waveCount: number;
|
|
41
|
+
// Task count of the recorded plan. The plan-fanout gateway (`gw-plan-empty`) reads this to
|
|
42
|
+
// SHORT-CIRCUIT an intentionally-empty plan (`{tasks:[]}`) to a terminal taskless-done arm
|
|
43
|
+
// BEFORE the adversarial plan-review gate (issue #623). Feeding an empty plan into review
|
|
44
|
+
// caused a plan↔plan-review livelock — it can neither be approved nor produce findings.
|
|
45
|
+
taskCount: number;
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
|
|
@@ -149,8 +154,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
149
154
|
if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
|
|
150
155
|
await plans(app.data).update(planKey, patch);
|
|
151
156
|
|
|
152
|
-
// Kick off the wave loop at wave 0.
|
|
153
|
-
|
|
157
|
+
// Kick off the wave loop at wave 0. `taskCount` lets the BPMN gateway terminate an empty plan
|
|
158
|
+
// before the review loop (issue #623).
|
|
159
|
+
return { currentWave: 0, waveCount, taskCount: tasks.length };
|
|
154
160
|
};
|
|
155
161
|
|
|
156
162
|
export default handler;
|
|
@@ -65,6 +65,24 @@ test("unapproved, non-final round revises (planApproved=false, no escalation)",
|
|
|
65
65
|
assertEquals((out as any).planFindings, "fix X");
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
test("unapproved with EMPTY findings escalates immediately — contentless disapproval is malformed (issue #623)", async () => {
|
|
69
|
+
// First round of a 3-round cap: NOT the final round, so the old behaviour would revise and loop.
|
|
70
|
+
// A disapproval with no findings is malformed (findings are required per plan-review.md) and
|
|
71
|
+
// gives the planner nothing to act on — escalate to a human instead of spinning on re-plan.
|
|
72
|
+
const app = fakeApp(priorRounds("o/r#2b", 0));
|
|
73
|
+
const out = await call(app, { planKey: "o/r#2b", approved: false, findings: "" });
|
|
74
|
+
assertEquals((out as any).planApproved, false);
|
|
75
|
+
assertEquals((out as any).planEscalated, true);
|
|
76
|
+
assertEquals((out as any).planReviewRound, 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("unapproved with MISSING findings escalates immediately (issue #623)", async () => {
|
|
80
|
+
const app = fakeApp(priorRounds("o/r#2c", 0));
|
|
81
|
+
const out = await call(app, { planKey: "o/r#2c", approved: false });
|
|
82
|
+
assertEquals((out as any).planApproved, false);
|
|
83
|
+
assertEquals((out as any).planEscalated, true);
|
|
84
|
+
});
|
|
85
|
+
|
|
68
86
|
test("unapproved FINAL round escalates instead of throwing or proceeding", async () => {
|
|
69
87
|
// Seed cap-1 prior rounds so this job is the last permitted round; unapproved ⇒ human escalation.
|
|
70
88
|
const app = fakeApp(priorRounds("o/r#3", MAX_PLAN_REVIEW_ROUNDS - 1));
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
// unhandled incident. Proceeding used to dispatch an un-vetted plan and — when the plan was empty —
|
|
18
18
|
// let the whole epic complete GREEN having done nothing. A missing/ambiguous `approved` is treated
|
|
19
19
|
// as NOT approved (revise until the cap, then escalate).
|
|
20
|
+
//
|
|
21
|
+
// A disapproval with EMPTY findings is malformed (issue #623): plan-review.md REQUIRES findings when
|
|
22
|
+
// `approved` is false. Re-planning against a contentless disapproval gives the planner nothing to
|
|
23
|
+
// act on, so it re-emits the same plan and the loop spins (the plan↔plan-review livelock). This
|
|
24
|
+
// worker escalates such a verdict to a human immediately instead of burning re-plan rounds.
|
|
20
25
|
|
|
21
26
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
22
27
|
import {
|
|
@@ -109,9 +114,28 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
109
114
|
};
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
// Not approved this round.
|
|
113
|
-
//
|
|
114
|
-
//
|
|
117
|
+
// Not approved this round. A disapproval with EMPTY findings is malformed (issue #623): per
|
|
118
|
+
// resources/prompts/plan-review.md, findings are REQUIRED when `approved` is false. Re-planning
|
|
119
|
+
// against a contentless disapproval gives the planner nothing to act on, so it re-emits the same
|
|
120
|
+
// plan and the loop spins (plan↔plan-review livelock, esp. for an empty plan). Escalate to a human
|
|
121
|
+
// immediately rather than burning re-plan rounds on a malformed verdict.
|
|
122
|
+
if (roundFindings === "") {
|
|
123
|
+
app.log.warn(`record-plan-review: ${planKey} not approved with EMPTY findings — malformed, escalating`, {
|
|
124
|
+
epoch: recordedEpoch,
|
|
125
|
+
round,
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
planApproved: false,
|
|
129
|
+
planEscalated: true,
|
|
130
|
+
planFindings: roundFindings,
|
|
131
|
+
planReviewEpoch: recordedEpoch,
|
|
132
|
+
planReviewRound: round,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Escalate once the per-epoch round cap is reached (issue #86): previously the fan-out PROCEEDED
|
|
137
|
+
// regardless, dispatching an un-vetted plan. The round is 0-based, so `round + 1 >= cap` is the
|
|
138
|
+
// last permitted round.
|
|
115
139
|
if (round + 1 >= MAX_PLAN_REVIEW_ROUNDS) {
|
|
116
140
|
app.log.warn(`record-plan-review: ${planKey} not approved after ${MAX_PLAN_REVIEW_ROUNDS} round(s)`, {
|
|
117
141
|
epoch: recordedEpoch,
|