@nanobpm/nano-workforce 0.187.0 → 0.187.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +1 -1
- package/app/convergeGate.test.ts +245 -17
- package/app/convergeGate.ts +15 -8
- package/app/deliveryGraphCompiler.ts +11 -7
- package/app/deliveryRunner.test.ts +66 -1
- package/app/deliveryRunner.ts +22 -5
- package/app/github.ts +149 -19
- package/app/repoEnvelope.ts +22 -6
- package/openapi.yaml +4 -3
- package/package.json +1 -1
- package/resources/prompts/review-round.md +30 -6
- package/workers/converge-gate/worker.ts +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.187.2](https://github.com/nanobpm/nano-workforce/compare/v0.187.1...v0.187.2) (2026-09-13)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **convergence:** key suppressed-advisory acks on line-stable identity ([#788](https://github.com/nanobpm/nano-workforce/issues/788)) ([9b9d124](https://github.com/nanobpm/nano-workforce/commit/9b9d1241dbd725cfe9b8ac88f5a9f5b949dc4fc4)), closes [#787](https://github.com/nanobpm/nano-workforce/issues/787) [#787](https://github.com/nanobpm/nano-workforce/issues/787) [pre-#787](https://github.com/nanobpm/pre-/issues/787) [#787](https://github.com/nanobpm/nano-workforce/issues/787)
|
|
6
|
+
|
|
7
|
+
## [0.187.1](https://github.com/nanobpm/nano-workforce/compare/v0.187.0...v0.187.1) (2026-09-12)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **delivery-graph:** emit a deterministic branch.create so agent cells never commit on the base branch ([#781](https://github.com/nanobpm/nano-workforce/issues/781)) ([56f6488](https://github.com/nanobpm/nano-workforce/commit/56f6488344b439a5ac12f5069567facd9519bf66)), closes [#776](https://github.com/nanobpm/nano-workforce/issues/776) [pre-#776](https://github.com/nanobpm/pre-/issues/776) [#776](https://github.com/nanobpm/nano-workforce/issues/776)
|
|
12
|
+
|
|
1
13
|
## [0.187.0](https://github.com/nanobpm/nano-workforce/compare/v0.186.4...v0.187.0) (2026-09-12)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/app/contracts.ts
CHANGED
|
@@ -399,7 +399,7 @@ export const WIRE_CONTRACTS = {
|
|
|
399
399
|
name: "io.nanobpm.agentTask.repository",
|
|
400
400
|
owner: "app/repoEnvelope.ts",
|
|
401
401
|
semantics:
|
|
402
|
-
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted
|
|
402
|
+
"Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create` (the deterministic `feat/<task.id>`, emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/<task.id>` the app can't name at compile time, so those agents branch themselves). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231).",
|
|
403
403
|
shape:
|
|
404
404
|
'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string } }, task: { allowPr: true } }',
|
|
405
405
|
},
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// thread, escalating to the human `wait-answer` task instead of finalizing.
|
|
12
12
|
import { readFileSync } from "node:fs";
|
|
13
13
|
import { test } from "node:test";
|
|
14
|
-
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
14
|
+
import { assert, assertEquals, assertNotEquals, assertStringIncludes } from "#test-assert";
|
|
15
15
|
import { evaluateConvergeGate } from "./convergeGate.ts";
|
|
16
16
|
import {
|
|
17
|
+
advisoryStableKey,
|
|
17
18
|
parseAckedAdvisories,
|
|
18
19
|
parseReviewThreadsPage,
|
|
19
20
|
parseSuppressedAdvisories,
|
|
@@ -24,13 +25,13 @@ import {
|
|
|
24
25
|
// ── The canonical router ────────────────────────────────────────────────────
|
|
25
26
|
|
|
26
27
|
test("evaluateConvergeGate: a clean PR (no unresolved threads, no advisories) converges", () => {
|
|
27
|
-
const r = evaluateConvergeGate({ unresolvedThreadCount: 0,
|
|
28
|
+
const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedAdvisories: [], acknowledgedKeys: [] });
|
|
28
29
|
assertEquals(r.convergeBlocked, false);
|
|
29
30
|
assertEquals(r.convergeBlockReason, "");
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
test("evaluateConvergeGate: an unresolved review thread blocks convergence", () => {
|
|
33
|
-
const r = evaluateConvergeGate({ unresolvedThreadCount: 2,
|
|
34
|
+
const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedAdvisories: [], acknowledgedKeys: [] });
|
|
34
35
|
assertEquals(r.convergeBlocked, true);
|
|
35
36
|
assertStringIncludes(r.convergeBlockReason, "2 unresolved review threads");
|
|
36
37
|
});
|
|
@@ -38,7 +39,7 @@ test("evaluateConvergeGate: an unresolved review thread blocks convergence", ()
|
|
|
38
39
|
test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks convergence", () => {
|
|
39
40
|
const r = evaluateConvergeGate({
|
|
40
41
|
unresolvedThreadCount: 0,
|
|
41
|
-
|
|
42
|
+
suppressedAdvisories: [{ key: "spec/a.json#deadbeef", label: "spec/a.json:613" }],
|
|
42
43
|
acknowledgedKeys: [],
|
|
43
44
|
});
|
|
44
45
|
assertEquals(r.convergeBlocked, true);
|
|
@@ -50,16 +51,34 @@ test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks converg
|
|
|
50
51
|
test("evaluateConvergeGate: an ACKNOWLEDGED suppressed advisory no longer blocks convergence", () => {
|
|
51
52
|
const r = evaluateConvergeGate({
|
|
52
53
|
unresolvedThreadCount: 0,
|
|
53
|
-
|
|
54
|
-
acknowledgedKeys: ["spec/a.json
|
|
54
|
+
suppressedAdvisories: [{ key: "spec/a.json#deadbeef", label: "spec/a.json:613" }],
|
|
55
|
+
acknowledgedKeys: ["spec/a.json#deadbeef"],
|
|
55
56
|
});
|
|
56
57
|
assertEquals(r.convergeBlocked, false);
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
test("evaluateConvergeGate: a prose-blind legacy `path:line` ack does NOT acknowledge an advisory (no false-OPEN)", () => {
|
|
61
|
+
// A resolved `nano-ack: spec/a.json:613` for a PRIOR advisory would yield only the `path:line`
|
|
62
|
+
// string — never a stable `<path>#<fp>` key. A genuinely new advisory at that same line carries a
|
|
63
|
+
// different stable key, so a bare-line ack can never satisfy it: the gate stays blocked. This is
|
|
64
|
+
// the guard for issue #787's re-review finding — honouring `path:line` let a resolved ack for
|
|
65
|
+
// advisory A silently converge a NEW advisory B re-emitted at the same line.
|
|
66
|
+
const r = evaluateConvergeGate({
|
|
67
|
+
unresolvedThreadCount: 0,
|
|
68
|
+
suppressedAdvisories: [{ key: "spec/a.json#deadbeef", label: "spec/a.json:613" }],
|
|
69
|
+
acknowledgedKeys: ["spec/a.json:613"],
|
|
70
|
+
});
|
|
71
|
+
assertEquals(r.convergeBlocked, true);
|
|
72
|
+
assertStringIncludes(r.convergeBlockReason, "spec/a.json:613");
|
|
73
|
+
});
|
|
74
|
+
|
|
59
75
|
test("evaluateConvergeGate: multiple unacknowledged advisories use the plural noun", () => {
|
|
60
76
|
const r = evaluateConvergeGate({
|
|
61
77
|
unresolvedThreadCount: 0,
|
|
62
|
-
|
|
78
|
+
suppressedAdvisories: [
|
|
79
|
+
{ key: "x.ts#a", label: "x.ts:10" },
|
|
80
|
+
{ key: "y.ts#b", label: "y.ts:20" },
|
|
81
|
+
],
|
|
63
82
|
acknowledgedKeys: [],
|
|
64
83
|
});
|
|
65
84
|
assertEquals(r.convergeBlocked, true);
|
|
@@ -69,8 +88,11 @@ test("evaluateConvergeGate: multiple unacknowledged advisories use the plural no
|
|
|
69
88
|
test("evaluateConvergeGate: reports both a thread and an advisory when both are outstanding", () => {
|
|
70
89
|
const r = evaluateConvergeGate({
|
|
71
90
|
unresolvedThreadCount: 1,
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
suppressedAdvisories: [
|
|
92
|
+
{ key: "x.ts#a", label: "x.ts:10" },
|
|
93
|
+
{ key: "y.ts#b", label: "y.ts:20" },
|
|
94
|
+
],
|
|
95
|
+
acknowledgedKeys: ["x.ts#a"],
|
|
74
96
|
});
|
|
75
97
|
assertEquals(r.convergeBlocked, true);
|
|
76
98
|
assertStringIncludes(r.convergeBlockReason, "1 unresolved review thread");
|
|
@@ -95,9 +117,19 @@ const SAMPLE_REVIEW_BODY = [
|
|
|
95
117
|
"</details>",
|
|
96
118
|
].join("\n");
|
|
97
119
|
|
|
98
|
-
test("parseSuppressedAdvisories: extracts
|
|
99
|
-
const
|
|
100
|
-
assertEquals(
|
|
120
|
+
test("parseSuppressedAdvisories: extracts advisories inside the Suppressed comments block", () => {
|
|
121
|
+
const advisories = parseSuppressedAdvisories(SAMPLE_REVIEW_BODY);
|
|
122
|
+
assertEquals(
|
|
123
|
+
advisories.map((a) => a.label),
|
|
124
|
+
["spec-app/nano-app.schema.json:613", "server/src/main.rs:42"],
|
|
125
|
+
);
|
|
126
|
+
// The line-stable key is `<path>#<fingerprint>` of the prose; `label` remains the human `path:line`.
|
|
127
|
+
assertEquals(advisories[0].label, "spec-app/nano-app.schema.json:613");
|
|
128
|
+
assertEquals(
|
|
129
|
+
advisories[0].key,
|
|
130
|
+
advisoryStableKey("spec-app/nano-app.schema.json", "The description could be clearer about the loopback default."),
|
|
131
|
+
);
|
|
132
|
+
assert(advisories[0].key.startsWith("spec-app/nano-app.schema.json#"), "stable key is path#fingerprint");
|
|
101
133
|
});
|
|
102
134
|
|
|
103
135
|
test("parseSuppressedAdvisories: returns [] when there is no suppressed block", () => {
|
|
@@ -108,12 +140,198 @@ test("parseSuppressedAdvisories: returns [] when there is no suppressed block",
|
|
|
108
140
|
|
|
109
141
|
test("parseAckedAdvisories: only RESOLVED threads carrying a nano-ack marker count", () => {
|
|
110
142
|
const threads: ReviewThread[] = [
|
|
111
|
-
{
|
|
112
|
-
|
|
143
|
+
{
|
|
144
|
+
isResolved: true,
|
|
145
|
+
path: "a.ts",
|
|
146
|
+
bodies: ["Fixed. nano-ack: spec-app/nano-app.schema.json :: Clarify the loopback default."],
|
|
147
|
+
},
|
|
148
|
+
{ isResolved: false, path: "b.ts", bodies: ["nano-ack: server/src/main.rs :: Narrow this type."] }, // open -> ignored
|
|
113
149
|
{ isResolved: true, path: "c.ts", bodies: ["unrelated resolved comment"] },
|
|
114
150
|
];
|
|
115
151
|
const acked = parseAckedAdvisories(threads);
|
|
116
|
-
assertEquals(acked, ["spec-app/nano-app.schema.json
|
|
152
|
+
assertEquals(acked, [advisoryStableKey("spec-app/nano-app.schema.json", "Clarify the loopback default.")]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// A resolved bare `<path>:<line>` ack (the retired legacy form) must NOT count: it carries no
|
|
156
|
+
// advisory prose, so it cannot identify WHICH advisory it acknowledged. Honouring it would false-OPEN
|
|
157
|
+
// a genuinely new advisory re-emitted at that same line (issue #787's re-review finding).
|
|
158
|
+
test("parseAckedAdvisories: a bare legacy `<path>:<line>` marker is NOT honoured (no prose-blind ack)", () => {
|
|
159
|
+
const threads: ReviewThread[] = [
|
|
160
|
+
{ isResolved: true, path: "a.ts", bodies: ["Fixed. nano-ack: spec-app/nano-app.schema.json:613"] },
|
|
161
|
+
];
|
|
162
|
+
assertEquals(parseAckedAdvisories(threads), []);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("parseAckedAdvisories: the new `<path> :: <text>` form yields the line-stable key", () => {
|
|
166
|
+
const threads: ReviewThread[] = [
|
|
167
|
+
{ isResolved: true, path: "a.ts", bodies: ["Declined. nano-ack: server/src/main.rs :: Consider narrowing this type."] },
|
|
168
|
+
];
|
|
169
|
+
const acked = parseAckedAdvisories(threads);
|
|
170
|
+
assertEquals(acked, [advisoryStableKey("server/src/main.rs", "Consider narrowing this type.")]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// A valid GitHub path can contain spaces; the ack form must not reject it (regression for the
|
|
174
|
+
// critical review finding — `\S+`/`[^\s]` path groups silently dropped a spaced-path ack, so the
|
|
175
|
+
// gate could never observe the acknowledgement and escalated forever).
|
|
176
|
+
test("parseAckedAdvisories: a path containing spaces is honoured in the new `<path> :: <text>` form", () => {
|
|
177
|
+
const newForm: ReviewThread[] = [
|
|
178
|
+
{ isResolved: true, path: "d.md", bodies: ["Applied. nano-ack: docs/my file.md :: Clarify the loopback default."] },
|
|
179
|
+
];
|
|
180
|
+
assertEquals(parseAckedAdvisories(newForm), [advisoryStableKey("docs/my file.md", "Clarify the loopback default.")]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// A valid GitHub path can itself contain `::` (e.g. `src/a::b.ts`); the ` :: ` separator must be
|
|
184
|
+
// whitespace-delimited so a bare `::` inside the path is not mistaken for the delimiter (regression
|
|
185
|
+
// for the suppressed finding: `\s*::\s*` split `src/a::b.ts :: text` at the wrong `::`, mangling the
|
|
186
|
+
// path and producing a key that could never match the advisory).
|
|
187
|
+
// The `<path> :: <text>` separator is whitespace-delimited, so a path containing a bare `::` is not
|
|
188
|
+
// split at the interior `::` (a legacy `<path>:<line>` interior-colon test was retired with the
|
|
189
|
+
// prose-blind legacy form).
|
|
190
|
+
test("parseAckedAdvisories: a path containing `::` is not split at the interior `::`", () => {
|
|
191
|
+
const threads: ReviewThread[] = [
|
|
192
|
+
{ isResolved: true, path: "a.ts", bodies: ["Applied. nano-ack: src/a::b.ts :: Narrow the return type here."] },
|
|
193
|
+
];
|
|
194
|
+
assertEquals(parseAckedAdvisories(threads), [advisoryStableKey("src/a::b.ts", "Narrow the return type here.")]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Normalization must preserve word boundaries, punctuation, and Unicode so distinct advisories on
|
|
198
|
+
// one path do not alias to the same key (regression for the suppressed findings: deleting every
|
|
199
|
+
// separator made `foo-bar`/`foobar` collide and non-ASCII-only prose normalized to an empty key;
|
|
200
|
+
// collapsing punctuation-into-space aliased `Use foo() here` with `Use foo here` — a false-OPEN).
|
|
201
|
+
test("advisoryStableKey: word boundaries, punctuation and Unicode are preserved (distinct prose -> distinct keys)", () => {
|
|
202
|
+
const p = "app/x.ts";
|
|
203
|
+
assertNotEquals(advisoryStableKey(p, "foo-bar"), advisoryStableKey(p, "foobar"));
|
|
204
|
+
// Two different non-ASCII-only advisories must not both collapse to the empty-string key.
|
|
205
|
+
assertNotEquals(advisoryStableKey(p, "café"), advisoryStableKey(p, "naïve"));
|
|
206
|
+
// Punctuation must NOT collapse into whitespace: prose differing only by punctuation-vs-space
|
|
207
|
+
// stays distinct, so a resolved ack for one cannot silently acknowledge the other (false-OPEN).
|
|
208
|
+
assertNotEquals(advisoryStableKey(p, "Use foo() here"), advisoryStableKey(p, "Use foo here"));
|
|
209
|
+
assertNotEquals(advisoryStableKey(p, "foo/bar"), advisoryStableKey(p, "foo bar"));
|
|
210
|
+
// Only case and whitespace runs are normalized (verbatim copy modulo reflow -> same key).
|
|
211
|
+
assertEquals(advisoryStableKey(p, "Foo bar, baz."), advisoryStableKey(p, "foo bar, baz."));
|
|
212
|
+
// NFC (not NFKC): compatibility variants must stay DISTINCT, or acking one false-OPENs the other.
|
|
213
|
+
// Full-width `!` vs ASCII `!` (NFKC would fold them together); ligature `fi` vs `fi`.
|
|
214
|
+
assertNotEquals(advisoryStableKey(p, "Use foo\uFF01"), advisoryStableKey(p, "Use foo!"));
|
|
215
|
+
assertNotEquals(advisoryStableKey(p, "The \uFB01le"), advisoryStableKey(p, "The file"));
|
|
216
|
+
// A precomposed vs decomposed accent IS canonically equivalent (NFC unifies) -> same key.
|
|
217
|
+
assertEquals(advisoryStableKey(p, "caf\u00E9"), advisoryStableKey(p, "cafe\u0301"));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// The advisory side strips a leading markdown bullet (Copilot renders suppressed prose as `* …`),
|
|
221
|
+
// and the prompt tells the agent to copy that first line VERBATIM — so an ack marker legitimately
|
|
222
|
+
// carries the `* ` bullet. Keying must therefore be bullet-insensitive on BOTH sides, else the ack
|
|
223
|
+
// key never matches the advisory key and the gate livelocks (fail-CLOSED). Regression for the
|
|
224
|
+
// suppressed finding that `parseSuppressedAdvisories` stripped the bullet but the ack path did not.
|
|
225
|
+
test("advisoryStableKey: a leading markdown bullet is stripped so bulleted ack text matches", () => {
|
|
226
|
+
const p = "app/x.ts";
|
|
227
|
+
assertEquals(advisoryStableKey(p, "* Consider narrowing this type."), advisoryStableKey(p, "Consider narrowing this type."));
|
|
228
|
+
assertEquals(advisoryStableKey(p, "- Consider narrowing this type."), advisoryStableKey(p, "Consider narrowing this type."));
|
|
229
|
+
// But a leading `-`/`*` with NO trailing whitespace is NOT a bullet: it is preserved, so distinct
|
|
230
|
+
// first-line prose keeps a distinct key (else `-foo` false-acks `foo`). Regression for the finding
|
|
231
|
+
// that the greedy `[-*]\s*` stripped a non-bullet leading punctuation char.
|
|
232
|
+
assertNotEquals(advisoryStableKey(p, "-foo"), advisoryStableKey(p, "foo"));
|
|
233
|
+
assertNotEquals(advisoryStableKey(p, "*foo"), advisoryStableKey(p, "foo"));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// End-to-end: an ack whose marker copies Copilot's bulleted first line verbatim acknowledges the
|
|
237
|
+
// advisory parsed from that same rendered bullet (the ack key == the parsed advisory key).
|
|
238
|
+
test("parseAckedAdvisories: an ack copying the rendered `* ` bullet verbatim matches the advisory key", () => {
|
|
239
|
+
const advisories = parseSuppressedAdvisories(SAMPLE_REVIEW_BODY);
|
|
240
|
+
const bulleted = advisories.map((a) => a.label); // ["…schema.json:613", "…main.rs:42"]
|
|
241
|
+
assert(bulleted.length === 2);
|
|
242
|
+
const threads: ReviewThread[] = [
|
|
243
|
+
{
|
|
244
|
+
isResolved: true,
|
|
245
|
+
path: "a.ts",
|
|
246
|
+
// Verbatim copy of Copilot's rendered first line, bullet included.
|
|
247
|
+
bodies: ["Declined. nano-ack: server/src/main.rs :: - Consider narrowing this type."],
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
const acked = parseAckedAdvisories(threads);
|
|
251
|
+
const mainRs = advisories.find((a) => a.path === "server/src/main.rs");
|
|
252
|
+
assert(mainRs !== undefined);
|
|
253
|
+
assert(acked.includes(mainRs.key), "bulleted verbatim ack resolves to the advisory's stable key");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// A collision in the advisory fingerprint would let a NEWER, unacknowledged advisory on the same
|
|
257
|
+
// path pass the gate on a resolved ack for a DIFFERENT advisory — a false-OPEN. The former 32-bit
|
|
258
|
+
// FNV-1a digest was cheaply collidable; the key now carries a 128-bit (32-hex) SHA-256 slice.
|
|
259
|
+
test("advisoryStableKey: digest is a collision-resistant 128-bit (32-hex) SHA-256 slice", () => {
|
|
260
|
+
const p = "app/x.ts";
|
|
261
|
+
const hash = advisoryStableKey(p, "Some advisory prose.").split("#")[1];
|
|
262
|
+
assertEquals(hash.length, 32, "digest is 32 hex chars (128 bits)");
|
|
263
|
+
assert(/^[0-9a-f]{32}$/.test(hash), "digest is lowercase hex");
|
|
264
|
+
// Distinct prose on the same path yields distinct keys (no cheap collision surface).
|
|
265
|
+
assertNotEquals(
|
|
266
|
+
advisoryStableKey(p, "Narrow this return type."),
|
|
267
|
+
advisoryStableKey(p, "Guard against a null argument here."),
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── Issue #787: a DECLINED advisory must not livelock the gate when its line drifts ──────────
|
|
272
|
+
//
|
|
273
|
+
// A declined advisory is re-emitted by Copilot every round; any unrelated edit shifts its line, so
|
|
274
|
+
// Copilot re-anchors it to a new line. Keying the ack on the line-stable prose fingerprint (not
|
|
275
|
+
// path:line) keeps a prior-round ack matching the re-emitted advisory across the drift.
|
|
276
|
+
test("converge gate #787: a stable-key ack survives a line drift and keeps the advisory acknowledged", () => {
|
|
277
|
+
const proseText = "Consider narrowing this type.";
|
|
278
|
+
// Round 1: Copilot suppressed the advisory at line 360; the agent acked it with the new form.
|
|
279
|
+
const round1Body = [
|
|
280
|
+
"<details>",
|
|
281
|
+
"<summary>Suppressed comments (1)</summary>",
|
|
282
|
+
"",
|
|
283
|
+
"**app/deliveryRunner.ts:360**",
|
|
284
|
+
`- ${proseText}`,
|
|
285
|
+
"</details>",
|
|
286
|
+
].join("\n");
|
|
287
|
+
// Round 2: an unrelated edit shifted the SAME advisory to line 369; Copilot re-emitted it there.
|
|
288
|
+
const round2Body = round1Body.replace("app/deliveryRunner.ts:360", "app/deliveryRunner.ts:369");
|
|
289
|
+
// The resolved ack thread from round 1 persists (its marker text is line-independent).
|
|
290
|
+
const ackThreads: ReviewThread[] = [
|
|
291
|
+
{ isResolved: true, path: "app/deliveryRunner.ts", bodies: [`Declined, false positive. nano-ack: app/deliveryRunner.ts :: ${proseText}`] },
|
|
292
|
+
];
|
|
293
|
+
const acknowledgedKeys = parseAckedAdvisories(ackThreads);
|
|
294
|
+
|
|
295
|
+
const round1 = evaluateConvergeGate({
|
|
296
|
+
unresolvedThreadCount: 0,
|
|
297
|
+
suppressedAdvisories: parseSuppressedAdvisories(round1Body),
|
|
298
|
+
acknowledgedKeys,
|
|
299
|
+
});
|
|
300
|
+
assertEquals(round1.convergeBlocked, false);
|
|
301
|
+
|
|
302
|
+
// The drift MUST NOT re-block: the round-1 ack still acknowledges the round-2 re-emission.
|
|
303
|
+
const round2 = evaluateConvergeGate({
|
|
304
|
+
unresolvedThreadCount: 0,
|
|
305
|
+
suppressedAdvisories: parseSuppressedAdvisories(round2Body),
|
|
306
|
+
acknowledgedKeys,
|
|
307
|
+
});
|
|
308
|
+
assertEquals(round2.convergeBlocked, false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("converge gate #787: a genuinely new, never-acked advisory still blocks (no false-open)", () => {
|
|
312
|
+
const body = [
|
|
313
|
+
"<details>",
|
|
314
|
+
"<summary>Suppressed comments (2)</summary>",
|
|
315
|
+
"",
|
|
316
|
+
"**app/x.ts:10**",
|
|
317
|
+
"- The declined advisory that was acknowledged.",
|
|
318
|
+
"",
|
|
319
|
+
"**app/x.ts:20**", // SAME path, DIFFERENT advisory — never acknowledged.
|
|
320
|
+
"- A brand-new concern that was never triaged.",
|
|
321
|
+
"</details>",
|
|
322
|
+
].join("\n");
|
|
323
|
+
const ackThreads: ReviewThread[] = [
|
|
324
|
+
{ isResolved: true, path: "app/x.ts", bodies: ["Declined. nano-ack: app/x.ts :: The declined advisory that was acknowledged."] },
|
|
325
|
+
];
|
|
326
|
+
const r = evaluateConvergeGate({
|
|
327
|
+
unresolvedThreadCount: 0,
|
|
328
|
+
suppressedAdvisories: parseSuppressedAdvisories(body),
|
|
329
|
+
acknowledgedKeys: parseAckedAdvisories(ackThreads),
|
|
330
|
+
});
|
|
331
|
+
assertEquals(r.convergeBlocked, true);
|
|
332
|
+
// Only the un-acked advisory on the same path is reported; the acked one is not.
|
|
333
|
+
assertStringIncludes(r.convergeBlockReason, "app/x.ts:20");
|
|
334
|
+
assert(!r.convergeBlockReason.includes("app/x.ts:10"), "the acknowledged advisory must not be listed");
|
|
117
335
|
});
|
|
118
336
|
|
|
119
337
|
test("parseReviewThreadsPage: maps nodes and reports a complete (final) page", () => {
|
|
@@ -254,8 +472,18 @@ test("converge-gate: an unacknowledged suppressed advisory blocks convergence",
|
|
|
254
472
|
test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed", async () => {
|
|
255
473
|
const handler = await makeUnderTest({
|
|
256
474
|
readThreads: async () => [
|
|
257
|
-
{
|
|
258
|
-
|
|
475
|
+
{
|
|
476
|
+
isResolved: true,
|
|
477
|
+
path: "spec-app/nano-app.schema.json",
|
|
478
|
+
bodies: [
|
|
479
|
+
"Applied. nano-ack: spec-app/nano-app.schema.json :: The description could be clearer about the loopback default.",
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
isResolved: true,
|
|
484
|
+
path: "server/src/main.rs",
|
|
485
|
+
bodies: ["Declined, false positive. nano-ack: server/src/main.rs :: Consider narrowing this type."],
|
|
486
|
+
},
|
|
259
487
|
],
|
|
260
488
|
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
261
489
|
});
|
package/app/convergeGate.ts
CHANGED
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
// the comment unaddressed). This deterministic gate runs on the converged path and blocks handoff
|
|
7
7
|
// while either:
|
|
8
8
|
// • any review THREAD is still unresolved, or
|
|
9
|
-
// • any SUPPRESSED advisory (
|
|
10
|
-
// RESOLVED ack thread (a thread carrying a `nano-ack: <path
|
|
9
|
+
// • any SUPPRESSED advisory (in the latest Copilot review body) lacks a matching
|
|
10
|
+
// RESOLVED ack thread (a thread carrying a line-stable `nano-ack: <path> :: <text>` marker; the
|
|
11
|
+
// bare `nano-ack: <path>:<line>` form is NOT honoured — its `path:line` key is prose-blind and
|
|
12
|
+
// would false-OPEN a new advisory re-emitted at a previously-acked line).
|
|
11
13
|
// A blocked gate escalates to the human wait-answer task (recoverable), never a hard wedge.
|
|
12
14
|
|
|
13
15
|
export interface ConvergeGateInput {
|
|
14
16
|
/** Count of review threads with `isResolved === false`. */
|
|
15
17
|
unresolvedThreadCount: number;
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
/**
|
|
18
|
+
/** Copilot's suppressed advisories (latest review body), each with its line-stable key + label. */
|
|
19
|
+
suppressedAdvisories: { key: string; label: string }[];
|
|
20
|
+
/** Acknowledged line-stable keys (`<path>#<fp>`) from RESOLVED `nano-ack:` threads. An advisory is
|
|
21
|
+
* acked iff its stable key appears here. */
|
|
19
22
|
acknowledgedKeys: string[];
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -28,7 +31,11 @@ export interface ConvergeGateResult {
|
|
|
28
31
|
* feeds it live GitHub state and fails CLOSED (blocks) when that state cannot be read. */
|
|
29
32
|
export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResult {
|
|
30
33
|
const acked = new Set(input.acknowledgedKeys);
|
|
31
|
-
|
|
34
|
+
// An advisory is acknowledged iff its line-stable prose key was acked — the stable key survives a
|
|
35
|
+
// line drift across rounds (issue #787). The prose-blind legacy `path:line` key is intentionally
|
|
36
|
+
// NOT consulted: it would let a resolved ack for one advisory silently acknowledge a genuinely new
|
|
37
|
+
// advisory re-emitted at the same line (a false-OPEN this gate exists to prevent).
|
|
38
|
+
const unacked = input.suppressedAdvisories.filter((a) => !acked.has(a.key));
|
|
32
39
|
const reasons: string[] = [];
|
|
33
40
|
if (input.unresolvedThreadCount > 0) {
|
|
34
41
|
const n = input.unresolvedThreadCount;
|
|
@@ -36,13 +43,13 @@ export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResu
|
|
|
36
43
|
}
|
|
37
44
|
if (unacked.length > 0) {
|
|
38
45
|
const noun = unacked.length === 1 ? "advisory" : "advisories";
|
|
39
|
-
reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.join(", ")})`);
|
|
46
|
+
reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.map((a) => a.label).join(", ")})`);
|
|
40
47
|
}
|
|
41
48
|
if (reasons.length === 0) {
|
|
42
49
|
return { convergeBlocked: false, convergeBlockReason: "" };
|
|
43
50
|
}
|
|
44
51
|
return {
|
|
45
52
|
convergeBlocked: true,
|
|
46
|
-
convergeBlockReason: `Convergence blocked: ${reasons.join("; ")}. Resolve every review thread and reply-and-resolve an ack thread (nano-ack: <path
|
|
53
|
+
convergeBlockReason: `Convergence blocked: ${reasons.join("; ")}. Resolve every review thread and reply-and-resolve an ack thread (nano-ack: <path> :: <verbatim advisory text>) for each suppressed advisory before converging.`,
|
|
47
54
|
};
|
|
48
55
|
}
|
|
@@ -1057,15 +1057,19 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
|
|
|
1057
1057
|
|
|
1058
1058
|
/** Render the DECLARED per-node repository-spec marker task header (#739) for an `agent` node — a single
|
|
1059
1059
|
* `<zeebe:taskHeaders>` block carrying {@link AGENT_REPO_SPEC_HEADER} with a compact JSON of the node's
|
|
1060
|
-
* DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY
|
|
1061
|
-
* task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the
|
|
1062
|
-
* single, uniform anchor to replace with the effective envelope on every cell.
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
*
|
|
1060
|
+
* `id` and its DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY
|
|
1061
|
+
* agent service task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the
|
|
1062
|
+
* runner has a single, uniform anchor to replace with the effective envelope on every cell. The node
|
|
1063
|
+
* `id` is carried here (issue #776) so the runner can emit the deterministic per-node `feat/<node.id>`
|
|
1064
|
+
* `branch.create` on the injected envelope — the identity is not otherwise recoverable from the isolated
|
|
1065
|
+
* `<zeebe:taskHeaders>` block the runner rewrites. Digest-stable and env-free — only the node id and
|
|
1066
|
+
* declared values (pure graph content) appear here; the run-level fallback and the env-dependent
|
|
1067
|
+
* `cloneTimeoutMs` are injected by the runner POST-digest. The id + declared values pass the
|
|
1068
|
+
* `owner/repo` + node-id/branch-name allowlists (validator/OpenAPI), so the JSON carries no XML-hostile
|
|
1069
|
+
* chars. */
|
|
1066
1070
|
function agentRepoSpecHeaderLines(node: Extract<DeliveryNode, { kind: "agent" }>): string[] {
|
|
1067
1071
|
const trimOrNull = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
|
|
1068
|
-
const spec = JSON.stringify({ repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
|
|
1072
|
+
const spec = JSON.stringify({ nodeId: node.id, repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
|
|
1069
1073
|
return [
|
|
1070
1074
|
" <zeebe:taskHeaders>",
|
|
1071
1075
|
` <zeebe:header key="${AGENT_REPO_SPEC_HEADER}" ${attr("value", spec)} />`,
|
|
@@ -13,7 +13,7 @@ import { test } from "node:test";
|
|
|
13
13
|
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
14
14
|
import { AGENT_TERMINAL_SUCCESS_STATUSES } from "./deliveryGraphCompiler.ts";
|
|
15
15
|
import { prepareDeliveryGraph, renderEmitContract, renderIdempotencyPreamble, renderProducerContract, runDeliveryGraph } from "./deliveryRunner.ts";
|
|
16
|
-
import { RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
|
|
16
|
+
import { RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError, agentNodeRepoEnvelope } from "./repoEnvelope.ts";
|
|
17
17
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
18
18
|
|
|
19
19
|
const GRAPH: DeliveryGraph = {
|
|
@@ -488,6 +488,9 @@ test("prepareDeliveryGraph injects the repository envelope PER agent cell from t
|
|
|
488
488
|
// Repo-provisioning auth gate (issue #770): the per-cell envelope carries `task.allowPr:true` so the
|
|
489
489
|
// c8ctl harness resolves the git credential for the clone instead of failing on password prompt.
|
|
490
490
|
assert(headers.some((h) => h.includes('task.allowPr" value="true"')), `expected a task.allowPr header, got ${JSON.stringify(headers)}`);
|
|
491
|
+
// The deterministic per-node isolation branch (issue #776): the harness cuts `feat/<node.id>` itself,
|
|
492
|
+
// so the agent can never be left committing on the base branch (a non-ff push that strands the run).
|
|
493
|
+
assert(headers.some((h) => h.includes('repository.branch.create" value="feat/open-b"')), `expected a per-node branch.create header, got ${JSON.stringify(headers)}`);
|
|
491
494
|
// No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
|
|
492
495
|
assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
|
|
493
496
|
// No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
|
|
@@ -542,6 +545,68 @@ test("a declared repository WITHOUT a base branch omits ref/baseRef — the harn
|
|
|
542
545
|
assert(headers.some((h) => h.includes('repository.url" value="https://github.com/acme/one.git"')), "url present");
|
|
543
546
|
assert(!headers.some((h) => h.includes("repository.ref")), "no ref → clone the repo default branch");
|
|
544
547
|
assert(!headers.some((h) => h.includes("repository.baseRef")), "no baseRef either");
|
|
548
|
+
// Even without a known base, the deterministic isolation branch is still emitted (issue #776): the
|
|
549
|
+
// harness cuts `feat/<node.id>` off the cloned default tip, so the agent never commits on the default.
|
|
550
|
+
assert(headers.some((h) => h.includes('repository.branch.create" value="feat/a"')), `expected branch.create even without a base, got ${JSON.stringify(headers)}`);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("each agent cell gets its OWN deterministic feat/<node.id> branch.create — never a shared branch (#776)", async () => {
|
|
554
|
+
const graph: DeliveryGraph = {
|
|
555
|
+
name: "deterministic isolation branches",
|
|
556
|
+
nodes: [
|
|
557
|
+
{ id: "impl-a", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } },
|
|
558
|
+
{ id: "impl-b", kind: "agent", agent: { jobType: "senior:feature", prompt: "b", repository: "acme/two" } },
|
|
559
|
+
],
|
|
560
|
+
edges: [{ from: "impl-a", to: "impl-b" }],
|
|
561
|
+
};
|
|
562
|
+
const p = await prepareOk(graph, {});
|
|
563
|
+
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("repository.branch.create"));
|
|
564
|
+
// Two distinct cells → two distinct per-node branches, so no two agents can collide on one branch.
|
|
565
|
+
assert(branches.some((h) => h.includes('value="feat/impl-a"')), `expected feat/impl-a, got ${JSON.stringify(branches)}`);
|
|
566
|
+
assert(branches.some((h) => h.includes('value="feat/impl-b"')), `expected feat/impl-b, got ${JSON.stringify(branches)}`);
|
|
567
|
+
assertEquals(branches.length, 2, "exactly one branch.create per agent cell");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("an explicit repoless run emits NO branch.create — no envelope at all (#776/#729)", async () => {
|
|
571
|
+
const p = await prepareOk(GRAPH, { repoless: true });
|
|
572
|
+
assert(!p.bpmn.includes("repository.branch.create"), "a repoless run carries no isolation branch either");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("a node id that derives an invalid git ref degrades to NO branch.create — never emits an unusable feat/... (#776)", async () => {
|
|
576
|
+
// Node ids are only constrained by `^[A-Za-z_][A-Za-z0-9_.-]*$`, laxer than git's ref rules: `a..b` and
|
|
577
|
+
// `a.lock` pass id validation but produce ill-formed `feat/...` refs the harness cannot create. The
|
|
578
|
+
// runner must degrade to the pre-#776 agent-cuts-its-own-branch behaviour (no branch.create) for those,
|
|
579
|
+
// not emit a branch the harness will choke on — while a sibling with a valid id still gets its branch.
|
|
580
|
+
const graph: DeliveryGraph = {
|
|
581
|
+
name: "invalid-ref node ids degrade",
|
|
582
|
+
nodes: [
|
|
583
|
+
{ id: "a..b", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } },
|
|
584
|
+
{ id: "a.lock", kind: "agent", agent: { jobType: "senior:feature", prompt: "b", repository: "acme/two" } },
|
|
585
|
+
{ id: "ok", kind: "agent", agent: { jobType: "senior:feature", prompt: "c", repository: "acme/three" } },
|
|
586
|
+
],
|
|
587
|
+
edges: [{ from: "a..b", to: "a.lock" }, { from: "a.lock", to: "ok" }],
|
|
588
|
+
};
|
|
589
|
+
const p = await prepareOk(graph, {});
|
|
590
|
+
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("repository.branch.create"));
|
|
591
|
+
// Only the valid-id cell keeps a branch.create; the two invalid-ref ids emit none (their agents still
|
|
592
|
+
// provision an isolated clone, they just cut their own branch inside it — the pre-#776 fallback).
|
|
593
|
+
assertEquals(branches.length, 1, `only the valid id keeps a branch.create, got ${JSON.stringify(branches)}`);
|
|
594
|
+
assert(branches[0].includes('value="feat/ok"'), `expected feat/ok, got ${JSON.stringify(branches)}`);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("agentNodeRepoEnvelope emits branch.create off the base when known, off the default when not, and omits it when blank (#776)", () => {
|
|
598
|
+
const withBase = (agentNodeRepoEnvelope("acme/one", "main", "feat/n1") as any)["io.nanobpm.agentTask"].repository;
|
|
599
|
+
assertEquals(withBase.ref, "main", "known base → checked-out ref");
|
|
600
|
+
assertEquals(withBase.branch.create, "feat/n1", "the harness cuts feat/<node.id> off the base");
|
|
601
|
+
const noBase = (agentNodeRepoEnvelope("acme/one", null, "feat/n2") as any)["io.nanobpm.agentTask"].repository;
|
|
602
|
+
assertEquals("ref" in noBase, false, "no base → clone the default branch");
|
|
603
|
+
assertEquals(noBase.branch.create, "feat/n2", "branch.create is still emitted off the default tip");
|
|
604
|
+
// A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key (pre-#776 behaviour).
|
|
605
|
+
assertEquals((agentNodeRepoEnvelope("acme/one", "main", " feat/n3 ") as any)["io.nanobpm.agentTask"].repository.branch.create, "feat/n3");
|
|
606
|
+
for (const blank of [null, undefined, "", " "]) {
|
|
607
|
+
const r = (agentNodeRepoEnvelope("acme/one", "main", blank as any) as any)["io.nanobpm.agentTask"].repository;
|
|
608
|
+
assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
|
|
609
|
+
}
|
|
545
610
|
});
|
|
546
611
|
|
|
547
612
|
test("prepareDeliveryGraph seeds NO envelope ONLY on an EXPLICIT repoless run — the conscious opt-out (#729)", async () => {
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
18
18
|
import type { EngineClient } from "@nanobpm/urban";
|
|
19
19
|
import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
|
|
20
20
|
import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
|
|
21
|
+
import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
21
22
|
import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
|
|
22
23
|
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
|
|
23
24
|
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
|
|
@@ -80,9 +81,10 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
|
|
|
80
81
|
repository?: string | null;
|
|
81
82
|
/** OPTIONAL run-level base-branch DEFAULT for `agent` nodes that declare a `repository` but no
|
|
82
83
|
* `baseBranch` — the `ref` the harness checks out in the isolated clone (the PRE-PR shape: no PR head
|
|
83
|
-
* exists yet
|
|
84
|
-
*
|
|
85
|
-
*
|
|
84
|
+
* exists yet). Per issue #776 the harness itself cuts the deterministic `feat/<node.id>` branch off
|
|
85
|
+
* this base (emitted as `branch.create`) so a forgetful agent can never be left committing on the base
|
|
86
|
+
* branch; a node with a resolvable repository but no base clones the repo's default branch, so this is
|
|
87
|
+
* a convenience default, not a hard requirement. */
|
|
86
88
|
baseBranch?: string | null;
|
|
87
89
|
/** EXPLICIT opt-out of repository provisioning (issue #729). `true` → the run is dispatched with NO
|
|
88
90
|
* isolation envelope on ANY node (the per-node headers are stripped, the legacy launch-dir behaviour),
|
|
@@ -345,13 +347,28 @@ function injectAgentRepoEnvelopes(bpmn: string, graph: DeliveryGraph, options: D
|
|
|
345
347
|
);
|
|
346
348
|
return bpmn.replace(blockRe, (_full, indent: string, sq: string | undefined, dq: string | undefined, tail: string) => {
|
|
347
349
|
const raw = sq ?? (dq ?? "").replace(/'/g, "'").replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
348
|
-
const declared: { repository: string | null; baseBranch: string | null } = JSON.parse(raw);
|
|
350
|
+
const declared: { nodeId?: string; repository: string | null; baseBranch: string | null } = JSON.parse(raw);
|
|
349
351
|
// `repoless` → strip the block entirely (no isolation envelope, the launch-dir fallback).
|
|
350
352
|
if (repoless) return "";
|
|
351
353
|
const effRepo = trimOrNull(declared.repository) ?? runRepo;
|
|
352
354
|
const effBase = trimOrNull(declared.baseBranch) ?? runBase;
|
|
355
|
+
// The deterministic per-node isolation branch (issue #776): the harness cuts `feat/<node.id>` itself
|
|
356
|
+
// so a cell can never leave its agent committing on the checked-out base branch (a non-ff push that
|
|
357
|
+
// strands the run — merlin job 20974). The node id rides the marker so it survives into this
|
|
358
|
+
// per-block rewrite; delivery-graph agent cells are single-instance, so the static per-node branch
|
|
359
|
+
// never collides across siblings. Omitted when the marker predates the id (a blank id degrades to the
|
|
360
|
+
// pre-#776 agent-cuts-its-own-branch behaviour rather than an ill-formed `feat/`).
|
|
361
|
+
// A graph node id is only constrained by `^[A-Za-z_][A-Za-z0-9_.-]*$` (deliveryGraph.ts), which is
|
|
362
|
+
// laxer than git's ref rules: ids like `a..b`, `a.`, or `a.lock` pass id validation yet produce an
|
|
363
|
+
// ill-formed `feat/...` ref the harness cannot create — turning the isolation guarantee into a
|
|
364
|
+
// launch/checkout failure. Validate the DERIVED branch with the same `isPlausibleBranchName` gate the
|
|
365
|
+
// dispatch doors use and degrade to null (the pre-#776 agent-cuts-its-own-branch behaviour) rather
|
|
366
|
+
// than emit an unusable branch.create.
|
|
367
|
+
const nodeId = trimOrNull(declared.nodeId);
|
|
368
|
+
const derivedBranch = nodeId ? `feat/${nodeId}` : null;
|
|
369
|
+
const branchCreate = derivedBranch !== null && isPlausibleBranchName(derivedBranch) ? derivedBranch : null;
|
|
353
370
|
// The unresolved invariant above guarantees a resolvable repo here on a non-repoless run.
|
|
354
|
-
const envelope = agentNodeRepoEnvelope(effRepo ?? "", effBase);
|
|
371
|
+
const envelope = agentNodeRepoEnvelope(effRepo ?? "", effBase, branchCreate);
|
|
355
372
|
const flat = flattenAgentTaskEnvelope(envelope);
|
|
356
373
|
const headerLines = Object.entries(flat).map(([k, v]) => `${indent} <zeebe:header key="${xmlAttr(k)}" value="${xmlAttr(v)}" />`);
|
|
357
374
|
if (headerLines.length === 0) return "";
|
package/app/github.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
|
|
12
12
|
// Cross-runtime: runs under Node (`node:child_process`).
|
|
13
13
|
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
14
16
|
// Type-only import (erased at runtime, so no runtime cycle with mergeProtocol.ts, which imports
|
|
15
17
|
// `fetchRepoFile` from here): `classifyMergeability` reads a repo's declared required checks to gate
|
|
16
18
|
// a merge independently of GitHub branch protection.
|
|
@@ -90,9 +92,23 @@ export async function fetchPrReviews(
|
|
|
90
92
|
// • SUPPRESSED / low-confidence advisories — Copilot folds these into the review BODY under a
|
|
91
93
|
// "Suppressed comments (N)" block; they are NOT threads, cannot be resolved, and are re-listed
|
|
92
94
|
// every round. To make "acknowledged" trackable, the review-round agent must post a RESOLVED
|
|
93
|
-
// review thread carrying a `nano-ack
|
|
94
|
-
//
|
|
95
|
-
//
|
|
95
|
+
// review thread carrying a `nano-ack:` marker for each advisory it applies or declines. The gate
|
|
96
|
+
// then treats an advisory as addressed iff a resolved thread carries a matching ack marker.
|
|
97
|
+
//
|
|
98
|
+
// The ack key must be LINE-STABLE. Keying it on `path:line` (issue #787) livelocks a DECLINED
|
|
99
|
+
// advisory: Copilot re-emits a declined advisory every round, but any unrelated edit in the PR
|
|
100
|
+
// shifts its line, so Copilot re-anchors it to a new line. A prior-round `nano-ack: path:OLD` no
|
|
101
|
+
// longer matches the re-emitted `path:NEW`, the gate sees a freshly "unacknowledged" advisory, and
|
|
102
|
+
// escalates to a human every round. The fix keys acknowledgement on a line-independent identity —
|
|
103
|
+
// `<path>#<fingerprint>` of the advisory's PROSE — so a drifted line still matches. The resolved
|
|
104
|
+
// ack thread is itself the durable store: its marker text survives across rounds regardless of the
|
|
105
|
+
// line, so a decline stays acknowledged without the agent re-acking each round. The new marker is
|
|
106
|
+
// `nano-ack: <path> :: <verbatim advisory text>`. This line-stable prose key is the SOLE ack
|
|
107
|
+
// identity. A bare `nano-ack: <path>:<line>` form is NOT honoured: keyed only on `path:line`, it is
|
|
108
|
+
// blind to the advisory's prose, so a resolved legacy ack for advisory A at a line would silently
|
|
109
|
+
// acknowledge a genuinely NEW advisory B re-emitted at that same line — a false-OPEN this gate exists
|
|
110
|
+
// to prevent. (Issue #787 introduces the ack mechanism itself in this change, so there is no pre-#787
|
|
111
|
+
// legacy-ack corpus to protect by honouring the prose-blind form.)
|
|
96
112
|
|
|
97
113
|
/** One PR review thread, narrowed to what the convergence gate needs. */
|
|
98
114
|
export interface ReviewThread {
|
|
@@ -101,30 +117,141 @@ export interface ReviewThread {
|
|
|
101
117
|
bodies: string[];
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
/**
|
|
105
|
-
*
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
/** A suppressed / low-confidence Copilot advisory parsed out of a review body. Its `key` is the
|
|
121
|
+
* line-stable identity (survives a line drift); `label` is the human-facing `path:line` shown in
|
|
122
|
+
* block reasons. */
|
|
123
|
+
export interface SuppressedAdvisory {
|
|
124
|
+
path: string;
|
|
125
|
+
line: number;
|
|
126
|
+
/** The advisory prose (first non-empty line after the header), used for the stable fingerprint. */
|
|
127
|
+
text: string;
|
|
128
|
+
/** Line-stable identity: `<path>#<fingerprint>` of the normalized prose. Survives line drift. */
|
|
129
|
+
key: string;
|
|
130
|
+
/** Human-facing `path:line` label for block-reason messages. */
|
|
131
|
+
label: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Any `nano-ack:` marker — captures the rest of the marker's line (path + optional `:: text`). */
|
|
135
|
+
const ACK_MARKER = /nano-ack:\s*([^\n\r]+)/gi;
|
|
136
|
+
/** The ONLY honoured ack form: line-stable `<path> :: <advisory text>`. The delimiter is ` :: ` with
|
|
137
|
+
* REQUIRED surrounding whitespace (matching the canonical marker the agent authors), so a bare `::`
|
|
138
|
+
* inside a valid GitHub path (e.g. `src/a::b.ts`) is NOT mistaken for the separator — the path group
|
|
139
|
+
* parses non-greedily up to the first *whitespace-delimited* ` :: `, so a path containing spaces
|
|
140
|
+
* (e.g. `docs/my file.md`) is still honoured. A bare `<path>:<line>` marker is intentionally not
|
|
141
|
+
* parsed: keyed only on `path:line`, it is blind to the advisory prose and would false-OPEN a new
|
|
142
|
+
* advisory re-emitted at a previously-acked line. */
|
|
143
|
+
const NEW_ACK = /^(.+?)\s+::\s+(.+)$/s;
|
|
144
|
+
|
|
145
|
+
/** Normalize advisory prose to a line-/format-independent form before fingerprinting: strip a
|
|
146
|
+
* leading markdown bullet, NFC-normalize, lowercase, and collapse runs of WHITESPACE to a single
|
|
147
|
+
* space. Punctuation is PRESERVED, NOT collapsed: the prompt requires the agent to copy the
|
|
148
|
+
* advisory's first line VERBATIM, so whitespace/case tolerance is all that is needed to absorb
|
|
149
|
+
* trivial markdown/whitespace reflow. Collapsing every non-word run into a space (as an earlier
|
|
150
|
+
* revision did) instead ALIASES genuinely-distinct advisories whose prose differs only by
|
|
151
|
+
* punctuation-vs-space — e.g. `Use foo() here` vs `Use foo here`, or `foo/bar` vs `foo bar` — so a
|
|
152
|
+
* resolved ack for advisory A would silently acknowledge a DIFFERENT advisory B that normalizes to
|
|
153
|
+
* the same key: a false-OPEN this gate exists to prevent. Preserving punctuation errs toward a
|
|
154
|
+
* stricter match, which is fail-CLOSED: a benign punctuation mismatch merely re-escalates to a
|
|
155
|
+
* human, and never converges an unacknowledged advisory.
|
|
156
|
+
*
|
|
157
|
+
* NFC — canonical composition — is used deliberately in preference to NFKC. NFKC additionally folds
|
|
158
|
+
* COMPATIBILITY variants (full-width `!` → ASCII `!`, ligatures, super/subscripts, …), which would
|
|
159
|
+
* ALIAS genuinely-distinct advisories such as `Use foo!` and `Use foo!` to one key — the very
|
|
160
|
+
* false-OPEN this fingerprint exists to prevent, and a contradiction with "punctuation is
|
|
161
|
+
* preserved". NFC only unifies sequences that are canonically equivalent (visually and semantically
|
|
162
|
+
* identical, e.g. a precomposed `é` vs `e`+combining-acute), so verbatim copies still match while
|
|
163
|
+
* distinct compatibility forms stay distinct (fail-CLOSED). Unicode letters/digits are preserved
|
|
164
|
+
* rather than stripped, so non-ASCII-only prose still yields a non-empty, distinct key.
|
|
165
|
+
*
|
|
166
|
+
* The leading-bullet strip keeps the ADVISORY side (Copilot renders suppressed prose as `* …`, which
|
|
167
|
+
* `parseSuppressedAdvisories` also strips for display) and the ACK side SYMMETRIC: the prompt tells
|
|
168
|
+
* the agent to copy the advisory's first line verbatim, so an ack marker legitimately carries the
|
|
169
|
+
* `* ` bullet — without stripping it here the ack key would differ from the advisory key and the
|
|
170
|
+
* gate would never converge (fail-CLOSED livelock). Applying it in this shared canonicaliser is the
|
|
171
|
+
* SINGLE source of truth for both sides.
|
|
172
|
+
*
|
|
173
|
+
* The bullet marker REQUIRES trailing whitespace (`[-*]\s+`): a genuine markdown bullet is always
|
|
174
|
+
* `- ` / `* ` followed by a space, so `-foo` / `*foo` (leading punctuation, no separator) is NOT a
|
|
175
|
+
* bullet and its leading char is PRESERVED. A greedy `\s*` there would strip the `-`/`*` off such
|
|
176
|
+
* prose too, collapsing distinct first lines like `-foo` and `foo` to one key — a false-ACK
|
|
177
|
+
* (false-OPEN) where acking one silently satisfies the other. */
|
|
178
|
+
function normalizeAdvisoryText(text: string): string {
|
|
179
|
+
return text
|
|
180
|
+
.normalize("NFC")
|
|
181
|
+
.replace(/^\s*[-*]\s+/u, "")
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.replace(/\s+/gu, " ")
|
|
184
|
+
.trim();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** COLLISION-RESISTANT fingerprint of a string → 32-hex-char (128-bit) digest, the leading half of
|
|
188
|
+
* a SHA-256 hash. Deterministic and dependency-free (Node's built-in `node:crypto`, no npm dep).
|
|
189
|
+
*
|
|
190
|
+
* The gate treats an advisory whose key `<path>#<fingerprint>` matches a resolved ack as addressed,
|
|
191
|
+
* so a *collision* would let a NEWER, unacknowledged advisory on the same path pass without its own
|
|
192
|
+
* ack — a false-OPEN that violates the gate's no-false-open guarantee. The former 32-bit FNV-1a
|
|
193
|
+
* digest was cheap to collide (birthday bound ~2^16); a 128-bit SHA-256 slice makes an accidental
|
|
194
|
+
* collision (~2^-64 for realistic advisory counts) infeasible. The digest is INTERNAL to the key —
|
|
195
|
+
* it never appears in a human-authored `nano-ack:` marker (those carry the verbatim prose, which is
|
|
196
|
+
* re-fingerprinted at read time), so widening it neither lengthens any marker nor breaks a
|
|
197
|
+
* previously-issued one: both the advisory side and the ack side recompute with this same function. */
|
|
198
|
+
function fingerprint(s: string): string {
|
|
199
|
+
return createHash("sha256").update(s, "utf8").digest("hex").slice(0, 32);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The line-stable acknowledgement key for an advisory: `<path>#<fingerprint(normalized prose)>`.
|
|
203
|
+
* Exported so the review-round agent's contract and tests share one canonical implementation. */
|
|
204
|
+
export function advisoryStableKey(path: string, text: string): string {
|
|
205
|
+
return `${path.trim()}#${fingerprint(normalizeAdvisoryText(text))}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Parse Copilot's suppressed / low-confidence advisories out of a review body. Copilot renders them
|
|
209
|
+
* under a `<summary>Suppressed comments (N)</summary>` block, each as a bold `**path:line**` header
|
|
210
|
+
* followed by the advisory prose. Returns de-duplicated advisories (empty when there is no block). */
|
|
211
|
+
export function parseSuppressedAdvisories(reviewBody: string | null | undefined): SuppressedAdvisory[] {
|
|
112
212
|
const body = reviewBody ?? "";
|
|
113
213
|
const idx = body.search(/Suppressed comments\s*\(/i);
|
|
114
214
|
if (idx < 0) return [];
|
|
115
215
|
// Scan only from the "Suppressed comments" marker onward so a `**path:line**` elsewhere in the
|
|
116
216
|
// overview prose can never be mistaken for an advisory.
|
|
117
217
|
const region = body.slice(idx);
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
218
|
+
const lines = region.split(/\r?\n/);
|
|
219
|
+
const headerRe = /\*\*([^*]+?):(\d+)\*\*/;
|
|
220
|
+
const out: SuppressedAdvisory[] = [];
|
|
221
|
+
const seen = new Set<string>();
|
|
222
|
+
for (let i = 0; i < lines.length; i++) {
|
|
223
|
+
const h = headerRe.exec(lines[i]);
|
|
224
|
+
if (!h) continue;
|
|
225
|
+
const path = h[1].trim();
|
|
226
|
+
const line = Number(h[2]);
|
|
227
|
+
const label = `${path}:${line}`;
|
|
228
|
+
// The advisory prose is the first non-empty line after the header (up to the next header). A
|
|
229
|
+
// single bullet is the common shape; strip a leading markdown bullet marker for the display
|
|
230
|
+
// `text`. (Keying is bullet-insensitive regardless: `normalizeAdvisoryText` strips a leading
|
|
231
|
+
// bullet too, so the ack side — which copies the bulleted first line verbatim — keys the same.)
|
|
232
|
+
let text = "";
|
|
233
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
234
|
+
if (headerRe.test(lines[j])) break;
|
|
235
|
+
const t = lines[j].replace(/^\s*[-*]\s+/, "").trim();
|
|
236
|
+
if (t) {
|
|
237
|
+
text = t;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const key = advisoryStableKey(path, text);
|
|
242
|
+
if (seen.has(key)) continue;
|
|
243
|
+
seen.add(key);
|
|
244
|
+
out.push({ path, line, text, key, label });
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
124
247
|
}
|
|
125
248
|
|
|
126
249
|
/** Extract the acknowledged advisory keys from a set of review threads (only RESOLVED threads
|
|
127
|
-
* count — an open ack thread is not yet an acknowledgement).
|
|
250
|
+
* count — an open ack thread is not yet an acknowledgement). Returns line-stable keys (`<path>#<fp>`)
|
|
251
|
+
* parsed from the `nano-ack: <path> :: <text>` form ONLY. A bare `nano-ack: <path>:<line>` marker is
|
|
252
|
+
* intentionally NOT honoured: its `path:line` key is blind to the advisory prose and would false-OPEN
|
|
253
|
+
* a genuinely new advisory re-emitted at a previously-acked line. The gate treats an advisory as
|
|
254
|
+
* acked iff its stable key appears here. */
|
|
128
255
|
export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
|
|
129
256
|
const acked = new Set<string>();
|
|
130
257
|
for (const t of threads) {
|
|
@@ -133,7 +260,10 @@ export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
|
|
|
133
260
|
ACK_MARKER.lastIndex = 0;
|
|
134
261
|
let m: RegExpExecArray | null;
|
|
135
262
|
// biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
|
|
136
|
-
while ((m = ACK_MARKER.exec(body)) !== null)
|
|
263
|
+
while ((m = ACK_MARKER.exec(body)) !== null) {
|
|
264
|
+
const nw = NEW_ACK.exec(m[1].trim());
|
|
265
|
+
if (nw) acked.add(advisoryStableKey(nw[1], nw[2]));
|
|
266
|
+
}
|
|
137
267
|
}
|
|
138
268
|
}
|
|
139
269
|
return [...acked];
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -211,13 +211,26 @@ export function repoEnvelopeVars(
|
|
|
211
211
|
* `ref`): a cross-repo graph node carries only its `owner/repo` (an issue ref names no branch), so when
|
|
212
212
|
* neither the node nor the run declares a base the cell must still provision an isolated clone — of the
|
|
213
213
|
* repository's DEFAULT branch. The harness `provisionRepo` omits `--branch` when `ref` is blank, so a
|
|
214
|
-
* `ref`-less envelope clones the default branch
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
|
|
214
|
+
* `ref`-less envelope clones the default branch. Returns `{}` for a `repo` that is not a plain
|
|
215
|
+
* `owner/repo` (an unresolved cell — the runner's invariant rejects the run before this degrades to a
|
|
216
|
+
* launch-dir share). Blobless single-branch shaping + `cloneTimeoutMs` (issues #287/#694) are emitted on
|
|
217
|
+
* every cell.
|
|
218
|
+
*
|
|
219
|
+
* Deterministic isolation branch (issue #776): `branchCreate` (the per-node `feat/<node.id>`) is emitted
|
|
220
|
+
* as `branch.create` so the harness cuts the isolation branch ITSELF — mirroring the single-feature path
|
|
221
|
+
* (`repoEnvelopeVars`/`feature.ts`) — instead of delegating branch-cutting to agent prompt discipline. A
|
|
222
|
+
* forgetful agent that never branched used to commit on the checked-out BASE branch; its `git push
|
|
223
|
+
* origin <base>` was then rejected non-fast-forward and the whole run's work was stranded (merlin job
|
|
224
|
+
* 20974; harness counterpart jwulf/c8ctl-plugin-nano#231). Emitting `branch.create` makes it impossible
|
|
225
|
+
* for a cell to leave its agent committing on the base. Emitted independently of `ref`: when the base is
|
|
226
|
+
* unknown the harness cuts the branch off the cloned default tip; when known, off `ref`. Omitted for a
|
|
227
|
+
* blank `branchCreate` (falls back to the pre-#776 agent-cuts-its-own-branch behaviour). Delivery-graph
|
|
228
|
+
* agent cells are single-instance, so a static per-node `feat/<node.id>` never collides across siblings
|
|
229
|
+
* (the MI-child collision the issue flags does not arise in the current single-instance cells). */
|
|
230
|
+
export function agentNodeRepoEnvelope(repo: string, base: string | null, branchCreate: string | null = null): Record<string, unknown> {
|
|
219
231
|
if (!isPlainOwnerRepo(repo)) return {};
|
|
220
232
|
const ref = typeof base === "string" && base.trim() !== "" ? base.trim() : null;
|
|
233
|
+
const create = typeof branchCreate === "string" && branchCreate.trim() !== "" ? branchCreate.trim() : null;
|
|
221
234
|
return {
|
|
222
235
|
[AGENT_TASK_NS]: {
|
|
223
236
|
repository: {
|
|
@@ -226,9 +239,12 @@ export function agentNodeRepoEnvelope(repo: string, base: string | null): Record
|
|
|
226
239
|
singleBranch: true,
|
|
227
240
|
filter: "blob:none",
|
|
228
241
|
cloneTimeoutMs: cloneTimeoutMs(),
|
|
229
|
-
// A per-node base (declared, or defaulted from the run level) — the branch the
|
|
242
|
+
// A per-node base (declared, or defaulted from the run level) — the branch the harness cuts its
|
|
230
243
|
// `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
|
|
231
244
|
...(ref ? { ref, baseRef: ref } : {}),
|
|
245
|
+
// The deterministic isolation branch the harness cuts (issue #776), so the agent can never be
|
|
246
|
+
// left committing on the checked-out base branch. Off `ref` when known, else off the default tip.
|
|
247
|
+
...(create ? { branch: { create } } : {}),
|
|
232
248
|
},
|
|
233
249
|
// Repo-provisioning auth gate (issue #770): as in `repoEnvelopeVars`, a repo-backed cell must
|
|
234
250
|
// opt the harness into git-credential resolution (`task.allowPr`) or the clone fails with
|
package/openapi.yaml
CHANGED
|
@@ -2112,8 +2112,9 @@ components:
|
|
|
2112
2112
|
pattern: '^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$'
|
|
2113
2113
|
description: >-
|
|
2114
2114
|
OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the
|
|
2115
|
-
harness checks out in the isolated clone (the pre-PR shape: the
|
|
2116
|
-
`feat/<node.id>` branch off this base
|
|
2115
|
+
harness checks out in the isolated clone (the pre-PR shape: the harness cuts the
|
|
2116
|
+
deterministic `feat/<node.id>` branch off this base itself, per #776, so a forgetful
|
|
2117
|
+
agent can never be left committing on the base branch). Absent → the run-level dispatch `baseBranch`
|
|
2117
2118
|
(else the node's repository default branch). A value that is not a plausible git
|
|
2118
2119
|
branch name is rejected at submit; the pattern mirrors the authoritative server-side
|
|
2119
2120
|
gate (`isPlausibleBranchName`, app/baseBranch.ts).
|
|
@@ -5005,7 +5006,7 @@ paths:
|
|
|
5005
5006
|
type: string
|
|
5006
5007
|
maxLength: 255
|
|
5007
5008
|
pattern: ^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$
|
|
5008
|
-
description: "OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the harness checks out in the isolated clone (the pre-PR shape: the
|
|
5009
|
+
description: "OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the harness checks out in the isolated clone (the pre-PR shape: the harness cuts the deterministic `feat/<node.id>` branch off this base itself, per #776, so a forgetful agent can never be left committing on the base branch). Absent → the run-level dispatch `baseBranch` (else the node's repository default branch). A value that is not a plausible git branch name is rejected at submit; the pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`, app/baseBranch.ts)."
|
|
5009
5010
|
converge:
|
|
5010
5011
|
type: boolean
|
|
5011
5012
|
description: 'OPTIONAL first-class CONVERGE policy (ADR 0006 §3 / S5) — a DECLARED, compiler- validated completion-policy flag on this cell node. It declares that the node''s opened PR is to be driven through the review-convergence loop to green as an edge-gated completion policy; this slice adds and validates the flag, with the delivery-graph execution wiring that consumes it landing in a follow-up slice. It supersedes (in intent) the emergent `feature.bpmn` `gw-converge` gateway and the "un-draft + merge #B" prompt prose a delivery-graph `agent` node used to smuggle. Converge and merge are SEPARABLE phases; a node may converge without merging (stop at green and gate the landing behind a downstream node).'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.187.
|
|
3
|
+
"version": "0.187.2",
|
|
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",
|
|
@@ -119,19 +119,43 @@ Because several agents may run on the same host at once:
|
|
|
119
119
|
carries a **resolved** acknowledgement, so a decision you only wrote into your
|
|
120
120
|
`summary` is invisible to the gate. For each suppressed advisory you applied or
|
|
121
121
|
declined (step 2), post a **new review comment thread** whose body contains the
|
|
122
|
-
verbatim marker **`nano-ack: <path
|
|
123
|
-
bold `**<path>:<line>**` header
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
verbatim marker **`nano-ack: <path> :: <advisory text>`** — the `<path>` from
|
|
123
|
+
Copilot's bold `**<path>:<line>**` header and `<advisory text>` copied
|
|
124
|
+
**verbatim** from the first line of that advisory's prose — then **resolve** that
|
|
125
|
+
thread. The gate keys the acknowledgement on the advisory **text** (a
|
|
126
|
+
line-independent fingerprint of `<path> + <advisory text>`), *not* on the line
|
|
127
|
+
number: this is deliberate. A **declined** advisory is re-emitted every round,
|
|
128
|
+
and any unrelated edit you make shifts its line, so Copilot re-anchors it to a
|
|
129
|
+
new line — a line-based ack would go stale and the gate would escalate to a human
|
|
130
|
+
every round (issue #787). Because the ack is keyed on the prose, a decline you
|
|
131
|
+
made in an earlier round stays acknowledged across the drift and you need **not**
|
|
132
|
+
re-ack it. The ack thread may sit on any valid diff line. Example:
|
|
126
133
|
|
|
127
134
|
```sh
|
|
128
135
|
# Post the ack thread (pick any changed line in the diff for path/line). Use the PR's real HEAD
|
|
129
136
|
# SHA as commit_id — `git rev-parse HEAD` can drift from the PR head; ask GitHub:
|
|
130
137
|
CID=$(gh api repos/OWNER/REPO/pulls/PR --jq .head.sha)
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
# Build the body via a QUOTED heredoc so the verbatim advisory prose is never re-interpreted by
|
|
139
|
+
# the shell — a single-quoted `-f body='...'` breaks the moment the prose contains a `'` (e.g.
|
|
140
|
+
# "doesn't handle ..."), and a double-quoted one breaks on `$`/backticks. `<<'EOF'` (quoted
|
|
141
|
+
# delimiter) disables ALL expansion, so any advisory text is safe:
|
|
142
|
+
BODY=$(cat <<'EOF'
|
|
143
|
+
Applied. nano-ack: <path> :: <verbatim advisory text>
|
|
144
|
+
EOF
|
|
145
|
+
) # to DECLINE instead, build the body the same quoted-heredoc way (never a single-quoted
|
|
146
|
+
# `-f body='...'`, which breaks the moment the reason or advisory prose contains a `'`):
|
|
147
|
+
# BODY=$(cat <<'EOF'
|
|
148
|
+
# Declined, false positive — <reason>. nano-ack: <path> :: <verbatim advisory text>
|
|
149
|
+
# EOF
|
|
150
|
+
# )
|
|
151
|
+
gh api repos/OWNER/REPO/pulls/PR/comments -f commit_id="$CID" -f path="PATH" -F line=LINE -f side=RIGHT -f body="$BODY"
|
|
133
152
|
# Then resolve it exactly like any other thread (map its databaseId -> thread node id -> resolveReviewThread).
|
|
134
153
|
```
|
|
154
|
+
Only the `nano-ack: <path> :: <text>` (prose-keyed) form is honoured. A bare
|
|
155
|
+
`nano-ack: <path>:<line>` marker is **not** an acknowledgement: keyed only on
|
|
156
|
+
`path:line`, it is blind to the advisory's prose, so it would let a resolved ack
|
|
157
|
+
for one advisory silently acknowledge a genuinely new advisory re-emitted at that
|
|
158
|
+
same line. Always use the `<path> :: <text>` form.
|
|
135
159
|
6. **Do NOT request, re-request, or remove the reviewer yourself.** Keeping
|
|
136
160
|
Copilot attached is the **process's** job: a deterministic poller ensures a
|
|
137
161
|
Copilot review is requested (idempotently) whenever this PR is waiting, and it
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
// comments:
|
|
9
9
|
// • any review THREAD is still unresolved (GraphQL `isResolved = false`), or
|
|
10
10
|
// • any SUPPRESSED advisory in the latest Copilot review body lacks a matching RESOLVED ack
|
|
11
|
-
// thread (a `nano-ack: <path
|
|
11
|
+
// thread (a `nano-ack: <path> :: <verbatim advisory text>` marker whose line-stable prose
|
|
12
|
+
// fingerprint matches Copilot's advisory). The bare legacy `nano-ack: <path>:<line>` form is
|
|
13
|
+
// NOT honoured: keyed only on `path:line`, it is blind to the advisory prose and would let a
|
|
14
|
+
// resolved ack for one advisory silently acknowledge a genuinely new advisory re-emitted at
|
|
15
|
+
// that same line (a false-OPEN). Only the prose-keyed `<path> :: <text>` form acknowledges.
|
|
12
16
|
// A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
|
|
13
17
|
// the human `wait-answer` escalation (recoverable), never a hard wedge.
|
|
14
18
|
//
|
|
@@ -87,9 +91,10 @@ export function makeHandler(deps: {
|
|
|
87
91
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
88
92
|
}
|
|
89
93
|
const unresolvedThreadCount = threads.filter((t) => !t.isResolved).length;
|
|
94
|
+
const advisories = parseSuppressedAdvisories(reviewBody);
|
|
90
95
|
result = evaluateConvergeGate({
|
|
91
96
|
unresolvedThreadCount,
|
|
92
|
-
|
|
97
|
+
suppressedAdvisories: advisories.map((a) => ({ key: a.key, label: a.label })),
|
|
93
98
|
acknowledgedKeys: parseAckedAdvisories(threads),
|
|
94
99
|
});
|
|
95
100
|
} catch {
|