@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,51 @@
|
|
|
1
|
+
-- Plan-review cap escalation. When the adversarial review loop exhausts its per-epoch budget
|
|
2
|
+
-- without approval, the plan-fanout process parks for a human directive instead of raising an
|
|
3
|
+
-- unhandled PLAN_REJECTED incident. A `revise` answer starts a new review epoch; a `proceed`
|
|
4
|
+
-- answer explicitly dispatches the current plan as-is.
|
|
5
|
+
--
|
|
6
|
+
-- `plan_reviews.round` remains derived from the append-only review log, but now within the current
|
|
7
|
+
-- epoch. SQLite cannot alter the existing PRIMARY KEY (plan_key, round), so recreate the table with
|
|
8
|
+
-- (plan_key, epoch, round) and backfill existing rows into epoch 0.
|
|
9
|
+
|
|
10
|
+
ALTER TABLE plan_reviews ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0;
|
|
11
|
+
|
|
12
|
+
CREATE TABLE plan_reviews_new (
|
|
13
|
+
plan_key TEXT NOT NULL REFERENCES plans(plan_key),
|
|
14
|
+
epoch INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
round INTEGER NOT NULL,
|
|
16
|
+
approved INTEGER NOT NULL,
|
|
17
|
+
findings TEXT,
|
|
18
|
+
created_at TEXT NOT NULL,
|
|
19
|
+
job_key TEXT,
|
|
20
|
+
PRIMARY KEY (plan_key, epoch, round)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
INSERT INTO plan_reviews_new (plan_key, epoch, round, approved, findings, created_at, job_key)
|
|
24
|
+
SELECT plan_key, epoch, round, approved, findings, created_at, job_key
|
|
25
|
+
FROM plan_reviews;
|
|
26
|
+
|
|
27
|
+
DROP TABLE plan_reviews;
|
|
28
|
+
ALTER TABLE plan_reviews_new RENAME TO plan_reviews;
|
|
29
|
+
|
|
30
|
+
CREATE INDEX idx_plan_reviews_plan ON plan_reviews(plan_key);
|
|
31
|
+
CREATE UNIQUE INDEX idx_plan_reviews_job ON plan_reviews(plan_key, job_key);
|
|
32
|
+
|
|
33
|
+
CREATE TABLE plan_review_escalations (
|
|
34
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
35
|
+
plan_key TEXT NOT NULL REFERENCES plans(plan_key),
|
|
36
|
+
epoch INTEGER NOT NULL,
|
|
37
|
+
round INTEGER NOT NULL,
|
|
38
|
+
findings TEXT,
|
|
39
|
+
status TEXT NOT NULL, -- open | answered
|
|
40
|
+
directive TEXT, -- proceed | revise (answered rows only)
|
|
41
|
+
note TEXT,
|
|
42
|
+
asked_at TEXT NOT NULL,
|
|
43
|
+
answered_at TEXT
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX idx_plan_review_escalations_plan ON plan_review_escalations(plan_key);
|
|
47
|
+
CREATE INDEX idx_plan_review_escalations_open ON plan_review_escalations(plan_key, status);
|
|
48
|
+
|
|
49
|
+
ALTER TABLE plans ADD COLUMN open_plan_escalation_id INTEGER;
|
|
50
|
+
ALTER TABLE plans ADD COLUMN open_plan_findings TEXT;
|
|
51
|
+
ALTER TABLE plans ADD COLUMN open_plan_round INTEGER;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- Durable "needs attention" resolution for the trial-merge audit log (issue: the
|
|
2
|
+
-- epic page's "Needs attention" tab never cleared).
|
|
3
|
+
--
|
|
4
|
+
-- `plan_trial_merges` is an append-only audit trail: a re-run after a suite
|
|
5
|
+
-- failure INSERTs a fresh row but never supersedes the old red one, so a
|
|
6
|
+
-- `merge-conflict`/`suite-failed` row stayed in "Needs attention" forever even
|
|
7
|
+
-- after the escalation was answered and the wave re-run clean. Add an explicit
|
|
8
|
+
-- `resolved` flag so the page can hide history, and backfill it for existing
|
|
9
|
+
-- rows. Going forward `recordTrialMergeAudit` marks prior same-(plan,wave) rows
|
|
10
|
+
-- resolved on each new insert (supersede-on-insert).
|
|
11
|
+
--
|
|
12
|
+
-- NB: the migration runner wraps each file in its own transaction — this file
|
|
13
|
+
-- must NOT contain BEGIN/COMMIT.
|
|
14
|
+
|
|
15
|
+
ALTER TABLE plan_trial_merges ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0;
|
|
16
|
+
|
|
17
|
+
-- Backfill 1 (supersede): any audit row that has a NEWER row (higher id) for the
|
|
18
|
+
-- same (plan_key, wave) is superseded history — resolve it. The newest row per
|
|
19
|
+
-- wave stays unresolved so a still-red latest attempt keeps showing.
|
|
20
|
+
UPDATE plan_trial_merges
|
|
21
|
+
SET resolved = 1
|
|
22
|
+
WHERE EXISTS (
|
|
23
|
+
SELECT 1 FROM plan_trial_merges AS newer
|
|
24
|
+
WHERE newer.plan_key = plan_trial_merges.plan_key
|
|
25
|
+
AND newer.wave = plan_trial_merges.wave
|
|
26
|
+
AND newer.id > plan_trial_merges.id
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
-- Backfill 2 (answered): a red (needs-attention) row whose trial escalation has
|
|
30
|
+
-- already been answered is resolved — even if no re-run row was ever recorded
|
|
31
|
+
-- (e.g. the operator answered "proceed"/override). The trial escalation's
|
|
32
|
+
-- task_id is 'trial-merge-wave-<wave>' (see app/trialMerge.ts trialMergeTaskId).
|
|
33
|
+
UPDATE plan_trial_merges
|
|
34
|
+
SET resolved = 1
|
|
35
|
+
WHERE result IN ('merge-conflict', 'suite-failed')
|
|
36
|
+
AND EXISTS (
|
|
37
|
+
SELECT 1 FROM plan_escalations AS e
|
|
38
|
+
WHERE e.plan_key = plan_trial_merges.plan_key
|
|
39
|
+
AND e.task_id = 'trial-merge-wave-' || plan_trial_merges.wave
|
|
40
|
+
AND e.status = 'answered'
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_plan_trial_merges_attention
|
|
44
|
+
ON plan_trial_merges(plan_key, resolved, result);
|
package/nano.app.json
CHANGED
|
@@ -119,6 +119,10 @@
|
|
|
119
119
|
"taskType": "pr.persist-task-escalation",
|
|
120
120
|
"handler": "workers/persist-task-escalation/worker.ts"
|
|
121
121
|
},
|
|
122
|
+
{
|
|
123
|
+
"taskType": "pr.persist-plan-escalation",
|
|
124
|
+
"handler": "workers/persist-plan-escalation/worker.ts"
|
|
125
|
+
},
|
|
122
126
|
{
|
|
123
127
|
"taskType": "pr.retro-gather",
|
|
124
128
|
"handler": "workers/retro-gather/worker.ts"
|
package/openapi.yaml
CHANGED
|
@@ -345,6 +345,27 @@ components:
|
|
|
345
345
|
type: string
|
|
346
346
|
minLength: 1
|
|
347
347
|
description: The operator's answer that resumes the parked implementation agent.
|
|
348
|
+
PlanAnswerRequest:
|
|
349
|
+
type: object
|
|
350
|
+
additionalProperties: false
|
|
351
|
+
required:
|
|
352
|
+
- plan
|
|
353
|
+
- directive
|
|
354
|
+
properties:
|
|
355
|
+
plan:
|
|
356
|
+
type: string
|
|
357
|
+
description: Plan reference (owner/repo#N), also the message correlation key.
|
|
358
|
+
directive:
|
|
359
|
+
type: string
|
|
360
|
+
description: >-
|
|
361
|
+
One of `proceed` or `revise` (case-insensitive; normalized to lowercase and trimmed
|
|
362
|
+
server-side, see `parsePlanEscalationDirective`).
|
|
363
|
+
`proceed` dispatches the current unapproved plan as an explicit human override;
|
|
364
|
+
`revise` loops back to the planner with the note folded into planFindings and a fresh
|
|
365
|
+
review budget.
|
|
366
|
+
note:
|
|
367
|
+
type: string
|
|
368
|
+
description: Human guidance for the planner (used for `revise`; optional for `proceed`).
|
|
348
369
|
BlackboardEntry:
|
|
349
370
|
type: object
|
|
350
371
|
additionalProperties: false
|
|
@@ -578,9 +599,9 @@ paths:
|
|
|
578
599
|
/actions/message:
|
|
579
600
|
post:
|
|
580
601
|
operationId: postMessage
|
|
581
|
-
summary: Publish a message / answer an escalation. For escalation-answered
|
|
582
|
-
feature-escalation-answered names, runs the corresponding
|
|
583
|
-
publishMessage.
|
|
602
|
+
summary: Publish a message / answer an escalation. For escalation-answered,
|
|
603
|
+
feature-escalation-answered, and plan-escalation-answered names, runs the corresponding
|
|
604
|
+
answer flow; otherwise a plain publishMessage.
|
|
584
605
|
requestBody:
|
|
585
606
|
required: true
|
|
586
607
|
content:
|
|
@@ -603,6 +624,13 @@ paths:
|
|
|
603
624
|
properties:
|
|
604
625
|
answer:
|
|
605
626
|
type: string
|
|
627
|
+
directive:
|
|
628
|
+
type: string
|
|
629
|
+
description: >-
|
|
630
|
+
One of `proceed` or `revise` (case-insensitive; normalized to lowercase and
|
|
631
|
+
trimmed server-side, see `parsePlanEscalationDirective`).
|
|
632
|
+
note:
|
|
633
|
+
type: string
|
|
606
634
|
responses:
|
|
607
635
|
"200":
|
|
608
636
|
description: The message was published (or the escalation answered).
|
|
@@ -661,6 +689,45 @@ paths:
|
|
|
661
689
|
application/json:
|
|
662
690
|
schema:
|
|
663
691
|
$ref: "#/components/schemas/MessageResult"
|
|
692
|
+
/hooks/plan-answer:
|
|
693
|
+
post:
|
|
694
|
+
operationId: answerPlanEscalation
|
|
695
|
+
summary: "Answer a plan-review cap escalation out of band. Optional shared-secret guard
|
|
696
|
+
(x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET is set."
|
|
697
|
+
security:
|
|
698
|
+
- hookSecret: []
|
|
699
|
+
- {}
|
|
700
|
+
requestBody:
|
|
701
|
+
required: true
|
|
702
|
+
content:
|
|
703
|
+
application/json:
|
|
704
|
+
schema:
|
|
705
|
+
$ref: "#/components/schemas/PlanAnswerRequest"
|
|
706
|
+
responses:
|
|
707
|
+
"200":
|
|
708
|
+
description: The escalation was answered and the parked plan resumed.
|
|
709
|
+
content:
|
|
710
|
+
application/json:
|
|
711
|
+
schema:
|
|
712
|
+
$ref: "#/components/schemas/MessageResult"
|
|
713
|
+
"400":
|
|
714
|
+
description: A required field was missing (plan or directive).
|
|
715
|
+
content:
|
|
716
|
+
application/json:
|
|
717
|
+
schema:
|
|
718
|
+
$ref: "#/components/schemas/MessageResult"
|
|
719
|
+
"401":
|
|
720
|
+
description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
|
|
721
|
+
content:
|
|
722
|
+
application/json:
|
|
723
|
+
schema:
|
|
724
|
+
$ref: "#/components/schemas/MessageResult"
|
|
725
|
+
"404":
|
|
726
|
+
description: No matching open plan escalation for the plan key.
|
|
727
|
+
content:
|
|
728
|
+
application/json:
|
|
729
|
+
schema:
|
|
730
|
+
$ref: "#/components/schemas/MessageResult"
|
|
664
731
|
/hooks/blackboard:
|
|
665
732
|
get:
|
|
666
733
|
operationId: readBlackboard
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { assertEquals } from "#test-assert";
|
|
3
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
4
|
+
import { noopLog } from "../test/log.ts";
|
|
5
|
+
|
|
6
|
+
const hadSecret = Object.prototype.hasOwnProperty.call(process.env, "NANO_PR_WEBHOOK_SECRET");
|
|
7
|
+
const previousSecret = process.env.NANO_PR_WEBHOOK_SECRET;
|
|
8
|
+
let answerPlanEscalation: typeof import("./answerPlanEscalation.ts").default;
|
|
9
|
+
try {
|
|
10
|
+
process.env.NANO_PR_WEBHOOK_SECRET = " test-secret ";
|
|
11
|
+
answerPlanEscalation = (await import("./answerPlanEscalation.ts")).default;
|
|
12
|
+
} finally {
|
|
13
|
+
if (hadSecret && previousSecret !== undefined) process.env.NANO_PR_WEBHOOK_SECRET = previousSecret;
|
|
14
|
+
else delete process.env.NANO_PR_WEBHOOK_SECRET;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function memTable(rows: any[], key: string) {
|
|
18
|
+
return {
|
|
19
|
+
find: (where: Record<string, unknown>) =>
|
|
20
|
+
Promise.resolve(rows.filter((row) => Object.entries(where).every(([field, value]) => row[field] === value))),
|
|
21
|
+
update: (value: unknown, patch: Record<string, unknown>) => {
|
|
22
|
+
const row = rows.find((candidate) => candidate[key] === value);
|
|
23
|
+
if (row) Object.assign(row, patch);
|
|
24
|
+
return Promise.resolve(row);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function memApp(escalations: any[] = []) {
|
|
30
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
31
|
+
plans: { rows: [{ plan_key: "owner/repo#9", open_plan_escalation_id: 1 }], key: "plan_key" },
|
|
32
|
+
plan_review_escalations: { rows: escalations, key: "id" },
|
|
33
|
+
};
|
|
34
|
+
const published: Record<string, unknown>[] = [];
|
|
35
|
+
const app = {
|
|
36
|
+
data: {
|
|
37
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
38
|
+
},
|
|
39
|
+
engine: {
|
|
40
|
+
publishMessage: (message: Record<string, unknown>) => {
|
|
41
|
+
published.push(message);
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
log: noopLog(),
|
|
46
|
+
} as any as AppApi;
|
|
47
|
+
return { app, published, stores };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function input(body: Record<string, unknown>, secret?: string) {
|
|
51
|
+
const headers = new Headers();
|
|
52
|
+
if (secret !== undefined) headers.set("x-hook-secret", secret);
|
|
53
|
+
return {
|
|
54
|
+
req: {
|
|
55
|
+
method: "POST",
|
|
56
|
+
path: "/app/api/hooks/plan-answer",
|
|
57
|
+
query: new URLSearchParams(),
|
|
58
|
+
headers,
|
|
59
|
+
text: async () => "",
|
|
60
|
+
} as any,
|
|
61
|
+
params: {},
|
|
62
|
+
query: {},
|
|
63
|
+
body,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("rejects a request without the configured hook secret", async () => {
|
|
68
|
+
const { app } = memApp();
|
|
69
|
+
const result = await answerPlanEscalation(input({ plan: "owner/repo#9", directive: "revise" }), app) as any;
|
|
70
|
+
assertEquals(result.status, 401);
|
|
71
|
+
assertEquals(result.body, { ok: false, error: "unauthorized" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("answers an open plan escalation and publishes the plan correlation message", async () => {
|
|
75
|
+
const { app, published, stores } = memApp([{
|
|
76
|
+
id: 1,
|
|
77
|
+
plan_key: "owner/repo#9",
|
|
78
|
+
epoch: 0,
|
|
79
|
+
round: 2,
|
|
80
|
+
findings: "needs wave 0",
|
|
81
|
+
status: "open",
|
|
82
|
+
}]);
|
|
83
|
+
const result = await answerPlanEscalation(
|
|
84
|
+
input({ plan: "owner/repo#9", directive: "revise", note: " use issue-1 first " }, "test-secret"),
|
|
85
|
+
app,
|
|
86
|
+
) as any;
|
|
87
|
+
assertEquals(result.status, 200);
|
|
88
|
+
assertEquals(result.body.ok, true);
|
|
89
|
+
assertEquals(stores.plan_review_escalations.rows[0].status, "answered");
|
|
90
|
+
assertEquals(stores.plan_review_escalations.rows[0].directive, "revise");
|
|
91
|
+
assertEquals(stores.plan_review_escalations.rows[0].note, "use issue-1 first");
|
|
92
|
+
assertEquals(published[0]?.name, "plan-escalation-answered");
|
|
93
|
+
assertEquals(published[0]?.correlationKey, "owner/repo#9");
|
|
94
|
+
assertEquals((published[0]?.variables as Record<string, unknown>).planEscalationDirective, "revise");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("maps an unmatched plan to 404", async () => {
|
|
98
|
+
const { app } = memApp();
|
|
99
|
+
const result = await answerPlanEscalation(
|
|
100
|
+
input({ plan: "owner/repo#missing", directive: "revise" }, "test-secret"),
|
|
101
|
+
app,
|
|
102
|
+
) as any;
|
|
103
|
+
assertEquals(result.status, 404);
|
|
104
|
+
assertEquals(result.body.ok, false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("rejects an invalid directive with 400", async () => {
|
|
108
|
+
const { app } = memApp();
|
|
109
|
+
const result = await answerPlanEscalation(
|
|
110
|
+
input({ plan: "owner/repo#9", directive: "ship-it" }, "test-secret"),
|
|
111
|
+
app,
|
|
112
|
+
) as any;
|
|
113
|
+
assertEquals(result.status, 400);
|
|
114
|
+
assertEquals(result.body.ok, false);
|
|
115
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// POST /app/api/hooks/plan-answer → operationId `answerPlanEscalation`. Answers a plan-review
|
|
2
|
+
// cap escalation out of band. The process is parked on `plan-escalation-answered`, correlated by
|
|
3
|
+
// planKey; the answer records the human directive and resumes the plan.
|
|
4
|
+
import {
|
|
5
|
+
answerPlanEscalation as answerPlanEscalationState,
|
|
6
|
+
parsePlanEscalationDirective,
|
|
7
|
+
} from "../app/plan.ts";
|
|
8
|
+
import { envVar } from "../app/version.ts";
|
|
9
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
10
|
+
|
|
11
|
+
const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
12
|
+
|
|
13
|
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
14
|
+
|
|
15
|
+
export default defineOperation("answerPlanEscalation", async ({ req, body }, app) => {
|
|
16
|
+
if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
|
|
17
|
+
app.log.warn("plan-answer rejected: missing/invalid shared secret");
|
|
18
|
+
return { status: 401, body: { ok: false, error: "unauthorized" } };
|
|
19
|
+
}
|
|
20
|
+
if (!body || typeof body !== "object") {
|
|
21
|
+
app.log.warn("plan-answer rejected: missing request body");
|
|
22
|
+
return { status: 400, body: { ok: false, error: "plan and directive are required" } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const planKey = str(body.plan);
|
|
26
|
+
const directive = parsePlanEscalationDirective(body.directive);
|
|
27
|
+
const note = str(body.note);
|
|
28
|
+
if (!planKey) {
|
|
29
|
+
app.log.warn("plan-answer rejected: missing plan");
|
|
30
|
+
return { status: 400, body: { ok: false, error: "plan is required" } };
|
|
31
|
+
}
|
|
32
|
+
if (!directive) {
|
|
33
|
+
app.log.warn("plan-answer rejected: invalid directive", { directive: str(body.directive) });
|
|
34
|
+
return { status: 400, body: { ok: false, error: "directive must be proceed or revise" } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const r = await answerPlanEscalationState(app.data, app.engine, planKey, directive, note);
|
|
38
|
+
if (r.ok) app.log.info("plan escalation answered", { planKey, directive });
|
|
39
|
+
else app.log.warn("plan-answer: no open plan escalation to answer", { planKey });
|
|
40
|
+
return { status: r.ok ? 200 : 404, body: r };
|
|
41
|
+
});
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
// POST /app/api/actions/message → operationId `postMessage` (ADR 0058, base /app/api).
|
|
2
2
|
// Replaces the hand-rolled action that overrode the generic publishMessage action. For the
|
|
3
|
-
// `escalation-answered` message we run the review answer flow,
|
|
4
|
-
// (issue #25) the implementation-phase (per-task) answer flow
|
|
5
|
-
//
|
|
6
|
-
// publishMessage.
|
|
3
|
+
// `escalation-answered` message we run the review answer flow, for `feature-escalation-answered`
|
|
4
|
+
// (issue #25) the implementation-phase (per-task) answer flow, and for
|
|
5
|
+
// `plan-escalation-answered` the plan-review cap answer flow. Any other message falls back to a
|
|
6
|
+
// plain publishMessage.
|
|
7
7
|
//
|
|
8
8
|
// The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
|
|
9
9
|
// for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
|
|
10
10
|
// is app logic, not something the JSON schema can express.
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
answerPlanEscalation,
|
|
14
|
+
answerTaskEscalation,
|
|
15
|
+
FEATURE_ESCALATION_MESSAGE,
|
|
16
|
+
PLAN_ESCALATION_MESSAGE,
|
|
17
|
+
parsePlanEscalationDirective,
|
|
18
|
+
} from "../app/plan.ts";
|
|
13
19
|
import { answerEscalation } from "../app/service.ts";
|
|
14
20
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
15
21
|
|
|
@@ -46,6 +52,20 @@ export default defineOperation("postMessage", async ({ body }, app) => {
|
|
|
46
52
|
return { status: r.ok ? 200 : 404, body: r };
|
|
47
53
|
}
|
|
48
54
|
|
|
55
|
+
if (name === PLAN_ESCALATION_MESSAGE) {
|
|
56
|
+
const planKey = String(b.correlationKey ?? "");
|
|
57
|
+
const directive = parsePlanEscalationDirective(b.variables?.directive ?? "revise");
|
|
58
|
+
const note = String(b.variables?.note ?? b.variables?.answer ?? "").trim();
|
|
59
|
+
if (!planKey) return { status: 400, body: { error: "correlationKey is required" } };
|
|
60
|
+
if (!directive) {
|
|
61
|
+
return { status: 400, body: { error: "directive must be proceed or revise" } };
|
|
62
|
+
}
|
|
63
|
+
const r = await answerPlanEscalation(app.data, app.engine, planKey, directive, note);
|
|
64
|
+
if (r.ok) app.log.info("plan escalation answered", { name, planKey, directive });
|
|
65
|
+
else app.log.warn("postMessage: no open plan escalation to answer", { name, planKey });
|
|
66
|
+
return { status: r.ok ? 200 : 404, body: r };
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
await app.engine.publishMessage({
|
|
50
70
|
name,
|
|
51
71
|
correlationKey: b.correlationKey != null ? String(b.correlationKey) : undefined,
|
|
@@ -162,3 +162,61 @@ test("postMessage → 400 when escalation-answered lacks a correlationKey", asyn
|
|
|
162
162
|
assertEquals(r.status, 400);
|
|
163
163
|
assertEquals(r.body.error, "correlationKey is required");
|
|
164
164
|
});
|
|
165
|
+
|
|
166
|
+
function planEscalationMessageApp() {
|
|
167
|
+
const plans = [{ plan_key: "owner/repo#12", open_plan_escalation_id: 1 }];
|
|
168
|
+
const escalations = [{
|
|
169
|
+
id: 1,
|
|
170
|
+
plan_key: "owner/repo#12",
|
|
171
|
+
epoch: 0,
|
|
172
|
+
round: 2,
|
|
173
|
+
findings: "needs guidance",
|
|
174
|
+
status: "open",
|
|
175
|
+
directive: null,
|
|
176
|
+
note: null,
|
|
177
|
+
}];
|
|
178
|
+
const published: any[] = [];
|
|
179
|
+
const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
|
|
180
|
+
Object.entries(q).every(([f, v]) => r[f] === v);
|
|
181
|
+
const table = (rows: any[], key: string) => ({
|
|
182
|
+
find: (q: any) => Promise.resolve(rows.filter((r) => match(r, q))),
|
|
183
|
+
update: (id: any, patch: any) => {
|
|
184
|
+
const row = rows.find((r) => r[key] === id);
|
|
185
|
+
if (row) Object.assign(row, patch);
|
|
186
|
+
return Promise.resolve(row);
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
app: {
|
|
191
|
+
data: {
|
|
192
|
+
table(name: string) {
|
|
193
|
+
return name === "plans" ? table(plans, "plan_key") : table(escalations, "id");
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
engine: {
|
|
197
|
+
publishMessage: (m: any) => {
|
|
198
|
+
published.push(m);
|
|
199
|
+
return Promise.resolve();
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
log: noopLog(),
|
|
203
|
+
} as any as AppApi,
|
|
204
|
+
escalations,
|
|
205
|
+
published,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test("postMessage accepts mixed-case plan escalation directive like the dedicated hook", async () => {
|
|
210
|
+
const { app: msgApp, escalations, published } = planEscalationMessageApp();
|
|
211
|
+
const res = await postMessage(input({
|
|
212
|
+
name: "plan-escalation-answered",
|
|
213
|
+
correlationKey: "owner/repo#12",
|
|
214
|
+
variables: { directive: "PrOcEeD", note: "ship it" },
|
|
215
|
+
}), msgApp);
|
|
216
|
+
const r = res as any;
|
|
217
|
+
assertEquals(r.status, 200);
|
|
218
|
+
assertEquals(r.body.ok, true);
|
|
219
|
+
assertEquals(r.body.directive, "proceed");
|
|
220
|
+
assertEquals(escalations[0].directive, "proceed");
|
|
221
|
+
assertEquals(published[0].variables.planEscalationDirective, "proceed");
|
|
222
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/epic.page.json
CHANGED
|
@@ -70,8 +70,24 @@
|
|
|
70
70
|
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
71
71
|
{ "field": "task_count", "header": "Tasks" },
|
|
72
72
|
{ "field": "open_task_id", "header": "Open escalation" },
|
|
73
|
+
{ "field": "open_plan_round", "header": "Plan escalation round" },
|
|
73
74
|
{ "field": "updated_at", "header": "Updated" }
|
|
74
75
|
],
|
|
76
|
+
"rowActions": [
|
|
77
|
+
{
|
|
78
|
+
"label": "Proceed with plan",
|
|
79
|
+
"confirm": "Dispatch the current unapproved plan as a human override?",
|
|
80
|
+
"showWhenField": "open_plan_escalation_id",
|
|
81
|
+
"action": {
|
|
82
|
+
"path": "/app/api/actions/message",
|
|
83
|
+
"body": {
|
|
84
|
+
"name": "plan-escalation-answered",
|
|
85
|
+
"correlationKey": "{{row.plan_key}}",
|
|
86
|
+
"variables": { "directive": "proceed", "note": "Proceed override from the plans page." }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
],
|
|
75
91
|
"detail": {
|
|
76
92
|
"linkField": "issue_url",
|
|
77
93
|
"fields": [
|
|
@@ -79,8 +95,25 @@
|
|
|
79
95
|
{ "field": "issue_number", "label": "Issue number" },
|
|
80
96
|
{ "field": "base_branch", "label": "Base branch (blank = repo default)" },
|
|
81
97
|
{ "field": "outcome", "label": "Outcome" },
|
|
98
|
+
{ "field": "open_plan_findings", "label": "Open plan-review findings" },
|
|
82
99
|
{ "field": "open_task_question", "label": "Open escalation question" }
|
|
83
|
-
]
|
|
100
|
+
],
|
|
101
|
+
"form": {
|
|
102
|
+
"showWhenField": "open_plan_escalation_id",
|
|
103
|
+
"title": "Answer the open plan-review escalation",
|
|
104
|
+
"promptField": "open_plan_findings",
|
|
105
|
+
"inputKey": "note",
|
|
106
|
+
"inputLabel": "Revision directive note for the planner",
|
|
107
|
+
"submitLabel": "Revise plan",
|
|
108
|
+
"action": {
|
|
109
|
+
"path": "/app/api/actions/message",
|
|
110
|
+
"body": {
|
|
111
|
+
"name": "plan-escalation-answered",
|
|
112
|
+
"correlationKey": "{{row.plan_key}}",
|
|
113
|
+
"variables": { "directive": "revise", "note": "{{form.note}}" }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
84
117
|
}
|
|
85
118
|
}
|
|
86
119
|
},
|
|
@@ -99,12 +132,39 @@
|
|
|
99
132
|
"columns": [
|
|
100
133
|
{ "field": "plan_key", "header": "Plan" },
|
|
101
134
|
{ "field": "round", "header": "Round" },
|
|
135
|
+
{ "field": "epoch", "header": "Epoch" },
|
|
102
136
|
{ "field": "approved", "header": "Approved? (1/0)" },
|
|
103
137
|
{ "field": "findings", "header": "Reviewer findings" },
|
|
104
138
|
{ "field": "created_at", "header": "Recorded" }
|
|
105
139
|
]
|
|
106
140
|
}
|
|
107
141
|
},
|
|
142
|
+
{
|
|
143
|
+
"type": "dataGrid",
|
|
144
|
+
"id": "plan-review-escalations",
|
|
145
|
+
"props": {
|
|
146
|
+
"title": "Plan-review escalations",
|
|
147
|
+
"rowKey": "id",
|
|
148
|
+
"refreshMs": 5000,
|
|
149
|
+
"data": {
|
|
150
|
+
"kind": "datasource",
|
|
151
|
+
"source": "app",
|
|
152
|
+
"table": "plan_review_escalations",
|
|
153
|
+
"orderBy": { "field": "id", "dir": "desc" }
|
|
154
|
+
},
|
|
155
|
+
"columns": [
|
|
156
|
+
{ "field": "plan_key", "header": "Plan" },
|
|
157
|
+
{ "field": "epoch", "header": "Epoch" },
|
|
158
|
+
{ "field": "round", "header": "Round" },
|
|
159
|
+
{ "field": "findings", "header": "Findings" },
|
|
160
|
+
{ "field": "status", "header": "Status" },
|
|
161
|
+
{ "field": "directive", "header": "Directive" },
|
|
162
|
+
{ "field": "note", "header": "Human note" },
|
|
163
|
+
{ "field": "asked_at", "header": "Asked" },
|
|
164
|
+
{ "field": "answered_at", "header": "Answered" }
|
|
165
|
+
]
|
|
166
|
+
}
|
|
167
|
+
},
|
|
108
168
|
{
|
|
109
169
|
"type": "dataGrid",
|
|
110
170
|
"id": "wave-state",
|
|
@@ -213,12 +273,18 @@
|
|
|
213
273
|
"source": "app",
|
|
214
274
|
"table": "plan_trial_merges",
|
|
215
275
|
"orderBy": { "field": "created_at", "dir": "desc" },
|
|
216
|
-
"filter": [
|
|
276
|
+
"filter": [
|
|
277
|
+
{ "field": "result", "in": ["merge-conflict", "suite-failed"] },
|
|
278
|
+
{ "field": "resolved", "in": [0] }
|
|
279
|
+
]
|
|
217
280
|
},
|
|
218
281
|
"tabs": [
|
|
219
282
|
{
|
|
220
283
|
"label": "Needs attention",
|
|
221
|
-
"filter": [
|
|
284
|
+
"filter": [
|
|
285
|
+
{ "field": "result", "in": ["merge-conflict", "suite-failed"] },
|
|
286
|
+
{ "field": "resolved", "in": [0] }
|
|
287
|
+
]
|
|
222
288
|
},
|
|
223
289
|
{
|
|
224
290
|
"label": "Clean",
|
package/prompts/plan.md
CHANGED
|
@@ -36,6 +36,17 @@ Before decomposing anything yourself, check whether the issue is an **epic that
|
|
|
36
36
|
has already been split into sub-issues**. If it has, **do not invent a new
|
|
37
37
|
breakdown** — adopt the existing one, one task per sub-issue. This keeps the
|
|
38
38
|
fan-out faithful to the human's plan and links each PR back to its sub-issue.
|
|
39
|
+
On the first pass this means adopting the open sub-issue set faithfully. On a
|
|
40
|
+
rejected review, however, still keep the task set 1:1 with those open
|
|
41
|
+
sub-issues while you revise each task's `prompt` and `dependsOn` to satisfy the
|
|
42
|
+
findings. You MAY and SHOULD add a contract/seam deliverable to one existing
|
|
43
|
+
sub-issue task's prompt (for example, a shared-surface registration seam), point
|
|
44
|
+
sibling tasks at it, and create wave-0 ordering via an existing sub-issue — but
|
|
45
|
+
never add, merge, re-split, or remove tasks, and never edit the GitHub issues. If
|
|
46
|
+
a finding genuinely requires changing the sub-issue boundary (splitting,
|
|
47
|
+
merging, adding, or removing a sub-issue) and cannot be expressed as a
|
|
48
|
+
`prompt`/`dependsOn` revision, say that it requires a human decomposition change,
|
|
49
|
+
then re-emit the best in-boundary plan you can.
|
|
39
50
|
|
|
40
51
|
Detect existing children two ways (try both; union the results, de-duplicated):
|
|
41
52
|
|
package/resources/agent-guide.md
CHANGED
|
@@ -151,9 +151,19 @@ curl -sS -X POST __BASE__/hooks/feature-answer \
|
|
|
151
151
|
|
|
152
152
|
If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
**Answer a plan-review escalation** raised when the adversarial plan review cannot
|
|
155
|
+
converge within its round budget. Use `revise` to send guidance back to the planner
|
|
156
|
+
with a fresh review budget, or `proceed` to explicitly approve the current plan as-is:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
curl -sS -X POST __BASE__/hooks/plan-answer \
|
|
160
|
+
-H 'content-type: application/json' \
|
|
161
|
+
-d '{ "plan": "owner/repo#123", "directive": "revise", "note": "Keep the sub-issues 1:1; make issue-7 the seam and point siblings at it." }'
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Guidance for the human you assist: read the escalation `question` or plan-review
|
|
165
|
+
`findings` first, decide the smallest unblocking answer, and answer it precisely —
|
|
166
|
+
the answer becomes the agent's next-round context.
|
|
157
167
|
|
|
158
168
|
---
|
|
159
169
|
|