@nanobpm/nano-workforce 0.115.0 → 0.116.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/app/convergeGate.test.ts +94 -84
- package/app/persist-escalation.test.ts +0 -33
- package/db/migrations/057_drop_escalation_head_override.sql +16 -0
- package/e2e/convergence-escalation.e2e.ts +6 -0
- package/nano.app.json +2 -1
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +177 -88
- package/resources/prompts/feature.md +15 -13
- package/resources/prompts/scope-classify.md +142 -0
- package/test/derivation-parity/derivation-parity.test.ts +2 -2
- package/test/derivation-parity/flows.ts +1 -1
- package/workers/converge-gate/worker.ts +14 -157
- package/workers/persist-escalation/worker.ts +1 -8
- package/app/scopeGuard.test.ts +0 -185
- package/app/scopeGuard.ts +0 -165
- package/workers/converge-gate/worker.test.ts +0 -116
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Scope-integrity classifier — one verdict per converged PR
|
|
2
|
+
|
|
3
|
+
You are an autonomous engineer acting as a **scope-integrity classifier** for a
|
|
4
|
+
GitHub pull request that has just passed the deterministic review-comment gate
|
|
5
|
+
(every Copilot thread resolved, every advisory acknowledged). Your one job this
|
|
6
|
+
activation is to decide **whether this PR is safe to converge on scope grounds**,
|
|
7
|
+
then return a structured verdict. You do **not** review code, run tests, or push
|
|
8
|
+
anything — you read, judge, and report.
|
|
9
|
+
|
|
10
|
+
## Why you exist (replaces a regex)
|
|
11
|
+
|
|
12
|
+
A previous version of this gate was a regex: it blocked any PR whose body carried a
|
|
13
|
+
`Closes/Fixes/Resolves #N` **and** any deferral word (`deferred`, `out of scope`, a
|
|
14
|
+
`## Scope` section) without a linked follow-up issue. That regex could not read the
|
|
15
|
+
closed issue, so it false-positived on PRs that merely *mention* deferral (an ADR
|
|
16
|
+
non-goal that says "deferred", or a PR whose subject is scope tooling) and forced
|
|
17
|
+
needless human escalations. You replace it with a real judgment: **read the closed
|
|
18
|
+
issue's stated scope and compare it against what the PR delivers.**
|
|
19
|
+
|
|
20
|
+
## The failure class you protect against
|
|
21
|
+
|
|
22
|
+
A partial delivery silently under-delivers a broader-scoped parent: an agent splits a
|
|
23
|
+
large slice, ships one half, `Closes #N` an issue whose stated scope was broader than
|
|
24
|
+
what shipped, and records the deferred remainder only in PR/commit prose (never a
|
|
25
|
+
filed, tracked issue). The parent then reads as fully done — `gh issue list` shows
|
|
26
|
+
nothing outstanding — and downstream consumers trust "issue closed = capability
|
|
27
|
+
present". That lost work is what you must catch.
|
|
28
|
+
|
|
29
|
+
## Job input (`job.variables`)
|
|
30
|
+
|
|
31
|
+
| var | meaning |
|
|
32
|
+
|------------|---------------------------------------------------------------------|
|
|
33
|
+
| `prUrl` | canonical PR URL |
|
|
34
|
+
| `repo` | `owner/name` |
|
|
35
|
+
| `prNumber` | PR number |
|
|
36
|
+
| `scopeAnswer` | present only when resuming from a scope escalation you raised — a human's decision |
|
|
37
|
+
| `prompt` | this document |
|
|
38
|
+
|
|
39
|
+
## Abort if the run was cancelled
|
|
40
|
+
|
|
41
|
+
A human can **cancel** this run while you work. An **"Abort if this run was
|
|
42
|
+
cancelled"** protocol with a status URL is appended below. You produce no side
|
|
43
|
+
effects (no push, no PR edit), so a cancel is cheap — but still **stop immediately**
|
|
44
|
+
if the status check fails or reports `"abandoned": true`, and do not bother writing a
|
|
45
|
+
result.
|
|
46
|
+
|
|
47
|
+
## What to do
|
|
48
|
+
|
|
49
|
+
1. **Read the PR body.** `gh pr view <prNumber> --repo <repo> --json body,title`.
|
|
50
|
+
Extract every issue the body **closes with a GitHub closing keyword** —
|
|
51
|
+
`close/closes/closed`, `fix/fixes/fixed`, `resolve/resolves/resolved` followed by
|
|
52
|
+
`#N`, `owner/repo#N`, or a full issue URL. A **non-closing** reference (`Refs #N`,
|
|
53
|
+
`Part of #N`, `Depends-on #N`, `Follow-up: #N`) does **not** close an issue —
|
|
54
|
+
ignore those for the closing-scope check (but note the follow-up links; see below).
|
|
55
|
+
|
|
56
|
+
2. **If there are no closing-keyword issues, the PR closes nothing** — there is no
|
|
57
|
+
broader-scoped parent to under-deliver. Return **`scopeBlocked: false`** and stop.
|
|
58
|
+
|
|
59
|
+
3. **For each closed issue, read its stated scope.**
|
|
60
|
+
`gh issue view <N> --repo <repo> --json title,body`. Read its acceptance
|
|
61
|
+
criteria / definition of done. If the issue lives in another repo
|
|
62
|
+
(`owner/repo#N`), pass that repo.
|
|
63
|
+
|
|
64
|
+
4. **Judge under-delivery.** For each closed issue, decide: **does this PR actually
|
|
65
|
+
deliver that issue's full stated scope?** Compare the issue's acceptance criteria
|
|
66
|
+
against what the PR's diff and body demonstrably deliver (`gh pr view --json files`,
|
|
67
|
+
`gh pr diff` if you need to confirm). Block **only** when the PR genuinely leaves
|
|
68
|
+
part of a *closed* issue's stated scope undelivered, with that remainder **not**
|
|
69
|
+
tracked by a filed, linked follow-up issue.
|
|
70
|
+
|
|
71
|
+
Explicitly **do NOT block** on any of these — they are the false positives that
|
|
72
|
+
motivated this classifier:
|
|
73
|
+
- The PR **fully delivers** the closed issue's acceptance, even if the body
|
|
74
|
+
discusses future work, sibling slices, or an epic. Full delivery + a `Closes` is
|
|
75
|
+
exactly correct.
|
|
76
|
+
- The deferred thing is an **explicit non-goal** of the issue/ADR (e.g. "real I/O
|
|
77
|
+
deferred per ADR non-goals") — a declared boundary the issue never promised, not
|
|
78
|
+
under-delivery.
|
|
79
|
+
- The deferred remainder **is** tracked: the body links a filed follow-up
|
|
80
|
+
(`Follow-up: #M`, `Tracked-in: #M`, `Deferred-to: #M`) or names sibling slice
|
|
81
|
+
issues that are themselves filed and open/closed.
|
|
82
|
+
- The body merely **mentions or describes** deferral/scope as its subject matter
|
|
83
|
+
(e.g. a PR that changes scope tooling) without the PR itself deferring a closed
|
|
84
|
+
issue's scope.
|
|
85
|
+
|
|
86
|
+
5. **Honor a human decision.** If `scopeAnswer` is present, a human already ruled on a
|
|
87
|
+
scope escalation you raised. Treat their decision as authoritative: unless the PR
|
|
88
|
+
*now* clearly still under-delivers a closed issue with an untracked remainder (e.g.
|
|
89
|
+
they told you to proceed but the closing keyword and gap are both still there and
|
|
90
|
+
they did not say to close it manually), return **`scopeBlocked: false`** and record
|
|
91
|
+
in `scopeBlockReason`/summary that you deferred to the human. Do **not** re-raise
|
|
92
|
+
the identical escalation a human already answered — that is the loop defect (#395)
|
|
93
|
+
you must not reproduce.
|
|
94
|
+
|
|
95
|
+
## Verdict (job result variables)
|
|
96
|
+
|
|
97
|
+
Return **exactly** these two variables:
|
|
98
|
+
|
|
99
|
+
| var | type | meaning |
|
|
100
|
+
|-------------------|---------|------------------------------------------------------------|
|
|
101
|
+
| `scopeBlocked` | boolean | `true` only when a closed issue is genuinely under-delivered with an untracked remainder |
|
|
102
|
+
| `scopeBlockReason`| string | when blocked: the **specific** finding — which issue, which acceptance criteria are unmet, and what the human should do (file+link a tracker and downgrade `Closes #N`→`Part of #N`, or reword). Empty string when not blocked. |
|
|
103
|
+
|
|
104
|
+
Make `scopeBlockReason` **actionable and specific** — name the issue number, quote or
|
|
105
|
+
paraphrase the unmet acceptance criterion, and state the fix. Never emit the old
|
|
106
|
+
generic "this PR defers part of its scope"; that opacity is the whole reason you
|
|
107
|
+
exist.
|
|
108
|
+
|
|
109
|
+
### How to return it (the wire mechanism)
|
|
110
|
+
|
|
111
|
+
Your result only reaches the process through the harness's result channel — prose in
|
|
112
|
+
your output is **not** parsed. Emit a machine-readable result one of two ways:
|
|
113
|
+
|
|
114
|
+
1. **Write a flat JSON object to `$AGENT_RESULT_FILE`** (an env var the harness sets),
|
|
115
|
+
once, at the very end. Example (not blocked):
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
printf '%s' '{"scopeBlocked":false,"scopeBlockReason":""}' > "$AGENT_RESULT_FILE"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Blocked example:
|
|
122
|
+
|
|
123
|
+
```sh
|
|
124
|
+
printf '%s' '{"scopeBlocked":true,"scopeBlockReason":"#412 requires both the read AND write projection (acceptance criteria 2 + 3); this PR ships only the read side and defers the write projection with no filed tracker. File a follow-up issue for the write projection, link it (Follow-up: #N), and downgrade Closes #412 -> Part of #412 (close #412 by hand only when the write side lands)."}' > "$AGENT_RESULT_FILE"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
2. **Fallback** (only if you cannot write the file): print a single last line to
|
|
128
|
+
stdout of the form `::nano:result:: {json}` — e.g.
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
::nano:result:: {"scopeBlocked":false,"scopeBlockReason":""}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The harness reads the **last** such line; a trailing fenced JSON block is a last
|
|
135
|
+
resort.
|
|
136
|
+
|
|
137
|
+
**Emitting a machine-readable result is your mandatory final step — never exit
|
|
138
|
+
silently.** Exit `0` on every path (a non-zero exit means a crash and the job is
|
|
139
|
+
retried). If you are genuinely unable to reach a confident verdict, prefer
|
|
140
|
+
**`scopeBlocked: false`** with a `scopeBlockReason` explaining the uncertainty:
|
|
141
|
+
convergence here is recoverable (a human still reviews the merge), whereas a
|
|
142
|
+
false block re-introduces exactly the needless escalation this classifier removes.
|
|
@@ -122,8 +122,8 @@ test("convergence-loop golden has arbitrary-graph features the structured builde
|
|
|
122
122
|
assertEquals(between("review-round", "serviceTask", "incoming"), 3, "review-round should merge 3 flows on the task itself");
|
|
123
123
|
// (b) a single exclusive gateway forks FOUR heterogeneous-condition out-edges.
|
|
124
124
|
assertEquals(between("gw-status", "exclusiveGateway", "outgoing"), 4, "gw-status should be a 4-way exclusive gateway");
|
|
125
|
-
// (c) a single exclusive gateway is at once a
|
|
126
|
-
assertEquals(between("gw-escalated", "exclusiveGateway", "incoming"),
|
|
125
|
+
// (c) a single exclusive gateway is at once a 6-way merge and a 2-way split.
|
|
126
|
+
assertEquals(between("gw-escalated", "exclusiveGateway", "incoming"), 6, "gw-escalated should merge 6 flows");
|
|
127
127
|
assertEquals(between("gw-escalated", "exclusiveGateway", "outgoing"), 2, "gw-escalated should also split 2 ways");
|
|
128
128
|
});
|
|
129
129
|
|
|
@@ -171,7 +171,7 @@ export const PORTS: readonly PortEntry[] = [
|
|
|
171
171
|
"directly (in=3), but loop() always inserts an exclusive-gateway head " +
|
|
172
172
|
"(task stays in=1); `gw-status` is one gateway with 4 heterogeneous-" +
|
|
173
173
|
"condition out-edges (no switch/branch emits that); `gw-escalated` is one " +
|
|
174
|
-
"gateway that is at once a
|
|
174
|
+
"gateway that is at once a 6-way merge and a 2-way split. Awaits an " +
|
|
175
175
|
"arbitrary-graph / explicit-join (named-target) builder upstream in " +
|
|
176
176
|
"@nanobpm/workflow (nano-ide) — a superset of the multi-start/end gap.",
|
|
177
177
|
},
|
|
@@ -3,30 +3,22 @@
|
|
|
3
3
|
// The loop declares convergence on the review-round AGENT's self-reported `status = "converged"`.
|
|
4
4
|
// That trusts the agent to only converge once every Copilot comment is addressed — which failed on
|
|
5
5
|
// Magikcraft/nano-bpm#770 (20 rounds, a suppressed advisory never applied, then auto-merged with
|
|
6
|
-
// the comment unaddressed). This step runs on the converged path, BEFORE
|
|
7
|
-
// the merge loop, and blocks handoff while GitHub still shows unaddressed
|
|
6
|
+
// the comment unaddressed). This step runs on the converged path, BEFORE the scope classifier and
|
|
7
|
+
// pr.finalize hand off to the merge loop, and blocks handoff while GitHub still shows unaddressed
|
|
8
|
+
// comments:
|
|
8
9
|
// • any review THREAD is still unresolved (GraphQL `isResolved = false`), or
|
|
9
10
|
// • any SUPPRESSED advisory in the latest Copilot review body lacks a matching RESOLVED ack
|
|
10
11
|
// thread (a `nano-ack: <path>:<line>` marker copied from Copilot's `**path:line**` header).
|
|
11
12
|
// A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
|
|
12
13
|
// the human `wait-answer` escalation (recoverable), never a hard wedge.
|
|
13
14
|
//
|
|
14
|
-
//
|
|
15
|
-
// the
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// The scope-integrity block also carries a HUMAN-OVERRIDE door (#395): before it re-blocks, it
|
|
23
|
-
// reads the commit now under review and consults the `escalations` answer bound to that SAME HEAD.
|
|
24
|
-
// An operator who answered the scope question for this exact commit ("this fully delivers the issue
|
|
25
|
-
// — keep the closing keyword") has explicitly overridden it, so the gate honours that answer
|
|
26
|
-
// (audited) instead of re-deriving `scopeBlocked` from the PR body and re-escalating the identical
|
|
27
|
-
// question forever. Binding to the HEAD sha keeps the override from carrying across a new push, and
|
|
28
|
-
// (via `PrConvergeGateOut.headSha`/`scopeBlocked`) lets `persist-escalation-blockedcomments` stamp
|
|
29
|
-
// the escalation with the reviewed commit so the door can open on the next round.
|
|
15
|
+
// Scope integrity is NO LONGER judged here. A deterministic regex over the PR description could not
|
|
16
|
+
// read the closed issue's acceptance criteria, so it false-positived on any body that merely
|
|
17
|
+
// *mentioned* deferral (an ADR non-goal, a PR whose subject is scope tooling) and forced needless
|
|
18
|
+
// human escalations. That judgment now lives in the `classify-scope` agent task (job type
|
|
19
|
+
// `senior:scope-classify`, prompt `resources/prompts/scope-classify.md`), which runs immediately
|
|
20
|
+
// after this gate on the converged path and reads each closed issue's stated scope. This worker's
|
|
21
|
+
// sole responsibility is the review-comment gate.
|
|
30
22
|
//
|
|
31
23
|
// It FAILS CLOSED: if the live GitHub state cannot be read, it blocks (escalates) rather than
|
|
32
24
|
// letting an unverifiable "converged" through — the opposite of the no-progress guard, because a
|
|
@@ -35,14 +27,11 @@ import type { AppJobHandler } from "@nanobpm/urban";
|
|
|
35
27
|
import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
|
|
36
28
|
import {
|
|
37
29
|
fetchLatestCopilotReviewBody,
|
|
38
|
-
fetchPrHead,
|
|
39
|
-
fetchPrMeta,
|
|
40
30
|
fetchReviewThreads,
|
|
41
31
|
parseAckedAdvisories,
|
|
42
32
|
parseSuppressedAdvisories,
|
|
43
33
|
type ReviewThread,
|
|
44
34
|
} from "../../app/github.ts";
|
|
45
|
-
import { evaluateScopeGuard, isScopeOverridden, type ScopeEscalationAnswer } from "../../app/scopeGuard.ts";
|
|
46
35
|
import { parsePr } from "../../app/service.ts";
|
|
47
36
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
48
37
|
|
|
@@ -57,82 +46,22 @@ export type ThreadsReader = (repo: string, prNumber: number) => Promise<ReviewTh
|
|
|
57
46
|
// Reads the latest Copilot review body. `null` = no usable transport (unverifiable → fail closed);
|
|
58
47
|
// `""` = transport usable but no Copilot review yet (verified: no suppressed advisories).
|
|
59
48
|
export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
|
|
60
|
-
// Reads the PR's own description body. `null` = no usable transport (unverifiable → fail closed);
|
|
61
|
-
// `""` = transport usable but the PR has an empty description (verified: nothing to scope-check).
|
|
62
|
-
export type PrBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
|
|
63
|
-
// Reads the PR's current HEAD sha (the commit under review). `null` = unreadable/no transport — the
|
|
64
|
-
// scope override cannot be verified or bound to a commit, so the gate keeps blocking (fail closed).
|
|
65
|
-
export type HeadShaReader = (repo: string, prNumber: number) => Promise<string | null>;
|
|
66
49
|
|
|
67
50
|
const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
|
|
68
51
|
fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
69
52
|
const defaultReadReviewBody: ReviewBodyReader = (repo, prNumber) =>
|
|
70
53
|
fetchLatestCopilotReviewBody(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
71
|
-
const defaultReadPrBody: PrBodyReader = async (repo, prNumber) => {
|
|
72
|
-
const meta = await fetchPrMeta(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
73
|
-
return meta ? meta.body : null;
|
|
74
|
-
};
|
|
75
|
-
const defaultReadHeadSha: HeadShaReader = async (repo, prNumber) => {
|
|
76
|
-
const head = await fetchPrHead(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
77
|
-
return head ? head.headSha : null;
|
|
78
|
-
};
|
|
79
54
|
|
|
80
55
|
const BLOCK_UNVERIFIABLE =
|
|
81
56
|
"Convergence blocked: could not verify the PR's review comments against GitHub. A human must confirm every Copilot review thread is resolved and every suppressed advisory acknowledged before this PR converges (reply to resume the loop).";
|
|
82
57
|
|
|
83
|
-
const BLOCK_UNVERIFIABLE_BODY =
|
|
84
|
-
"Convergence blocked: could not read the PR description from GitHub to verify scope integrity. A human must confirm this PR does not close a broader-scoped parent with an untracked deferred remainder before it converges (reply to resume the loop).";
|
|
85
|
-
|
|
86
|
-
// An `escalations` row as this worker reads it back when looking for a recorded human override.
|
|
87
|
-
interface EscalationRow extends Record<string, unknown> {
|
|
88
|
-
id: number;
|
|
89
|
-
head_sha: string | null;
|
|
90
|
-
answer: string | null;
|
|
91
|
-
scope_block: number | boolean | null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Find the newest ANSWERED scope-integrity escalation for this PR whose recorded HEAD matches the
|
|
95
|
-
// commit now under review (issue #395). This is the human-override door: `persist-escalation` binds
|
|
96
|
-
// a scope block to the HEAD it was raised against, `answer-escalation` marks the row `answered`, and
|
|
97
|
-
// here we honour that answer for the SAME HEAD so the gate stops re-deriving `scopeBlocked` from the
|
|
98
|
-
// PR body and re-escalating the identical question forever. Newest-first so a re-escalated-then-
|
|
99
|
-
// answered duplicate resolves to the operator's latest reply. Returns `null` on any read failure —
|
|
100
|
-
// the caller then keeps the block (fail closed), never fabricates an override.
|
|
101
|
-
async function findScopeOverride(
|
|
102
|
-
app: Parameters<AppJobHandler<In, Out>>[1],
|
|
103
|
-
prKey: string,
|
|
104
|
-
headSha: string,
|
|
105
|
-
): Promise<ScopeEscalationAnswer | null> {
|
|
106
|
-
try {
|
|
107
|
-
// `scope_block` is a first-class column (persist-escalation writes it as 0/1), so filter on it
|
|
108
|
-
// in the query rather than reading every answered escalation and filtering in memory — a PR with
|
|
109
|
-
// many answered non-scope escalations no longer loads them all just to discard them.
|
|
110
|
-
const rows = await app.data.table<EscalationRow>("escalations", "id").find({
|
|
111
|
-
pr_key: prKey,
|
|
112
|
-
status: "answered",
|
|
113
|
-
scope_block: 1,
|
|
114
|
-
});
|
|
115
|
-
const scoped = rows
|
|
116
|
-
.map((r) => ({ escalationId: Number(r.id), headSha: r.head_sha ?? null, answer: r.answer ?? null }))
|
|
117
|
-
.sort((a, b) => (b.escalationId ?? 0) - (a.escalationId ?? 0));
|
|
118
|
-
for (const candidate of scoped) {
|
|
119
|
-
if (isScopeOverridden(headSha, candidate)) return candidate;
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
} catch {
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
58
|
/** Build the handler with injectable GitHub readers. The default export binds the real readers;
|
|
128
59
|
* tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
|
|
129
60
|
export function makeHandler(deps: {
|
|
130
61
|
readThreads: ThreadsReader;
|
|
131
62
|
readReviewBody: ReviewBodyReader;
|
|
132
|
-
readPrBody: PrBodyReader;
|
|
133
|
-
readHeadSha: HeadShaReader;
|
|
134
63
|
}): AppJobHandler<In, Out> {
|
|
135
|
-
return async (job
|
|
64
|
+
return async (job) => {
|
|
136
65
|
const { prKey, repo, prNumber } = job.variables;
|
|
137
66
|
// `parsePr` is total on any input (fails closed to `null` on a missing/non-string prKey), so
|
|
138
67
|
// pass it straight through — a malformed prKey degrades to the fail-closed target check below.
|
|
@@ -142,12 +71,8 @@ export function makeHandler(deps: {
|
|
|
142
71
|
if (!ghRepo || typeof ghNumber !== "number") {
|
|
143
72
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
144
73
|
}
|
|
145
|
-
// The canonical escalations key. Prefer the carried prKey; fall back to the parsed identity so
|
|
146
|
-
// the override lookup still keys off `owner/repo#N` when only repo/prNumber survived.
|
|
147
|
-
const escPrKey = typeof prKey === "string" && prKey !== "" ? prKey : `${ghRepo}#${ghNumber}`;
|
|
148
74
|
|
|
149
75
|
let result: ConvergeGateResult;
|
|
150
|
-
let scopeReason: string;
|
|
151
76
|
try {
|
|
152
77
|
const threads = await deps.readThreads(ghRepo, ghNumber);
|
|
153
78
|
// A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
|
|
@@ -171,83 +96,15 @@ export function makeHandler(deps: {
|
|
|
171
96
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
172
97
|
}
|
|
173
98
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// read failure as a review-thread verification failure and point the human escalation at the
|
|
178
|
-
// wrong place.
|
|
179
|
-
try {
|
|
180
|
-
// The PR description drives the scope-integrity guard (#313). A null read is unverifiable —
|
|
181
|
-
// fail closed with a scope-specific reason. (An empty STRING is a verified empty description:
|
|
182
|
-
// no closing keyword, no deferral, so the scope guard passes.)
|
|
183
|
-
const prBody = await deps.readPrBody(ghRepo, ghNumber);
|
|
184
|
-
if (prBody === null) {
|
|
185
|
-
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
|
|
186
|
-
}
|
|
187
|
-
scopeReason = evaluateScopeGuard({ prBody }).scopeBlockReason;
|
|
188
|
-
} catch {
|
|
189
|
-
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE_BODY };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// The human-override door for the scope-integrity block (issue #395). When the deterministic
|
|
193
|
-
// scope guard would re-block, read the commit now under review and consult the recorded
|
|
194
|
-
// escalation answer bound to that SAME HEAD: an operator who answered the scope question for
|
|
195
|
-
// this exact commit has explicitly overridden it ("this fully delivers the issue — keep the
|
|
196
|
-
// closing keyword"), so honour it (audited) instead of re-deriving the block from the body and
|
|
197
|
-
// re-escalating forever. Binding to the HEAD sha keeps the override from carrying across a new
|
|
198
|
-
// push (a different HEAD legitimately re-opens the gate); and if the human instead asked for a
|
|
199
|
-
// real split, the servicing agent pushes a fix — moving the HEAD so this stale override never
|
|
200
|
-
// fires. This is what turns the infinite escalation loop into a resolvable one.
|
|
201
|
-
let headSha: string | null = null;
|
|
202
|
-
let scopeBlocked = scopeReason !== "";
|
|
203
|
-
if (scopeBlocked) {
|
|
204
|
-
try {
|
|
205
|
-
headSha = await deps.readHeadSha(ghRepo, ghNumber);
|
|
206
|
-
} catch {
|
|
207
|
-
headSha = null;
|
|
208
|
-
}
|
|
209
|
-
if (headSha) {
|
|
210
|
-
const override = await findScopeOverride(app, escPrKey, headSha);
|
|
211
|
-
if (override) {
|
|
212
|
-
app.log.info("converge-gate: scope-integrity block overridden by human answer", {
|
|
213
|
-
prKey: escPrKey,
|
|
214
|
-
headSha,
|
|
215
|
-
escalationId: override.escalationId ?? null,
|
|
216
|
-
// The human answer is free-form operator input — never log it verbatim (it can carry
|
|
217
|
-
// sensitive content into application logs). Record only stable identifiers plus a
|
|
218
|
-
// minimal presence/length signal for debugging.
|
|
219
|
-
hasAnswer: override.answer != null && override.answer !== "",
|
|
220
|
-
answerLength: override.answer?.length ?? 0,
|
|
221
|
-
});
|
|
222
|
-
scopeReason = "";
|
|
223
|
-
scopeBlocked = false;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Both guards gate the same handoff to the merge loop: block if EITHER the review-comment gate
|
|
229
|
-
// or the scope-integrity gate blocks, joining their reasons so the human sees every cause.
|
|
230
|
-
const reason = [result.convergeBlockReason, scopeReason].filter((r) => r !== "").join(" ");
|
|
231
|
-
const out: Out = {
|
|
232
|
-
convergeBlocked: result.convergeBlocked || scopeBlocked,
|
|
233
|
-
convergeBlockReason: reason,
|
|
99
|
+
return {
|
|
100
|
+
convergeBlocked: result.convergeBlocked,
|
|
101
|
+
convergeBlockReason: result.convergeBlockReason,
|
|
234
102
|
};
|
|
235
|
-
// Surface the reviewed HEAD and the scope-block flag ONLY when scope actually blocks — the
|
|
236
|
-
// `persist-escalation-blockedcomments` arm (which runs only on a blocked gate) binds the
|
|
237
|
-
// escalation to this commit with them, opening the override door on the next round. A clean
|
|
238
|
-
// converge keeps its original `{ convergeBlocked, convergeBlockReason }` shape.
|
|
239
|
-
if (scopeBlocked) {
|
|
240
|
-
out.scopeBlocked = true;
|
|
241
|
-
out.headSha = headSha ?? undefined;
|
|
242
|
-
}
|
|
243
|
-
return out;
|
|
244
103
|
};
|
|
245
104
|
}
|
|
246
105
|
|
|
247
106
|
const handler = makeHandler({
|
|
248
107
|
readThreads: defaultReadThreads,
|
|
249
108
|
readReviewBody: defaultReadReviewBody,
|
|
250
|
-
readPrBody: defaultReadPrBody,
|
|
251
|
-
readHeadSha: defaultReadHeadSha,
|
|
252
109
|
});
|
|
253
110
|
export default handler;
|
|
@@ -50,7 +50,7 @@ function workerOf(vars: Record<string, unknown>): string | undefined {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
const handler: AppJobHandler<In> = async (job, app) => {
|
|
53
|
-
const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl
|
|
53
|
+
const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
|
|
54
54
|
// `status` drives the escalation kind (control flow); a blank/absent status is an
|
|
55
55
|
// unclassified escalation -> a question needing input. `question` is returned as a
|
|
56
56
|
// process variable below so the downstream `wait-answer` userTask + `pr-escalation.form`
|
|
@@ -116,13 +116,6 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
116
116
|
worker,
|
|
117
117
|
status: "open",
|
|
118
118
|
asked_at: now,
|
|
119
|
-
// Bind a scope-integrity escalation to the reviewed commit (issue #395) so the converge-gate
|
|
120
|
-
// can honour a human answer as an override for THIS HEAD instead of re-deriving the block from
|
|
121
|
-
// the PR body and re-escalating forever. Only the scope-integrity arm passes these; every other
|
|
122
|
-
// arm leaves them absent (→ head_sha NULL, scope_block DEFAULT 0), so the override door opens
|
|
123
|
-
// exclusively for the block the human can actually answer.
|
|
124
|
-
head_sha: headSha,
|
|
125
|
-
scope_block: scopeBlock === true ? 1 : 0,
|
|
126
119
|
});
|
|
127
120
|
await app.data.table("pull_requests", "pr_key").update(prKey, {
|
|
128
121
|
status: "escalated",
|
package/app/scopeGuard.test.ts
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
// Scope-integrity guard — unit tests for the canonical router (app/scopeGuard.ts) and its parsing
|
|
2
|
-
// helpers.
|
|
3
|
-
//
|
|
4
|
-
// A parity slice can be silently under-delivered: an agent splits a large slice, ships one half,
|
|
5
|
-
// then `Closes #N` a broader-scoped parent while recording the deferred remainder only in PR prose
|
|
6
|
-
// (a `## Scope` section) with no filed follow-up issue. Magikcraft/nano-bpm#631 → PR #863 did
|
|
7
|
-
// exactly this and the deferred half was lost until a human re-filed it as #872. These two guards
|
|
8
|
-
// (#313) block that class: a partial delivery may not close-keyword a broader-scoped parent, and any
|
|
9
|
-
// deferral must link a filed follow-up issue rather than live in prose.
|
|
10
|
-
import { test } from "node:test";
|
|
11
|
-
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
12
|
-
import {
|
|
13
|
-
evaluateScopeGuard,
|
|
14
|
-
findClosingKeywordRefs,
|
|
15
|
-
hasDeferralMarker,
|
|
16
|
-
hasFollowupIssueRef,
|
|
17
|
-
isScopeOverridden,
|
|
18
|
-
} from "./scopeGuard.ts";
|
|
19
|
-
|
|
20
|
-
// ── The canonical router ────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
test("evaluateScopeGuard: a full-scope PR that Closes its parent, no deferral, is allowed", () => {
|
|
23
|
-
const r = evaluateScopeGuard({ prBody: "Implements the feature end to end.\n\nCloses #313" });
|
|
24
|
-
assertEquals(r.scopeBlocked, false);
|
|
25
|
-
assertEquals(r.scopeBlockReason, "");
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("evaluateScopeGuard: a plain PR with no closing keyword and no deferral is allowed", () => {
|
|
29
|
-
const r = evaluateScopeGuard({ prBody: "A small refactor. Refs #10" });
|
|
30
|
-
assertEquals(r.scopeBlocked, false);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("evaluateScopeGuard: Closes a broader parent AND defers scope → blocked (guard 1)", () => {
|
|
34
|
-
const r = evaluateScopeGuard({
|
|
35
|
-
prBody:
|
|
36
|
-
"Delivers the nested ad-hoc half.\n\n## Scope\nEmbedded SUB_PROCESS tools remain the deferred refinement.\n\nCloses #631",
|
|
37
|
-
});
|
|
38
|
-
assertEquals(r.scopeBlocked, true);
|
|
39
|
-
assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
|
|
40
|
-
assertStringIncludes(r.scopeBlockReason, "#631");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("evaluateScopeGuard: defers scope but links NO follow-up issue → blocked (guard 2)", () => {
|
|
44
|
-
const r = evaluateScopeGuard({
|
|
45
|
-
prBody: "Ships the first half.\n\n## Scope\nThe rest is deferred.\n\nRefs #631",
|
|
46
|
-
});
|
|
47
|
-
assertEquals(r.scopeBlocked, true);
|
|
48
|
-
assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
|
|
49
|
-
// Guard 1 must NOT fire — this PR correctly used a non-closing ref.
|
|
50
|
-
assert(
|
|
51
|
-
!r.scopeBlockReason.includes("must not close"),
|
|
52
|
-
"a non-closing ref must not trip the closing-keyword guard",
|
|
53
|
-
);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("evaluateScopeGuard: defers scope AND links a filed follow-up AND uses a non-closing ref → allowed", () => {
|
|
57
|
-
const r = evaluateScopeGuard({
|
|
58
|
-
prBody:
|
|
59
|
-
"Ships the first half.\n\n## Scope\nThe embedded SUB_PROCESS half is deferred.\nTracked-in: #872\n\nRefs #631",
|
|
60
|
-
});
|
|
61
|
-
assertEquals(r.scopeBlocked, false);
|
|
62
|
-
assertEquals(r.scopeBlockReason, "");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("evaluateScopeGuard: the motivating incident (Closes #631 + ## Scope + no follow-up) trips BOTH guards", () => {
|
|
66
|
-
const r = evaluateScopeGuard({
|
|
67
|
-
prBody:
|
|
68
|
-
"## Summary\nNested ad-hoc / agent-of-agents delivered.\n\n## Scope\nembedded `SUB_PROCESS` tools whose multi-element body runs by token flow remain the deferred refinement.\n\nCloses #631",
|
|
69
|
-
});
|
|
70
|
-
assertEquals(r.scopeBlocked, true);
|
|
71
|
-
assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
|
|
72
|
-
assertStringIncludes(r.scopeBlockReason, "no filed follow-up issue");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("evaluateScopeGuard: a follow-up link alone does not excuse a closing keyword on a split", () => {
|
|
76
|
-
// Even with the remainder tracked, closing the broader parent is still wrong — it reads as done.
|
|
77
|
-
const r = evaluateScopeGuard({
|
|
78
|
-
prBody: "Ships half.\n\nDeferred: the rest. Follow-up: #872\n\nCloses #631",
|
|
79
|
-
});
|
|
80
|
-
assertEquals(r.scopeBlocked, true);
|
|
81
|
-
assertStringIncludes(r.scopeBlockReason, "must not close a broader-scoped issue");
|
|
82
|
-
assert(!r.scopeBlockReason.includes("no filed follow-up issue"), "the follow-up was linked");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("evaluateScopeGuard: tolerates null / empty bodies", () => {
|
|
86
|
-
assertEquals(evaluateScopeGuard({ prBody: null }).scopeBlocked, false);
|
|
87
|
-
assertEquals(evaluateScopeGuard({ prBody: undefined }).scopeBlocked, false);
|
|
88
|
-
assertEquals(evaluateScopeGuard({ prBody: "" }).scopeBlocked, false);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// ── The parsers ─────────────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
test("findClosingKeywordRefs: extracts bare, cross-repo, and URL closing refs; dedupes", () => {
|
|
94
|
-
const body = [
|
|
95
|
-
"Closes #12",
|
|
96
|
-
"fixes: owner/repo#34",
|
|
97
|
-
"Resolved https://github.com/owner/repo/issues/56",
|
|
98
|
-
"Closes #12", // duplicate
|
|
99
|
-
].join("\n");
|
|
100
|
-
assertEquals(findClosingKeywordRefs(body), [
|
|
101
|
-
"#12",
|
|
102
|
-
"owner/repo#34",
|
|
103
|
-
"https://github.com/owner/repo/issues/56",
|
|
104
|
-
]);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test("findClosingKeywordRefs: a non-closing ref (Refs / Part of) is not a closing keyword", () => {
|
|
108
|
-
assertEquals(findClosingKeywordRefs("Refs #12\nPart of #34\nDepends-on: #56"), []);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("hasDeferralMarker: detects a ## Scope heading and deferral phrases; ignores clean prose", () => {
|
|
112
|
-
assert(hasDeferralMarker("## Scope\nfoo"), "a Scope heading defers");
|
|
113
|
-
assert(hasDeferralMarker("### scope of work"), "any heading level counts");
|
|
114
|
-
assert(hasDeferralMarker("The rest is deferred to later."), "'deferred' defers");
|
|
115
|
-
assert(hasDeferralMarker("This is out of scope for now."), "'out of scope' defers");
|
|
116
|
-
assert(hasDeferralMarker("The remainder is left for a follow-up."), "'remainder' defers");
|
|
117
|
-
assert(!hasDeferralMarker("Implements everything. Closes #1."), "clean prose does not defer");
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("hasDeferralMarker: a bare 'remain*' without deferral context is not a deferral", () => {
|
|
121
|
-
// "all done" phrasing must not be read as a scope deferral (Copilot advisory,
|
|
122
|
-
// app/scopeGuard.ts:48): a full-scope PR that merely reports nothing outstanding
|
|
123
|
-
// would otherwise be blocked from converging.
|
|
124
|
-
assert(!hasDeferralMarker("No issues remain.\n\nCloses #123"), "'No issues remain' is not a deferral");
|
|
125
|
-
assert(!hasDeferralMarker("All checks remain green."), "'remain green' is not a deferral");
|
|
126
|
-
assert(!hasDeferralMarker("No failing tests remaining. Closes #7"), "'remaining' alone is not a deferral");
|
|
127
|
-
// ...but a remainder mention near genuine deferral context still defers.
|
|
128
|
-
assert(hasDeferralMarker("The remaining scope is tracked separately."), "'remaining' near 'scope' defers");
|
|
129
|
-
assert(hasDeferralMarker("Remaining work is a follow-up."), "'remaining' near 'follow-up' defers");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("hasFollowupIssueRef: only an explicit tracking marker + issue ref counts", () => {
|
|
133
|
-
assert(hasFollowupIssueRef("Deferred-to: #872"), "Deferred-to marker");
|
|
134
|
-
assert(hasFollowupIssueRef("Tracked-in: owner/repo#872"), "cross-repo tracking marker");
|
|
135
|
-
assert(hasFollowupIssueRef("Follow-up: #900"), "Follow-up marker");
|
|
136
|
-
assert(hasFollowupIssueRef("Follow up issue: #900"), "Follow up issue marker");
|
|
137
|
-
assert(!hasFollowupIssueRef("The rest is deferred."), "bare deferral prose is not a filed link");
|
|
138
|
-
assert(!hasFollowupIssueRef("Refs #631"), "a parent ref is not a remainder tracker");
|
|
139
|
-
// A full GitHub issue URL is a valid filed follow-up link, same as the closing-keyword parser accepts.
|
|
140
|
-
assert(
|
|
141
|
-
hasFollowupIssueRef("Deferred-to: https://github.com/owner/repo/issues/872"),
|
|
142
|
-
"Deferred-to marker with a full issue URL",
|
|
143
|
-
);
|
|
144
|
-
assert(
|
|
145
|
-
hasFollowupIssueRef("Follow-up issue: https://github.com/nanobpm/nano-workforce/issues/900"),
|
|
146
|
-
"Follow-up marker with a full issue URL",
|
|
147
|
-
);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// ── The human-override door (#395) ──────────────────────────────────────────
|
|
151
|
-
// The scope-integrity gate re-derives `scopeBlocked` from the PR body every round, so answering
|
|
152
|
-
// its escalation used to re-block identically (an infinite loop). An answer bound to the SAME
|
|
153
|
-
// reviewed HEAD is now honoured as an explicit override; a different HEAD (a new push) is not.
|
|
154
|
-
|
|
155
|
-
test("isScopeOverridden: an answer bound to the same HEAD overrides the block", () => {
|
|
156
|
-
assert(
|
|
157
|
-
isScopeOverridden("abc123", { escalationId: 7, headSha: "abc123", answer: "Full delivery — keep Closes." }),
|
|
158
|
-
"same-HEAD answered escalation is an override",
|
|
159
|
-
);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("isScopeOverridden: an answer for a DIFFERENT HEAD does not override (a new push re-opens)", () => {
|
|
163
|
-
assert(
|
|
164
|
-
!isScopeOverridden("newHEAD", { escalationId: 7, headSha: "oldHEAD", answer: "Full delivery." }),
|
|
165
|
-
"an override never carries across a new push",
|
|
166
|
-
);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("isScopeOverridden: no recorded answer is never an override", () => {
|
|
170
|
-
assertEquals(isScopeOverridden("abc123", null), false);
|
|
171
|
-
assertEquals(isScopeOverridden("abc123", undefined), false);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test("isScopeOverridden: a missing/blank HEAD on either side fails closed (no override)", () => {
|
|
175
|
-
assertEquals(isScopeOverridden(null, { headSha: "abc123", answer: "x" }), false, "unreadable current HEAD");
|
|
176
|
-
assertEquals(isScopeOverridden("", { headSha: "abc123", answer: "x" }), false, "blank current HEAD");
|
|
177
|
-
assertEquals(isScopeOverridden("abc123", { headSha: null, answer: "x" }), false, "unrecorded escalation HEAD");
|
|
178
|
-
assertEquals(isScopeOverridden("abc123", { headSha: " ", answer: "x" }), false, "blank escalation HEAD");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("isScopeOverridden: the answer text is not parsed for intent — presence at the HEAD is the signal", () => {
|
|
182
|
-
// On an unchanged HEAD, the operator completing the escalation IS the explicit approval: had they
|
|
183
|
-
// wanted a real split, the servicing agent would have pushed a fix, moving the HEAD.
|
|
184
|
-
assert(isScopeOverridden("abc123", { headSha: "abc123", answer: null }), "a null answer at the HEAD still overrides");
|
|
185
|
-
});
|