@nanobpm/nano-workforce 0.166.0 → 0.167.1

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.
Files changed (44) hide show
  1. package/.github/workflows/mirror-install-dispatch.yml +54 -0
  2. package/CHANGELOG.md +13 -0
  3. package/app/backfillAcknowledgedAt.test.ts +121 -0
  4. package/app/contracts.ts +17 -1
  5. package/app/delivery.ts +15 -13
  6. package/app/deliveryGraphReadModel.test.ts +44 -8
  7. package/app/deliveryGraphReadModel.ts +19 -2
  8. package/app/deliveryGraphRun.ts +5 -0
  9. package/app/epicBucket.test.ts +9 -3
  10. package/app/featureReadModel.ts +7 -11
  11. package/app/listBucket.ts +86 -0
  12. package/app/planReadModel.test.ts +29 -19
  13. package/app/planReadModel.ts +32 -26
  14. package/app/pollUserTasks.test.ts +165 -0
  15. package/app/pullRequestReadModel.test.ts +208 -0
  16. package/app/pullRequestReadModel.ts +76 -0
  17. package/app/service.ts +52 -0
  18. package/db/migrations/093_pull_requests_acknowledged_at.sql +30 -0
  19. package/db/migrations/094_pull_requests_read_model.sql +65 -0
  20. package/db/migrations/095_delivery_graph_acknowledged_at.sql +24 -0
  21. package/db/migrations/096_delivery_graph_read_model_list_bucket.sql +63 -0
  22. package/db/migrations/097_plan_read_model_terminal_dismiss.sql +65 -0
  23. package/db/migrations/098_delivery_graph_units_acknowledged_at.sql +63 -0
  24. package/e2e/feature-preflight.e2e.ts +3 -2
  25. package/e2e/feature-run.e2e.ts +60 -5
  26. package/nano.app.json +4 -0
  27. package/openapi.yaml +98 -0
  28. package/operations/acknowledgeDeliveryGraph.test.ts +93 -0
  29. package/operations/acknowledgeDeliveryGraph.ts +58 -0
  30. package/operations/acknowledgePr.test.ts +94 -0
  31. package/operations/acknowledgePr.ts +62 -0
  32. package/package.json +2 -2
  33. package/pages/delivery-graphs/library.mount.js +59 -2
  34. package/pages/delivery-graphs/mount.js +88 -2
  35. package/pages/delivery-graphs.page.json +12 -3
  36. package/pages/home.page.json +18 -27
  37. package/pages/overview.page.json +26 -17
  38. package/resources/processes/feature.bpmn +90 -69
  39. package/scripts/pages-contract.test.ts +80 -18
  40. package/test/delivery-graphs-ack-or-timeout.test.ts +280 -0
  41. package/test/delivery-graphs-library-embed.test.ts +1 -1
  42. package/workers/record-feature-implementing/worker.test.ts +57 -0
  43. package/workers/record-feature-implementing/worker.ts +31 -0
  44. package/workers/record-results/worker.test.ts +5 -3
