@nanobpm/nano-workforce 0.87.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 CHANGED
@@ -1,3 +1,17 @@
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
+
8
+ # [0.88.0](https://github.com/nanobpm/nano-workforce/compare/v0.87.0...v0.88.0) (2026-08-18)
9
+
10
+
11
+ ### Features
12
+
13
+ * **service:** branch-scoped treeless clone for review-job repo envelope ([#287](https://github.com/nanobpm/nano-workforce/issues/287)) ([#288](https://github.com/nanobpm/nano-workforce/issues/288)) ([c90dc23](https://github.com/nanobpm/nano-workforce/commit/c90dc23274fbb5e19835e25eef755f88337ab86f)), closes [jwulf/c8ctl-plugin-nano#91](https://github.com/jwulf/c8ctl-plugin-nano/issues/91)
14
+
1
15
  # [0.87.0](https://github.com/nanobpm/nano-workforce/compare/v0.86.0...v0.87.0) (2026-08-18)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -193,9 +193,17 @@ Consequences the prompt (`resources/prompts/review-round.md`) encodes:
193
193
  `io.nanobpm.agentTask.repository.url`; the **app** supplies it — plus the head
194
194
  branch as `…repository.ref` — as a process variable at `createInstance`
195
195
  (`repoEnvelopeVars` in `app/service.ts`, resolving the head via `fetchPrMeta`/
196
- `fetchPrHead`). The harness is PR-agnostic: it does **not** derive the head branch
197
- from `prNumber`/`prUrl`. When the head can't be resolved the envelope is omitted and
198
- the agent falls back to the worker's launch directory (the legacy behavior).
196
+ `fetchPrHead`). The envelope also carries clone-shaping fields so large monorepos
197
+ provision within the c8ctl clone timeout (issue #287): `singleBranch: true` and
198
+ `filter: "blob:none"` request a **branch-scoped, blobless partial clone** (trees are
199
+ still fetched up-front — a *treeless* clone would be `--filter=tree:0`; the full
200
+ commit graph is kept — no `--depth 1` — so `git merge-base` / the review 3-dot diff
201
+ stays correct while blobs fetch lazily), and, when the PR base branch is resolvable,
202
+ an optional `…repository.baseRef` so the harness fetches the base tip alongside the
203
+ head and keeps `origin/<base>` reachable for the diff. The harness is PR-agnostic: it
204
+ does **not** derive the head branch from `prNumber`/`prUrl`. When the head can't be
205
+ resolved the envelope is omitted and the agent falls back to the worker's launch
206
+ directory (the legacy behavior).
199
207
 
200
208
  ## 6. Signals
201
209
 
@@ -625,8 +633,11 @@ but encode incompatible decisions about a shared contract** — a genuine design
625
633
  integration provisions the repo and checks out the PR's head branch (it must
626
634
  already give the worker repo access to work at all). The **app** resolves the head
627
635
  branch and passes it in the `io.nanobpm.agentTask.repository.{url,ref}` envelope
628
- (a `createInstance` process variable — see `repoEnvelopeVars`); the harness is
629
- PR-agnostic and provisions from that envelope. The worker stays a pure provisioner.
636
+ (a `createInstance` process variable — see `repoEnvelopeVars`), along with the
637
+ branch-scoped, blobless clone-shaping fields (`singleBranch`, `filter`, optional
638
+ `baseRef`) that let large monorepos provision within the clone timeout (#287); the
639
+ harness is PR-agnostic and provisions from that envelope. The worker stays a pure
640
+ provisioner.
630
641
  - **review-ready via GitHub webhook** — same message, swappable faster trigger,
631
642
  when the app is publicly reachable. Deferred (poller-only for v1).
632
643
  - **Supervised vs external worker** — the agent runs as an external
package/app/contracts.ts CHANGED
@@ -334,6 +334,15 @@ export const WIRE_CONTRACTS = {
334
334
  "Op-tagged relay control frame a worker terminal chunk producer emits and the hub consumes. The op-tagged shape superseded the legacy positional `{stream, offset, chunk}` frame (nano-ide #234/#236); a producer must emit the op-tagged shape or the hub rejects it as `malformed relay message payload`.",
335
335
  shape: '{ op: "produce", incarnation: number, stream: string, offset: number, chunk: string }',
336
336
  },
337
+ "io.nanobpm.agentTask.repository": {
338
+ category: "wire",
339
+ name: "io.nanobpm.agentTask.repository",
340
+ owner: "app/service.ts",
341
+ semantics:
342
+ "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`) and the c8ctl worker harness consumes to provision an isolated clone on the PR head branch. Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
343
+ shape:
344
+ '{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string }',
345
+ },
337
346
  } as const satisfies Record<string, WireContract>;
338
347
 
339
348
  export const TYPE_CONTRACTS = {
@@ -345,6 +354,14 @@ export const TYPE_CONTRACTS = {
345
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.",
346
355
  module: "app/blackboard.ts",
347
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
+ },
348
365
  } as const satisfies Record<string, TypeContract>;
349
366
 
350
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
- * `searchUserTasks`: the completable user-task key the pages drive an attributed answer against.
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 `searchUserTasks`, so relying on it would
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 `searchUserTasks`:
244
- * the completable user-task key the pages drive an attributed acknowledgement against. */
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 fake engine whose open user tasks are keyed by processInstanceKey (the only field
53
- * pollFeatureBlocked queries on). */
54
- function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
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
- Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
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 fake engine whose open user tasks are keyed by processInstanceKey (the only field
54
- * pollFeatureEscalations queries on). */
55
- function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
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
- Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
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
+ });
package/app/github.ts CHANGED
@@ -406,6 +406,10 @@ export interface PrMeta {
406
406
  * workspace checkout (`io.nanobpm.agentTask.repository.ref`) so the review agent lands on the
407
407
  * PR branch instead of the worker's launch directory. `null` when GitHub doesn't return it. */
408
408
  headRef: string | null;
409
+ /** The PR's base branch name (e.g. `main`). Emitted in the repository envelope so the c8ctl
410
+ * harness fetches the base tip alongside the single-branch head clone, keeping `git diff
411
+ * origin/<base>...HEAD` (the review 3-dot diff) computable. `null` when GitHub doesn't return it. */
412
+ baseRef: string | null;
409
413
  }
