@nanobpm/nano-workforce 0.205.1 → 0.206.0

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.
@@ -24,8 +24,11 @@ test("routeProgress: an addressed round whose head advanced continues", () => {
24
24
 
25
25
  test("routeProgress: an explicit non-addressed round always continues (no push is expected)", () => {
26
26
  // These are the statuses gw-status routes AWAY from the addressed/default arm — a legit no-push
27
- // round. They always continue regardless of the head.
28
- for (const status of ["waiting", "converged", "needs_input", "blocked"]) {
27
+ // round. They always continue regardless of the head. `review-stale` (#923 §4) joins them: the
28
+ // round re-entered via the converge gate's stale/missing-review path, so no reviewer verdict was
29
+ // obtained and no push was claimed — it must continue to the review wait, never escalate as
30
+ // no-progress.
31
+ for (const status of ["waiting", "converged", "needs_input", "blocked", "review-stale"]) {
29
32
  assertEquals(
30
33
  routeProgress(status, "sha-1", "sha-1"),
31
34
  "continue",
@@ -280,6 +283,64 @@ test("progress-check: a non-addressed round records the baseline and consumes th
280
283
  );
281
284
  });
282
285
 
286
+ test("progress-check: a review-stale re-entry parks with waiting_since at the gate's reviewWaitCutoff, not the park time", async () => {
287
+ // Issue #923 review ("previously missed"): the converge-gate decides stale/missing from ITS review
288
+ // read, but the loop parks several tasks later — and the old park stamped `waiting_since` at the
289
+ // (later) park time. A head-current review landing in that read→park window was then REJECTED by
290
+ // `pollReviews` (`submitted_at >= waiting_since`), so the loop re-solicited and could time out
291
+ // despite a valid verdict already sitting unconsumed on the PR. The gate emits `reviewWaitCutoff`
292
+ // (its read time); the park must stamp `waiting_since` at THAT cutoff so the in-window review
293
+ // satisfies the wait.
294
+ const CUTOFF = "2026-11-08T01:00:00.000Z"; // the gate's review-read time (earlier than "now")
295
+ const handler = await makeUnderTest(
296
+ async () => "sha-2",
297
+ async () => ({ work: true, consumedKey: "9" }),
298
+ );
299
+ const { app, updates } = fakeApp({ last_round_head: "sha-1" });
300
+ const out = await handler(
301
+ {
302
+ processInstanceKey: "pik",
303
+ variables: {
304
+ prKey: "o/r#1",
305
+ status: "waiting",
306
+ repo: "o/r",
307
+ prNumber: 1,
308
+ reviewWaitCutoff: CUTOFF,
309
+ },
310
+ } as any,
311
+ app as any,
312
+ );
313
+ assertEquals(out, { progressed: true, huskRetries: 0 });
314
+ assertEquals(updates.length, 1, "one atomic park write");
315
+ assertEquals(updates[0]!.patch.status, "waiting_review", "the round parks for review");
316
+ assertEquals(
317
+ updates[0]!.patch.waiting_since,
318
+ CUTOFF,
319
+ "waiting_since is stamped at the gate's review-read cutoff, so a review landing in the read→park window satisfies the wait",
320
+ );
321
+ });
322
+
323
+ test("progress-check: a normal round (no reviewWaitCutoff) still stamps waiting_since at the park time", async () => {
324
+ // The cutoff is only emitted on a review-stale re-entry. A normal round carries no
325
+ // `reviewWaitCutoff`, so the park falls back to "now" — the pre-fix behaviour every existing test
326
+ // relies on.
327
+ const handler = await makeUnderTest(
328
+ async () => "sha-2",
329
+ async () => ({ work: true, consumedKey: "9" }),
330
+ );
331
+ const { app, updates } = fakeApp({ last_round_head: "sha-1" });
332
+ await handler(
333
+ { processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "waiting", repo: "o/r", prNumber: 1 } } as any,
334
+ app as any,
335
+ );
336
+ assertEquals(updates[0]!.patch.status, "waiting_review");
337
+ const stamped = updates[0]!.patch.waiting_since;
338
+ assertEquals(typeof stamped, "string", "a park time is stamped");
339
+ // Not the (absent) cutoff — a fresh "now" timestamp, i.e. close to the wall clock.
340
+ const driftMs = Math.abs(Date.now() - Date.parse(stamped as string));
341
+ assertEquals(driftMs < 60_000, true, "waiting_since falls back to the park time when no cutoff is carried");
342
+ });
343
+
283
344
  test("progress-check: a blank/unknown status is treated as addressed — reads the head and can report no progress", async () => {
284
345
  let called = false;
285
346
  const handler = await makeUnderTest(async () => {
@@ -39,6 +39,12 @@ const NON_ADDRESSED_STATUSES: ReadonlySet<string> = new Set([
39
39
  "converged",
40
40
  "needs_input",
41
41
  "blocked",
42
+ // `review-stale` (issue #923 §4): the round re-entered persist-round via the converge gate's
43
+ // stale/missing-review path (f_convergeStale) — no head-current reviewer verdict was ever
44
+ // obtained, so the round claims no push and must always continue to the review wait (the poller
45
+ // re-solicits a fresh review of the current head). Treating it as `addressed` would fabricate a
46
+ // no-progress escalation on a round that never claimed progress.
47
+ "review-stale",
42
48
  ]);
43
49
 
44
50
  /** True when a round's status is `addressed` for no-progress purposes — i.e. NOT one of 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
+ });
package/app/version.ts CHANGED
@@ -5,8 +5,10 @@
5
5
  // inspecting the working tree at runtime. This module gathers that identity — the app's package
6
6
  // version, the resolved `@nanobpm/urban` version, the git commit (read from `.git`, handling both
7
7
  // an ordinary `.git` directory and the `gitdir:` file pointer used by worktrees/submodules, with
8
- // an env override for detached deploys), plus the Node runtime, pid and start time — so an operator
9
- // debugging a stuck instance can confirm the process is on the code they think it is.
8
+ // an env override for detached deploys AND a `build-info.json` fallback baked into the release
9
+ // package — an npm install has neither `.git` nor the env var, issue #925), plus the Node runtime,
10
+ // pid and start time — so an operator debugging a stuck instance can confirm the process is on the
11
+ // code they think it is.
10
12
  //
11
13
  // Every probe is best-effort: a missing file or unavailable `.git` yields `null` for that field
12
14
  // rather than throwing, so `/app/api/version` never fails just because one source is absent.
@@ -98,17 +100,68 @@ function urbanVersion(): string | null {
98
100
  return typeof pkg?.version === "string" ? pkg.version : null;
99
101
  }
100
102
 
103
+ /**
104
+ * The build identity baked into the release package (`build-info.json` at the package root) by
105
+ * `scripts/write-build-info.ts` during semantic-release's `prepare` step (issue #925). An npm
106
+ * install ships this file but no `.git`, so it is the ONLY source of `gitSha` there. Its `gitSha`
107
+ * is the SOURCE HEAD the release was cut from (the tag commit does not exist yet at prepare time);
108
+ * its `gitTag` names the tag placed on the release commit, whose tree matches that source HEAD
109
+ * except for the version-bump metadata. Returns null on a dev checkout, which has no such file.
110
+ */
111
+ export const SHA_RE = /^[0-9a-f]{40}$/;
112
+
113
+ /** Validate a parsed build-info payload; returns a list of problems (empty = valid). The single
114
+ * validator shared by the writer (scripts/write-build-info.ts) and the reader below. */
115
+ export function buildInfoProblems(info: unknown): string[] {
116
+ const problems: string[] = [];
117
+ if (typeof info !== "object" || info == null) return ["build-info.json is not a JSON object"];
118
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape (parsed JSON)
119
+ const bi = info as Record<string, unknown>;
120
+ if (typeof bi.version !== "string" || !bi.version.trim()) problems.push("version must be a non-blank string");
121
+ if (typeof bi.gitSha !== "string" || !SHA_RE.test(bi.gitSha)) problems.push("gitSha must be a 40-hex string");
122
+ if (typeof bi.gitTag !== "string" || !bi.gitTag.trim()) problems.push("gitTag must be a non-blank string");
123
+ if (typeof bi.builtAt !== "string" || Number.isNaN(Date.parse(bi.builtAt)))
124
+ problems.push("builtAt must be an ISO-8601 timestamp");
125
+ return problems;
126
+ }
127
+
128
+
129
+ function buildInfo(): { version: string; gitSha: string; gitTag: string; builtAt: string } | null {
130
+ const raw = readJson(join(REPO_ROOT, "build-info.json"));
131
+ if (raw == null) return null;
132
+ // Fail closed on ANY malformed field (bad SHA, unparseable builtAt, blank tag/version): a corrupt
133
+ // or tampered build-info.json is ignored wholesale rather than partially surfaced. Uses the SAME
134
+ // validator the writer's --check uses, so reader and writer can never drift.
135
+ if (buildInfoProblems(raw).length > 0) return null;
136
+ return {
137
+ version: String(raw.version),
138
+ gitSha: String(raw.gitSha),
139
+ gitTag: String(raw.gitTag),
140
+ builtAt: String(raw.builtAt),
141
+ };
142
+ }
143
+
101
144
  /**
102
145
  * The git commit the working tree is on, read straight from `.git` so it reflects the ACTUAL
103
146
  * checked-out code — not a value baked at some earlier build. Resolves a symbolic `HEAD`
104
147
  * (`ref: refs/heads/…`) via the loose ref file (checking both the per-worktree and common dirs),
105
- * falling back to `packed-refs`. An explicit `NANO_WORKFORCE_GIT_SHA` env var wins (for deploys
106
- * that ship without a `.git` directory).
148
+ * falling back to `packed-refs`. Resolution order (issue #925): an explicit
149
+ * `NANO_WORKFORCE_GIT_SHA` env var wins (for deploys that ship without a `.git` directory), then
150
+ * the live `.git` checkout, then the release-baked `build-info.json` (the only source available to
151
+ * an npm-installed instance).
107
152
  */
108
153
  function gitSha(): string | null {
109
154
  const env = envVar("NANO_WORKFORCE_GIT_SHA");
110
155
  if (env) return env;
111
156
 
157
+ const fromCheckout = gitShaFromCheckout();
158
+ if (fromCheckout != null) return fromCheckout;
159
+
160
+ return buildInfo()?.gitSha ?? null;
161
+ }
162
+
163
+ /** The commit SHA read from the live `.git` checkout, or null when there is none (npm install). */
164
+ function gitShaFromCheckout(): string | null {
112
165
  const dirs = resolveGitDirs();
113
166
  if (dirs == null) return null;
114
167
  const head = readText(join(dirs.gitDir, "HEAD"));
@@ -160,6 +213,10 @@ export interface VersionInfo {
160
213
  urbanVersion: string | null;
161
214
  gitSha: string | null;
162
215
  gitBranch: string | null;
216
+ /** The release tag (e.g. "v0.206.0") from the baked `build-info.json`; null on a dev checkout. */
217
+ gitTag: string | null;
218
+ /** ISO-8601 time the release package was built (from `build-info.json`); null on a dev checkout. */
219
+ builtAt: string | null;
163
220
  runtime: string;
164
221
  pid: number | null;
165
222
  startedAt: string;
@@ -172,12 +229,15 @@ export interface VersionInfo {
172
229
  */
173
230
  const STATIC: Omit<VersionInfo, "uptimeSeconds"> = (() => {
174
231
  const pkg = appPackage();
232
+ const build = buildInfo();
175
233
  return Object.freeze({
176
234
  name: appName(pkg),
177
235
  version: appVersion(pkg),
178
236
  urbanVersion: urbanVersion(),
179
237
  gitSha: gitSha(),
180
238
  gitBranch: gitBranch(),
239
+ gitTag: build?.gitTag || null,
240
+ builtAt: build?.builtAt || null,
181
241
  runtime: runtime(),
182
242
  pid: typeof process.pid === "number" ? process.pid : null,
183
243
  startedAt: STARTED_AT.toISOString(),
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "0.206.0",
3
+ "gitSha": "e5eedca0ee28db1f54167b4bb962ce4c06776e9f",
4
+ "gitTag": "v0.206.0",
5
+ "builtAt": "2026-11-08T12:12:11.017Z"
6
+ }
@@ -26,6 +26,11 @@ curl -sS __BASE__/version | jq # tool: getVersion: app/urban version, git sha,
26
26
  curl -sS __BASE__/status | jq # tool: listActivePrs: every PR in flight
27
27
  ```
28
28
 
29
+ `/status` also carries the running app's build identity as a top-level `version` object
30
+ (`{ version, gitSha }`, the same source as `/version` — env override, then the live `.git`
31
+ checkout, then the release-baked `build-info.json` an npm install ships), so the endpoint
32
+ everyone polls answers "is the fix live?" in one call.
33
+
29
34
  Each `/status` entry carries `prKey` (`owner/repo#123`), `status`, `round`, `processKey`
30
35
  (the **engine process-instance key**, the bridge to §5), `openEscalation`,
31
36
  `activeWorker`/`leaseUntil` (an agent holds the round, or the job is just queued), and
package/openapi.yaml CHANGED
@@ -195,6 +195,7 @@ components:
195
195
  - count
196
196
  - prs
197
197
  - deliveries
198
+ - version
198
199
  properties:
199
200
  count:
200
201
  type: integer
@@ -209,6 +210,26 @@ components:
209
210
  first (issue #889 §1 — the opt-in is shown for each PR AND each delivery).
210
211
  items:
211
212
  $ref: "#/components/schemas/ActiveDelivery"
213
+ version:
214
+ type: object
215
+ additionalProperties: false
216
+ required:
217
+ - version
218
+ - gitSha
219
+ description: >-
220
+ The running app's build identity (issue #925), derived from the same app/version.ts
221
+ getter as GET /version — so the endpoint everyone polls answers "is the fix live?" in
222
+ one call. gitSha resolves from NANO_WORKFORCE_GIT_SHA, then the .git checkout, then the
223
+ release-baked build-info.json (the only source an npm install has).
224
+ properties:
225
+ version:
226
+ type: string
227
+ nullable: true
228
+ description: The app's package version.
229
+ gitSha:
230
+ type: string
231
+ nullable: true
232
+ description: The commit the instance runs; null only when no source (env, .git, build-info.json) knows it.
212
233
  ActiveDelivery:
213
234
  type: object
214
235
  description: A delivery-graph run currently in flight, as reported by the status endpoint.
@@ -1561,6 +1582,8 @@ components:
1561
1582
  - urbanVersion
1562
1583
  - gitSha
1563
1584
  - gitBranch
1585
+ - gitTag
1586
+ - builtAt
1564
1587
  - runtime
1565
1588
  - pid
1566
1589
  - startedAt
@@ -1577,9 +1600,24 @@ components:
1577
1600
  gitSha:
1578
1601
  type: string
1579
1602
  nullable: true
1603
+ description: >-
1604
+ The commit the instance runs. Resolution order (issue #925): NANO_WORKFORCE_GIT_SHA env,
1605
+ then the live .git checkout, then the release-baked build-info.json (the only source an
1606
+ npm install has — this is the source HEAD the release was cut from).
1580
1607
  gitBranch:
1581
1608
  type: string
1582
1609
  nullable: true
1610
+ gitTag:
1611
+ type: string
1612
+ nullable: true
1613
+ description: >-
1614
+ The release tag (e.g. "v0.206.0") naming the release commit, from the baked
1615
+ build-info.json; null on a dev checkout. The tag commit's tree matches gitSha's tree
1616
+ except for the version-bump metadata.
1617
+ builtAt:
1618
+ type: string
1619
+ nullable: true
1620
+ description: ISO-8601 time the release package was built (from build-info.json); null on a dev checkout.
1583
1621
  runtime:
1584
1622
  type: string
1585
1623
  pid:
@@ -34,6 +34,9 @@ test("returns 200 with the app identity", async () => {
34
34
  assert("urbanVersion" in r.body);
35
35
  assert("gitSha" in r.body);
36
36
  assert("gitBranch" in r.body);
37
+ // Release-baked identity fields (issue #925): always present, null on a dev checkout.
38
+ assert("gitTag" in r.body);
39
+ assert("builtAt" in r.body);
37
40
  assert(typeof r.body.runtime === "string" && r.body.runtime.length > 0);
38
41
  assert(typeof r.body.startedAt === "string");
39
42
  assert(typeof r.body.uptimeSeconds === "number");
@@ -66,6 +66,21 @@ test("returns 200 with a count + projected active PRs", async () => {
66
66
  assertEquals(r.body.prs[0].processKey, "9");
67
67
  });
68
68
 
69
+ test("carries the running app's identity as a top-level version object (issue #925)", async () => {
70
+ // `/status` is the endpoint everyone polls, so it must answer "is the fix live?" on its own:
71
+ // version.version + version.gitSha, derived from the SAME app/version.ts getter /version uses.
72
+ const app = memApp([]);
73
+ const res = (await handler(input(), app)) as any;
74
+ assertEquals(res.status, 200);
75
+ assert("version" in res.body, "the status payload carries a top-level version object");
76
+ assert("version" in res.body.version, "version.version is present (string|null)");
77
+ assert("gitSha" in res.body.version, "version.gitSha is present (string|null)");
78
+ const { buildVersionInfo } = await import("../app/version.ts");
79
+ const identity = buildVersionInfo();
80
+ assertEquals(res.body.version.version, identity.version, "derived from the app/version.ts getter, not re-read");
81
+ assertEquals(res.body.version.gitSha, identity.gitSha, "derived from the app/version.ts getter, not re-read");
82
+ });
83
+
69
84
  test("surfaces the structured openEscalation from the user_tasks read model (both loops)", async () => {
70
85
  // Issue #666: openEscalation is derived from the ONE `user_tasks` read model (the same surface
71
86
  // `listEscalations` and the Convergence page consume), so `/status` carries the completable
@@ -8,7 +8,7 @@
8
8
  // callers must present it via the x-hook-secret header. Unset → open (unchanged default).
9
9
 
10
10
  import { activeDeliveries, activePrs } from "../app/service.ts";
11
- import { envVar } from "../app/version.ts";
11
+ import { buildVersionInfo, envVar } from "../app/version.ts";
12
12
  import { defineOperation } from "../nano-generated/operations.ts";
13
13
 
14
14
  // The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
@@ -21,5 +21,8 @@ export default defineOperation("listActivePrs", async ({ req }, app) => {
21
21
  return { status: 401, body: { error: "unauthorized" } };
22
22
  }
23
23
  const [prs, deliveries] = await Promise.all([activePrs(app.data, app.engine), activeDeliveries(app.data)]);
24
- return { status: 200, body: { count: prs.length, prs, deliveries } };
24
+ // The running app's identity, derived from the SAME app/version.ts getter /version uses (never
25
+ // re-read), so the endpoint everyone polls answers "is the fix live?" in one call (issue #925).
26
+ const { version, gitSha } = buildVersionInfo();
27
+ return { status: 200, body: { count: prs.length, prs, deliveries, version: { version, gitSha } } };
25
28
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.205.1",
3
+ "version": "0.206.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -74,6 +74,7 @@
74
74
  "@nanobpm/urban-testkit": "^1.6.0",
75
75
  "@nanobpm/workflow": "^0.15.0",
76
76
  "@semantic-release/changelog": "^7.0.0",
77
+ "@semantic-release/exec": "^7.1.0",
77
78
  "@semantic-release/git": "^11.0.0",
78
79
  "@semantic-release/npm": "^13.1.5",
79
80
  "@types/node": "^24.0.0",
@@ -42,6 +42,7 @@
42
42
  <nano:extend name="round" type="integer" optional="true" />
43
43
  <nano:extend name="huskRetries" type="integer" optional="true" />
44
44
  <nano:extend name="roundEntryHead" type="string" optional="true" />
45
+ <nano:extend name="reviewWaitCutoff" type="string" optional="true" />
45
46
  </nano:shape>
46
47
  <nano:shape id="PrCaptureHeadIn" name="Capture round-entry head — input">
47
48
  <nano:extend name="prKey" type="string" />
@@ -68,6 +69,7 @@
68
69
  <nano:extend name="convergeBlockReason" type="string" optional="true" />
69
70
  <nano:extend name="convergeAckOnly" type="boolean" optional="true" />
70
71
  <nano:extend name="reviewStale" type="boolean" optional="true" />
72
+ <nano:extend name="reviewWaitCutoff" type="string" optional="true" />
71
73
  </nano:shape>
72
74
  <nano:shape id="EscalationIn" name="Record escalation — input">
73
75
  <nano:extend name="prKey" type="string" />
@@ -307,6 +309,9 @@
307
309
  <zeebe:properties>
308
310
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrPersistRoundIn" />
309
311
  </zeebe:properties>
312
+ <zeebe:ioMapping>
313
+ <zeebe:input source="=if reviewStale = true then &#34;review-stale&#34; else status" target="status" />
314
+ </zeebe:ioMapping>
310
315
  </bpmn:extensionElements>
311
316
  <bpmn:incoming>f_advSkip</bpmn:incoming>
312
317
  <bpmn:incoming>f_advClean</bpmn:incoming>
@@ -282,7 +282,7 @@
282
282
  </zeebe:properties>
283
283
  <zeebe:ioMapping>
284
284
  <zeebe:input source="=status" target="agentVerdict" />
285
- <zeebe:input source="=&#34;This PR cannot be merged and needs a human decision. &#34; + (if mergeState = &#34;conflict&#34; then &#34;The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry.&#34; else if mergeStatus = &#34;retry&#34; then &#34;The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (&#34; + string(mergeRetryMax) + &#34; retries). This is a repeated transient race, not a conflict, failing check, or permissions block. Reply to retry, or investigate why the base branch keeps moving.&#34; else if mergeState = &#34;ready&#34; then &#34;The merge attempt did not land (merge result: &#34; + mergeStatus + &#34;). Investigate why GitHub refused the merge, then reply to retry.&#34; else if status = &#34;blocked&#34; then &#34;The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry.&#34; else &#34;The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.&#34;)" target="question" />
285
+ <zeebe:input source="=&#34;This PR cannot be merged and needs a human decision. &#34; + (if mergeState = &#34;conflict&#34; then &#34;The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry.&#34; else if mergeStatus = &#34;retry&#34; then &#34;The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (&#34; + string(mergeRetryMax) + &#34; retries). This is a repeated transient race, not a conflict, failing check, or permissions block. Reply to retry, or investigate why the base branch keeps moving.&#34; else if mergeState = &#34;ready&#34; and mergeStatus = &#34;blocked&#34; and question != null then question else if mergeState = &#34;ready&#34; then &#34;The merge attempt did not land (merge result: &#34; + mergeStatus + &#34;). Investigate why GitHub refused the merge, then reply to retry.&#34; else if status = &#34;blocked&#34; then &#34;The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry.&#34; else &#34;The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.&#34;)" target="question" />
286
286
  <zeebe:input source="=&#34;blocked&#34;" target="status" />
287
287
  </zeebe:ioMapping>
288
288
  </bpmn:extensionElements>