@@ -0,0 +1,76 @@
1
+ // The `pull_requests_read_model` derived read model — DECLARED ONCE and compiled to BOTH backends via
2
+ // Urban's ADR-0065 reconciling-read-model primitive (`defineReadModel`, `@nanobpm/urban`). The
3
+ // exemplars are app/featureReadModel.ts / app/deliveryGraphReadModel.ts; this is the Convergence-PR
4
+ // twin (issue #641).
5
+ //
6
+ // WHY IT EXISTS. Before #641 the Convergence (PRs) surfaces — Overview's "Active PR convergences" and
7
+ // home's "Pull requests" — filtered the RAW `pull_requests` table on a hand-synced base-`status`
8
+ // allowlist (the 7 in-flight convergence states), so a PR dropped out of Active the instant its
9
+ // `status` reached a terminal state, with NO operator dismiss. That is the last-but-one base-`status`
10
+ // allowlist #637 set out to retire. This model gives PRs the SAME acknowledge-to-dismiss behaviour as
11
+ // Features/Epics/Delivery-Graphs: a terminal PR STAYS in Active until an operator dismisses it
12
+ // (`acknowledgePr` stamps `acknowledged_at`), then drops to History — the derived `list_bucket`
13
+ // (app/listBucket.ts, the ONE shared oracle) is the single activeness predicate every "Active …" grid
14
+ // now filters.
15
+ //
16
+ // BASE TABLE. Reads the auto-provisioned `pull_requests__tracking` derived VIEW (ADR-0065), NOT the raw
17
+ // `pull_requests` table: it re-exports `pull_requests.*` plus a terminal-folded `derived_status` (the
18
+ // `instanceTracking` reconciler's `onTerminated → abandoned` edge, recomputed on read), so a
19
+ // terminated PR classifies on ENGINE TRUTH — exactly as `feature_read_model` reads
20
+ // `feature_runs__tracking`.
21
+
22
+ import { defineReadModel, type Expr, type ReadModel } from "@nanobpm/urban";
23
+ import { deriveAckOpenExpr, deriveListBucketExpr } from "./listBucket.ts";
24
+
25
+ /** The base table the read model reads: the auto-provisioned `pull_requests__tracking` derived VIEW
26
+ * (ADR-0065), NOT the raw `pull_requests` table — so the status-classifying derivations read the
27
+ * terminal-folded `derived_status` and a terminated PR drops to History with no worker write. */
28
+ export const PULL_REQUEST_READ_MODEL_BASE_TABLE = "pull_requests__tracking";
29
+
30
+ /** The base alias the managed VIEW gives `pull_requests__tracking` — pinned so the emitted derived-
31
+ * column SQL (`pr."col"`) matches the migration exactly (the drift guard compares against this alias). */
32
+ export const PULL_REQUEST_READ_MODEL_BASE_ALIAS = "pr";
33
+
34
+ /** The effective (terminal-folded) status column the bucket/ack derivations classify on — the tracking
35
+ * VIEW's `derived_status`. Single source of truth for the name so the derivations can't drift from it. */
36
+ export const EFFECTIVE_STATUS_COLUMN = "derived_status";
37
+
38
+ /** The PR statuses that are TERMINAL for the Active/History partition (issue #641). A PR in any of
39
+ * these has finished its convergence/merge lifecycle: `merged` (landed), `converged` (review-only
40
+ * consensus, no auto-merge), `abandoned` (cancelled / out-of-band terminated — the reconciler's
41
+ * `onTerminated` edge), `closed` (the PR was closed on GitHub without merging), `failed` (a terminal
42
+ * merge/convergence failure). Distinct from `deliveryStatuses.ts`'s `TERMINAL_STATUSES` (the epic-
43
+ * delivery in-flight fold, which counts `converged`/`merged`/`abandoned` only): this is the FULL PR
44
+ * terminal tier the acknowledge-to-dismiss rule folds to History. */
45
+ export const PR_TERMINAL_STATUSES: readonly string[] = ["merged", "converged", "abandoned", "closed", "failed"];
46
+
47
+ /** The Active/History partition — `history` IFF the PR is terminal AND acknowledged, else `active`
48
+ * (live PRs + terminal-but-UNACKNOWLEDGED PRs that stay actionable until dismissed). The ONE shared
49
+ * oracle (app/listBucket.ts) parameterised by {@link PR_TERMINAL_STATUSES}. */
50
+ const listBucket: Expr = deriveListBucketExpr(EFFECTIVE_STATUS_COLUMN, PR_TERMINAL_STATUSES);
51
+
52
+ /** The operator "Dismiss" affordance flag — `1` IFF the PR is terminal AND not yet acknowledged (so the
53
+ * page's `showWhenField` Dismiss button renders only for a terminal-but-unacknowledged PR), else `0`. */
54
+ const ackOpen: Expr = deriveAckOpenExpr(EFFECTIVE_STATUS_COLUMN, PR_TERMINAL_STATUSES);
55
+
56
+ /** The keys of {@link pullRequestReadModel}'s DERIVED columns, in the order the migration emits them.
57
+ * Base columns are identity pass-throughs (not derivations) and are listed in the migration directly. */
58
+ export const PULL_REQUEST_READ_MODEL_DERIVED = ["list_bucket", "ack_open"] as const;
59
+ export type PullRequestReadModelDerivedColumn = (typeof PULL_REQUEST_READ_MODEL_DERIVED)[number];
60
+
61
+ /**
62
+ * The declare-once `pull_requests_read_model` derived columns. `selectBaseColumns: false` because the
63
+ * base columns are plain identity pass-throughs enumerated in the migration (so the static
64
+ * pages↔schema contract guard, which reads a VIEW's columns off an aliased select-list, sees them).
65
+ * Both the migration VIEW (`sqlSelectFor`, drift-guarded) and the runtime TS oracle (`fnFor`) are
66
+ * generated from THIS single declaration.
67
+ */
68
+ export const pullRequestReadModel: ReadModel = defineReadModel({
69
+ name: "pull_requests_read_model",
70
+ baseTable: PULL_REQUEST_READ_MODEL_BASE_TABLE,
71
+ selectBaseColumns: false,
72
+ derive: {
73
+ list_bucket: listBucket,
74
+ ack_open: ackOpen,
75
+ },
76
+ });
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
@@ -0,0 +1,30 @@
1
+ -- Convergence (PRs): add the `acknowledged_at` dismissal stamp + backfill currently-terminal rows
2
+ -- (issue #641). This is the PR half of making all four "Active …" grids behave uniformly — a row STAYS
3
+ -- in Active until an operator dismisses it (acknowledge-to-dismiss), then drops to History — completing
4
+ -- the direction set in #637 by retiring the last two base-`status` allowlists (Convergence + Delivery
5
+ -- Graphs). The twin stamp already exists on `feature_runs` (073) and `plans` (074).
6
+ --
7
+ -- BACKFILL IS MANDATORY (the highest-risk item). Merlin carries hundreds of already-terminal PRs (~411
8
+ -- merged + 35 abandoned + 5 converged at authoring). Without the backfill, repointing the Active grids
9
+ -- at the derived `list_bucket` (094) — which folds an UNACKNOWLEDGED terminal PR into `active` — would
10
+ -- dump every one of those historical PRs into Active on the next boot. So we stamp `acknowledged_at` on
11
+ -- every CURRENTLY-terminal row here: they load in History from day one, and only PRs that reach a
12
+ -- terminal state AFTER this migration require an operator dismiss (their `acknowledged_at` stays NULL
13
+ -- until `acknowledgePr`). Stamp = COALESCE(merged_at, converged_at, updated_at): the best available
14
+ -- "when it settled" timestamp, and `updated_at` is NOT NULL so the stamp is never NULL for a matched
15
+ -- row.
16
+ --
17
+ -- Terminal set = the PR terminal tier {merged, converged, abandoned, closed, failed}
18
+ -- (app/pullRequestReadModel.ts `PR_TERMINAL_STATUSES`). Classified on the base `status` (the stored
19
+ -- ground truth at migration time; the reconciler's `derived_status` is a read-time projection). The
20
+ -- `acknowledged_at IS NULL` guard keeps the backfill idempotent — re-running never re-stamps a row an
21
+ -- operator later dismissed with a different timestamp.
22
+ --
23
+ -- The runner wraps each file in its own transaction — no BEGIN/COMMIT here. Numbered after 092.
24
+
25
+ ALTER TABLE pull_requests ADD COLUMN acknowledged_at TEXT;
26
+
27
+ UPDATE pull_requests
28
+ SET acknowledged_at = COALESCE(merged_at, converged_at, updated_at)
29
+ WHERE acknowledged_at IS NULL
30
+ AND status IN ('merged', 'converged', 'abandoned', 'closed', 'failed');
@@ -0,0 +1,65 @@
1
+ -- Convergence (PRs) read model: DECLARE ONCE, compile to BOTH backends (ADR-0065, nano-ide#452) — the
2
+ -- `pull_requests_read_model` VIEW that gives the Convergence surfaces the SAME acknowledge-to-dismiss
3
+ -- Active/History partition Features/Epics/Delivery-Graphs already have (issue #641).
4
+ --
5
+ -- Before #641 the Overview "Active PR convergences" and home "Pull requests" grids filtered the RAW
6
+ -- `pull_requests` table on a hand-synced base-`status` allowlist (the 7 in-flight convergence states),
7
+ -- so a PR dropped out of Active the instant its `status` reached terminal, with NO operator dismiss —
8
+ -- the last-but-one base-`status` allowlist #637 set out to retire. This VIEW introduces a declared
9
+ -- `list_bucket` (active/history) + `ack_open` (the Dismiss affordance flag) derived from the ONE shared
10
+ -- oracle (app/listBucket.ts): a terminal PR STAYS in `active` until `acknowledged_at` is stamped
11
+ -- (`acknowledgePr`), then folds to `history`.
12
+ --
13
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
14
+ -- app/pullRequestReadModel.ts (`pullRequestReadModel.sqlSelectFor(col, { baseAlias: "pr" })`), which
15
+ -- ALSO drives the runtime TS via `fnFor` — the two lowerings fall out of the same closed-DSL AST and
16
+ -- cannot diverge. A drift guard (app/pullRequestReadModel.test.ts) fails if this file stops matching the
17
+ -- declaration, and `assertReadModelParity` proves the SQL and TS lowerings agree.
18
+ --
19
+ -- SEMANTICS. The status-classifying `list_bucket`/`ack_open` read the terminal-folded `derived_status`
20
+ -- off the auto-provisioned `pull_requests__tracking` derived VIEW (ADR-0065), so an out-of-band-
21
+ -- terminated PR classifies on ENGINE TRUTH (`abandoned`) rather than a frozen base `status`. Base
22
+ -- columns stay aliased identity pass-throughs (so the static pages↔schema contract guard sees the
23
+ -- VIEW's columns), sourced off `pull_requests__tracking`'s re-export of the base `pull_requests.*`;
24
+ -- `status` is exposed as the effective `COALESCE(derived_status, status)` so the pages' Status cell and
25
+ -- any status reader track a terminated PR. `acknowledged_at` (093) passes through so the read model can
26
+ -- classify on it.
27
+ --
28
+ -- Forward-only VIEW definition (DROP then CREATE). `pull_requests__tracking` is the managed VIEW urban
29
+ -- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
30
+ -- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
31
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 093.
32
+
33
+ DROP VIEW IF EXISTS pull_requests_read_model;
34
+
35
+ CREATE VIEW pull_requests_read_model AS
36
+ SELECT
37
+ pr.pr_key AS pr_key,
38
+ pr.repo AS repo,
39
+ pr.number AS number,
40
+ pr.url AS url,
41
+ pr.title AS title,
42
+ COALESCE(pr.derived_status, pr.status) AS status,
43
+ pr.current_round AS current_round,
44
+ pr.process_key AS process_key,
45
+ pr.waiting_since AS waiting_since,
46
+ pr.last_review_id AS last_review_id,
47
+ pr.outcome AS outcome,
48
+ pr.created_at AS created_at,
49
+ pr.updated_at AS updated_at,
50
+ pr.converged_at AS converged_at,
51
+ pr.merged_at AS merged_at,
52
+ pr.active_worker AS active_worker,
53
+ pr.lease_until AS lease_until,
54
+ pr.last_nudge_at AS last_nudge_at,
55
+ pr.fresh_head_run_head AS fresh_head_run_head,
56
+ pr.abandon_token AS abandon_token,
57
+ pr.incident_key AS incident_key,
58
+ pr.incident_message AS incident_message,
59
+ pr.last_round_head AS last_round_head,
60
+ pr.root_request_key AS root_request_key,
61
+ pr.epic_phase_label AS epic_phase_label,
62
+ pr.acknowledged_at AS acknowledged_at,
63
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
64
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
65
+ FROM pull_requests__tracking pr;
@@ -0,0 +1,24 @@
1
+ -- Delivery Graphs: add the `acknowledged_at` dismissal stamp + backfill currently-terminal runs (issue
2
+ -- #641). The Delivery-Graph half of the uniform acknowledge-to-dismiss behaviour (see 093 for the PR
3
+ -- half and the rationale): a terminal run STAYS in Active until an operator dismisses it, then drops to
4
+ -- History — retiring the `status IN ('awaiting-approval','running')` allowlist the pages filtered.
5
+ --
6
+ -- BACKFILL (mandatory, same risk as 093). Repointing the Active grids at the derived `list_bucket`
7
+ -- (096) folds an UNACKNOWLEDGED terminal run into `active`, so without a backfill every historical
8
+ -- terminal run would flood Active on boot. Stamp `acknowledged_at = updated_at` (a delivery-graph run
9
+ -- has no merged/converged timestamp; `updated_at` is its last-touch and is NOT NULL) on every currently-
10
+ -- terminal run so they load in History, and only runs that reach terminal AFTER this migration require
11
+ -- an operator dismiss.
12
+ --
13
+ -- Terminal set = {done, failed, abandoned} (app/deliveryGraphRun.ts `DELIVERY_GRAPH_TERMINAL_STATUSES`).
14
+ -- Classified on the base `status` (the stored ground truth at migration time). The `acknowledged_at IS
15
+ -- NULL` guard keeps the backfill idempotent.
16
+ --
17
+ -- The runner wraps each file in its own transaction — no BEGIN/COMMIT here. Numbered after 094.
18
+
19
+ ALTER TABLE delivery_graph_runs ADD COLUMN acknowledged_at TEXT;
20
+
21
+ UPDATE delivery_graph_runs
22
+ SET acknowledged_at = updated_at
23
+ WHERE acknowledged_at IS NULL
24
+ AND status IN ('done', 'failed', 'abandoned');
@@ -0,0 +1,63 @@
1
+ -- Delivery-Graph read model: fold the acknowledge-to-dismiss `list_bucket`/`ack_open` derivations into
2
+ -- the `delivery_graph_read_model` VIEW (issue #641). SUPERSEDES 087's VIEW body — every DERIVED column
3
+ -- is emitted VERBATIM from the ONE declaration in app/deliveryGraphReadModel.ts, now extended with the
4
+ -- shared Active/History oracle (app/listBucket.ts). 087 is a MERGED, IMMUTABLE migration — never edited;
5
+ -- this is a NEW migration superseding its VIEW body (the same pattern by which 081 superseded 076).
6
+ --
7
+ -- WHY. Before #641 the Overview "Active Delivery Graphs" and delivery-graphs "In-flight" grids filtered
8
+ -- the derived VIEW's effective `status` on a base allowlist (`status IN ('awaiting-approval','running')`)
9
+ -- — so a terminal run dropped out of Active the instant it settled, with NO operator dismiss (the last
10
+ -- base-`status` allowlist #637 set out to retire). This VIEW adds a declared `list_bucket`
11
+ -- (active/history) + `ack_open` (Dismiss affordance) so a terminal run STAYS in `active` until
12
+ -- `acknowledged_at` is stamped (`acknowledgeDeliveryGraph`), then folds to `history`, uniformly with the
13
+ -- other three surfaces.
14
+ --
15
+ -- Every DERIVED column below is emitted VERBATIM from `deliveryGraphReadModel.sqlSelectFor(col,
16
+ -- { baseAlias: "dg" })` (which ALSO drives the runtime TS via `fnFor`); the member-PR rollup DDL is
17
+ -- unchanged from 087 and re-emitted from `deliveryGraphPrCounts.viewDdl()`. The drift guard
18
+ -- (app/deliveryGraphReadModel.test.ts) fails if this file stops matching the declaration, and
19
+ -- `assertRollupParity`/`assertReadModelParity` prove the SQL and TS lowerings agree.
20
+ --
21
+ -- SEMANTICS unchanged from 087 EXCEPT the two new derived columns: `stage`/`stage_state` still read the
22
+ -- terminal-folded `derived_status`; `park_label` is the same hand-authored display column; base columns
23
+ -- stay aliased identity pass-throughs; `status` is the effective `COALESCE(derived_status, status)`.
24
+ -- `acknowledged_at` (095) now passes through so the read model can classify on it.
25
+ --
26
+ -- Forward-only VIEW redefinition (DROP then CREATE). The runner wraps each file in its own transaction,
27
+ -- so this file must NOT contain BEGIN/COMMIT. Numbered after 095.
28
+
29
+ DROP VIEW IF EXISTS delivery_graph_read_model;
30
+ DROP VIEW IF EXISTS delivery_graph_pr_counts;
31
+
32
+ CREATE VIEW IF NOT EXISTS "delivery_graph_pr_counts" AS
33
+ SELECT
34
+ "__urban_rollup_src"."root_request_key" AS "root_request_key",
35
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(("__urban_rollup_src"."root_request_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("__urban_rollup_src"."derived_status" = 'converged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'merged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight"
36
+ FROM "pull_requests__tracking" "__urban_rollup_src"
37
+ GROUP BY "__urban_rollup_src"."root_request_key";
38
+
39
+ CREATE VIEW delivery_graph_read_model AS
40
+ SELECT
41
+ dg.run_key AS run_key,
42
+ COALESCE(dg.derived_status, dg.status) AS status,
43
+ dg.process_key AS process_key,
44
+ dg.process_definition_id AS process_definition_id,
45
+ dg.digest AS digest,
46
+ dg.side_effecting AS side_effecting,
47
+ dg.node_count AS node_count,
48
+ dg.human_node_count AS human_node_count,
49
+ dg.side_effect_count AS side_effect_count,
50
+ dg.title AS title,
51
+ dg.phase AS phase,
52
+ dg.phase_node_id AS phase_node_id,
53
+ dg.human_labels AS human_labels,
54
+ dg.created_at AS created_at,
55
+ dg.updated_at AS updated_at,
56
+ dg.acknowledged_at AS acknowledged_at,
57
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'Done' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("dg"."derived_status" = 'awaiting-approval'), 0) THEN 'Requested' WHEN COALESCE((COALESCE("pc"."prs_in_flight", 0) > 0), 0) THEN 'Converging' ELSE 'Implementing' END AS stage,
58
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'ok' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
59
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
60
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open,
61
+ CASE WHEN dg.phase_node_id IS NOT NULL THEN dg.phase ELSE NULL END AS park_label
62
+ FROM delivery_graph_runs__tracking dg
63
+ LEFT JOIN delivery_graph_pr_counts pc ON dg.run_key = pc.root_request_key;
@@ -0,0 +1,65 @@
1
+ -- Epic `plan_read_model`: fold the terminal-non-`done` dismissal arm into the Active/History partition
2
+ -- (issue #641, Scope C). SUPERSEDES 083's VIEW body — every DERIVED column is emitted VERBATIM from the
3
+ -- ONE `planReadModel` declaration (app/planReadModel.ts), now with `list_bucket`/`ack_open` classified
4
+ -- over the SHARED acknowledge-to-dismiss oracle (app/listBucket.ts) parameterised by the epic terminal
5
+ -- set {done, failed, abandoned}. 061/074/080/083 are MERGED, IMMUTABLE migrations — never edited; this
6
+ -- is a NEW migration superseding 083's VIEW body (the pattern by which 083 superseded 080).
7
+ --
8
+ -- WHY. Epics already stayed active-until-dismissed on the SUCCESS path (a `done` epic). But a terminal-
9
+ -- non-`done` epic (`failed`/`abandoned` — cancelled) fell STRAIGHT to History (the old bucket CASE's
10
+ -- default), skipping the operator tick-off. For true uniformity with features/PRs/delivery-graphs, the
11
+ -- new `list_bucket` keeps a terminal-but-UNACKNOWLEDGED epic of ANY terminal status in `active` until
12
+ -- dismissed, and `ack_open` extends the Dismiss affordance from `done`-only to the full terminal set (a
13
+ -- `failed`/`abandoned` epic's `delivery` is always non-`converging`, so it is immediately dismissable).
14
+ --
15
+ -- Every DERIVED column below is emitted VERBATIM from `planReadModel.sqlSelectFor(col, { baseAlias:
16
+ -- "pl" })` (which ALSO drives the runtime TS via `fnFor`, behind the app/delivery.ts adapters). The
17
+ -- drift guard (app/planReadModel.test.ts) fails if this file stops matching the declaration, and
18
+ -- `assertReadModelParity` proves the SQL and TS lowerings agree.
19
+ --
20
+ -- SEMANTICS unchanged from 083 EXCEPT `list_bucket`/`ack_open`: `delivery` still reads the base
21
+ -- `plans.status`; the status-classifying arms read the terminal-folded `derived_status`; the wave
22
+ -- columns pass through the `plan_wave_progress` lookup; the display strings (`delivery_label`,
23
+ -- `wave_label`) stay hand-authored. Base columns stay aliased pass-throughs; `status` is the effective
24
+ -- `COALESCE(derived_status, status)`. The two rollup lookups are re-joined identically.
25
+ --
26
+ -- Forward-only VIEW redefinition (DROP then CREATE). `plans__tracking` is the managed VIEW urban
27
+ -- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
28
+ -- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
29
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 096.
30
+
31
+ DROP VIEW IF EXISTS plan_read_model;
32
+
33
+ CREATE VIEW plan_read_model AS
34
+ SELECT
35
+ pl.plan_key AS plan_key,
36
+ pl.repo AS repo,
37
+ pl.issue_number AS issue_number,
38
+ pl.issue_url AS issue_url,
39
+ pl.title AS title,
40
+ COALESCE(pl.derived_status, pl.status) AS status,
41
+ pl.task_count AS task_count,
42
+ pl.process_key AS process_key,
43
+ pl.outcome AS outcome,
44
+ pl.updated_at AS updated_at,
45
+ pl.epic_phase AS epic_phase,
46
+ pl.base_branch AS base_branch,
47
+ pl.wait_gate_label AS wait_gate_label,
48
+ pl.bound_artifacts AS bound_artifacts,
49
+ pl.promotion_pr AS promotion_pr,
50
+ pl.promotion_state AS promotion_state,
51
+ CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END AS delivery,
52
+ "wp"."wave_count" AS wave_count,
53
+ "wp"."current_wave" AS current_wave,
54
+ CASE WHEN COALESCE((COALESCE((COALESCE((COALESCE(("pl"."derived_status" = 'done'), 0) OR COALESCE(("pl"."derived_status" = 'failed'), 0) OR COALESCE(("pl"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE((CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END = 'converging'), 0), 0))), 0) AND COALESCE(("pl"."acknowledged_at" = "pl"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
55
+ CASE WHEN COALESCE((COALESCE((COALESCE((COALESCE(("pl"."derived_status" = 'done'), 0) OR COALESCE(("pl"."derived_status" = 'failed'), 0) OR COALESCE(("pl"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE((CASE WHEN COALESCE(((NOT COALESCE(COALESCE(("pl"."status" = 'done'), 0), 0)) OR COALESCE((COALESCE("dc"."prs_opened", 0) = 0), 0)), 0) THEN NULL WHEN COALESCE((COALESCE("dc"."prs_in_flight", 0) > 0), 0) THEN 'converging' WHEN COALESCE((COALESCE("dc"."prs_merged", 0) = COALESCE("dc"."prs_opened", 0)), 0) THEN 'landed' ELSE NULL END = 'converging'), 0), 0))), 0) AND (NOT COALESCE(COALESCE(("pl"."acknowledged_at" = "pl"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open,
56
+ CASE
57
+ WHEN pl.status IS NOT 'done' OR COALESCE(dc.prs_opened, 0) = 0 THEN NULL
58
+ WHEN COALESCE(dc.prs_in_flight, 0) > 0 THEN dc.prs_merged || '/' || dc.prs_opened || ' slices merged, ' || dc.prs_in_flight || ' converging'
59
+ WHEN dc.prs_merged = dc.prs_opened THEN dc.prs_opened || '/' || dc.prs_opened || ' slices merged'
60
+ ELSE NULL
61
+ END AS delivery_label,
62
+ (wp.current_wave + 1) || '/' || wp.wave_count AS wave_label
63
+ FROM plans__tracking pl
64
+ LEFT JOIN plan_delivery_counts dc ON pl.plan_key = dc.plan_key
65
+ LEFT JOIN plan_wave_progress wp ON pl.plan_key = wp.plan_key;
@@ -0,0 +1,63 @@
1
+ -- Delivery Graphs: carry the new `acknowledged_at` dismissal stamp (095) through the `delivery_units`
2
+ -- aggregate (issue #641). Migration 095 added `acknowledged_at` to the base `delivery_graph_runs`
3
+ -- table, but its projection onto the ADR "declare-once" `delivery_units` aggregate (088-091) predates
4
+ -- the column: the delivery-graph sync triggers (089) never copied it and the `delivery_graph_runs__units`
5
+ -- compat VIEW (091) never re-exported it. That broke the aggregate's compat-view parity invariant
6
+ -- (`delivery_graph_runs__units` must be byte-identical to `delivery_graph_runs`), and — since the
7
+ -- aggregate is the S9 unified store the feature/epic surfaces already read `acknowledged_at`/`list_bucket`
8
+ -- from — left the delivery-graph unit rows without the dismissal stamp the other kinds carry.
9
+ --
10
+ -- This migration supersedes the two delivery-graph WRITE triggers (INSERT/UPDATE) to also project
11
+ -- `NEW.acknowledged_at`, re-creates the `delivery_graph_runs__units` compat VIEW to re-export it, and
12
+ -- backfills the stamp onto the existing aggregate rows (095's backfill fired the OLD trigger, which
13
+ -- dropped `acknowledged_at`, so the aggregate must be re-synced from the base once here). The feature
14
+ -- and epic triggers/views already project `acknowledged_at` (089/091) and are untouched; the DELETE
15
+ -- trigger is column-agnostic and is left as-is.
16
+ --
17
+ -- Migrations are forward-only and immutable once merged, so 089/091 are NOT edited — the triggers/VIEW
18
+ -- are DROPped and re-created here (numbered after 097). The runner wraps each file in its own
19
+ -- transaction — no BEGIN/COMMIT.
20
+
21
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_ai;
22
+ CREATE TRIGGER delivery_graph_runs__du_ai AFTER INSERT ON delivery_graph_runs
23
+ BEGIN
24
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, created_at, updated_at)
25
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.created_at, NEW.updated_at);
26
+ END;
27
+
28
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_au;
29
+ CREATE TRIGGER delivery_graph_runs__du_au AFTER UPDATE ON delivery_graph_runs
30
+ BEGIN
31
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, created_at, updated_at)
32
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.created_at, NEW.updated_at);
33
+ END;
34
+
35
+ DROP VIEW IF EXISTS delivery_graph_runs__units;
36
+ CREATE VIEW delivery_graph_runs__units AS
37
+ SELECT
38
+ du.legacy_key AS run_key,
39
+ du.process_key AS process_key,
40
+ du.process_definition_id AS process_definition_id,
41
+ du.digest AS digest,
42
+ du.status AS status,
43
+ du.side_effecting AS side_effecting,
44
+ du.node_count AS node_count,
45
+ du.human_node_count AS human_node_count,
46
+ du.side_effect_count AS side_effect_count,
47
+ du.title AS title,
48
+ du.phase AS phase,
49
+ du.phase_node_id AS phase_node_id,
50
+ du.human_labels AS human_labels,
51
+ du.acknowledged_at AS acknowledged_at,
52
+ du.created_at AS created_at,
53
+ du.updated_at AS updated_at
54
+ FROM delivery_units du
55
+ WHERE du.kind = 'delivery-graph';
56
+
57
+ -- Re-sync the stamp onto the existing aggregate rows (095's backfill fired the OLD, column-dropping
58
+ -- trigger). Keyed by the aggregate's `legacy_key` = the run's `run_key`.
59
+ UPDATE delivery_units
60
+ SET acknowledged_at = (
61
+ SELECT d.acknowledged_at FROM delivery_graph_runs d WHERE d.run_key = delivery_units.legacy_key
62
+ )
63
+ WHERE kind = 'delivery-graph';
@@ -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": [