@nanobpm/nano-workforce 0.163.3 → 0.165.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.165.0](https://github.com/nanobpm/nano-workforce/compare/v0.164.0...v0.165.0) (2026-08-31)
2
+
3
+ ### Features
4
+
5
+ * **overview:** add Dismiss row action to Active Features grid ([#644](https://github.com/nanobpm/nano-workforce/issues/644)) ([cda5962](https://github.com/nanobpm/nano-workforce/commit/cda59628d31a37faf26e3a749b6443b33f532d7d)), closes [#643](https://github.com/nanobpm/nano-workforce/issues/643)
6
+
7
+ ## [0.164.0](https://github.com/nanobpm/nano-workforce/compare/v0.163.3...v0.164.0) (2026-08-31)
8
+
9
+ ### Features
10
+
11
+ * **merge-loop:** bound wait-mergeable with a timeout + stall probe ([#636](https://github.com/nanobpm/nano-workforce/issues/636)) ([#640](https://github.com/nanobpm/nano-workforce/issues/640)) ([e12dd04](https://github.com/nanobpm/nano-workforce/commit/e12dd0426d0c7b3defe24d339502397b24b9302c))
12
+
1
13
  ## [0.163.3](https://github.com/nanobpm/nano-workforce/compare/v0.163.2...v0.163.3) (2026-08-31)
2
14
 
3
15
  ### Bug Fixes
package/app/contracts.ts CHANGED
@@ -124,6 +124,14 @@ export const ENV_CONTRACTS = {
124
124
  semantics: "Maximum transient base/head-moved merge-race retries per PR before escalating.",
125
125
  default: "5",
126
126
  },
127
+ NANO_PR_MAX_MERGE_STALL_ROUNDS: {
128
+ category: "env",
129
+ name: "NANO_PR_MAX_MERGE_STALL_ROUNDS",
130
+ owner: "app/service.ts",
131
+ semantics:
132
+ "Maximum mergeable-wait-timeout stall-probe re-derivations (dead-poller backstop, #636) before escalating; 0 escalates on the first stall.",
133
+ default: "3",
134
+ },
127
135
  NANO_PR_REVIEW_WAIT_TIMEOUT: {
128
136
  category: "env",
129
137
  name: "NANO_PR_REVIEW_WAIT_TIMEOUT",
@@ -143,6 +151,13 @@ export const ENV_CONTRACTS = {
143
151
  semantics:
144
152
  "How long the merge loop waits for a queued PR to actually land before escalating (FEEL/ISO-8601 duration).",
145
153
  },
154
+ NANO_PR_MERGEABLE_WAIT_TIMEOUT: {
155
+ category: "env",
156
+ name: "NANO_PR_MERGEABLE_WAIT_TIMEOUT",
157
+ owner: "app/service.ts",
158
+ semantics:
159
+ "How long the merge loop waits for the poller's `merge-ready` before the stall-probe timer arm fires (dead-poller backstop, #636; FEEL/ISO-8601 duration).",
160
+ },
146
161
  NANO_PR_AUTO_MERGE: {
147
162
  category: "env",
148
163
  name: "NANO_PR_AUTO_MERGE",
@@ -42,6 +42,7 @@ const MODEL = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
42
42
 
43
43
  const AGENT_SLA_MS = 30 * 60 * 1000; // matches the PT30M we start instances with
44
44
  const LANDED_WAIT_MS = 30 * 60 * 1000; // matches the PT30M landedWaitTimeout we start instances with
45
+ const MERGEABLE_WAIT_MS = 30 * 60 * 1000; // matches the PT30M mergeableWaitTimeout we start instances with
45
46
 
46
47
  type Output = Record<string, unknown>;
47
48
  type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
@@ -49,6 +50,7 @@ type Responder = Output | Output[] | ((job: { variables: Record<string, unknown>
49
50
  const ALL_JOB_TYPES = [
50
51
  "pr.arm-merge",
51
52
  "pr.merge",
53
+ "pr.merge-stall-probe",
52
54
  "pr.mark-merged",
53
55
  "senior:fix-ci",
54
56
  "senior:rebase",
@@ -82,6 +84,9 @@ const DEFAULT_VARS: Record<string, unknown> = {
82
84
  mergeRetryRound: 0,
83
85
  agentSlaTimeout: "PT30M",
84
86
  landedWaitTimeout: "PT30M",
87
+ mergeableWaitTimeout: "PT30M",
88
+ mergeStallRounds: 0,
89
+ mergeStallMax: 3,
85
90
  abandonBrief: null,
86
91
  failingChecksList: null,
87
92
  status: null,
@@ -238,6 +243,112 @@ test("answering the landing-timeout escalation re-arms the merge poller (#556)",
238
243
  assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
239
244
  });
240
245
 
246
+ // ---------------------------------------------------------------------------
247
+ // Mergeable-wait bounded-wait backstop (#636) — a stalled poller must never
248
+ // wedge the instance at `waiting_merge` forever. The `gw-merge-wait` event-based
249
+ // gateway races the poller's `merge-ready` message against a `mergeableWaitTimeout`
250
+ // timer; on timeout `merge-stall-probe` re-derives mergeability and the existing
251
+ // `gw-mergeable` routes to the correct arm — or, once `mergeStallMax` is exhausted,
252
+ // to human escalation.
253
+ // ---------------------------------------------------------------------------
254
+
255
+ test("wait-mergeable races merge-ready against a timer (no bare catch) (#636)", async () => {
256
+ // After the poller arms the merge, the token forks the event-based gateway: it parks at BOTH the
257
+ // `merge-ready` catch and the timeout timer. Before the fix, `wait-mergeable` was a bare catch —
258
+ // no timer sibling — so a dead poller wedged it forever.
259
+ const engine = await boot();
260
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
261
+ assertThatInstance(engine, byProcessId("merge-loop"))
262
+ .isActive()
263
+ .hasActiveElements("wait-mergeable", "wait-mergeable-timeout");
264
+ });
265
+
266
+ test("a dead poller's PR is probed on timeout and advances to merge — never wedges (#636)", async () => {
267
+ // No `merge-ready` is EVER published (the poller is dead). RED (before the fix): the instance sits
268
+ // at `wait-mergeable` forever. GREEN: the timer fires, `merge-stall-probe` re-derives `ready`, and
269
+ // the existing `ready` arm merges the PR.
270
+ const engine = await boot({
271
+ responses: {
272
+ "pr.merge-stall-probe": { mergeState: "ready", failingChecks: 0, failingChecksList: "" },
273
+ "pr.merge": { mergeStatus: "merged" },
274
+ },
275
+ });
276
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
277
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
278
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
279
+ assertThatInstance(engine, byProcessId("merge-loop"))
280
+ .hasCompleted()
281
+ .hasNoIncident()
282
+ .hasCompletedElements("merge-stall-probe", "attempt-merge", "mark-merged");
283
+ });
284
+
285
+ test("a CONFLICTING PR whose poller never signalled reaches the rebase arm via the timeout path (#636)", async () => {
286
+ // The incident that motivated #636: a `CONFLICTING`/`DIRTY` PR (base advanced under it) parked at
287
+ // `waiting_merge` because the poller never signalled — the auto-rebase arm was never reached. The
288
+ // timer path re-derives `conflict` and routes it to the bounded rebase agent.
289
+ const engine = await boot({
290
+ responses: {
291
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
292
+ "senior:rebase": { status: "rebased" },
293
+ },
294
+ });
295
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
296
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
297
+ assertThatInstance(engine, byProcessId("merge-loop"))
298
+ .isActive()
299
+ .hasActiveElement("wait-mergeable")
300
+ .hasCompletedElements("merge-stall-probe", "rebase") // reached the rebase arm via the timeout path
301
+ .hasVariable("rebaseRound", 1);
302
+ });
303
+
304
+ test("a blocked PR probed on timeout reaches the CI-fix arm via the timeout path (#636)", async () => {
305
+ const engine = await boot({
306
+ responses: {
307
+ "pr.merge-stall-probe": { mergeState: "blocked", failingChecks: 1, failingChecksList: "build" },
308
+ "senior:fix-ci": { status: "fixed" },
309
+ },
310
+ });
311
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
312
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
313
+ assertThatInstance(engine, byProcessId("merge-loop"))
314
+ .isActive()
315
+ .hasActiveElement("wait-mergeable")
316
+ .hasCompletedElements("merge-stall-probe", "fix-ci")
317
+ .hasVariable("ciFixRound", 1);
318
+ });
319
+
320
+ test("the probe advances mergeStallRounds and re-arms within budget (#636)", async () => {
321
+ // A re-derived `conflict` re-arms after the rebase; the stall counter advanced so a still-dead
322
+ // poller cannot loop the probe forever — it is bounded by mergeStallMax.
323
+ const engine = await boot({
324
+ responses: {
325
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
326
+ "senior:rebase": { status: "rebased" },
327
+ },
328
+ });
329
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
330
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
331
+ assertThatInstance(engine, byProcessId("merge-loop")).hasVariable("mergeStallRounds", 1);
332
+ assert(!completedElementIds(engine).has("merge-esc-conflict"), "a within-budget stall must NOT escalate");
333
+ });
334
+
335
+ test("the stall-round cap escalates to a human instead of looping forever (#636)", async () => {
336
+ // `mergeStallMax: 0` — escalate on the first stall. The probe runs once, `gw-merge-stall` finds the
337
+ // budget exhausted, and routes to `merge-esc-conflict` → the native `wait-merge-answer` user task
338
+ // rather than re-arming into another timeout round.
339
+ const engine = await boot({
340
+ vars: { mergeStallMax: 0 },
341
+ responses: {
342
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
343
+ },
344
+ });
345
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
346
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
347
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
348
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-stall-probe", "merge-esc-conflict");
349
+ assert(!completedElementIds(engine).has("mark-merged"), "an exhausted stall must not mark-merged");
350
+ });
351
+
241
352
  test("an evicted queued merge re-arms the poller rather than completing", async () => {
242
353
  const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
243
354
  await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
@@ -0,0 +1,39 @@
1
+ // Unit coverage for the mergeable-wait liveness timeout policy. The value is baked into every
2
+ // merge-loop instance's `mergeableWaitTimeout` process variable and evaluated by the
3
+ // `wait-mergeable-timeout` timer catch (the timer arm of the `gw-merge-wait` event-based gateway),
4
+ // so a malformed operator env must never deploy an uninterpretable `<bpmn:timeDuration>` — it falls
5
+ // back to the default instead. Run with `node --test`.
6
+
7
+ import assert from "node:assert/strict";
8
+ import { test } from "node:test";
9
+ import { DEFAULT_MERGEABLE_WAIT_TIMEOUT, mergeableWaitTimeout } from "./mergeableWait.ts";
10
+
11
+ test("mergeableWaitTimeout: blank / absent / malformed → default", () => {
12
+ assert.equal(mergeableWaitTimeout(undefined), DEFAULT_MERGEABLE_WAIT_TIMEOUT);
13
+ assert.equal(mergeableWaitTimeout(""), DEFAULT_MERGEABLE_WAIT_TIMEOUT);
14
+ assert.equal(mergeableWaitTimeout(" "), DEFAULT_MERGEABLE_WAIT_TIMEOUT);
15
+ assert.equal(mergeableWaitTimeout("30m"), DEFAULT_MERGEABLE_WAIT_TIMEOUT); // missing leading P/T
16
+ assert.equal(mergeableWaitTimeout("P"), DEFAULT_MERGEABLE_WAIT_TIMEOUT); // no component
17
+ assert.equal(mergeableWaitTimeout("PT"), DEFAULT_MERGEABLE_WAIT_TIMEOUT); // T with no time part
18
+ assert.equal(mergeableWaitTimeout("garbage"), DEFAULT_MERGEABLE_WAIT_TIMEOUT);
19
+ });
20
+
21
+ test("mergeableWaitTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
22
+ assert.equal(mergeableWaitTimeout("PT30M"), "PT30M");
23
+ assert.equal(mergeableWaitTimeout("pt2h"), "PT2H");
24
+ assert.equal(mergeableWaitTimeout("P1D"), "P1D");
25
+ assert.equal(mergeableWaitTimeout(" pt45m "), "PT45M");
26
+ });
27
+
28
+ test("mergeableWaitTimeout: an explicit fallback is honoured for a bad value", () => {
29
+ assert.equal(mergeableWaitTimeout("nope", "PT10M"), "PT10M");
30
+ assert.equal(mergeableWaitTimeout("PT15M", "PT10M"), "PT15M");
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_MERGEABLE_WAIT_TIMEOUT, sentinel);
38
+ assert.equal(mergeableWaitTimeout(DEFAULT_MERGEABLE_WAIT_TIMEOUT, sentinel), DEFAULT_MERGEABLE_WAIT_TIMEOUT);
39
+ });
@@ -0,0 +1,38 @@
1
+ // Mergeable-wait liveness policy — kept as a pure module (no env, no I/O) so it is trivially
2
+ // testable, mirroring app/mergeLandedWait.ts. `app/service.ts` seeds the validated
3
+ // `mergeableWaitTimeout` process variable when it starts the merge-loop; the merge-loop's
4
+ // `wait-mergeable-timeout` timer catch (the timer arm of the `gw-merge-wait` event-based gateway,
5
+ // racing the poller's `merge-ready` message) evaluates its `<bpmn:timeDuration>=mergeableWaitTimeout`
6
+ // at timer creation (FEEL-expression timer durations, engine-native).
7
+ //
8
+ // This closes the mergeable-wait liveness gap (issue #636): `wait-mergeable` was the only long-lived
9
+ // wait in the merge loop with NO bounded-wait timer — a bare `intermediateCatchEvent` woken *solely*
10
+ // by the in-process poller's `merge-ready` message. If that poller stalls (dies across a redeploy,
11
+ // errors on a PR, or skips a verdict) the instance parks at `waiting_merge` forever, with no timeout
12
+ // and no escalation — the entire conflict/CI remediation machinery (`gw-mergeable` → rebase / CI-fix
13
+ // / escalate) sits downstream of `wait-mergeable`, so it is never entered. Bounding the wait with a
14
+ // timer arm makes that impossible: when the timeout elapses the token routes to `merge-stall-probe`,
15
+ // which re-derives ground-truth mergeability from GitHub and feeds the existing `gw-mergeable` arms
16
+ // (or, after the stall-round cap is exhausted, escalates to a human). It is a durable, in-process
17
+ // backstop — no external watchdog required — mirroring the convergence loop's `wait-review-timeout`
18
+ // and the merge loop's own `wait-landed-timeout`.
19
+
20
+ import { isoDuration } from "./reviewWait.ts";
21
+
22
+ /** Default mergeable-wait timeout (ISO-8601 duration): how long the merge loop waits for the poller
23
+ * to publish `merge-ready` before the timer arm of the `gw-merge-wait` event-based gateway fires and
24
+ * `merge-stall-probe` re-derives mergeability from ground truth. Deliberately short — a healthy
25
+ * poller signals within one poll interval, so a 30-minute silence means the poller is dead and the
26
+ * PR must be reconciled — while still generous enough not to fire against a merely-slow poll pass. */
27
+ export const DEFAULT_MERGEABLE_WAIT_TIMEOUT = "PT30M";
28
+
29
+ /** Validate the operator-supplied mergeable-wait timeout (env `NANO_PR_MERGEABLE_WAIT_TIMEOUT`,
30
+ * ISO-8601 duration), falling back to {@link DEFAULT_MERGEABLE_WAIT_TIMEOUT} when absent, blank, or
31
+ * malformed — a bad env value must never deploy an uninterpretable timer expression. Derives its
32
+ * validation from the single canonical {@link isoDuration}. */
33
+ export function mergeableWaitTimeout(
34
+ raw: string | undefined,
35
+ def: string = DEFAULT_MERGEABLE_WAIT_TIMEOUT,
36
+ ): string {
37
+ return isoDuration(raw, def);
38
+ }
package/app/service.ts CHANGED
@@ -53,6 +53,7 @@ import {
53
53
  } from "./github.ts";
54
54
  import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
55
55
  import { pollLineage } from "./lineage.ts";
56
+ import { mergeableWaitTimeout } from "./mergeableWait.ts";
56
57
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
57
58
  import { mergeLandedWaitTimeout } from "./mergeLandedWait.ts";
58
59
  import {
@@ -143,6 +144,15 @@ export const MAX_REBASE_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_REBASE
143
144
  * retry (a race escalates immediately). Reuses the CI-fix budget clamp (allows 0 = disable). */
144
145
  export const MAX_MERGE_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_RETRIES, 5);
145
146
 
147
+ /** How many times the mergeable-wait timeout backstop (`merge-stall-probe`) will re-derive
148
+ * mergeability from ground truth and re-arm the merge stage before giving up and escalating to a
149
+ * human. Bounds the timer arm of the `gw-merge-wait` event-based gateway so a dead in-process poller
150
+ * (the #636 wedge) can never loop the probe forever: once the cap is exhausted, `gw-merge-stall`
151
+ * routes to `merge-esc-conflict` (human escalation) instead of re-arming. Default 3; set
152
+ * `NANO_PR_MAX_MERGE_STALL_ROUNDS=0` to escalate on the first stall. Reuses the CI-fix budget clamp
153
+ * (allows 0 = escalate immediately, ceiling-capped). */
154
+ export const MAX_MERGE_STALL_ROUNDS = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_STALL_ROUNDS, 3);
155
+
146
156
  /** How long a merge-loop AGENT service task (rebase / fix-ci) may sit without completing before its
147
157
  * interrupting timer boundary fires and the PR escalates for human attention. Seeded as the
148
158
  * `agentSlaTimeout` process variable at merge start and evaluated by those tasks' boundary timers.
@@ -170,6 +180,19 @@ export const MERGE_LANDED_WAIT_TIMEOUT = mergeLandedWaitTimeout(
170
180
  process.env.NANO_PR_MERGE_LANDED_WAIT_TIMEOUT,
171
181
  );
172
182
 
183
+ /** How long the merge loop waits for the in-process poller to publish `merge-ready` before the timer
184
+ * arm of the `gw-merge-wait` event-based gateway fires and `merge-stall-probe` re-derives
185
+ * mergeability from ground truth. Seeded as the `mergeableWaitTimeout` process variable at merge
186
+ * start and evaluated by the merge-loop's `wait-mergeable-timeout` timer catch. `wait-mergeable` was
187
+ * the only long-lived wait in the merge loop with NO bounded-wait timer, so a stalled poller (dead
188
+ * across a redeploy, errored on a PR, or a skipped verdict) parked the instance at `waiting_merge`
189
+ * forever with no timeout and no escalation (issue #636). ISO-8601 duration; a malformed
190
+ * `NANO_PR_MERGEABLE_WAIT_TIMEOUT` falls back to the default so an uninterpretable timer is never
191
+ * deployed. */
192
+ export const MERGEABLE_WAIT_TIMEOUT = mergeableWaitTimeout(
193
+ process.env.NANO_PR_MERGEABLE_WAIT_TIMEOUT,
194
+ );
195
+
173
196
  /** Cooldown (ms) between the poller's automatic Copilot re-request nudges for a single waiting PR.
174
197
  * Copilot dismisses re-requests, so the poller retries — but not on every tick; this throttles it
175
198
  * to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
@@ -723,6 +746,9 @@ export async function startMerge(
723
746
  mergeRetryMax: MAX_MERGE_RETRIES,
724
747
  agentSlaTimeout: AGENT_SLA_TIMEOUT,
725
748
  landedWaitTimeout: MERGE_LANDED_WAIT_TIMEOUT,
749
+ mergeableWaitTimeout: MERGEABLE_WAIT_TIMEOUT,
750
+ mergeStallRounds: 0,
751
+ mergeStallMax: MAX_MERGE_STALL_ROUNDS,
726
752
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
727
753
  rootRequestKey,
728
754
  abandonUrl: abUrl,
package/nano.app.json CHANGED
@@ -144,6 +144,10 @@
144
144
  "taskType": "pr.mark-merged",
145
145
  "handler": "workers/mark-merged/worker.ts"
146
146
  },
147
+ {
148
+ "taskType": "pr.merge-stall-probe",
149
+ "handler": "workers/merge-stall-probe/worker.ts"
150
+ },
147
151
  {
148
152
  "taskType": "pr.record-dependency",
149
153
  "handler": "workers/record-dependency/worker.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.163.3",
3
+ "version": "0.165.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",
@@ -187,6 +187,17 @@
187
187
  { "field": "delivery_label", "header": "Delivery" },
188
188
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
189
189
  ],
190
+ "rowActions": [
191
+ {
192
+ "label": "Dismiss",
193
+ "confirm": "Tick off this finished run? It acknowledges the run as done and files it under History.",
194
+ "showWhenField": "stage_state",
195
+ "action": {
196
+ "path": "/app/api/actions/acknowledge-done",
197
+ "body": { "feature_key": "{{row.feature_key}}" }
198
+ }
199
+ }
200
+ ],
190
201
  "detail": {
191
202
  "fields": [
192
203
  { "field": "outcome", "label": "Outcome" }