@nanobpm/nano-workforce 0.70.0 → 0.70.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ // pr.converge-gate — the deterministic review-comment gate for the convergence loop.
2
+ //
3
+ // The loop declares convergence on the review-round AGENT's self-reported `status = "converged"`.
4
+ // That trusts the agent to only converge once every Copilot comment is addressed — which failed on
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 pr.finalize hands off to
7
+ // the merge loop, and blocks handoff while GitHub still shows unaddressed comments:
8
+ // • any review THREAD is still unresolved (GraphQL `isResolved = false`), or
9
+ // • any SUPPRESSED advisory in the latest Copilot review body lacks a matching RESOLVED ack
10
+ // thread (a `nano-ack: <path>:<line>` marker copied from Copilot's `**path:line**` header).
11
+ // A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
12
+ // the human `wait-answer` escalation (recoverable), never a hard wedge.
13
+ //
14
+ // It FAILS CLOSED: if the live GitHub state cannot be read, it blocks (escalates) rather than
15
+ // letting an unverifiable "converged" through — the opposite of the no-progress guard, because a
16
+ // merge-gating check must escalate-on-uncertainty so #770 cannot recur.
17
+ import type { AppJobHandler } from "@nanobpm/urban";
18
+ import { type ConvergeGateResult, evaluateConvergeGate } from "../../app/convergeGate.ts";
19
+ import {
20
+ fetchLatestCopilotReviewBody,
21
+ fetchReviewThreads,
22
+ parseAckedAdvisories,
23
+ parseSuppressedAdvisories,
24
+ type ReviewThread,
25
+ } from "../../app/github.ts";
26
+ import { parsePr } from "../../app/service.ts";
27
+ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
28
+
29
+ // Input/output typed off the model data envelopes (`PrConvergeGateIn` / `PrConvergeGateOut` in
30
+ // convergence-loop.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
31
+ type In = WorkerInputs["pr.converge-gate"];
32
+ type Out = WorkerOutputs["pr.converge-gate"];
33
+
34
+ // Reads a PR's review threads. `null` = no usable transport (treated as an unverifiable read →
35
+ // fail closed). Throws propagate to the fail-closed catch below.
36
+ export type ThreadsReader = (repo: string, prNumber: number) => Promise<ReviewThread[] | null>;
37
+ // Reads the latest Copilot review body. `null` = no usable transport (unverifiable → fail closed);
38
+ // `""` = transport usable but no Copilot review yet (verified: no suppressed advisories).
39
+ export type ReviewBodyReader = (repo: string, prNumber: number) => Promise<string | null>;
40
+
41
+ const defaultReadThreads: ThreadsReader = (repo, prNumber) =>
42
+ fetchReviewThreads(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
43
+ const defaultReadReviewBody: ReviewBodyReader = (repo, prNumber) =>
44
+ fetchLatestCopilotReviewBody(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
45
+
46
+ const BLOCK_UNVERIFIABLE =
47
+ "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).";
48
+
49
+ /** Build the handler with injectable GitHub readers. The default export binds the real readers;
50
+ * tests inject stubs. Fails CLOSED — any unreadable/errored state blocks convergence. */
51
+ export function makeHandler(deps: {
52
+ readThreads: ThreadsReader;
53
+ readReviewBody: ReviewBodyReader;
54
+ }): AppJobHandler<In, Out> {
55
+ return async (job) => {
56
+ const { prKey, repo, prNumber } = job.variables;
57
+ // `parsePr` is total on any input (fails closed to `null` on a missing/non-string prKey), so
58
+ // pass it straight through — a malformed prKey degrades to the fail-closed target check below.
59
+ const parsed = parsePr(prKey);
60
+ const ghRepo = repo ?? parsed?.repo;
61
+ const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
62
+ if (!ghRepo || typeof ghNumber !== "number") {
63
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
64
+ }
65
+
66
+ let result: ConvergeGateResult;
67
+ try {
68
+ const threads = await deps.readThreads(ghRepo, ghNumber);
69
+ // A null threads read is an unverifiable gate — fail closed. (An empty ARRAY is a verified
70
+ // "no threads" and is fine.)
71
+ if (threads === null) {
72
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
73
+ }
74
+ const reviewBody = await deps.readReviewBody(ghRepo, ghNumber);
75
+ // A null review body is an unverifiable read (no usable transport) — fail closed, same as a
76
+ // null threads read. (An empty STRING is a verified "no Copilot review / no advisories".)
77
+ if (reviewBody === null) {
78
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
79
+ }
80
+ const unresolvedThreadCount = threads.filter((t) => !t.isResolved).length;
81
+ result = evaluateConvergeGate({
82
+ unresolvedThreadCount,
83
+ suppressedKeys: parseSuppressedAdvisories(reviewBody),
84
+ acknowledgedKeys: parseAckedAdvisories(threads),
85
+ });
86
+ } catch {
87
+ return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
88
+ }
89
+
90
+ return {
91
+ convergeBlocked: result.convergeBlocked,
92
+ convergeBlockReason: result.convergeBlockReason,
93
+ };
94
+ };
95
+ }
96
+
97
+ const handler = makeHandler({
98
+ readThreads: defaultReadThreads,
99
+ readReviewBody: defaultReadReviewBody,
100
+ });
101
+ export default handler;
@@ -0,0 +1,77 @@
1
+ // pr.progress-check — the deterministic no-progress guard for the convergence loop.
2
+ //
3
+ // After a round is recorded (pr.persist-round), this step reads the PR's current head SHA and
4
+ // compares it to the head observed at the previous recorded round. An `addressed` round whose head
5
+ // did NOT advance pushed no commit, so requesting another Copilot review would loop on
6
+ // byte-identical code — the same comments, round after round, until the round cap escalates. It
7
+ // returns `progressed:false`, and the model's `gw-progress` gateway escalates to the human
8
+ // `wait-answer` task instead of soliciting another review.
9
+ //
10
+ // Every other case returns `progressed:true` (continue): a `waiting` round (round 1, awaiting the
11
+ // first review) legitimately has no push; an advanced head means real work landed; and a head we
12
+ // could not read fails OPEN — the round cap and the review-wait timeout stay the safety nets so a
13
+ // transient GitHub hiccup can never fabricate a no-progress escalation. The routing decision lives
14
+ // in app/roundProgress.ts, the single source of truth this worker and `gw-progress` both mirror.
15
+ import type { AppJobHandler } from "@nanobpm/urban";
16
+ import { fetchPrHead } from "../../app/github.ts";
17
+ import { isAddressedStatus, routeProgress } from "../../app/roundProgress.ts";
18
+ import { parsePr } from "../../app/service.ts";
19
+ import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
20
+
21
+ // Input/output typed off the model data envelopes (`PrProgressCheckIn` / `PrProgressCheckOut` in
22
+ // convergence-loop.bpmn), the single source of truth for this worker's wire contract (ADR 0040).
23
+ type In = WorkerInputs["pr.progress-check"];
24
+ type Out = WorkerOutputs["pr.progress-check"];
25
+
26
+ // Reads a PR's current head SHA. Injectable so unit tests never touch git/network; the default
27
+ // binds the real GitHub reader (the shared gh | token transport) and swallows any failure to
28
+ // `null` so the guard fails OPEN.
29
+ export type HeadReader = (repo: string, prNumber: number) => Promise<string | null>;
30
+
31
+ const defaultReadHead: HeadReader = async (repo, prNumber) => {
32
+ const token = process.env.GITHUB_TOKEN ?? "";
33
+ const head = await fetchPrHead(repo, prNumber, token).catch(() => null);
34
+ return head?.headSha ?? null;
35
+ };
36
+
37
+ /** Build the handler with an injectable head reader (see {@link HeadReader}). The default export
38
+ * binds the real GitHub reader; tests inject a stub. */
39
+ export function makeHandler(deps: { readHead: HeadReader }): AppJobHandler<In, Out> {
40
+ return async (job, app) => {
41
+ const { prKey, status, repo, prNumber } = job.variables;
42
+
43
+ // Only an `addressed` round claims a push, so only it can be a no-progress round — and a
44
+ // blank/unknown status counts as `addressed` here (gw-status defaults it down the addressed
45
+ // arm and pr.persist-round records a missing status as `addressed`), so it is the safe-default
46
+ // trap this guard exists for. Skip the GitHub read entirely only for an explicitly recognized
47
+ // non-addressed status — a `waiting` round costs nothing and continues.
48
+ if (!isAddressedStatus(status)) return { progressed: true };
49
+
50
+ // Prefer the carried repo/prNumber; fall back to parsing the canonical `owner/repo#N` prKey so
51
+ // an older in-flight instance (or a process-variable regression) still resolves a target. If
52
+ // neither yields one, fail open rather than guess.
53
+ const parsed = parsePr(prKey);
54
+ const ghRepo = repo ?? parsed?.repo;
55
+ const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
56
+ if (!ghRepo || typeof ghNumber !== "number") return { progressed: true };
57
+
58
+ const currentHead = await deps.readHead(ghRepo, ghNumber).catch(() => null);
59
+ const prs = app.data.table<{ pr_key: string; last_round_head: string | null }>(
60
+ "pull_requests",
61
+ "pr_key",
62
+ );
63
+ const row = await prs.get(prKey);
64
+ const previousHead = row?.last_round_head ?? null;
65
+
66
+ // Record the observed head as the baseline for the next round's comparison — but only when we
67
+ // actually read one, so a null (unreadable) head never clobbers a good baseline.
68
+ if (currentHead) {
69
+ await prs.update(prKey, { last_round_head: currentHead });
70
+ }
71
+
72
+ return { progressed: routeProgress(status, previousHead, currentHead) === "continue" };
73
+ };
74
+ }
75
+
76
+ const handler = makeHandler({ readHead: defaultReadHead });
77
+ export default handler;