@nanobpm/nano-workforce 0.189.2 → 0.190.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.190.0](https://github.com/nanobpm/nano-workforce/compare/v0.189.3...v0.190.0) (2026-09-21)
2
+
3
+ ### Features
4
+
5
+ * **ui:** make Convergence auto-merge opt-in ([#752](https://github.com/nanobpm/nano-workforce/issues/752)) ([db18b17](https://github.com/nanobpm/nano-workforce/commit/db18b17ceadc1cccd48e50a12c98d867f657f3d5)), closes [#751](https://github.com/nanobpm/nano-workforce/issues/751)
6
+
7
+ ## [0.189.3](https://github.com/nanobpm/nano-workforce/compare/v0.189.2...v0.189.3) (2026-09-20)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **feature:** reconcile a COMPLETED feature-run engine instance to terminal (raise-only runs wedge in Active) ([#809](https://github.com/nanobpm/nano-workforce/issues/809)) ([39add8c](https://github.com/nanobpm/nano-workforce/commit/39add8c81f419ef41c7c4596adb1f5ad05c80e5a)), closes [#624](https://github.com/nanobpm/nano-workforce/issues/624) [#808](https://github.com/nanobpm/nano-workforce/issues/808) [#808](https://github.com/nanobpm/nano-workforce/issues/808) [#808](https://github.com/nanobpm/nano-workforce/issues/808) [#808](https://github.com/nanobpm/nano-workforce/issues/808) [#808](https://github.com/nanobpm/nano-workforce/issues/808) [#808](https://github.com/nanobpm/nano-workforce/issues/808) [#3](https://github.com/nanobpm/nano-workforce/issues/3) [704/#497](https://github.com/704/nano-workforce/issues/497) [503/#704](https://github.com/503/nano-workforce/issues/704) [#810](https://github.com/nanobpm/nano-workforce/issues/810) [#810](https://github.com/nanobpm/nano-workforce/issues/810) [#806](https://github.com/nanobpm/nano-workforce/issues/806)
12
+
1
13
  ## [0.189.2](https://github.com/nanobpm/nano-workforce/compare/v0.189.1...v0.189.2) (2026-09-18)
2
14
 
3
15
  ### Bug Fixes
package/README.md CHANGED
@@ -285,9 +285,10 @@ branch before escalating (`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3). Conflicts, an
285
285
  exhausted budget, or an agent that can't fix the build escalate to a human; answer in
286
286
  the UI and the process re-arms and retries.
287
287
 
288
- A single submission can pin **review-only** regardless of the global default by
289
- passing `convergeOnly: true` on the `start/convergence-loop` request — the PR stops at
290
- `converged` and is never handed to `merge-loop`, even with `NANO_PR_AUTO_MERGE` on.
288
+ A single submission can choose its merge behavior explicitly with the positive
289
+ `autoMerge` field on the `start/convergence-loop` request: `false` stops at
290
+ `converged` regardless of the global default, while `true` enables the merge-loop
291
+ after convergence only when `NANO_PR_AUTO_MERGE` is enabled.
291
292
 
292
293
  ### Fleet mode: hand it an issue (plan → implement → converge)
293
294
 
@@ -356,7 +357,7 @@ agent at that URL to author, compile, and submit a graph unaided. See
356
357
  | `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
357
358
  | `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / the `maxRounds` field on `start/convergence-loop`; clamped 1–100) |
358
359
  | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `GET /app/api/agent`, `/app/api/version`, `/app/api/status`); unset = open |
359
- | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the `convergeOnly` field on `start/convergence-loop` (`true` forces review-only for that PR) |
360
+ | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the positive `autoMerge` field on `start/convergence-loop` |
360
361
  | `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
361
362
  | `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
362
363
  | `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
package/SPEC.md CHANGED
@@ -582,10 +582,10 @@ same `prKey`, sharing the datasource and poller. It merges the PR, honouring
582
582
  merge-queue branches and cross-PR dependencies, and reuses the review stage's
583
583
  escalation machinery for anything it can't resolve autonomously.
584
584
 
585
- A per-submit `convergeOnly: true` on the `start/convergence-loop` request pins that PR
586
- to review-only regardless of the global default: `pr.finalize` reads the flag off the
587
- instance and rests the PR at `converged` without starting `merge-loop`. The flag only
588
- ever narrows (it never forces the merge stage on when `NANO_PR_AUTO_MERGE` is off).
585
+ A per-submit `autoMerge` setting on the `start/convergence-loop` request is the preferred
586
+ positive control: `false` pins that PR to review-only regardless of the global default, while
587
+ `true` enables the merge-loop after convergence only when `NANO_PR_AUTO_MERGE` is on. No per-submit
588
+ setting forces the merge stage on when the global toggle is off.
589
589
 
590
590
  Flow:
591
591
 
@@ -691,7 +691,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
691
691
  | `NANO_PR_POLL_MS` | 60000 | poll interval |
692
692
  | `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
693
693
  | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `/app/api/agent`, `/app/api/version`, `/app/api/status`) |
694
- | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `convergeOnly: true` override) |
694
+ | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `autoMerge: false` override) |
695
695
  | `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
696
696
  | `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
697
697
  | `NANO_PR_REVIEW_WAIT_TIMEOUT` | PT30M | ISO-8601 wait before a stalled review escalates (timer arm of the `wait-review` event-based gateway); malformed → default |
package/app/agentGuide.ts CHANGED
@@ -31,7 +31,7 @@ const RAW_GUIDE: string = (() => {
31
31
  "Key endpoints (under the app control-API base `__BASE__`):",
32
32
  "- `GET /status` — every PR in flight, with its engine `processKey` and any open escalation.",
33
33
  "- `GET /version` — which code is live.",
34
- "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, convergeOnly?, maxRounds?, dependsOn? }`).",
34
+ "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, autoMerge?, maxRounds?, dependsOn? }`; `autoMerge` is the positive per-request merge control).",
35
35
  "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue, baseBranch }` or `{ url, baseBranch }`; base is required — a missing `epic/*` base is auto-created, and `confirmDefaultBase`/`allowSharedBase` gate the default-branch and shared-base cases — see ADR 0003).",
36
36
  "- `POST /actions/complete-user-task` — answer an escalation (`{ userTaskKey, variables }`); the parked user task's key comes from `GET /status`/the Tasks inbox, and the typed variables match its `.form` (e.g. a PR escalation's `{ answer }`).",
37
37
  "- `POST /actions/message` — publish a BPMN message (optionally correlated) into the engine.",
@@ -67,7 +67,7 @@ export interface WaitProbeEntry {
67
67
  export interface ConnectorTargetEntry {
68
68
  target: string;
69
69
  status: "real" | "forward-declared";
70
- convergeOnlyDefault?: boolean;
70
+ autoMergeDefault?: boolean;
71
71
  summary: string;
72
72
  }
73
73
 
@@ -143,7 +143,7 @@ const NODE_KIND_DETAIL: Record<string, Omit<NodeKindEntry, "kind">> = {
143
143
  mayEmit: true,
144
144
  summary:
145
145
  "An automated, side-effecting outbound action. `payload` for a converge target is " +
146
- "`{ pr, convergeOnly?, dependsOn? }` (`pr` may be a literal `owner/repo#N`, a `<node>.pr` fact " +
146
+ "`{ pr, autoMerge?, dependsOn? }` (`pr` may be a literal `owner/repo#N`, a `<node>.pr` fact " +
147
147
  "reference, or omitted to auto-bind the single incoming `pr` fact). Carries a `dedupeKey` " +
148
148
  "(at-least-once safe). See connectorTargets for which targets are real vs. forward-declared.",
149
149
  },
@@ -218,13 +218,13 @@ const REAL_CONNECTOR_TARGETS: ConnectorTargetEntry[] = [
218
218
  {
219
219
  target: CONVERGE_TARGET,
220
220
  status: "real",
221
- convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_TARGET),
222
- summary: "Converge-only: drive review convergence and STOP at `converged`, never handing off to the merge loop.",
221
+ autoMergeDefault: !convergeOnlyForTarget(CONVERGE_TARGET),
222
+ summary: "Review-only: drive convergence and STOP at `converged`, never handing off to the merge loop.",
223
223
  },
224
224
  {
225
225
  target: CONVERGE_MERGE_TARGET,
226
226
  status: "real",
227
- convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_MERGE_TARGET),
227
+ autoMergeDefault: !convergeOnlyForTarget(CONVERGE_MERGE_TARGET),
228
228
  summary:
229
229
  "Unit-level land: drive review convergence AND the merge loop, landing the PR onto its OWN base branch " +
230
230
  "(for a unit inside an epic that base is the epic integration branch, never `main` directly).",
@@ -232,7 +232,7 @@ const REAL_CONNECTOR_TARGETS: ConnectorTargetEntry[] = [
232
232
  {
233
233
  target: MERGE_MAIN_TARGET,
234
234
  status: "real",
235
- convergeOnlyDefault: convergeOnlyForTarget(MERGE_MAIN_TARGET),
235
+ autoMergeDefault: !convergeOnlyForTarget(MERGE_MAIN_TARGET),
236
236
  summary:
237
237
  "Graph-level top-level land (two-level merge, ADR 0006 §3): land the graph/epic INTEGRATION PR onto `main`. " +
238
238
  "Dispatch-identical to `converge-merge`; the distinction is the LEVEL, kept a first-class literal.",
@@ -8,7 +8,7 @@
8
8
  import { after, test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
10
  import { withTrackingViews } from "../test/trackingViews.ts";
11
- import { FEATURE_PROCESS_ID, FEATURE_TERMINAL_STATUSES, featureTaskId, startFeature } from "./feature.ts";
11
+ import { FEATURE_PROCESS_ID, FEATURE_TERMINAL_STATUSES, featureTaskId, foldCompletedFeatureRun, startFeature } from "./feature.ts";
12
12
 
13
13
  // `startFeature` now fetches the issue title (issue #248) via the GitHub transport. Force the token
14
14
  // transport with no token so the fetch is a hermetic no-op (returns null) — no `gh` subprocess, no
@@ -55,6 +55,28 @@ function memData(stores: Record<string, { rows: any[]; key: string }>) {
55
55
  // rows — a base row with no explicitly-seeded `derived_status` folds `derived_status := status`.
56
56
  table: withTrackingViews((name: string, key: string) =>
57
57
  memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
58
+ // Minimal `open().exec()` for the guarded CAS folds (`foldCompletedFeatureRun`), so the re-seed-
59
+ // window regression below can drive the real CAS predicate (same feature_key + process_key +
60
+ // status = the observed transient, now a bound param) against the mid-reseed row.
61
+ open: () => ({
62
+ exec: (sql: string, params: any[]) => {
63
+ if (!/UPDATE "feature_runs" SET .* WHERE "feature_key" = \? AND "process_key" = \? AND "status" = \?/.test(sql)) {
64
+ throw new Error(`memData.exec: unhandled sql: ${sql}`);
65
+ }
66
+ const [status, label, updated_at, feature_key, process_key, expect_status] = params;
67
+ const rows = stores.feature_runs?.rows ?? [];
68
+ let changed = 0;
69
+ for (const r of rows) {
70
+ if (r.feature_key === feature_key && r.process_key === process_key && r.status === expect_status) {
71
+ r.status = status;
72
+ r.delivery_label = label;
73
+ r.updated_at = updated_at;
74
+ changed += 1;
75
+ }
76
+ }
77
+ return Promise.resolve({ changed });
78
+ },
79
+ }),
58
80
  } as any;
59
81
  }
60
82
 
@@ -277,6 +299,57 @@ test("startFeature: a settled run is restarted in place (status reset, pr/outcom
277
299
  assertEquals(row.process_key, "PI-2");
278
300
  });
279
301
 
302
+ // Issue #808 (TOCTOU review follow-up, RED first): the re-seed of a settled run flips `status` back to
303
+ // `running` but installs the NEW process key only AFTER `createInstance` returns. If the OLD
304
+ // `process_key` were left in place across that await, the poller's guarded fold
305
+ // (`foldCompletedFeatureRun`, keyed on the OLD key + `status='running'`) would still match the fresh
306
+ // incarnation mid-reseed and terminalize it. The reset now clears `process_key` to NULL atomically
307
+ // with the status reset, so throughout the window the guard matches ZERO rows. This test fires the
308
+ // exact interval: inside the `createInstance` stub (the window after the reset, before the new key is
309
+ // written) it runs the real CAS against the OLD key and asserts it is a no-op and the row is untouched.
310
+ test("startFeature: the re-seed window nulls process_key so a concurrent COMPLETED fold on the OLD key is a no-op (issue #808)", async () => {
311
+ const stores = {
312
+ feature_runs: {
313
+ rows: [{
314
+ feature_key: "owner/repo#42",
315
+ repo: "owner/repo",
316
+ issue_number: 42,
317
+ status: "opened", // a prior settled incarnation
318
+ process_key: "PI-OLD",
319
+ pr_key: "owner/repo#100",
320
+ delivery_label: "PR raised",
321
+ converge: 0,
322
+ auto_merge: 0,
323
+ }],
324
+ key: "feature_key",
325
+ },
326
+ };
327
+ const data = memData(stores);
328
+ let windowStatus: string | undefined;
329
+ let windowProcessKey: string | undefined;
330
+ let foldWon: boolean | undefined;
331
+ const engine = {
332
+ // Runs DURING the re-seed window: the row has been reset to `running` but the new key is not yet
333
+ // installed. Simulate the poller's guarded fold on the OLD key racing here.
334
+ createInstance: async () => {
335
+ const row = stores.feature_runs.rows[0];
336
+ windowStatus = row.status;
337
+ windowProcessKey = row.process_key;
338
+ foldWon = await foldCompletedFeatureRun(data, "owner/repo#42", "PI-OLD", "opened", "PR raised");
339
+ return { processInstanceKey: "PI-NEW" };
340
+ },
341
+ } as any;
342
+ await startFeature(data, engine, PARSED, "main", false, false);
343
+ // Mid-reseed the row was `running` with process_key CLEARED, so the OLD-key guard matched nothing.
344
+ assertEquals(windowStatus, "running");
345
+ assertEquals(windowProcessKey, null);
346
+ assertEquals(foldWon, false); // the concurrent fold was a no-op — the fresh incarnation is safe
347
+ const row = stores.feature_runs.rows[0];
348
+ // The run finished the reseed uncorrupted: still `running`, now carrying the NEW key.
349
+ assertEquals(row.status, "running");
350
+ assertEquals(row.process_key, "PI-NEW");
351
+ });
352
+
280
353
  test("startFeature: an in-place restart clears a stale acknowledged_at (re-earn the tick-off)", async () => {
281
354
  const stores = {
282
355
  feature_runs: {
package/app/feature.ts CHANGED
@@ -167,6 +167,23 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
167
167
  }
168
168
  }
169
169
 
170
+ /** Reconciled TERMINAL outcome for a feature run whose ENGINE instance read COMPLETED while the base
171
+ * `status` is still the non-terminal `running` (issue #808). A normally-completing raise-only (or
172
+ * nothing-to-do) run has no PR loop to reconcile it — `pollFeatureDelivery`'s converging reconcile and
173
+ * `instanceTracking`'s TERMINATED → `abandoned` edge both skip it — so it would wedge in the Active
174
+ * bucket forever (the "poller owns liveness; never leave a run on a status no pass scans" rule,
175
+ * AGENTS.md). The terminal is derived from the run's own durable completion shape, ENGINE-truth-gated
176
+ * by the caller (only ever applied on a real COMPLETED read): a run that raised a PR (its `pr_key`
177
+ * survives on the row from `record-feature`) folds to `opened`; a run that raised none folds to
178
+ * `skipped`. Pure and idempotent — the caller writes only when this differs from the current status. */
179
+ export interface FeatureCompletion {
180
+ status: FeatureRunStatus;
181
+ label: string;
182
+ }
183
+ export function deriveFeatureCompletion(run: Pick<FeatureRun, "pr_key">): FeatureCompletion {
184
+ return run.pr_key ? { status: "opened", label: "PR raised" } : { status: "skipped", label: "nothing to do" };
185
+ }
186
+
170
187
  /** The `feature-escalation` user-task element id (feature.bpmn) — the native operator wait a run
171
188
  * parks on when the agent escalates. `pollUserTasks` reads the engine's open task for this element to
172
189
  * project it onto the `user_tasks` Tasks inbox. */
@@ -236,6 +253,39 @@ export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
236
253
  * that reproduces the reconciler bypass). */
237
254
  export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
238
255
 
256
+ /** Guarded CAS fold of a settled feature run to its terminal delivery `status` + `delivery_label`
257
+ * (issue #808). This is NOT a blind `featureRuns(data).update(feature_key, …)`: the poller reads the
258
+ * engine instance state (`searchProcessInstances`) BEFORE it writes, and in that window `startFeature`
259
+ * can re-seed the SAME `feature_key` row to a FRESH `running` incarnation with a NEW `process_key`
260
+ * (the old instance completed, a resubmit relaunched). A blind write keyed on `feature_key` alone
261
+ * would clobber that newer incarnation to `opened`/`skipped` while its new instance is still active.
262
+ * The single guarded UPDATE requires the row to still be the exact incarnation we read (same
263
+ * `feature_key` + `process_key` + `status = expectStatus`, the transient the caller observed — the
264
+ * normally-completing fold reads `running`; the mid-handoff reconcile — {@link pollFeatureDelivery}'s
265
+ * handoff edge — reads `opened`), so a lost race matches zero rows and is a no-op. This is safe across
266
+ * the FULL re-seed window because `startFeature` clears `process_key` to NULL atomically with its
267
+ * `status = 'running'` reset (issue #808) and only reinstalls the new key after `createInstance`
268
+ * returns — so throughout the interval the fresh incarnation carries no key that this guard's OLD
269
+ * `process_key` predicate can match. Mirrors the `markProposalExpired`/`claimRunForLaunch` CAS pattern.
270
+ * Returns whether the write flipped a row (`res.changed > 0`) — `false` means a concurrent re-seed won
271
+ * the race, OR the observed transient had already advanced. */
272
+ export async function foldCompletedFeatureRun(
273
+ data: DataLayer,
274
+ featureKey: string,
275
+ processKey: string,
276
+ status: string,
277
+ label: string,
278
+ expectStatus: FeatureRunStatus = "running",
279
+ ): Promise<boolean> {
280
+ const res = await data
281
+ .open()
282
+ .exec(
283
+ `UPDATE "feature_runs" SET "status" = ?, "delivery_label" = ?, "updated_at" = ? WHERE "feature_key" = ? AND "process_key" = ? AND "status" = ?`,
284
+ [status, label, new Date().toISOString(), featureKey, processKey, expectStatus],
285
+ );
286
+ return res.changed > 0;
287
+ }
288
+
239
289
  /** A `feature_runs` row as seen through its derived tracking VIEW (`feature_runs__tracking`): the base
240
290
  * columns plus urban's ADR-0065 `derived_status`, which FOLDS the reconciler's terminal edge
241
291
  * (out-of-band terminate / in-app cancel → `abandoned`) over the worker-owned transient. Since urban
@@ -380,6 +430,23 @@ export async function startFeature(
380
430
  base_branch: base,
381
431
  issue_url: parsed.url,
382
432
  title,
433
+ // Clear the OLD process key ATOMICALLY with the status reset (issue #808). The re-seed flips the
434
+ // row back to `running` but does not install the new process key until AFTER `createInstance`
435
+ // returns below; leaving the stale `process_key` in place across that await opens a TOCTOU window
436
+ // where the poller's guarded fold (`foldCompletedFeatureRun`, keyed on the OLD process_key +
437
+ // `status='running'`) would still match this fresh incarnation and terminalize it to
438
+ // `opened`/`skipped`. Nulling it here — exactly as the insert branch below does — makes that
439
+ // guard match zero rows during the window, so the race collapses to a no-op. The new key is
440
+ // written back once the instance is created.
441
+ //
442
+ // DEFERRED (issue #810): nulling `process_key` across the `createInstance` await also opens a
443
+ // SECOND, distinct window — a concurrent `startFeature` for the same key sees `process_key=null`
444
+ // and, because the already-active guard is null-blind by design, can launch a DUPLICATE instance
445
+ // that orphans the first. This is a pre-existing intake-path race (the fresh-insert branch below
446
+ // already nulls the key the same way), out of scope for this reconciliation PR. The race-free fix
447
+ // — a launch-generation claim / serialized re-seed-and-recheck on the intake path — is tracked in
448
+ // issue #810; do NOT expand this PR back into the intake path to fix it here.
449
+ process_key: null,
383
450
  pr_key: null,
384
451
  converge: converge ? 1 : 0,
385
452
  auto_merge: autoMerge ? 1 : 0,