@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.
- package/CHANGELOG.md +12 -0
- package/app/agentic/transcript-events.test.ts +216 -0
- package/app/agentic/transcript-events.ts +244 -5
- package/app/contracts.ts +24 -0
- package/app/mergeLandedWait.test.ts +39 -0
- package/app/mergeLandedWait.ts +39 -0
- package/app/mergeLoopBehaviour.test.ts +28 -1
- package/app/service.ts +14 -0
- package/package.json +1 -1
- package/resources/processes/merge-loop.bpmn +277 -209
|
@@ -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.
|
|
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",
|