@nanobpm/nano-workforce 0.70.0 → 0.70.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.
@@ -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;