410
414
 
411
415
  export async function fetchPrMeta(
@@ -414,10 +418,10 @@ export async function fetchPrMeta(
414
418
  token: string,
415
419
  ): Promise<PrMeta | null> {
416
420
  if (await useGh()) {
417
- const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName"]);
421
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName,baseRefName"]);
418
422
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
419
- const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null };
420
- return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null };
423
+ const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null; baseRefName?: string | null };
424
+ return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null, baseRef: j.baseRefName ?? null };
421
425
  }
422
426
  if (!token) return null;
423
427
  const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
@@ -425,8 +429,8 @@ export async function fetchPrMeta(
425
429
  });
426
430
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
427
431
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
428
- const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null } };
429
- return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
432
+ const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null }; base?: { ref?: string | null } };
433
+ return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null, baseRef: j.base?.ref ?? null };
430
434
  }
431
435
 
432
436
  /** Fetch an issue's title via the configured transport, mirroring `fetchPrMeta` (both `gh` and
@@ -666,12 +670,12 @@ export async function fetchPrHead(
666
670
  repo: string,
667
671
  number: number | string,
668
672
  token: string,
669
- ): Promise<{ headRef: string | null; headSha: string | null } | null> {
673
+ ): Promise<{ headRef: string | null; headSha: string | null; baseRef: string | null } | null> {
670
674
  if (await useGh()) {
671
- const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid"]);
675
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid,baseRefName"]);
672
676
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
673
- const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null };
674
- return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null };
677
+ const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null; baseRefName?: string | null };
678
+ return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null, baseRef: j.baseRefName ?? null };
675
679
  }
676
680
  if (!token) return null;
677
681
  const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
@@ -679,8 +683,8 @@ export async function fetchPrHead(
679
683
  });
680
684
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
681
685
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
682
- const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null } };
683
- return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null };
686
+ const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null }; base?: { ref?: string | null } };
687
+ return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null, baseRef: j.base?.ref ?? null };
684
688
  }
685
689
 
686
690
  /** The PR's current base branch ref — the branch this PR would land *into*. `null` when no
@@ -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 fake engine whose open user tasks are keyed by processInstanceKey (the only field the poller
55
- * queries on for plan / PR instances). */
56
- function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
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
- Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
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
+ });
@@ -439,11 +439,26 @@ test("startMerge reads root_request_key off the PR row onto the merge instance",
439
439
  // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
440
440
  // falls back to the legacy launch-dir behavior instead of cloning the wrong default branch).
441
441
  test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch", () => {
442
- const vars = repoEnvelopeVars("owner/repo", "feat/issue-12");
442
+ const vars = repoEnvelopeVars("owner/repo", "feat/issue-12", "main");
443
443
  const env = (vars as any)["io.nanobpm.agentTask"];
444
444
  assertEquals(env.repository.url, "https://github.com/owner/repo.git");
445
445
  assertEquals(env.repository.ref, "feat/issue-12");
446
446
  assertEquals(env.repository.provider, "github");
447
+ // Branch-scoped, blobless partial clone (issue #287): large monorepos provision within the clone
448
+ // timeout while the full commit graph is kept so `git diff origin/<base>...HEAD` has a merge-base.
449
+ assertEquals(env.repository.singleBranch, true);
450
+ assertEquals(env.repository.filter, "blob:none");
451
+ // The base branch is emitted so the harness fetches its tip, keeping `origin/<base>` reachable.
452
+ assertEquals(env.repository.baseRef, "main");
453
+ });
454
+
455
+ test("repoEnvelopeVars omits baseRef when the base branch is unresolved", () => {
456
+ const env = (repoEnvelopeVars("owner/repo", "feat/issue-12") as any)["io.nanobpm.agentTask"];
457
+ // The single-branch/blobless partial-clone request still stands without a base ref…
458
+ assertEquals(env.repository.singleBranch, true);
459
+ assertEquals(env.repository.filter, "blob:none");
460
+ // …but `baseRef` is omitted entirely rather than emitted as null (no key at all).
461
+ assertEquals("baseRef" in env.repository, false);
447
462
  });
