@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.
@@ -31,16 +31,32 @@
31
31
  // LATER ADR-0065 rollout steps (3/4), deliberately out of scope here.
32
32
 
33
33
  import { and, caseWhen, col, defineReadModel, type Expr, eq, exists, lit, neq, not, or, pcol, type ReadModel, when } from "@nanobpm/urban";
34
- import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
34
+ import { deriveAckOpenFromTerminal, deriveListBucketFromTerminal, terminalStatusIn } from "./listBucket.ts";
35
35
 
36
36
  /** The 6 TRULY-terminal statuses that map to the `Done` stage — the single source of truth for the
37
- * terminal tier of BOTH the pipeline `stage`/`stage_state` derivations and the `list_bucket` history
38
- * partition. Distinct from `FEATURE_TERMINAL_STATUSES` (app/feature.ts), the redispatch-settled set,
39
- * which also counts `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/
40
- * `Converging`), NOT `Done`, so the two lists must stay separate. Re-exported from app/stage.ts for
41
- * back-compat with its existing importers. */
37
+ * terminal tier of the pipeline `stage`/`stage_state` derivations (and the `stepAxis` render tier).
38
+ * Distinct from `FEATURE_TERMINAL_STATUSES` (app/feature.ts), the redispatch-settled set, which also
39
+ * counts `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/`Converging`),
40
+ * NOT `Done`, so the two lists stay separate for the STAGE derivations. Re-exported from app/stage.ts
41
+ * for back-compat with its existing importers.
42
+ *
43
+ * NOTE — the Active/History `list_bucket`/`ack_open` derivations do NOT use this set; they use the
44
+ * WIDER {@link FEATURE_ACK_TERMINAL_STATUSES} (issue #808). The dismissable-terminal set is
45
+ * deliberately decoupled from the pipeline `Done` set: a raise-only run that ends at `opened` is a live
46
+ * `PR open` STAGE yet a FINISHED run that must be acknowledgeable into History. */
42
47
  export const STAGE_DONE_STATUSES: readonly string[] = ["merged", "converged", "blocked", "failed", "skipped", "abandoned"];
43
48
 
49
+ /** The status-set half of the DISMISSABLE-terminal predicate for the Active/History `list_bucket` +
50
+ * operator-`ack_open` derivations (issue #808) — the pipeline `Done` set ({@link STAGE_DONE_STATUSES})
51
+ * PLUS `opened`. A run folded to `opened` (a PR was raised) is dismissable-terminal, so it can drop to
52
+ * History on dismissal even though the pipeline still renders it in the LIVE `PR open` STAGE (which
53
+ * classifies on {@link STAGE_DONE_STATUSES}, so `opened` stays `PR open`, never `Done`). This is WIDER
54
+ * than `STAGE_DONE_STATUSES` (adds `opened`) and NARROWER than `FEATURE_TERMINAL_STATUSES` (excludes
55
+ * `converging`, still live in the convergence loop). NOTE — the derivation does NOT dismiss EVERY
56
+ * `opened` row on this set alone: an `opened` row that is still mid-handoff to the convergence loop is
57
+ * excluded by {@link featureDismissableTerminal} (see there). Exported for the drift/parity tests. */
58
+ export const FEATURE_ACK_TERMINAL_STATUSES: readonly string[] = [...STAGE_DONE_STATUSES, "opened"];
59
+
44
60
  /** The DSL projection name the `attention` derivation `exists(...)`-reads. Unregistered in the
45
61
  * `projectionRegistry`, so it resolves to the same-named physical table — the app's own `user_tasks`
46
62
  * inbox (034_user_tasks_inbox.sql), reconciled by `pollUserTasks` (app/service.ts): a row exists IFF a
@@ -129,21 +145,69 @@ const attention: Expr = caseWhen(
129
145
  lit(null),
130
146
  );
131
147
 
132
- /** The Active/History partition: `history` IFF the row is in a truly-terminal status AND has been
148
+ /** The one transient to EXCLUDE from the dismissable-terminal set: an `opened` row still mid-handoff
149
+ * into the convergence loop.
150
+ *
151
+ * Why (issue #808 follow-up): `record-feature` writes `status="opened"` for a converge-REQUESTED run
152
+ * too, BEFORE `gw-converge` routes it into `converge-feature` (which flips it to `converging`). During
153
+ * that in-between window the row reads `opened` while the engine instance is still ACTIVE, so a plain
154
+ * "any `opened` is dismissable" rule would offer Dismiss on an in-flight handoff and let a premature
155
+ * ack later drag a still-converging run to History — the SAME failure mode the epic model guards
156
+ * against by excluding its own mid-flight `converging`. The row itself distinguishes the two: a
157
+ * converge-requested run that actually handed off carries `converge=1 AND pr_key IS NOT NULL` — exactly
158
+ * the transient to exclude. Every other `opened` is finished: a raise-only run (`converge=0`), or a
159
+ * keyless `opened` that never satisfied the gateway's `prKey != null` and so fell through to `End`
160
+ * (`pr_key IS NULL`). The status guard (`derived_status = 'opened'`) scopes the exclusion to `opened`
161
+ * ONLY, so a genuinely-terminal row (e.g. `merged`) with `converge=1 AND pr_key` stays dismissable.
162
+ * `eq(col,col)` is the DSL's null-safe `IS NOT NULL` (see {@link isAckStamped}). A run stranded in this
163
+ * window by an out-of-band termination is reconciled to terminal by `pollFeatureDelivery`'s handoff
164
+ * edge (app/service.ts), which owns its liveness — so excluding it here never wedges it in Active. */
165
+ const openedMidHandoff: Expr = and(
166
+ eq(col(EFFECTIVE_STATUS_COLUMN), lit("opened")),
167
+ eq(col("converge"), lit(1)),
168
+ eq(col("pr_key"), col("pr_key")),
169
+ );
170
+
171
+ /* DEFERRED (issue #810): `openedMidHandoff` only excludes the `converge=1 AND pr_key` handoff window,
172
+ * so a raise-only (`converge=0`) or keyless `opened` row IS treated as dismissable-terminal even in the
173
+ * brief window before `record-feature`'s token reaches `gw-blocked`/`gw-converge`/End. This is an
174
+ * accepted, bounded sub-transaction transient: `record-feature` writes `opened` only AFTER the
175
+ * productive work (the PR was raised) is done, so the sole still-"live" work in that window is one inert
176
+ * gateway→End hop the instance auto-completes near-instantly — there is nothing productive for an
177
+ * ack/resubmission to hide or replace. The categorical fix — gating dismissability on a PERSISTED
178
+ * engine-completion marker (which would reverse the option-1a `pr_key` heuristic adjudicated in
179
+ * escalation 162) — is tracked in issue #810; do NOT reverse option-1a here. */
180
+
181
+ /** The feature DISMISSABLE-terminal PREDICATE — the epic read model's mid-flight refinement
182
+ * (app/planReadModel.ts / app/listBucket.ts) applied to features. A row is dismissable-terminal IFF its
183
+ * effective (terminal-folded) status is in the ONE canonical {@link FEATURE_ACK_TERMINAL_STATUSES} set
184
+ * (the pipeline-`Done` set PLUS `opened`) AND it is NOT a mid-handoff `opened` transient
185
+ * ({@link openedMidHandoff}). Built from the single {@link FEATURE_ACK_TERMINAL_STATUSES} source of
186
+ * truth — never a re-listed copy of its members — so a newly-acknowledgeable status added there flows
187
+ * through to `list_bucket`/`ack_open` without a second edit that could drift (issue #808 review). */
188
+ export const featureDismissableTerminal: Expr = and(
189
+ terminalStatusIn(EFFECTIVE_STATUS_COLUMN, FEATURE_ACK_TERMINAL_STATUSES),
190
+ not(openedMidHandoff),
191
+ );
192
+
193
+ /** The Active/History partition: `history` IFF the row is DISMISSABLE-terminal AND has been
133
194
  * acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). Delegates to the ONE
134
- * shared `deriveListBucketExpr` oracle (app/listBucket.ts, issue #641) parameterised by the feature
135
- * terminal set ({@link STAGE_DONE_STATUSES}) so all four "Active …" grids share the identical AST — the
136
- * emitted SQL stays byte-equivalent to migration 081's already-merged VIEW body (the shared oracle
137
- * reproduces this model's `isDone`/`isNotNull` forms exactly). */
138
- const listBucket: Expr = deriveListBucketExpr(EFFECTIVE_STATUS_COLUMN, STAGE_DONE_STATUSES);
139
-
140
- /** The operator "Dismiss" (tick-off) affordance flag — `1` IFF the run is terminal AND not yet
141
- * acknowledged (so the page's `showWhenField` Dismiss button renders only for a terminal-but-
195
+ * shared oracle (app/listBucket.ts, issue #641) over the feature {@link featureDismissableTerminal}
196
+ * predicate — WIDER than the pipeline `Done` set so a finished raise-only `opened` run is dismissable
197
+ * into History while the STAGE derivations still render it `PR open`, yet excluding a mid-handoff
198
+ * `opened` so a stray/premature ack never drags a still-converging run to History. All four "Active …"
199
+ * grids share the identical AST shape. */
200
+ const listBucket: Expr = deriveListBucketFromTerminal(featureDismissableTerminal);
201
+
202
+ /** The operator "Dismiss" (tick-off) affordance flag — `1` IFF the run is DISMISSABLE-terminal AND not
203
+ * yet acknowledged (so the page's `showWhenField` Dismiss button renders only for a terminal-but-
142
204
  * unacknowledged run), else `0`. The feature twin of the PR/Delivery-Graph/Epic `ack_open`, from the
143
- * ONE shared oracle (app/listBucket.ts, issue #641) parameterised by the SAME {@link
144
- * STAGE_DONE_STATUSES} terminal set `list_bucket` uses — so the Dismiss button and the
145
- * `acknowledgeDone` guard consume the identical predicate and cannot drift (issue #654). */
146
- const ackOpen: Expr = deriveAckOpenExpr(EFFECTIVE_STATUS_COLUMN, STAGE_DONE_STATUSES);
205
+ * ONE shared oracle (app/listBucket.ts, issue #641) over the SAME {@link featureDismissableTerminal}
206
+ * predicate `list_bucket` uses — so the Dismiss button and the `acknowledgeDone` guard consume the
207
+ * identical predicate and cannot drift (issue #654). Including a FINISHED `opened` (issue #808) lets a
208
+ * raise-only run be ticked off so it no longer wedges in Active; excluding a mid-handoff `opened` keeps
209
+ * an in-flight convergence handoff non-dismissable. */
210
+ const ackOpen: Expr = deriveAckOpenFromTerminal(featureDismissableTerminal);
147
211
 
148
212
  /** The keys of {@link featureReadModel}'s DERIVED columns, in the order migration 099 emits them.
149
213
  * Base columns are identity pass-throughs (not derivations) and are listed in the migration directly. */
package/app/reconcile.ts CHANGED
@@ -232,7 +232,7 @@ interface InstanceSearchStateItem {
232
232
  * outside this set is a partial/malformed read this app cannot interpret, so it degrades to `null`
233
233
  * ("unknown" → the caller spares the row). Classifying an unrecognized state as "gone" would fold
234
234
  * live work off a wire shape we misread — the exact failure mode the #736 cross-check exists to stop. */
235
- const ENGINE_TERMINAL_STATES: ReadonlySet<string> = new Set(["COMPLETED", "TERMINATED", "CANCELED", "FAILED"]);
235
+ export const ENGINE_TERMINAL_STATES: ReadonlySet<string> = new Set(["COMPLETED", "TERMINATED", "CANCELED", "FAILED"]);
236
236
 
237
237
  /** Build an {@link EngineActiveProbe} that queries the engine's own `/v2/process-instances/search` for
238
238
  * a single process instance key and reports whether the engine still considers it ACTIVE. This is the
@@ -580,6 +580,82 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
580
580
  });
581
581
  });
582
582
 
583
+ // Phantom-enrollment guard (#704/#497, PR #809 review): `submitPr` INSERTS the `pull_requests` row
584
+ // (`status='converging'`, `process_key` NULL) BEFORE `createInstance` and writes `process_key` only
585
+ // after it returns. A create-instance failure/crash in that window strands a NON-terminal row with NO
586
+ // `process_key` and NO live instance. Such a row must NOT be classified `alreadyRunning` (that would
587
+ // wedge it forever, and `pollFeatureDelivery` edge (1) would skip it as non-terminal) — it must fall
588
+ // through and RE-ENROLL, exactly as a genuinely-live row (non-terminal + `process_key` present) is
589
+ // still short-circuited to `alreadyRunning`.
590
+ test("submitPr re-enrolls a phantom row (non-terminal, process_key NULL) rather than reporting alreadyRunning (#704/#497)", async () => {
591
+ await withGithubOff(async () => {
592
+ const PR_KEY = "owner/repo#77";
593
+ const phantom = { pr_key: PR_KEY, repo: "owner/repo", number: 77, status: "converging", process_key: null };
594
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
595
+ pull_requests: { rows: [phantom], key: "pr_key" },
596
+ escalations: { rows: [], key: "id" },
597
+ pr_dependencies: { rows: [], key: "pr_key" },
598
+ };
599
+ const data = {
600
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
601
+ open: () => memOpen(stores as any),
602
+ } as any;
603
+ let created = 0;
604
+ const engine = {
605
+ createInstance: () => {
606
+ created++;
607
+ return Promise.resolve({ processInstanceKey: "PI-77" });
608
+ },
609
+ } as any;
610
+
611
+ const res = await submitPr(data, engine, {
612
+ repo: "owner/repo",
613
+ number: 77,
614
+ url: "https://github.com/owner/repo/pull/77",
615
+ prKey: PR_KEY,
616
+ });
617
+
618
+ assertEquals((res as any).alreadyRunning, undefined, "a phantom row is not short-circuited as alreadyRunning");
619
+ assertEquals(created, 1, "the phantom row is re-enrolled with a fresh instance");
620
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
621
+ assertEquals(pr.process_key, "PI-77", "the previously-missing process_key is installed");
622
+ });
623
+ });
624
+
625
+ test("submitPr still short-circuits a genuinely-live non-terminal row (process_key present) as alreadyRunning (#704/#497)", async () => {
626
+ await withGithubOff(async () => {
627
+ const PR_KEY = "owner/repo#78";
628
+ const live = { pr_key: PR_KEY, repo: "owner/repo", number: 78, status: "converging", process_key: "PI-live" };
629
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
630
+ pull_requests: { rows: [live], key: "pr_key" },
631
+ escalations: { rows: [], key: "id" },
632
+ pr_dependencies: { rows: [], key: "pr_key" },
633
+ };
634
+ const data = {
635
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
636
+ } as any;
637
+ let created = 0;
638
+ const engine = {
639
+ createInstance: () => {
640
+ created++;
641
+ return Promise.resolve({ processInstanceKey: "PI-new" });
642
+ },
643
+ } as any;
644
+
645
+ const res = await submitPr(data, engine, {
646
+ repo: "owner/repo",
647
+ number: 78,
648
+ url: "https://github.com/owner/repo/pull/78",
649
+ prKey: PR_KEY,
650
+ });
651
+
652
+ assertEquals((res as any).alreadyRunning, true, "a live enrolled PR is still short-circuited");
653
+ assertEquals(created, 0, "no duplicate instance is created for a live PR");
654
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
655
+ assertEquals(pr.process_key, "PI-live", "the live PR's process_key is untouched");
656
+ });
657
+ });
658
+
583
659
  // Per-request review-only override: `submitPr` carries `convergeOnly` onto the convergence
584
660
  // instance so `pr.finalize` can stop at `converged` without handing off to the merge-loop. Default
585
661
  // false (so the global auto-merge default governs); true when the caller pins review-only.
package/app/service.ts CHANGED
@@ -35,7 +35,7 @@ import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deli
35
35
  import { deliveryHumanContextQuestion, isDeliveryHumanElement } from "./deliveryHuman.ts";
36
36
  import { fleetSupportsDurableResume } from "./durableResume.ts";
37
37
  import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
38
- import { deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns } from "./feature.ts";
38
+ import { deriveFeatureCompletion, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns, featureRunsTracking, foldCompletedFeatureRun } from "./feature.ts";
39
39
  import {
40
40
  classifyMergeability,
41
41
  classifyPrLiveness,
@@ -88,6 +88,7 @@ import {
88
88
  readinessPollEvery,
89
89
  readinessTimeout,
90
90
  } from "./readiness.ts";
91
+ import { ENGINE_TERMINAL_STATES } from "./reconcile.ts";
91
92
  // The repository-provisioning envelope builder lives in its own module (app/repoEnvelope.ts) so the
92
93
  // PRE-PR implementation dispatch (`feature.ts`/`plan.ts`) can reuse the ONE canonical implementation
93
94
  // without a `plan.ts ↔ service.ts` import cycle (issue #684). Imported for the PR-based callers
@@ -534,7 +535,16 @@ export async function submitPr(
534
535
  // it `alreadyRunning` forever (the #497 phantom). Route the idempotency gate through the derived view
535
536
  // so a cancelled PR is correctly seen terminal and RESUBMITTABLE.
536
537
  const trackedExisting = existing ? await prsTracking(data).get(parsed.prKey) : undefined;
537
- if (trackedExisting && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
538
+ // Phantom-enrollment guard (#704/#497, PR #809 review): a non-terminal row is "already running" ONLY
539
+ // if an engine instance was actually dispatched — i.e. it holds a `process_key`. `submitPr` INSERTS
540
+ // the `pull_requests` row (`status='converging'`, `process_key` NULL) BEFORE `createInstance` and
541
+ // writes `process_key` only AFTER it returns (below), so a create-instance failure/crash in that
542
+ // window strands a `converging` row with NO `process_key` and NO live instance. Classifying it off
543
+ // the derived status alone would wedge it `alreadyRunning` forever (and `pollFeatureDelivery` edge (1)
544
+ // would skip it as non-terminal, never re-enrolling). Treat a keyless non-terminal row as
545
+ // RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
546
+ // treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
547
+ if (trackedExisting && existing?.process_key != null && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
538
548
  return { prKey: parsed.prKey, alreadyRunning: true };
539
549
  }
540
550
 
@@ -2294,19 +2304,69 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
2294
2304
  }
2295
2305
  }
2296
2306
 
2297
- /** Reconcile each in-flight FEATURE run against its handed-off PR (fix: Feature history stuck at
2298
- * `converging`). A feature run ends its own process with `status = converging` and its PR's live
2299
- * outcome (merged / converged / abandoned) thereafter lives only on the `pull_requests` row keyed
2300
- * by `pr_key` — so the Feature history grid, which reads `feature_runs`, showed `converging` forever.
2301
- * This is the `feature_runs` twin of `pollDelivery` (which does the same for epic `plans`): for each
2302
- * run currently `converging` with a `pr_key`, project the PR's status onto `feature_runs.status`
2303
- * (advancing it to the matching terminal outcome once the PR settles) + a human `delivery_label`.
2304
- * Never touches a run that isn't `converging` — additive/derived only, idempotent, best-effort. */
2305
- export async function pollFeatureDelivery(data: DataLayer) {
2307
+ /** Reconcile each in-flight FEATURE run against ENGINE truth (fix: Feature history stuck at
2308
+ * `converging`; issue #808: raise-only runs wedge in Active). Two idempotent, best-effort edges over
2309
+ * the canonical feature reconciliation — no parallel reconciler:
2310
+ *
2311
+ * 1. CONVERGING → terminal (from its handed-off PR). A feature run ends its own process with
2312
+ * `status = converging` and its PR's live outcome (merged / converged / abandoned) thereafter lives
2313
+ * only on the `pull_requests` row keyed by `pr_key` — so the Feature history grid, which reads
2314
+ * `feature_runs`, showed `converging` forever. The `feature_runs` twin of `pollDelivery`: for each
2315
+ * run currently `converging` with a `pr_key`, project the PR's status onto `feature_runs.status`
2316
+ * (advancing it to the matching terminal outcome once the PR settles) + a human `delivery_label`.
2317
+ *
2318
+ * 2. COMPLETED → terminal (from the engine instance; issue #808). A raise-only / normally-completing
2319
+ * run ends its process with the non-terminal base `status = running` and is NEVER handed to a PR
2320
+ * loop, so neither edge (1) nor `instanceTracking`'s TERMINATED → `abandoned` edge folds it — it
2321
+ * would wedge in Active forever ("poller owns liveness; never leave a run on a status no pass
2322
+ * scans"). This owns the COMPLETED → terminal fold for such runs, mirroring the taskless-plan pass
2323
+ * ({@link pollTasklessPlanTermination}, #624) and {@link pollDeliveryGraphPhase}. Scoped to
2324
+ * `running` — the only non-terminal, non-parked status that can reach COMPLETED (`escalated` /
2325
+ * `awaiting_operator` keep the instance ALIVE at a user task, owned by their own edges) — so a
2326
+ * `converging` run is untouched by this fold. Liveness is read off the ADR-0065 derived tracking
2327
+ * VIEW (`derived_status`), so a run TERMINATED out of band (folding `derived_status` → `abandoned`)
2328
+ * is left to the `onTerminated` edge and never re-queried every pass. Writes only on a real
2329
+ * COMPLETED read (idempotent — a pass over a still-active or already-terminal run is a no-op).
2330
+ *
2331
+ * 3. MID-HANDOFF `opened` → terminal (PR #809 review / escalation 162). `record-feature` writes base
2332
+ * `status = opened` for a converge-REQUESTED run too, BEFORE `gw-converge` hands it to
2333
+ * `converge-feature` (which flips it to `converging` and enrolls the PR via `submitPr`). That
2334
+ * transient `opened` + `converge=1` + `pr_key` window is NOT dismissable (`featureReadModel` excludes
2335
+ * it so a premature ack can't drag a still-converging run to History), yet `opened` is no
2336
+ * `instanceTracking` activeStatus and neither edge (1) nor (2) scans it — so a row stranded there has
2337
+ * no owner. This owns that liveness, deciding PR-FIRST (never off the FEATURE instance state alone —
2338
+ * that was the drift surface): the `pull_requests` row keyed by `pr_key` is the single source of truth
2339
+ * for whether `submitPr` enrollment happened. If a PR row EXISTS, enrollment succeeded and the PR is a
2340
+ * live/settled SEPARATE process whose outcome edge (1) owns — fold `opened` → `converging`/terminal via
2341
+ * the same `deriveFeatureDelivery`, NEVER `abandoned` (it is structurally impossible for this edge to
2342
+ * abandon an enrolled PR). ONLY when NO PR row exists (enrollment never happened) is the engine probed:
2343
+ * a mid-handoff row can then only be stranded if its instance died before `submitPr`, so a KNOWN
2344
+ * terminal (or genuinely absent) instance means the handoff was interrupted — fold to the terminal
2345
+ * `abandoned` (mirroring `onTerminated`). That probe applies the reconcile-probe tri-state
2346
+ * (app/reconcile.ts): ACTIVE is left for `converge-feature`, and an instance whose snapshot carries an
2347
+ * empty/unknown state is SPARED (never folded off a wire shape we misread). A key edge (2) folded THIS
2348
+ * pass is skipped (it is already terminalized, not stranded). Read off the derived tracking VIEW so an
2349
+ * out-of-band fold is not re-queried; scoped to `converge=1 AND pr_key` (a raise-only or keyless
2350
+ * `opened` is genuinely terminal, owned by its own dismissable edge). */
2351
+ export async function pollFeatureDelivery(
2352
+ data: DataLayer,
2353
+ // Full `EngineClient`: edge (1) re-enrolls a converging run whose PR row is still missing via
2354
+ // `submitPr` (which needs `createInstance`), not just the `searchProcessInstances` the fold edges use.
2355
+ engine: EngineClient,
2356
+ ) {
2306
2357
  // Preload every PR status once per pass (mirrors pollDelivery — avoids an N+1 `prs(data).get`).
2307
2358
  // Read the ADR-0065 derived edge (`derived_status`) so a terminated run reads `abandoned`.
2308
2359
  const statusByPrKey = new Map<string, string>();
2309
- for (const pr of await prsTracking(data).all()) statusByPrKey.set(pr.pr_key, pr.derived_status);
2360
+ // Also index each tracked PR row so edge (1) can distinguish a NEVER-enrolled PR (no row) from a
2361
+ // PARTIALLY-enrolled one (row present, but `submitPr` crashed after the row insert and before
2362
+ // `createInstance` returned, so `process_key` is NULL and no instance exists — #704/#497 phantom).
2363
+ // Keep the whole row (not just the key) so the terminal-status classification below reads the
2364
+ // ADR-0065 `.derived_status`, satisfying the derive-only-reader class guard (#503/#704).
2365
+ const prRowByKey = new Map<string, TrackedPullRequest>();
2366
+ for (const pr of await prsTracking(data).all()) {
2367
+ statusByPrKey.set(pr.pr_key, pr.derived_status);
2368
+ prRowByKey.set(pr.pr_key, pr);
2369
+ }
2310
2370
  // Only `converging` runs are ever reconciled — query them via the `feature_runs(status)` index
2311
2371
  // (db/migrations/028) instead of scanning all history, so this pass stays O(in-flight), not
2312
2372
  // O(total runs), as the table grows.
@@ -2314,6 +2374,27 @@ export async function pollFeatureDelivery(data: DataLayer) {
2314
2374
  if (!run.pr_key) continue;
2315
2375
  try {
2316
2376
  const prStatus = statusByPrKey.get(run.pr_key) ?? null;
2377
+ const trackedPr = prRowByKey.get(run.pr_key) ?? null;
2378
+ // RE-ENROLL a converging run whose PR is not actually live, in EITHER of two shapes (PR #809
2379
+ // review):
2380
+ // (a) NO `pull_requests` row (`prStatus === null`) — since `converge-feature` writes
2381
+ // `converging` BEFORE `submitPr`, a terminate/crash in that gap (or an old submit failure /
2382
+ // store desync) leaves a converging row whose PR was never enrolled; and
2383
+ // (b) a PARTIALLY-enrolled row — present but with NO `process_key` and a non-terminal status:
2384
+ // `submitPr` inserts the row before `createInstance`, so a create-instance failure/crash
2385
+ // after the insert leaves a `converging` PR row with `process_key` NULL and no instance
2386
+ // (#704/#497 phantom). `prStatus` is non-null there, so the (a) check alone would skip it and
2387
+ // it would wedge `converging` forever with no PR process.
2388
+ // Both heal via the SAME idempotent `submitPr`: it is idempotent on the PR key (a redundant call
2389
+ // on an already-live PR early-returns `alreadyRunning`), and now treats a keyless non-terminal row
2390
+ // as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly`
2391
+ // mirrors `converge-feature` — the inverse of the run's `auto_merge` flag.
2392
+ const partiallyEnrolled =
2393
+ trackedPr != null && trackedPr.process_key == null && !TERMINAL_STATUSES.includes(trackedPr.derived_status);
2394
+ if (prStatus === null || partiallyEnrolled) {
2395
+ const parsed = parsePr(run.pr_key);
2396
+ if (parsed) await submitPr(data, engine, parsed, [], MAX_ROUNDS, run.auto_merge !== 1, run.feature_key);
2397
+ }
2317
2398
  const { status, label } = deriveFeatureDelivery(prStatus);
