@nanobpm/nano-workforce 0.88.0 → 0.88.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 +7 -0
- package/app/contracts.ts +8 -0
- package/app/feature.ts +8 -4
- package/app/featureBlocked.test.ts +52 -5
- package/app/featureEscalation.test.ts +60 -5
- package/app/migration041.test.ts +79 -0
- package/app/plan.ts +81 -0
- package/app/planDeps.test.ts +192 -0
- package/app/pollUserTasks.test.ts +62 -5
- package/app/service.ts +4 -4
- package/db/migrations/041_inter_epic_plan_deps.sql +38 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [0.88.1](https://github.com/nanobpm/nano-workforce/compare/v0.88.0...v0.88.1) (2026-08-18)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* scope user-task pollers to open tasks so they can't latch a COMPLETED task ([#297](https://github.com/nanobpm/nano-workforce/issues/297)) ([131a796](https://github.com/nanobpm/nano-workforce/commit/131a796dfaed8f9b2eb25526f6583b86b8d7b0df)), closes [#294](https://github.com/nanobpm/nano-workforce/issues/294)
|
|
7
|
+
|
|
1
8
|
# [0.88.0](https://github.com/nanobpm/nano-workforce/compare/v0.87.0...v0.88.0) (2026-08-18)
|
|
2
9
|
|
|
3
10
|
|
package/app/contracts.ts
CHANGED
|
@@ -354,6 +354,14 @@ export const TYPE_CONTRACTS = {
|
|
|
354
354
|
"The snake_case, agent-facing view of a blackboard entry — the HTTP-hook boundary shape every caller and agent consumes. Both the read and write halves import this ONE definition.",
|
|
355
355
|
module: "app/blackboard.ts",
|
|
356
356
|
},
|
|
357
|
+
PlanDep: {
|
|
358
|
+
category: "type",
|
|
359
|
+
name: "PlanDep",
|
|
360
|
+
owner: "app/plan.ts",
|
|
361
|
+
semantics:
|
|
362
|
+
"One INTER-epic dependency edge (issue #292): dependent epic `plan_key` waits for producer epic `depends_on_plan_key`, gated by the producer's `{ package, capability_ref }` capability descriptor. Set admission (S2), planner lowering (S3), and operator visibility (S4) all import this ONE row shape from app/plan.ts — no re-declared synonym.",
|
|
363
|
+
module: "app/plan.ts",
|
|
364
|
+
},
|
|
357
365
|
} as const satisfies Record<string, TypeContract>;
|
|
358
366
|
|
|
359
367
|
export const CAPABILITY_URL_CONTRACTS = {
|
package/app/feature.ts
CHANGED
|
@@ -181,10 +181,12 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
|
|
|
181
181
|
export const FEATURE_ESCALATION_ELEMENT = "feature-escalation";
|
|
182
182
|
|
|
183
183
|
/** The parked `feature-escalation` user task, as `pollFeatureEscalations` observes it via
|
|
184
|
-
*
|
|
184
|
+
* `openUserTasks` (the open-task-scoped query — issue #294): the completable user-task key the pages
|
|
185
|
+
* drive an attributed answer against. Scoping to `state:"CREATED"` is what keeps a looping run — which
|
|
186
|
+
* holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task.
|
|
185
187
|
*
|
|
186
188
|
* The agent's `question` is NOT read from here — the WASM testkit engine does not surface a user
|
|
187
|
-
* task's `zeebe:ioMapping`-mapped local variables through
|
|
189
|
+
* task's `zeebe:ioMapping`-mapped local variables through the user-task query, so relying on it would
|
|
188
190
|
* make the question untestable. Instead the `record-feature-escalation` service task (feature.bpmn)
|
|
189
191
|
* persists `question` onto the row at escalation entry — see `workers/record-feature-escalation`. */
|
|
190
192
|
export interface FeatureEscalationParked {
|
|
@@ -240,8 +242,10 @@ export function deriveFeatureEscalationPatch(
|
|
|
240
242
|
* `pollFeatureBlocked` reconciles it onto the read model. */
|
|
241
243
|
export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
|
|
242
244
|
|
|
243
|
-
/** The parked `feature-blocked` user task, as `pollFeatureBlocked` observes it via `
|
|
244
|
-
* the completable user-task key the pages drive an
|
|
245
|
+
/** The parked `feature-blocked` user task, as `pollFeatureBlocked` observes it via `openUserTasks`
|
|
246
|
+
* (the open-task-scoped query — issue #294): the completable user-task key the pages drive an
|
|
247
|
+
* attributed acknowledgement against. Scoping to `state:"CREATED"` keeps a re-blocked run — which
|
|
248
|
+
* holds COMPLETED prior-round tasks for the same element — from latching the pointer onto a dead task. */
|
|
245
249
|
export interface FeatureBlockedParked {
|
|
246
250
|
userTaskKey: string;
|
|
247
251
|
}
|
|
@@ -49,12 +49,22 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
49
49
|
return { data, stores };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/** A
|
|
53
|
-
*
|
|
54
|
-
|
|
52
|
+
/** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
|
|
53
|
+
* defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
|
|
54
|
+
* A looping run holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
|
|
55
|
+
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
|
|
56
|
+
|
|
57
|
+
/** A fake engine whose user tasks are keyed by processInstanceKey (the only field
|
|
58
|
+
* pollFeatureBlocked queries on). It models the real engine's two accessors from ONE fixture so a test
|
|
59
|
+
* genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
|
|
60
|
+
* (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
|
|
61
|
+
function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
|
|
62
|
+
const all = (filter?: { processInstanceKey?: string }) =>
|
|
63
|
+
filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
|
|
55
64
|
return {
|
|
56
|
-
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
57
|
-
|
|
65
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
|
|
66
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
67
|
+
Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
|
|
58
68
|
} as unknown as EngineClient;
|
|
59
69
|
}
|
|
60
70
|
|
|
@@ -133,3 +143,40 @@ test("pollFeatureBlocked: a parked non-blocked task (feature-escalation) does no
|
|
|
133
143
|
|
|
134
144
|
assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
|
|
135
145
|
});
|
|
146
|
+
|
|
147
|
+
// ── Defect-class guard (issue #294): a looping run holds MULTIPLE feature-blocked tasks ────────────
|
|
148
|
+
// The blocked wait sits on a blocked→ack loop, so a re-blocked run holds a COMPLETED feature-blocked
|
|
149
|
+
// task from a prior round alongside the live CREATED one; the engine returns the COMPLETED task first.
|
|
150
|
+
// Scoping the query to open (CREATED) tasks records the live key, never the terminal one.
|
|
151
|
+
test("pollFeatureBlocked: a looping run records the CREATED task, never the COMPLETED one", async () => {
|
|
152
|
+
const { data, stores } = memData();
|
|
153
|
+
stores.feature_runs = [
|
|
154
|
+
{ feature_key: "o/r#6", status: "awaiting_operator", process_key: "600", blocked_user_task_key: null },
|
|
155
|
+
];
|
|
156
|
+
const engine = fakeEngine({
|
|
157
|
+
"600": [
|
|
158
|
+
{ userTaskKey: "ut-completed", elementId: "feature-blocked", state: "COMPLETED" },
|
|
159
|
+
{ userTaskKey: "ut-live", elementId: "feature-blocked", state: "CREATED" },
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await pollFeatureBlocked(data, engine);
|
|
164
|
+
|
|
165
|
+
assertEquals(stores.feature_runs[0].blocked_user_task_key, "ut-live");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Self-heal reached: a run past the blocked wait whose only feature-blocked task is COMPLETED must
|
|
169
|
+
// clear the stale pointer (open-task query returns [] → `parked=null`), not latch it on the dead key.
|
|
170
|
+
test("pollFeatureBlocked: a run whose only feature-blocked task is COMPLETED clears the stale pointer", async () => {
|
|
171
|
+
const { data, stores } = memData();
|
|
172
|
+
stores.feature_runs = [
|
|
173
|
+
{ feature_key: "o/r#7", status: "awaiting_operator", process_key: "700", blocked_user_task_key: "ut-7" },
|
|
174
|
+
];
|
|
175
|
+
const engine = fakeEngine({
|
|
176
|
+
"700": [{ userTaskKey: "ut-7", elementId: "feature-blocked", state: "COMPLETED" }],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await pollFeatureBlocked(data, engine);
|
|
180
|
+
|
|
181
|
+
assertEquals(stores.feature_runs[0].blocked_user_task_key, null);
|
|
182
|
+
});
|
|
@@ -50,12 +50,22 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
50
50
|
return { data, stores };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/** A
|
|
54
|
-
*
|
|
55
|
-
|
|
53
|
+
/** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
|
|
54
|
+
* defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
|
|
55
|
+
* A looping run holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
|
|
56
|
+
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
|
|
57
|
+
|
|
58
|
+
/** A fake engine whose user tasks are keyed by processInstanceKey (the only field
|
|
59
|
+
* pollFeatureEscalations queries on). It models the real engine's two accessors from ONE fixture so a
|
|
60
|
+
* test genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
|
|
61
|
+
* (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
|
|
62
|
+
function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
|
|
63
|
+
const all = (filter?: { processInstanceKey?: string }) =>
|
|
64
|
+
filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
|
|
56
65
|
return {
|
|
57
|
-
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
58
|
-
|
|
66
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
|
|
67
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
68
|
+
Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
|
|
59
69
|
} as unknown as EngineClient;
|
|
60
70
|
}
|
|
61
71
|
|
|
@@ -178,3 +188,48 @@ test("pollFeatureEscalations: a parked non-escalation task (feature-blocked) doe
|
|
|
178
188
|
assertEquals(stores.feature_runs[0].status, "running");
|
|
179
189
|
assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
|
|
180
190
|
});
|
|
191
|
+
|
|
192
|
+
// ── Defect-class guard (issue #294): a looping run holds MULTIPLE feature-escalation tasks ─────────
|
|
193
|
+
// A run in an escalate→answer→implement→re-escalate loop holds a COMPLETED feature-escalation task
|
|
194
|
+
// from a prior round alongside the live CREATED one. The engine returns the COMPLETED task first, so a
|
|
195
|
+
// poller that reads UNFILTERED tasks and `.find`s by element latches onto the terminal key — pinning
|
|
196
|
+
// the pointer at a dead task. Scoping the query to open (CREATED) tasks resolves the live one.
|
|
197
|
+
test("pollFeatureEscalations: a looping run resolves the CREATED task, never the COMPLETED one", async () => {
|
|
198
|
+
const { data, stores } = memData();
|
|
199
|
+
stores.feature_runs = [
|
|
200
|
+
{ feature_key: "o/r#7", status: "running", process_key: "700", escalation_question: null, escalation_user_task_key: null },
|
|
201
|
+
];
|
|
202
|
+
// Engine returns the COMPLETED (prior-round) task FIRST, then the live CREATED one (issue #294).
|
|
203
|
+
const engine = fakeEngine({
|
|
204
|
+
"700": [
|
|
205
|
+
{ userTaskKey: "ut-completed", elementId: "feature-escalation", state: "COMPLETED" },
|
|
206
|
+
{ userTaskKey: "ut-live", elementId: "feature-escalation", state: "CREATED" },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await pollFeatureEscalations(data, engine);
|
|
211
|
+
|
|
212
|
+
assertEquals(stores.feature_runs[0].status, "escalated");
|
|
213
|
+
// Must be the live CREATED task, not the COMPLETED prior-round one.
|
|
214
|
+
assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-live");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Self-heal reached: once a run finally exits escalation for good, only a lingering COMPLETED
|
|
218
|
+
// feature-escalation task remains. The open-task query returns [], so `parked=null` and the run
|
|
219
|
+
// resets escalated→running — instead of a false-positive pointer built from the terminal task pinning
|
|
220
|
+
// `status='escalated'` forever.
|
|
221
|
+
test("pollFeatureEscalations: a run whose only feature-escalation task is COMPLETED self-heals to running", async () => {
|
|
222
|
+
const { data, stores } = memData();
|
|
223
|
+
stores.feature_runs = [
|
|
224
|
+
{ feature_key: "o/r#8", status: "escalated", process_key: "800", escalation_question: "Q", escalation_user_task_key: "ut-8" },
|
|
225
|
+
];
|
|
226
|
+
const engine = fakeEngine({
|
|
227
|
+
"800": [{ userTaskKey: "ut-8", elementId: "feature-escalation", state: "COMPLETED" }],
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await pollFeatureEscalations(data, engine);
|
|
231
|
+
|
|
232
|
+
assertEquals(stores.feature_runs[0].status, "running");
|
|
233
|
+
assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
|
|
234
|
+
assertEquals(stores.feature_runs[0].escalation_question, null);
|
|
235
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Regression guard for migration 041 (issue #292 slice S1): the durable constraints on the INTER-epic
|
|
2
|
+
// `plan_deps` table. The app-layer accessors (app/planDeps.test.ts) enforce self-edge / duplicate
|
|
3
|
+
// rejection for the in-memory data layer; this test proves the SCHEMA itself is the backstop — the
|
|
4
|
+
// migration applies cleanly, a self-edge trips the CHECK, a duplicate consumer→producer pair trips
|
|
5
|
+
// the PRIMARY KEY, and the consumer `plan_key` foreign-keys to an admitted plan.
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { assertEquals } from "#test-assert";
|
|
11
|
+
|
|
12
|
+
function migratedDb(): DatabaseSync {
|
|
13
|
+
const db = new DatabaseSync(":memory:");
|
|
14
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
15
|
+
// Minimal `plans` shape the migration's FK references.
|
|
16
|
+
db.exec("CREATE TABLE plans (plan_key TEXT PRIMARY KEY);");
|
|
17
|
+
for (const k of ["o/r#1", "o/r#2", "o/r#3"]) {
|
|
18
|
+
db.prepare("INSERT INTO plans (plan_key) VALUES (?)").run(k);
|
|
19
|
+
}
|
|
20
|
+
const sql = readFileSync(
|
|
21
|
+
fileURLToPath(new URL("../db/migrations/041_inter_epic_plan_deps.sql", import.meta.url)),
|
|
22
|
+
"utf8",
|
|
23
|
+
);
|
|
24
|
+
db.exec(sql);
|
|
25
|
+
return db;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const insert = (db: DatabaseSync, planKey: string, dependsOn: string) =>
|
|
29
|
+
db
|
|
30
|
+
.prepare(
|
|
31
|
+
`INSERT INTO plan_deps (plan_key, depends_on_plan_key, package, capability_ref, created_at)
|
|
32
|
+
VALUES (?, ?, '@nanobpm/p', ?, 't')`,
|
|
33
|
+
)
|
|
34
|
+
.run(planKey, dependsOn, dependsOn);
|
|
35
|
+
|
|
36
|
+
test("migration 041 applies cleanly and records a valid inter-epic edge", () => {
|
|
37
|
+
const db = migratedDb();
|
|
38
|
+
insert(db, "o/r#2", "o/r#1");
|
|
39
|
+
const row = db
|
|
40
|
+
.prepare("SELECT plan_key, depends_on_plan_key, package FROM plan_deps WHERE plan_key = ?")
|
|
41
|
+
.get("o/r#2") as { plan_key: string; depends_on_plan_key: string; package: string };
|
|
42
|
+
assertEquals(row.plan_key, "o/r#2");
|
|
43
|
+
assertEquals(row.depends_on_plan_key, "o/r#1");
|
|
44
|
+
assertEquals(row.package, "@nanobpm/p");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("migration 041 CHECK rejects a self-edge", () => {
|
|
48
|
+
const db = migratedDb();
|
|
49
|
+
let threw = false;
|
|
50
|
+
try {
|
|
51
|
+
insert(db, "o/r#1", "o/r#1");
|
|
52
|
+
} catch {
|
|
53
|
+
threw = true;
|
|
54
|
+
}
|
|
55
|
+
assertEquals(threw, true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("migration 041 PRIMARY KEY rejects a duplicate consumer→producer edge", () => {
|
|
59
|
+
const db = migratedDb();
|
|
60
|
+
insert(db, "o/r#2", "o/r#1");
|
|
61
|
+
let threw = false;
|
|
62
|
+
try {
|
|
63
|
+
insert(db, "o/r#2", "o/r#1");
|
|
64
|
+
} catch {
|
|
65
|
+
threw = true;
|
|
66
|
+
}
|
|
67
|
+
assertEquals(threw, true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("migration 041 FK ties the consumer plan_key to an admitted plan", () => {
|
|
71
|
+
const db = migratedDb();
|
|
72
|
+
let threw = false;
|
|
73
|
+
try {
|
|
74
|
+
insert(db, "o/r#404", "o/r#1");
|
|
75
|
+
} catch {
|
|
76
|
+
threw = true;
|
|
77
|
+
}
|
|
78
|
+
assertEquals(threw, true);
|
|
79
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -138,6 +138,87 @@ export interface PlanTaskDep {
|
|
|
138
138
|
export const planTaskDeps = (data: DataLayer) =>
|
|
139
139
|
data.table<PlanTaskDep>("plan_task_deps", "plan_key");
|
|
140
140
|
|
|
141
|
+
/** One INTER-epic dependency edge in the plan-set DAG (issue #292, slice S1): the epic `plan_key`
|
|
142
|
+
* waits for the producer epic `depends_on_plan_key` to publish a capability before it may fan out.
|
|
143
|
+
*
|
|
144
|
+
* This is the coarser sibling of {@link PlanTaskDep} (which orders TASKS *within* one epic into
|
|
145
|
+
* waves). A `PlanDep` orders whole EPICS relative to each other. The edge additionally carries the
|
|
146
|
+
* gating contract descriptor the capability probe (slice S3) resolves against:
|
|
147
|
+
* • `package` — the producer epic's published package name, and
|
|
148
|
+
* • `capability_ref` — the producer epic's issue handle, used to resolve which published
|
|
149
|
+
* `pkg@version` FIRST carries the awaited capability (late-bound into the dependent's build).
|
|
150
|
+
* Keyed on `plan_key` (the dependent) so a single delete clears a dependent's whole inbound edge set
|
|
151
|
+
* — mirroring how `plan_task_deps` is keyed on `plan_key`. See db/migrations/041_inter_epic_plan_deps.sql
|
|
152
|
+
* for the durable constraints (one edge per consumer→producer pair; no self-edge). */
|
|
153
|
+
export interface PlanDep {
|
|
154
|
+
plan_key: string;
|
|
155
|
+
depends_on_plan_key: string;
|
|
156
|
+
package: string;
|
|
157
|
+
capability_ref: string;
|
|
158
|
+
created_at: string;
|
|
159
|
+
}
|
|
160
|
+
export const planDeps = (data: DataLayer) => data.table<PlanDep>("plan_deps", "plan_key");
|
|
161
|
+
|
|
162
|
+
/** The fields an admission caller supplies for one inter-epic edge; `created_at` is stamped here. */
|
|
163
|
+
export type PlanDepInput = Omit<PlanDep, "created_at">;
|
|
164
|
+
|
|
165
|
+
/** Record one inter-epic dependency edge, enforcing the schema's two invariants at the app layer too
|
|
166
|
+
* (the durable table backstops both, but the in-memory test data layer does not): an epic may not
|
|
167
|
+
* depend on itself, and a consumer→producer edge is recorded at most once. A duplicate re-submission
|
|
168
|
+
* is a no-op that returns the existing row rather than throwing, so batch admission (S2) stays
|
|
169
|
+
* idempotent; a self-edge is a programming/validation error and throws. */
|
|
170
|
+
export async function recordPlanDep(data: DataLayer, edge: PlanDepInput): Promise<PlanDep> {
|
|
171
|
+
if (edge.plan_key === edge.depends_on_plan_key) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`plan_deps: self-edge rejected — epic ${edge.plan_key} cannot depend on itself`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const table = planDeps(data);
|
|
177
|
+
const match = { plan_key: edge.plan_key, depends_on_plan_key: edge.depends_on_plan_key };
|
|
178
|
+
const existing = (await table.find(match))[0];
|
|
179
|
+
if (existing) return existing;
|
|
180
|
+
const row: PlanDep = { ...edge, created_at: now() };
|
|
181
|
+
try {
|
|
182
|
+
await table.insert(row);
|
|
183
|
+
return row;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// A concurrent caller may have inserted the same consumer→producer pair between our find and
|
|
186
|
+
// our insert (classic check-then-insert race); the composite PRIMARY KEY is the durable
|
|
187
|
+
// backstop that rejects the loser. Honour the "duplicate re-submission is a no-op" contract by
|
|
188
|
+
// re-reading and returning the winning row rather than surfacing the constraint error. Only a
|
|
189
|
+
// genuine non-collision failure (the pair still absent after the re-read) is re-raised.
|
|
190
|
+
const raced = (await table.find(match))[0];
|
|
191
|
+
if (raced) return raced;
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** All INBOUND edges for `planKey` — i.e. every producer epic this dependent waits on. Empty for a
|
|
197
|
+
* root epic (no inter-epic dependencies). */
|
|
198
|
+
export function inboundPlanDeps(data: DataLayer, planKey: string): Promise<PlanDep[]> {
|
|
199
|
+
return planDeps(data).find({ plan_key: planKey });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Every inter-epic edge whose dependent is in `planKeys` — the whole DAG for a submitted plan set.
|
|
203
|
+
* Reads per-key (not a table scan) so it composes with the same equality-filtered data layer the
|
|
204
|
+
* unit tests exercise. Producers outside the set are still returned as edge fields; the set
|
|
205
|
+
* validator (S3) is what rejects an edge naming an unsubmitted epic. */
|
|
206
|
+
export async function planDepsForSet(data: DataLayer, planKeys: string[]): Promise<PlanDep[]> {
|
|
207
|
+
const seen = new Set<string>();
|
|
208
|
+
const out: PlanDep[] = [];
|
|
209
|
+
// De-duplicate the keys first so a repeated key (retries / accidental repeats) does not trigger a
|
|
210
|
+
// redundant per-key inbound read; the edge de-dup below still guards against any overlap.
|
|
211
|
+
for (const key of new Set(planKeys)) {
|
|
212
|
+
for (const edge of await inboundPlanDeps(data, key)) {
|
|
213
|
+
const id = `${edge.plan_key}\u0000${edge.depends_on_plan_key}`;
|
|
214
|
+
if (seen.has(id)) continue;
|
|
215
|
+
seen.add(id);
|
|
216
|
+
out.push(edge);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
141
222
|
/** One adversarial plan-review round (006_plan_review.sql): the `senior:plan-review` agent's
|
|
142
223
|
* verdict on the plan before fan-out. Append-only within a plan run; the current round is
|
|
143
224
|
* `count(plan_reviews)`. Re-planning a finished issue clears the prior rows (see startPlan) so
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Red/green regression for the INTER-epic dependency edge (PlanDep) data layer — issue #292 slice S1.
|
|
2
|
+
//
|
|
3
|
+
// This slice adds `plan_deps` (db/migrations/041_inter_epic_plan_deps.sql) and its typed read/write
|
|
4
|
+
// surface in app/plan.ts, mirroring the intra-epic `plan_task_deps` accessors. The durable table
|
|
5
|
+
// enforces "one edge per consumer→producer pair" (PRIMARY KEY) and "no self-edge" (CHECK); these
|
|
6
|
+
// tests pin the app-layer accessors that admission (S2) and the planner (S3) build on, driven against
|
|
7
|
+
// the same in-memory data layer the rest of app/plan's tests use.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { assertEquals, assertRejects } from "#test-assert";
|
|
10
|
+
import {
|
|
11
|
+
inboundPlanDeps,
|
|
12
|
+
type PlanDep,
|
|
13
|
+
planDepsForSet,
|
|
14
|
+
recordPlanDep,
|
|
15
|
+
} from "./plan.ts";
|
|
16
|
+
|
|
17
|
+
// Minimal in-memory data layer, matching the helper style in app/plan.test.ts: equality-filtered
|
|
18
|
+
// `find`, append `insert`, and a `delete(planKey)` that clears every row keyed on `plan_key` (so a
|
|
19
|
+
// re-seed of a plan's inbound edge set is one delete, exactly as `plan_task_deps` is cleared).
|
|
20
|
+
function memData() {
|
|
21
|
+
const rows: any[] = [];
|
|
22
|
+
const key = "plan_key";
|
|
23
|
+
const table = {
|
|
24
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
25
|
+
find: (q: any) =>
|
|
26
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
27
|
+
insert: (r: any) => {
|
|
28
|
+
rows.push(r);
|
|
29
|
+
return Promise.resolve(r);
|
|
30
|
+
},
|
|
31
|
+
count: (q: any) =>
|
|
32
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
|
|
33
|
+
delete: (k: any) => {
|
|
34
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
35
|
+
return Promise.resolve();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
rows,
|
|
40
|
+
data: { table: () => table } as any,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const EDGE = {
|
|
45
|
+
plan_key: "owner/repo#2",
|
|
46
|
+
depends_on_plan_key: "owner/repo#1",
|
|
47
|
+
package: "@nanobpm/producer",
|
|
48
|
+
capability_ref: "owner/repo#1",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
test("recordPlanDep persists an edge with a stamped created_at", async () => {
|
|
52
|
+
const { data, rows } = memData();
|
|
53
|
+
const row = await recordPlanDep(data, EDGE);
|
|
54
|
+
assertEquals(rows.length, 1);
|
|
55
|
+
assertEquals(row.plan_key, "owner/repo#2");
|
|
56
|
+
assertEquals(row.depends_on_plan_key, "owner/repo#1");
|
|
57
|
+
assertEquals(row.package, "@nanobpm/producer");
|
|
58
|
+
assertEquals(row.capability_ref, "owner/repo#1");
|
|
59
|
+
assertEquals(typeof row.created_at, "string");
|
|
60
|
+
assertEquals(row.created_at.length > 0, true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("recordPlanDep rejects a self-edge (an epic cannot depend on itself)", async () => {
|
|
64
|
+
const { data, rows } = memData();
|
|
65
|
+
await assertRejects(() =>
|
|
66
|
+
recordPlanDep(data, { ...EDGE, plan_key: "owner/repo#1", depends_on_plan_key: "owner/repo#1" }),
|
|
67
|
+
);
|
|
68
|
+
assertEquals(rows.length, 0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("recordPlanDep is idempotent on a duplicate edge (no second row)", async () => {
|
|
72
|
+
const { data, rows } = memData();
|
|
73
|
+
const first = await recordPlanDep(data, EDGE);
|
|
74
|
+
const again = await recordPlanDep(data, { ...EDGE, package: "@nanobpm/ignored-on-dupe" });
|
|
75
|
+
assertEquals(rows.length, 1);
|
|
76
|
+
// The existing row wins — a re-submission does not overwrite nor append.
|
|
77
|
+
assertEquals(again.created_at, first.created_at);
|
|
78
|
+
assertEquals(again.package, "@nanobpm/producer");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("recordPlanDep treats a concurrent PK collision as idempotent (no throw)", async () => {
|
|
82
|
+
// Simulate the check-then-insert race the durable composite PRIMARY KEY backstops: a sibling
|
|
83
|
+
// caller wins between our find and our insert, so `find` sees nothing but `insert` collides.
|
|
84
|
+
const rows: any[] = [];
|
|
85
|
+
const key = "plan_key";
|
|
86
|
+
let raceArmed = true;
|
|
87
|
+
const table = {
|
|
88
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
89
|
+
find: (q: any) =>
|
|
90
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
91
|
+
insert: (r: any) => {
|
|
92
|
+
// On the first insert, a concurrent writer has already landed the same pair: append the
|
|
93
|
+
// rival row and reject this one with a UNIQUE/PK constraint error, as SQLite would.
|
|
94
|
+
if (raceArmed) {
|
|
95
|
+
raceArmed = false;
|
|
96
|
+
rows.push({ ...r, package: "@nanobpm/winner", created_at: "1999-01-01T00:00:00.000Z" });
|
|
97
|
+
return Promise.reject(new Error("UNIQUE constraint failed: plan_deps.plan_key"));
|
|
98
|
+
}
|
|
99
|
+
rows.push(r);
|
|
100
|
+
return Promise.resolve(r);
|
|
101
|
+
},
|
|
102
|
+
count: (q: any) =>
|
|
103
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length),
|
|
104
|
+
delete: (k: any) => {
|
|
105
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
106
|
+
return Promise.resolve();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const data = { table: () => table } as any;
|
|
110
|
+
|
|
111
|
+
const row = await recordPlanDep(data, EDGE);
|
|
112
|
+
// The rival row is returned rather than the constraint error surfacing, and no duplicate lands.
|
|
113
|
+
assertEquals(rows.length, 1);
|
|
114
|
+
assertEquals(row.package, "@nanobpm/winner");
|
|
115
|
+
assertEquals(row.created_at, "1999-01-01T00:00:00.000Z");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("recordPlanDep re-raises a non-collision insert failure", async () => {
|
|
119
|
+
// A genuine failure (the pair is still absent after the re-read) must not be swallowed.
|
|
120
|
+
const table = {
|
|
121
|
+
get: () => Promise.resolve(null),
|
|
122
|
+
find: () => Promise.resolve([] as any[]),
|
|
123
|
+
insert: () => Promise.reject(new Error("disk I/O error")),
|
|
124
|
+
count: () => Promise.resolve(0),
|
|
125
|
+
delete: () => Promise.resolve(),
|
|
126
|
+
};
|
|
127
|
+
const data = { table: () => table } as any;
|
|
128
|
+
await assertRejects(() => recordPlanDep(data, EDGE), Error, "disk I/O error");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("inboundPlanDeps returns every producer a dependent waits on; empty for a root", async () => {
|
|
132
|
+
const { data } = memData();
|
|
133
|
+
await recordPlanDep(data, EDGE);
|
|
134
|
+
await recordPlanDep(data, { ...EDGE, depends_on_plan_key: "owner/repo#3", capability_ref: "owner/repo#3" });
|
|
135
|
+
|
|
136
|
+
const inbound = await inboundPlanDeps(data, "owner/repo#2");
|
|
137
|
+
assertEquals(inbound.length, 2);
|
|
138
|
+
assertEquals(
|
|
139
|
+
inbound.map((e: PlanDep) => e.depends_on_plan_key).sort(),
|
|
140
|
+
["owner/repo#1", "owner/repo#3"],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const root = await inboundPlanDeps(data, "owner/repo#1");
|
|
144
|
+
assertEquals(root.length, 0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("planDepsForSet returns the whole DAG for a submitted set, de-duplicated", async () => {
|
|
148
|
+
const { data } = memData();
|
|
149
|
+
// #3 -> #1, #3 -> #2, #2 -> #1 : a small DAG across three epics.
|
|
150
|
+
await recordPlanDep(data, {
|
|
151
|
+
plan_key: "owner/repo#3",
|
|
152
|
+
depends_on_plan_key: "owner/repo#1",
|
|
153
|
+
package: "@nanobpm/a",
|
|
154
|
+
capability_ref: "owner/repo#1",
|
|
155
|
+
});
|
|
156
|
+
await recordPlanDep(data, {
|
|
157
|
+
plan_key: "owner/repo#3",
|
|
158
|
+
depends_on_plan_key: "owner/repo#2",
|
|
159
|
+
package: "@nanobpm/b",
|
|
160
|
+
capability_ref: "owner/repo#2",
|
|
161
|
+
});
|
|
162
|
+
await recordPlanDep(data, {
|
|
163
|
+
plan_key: "owner/repo#2",
|
|
164
|
+
depends_on_plan_key: "owner/repo#1",
|
|
165
|
+
package: "@nanobpm/a",
|
|
166
|
+
capability_ref: "owner/repo#1",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const edges = await planDepsForSet(data, ["owner/repo#1", "owner/repo#2", "owner/repo#3"]);
|
|
170
|
+
assertEquals(edges.length, 3);
|
|
171
|
+
// A root (#1) contributes no inbound edges; passing overlapping keys never double-counts an edge.
|
|
172
|
+
const overlapped = await planDepsForSet(data, ["owner/repo#3", "owner/repo#3"]);
|
|
173
|
+
assertEquals(overlapped.length, 2);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("planDepsForSet reads each key once even when planKeys repeats", async () => {
|
|
177
|
+
// Prove the key de-dup: a repeated key must not drive a redundant inbound read.
|
|
178
|
+
const findKeys: string[] = [];
|
|
179
|
+
const table = {
|
|
180
|
+
get: () => Promise.resolve(null),
|
|
181
|
+
find: (q: any) => {
|
|
182
|
+
findKeys.push(q.plan_key);
|
|
183
|
+
return Promise.resolve([] as any[]);
|
|
184
|
+
},
|
|
185
|
+
insert: (r: any) => Promise.resolve(r),
|
|
186
|
+
count: () => Promise.resolve(0),
|
|
187
|
+
delete: () => Promise.resolve(),
|
|
188
|
+
};
|
|
189
|
+
const data = { table: () => table } as any;
|
|
190
|
+
await planDepsForSet(data, ["owner/repo#3", "owner/repo#3", "owner/repo#4", "owner/repo#3"]);
|
|
191
|
+
assertEquals(findKeys.sort(), ["owner/repo#3", "owner/repo#4"]);
|
|
192
|
+
});
|
|
@@ -51,12 +51,22 @@ function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: R
|
|
|
51
51
|
return { data, stores };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/** A
|
|
55
|
-
*
|
|
56
|
-
|
|
54
|
+
/** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
|
|
55
|
+
* defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
|
|
56
|
+
* A looping instance holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
|
|
57
|
+
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
|
|
58
|
+
|
|
59
|
+
/** A fake engine whose user tasks are keyed by processInstanceKey (the only field the poller queries on
|
|
60
|
+
* for plan / PR instances). It models the real engine's two accessors from ONE fixture so a test
|
|
61
|
+
* genuinely exercises the lifecycle-state filtering: `searchUserTasks` returns tasks in ANY state
|
|
62
|
+
* (COMPLETED first, as the live API does — issue #294), while `openUserTasks` pins `state:"CREATED"`. */
|
|
63
|
+
function fakeEngine(byInstance: Record<string, FakeTask[]>): EngineClient {
|
|
64
|
+
const all = (filter?: { processInstanceKey?: string }) =>
|
|
65
|
+
filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : [];
|
|
57
66
|
return {
|
|
58
|
-
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
59
|
-
|
|
67
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) => Promise.resolve(all(filter)),
|
|
68
|
+
openUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
69
|
+
Promise.resolve(all(filter).filter((t) => (t.state ?? "CREATED") === "CREATED")),
|
|
60
70
|
} as unknown as EngineClient;
|
|
61
71
|
}
|
|
62
72
|
|
|
@@ -172,3 +182,50 @@ test("pollUserTasks: skips terminal plans and PRs without a process key", async
|
|
|
172
182
|
|
|
173
183
|
assertEquals(stores.user_tasks ?? [], []);
|
|
174
184
|
});
|
|
185
|
+
|
|
186
|
+
// ── Defect-class guard (issue #294): a looping instance holds MULTIPLE tasks for one element ───────
|
|
187
|
+
// The plan-review (review→revise→review) and PR-wait (escalate→answer→re-escalate) elements sit on a
|
|
188
|
+
// loop, so a looping instance holds a COMPLETED task from a prior round alongside the live CREATED one,
|
|
189
|
+
// and the engine returns the COMPLETED one first. Scoping the query to open (CREATED) tasks projects
|
|
190
|
+
// only the live completable key onto `user_tasks`, never a terminal one the page could not complete.
|
|
191
|
+
test("pollUserTasks: a looping plan/PR projects only the CREATED task, never the COMPLETED one", async () => {
|
|
192
|
+
const { data, stores } = memData({
|
|
193
|
+
plans: [{ plan_key: "o/r#50", status: "dispatched", process_key: "pp-50", issue_url: "https://github.com/o/r/issues/50" }],
|
|
194
|
+
plan_reviews: [{ plan_key: "o/r#50", epoch: 0, round: 0, approved: 0, findings: "scope too broad", created_at: "2025-01-01T00:00:00.000Z" }],
|
|
195
|
+
pull_requests: [{ pr_key: "o/r#51", status: "escalated", process_key: "rp-51", url: "https://github.com/o/r/pull/51" }],
|
|
196
|
+
escalations: [{ id: 1, pr_key: "o/r#51", status: "open", question: "conflicting reviews" }],
|
|
197
|
+
});
|
|
198
|
+
// Each looping instance returns its COMPLETED prior-round task FIRST, then the live CREATED one.
|
|
199
|
+
const engine = fakeEngine({
|
|
200
|
+
"pp-50": [
|
|
201
|
+
{ userTaskKey: "ut-plan-completed", elementId: "plan-review-decision", state: "COMPLETED" },
|
|
202
|
+
{ userTaskKey: "ut-plan-live", elementId: "plan-review-decision", state: "CREATED" },
|
|
203
|
+
],
|
|
204
|
+
"rp-51": [
|
|
205
|
+
{ userTaskKey: "ut-pr-completed", elementId: "wait-answer", state: "COMPLETED" },
|
|
206
|
+
{ userTaskKey: "ut-pr-live", elementId: "wait-answer", state: "CREATED" },
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await pollUserTasks(data, engine);
|
|
211
|
+
|
|
212
|
+
const keys = (stores.user_tasks ?? []).map((r) => r.user_task_key).sort();
|
|
213
|
+
// Only the live CREATED keys — the COMPLETED prior-round tasks must never surface a dead affordance.
|
|
214
|
+
assertEquals(keys, ["ut-plan-live", "ut-pr-live"]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Self-heal reached: an instance whose only task for an element is COMPLETED yields no open task, so
|
|
218
|
+
// its row is removed (open-task query returns []), rather than pinning a dead completable pointer.
|
|
219
|
+
test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row", async () => {
|
|
220
|
+
const { data, stores } = memData({
|
|
221
|
+
plans: [{ plan_key: "o/r#52", status: "dispatched", process_key: "pp-52", issue_url: "https://github.com/o/r/issues/52" }],
|
|
222
|
+
plan_reviews: [{ plan_key: "o/r#52", epoch: 0, round: 0, approved: 0, findings: "scope too broad", created_at: "2025-01-01T00:00:00.000Z" }],
|
|
223
|
+
});
|
|
224
|
+
const engine = fakeEngine({
|
|
225
|
+
"pp-52": [{ userTaskKey: "ut-plan-completed", elementId: "plan-review-decision", state: "COMPLETED" }],
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await pollUserTasks(data, engine);
|
|
229
|
+
|
|
230
|
+
assertEquals(stores.user_tasks ?? [], []);
|
|
231
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -1410,7 +1410,7 @@ export async function pollFeatureEscalations(data: DataLayer, engine: EngineClie
|
|
|
1410
1410
|
for (const run of candidates) {
|
|
1411
1411
|
if (!run.process_key) continue;
|
|
1412
1412
|
try {
|
|
1413
|
-
const tasks = await engine.
|
|
1413
|
+
const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
|
|
1414
1414
|
const task = tasks.find((t) => t.elementId === FEATURE_ESCALATION_ELEMENT);
|
|
1415
1415
|
const parked = task ? { userTaskKey: task.userTaskKey } : null;
|
|
1416
1416
|
const patch = deriveFeatureEscalationPatch(run, parked);
|
|
@@ -1444,7 +1444,7 @@ export async function pollFeatureBlocked(data: DataLayer, engine: EngineClient)
|
|
|
1444
1444
|
for (const run of await featureRuns(data).find({ status: "awaiting_operator" })) {
|
|
1445
1445
|
if (!run.process_key) continue;
|
|
1446
1446
|
try {
|
|
1447
|
-
const tasks = await engine.
|
|
1447
|
+
const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
|
|
1448
1448
|
const task = tasks.find((t) => t.elementId === FEATURE_BLOCKED_ELEMENT);
|
|
1449
1449
|
const parked = task ? { userTaskKey: task.userTaskKey } : null;
|
|
1450
1450
|
const patch = deriveFeatureBlockedPatch(run, parked);
|
|
@@ -1577,7 +1577,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1577
1577
|
planSeen.add(plan.plan_key);
|
|
1578
1578
|
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
1579
1579
|
try {
|
|
1580
|
-
tasks = await engine.
|
|
1580
|
+
tasks = await engine.openUserTasks({ processInstanceKey: plan.process_key });
|
|
1581
1581
|
} catch (err) {
|
|
1582
1582
|
console.error(`[poller] user tasks (plan ${plan.plan_key}): ${err}`);
|
|
1583
1583
|
continue;
|
|
@@ -1632,7 +1632,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
|
|
|
1632
1632
|
prSeen.add(pr.pr_key);
|
|
1633
1633
|
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
1634
1634
|
try {
|
|
1635
|
-
tasks = await engine.
|
|
1635
|
+
tasks = await engine.openUserTasks({ processInstanceKey: pr.process_key });
|
|
1636
1636
|
} catch (err) {
|
|
1637
1637
|
console.error(`[poller] user tasks (pr ${pr.pr_key}): ${err}`);
|
|
1638
1638
|
continue;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- 041_inter_epic_plan_deps.sql — issue #292 slice S1: first-class INTER-epic dependency edge.
|
|
2
|
+
--
|
|
3
|
+
-- `plan_task_deps` (005_plan_deps.sql) records the INTRA-epic task DAG — edges that order tasks
|
|
4
|
+
-- *within a single plan* into waves. There is no way today to say "epic B depends-on epic A": that
|
|
5
|
+
-- the whole of epic B must wait until epic A has published a capability before B may fan out.
|
|
6
|
+
--
|
|
7
|
+
-- This table adds that second, coarser grain: one row per INTER-epic edge. `plan_key` is the
|
|
8
|
+
-- dependent/consumer epic that waits; `depends_on_plan_key` is the producer epic it waits for. The
|
|
9
|
+
-- edge also carries the GATING CONTRACT DESCRIPTOR the later capability probe (slice S3) needs to
|
|
10
|
+
-- resolve which published `pkg@version` first carries the awaited capability: `package` is the
|
|
11
|
+
-- producer's published package name, and `capability_ref` is the producer epic's issue handle used
|
|
12
|
+
-- to resolve that version. This slice (S1) only lands the durable schema + typed read/write surface;
|
|
13
|
+
-- admission (S2), lowering into a readiness gate (S3), and visibility (S4) build on it later.
|
|
14
|
+
--
|
|
15
|
+
-- Constraints mirror how `plan_task_deps` is constrained:
|
|
16
|
+
-- • PRIMARY KEY (plan_key, depends_on_plan_key) — one edge per consumer→producer pair, so a
|
|
17
|
+
-- re-submitted set cannot duplicate an edge.
|
|
18
|
+
-- • CHECK (plan_key <> depends_on_plan_key) — an epic cannot depend on itself.
|
|
19
|
+
-- • plan_key REFERENCES plans(plan_key) — the consumer is an admitted plan (as plan_task_deps FKs
|
|
20
|
+
-- its plan_key). `depends_on_plan_key` is intentionally NOT FK-constrained: a batch admission
|
|
21
|
+
-- (S2) may insert edges before every producer row exists, and the set validator enforces that
|
|
22
|
+
-- every edge names a submitted epic. An index on plan_key backs the inbound read.
|
|
23
|
+
--
|
|
24
|
+
-- Numbered after the current highest prefix on origin/main (040); the runner wraps each file in its
|
|
25
|
+
-- own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
26
|
+
|
|
27
|
+
CREATE TABLE plan_deps (
|
|
28
|
+
plan_key TEXT NOT NULL REFERENCES plans(plan_key), -- dependent/consumer epic that waits
|
|
29
|
+
depends_on_plan_key TEXT NOT NULL, -- producer epic it waits for
|
|
30
|
+
package TEXT NOT NULL, -- producer's published package name
|
|
31
|
+
capability_ref TEXT NOT NULL, -- producer epic issue handle → pkg@version
|
|
32
|
+
created_at TEXT NOT NULL,
|
|
33
|
+
PRIMARY KEY (plan_key, depends_on_plan_key),
|
|
34
|
+
CHECK (plan_key <> depends_on_plan_key)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
CREATE INDEX idx_plan_deps_plan ON plan_deps(plan_key);
|
|
38
|
+
CREATE INDEX idx_plan_deps_producer ON plan_deps(depends_on_plan_key);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.88.
|
|
3
|
+
"version": "0.88.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",
|