@nanobpm/nano-workforce 0.205.2 → 0.206.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/.github/workflows/release.yml +18 -3
- package/.releaserc.json +7 -0
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +1 -1
- package/app/pollReviewsTerminal.test.ts +175 -0
- package/app/service.ts +62 -0
- package/app/version.test.ts +204 -0
- package/app/version.ts +64 -4
- package/build-info.json +6 -0
- package/docs/agent-guide.md +5 -0
- package/openapi.yaml +38 -0
- package/operations/getVersion.test.ts +3 -0
- package/operations/listActivePrs.test.ts +15 -0
- package/operations/listActivePrs.ts +5 -2
- package/package.json +2 -1
- package/scripts/write-build-info.test.ts +87 -0
- package/scripts/write-build-info.ts +126 -0
- package/workers/merge/worker.test.ts +99 -46
- package/workers/merge/worker.ts +27 -20
|
@@ -197,8 +197,11 @@ jobs:
|
|
|
197
197
|
# token, still OIDC + provenance via the job's id-token. This step is a NO-OP
|
|
198
198
|
# when the release step succeeded or when the version is already on npm, so it
|
|
199
199
|
# can never double-publish. If OIDC stays down through every retry, fail loudly:
|
|
200
|
-
# a human must `npm publish` the pushed tag locally
|
|
201
|
-
#
|
|
200
|
+
# a human must `npm publish` the pushed tag locally — restoring the build identity first,
|
|
201
|
+
# because a fresh tag checkout has no build-info.json and plain `npm publish` skips the
|
|
202
|
+
# prepare hook (npm login with 2FA, then `git checkout vX.Y.Z && npm ci &&
|
|
203
|
+
# node --experimental-strip-types scripts/write-build-info.ts X.Y.Z --sha "$(git rev-parse
|
|
204
|
+
# vX.Y.Z^)" && npm publish --provenance --access public`; see the ::error:: message below).
|
|
202
205
|
- name: Retry OIDC publish if the release publish failed
|
|
203
206
|
if: steps.release.outcome == 'failure'
|
|
204
207
|
env:
|
|
@@ -213,6 +216,18 @@ jobs:
|
|
|
213
216
|
exit 0
|
|
214
217
|
fi
|
|
215
218
|
echo "semantic-release prepared + tagged ${PKG}@${VERSION} but the OIDC publish failed; retrying."
|
|
219
|
+
# Reuse the build-info.json that semantic-release's prepare step already wrote (prepareCmd
|
|
220
|
+
# in .releaserc.json) — do NOT regenerate it here. By the time this retry runs,
|
|
221
|
+
# @semantic-release/git has already created the release commit, so HEAD is that
|
|
222
|
+
# metadata-only `chore(release)` commit, NOT the source HEAD the release was cut from.
|
|
223
|
+
# Re-running write-build-info.ts would call `git rev-parse HEAD` and overwrite the recorded
|
|
224
|
+
# source SHA with the release commit's SHA (and replace builtAt), making the documented
|
|
225
|
+
# identity depend on whether OIDC publishing failed. The prepared file is already present
|
|
226
|
+
# in this checkout with the correct source HEAD; this step re-packs via plain `npm publish`
|
|
227
|
+
# (which runs OUTSIDE semantic-release, so the prepare hook does not re-run), so the baked
|
|
228
|
+
# file is packed as-is. Validate it is present and well-formed (fail loudly if a future
|
|
229
|
+
# change ever drops it) rather than regenerate it from the changed HEAD (issue #925).
|
|
230
|
+
node --experimental-strip-types scripts/write-build-info.ts --check
|
|
216
231
|
published=false
|
|
217
232
|
for attempt in 1 2 3; do
|
|
218
233
|
echo "OIDC publish attempt ${attempt}/3 for ${PKG}@${VERSION}"
|
|
@@ -224,7 +239,7 @@ jobs:
|
|
|
224
239
|
sleep $((attempt * 30))
|
|
225
240
|
done
|
|
226
241
|
if [ "$published" != true ]; then
|
|
227
|
-
echo "::error::OIDC publish of ${PKG}@${VERSION} still failing after retries. The git tag v${VERSION} is pushed but npm has no tarball. Publish it locally: 'git fetch --tags && git checkout v${VERSION} && npm ci && npm publish --provenance --access public' (npm
|
|
242
|
+
echo "::error::OIDC publish of ${PKG}@${VERSION} still failing after retries. The git tag v${VERSION} is pushed but npm has no tarball. Publish it locally (npm login with 2FA first): 'git fetch --tags && git checkout v${VERSION} && npm ci && node --experimental-strip-types scripts/write-build-info.ts ${VERSION} --sha \"\$(git rev-parse v${VERSION}^)\" && node --experimental-strip-types scripts/write-build-info.ts --check && npm publish --provenance --access public'. The write-build-info step is REQUIRED: a fresh tag checkout has no build-info.json (it is an uncommitted build artifact) and plain npm publish does not run the semantic-release prepare hook, so without it the package ships with gitSha: null. Passing --sha \"\$(git rev-parse v${VERSION}^)\" records the SOURCE HEAD (the tag's parent), not the metadata-only release commit the tag points at."
|
|
228
243
|
exit 1
|
|
229
244
|
fi
|
|
230
245
|
# semantic-release aborts before @semantic-release/github when the npm
|
package/.releaserc.json
CHANGED
|
@@ -40,6 +40,13 @@
|
|
|
40
40
|
"@semantic-release/changelog",
|
|
41
41
|
"@semantic-release/npm",
|
|
42
42
|
"@semantic-release/github",
|
|
43
|
+
[
|
|
44
|
+
"@semantic-release/exec",
|
|
45
|
+
{
|
|
46
|
+
"//": "Bake the build identity into the package (issue #925). prepareCmd runs in semantic-release's prepare step — AFTER @semantic-release/npm bumps package.json to ${nextRelease.version} and BEFORE it packs/publishes the tarball — so the published package ships build-info.json and an npm-installed instance (no .git, no env override) can still report its gitSha. The recorded gitSha is the SOURCE HEAD the release is cut from; the tag commit is created afterwards by @semantic-release/git (see scripts/write-build-info.ts). There is deliberately NO verifyConditionsCmd: semantic-release runs verifyConditions BEFORE it computes nextRelease, so ${nextRelease.version} interpolates empty there and the command would fail with EVERIFYCONDITIONS, blocking every release. The workflow's OIDC-retry step re-runs plain `npm publish` (which re-packs) in the same checkout — where prepare already wrote build-info.json — and validates + reuses that prepared file (it does NOT regenerate it: by then HEAD is the release commit, so re-running the writer would overwrite the source SHA with the release commit's SHA; see release.yml), so the retry never publishes a tarball with no or wrong build-info.json.",
|
|
47
|
+
"prepareCmd": "node --experimental-strip-types scripts/write-build-info.ts ${nextRelease.version}"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
43
50
|
[
|
|
44
51
|
"@semantic-release/git",
|
|
45
52
|
{
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.206.1](https://github.com/nanobpm/nano-workforce/compare/v0.206.0...v0.206.1) (2026-11-08)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **merge:** fail the review backstop closed and retire review waits on merged PRs ([#928](https://github.com/nanobpm/nano-workforce/issues/928)) ([973c161](https://github.com/nanobpm/nano-workforce/commit/973c161d8b279db21915601647c5e8c43f3cac2f)), closes [nano-bpm#1467](https://github.com/nanobpm/nano-bpm/issues/1467) [#927](https://github.com/nanobpm/nano-workforce/issues/927)
|
|
6
|
+
|
|
7
|
+
## [0.206.0](https://github.com/nanobpm/nano-workforce/compare/v0.205.2...v0.206.0) (2026-11-08)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **version:** bake the git SHA into release packages and report identity on /status ([#926](https://github.com/nanobpm/nano-workforce/issues/926)) ([e5eedca](https://github.com/nanobpm/nano-workforce/commit/e5eedca0ee28db1f54167b4bb962ce4c06776e9f)), closes [#925](https://github.com/nanobpm/nano-workforce/issues/925) [#925](https://github.com/nanobpm/nano-workforce/issues/925) [#925](https://github.com/nanobpm/nano-workforce/issues/925) [#925](https://github.com/nanobpm/nano-workforce/issues/925)
|
|
12
|
+
|
|
1
13
|
## [0.205.2](https://github.com/nanobpm/nano-workforce/compare/v0.205.1...v0.205.2) (2026-11-08)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/app/contracts.ts
CHANGED
|
@@ -301,7 +301,7 @@ export const ENV_CONTRACTS = {
|
|
|
301
301
|
name: "NANO_WORKFORCE_GIT_SHA",
|
|
302
302
|
owner: "app/version.ts",
|
|
303
303
|
semantics:
|
|
304
|
-
"Explicit git SHA override for version reporting on deploys shipped without a .git directory; version derivation reads .git when unset.",
|
|
304
|
+
"Explicit git SHA override for version reporting on deploys shipped without a .git directory; version derivation reads .git when unset, then falls back to the release-baked build-info.json (issue #925).",
|
|
305
305
|
},
|
|
306
306
|
NANO_AUTO_RETRO: {
|
|
307
307
|
category: "env",
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Regression for issue #927 §3 (nano-bpm#1467): after the PR merged, the convergence loop kept
|
|
2
|
+
// re-requesting Copilot reviews on the MERGED PR for six rounds and finally escalated a review
|
|
3
|
+
// timeout. `pollReviews` must check the PR's live state before re-soliciting: a merged/closed PR
|
|
4
|
+
// retires the parked convergence instance and closes the row out — no reviewer-request POST.
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { assertEquals } from "#test-assert";
|
|
7
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
8
|
+
import { pollReviews } from "./service.ts";
|
|
9
|
+
|
|
10
|
+
const REPO = "owner/repo";
|
|
11
|
+
const NUMBER = 9;
|
|
12
|
+
|
|
13
|
+
function memTable(rows: any[], key: string, lookups: unknown[] = []) {
|
|
14
|
+
return {
|
|
15
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
16
|
+
all: () => Promise.resolve([...rows]),
|
|
17
|
+
find: (q: any) => Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
18
|
+
findOne: (q: any) => {
|
|
19
|
+
lookups.push(q);
|
|
20
|
+
return Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null);
|
|
21
|
+
},
|
|
22
|
+
insert: (r: any) => {
|
|
23
|
+
rows.push(r);
|
|
24
|
+
return Promise.resolve(r);
|
|
25
|
+
},
|
|
26
|
+
update: (k: any, patch: any) => {
|
|
27
|
+
const r = rows.find((x) => x[key] === k);
|
|
28
|
+
if (r) Object.assign(r, patch);
|
|
29
|
+
return Promise.resolve(r);
|
|
30
|
+
},
|
|
31
|
+
delete: () => Promise.resolve(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Opts = { reviewsFail?: boolean; reviews?: unknown[]; cancelFails?: boolean; searchState?: string | null };
|
|
36
|
+
|
|
37
|
+
async function run(prState: { merged: boolean; state: string }, opts: Opts = {}) {
|
|
38
|
+
const row: any = {
|
|
39
|
+
pr_key: `${REPO}#${NUMBER}`,
|
|
40
|
+
repo: REPO,
|
|
41
|
+
number: NUMBER,
|
|
42
|
+
status: "waiting_review",
|
|
43
|
+
last_review_id: 0,
|
|
44
|
+
waiting_since: "2026-11-08T15:08:58Z",
|
|
45
|
+
last_nudge_at: "2000-01-01T00:00:00Z",
|
|
46
|
+
process_key: "589000",
|
|
47
|
+
updated_at: "t0",
|
|
48
|
+
};
|
|
49
|
+
const planLookups: unknown[] = [];
|
|
50
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
51
|
+
pull_requests: { rows: [row], key: "pr_key" },
|
|
52
|
+
merges: { rows: [], key: "id" },
|
|
53
|
+
plan_tasks: { rows: [], key: "id" },
|
|
54
|
+
};
|
|
55
|
+
const data = {
|
|
56
|
+
table: withTrackingViews((name: string, key: string) => {
|
|
57
|
+
stores[name] ??= { rows: [], key };
|
|
58
|
+
return memTable(stores[name].rows, stores[name].key, name === "plan_tasks" ? planLookups : []);
|
|
59
|
+
}),
|
|
60
|
+
} as any;
|
|
61
|
+
const cancelled: string[] = [];
|
|
62
|
+
const published: string[] = [];
|
|
63
|
+
const engine = {
|
|
64
|
+
publishMessage: (m: { name: string }) => {
|
|
65
|
+
published.push(m.name);
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
},
|
|
68
|
+
cancelInstance: (a: { processInstanceKey: string }) => {
|
|
69
|
+
if (opts.cancelFails) return Promise.reject(new Error("transient engine error"));
|
|
70
|
+
cancelled.push(a.processInstanceKey);
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
},
|
|
73
|
+
searchProcessInstances: () =>
|
|
74
|
+
Promise.resolve(
|
|
75
|
+
opts.searchState === null ? [] : [{ processInstanceKey: "589000", state: opts.searchState ?? "ACTIVE" }],
|
|
76
|
+
),
|
|
77
|
+
} as any;
|
|
78
|
+
const posts: string[] = [];
|
|
79
|
+
const prevFetch = globalThis.fetch;
|
|
80
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
81
|
+
const prevTok = process.env["GITHUB_TOKEN"];
|
|
82
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
83
|
+
process.env["GITHUB_TOKEN"] = "test-token";
|
|
84
|
+
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
|
|
85
|
+
const u = String(url);
|
|
86
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
87
|
+
const json = (b: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(b), { status }));
|
|
88
|
+
if (method !== "GET") posts.push(`${method} ${u}`);
|
|
89
|
+
if (u.includes(`/pulls/${NUMBER}/requested_reviewers`)) return method === "GET" ? json({ users: [] }) : json({}, 201);
|
|
90
|
+
if (u.includes(`/pulls/${NUMBER}/reviews`) && opts.reviewsFail) return json({ message: "bad gateway" }, 502);
|
|
91
|
+
if (u.includes(`/pulls/${NUMBER}/reviews`)) return json(opts.reviews ?? []); // default: review requested, none arrived
|
|
92
|
+
if (u.includes("/git/ref")) return json({ object: { sha: "e597b399" } });
|
|
93
|
+
if (u.endsWith(`/pulls/${NUMBER}`)) return json({ ...prState, mergeable_state: "clean", head: { sha: "e597b399" } });
|
|
94
|
+
return json({}, 404);
|
|
95
|
+
}) as typeof fetch;
|
|
96
|
+
try {
|
|
97
|
+
await pollReviews(data, engine, "test-token");
|
|
98
|
+
} finally {
|
|
99
|
+
globalThis.fetch = prevFetch;
|
|
100
|
+
if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
101
|
+
else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
102
|
+
if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
|
|
103
|
+
else delete process.env["GITHUB_TOKEN"];
|
|
104
|
+
}
|
|
105
|
+
return { row, posts, cancelled, published, stores, planLookups };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("pollReviews #927: a PR MERGED while awaiting review retires the loop — no review re-request", async () => {
|
|
109
|
+
const { row, posts, cancelled, published } = await run({ merged: true, state: "closed" });
|
|
110
|
+
assertEquals(posts.length, 0, "no reviewer-request POST on a merged PR");
|
|
111
|
+
assertEquals(cancelled, ["589000"], "the parked convergence instance is retired");
|
|
112
|
+
assertEquals(published.length, 0);
|
|
113
|
+
assertEquals(row.status, "merged");
|
|
114
|
+
assertEquals(typeof row.merged_at, "string");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("pollReviews #927: a PR CLOSED unmerged while awaiting review is abandoned — no review re-request", async () => {
|
|
118
|
+
const { row, posts, cancelled, stores } = await run({ merged: false, state: "closed" });
|
|
119
|
+
assertEquals(posts.length, 0);
|
|
120
|
+
assertEquals(cancelled, ["589000"]);
|
|
121
|
+
assertEquals((stores.merges.rows as any[]).some((m) => m.method === "pr-closed"), true);
|
|
122
|
+
assertEquals(row.status === "waiting_review", false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("pollReviews #927: an OPEN PR awaiting review is still re-solicited (control)", async () => {
|
|
126
|
+
const { row, posts, cancelled } = await run({ merged: false, state: "open" });
|
|
127
|
+
assertEquals(cancelled.length, 0);
|
|
128
|
+
assertEquals(row.status, "waiting_review");
|
|
129
|
+
assertEquals(posts.length, 1, "the review nudge still fires for a live PR");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const HEAD_CURRENT_REVIEW = [
|
|
133
|
+
{ id: 77, state: "COMMENTED", submitted_at: "2026-11-08T16:00:00Z", commit_id: "e597b399", user: { login: "copilot-pull-request-reviewer[bot]" } },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
for (const [label, prState] of [
|
|
137
|
+
["MERGED", { merged: true, state: "closed" }],
|
|
138
|
+
["CLOSED", { merged: false, state: "closed" }],
|
|
139
|
+
] as const) {
|
|
140
|
+
test(`pollReviews #927: a HEAD-CURRENT review on a ${label} PR retires the loop — never resumes a round`, async () => {
|
|
141
|
+
const { row, posts, cancelled, published } = await run(prState, { reviews: HEAD_CURRENT_REVIEW });
|
|
142
|
+
assertEquals(published.length, 0, "no readiness-ready published for a terminal PR");
|
|
143
|
+
assertEquals(posts.length, 0);
|
|
144
|
+
assertEquals(cancelled, ["589000"]);
|
|
145
|
+
assertEquals(row.status === "waiting_review" || row.status === "converging", false);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
test("pollReviews #927: a failed cancel of a still-LIVE instance leaves the row pollable", async () => {
|
|
150
|
+
const { row, posts, published } = await run({ merged: true, state: "closed" }, { cancelFails: true, searchState: "ACTIVE" });
|
|
151
|
+
assertEquals(row.status, "waiting_review", "not finalized while the instance (and its timer) lives");
|
|
152
|
+
assertEquals(posts.length, 0, "no re-solicit this pass");
|
|
153
|
+
assertEquals(published.length, 0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
for (const searchState of ["COMPLETED", null] as const) {
|
|
157
|
+
test(`pollReviews #927: a failed cancel of an already-${searchState ?? "gone"} instance still finalizes`, async () => {
|
|
158
|
+
const { row } = await run({ merged: true, state: "closed" }, { cancelFails: true, searchState });
|
|
159
|
+
assertEquals(row.status, "merged");
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test("pollReviews #927: a PR merged externally while awaiting review runs the retro hook", async () => {
|
|
164
|
+
// maybeStartRetro resolves the PR's plan via `plan_tasks`; the lookup proves the hook ran.
|
|
165
|
+
const { planLookups } = await run({ merged: true, state: "closed" });
|
|
166
|
+
assertEquals(planLookups, [{ pr_key: `${REPO}#${NUMBER}` }]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("pollReviews #927: a MERGED PR whose review fetch FAILS is still retired (terminal check precedes fetch)", async () => {
|
|
170
|
+
const { row, posts, cancelled, published } = await run({ merged: true, state: "closed" }, { reviewsFail: true });
|
|
171
|
+
assertEquals(cancelled, ["589000"]);
|
|
172
|
+
assertEquals(published.length, 0);
|
|
173
|
+
assertEquals(posts.length, 0);
|
|
174
|
+
assertEquals(row.status, "merged");
|
|
175
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -100,6 +100,7 @@ import { ENGINE_TERMINAL_STATES } from "./reconcile.ts";
|
|
|
100
100
|
// (submitPr/startMerge) and re-exported below so the long-standing `import { repoEnvelopeVars } from
|
|
101
101
|
// "./service.ts"` call sites (and its tests) keep resolving.
|
|
102
102
|
import { repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
103
|
+
import { maybeStartRetro } from "./retro.ts";
|
|
103
104
|
import { clampNudgeMinutes, isoDurationToMs, isReviewStale, reviewWaitTimeout } from "./reviewWait.ts";
|
|
104
105
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
105
106
|
import {
|
|
@@ -1283,6 +1284,13 @@ export async function pollReviews(data: DataLayer, engine: EngineClient, token:
|
|
|
1283
1284
|
const { repo, number, pr_key: prKey } = pr;
|
|
1284
1285
|
const lastId = pr.last_review_id ?? 0;
|
|
1285
1286
|
try {
|
|
1287
|
+
// #927: a PR that already landed/closed must never resume or re-solicit a review — whatever
|
|
1288
|
+
// the review freshness (no review, stale review, OR a head-current one landing after the
|
|
1289
|
+
// merge). Check terminal state FIRST — before even fetching reviews, so a failing
|
|
1290
|
+
// reviews endpoint (e.g. 502) can't strand a merged PR in `waiting_review` with a live timer — so no branch below can route a merged PR back into a
|
|
1291
|
+
// review round. `null` → handled-but-not-finalized (cancel failed): skip this pass, stay pollable.
|
|
1292
|
+
const retired = await retireTerminalReviewWait(data, engine, pr, token);
|
|
1293
|
+
if (retired !== false) continue;
|
|
1286
1294
|
const reviews = await fetchPrReviews(repo, number, token);
|
|
1287
1295
|
if (reviews === null) return; // no usable transport (no gh, no token) → idle
|
|
1288
1296
|
const fresh = reviews
|
|
@@ -1342,6 +1350,60 @@ export async function pollReviews(data: DataLayer, engine: EngineClient, token:
|
|
|
1342
1350
|
}
|
|
1343
1351
|
}
|
|
1344
1352
|
|
|
1353
|
+
/** Review-wait out-of-band terminal check (issue #927). nano-bpm#1467 landed while its convergence
|
|
1354
|
+
* loop was parked at `wait-review`; the poller then kept re-requesting Copilot reviews on the MERGED
|
|
1355
|
+
* PR for six rounds and finally escalated a review timeout no human could act on. Before any
|
|
1356
|
+
* re-solicitation, read the PR's live state: if it is terminal (merged or closed) the review wait is
|
|
1357
|
+
* moot, so RETIRE the parked convergence instance (`process_key`) and close the row out — `merged`
|
|
1358
|
+
* (with `merged_at`) for a landed PR, the canonical `abandonClosedPr` for a closed-unmerged one.
|
|
1359
|
+
* Conservative: an unreadable/open state returns `false` and the caller re-solicits as before.
|
|
1360
|
+
* Exported for tests. */
|
|
1361
|
+
export async function retireTerminalReviewWait(
|
|
1362
|
+
data: DataLayer,
|
|
1363
|
+
engine: EngineClient,
|
|
1364
|
+
pr: PullRequest,
|
|
1365
|
+
token: string,
|
|
1366
|
+
): Promise<boolean | null> {
|
|
1367
|
+
const st = await fetchPrState(pr.repo, pr.number, token).catch(() => null);
|
|
1368
|
+
const liveness = classifyPrLiveness(st);
|
|
1369
|
+
if (liveness !== "merged" && liveness !== "closed") return false;
|
|
1370
|
+
if (pr.process_key != null) {
|
|
1371
|
+
const key = String(pr.process_key);
|
|
1372
|
+
try {
|
|
1373
|
+
await engine.cancelInstance({ processInstanceKey: key });
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
// Only finalize the row once the instance is confirmed gone/terminal: a transiently-failed
|
|
1376
|
+
// cancel would otherwise drop the row from `waiting_review` scans while the live instance's
|
|
1377
|
+
// review-timeout timer still escalates a human. Unconfirmed → stay pollable, retry next pass.
|
|
1378
|
+
let confirmedTerminal = false;
|
|
1379
|
+
try {
|
|
1380
|
+
const match = (await engine.searchProcessInstances({ processInstanceKeys: [key] })).find(
|
|
1381
|
+
(s) => String(s.processInstanceKey) === key,
|
|
1382
|
+
);
|
|
1383
|
+
confirmedTerminal = !match || ENGINE_TERMINAL_STATES.has(String(match.state ?? "").trim().toUpperCase());
|
|
1384
|
+
} catch {
|
|
1385
|
+
confirmedTerminal = false;
|
|
1386
|
+
}
|
|
1387
|
+
if (!confirmedTerminal) {
|
|
1388
|
+
console.error(`[poller] ${pr.pr_key}: retire review-wait instance ${key} failed, retrying next pass: ${err}`);
|
|
1389
|
+
return null;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
if (liveness === "merged") {
|
|
1394
|
+
const ts = now();
|
|
1395
|
+
await prs(data).update(pr.pr_key, { status: "merged", merged_at: ts, updated_at: ts });
|
|
1396
|
+
// Reuse the merged-completion side effect `pr.mark-merged`/`pr.finalize` run: an externally
|
|
1397
|
+
// merged PR may be its plan's last to settle, so its automatic retrospective must still start.
|
|
1398
|
+
// `maybeStartRetro` is best-effort and never throws.
|
|
1399
|
+
await maybeStartRetro(data, engine, pr.pr_key);
|
|
1400
|
+
} else {
|
|
1401
|
+
await abandonClosedPr(data, pr.pr_key, "PR was closed on GitHub while awaiting review — retiring the convergence loop");
|
|
1402
|
+
}
|
|
1403
|
+
console.log(`[poller] ${liveness} while waiting for review -> retired loop ${pr.pr_key}`);
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1345
1407
|
/** Ensure a Copilot review is in flight for a PR still waiting, throttled to one attempt per
|
|
1346
1408
|
* REVIEW_NUDGE_MS window. Skips when Copilot is already a pending reviewer (a review is coming);
|
|
1347
1409
|
* otherwise re-requests one and records the nudge. This is the primary liveness mechanism — the
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Tests for app/version.ts identity resolution (issue #925).
|
|
2
|
+
//
|
|
3
|
+
// The module probes the working tree ONCE at import time (STATIC), so each scenario imports a
|
|
4
|
+
// cache-busted copy (`?scenario=…`) to re-run the probe under a controlled environment:
|
|
5
|
+
// - env override (NANO_WORKFORCE_GIT_SHA) always wins;
|
|
6
|
+
// - a live .git checkout beats the baked build-info.json;
|
|
7
|
+
// - with neither env nor .git, the release-baked build-info.json is the fallback (the npm-install
|
|
8
|
+
// case — the whole point of #925);
|
|
9
|
+
// - gitTag/builtAt are exposed from build-info.json when known, null on a plain checkout.
|
|
10
|
+
//
|
|
11
|
+
// The "no .git" scenarios copy the app's identity inputs (package.json + build-info.json) into a
|
|
12
|
+
// temp dir WITHOUT a .git directory and load a thin re-export fixture from there — the fixture's
|
|
13
|
+
// own location is what `version.ts` derives REPO_ROOT from, so loading it via the temp path
|
|
14
|
+
// reproduces an npm-installed layout faithfully.
|
|
15
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import { assert, assertEquals } from "#test-assert";
|
|
20
|
+
import { buildVersionInfo } from "./version.ts";
|
|
21
|
+
|
|
22
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
23
|
+
// A build-info.json the release pipeline would have baked (scripts/write-build-info.ts shape).
|
|
24
|
+
const BAKED = {
|
|
25
|
+
version: "9.9.9",
|
|
26
|
+
gitSha: "0123456789abcdef0123456789abcdef01234567",
|
|
27
|
+
gitTag: "v9.9.9",
|
|
28
|
+
builtAt: "2026-01-02T03:04:05.000Z",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Materialise an npm-install-like layout (package.json + build-info.json, NO .git) in a temp dir. */
|
|
32
|
+
function npmInstallLayout(): { dir: string; cleanup: () => void } {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-"));
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(dir, "package.json"),
|
|
36
|
+
// "type": "module" so the copied version.ts loads as ESM without a MODULE_TYPELESS_PACKAGE_JSON warning.
|
|
37
|
+
JSON.stringify({ name: "@nanobpm/nano-workforce", version: BAKED.version, type: "module" }),
|
|
38
|
+
);
|
|
39
|
+
writeFileSync(join(dir, "build-info.json"), JSON.stringify(BAKED));
|
|
40
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Import version.ts AS IF it lived under `dir` (a copy whose REPO_ROOT resolves to `dir`).
|
|
45
|
+
* version.ts derives REPO_ROOT as dirname(dirname(import.meta.url)) — it lives in `app/` — so the
|
|
46
|
+
* copy must sit at the SAME depth (`<dir>/app/version-copy.ts`) for the derivation to land on `dir`.
|
|
47
|
+
*/
|
|
48
|
+
async function importFrom(dir: string) {
|
|
49
|
+
const { mkdirSync, readFileSync } = await import("node:fs");
|
|
50
|
+
const src = readFileSync(new URL("./version.ts", import.meta.url), "utf8");
|
|
51
|
+
mkdirSync(join(dir, "app"), { recursive: true });
|
|
52
|
+
const dest = join(dir, "app", `version-copy-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`);
|
|
53
|
+
writeFileSync(dest, src);
|
|
54
|
+
return (await import(dest)) as typeof import("./version.ts");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function withEnv<T>(key: string, value: string | undefined, fn: () => Promise<T>): Promise<T> {
|
|
58
|
+
const prev = process.env[key];
|
|
59
|
+
if (value === undefined) delete process.env[key];
|
|
60
|
+
else process.env[key] = value;
|
|
61
|
+
try {
|
|
62
|
+
return await fn();
|
|
63
|
+
} finally {
|
|
64
|
+
if (prev === undefined) delete process.env[key];
|
|
65
|
+
else process.env[key] = value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("a live .git checkout resolves gitSha from HEAD (the dev-clone case)", () => {
|
|
70
|
+
// This test itself runs from a real checkout, so the plain import sees .git.
|
|
71
|
+
const info = buildVersionInfo();
|
|
72
|
+
assert(info.gitSha == null || SHA_RE.test(info.gitSha), `gitSha is null or 40-hex, got ${info.gitSha}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a live .git checkout WINS over a competing baked build-info.json (checkout > baked)", async () => {
|
|
76
|
+
// Precedence guard (issue #925): env > .git checkout > build-info.json. Lay out BOTH a live
|
|
77
|
+
// (detached) .git whose HEAD SHA differs from the baked one AND a build-info.json, with no env
|
|
78
|
+
// override, and assert HEAD wins. Without a competing baked file this could not catch the baked
|
|
79
|
+
// fallback incorrectly taking precedence over the checkout.
|
|
80
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-precedence-"));
|
|
81
|
+
try {
|
|
82
|
+
const checkoutSha = "cccccccccccccccccccccccccccccccccccccccc";
|
|
83
|
+
writeFileSync(
|
|
84
|
+
join(dir, "package.json"),
|
|
85
|
+
JSON.stringify({ name: "@nanobpm/nano-workforce", version: BAKED.version, type: "module" }),
|
|
86
|
+
);
|
|
87
|
+
// A live checkout: .git is a directory whose HEAD is a detached commit SHA distinct from the baked one.
|
|
88
|
+
mkdirSync(join(dir, ".git"), { recursive: true });
|
|
89
|
+
writeFileSync(join(dir, ".git", "HEAD"), `${checkoutSha}\n`);
|
|
90
|
+
// A competing baked file whose gitSha must LOSE to the checkout above.
|
|
91
|
+
writeFileSync(join(dir, "build-info.json"), JSON.stringify(BAKED));
|
|
92
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
93
|
+
const mod = await importFrom(dir);
|
|
94
|
+
const info = mod.buildVersionInfo();
|
|
95
|
+
assertEquals(info.gitSha, checkoutSha, "the live .git checkout beats the baked build-info.json");
|
|
96
|
+
});
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("env override NANO_WORKFORCE_GIT_SHA wins over .git and build-info.json", async () => {
|
|
103
|
+
const envSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
104
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", envSha, async () => {
|
|
105
|
+
const mod = (await import(`./version.ts?env-wins=${Date.now()}`)) as typeof import("./version.ts");
|
|
106
|
+
assertEquals(mod.buildVersionInfo().gitSha, envSha);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("env override wins even in an npm-install layout (env > build-info.json)", async () => {
|
|
111
|
+
const { dir, cleanup } = npmInstallLayout();
|
|
112
|
+
try {
|
|
113
|
+
const envSha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
114
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", envSha, async () => {
|
|
115
|
+
const mod = await importFrom(dir);
|
|
116
|
+
assertEquals(mod.buildVersionInfo().gitSha, envSha, "env beats the baked build-info.json");
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
cleanup();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("with no env and no .git, gitSha falls back to the baked build-info.json (the npm-install case)", async () => {
|
|
124
|
+
const { dir, cleanup } = npmInstallLayout();
|
|
125
|
+
try {
|
|
126
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
127
|
+
const mod = await importFrom(dir);
|
|
128
|
+
const info = mod.buildVersionInfo();
|
|
129
|
+
assertEquals(info.gitSha, BAKED.gitSha, "build-info.json is the fallback source of gitSha");
|
|
130
|
+
assertEquals(info.gitTag, BAKED.gitTag, "gitTag is exposed from build-info.json");
|
|
131
|
+
assertEquals(info.builtAt, BAKED.builtAt, "builtAt is exposed from build-info.json");
|
|
132
|
+
assertEquals(info.gitBranch, null, "no .git → no branch");
|
|
133
|
+
assertEquals(info.version, BAKED.version, "version still comes from package.json");
|
|
134
|
+
});
|
|
135
|
+
} finally {
|
|
136
|
+
cleanup();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a malformed build-info.json (non-string gitSha) is ignored, not fatal", async () => {
|
|
141
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-bad-"));
|
|
142
|
+
try {
|
|
143
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@nanobpm/nano-workforce", version: "1.0.0", type: "module" }));
|
|
144
|
+
writeFileSync(join(dir, "build-info.json"), JSON.stringify({ version: "1.0.0", gitSha: 42 }));
|
|
145
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
146
|
+
const mod = await importFrom(dir);
|
|
147
|
+
const info = mod.buildVersionInfo();
|
|
148
|
+
assertEquals(info.gitSha, null);
|
|
149
|
+
assertEquals(info.gitTag, null);
|
|
150
|
+
assertEquals(info.builtAt, null);
|
|
151
|
+
});
|
|
152
|
+
} finally {
|
|
153
|
+
rmSync(dir, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a build-info.json whose gitSha is a non-blank but non-40-hex string is rejected (fail closed)", async () => {
|
|
158
|
+
// Regression for the malformed-baked-SHA finding: a payload like `gitSha: "not-a-sha"` must NOT
|
|
159
|
+
// be reported as the commit — the getter fails closed and reports null rather than surfacing an
|
|
160
|
+
// invalid commit on the identity endpoints.
|
|
161
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-badsha-"));
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@nanobpm/nano-workforce", version: "1.0.0", type: "module" }));
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(dir, "build-info.json"),
|
|
166
|
+
JSON.stringify({ version: "1.0.0", gitSha: "not-a-sha", gitTag: "v1.0.0", builtAt: "2026-01-02T03:04:05.000Z" }),
|
|
167
|
+
);
|
|
168
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
169
|
+
const mod = await importFrom(dir);
|
|
170
|
+
const info = mod.buildVersionInfo();
|
|
171
|
+
assertEquals(info.gitSha, null, "a malformed baked SHA is ignored, not reported");
|
|
172
|
+
assertEquals(info.gitTag, null, "the whole baked payload is dropped when its gitSha is invalid");
|
|
173
|
+
assertEquals(info.builtAt, null);
|
|
174
|
+
});
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("gitTag/builtAt are always present in the payload (null on a plain checkout)", () => {
|
|
181
|
+
// The committed repo carries no build-info.json, so a dev checkout reports the release-only
|
|
182
|
+
// fields as null; the assertion keys off the SAME getter the endpoint uses, so it documents the
|
|
183
|
+
// contract either way.
|
|
184
|
+
const info = buildVersionInfo();
|
|
185
|
+
assert("gitTag" in info && "builtAt" in info, "the fields are always present in the payload");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("a build-info.json with a valid SHA but an unparseable builtAt is rejected (shared validator)", async () => {
|
|
189
|
+
// Regression: the reader once validated only gitSha, so `builtAt: "not-a-date"` reached /version
|
|
190
|
+
// even though the writer's buildInfoProblems() rejects it. Reader + writer now share one validator.
|
|
191
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-baddate-"));
|
|
192
|
+
try {
|
|
193
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@nanobpm/nano-workforce", version: "1.0.0", type: "module" }));
|
|
194
|
+
writeFileSync(join(dir, "build-info.json"), JSON.stringify({ ...BAKED, builtAt: "not-a-date" }));
|
|
195
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
196
|
+
const mod = await importFrom(dir);
|
|
197
|
+
const info = mod.buildVersionInfo();
|
|
198
|
+
assertEquals(info.gitSha, null, "the whole baked payload is dropped when builtAt is invalid");
|
|
199
|
+
assertEquals(info.builtAt, null);
|
|
200
|
+
});
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(dir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|