@nanobpm/nano-workforce 0.200.4 → 0.200.6

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.
@@ -122,6 +122,10 @@ export interface PreparedDeliveryGraph {
122
122
  processDefinitionId: string;
123
123
  bpmn: string;
124
124
  nodeInputs: Record<string, NodeInput>;
125
+ /** The per-run token this prepare resolved (`options.runKey` or a fresh random). Surfaced so
126
+ * `runDeliveryGraph` seeds the SAME value as a run-root process variable — the reconciliation
127
+ * handle a stale-claim relaunch matches a still-running original instance on (issue #852). */
128
+ runKey: string;
125
129
  }
126
130
 
127
131
  export type PrepareDeliveryResult =
@@ -193,7 +197,7 @@ export async function prepareDeliveryGraph(
193
197
  if (element === undefined) continue; // unreachable — resolved covers every node — but keep total.
194
198
  nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts, requiredEmits: requiredEmitsByNodeId.get(node.id) ?? EMPTY_REQUIRED_EMITS });
195
199
  }
196
- return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs } };
200
+ return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs, runKey } };
197
201
  }
198
202
 
199
203
  /** Deploy + start a compiled graph as a running engine-native instance. Idempotent at the DEFINITION
@@ -207,7 +211,7 @@ export async function runDeliveryGraph(
207
211
  ): Promise<RunDeliveryResult> {
208
212
  const prep = await prepareDeliveryGraph(graph, options);
209
213
  if (!prep.ok) return prep;
210
- const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
214
+ const { processDefinitionId, bpmn, nodeInputs, runKey } = prep.prepared;
211
215
 
212
216
  await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
213
217
  // Per-node repository isolation (#739): the `io.nanobpm.agentTask.repository` envelope is now seeded
@@ -218,10 +222,18 @@ export async function runDeliveryGraph(
218
222
  // repository variable is seeded here; the resolution + loud-failure invariant (unresolved agent cell
219
223
  // on a non-`repoless` run) and the `repoless`/repo-base conflict guard all live in
220
224
  // `injectAgentRepoEnvelopes`, which the prepare step above already ran (throwing before deploy).
225
+ //
226
+ // `runKey` IS seeded as a run-root variable (unlike the repo envelope): it is the durable
227
+ // reconciliation handle a stale-claim relaunch uses to find a still-running ORIGINAL instance of this
228
+ // run before starting a second one (issue #852 review — thread deliveryGraphRun.ts:180). Every
229
+ // instance of this content-addressed definition carries its run key, so
230
+ // `searchProcessInstances({ processDefinitionId, state:"ACTIVE" })` + a `runKey` variable match
231
+ // identifies the live duplicate a timeout-only reclaim cannot distinguish from a dead claim.
221
232
  const { processInstanceKey } = await engine.createInstance({
222
233
  processDefinitionId,
223
234
  variables: {
224
235
  nodeInputs,
236
+ runKey,
225
237
  // Stage 0 transcript correlation (#543): the transcript-endpoint base every agent node's
226
238
  // completing worker appends its jobKey-scoped stream to, to emit `transcriptUrl` (see the agent
227
239
  // node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
@@ -233,7 +245,7 @@ export async function runDeliveryGraph(
233
245
  // downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
234
246
  return {
235
247
  ok: true,
236
- handle: { processDefinitionId, bpmn, nodeInputs, processInstanceKey: String(processInstanceKey) },
248
+ handle: { processDefinitionId, bpmn, nodeInputs, runKey, processInstanceKey: String(processInstanceKey) },
237
249
  };
238
250
  }
239
251
 
package/app/service.ts CHANGED
@@ -31,8 +31,9 @@ import { envContract, readEnvOr } from "./contracts.ts";
31
31
  import { makeDefaultReadHead } from "./currentHead.ts";
32
32
  import { isUniqueConstraintFence } from "./dbFence.ts";
33
33
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
34
+ import { DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
34
35
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
35
- import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
36
+ import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels, reconcileOriginalInstanceBeforeRelaunch, reconcileStaleLaunchClaim } from "./deliveryGraphRun.ts";
36
37
  import { deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement } from "./deliveryHuman.ts";
37
38
  import { fleetSupportsDurableResume } from "./durableResume.ts";
38
39
  import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
@@ -3049,10 +3050,58 @@ export async function pollTasklessPlanTermination(
3049
3050
  * Scoped to `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
3050
3051
  export async function pollDeliveryGraphPhase(
3051
3052
  data: DataLayer,
3052
- engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks">,
3053
+ engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks" | "searchVariables" | "cancelInstance">,
3053
3054
  ) {
3054
3055
  for (const run of await deliveryGraphRuns(data).find({ status: "running" })) {
3055
- if (!run.process_key) continue;
3056
+ if (!run.process_key) {
3057
+ // #852: a launch claim that never got an instance key within the TTL died mid-launch (the dispatch
3058
+ // process went down before deploy, so its own `markClaimFailed` never ran). Reconcile it to
3059
+ // `failed` so the cockpit stops showing a phantom in-flight run; a re-dispatch re-claims it. The
3060
+ // flip is a CAS on the snapshot we read (via `reconcileStaleLaunchClaim`), so a claim a concurrent
3061
+ // dispatch has already re-claimed between our `find()` and here is left untouched, never clobbered.
3062
+ // Reconcile-before-retire (issue #852 review — thread deliveryGraphRun.ts:180): the crash may have
3063
+ // happened AFTER `createInstance` succeeded but BEFORE the key was stamped, leaving a LIVE instance
3064
+ // executing this graph's side-effecting nodes. Retiring the row to `failed` and letting a
3065
+ // re-dispatch relaunch would then double-run those nodes. Cancel any still-ACTIVE instance of THIS
3066
+ // run (matched by its seeded `runKey` variable) BEFORE retiring the claim, so the recovery stays
3067
+ // at-most-once. Best-effort: a reconcile read failure must not wedge the pass — log and skip the
3068
+ // retire this pass (the row stays stale-claim `running` and is retried next pass). The definition
3069
+ // id is derived from the row's `digest` (`delivery-graph-<digest>`), NOT the `process_definition_id`
3070
+ // column — that column is stamped only AFTER a successful launch, so it is NULL on exactly the
3071
+ // crashed-mid-launch claim this path exists to recover.
3072
+ //
3073
+ // Fence the engine cancellation with the stale-claim CAS lease (issue #853 review — thread
3074
+ // service.ts:3077): the cancel is passed to `reconcileStaleLaunchClaim` as `finalize`, run INSIDE
3075
+ // the CAS transaction ONLY after the lease flip lands. Two races close:
3076
+ // • a FRESH `running`/NULL-key claim (still within its TTL) is non-stale, so
3077
+ // `reconcileStaleLaunchClaim` returns before the flip and the cancel never runs — a legitimate
3078
+ // just-launched instance (createInstance succeeded, key stamp pending) is never cancelled; and
3079
+ // • a concurrent dispatch that RE-CLAIMS the row first (refreshing `updated_at`) makes the CAS
3080
+ // match zero rows, so `finalize` never runs and the live replacement is never cancelled — the
3081
+ // retire is finalized only for the lease owner, and the lease is non-reclaimable for the
3082
+ // cancel's duration (the flipped `failed` row fails the dispatch's `status <> 'running'` guard).
3083
+ // Best-effort: a reconcile read failure must not wedge the pass — `finalize` throws, the
3084
+ // transaction rolls back (the row stays stale-claim `running`), we log and skip the retire this
3085
+ // pass, and the next pass retries. A fail-closed `ReconcileConflictError` (an ACTIVE
3086
+ // same-definition instance with no readable run-root `runKey`, e.g. a pre-upgrade legacy original)
3087
+ // takes this same path: the retire is deferred rather than relaunching a possible duplicate.
3088
+ const cancelOriginal = async () => {
3089
+ if (!run.digest) return;
3090
+ const cancelled = await reconcileOriginalInstanceBeforeRelaunch(engine, {
3091
+ runKey: run.run_key,
3092
+ processDefinitionId: `${DELIVERY_GRAPH_PROCESS_ID}-${run.digest}`,
3093
+ });
3094
+ if (cancelled.length > 0) {
3095
+ console.error(`[poller] delivery graph ${run.run_key}: cancelled a still-running original instance before retiring the stale claim: ${cancelled.join(", ")}`);
3096
+ }
3097
+ };
3098
+ try {
3099
+ await reconcileStaleLaunchClaim(data, run, cancelOriginal);
3100
+ } catch (err) {
3101
+ console.error(`[poller] delivery graph ${run.run_key}: reconcile-before-retire failed, leaving the claim for next pass: ${err}`);
3102
+ }
3103
+ continue;
3104
+ }
3056
3105
  const processKey = run.process_key;
3057
3106
  try {
3058
3107
  const [snapshots, parks] = await Promise.all([
@@ -15,7 +15,7 @@ import assert from "node:assert/strict";
15
15
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
16
16
  import { redactFreeText } from "../app/deliveryGraphCompiler.ts";
17
17
  import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
18
- import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
18
+ import { deliveryGraphRuns, LAUNCH_CLAIM_TTL_MS } from "../app/deliveryGraphRun.ts";
19
19
  import { isEquivalentReStage, stableProposalRunKey } from "./dispatchDeliveryGraph.ts";
20
20
 
21
21
  const APP_ROOT = resolve(import.meta.dirname, "..");
@@ -190,6 +190,28 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
190
190
  assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
191
191
  });
192
192
 
193
+ test("#852: a launch claim stranded by a crash mid-launch (running, no process key, past the TTL) does not wedge the proposal — a re-dispatch LAUNCHES it", async () => {
194
+ const app = await boot();
195
+ assert.ok(app.api);
196
+ const api = app.api;
197
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
198
+ const digest = staged.body.digest;
199
+ // The merlin state: the claim row + identity were written, then the process died before deploy.
200
+ const stale = new Date(Date.now() - LAUNCH_CLAIM_TTL_MS - 60_000).toISOString();
201
+ await deliveryGraphRuns(app.db).insert({
202
+ run_key: digest, process_key: null, process_definition_id: null, digest, status: "running", side_effecting: 0,
203
+ node_count: 1, human_node_count: 1, side_effect_count: 0, title: "manual gate", phase: "Running", phase_node_id: null,
204
+ human_labels: null, created_at: stale, updated_at: stale, acknowledged_at: null,
205
+ });
206
+ const res = await api.call<{ ok: boolean; alreadyRunning: boolean; processInstanceKey?: string }>("dispatchDeliveryGraph", {
207
+ body: { digest, repoless: true },
208
+ });
209
+ assert.equal(res.status, 202, JSON.stringify(res.body));
210
+ assert.equal(res.body.alreadyRunning, false, "must not short-circuit onto the phantom run");
211
+ assert.ok(res.body.processInstanceKey, "a real instance was started");
212
+ assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "dispatched");
213
+ });
214
+
193
215
  test("re-dispatching an ALREADY-dispatched digest → 400 (the proposal is consumed; the run shows in the in-flight grid)", async () => {
194
216
  const app = await boot();
195
217
  assert.ok(app.api);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.200.4",
3
+ "version": "0.200.6",
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",
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "@biomejs/biome": "^2.4.11",
73
- "@nanobpm/urban-testkit": "^1.4.0",
73
+ "@nanobpm/urban-testkit": "^1.6.0",
74
74
  "@nanobpm/workflow": "^0.15.0",
75
75
  "@semantic-release/changelog": "^7.0.0",
76
76
  "@semantic-release/git": "^11.0.0",
@@ -53,6 +53,12 @@ const DEFAULT_REFRESH_MS = 5000;
53
53
  // forever, so the busy() lock never clears and the UI is stranded; on timeout the AbortController
54
54
  // rejects the fetch, surfacing as an error banner and re-enabling the controls via the finally blocks.
55
55
  const REQUEST_TIMEOUT_MS = 30000;
56
+ // The dispatch POST gets its own, much longer budget (#852): the server lays out + deploys the graph
57
+ // before it answers, which for a large graph legitimately takes minutes (54 nodes ≈ 105 s on merlin).
58
+ // Aborting at 30 s showed the operator a failure while the launch was still in flight. Stays bounded so
59
+ // a genuinely hung door still releases the controls. Kept under the server's launch-claim TTL
60
+ // (`LAUNCH_CLAIM_TTL_MS`, 15 min) so the UI never gives up AFTER a crashed launch became re-claimable.
61
+ const DISPATCH_TIMEOUT_MS = 10 * 60_000;
56
62
 
57
63
  // The confirm shown before a dispatch — dispatching authorises every side-effecting node, so the
58
64
  // operator acknowledges that the launch (and its side effects) is content-addressed to this graph.
@@ -273,9 +279,9 @@ export function mountStagedProposals(host, config = {}) {
273
279
 
274
280
  /** Fetch JSON from a door and return { status, body } (never throws on an HTTP error). Rejects
275
281
  * (AbortError) if the request outlives REQUEST_TIMEOUT_MS so a hung door can't wedge the busy lock. */
