@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 +12 -0
- package/README.md +5 -4
- package/SPEC.md +5 -5
- package/app/agentGuide.ts +1 -1
- package/app/deliveryGraphVocabulary.ts +6 -6
- package/app/feature.test.ts +74 -1
- package/app/feature.ts +67 -0
- package/app/featureDelivery.test.ts +466 -7
- package/app/featureReadModel.test.ts +80 -1
- package/app/featureReadModel.ts +83 -19
- package/app/reconcile.ts +1 -1
- package/app/service.test.ts +76 -0
- package/app/service.ts +180 -13
- package/app/stage.ts +21 -4
- package/db/migrations/111_feature_read_model_ack_opened.sql +61 -0
- package/db/migrations/112_feature_read_model_opened_midhandoff.sql +63 -0
- package/docs/adr/0005-agent-authored-delivery-graphs.md +1 -1
- package/docs/agent-guide.md +9 -9
- package/openapi.yaml +29 -17
- package/operations/getAgentInstructions.test.ts +2 -1
- package/operations/startAndMessage.test.ts +44 -2
- package/operations/startConvergenceLoop.ts +13 -10
- package/package.json +1 -1
- package/pages/home.page.json +2 -2
- package/workers/converge-feature/worker.ts +12 -3
- package/workers/delivery-connector/worker.test.ts +8 -0
- package/workers/delivery-connector/worker.ts +13 -6
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
|
|
289
|
-
|
|
290
|
-
`converged`
|
|
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 `
|
|
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 `
|
|
586
|
-
to review-only regardless of the global default
|
|
587
|
-
|
|
588
|
-
|
|
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 `
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
222
|
-
summary: "
|
|
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
|
-
|
|
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
|
-
|
|
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.",
|
package/app/feature.test.ts
CHANGED
|
@@ -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,
|