@nanobpm/nano-workforce 0.187.6 → 0.188.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 +12 -0
- package/README.md +1 -0
- package/SPEC.md +69 -21
- package/app/contracts.ts +8 -0
- package/app/convergeAutoAck.test.ts +252 -0
- package/app/convergeGate.test.ts +236 -5
- package/app/convergeGate.ts +41 -5
- package/app/fineGrainedCells.test.ts +23 -0
- package/app/github.ts +47 -13
- package/app/implementReconcile.test.ts +166 -0
- package/app/implementReconcile.ts +112 -0
- package/app/service.test.ts +40 -1
- package/app/service.ts +19 -0
- package/e2e/feature-run.e2e.ts +45 -1
- package/e2e/support/github-admit.ts +23 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +56 -22
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +77 -25
- package/resources/processes/plan-fanout.bpmn +1 -0
- package/test/derivation-parity/README.md +5 -2
- package/test/derivation-parity/derivation-parity.test.ts +10 -8
- package/test/derivation-parity/flows.ts +6 -4
- package/workers/converge-gate/worker.ts +42 -10
- package/workers/reconcile-implement/worker.test.ts +87 -0
- package/workers/reconcile-implement/worker.ts +60 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Red-first coverage for the implement-cell reconcile decision (issue #801) — the implement-stage twin
|
|
2
|
+
// of #796. The defect: an implement-step that returns NO machine-readable `status` but has an OPEN PR
|
|
3
|
+
// on its `feat/<task.id>` branch was dead-ended at a human escalation instead of adopting that PR and
|
|
4
|
+
// converging. These tests pin the canonical `ic_reconcile_gw` decision that reconciles from GitHub
|
|
5
|
+
// before escalating.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import type { HeadPr } from "./github.ts";
|
|
9
|
+
import {
|
|
10
|
+
implementCellBranch,
|
|
11
|
+
pickAdoptablePr,
|
|
12
|
+
reconcileImplement,
|
|
13
|
+
shouldReconcileImplement,
|
|
14
|
+
} from "./implementReconcile.ts";
|
|
15
|
+
|
|
16
|
+
const openPr = (number: number, base = "main"): HeadPr => ({
|
|
17
|
+
number,
|
|
18
|
+
url: `https://github.com/owner/repo/pull/${number}`,
|
|
19
|
+
state: "open",
|
|
20
|
+
baseRef: base,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("implementCellBranch: the deterministic feat/<task.id> branch", () => {
|
|
24
|
+
assertEquals(implementCellBranch("issue-796"), "feat/issue-796");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("shouldReconcileImplement: only a blank/absent status reconciles", () => {
|
|
28
|
+
assertEquals(shouldReconcileImplement(null), true);
|
|
29
|
+
assertEquals(shouldReconcileImplement(undefined), true);
|
|
30
|
+
assertEquals(shouldReconcileImplement(" "), true);
|
|
31
|
+
assertEquals(shouldReconcileImplement("escalated"), false);
|
|
32
|
+
assertEquals(shouldReconcileImplement("opened"), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("pickAdoptablePr: the first OPEN PR wins; merged/closed are not adoptable", () => {
|
|
36
|
+
assertEquals(pickAdoptablePr(null), null);
|
|
37
|
+
assertEquals(pickAdoptablePr([]), null);
|
|
38
|
+
assertEquals(pickAdoptablePr([{ ...openPr(1), state: "merged" }]), null);
|
|
39
|
+
assertEquals(pickAdoptablePr([{ ...openPr(2), state: "closed" }, openPr(3)])?.number, 3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("pickAdoptablePr: a known baseBranch adopts only an open PR that targets it", () => {
|
|
43
|
+
// Multiple open PRs from the same head branch to different bases — only the one matching the
|
|
44
|
+
// run's pinned base is adoptable; a stale/wrong-base PR (even if first) is never adopted.
|
|
45
|
+
const prs = [openPr(10, "old-epic-base"), openPr(11, "epic/feat-x")];
|
|
46
|
+
assertEquals(pickAdoptablePr(prs, "epic/feat-x")?.number, 11);
|
|
47
|
+
// No open PR targets the pinned base → nothing adoptable (escalate rather than converge the wrong PR).
|
|
48
|
+
assertEquals(pickAdoptablePr([openPr(12, "some-other-base")], "epic/feat-x"), null);
|
|
49
|
+
// Whitespace-only base is treated as "unknown" → first-open fallback.
|
|
50
|
+
assertEquals(pickAdoptablePr([openPr(13, "main")], " ")?.number, 13);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The core defect reproduction: blank status + an open PR on the branch → adopt & converge, no escalation.
|
|
54
|
+
test("reconcileImplement: blank status + open PR on feat/<task.id> → adopt (status=opened, pr set)", async () => {
|
|
55
|
+
const calls: Array<{ repo: string; branch: string }> = [];
|
|
56
|
+
const lookup = async (repo: string, branch: string): Promise<HeadPr[]> => {
|
|
57
|
+
calls.push({ repo, branch });
|
|
58
|
+
return [openPr(800)];
|
|
59
|
+
};
|
|
60
|
+
const res = await reconcileImplement(
|
|
61
|
+
{ status: null, subjectKey: "nanobpm/nano-workforce#796", taskId: "issue-796" },
|
|
62
|
+
lookup,
|
|
63
|
+
"token",
|
|
64
|
+
);
|
|
65
|
+
assertEquals(res, { reconciled: true, status: "opened", pr: "nanobpm/nano-workforce#800" });
|
|
66
|
+
assertEquals(calls, [{ repo: "nanobpm/nano-workforce", branch: "feat/issue-796" }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("reconcileImplement: blank status but NO branch/PR → escalate (unchanged behaviour)", async () => {
|
|
70
|
+
const res = await reconcileImplement(
|
|
71
|
+
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
|
|
72
|
+
async () => [],
|
|
73
|
+
"token",
|
|
74
|
+
);
|
|
75
|
+
assertEquals(res, { reconciled: false, status: null, pr: null });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("reconcileImplement: a genuine escalation status is honoured, GitHub never consulted", async () => {
|
|
79
|
+
let consulted = false;
|
|
80
|
+
const res = await reconcileImplement(
|
|
81
|
+
{ status: "escalated", subjectKey: "owner/repo#7", taskId: "issue-7" },
|
|
82
|
+
async () => {
|
|
83
|
+
consulted = true;
|
|
84
|
+
return [openPr(9)];
|
|
85
|
+
},
|
|
86
|
+
"token",
|
|
87
|
+
);
|
|
88
|
+
assertEquals(res, { reconciled: false, status: "escalated", pr: null });
|
|
89
|
+
assertEquals(consulted, false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("reconcileImplement: only merged/closed PRs on the branch → escalate (nothing in-flight to adopt)", async () => {
|
|
93
|
+
const res = await reconcileImplement(
|
|
94
|
+
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
|
|
95
|
+
async () => [{ ...openPr(5), state: "merged" }],
|
|
96
|
+
"token",
|
|
97
|
+
);
|
|
98
|
+
assertEquals(res.reconciled, false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("reconcileImplement: a lookup transport failure falls through to escalate (best-effort)", async () => {
|
|
102
|
+
const res = await reconcileImplement(
|
|
103
|
+
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
|
|
104
|
+
async () => {
|
|
105
|
+
throw new Error("github 502");
|
|
106
|
+
},
|
|
107
|
+
"token",
|
|
108
|
+
);
|
|
109
|
+
assertEquals(res, { reconciled: false, status: null, pr: null });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("reconcileImplement: an existing pr is carried through unchanged on fall-through (never wiped)", async () => {
|
|
113
|
+
// A genuine escalation status → escalate; any pr already in scope must survive the re-emit.
|
|
114
|
+
const escalated = await reconcileImplement(
|
|
115
|
+
{ status: "escalated", subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
|
|
116
|
+
async () => [],
|
|
117
|
+
"token",
|
|
118
|
+
);
|
|
119
|
+
assertEquals(escalated, { reconciled: false, status: "escalated", pr: "owner/repo#42" });
|
|
120
|
+
|
|
121
|
+
// Blank status but no adoptable PR → escalate; an existing pr still survives.
|
|
122
|
+
const noAdopt = await reconcileImplement(
|
|
123
|
+
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
|
|
124
|
+
async () => [],
|
|
125
|
+
"token",
|
|
126
|
+
);
|
|
127
|
+
assertEquals(noAdopt, { reconciled: false, status: null, pr: "owner/repo#42" });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("reconcileImplement: a successful adoption overwrites any existing pr with the adopted key", async () => {
|
|
131
|
+
const res = await reconcileImplement(
|
|
132
|
+
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
|
|
133
|
+
async () => [openPr(99)],
|
|
134
|
+
"token",
|
|
135
|
+
);
|
|
136
|
+
assertEquals(res, { reconciled: true, status: "opened", pr: "owner/repo#99" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("reconcileImplement: with a pinned baseBranch, only a PR targeting it is adopted", async () => {
|
|
140
|
+
// The head branch carries two open PRs to different bases — adopt the one matching the run's base.
|
|
141
|
+
const adopt = await reconcileImplement(
|
|
142
|
+
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7", baseBranch: "epic/feat-x" },
|
|
143
|
+
async () => [openPr(50, "stale-base"), openPr(51, "epic/feat-x")],
|
|
144
|
+
"token",
|
|
145
|
+
);
|
|
146
|
+
assertEquals(adopt, { reconciled: true, status: "opened", pr: "owner/repo#51" });
|
|
147
|
+
|
|
148
|
+
// Only a wrong-base PR exists → escalate rather than converge the wrong branch.
|
|
149
|
+
const escalate = await reconcileImplement(
|
|
150
|
+
{ status: null, subjectKey: "owner/repo#7", taskId: "issue-7", baseBranch: "epic/feat-x", pr: "owner/repo#42" },
|
|
151
|
+
async () => [openPr(52, "stale-base")],
|
|
152
|
+
"token",
|
|
153
|
+
);
|
|
154
|
+
assertEquals(escalate, { reconciled: false, status: null, pr: "owner/repo#42" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("reconcileImplement: a missing taskId or unparseable subjectKey → escalate, no lookup", async () => {
|
|
158
|
+
let consulted = false;
|
|
159
|
+
const lookup = async (): Promise<HeadPr[]> => {
|
|
160
|
+
consulted = true;
|
|
161
|
+
return [openPr(1)];
|
|
162
|
+
};
|
|
163
|
+
assertEquals((await reconcileImplement({ status: null, subjectKey: "owner/repo#7", taskId: null }, lookup, "t")).reconciled, false);
|
|
164
|
+
assertEquals((await reconcileImplement({ status: null, subjectKey: "not-a-key", taskId: "issue-7" }, lookup, "t")).reconciled, false);
|
|
165
|
+
assertEquals(consulted, false);
|
|
166
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Implement-step reconcile — reconcile the implement-cell result from GitHub BEFORE escalating to a
|
|
2
|
+
// human (issue #801).
|
|
3
|
+
//
|
|
4
|
+
// The shared `implement-cell` (resources/processes/implement-cell.bpmn) routes ANY implement-step
|
|
5
|
+
// outcome that is not a clean terminal (`opened`/`blocked`/`skipped`) to a human escalation. But a
|
|
6
|
+
// harness that returns NO machine-readable result envelope (a blank/absent `status`) can still have
|
|
7
|
+
// pushed the slice's branch and opened a green PR — a machine-observable, recoverable outcome the
|
|
8
|
+
// escalation question literally asked a human to go and check (#796's implement-stage twin). Dead-
|
|
9
|
+
// ending that at a person is the defect.
|
|
10
|
+
//
|
|
11
|
+
// This is the CANONICAL, pure decision for the cell's reconcile step: on a blank/absent `status`,
|
|
12
|
+
// look for an OPEN PR opened from the cell's deterministic branch (`feat/<task.id>`, the agent-guide
|
|
13
|
+
// convention every implement-cell caller shares — see resources/prompts/feature.md) and, when one
|
|
14
|
+
// exists, ADOPT it (derive `status = "opened"` + a `pr` key) so the run converges exactly as if the
|
|
15
|
+
// agent had reported it — no human escalation. Only when nothing is observable does the run escalate
|
|
16
|
+
// as today. The GitHub read is injected (the canonical `listPrsForHead`) so this stays a pure,
|
|
17
|
+
// exhaustively testable mirror of the `ic_reconcile_gw` gateway — no second GitHub reconciler.
|
|
18
|
+
import type { HeadPr } from "./github.ts";
|
|
19
|
+
import { parsePr } from "./prParse.ts";
|
|
20
|
+
|
|
21
|
+
/** The escalate-arm inputs the reconcile step reads from the implement-cell scope. `subjectKey` is the
|
|
22
|
+
* cell's `owner/repo#N` subject (a feature run's `feature_key`, or a wave slice's epic `plan_key`) —
|
|
23
|
+
* its `owner/repo` half is the repository to look in. `taskId` is `task.id`, which fixes the
|
|
24
|
+
* deterministic implement branch `feat/<task.id>`. `status` is the (blank, on this arm) implement-step
|
|
25
|
+
* status. `pr` is any PR key already in scope (the implement harness may have set it) — carried through
|
|
26
|
+
* unchanged on the non-adopt fall-through so re-emitting the output never wipes it. `baseBranch` is the
|
|
27
|
+
* run's pinned base branch (the epic/graph integration branch every implement-cell caller maps into the
|
|
28
|
+
* cell scope): when known, only a PR whose base matches it is adoptable, so a stale/unrelated PR sharing
|
|
29
|
+
* the deterministic head branch but targeting a different base is never adopted. */
|
|
30
|
+
export interface ReconcileImplementInput {
|
|
31
|
+
status: unknown;
|
|
32
|
+
subjectKey: unknown;
|
|
33
|
+
taskId: unknown;
|
|
34
|
+
pr?: unknown;
|
|
35
|
+
baseBranch?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The reconcile decision. `reconciled` is the `ic_reconcile_gw` gate: true → adopt-and-converge (with
|
|
39
|
+
* `status = "opened"` + `pr` set); false → escalate as today. `status`/`pr` are re-emitted so the
|
|
40
|
+
* cell (and its caller) route on the adopted values. */
|
|
41
|
+
export interface ReconcileImplementResult {
|
|
42
|
+
reconciled: boolean;
|
|
43
|
+
status: string | null;
|
|
44
|
+
pr: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The injected GitHub read — the canonical `listPrsForHead(repo, headBranch, token)` (so this module
|
|
48
|
+
* never grows a second PR-lookup transport). */
|
|
49
|
+
export type OpenPrLookup = (repo: string, branch: string, token: string) => Promise<HeadPr[] | null>;
|
|
50
|
+
|
|
51
|
+
const str = (v: unknown): string | undefined =>
|
|
52
|
+
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
53
|
+
|
|
54
|
+
/** Reconcile ONLY when the agent left no machine-readable status (blank/absent) — the #796/#801
|
|
55
|
+
* no-result condition. A genuine escalation that carries its own status string is honoured (escalate),
|
|
56
|
+
* never silently overridden by a branch PR that may be unrelated to the agent's question. */
|
|
57
|
+
export function shouldReconcileImplement(status: unknown): boolean {
|
|
58
|
+
return str(status) === undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md). */
|
|
62
|
+
export function implementCellBranch(taskId: string): string {
|
|
63
|
+
return `feat/${taskId}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The adoptable PR from a head-branch listing: the first OPEN one (a merged/closed PR on the branch is
|
|
67
|
+
* not an in-flight result to converge). When `baseBranch` is given, only an open PR whose `baseRef`
|
|
68
|
+
* matches it is adoptable — GitHub can carry multiple open PRs from one head branch to different bases,
|
|
69
|
+
* so adopting blind to the base could converge a stale/unrelated PR; with no base known, fall back to
|
|
70
|
+
* the first open PR (unchanged best-effort behaviour). `null` when the listing is absent (no transport)
|
|
71
|
+
* or has no adoptable PR. */
|
|
72
|
+
export function pickAdoptablePr(prs: HeadPr[] | null, baseBranch?: string): HeadPr | null {
|
|
73
|
+
if (!prs) return null;
|
|
74
|
+
const open = prs.filter((p) => p.state === "open");
|
|
75
|
+
const base = typeof baseBranch === "string" ? baseBranch.trim() : "";
|
|
76
|
+
if (base) return open.find((p) => p.baseRef === base) ?? null;
|
|
77
|
+
return open[0] ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The canonical implement-cell reconcile decision (mirror of `ic_reconcile_gw`). Best-effort: any
|
|
81
|
+
* missing input, unusable transport, or lookup failure falls through to `escalate` — never worse than
|
|
82
|
+
* today's behaviour, and idempotent (a pure GitHub read that adopts the SAME open PR on a re-run, so
|
|
83
|
+
* a re-dispatch never opens or double-adopts a second PR). */
|
|
84
|
+
export async function reconcileImplement(
|
|
85
|
+
input: ReconcileImplementInput,
|
|
86
|
+
lookup: OpenPrLookup,
|
|
87
|
+
token: string,
|
|
88
|
+
): Promise<ReconcileImplementResult> {
|
|
89
|
+
const escalate: ReconcileImplementResult = {
|
|
90
|
+
reconciled: false,
|
|
91
|
+
status: str(input.status) ?? null,
|
|
92
|
+
// Carry any existing PR key through unchanged — the reconcile step's `pr` output is mapped back
|
|
93
|
+
// into the process variable, so returning a bare `null` here would wipe a `pr` the implement
|
|
94
|
+
// harness already set. Only a successful adoption below overwrites it.
|
|
95
|
+
pr: str(input.pr) ?? null,
|
|
96
|
+
};
|
|
97
|
+
if (!shouldReconcileImplement(input.status)) return escalate;
|
|
98
|
+
const taskId = str(input.taskId);
|
|
99
|
+
// `subjectKey` shares the `owner/repo#N` shape parsePr validates; we use only its `repo` half.
|
|
100
|
+
const parsed = parsePr(input.subjectKey);
|
|
101
|
+
if (!taskId || !parsed) return escalate;
|
|
102
|
+
const branch = implementCellBranch(taskId);
|
|
103
|
+
let prs: HeadPr[] | null;
|
|
104
|
+
try {
|
|
105
|
+
prs = await lookup(parsed.repo, branch, token);
|
|
106
|
+
} catch {
|
|
107
|
+
return escalate; // transport hiccup → escalate as today
|
|
108
|
+
}
|
|
109
|
+
const adopt = pickAdoptablePr(prs, str(input.baseBranch));
|
|
110
|
+
if (!adopt) return escalate;
|
|
111
|
+
return { reconciled: true, status: "opened", pr: `${parsed.repo}#${adopt.number}` };
|
|
112
|
+
}
|
package/app/service.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { memDataFor } from "../test/worldDb.ts";
|
|
|
11
11
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
13
13
|
import { WorldStore } from "./world/index.ts";
|
|
14
|
-
import { abandonClosedPr, isPrSettled, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
14
|
+
import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
15
15
|
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
16
16
|
import type { DataLayer } from "@nanobpm/urban";
|
|
17
17
|
|
|
@@ -378,6 +378,45 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
|
|
|
378
378
|
});
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
// #796 auto-ack budget seeding: `submitPr` is the ONLY production write that makes the retry budget
|
|
382
|
+
// available to a fresh convergence instance — the engine behaviour tests seed `ackRetryRound` /
|
|
383
|
+
// `ackRetryMax` directly and never exercise `submitPr`, so a regression dropping or misconfiguring
|
|
384
|
+
// this seed would leave deployed loops on the escalation default while every added behaviour test
|
|
385
|
+
// still passes. Assert both the initial counter and the configured max propagate onto the instance.
|
|
386
|
+
function captureVars() {
|
|
387
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
388
|
+
pull_requests: { rows: [], key: "pr_key" },
|
|
389
|
+
escalations: { rows: [], key: "id" },
|
|
390
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
391
|
+
};
|
|
392
|
+
const data = {
|
|
393
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
394
|
+
} as any;
|
|
395
|
+
let captured: Record<string, unknown> | undefined;
|
|
396
|
+
const engine = {
|
|
397
|
+
createInstance: (req: { variables?: Record<string, unknown> }) => {
|
|
398
|
+
captured = req.variables;
|
|
399
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
400
|
+
},
|
|
401
|
+
} as any;
|
|
402
|
+
return { data, engine, get: () => captured };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0, ackRetryMax=MAX_ACK_RETRIES)", async () => {
|
|
406
|
+
await withGithubOff(async () => {
|
|
407
|
+
const { data, engine, get } = captureVars();
|
|
408
|
+
await submitPr(data, engine, {
|
|
409
|
+
repo: "owner/repo",
|
|
410
|
+
number: 10,
|
|
411
|
+
url: "https://github.com/owner/repo/pull/10",
|
|
412
|
+
prKey: "owner/repo#10",
|
|
413
|
+
});
|
|
414
|
+
const vars = get();
|
|
415
|
+
assertEquals(vars?.ackRetryRound, 0);
|
|
416
|
+
assertEquals(vars?.ackRetryMax, MAX_ACK_RETRIES);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
381
420
|
// Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
|
|
382
421
|
// and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
|
|
383
422
|
// merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
|
package/app/service.ts
CHANGED
|
@@ -159,6 +159,20 @@ export const MAX_REBASE_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_REBASE
|
|
|
159
159
|
* retry (a race escalates immediately). Reuses the CI-fix budget clamp (allows 0 = disable). */
|
|
160
160
|
export const MAX_MERGE_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_RETRIES, 5);
|
|
161
161
|
|
|
162
|
+
/** How many times the convergence loop will re-dispatch the `senior:pr-review` (review-round) agent
|
|
163
|
+
* to auto-ack unacked suppressed advisories before escalating to a human. When the converge-gate
|
|
164
|
+
* blocks SOLELY on unacknowledged suppressed advisories (no unresolved inline threads), the block is
|
|
165
|
+
* recoverable: re-running the review-round agent posts the missing `nano-ack:` threads and converges,
|
|
166
|
+
* so the loop tries that — bounded — before parking the human `wait-answer` (issue #796). A resolved
|
|
167
|
+
* `Declined … nano-ack:` advisory is an acknowledgement and CONVERGES (issue #787), so a decline does
|
|
168
|
+
* not escalate; only the agent returning `needs_input` (a genuinely contested advisory it cannot
|
|
169
|
+
* decide) or `blocked` (an external blocker it reports with a question — the `gw-status` arm at
|
|
170
|
+
* `convergence-loop.bpmn:442-443` routes both to `wait-answer`), or this budget being exhausted,
|
|
171
|
+
* escalates. Default 2; set
|
|
172
|
+
* `NANO_PR_MAX_ACK_RETRIES=0` to escalate on the first ack-only block. Reuses the CI-fix budget clamp
|
|
173
|
+
* (allows 0 = disable, ceiling-capped). */
|
|
174
|
+
export const MAX_ACK_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_ACK_RETRIES, 2);
|
|
175
|
+
|
|
162
176
|
/** How many times the mergeable-wait timeout backstop (`merge-stall-probe`) will re-derive
|
|
163
177
|
* mergeability from ground truth and re-arm the merge stage before giving up and escalating to a
|
|
164
178
|
* human. Bounds the timer arm of the `gw-merge-wait` event-based gateway so a dead in-process poller
|
|
@@ -644,6 +658,11 @@ export async function submitPr(
|
|
|
644
658
|
round: 1,
|
|
645
659
|
maxRounds: clampRounds(maxRounds, MAX_ROUNDS),
|
|
646
660
|
reviewWaitTimeout: REVIEW_WAIT_TIMEOUT,
|
|
661
|
+
// Bounded agent auto-ack (issue #796): the convergence loop re-dispatches the review-round
|
|
662
|
+
// agent up to `ackRetryMax` times to ack suppressed advisories when the converge-gate blocks
|
|
663
|
+
// solely on unacked ones, before escalating to a human. `ackRetryRound` counts those passes.
|
|
664
|
+
ackRetryRound: 0,
|
|
665
|
+
ackRetryMax: MAX_ACK_RETRIES,
|
|
647
666
|
// Lineage (issue #245): carry the origin identity onto the convergence instance so every
|
|
648
667
|
// descendant (and any message it correlates) is stitched back to the originating request.
|
|
649
668
|
// A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -67,6 +67,7 @@ interface PrRow {
|
|
|
67
67
|
describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
68
68
|
const savedEnv = new Map<string, string | undefined>();
|
|
69
69
|
let restoreGithub: (() => void) | undefined;
|
|
70
|
+
const githubState = admitGithubState("owner/repo", "main");
|
|
70
71
|
|
|
71
72
|
before(() => {
|
|
72
73
|
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
@@ -75,7 +76,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
75
76
|
}
|
|
76
77
|
// ADR 0003: `startFeature` + the `pr.ensure-base-branch` head task pass through base admission,
|
|
77
78
|
// which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
|
|
78
|
-
restoreGithub = installAdmitGithub(
|
|
79
|
+
restoreGithub = installAdmitGithub(githubState);
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
after(() => {
|
|
@@ -331,6 +332,49 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
331
332
|
}
|
|
332
333
|
});
|
|
333
334
|
|
|
335
|
+
test("reconcile before escalate: a no-status result with an open PR on the branch adopts & converges (issue #801)", async () => {
|
|
336
|
+
// The #796/#801 defect: a harness returns NO machine-readable status but has pushed the branch and
|
|
337
|
+
// opened a green PR. Instead of dead-ending at a human, the cell's reconcile step observes the open
|
|
338
|
+
// PR on `feat/<task.id>` (= `feat/issue-7`), adopts it (status=opened, prKey), and converges.
|
|
339
|
+
githubState.openPrs.set("feat/issue-7", { number: 801, base: "epic/e2e" });
|
|
340
|
+
try {
|
|
341
|
+
await withApp(
|
|
342
|
+
{ "senior:feature": () => ({ summary: "opened a PR but reported no status" }) },
|
|
343
|
+
{ baseBranch: "epic/e2e", converge: true },
|
|
344
|
+
async ({ app, featureKey }) => {
|
|
345
|
+
const flows = takenFlows(app);
|
|
346
|
+
assert.ok(
|
|
347
|
+
flows.includes("ic_reconcile_gw->ic_end"),
|
|
348
|
+
`the adopted PR routed straight to the cell's done end (flows: ${flows.join(", ")})`,
|
|
349
|
+
);
|
|
350
|
+
assert.ok(
|
|
351
|
+
!flows.includes("ic_reconcile_gw->record-escalation"),
|
|
352
|
+
"the run did NOT escalate to a human",
|
|
353
|
+
);
|
|
354
|
+
assert.ok(
|
|
355
|
+
flows.includes("gw-converge->converge"),
|
|
356
|
+
`the adopted PR was handed to the convergence loop (flows: ${flows.join(", ")})`,
|
|
357
|
+
);
|
|
358
|
+
const run = await featureRow(app, featureKey);
|
|
359
|
+
assert.equal(run.status, "converging", "the reconciled run settled at converging");
|
|
360
|
+
assert.equal(run.pr_key, "owner/repo#801", "the adopted PR key is recorded on the run");
|
|
361
|
+
const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({ pr_key: "owner/repo#801" });
|
|
362
|
+
assert.equal(prs.length, 1, "the adopted PR was enrolled into the convergence loop (submitPr)");
|
|
363
|
+
|
|
364
|
+
// A native user-task escalation was never parked — the machine-recoverable outcome was
|
|
365
|
+
// reconciled without pulling in a person.
|
|
366
|
+
const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: run.process_key! });
|
|
367
|
+
assert.ok(
|
|
368
|
+
!tasks.some((t) => t.elementId === "escalation"),
|
|
369
|
+
"no human-escalation task was created for the adopted run",
|
|
370
|
+
);
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
} finally {
|
|
374
|
+
githubState.openPrs.delete("feat/issue-7");
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
334
378
|
test("escalate + abandon: abandoning routes to record-feature (default flow)", async () => {
|
|
335
379
|
await withApp(
|
|
336
380
|
{
|
|
@@ -16,6 +16,10 @@ export interface AdmitGithubState {
|
|
|
16
16
|
branches: Map<string, string>; // branch → head sha
|
|
17
17
|
creates: { ref: string; sha: string }[];
|
|
18
18
|
resets: string[]; // any PATCH/force-update on an existing ref (must stay empty)
|
|
19
|
+
/** Open PRs keyed by head branch (issue #801): the implement-cell reconcile step lists PRs for a
|
|
20
|
+
* head via `listPrsForHead`. Empty by default → the pulls listing returns `[]` (no adoptable PR),
|
|
21
|
+
* so suites that don't opt in keep exactly today's escalate behaviour. */
|
|
22
|
+
openPrs: Map<string, { number: number; base?: string }>;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/** Build a fresh admit-github state with the default branch pre-seeded with a HEAD sha so an
|
|
@@ -30,6 +34,7 @@ export function admitGithubState(
|
|
|
30
34
|
branches: new Map([[defaultBranch, "0".repeat(40)]]),
|
|
31
35
|
creates: [],
|
|
32
36
|
resets: [],
|
|
37
|
+
openPrs: new Map(),
|
|
33
38
|
};
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -72,6 +77,24 @@ function admitFetch(state: AdmitGithubState) {
|
|
|
72
77
|
state.resets.push(decodeURIComponent(path.split("/git/refs/heads/")[1] ?? ""));
|
|
73
78
|
return Promise.resolve(json({ ok: true }));
|
|
74
79
|
}
|
|
80
|
+
// GET /repos/{repo}/pulls?state=…&head=owner:branch → the open PRs for a head branch, as read by
|
|
81
|
+
// `listPrsForHead` (the implement-cell reconcile step, issue #801). Default empty state → `[]`.
|
|
82
|
+
if (method === "GET" && path === `/repos/${state.repo}/pulls`) {
|
|
83
|
+
const head = u.searchParams.get("head") ?? "";
|
|
84
|
+
const branch = head.includes(":") ? head.slice(head.indexOf(":") + 1) : head;
|
|
85
|
+
const hit = state.openPrs.get(branch);
|
|
86
|
+
if (!hit) return Promise.resolve(json([]));
|
|
87
|
+
return Promise.resolve(
|
|
88
|
+
json([
|
|
89
|
+
{
|
|
90
|
+
number: hit.number,
|
|
91
|
+
html_url: `https://github.com/${state.repo}/pull/${hit.number}`,
|
|
92
|
+
state: "open",
|
|
93
|
+
base: { ref: hit.base ?? state.defaultBranch },
|
|
94
|
+
},
|
|
95
|
+
]),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
75
98
|
// Any other endpoint is a best-effort read the sealed transport used to skip → 404 (null).
|
|
76
99
|
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
77
100
|
};
|
package/nano.app.json
CHANGED
|
@@ -239,6 +239,10 @@
|
|
|
239
239
|
{
|
|
240
240
|
"taskType": "pr.record-feature-implementing",
|
|
241
241
|
"handler": "workers/record-feature-implementing/worker.ts"
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
"taskType": "pr.reconcile-implement",
|
|
245
|
+
"handler": "workers/reconcile-implement/worker.ts"
|
|
242
246
|
}
|
|
243
247
|
],
|
|
244
248
|
"externalTaskTypes": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.188.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",
|