276
- async function request(url, init) {
282
+ async function request(url, init, timeoutMs = REQUEST_TIMEOUT_MS) {
277
283
  const controller = new AbortController();
278
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
284
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
279
285
  try {
280
286
  const res = await fetch(url, { ...init, headers: headers(url), signal: controller.signal });
281
287
  let body = {};
@@ -291,7 +297,7 @@ export function mountStagedProposals(host, config = {}) {
291
297
  }
292
298
 
293
299
  const get = (url) => request(url, { method: "GET" });
294
- const post = (url, payload) => request(url, { method: "POST", body: JSON.stringify(payload) });
300
+ const post = (url, payload, timeoutMs) => request(url, { method: "POST", body: JSON.stringify(payload) }, timeoutMs);
295
301
 
296
302
  let disposed = false;
297
303
  // True while the last completed load failed — so a subsequent successful load knows to clear its own
@@ -372,9 +378,9 @@ export function mountStagedProposals(host, config = {}) {
372
378
  // the list on the next poll — refresh immediately so the operator sees it leave.
373
379
  async function doDispatch(payload) {
374
380
  busy(true);
375
- setStatus("Dispatching…");
381
+ setStatus("Dispatching… laying out and deploying the graph — a large graph can take a few minutes.");
376
382
  try {
377
- const { status, body } = await post(dispatchUrl, payload);
383
+ const { status, body } = await post(dispatchUrl, payload, DISPATCH_TIMEOUT_MS);
378
384
  if ((status === 202 || status === 200) && body.ok) {
379
385
  setStatus("\u2713 Dispatched — the run is now in flight.", "ok");
380
386
  await refresh();
@@ -382,7 +388,12 @@ export function mountStagedProposals(host, config = {}) {
382
388
  setStatus(body && body.error ? body.error : "Dispatch failed.", "err");
383
389
  }
384
390
  } catch (err) {
385
- setStatus(err && err.message ? err.message : "Dispatch request failed.", "err");
391
+ setStatus(
392
+ err && err.name === "AbortError"
393
+ ? "Dispatch is taking longer than expected — it may still be launching. Check Active Delivery Graphs before dispatching again."
394
+ : err && err.message ? err.message : "Dispatch request failed.",
395
+ "err",
396
+ );
386
397
  } finally {
387
398
  busy(false);
388
399
  }
@@ -133,6 +133,17 @@ you on the right side of it:
133
133
  (`Refs #N` / `Part of #N`) and **leave #N open** (or convert #N into a
134
134
  tracking/umbrella issue for the remainder). The classifier flags a PR that
135
135
  closes #N while leaving part of #N's stated scope undelivered and untracked.
136
+ **This rule governs your COMMIT MESSAGES and your PR TITLE too, not just the PR
137
+ body.** This repo family squash-merges with `COMMIT_MESSAGES`, so a `Closes #N`
138
+ in **any commit body** closes the issue on merge — even after you correct the PR
139
+ body to `Part of #N`. The **PR title becomes the squash commit SUBJECT**, so a
140
+ closing keyword in the title (`fix: parser slice (Closes #N)`) likewise closes #N
141
+ on merge. A partial slice whose PR body says `Part of #N` but whose first commit —
142
+ or whose title — still carries `Closes #N` *still* closes the broader parent on
143
+ merge, and the scope gate scans the title and commit bodies as well as the PR
144
+ body. So when you split scope, write `Part of #N` (never a closing keyword) in the
145
+ PR **title**, the PR body, and every commit body; if an early commit already
146
+ closed it, amend/reword that commit before you push.
136
147
  2. **A deferred remainder must be a FILED, tracked issue — never just prose.** If
137
148
  your PR defers part of its scope, **file a follow-up issue for each deferred
138
149
  item** and link it in the PR body with an explicit tracking marker the classifier
@@ -84,10 +84,15 @@ For every distinct child issue number `N` you find:
84
84
  - Otherwise, emit **one task** for it (see the output contract), with:
85
85
  - `id` = `issue-N`,
86
86
  - `title` = the sub-issue's title,
87
- - `prompt` = a self-contained brief built from the sub-issue's body, and end
88
- the prompt with an explicit instruction to the implementing agent to open its
89
- PR against this specific sub-issue and include `Closes #N` in the PR body so
90
- the sub-issue is linked and auto-closed on merge.
87
+ - `prompt` = a self-contained brief built from the sub-issue's body carrying
88
+ **#N's FULL stated scope** (every acceptance criterion / checkbox of the
89
+ sub-issue — an adopted sub-issue task owns its whole sub-issue, so the brief
90
+ must not silently cover only part of it), and end the prompt with an explicit
91
+ instruction to the implementing agent to open its PR against this specific
92
+ sub-issue and include `Closes #N` in the PR body so the sub-issue is linked and
93
+ auto-closed on merge. A closing keyword is only correct because the task
94
+ delivers #N in full; **never pair `Closes #N` with a brief scoped to a subset
95
+ of #N** (see "Closing keywords" below).
91
96
  - `dependsOn` = **honour any inter-sub-issue ordering the human declared.** Scan
92
97
  the sub-issue's body for an explicit dependency directive — a line such as
93
98
  `Depends-on: #7`, `Depends on #7`, or `Blocked by #7` (case-insensitive; there
@@ -192,6 +197,58 @@ a cohesive published artifact at the same time. Reserve genuinely separate packa
192
197
  for the consumer-facing cases above, and say in the task prompt which consumer
193
198
  justifies the split.
194
199
 
200
+ ### Closing keywords → only a FULL-scope slice may close its issue
201
+
202
+ When you decompose one issue into several slices, each slice delivers only **part**
203
+ of that issue's acceptance criteria. A slice scoped to a subset of an issue's scope
204
+ **must not be told to close that issue** — that is the exact planning defect this
205
+ rule exists to prevent (a node scoped to one of an issue's three acceptance
206
+ criteria was told to "close" it, the remainder was lost, and a human had to file
207
+ the follow-up and rewrite the PR by hand).
208
+
209
+ So, when you author each slice's `prompt`:
210
+
211
+ - **Only instruct a closing keyword (`Closes/Fixes/Resolves #N`) in a slice whose
212
+ brief delivers #N's FULL stated scope — and say so in an issue-anchored
213
+ whole-scope phrase the deterministic validator recognises.** If exactly one
214
+ slice covers the whole issue (the common single-task case), it may close it. If
215
+ you split the issue across slices, the parent issue is **broader** than any one
216
+ slice — so **no slice** closes it with a bare brief: instruct each partial slice
217
+ to reference the parent non-blockingly (`Part of #N` / `Refs #N`) and **leave
218
+ #N open**, OR designate one final slice that carries the `Closes #N`. That final
219
+ slice is the exception only when its brief makes it **own and verify #N's
220
+ COMPLETE stated scope** — every acceptance criterion, not just the remainder.
221
+ `dependsOn` on the sibling slices orders the merges, but it does **not** by
222
+ itself make the final slice's brief full-scope: a final slice scoped only to
223
+ "the remaining criterion" is still a partial brief and must **not** close #N.
224
+ Scope the final closer to re-verify the whole issue (its brief must direct it to
225
+ check every checkbox and confirm the siblings' merged work satisfies them)
226
+ before it emits `Closes #N`.
227
+ **The validator (`validateDeliveryGraph`) enforces this deterministically:** an
228
+ `agent.prompt` that closes an issue is rejected unless the brief also carries an
229
+ explicit **issue-anchored whole-scope acknowledgement TIED TO THE ISSUE IT
230
+ CLOSES**. Anchor the acknowledgement to the **same** issue — e.g. "delivers #N's
231
+ full stated scope", "every acceptance criterion of #N", "all of #N", "the whole
232
+ issue #N". A marker that names a **different** issue than the one you close
233
+ (acknowledge #11's scope, but `Closes #12`), or one that attributes the scope to
234
+ **siblings/other slices** ("the full scope of #N is handled by siblings"), does
235
+ **not** count — the validator credits a whole-scope phrase to issue #N only when it
236
+ sits in the same clause as #N (or when the prompt references exactly one issue) and
237
+ is not attributed to others. When a prompt closes more than one issue, give **each**
238
+ closing target its own anchored acknowledgement. A **bare adverb of completeness does not count** —
239
+ "fully", "completely", "end-to-end", "in full", "in its entirety" can modify a
240
+ *partial* deliverable ("implement one criterion fully"), so the validator
241
+ ignores them; always name the issue's whole scope, not just an adverb.
242
+ - **The remainder must be tracked, never dropped.** Every acceptance criterion of
243
+ the split issue must be covered by **some** slice in this plan (collectively the
244
+ slices must deliver #N's whole scope). Do not emit a slice set that silently
245
+ under-delivers the parent while one slice closes it.
246
+ - **This governs the COMMIT MESSAGES you tell the agent to write, too.** This repo
247
+ family squash-merges with `COMMIT_MESSAGES`, so a `Closes #N` in **any commit
248
+ body** closes the issue on merge even if the PR body says `Part of #N`. When a
249
+ slice is partial, instruct the agent to write `Part of #N` (never a closing
250
+ keyword) in **both** the PR body and every commit body.
251
+
195
252
  ## Output contract
196
253
 
197
254
  Write a JSON object of **result variables** to the file named by the
@@ -46,13 +46,46 @@ result.
46
46
 
47
47
  ## What to do
48
48
 
49
- 1. **Read the PR body.** `gh pr view <prNumber> --repo <repo> --json body,title`.
49
+ 1. **Read the PR body, the PR TITLE, AND the commit bodies.**
50
+ `gh pr view <prNumber> --repo <repo> --json body,title`.
50
51
  Extract every issue the body **closes with a GitHub closing keyword** —
51
52
  `close/closes/closed`, `fix/fixes/fixed`, `resolve/resolves/resolved` followed by
52
- `#N`, `owner/repo#N`, or a full issue URL. A **non-closing** reference (`Refs #N`,
53
+ `#N`, `owner/repo#N`, or a full issue URL. GitHub also recognises an **optional colon**
54
+ between the keyword and its target (`Closes: #12`, `Fixes: owner/repo#12`), so treat the
55
+ colon form as a closing reference too. A **non-closing** reference (`Refs #N`,
53
56
  `Part of #N`, `Depends-on #N`, `Follow-up: #N`) does **not** close an issue —
54
57
  ignore those for the closing-scope check (but note the follow-up links; see below).
55
58
 
59
+ **Scan the PR TITLE with these exact same closing-keyword rules too.** This repo
60
+ family squash-merges, so the **PR title becomes the squash commit SUBJECT**
61
+ (`AGENTS.md` "PR titles must be Conventional too — they become the release trigger"),
62
+ and GitHub honours a closing keyword in the squash subject exactly like one in the
63
+ body or a commit. So a conventional title such as `fix: parser slice (Closes #12)`
64
+ **closes #12 on merge** even when the body only says `Part of #12` and every commit
65
+ is clean. Treat a closing keyword found in the **title** exactly like one in the PR
66
+ body — add its `#N` to the set of closed issues you judge below (and if the slice is
67
+ partial, tell the human to reword the title to a non-closing form).
68
+
69
+ **Then scan the PR's commit messages for the same closing keywords.** Use the
70
+ **full commit history** — fetch base and head, then
71
+ `git log --format='%H%n%B' origin/<base>..<head>` (or `git log origin/<base>..<head>`).
72
+ **Do NOT rely on `gh pr view <prNumber> --repo <repo> --json commits` for this:**
73
+ the GitHub CLI silently truncates the `commits` field at **100 entries**, so on a
74
+ 101–250-commit PR it can miss an offending `Closes #N` in a later commit and
75
+ incorrectly clear the scope gate. If you must use the API instead of `git log`,
76
+ page it explicitly (`gh api --paginate .../pulls/<prNumber>/commits`) rather than
77
+ the capped `--json commits`. Likewise do **not** use `gh pr diff` here — it returns
78
+ only the patch, not the commit subjects/bodies, so it cannot detect a closing
79
+ keyword and would incorrectly clear the PR. This matters because this repo
80
+ family **squash-merges with `COMMIT_MESSAGES`**: the squash commit concatenates
81
+ every commit body, so a `Closes #N` left in **any commit body closes #N on
82
+ merge** even when the PR body was later corrected to `Part of #N`. Treat a
83
+ closing keyword found in a commit body **exactly** like one in the PR body — add
84
+ its `#N` to the set of closed issues you judge below. A PR whose body says
85
+ `Part of #N` but whose commit body still carries `Closes #N`, while part of #N's
86
+ scope is undelivered and untracked, is a genuine under-delivery you must block
87
+ (name the offending commit and tell the human to reword/amend it to `Part of #N`).
88
+
56
89
  2. **If there are no closing-keyword issues, the PR closes nothing** — there is no
57
90
  broader-scoped parent to under-deliver. Return **`scopeBlocked: false`** and stop.
58
91
 
@@ -0,0 +1,139 @@
1
+ // Guard for issue #858: a planner must not scope an agent node to PART of an issue while still
2
+ // telling it to CLOSE that issue, and the scope gate + agent contract must cover COMMIT bodies.
3
+ //
4
+ // Field case (merlin, delivery graph `5e36636255ab`): node `i12`'s prompt said "implement
5
+ // nano-supervisor#12 and open a PR … that closes it", but the body only described one of #12's
6
+ // three acceptance criteria. The agent did exactly that and wrote `Closes #12` in BOTH the PR body
7
+ // and the commit body. The convergence scope gate blocked, but a human still had to file the
8
+ // follow-up and rewrite the PR. The defect was in planning: a node scoped narrower than its issue
9
+ // must not be told to close it. The repo squash-merges with `COMMIT_MESSAGES`, so a `Closes #N` in a
10
+ // commit body closes the issue on merge even after the PR body is corrected — so the agent contract,
11
+ // the planner, and the scope gate must all reason about commit bodies, not just the PR body.
12
+ //
13
+ // These are prompt CONTRACTS (not code paths): the planner (`plan.md`), the implementation agent
14
+ // (`feature.md`), and the scope gate (`scope-classify.md`) are all prompt-driven. This test pins the
15
+ // guidance so a future prompt edit cannot silently drop the guard that closes this failure class.
16
+ import { test } from "node:test";
17
+ import { assert } from "#test-assert";
18
+ import { readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ const PROMPTS_DIR = join(import.meta.dirname, "..", "resources", "prompts");
22
+ const read = (name: string): string => readFileSync(join(PROMPTS_DIR, name), "utf8");
23
+
24
+ // The GitHub closing keywords, as the gate/agent must recognise them. A "closing keyword" in prose
25
+ // is any of these; a non-closing ref (`Part of`, `Refs`, `Follow-up`) is explicitly NOT.
26
+ const CLOSING_KEYWORDS = ["close", "fix", "resolve"];
27
+
28
+ // The closing-keyword SYNTAX each prompt must document for EVERY verb family — the canonical
29
+ // instruction triplet `Closes/Fixes/Resolves` (or the inflected `close/closes/closed …
30
+ // fix/fixes/fixed … resolve/resolves/resolved` listing). Asserting the syntax (not a bare
31
+ // `includes("fix")`, which passes for unrelated reasons) means dropping a family from a contract —
32
+ // e.g. removing `Fixes` — turns the test red instead of leaving it green.
33
+ const CLOSING_SYNTAX = [
34
+ /closes?\/fixes?\/resolves?/i,
35
+ /close\/closes\/closed[\s\S]*fix\/fixes\/fixed[\s\S]*resolve\/resolves\/resolved/i,
36
+ ];
37
+
38
+ test("feature.md: the closing-keyword rule covers COMMIT MESSAGES, not just the PR body", () => {
39
+ const feature = read("feature.md").toLowerCase();
40
+ // The agent contract must tell the agent a closing keyword in a commit body closes the issue on
41
+ // merge (the COMMIT_MESSAGES squash concern), so a partial slice must write `Part of #N` in both.
42
+ assert(
43
+ feature.includes("commit messages") || feature.includes("commit body") || feature.includes("commit bodies"),
44
+ "feature.md must tie the closing-keyword rule to commit messages/bodies",
45
+ );
46
+ assert(feature.includes("commit_messages"), "feature.md must name the COMMIT_MESSAGES squash behaviour");
47
+ });
48
+
49
+ test("plan.md: a partial slice must not be told to close its broader-scoped parent issue", () => {
50
+ const plan = read("plan.md");
51
+ const lower = plan.toLowerCase();
52
+ // The decomposition step must carry explicit closing-keyword guidance for split issues.
53
+ assert(lower.includes("closing keyword"), "plan.md must discuss closing keywords for slices");
54
+ // A full-scope requirement must gate a closing keyword, and a partial slice must use `Part of #N`.
55
+ assert(lower.includes("full"), "plan.md must require FULL scope before a slice closes an issue");
56
+ assert(plan.includes("Part of #N"), "plan.md must steer a partial slice to `Part of #N`");
57
+ // The planner guidance must also reach commit messages (the COMMIT_MESSAGES squash concern).
58
+ assert(lower.includes("commit_messages") || lower.includes("commit bod"), "plan.md must cover commit bodies");
59
+ });
60
+
61
+ test("scope-classify.md: the scope gate scans COMMIT bodies for closing keywords too", () => {
62
+ const gate = read("scope-classify.md");
63
+ const lower = gate.toLowerCase();
64
+ // The gate must read the PR's commits (not only its body) for closing keywords.
65
+ assert(lower.includes("commit"), "scope-classify.md must mention commits");
66
+ // It must name the commit body/message as the thing to scan…
67
+ assert(
68
+ lower.includes("commit bod") || lower.includes("commit message"),
69
+ "scope-classify.md must instruct reading commit bodies/messages",
70
+ );
71
+ // …AND prescribe an actual FULL-HISTORY retrieval mechanism the contract permits (`git log` or an
72
+ // explicitly `--paginate`d API call). Merely mentioning "commit bodies" — or the capped, explicitly
73
+ // FORBIDDEN `--json commits` — is not enough: deleting the real retrieval instruction would leave a
74
+ // substring-only check green while the gate silently loses its ability to see a `Closes #N` in a
75
+ // later commit of a >100-commit PR (issue #858 round-5 review).
76
+ assert(
77
+ /git\s+log/.test(lower) || lower.includes("--paginate"),
78
+ "scope-classify.md must prescribe a full-history commit retrieval (`git log` or a `--paginate`d " +
79
+ "API call), not the capped `--json commits`",
80
+ );
81
+ // It must explain WHY: the COMMIT_MESSAGES squash carries a commit-body closing keyword to merge.
82
+ assert(lower.includes("commit_messages"), "scope-classify.md must name the COMMIT_MESSAGES squash behaviour");
83
+ });
84
+
85
+ test("scope-classify.md: the scope gate scans the PR TITLE for closing keywords (squash subject)", () => {
86
+ const gate = read("scope-classify.md");
87
+ const lower = gate.toLowerCase();
88
+ // A closing keyword in the PR title becomes the squash commit SUBJECT and closes the issue on merge,
89
+ // so the gate must scan the title with the same closing-keyword rules as the body/commits — not only
90
+ // fetch it (issue #858 round-16 review). Pin the instruction, not just the `--json body,title` fetch.
91
+ assert(lower.includes("title"), "scope-classify.md must reference the PR title");
92
+ assert(
93
+ /scan\s+the\s+pr\s+title|title[\s\S]{0,80}closing[- ]keyword|closing[- ]keyword[\s\S]{0,80}title/.test(lower),
94
+ "scope-classify.md must instruct scanning the PR TITLE with the closing-keyword rules",
95
+ );
96
+ // …and explain WHY the title matters: it becomes the squash commit subject.
97
+ assert(
98
+ /squash\s+(?:commit\s+)?subject|title\s+becomes\s+the\s+squash/.test(lower),
99
+ "scope-classify.md must explain the title becomes the squash commit subject",
100
+ );
101
+ });
102
+
103
+ test("feature.md: the no-close-on-partial rule also governs the PR TITLE (squash subject)", () => {
104
+ const lower = read("feature.md").toLowerCase();
105
+ // The agent authors the PR title, so its contract must warn that a closing keyword there closes the
106
+ // issue on merge (title -> squash subject), not only the body/commit bodies (issue #858 round-16).
107
+ assert(lower.includes("title"), "feature.md must reference the PR title in the closing-keyword rule");
108
+ assert(
109
+ /title\s+becomes\s+the\s+squash|squash\s+(?:commit\s+)?subject/.test(lower),
110
+ "feature.md must tie the PR title to the squash commit subject",
111
+ );
112
+ });
113
+
114
+ test("all three prompts recognise the full set of GitHub closing keywords", () => {
115
+ for (const name of ["feature.md", "plan.md", "scope-classify.md"]) {
116
+ const lower = read(name).toLowerCase();
117
+ for (const kw of CLOSING_KEYWORDS) {
118
+ assert(lower.includes(kw), `${name} must reference the closing keyword family "${kw}"`);
119
+ }
120
+ }
121
+ });
122
+
123
+ // Stronger than the substring check above: pin the closing-keyword SYNTAX in each prompt, so a future
124
+ // edit that drops a verb family from the contract (e.g. `Closes/Resolves` with `Fixes` removed) fails
125
+ // loudly instead of staying green because the word "fix" still appears somewhere unrelated.
126
+ test("all three prompts document the closing-keyword SYNTAX for every verb family (close/fix/resolve)", () => {
127
+ for (const name of ["feature.md", "plan.md", "scope-classify.md"]) {
128
+ const text = read(name);
129
+ assert(
130
+ CLOSING_SYNTAX.some((re) => re.test(text)),
131
+ `${name} must document the Closes/Fixes/Resolves closing-keyword triplet (or the inflected ` +
132
+ `close/closes/closed … listing), not merely the bare substrings`,
133
+ );
134
+ assert(
135
+ /\bcloses?\s+#(?:n|\d)/i.test(text),
136
+ `${name} must show the actionable \`Closes #N\` instruction form`,
137
+ );
138
+ }
139
+ });
@@ -0,0 +1,85 @@
1
+ // #852 — a large graph's dispatch legitimately takes minutes (layout + deploy: ~105 s for 54 nodes on
2
+ // merlin). The staged view aborted EVERY request at a 30 s timeout and rendered the AbortError as the
3
+ // dispatch failure — the operator saw a "crash" while the server was still launching. The dispatch POST
4
+ // must outlive a slow-but-healthy launch (bounded by its own, much longer budget) and tell the operator
5
+ // it is still working.
6
+ import { mock, test } from "node:test";
7
+ import { assert, assertEquals } from "#test-assert";
8
+ import { parseHTML } from "linkedom";
9
+ import { mountStagedProposals } from "../pages/delivery-graphs/staged.mount.js";
10
+
11
+ const STAGED_URL = "https://app.test/app/api/delivery-graph/staged";
12
+ const DISPATCH_URL = "https://app.test/app/api/actions/delivery-graph/dispatch";
13
+ const PROPOSAL = {
14
+ digest: "abc123def456",
15
+ title: "Big graph",
16
+ sideEffecting: true,
17
+ nodeCount: 54,
18
+ humanNodeCount: 2,
19
+ sideEffectCount: 34,
20
+ createdAt: "2026-01-01T00:00:00.000Z",
21
+ expiresAt: "2026-01-02T00:00:00.000Z",
22
+ };
23
+
24
+ const flush = async () => {
25
+ for (let i = 0; i < 6; i++) await new Promise((resolve) => setImmediate(resolve));
26
+ };
27
+
28
+ test("#852: a dispatch slower than the generic 30 s request timeout is NOT aborted — it completes and reports success", async () => {
29
+ const { window: domWindow, document } = parseHTML("<!doctype html><html><body><div id='host'></div></body></html>");
30
+ const host = document.getElementById("host");
31
+ assert(host, "host");
32
+ let resolveDispatch: (() => void) | undefined;
33
+ let aborted = false;
34
+ const origFetch = globalThis.fetch;
35
+ globalThis.fetch = async (input, init) => {
36
+ const url = String(input);
37
+ if (url === STAGED_URL) return new Response(JSON.stringify({ proposals: [PROPOSAL] }), { status: 200 });
38
+ if (url === DISPATCH_URL) {
39
+ return await new Promise<Response>((resolve, reject) => {
40
+ init?.signal?.addEventListener("abort", () => {
41
+ aborted = true;
42
+ reject(new DOMException("signal is aborted without reason", "AbortError"));
43
+ });
44
+ resolveDispatch = () => resolve(new Response(JSON.stringify({ ok: true, status: "running" }), { status: 202 }));
45
+ });
46
+ }
47
+ return new Response("{}", { status: 404 });
48
+ };
49
+ const win = { confirm: () => false, prompt: () => null, location: { href: "https://app.test/delivery-graphs/", origin: "https://app.test" }, parent: undefined as unknown };
50
+ win.parent = win;
51
+ const origWindow = Reflect.get(globalThis, "window");
52
+ Reflect.set(globalThis, "window", win);
53
+ mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
54
+ const dispose = mountStagedProposals(host, { stagedUrl: STAGED_URL, dispatchUrl: DISPATCH_URL, refreshMs: 1_000_000_000 });
55
+ const click = (sel: string) => {
56
+ const el = host.querySelector(sel);
57
+ assert(el, `missing ${sel}`);
58
+ el.dispatchEvent(new domWindow.Event("click", { bubbles: true, cancelable: true }));
59
+ };
60
+ try {
61
+ await flush();
62
+ click("[data-dispatch]");
63
+ await flush();
64
+ click("[data-dispatch-confirm]");
65
+ await flush();
66
+ assert(resolveDispatch, "the dispatch POST was sent");
67
+ // Two minutes of a healthy-but-slow launch (layout + deploy) — well past the old 30 s abort.
68
+ for (let s = 0; s < 120; s++) {
69
+ mock.timers.tick(1000);
70
+ await flush();
71
+ }
72
+ assertEquals(aborted, false, "the dispatch request must not be aborted while the server is still launching");
73
+ const working = host.querySelector(".status")?.textContent ?? "";
74
+ assert(/dispatching/i.test(working), `the operator is told it is still working, got: ${working}`);
75
+ resolveDispatch();
76
+ await flush();
77
+ const status = host.querySelector(".status")?.textContent ?? "";
78
+ assert(/dispatched/i.test(status), `expected success, got: ${status}`);
79
+ } finally {
80
+ dispose();
81
+ mock.timers.reset();
82
+ globalThis.fetch = origFetch;
83
+ Reflect.set(globalThis, "window", origWindow);
84
+ }
85
+ });