448
463
 
449
464
  test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
package/app/service.ts CHANGED
@@ -323,8 +323,18 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
323
323
  * a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
324
324
  * is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
325
325
  * launch-dir behavior rather than silently cloning the repo's default branch. The static
326
- * `task.prompt` header on the service task deep-merges with this over the same namespace. */
327
- export function repoEnvelopeVars(repo: string, ref: string | null): Record<string, unknown> {
326
+ * `task.prompt` header on the service task deep-merges with this over the same namespace.
327
+ *
328
+ * The clone is requested **branch-scoped and blobless** (`singleBranch: true` + `filter:
329
+ * "blob:none"`) so large monorepos (e.g. `camunda/camunda`, ~1.16 GB) provision within the c8ctl
330
+ * clone timeout instead of full-cloning the whole history (issue #287). `blob:none` is a *blobless*
331
+ * partial clone (trees are still fetched up-front — a *treeless* clone would be `--filter=tree:0`); it
332
+ * keeps the full *commit graph* (so `git merge-base` / the review 3-dot diff stays correct) while
333
+ * fetching file blobs lazily — small upfront, correct diffs. `--depth 1` is deliberately NOT used:
334
+ * it would drop the merge-base and break `git diff origin/<base>...HEAD`. When the PR base branch
335
+ * is known we also emit `baseRef` so the harness fetches the base tip alongside the head, keeping
336
+ * that base reachable for the diff. */
337
+ export function repoEnvelopeVars(repo: string, ref: string | null, baseRef: string | null = null): Record<string, unknown> {
328
338
  if (!ref) return {};
329
339
  // Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
330
340
  // `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
@@ -336,7 +346,20 @@ export function repoEnvelopeVars(repo: string, ref: string | null): Record<strin
336
346
  if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
337
347
  return {
338
348
  [AGENT_TASK_NS]: {
339
- repository: { provider: "github", url: `https://github.com/${repo}.git`, ref },
349
+ repository: {
350
+ provider: "github",
351
+ url: `https://github.com/${repo}.git`,
352
+ ref,
353
+ // Branch-scoped, blobless partial clone (issue #287): fetch only the head branch with lazy
354
+ // blobs so large monorepos provision within the clone timeout. Single-branch + blob:none
355
+ // (not --depth 1) preserves the commit graph so the review's `git diff origin/<base>...HEAD`
356
+ // has a valid merge-base. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).
357
+ singleBranch: true,
358
+ filter: "blob:none",
359
+ // The base branch this PR targets — emitted so the harness fetches its tip alongside the
360
+ // single-branch head, keeping `origin/<base>` reachable for the diff. Omitted when unknown.
361
+ ...(baseRef ? { baseRef } : {}),
362
+ },
340
363
  },
341
364
  };
342
365
  }
@@ -364,12 +387,14 @@ export async function submitPr(
364
387
  const token = process.env.GITHUB_TOKEN ?? "";
365
388
  let title: string | null = null;
366
389
  let headRef: string | null = null;
390
+ let baseRef: string | null = null;
367
391
  const depKeys = new Set(dependsOn.map((d) => parsePr(d)?.prKey).filter((k): k is string => !!k));
368
392
  try {
369
393
  const meta = await fetchPrMeta(parsed.repo, parsed.number, token);
370
394
  if (meta) {
371
395
  title = meta.title;
372
396
  headRef = meta.headRef;
397
+ baseRef = meta.baseRef;
373
398
  for (const k of parseDependsOn(meta.body)) depKeys.add(k);
374
399
  }
375
400
  } catch (err) {
@@ -468,7 +493,7 @@ export async function submitPr(
468
493
  // Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
469
494
  // harness clones an isolated workspace checked out on the PR head branch. Spread last so an
470
495
  // unresolved head (`{}`) leaves the other vars untouched.
471
- ...repoEnvelopeVars(parsed.repo, headRef),
496
+ ...repoEnvelopeVars(parsed.repo, headRef, baseRef),
472
497
  },
473
498
  });
474
499
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -504,8 +529,11 @@ export async function startMerge(
504
529
  // means the envelope is omitted and the agent falls back to the worker's launch dir.
505
530
  const token = process.env.GITHUB_TOKEN ?? "";
506
531
  let headRef: string | null = null;
532
+ let baseRef: string | null = null;
507
533
  try {
508
- headRef = (await fetchPrHead(pr.repo, pr.number, token))?.headRef ?? null;
534
+ const head = await fetchPrHead(pr.repo, pr.number, token);
535
+ headRef = head?.headRef ?? null;
536
+ baseRef = head?.baseRef ?? null;
509
537
  } catch (err) {
510
538
  console.warn(`[startMerge] ${pr.prKey} head branch fetch: ${err}`);
511
539
  }
@@ -531,7 +559,7 @@ export async function startMerge(
531
559
  abandonBrief: renderAbandonBrief(abUrl),
532
560
  // Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
533
561
  // fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
534
- ...repoEnvelopeVars(pr.repo, headRef),
562
+ ...repoEnvelopeVars(pr.repo, headRef, baseRef),
535
563
  },
536
564
  });
537
565
  if (processInstanceKey != null) {
@@ -1382,7 +1410,7 @@ export async function pollFeatureEscalations(data: DataLayer, engine: EngineClie
1382
1410
  for (const run of candidates) {
1383
1411
  if (!run.process_key) continue;
1384
1412
  try {
1385
- const tasks = await engine.searchUserTasks({ processInstanceKey: run.process_key });
1413
+ const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1386
1414
  const task = tasks.find((t) => t.elementId === FEATURE_ESCALATION_ELEMENT);
1387
1415
  const parked = task ? { userTaskKey: task.userTaskKey } : null;
1388
1416
  const patch = deriveFeatureEscalationPatch(run, parked);
@@ -1416,7 +1444,7 @@ export async function pollFeatureBlocked(data: DataLayer, engine: EngineClient)
1416
1444
  for (const run of await featureRuns(data).find({ status: "awaiting_operator" })) {
1417
1445
  if (!run.process_key) continue;
1418
1446
  try {
1419
- const tasks = await engine.searchUserTasks({ processInstanceKey: run.process_key });
1447
+ const tasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
1420
1448
  const task = tasks.find((t) => t.elementId === FEATURE_BLOCKED_ELEMENT);
1421
1449
  const parked = task ? { userTaskKey: task.userTaskKey } : null;
1422
1450
  const patch = deriveFeatureBlockedPatch(run, parked);
@@ -1549,7 +1577,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1549
1577
  planSeen.add(plan.plan_key);
1550
1578
  let tasks: { userTaskKey: string; elementId?: string }[];
1551
1579
  try {
1552
- tasks = await engine.searchUserTasks({ processInstanceKey: plan.process_key });
1580
+ tasks = await engine.openUserTasks({ processInstanceKey: plan.process_key });
1553
1581
  } catch (err) {
1554
1582
  console.error(`[poller] user tasks (plan ${plan.plan_key}): ${err}`);
1555
1583
  continue;
@@ -1604,7 +1632,7 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1604
1632
  prSeen.add(pr.pr_key);
1605
1633
  let tasks: { userTaskKey: string; elementId?: string }[];
1606
1634
  try {
1607
- tasks = await engine.searchUserTasks({ processInstanceKey: pr.process_key });
1635
+ tasks = await engine.openUserTasks({ processInstanceKey: pr.process_key });
1608
1636
  } catch (err) {
1609
1637
  console.error(`[poller] user tasks (pr ${pr.pr_key}): ${err}`);
1610
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.87.0",
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",
@@ -67,6 +67,7 @@
67
67
  "columns": [
68
68
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "pr_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
69
69
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
70
+ { "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
70
71
  { "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
71
72
  { "field": "updated_at", "header": "Updated", "width": "9rem" }
72
73
  ]