@nanobpm/nano-workforce 0.189.1 → 0.189.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -0,0 +1,63 @@
1
+ -- Feature-run read model: exclude a MID-HANDOFF `opened` run from the `list_bucket` / `ack_open`
2
+ -- DISMISSABLE-terminal predicate (issue #808 follow-up). SUPERSEDES 111's VIEW body — every DERIVED
3
+ -- column is emitted VERBATIM from the ONE declaration in app/featureReadModel.ts, now with `list_bucket`/
4
+ -- `ack_open` driven by the `featureDismissableTerminal` PREDICATE instead of a plain status set. 111 is a
5
+ -- MERGED, IMMUTABLE migration — never edited; this is a NEW migration superseding its VIEW body (the same
6
+ -- pattern by which 111 superseded 099, 099 superseded 081, 081 superseded 076).
7
+ --
8
+ -- WHY. 111 (issue #808) made EVERY `opened` row dismissable-terminal so a finished raise-only run stops
9
+ -- wedging in Active. But `record-feature` writes `status="opened"` for a converge-REQUESTED run TOO,
10
+ -- BEFORE the `gw-converge` gateway routes it into `converge-feature` (which flips it to `converging`).
11
+ -- In that in-between window the row reads `opened` while the feature engine instance is still ACTIVE, so
12
+ -- 111's "any opened is dismissable" rule offered Dismiss on an in-flight convergence handoff — and a
13
+ -- premature ack there stamps `acknowledged_at`, which would later drag a still-converging run to History.
14
+ -- This is the SAME mid-flight failure mode the epic read model already guards against by excluding its
15
+ -- own `converging` delivery (app/planReadModel.ts / app/listBucket.ts). The feature row distinguishes the
16
+ -- transient from a finished run by its own columns: a converge-requested run that actually handed off
17
+ -- carries `converge=1 AND pr_key IS NOT NULL` — exactly the window to exclude. Every other `opened` stays
18
+ -- dismissable: a raise-only run (`converge=0`), or a keyless `opened` that never satisfied the gateway's
19
+ -- `prKey != null` and fell through to `End` (`pr_key IS NULL`).
20
+ --
21
+ -- Every DERIVED column below is emitted VERBATIM from `featureReadModel.sqlSelectFor(col,
22
+ -- { baseAlias: "fr" })` (which ALSO drives the runtime TS via `fnFor`); the `stage`/`stage_state`/
23
+ -- `stage_skipped`/`attention` bodies are UNCHANGED from 111 and only `list_bucket`/`ack_open` gain the
24
+ -- mid-handoff exclusion. The drift guard (app/featureReadModel.test.ts) fails if this file stops matching
25
+ -- the declaration, and `assertReadModelParity` proves the SQL and TS lowerings agree.
26
+ --
27
+ -- SEMANTICS unchanged from 111 EXCEPT the two mid-handoff-narrowed columns: the status-classifying
28
+ -- columns still read the terminal-folded `feature_runs__tracking.derived_status`; base columns stay
29
+ -- aliased identity pass-throughs; `acknowledged_at` still passes through so the read model can classify
30
+ -- on it. The STAGE derivations are untouched, so a mid-handoff `opened` still renders `PR open`.
31
+ --
32
+ -- Forward-only VIEW redefinition (DROP then CREATE). `feature_runs__tracking` is the managed VIEW urban
33
+ -- provisions at mount; SQLite does not validate a view body at CREATE time, so this migration (which
34
+ -- runs before that mount) is created fine and resolves once the managed VIEW exists. The runner wraps
35
+ -- each file in its own transaction, so this file must NOT contain BEGIN/COMMIT. Numbered after 111.
36
+
37
+ DROP VIEW IF EXISTS feature_read_model;
38
+
39
+ CREATE VIEW feature_read_model AS
40
+ SELECT
41
+ fr.feature_key AS feature_key,
42
+ fr.repo AS repo,
43
+ fr.issue_number AS issue_number,
44
+ fr.issue_url AS issue_url,
45
+ fr.title AS title,
46
+ fr.base_branch AS base_branch,
47
+ fr.status AS status,
48
+ fr.process_key AS process_key,
49
+ fr.pr_key AS pr_key,
50
+ fr.converge AS converge,
51
+ fr.auto_merge AS auto_merge,
52
+ fr.outcome AS outcome,
53
+ fr.delivery_label AS delivery_label,
54
+ fr.acknowledged_at AS acknowledged_at,
55
+ fr.created_at AS created_at,
56
+ fr.updated_at AS updated_at,
57
+ 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,
58
+ 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,
59
+ CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
60
+ 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,
61
+ CASE WHEN COALESCE((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((COALESCE(("fr"."derived_status" = 'opened'), 0) AND COALESCE(("fr"."converge" = 1), 0) AND COALESCE(("fr"."pr_key" = "fr"."pr_key"), 0)), 0), 0))), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
62
+ CASE WHEN COALESCE((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((COALESCE(("fr"."derived_status" = 'opened'), 0) AND COALESCE(("fr"."converge" = 1), 0) AND COALESCE(("fr"."pr_key" = "fr"."pr_key"), 0)), 0), 0))), 0) AND (NOT COALESCE(COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
63
+ FROM feature_runs__tracking fr;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.189.1",
3
+ "version": "0.189.3",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -30,14 +30,23 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
30
30
  await featureRuns(app.data).update(featureKey, { status: "opened", updated_at: new Date().toISOString() });
31
31
  return {};
32
32
  }
33
- // `convergeOnly` is the inverse of auto-merge: converge-only stops at `converged`; auto-merge lets
34
- // the merge-loop drive the merge. `submitPr` is idempotent on the PR key.
35
- await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge, featureKey);
33
+ // Write `converging` BEFORE enrolling the PR (issue #808 / PR #809 review). The old order —
34
+ // `submitPr` then the status write — left an `opened` + `converge=1` + `pr_key` window spanning an
35
+ // ALREADY-SUBMITTED, live PR: a terminate/crash in that gap stranded a row whose PR was live while
36
+ // `pollFeatureDelivery`'s handoff edge (edge 3) read the FEATURE instance state alone and wrongly
37
+ // folded it to `abandoned`. Flipping to `converging` first shrinks the interrupted-handoff window to
38
+ // the (now converging-owned) gap BEFORE `submitPr`, which `pollFeatureDelivery`'s converging edge
39
+ // (edge 1) heals by re-enrolling when the PR row is still missing (idempotent, mirroring the
40
+ // promotion-PR re-enroll in `pollPromotion`). The reorder is safe: `submitPr` is idempotent on the PR
41
+ // key (it early-returns `alreadyRunning` for a non-terminal PR), and a `converging` row is owned by
42
+ // edge (1), never by the handoff edge. `convergeOnly` is the inverse of auto-merge: converge-only
43
+ // stops at `converged`; auto-merge lets the merge-loop drive the merge.
36
44
  await featureRuns(app.data).update(featureKey, {
37
45
  status: "converging",
38
46
  pr_key: parsed.prKey,
39
47
  updated_at: new Date().toISOString(),
40
48
  });
49
+ await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge, featureKey);
41
50
  app.log.info("converge-feature: enrolled PR into convergence loop", {
42
51
  featureKey,
43
52
  prKey: parsed.prKey,