@nanobpm/nano-workforce 0.163.0 → 0.163.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.
- package/CHANGELOG.md +6 -0
- package/SPEC.md +14 -2
- package/app/agentCompletion.test.ts +33 -0
- package/app/agentCompletion.ts +11 -1
- package/app/epicPhase.test.ts +2 -0
- package/app/epicPhase.ts +1 -0
- package/app/pollTasklessPlanTermination.test.ts +188 -0
- package/app/service.ts +53 -0
- package/app/userTasks.ts +8 -0
- package/e2e/plan-fanout.e2e.ts +86 -11
- package/package.json +1 -1
- package/resources/forms/empty-plan-escalation.form +31 -0
- package/resources/processes/plan-fanout.bpmn +72 -17
- package/workers/record-plan/worker.test.ts +8 -4
- package/workers/record-plan/worker.ts +31 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.163.1](https://github.com/nanobpm/nano-workforce/compare/v0.163.0...v0.163.1) (2026-08-30)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **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)
|
|
6
|
+
|
|
1
7
|
## [0.163.0](https://github.com/nanobpm/nano-workforce/compare/v0.162.2...v0.163.0) (2026-08-30)
|
|
2
8
|
|
|
3
9
|
### 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
|
});
|
package/app/agentCompletion.ts
CHANGED
|
@@ -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
|
package/app/epicPhase.test.ts
CHANGED
|
@@ -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",
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -241,27 +241,30 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
241
241
|
);
|
|
242
242
|
});
|
|
243
243
|
|
|
244
|
-
// Empty-plan
|
|
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
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
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
|
|
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->
|
|
264
|
-
`empty plan routed to the
|
|
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
|
-
|
|
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(
|
|
276
|
-
assert.equal(
|
|
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.
|
|
3
|
+
"version": "0.163.1",
|
|
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",
|
|
@@ -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:
|
|
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 "" else planFindings) + (if (notes = null or notes = "") then "" else " Human guidance: " + 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="
|
|
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="
|
|
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="
|
|
738
|
+
<dc:Bounds x="1723" y="611" width="36" height="36" />
|
|
708
739
|
<bpmndi:BPMNLabel>
|
|
709
|
-
<dc:Bounds x="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
934
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
949
|
-
<di:waypoint x="
|
|
950
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
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
|
|
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
|
-
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
|
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
|
|
158
|
-
//
|
|
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
|
|