@nanobpm/nano-workforce 0.187.3 → 0.187.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/SPEC.md +71 -9
- package/app/contracts.ts +1 -0
- package/app/convergenceEscalationGuard.test.ts +3 -2
- package/app/github.test.ts +125 -1
- package/app/github.ts +43 -8
- package/app/persist-escalation.test.ts +8 -6
- package/app/persist-round.test.ts +178 -11
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/reviewWait.test.ts +7 -0
- package/app/reviewWait.ts +1 -1
- package/app/roundProgress.test.ts +755 -33
- package/app/roundProgress.ts +144 -0
- package/app/roundResultDefault.test.ts +10 -8
- package/app/service.test.ts +14 -0
- package/app/service.ts +35 -0
- package/db/migrations/102_rounds_process_instance_key.sql +28 -0
- package/db/migrations/103_pr_progress_idempotency.sql +29 -0
- package/db/migrations/104_pull_requests_read_model_progress_idempotency.sql +55 -0
- package/docs/agent-guide.md +1 -1
- package/e2e/convergence-escalation.e2e.ts +5 -4
- package/e2e/feature-run.e2e.ts +6 -1
- package/e2e/plan-fanout-sla.e2e.ts +5 -2
- package/e2e/plan-fanout.e2e.ts +6 -2
- package/e2e/support/time.ts +34 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +211 -142
- package/test/derivation-parity/README.md +3 -3
- package/test/derivation-parity/derivation-parity.test.ts +9 -3
- package/test/derivation-parity/flows.ts +4 -4
- package/workers/capture-head/worker.test.ts +77 -0
- package/workers/capture-head/worker.ts +64 -0
- package/workers/persist-escalation/worker.ts +4 -0
- package/workers/persist-round/worker.ts +75 -9
- package/workers/progress-check/worker.ts +394 -35
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.187.5](https://github.com/nanobpm/nano-workforce/compare/v0.187.4...v0.187.5) (2026-09-15)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **convergence:** classify husked review rounds and bound-retry the no-progress loop ([#789](https://github.com/nanobpm/nano-workforce/issues/789)) ([faebcdc](https://github.com/nanobpm/nano-workforce/commit/faebcdcaca017fab8d7436a50e3dc09354dc3022)), closes [jwulf/c8ctl-plugin-nano#230](https://github.com/jwulf/c8ctl-plugin-nano/issues/230) [#786](https://github.com/nanobpm/nano-workforce/issues/786) [#786](https://github.com/nanobpm/nano-workforce/issues/786)
|
|
6
|
+
|
|
7
|
+
## [0.187.4](https://github.com/nanobpm/nano-workforce/compare/v0.187.3...v0.187.4) (2026-09-14)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* raise default review-wait timeout to PT30M ([#792](https://github.com/nanobpm/nano-workforce/issues/792)) ([042573d](https://github.com/nanobpm/nano-workforce/commit/042573dc5e691dc97efebc12da6a63f2cf398c2a)), closes [#783](https://github.com/nanobpm/nano-workforce/issues/783) [#791](https://github.com/nanobpm/nano-workforce/issues/791)
|
|
12
|
+
|
|
1
13
|
## [0.187.3](https://github.com/nanobpm/nano-workforce/compare/v0.187.2...v0.187.3) (2026-09-14)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/README.md
CHANGED
|
@@ -360,7 +360,7 @@ agent at that URL to author, compile, and submit a graph unaided. See
|
|
|
360
360
|
| `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
|
|
361
361
|
| `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
|
|
362
362
|
| `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
|
|
363
|
-
| `NANO_PR_REVIEW_WAIT_TIMEOUT` | `
|
|
363
|
+
| `NANO_PR_REVIEW_WAIT_TIMEOUT` | `PT30M` | ISO-8601 duration the loop waits for a fresh review before escalating a stalled review (timer arm of the `wait-review` gateway) |
|
|
364
364
|
| `NANO_PR_REVIEW_NUDGE_MINUTES` | `5` | cooldown between the poller's automatic reviewer re-request nudges for one waiting PR (clamped 1–1440) |
|
|
365
365
|
| `NANO_WORKFORCE_BASE_URL` | `http://localhost:3000` | externally-reachable base URL for the capability hooks (`/app/api/hooks/*`). Must resolve from **wherever the agent runs** — set it to the app's LAN address (or console-proxy URL) for a remote fleet. See [Fleet networking](#fleet-networking-remote-workers) |
|
|
366
366
|
| `NANO_AGENTIC_SECRET` | — | enables **secure mode** for the agentic visibility channel (`/agentic`): every peer must present the **same** `NANO_AGENTIC_SECRET` value (set the identical env var on the server and every worker box — Tab A → Slot A). Unset = on-by-default **LOCAL mode** — the well-known token is honoured from **any origin** (open on the trusted LAN, matching the engine's posture); exposure is governed by the server bind address, not a shared secret. Also accepts `NANO_PR_WEBHOOK_SECRET` |
|
package/SPEC.md
CHANGED
|
@@ -101,11 +101,17 @@ known at submit time, carried as a process variable and stored on the DB row.
|
|
|
101
101
|
│ <gateway: status>
|
|
102
102
|
│ ├── converged → [Mark converged] → (end: converged)
|
|
103
103
|
│ │
|
|
104
|
-
│ ├── addressed → [Record round] →
|
|
105
|
-
│ │
|
|
106
|
-
│ │
|
|
107
|
-
│ │
|
|
108
|
-
│ │
|
|
104
|
+
│ ├── addressed → [Record round] → [Check progress] (did the PR head advance?)
|
|
105
|
+
│ │ ├── progressed → <guard: round ≥ maxRounds → escalate "not converged"> │
|
|
106
|
+
│ │ │ → <event-based gateway: review ready or timeout?> │
|
|
107
|
+
│ │ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐
|
|
108
|
+
│ │ │ └── =reviewWaitTimeout (timer catch) │
|
|
109
|
+
│ │ │ → [Escalate: review stalled] (blocked) │
|
|
110
|
+
│ │ │ → [Wait: wait-answer userTask] ────────────────────┤
|
|
111
|
+
│ │ └── no progress → <husk? no commit AND no terminal instance> │
|
|
112
|
+
│ │ ├── husk & retries < MAX → re-enter [Review round] (bypasses the round-cap guard) │
|
|
113
|
+
│ │ └── no-advance / husk cap → [Escalate: no progress] │
|
|
114
|
+
│ │ → [Wait: wait-answer userTask] ───────────────────┤
|
|
109
115
|
│ │ │
|
|
110
116
|
│ └── needs_input [Record escalation] │ │
|
|
111
117
|
│ or blocked → (kind = question | blocker) │ │
|
|
@@ -124,8 +130,11 @@ step, then retry the same round with the human's `answer`. They differ only by e
|
|
|
124
130
|
which the UI uses to label the card. Neither ends the run — a human always gets
|
|
125
131
|
a chance to unblock and resume.
|
|
126
132
|
|
|
127
|
-
Guard:
|
|
128
|
-
("not converged after N rounds") so a human
|
|
133
|
+
Guard: after progress classification, a **progressing** round with round ≥
|
|
134
|
+
MAX_ROUNDS forces an escalation ("not converged after N rounds") so a human
|
|
135
|
+
decides rather than looping forever. The guard sits *after* `check-progress`
|
|
136
|
+
(not before), so a husk auto-retry — which does not consume a round — bypasses
|
|
137
|
+
the cap and is re-tried onto a healthy worker even on the final configured round.
|
|
129
138
|
```
|
|
130
139
|
|
|
131
140
|
Notes:
|
|
@@ -133,7 +142,7 @@ Notes:
|
|
|
133
142
|
canonical `readiness-ready` wait-gate message (ADR 0001 §2; correlated by the
|
|
134
143
|
poller when a fresh review lands)
|
|
135
144
|
against a `=reviewWaitTimeout` timer (seeded at submit from
|
|
136
|
-
`NANO_PR_REVIEW_WAIT_TIMEOUT`, default `
|
|
145
|
+
`NANO_PR_REVIEW_WAIT_TIMEOUT`, default `PT30M`). Whichever fires first
|
|
137
146
|
withdraws the other — the message arm advances `round`, the timer arm escalates
|
|
138
147
|
a **stalled review** (`blocked`) so a human decides rather than the instance
|
|
139
148
|
hanging forever. Because `persist-round` already recorded this `round` as
|
|
@@ -149,6 +158,59 @@ Notes:
|
|
|
149
158
|
backstop when even repeated nudges fail.
|
|
150
159
|
- On `needs_input`, the same `round` is retried after the answer (the answer is
|
|
151
160
|
added to the agent's context; the round number does not advance).
|
|
161
|
+
- **No-progress guard + husk classification (issue #786).** Before the review
|
|
162
|
+
wait, an `addressed` round passes through `pr.progress-check`
|
|
163
|
+
(`workers/progress-check/worker.ts`, mirrored by `app/roundProgress.ts`): it
|
|
164
|
+
reads the PR's current head SHA (the branch ref, atomic with the push) and
|
|
165
|
+
compares it to the **round-entry head** — the head captured by `pr.capture-head`
|
|
166
|
+
immediately BEFORE `review-round` ran this round, published as the
|
|
167
|
+
`roundEntryHead` process variable. `pr.capture-head` sits on EVERY entry into
|
|
168
|
+
`review-round` (the first round from `Start`, a review-loop re-enter, a
|
|
169
|
+
human-answer resume, and a husk auto-retry), so within any round there is always
|
|
170
|
+
a baseline captured against the agent's own starting point — closing the
|
|
171
|
+
no-baseline gap where a FIRST addressed round had no prior-round head to compare
|
|
172
|
+
against (and either waved a first-round husk through as progress, or risked
|
|
173
|
+
mis-escalating a straggler push). If `roundEntryHead` is absent — an older
|
|
174
|
+
in-flight instance whose flow predates `capture-head`, or a capture read that
|
|
175
|
+
failed open (it publishes the empty string as its "unknown" sentinel) —
|
|
176
|
+
progress-check falls back to the head persisted from the previous round
|
|
177
|
+
(`last_round_head`). A round whose head DID advance past the round-entry baseline
|
|
178
|
+
is real progress and continues to the review-wait gateway. A round whose head did
|
|
179
|
+
NOT advance pushed no commit, so re-requesting a review would loop on
|
|
180
|
+
byte-identical code; `gw-progress` routes it to `gw-husk`, which SPLITS it on a
|
|
181
|
+
corroboration correlated to the COMPLETING `review-round` element-instance (NOT
|
|
182
|
+
an aggregate terminal count — a same-round human-answered resume is classified on
|
|
183
|
+
its own fresh attempt):
|
|
184
|
+
- a **husk** — no commit AND the completing `review-round` attempt is
|
|
185
|
+
NON-TERMINAL (the producer harness died mid-run, leaving a stuck instance) —
|
|
186
|
+
is auto-re-run onto a healthy worker up to `MAX_HUSK_RETRIES` (2) before
|
|
187
|
+
escalating; and
|
|
188
|
+
- a **no-advance** — the completing attempt ran to a terminal instance but
|
|
189
|
+
nothing was pushed — (and a husk that exhausts its retries) escalates to the
|
|
190
|
+
human `wait-answer` task.
|
|
191
|
+
The agent-instance read is AVAILABILITY-AWARE via a **two-tier probe** and fails
|
|
192
|
+
SAFE (ADR 0056). `review-round` is an external-agent service task, so a job that
|
|
193
|
+
husks BEFORE it ever registers an AgentInstance leaves the scoped `review-round`
|
|
194
|
+
search EMPTY — indistinguishable, on that query alone, from an engine that has no
|
|
195
|
+
AgentInstance projection at all. The probe therefore resolves an empty
|
|
196
|
+
`review-round` search against a SECOND, process-wide read:
|
|
197
|
+
- if the process-wide read also finds NO instance, the **channel is absent**
|
|
198
|
+
(an engine with no AgentInstance projection) — UNKNOWN, treated as no-advance,
|
|
199
|
+
never an auto-retry that could duplicate genuinely-completed work;
|
|
200
|
+
- if the process-wide read finds ANOTHER instance (from `classify-scope`, an
|
|
201
|
+
earlier round, etc.), the **channel is PRESENT** but this round registered
|
|
202
|
+
nothing — a genuine **pre-registration husk**, so it is classified as a husk
|
|
203
|
+
and auto-retried.
|
|
204
|
+
A head that cannot be read fails OPEN (continue), so a transient GitHub hiccup
|
|
205
|
+
never fabricates a no-progress escalation. Two supporting invariants keep an
|
|
206
|
+
auto-retry clean: `pr.persist-round`
|
|
207
|
+
records a round IDEMPOTENTLY on `(pr_key, round_no, process_instance_key)` — a husk
|
|
208
|
+
retry (same process instance) updates its row in place, while a resubmission that
|
|
209
|
+
re-opens the PR at round 1 in a NEW process instance inserts a fresh row and so
|
|
210
|
+
never clobbers a prior run's durable round history (migration 102) — and the guard flips the PR back to
|
|
211
|
+
the running `converging` status before a retry re-enters `review-round` so the
|
|
212
|
+
poller does not solicit a spurious review against the still-running round. The
|
|
213
|
+
round cap and the review-wait timeout remain the outer safety nets.
|
|
152
214
|
|
|
153
215
|
|
|
154
216
|
## 5. Agent job contract (`senior:pr-review`)
|
|
@@ -544,7 +606,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
|
|
|
544
606
|
| `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `convergeOnly: true` override) |
|
|
545
607
|
| `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
|
|
546
608
|
| `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
|
|
547
|
-
| `NANO_PR_REVIEW_WAIT_TIMEOUT` |
|
|
609
|
+
| `NANO_PR_REVIEW_WAIT_TIMEOUT` | PT30M | ISO-8601 wait before a stalled review escalates (timer arm of the `wait-review` event-based gateway); malformed → default |
|
|
548
610
|
| `NANO_PR_REVIEW_NUDGE_MINUTES` | 5 | cooldown between poller Copilot re-request nudges per PR (clamped 1–1440) |
|
|
549
611
|
|
|
550
612
|
## 13. Planning fan-out (`plan-fanout.bpmn`) — issue #14
|
package/app/contracts.ts
CHANGED
|
@@ -145,6 +145,7 @@ export const ENV_CONTRACTS = {
|
|
|
145
145
|
name: "NANO_PR_REVIEW_WAIT_TIMEOUT",
|
|
146
146
|
owner: "app/service.ts",
|
|
147
147
|
semantics: "How long to wait for a review before nudging/escalating (FEEL/ISO-8601 duration).",
|
|
148
|
+
default: "PT30M",
|
|
148
149
|
},
|
|
149
150
|
NANO_PR_REVIEW_NUDGE_MINUTES: {
|
|
150
151
|
category: "env",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
//
|
|
20
20
|
// The fix (mirroring the merge loop's `gw-merge-escalated`, PR #331): route EVERY arm that can reach
|
|
21
21
|
// `wait-answer` through the single `gw-escalated` guard, so a `persist-escalation` returning
|
|
22
|
-
// `escalated:false` RE-ENTERS the loop (`
|
|
22
|
+
// `escalated:false` RE-ENTERS the loop (round processing, `persist-round`) instead of parking a dead
|
|
23
|
+
// wait. This makes the
|
|
23
24
|
// invariant structural — `wait-answer` is reachable ONLY from a `gw-escalated == true` edge, so a
|
|
24
25
|
// "durable answer-wait with no escalation" is unrepresentable.
|
|
25
26
|
//
|
|
@@ -77,7 +78,7 @@ test("gw-escalated honours persist-escalation's escalated output for every arm",
|
|
|
77
78
|
assertStringIncludes(escWait![0], "escalated = true", "the wait arm must be guarded by escalated = true");
|
|
78
79
|
// escalated:false (a non-escalation, e.g. a blank convergeBlockReason) → re-enter the loop, not a dead wait.
|
|
79
80
|
assert(gatewayDefault("gw-escalated", "f_escReenter"), "gw-escalated default must re-enter the loop");
|
|
80
|
-
assert(flowHasId("f_escReenter", "gw-escalated", "
|
|
81
|
+
assert(flowHasId("f_escReenter", "gw-escalated", "persist-round"), "the non-escalation arm must re-enter round processing (persist-round), not park a wait");
|
|
81
82
|
});
|
|
82
83
|
|
|
83
84
|
test("wait-answer is reachable ONLY from the gw-escalated == true edge (structural invariant)", () => {
|
package/app/github.test.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
|
|
4
4
|
import { test } from "node:test";
|
|
5
5
|
import { assertEquals, assertRejects } from "#test-assert";
|
|
6
|
-
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchIssueTitle, fetchPrFiles, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
|
|
6
|
+
import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
|
|
7
7
|
import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
|
|
8
8
|
|
|
9
9
|
// A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
|
|
@@ -723,3 +723,127 @@ test("checkConclusions: in-flight runs map to '' for both CheckRun and StatusCon
|
|
|
723
723
|
"legacy-error": "ERROR",
|
|
724
724
|
});
|
|
725
725
|
});
|
|
726
|
+
|
|
727
|
+
// ── fetchBranchHead — the atomic branch-ref reader (issue #786) ──────────────
|
|
728
|
+
//
|
|
729
|
+
// The no-progress guard reads the branch ref (git/ref/heads/<branch>), updated ATOMICALLY with the
|
|
730
|
+
// push, rather than the PR object's asynchronously-denormalized head.sha, so a lagging PR projection
|
|
731
|
+
// can never fabricate a stale-but-valid no-advance escalation. Force the token transport and stub
|
|
732
|
+
// `globalThis.fetch` to serve the git-ref endpoint.
|
|
733
|
+
async function withRefFetch<T>(
|
|
734
|
+
serve: (path: string) => { status: number; body: unknown },
|
|
735
|
+
fn: () => Promise<T>,
|
|
736
|
+
): Promise<T> {
|
|
737
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
738
|
+
const prevFetch = globalThis.fetch;
|
|
739
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
740
|
+
globalThis.fetch = ((url: string | URL | Request): Promise<Response> => {
|
|
741
|
+
const path = new URL(String(url)).pathname.replace(/^\/repos\//, "");
|
|
742
|
+
const { status, body } = serve(path);
|
|
743
|
+
return Promise.resolve(new Response(JSON.stringify(body), { status }));
|
|
744
|
+
}) as typeof fetch;
|
|
745
|
+
try {
|
|
746
|
+
return await fn();
|
|
747
|
+
} finally {
|
|
748
|
+
globalThis.fetch = prevFetch;
|
|
749
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
750
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
test("fetchBranchHead: returns the branch ref's atomic head SHA", async () => {
|
|
755
|
+
const sha = await withRefFetch(
|
|
756
|
+
(path) => {
|
|
757
|
+
assertEquals(path, "o/r/git/ref/heads/feat/x");
|
|
758
|
+
return { status: 200, body: { object: { sha: "deadbeef" } } };
|
|
759
|
+
},
|
|
760
|
+
() => fetchBranchHead("o/r", "feat/x", "tok"),
|
|
761
|
+
);
|
|
762
|
+
assertEquals(sha, "deadbeef");
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test("fetchBranchHead: a 404 (branch absent) resolves to null, never throws", async () => {
|
|
766
|
+
const sha = await withRefFetch(
|
|
767
|
+
() => ({ status: 404, body: { message: "Not Found" } }),
|
|
768
|
+
() => fetchBranchHead("o/r", "feat/missing", "tok"),
|
|
769
|
+
);
|
|
770
|
+
assertEquals(sha, null);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
test("fetchBranchHead: no usable transport (token mode, empty token) resolves to null, never throws", async () => {
|
|
774
|
+
// The documented contract promises `null` when no transport is usable, matching fetchPrHead /
|
|
775
|
+
// fetchPrBase — a missing token under the token transport must not surface an exception to callers
|
|
776
|
+
// relying on the Promise<string | null> shape.
|
|
777
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
778
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
779
|
+
try {
|
|
780
|
+
const sha = await fetchBranchHead("o/r", "feat/x", "");
|
|
781
|
+
assertEquals(sha, null);
|
|
782
|
+
} finally {
|
|
783
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
784
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// ── fetchPrHead — the PR head reader surfaces the head branch's OWNING repo (issue #786) ─────
|
|
789
|
+
//
|
|
790
|
+
// The no-progress head reader resolves the head ref in `headRepo`, so a cross-repo (fork) PR reads
|
|
791
|
+
// the fork's ref, not a same-named branch in the base repo (which would resolve to an unrelated
|
|
792
|
+
// SHA). These assert the transport-level mapping of the source repository through `fetchPrHead`'s
|
|
793
|
+
// REST branch (forced via the token transport), which the handler-level tests — injecting an
|
|
794
|
+
// already-parsed `{ headRepo }` — do not exercise.
|
|
795
|
+
test("fetchPrHead: REST maps head.repo.full_name to the fork's source repository", async () => {
|
|
796
|
+
const head = await withRefFetch(
|
|
797
|
+
(path) => {
|
|
798
|
+
assertEquals(path, "base/repo/pulls/789");
|
|
799
|
+
return {
|
|
800
|
+
status: 200,
|
|
801
|
+
body: {
|
|
802
|
+
head: { ref: "feat/x", sha: "cafef00d", repo: { full_name: "fork-owner/repo" } },
|
|
803
|
+
base: { ref: "main" },
|
|
804
|
+
},
|
|
805
|
+
};
|
|
806
|
+
},
|
|
807
|
+
() => fetchPrHead("base/repo", 789, "tok"),
|
|
808
|
+
);
|
|
809
|
+
assertEquals(head, { headRef: "feat/x", headSha: "cafef00d", baseRef: "main", headRepo: "fork-owner/repo" });
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("fetchPrHead: REST fails open to headRepo=null when the head repo is absent (deleted fork)", async () => {
|
|
813
|
+
const head = await withRefFetch(
|
|
814
|
+
() => ({
|
|
815
|
+
status: 200,
|
|
816
|
+
body: { head: { ref: "feat/x", sha: "cafef00d", repo: null }, base: { ref: "main" } },
|
|
817
|
+
}),
|
|
818
|
+
() => fetchPrHead("base/repo", 789, "tok"),
|
|
819
|
+
);
|
|
820
|
+
// A null head repo must surface as headRepo=null (the reader then fails open), never the base repo.
|
|
821
|
+
assertEquals(head?.headRepo, null);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
test("fetchPrHead: no usable transport (token mode, empty token) resolves to null, never throws", async () => {
|
|
825
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
826
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
827
|
+
try {
|
|
828
|
+
const head = await fetchPrHead("o/r", 1, "");
|
|
829
|
+
assertEquals(head, null);
|
|
830
|
+
} finally {
|
|
831
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
832
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
// A branch name may legally contain `#`, `?`, or spaces. The reader must percent-encode each ref
|
|
837
|
+
// SEGMENT (preserving `/`) before building the API path/URL — otherwise a `#` starts a URL fragment,
|
|
838
|
+
// the path is truncated to the wrong ref, and the no-progress guard fails open (issue #786).
|
|
839
|
+
test("fetchBranchHead: percent-encodes a special-character branch ref (preserving '/')", async () => {
|
|
840
|
+
const sha = await withRefFetch(
|
|
841
|
+
(path) => {
|
|
842
|
+
// The `#` must survive as %23 inside the path, not truncate it into a URL fragment.
|
|
843
|
+
assertEquals(path, "o/r/git/ref/heads/feat/x%23123");
|
|
844
|
+
return { status: 200, body: { object: { sha: "cafef00d" } } };
|
|
845
|
+
},
|
|
846
|
+
() => fetchBranchHead("o/r", "feat/x#123", "tok"),
|
|
847
|
+
);
|
|
848
|
+
assertEquals(sha, "cafef00d");
|
|
849
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -1067,17 +1067,25 @@ export async function fetchPrFiles(
|
|
|
1067
1067
|
return paths;
|
|
1068
1068
|
}
|
|
1069
1069
|
|
|
1070
|
-
/** The PR head ref/sha for D3's trial-merge gate. `null` when no transport is usable.
|
|
1070
|
+
/** The PR head ref/sha for D3's trial-merge gate. `null` when no transport is usable. `headRepo` is
|
|
1071
|
+
* the head branch's OWNING repository as `owner/repo` — the FORK for a cross-repo PR, else the base
|
|
1072
|
+
* repo — so a caller that resolves the head ref (e.g. the no-progress head reader, #786) queries the
|
|
1073
|
+
* repository the head branch actually lives in, not the base repo (where a same-named branch would
|
|
1074
|
+
* resolve to an unrelated SHA). `null` when the head repository cannot be resolved (e.g. a deleted
|
|
1075
|
+
* fork). */
|
|
1071
1076
|
export async function fetchPrHead(
|
|
1072
1077
|
repo: string,
|
|
1073
1078
|
number: number | string,
|
|
1074
1079
|
token: string,
|
|
1075
|
-
): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null } | null> {
|
|
1080
|
+
): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null; headRepo: string | null } | null> {
|
|
1076
1081
|
if (await useGh()) {
|
|
1077
|
-
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName"]);
|
|
1082
|
+
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName,headRepository,headRepositoryOwner"]);
|
|
1078
1083
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
1079
|
-
const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null; baseRefName?: string | null };
|
|
1080
|
-
|
|
1084
|
+
const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null; baseRefName?: string | null; headRepository?: { name?: string | null } | null; headRepositoryOwner?: { login?: string | null } | null };
|
|
1085
|
+
const owner = j.headRepositoryOwner?.login;
|
|
1086
|
+
const name = j.headRepository?.name;
|
|
1087
|
+
const headRepo = owner && name ? `${owner}/${name}` : null;
|
|
1088
|
+
return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null, baseRef: j.baseRefName ?? null, headRepo };
|
|
1081
1089
|
}
|
|
1082
1090
|
if (!token) return null;
|
|
1083
1091
|
const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
|
|
@@ -1085,8 +1093,29 @@ export async function fetchPrHead(
|
|
|
1085
1093
|
});
|
|
1086
1094
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
1087
1095
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
1088
|
-
const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null }; base?: { ref?: string | null } };
|
|
1089
|
-
return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null };
|
|
1096
|
+
const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null; repo?: { full_name?: string | null } | null }; base?: { ref?: string | null } };
|
|
1097
|
+
return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null, headRepo: j.head?.repo?.full_name ?? null };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** The head commit SHA of `branch` on `repo`, read from the git-ref endpoint
|
|
1101
|
+
* (`git/ref/heads/<branch>`) — the ref that GitHub updates ATOMICALLY with the push, unlike a PR
|
|
1102
|
+
* object's `head.sha`, which is an asynchronously-denormalized projection that can briefly report a
|
|
1103
|
+
* stale-but-valid SHA after a push. The no-progress guard (#786) reads this in preference to the PR
|
|
1104
|
+
* head so a lagging PR denormalization can never fabricate a no-advance escalation. `null` when the
|
|
1105
|
+
* branch does not exist (a 404) or no transport is usable; throws only on a genuine transport
|
|
1106
|
+
* failure. */
|
|
1107
|
+
export async function fetchBranchHead(
|
|
1108
|
+
repo: string,
|
|
1109
|
+
branch: string,
|
|
1110
|
+
token: string,
|
|
1111
|
+
): Promise<string | null> {
|
|
1112
|
+
// Honor the documented no-transport contract at this public boundary, exactly like the sibling
|
|
1113
|
+
// readers `fetchPrHead`/`fetchPrBase`: with no `gh` CLI and no token there is no usable transport,
|
|
1114
|
+
// which is the idle "unknown" case → `null`, NOT an exception. The internal `branchHeadSha` still
|
|
1115
|
+
// throws in that case for `ensureBaseBranch`'s callers, which treat a missing transport as a hard
|
|
1116
|
+
// failure; this wrapper's `Promise<string | null>` contract promises `null` instead.
|
|
1117
|
+
if (!(await useGh()) && !token) return null;
|
|
1118
|
+
return branchHeadSha(repo, branch, token);
|
|
1090
1119
|
}
|
|
1091
1120
|
|
|
1092
1121
|
/** The PR's current base branch ref — the branch this PR would land *into*. `null` when no
|
|
@@ -1483,7 +1512,13 @@ function isEpicBranch(branch: string): boolean {
|
|
|
1483
1512
|
/** Resolve the head commit SHA of `branch` on `repo`, or `null` when the branch does not exist
|
|
1484
1513
|
* (a 404 from the git-ref endpoint). Throws only on a genuine transport failure. */
|
|
1485
1514
|
async function branchHeadSha(repo: string, branch: string, token: string): Promise<string | null> {
|
|
1486
|
-
|
|
1515
|
+
// Percent-encode each ref SEGMENT (git permits `#`, `?`, spaces, etc. in a branch name) while
|
|
1516
|
+
// preserving the `/` separators that git uses for hierarchical refs (`feat/x`). Interpolating the
|
|
1517
|
+
// raw name would, in the direct `fetch` URL, let a `#` start a fragment (and `?` a query) — the
|
|
1518
|
+
// path is truncated, the wrong ref (or a 404) is read, and the no-progress guard fails open. gh
|
|
1519
|
+
// api receives the same already-encoded path.
|
|
1520
|
+
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
|
|
1521
|
+
const apiPath = `repos/${repo}/git/ref/heads/${encodedBranch}`;
|
|
1487
1522
|
if (await useGh()) {
|
|
1488
1523
|
try {
|
|
1489
1524
|
const out = await runGh(["api", apiPath]);
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// `addressed` row for this `round`. Re-inserting a `rounds` row there would record one round as
|
|
5
5
|
// both `addressed` and `blocked`, making round history/UI ambiguous. The stalled arm therefore
|
|
6
6
|
// passes `recordRound=false`, which must suppress the round insert while still opening the
|
|
7
|
-
// escalation.
|
|
8
|
-
//
|
|
7
|
+
// escalation. After #786/#789 the max-rounds arm ALSO runs after `persist-round` (the round-cap
|
|
8
|
+
// guard moved downstream of progress classification) and likewise passes `recordRound=false`; an
|
|
9
|
+
// agent-raised arm with no prior round row omits the flag and must still record the round.
|
|
9
10
|
import { test } from "node:test";
|
|
10
11
|
import { assertEquals } from "#test-assert";
|
|
11
12
|
import handler from "../workers/persist-escalation/worker.ts";
|
|
@@ -49,9 +50,9 @@ test("stalled arm (recordRound=false) does not insert a duplicate rounds row", a
|
|
|
49
50
|
assertEquals((out as any).escalationId, 42);
|
|
50
51
|
});
|
|
51
52
|
|
|
52
|
-
test("
|
|
53
|
+
test("an agent-raised arm without the flag still records the round", async () => {
|
|
53
54
|
const { app, inserts } = fakeApp();
|
|
54
|
-
const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "
|
|
55
|
+
const job = { variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "needs input" } };
|
|
55
56
|
await handler(job as any, app as any);
|
|
56
57
|
assertEquals(inserts.rounds.length, 1);
|
|
57
58
|
assertEquals((inserts.rounds[0] as any).round_no, 3);
|
|
@@ -183,14 +184,15 @@ test("persist-escalation heals from the prKey when repo/prNumber are absent", as
|
|
|
183
184
|
|
|
184
185
|
// #333 — the control-flow escalation arms (no-progress / review-stalled / unaddressed-comments /
|
|
185
186
|
// max-rounds) each set an explicit `status="blocked"` + a concrete `question` via `zeebe:input`
|
|
186
|
-
// (recordRound=false
|
|
187
|
+
// (recordRound=false on every arm that runs after `persist-round` — which, after #786/#789 moved the
|
|
188
|
+
// round-cap guard downstream of progress classification, now INCLUDES max-rounds). They route through
|
|
187
189
|
// `gw-escalated`, which branches on the worker's `escalated` output. This pins the contract that
|
|
188
190
|
// gateway depends on: a control-flow arm with a real question OPENS an escalation and returns
|
|
189
191
|
// `escalated:true` + the (trimmed) question, so gw-escalated parks a wait carrying that question —
|
|
190
192
|
// never a dead wait with a null question (the #333 defect).
|
|
191
193
|
test("a control-flow arm with a concrete question opens an escalation gw-escalated can park", async () => {
|
|
192
194
|
const { app, inserts } = fakeApp();
|
|
193
|
-
const question = "No review arrived within the review-wait timeout (
|
|
195
|
+
const question = "No review arrived within the review-wait timeout (PT30M). A human must decide how to proceed.";
|
|
194
196
|
const job = { variables: { prKey: "o/r#5", round: 2, status: "blocked", question, recordRound: false } };
|
|
195
197
|
const out = await handler(job as any, app as any);
|
|
196
198
|
assertEquals((out as any).escalated, true, "a real control-flow escalation reports escalated:true");
|