@nanobpm/nano-workforce 0.57.0 → 0.58.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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/SPEC.md +21 -22
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- 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/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +0 -294
- package/app/plan.ts +26 -216
- 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 +37 -11
- package/openapi.yaml +181 -73
- 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 +0 -58
- package/operations/startFeature.ts +127 -0
- package/package.json +5 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +1 -1
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +52 -24
- 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 +304 -223
- 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/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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Escalation taxonomy — the single canonical source of truth for classifying an
|
|
2
|
+
// escalation raise site into a tier (nano-workforce ADR 0002 §1).
|
|
3
|
+
//
|
|
4
|
+
// The app used to page a human from several independent raise sites, each with its own
|
|
5
|
+
// ad-hoc "should this become an escalation?" logic, and — worst of all — a blank/absent
|
|
6
|
+
// question could FABRICATE an answerable escalation (or, on the plan-fanout arm, wedge an
|
|
7
|
+
// un-remediable incident). This module replaces that scattered logic with ONE classifier so
|
|
8
|
+
// every raise site, and every downstream escalation-conversion slice (U2–U7), asks the same
|
|
9
|
+
// question in the same place.
|
|
10
|
+
//
|
|
11
|
+
// ADR 0002 §1 defines three tiers:
|
|
12
|
+
// • transient — handled in-process (retry / re-enter a durable wait / re-request a
|
|
13
|
+
// review). Never becomes a task. The empty-status backstop and the
|
|
14
|
+
// re-request-review nudge are transient.
|
|
15
|
+
// • advisory — recorded on the coordination blackboard for humans/siblings to read.
|
|
16
|
+
// Never becomes a task and never blocks a token.
|
|
17
|
+
// • decision-required — a human (or, per ADR 0046, an agent assignee) must make a call.
|
|
18
|
+
// ONLY this tier proceeds toward a user task.
|
|
19
|
+
//
|
|
20
|
+
// A fourth disposition, `none`, means "not an escalation at all": the signal was raised but,
|
|
21
|
+
// on inspection, there is nothing to escalate — most importantly a decision-required kind
|
|
22
|
+
// whose question is blank. A blank question can no longer fabricate an answerable escalation:
|
|
23
|
+
// it is a NON-escalation (no task, no wait).
|
|
24
|
+
|
|
25
|
+
/** The three escalation tiers of ADR 0002 §1. */
|
|
26
|
+
export type EscalationTier = "transient" | "advisory" | "decision-required";
|
|
27
|
+
|
|
28
|
+
/** A raise site's disposition: either it is not an escalation at all (`none`), or it falls
|
|
29
|
+
* into one of the three tiers. Only `decision-required` proceeds toward a user task. */
|
|
30
|
+
export type EscalationDisposition = "none" | EscalationTier;
|
|
31
|
+
|
|
32
|
+
/** Every escalation raise site in the codebase, tagged by origin so the classifier can apply
|
|
33
|
+
* that site's policy. */
|
|
34
|
+
export type EscalationKind =
|
|
35
|
+
// convergence-loop `gw-status` gateway — the review-round "safe default" routing that
|
|
36
|
+
// `roundResultDefault` mirrors.
|
|
37
|
+
| "review-round"
|
|
38
|
+
// `baseGuard` (app/baseGuard.ts) — a PR that targets a dead-end (already-landed) base.
|
|
39
|
+
| "dead-end-base"
|
|
40
|
+
// `mergeProtocol` (app/mergeProtocol.ts) — the repo's declared land method.
|
|
41
|
+
| "merge-protocol"
|
|
42
|
+
// plan-fanout `w_gw` "escalated?" gateway — an implementation agent reported
|
|
43
|
+
// `status = "escalated"` with a question.
|
|
44
|
+
| "task";
|
|
45
|
+
|
|
46
|
+
/** Everything the classifier may need from any raise site. Each field is consumed only by the
|
|
47
|
+
* kind(s) it applies to; the rest are ignored. */
|
|
48
|
+
export interface EscalationSignal {
|
|
49
|
+
kind: EscalationKind;
|
|
50
|
+
/** The human-facing question / reason. Blank (absent, empty, or whitespace-only) means the
|
|
51
|
+
* signal can never become a task — see {@link hasAnswerableQuestion}. */
|
|
52
|
+
question?: string | null;
|
|
53
|
+
/** `review-round` only: the machine-readable status the review agent reported. */
|
|
54
|
+
status?: string | null;
|
|
55
|
+
/** `dead-end-base` only: whether the base is a CONFIRMED dead end (ambiguity is never a
|
|
56
|
+
* dead end — see {@link import("./baseGuard.ts").isDeadEndBase}). */
|
|
57
|
+
deadEnd?: boolean;
|
|
58
|
+
/** `merge-protocol` only: the repo's declared land method (`ui` needs a human). */
|
|
59
|
+
landMethod?: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** True iff `question` is a concrete, human-answerable string — a non-blank value after
|
|
63
|
+
* trimming. This is the canonical blank-question rule the whole taxonomy shares: an
|
|
64
|
+
* empty / whitespace-only / absent question is NOT answerable, so it can never fabricate an
|
|
65
|
+
* escalation. */
|
|
66
|
+
export function hasAnswerableQuestion(question: string | null | undefined): boolean {
|
|
67
|
+
return typeof question === "string" && question.trim() !== "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The review-round statuses that DEMAND a human decision. Any other status (converged,
|
|
71
|
+
* addressed, waiting, or an unknown/empty one — the empty-status backstop) is transient: it
|
|
72
|
+
* re-enters the durable review wait rather than paging a human. */
|
|
73
|
+
const DECISION_STATUSES: ReadonlySet<string> = new Set(["needs_input", "blocked"]);
|
|
74
|
+
|
|
75
|
+
/** Classify an escalation signal into its disposition. The one place the tiered taxonomy of
|
|
76
|
+
* ADR 0002 §1 is decided — every raise site and every escalation-conversion slice routes
|
|
77
|
+
* through here rather than re-deriving tier logic per kind. */
|
|
78
|
+
export function classifyEscalation(signal: EscalationSignal): EscalationDisposition {
|
|
79
|
+
switch (signal.kind) {
|
|
80
|
+
case "review-round": {
|
|
81
|
+
// Exact-token match, mirroring the convergence-loop `gw-status` gateway (whose
|
|
82
|
+
// status conditions do NOT trim) so this canonical router can never drift from the
|
|
83
|
+
// deployed model. `status` is a machine-produced enum, not free-form prose, so it is
|
|
84
|
+
// compared exactly — unlike `question`, whose blank-detection trims (hasAnswerableQuestion).
|
|
85
|
+
const status = signal.status ?? "";
|
|
86
|
+
// Only an explicit human-blocking status is even a candidate; everything else is
|
|
87
|
+
// transient (re-enter the review wait — this is the empty-status backstop).
|
|
88
|
+
if (!DECISION_STATUSES.has(status)) return "transient";
|
|
89
|
+
// A human-blocking status with no answerable question is a NON-escalation: a blank
|
|
90
|
+
// question can no longer fabricate an answerable escalation.
|
|
91
|
+
return hasAnswerableQuestion(signal.question) ? "decision-required" : "none";
|
|
92
|
+
}
|
|
93
|
+
case "dead-end-base":
|
|
94
|
+
// A confirmed dead-end base needs a human retarget; ambiguity is never raised, so a
|
|
95
|
+
// non-dead-end signal is simply not an escalation.
|
|
96
|
+
return signal.deadEnd ? "decision-required" : "none";
|
|
97
|
+
case "merge-protocol":
|
|
98
|
+
// Only a `ui` land method needs a human (to click Merge). Every machine-landable method
|
|
99
|
+
// (gh-merge / admin / mergify-queue) stays in-process — transient.
|
|
100
|
+
return signal.landMethod === "ui" ? "decision-required" : "transient";
|
|
101
|
+
case "task":
|
|
102
|
+
// The agent declared `status = "escalated"`; a blank question is a NON-escalation. This
|
|
103
|
+
// retires the blank-question fabrication failure mode — no task, no wait.
|
|
104
|
+
return hasAnswerableQuestion(signal.question) ? "decision-required" : "none";
|
|
105
|
+
default:
|
|
106
|
+
return "none";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The guard every raise site and escalation-conversion slice shares: does this signal proceed
|
|
111
|
+
* toward a user task? Only the `decision-required` tier does — transient and advisory signals,
|
|
112
|
+
* and non-escalations (`none`, including blank-question signals), never raise a task. */
|
|
113
|
+
export function shouldRaiseTask(signal: EscalationSignal): boolean {
|
|
114
|
+
return classifyEscalation(signal) === "decision-required";
|
|
115
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Unit coverage for the single-issue feature run domain (issue #172).
|
|
2
|
+
//
|
|
3
|
+
// `startFeature` mirrors `startPlan`: it registers/refreshes the `feature_runs` aggregate
|
|
4
|
+
// (idempotent on `feature_key`), starts `feature.bpmn`, and persists the process key. These tests
|
|
5
|
+
// drive it against an in-memory data layer + a stub engine and assert the row shape, the
|
|
6
|
+
// short-circuit on an already-running run, the in-place restart of a settled run, and the seeded
|
|
7
|
+
// process variables (the single `task` slice + the base-branch brief).
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals } from "#test-assert";
|
|
10
|
+
import { FEATURE_PROCESS_ID, featureTaskId, startFeature } from "./feature.ts";
|
|
11
|
+
|
|
12
|
+
function memTable(rows: any[], key: string) {
|
|
13
|
+
return {
|
|
14
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
15
|
+
find: (q: any) =>
|
|
16
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
17
|
+
insert: (r: any) => {
|
|
18
|
+
rows.push(r);
|
|
19
|
+
return Promise.resolve(r);
|
|
20
|
+
},
|
|
21
|
+
update: (k: any, patch: any) => {
|
|
22
|
+
const r = rows.find((x) => x[key] === k);
|
|
23
|
+
if (r) Object.assign(r, patch);
|
|
24
|
+
return Promise.resolve(r);
|
|
25
|
+
},
|
|
26
|
+
delete: (k: any) => {
|
|
27
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
34
|
+
return {
|
|
35
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
36
|
+
} as any;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PARSED = {
|
|
40
|
+
repo: "owner/repo",
|
|
41
|
+
number: 42,
|
|
42
|
+
url: "https://github.com/owner/repo/issues/42",
|
|
43
|
+
planKey: "owner/repo#42",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
test("featureTaskId: deterministic branch slug derivable from the issue number alone", () => {
|
|
47
|
+
assertEquals(featureTaskId(42), "issue-42");
|
|
48
|
+
assertEquals(featureTaskId(1), "issue-1");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("startFeature: inserts a running feature_runs row and persists the process key", async () => {
|
|
52
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
53
|
+
const data = memData(stores);
|
|
54
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any;
|
|
55
|
+
|
|
56
|
+
const result = await startFeature(data, engine, PARSED, "main", false, false);
|
|
57
|
+
|
|
58
|
+
assertEquals(result.featureKey, "owner/repo#42");
|
|
59
|
+
assertEquals(result.processKey, "PI-9");
|
|
60
|
+
const row = stores.feature_runs.rows[0];
|
|
61
|
+
assertEquals(row.feature_key, "owner/repo#42");
|
|
62
|
+
assertEquals(row.repo, "owner/repo");
|
|
63
|
+
assertEquals(row.issue_number, 42);
|
|
64
|
+
assertEquals(row.base_branch, "main");
|
|
65
|
+
assertEquals(row.status, "running");
|
|
66
|
+
assertEquals(row.process_key, "PI-9");
|
|
67
|
+
assertEquals(row.converge, 0);
|
|
68
|
+
assertEquals(row.auto_merge, 0);
|
|
69
|
+
assertEquals(row.pr_key, null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("startFeature: converge/autoMerge flags are persisted as 0/1", async () => {
|
|
73
|
+
const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
|
|
74
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) } as any;
|
|
75
|
+
await startFeature(memData(stores), engine, PARSED, "main", true, true);
|
|
76
|
+
const row = stores.feature_runs.rows[0];
|
|
77
|
+
assertEquals(row.converge, 1);
|
|
78
|
+
assertEquals(row.auto_merge, 1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("startFeature: seeds the single task slice + base-branch brief onto the instance", async () => {
|
|
82
|
+
let captured: any = null;
|
|
83
|
+
const engine = {
|
|
84
|
+
createInstance: (req: any) => {
|
|
85
|
+
captured = req;
|
|
86
|
+
return Promise.resolve({ processInstanceKey: "PI-2" });
|
|
87
|
+
},
|
|
88
|
+
} as any;
|
|
89
|
+
await startFeature(
|
|
90
|
+
memData({ feature_runs: { rows: [], key: "feature_key" } }),
|
|
91
|
+
engine,
|
|
92
|
+
PARSED,
|
|
93
|
+
"epic/x",
|
|
94
|
+
true,
|
|
95
|
+
false,
|
|
96
|
+
);
|
|
97
|
+
assertEquals(captured.processDefinitionId, FEATURE_PROCESS_ID);
|
|
98
|
+
const v = captured.variables;
|
|
99
|
+
assertEquals(v.featureKey, "owner/repo#42");
|
|
100
|
+
assertEquals(v.issue, "owner/repo#42");
|
|
101
|
+
assertEquals(v.task.id, "issue-42");
|
|
102
|
+
assertEquals(v.task.title, "owner/repo#42");
|
|
103
|
+
assertEquals(typeof v.task.prompt, "string");
|
|
104
|
+
assertEquals(v.task.prompt.includes("owner/repo#42"), true);
|
|
105
|
+
assertEquals(v.converge, true);
|
|
106
|
+
assertEquals(v.autoMerge, false);
|
|
107
|
+
assertEquals(v.baseBranch, "epic/x");
|
|
108
|
+
// The brief is the authoritative base-branch override the agent gets via appendPrompt.
|
|
109
|
+
assertEquals(v.baseBranchBrief.includes("epic/x"), true);
|
|
110
|
+
// Agent-result variables are pre-seeded so the escalation loop + record worker can reference them.
|
|
111
|
+
assertEquals(v.pr, null);
|
|
112
|
+
assertEquals(v.status, null);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("startFeature: an already-running run short-circuits (no new instance)", async () => {
|
|
116
|
+
const stores = {
|
|
117
|
+
feature_runs: {
|
|
118
|
+
rows: [{ feature_key: "owner/repo#42", status: "running", process_key: "PI-OLD" }],
|
|
119
|
+
key: "feature_key",
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
let created = 0;
|
|
123
|
+
const engine = {
|
|
124
|
+
createInstance: () => {
|
|
125
|
+
created += 1;
|
|
126
|
+
return Promise.resolve({ processInstanceKey: "PI-NEW" });
|
|
127
|
+
},
|
|
128
|
+
} as any;
|
|
129
|
+
const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
|
|
130
|
+
assertEquals(created, 0);
|
|
131
|
+
assertEquals("alreadyRunning" in result && (result as any).alreadyRunning, true);
|
|
132
|
+
assertEquals(result.processKey, "PI-OLD");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("startFeature: a settled run is restarted in place (status reset, pr/outcome cleared)", async () => {
|
|
136
|
+
const stores = {
|
|
137
|
+
feature_runs: {
|
|
138
|
+
rows: [
|
|
139
|
+
{
|
|
140
|
+
feature_key: "owner/repo#42",
|
|
141
|
+
status: "opened",
|
|
142
|
+
process_key: "PI-OLD",
|
|
143
|
+
pr_key: "owner/repo#100",
|
|
144
|
+
outcome: "prior run",
|
|
145
|
+
converge: 0,
|
|
146
|
+
auto_merge: 0,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
key: "feature_key",
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-2" }) } as any;
|
|
153
|
+
await startFeature(memData(stores), engine, PARSED, "main", true, true);
|
|
154
|
+
const row = stores.feature_runs.rows[0];
|
|
155
|
+
assertEquals(row.status, "running");
|
|
156
|
+
assertEquals(row.pr_key, null);
|
|
157
|
+
assertEquals(row.outcome, null);
|
|
158
|
+
assertEquals(row.converge, 1);
|
|
159
|
+
assertEquals(row.auto_merge, 1);
|
|
160
|
+
assertEquals(row.process_key, "PI-2");
|
|
161
|
+
});
|
package/app/feature.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// nano-workforce — single-issue feature run (issue #172).
|
|
2
|
+
//
|
|
3
|
+
// The "missing middle" dispatch surface, between Epics (plan-fanout: one issue →
|
|
4
|
+
// many PRs) and PR convergence (an already-open PR → review → merge): hand ONE
|
|
5
|
+
// issue to a single implementation agent (`senior:feature`), which raises exactly
|
|
6
|
+
// ONE PR, then OPTIONALLY hand that PR to the convergence loop (and, with
|
|
7
|
+
// auto-merge, the merge-loop).
|
|
8
|
+
//
|
|
9
|
+
// This module is the seam the `startFeature` action and the `feature.bpmn` record
|
|
10
|
+
// workers call: it owns the `feature_runs` row shape, the per-run task derivation,
|
|
11
|
+
// and starting the process. It deliberately REUSES the epic primitives rather than
|
|
12
|
+
// forking them — `parseIssue`/`renderBaseBranchBrief`/`normalizeBaseBranch` and the
|
|
13
|
+
// `ESCALATION_SLA_TIMEOUT` come from app/plan.ts, and the downstream PR lifecycle is
|
|
14
|
+
// the existing convergence loop (`submitPr`), keyed on `pr_key` → `pull_requests`.
|
|
15
|
+
// Data access goes through the record gateway (`data.table`), never hand-written
|
|
16
|
+
// SQL — matching app/plan.ts and app/service.ts.
|
|
17
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
18
|
+
import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
|
|
19
|
+
|
|
20
|
+
/** The BPMN process this module drives (resources/processes/feature.bpmn). */
|
|
21
|
+
export const FEATURE_PROCESS_ID = "feature";
|
|
22
|
+
|
|
23
|
+
const now = () => new Date().toISOString();
|
|
24
|
+
|
|
25
|
+
/** One row per issue handed to a single implementation agent. Keyed on `feature_key`
|
|
26
|
+
* (`<owner>/<repo>#<issue-number>`, the same string `parseIssue` returns as `planKey`).
|
|
27
|
+
* The downstream PR lifecycle (review/merge) is NOT duplicated here — once `converge`
|
|
28
|
+
* hands the opened PR to `submitPr`, its live state lives on the `pull_requests` row
|
|
29
|
+
* keyed by `pr_key`. */
|
|
30
|
+
export interface FeatureRun {
|
|
31
|
+
feature_key: string;
|
|
32
|
+
repo: string;
|
|
33
|
+
issue_number: number;
|
|
34
|
+
issue_url: string;
|
|
35
|
+
base_branch: string;
|
|
36
|
+
status: FeatureRunStatus;
|
|
37
|
+
process_key: string | null;
|
|
38
|
+
pr_key: string | null;
|
|
39
|
+
converge: number;
|
|
40
|
+
auto_merge: number;
|
|
41
|
+
outcome: string | null;
|
|
42
|
+
created_at: string;
|
|
43
|
+
updated_at: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const FEATURE_RUN_STATUSES = [
|
|
47
|
+
"running", // the agent is implementing (including while parked at an escalation user task)
|
|
48
|
+
"opened", // a PR was raised and the run ends here (converge was not requested)
|
|
49
|
+
"converging", // the opened PR was handed to the convergence loop (live state via pr_key → pull_requests)
|
|
50
|
+
"blocked", // the agent could not open a PR (gave up / escalation abandoned)
|
|
51
|
+
"skipped", // nothing to do
|
|
52
|
+
"failed", // an unexpected failure
|
|
53
|
+
"abandoned", // the process instance was cancelled (set by instanceTracking.onTerminated)
|
|
54
|
+
] as const;
|
|
55
|
+
export type FeatureRunStatus = typeof FEATURE_RUN_STATUSES[number];
|
|
56
|
+
|
|
57
|
+
/** A feature run is finished once it leaves `running`. Mirrors PLAN_TERMINAL_STATUSES: a
|
|
58
|
+
* re-dispatch of the same issue restarts only when the prior run has settled. */
|
|
59
|
+
export const FEATURE_TERMINAL_STATUSES: readonly FeatureRunStatus[] = [
|
|
60
|
+
"opened",
|
|
61
|
+
"converging",
|
|
62
|
+
"blocked",
|
|
63
|
+
"skipped",
|
|
64
|
+
"failed",
|
|
65
|
+
"abandoned",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
|
|
69
|
+
|
|
70
|
+
/** The deterministic task id for a single-issue run — the implementation agent branches
|
|
71
|
+
* `feat/<task.id>` (see prompts/feature.md), so it MUST be derivable from the issue alone
|
|
72
|
+
* and stable across a resume. The PR is opened on the target repo, so the issue number
|
|
73
|
+
* alone is unambiguous within it. */
|
|
74
|
+
export function featureTaskId(issueNumber: number): string {
|
|
75
|
+
return `issue-${issueNumber}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Register/refresh the feature-run aggregate (idempotent on `feature_key`) and start
|
|
79
|
+
* `feature.bpmn`. Mirrors startPlan: an already-running run for the same issue
|
|
80
|
+
* short-circuits; a settled prior run is restarted in place. `baseBranch` is admitted at
|
|
81
|
+
* the operation edge (`admitPlan`, ADR 0003) exactly as the epic path — this seam only
|
|
82
|
+
* normalizes it again defensively before seeding the brief. */
|
|
83
|
+
export async function startFeature(
|
|
84
|
+
data: DataLayer,
|
|
85
|
+
engine: EngineClient,
|
|
86
|
+
parsed: ParsedIssue,
|
|
87
|
+
baseBranch: string,
|
|
88
|
+
converge: boolean,
|
|
89
|
+
autoMerge: boolean,
|
|
90
|
+
) {
|
|
91
|
+
const table = featureRuns(data);
|
|
92
|
+
const existing = await table.get(parsed.planKey);
|
|
93
|
+
if (existing && !FEATURE_TERMINAL_STATUSES.includes(existing.status)) {
|
|
94
|
+
return { featureKey: parsed.planKey, alreadyRunning: true, processKey: existing.process_key };
|
|
95
|
+
}
|
|
96
|
+
const base = normalizeBaseBranch(baseBranch);
|
|
97
|
+
const ts = now();
|
|
98
|
+
if (existing) {
|
|
99
|
+
await table.update(parsed.planKey, {
|
|
100
|
+
status: "running",
|
|
101
|
+
base_branch: base,
|
|
102
|
+
issue_url: parsed.url,
|
|
103
|
+
pr_key: null,
|
|
104
|
+
converge: converge ? 1 : 0,
|
|
105
|
+
auto_merge: autoMerge ? 1 : 0,
|
|
106
|
+
outcome: null,
|
|
107
|
+
updated_at: ts,
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
await table.insert({
|
|
111
|
+
feature_key: parsed.planKey,
|
|
112
|
+
repo: parsed.repo,
|
|
113
|
+
issue_number: parsed.number,
|
|
114
|
+
issue_url: parsed.url,
|
|
115
|
+
base_branch: base,
|
|
116
|
+
status: "running",
|
|
117
|
+
process_key: null,
|
|
118
|
+
pr_key: null,
|
|
119
|
+
converge: converge ? 1 : 0,
|
|
120
|
+
auto_merge: autoMerge ? 1 : 0,
|
|
121
|
+
outcome: null,
|
|
122
|
+
created_at: ts,
|
|
123
|
+
updated_at: ts,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const { processInstanceKey } = await engine.createInstance({
|
|
127
|
+
processDefinitionId: FEATURE_PROCESS_ID,
|
|
128
|
+
variables: {
|
|
129
|
+
featureKey: parsed.planKey,
|
|
130
|
+
repo: parsed.repo,
|
|
131
|
+
// The parent issue reference (`owner/repo#123`) the agent reads for context (`gh issue view`).
|
|
132
|
+
issue: parsed.planKey,
|
|
133
|
+
issueNumber: parsed.number,
|
|
134
|
+
issueUrl: parsed.url,
|
|
135
|
+
// The single slice the implementation agent builds. `task.prompt` is its primary instruction
|
|
136
|
+
// (prompts/feature.md); `task.id` fixes its deterministic branch `feat/<task.id>` across a
|
|
137
|
+
// resume. Unlike an epic, there is no planner — the whole issue IS the slice.
|
|
138
|
+
task: {
|
|
139
|
+
id: featureTaskId(parsed.number),
|
|
140
|
+
title: parsed.planKey,
|
|
141
|
+
prompt:
|
|
142
|
+
`Implement the GitHub issue ${parsed.planKey} end to end. Read it in full first ` +
|
|
143
|
+
`(\`gh issue view ${parsed.number} -R ${parsed.repo}\`), implement it completely, ` +
|
|
144
|
+
`and open exactly one pull request that closes it.`,
|
|
145
|
+
},
|
|
146
|
+
// Follow-on knobs, read by the record/converge workers. `autoMerge` maps to submitPr's
|
|
147
|
+
// `convergeOnly` (inverted): converge-only stops at `converged`; auto-merge drives the
|
|
148
|
+
// merge-loop. `converge=false` ⇒ merge is moot.
|
|
149
|
+
converge,
|
|
150
|
+
autoMerge,
|
|
151
|
+
// Seed the agent-result variables so the escalation loop + record worker can reference them
|
|
152
|
+
// before the first `senior:feature` job completes (the harness merges the real values in).
|
|
153
|
+
answer: null,
|
|
154
|
+
status: null,
|
|
155
|
+
question: null,
|
|
156
|
+
summary: null,
|
|
157
|
+
pr: null,
|
|
158
|
+
// Escalation SLA + optional assignee — identical to the epic path (plan-fanout), read by the
|
|
159
|
+
// `feature-escalation` user task's interrupting timer boundary and assignment definition.
|
|
160
|
+
escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
|
|
161
|
+
escalationAssignee: null,
|
|
162
|
+
// Base branch (ADR 0003): the branch the agent branches off and opens its PR against. The
|
|
163
|
+
// brief rides `appendPrompt` in the implement task, exactly like the epic implementer.
|
|
164
|
+
baseBranch: base,
|
|
165
|
+
baseBranchBrief: renderBaseBranchBrief(base),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
169
|
+
if (processKey != null) {
|
|
170
|
+
await table.update(parsed.planKey, { process_key: processKey, updated_at: now() });
|
|
171
|
+
}
|
|
172
|
+
return { featureKey: parsed.planKey, processKey };
|
|
173
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import { assertEquals } from "#test-assert";
|
|
9
9
|
import {
|
|
10
|
+
classifyMergeLanding,
|
|
10
11
|
DEFAULT_MERGE_PROTOCOL,
|
|
11
12
|
extractProtocolBlock,
|
|
12
13
|
freshHeadRunAction,
|
|
@@ -195,3 +196,17 @@ test("headRunPresenceCount: token mode (totalChecks < 0) stays conservative (-1)
|
|
|
195
196
|
assertEquals(headRunPresenceCount(NANO_REQ, { totalChecks: -1, presentCheckNames: [] }), -1);
|
|
196
197
|
assertEquals(freshHeadRunAction(NANO_REQ, "waiting", -1, false), null);
|
|
197
198
|
});
|
|
199
|
+
|
|
200
|
+
// The land method is classified against the canonical escalation taxonomy: only a `ui` method is a
|
|
201
|
+
// decision-required escalation (a human must click Merge); every machine-landable method is
|
|
202
|
+
// transient (the merge stage lands it in-process).
|
|
203
|
+
test("classifyMergeLanding: only `ui` is decision-required", () => {
|
|
204
|
+
const withMethod = (method: MergeProtocol["land"]["method"]): MergeProtocol => ({
|
|
205
|
+
...DEFAULT_MERGE_PROTOCOL,
|
|
206
|
+
land: { method },
|
|
207
|
+
});
|
|
208
|
+
assertEquals(classifyMergeLanding(withMethod("ui")), "decision-required");
|
|
209
|
+
assertEquals(classifyMergeLanding(withMethod("gh-merge")), "transient");
|
|
210
|
+
assertEquals(classifyMergeLanding(withMethod("admin")), "transient");
|
|
211
|
+
assertEquals(classifyMergeLanding(withMethod("mergify-queue")), "transient");
|
|
212
|
+
});
|
package/app/mergeProtocol.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// 2. `.github/merge-protocol.json`.
|
|
15
15
|
// A repo that publishes neither keeps today's behaviour (DEFAULT_MERGE_PROTOCOL).
|
|
16
16
|
|
|
17
|
+
import { classifyEscalation, type EscalationDisposition } from "./escalationTaxonomy.ts";
|
|
17
18
|
import { fetchRepoFile } from "./github.ts";
|
|
18
19
|
|
|
19
20
|
/** How to give branch protection a fresh head `pull_request` run before landing. `none` = the
|
|
@@ -66,6 +67,15 @@ export const DEFAULT_MERGE_PROTOCOL: MergeProtocol = {
|
|
|
66
67
|
requiredChecks: [],
|
|
67
68
|
};
|
|
68
69
|
|
|
70
|
+
/** Classify a repo's landing method against the canonical escalation taxonomy. A `ui` land
|
|
71
|
+
* method is a `decision-required` escalation (only a human can click Merge); every
|
|
72
|
+
* machine-landable method (gh-merge / admin / mergify-queue) is `transient` — the merge stage
|
|
73
|
+
* lands it in-process without paging a human. Delegates to {@link classifyEscalation} so the
|
|
74
|
+
* merge worker shares one source of truth with every other raise site. */
|
|
75
|
+
export function classifyMergeLanding(protocol: MergeProtocol): EscalationDisposition {
|
|
76
|
+
return classifyEscalation({ kind: "merge-protocol", landMethod: protocol.land.method });
|
|
77
|
+
}
|
|
78
|
+
|
|
69
79
|
const FRESH_HEAD_RUNS: ReadonlySet<string> = new Set(["none", "ready", "reopen", "ready-or-reopen"]);
|
|
70
80
|
const LAND_METHODS: ReadonlySet<string> = new Set(["gh-merge", "admin", "mergify-queue", "ui"]);
|
|
71
81
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// escalation. The agent-raised / max-rounds arms omit the flag (no prior round row) and must
|
|
8
8
|
// still record the round.
|
|
9
9
|
import { test } from "node:test";
|
|
10
|
-
import {
|
|
10
|
+
import { assertEquals } from "#test-assert";
|
|
11
11
|
import handler from "../workers/persist-escalation/worker.ts";
|
|
12
12
|
|
|
13
13
|
function fakeApp() {
|
|
@@ -99,62 +99,60 @@ test("persist-escalation heals a missing pull_requests parent before recording",
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
test("a padded question is persisted trimmed (no whitespace drift)", async () => {
|
|
102
|
+
test("a padded question is persisted trimmed and returned trimmed (no whitespace drift)", async () => {
|
|
103
103
|
const { app, inserts, updates } = fakeApp();
|
|
104
104
|
const job = { variables: { prKey: "o/r#1", round: 4, status: "needs_input", question: " needs a decision " } };
|
|
105
|
-
await handler(job as any, app as any);
|
|
105
|
+
const out = await handler(job as any, app as any);
|
|
106
106
|
assertEquals((inserts.escalations[0] as any).question, "needs a decision", "escalation stores the trimmed question");
|
|
107
|
-
|
|
107
|
+
// The trimmed question is returned as a process variable so the downstream `wait-answer`
|
|
108
|
+
// userTask + `pr-escalation.form` can display it (there is no denormalised PR-row pointer).
|
|
109
|
+
assertEquals((out as any).question, "needs a decision", "the returned question is trimmed too");
|
|
110
|
+
const patch = (updates.pull_requests![0] as any).patch;
|
|
111
|
+
assertEquals(patch.open_escalation_question, undefined, "no denormalised question pointer is written");
|
|
112
|
+
assertEquals(patch.open_escalation_id, undefined, "no denormalised id pointer is written");
|
|
108
113
|
});
|
|
109
114
|
|
|
110
|
-
//
|
|
111
|
-
// blank-status / blank-question job here (an agent-raised `needs_input`/`blocked` with no
|
|
112
|
-
// question, or the max-rounds / review-stalled arms), the worker must NOT throw — throwing
|
|
113
|
-
// parked an un-remediable JobNoRetries incident (the empty "(no question provided)" escalations
|
|
114
|
-
// on Magikcraft/nano-bpm #597/#599). It opens an *answerable* escalation with a fabricated,
|
|
115
|
-
// concrete question and the agent's transcript attached, so a human can unblock the loop from
|
|
116
|
-
// the UI.
|
|
115
|
+
// REGRESSION (nano-workforce ADR 0002 §1 — retire the blank-question fabrication failure mode).
|
|
117
116
|
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
117
|
+
// A blank/absent question must be treated as a NON-escalation: no escalation row, no PR status
|
|
118
|
+
// flip to `escalated`, no wait. The worker previously FABRICATED a concrete question from the
|
|
119
|
+
// transcript and opened an answerable escalation — that is exactly the failure mode this slice
|
|
120
|
+
// retires. This test reproduces that defect (it fails against the old fabricating worker, which
|
|
121
|
+
// opened an escalation with `escalationId:42`) and pins the new non-escalation behaviour.
|
|
122
|
+
//
|
|
123
|
+
// The `gw-status` gateway is the primary guard (its `f_escalate` arm now requires a non-blank
|
|
124
|
+
// question — see roundResultDefault.test.ts), so a blank-question round never reaches this worker
|
|
125
|
+
// in practice; this asserts the worker's defence-in-depth via the canonical taxonomy.
|
|
126
|
+
test("blank question is a non-escalation (no row fabricated, no status flip)", async () => {
|
|
122
127
|
for (const question of [undefined, "", " "]) {
|
|
123
128
|
const { app, inserts, updates } = fakeApp();
|
|
124
129
|
const job = {
|
|
125
130
|
variables: {
|
|
126
131
|
prKey: "o/r#1",
|
|
127
132
|
round: 2,
|
|
133
|
+
status: "needs_input",
|
|
128
134
|
...(question === undefined ? {} : { question }),
|
|
129
135
|
"io.nanobpm.agentResult": { output: "the agent's prose review, no result file" },
|
|
130
136
|
},
|
|
131
137
|
};
|
|
132
138
|
const out = await handler(job as any, app as any);
|
|
133
|
-
assertEquals((out as any).
|
|
134
|
-
assertEquals(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
esc.question.includes("machine-readable result"),
|
|
139
|
-
"no-result rounds explain the missing status",
|
|
140
|
-
);
|
|
141
|
-
assertEquals(esc.transcript, "the agent's prose review, no result file", "transcript attached");
|
|
142
|
-
// Default status for an unclassified escalation is a question needing input.
|
|
143
|
-
assertEquals(esc.kind, "question");
|
|
144
|
-
const pr = updates.pull_requests![0] as any;
|
|
145
|
-
assertEquals(pr.patch.open_escalation_question, esc.question, "denormalised question set");
|
|
139
|
+
assertEquals((out as any).escalated, false, "the job reports no escalation");
|
|
140
|
+
assertEquals((out as any).escalationId, null, "no escalation id is minted");
|
|
141
|
+
assertEquals(inserts.escalations.length, 0, "no escalation row is fabricated");
|
|
142
|
+
assertEquals(inserts.rounds.length, 0, "no round row is written for a non-escalation");
|
|
143
|
+
assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
|
|
146
144
|
}
|
|
147
145
|
});
|
|
148
146
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
test("
|
|
152
|
-
const { app, inserts } = fakeApp();
|
|
147
|
+
// A non-human-blocking status (never routed here by `gw-status`, but defensively handled) is a
|
|
148
|
+
// transient signal, not a decision-required escalation: it opens nothing.
|
|
149
|
+
test("a non-decision status is a non-escalation even with no question", async () => {
|
|
150
|
+
const { app, inserts, updates } = fakeApp();
|
|
153
151
|
const job = { variables: { prKey: "o/r#1", round: 3, status: "in_progress" } };
|
|
154
|
-
await handler(job as any, app as any);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
assertEquals(
|
|
152
|
+
const out = await handler(job as any, app as any);
|
|
153
|
+
assertEquals((out as any).escalated, false);
|
|
154
|
+
assertEquals(inserts.escalations.length, 0, "no escalation is opened for a transient status");
|
|
155
|
+
assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is not flipped to escalated");
|
|
158
156
|
});
|
|
159
157
|
|
|
160
158
|
// When repo/prNumber process variables are absent the heal still runs by parsing the canonical
|