@nanobpm/nano-workforce 0.56.0 → 0.58.0
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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +14 -0
- package/README.md +10 -2
- package/SPEC.md +27 -24
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/agentGuide.ts +1 -1
- package/app/answer-escalation.test.ts +106 -0
- package/app/answerEscalation.test.ts +67 -0
- package/app/baseGuard.test.ts +9 -1
- package/app/baseGuard.ts +11 -0
- package/app/escalationSla.test.ts +39 -0
- package/app/escalationSla.ts +28 -0
- package/app/escalationTaxonomy.test.ts +115 -0
- package/app/escalationTaxonomy.ts +115 -0
- package/app/feature.test.ts +161 -0
- package/app/feature.ts +173 -0
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +267 -313
- package/app/plan.ts +173 -231
- package/app/reviewWait.ts +12 -4
- package/app/roundResultDefault.test.ts +111 -2
- package/app/roundResultDefault.ts +35 -0
- package/app/service.test.ts +6 -7
- package/app/service.ts +52 -35
- package/db/migrations/026_agent_completion.sql +36 -0
- package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
- package/db/migrations/028_feature_runs.sql +28 -0
- package/e2e/agent-answerable.e2e.ts +185 -0
- package/e2e/convergence-escalation.e2e.ts +180 -0
- package/e2e/convergence-loop.e2e.ts +1 -1
- package/e2e/feature-run.e2e.ts +231 -0
- package/e2e/plan-fanout-sla.e2e.ts +238 -0
- package/e2e/plan-fanout.e2e.ts +303 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
- package/e2e/support/github-admit.ts +99 -0
- package/e2e/user-task-spine.e2e.ts +155 -0
- package/nano.app.json +41 -11
- package/openapi.yaml +237 -84
- package/operations/agentCompleteEscalation.ts +53 -0
- package/operations/listActivePrs.test.ts +39 -6
- package/operations/postMessage.ts +10 -41
- package/operations/revertEscalationCompletion.ts +44 -0
- package/operations/startAndMessage.test.ts +62 -60
- package/operations/startFeature.ts +127 -0
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +4 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +5 -2
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +90 -26
- package/resources/forms/feature-escalation.form +27 -0
- package/resources/forms/plan-review-decision.form +27 -0
- package/resources/forms/pr-escalation.form +23 -0
- package/resources/forms/spine-demo.form +15 -0
- package/resources/forms/trial-merge-decision.form +25 -0
- package/resources/processes/convergence-loop.bpmn +127 -75
- package/resources/processes/feature.bpmn +240 -0
- package/resources/processes/plan-fanout.bpmn +322 -222
- package/resources/processes/spine-demo.bpmn +72 -0
- package/scripts/check-migrations.ts +68 -0
- package/workers/answer-escalation/worker.ts +78 -0
- package/workers/converge-feature/worker.ts +51 -0
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -0
- package/workers/finalize/worker.ts +0 -2
- package/workers/mark-merged/worker.ts +0 -2
- package/workers/merge/worker.ts +6 -5
- package/workers/persist-escalation/worker.ts +28 -32
- package/workers/record-feature/worker.ts +61 -0
- package/workers/record-plan-review/worker.test.ts +9 -10
- package/workers/record-plan-review/worker.ts +15 -5
- package/workers/resolve-trial-attention/worker.test.ts +77 -0
- package/workers/resolve-trial-attention/worker.ts +43 -0
- package/operations/answerFeatureEscalation.test.ts +0 -112
- package/operations/answerFeatureEscalation.ts +0 -58
- package/operations/answerPlanEscalation.test.ts +0 -115
- package/operations/answerPlanEscalation.ts +0 -41
- package/workers/persist-plan-escalation/worker.test.ts +0 -80
- package/workers/persist-plan-escalation/worker.ts +0 -73
- package/workers/persist-task-escalation/worker.ts +0 -120
|
@@ -1,41 +0,0 @@
|
|
|
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,80 +0,0 @@
|
|
|
1
|
-
import { test } from "node:test";
|
|
2
|
-
import { assertEquals, assertRejects } from "#test-assert";
|
|
3
|
-
import { noopLog } from "../../test/log.ts";
|
|
4
|
-
import handler from "./worker.ts";
|
|
5
|
-
|
|
6
|
-
function fakeApp(escalations: any[] = []) {
|
|
7
|
-
const plans = [{ plan_key: "owner/repo#12", status: "dispatched" }];
|
|
8
|
-
const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
|
|
9
|
-
Object.entries(q).every(([f, v]) => r[f] === v);
|
|
10
|
-
const table = (rows: any[], key: string) => ({
|
|
11
|
-
find: (q: any) => Promise.resolve(rows.filter((r) => match(r, q))),
|
|
12
|
-
insert: (row: any) => {
|
|
13
|
-
row.id = row.id ?? rows.length + 1;
|
|
14
|
-
rows.push(row);
|
|
15
|
-
return Promise.resolve(row.id);
|
|
16
|
-
},
|
|
17
|
-
update: (id: any, patch: any) => {
|
|
18
|
-
const row = rows.find((r) => r[key] === id);
|
|
19
|
-
if (row) Object.assign(row, patch);
|
|
20
|
-
return Promise.resolve(row);
|
|
21
|
-
},
|
|
22
|
-
});
|
|
23
|
-
return {
|
|
24
|
-
data: {
|
|
25
|
-
table(name: string) {
|
|
26
|
-
return name === "plans" ? table(plans, "plan_key") : table(escalations, "id");
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
log: noopLog(),
|
|
30
|
-
_plans: plans,
|
|
31
|
-
_escalations: escalations,
|
|
32
|
-
} as any;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
test("records an open plan-review escalation and surfaces it on the plan", async () => {
|
|
36
|
-
const app = fakeApp();
|
|
37
|
-
const out = await handler({
|
|
38
|
-
variables: {
|
|
39
|
-
planKey: "owner/repo#12",
|
|
40
|
-
planReviewEpoch: 1,
|
|
41
|
-
planReviewRound: 2,
|
|
42
|
-
planFindings: "needs a seam",
|
|
43
|
-
},
|
|
44
|
-
jobKey: "j1",
|
|
45
|
-
} as any, app as any);
|
|
46
|
-
|
|
47
|
-
assertEquals(out.planEscalationId, 1);
|
|
48
|
-
assertEquals(app._escalations[0].plan_key, "owner/repo#12");
|
|
49
|
-
assertEquals(app._escalations[0].epoch, 1);
|
|
50
|
-
assertEquals(app._escalations[0].round, 2);
|
|
51
|
-
assertEquals(app._escalations[0].findings, "needs a seam");
|
|
52
|
-
assertEquals(app._plans[0].open_plan_escalation_id, 1);
|
|
53
|
-
assertEquals(app._plans[0].open_plan_findings, "needs a seam");
|
|
54
|
-
assertEquals(app._plans[0].status, "planning");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("missing planKey fails loudly instead of parking an unanswerable escalation", async () => {
|
|
58
|
-
const app = fakeApp();
|
|
59
|
-
await assertRejects(
|
|
60
|
-
() => handler({
|
|
61
|
-
variables: { planKey: " ", planFindings: "needs a seam" },
|
|
62
|
-
jobKey: "j-missing",
|
|
63
|
-
} as any, app as any),
|
|
64
|
-
Error,
|
|
65
|
-
"persist-plan-escalation: missing planKey in process scope",
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("non-string planKey is treated as missing rather than coerced to a bogus key", async () => {
|
|
70
|
-
const app = fakeApp();
|
|
71
|
-
await assertRejects(
|
|
72
|
-
() => handler({
|
|
73
|
-
variables: { planKey: 123, planFindings: "needs a seam" },
|
|
74
|
-
jobKey: "j-nonstring",
|
|
75
|
-
} as any, app as any),
|
|
76
|
-
Error,
|
|
77
|
-
"persist-plan-escalation: missing planKey in process scope",
|
|
78
|
-
);
|
|
79
|
-
assertEquals(app._escalations.length, 0);
|
|
80
|
-
});
|
|
@@ -1,73 +0,0 @@
|
|
|
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;
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
// pr.persist-task-escalation — an implementation agent escalated a task during the
|
|
2
|
-
// fan-out (issue #25). It reported `status = "escalated"` with a `question` and,
|
|
3
|
-
// ideally, a work-preserving DRAFT PR, then completed its job. This worker records
|
|
4
|
-
// that escalation and parks the plan on a per-task human answer:
|
|
5
|
-
// • opens (or refreshes) a `plan_escalations` row (status = open),
|
|
6
|
-
// • marks the `plan_tasks` row `escalated` with the question / draft PR / corr key,
|
|
7
|
-
// • re-points the plan's denormalised "open task escalation" fields at the
|
|
8
|
-
// oldest still-open escalation, so the page's single answer form surfaces it.
|
|
9
|
-
//
|
|
10
|
-
// The process then parks the child at the `feature-escalation-answered` message
|
|
11
|
-
// catch (correlationKey `<plan_key>:<task_id>`). Answering it (page form or
|
|
12
|
-
// `/app/api/hooks/feature-answer`) resumes the child, which re-dispatches the SAME task.
|
|
13
|
-
//
|
|
14
|
-
// Retry-safe: if an open escalation already exists for this corr key (a worker
|
|
15
|
-
// re-activation before the wait subscription opened), it is UPDATED, not
|
|
16
|
-
// duplicated — a fresh escalation row is only created after the previous one was
|
|
17
|
-
// answered.
|
|
18
|
-
import type { AppJobHandler } from "@nanobpm/urban";
|
|
19
|
-
import {
|
|
20
|
-
featureCorrKey,
|
|
21
|
-
planEscalations,
|
|
22
|
-
planTasks,
|
|
23
|
-
refreshOpenTaskEscalation,
|
|
24
|
-
} from "../../app/plan.ts";
|
|
25
|
-
|
|
26
|
-
interface TaskIn {
|
|
27
|
-
id?: unknown;
|
|
28
|
-
title?: unknown;
|
|
29
|
-
}
|
|
30
|
-
interface In extends Record<string, unknown> {
|
|
31
|
-
planKey: string;
|
|
32
|
-
task?: TaskIn;
|
|
33
|
-
question?: unknown;
|
|
34
|
-
pr?: unknown;
|
|
35
|
-
summary?: unknown;
|
|
36
|
-
}
|
|
37
|
-
interface Out extends Record<string, unknown> {
|
|
38
|
-
escalationId: number;
|
|
39
|
-
escalationCorrKey: string;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// A non-blank trimmed string, else undefined. A blank question/PR must not reach
|
|
43
|
-
// the UI answer form or masquerade as preserved work.
|
|
44
|
-
const str = (v: unknown): string | undefined =>
|
|
45
|
-
typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
46
|
-
|
|
47
|
-
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
48
|
-
const planKey = job.variables.planKey;
|
|
49
|
-
const taskId = str(job.variables.task?.id);
|
|
50
|
-
if (!taskId) {
|
|
51
|
-
// No task binding means we cannot correlate a resume — fail loudly rather
|
|
52
|
-
// than silently parking a token that can never be answered.
|
|
53
|
-
throw new Error("persist-task-escalation: missing task.id in child scope");
|
|
54
|
-
}
|
|
55
|
-
const corrKey = featureCorrKey(planKey, taskId);
|
|
56
|
-
const question = str(job.variables.question);
|
|
57
|
-
if (!question) {
|
|
58
|
-
// A blank question would surface a non-actionable placeholder in the answer
|
|
59
|
-
// form and, on a retry, overwrite a previously recorded question. The output
|
|
60
|
-
// contract requires `question` for an escalation — fail loudly, like the
|
|
61
|
-
// missing-task.id guard above, rather than park an unanswerable escalation.
|
|
62
|
-
throw new Error("persist-task-escalation: missing question for escalated task");
|
|
63
|
-
}
|
|
64
|
-
const draftPr = str(job.variables.pr) ?? null;
|
|
65
|
-
// Prior-attempt context the escalating agent reported. Persist it so the UI
|
|
66
|
-
// and any later resume/debugging keep the task's summary instead of NULL.
|
|
67
|
-
const summary = str(job.variables.summary) ?? null;
|
|
68
|
-
const ts = new Date().toISOString();
|
|
69
|
-
|
|
70
|
-
const escTable = planEscalations(app.data);
|
|
71
|
-
const existing = (await escTable.find({ corr_key: corrKey, status: "open" }))
|
|
72
|
-
.sort((a, b) => b.id - a.id)[0];
|
|
73
|
-
let escalationId: number;
|
|
74
|
-
if (existing) {
|
|
75
|
-
await escTable.update(existing.id, {
|
|
76
|
-
question,
|
|
77
|
-
draft_pr_key: draftPr ?? existing.draft_pr_key,
|
|
78
|
-
});
|
|
79
|
-
escalationId = existing.id;
|
|
80
|
-
} else {
|
|
81
|
-
escalationId = Number(
|
|
82
|
-
await escTable.insert({
|
|
83
|
-
plan_key: planKey,
|
|
84
|
-
task_id: taskId,
|
|
85
|
-
corr_key: corrKey,
|
|
86
|
-
question,
|
|
87
|
-
draft_pr_key: draftPr,
|
|
88
|
-
status: "open",
|
|
89
|
-
asked_at: ts,
|
|
90
|
-
}),
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
for (const t of await planTasks(app.data).find({ plan_key: planKey, task_id: taskId })) {
|
|
95
|
-
await planTasks(app.data).update(t.id, {
|
|
96
|
-
status: "escalated",
|
|
97
|
-
open_question: question,
|
|
98
|
-
// Clear any answer from a prior (already-answered) escalation so the task
|
|
99
|
-
// row stays aligned with the currently-open question — otherwise a
|
|
100
|
-
// re-escalated task would show a stale answer next to the new question.
|
|
101
|
-
answer: null,
|
|
102
|
-
draft_pr_key: draftPr ?? t.draft_pr_key,
|
|
103
|
-
summary: summary ?? t.summary,
|
|
104
|
-
corr_key: corrKey,
|
|
105
|
-
updated_at: ts,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
await refreshOpenTaskEscalation(app.data, planKey);
|
|
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 };
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
export default handler;
|