@nanobpm/nano-workforce 0.167.0 → 0.167.2

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.
@@ -0,0 +1,54 @@
1
+ # Refresh the mirrored install script on nanobpm.io the moment install.sh changes.
2
+ #
3
+ # https://nanobpm.io/install.sh is served as the ACTUAL bytes of THIS repo's
4
+ # install.sh (GitHub Pages can't 301-redirect a `curl … | sh` URL). The site is
5
+ # built in a DIFFERENT repo (Magikcraft/nano-bpm) whose Pages workflow re-fetches
6
+ # install.sh on every deploy — but that deploy only triggers on changes to files
7
+ # in that repo, so an edit here would otherwise not reach the published mirror
8
+ # until the nightly backstop cron. This job pokes that repo's Pages deploy via a
9
+ # `repository_dispatch` (event type `install-sh-updated`) so the mirror refreshes
10
+ # within minutes of merging an install.sh change.
11
+ #
12
+ # Requires a token that can write to Magikcraft/nano-bpm — the default
13
+ # GITHUB_TOKEN is scoped to THIS repo only. Store a fine-grained PAT (or GitHub
14
+ # App token) with `Contents: read and write` on Magikcraft/nano-bpm as the repo
15
+ # secret PAGES_DISPATCH_TOKEN. Absent the secret, the job no-ops (the nightly
16
+ # cron on the site keeps the mirror eventually-consistent).
17
+ name: Refresh nanobpm.io install mirror
18
+
19
+ on:
20
+ push:
21
+ branches: [main]
22
+ paths:
23
+ - 'install.sh'
24
+ workflow_dispatch:
25
+
26
+ permissions:
27
+ contents: read
28
+
29
+ jobs:
30
+ dispatch:
31
+ name: dispatch install-sh-updated
32
+ runs-on: ubuntu-latest
33
+ steps:
34
+ - name: Notify the nanobpm.io Pages deploy
35
+ env:
36
+ DISPATCH_TOKEN: ${{ secrets.PAGES_DISPATCH_TOKEN }}
37
+ run: |
38
+ set -eu
39
+ if [ -z "${DISPATCH_TOKEN:-}" ]; then
40
+ echo "::warning::PAGES_DISPATCH_TOKEN is not set — skipping. nanobpm.io/install.sh will refresh on its nightly cron instead."
41
+ exit 0
42
+ fi
43
+ code=$(curl -sS -o /dev/null -w '%{http_code}' \
44
+ -X POST \
45
+ -H 'Accept: application/vnd.github+json' \
46
+ -H "Authorization: Bearer ${DISPATCH_TOKEN}" \
47
+ -H 'X-GitHub-Api-Version: 2022-11-28' \
48
+ https://api.github.com/repos/Magikcraft/nano-bpm/dispatches \
49
+ -d '{"event_type":"install-sh-updated"}')
50
+ if [ "$code" != "204" ]; then
51
+ echo "::error::repository_dispatch to Magikcraft/nano-bpm returned HTTP ${code} (expected 204). Check PAGES_DISPATCH_TOKEN scope (fine-grained PAT: Contents: read & write)."
52
+ exit 1
53
+ fi
54
+ echo "dispatched install-sh-updated to Magikcraft/nano-bpm (HTTP 204)"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [0.167.2](https://github.com/nanobpm/nano-workforce/compare/v0.167.1...v0.167.2) (2026-08-31)
2
+
3
+ ### Bug Fixes
4
+
5
+ * gate acknowledgePr on the read model's folded status, not the base column ([#653](https://github.com/nanobpm/nano-workforce/issues/653)) ([80446e2](https://github.com/nanobpm/nano-workforce/commit/80446e24af3c62570c041a109eef85eb34f8bb23)), closes [#652](https://github.com/nanobpm/nano-workforce/issues/652)
6
+
7
+ ## [0.167.1](https://github.com/nanobpm/nano-workforce/compare/v0.167.0...v0.167.1) (2026-08-31)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **delivery-graphs:** honest Preview & Reuse toasts (drop optimistic ✓, ack-or-timeout, bump @nanobpm/urban) ([#645](https://github.com/nanobpm/nano-workforce/issues/645)) ([#650](https://github.com/nanobpm/nano-workforce/issues/650)) ([b9cd357](https://github.com/nanobpm/nano-workforce/commit/b9cd357335fc555224bb35c5043b5d5df9cc273d)), closes [#518](https://github.com/nanobpm/nano-workforce/issues/518) [#518](https://github.com/nanobpm/nano-workforce/issues/518)
12
+ * **feature:** reset status to running on answer re-entry to implement-task ([#642](https://github.com/nanobpm/nano-workforce/issues/642)) ([#647](https://github.com/nanobpm/nano-workforce/issues/647)) ([fd636ba](https://github.com/nanobpm/nano-workforce/commit/fd636ba482562269ab64a242c5f29d5c4b1e2020)), closes [#632](https://github.com/nanobpm/nano-workforce/issues/632)
13
+
1
14
  ## [0.167.0](https://github.com/nanobpm/nano-workforce/compare/v0.166.0...v0.167.0) (2026-08-31)
2
15
 
3
16
  ### Features
package/app/contracts.ts CHANGED
@@ -420,7 +420,23 @@ export const WIRE_CONTRACTS = {
420
420
  owner: "pages/delivery-graphs/mount.js",
421
421
  semantics:
422
422
  "The INBOUND reuse-fill host-bridge message that loads a saved `DeliveryGraph` JSON into the Delivery Graphs COMPOSE App-View textarea (`#dg-json`) — issue #523, epic #519 S4. The compose mount (consumer) registers a same-origin `window` `message` listener for this shape and routes it through its single `fillComposer()` seam; the producer is the Library App-View **Reuse** action (#523), which posts it across the App-View iframe boundary (the INBOUND twin of the existing OUTBOUND `nano-navigate` DI-preview bridge). The filesystem **Import** control (#524) is NOT a producer of this message — it lives in the same compose mount and fills directly through `fillComposer()`, no cross-frame hop. The `type` string is exported ONCE as `DG_COMPOSE_FILL_MESSAGE` from pages/delivery-graphs/mount.js — the Reuse producer imports it, never re-declares a synonym.",
423
- shape: '{ type: "nano-delivery-graph-compose-fill", graphJson: string }',
423
+ shape: '{ type: "nano-delivery-graph-compose-fill", graphJson: string, token?: string }',
424
+ },
425
+ "deliveryGraph.compose.fill.ack": {
426
+ category: "wire",
427
+ name: "deliveryGraph.compose.fill.ack",
428
+ owner: "pages/delivery-graphs/mount.js",
429
+ semantics:
430
+ "The ACK half of the reuse-fill host-bridge message (issue #645). The compose App-View posts it back UP to the host — relayed across to the Library sibling App-View by the Urban App-View relay (nano-ide #518) — the moment it has actually filled `#dg-json` from a `deliveryGraph.compose.fill`. It exists to make the Library's success toast EARNED, not optimistic: the Library shows \"✓ Loaded…\" ONLY on this ack (matching its correlation `token`) and a clear \"Couldn't reach the composer\" on a short timeout, closing the #645 false-positive-toast defect. Its `token` echoes the producer's fill `token` so a stale ack from a prior Reuse can't complete a newer one. The `type` string is exported ONCE as `DG_COMPOSE_FILL_ACK_MESSAGE` from pages/delivery-graphs/mount.js — the Library consumer imports it, never re-declares a synonym.",
431
+ shape: '{ type: "nano-delivery-graph-compose-fill-ack", token: string | null }',
432
+ },
433
+ "nano.navigate.ack": {
434
+ category: "wire",
435
+ name: "nano.navigate.ack",
436
+ owner: "pages/delivery-graphs/mount.js",
437
+ semantics:
438
+ "The host's acknowledgment of a `nano-navigate` (issue #645). \"Preview generated DI\" posts `nano-navigate` UP to the console (forwarded to the host explorer by the Urban App-View relay, nano-ide #518), but the host does not synchronously confirm it navigated — so the old \"✓ Opening…\" toast printed right after the post claimed success even when the message was dropped (standalone, no relay, a console that never navigated). The compose view now treats Preview as fire-to-host with a bounded budget: a NEUTRAL in-progress status, resolved to \"✓ Opened…\" ONLY on this same-origin ack from the parent for the matching `target`, or to \"Couldn't reach the console explorer\" on a short timeout. Consumed same-origin from `window.parent`; the `type` string is `NANO_NAVIGATE_ACK_MESSAGE` in pages/delivery-graphs/mount.js.",
439
+ shape: '{ type: "nano-navigate-ack", target: string }',
424
440
  },
425
441
  "deliveryGraph.library.import.submit": {
426
442
  category: "wire",
@@ -675,3 +675,168 @@ test("pollUserTasks (typed-seam fallback): denormalises the engine form_key from
675
675
  const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
676
676
  assertEquals(byKey["ut-plan"].form_key, "form-77");
677
677
  });
678
+
679
+ test("pollUserTasks (engine-first): self-heals an escalated run stranded off its parked task, sparing a genuinely parked one (issue #642)", async () => {
680
+ // `status="escalated"` must hold ONLY while a `feature-escalation` task is open (parity with the PR
681
+ // contract). A run whose instance the engine no longer reports parked (its escalation was answered,
682
+ // or it predates the write-side reset — the #632 tear) is reconciled to `running`; a run whose task
683
+ // IS still open is left escalated. The engine's open set is the authority, not the raw status column.
684
+ const { data, stores } = memData({
685
+ feature_runs: [
686
+ { feature_key: "o/r#632", status: "escalated", process_key: "fp-632", issue_url: null, title: "stranded", delivery_label: null },
687
+ { feature_key: "o/r#77", status: "escalated", process_key: "fp-77", issue_url: null, title: "still parked", delivery_label: null },
688
+ ],
689
+ });
690
+ const restore = stubUserTaskSearch([
691
+ // Only fp-77 is genuinely parked; the engine reports NO open task on fp-632.
692
+ { userTaskKey: "ut-parked", elementId: "feature-escalation", processInstanceKey: "fp-77", state: "CREATED" },
693
+ ]);
694
+ try {
695
+ await pollUserTasks(data, fakeEngine({ "fp-77": [{ userTaskKey: "ut-parked", elementId: "feature-escalation" }] }), REST);
696
+ } finally {
697
+ restore();
698
+ }
699
+
700
+ const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
701
+ assertEquals(byKey["o/r#632"].status, "running", "the stranded escalated run is healed to running");
702
+ assertEquals(byKey["o/r#77"].status, "escalated", "the genuinely parked run stays escalated");
703
+ });
704
+
705
+ test("pollUserTasks (engine-first): skips the per-instance open-task RPC for an escalated run already seen parked in this pass's sweep (issue #642)", async () => {
706
+ // Presence in THIS pass's swept `desired` set is POSITIVE evidence the run is genuinely parked — the
707
+ // best-effort sweep may truncate (drop tasks) but never invents one. Re-confirming such a run with a
708
+ // per-instance `openUserTasks` RPC is a redundant N+1 query on every poll tick; the self-heal must skip
709
+ // it. Only a run NOT confirmed parked by the sweep still needs the per-instance check (unchanged).
710
+ const { data, stores } = memData({
711
+ feature_runs: [
712
+ { feature_key: "o/r#parked", status: "escalated", process_key: "fp-parked", issue_url: null, title: "parked", delivery_label: null },
713
+ { feature_key: "o/r#stranded", status: "escalated", process_key: "fp-stranded", issue_url: null, title: "stranded", delivery_label: null },
714
+ ],
715
+ });
716
+ const restore = stubUserTaskSearch([
717
+ { userTaskKey: "ut-parked", elementId: "feature-escalation", processInstanceKey: "fp-parked", state: "CREATED" },
718
+ ]);
719
+ const openUserTasksCalls: string[] = [];
720
+ const engine = {
721
+ searchUserTasks: () => Promise.resolve([]),
722
+ openUserTasks: (filter?: { processInstanceKey?: string }) => {
723
+ if (filter?.processInstanceKey) openUserTasksCalls.push(filter.processInstanceKey);
724
+ return Promise.resolve([]); // no instance reports an open escalation via the per-instance seam
725
+ },
726
+ } as unknown as EngineClient;
727
+ try {
728
+ await pollUserTasks(data, engine, REST);
729
+ } finally {
730
+ restore();
731
+ }
732
+ const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
733
+ assertEquals(byKey["o/r#parked"].status, "escalated", "the swept-parked run stays escalated without a per-instance query");
734
+ assertEquals(byKey["o/r#stranded"].status, "running", "the run absent from the sweep is still confirmed per-instance and healed");
735
+ assertEquals(openUserTasksCalls.includes("fp-parked"), false, "no redundant per-instance RPC for the already-parked run");
736
+ assertEquals(openUserTasksCalls.includes("fp-stranded"), true, "the unconfirmed run still needs the per-instance RPC");
737
+ });
738
+
739
+ test("pollUserTasks (engine-first): a TRUNCATED best-effort sweep never heals a genuinely-parked escalated run (issue #642)", async () => {
740
+ // `sweepOpenEscalationTasks` is explicitly best-effort — it BREAKS early on a paging/transport error
741
+ // and projects only what it had gathered. Healing `escalated -> running` on ABSENCE from that partial
742
+ // set is mutating durable state on negative evidence: a genuinely-parked human escalation whose task
743
+ // lived on an unreached page would be silently stolen. The self-heal must confirm per-instance against
744
+ // the engine's authoritative open set, NOT the (possibly truncated) global sweep.
745
+ const { data, stores } = memData({
746
+ feature_runs: [
747
+ { feature_key: "o/r#parked", status: "escalated", process_key: "fp-parked", issue_url: null, title: "genuinely parked", delivery_label: null },
748
+ { feature_key: "o/r#stranded", status: "escalated", process_key: "fp-stranded", issue_url: null, title: "stranded", delivery_label: null },
749
+ ],
750
+ });
751
+ // Page 1 fills the limit (forcing a second page) with unrelated open escalations; page 2 — which WOULD
752
+ // carry fp-parked's escalation — errors, so the sweep truncates and `desired` never sees fp-parked.
753
+ const page1: RawTask[] = Array.from({ length: 100 }, (_, i) => ({
754
+ userTaskKey: `other-${i}`,
755
+ elementId: "feature-escalation",
756
+ processInstanceKey: `other-${i}`,
757
+ state: "CREATED",
758
+ }));
759
+ const orig = globalThis.fetch;
760
+ // biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
761
+ globalThis.fetch = (async (url: string | URL, init?: any) => {
762
+ if (!String(url).endsWith("/user-tasks/search")) return new Response("not found", { status: 404 });
763
+ const from: number = JSON.parse(init?.body ?? "{}")?.page?.from ?? 0;
764
+ if (from === 0) {
765
+ return new Response(JSON.stringify({ items: page1 }), { status: 200, headers: { "content-type": "application/json" } });
766
+ }
767
+ return new Response("boom", { status: 500 }); // page 2 transport error -> sweep truncates here
768
+ }) as typeof fetch;
769
+ // The engine's authoritative per-instance open set: fp-parked IS parked; fp-stranded is not.
770
+ const engine = fakeEngine({ "fp-parked": [{ userTaskKey: "ut-parked", elementId: "feature-escalation" }] });
771
+ try {
772
+ await pollUserTasks(data, engine, REST);
773
+ } finally {
774
+ globalThis.fetch = orig;
775
+ }
776
+ const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
777
+ assertEquals(byKey["o/r#parked"].status, "escalated", "a genuinely-parked run survives a truncated sweep");
778
+ assertEquals(byKey["o/r#stranded"].status, "running", "a truly stranded run is still healed");
779
+ });
780
+
781
+ test("pollUserTasks (engine-first): does NOT heal an escalated run when the per-instance open-task query errors (issue #642)", async () => {
782
+ // A per-instance query error is not proof the run is unparked — mutating on that negative evidence would
783
+ // again steal a parked escalation. On query error the run must be left `escalated` for a later pass.
784
+ const { data, stores } = memData({
785
+ feature_runs: [{ feature_key: "o/r#err", status: "escalated", process_key: "fp-err", issue_url: null, title: "query errors", delivery_label: null }],
786
+ });
787
+ const restore = stubUserTaskSearch([]); // empty sweep -> old code would heal on absence
788
+ const engine = {
789
+ searchUserTasks: () => Promise.resolve([]),
790
+ openUserTasks: (filter?: { processInstanceKey?: string }) =>
791
+ filter?.processInstanceKey === "fp-err" ? Promise.reject(new Error("engine down")) : Promise.resolve([]),
792
+ } as unknown as EngineClient;
793
+ try {
794
+ await pollUserTasks(data, engine, REST);
795
+ } finally {
796
+ restore();
797
+ }
798
+ const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
799
+ assertEquals(byKey["o/r#err"].status, "escalated", "a failed per-instance query leaves the run escalated");
800
+ });
801
+
802
+ test("pollUserTasks (engine-first): does NOT heal a JUST-escalated run inside the grace window before its user task exists (issue #642)", async () => {
803
+ // `record-feature-escalation` writes `status="escalated"` (stamping `updated_at`) on the `escalated`
804
+ // arm IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing in that
805
+ // window sees `openUserTasks` return none and would wrongly flip the fresh escalation back to `running`,
806
+ // making the just-raised escalation invisible. A short grace window on `updated_at` spares a just-written
807
+ // escalation while still healing genuinely-stranded (old) rows.
808
+ const fresh = new Date().toISOString();
809
+ const stale = new Date(Date.now() - 60 * 60_000).toISOString(); // an hour ago — comfortably past grace
810
+ const { data, stores } = memData({
811
+ feature_runs: [
812
+ { feature_key: "o/r#fresh", status: "escalated", process_key: "fp-fresh", updated_at: fresh, issue_url: null, title: "just escalated", delivery_label: null },
813
+ { feature_key: "o/r#old", status: "escalated", process_key: "fp-old", updated_at: stale, issue_url: null, title: "genuinely stranded", delivery_label: null },
814
+ ],
815
+ });
816
+ const restore = stubUserTaskSearch([]); // engine reports no open escalation task for either instance
817
+ const engine = fakeEngine({}); // openUserTasks returns [] for every instance (task not yet created / gone)
818
+ try {
819
+ await pollUserTasks(data, engine, REST);
820
+ } finally {
821
+ restore();
822
+ }
823
+ const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
824
+ assertEquals(byKey["o/r#fresh"].status, "escalated", "a just-escalated run inside the grace window is spared the heal");
825
+ assertEquals(byKey["o/r#old"].status, "running", "a genuinely-stranded (old) run is still healed");
826
+ });
827
+
828
+ test("pollUserTasks (typed-seam fallback): self-heals an escalated run with no open feature-escalation task (issue #642)", async () => {
829
+ // The reduced-capability path scans FEATURE_ACTIVE_STATUSES instances (incl. `escalated`) directly,
830
+ // so the per-instance open-task read is just as authoritative for the self-heal.
831
+ const { data, stores } = memData({
832
+ feature_runs: [
833
+ { feature_key: "o/r#632", status: "escalated", process_key: "fp-632", issue_url: null, title: "stranded", delivery_label: null },
834
+ ],
835
+ });
836
+ const engine = fakeEngine({ "fp-632": [] }); // instance active at implement-task, no open user task
837
+
838
+ await pollUserTasks(data, engine); // no engineRest → typed-seam fallback
839
+
840
+ const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
841
+ assertEquals(byKey["o/r#632"].status, "running", "the stranded escalated run is healed to running");
842
+ });
@@ -22,6 +22,11 @@
22
22
  import { defineReadModel, type Expr, type ReadModel } from "@nanobpm/urban";
23
23
  import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
24
24
 
25
+ /** The read model's VIEW name — the single source of truth for the table an operation queries when it
26
+ * must gate on the folded/effective status (e.g. `acknowledgePr`, issue #652) rather than re-derive
27
+ * terminality off the frozen base `pull_requests.status` column. */
28
+ export const PULL_REQUEST_READ_MODEL_NAME = "pull_requests_read_model";
29
+
25
30
  /** The base table the read model reads: the auto-provisioned `pull_requests__tracking` derived VIEW
26
31
  * (ADR-0065), NOT the raw `pull_requests` table — so the status-classifying derivations read the
27
32
  * terminal-folded `derived_status` and a terminated PR drops to History with no worker write. */
@@ -66,7 +71,7 @@ export type PullRequestReadModelDerivedColumn = (typeof PULL_REQUEST_READ_MODEL_
66
71
  * generated from THIS single declaration.
67
72
  */
68
73
  export const pullRequestReadModel: ReadModel = defineReadModel({
69
- name: "pull_requests_read_model",
74
+ name: PULL_REQUEST_READ_MODEL_NAME,
70
75
  baseTable: PULL_REQUEST_READ_MODEL_BASE_TABLE,
71
76
  selectBaseColumns: false,
72
77
  derive: {
package/app/service.ts CHANGED
@@ -198,6 +198,15 @@ export const MERGEABLE_WAIT_TIMEOUT = mergeableWaitTimeout(
198
198
  * to one attempt per window. Set via `NANO_PR_REVIEW_NUDGE_MINUTES` (minutes). */
199
199
  export const REVIEW_NUDGE_MS = clampNudgeMinutes(process.env.NANO_PR_REVIEW_NUDGE_MINUTES) * 60_000;
200
200
 
201
+ /** Grace window (ms) before the `escalated`→`running` self-heal (below) may act on a row. The sole
202
+ * writer of `status="escalated"` — `record-feature-escalation` — runs on the `escalated` arm and stamps
203
+ * `updated_at` IMMEDIATELY BEFORE the engine creates the `feature-escalation` user task. A poll landing
204
+ * in that window would see `openUserTasks` report none and wrongly heal the just-raised escalation back
205
+ * to `running`, hiding it until a future write. Only heal rows whose escalation is older than this
206
+ * window, by when the user task must already be observable — sparing the in-flight transition while
207
+ * still reconciling genuinely-stranded rows. */
208
+ export const FEATURE_ESCALATION_HEAL_GRACE_MS = 60_000;
209
+
201
210
  /** Whether a converged PR is automatically driven to merge (the merge-loop). Default on; set
202
211
  * `NANO_PR_AUTO_MERGE=0` to stop at `converged` (review-only mode). */
203
212
  export const AUTO_MERGE = !["0", "false", "off", "no"].includes(
@@ -2677,6 +2686,49 @@ export async function pollUserTasks(
2677
2686
  await userTasks(data).update(user_task_key, { ...patch, updated_at: at });
2678
2687
  }
2679
2688
  for (const key of deletes) await userTasks(data).delete(key);
2689
+
2690
+ // ── Self-heal: `escalated` holds ONLY while parked (issue #642) ──────────────────────────────────
2691
+ // `record-feature-escalation` is the sole writer of `status="escalated"`, but the answer loop-back
2692
+ // now stamps `running` (via `record-feature-implementing`). This read-side sweep heals any row that
2693
+ // is ALREADY stranded at `escalated` — e.g. runs that escalated before the write-side twin shipped
2694
+ // (#632), or any future missed transition — by reconciling against the ENGINE's authoritative
2695
+ // open-escalation set. But it must NOT decide on absence from THIS pass's `desired` set:
2696
+ // `sweepOpenEscalationTasks` is best-effort and breaks early on a paging/transport error, so a
2697
+ // truncated `desired` would wrongly flip a genuinely-parked run whose task lived on an unreached page.
2698
+ // Confirm each escalated run POSITIVELY, per-instance, against `openUserTasks` (which pins
2699
+ // `state:"CREATED"`), and skip healing when that query errors — durable state is mutated only on
2700
+ // positive evidence that no `feature-escalation` task is open for the instance (parity with the PR
2701
+ // `status="escalated"` contract). Confined to rows with a known `process_key` so an untracked instance
2702
+ // is never guessed at.
2703
+ //
2704
+ // Presence in THIS pass's `desired` set is itself POSITIVE evidence of parking (truncation only ever
2705
+ // DROPS tasks, never invents one), so a run whose `feature-escalation` task was already swept is
2706
+ // genuinely parked — skip its per-instance `openUserTasks` RPC entirely (an avoidable N+1 on every
2707
+ // tick). Only a run NOT confirmed parked by the sweep falls through to the per-instance check below.
2708
+ const sweptParkedEscalations = new Set(
2709
+ desired.filter((r) => r.element_id === FEATURE_ESCALATION_ELEMENT && r.process_key).map((r) => r.process_key),
2710
+ );
2711
+ for (const run of await featureRuns(data).find({ status: "escalated" })) {
2712
+ if (!run.process_key) continue;
2713
+ if (sweptParkedEscalations.has(run.process_key)) continue; // already seen parked this pass — no RPC, no heal
2714
+ // A just-written escalation may not have its `feature-escalation` user task yet: the sole writer,
2715
+ // `record-feature-escalation`, stamps `updated_at` immediately BEFORE the engine creates the task.
2716
+ // Skip healing inside the grace window so this pass never races that transition and steals a fresh
2717
+ // escalation; a genuinely-stranded (old, or timestamp-less) row is past the window and still healed.
2718
+ const escalatedAt = Date.parse(run.updated_at ?? "");
2719
+ if (Number.isFinite(escalatedAt) && Date.now() - escalatedAt < FEATURE_ESCALATION_HEAL_GRACE_MS) continue;
2720
+ let openTasks: { elementId?: string }[];
2721
+ try {
2722
+ openTasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
2723
+ } catch (err) {
2724
+ // Negative evidence from a failed query is not proof the run is unparked — leave it for a later pass.
2725
+ console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${run.process_key}): ${err}`);
2726
+ continue;
2727
+ }
2728
+ if (!openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT)) {
2729
+ await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
2730
+ }
2731
+ }
2680
2732
  }
2681
2733
 
2682
2734
  /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
@@ -157,7 +157,8 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
157
157
  "the green gate leads into the fan-out head",
158
158
  );
159
159
  assert.ok(
160
- flows.includes("ensure-base-branch->implement-task"),
160
+ flows.includes("ensure-base-branch->record-feature-implementing") &&
161
+ flows.includes("record-feature-implementing->implement-task"),
161
162
  `the run reaches the implement agent only after the gate (flows: ${flows.join(", ")})`,
162
163
  );
163
164
  // A green probe never escalates.
@@ -189,7 +190,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
189
190
  );
190
191
  assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
191
192
  assert.ok(
192
- flows.includes("ensure-base-branch->implement-task"),
193
+ flows.includes("record-feature-implementing->implement-task"),
193
194
  "an ungated feature reaches the implement agent",
194
195
  );
195
196
  } finally {
@@ -245,8 +245,12 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
245
245
 
246
246
  const flows = takenFlows(app);
247
247
  assert.ok(
248
- flows.includes("w_gw_answer->implement-task"),
249
- `answer re-dispatched the same implement task (flows: ${flows.join(", ")})`,
248
+ flows.includes("w_gw_answer->record-feature-implementing"),
249
+ `answer re-dispatched through the implementing-reset task (flows: ${flows.join(", ")})`,
250
+ );
251
+ assert.ok(
252
+ flows.includes("record-feature-implementing->implement-task"),
253
+ `the reset task re-enters the same implement task (flows: ${flows.join(", ")})`,
250
254
  );
251
255
  assert.ok(!flows.includes("w_gw_answer->record-feature"), "the abandon (default) flow was NOT taken");
252
256
  assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
@@ -254,6 +258,56 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
254
258
  );
255
259
  });
256
260
 
261
+ test("escalate + answer: the run reads `running` (not a stale escalated) through the post-answer re-implementation (issue #642)", async () => {
262
+ // The write-side twin of `record-feature-escalation`: `record-feature-implementing` sits on the
263
+ // answer loop-back and stamps `running` BEFORE implement-task re-runs, so `feature_runs.status` no
264
+ // longer holds a stale `escalated` for the entire re-implementation (the #632 tear). Assert the
265
+ // agent observes `running` at the moment it is re-dispatched. Boots inline (not `withApp`) so the
266
+ // re-implementation stub can read the row through `app.db`.
267
+ const dbDir = mkdtempSync(join(tmpdir(), "nwf-f642-"));
268
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` } });
269
+ try {
270
+ let calls = 0;
271
+ let statusDuringReimpl: string | undefined;
272
+ await app.engine.registerWorker("senior:feature", async () => {
273
+ calls += 1;
274
+ if (calls === 1) {
275
+ return { status: "escalated", question: "Which API should I use?", summary: "parked for a human" };
276
+ }
277
+ const run = await app.db
278
+ .table<FeatureRow>("feature_runs", "feature_key")
279
+ .findOne({ feature_key: "owner/repo#7" });
280
+ statusDuringReimpl = run?.status;
281
+ return { status: "opened", pr: "owner/repo#642", summary: "resumed and opened" };
282
+ });
283
+ const featureKey = "owner/repo#7";
284
+ const started = await app.api?.call("startFeature", { body: { issue: featureKey, baseBranch: "epic/e2e" } });
285
+ assert.equal(started?.status, 202, "startFeature accepted the issue");
286
+ await app.settle();
287
+
288
+ const parked = await featureRow(app, featureKey);
289
+ assert.equal(parked.status, "escalated", "the run parks at escalated while awaiting the answer");
290
+ assert.ok(parked.process_key, "the parked run carries its engine process-instance key");
291
+
292
+ const tasks = await app.engine.searchUserTasks({ processInstanceKey: parked.process_key! });
293
+ const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
294
+ assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
295
+
296
+ await app.engine.completeUserTask(task!.userTaskKey, { resolution: "answer", answer: "use v2" });
297
+ await app.settle();
298
+
299
+ assert.equal(
300
+ statusDuringReimpl,
301
+ "running",
302
+ "the reset task stamped `running` before implement-task re-ran — no stale escalated",
303
+ );
304
+ assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
305
+ } finally {
306
+ await app.stop();
307
+ rmSync(dbDir, { recursive: true, force: true });
308
+ }
309
+ });
310
+
257
311
  test("escalate + abandon: abandoning routes to record-feature (default flow)", async () => {
258
312
  await withApp(
259
313
  {
@@ -273,7 +327,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
273
327
  flows.includes("w_gw_answer->record-feature"),
274
328
  `abandon routed to record-feature (flows: ${flows.join(", ")})`,
275
329
  );
276
- assert.ok(!flows.includes("w_gw_answer->implement-task"), "the answer loop was NOT taken");
330
+ assert.ok(!flows.includes("w_gw_answer->record-feature-implementing"), "the answer loop was NOT taken");
277
331
  },
278
332
  );
279
333
  });
@@ -322,8 +376,9 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
322
376
 
323
377
  const flows = takenFlows(app);
324
378
  assert.ok(
325
- flows.includes("w_gw_answer->implement-task"),
326
- `the answer re-dispatched the same implement task (flows: ${flows.join(", ")})`,
379
+ flows.includes("w_gw_answer->record-feature-implementing") &&
380
+ flows.includes("record-feature-implementing->implement-task"),
381
+ `the answer re-dispatched the same implement task through the reset (flows: ${flows.join(", ")})`,
327
382
  );
328
383
  assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
329
384
 
package/nano.app.json CHANGED
@@ -235,6 +235,10 @@
235
235
  {
236
236
  "taskType": "pr.delivery-connector",
237
237
  "handler": "workers/delivery-connector/worker.ts"
238
+ },
239
+ {
240
+ "taskType": "pr.record-feature-implementing",
241
+ "handler": "workers/record-feature-implementing/worker.ts"
238
242
  }
239
243
  ],
240
244
  "externalTaskTypes": [
@@ -1,25 +1,45 @@
1
- // Tests for the POST /app/api/actions/acknowledge-pr operation `acknowledgePr` (issue #641).
1
+ // Tests for the POST /app/api/actions/acknowledge-pr operation `acknowledgePr` (issue #641, #652).
2
2
  // The nwf UI's "Dismiss" (Done ✓) affordance for a TERMINAL pull request. It stamps `acknowledged_at`,
3
3
  // which the `pull_requests_read_model` VIEW (094) derives into `list_bucket` = 'history' and `ack_open`
4
4
  // = 0, dropping the finished PR from the Active convergence list into History. Unlike acknowledge-
5
5
  // blocked it completes NO user task (a terminal PR is not parked). The PR twin of acknowledge-done /
6
6
  // acknowledge-epic.
7
7
  //
8
- // The op's only write is the `acknowledged_at` (+ `updated_at`) stamp, gated on the base `status`
9
- // belonging to the terminal tier (`PR_TERMINAL_STATUSES`). These tests seed an in-memory `pull_requests`
10
- // store and assert the 400/404/409/200 gate + the stamp; the bucket derivation itself is proven in
11
- // app/pullRequestReadModel.test.ts.
8
+ // The op's only write is the `acknowledged_at` (+ `updated_at`) stamp on the base `pull_requests` row,
9
+ // gated on the TERMINAL tier (`PR_TERMINAL_STATUSES`) but read off the `pull_requests_read_model`
10
+ // VIEW's FOLDED/effective `status` (issue #652), the SAME source of truth the Dismiss affordance
11
+ // (`ack_open`) derives from, NOT the frozen base column. These tests seed an in-memory read model +
12
+ // base store and assert the 400/404/409/200 gate + the stamp; the bucket derivation itself is proven
13
+ // in app/pullRequestReadModel.test.ts.
12
14
  import { test } from "node:test";
13
15
  import { assertEquals } from "#test-assert";
14
16
  import type { AppApi } from "@nanobpm/urban";
15
17
  import { noopLog } from "../test/log.ts";
16
- import { PR_TERMINAL_STATUSES } from "../app/pullRequestReadModel.ts";
18
+ import { PR_TERMINAL_STATUSES, PULL_REQUEST_READ_MODEL_NAME } from "../app/pullRequestReadModel.ts";
17
19
  import handler from "./acknowledgePr.ts";
18
20
 
21
+ // A read model row (folded/effective `status` the op gates on) and its base `pull_requests` row (the
22
+ // stamp target). `derivedStatus` folds over `status` exactly as `COALESCE(pr.derived_status, pr.status)`
23
+ // does in the 094 VIEW, so a seed can model an out-of-band-terminated PR (base `escalated`, folded
24
+ // `abandoned`) — the #652 drift case.
25
+ type Seed = { prKey: string; status: string; derivedStatus?: string; acknowledgedAt?: string | null };
26
+
19
27
  // biome-ignore lint/suspicious/noExplicitAny: test-only fake data layer over dynamic row shapes.
20
- function memApp(seed: any[]): { app: AppApi; rows: any[] } {
28
+ function memApp(seeds: Seed[]): { app: AppApi; base: any[] } {
29
+ // biome-ignore lint/suspicious/noExplicitAny: test-only store.
30
+ const base: any[] = seeds.map((s) => ({
31
+ pr_key: s.prKey,
32
+ status: s.status,
33
+ acknowledged_at: s.acknowledgedAt ?? null,
34
+ }));
35
+ // The read model VIEW exposes the folded effective status as `status` (COALESCE(derived_status, status)).
36
+ // biome-ignore lint/suspicious/noExplicitAny: test-only store.
37
+ const readModel: any[] = seeds.map((s) => ({
38
+ pr_key: s.prKey,
39
+ status: s.derivedStatus ?? s.status,
40
+ }));
21
41
  // biome-ignore lint/suspicious/noExplicitAny: test-only store.
22
- const stores: Record<string, any[]> = { pull_requests: seed };
42
+ const stores: Record<string, any[]> = { pull_requests: base, [PULL_REQUEST_READ_MODEL_NAME]: readModel };
23
43
  function tbl(name: string, pk = "id") {
24
44
  // biome-ignore lint/suspicious/noExplicitAny: test-only.
25
45
  const rows = (stores[name] ??= [] as any[]);
@@ -40,7 +60,7 @@ function memApp(seed: any[]): { app: AppApi; rows: any[] } {
40
60
  data: { table: (n: string, pk?: string) => tbl(n, pk) },
41
61
  log: noopLog(),
42
62
  } as unknown as AppApi;
43
- return { app, rows: stores.pull_requests };
63
+ return { app, base };
44
64
  }
45
65
 
46
66
  async function call(app: AppApi, body: unknown) {
@@ -49,29 +69,54 @@ async function call(app: AppApi, body: unknown) {
49
69
  }
50
70
 
51
71
  test("acknowledge-pr: stamps acknowledged_at on a terminal PR and returns 200", async () => {
52
- const { app, rows } = memApp([{ pr_key: "o/r#1", status: "merged", acknowledged_at: null }]);
72
+ const { app, base } = memApp([{ prKey: "o/r#1", status: "merged" }]);
53
73
  const res = await call(app, { pr_key: "o/r#1" });
54
74
  assertEquals(res.status, 200);
55
75
  assertEquals(res.body.ok, true);
56
- assertEquals(typeof rows[0].acknowledged_at, "string");
57
- assertEquals(typeof rows[0].updated_at, "string");
76
+ assertEquals(typeof base[0].acknowledged_at, "string");
77
+ assertEquals(typeof base[0].updated_at, "string");
58
78
  });
59
79
 
60
80
  test("acknowledge-pr: every terminal status is dismissable", async () => {
61
81
  for (const status of PR_TERMINAL_STATUSES) {
62
- const { app, rows } = memApp([{ pr_key: "o/r#1", status, acknowledged_at: null }]);
82
+ const { app, base } = memApp([{ prKey: "o/r#1", status }]);
63
83
  const res = await call(app, { pr_key: "o/r#1" });
64
84
  assertEquals(res.status, 200, `status ${status} must be dismissable`);
65
- assertEquals(typeof rows[0].acknowledged_at, "string");
85
+ assertEquals(typeof base[0].acknowledged_at, "string");
66
86
  }
67
87
  });
68
88
 
89
+ // Issue #652 — the drift case. A PR terminated OUT OF BAND leaves base `status` frozen at its last
90
+ // transient (`escalated`) while the tracking VIEW folds `derived_status` to `abandoned`, so the read
91
+ // model reports the effective `status = abandoned` and lights `ack_open`. Gating on the base column
92
+ // (the old bug) 409'd "pull request is not terminal" on exactly the row the UI shows Dismiss on;
93
+ // gating on the folded read-model status accepts it.
94
+ test("acknowledge-pr: an out-of-band-terminated PR (base escalated, folded abandoned) is dismissable (200)", async () => {
95
+ const { app, base } = memApp([{ prKey: "o/r#1716", status: "escalated", derivedStatus: "abandoned" }]);
96
+ const res = await call(app, { pr_key: "o/r#1716" });
97
+ assertEquals(res.status, 200);
98
+ assertEquals(res.body.ok, true);
99
+ assertEquals(typeof base[0].acknowledged_at, "string");
100
+ // The base row's frozen transient is NOT rewritten — only the acknowledgement is stamped; the VIEW
101
+ // folds it to History (list_bucket = 'history', ack_open = 0) off derived_status + acknowledged_at.
102
+ assertEquals(base[0].status, "escalated");
103
+ });
104
+
69
105
  test("acknowledge-pr: a live (still-converging) PR is rejected (409) and stays unstamped", async () => {
70
- const { app, rows } = memApp([{ pr_key: "o/r#2", status: "converging", acknowledged_at: null }]);
106
+ const { app, base } = memApp([{ prKey: "o/r#2", status: "converging" }]);
71
107
  const res = await call(app, { pr_key: "o/r#2" });
72
108
  assertEquals(res.status, 409);
73
109
  assertEquals(res.body.ok, false);
74
- assertEquals(rows[0].acknowledged_at, null);
110
+ assertEquals(base[0].acknowledged_at, null);
111
+ });
112
+
113
+ // A genuinely live PR whose engine instance is still ACTIVE has `derived_status === status` (a live PR
114
+ // has no terminal fold), so the read model reports the live transient — still not terminal → 409.
115
+ test("acknowledge-pr: a live PR awaiting review (instance active, folded == base) is rejected (409)", async () => {
116
+ const { app, base } = memApp([{ prKey: "o/r#3", status: "waiting_review", derivedStatus: "waiting_review" }]);
117
+ const res = await call(app, { pr_key: "o/r#3" });
118
+ assertEquals(res.status, 409);
119
+ assertEquals(base[0].acknowledged_at, null);
75
120
  });
76
121
 
77
122
  test("acknowledge-pr: a missing pr_key → 400", async () => {
@@ -86,9 +131,9 @@ test("acknowledge-pr: no matching PR → 404", async () => {
86
131
  });
87
132
 
88
133
  test("acknowledge-pr: idempotent — re-acknowledging a terminal PR re-stamps and returns 200", async () => {
89
- const { app, rows } = memApp([{ pr_key: "o/r#5", status: "converged", acknowledged_at: null }]);
134
+ const { app, base } = memApp([{ prKey: "o/r#5", status: "converged" }]);
90
135
  assertEquals((await call(app, { pr_key: "o/r#5" })).status, 200);
91
- assertEquals(typeof rows[0].acknowledged_at, "string");
136
+ assertEquals(typeof base[0].acknowledged_at, "string");
92
137
  assertEquals((await call(app, { pr_key: "o/r#5" })).status, 200);
93
- assertEquals(typeof rows[0].acknowledged_at, "string");
138
+ assertEquals(typeof base[0].acknowledged_at, "string");
94
139
  });