@nanobpm/nano-workforce 0.97.1 → 0.98.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.
- package/CHANGELOG.md +14 -0
- package/app/capabilityNeed.test.ts +156 -0
- package/app/capabilityNeed.ts +200 -0
- package/app/capsWait.test.ts +39 -0
- package/app/capsWait.ts +33 -0
- package/app/contracts.ts +15 -0
- package/app/github.ts +22 -4
- package/app/mergeEscalationQuestion.test.ts +17 -0
- package/app/mergeRetry.test.ts +90 -0
- package/app/mergeRetryArm.test.ts +100 -0
- package/app/plan.ts +63 -0
- package/app/service.test.ts +375 -1
- package/app/service.ts +249 -0
- package/app/waitGateVisibility.test.ts +5 -2
- package/db/migrations/049_plan_task_needs.sql +30 -0
- package/db/migrations/050_capability_gates.sql +40 -0
- package/e2e/plan-fanout.e2e.ts +145 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +124 -68
- package/resources/processes/plan-fanout.bpmn +347 -194
- package/resources/prompts/plan.md +29 -1
- package/workers/caps-prepare/worker.test.ts +62 -0
- package/workers/caps-prepare/worker.ts +38 -0
- package/workers/merge/worker.test.ts +38 -0
- package/workers/merge/worker.ts +9 -3
- package/workers/record-plan/worker.test.ts +33 -1
- package/workers/record-plan/worker.ts +24 -1
- package/workers/select-wave/worker.test.ts +34 -2
- package/workers/select-wave/worker.ts +26 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## [0.98.1](https://github.com/nanobpm/nano-workforce/compare/v0.98.0...v0.98.1) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **merge-loop:** transient 'Base branch was modified' merge race → bounded retry, not escalate ([#335](https://github.com/nanobpm/nano-workforce/issues/335)) ([4093d50](https://github.com/nanobpm/nano-workforce/commit/4093d50b0ecd1a226bcfe7a5ecca10ba1db0937a)), closes [#330](https://github.com/nanobpm/nano-workforce/issues/330) [#334](https://github.com/nanobpm/nano-workforce/issues/334)
|
|
7
|
+
|
|
8
|
+
# [0.98.0](https://github.com/nanobpm/nano-workforce/compare/v0.97.1...v0.98.0) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* host-orchestrated per-task capability barrier for epic dispatch ([#289](https://github.com/nanobpm/nano-workforce/issues/289)) ([#290](https://github.com/nanobpm/nano-workforce/issues/290)) ([bb2f4bf](https://github.com/nanobpm/nano-workforce/commit/bb2f4bffb04b63d3b43bdec762d37770d03b1efb)), closes [263/#274](https://github.com/nanobpm/nano-workforce/issues/274)
|
|
14
|
+
|
|
1
15
|
## [0.97.1](https://github.com/nanobpm/nano-workforce/compare/v0.97.0...v0.97.1) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Unit coverage for the cross-repo capability-edge helpers (app/capabilityNeed.ts, issue #289).
|
|
2
|
+
//
|
|
3
|
+
// The gate wiring is proven through the process; these tests pin the pure surface: tolerant need
|
|
4
|
+
// parsing + de-dupe, the handle → releases-repo derivation, the need → readiness-gate input mapping
|
|
5
|
+
// (reusing the #274 `capability` probe verbatim), the gate-key shape, and the late-bind prompt brief.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assert, assertEquals, assertStringIncludes, assertThrows } from "#test-assert";
|
|
8
|
+
import {
|
|
9
|
+
type CapabilityNeed,
|
|
10
|
+
capabilityGateKey,
|
|
11
|
+
capabilityNeedToProbeInput,
|
|
12
|
+
capabilityRefNumber,
|
|
13
|
+
capabilityReleasesRepo,
|
|
14
|
+
parseCapabilityNeed,
|
|
15
|
+
parseCapabilityNeeds,
|
|
16
|
+
renderResolvedDepsBrief,
|
|
17
|
+
UnresolvableCapabilityRefError,
|
|
18
|
+
} from "./capabilityNeed.ts";
|
|
19
|
+
|
|
20
|
+
// ── parseCapabilityNeed / parseCapabilityNeeds ───────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
test("parseCapabilityNeed: a well-formed need round-trips its fields", () => {
|
|
23
|
+
const need = parseCapabilityNeed({
|
|
24
|
+
capabilityRef: "nanobpm/nano-ide#274",
|
|
25
|
+
package: "@nanobpm/urban",
|
|
26
|
+
verifyCommand: "node -e 0",
|
|
27
|
+
});
|
|
28
|
+
assertEquals(need, {
|
|
29
|
+
capabilityRef: "nanobpm/nano-ide#274",
|
|
30
|
+
package: "@nanobpm/urban",
|
|
31
|
+
verifyCommand: "node -e 0",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("parseCapabilityNeed: verifyCommand is dropped when blank (deterministic-only edge)", () => {
|
|
36
|
+
const need = parseCapabilityNeed({ capabilityRef: "#274", package: "@nanobpm/urban", verifyCommand: " " });
|
|
37
|
+
assertEquals(need, { capabilityRef: "#274", package: "@nanobpm/urban" });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("parseCapabilityNeed: a blank capabilityRef or package is dropped (unusable, never throws)", () => {
|
|
41
|
+
assertEquals(parseCapabilityNeed({ capabilityRef: " ", package: "@nanobpm/urban" }), null);
|
|
42
|
+
assertEquals(parseCapabilityNeed({ capabilityRef: "#274", package: "" }), null);
|
|
43
|
+
assertEquals(parseCapabilityNeed(null), null);
|
|
44
|
+
assertEquals(parseCapabilityNeed("nope"), null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("parseCapabilityNeeds: drops malformed entries and de-dupes on capabilityRef@package", () => {
|
|
48
|
+
const needs = parseCapabilityNeeds([
|
|
49
|
+
{ capabilityRef: "owner/repo#1", package: "@nanobpm/urban" },
|
|
50
|
+
{ capabilityRef: "owner/repo#1", package: "@nanobpm/urban" }, // dup
|
|
51
|
+
{ capabilityRef: "owner/repo#1", package: "@nanobpm/agentic" }, // different package, kept
|
|
52
|
+
{ capabilityRef: " ", package: "@x" }, // malformed
|
|
53
|
+
"garbage",
|
|
54
|
+
]);
|
|
55
|
+
assertEquals(needs.length, 2);
|
|
56
|
+
assertEquals(needs[0], { capabilityRef: "owner/repo#1", package: "@nanobpm/urban" });
|
|
57
|
+
assertEquals(needs[1], { capabilityRef: "owner/repo#1", package: "@nanobpm/agentic" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("parseCapabilityNeeds: a non-array is []", () => {
|
|
61
|
+
assertEquals(parseCapabilityNeeds(undefined), []);
|
|
62
|
+
assertEquals(parseCapabilityNeeds({}), []);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── capabilityRefNumber / capabilityReleasesRepo ─────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
test("capabilityRefNumber: extracts the trailing number from any handle form", () => {
|
|
68
|
+
assertEquals(capabilityRefNumber("nanobpm/nano-ide#274"), "274");
|
|
69
|
+
assertEquals(capabilityRefNumber("nano-ide#274"), "274");
|
|
70
|
+
assertEquals(capabilityRefNumber("#274"), "274");
|
|
71
|
+
assertEquals(capabilityRefNumber("274"), "274");
|
|
72
|
+
assertEquals(capabilityRefNumber("no-number-here"), undefined);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("capabilityReleasesRepo: owner/repo prefix yields the releases source, bare/short forms yield undefined", () => {
|
|
76
|
+
assertEquals(capabilityReleasesRepo("nanobpm/nano-ide#274"), "nanobpm/nano-ide");
|
|
77
|
+
assertEquals(capabilityReleasesRepo("nano-ide#274"), undefined);
|
|
78
|
+
assertEquals(capabilityReleasesRepo("#274"), undefined);
|
|
79
|
+
assertEquals(capabilityReleasesRepo("a/b/c#1"), undefined);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── capabilityGateKey ────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
test("capabilityGateKey: <planKey>:<taskId>:<capabilityRef>:<package> (stable across a resume)", () => {
|
|
85
|
+
assertEquals(
|
|
86
|
+
capabilityGateKey("owner/repo#289", "issue-289", "nanobpm/nano-ide#274", "@nanobpm/urban"),
|
|
87
|
+
"owner/repo#289:issue-289:nanobpm/nano-ide#274:@nanobpm/urban",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("capabilityGateKey: same capabilityRef, different package → distinct keys (no gate collision)", () => {
|
|
92
|
+
const a = capabilityGateKey("owner/repo#289", "issue-289", "nanobpm/nano-ide#274", "@nanobpm/urban");
|
|
93
|
+
const b = capabilityGateKey("owner/repo#289", "issue-289", "nanobpm/nano-ide#274", "@nanobpm/urban-testkit");
|
|
94
|
+
assertEquals(a === b, false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── capabilityNeedToProbeInput ───────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
test("capabilityNeedToProbeInput: maps a need to the readiness-gate capability probe input", () => {
|
|
100
|
+
const need: CapabilityNeed = {
|
|
101
|
+
capabilityRef: "nanobpm/nano-ide#274",
|
|
102
|
+
package: "@nanobpm/urban",
|
|
103
|
+
verifyCommand: "verify.sh",
|
|
104
|
+
};
|
|
105
|
+
const input = capabilityNeedToProbeInput(need, {
|
|
106
|
+
planKey: "owner/repo#289",
|
|
107
|
+
taskId: "issue-289",
|
|
108
|
+
probeTimeout: "PT12H",
|
|
109
|
+
});
|
|
110
|
+
assertEquals(input.gateKey, "owner/repo#289:issue-289:nanobpm/nano-ide#274:@nanobpm/urban");
|
|
111
|
+
assertEquals(input.probeTimeout, "PT12H");
|
|
112
|
+
assertEquals(input.onTimeout, "escalate");
|
|
113
|
+
assertEquals(input.probe.kind, "capability");
|
|
114
|
+
assertEquals(input.probe.target, "github-releases:nanobpm/nano-ide");
|
|
115
|
+
assertEquals(input.probe.onTimeout, "escalate");
|
|
116
|
+
assertEquals(input.probe.match?.capabilityRef, "nanobpm/nano-ide#274");
|
|
117
|
+
assertEquals(input.probe.match?.package, "@nanobpm/urban");
|
|
118
|
+
assertEquals(input.probe.match?.verifyCommand, "verify.sh");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("capabilityNeedToProbeInput: omits verifyCommand when the need has none", () => {
|
|
122
|
+
const input = capabilityNeedToProbeInput(
|
|
123
|
+
{ capabilityRef: "nanobpm/nano-ide#274", package: "@nanobpm/urban" },
|
|
124
|
+
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
|
|
125
|
+
);
|
|
126
|
+
assertEquals(input.probe.match?.verifyCommand, undefined);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("capabilityNeedToProbeInput: throws when the handle names no releases source (fail loudly)", () => {
|
|
130
|
+
assertThrows(
|
|
131
|
+
() =>
|
|
132
|
+
capabilityNeedToProbeInput(
|
|
133
|
+
{ capabilityRef: "#274", package: "@nanobpm/urban" },
|
|
134
|
+
{ planKey: "o/r#1", taskId: "t1", probeTimeout: "PT1H" },
|
|
135
|
+
),
|
|
136
|
+
UnresolvableCapabilityRefError,
|
|
137
|
+
"names no owner/repo releases source",
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// ── renderResolvedDepsBrief ──────────────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
test("renderResolvedDepsBrief: empty list renders nothing (unconditional concatenation is safe)", () => {
|
|
144
|
+
assertEquals(renderResolvedDepsBrief([]), "");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("renderResolvedDepsBrief: pins each capabilityRef → resolvedArtifact", () => {
|
|
148
|
+
const brief = renderResolvedDepsBrief([
|
|
149
|
+
{ capabilityRef: "nanobpm/nano-ide#274", resolvedArtifact: "@nanobpm/urban@0.54.0" },
|
|
150
|
+
{ capabilityRef: "nanobpm/nano-ide#280", resolvedArtifact: "@nanobpm/agentic@0.9.0" },
|
|
151
|
+
]);
|
|
152
|
+
assert(brief.startsWith("\n\n---\n"));
|
|
153
|
+
assertStringIncludes(brief, "`nanobpm/nano-ide#274` → `@nanobpm/urban@0.54.0`");
|
|
154
|
+
assertStringIncludes(brief, "`nanobpm/nano-ide#280` → `@nanobpm/agentic@0.9.0`");
|
|
155
|
+
assertStringIncludes(brief, "pin these EXACT versions");
|
|
156
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// nano-workforce — the cross-repo CAPABILITY EDGE authoring/plumbing helpers (issue #289).
|
|
2
|
+
//
|
|
3
|
+
// This is the pure, I/O-free half of the "consumer readiness edge" (ADR 0001 §4, #263): a plan task
|
|
4
|
+
// declares that it consumes an upstream capability C that ships as some `pkg@version` from ANOTHER
|
|
5
|
+
// repo, and must not start until C first ships. The planner emits this as `RecordPlanTask.needs[]`
|
|
6
|
+
// (plan-fanout.bpmn); `pr.record-plan` levelizes it to `plan_task_needs` and `pr.select-wave` reads
|
|
7
|
+
// it back to gate the task before dispatch.
|
|
8
|
+
//
|
|
9
|
+
// The gate itself is the EXISTING durable `readiness-gate` process (#258) driven with a `capability`
|
|
10
|
+
// {@link ReadinessProbe} (#274) — this module never re-implements the wait or the matcher; it only
|
|
11
|
+
// (a) normalises a raw planner need, (b) maps a need to the gate's `ReadinessProbeIn`, and (c) renders
|
|
12
|
+
// the late-bound "resolved-dependencies" prompt brief that pins each `pkg@version` into the agent's
|
|
13
|
+
// context (mirroring `renderBaseBranchBrief` in app/plan.ts). Pure + unit-testable — no network, no DB.
|
|
14
|
+
import type { OnTimeout, ProbeMatch, ReadinessProbe } from "./readiness.ts";
|
|
15
|
+
|
|
16
|
+
/** A single cross-repo capability dependency declared on a plan task — the stable authoring contract
|
|
17
|
+
* (#263: declare the HANDLE, never a version). Mirrors the `CapabilityNeed` `nano:shape` in
|
|
18
|
+
* plan-fanout.bpmn so it is typed end to end. */
|
|
19
|
+
export interface CapabilityNeed {
|
|
20
|
+
/** The upstream capability handle — `owner/repo#NNN`, `repo#NNN`, or the bare `#NNN`. It carries
|
|
21
|
+
* the provenance issue/PR ref the resolved version must reference; the leading `owner/repo` (when
|
|
22
|
+
* present) also names the releases source repo the gate polls (see {@link capabilityReleasesRepo}). */
|
|
23
|
+
readonly capabilityRef: string;
|
|
24
|
+
/** The artifact whose GitHub Releases carry the publish provenance (e.g. `@nanobpm/urban`).
|
|
25
|
+
* Per-package scoped — a sibling package's provenance can never resolve this edge. */
|
|
26
|
+
readonly package: string;
|
|
27
|
+
/** OPTIONAL gated empirical verifier (#274 decision 5): run ONCE at the gate boundary against the
|
|
28
|
+
* newest published `package` version when deterministic provenance resolved nothing. Left unset,
|
|
29
|
+
* the edge is deterministic-provenance-only. */
|
|
30
|
+
readonly verifyCommand?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The gate's input envelope — mirrors the `ReadinessProbeIn` `nano:shape` in readiness-gate.bpmn
|
|
34
|
+
* (`gateKey`, `probeTimeout`, optional `onTimeout`, nested `probe`). Kept local (not imported) so this
|
|
35
|
+
* pure module never depends on the generated worker-io types. */
|
|
36
|
+
export interface ReadinessProbeInput {
|
|
37
|
+
readonly gateKey: string;
|
|
38
|
+
readonly probeTimeout: string;
|
|
39
|
+
readonly onTimeout?: OnTimeout;
|
|
40
|
+
readonly probe: ReadinessProbe;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One resolved capability edge — the late-bound fact the gate hands back (`capabilityRef →
|
|
44
|
+
* resolvedArtifact`, e.g. `nanobpm/nano-ide#274 → @nanobpm/urban@0.54.0`). Fanned in over a task's
|
|
45
|
+
* needs and rendered into the implementation prompt by {@link renderResolvedDepsBrief}. */
|
|
46
|
+
export interface ResolvedCapability {
|
|
47
|
+
readonly capabilityRef: string;
|
|
48
|
+
readonly resolvedArtifact: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
|
|
52
|
+
|
|
53
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
54
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
55
|
+
|
|
56
|
+
/** Normalise ONE raw planner-emitted need into a {@link CapabilityNeed}, or `null` when it is unusable
|
|
57
|
+
* (blank `capabilityRef` or `package`). A malformed need is DROPPED rather than throwing so one bad
|
|
58
|
+
* entry can never fail the whole plan record — mirroring `record-plan`'s tolerant task normalisation. */
|
|
59
|
+
export function parseCapabilityNeed(raw: unknown): CapabilityNeed | null {
|
|
60
|
+
if (!isRecord(raw)) return null;
|
|
61
|
+
const capabilityRef = str(raw.capabilityRef).trim();
|
|
62
|
+
const pkg = str(raw.package).trim();
|
|
63
|
+
if (capabilityRef === "" || pkg === "") return null;
|
|
64
|
+
const verifyCommand = str(raw.verifyCommand).trim();
|
|
65
|
+
return { capabilityRef, package: pkg, ...(verifyCommand === "" ? {} : { verifyCommand }) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Normalise a whole `needs[]` array, dropping malformed entries and de-duplicating on
|
|
69
|
+
* `capabilityRef@package` (a task that lists the same edge twice must gate on it once). */
|
|
70
|
+
export function parseCapabilityNeeds(raw: unknown): CapabilityNeed[] {
|
|
71
|
+
if (!Array.isArray(raw)) return [];
|
|
72
|
+
const out: CapabilityNeed[] = [];
|
|
73
|
+
const seen = new Set<string>();
|
|
74
|
+
for (const entry of raw) {
|
|
75
|
+
const need = parseCapabilityNeed(entry);
|
|
76
|
+
if (!need) continue;
|
|
77
|
+
const key = `${need.capabilityRef}\u0000${need.package}`;
|
|
78
|
+
if (seen.has(key)) continue;
|
|
79
|
+
seen.add(key);
|
|
80
|
+
out.push(need);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The bare numeric id of a capability handle — `nanobpm/nano-ide#274`, `nano-ide#274`, `#274`, and a
|
|
86
|
+
* naked `274` all normalise to `274`. Returns `undefined` for a handle with no number (never
|
|
87
|
+
* resolvable). Kept in lockstep with `capabilityNumber` in app/readiness.ts (the matcher's predicate). */
|
|
88
|
+
export function capabilityRefNumber(capabilityRef: string): string | undefined {
|
|
89
|
+
const m = capabilityRef.match(/(\d+)\s*$/);
|
|
90
|
+
return m ? m[1] : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The releases-source repo a capability handle names, as `owner/repo`, or `undefined` when the handle
|
|
94
|
+
* carries no `owner/repo` prefix (a bare `#274` / `repo#274`). The gate polls THIS repo's GitHub
|
|
95
|
+
* Releases for the package's publish provenance, so a handle without it cannot be gated deterministically
|
|
96
|
+
* — the planner is taught to always write the full `owner/repo#NNN` (resources/prompts/plan.md). */
|
|
97
|
+
export function capabilityReleasesRepo(capabilityRef: string): string | undefined {
|
|
98
|
+
const beforeHash = capabilityRef.split("#")[0]?.trim() ?? "";
|
|
99
|
+
const m = beforeHash.match(/^([^/\s]+\/[^/\s]+)$/);
|
|
100
|
+
return m ? m[1] : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Raised when a capability need cannot be turned into a gateable probe because its handle names no
|
|
104
|
+
* `owner/repo` releases source (e.g. a bare `#274`). Fail LOUDLY at wiring time — a silently
|
|
105
|
+
* un-pollable edge would only surface as a spurious gate timeout much later. */
|
|
106
|
+
export class UnresolvableCapabilityRefError extends Error {
|
|
107
|
+
readonly capabilityRef: string;
|
|
108
|
+
constructor(capabilityRef: string) {
|
|
109
|
+
super(
|
|
110
|
+
`capability need '${capabilityRef}': the handle names no owner/repo releases source. ` +
|
|
111
|
+
`Declare the full 'owner/repo#NNN' handle so the gate knows which repo's Releases to poll.`,
|
|
112
|
+
);
|
|
113
|
+
this.name = "UnresolvableCapabilityRefError";
|
|
114
|
+
this.capabilityRef = capabilityRef;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The gate's correlation/idempotency key for a task's capability edge:
|
|
119
|
+
* `<planKey>:<taskId>:<capabilityRef>:<package>` (issue #289 design §2). Stable across a resume so a
|
|
120
|
+
* re-dispatched agent re-attaches to the same gate. `package` is part of the identity because a task
|
|
121
|
+
* can legitimately declare the SAME `capabilityRef` for different packages (`parseCapabilityNeeds`
|
|
122
|
+
* de-dupes on `capabilityRef+package`; `plan_task_needs`' PK includes `package`) — each
|
|
123
|
+
* `(capabilityRef, package)` edge is a distinct need with its own gate row, so the key must be 1:1
|
|
124
|
+
* with the need or two edges would collide on one gate row and only one could ever resolve. */
|
|
125
|
+
export function capabilityGateKey(
|
|
126
|
+
planKey: string,
|
|
127
|
+
taskId: string,
|
|
128
|
+
capabilityRef: string,
|
|
129
|
+
pkg: string,
|
|
130
|
+
): string {
|
|
131
|
+
return `${planKey}:${taskId}:${capabilityRef}:${pkg}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The message name plan-fanout's per-task capability barrier (`wait-caps-resolved`) subscribes to and
|
|
135
|
+
* the host publishes to release the gated task once every one of its capability needs has resolved
|
|
136
|
+
* (issue #289 §2/§3). The single source of truth for the string shared by the BPMN subscription and
|
|
137
|
+
* the host publisher, mirroring `WAVE_MERGED_MESSAGE`. */
|
|
138
|
+
export const CAPS_RESOLVED_MESSAGE = "caps-resolved";
|
|
139
|
+
|
|
140
|
+
/** The per-TASK barrier correlation key `<planKey>:<taskId>` the `wait-caps-resolved` catch binds
|
|
141
|
+
* and the host publishes `caps-resolved` on (issue #289 §2). Distinct from {@link capabilityGateKey}
|
|
142
|
+
* (which is per-NEED): a task fans in ALL its needs, so its barrier releases ONCE, keyed on the task —
|
|
143
|
+
* not once per need. Stable across a resume so a re-dispatched fan-out re-attaches to the same barrier. */
|
|
144
|
+
export function capabilityTaskBarrierKey(planKey: string, taskId: string): string {
|
|
145
|
+
return `${planKey}:${taskId}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Map a normalised {@link CapabilityNeed} to the EXISTING `readiness-gate` process input (#289 §2):
|
|
149
|
+
* a `capability` probe scanning the handle's releases source for the package's provenance, bounded by
|
|
150
|
+
* `probeTimeout` and escalating on timeout. Reuses the gate + matcher verbatim (derivation over
|
|
151
|
+
* duplication) — this only shapes the descriptor. Throws {@link UnresolvableCapabilityRefError} when
|
|
152
|
+
* the handle names no releases source. */
|
|
153
|
+
export function capabilityNeedToProbeInput(
|
|
154
|
+
need: CapabilityNeed,
|
|
155
|
+
opts: { planKey: string; taskId: string; probeTimeout: string },
|
|
156
|
+
): ReadinessProbeInput {
|
|
157
|
+
const repo = capabilityReleasesRepo(need.capabilityRef);
|
|
158
|
+
if (!repo) throw new UnresolvableCapabilityRefError(need.capabilityRef);
|
|
159
|
+
const match: ProbeMatch = {
|
|
160
|
+
capabilityRef: need.capabilityRef,
|
|
161
|
+
package: need.package,
|
|
162
|
+
...(need.verifyCommand ? { verifyCommand: need.verifyCommand } : {}),
|
|
163
|
+
};
|
|
164
|
+
const probe: ReadinessProbe = {
|
|
165
|
+
kind: "capability",
|
|
166
|
+
target: `github-releases:${repo}`,
|
|
167
|
+
match,
|
|
168
|
+
onTimeout: "escalate",
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
gateKey: capabilityGateKey(opts.planKey, opts.taskId, need.capabilityRef, need.package),
|
|
172
|
+
probeTimeout: opts.probeTimeout,
|
|
173
|
+
onTimeout: "escalate",
|
|
174
|
+
probe,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Render the per-task "resolved-dependencies" brief appended to an implementer agent's prompt once
|
|
179
|
+
* every capability gate on the task has resolved (#289 §3), mirroring `renderBaseBranchBrief`. It pins
|
|
180
|
+
* each `capabilityRef → pkg@version` the gate discovered so the agent installs EXACTLY that version —
|
|
181
|
+
* no pre-named version, no human. Returns "" for an empty list so callers can concatenate unconditionally. */
|
|
182
|
+
export function renderResolvedDepsBrief(resolved: readonly ResolvedCapability[]): string {
|
|
183
|
+
if (resolved.length === 0) return "";
|
|
184
|
+
const lines = [
|
|
185
|
+
"",
|
|
186
|
+
"",
|
|
187
|
+
"---",
|
|
188
|
+
"",
|
|
189
|
+
"**Resolved cross-repo dependencies (authoritative — pin these EXACT versions):**",
|
|
190
|
+
"",
|
|
191
|
+
"An upstream capability this task depends on has shipped. Install/pin exactly the resolved",
|
|
192
|
+
"`package@version` below — do NOT bump, float, or re-resolve it:",
|
|
193
|
+
"",
|
|
194
|
+
];
|
|
195
|
+
for (const r of resolved) lines.push(`- \`${r.capabilityRef}\` → \`${r.resolvedArtifact}\``);
|
|
196
|
+
lines.push("");
|
|
197
|
+
lines.push("These versions first carry the capability your slice consumes; a newer or older version");
|
|
198
|
+
lines.push("may not. Treat them as the contract you build against.");
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Unit coverage for the capability-barrier bounded-wait duration policy (#289). The value is baked
|
|
2
|
+
// into every plan-fanout instance's `capsWaitTimeout` process variable and evaluated by the
|
|
3
|
+
// `wait-caps-timeout` timer arm on the `wait-caps-resolved` event-based gateway, so a malformed
|
|
4
|
+
// operator env must never deploy an uninterpretable `<bpmn:timeDuration>` — it falls back to the
|
|
5
|
+
// default instead. Run with `node --test`.
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { capsWaitTimeout, DEFAULT_CAPS_WAIT_TIMEOUT } from "./capsWait.ts";
|
|
10
|
+
|
|
11
|
+
test("capsWaitTimeout: blank / absent / malformed → default", () => {
|
|
12
|
+
assert.equal(capsWaitTimeout(undefined), DEFAULT_CAPS_WAIT_TIMEOUT);
|
|
13
|
+
assert.equal(capsWaitTimeout(""), DEFAULT_CAPS_WAIT_TIMEOUT);
|
|
14
|
+
assert.equal(capsWaitTimeout(" "), DEFAULT_CAPS_WAIT_TIMEOUT);
|
|
15
|
+
assert.equal(capsWaitTimeout("2h"), DEFAULT_CAPS_WAIT_TIMEOUT); // missing leading P/T
|
|
16
|
+
assert.equal(capsWaitTimeout("P"), DEFAULT_CAPS_WAIT_TIMEOUT); // no component
|
|
17
|
+
assert.equal(capsWaitTimeout("PT"), DEFAULT_CAPS_WAIT_TIMEOUT); // T with no time part
|
|
18
|
+
assert.equal(capsWaitTimeout("garbage"), DEFAULT_CAPS_WAIT_TIMEOUT);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("capsWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
|
|
22
|
+
assert.equal(capsWaitTimeout("PT30M"), "PT30M");
|
|
23
|
+
assert.equal(capsWaitTimeout("pt2h"), "PT2H");
|
|
24
|
+
assert.equal(capsWaitTimeout("P2D"), "P2D");
|
|
25
|
+
assert.equal(capsWaitTimeout(" pt15m "), "PT15M");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("capsWaitTimeout: an explicit fallback is honoured for a bad value", () => {
|
|
29
|
+
assert.equal(capsWaitTimeout("nope", "PT10M"), "PT10M");
|
|
30
|
+
assert.equal(capsWaitTimeout("PT45M", "PT10M"), "PT45M");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the default is itself a well-formed ISO-8601 duration (never an uninterpretable timer)", () => {
|
|
34
|
+
// Validate the default against the grammar with a *distinct* fallback: if the default were
|
|
35
|
+
// malformed it would fall through to the sentinel, so equality to itself proves it parses.
|
|
36
|
+
const sentinel = "PT1S";
|
|
37
|
+
assert.notEqual(DEFAULT_CAPS_WAIT_TIMEOUT, sentinel);
|
|
38
|
+
assert.equal(capsWaitTimeout(DEFAULT_CAPS_WAIT_TIMEOUT, sentinel), DEFAULT_CAPS_WAIT_TIMEOUT);
|
|
39
|
+
});
|
package/app/capsWait.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Capability-barrier bounded-wait policy (issue #289), kept as a pure module (no env, no I/O) so it
|
|
2
|
+
// is trivially testable — mirrors app/escalationSla.ts and app/reviewWait.ts. `app/plan.ts` seeds the
|
|
3
|
+
// validated `capsWaitTimeout` process variable at submit; the `wait-caps-timeout` timer catch on the
|
|
4
|
+
// `wait-caps-resolved` event-based gateway in plan-fanout.bpmn evaluates it at timer creation
|
|
5
|
+
// (FEEL-expression timer durations, engine-native).
|
|
6
|
+
//
|
|
7
|
+
// This models liveness IN the process: a task parked at the `wait-caps-resolved` capability barrier
|
|
8
|
+
// can no longer hang forever when a declared cross-repo capability never resolves — most acutely when
|
|
9
|
+
// the handle names no `owner/repo` releases source (an `UnresolvableCapabilityRefError`), so the host
|
|
10
|
+
// reconciler can never start a readiness-gate and never publishes `caps-resolved`. When this bound
|
|
11
|
+
// elapses the event-based gateway's timer arm fires and the token routes to the existing
|
|
12
|
+
// `feature-escalation` operator user task — a bounded wait + operator escalation, not a poller-side
|
|
13
|
+
// watchdog and not a silent wedge.
|
|
14
|
+
|
|
15
|
+
import { isoDuration } from "./reviewWait.ts";
|
|
16
|
+
|
|
17
|
+
/** Default capability-barrier bound (ISO-8601 duration): how long a task may park at
|
|
18
|
+
* `wait-caps-resolved` waiting for every declared cross-repo capability to ship before the timer arm
|
|
19
|
+
* fires and the token escalates to an operator. A day is generous for a genuine cross-team dependency
|
|
20
|
+
* to publish while still bounding the wait — and it decisively unwedges a permanently-unresolvable
|
|
21
|
+
* capability handle, which would otherwise never resolve at all. */
|
|
22
|
+
export const DEFAULT_CAPS_WAIT_TIMEOUT = "P1D";
|
|
23
|
+
|
|
24
|
+
/** Validate the operator-supplied capability-barrier bound (env `NANO_CAPS_WAIT_TIMEOUT`, ISO-8601
|
|
25
|
+
* duration), falling back to {@link DEFAULT_CAPS_WAIT_TIMEOUT} when absent, blank, or malformed — a
|
|
26
|
+
* bad env value must never deploy an uninterpretable timer expression. Derives its validation from
|
|
27
|
+
* the single canonical {@link isoDuration}. */
|
|
28
|
+
export function capsWaitTimeout(
|
|
29
|
+
raw: string | undefined,
|
|
30
|
+
def: string = DEFAULT_CAPS_WAIT_TIMEOUT,
|
|
31
|
+
): string {
|
|
32
|
+
return isoDuration(raw, def);
|
|
33
|
+
}
|
package/app/contracts.ts
CHANGED
|
@@ -117,6 +117,13 @@ export const ENV_CONTRACTS = {
|
|
|
117
117
|
semantics: "Maximum rebase attempts per PR.",
|
|
118
118
|
default: "3",
|
|
119
119
|
},
|
|
120
|
+
NANO_PR_MAX_MERGE_RETRIES: {
|
|
121
|
+
category: "env",
|
|
122
|
+
name: "NANO_PR_MAX_MERGE_RETRIES",
|
|
123
|
+
owner: "app/service.ts",
|
|
124
|
+
semantics: "Maximum transient base/head-moved merge-race retries per PR before escalating.",
|
|
125
|
+
default: "5",
|
|
126
|
+
},
|
|
120
127
|
NANO_PR_REVIEW_WAIT_TIMEOUT: {
|
|
121
128
|
category: "env",
|
|
122
129
|
name: "NANO_PR_REVIEW_WAIT_TIMEOUT",
|
|
@@ -208,6 +215,14 @@ export const ENV_CONTRACTS = {
|
|
|
208
215
|
"SLA timeout for an agent (service) task before its boundary timer fires and the PR escalates for human attention (ISO-8601 duration). A malformed value falls back to the default.",
|
|
209
216
|
default: "PT2H",
|
|
210
217
|
},
|
|
218
|
+
NANO_CAPS_WAIT_TIMEOUT: {
|
|
219
|
+
category: "env",
|
|
220
|
+
name: "NANO_CAPS_WAIT_TIMEOUT",
|
|
221
|
+
owner: "app/plan.ts",
|
|
222
|
+
semantics:
|
|
223
|
+
"Bounded wait (FEEL/ISO-8601 duration) a plan-fanout task may park at the wait-caps-resolved capability barrier before the event-based gateway's timer arm fires and it escalates to an operator. Bounds a permanently-unresolvable capability handle (UnresolvableCapabilityRefError) so it can never silently wedge the epic. A malformed value falls back to the default.",
|
|
224
|
+
default: "P1D",
|
|
225
|
+
},
|
|
211
226
|
NANO_READINESS_POLL_TIMEOUT: {
|
|
212
227
|
category: "env",
|
|
213
228
|
name: "NANO_READINESS_POLL_TIMEOUT",
|
package/app/github.ts
CHANGED
|
@@ -822,14 +822,26 @@ export interface MergeOptions {
|
|
|
822
822
|
admin: boolean;
|
|
823
823
|
}
|
|
824
824
|
export interface MergeResult {
|
|
825
|
-
outcome: "merged" | "queued" | "blocked";
|
|
825
|
+
outcome: "merged" | "queued" | "blocked" | "retry";
|
|
826
826
|
detail: string;
|
|
827
827
|
}
|
|
828
828
|
|
|
829
|
+
/** GitHub-flagged *retryable* merge races: the base (or head) branch advanced between the
|
|
830
|
+
* mergeability read and the merge mutation, so GitHub aborted the merge with a "… try the merge
|
|
831
|
+
* again" message. These are transient — GitHub itself tells us to just retry — so the merge loop
|
|
832
|
+
* must re-attempt on the settled base, NOT page a human. Matches GitHub's stable message across
|
|
833
|
+
* both the GraphQL `mergePullRequest` error and its HTTP 405 REST variant. Kept narrow — the exact
|
|
834
|
+
* "<Base|Head> branch was modified" phrase — so a genuine block (conflict, failing required check,
|
|
835
|
+
* 403 perms, 422 not-mergeable) is never swallowed as transient. */
|
|
836
|
+
export function isTransientMergeRace(detail: string): boolean {
|
|
837
|
+
return /\b(?:base|head) branch was modified\b/i.test(detail);
|
|
838
|
+
}
|
|
839
|
+
|
|
829
840
|
/** Attempt to land the PR. Returns `merged` (landed now), `queued` (added to the repo's merge
|
|
830
|
-
* queue — the poller then watches for it to land),
|
|
831
|
-
*
|
|
832
|
-
*
|
|
841
|
+
* queue — the poller then watches for it to land), `retry` (a transient base/head-moved race —
|
|
842
|
+
* GitHub says to re-attempt on the settled base, no human needed), or `blocked` (GitHub refused —
|
|
843
|
+
* a human must resolve it, then reply to retry). `null` when no transport is usable. Never throws
|
|
844
|
+
* for a refused merge; only a genuine transport failure propagates. */
|
|
833
845
|
export async function mergePr(
|
|
834
846
|
repo: string,
|
|
835
847
|
number: number | string,
|
|
@@ -850,6 +862,9 @@ export async function mergePr(
|
|
|
850
862
|
// A merge-queue-required branch surfaces as an error on older gh; treat as queued when the
|
|
851
863
|
// message says so, otherwise it is a genuine block (conflict, failing gate, perms).
|
|
852
864
|
if (/added to the merge queue|enqueued/i.test(msg)) return { outcome: "queued", detail: msg };
|
|
865
|
+
// A base/head-moved race is transient (GitHub says to retry) — re-enter the merge loop
|
|
866
|
+
// rather than escalate. Checked before the catch-all block so it is never swallowed as blocked.
|
|
867
|
+
if (isTransientMergeRace(msg)) return { outcome: "retry", detail: msg };
|
|
853
868
|
return { outcome: "blocked", detail: msg };
|
|
854
869
|
}
|
|
855
870
|
}
|
|
@@ -877,6 +892,9 @@ export async function mergePr(
|
|
|
877
892
|
return { outcome: "queued", detail: "merge accepted; PR not yet landed (awaiting merge queue)" };
|
|
878
893
|
}
|
|
879
894
|
const detail = `github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim();
|
|
895
|
+
// The REST merge endpoint returns 405 "Base branch was modified. Review and try the merge again."
|
|
896
|
+
// for the same transient race — classify it as retry, not a human-actionable block.
|
|
897
|
+
if (isTransientMergeRace(detail)) return { outcome: "retry", detail };
|
|
880
898
|
return { outcome: "blocked", detail };
|
|
881
899
|
}
|
|
882
900
|
|
|
@@ -103,6 +103,23 @@ test("the question distinguishes all four blocked/SLA triggers rather than a sin
|
|
|
103
103
|
assertStringIncludes(el, 'agentVerdict = "blocked"', "must branch CI could-not-fix vs SLA on the agent verdict binding, not the overwritten status");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
test("retry-budget-exhausted escalation reads as a repeated race, not a generic merge refusal", () => {
|
|
107
|
+
// `f_mr_giveup` (transient merge-retry budget exhausted) routes into merge-esc-attempt with
|
|
108
|
+
// mergeState = "ready" AND mergeStatus = "retry". Without a dedicated branch this reused the
|
|
109
|
+
// generic gate-blocked ("Investigate why GitHub refused the merge") text, which is misleading for
|
|
110
|
+
// a repeated base/head-moved race whose retry budget simply ran out. The question must branch on
|
|
111
|
+
// mergeStatus = "retry" — ahead of the generic `mergeState = "ready"` arm — and name the budget.
|
|
112
|
+
assert(escAttempt, "merge-esc-attempt service task must exist");
|
|
113
|
+
const el = escAttempt![0];
|
|
114
|
+
assertStringIncludes(el, 'mergeStatus = "retry"', "must branch the retry-budget-exhausted escalation on mergeStatus = retry");
|
|
115
|
+
assertStringIncludes(el, "mergeRetryMax", "the retry-exhausted question must surface the retry budget");
|
|
116
|
+
// The retry branch must precede the generic `mergeState = "ready"` branch, or the generic arm
|
|
117
|
+
// (also true here) would shadow it and re-emit the misleading refusal text.
|
|
118
|
+
const retryIdx = el.indexOf('mergeStatus = "retry"');
|
|
119
|
+
const readyIdx = el.indexOf('mergeState = "ready"');
|
|
120
|
+
assert(retryIdx !== -1 && readyIdx !== -1 && retryIdx < readyIdx, "the retry branch must be evaluated before the generic ready branch");
|
|
121
|
+
});
|
|
122
|
+
|
|
106
123
|
test("a gw-merge-escalated guard honours persist-escalation's escalated:false (mirrors the convergence loop)", () => {
|
|
107
124
|
// The escalation output no longer flows UNCONDITIONALLY into the durable answer wait: it passes
|
|
108
125
|
// through a gateway that reads the worker's `escalated` output.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Unit tests for the transient merge-race classification (issue #334).
|
|
2
|
+
//
|
|
3
|
+
// #334: a GitHub-flagged *retryable* merge race — the base (or head) branch advanced between the
|
|
4
|
+
// mergeability read and the merge mutation, so GitHub aborted with "… try the merge again" — was
|
|
5
|
+
// misclassified by `mergePr`'s catch-all as `blocked`, producing a misleading human escalation on
|
|
6
|
+
// a PR that was actually mergeable once the base settled. The fix classifies the stable
|
|
7
|
+
// base/head-moved messages as a new `retry` outcome (the merge loop re-attempts on the settled
|
|
8
|
+
// base), while every genuine block (conflict / failing check / 403 perms / 422 not-mergeable)
|
|
9
|
+
// stays `blocked`. These tests pin that split so a real block is never swallowed as transient.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assertEquals } from "#test-assert";
|
|
12
|
+
import { isTransientMergeRace, mergePr } from "./github.ts";
|
|
13
|
+
|
|
14
|
+
test("isTransientMergeRace: the base/head-moved races are transient", () => {
|
|
15
|
+
// GraphQL mergePullRequest error (observed live on nano-workforce #330).
|
|
16
|
+
assertEquals(
|
|
17
|
+
isTransientMergeRace(
|
|
18
|
+
"GraphQL: Base branch was modified. Review and try the merge again. (mergePullRequest)",
|
|
19
|
+
),
|
|
20
|
+
true,
|
|
21
|
+
);
|
|
22
|
+
// The head-branch-moved sibling.
|
|
23
|
+
assertEquals(
|
|
24
|
+
isTransientMergeRace("Head branch was modified. Review and try the merge again."),
|
|
25
|
+
true,
|
|
26
|
+
);
|
|
27
|
+
// The HTTP 405 REST variant.
|
|
28
|
+
assertEquals(
|
|
29
|
+
isTransientMergeRace(
|
|
30
|
+
"github 405 Method Not Allowed: Base branch was modified. Review and try the merge again.",
|
|
31
|
+
),
|
|
32
|
+
true,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("isTransientMergeRace: genuine blocks are NOT transient (never swallowed)", () => {
|
|
37
|
+
assertEquals(isTransientMergeRace("Pull Request is not mergeable"), false); // conflict / failing check
|
|
38
|
+
assertEquals(isTransientMergeRace("github 403 Forbidden: Resource not accessible"), false); // perms
|
|
39
|
+
assertEquals(
|
|
40
|
+
isTransientMergeRace("github 422 Unprocessable Entity: Required status check is expected"),
|
|
41
|
+
false,
|
|
42
|
+
); // not-mergeable gate
|
|
43
|
+
assertEquals(isTransientMergeRace("Merge conflict; base branch has conflicts"), false); // a base conflict is a real block
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Drive `mergePr`'s REST (token) transport: the 405 base-moved race must surface as `retry`, while
|
|
47
|
+
// a genuine 405 refusal stays `blocked`.
|
|
48
|
+
async function withMergePut(
|
|
49
|
+
status: number,
|
|
50
|
+
statusText: string,
|
|
51
|
+
body: string,
|
|
52
|
+
run: () => Promise<void>,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
55
|
+
const prevFetch = globalThis.fetch;
|
|
56
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
57
|
+
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
|
|
58
|
+
const url = String(input);
|
|
59
|
+
if (/\/pulls\/\d+\/merge$/.test(url) && (init?.method ?? "").toUpperCase() === "PUT") {
|
|
60
|
+
return Promise.resolve(new Response(body, { status, statusText }));
|
|
61
|
+
}
|
|
62
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
63
|
+
}) as typeof fetch;
|
|
64
|
+
try {
|
|
65
|
+
await run();
|
|
66
|
+
} finally {
|
|
67
|
+
globalThis.fetch = prevFetch;
|
|
68
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
69
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("mergePr: a 405 base-moved race → outcome 'retry'", async () => {
|
|
74
|
+
await withMergePut(
|
|
75
|
+
405,
|
|
76
|
+
"Method Not Allowed",
|
|
77
|
+
"Base branch was modified. Review and try the merge again.",
|
|
78
|
+
async () => {
|
|
79
|
+
const res = await mergePr("acme/widgets", 42, "test-token", { method: "squash", admin: false });
|
|
80
|
+
assertEquals(res?.outcome, "retry");
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("mergePr: a genuine 405 refusal → outcome 'blocked' (not swallowed as transient)", async () => {
|
|
86
|
+
await withMergePut(405, "Method Not Allowed", "Pull Request is not mergeable", async () => {
|
|
87
|
+
const res = await mergePr("acme/widgets", 42, "test-token", { method: "squash", admin: false });
|
|
88
|
+
assertEquals(res?.outcome, "blocked");
|
|
89
|
+
});
|
|
90
|
+
});
|