@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 +12 -0
- package/app/contracts.ts +15 -0
- package/app/mergeLoopBehaviour.test.ts +111 -0
- package/app/mergeableWait.test.ts +39 -0
- package/app/mergeableWait.ts +38 -0
- package/app/service.ts +26 -0
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/pages/overview.page.json +11 -0
- package/resources/processes/merge-loop.bpmn +339 -217
- package/workers/merge-stall-probe/worker.ts +66 -0
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.
|
|
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",
|
package/pages/overview.page.json
CHANGED
|
@@ -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" }
|