2318
2399
  if (run.status !== status || run.delivery_label !== label) {
2319
2400
  await featureRuns(data).update(run.feature_key, {
@@ -2326,6 +2407,92 @@ export async function pollFeatureDelivery(data: DataLayer) {
2326
2407
  console.error(`[poller] feature delivery ${run.feature_key}: ${err}`);
2327
2408
  }
2328
2409
  }
2410
+ // Edge (2): COMPLETED → terminal for a normally-completing `running` run (issue #808). Read the
2411
+ // derived tracking VIEW so a base-`running` row whose instance TERMINATED out of band (derived
2412
+ // `abandoned`) is skipped here (the `onTerminated` edge owns it) and never re-queried the engine.
2413
+ // Feature keys this edge folds this pass are tracked in `foldedByCompletion` so edge (3) — which
2414
+ // re-reads the DB and would otherwise see a row edge (2) just moved to `opened` in the SAME pass —
2415
+ // leaves them alone (PR #809 review): edge (2) has already assigned such a row its terminal outcome.
2416
+ const foldedByCompletion = new Set<string>();
2417
+ for (const run of await featureRunsTracking(data).find({ status: "running" })) {
2418
+ if (!run.process_key) continue;
2419
+ if (run.derived_status !== "running") continue;
2420
+ const processKey = run.process_key;
2421
+ try {
2422
+ const snapshots = await engine.searchProcessInstances({ processInstanceKeys: [processKey] });
2423
+ const state = snapshots.find((s) => String(s.processInstanceKey) === processKey)?.state ?? null;
2424
+ if (state !== "COMPLETED") continue;
2425
+ const { status, label } = deriveFeatureCompletion(run);
2426
+ // Guarded CAS (not a blind update-by-key): a concurrent `startFeature` may have re-seeded this
2427
+ // same row to a fresh `running` incarnation (new `process_key`) in the window since the engine
2428
+ // read above — the guard makes such a lost race a no-op instead of clobbering the new run.
2429
+ if (await foldCompletedFeatureRun(data, run.feature_key, processKey, status, label)) {
2430
+ foldedByCompletion.add(run.feature_key);
2431
+ }
2432
+ } catch (err) {
2433
+ console.error(`[poller] feature completion ${run.feature_key}: ${err}`);
2434
+ }
2435
+ }
2436
+ // Edge (3): mid-handoff `opened` → `abandoned` for a run whose instance died before convergence (PR
2437
+ // #809 review, issue #808 follow-up). `record-feature` writes base `status="opened"` for a
2438
+ // converge-REQUESTED run TOO, BEFORE `gw-converge` hands it to `converge-feature` (which flips it to
2439
+ // `converging`). That transient `opened` + `converge=1` + non-null `pr_key` window is deliberately
2440
+ // NOT dismissable (`featureReadModel` excludes it, so a premature ack can't drag a still-converging
2441
+ // run to History) — but `opened` is not an `instanceTracking` activeStatus (nano.app.json), and
2442
+ // neither the converging edge (1) nor the running edge (2) scans it, so an instance TERMINATED in
2443
+ // that window would strand the row in Active FOREVER with `ack_open=0` and no reconciler. This edge
2444
+ // owns that liveness: a mid-handoff row can only LEAVE `opened` by writing `converging` (a clean
2445
+ // handoff) — so if its instance is a KNOWN terminal (or genuinely absent) while the row still reads
2446
+ // `opened`, the handoff was interrupted (out-of-band terminate / crash) and the run is folded to the
2447
+ // terminal `abandoned` (the same edge `instanceTracking.onTerminated` applies to a tracked run's
2448
+ // out-of-band termination). The snapshot is read with the SAME tri-state as the reconcile probe
2449
+ // (app/reconcile.ts): a still-ACTIVE instance is left untouched (`converge-feature` will advance it),
2450
+ // and an instance present with an empty/unknown/newly-introduced state is SPARED, never folded off a
2451
+ // wire shape we misread. A row edge (2) folded to `opened` in THIS pass is skipped (`foldedByCompletion`)
2452
+ // — it is already terminalized, not a stranded handoff. Read the derived tracking VIEW so a row
2453
+ // already folded `abandoned` out of band is skipped and never re-queried. Guarded CAS on the OLD
2454
+ // `process_key` + `status='opened'`, so a concurrent `startFeature` re-seed is a no-op, never a
2455
+ // clobber. Scoped to `converge=1 AND pr_key` — a raise-only (`converge=0`) or keyless (`pr_key IS
2456
+ // NULL`) `opened` is genuinely terminal and owned by its own dismissable edge, so it is never queried here.
2457
+ for (const run of await featureRunsTracking(data).find({ status: "opened" })) {
2458
+ if (!run.process_key || !run.pr_key || run.converge !== 1) continue;
2459
+ if (run.derived_status !== "opened") continue;
2460
+ // Edge (2) may have folded a COMPLETED `running` run to `opened` in THIS pass (thread 1): that row
2461
+ // is already terminalized by its own derivation, not a stranded mid-handoff — never re-fold it here.
2462
+ if (foldedByCompletion.has(run.feature_key)) continue;
2463
+ const processKey = run.process_key;
2464
+ try {
2465
+ // PR-DERIVED liveness FIRST (PR #809 review / escalation 162 decision 2): a `pull_requests` row
2466
+ // keyed by `pr_key` is the single source of truth for "did `submitPr` enrollment actually happen".
2467
+ // If one EXISTS, `converge-feature` already enrolled the PR — it is a live/settled SEPARATE process
2468
+ // whose outcome edge (1) owns — so the handoff was NOT interrupted regardless of the FEATURE
2469
+ // instance's engine state. Project the PR's derived outcome onto the row (fold `opened` →
2470
+ // `converging`/terminal via the SAME `deriveFeatureDelivery` edge (1) uses) and NEVER abandon it.
2471
+ // This makes it structurally impossible for the handoff edge to abandon an enrolled PR — inferring
2472
+ // "interrupted" from the FEATURE instance state alone was the drift surface. The engine probe below
2473
+ // is reached ONLY when no PR row exists (enrollment never happened), the genuine interrupted handoff.
2474
+ const prStatus = statusByPrKey.get(run.pr_key) ?? null;
2475
+ if (prStatus !== null) {
2476
+ const { status, label } = deriveFeatureDelivery(prStatus);
2477
+ await foldCompletedFeatureRun(data, run.feature_key, processKey, status, label, "opened");
2478
+ continue;
2479
+ }
2480
+ const snapshots = await engine.searchProcessInstances({ processInstanceKeys: [processKey] });
2481
+ const match = snapshots.find((s) => String(s.processInstanceKey) === processKey);
2482
+ // Interpret the snapshot with the SAME tri-state semantics as the reconcile engine-probe
2483
+ // (app/reconcile.ts): ACTIVE → still mid-handoff; a KNOWN terminal state or a genuinely-absent
2484
+ // instance → the handoff was interrupted (fold); an instance present with an empty/unknown/
2485
+ // newly-introduced state → a partial answer this app cannot interpret, so spare the row rather
2486
+ // than fold a possibly still-live run to `abandoned` off a wire shape we misread (thread 3).
2487
+ const state = match ? String(match.state ?? "").trim().toUpperCase() : null;
2488
+ if (state === "ACTIVE") continue; // still mid-handoff — converge-feature will advance it
2489
+ if (state !== null && !ENGINE_TERMINAL_STATES.has(state)) continue; // unknown/empty → spare
2490
+ // state === null (engine answered, instance absent) OR a known terminal → interrupted handoff.
2491
+ await foldCompletedFeatureRun(data, run.feature_key, processKey, "abandoned", "handoff interrupted", "opened");
2492
+ } catch (err) {
2493
+ console.error(`[poller] feature handoff ${run.feature_key}: ${err}`);
2494
+ }
2495
+ }
2329
2496
  }
2330
2497
 
2331
2498
  /** The `pull_requests` statuses a PR instance can be parked-and-active on, DERIVED from the single
@@ -3102,7 +3269,7 @@ export async function pollOnce(
3102
3269
  await pollMerges(data, engine, token);
3103
3270
  await pollWaitGate(data);
3104
3271
  await pollPromotion(data, engine, token);
3105
- await pollFeatureDelivery(data);
3272
+ await pollFeatureDelivery(data, engine);
3106
3273
  await pollLineage(data);
3107
3274
  await pollUserTasks(data, engine, engineRest);
3108
3275
  await pollEpicPhase(data, engine, engineRest);
package/app/stage.ts CHANGED
@@ -109,12 +109,29 @@ export function deriveStage(run: StageInput): DerivedStage {
109
109
  };
110
110
  }
111
111
 
112
- /** The Active/History partition label (§5): `history` iff the row is in a truly-terminal status AND
112
+ /** The Active/History partition label (§5): `history` iff the row is DISMISSABLE-terminal AND
113
113
  * acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). DERIVED on read from
114
114
  * the ONE `featureReadModel` `list_bucket` declaration — the same AST the VIEW compiles (migration 076),
115
115
  * NOT a write-time projection: the page's tabs filter the VIEW's derived `list_bucket`, and the stored
116
116
  * `feature_runs.list_bucket` base column is vestigial (retired as a write projection, issue #439). This
117
- * adapter is the TS lowering of that derivation, used off the write path (redispatch gating, tests). */
118
- export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
119
- return evalDerived<"active" | "history">("list_bucket", { [EFFECTIVE_STATUS_COLUMN]: status, acknowledged_at: acknowledgedAt ?? null });
117
+ * adapter is the TS lowering of that derivation, used off the write path (redispatch gating, tests).
118
+ *
119
+ * The `list_bucket` derivation is NOT a pure function of `status`/`acknowledged_at` alone: its
120
+ * DISMISSABLE-terminal predicate ({@link featureDismissableTerminal}) also reads `converge`/`pr_key` to
121
+ * carve out a mid-handoff `opened` transient (issue #808 follow-up). The optional `row` MUST therefore
122
+ * carry those two columns for any `opened` input, or the TS oracle diverges from the SQL VIEW for the
123
+ * mid-handoff case (SQL keeps it `active`; a converge/pr_key-blind oracle would drop an acked one to
124
+ * `history`) — the exact SQL/TS parity gap the PR #809 review flagged. Omit `row` only for a status
125
+ * that cannot be a mid-handoff `opened`. */
126
+ export function deriveListBucket(
127
+ status: string,
128
+ acknowledgedAt: string | null | undefined,
129
+ row?: { pr_key?: string | null; converge?: number | boolean | null },
130
+ ): "active" | "history" {
131
+ return evalDerived<"active" | "history">("list_bucket", {
132
+ [EFFECTIVE_STATUS_COLUMN]: status,
133
+ acknowledged_at: acknowledgedAt ?? null,
134
+ pr_key: row?.pr_key ?? null,
135
+ converge: row?.converge ?? null,
136
+ });
120
137
  }
@@ -0,0 +1,61 @@
1
+ -- Feature-run read model: widen the DISMISSABLE-terminal set of the `list_bucket` / `ack_open` derived
2
+ -- columns to include `opened` (issue #808). SUPERSEDES 099's VIEW body — every DERIVED column is
3
+ -- emitted VERBATIM from the ONE declaration in app/featureReadModel.ts, now with `list_bucket`/
4
+ -- `ack_open` parameterised by the WIDER `FEATURE_ACK_TERMINAL_STATUSES` (= `STAGE_DONE_STATUSES` +
5
+ -- `opened`) instead of `STAGE_DONE_STATUSES`. 099 is a MERGED, IMMUTABLE migration — never edited; this
6
+ -- is a NEW migration superseding its VIEW body (the same pattern by which 099 superseded 081, 081
7
+ -- superseded 076).
8
+ --
9
+ -- WHY. A raise-only feature run (converge not requested) ends at status `opened` after raising its PR.
10
+ -- Issue #808 folds a COMPLETED such run's frozen `running` status to `opened` (`deriveFeatureCompletion`
11
+ -- / `foldCompletedFeatureRun`) so it stops wedging in the Active bucket. But `opened` is deliberately a
12
+ -- LIVE `PR open` pipeline STAGE, NOT a `Done` status, so it was excluded from `STAGE_DONE_STATUSES` —
13
+ -- the set the `list_bucket`/`ack_open` derivations used. Result: the folded run had `ack_open=0` and
14
+ -- `list_bucket='active'`, so `acknowledgeDone` still 409'd ("feature run is not terminal") and the run
15
+ -- stayed wedged in Active — the exact wedge #808 aims to fix. This DECOUPLES the ack/History terminal
16
+ -- set from the stage-`Done` set: `opened` is now dismissable into History (so a finished raise-only run
17
+ -- can be ticked off) while the STAGE derivations still classify on `STAGE_DONE_STATUSES`, so the
18
+ -- pipeline renders `opened` as `PR open`, never `Done`.
19
+ --
20
+ -- Every DERIVED column below is emitted VERBATIM from `featureReadModel.sqlSelectFor(col,
21
+ -- { baseAlias: "fr" })` (which ALSO drives the runtime TS via `fnFor`); the `stage`/`stage_state`/
22
+ -- `stage_skipped`/`attention` bodies are UNCHANGED from 099 and only `list_bucket`/`ack_open` gain the
23
+ -- `'opened'` disjunct. The drift guard (app/featureReadModel.test.ts) fails if this file stops matching
24
+ -- the declaration, and `assertReadModelParity` proves the SQL and TS lowerings agree.
25
+ --
26
+ -- SEMANTICS unchanged from 099 EXCEPT the two `opened`-widened columns: the status-classifying columns
27
+ -- still read the terminal-folded `feature_runs__tracking.derived_status`; base columns stay aliased
28
+ -- identity pass-throughs; `acknowledged_at` still passes through so the read model can classify on it.
29
+ --
30
+ -- Forward-only VIEW redefinition (DROP then CREATE). `feature_runs__tracking` is the managed VIEW urban
31
+ -- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
32
+ -- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
33
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 110.
34
+
35
+ DROP VIEW IF EXISTS feature_read_model;
36
+
37
+ CREATE VIEW feature_read_model AS
38
+ SELECT
39
+ fr.feature_key AS feature_key,
40
+ fr.repo AS repo,
41
+ fr.issue_number AS issue_number,
42
+ fr.issue_url AS issue_url,
43
+ fr.title AS title,
44
+ fr.base_branch AS base_branch,
45
+ fr.status AS status,
46
+ fr.process_key AS process_key,
47
+ fr.pr_key AS pr_key,
48
+ fr.converge AS converge,
49
+ fr.auto_merge AS auto_merge,
50
+ fr.outcome AS outcome,
51
+ fr.delivery_label AS delivery_label,
52
+ fr.acknowledged_at AS acknowledged_at,
53
+ fr.created_at AS created_at,
54
+ fr.updated_at AS updated_at,
55
+ CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("fr"."derived_status" = 'converging'), 0) THEN 'Converging' WHEN COALESCE((COALESCE(("fr"."pr_key" <> ''), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) THEN 'PR open' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'running'), 0) OR COALESCE(("fr"."derived_status" = 'escalated'), 0) OR COALESCE(("fr"."derived_status" = 'awaiting_operator'), 0)), 0) THEN 'Implementing' ELSE 'Requested' END AS stage,
56
+ CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0)), 0) THEN 'ok' WHEN COALESCE(("fr"."derived_status" = 'blocked'), 0) THEN 'blocked' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
57
+ CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
58
+ CASE WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-blocked'), 0)), 0)) THEN 'blocked' WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-escalation'), 0)), 0)) THEN '⚠' ELSE NULL END AS attention,
59
+ CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
60
+ CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) AND (NOT COALESCE(COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
61
+ FROM feature_runs__tracking fr;