@nanobpm/nano-workforce 0.145.0 → 0.146.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.
@@ -0,0 +1,39 @@
1
+ // Merge-queue landing liveness policy — kept as a pure module (no env, no I/O) so it is trivially
2
+ // testable, mirroring app/agentSla.ts. `app/service.ts` seeds the validated `landedWaitTimeout`
3
+ // process variable when it starts the merge-loop; the merge-loop's `wait-landed-timeout` timer catch
4
+ // (the timer arm of the `eg-landed` event-based gateway, racing `merge-landed` / `merge-evicted`)
5
+ // evaluates its `<bpmn:timeDuration>=landedWaitTimeout` at timer creation (FEEL-expression timer
6
+ // durations, engine-native).
7
+ //
8
+ // This closes the merge-queue landing liveness gap (issue #556): `attempt-merge` classifies a merge
9
+ // as `queued` on an ambiguous "merge queue" signal (or a REST "accepted but not yet landed"
10
+ // fallback) WITHOUT verifying the PR was actually enqueued. On a repo where a plain `gh pr merge`
11
+ // does not enqueue (e.g. Mergify, which needs an explicit `@Mergifyio queue`), the PR is never
12
+ // placed in any queue, yet the loop parks at `wait-landed` awaiting a `merge-landed` that can never
13
+ // be published — ACTIVE, no incident, no escalation, forever. Bounding the wait with a timer arm
14
+ // makes that impossible: when the timeout elapses the token routes to the existing merge escalation
15
+ // so a human is pulled in (add it to the queue / merge it, then reply to retry). It is a durable,
16
+ // in-process backstop — no external watchdog required, mirroring the convergence loop's
17
+ // `wait-review-timeout`.
18
+
19
+ import { isoDuration } from "./reviewWait.ts";
20
+
21
+ /** Default merge-queue landing timeout (ISO-8601 duration): how long the merge loop waits for a
22
+ * `queued` PR to actually land before the timer arm of the `eg-landed` event-based gateway fires and
23
+ * it escalates to a human. Deliberately generous — a native GitHub merge queue legitimately takes a
24
+ * while to build the prospective merged commit and run its required checks — while still bounded so a
25
+ * never-enqueued PR (the Mergify-eligible-but-unqueued wedge, #556) surfaces to a human rather than
26
+ * hanging forever. */
27
+ export const DEFAULT_MERGE_LANDED_WAIT_TIMEOUT = "PT1H";
28
+
29
+ /** Validate the operator-supplied merge-queue landing timeout (env
30
+ * `NANO_PR_MERGE_LANDED_WAIT_TIMEOUT`, ISO-8601 duration), falling back to
31
+ * {@link DEFAULT_MERGE_LANDED_WAIT_TIMEOUT} when absent, blank, or malformed — a bad env value must
32
+ * never deploy an uninterpretable timer expression. Derives its validation from the single canonical
33
+ * {@link isoDuration}. */
34
+ export function mergeLandedWaitTimeout(
35
+ raw: string | undefined,
36
+ def: string = DEFAULT_MERGE_LANDED_WAIT_TIMEOUT,
37
+ ): string {
38
+ return isoDuration(raw, def);
39
+ }
@@ -41,6 +41,7 @@ import {
41
41
  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
+ const LANDED_WAIT_MS = 30 * 60 * 1000; // matches the PT30M landedWaitTimeout we start instances with
44
45
 
45
46
  type Output = Record<string, unknown>;
46
47
  type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
@@ -80,6 +81,7 @@ const DEFAULT_VARS: Record<string, unknown> = {
80
81
  rebaseRound: 0,
81
82
  mergeRetryRound: 0,
82
83
  agentSlaTimeout: "PT30M",
84
+ landedWaitTimeout: "PT30M",
83
85
  abandonBrief: null,
84
86
  failingChecksList: null,
85
87
  status: null,
@@ -206,11 +208,36 @@ test("a queued merge parks on the event gateway; the landed message marks it mer
206
208
  const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
207
209
  await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
208
210
  await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
209
- assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElements("wait-landed", "wait-evicted");
211
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElements("wait-landed", "wait-evicted", "wait-landed-timeout");
210
212
  await engine.publishMessage({ name: "merge-landed", correlationKey: "pr-1" });
211
213
  assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("mark-merged");
212
214
  });
213
215
 
216
+ test("a queued merge that never lands escalates when the landing timeout fires (#556)", async () => {
217
+ // The Mergify wedge: `attempt-merge` classifies the merge `queued` on an ambiguous signal, but the
218
+ // repo never actually enqueued the PR, so `merge-landed` can never be published. Without the timer
219
+ // arm the token would park at `wait-landed` forever (ACTIVE, no incident). The timer bounds it.
220
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
221
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
222
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
223
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-landed");
224
+ await engine.advanceTime(LANDED_WAIT_MS + 1);
225
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
226
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-landed");
227
+ assert(!completedElementIds(engine).has("mark-merged"), "a never-landed queued merge must not mark-merged");
228
+ assertStringIncludes(String(escalation(engine).question ?? ""), "did not land within the merge-queue landing timeout", "the escalation must name the landing-timeout trigger");
229
+ });
230
+
231
+ test("answering the landing-timeout escalation re-arms the merge poller (#556)", async () => {
232
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
233
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
234
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
235
+ await engine.advanceTime(LANDED_WAIT_MS + 1);
236
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
237
+ await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "queued it manually, retry" });
238
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
239
+ });
240
+
214
241
  test("an evicted queued merge re-arms the poller rather than completing", async () => {
215
242
  const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
216
243
  await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
package/app/service.ts CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
55
55
  import { pollLineage } from "./lineage.ts";
56
56
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
57
+ import { mergeLandedWaitTimeout } from "./mergeLandedWait.ts";
57
58
  import {
58
59
  freshHeadRunAction,
59
60
  headRunPresenceCount,
@@ -156,6 +157,18 @@ export const AGENT_SLA_TIMEOUT = agentSlaTimeout(process.env.NANO_PR_AGENT_SLA_T
156
157
  * default so an uninterpretable timer is never deployed. */
157
158
  export const REVIEW_WAIT_TIMEOUT = reviewWaitTimeout(process.env.NANO_PR_REVIEW_WAIT_TIMEOUT);
158
159
 
160
+ /** How long the merge loop waits for a `queued` PR to actually land before escalating to a human.
161
+ * Seeded as the `landedWaitTimeout` process variable at merge start and evaluated by the merge-loop's
162
+ * `wait-landed-timeout` timer catch (the timer arm of the `eg-landed` event-based gateway, racing the
163
+ * `merge-landed` / `merge-evicted` messages). `attempt-merge` classifies a merge as `queued` on an
164
+ * ambiguous "merge queue" signal without verifying a real enqueue, so on a repo that never actually
165
+ * queues a plain `gh pr merge` (e.g. Mergify) the loop would otherwise park at `wait-landed` forever
166
+ * (issue #556). ISO-8601 duration; a malformed `NANO_PR_MERGE_LANDED_WAIT_TIMEOUT` falls back to the
167
+ * default so an uninterpretable timer is never deployed. */
168
+ export const MERGE_LANDED_WAIT_TIMEOUT = mergeLandedWaitTimeout(
169
+ process.env.NANO_PR_MERGE_LANDED_WAIT_TIMEOUT,
170
+ );
171
+
159
172
  /** Cooldown (ms) between the poller's automatic Copilot re-request nudges for a single waiting PR.
160
173
  * Copilot dismisses re-requests, so the poller retries — but not on every tick; this throttles it
161
174
  * to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
@@ -708,6 +721,7 @@ export async function startMerge(
708
721
  mergeRetryRound: 0,
709
722
  mergeRetryMax: MAX_MERGE_RETRIES,
710
723
  agentSlaTimeout: AGENT_SLA_TIMEOUT,
724
+ landedWaitTimeout: MERGE_LANDED_WAIT_TIMEOUT,
711
725
  // Lineage (issue #245): thread the origin identity onto the merge instance (see startMerge).
712
726
  rootRequestKey,
713
727
  abandonUrl: abUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.145.0",
3
+ "version": "0.146.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",