@nanobpm/nano-workforce 0.189.2 → 0.189.3

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,9 @@
1
+ ## [0.189.3](https://github.com/nanobpm/nano-workforce/compare/v0.189.2...v0.189.3) (2026-09-20)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **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)
6
+
1
7
  ## [0.189.2](https://github.com/nanobpm/nano-workforce/compare/v0.189.1...v0.189.2) (2026-09-18)
2
8
 
3
9
  ### Bug Fixes
@@ -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,