@nanobpm/nano-workforce 0.163.0 → 0.163.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.163.2](https://github.com/nanobpm/nano-workforce/compare/v0.163.1...v0.163.2) (2026-08-31)
2
+
3
+ ### Build System
4
+
5
+ * **deps:** bump @nanobpm/urban-testkit to ^1.0.0 (native callActivity in e2e) ([#635](https://github.com/nanobpm/nano-workforce/issues/635)) ([2020817](https://github.com/nanobpm/nano-workforce/commit/2020817a2b5baeec347b2c808212720ab58328cb)), closes [#631](https://github.com/nanobpm/nano-workforce/issues/631) [#591](https://github.com/nanobpm/nano-workforce/issues/591) [nano-ide#503](https://github.com/nanobpm/nano-ide/issues/503) [#607](https://github.com/nanobpm/nano-workforce/issues/607) [nano-ide#503](https://github.com/nanobpm/nano-ide/issues/503) [#607](https://github.com/nanobpm/nano-workforce/issues/607) [#503](https://github.com/nanobpm/nano-workforce/issues/503) [#607](https://github.com/nanobpm/nano-workforce/issues/607) [nano-ide#503](https://github.com/nanobpm/nano-ide/issues/503)
6
+
7
+ ## [0.163.1](https://github.com/nanobpm/nano-workforce/compare/v0.163.0...v0.163.1) (2026-08-30)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **plan:** taskless plan follows engine liveness, not empty-plan heuristic ([#624](https://github.com/nanobpm/nano-workforce/issues/624)) ([#626](https://github.com/nanobpm/nano-workforce/issues/626)) ([1dbb0d6](https://github.com/nanobpm/nano-workforce/commit/1dbb0d650c955886588a1e4caf3e8af7b150c4ca)), closes [#623](https://github.com/nanobpm/nano-workforce/issues/623) [#623](https://github.com/nanobpm/nano-workforce/issues/623) [#623](https://github.com/nanobpm/nano-workforce/issues/623)
12
+
1
13
  ## [0.163.0](https://github.com/nanobpm/nano-workforce/compare/v0.162.2...v0.163.0) (2026-08-30)
2
14
 
3
15
  ### Features
package/SPEC.md CHANGED
@@ -283,7 +283,7 @@ child grids and a lazily-loaded transcript. The round/escalation grids are
283
283
  read-only audit. Open native user-task escalations are additionally resolved
284
284
  app-side from the **Tasks** page (`pages/tasks.page.json`, issue #236) — a nav
285
285
  tab whose per-kind `dataGrid`s list every open escalation (feature / plan-review
286
- / trial-merge / PR review / blocked-run) off the `user_tasks` read-model and
286
+ / empty-plan / trial-merge / PR review / blocked-run) off the `user_tasks` read-model and
287
287
  submit the typed decision to the canonical human completer — so an operator no
288
288
  longer depends on Urban's read-only `taskInbox` stub at `/tasks`.
289
289
 
@@ -519,7 +519,19 @@ Start(issue) → plan → record-plan → implement (parallel MI) → record-res
519
519
  - **`record-plan`** — app worker `pr.record-plan`. Normalizes the tasks (assigns a
520
520
  stable `id`/index), writes one `plan_tasks` row each, sets `plans.task_count` and
521
521
  status `dispatched`, and **re-emits** the normalized `tasks` so the fan-out
522
- iterates the canonical list.
522
+ iterates the canonical list. It also emits `taskCount`, which the `gw-plan-empty`
523
+ gateway reads: a taskful plan proceeds to plan-review; an **empty plan**
524
+ (`{tasks:[]}`) is neither auto-terminated (which rendered "Done" over a still-live
525
+ instance, #624) nor fed into the adversarial plan-review loop (a plan↔plan-review
526
+ livelock, #623) — instead it parks at the **`empty-plan-escalation`** operator user
527
+ task for a human directive: **Accept** a legitimate no-op epic (→ the terminal
528
+ `EndTasklessDone` end; the poller reconciles the COMPLETED instance to `done`) or
529
+ **Revise** (→ back to `plan` to re-plan, folding the operator's `notes` into
530
+ `planFindings` so the guidance reaches the re-plan's `appendPrompt`, mirroring
531
+ `plan-review-decision`). An empty plan stays NON-terminal
532
+ (`planning`) with its planner `note` as the `outcome` while parked — terminal
533
+ status follows engine liveness via `pollTasklessPlanTermination`, never the
534
+ empty-plan signal (#624).
523
535
  - **`implement`** — service task, job type `senior:feature`, **parallel
524
536
  multi-instance** over `=tasks` (`inputElement="task"`,
525
537
  `outputCollection="results"`). Its base prompt is delivered via the `feature.md`
@@ -257,6 +257,35 @@ test("conformance-escalation is HUMAN-completable but NOT agent-completable (iss
257
257
  assertEquals(completed[0].variables, { note: "filed follow-up" });
258
258
  });
259
259
 
260
+ test("empty-plan-escalation is HUMAN-completable but NOT agent-completable (issues #623/#624)", async () => {
261
+ // The empty-plan operator decision mirrors feature-blocked/conformance: a HUMAN operator adjudicates
262
+ // whether an empty plan is a legitimate no-op (accept) or needs re-planning (revise), through
263
+ // `completeEscalationAsHuman`. It stays OUTSIDE the agent surface (`ESCALATION_TASK_ELEMENTS`) — the
264
+ // fleet must never silently auto-resolve the very "no work was produced" case a human must attend.
265
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
266
+ const data = memData(stores);
267
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-e", elementId: "empty-plan-escalation" }]);
268
+
269
+ const asAgent = await completeEscalationAsAgent(data, engine, {
270
+ userTaskKey: "ut-e",
271
+ agentId: "bot",
272
+ variables: { directive: "accept" },
273
+ });
274
+ assertEquals(asAgent.ok, false, "the agent completer refuses empty-plan-escalation");
275
+ assertEquals(asAgent.reason, "not a completable task");
276
+ assertEquals(completed.length, 0);
277
+
278
+ const asHuman = await completeEscalationAsHuman(data, engine, {
279
+ userTaskKey: "ut-e",
280
+ operatorId: "alice",
281
+ variables: { directive: "revise", notes: "look again" },
282
+ });
283
+ assertEquals(asHuman.ok, true, "the human completer retires empty-plan-escalation");
284
+ assertEquals(asHuman.elementId, "empty-plan-escalation");
285
+ assertEquals(completed.length, 1);
286
+ assertEquals(completed[0].variables, { directive: "revise", notes: "look again" });
287
+ });
288
+
260
289
  test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
261
290
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
262
291
  const data = memData(stores);
@@ -543,6 +572,10 @@ test("validateEscalationVariables derives its contract from the canonical .form
543
572
  // plan-review-decision -> directive required, allowed proceed/revise
544
573
  assertEquals(validateEscalationVariables("plan-review-decision", { directive: "proceed" }), null);
545
574
  assert(validateEscalationVariables("plan-review-decision", { directive: "" }) !== null);
575
+ // empty-plan-escalation -> directive required, allowed accept/revise
576
+ assertEquals(validateEscalationVariables("empty-plan-escalation", { directive: "accept" }), null);
577
+ assertEquals(validateEscalationVariables("empty-plan-escalation", { directive: "revise" }), null);
578
+ assert(validateEscalationVariables("empty-plan-escalation", { directive: "" }) !== null);
546
579
  // an element with no linked form contract is not enforced
547
580
  assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
548
581
  });
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
25
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
26
26
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
27
- import { ACP_PERMISSION_ELEMENT } from "./userTasks.ts";
27
+ import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT } from "./userTasks.ts";
28
28
 
29
29
  const now = () => new Date().toISOString();
30
30
 
@@ -91,6 +91,14 @@ export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
91
91
  * `CONFORMANCE_ESCALATION_ELEMENT` (app/conformance.ts) — one source of truth, no drift surface. */
92
92
  export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMENT;
93
93
 
94
+ /** The `empty-plan-escalation` operator user-task element id (plan-fanout.bpmn) — the native decision a
95
+ * plan-fanout run parks on when the planner emits an EMPTY plan. Like `feature-blocked` and
96
+ * `conformance-escalation` it is a HUMAN-only operator decision (never agent-answerable, or the fleet
97
+ * could silently auto-resolve the very "no work was produced" case a human must adjudicate), so it
98
+ * lives OUTSIDE `ESCALATION_TASK_ELEMENTS` and only the HUMAN completer accepts it (issues #623/#624).
99
+ * Re-exported from the canonical `EMPTY_PLAN_ELEMENT` (app/userTasks.ts) — one source of truth. */
100
+ export const EMPTY_PLAN_TASK_ELEMENT = EMPTY_PLAN_ELEMENT;
101
+
94
102
  /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
95
103
  * `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
96
104
  * and `conformance-escalation` acknowledgements, PLUS the advisory ACP permission prompt
@@ -104,6 +112,7 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
104
112
  ...ESCALATION_TASK_ELEMENTS,
105
113
  FEATURE_BLOCKED_TASK_ELEMENT,
106
114
  CONFORMANCE_ESCALATION_TASK_ELEMENT,
115
+ EMPTY_PLAN_TASK_ELEMENT,
107
116
  ACP_PERMISSION_ELEMENT,
108
117
  ]);
109
118
 
@@ -117,6 +126,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
117
126
  "wait-answer": "pr-escalation",
118
127
  "wait-merge-answer": "pr-escalation",
119
128
  "feature-blocked": "feature-blocked",
129
+ [EMPTY_PLAN_TASK_ELEMENT]: "empty-plan-escalation",
120
130
  [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
121
131
  // NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
122
132
  // ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
@@ -15,6 +15,8 @@ test("deriveEpicPhase maps each spine element to its domain phase", () => {
15
15
  assertEquals(deriveEpicPhase("review-plan"), EPIC_PHASE.REVIEWING);
16
16
  assertEquals(deriveEpicPhase("record-plan-review"), EPIC_PHASE.REVIEWING);
17
17
  assertEquals(deriveEpicPhase("plan-review-decision"), EPIC_PHASE.REVIEWING);
18
+ // The empty-plan operator escalation (gw-plan-empty) is a planning-stage decision (Accept/Revise).
19
+ assertEquals(deriveEpicPhase("empty-plan-escalation"), EPIC_PHASE.PLANNING);
18
20
  // Trial-merge band.
19
21
  assertEquals(deriveEpicPhase("trial-merge"), EPIC_PHASE.TRIAL_MERGING);
20
22
  assertEquals(deriveEpicPhase("record-trial-merge"), EPIC_PHASE.TRIAL_MERGING);
package/app/epicPhase.ts CHANGED
@@ -107,6 +107,7 @@ const ELEMENT_PHASE: Readonly<Record<string, string>> = {
107
107
  "ensure-base-branch": EPIC_PHASE.PLANNING,
108
108
  "plan": EPIC_PHASE.PLANNING,
109
109
  "record-plan": EPIC_PHASE.REVIEWING,
110
+ "empty-plan-escalation": EPIC_PHASE.PLANNING,
110
111
  "review-plan": EPIC_PHASE.REVIEWING,
111
112
  "record-plan-review": EPIC_PHASE.REVIEWING,
112
113
  "plan-review-decision": EPIC_PHASE.REVIEWING,
@@ -0,0 +1,188 @@
1
+ // Coverage for `pollTasklessPlanTermination` (issue #624) — the poll pass that owns a TASKLESS plan's
2
+ // COMPLETED → `done` transition from ENGINE truth, so terminal `plans.status` follows engine instance
3
+ // liveness instead of the retired "record-plan with task_count = 0 ⇒ done" heuristic (which rendered
4
+ // the epic "Done" over a still-active, in fact looping, plan-fanout instance).
5
+ //
6
+ // Booted against the real provisioned SQLite data layer (so the `plans` table exists) with a stubbed
7
+ // `searchProcessInstances`, proving: a taskless plan whose instance is STILL ACTIVE stays non-terminal
8
+ // (the core acceptance guarantee); it flips to `done` only once the instance reads COMPLETED; a
9
+ // TASKFUL plan is never touched (its `record-results` finalizer owns `done`); a plan with no engine
10
+ // instance is skipped; and an already-`done` plan is left alone.
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import { test } from "node:test";
15
+ import { assertEquals } from "#test-assert";
16
+ import type { DataLayer } from "@nanobpm/urban";
17
+ import { bootTestApp } from "@nanobpm/urban-testkit";
18
+ import { plans } from "./plan.ts";
19
+ import { pollTasklessPlanTermination } from "./service.ts";
20
+ import { withTrackingViews } from "../test/trackingViews.ts";
21
+
22
+ const APP_ROOT = resolve(import.meta.dirname, "..");
23
+
24
+ async function withData(fn: (data: DataLayer) => Promise<void>): Promise<void> {
25
+ const dir = mkdtempSync(join(tmpdir(), "nwf-taskless-term-"));
26
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
27
+ try {
28
+ await fn(app.db);
29
+ } finally {
30
+ await app.stop?.();
31
+ rmSync(dir, { recursive: true, force: true });
32
+ }
33
+ }
34
+
35
+ const now = () => new Date().toISOString();
36
+
37
+ async function seedPlan(
38
+ data: DataLayer,
39
+ over: { status?: string; process_key?: string | null; task_count?: number; outcome?: string | null } = {},
40
+ ) {
41
+ await plans(data).insert({
42
+ plan_key: "owner/repo#7",
43
+ repo: "owner/repo",
44
+ issue_number: 7,
45
+ issue_url: "https://github.com/owner/repo/issues/7",
46
+ title: "Epic",
47
+ status: over.status ?? "planning",
48
+ task_count: over.task_count ?? 0,
49
+ outcome: "outcome" in over ? over.outcome : "planner emitted no tasks",
50
+ process_key: "process_key" in over ? over.process_key : "pi-1",
51
+ created_at: now(),
52
+ updated_at: now(),
53
+ } as never);
54
+ }
55
+
56
+ test("pollTasklessPlanTermination leaves a taskless plan NON-terminal while its instance is ACTIVE", async () => {
57
+ await withData(async (data) => {
58
+ await seedPlan(data, { status: "planning", task_count: 0 });
59
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-1", state: "ACTIVE" }] };
60
+ await pollTasklessPlanTermination(data, engine as never);
61
+ // The core acceptance guarantee: a task_count = 0 plan is not terminal while its instance runs.
62
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "planning");
63
+ });
64
+ });
65
+
66
+ test("pollTasklessPlanTermination flips a taskless plan to done once its instance reads COMPLETED", async () => {
67
+ await withData(async (data) => {
68
+ await seedPlan(data, { status: "planning", task_count: 0 });
69
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-1", state: "COMPLETED" }] };
70
+ await pollTasklessPlanTermination(data, engine as never);
71
+ const row = await plans(data).get("owner/repo#7");
72
+ assertEquals(row?.status, "done");
73
+ assertEquals(row?.outcome, "planner emitted no tasks");
74
+ });
75
+ });
76
+
77
+ test("pollTasklessPlanTermination matches a numeric engine processInstanceKey against the string process_key", async () => {
78
+ await withData(async (data) => {
79
+ await seedPlan(data, { status: "planning", task_count: 0, process_key: "12345" });
80
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: 12345, state: "COMPLETED" }] };
81
+ await pollTasklessPlanTermination(data, engine as never);
82
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "done");
83
+ });
84
+ });
85
+
86
+ test("pollTasklessPlanTermination never touches a TASKFUL plan (record-results owns its done)", async () => {
87
+ await withData(async (data) => {
88
+ await seedPlan(data, { status: "dispatched", task_count: 3, outcome: null });
89
+ let called = false;
90
+ const engine = {
91
+ searchProcessInstances: async () => {
92
+ called = true;
93
+ return [{ processInstanceKey: "pi-1", state: "COMPLETED" }];
94
+ },
95
+ };
96
+ await pollTasklessPlanTermination(data, engine as never);
97
+ assertEquals(called, false);
98
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "dispatched");
99
+ });
100
+ });
101
+
102
+ test("pollTasklessPlanTermination skips a taskless plan that has no engine instance yet", async () => {
103
+ await withData(async (data) => {
104
+ await seedPlan(data, { status: "planning", task_count: 0, process_key: null });
105
+ let called = false;
106
+ const engine = {
107
+ searchProcessInstances: async () => {
108
+ called = true;
109
+ return [];
110
+ },
111
+ };
112
+ await pollTasklessPlanTermination(data, engine as never);
113
+ assertEquals(called, false);
114
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "planning");
115
+ });
116
+ });
117
+
118
+ test("pollTasklessPlanTermination never re-touches an already-terminal plan", async () => {
119
+ await withData(async (data) => {
120
+ await seedPlan(data, { status: "done", task_count: 0 });
121
+ let called = false;
122
+ const engine = {
123
+ searchProcessInstances: async () => {
124
+ called = true;
125
+ return [];
126
+ },
127
+ };
128
+ await pollTasklessPlanTermination(data, engine as never);
129
+ // `done` is not in EPIC_LIVE_STATUSES, so the pass never queries the engine for it.
130
+ assertEquals(called, false);
131
+ });
132
+ });
133
+
134
+ // A taskless plan whose instance TERMINATED out of band keeps its base `status = planning` (the
135
+ // worker-owned transient the reconciler no longer overwrites) while the ADR-0065 tracking VIEW folds
136
+ // its `derived_status` to `abandoned`. A base-`status` scan would keep re-querying the engine for that
137
+ // already-dead row every pass forever; the pass MUST read `derived_status` off `plansTracking` and skip
138
+ // it. Modelled with the `withTrackingViews` fake so the base `status` and derived `derived_status`
139
+ // diverge exactly as the real terminated instance produces, without a live engine.
140
+ // biome-ignore lint/suspicious/noExplicitAny: test-only fake over dynamic row shapes.
141
+ function trackedMemData(): DataLayer {
142
+ // biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row store.
143
+ const store: any[] = [];
144
+ const tbl = (_name: string, pk = "plan_key") => ({
145
+ // biome-ignore lint/suspicious/noExplicitAny: test-only dynamic row.
146
+ async insert(row: any) {
147
+ store.push({ ...row });
148
+ return row[pk];
149
+ },
150
+ async get(key: unknown) {
151
+ return store.find((r) => r[pk] === key);
152
+ },
153
+ async find(where: Record<string, unknown>) {
154
+ return store.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
155
+ },
156
+ // biome-ignore lint/suspicious/noExplicitAny: test-only patch.
157
+ async update(key: unknown, patch: any) {
158
+ const r = store.find((x) => x[pk] === key);
159
+ if (r) Object.assign(r, patch);
160
+ },
161
+ });
162
+ // biome-ignore lint/suspicious/noExplicitAny: test-only fake DataLayer.
163
+ return { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
164
+ }
165
+
166
+ test("pollTasklessPlanTermination skips a derive-only-terminal (TERMINATED) taskless plan without querying the engine", async () => {
167
+ const data = trackedMemData();
168
+ // Base `status` still reads live `planning` (frozen transient), but the instance terminated out of
169
+ // band so the tracking VIEW's `derived_status` is `abandoned`.
170
+ await plans(data).insert({
171
+ plan_key: "owner/repo#7",
172
+ status: "planning",
173
+ derived_status: "abandoned",
174
+ task_count: 0,
175
+ process_key: "pi-1",
176
+ } as never);
177
+ let called = false;
178
+ const engine = {
179
+ searchProcessInstances: async () => {
180
+ called = true;
181
+ return [{ processInstanceKey: "pi-1", state: "COMPLETED" }];
182
+ },
183
+ };
184
+ await pollTasklessPlanTermination(data, engine as never);
185
+ // The regression guard: a base-status scan would re-query the engine here forever.
186
+ assertEquals(called, false);
187
+ assertEquals((await plans(data).get("owner/repo#7"))?.status, "planning");
188
+ });
package/app/service.ts CHANGED
@@ -68,6 +68,7 @@ import {
68
68
  type Plan,
69
69
  planReviews,
70
70
  plans,
71
+ plansTracking,
71
72
  planTaskDeps,
72
73
  planTaskNeeds,
73
74
  planTasks,
@@ -2383,6 +2384,57 @@ export async function pollEpicPhase(
2383
2384
  }
2384
2385
  }
2385
2386
 
2387
+ /** Poll pass (issue #624): own the taskless plan's COMPLETED → `done` transition from ENGINE truth.
2388
+ *
2389
+ * A taskless plan (`task_count = 0`, the planner emitted no tasks) is NO LONGER collapsed to terminal
2390
+ * `done` by `record-plan` — "the planner produced nothing this pass" is an INTERMEDIATE state, not an
2391
+ * ended process (the plan-fanout instance is still live and may re-plan, escalate, or be cancelled).
2392
+ * So the plan stays non-terminal (`planning`) until its process instance ACTUALLY ends, and terminal
2393
+ * `plans.status` follows engine instance liveness rather than the empty-plan heuristic that rendered
2394
+ * the epic "Done" over a live (in fact looping) instance.
2395
+ *
2396
+ * This pass owns the COMPLETED → `done` edge for such plans, mirroring the delivery-graph
2397
+ * COMPLETED → `done` transition ({@link pollDeliveryGraphPhase}): `instanceTracking`'s `onTerminated`
2398
+ * edge reconciles only TERMINATED (→ `abandoned` on `derived_status`), never COMPLETED, so a taskless
2399
+ * plan whose instance ends GREEN would otherwise stay `planning` forever. A taskful plan reaches
2400
+ * `done` through its own `record-results` finalizer, so this pass is scoped to `task_count = 0` and to
2401
+ * the live rows only. Liveness is read off the ADR-0065 derived tracking VIEW (`plansTracking`'s
2402
+ * `derived_status`), NOT the base `plans.status`: a taskless plan whose instance TERMINATED out of
2403
+ * band keeps base `status = planning`/`dispatched` while `derived_status` folds to `abandoned`, so a
2404
+ * base-status scan would re-query the engine for that already-dead row every pass forever. Skipping
2405
+ * rows whose `derived_status` is no longer live confines the engine read to genuinely-live plans and
2406
+ * never races a worker-owned terminal. Best-effort +
2407
+ * idempotent: writes only on a real COMPLETED read, so a steady-state pass over a still-active
2408
+ * instance is a no-op (the acceptance guarantee — a taskless plan is never terminal while active). */
2409
+ export async function pollTasklessPlanTermination(
2410
+ data: DataLayer,
2411
+ engine: Pick<EngineClient, "searchProcessInstances">,
2412
+ ) {
2413
+ for (const status of EPIC_LIVE_STATUSES) {
2414
+ for (const plan of await plansTracking(data).find({ status })) {
2415
+ if (plan.task_count !== 0 || !plan.process_key) continue;
2416
+ // Base `status` is live, but the instance may have TERMINATED out of band (folding
2417
+ // `derived_status` → `abandoned`); skip such derive-only-terminal rows so the pass only
2418
+ // queries the engine for genuinely-live plans (ADR-0065).
2419
+ if (!EPIC_LIVE_STATUSES.some((s) => s === plan.derived_status)) continue;
2420
+ const processKey = plan.process_key;
2421
+ try {
2422
+ const snapshots = await engine.searchProcessInstances({ processInstanceKeys: [processKey] });
2423
+ const state = snapshots.find((s) => String(s.processInstanceKey) === processKey)?.state ?? null;
2424
+ if (state === "COMPLETED") {
2425
+ await plans(data).update(plan.plan_key, {
2426
+ status: "done",
2427
+ outcome: plan.outcome ?? "planner emitted no tasks",
2428
+ updated_at: now(),
2429
+ });
2430
+ }
2431
+ } catch (err) {
2432
+ console.error(`[poller] taskless plan termination ${plan.plan_key}: ${err}`);
2433
+ }
2434
+ }
2435
+ }
2436
+ }
2437
+
2386
2438
  /** Poll pass (ADR 0005 slice S5): reconcile each RUNNING delivery-graph run's derived phase from
2387
2439
  * engine truth, and complete it when its instance ends. A delivery graph is a DYNAMIC compiled
2388
2440
  * process with no happy-path host worker, so — unlike `plans`/`feature_runs`, whose spine workers
@@ -2622,6 +2674,7 @@ export async function pollOnce(
2622
2674
  await pollLineage(data);
2623
2675
  await pollUserTasks(data, engine, engineRest);
2624
2676
  await pollEpicPhase(data, engine);
2677
+ await pollTasklessPlanTermination(data, engine);
2625
2678
  await pollDeliveryGraphPhase(data, engine);
2626
2679
  await pollDeliveryProposals(data);
2627
2680
  if (engineRest) {
package/app/userTasks.ts CHANGED
@@ -30,6 +30,13 @@ const now = () => new Date().toISOString();
30
30
  * when the adversarial review loop exhausts its budget without approval. */
31
31
  export const PLAN_REVIEW_ELEMENT = "plan-review-decision";
32
32
 
33
+ /** The empty-plan operator escalation user task (plan-fanout.bpmn) — a human directive (accept/revise)
34
+ * when the planner emits an EMPTY plan (`{tasks:[]}`). Instead of auto-terminating the run from an
35
+ * intermediate signal (which rendered "Done" over a still-live instance, #624) or feeding the empty
36
+ * plan into the adversarial plan-review loop (a plan↔plan-review livelock, #623), the run parks here
37
+ * for operator attention: Accept a legitimate no-op epic (→ terminal done) or Revise (→ re-plan). */
38
+ export const EMPTY_PLAN_ELEMENT = "empty-plan-escalation";
39
+
33
40
  /** The trial-merge escalation user task (plan-fanout.bpmn) — a human decision (proceed/rebase/abandon)
34
41
  * when a wave's trial merge comes back red. */
35
42
  export const TRIAL_MERGE_ELEMENT = "trial-merge-decision";
@@ -87,6 +94,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
87
94
  [FEATURE_ESCALATION_ELEMENT]: "Feature escalation",
88
95
  [FEATURE_BLOCKED_ELEMENT]: "Blocked feature run",
89
96
  [PLAN_REVIEW_ELEMENT]: "Plan review",
97
+ [EMPTY_PLAN_ELEMENT]: "Empty plan",
90
98
  [TRIAL_MERGE_ELEMENT]: "Trial merge",
91
99
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
92
100
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
@@ -130,7 +130,10 @@ and does **not** resolve `$ref`s, so every projected (non-`x-mcp`) request-body
130
130
  in `openapi.yaml` presents an inline `type: object` body with no `$ref`; the two graph doors
131
131
  additionally carry a worked `example` — an agent discovers the body shape (and calls the tool
132
132
  with a real object, not a
133
- stringified one) from the surface alone. The two graph doors split by convention:
133
+ stringified one) from the surface alone and even if an agent's client emits a
134
+ stringified object body, the door itself now **faithfully parses** it rather than
135
+ rejecting it (nano-ide#503, a server-side input-compatibility behavior that needs no
136
+ client upgrade — see the faithful-transport note below). The two graph doors split by convention:
134
137
  `compileDeliveryGraph` takes the **structured `DeliveryGraph` object** (and *stages*);
135
138
  `previewDeliveryGraph` takes the **text shape `{ "graphJson": "<serialized DeliveryGraph>" }`**
136
139
  (and is *pure*). Every validation failure returns `issues`/`errors` as `[{ path, message }]`.
@@ -138,9 +141,14 @@ The inline bodies are **derived** from `components.schemas` by
138
141
  `scripts/inline-mcp-bodies.ts` (single source of truth; run `npm run gen:mcp-bodies` after
139
142
  editing a component), and `npm run check:mcp-bodies` + `test/mcp-tool-schemas.test.ts` (which
140
143
  runs the real projector) fail CI if a `$ref` ever re-leaks. The upstream projector fix that
141
- would make this mitigation unnecessary is tracked in
144
+ would make the **schema** mitigation unnecessary is tracked in
142
145
  [nano-ide#501](https://github.com/nanobpm/nano-ide/issues/501) (#502 self-contained schemas,
143
- #503 faithful object-body transport, #504 real-spec conformance guard).
146
+ #504 real-spec conformance guard). **#503 faithful object-body transport has landed**
147
+ `@nanobpm/urban` 0.87 ships ADR 0067's `normalizeBodyArg`, so the MCP door now **parses** a
148
+ stringified object body and forwards it faithfully instead of rejecting it with `expected object,
149
+ got string`. That retired the nwf-local stringified-body reject mitigation: the e2e guard in
150
+ `e2e/mcp-surface.e2e.ts` now asserts the door faithfully parses a stringified body (the
151
+ `assertObjectBodyAccepted` detector's teeth stay pinned synthetically).
144
152
 
145
153
  ## 5. Fallback
146
154
 
@@ -178,13 +186,16 @@ depends on:
178
186
 
179
187
  - every projected tool schema is `$ref`-free with an explicit `type` (a leaked
180
188
  `$ref` is unresolvable in the MCP context);
181
- - an object argument arrives **as an object**, never coerced to a string;
189
+ - an object argument arrives **as an object** and a stringified one is faithfully
190
+ **parsed** by the door (ADR 0067 / nano-ide#503, `@nanobpm/urban` ≥ 0.87), never
191
+ rejected as `expected object, got string`;
182
192
  - validation failures answer uniformly with `issues[{path,message}]`;
183
193
  - side-effecting calls stage nothing, so the suite is safe to re-run.
184
194
 
185
195
  It runs in CI under `npm run e2e` (hermetic — no socket, no GitHub), so a
186
- reintroduced `$ref` or a stringified object body fails the build instead of
187
- reaching an agent.
196
+ reintroduced `$ref` fails the build, and a stringified object body is asserted to be
197
+ faithfully parsed by the door (ADR 0067 / nano-ide#503) instead of reaching an agent
198
+ mis-serialized.
188
199
 
189
200
  **Extending it (new per-tool case).** Import `bootMcpHarness` from
190
201
  `e2e/support/mcp-harness.ts` in your own `e2e/<slice>.e2e.ts` and drive
@@ -6,7 +6,8 @@
6
6
  // `e2e/support/mcp-harness.ts` module. It PINS the client-visible contract the S0 defect broke:
7
7
  //
8
8
  // • every projected tool schema is `$ref`-free with an explicit `type` (S0 / nano-ide#502);
9
- // • an object argument arrives AS AN OBJECT, never coerced to a string (S0 / nano-ide#503);
9
+ // • an object argument arrives AS AN OBJECT and a stringified one is faithfully PARSED, not
10
+ // rejected, now that ADR 0067 / nano-ide#503 landed upstream in @nanobpm/urban 0.87;
10
11
  // • validation failures answer uniformly with `issues[{path,message}]`;
11
12
  // • the reads parse; the mutating framework tools are gated; side-effecting calls leave NO live
12
13
  // staged proposal behind (safe to run repeatedly).
@@ -192,9 +193,14 @@ describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S
192
193
  );
193
194
  });
194
195
 
195
- // The falsifiable core (issue #607 acceptance): DELIBERATELY reintroducing either half of the S0
196
+ // The falsifiable core (issue #607 acceptance): DELIBERATELY reintroducing the SCHEMA half of the S0
196
197
  // defect makes the harness fail. These pin the detector's teeth independently of whether S0 has
197
- // landed — so the guard cannot silently rot into a no-op.
198
+ // landed — so the guard cannot silently rot into a no-op. The object-body-stringification half
199
+ // (nano-ide#503) is now fixed UPSTREAM — @nanobpm/urban 0.87 lands ADR 0067 "faithful object-body
200
+ // transport" (`normalizeBodyArg`), so the door PARSES a stringified object body and forwards it
201
+ // faithfully instead of rejecting it. That retired the nwf-local reject mitigation (docs/mcp-runbook.md
202
+ // §4): the live door can no longer produce the S0 signature to exercise end-to-end, so that half is
203
+ // now covered by a live faithful-parse assertion plus a synthetic detector-teeth check below.
198
204
  describe("reintroducing the S0 defect fails the build", () => {
199
205
  test("a $ref in a tool schema is caught by the self-containment assertion", () => {
200
206
  const good = { type: "object", properties: { body: { type: "object", properties: { n: { type: "number" } } } } };
@@ -208,17 +214,42 @@ describe("MCP surface e2e — the runtime-served /app/mcp handshake, per tool (S
208
214
  assert.throws(() => assertSchemaSelfContained(typeless, "synthetic-typeless"), /type/, "a typeless schema must throw");
209
215
  });
210
216
 
211
- test("a stringified object body is rejected by the door and caught by assertObjectBodyAccepted", async () => {
212
- // Simulate the S0 client coercion: send the body as a JSON STRING instead of an object.
213
- const res = await h.callTool("compileDeliveryGraph", { body: JSON.stringify(MINIMAL_VALID_GRAPH) });
214
- assert.ok(res.isError, "a stringified object body must be rejected by the door");
217
+ test("a stringified object body is faithfully parsed by the door (ADR 0067 / nano-ide#503)", async () => {
218
+ // Simulate the old S0 client coercion: send the body as a JSON STRING instead of an object.
219
+ // @nanobpm/urban 0.87's faithful object-body transport (ADR 0067 `normalizeBodyArg`) now PARSES
220
+ // it and forwards it faithfully no longer the "expected object, got string" rejection the
221
+ // nwf-local S0 mitigation used to raise. Drive the PURE previewDeliveryGraph door so the
222
+ // parsed-and-compiled graph stages nothing.
223
+ const res = await h.callTool("previewDeliveryGraph", {
224
+ body: JSON.stringify({ graphJson: JSON.stringify(MINIMAL_VALID_GRAPH) }),
225
+ });
226
+ assert.ok(!res.isError, `the door must faithfully parse a stringified object body: ${res.text}`);
215
227
  assert.ok(
216
- res.text.includes(STRINGIFIED_BODY_MESSAGE),
217
- `the door must report "${STRINGIFIED_BODY_MESSAGE}": ${res.text}`,
228
+ !res.text.includes(STRINGIFIED_BODY_MESSAGE),
229
+ `faithful transport must not reject with "${STRINGIFIED_BODY_MESSAGE}": ${res.text}`,
218
230
  );
219
- // The harness's guard must recognize that signature as a failure.
231
+ assert.doesNotThrow(
232
+ () => assertObjectBodyAccepted(res, "previewDeliveryGraph"),
233
+ "a faithfully parsed object body must pass assertObjectBodyAccepted",
234
+ );
235
+ const json = res.json as { ok?: boolean; staged?: boolean } | undefined;
236
+ assert.equal(json?.ok, true, `previewDeliveryGraph must compile the parsed graph: ${res.text}`);
237
+ assert.equal(json?.staged, false, "previewDeliveryGraph is PURE — it must never stage");
238
+ });
239
+
240
+ test("assertObjectBodyAccepted still flags an S0 stringified-body signature (detector teeth)", () => {
241
+ // The live door can no longer produce the S0 signature (fixed upstream, ADR 0067), so pin the
242
+ // detector's teeth SYNTHETICALLY — mirroring the $ref/typeless guards above — so the helper
243
+ // cannot rot into a no-op if the signature ever re-surfaces from another surface.
244
+ const s0Result = {
245
+ isError: true,
246
+ text: `validation failed: body: ${STRINGIFIED_BODY_MESSAGE}`,
247
+ json: undefined,
248
+ httpStatus: 422,
249
+ raw: undefined,
250
+ };
220
251
  assert.throws(
221
- () => assertObjectBodyAccepted(res, "compileDeliveryGraph"),
252
+ () => assertObjectBodyAccepted(s0Result, "synthetic-stringified"),
222
253
  /stringified/,
223
254
  "assertObjectBodyAccepted must flag a stringified-body result",
224
255
  );
@@ -241,27 +241,30 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
241
241
  );
242
242
  });
243
243
 
244
- // Empty-plan short-circuit (issue #623 — regression for Merlin instance-46 / epic #1067). A
244
+ // Empty-plan escalation (issues #623/#624 — regression for Merlin instance-46 / epic #1067). A
245
245
  // planner that legitimately emits `{tasks:[]}` (meta/tracking epic, or all sub-issues closed) must
246
- // reach a terminal state WITHOUT entering the adversarial plan-review loop feeding an empty plan
247
- // into review caused a plan↔plan-review livelock (it can neither be approved nor produce findings).
248
- test("empty plan short-circuits to the taskless-done end, never entering plan-review (issue #623)", async () => {
246
+ // NOT enter the adversarial plan-review loop (feeding an empty plan into review caused a
247
+ // plan↔plan-review livelock it can neither be approved nor produce findings), and must NOT
248
+ // auto-terminate from an intermediate signal while the instance is still live (#624). Instead it is
249
+ // parked for OPERATOR ATTENTION at the `empty-plan-escalation` user task; a human then Accepts
250
+ // (no-op done) or Revises (re-plan).
251
+ test("empty plan parks at the operator escalation (non-terminal), never entering plan-review (issues #623/#624)", async () => {
249
252
  let reviewCalls = 0;
250
253
  await withApp(
251
254
  {
252
255
  "senior:plan": () => ({ tasks: [], note: "all sub-issues closed" }),
253
- // If this ever fires, the short-circuit failed and the empty plan entered the review loop.
256
+ // If this ever fires, the empty plan wrongly entered the review loop.
254
257
  "senior:plan-review": () => {
255
258
  reviewCalls += 1;
256
259
  return { approved: false, findings: "" };
257
260
  },
258
261
  "senior:feature": () => ({ status: "blocked", summary: "n/a" }),
259
262
  },
260
- async ({ app, planKey }) => {
263
+ async ({ app, planKey, processKey }) => {
261
264
  const flows = takenFlows(app);
262
265
  assert.ok(
263
- flows.includes("gw-plan-empty->EndTasklessDone"),
264
- `empty plan routed to the taskless-done end (flows: ${flows.join(", ")})`,
266
+ flows.includes("gw-plan-empty->empty-plan-escalation"),
267
+ `empty plan routed to the operator escalation (flows: ${flows.join(", ")})`,
265
268
  );
266
269
  assert.ok(
267
270
  !flows.includes("gw-plan-empty->review-plan"),
@@ -269,11 +272,83 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
269
272
  );
270
273
  assert.equal(reviewCalls, 0, "the plan-review agent must never run for an empty plan");
271
274
 
272
- const plan = await app.db
275
+ // The instance is parked at the operator user task — a completable escalation exists.
276
+ const task = await openTask(app, processKey, "empty-plan-escalation");
277
+ assert.ok(task.userTaskKey, "the empty-plan escalation carries a completable userTaskKey");
278
+
279
+ // While parked, the plan is NON-terminal: the instance is still live (#624), so terminal
280
+ // `done` is owned by the poller on COMPLETED, not written here from the empty-plan signal.
281
+ const parked = await app.db
273
282
  .table<{ plan_key: string; status: string; outcome: string | null }>("plans", "plan_key")
274
283
  .findOne({ plan_key: planKey });
275
- assert.equal(plan?.status, "done", "the empty plan reached a terminal done state");
276
- assert.equal(plan?.outcome, "all sub-issues closed", "the planner note was recorded as the outcome");
284
+ assert.equal(parked?.status, "planning", "a parked empty plan stays non-terminal (planning)");
285
+ assert.equal(parked?.outcome, "all sub-issues closed", "the planner note was recorded as the outcome");
286
+ },
287
+ );
288
+ });
289
+
290
+ test("empty-plan escalation: accept routes to the taskless-done end (no-op epic)", async () => {
291
+ await withApp(
292
+ {
293
+ "senior:plan": () => ({ tasks: [], note: "all sub-issues closed" }),
294
+ "senior:plan-review": () => ({ approved: true, findings: "" }),
295
+ "senior:feature": () => ({ status: "blocked", summary: "n/a" }),
296
+ },
297
+ async ({ app, processKey }) => {
298
+ const task = await openTask(app, processKey, "empty-plan-escalation");
299
+ await app.engine.completeUserTask(task.userTaskKey, { directive: "accept", notes: "meta epic" });
300
+ await app.settle();
301
+
302
+ const flows = takenFlows(app);
303
+ assert.ok(
304
+ flows.includes("gw-empty-plan-answer->EndTasklessDone"),
305
+ `accept routed to the taskless-done end (flows: ${flows.join(", ")})`,
306
+ );
307
+ assert.ok(
308
+ !flows.includes("gw-empty-plan-answer->plan"),
309
+ "the revise (default) flow was NOT taken",
310
+ );
311
+ },
312
+ );
313
+ });
314
+
315
+ test("empty-plan escalation: revise re-plans, re-parking a still-empty plan at the operator (never auto-terminating)", async () => {
316
+ let reviewCalls = 0;
317
+ const planPrompts: Array<string | undefined> = [];
318
+ await withApp(
319
+ {
320
+ "senior:plan": (job) => {
321
+ planPrompts.push((job.variables as Record<string, unknown>).appendPrompt as string | undefined);
322
+ return { tasks: [], note: "all sub-issues closed" };
323
+ },
324
+ "senior:plan-review": () => {
325
+ reviewCalls += 1;
326
+ return { approved: true, findings: "" };
327
+ },
328
+ "senior:feature": () => ({ status: "blocked", summary: "n/a" }),
329
+ },
330
+ async ({ app, processKey }) => {
331
+ const task = await openTask(app, processKey, "empty-plan-escalation");
332
+ await app.engine.completeUserTask(task.userTaskKey, { directive: "revise", notes: "look again" });
333
+ await app.settle();
334
+
335
+ const flows = takenFlows(app);
336
+ assert.ok(
337
+ flows.includes("gw-empty-plan-answer->plan"),
338
+ `revise routed back to the planner (flows: ${flows.join(", ")})`,
339
+ );
340
+ // The operator's revision guidance must actually reach the re-plan: `empty-plan-escalation`
341
+ // folds `notes` into `planFindings`, which the `plan` task renders into its `appendPrompt`.
342
+ const rePlanPrompt = planPrompts.at(-1);
343
+ assert.ok(
344
+ rePlanPrompt?.includes("look again"),
345
+ `operator revise notes were delivered to the re-plan appendPrompt (got: ${JSON.stringify(rePlanPrompt)})`,
346
+ );
347
+ // The re-plan is still empty, so it re-parks at a fresh operator escalation — it neither
348
+ // enters plan-review nor auto-terminates.
349
+ const reparked = await openTask(app, processKey, "empty-plan-escalation");
350
+ assert.ok(reparked.userTaskKey, "a still-empty re-plan re-parks at the operator escalation");
351
+ assert.equal(reviewCalls, 0, "the plan-review agent must never run for an empty plan");
277
352
  },
278
353
  );
279
354
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.163.0",
3
+ "version": "0.163.2",
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",
@@ -61,12 +61,12 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@nanobpm/agentic": "^0.4.0",
64
- "@nanobpm/urban": "^0.86.0",
64
+ "@nanobpm/urban": "^0.87.0",
65
65
  "bpmn-auto-layout": "^2.0.0-alpha.2"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@biomejs/biome": "^2.4.11",
69
- "@nanobpm/urban-testkit": "^0.14.0",
69
+ "@nanobpm/urban-testkit": "^1.0.0",
70
70
  "@nanobpm/workflow": "^0.14.0",
71
71
  "@semantic-release/changelog": "^7.0.0",
72
72
  "@semantic-release/git": "^11.0.0",
@@ -0,0 +1,31 @@
1
+ {
2
+ "id": "empty-plan-escalation",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "text",
8
+ "text": "The planner produced an **empty plan** (no tasks) for this epic. This can be a legitimate no-op (a meta/tracking epic, or one whose sub-issues are all closed) or a planning failure. It is parked here for an operator to decide — it does NOT auto-terminate and does NOT re-enter the adversarial plan-review loop."
9
+ },
10
+ {
11
+ "type": "select",
12
+ "key": "directive",
13
+ "label": "Directive",
14
+ "values": [
15
+ { "label": "Accept — a legitimate no-op epic; complete the run", "value": "accept" },
16
+ { "label": "Revise — send it back to the planner to re-plan", "value": "revise" }
17
+ ],
18
+ "validate": {
19
+ "required": true
20
+ }
21
+ },
22
+ {
23
+ "type": "textarea",
24
+ "key": "notes",
25
+ "label": "Revision guidance for the planner",
26
+ "conditional": {
27
+ "hide": "=directive != \"revise\""
28
+ }
29
+ }
30
+ ]
31
+ }
@@ -293,8 +293,25 @@
293
293
  <bpmn:outgoing>f_plan_empty</bpmn:outgoing>
294
294
  <bpmn:outgoing>f_toReviewPlan</bpmn:outgoing>
295
295
  </bpmn:exclusiveGateway>
296
- <bpmn:endEvent id="EndTasklessDone" name="Taskless done (no-op epic)">
296
+ <bpmn:userTask id="empty-plan-escalation" name="Empty plan — operator attention (human)">
297
+ <bpmn:extensionElements>
298
+ <zeebe:formDefinition formId="empty-plan-escalation" />
299
+ <zeebe:userTask />
300
+ <zeebe:assignmentDefinition candidateGroups="operators" assignee="=escalationAssignee" />
301
+ <zeebe:ioMapping>
302
+ <zeebe:output source="=(if planFindings = null then &#34;&#34; else planFindings) + (if (notes = null or notes = &#34;&#34;) then &#34;&#34; else &#34;&#10;&#10;Human guidance:&#10;&#34; + notes)" target="planFindings" />
303
+ </zeebe:ioMapping>
304
+ </bpmn:extensionElements>
297
305
  <bpmn:incoming>f_plan_empty</bpmn:incoming>
306
+ <bpmn:outgoing>f_toGwEmptyAnswer</bpmn:outgoing>
307
+ </bpmn:userTask>
308
+ <bpmn:exclusiveGateway id="gw-empty-plan-answer" name="accept no-op?" default="f_empty_revise">
309
+ <bpmn:incoming>f_toGwEmptyAnswer</bpmn:incoming>
310
+ <bpmn:outgoing>f_empty_accept</bpmn:outgoing>
311
+ <bpmn:outgoing>f_empty_revise</bpmn:outgoing>
312
+ </bpmn:exclusiveGateway>
313
+ <bpmn:endEvent id="EndTasklessDone" name="Taskless done (no-op epic)">
314
+ <bpmn:incoming>f_empty_accept</bpmn:incoming>
298
315
  </bpmn:endEvent>
299
316
  <bpmn:serviceTask id="review-plan" name="Review plan (agent)">
300
317
  <bpmn:extensionElements>
@@ -622,9 +639,14 @@
622
639
  <bpmn:sequenceFlow id="f_toPlan" sourceRef="ensure-base-branch" targetRef="plan" />
623
640
  <bpmn:sequenceFlow id="f_toRecordPlan" sourceRef="plan" targetRef="record-plan" />
624
641
  <bpmn:sequenceFlow id="f_toPlanEmptyGw" sourceRef="record-plan" targetRef="gw-plan-empty" />
625
- <bpmn:sequenceFlow id="f_plan_empty" name="no tasks" sourceRef="gw-plan-empty" targetRef="EndTasklessDone">
642
+ <bpmn:sequenceFlow id="f_plan_empty" name="no tasks" sourceRef="gw-plan-empty" targetRef="empty-plan-escalation">
626
643
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=taskCount = 0</bpmn:conditionExpression>
627
644
  </bpmn:sequenceFlow>
645
+ <bpmn:sequenceFlow id="f_toGwEmptyAnswer" sourceRef="empty-plan-escalation" targetRef="gw-empty-plan-answer" />
646
+ <bpmn:sequenceFlow id="f_empty_accept" name="accept (no-op)" sourceRef="gw-empty-plan-answer" targetRef="EndTasklessDone">
647
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=directive = "accept"</bpmn:conditionExpression>
648
+ </bpmn:sequenceFlow>
649
+ <bpmn:sequenceFlow id="f_empty_revise" name="revise" sourceRef="gw-empty-plan-answer" targetRef="plan" />
628
650
  <bpmn:sequenceFlow id="f_toReviewPlan" name="has tasks" sourceRef="gw-plan-empty" targetRef="review-plan" />
629
651
  <bpmn:sequenceFlow id="f_toRecordPlanReview" sourceRef="review-plan" targetRef="record-plan-review" />
630
652
  <bpmn:sequenceFlow id="f_toGwPlanReview" sourceRef="record-plan-review" targetRef="gw-plan-review" />
@@ -700,13 +722,22 @@
700
722
  <bpmndi:BPMNShape id="BPMNShape_gw-plan-empty" bpmnElement="gw-plan-empty" isMarkerVisible="true">
701
723
  <dc:Bounds x="1166" y="284" width="50" height="50" />
702
724
  <bpmndi:BPMNLabel>
703
- <dc:Bounds x="1123" y="339" width="57" height="28" />
725
+ <dc:Bounds x="1163" y="359" width="57" height="28" />
726
+ </bpmndi:BPMNLabel>
727
+ </bpmndi:BPMNShape>
728
+ <bpmndi:BPMNShape id="BPMNShape_empty-plan-escalation" bpmnElement="empty-plan-escalation">
729
+ <dc:Bounds x="1316" y="589" width="100" height="80" />
730
+ </bpmndi:BPMNShape>
731
+ <bpmndi:BPMNShape id="BPMNShape_gw-empty-plan-answer" bpmnElement="gw-empty-plan-answer" isMarkerVisible="true">
732
+ <dc:Bounds x="1541" y="604" width="50" height="50" />
733
+ <bpmndi:BPMNLabel>
734
+ <dc:Bounds x="1543" y="571" width="46" height="28" />
704
735
  </bpmndi:BPMNLabel>
705
736
  </bpmndi:BPMNShape>
706
737
  <bpmndi:BPMNShape id="BPMNShape_EndTasklessDone" bpmnElement="EndTasklessDone">
707
- <dc:Bounds x="1348" y="451" width="36" height="36" />
738
+ <dc:Bounds x="1723" y="611" width="36" height="36" />
708
739
  <bpmndi:BPMNLabel>
709
- <dc:Bounds x="1327" y="492" width="78" height="42" />
740
+ <dc:Bounds x="1702" y="652" width="78" height="42" />
710
741
  </bpmndi:BPMNLabel>
711
742
  </bpmndi:BPMNShape>
712
743
  <bpmndi:BPMNShape id="BPMNShape_review-plan" bpmnElement="review-plan">
@@ -718,7 +749,7 @@
718
749
  <bpmndi:BPMNShape id="BPMNShape_gw-plan-review" bpmnElement="gw-plan-review" isMarkerVisible="true">
719
750
  <dc:Bounds x="1716" y="284" width="50" height="50" />
720
751
  <bpmndi:BPMNLabel>
721
- <dc:Bounds x="1668" y="339" width="67" height="28" />
752
+ <dc:Bounds x="1668" y="359" width="67" height="28" />
722
753
  </bpmndi:BPMNLabel>
723
754
  </bpmndi:BPMNShape>
724
755
  <bpmndi:BPMNShape id="BPMNShape_plan-review-decision" bpmnElement="plan-review-decision">
@@ -727,7 +758,7 @@
727
758
  <bpmndi:BPMNShape id="BPMNShape_gw-plan-answer" bpmnElement="gw-plan-answer" isMarkerVisible="true">
728
759
  <dc:Bounds x="2066" y="444" width="50" height="50" />
729
760
  <bpmndi:BPMNLabel>
730
- <dc:Bounds x="2121" y="455" width="67" height="28" />
761
+ <dc:Bounds x="2058" y="411" width="67" height="28" />
731
762
  </bpmndi:BPMNLabel>
732
763
  </bpmndi:BPMNShape>
733
764
  <bpmndi:BPMNShape id="BPMNShape_select-wave" bpmnElement="select-wave">
@@ -844,7 +875,7 @@
844
875
  <di:waypoint x="1766" y="309" />
845
876
  <di:waypoint x="2216" y="309" />
846
877
  <bpmndi:BPMNLabel>
847
- <dc:Bounds x="1899" y="287" width="60" height="14" />
878
+ <dc:Bounds x="1961" y="287" width="60" height="14" />
848
879
  </bpmndi:BPMNLabel>
849
880
  </bpmndi:BPMNEdge>
850
881
  <bpmndi:BPMNEdge id="BPMNEdge_f_toImplement" bpmnElement="f_toImplement">
@@ -877,6 +908,15 @@
877
908
  <di:waypoint x="6004" y="309" />
878
909
  <di:waypoint x="6104" y="309" />
879
910
  </bpmndi:BPMNEdge>
911
+ <bpmndi:BPMNEdge id="BPMNEdge_f_empty_revise" bpmnElement="f_empty_revise">
912
+ <di:waypoint x="1566" y="654" />
913
+ <di:waypoint x="1566" y="689" />
914
+ <di:waypoint x="816" y="689" />
915
+ <di:waypoint x="816" y="349" />
916
+ <bpmndi:BPMNLabel>
917
+ <dc:Bounds x="1168" y="667" width="46" height="14" />
918
+ </bpmndi:BPMNLabel>
919
+ </bpmndi:BPMNEdge>
880
920
  <bpmndi:BPMNEdge id="BPMNEdge_f_plan_revise" bpmnElement="f_plan_revise">
881
921
  <di:waypoint x="1741" y="284" />
882
922
  <di:waypoint x="1741" y="249" />
@@ -892,7 +932,7 @@
892
932
  <di:waypoint x="816" y="549" />
893
933
  <di:waypoint x="816" y="349" />
894
934
  <bpmndi:BPMNLabel>
895
- <dc:Bounds x="1431" y="557" width="46" height="14" />
935
+ <dc:Bounds x="1431" y="527" width="46" height="14" />
896
936
  </bpmndi:BPMNLabel>
897
937
  </bpmndi:BPMNEdge>
898
938
  <bpmndi:BPMNEdge id="BPMNEdge_f_toRecordTrialMerge" bpmnElement="f_toRecordTrialMerge">
@@ -930,10 +970,14 @@
930
970
  </bpmndi:BPMNEdge>
931
971
  <bpmndi:BPMNEdge id="BPMNEdge_f_plan_empty" bpmnElement="f_plan_empty">
932
972
  <di:waypoint x="1191" y="334" />
933
- <di:waypoint x="1191" y="469" />
934
- <di:waypoint x="1348" y="469" />
973
+ <di:waypoint x="1191" y="354" />
974
+ <di:waypoint x="2136" y="354" />
975
+ <di:waypoint x="2136" y="558" />
976
+ <di:waypoint x="1296" y="558" />
977
+ <di:waypoint x="1296" y="609" />
978
+ <di:waypoint x="1316" y="609" />
935
979
  <bpmndi:BPMNLabel>
936
- <dc:Bounds x="1196" y="395" width="57" height="14" />
980
+ <dc:Bounds x="2141" y="489" width="57" height="14" />
937
981
  </bpmndi:BPMNLabel>
938
982
  </bpmndi:BPMNEdge>
939
983
  <bpmndi:BPMNEdge id="BPMNEdge_f_plan_escalate" bpmnElement="f_plan_escalate">
@@ -945,11 +989,11 @@
945
989
  </bpmndi:BPMNLabel>
946
990
  </bpmndi:BPMNEdge>
947
991
  <bpmndi:BPMNEdge id="BPMNEdge_f_plan_answer_proceed" bpmnElement="f_plan_answer_proceed">
948
- <di:waypoint x="2091" y="444" />
949
- <di:waypoint x="2091" y="309" />
950
- <di:waypoint x="2216" y="309" />
992
+ <di:waypoint x="2116" y="469" />
993
+ <di:waypoint x="2266" y="469" />
994
+ <di:waypoint x="2266" y="349" />
951
995
  <bpmndi:BPMNLabel>
952
- <dc:Bounds x="2096" y="370" width="53" height="14" />
996
+ <dc:Bounds x="2165" y="447" width="53" height="14" />
953
997
  </bpmndi:BPMNLabel>
954
998
  </bpmndi:BPMNEdge>
955
999
  <bpmndi:BPMNEdge id="BPMNEdge_f_runTrialMerge" bpmnElement="f_runTrialMerge">
@@ -1005,6 +1049,17 @@
1005
1049
  <di:waypoint x="616" y="469" />
1006
1050
  <di:waypoint x="616" y="349" />
1007
1051
  </bpmndi:BPMNEdge>
1052
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toGwEmptyAnswer" bpmnElement="f_toGwEmptyAnswer">
1053
+ <di:waypoint x="1416" y="629" />
1054
+ <di:waypoint x="1541" y="629" />
1055
+ </bpmndi:BPMNEdge>
1056
+ <bpmndi:BPMNEdge id="BPMNEdge_f_empty_accept" bpmnElement="f_empty_accept">
1057
+ <di:waypoint x="1591" y="629" />
1058
+ <di:waypoint x="1723" y="629" />
1059
+ <bpmndi:BPMNLabel>
1060
+ <dc:Bounds x="1631" y="596" width="53" height="28" />
1061
+ </bpmndi:BPMNLabel>
1062
+ </bpmndi:BPMNEdge>
1008
1063
  <bpmndi:BPMNEdge id="BPMNEdge_f_toGwPlanAnswer" bpmnElement="f_toGwPlanAnswer">
1009
1064
  <di:waypoint x="1966" y="469" />
1010
1065
  <di:waypoint x="2066" y="469" />
@@ -1029,7 +1084,7 @@
1029
1084
  <di:waypoint x="816" y="547" />
1030
1085
  <di:waypoint x="816" y="349" />
1031
1086
  <bpmndi:BPMNLabel>
1032
- <dc:Bounds x="1426" y="514" width="81" height="28" />
1087
+ <dc:Bounds x="1326" y="514" width="81" height="28" />
1033
1088
  </bpmndi:BPMNLabel>
1034
1089
  </bpmndi:BPMNEdge>
1035
1090
  <bpmndi:BPMNEdge id="BPMNEdge_f_trial_sla" bpmnElement="f_trial_sla">
@@ -84,15 +84,19 @@ test("record-plan dispatches a taskful plan and levelizes its tasks (wave progre
84
84
  assertEquals(plans[0].wave_label, undefined);
85
85
  });
86
86
 
87
- test("record-plan marks a taskless plan done (no wave-progress columns written)", async () => {
87
+ test("record-plan keeps a taskless plan NON-terminal (planning) with an outcome note (issue #624)", async () => {
88
88
  const { app, plans } = fakeApp();
89
89
  const out = await handler(
90
90
  { variables: { planKey: "owner/repo#137", tasks: [], note: "planner emitted no tasks" } } as any,
91
91
  app,
92
92
  );
93
- assertEquals(plans[0].status, "done");
94
- // taskCount 0 routes the plan-fanout gateway (`gw-plan-empty`) to the terminal taskless-done arm,
95
- // short-circuiting the adversarial plan-review loop that would otherwise livelock (issue #623).
93
+ // A taskless plan is INTERMEDIATE, not terminal: the plan-fanout instance is still live (may
94
+ // re-plan / escalate / be cancelled). Terminal `done` follows engine liveness (reconciled by the
95
+ // poller), never this empty-plan heuristic, so the status stays non-terminal here (issue #624).
96
+ assertEquals(plans[0].status, "planning");
97
+ assertEquals(plans[0].task_count, 0);
98
+ // `taskCount` still drives the plan-fanout gateway (`gw-plan-empty`): zero routes to the operator
99
+ // empty-plan escalation instead of the adversarial plan-review loop (issues #623/#624).
96
100
  assertEquals((out as any).taskCount, 0);
97
101
  assertEquals(plans[0].outcome, "planner emitted no tasks");
98
102
  assertEquals(plans[0].wave_count, undefined);
@@ -10,6 +10,13 @@
10
10
  // • records the task count, moves the plan to `dispatched`, and emits `currentWave = 0`
11
11
  // plus `waveCount` so the wave loop (`select-wave → implement → record-wave`) can run.
12
12
  //
13
+ // A TASKLESS plan (the planner emitted no tasks) is NOT terminal here (issue #624): "the planner
14
+ // produced nothing this pass" is an intermediate state, not an ended process — the plan-fanout
15
+ // instance is still live and may re-plan, escalate, or be cancelled. The plan stays NON-terminal
16
+ // (`planning`, with an `outcome` note for observability); terminal `plans.status` follows ENGINE
17
+ // instance liveness, reconciled by the poller (COMPLETED → `done`; TERMINATED → `abandoned`), never
18
+ // this empty-plan heuristic.
19
+ //
13
20
  // If the planner emits a malformed DAG (cycle / unknown or self dependency / duplicate id),
14
21
  // levelization can't order the tasks. Rather than dead-lock the plan we DEGRADE to the old
15
22
  // flat behaviour — a single wave (wave 0) of all tasks, run fully in parallel — and log a
@@ -38,10 +45,13 @@ interface NormalTask {
38
45
  interface Out extends Record<string, unknown> {
39
46
  currentWave: number;
40
47
  waveCount: number;
41
- // Task count of the recorded plan. The plan-fanout gateway (`gw-plan-empty`) reads this to
42
- // SHORT-CIRCUIT an intentionally-empty plan (`{tasks:[]}`) to a terminal taskless-done arm
43
- // BEFORE the adversarial plan-review gate (issue #623). Feeding an empty plan into review
44
- // caused a plan↔plan-review livelock it can neither be approved nor produce findings.
48
+ // Task count of the recorded plan. The plan-fanout gateway (`gw-plan-empty`) reads this to route
49
+ // an intentionally-empty plan (`{tasks:[]}`) to the OPERATOR empty-plan escalation
50
+ // (`empty-plan-escalation`) a human decides Accept (no-op done) or Revise (re-plan) instead of
51
+ // the adversarial plan-review gate (issues #623/#624). Feeding an empty plan into review caused a
52
+ // plan↔plan-review livelock (it can neither be approved nor produce findings), and auto-terminating
53
+ // it (issue #625) reached a terminal verdict from an intermediate signal while the instance was
54
+ // still live; escalating for operator attention resolves both.
45
55
  taskCount: number;
46
56
  }
47
57
 
@@ -143,7 +153,6 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
143
153
  }
144
154
 
145
155
  const patch: Record<string, unknown> = {
146
- status: tasks.length > 0 ? "dispatched" : "done",
147
156
  task_count: tasks.length,
148
157
  // Operator-visibility wave progress (wave_count / current_wave / wave_label) was RETIRED as a
149
158
  // stored projection (epic #412) — the epics-index reads it from the `plan_wave_label` /
@@ -151,11 +160,25 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
151
160
  // denormalises it onto the `plans` row.
152
161
  updated_at: ts,
153
162
  };
154
- if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
163
+ if (tasks.length > 0) {
164
+ patch.status = "dispatched";
165
+ } else {
166
+ // Taskless plan: DO NOT collapse to terminal `done` (issue #624). "The planner produced nothing
167
+ // this pass" is an INTERMEDIATE state, NOT an ended process — the plan-fanout instance is still
168
+ // live and may re-plan, escalate, or be cancelled. Terminal `plans.status` must follow ENGINE
169
+ // instance liveness, not this per-pass empty-plan heuristic, or the epic renders "Done" over a
170
+ // still-active (in fact looping) instance. So the plan stays NON-terminal (`planning`) until the
171
+ // poller sees the instance actually end: COMPLETED → `done` (pollTasklessPlanTermination,
172
+ // app/service.ts — the same poller that owns the delivery-graph COMPLETED→done transition) or
173
+ // TERMINATED → `abandoned` (instanceTracking's `onTerminated` derived edge). The outcome note is
174
+ // still recorded for observability; it is phase/label copy, not a terminal-status signal.
175
+ patch.status = "planning";
176
+ patch.outcome = note ? str(note) : "planner emitted no tasks";
177
+ }
155
178
  await plans(app.data).update(planKey, patch);
156
179
 
157
- // Kick off the wave loop at wave 0. `taskCount` lets the BPMN gateway terminate an empty plan
158
- // before the review loop (issue #623).
180
+ // Kick off the wave loop at wave 0. `taskCount` lets the BPMN gateway (`gw-plan-empty`) route an
181
+ // empty plan to the operator empty-plan escalation instead of the review loop (issues #623/#624).
159
182
  return { currentWave: 0, waveCount, taskCount: tasks.length };
160
183
  };
161
184