@nanobpm/nano-workforce 0.43.0 → 0.44.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.
@@ -51,3 +51,10 @@ jobs:
51
51
  # TypeScript types on the fly (Node >= 22.6) — no build step.
52
52
  - name: Test (Node)
53
53
  run: npm test
54
+
55
+ # End-to-end pilot (nano-ide issue #157, S3): boots the whole app in-process against the WASM
56
+ # engine + a virtual clock via @nanobpm/urban-testkit and drives the real /app/api operations.
57
+ # Hermetic (no socket, no GitHub network), so it runs on every push like the unit suite.
58
+ - name: E2E (urban-testkit)
59
+ run: npm run e2e
60
+
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.44.1](https://github.com/nanobpm/nano-workforce/compare/v0.44.0...v0.44.1) (2026-08-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * provision isolated agent workspaces via the c8ctl repository envelope ([#124](https://github.com/nanobpm/nano-workforce/issues/124)) ([79c999b](https://github.com/nanobpm/nano-workforce/commit/79c999bc34dd7984ceffd7746a4538705565f2a5))
7
+
8
+ # [0.44.0](https://github.com/nanobpm/nano-workforce/compare/v0.43.0...v0.44.0) (2026-08-11)
9
+
10
+
11
+ ### Features
12
+
13
+ * **merge-loop:** wait on a blocking PR instead of escalating to a human ([#122](https://github.com/nanobpm/nano-workforce/issues/122)) ([aec738f](https://github.com/nanobpm/nano-workforce/commit/aec738f165df5a216b9094b4eb9b5b6d4a7ac419))
14
+
1
15
  # [0.43.0](https://github.com/nanobpm/nano-workforce/compare/v0.42.0...v0.43.0) (2026-08-11)
2
16
 
3
17
 
package/SPEC.md CHANGED
@@ -173,7 +173,7 @@ triaging, editing/replying/pushing, and (when `addressed`) re-requesting review.
173
173
  Workspace isolation is the **worker harness's** responsibility, not this app's and
174
174
  not the prompt's. The `c8ctl nano work` host-git provisioning (frozen v1 envelope)
175
175
  gives **each job its own `mkdtemp` run-dir + fresh clone**, runs the agent with
176
- `cwd` set to it (`AGENT_WORKSPACE`/`REPO_URL`/`REPO_BRANCH`/`REPO_REF` env), and
176
+ `cwd` set to it (`AGENT_WORKSPACE`/`AGENT_REPO_URL`/`AGENT_REPO_BRANCH`/`AGENT_REPO_REF` env), and
177
177
  **reaps that run-dir when the job ends**. So multiple agents on one host do **not**
178
178
  collide even in host mode — the isolation lives below the agent.
179
179
 
@@ -183,8 +183,13 @@ Consequences the prompt (`prompts/review-round.md`) encodes:
183
183
  - The agent **cleans up anything it creates outside the commit** before returning
184
184
  (worktrees, scratch branches/clones, temp files), so host mode does not leak.
185
185
  - The harness checks out the PR's **existing head branch** and pushes back to it
186
- (no new branch/PR). The `c8ctl` integration provisions the repo and resolves the
187
- head branch from `prNumber`/`prUrl` the app does not pass a `headBranch` var.
186
+ (no new branch/PR). Provisioning only fires when the job carries a
187
+ `io.nanobpm.agentTask.repository.url`; the **app** supplies it plus the head
188
+ branch as `…repository.ref` — as a process variable at `createInstance`
189
+ (`repoEnvelopeVars` in `app/service.ts`, resolving the head via `fetchPrMeta`/
190
+ `fetchPrHead`). The harness is PR-agnostic: it does **not** derive the head branch
191
+ from `prNumber`/`prUrl`. When the head can't be resolved the envelope is omitted and
192
+ the agent falls back to the worker's launch directory (the legacy behavior).
188
193
 
189
194
  ## 6. Signals
190
195
 
@@ -368,11 +373,21 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
368
373
  exhausted, the agent reports `blocked`, or the branch is in `conflict` does it fall
369
374
  through to the human escalation path.
370
375
 
376
+ - **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
377
+ the PR cannot land because **another PR must merge first** (a required linked-issue
378
+ gate a sibling PR will close, a stacked base PR, or a `Depends-on:` the agent read
379
+ from the PR/issue text). That is an ordering **wait, not a human decision**: the
380
+ agent returns `status: "waiting-on-pr"` with a `dependsOn` list of `owner/repo#N`
381
+ refs. `pr.record-dependency` appends those edges to `pr_dependencies` and parks the
382
+ PR back at *wait: deps merged*, so the same poller pass lands it automatically once
383
+ the named PRs merge — no escalation is opened.
384
+
371
385
  - **Dependencies** — `pr_dependencies(pr_key, depends_on_key)` (migration 004).
372
- Declared two ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
373
- submit) and/or a `dependsOn` array on the submit request. `merge-loop` parks at
374
- *wait: deps merged*; the poller checks each dependency (own tracked row first,
375
- else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
386
+ Declared three ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
387
+ submit), a `dependsOn` array on the submit request, and/or **discovered at merge
388
+ time** by a `fix-ci`/`rebase` agent (`status: "waiting-on-pr"`, above). `merge-loop`
389
+ parks at *wait: deps merged*; the poller checks each dependency (own tracked row
390
+ first, else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
376
391
  - **Mergeability** — the poller classifies GitHub's `mergeStateStatus`:
377
392
  `CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND` → `ready`; `DIRTY` → `conflict`;
378
393
  `BLOCKED` → `blocked` if a required check is failing, else keep waiting;
@@ -500,9 +515,10 @@ the loop runs one parallel `implement` MI fan-out per wave:
500
515
 
501
516
  - **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
502
517
  integration provisions the repo and checks out the PR's head branch (it must
503
- already give the worker repo access to work at all), resolving the branch from
504
- `prNumber`/`prUrl`. The app does **not** pass a `headBranch` job variable; the
505
- job stays engine-shaped and the worker stays a pure provisioner.
518
+ already give the worker repo access to work at all). The **app** resolves the head
519
+ branch and passes it in the `io.nanobpm.agentTask.repository.{url,ref}` envelope
520
+ (a `createInstance` process variable see `repoEnvelopeVars`); the harness is
521
+ PR-agnostic and provisions from that envelope. The worker stays a pure provisioner.
506
522
  - **review-ready via GitHub webhook** — same message, swappable faster trigger,
507
523
  when the app is publicly reachable. Deferred (poller-only for v1).
508
524
  - **Supervised vs external worker** — the agent runs as an external
package/app/github.ts CHANGED
@@ -177,6 +177,10 @@ async function useGh(): Promise<boolean> {
177
177
  export interface PrMeta {
178
178
  title: string | null;
179
179
  body: string;
180
+ /** The PR's head branch name (e.g. `feat/issue-12`). Drives the c8ctl harness's isolated
181
+ * workspace checkout (`io.nanobpm.agentTask.repository.ref`) so the review agent lands on the
182
+ * PR branch instead of the worker's launch directory. `null` when GitHub doesn't return it. */
183
+ headRef: string | null;
180
184
  }
181
185
 
182
186
  export async function fetchPrMeta(
@@ -185,10 +189,10 @@ export async function fetchPrMeta(
185
189
  token: string,
186
190
  ): Promise<PrMeta | null> {
187
191
  if (await useGh()) {
188
- const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body"]);
192
+ const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName"]);
189
193
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
190
- const j = JSON.parse(out) as { title?: string; body?: string };
191
- return { title: j.title ?? null, body: j.body ?? "" };
194
+ const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null };
195
+ return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null };
192
196
  }
193
197
  if (!token) return null;
194
198
  const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
@@ -196,8 +200,8 @@ export async function fetchPrMeta(
196
200
  });
197
201
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
198
202
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
199
- const j = (await r.json()) as { title?: string; body?: string };
200
- return { title: j.title ?? null, body: j.body ?? "" };
203
+ const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null } };
204
+ return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
201
205
  }
202
206
 
203
207
  /** A PR's merge state, narrowed to what the merge poller needs to classify landability.
@@ -57,6 +57,18 @@ test("escalation arm without the flag still records the round", async () => {
57
57
  assertEquals((inserts.rounds[0] as any).round_no, 3);
58
58
  });
59
59
 
60
+ // The servicing worker name (harness `agent` var) is stamped on both the round it recorded and
61
+ // the escalation it opened, so the durable history identifies who did the work.
62
+ test("persist-escalation records the servicing worker on the round and escalation", async () => {
63
+ const { app, inserts } = fakeApp();
64
+ const job = {
65
+ variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds", agent: "senior" },
66
+ };
67
+ await handler(job as any, app as any);
68
+ assertEquals((inserts.rounds[0] as any).worker, "senior", "the round carries the worker name");
69
+ assertEquals((inserts.escalations[0] as any).worker, "senior", "the escalation carries the worker name");
70
+ });
71
+
60
72
  // When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
61
73
  // desync), persist-escalation reconstructs the `pull_requests` row before the rounds/escalations
62
74
  // inserts so opening an escalation never dies with an opaque FOREIGN KEY constraint failure.
@@ -56,6 +56,23 @@ for (const status of ["addressed", "waiting"]) {
56
56
  });
57
57
  }
58
58
 
59
+ // The harness completes each agent job with `agent` (its profile name); persist-round records it
60
+ // on the round so a human can identify the servicing worker from the durable history.
61
+ test("persist-round records the servicing worker name from the agent variable", async () => {
62
+ const { app, inserts } = fakeApp();
63
+ const job = { variables: { prKey: "o/r#1", round: 1, status: "addressed", agent: "senior" } };
64
+ await handler(job as any, app as any);
65
+ assertEquals((inserts.rounds[0] as any).worker, "senior", "the round carries the worker name");
66
+ });
67
+
68
+ // A blank/absent agent name leaves the nullable column NULL (the write boundary omits undefined).
69
+ test("persist-round leaves worker undefined when the agent name is blank", async () => {
70
+ const { app, inserts } = fakeApp();
71
+ const job = { variables: { prKey: "o/r#1", round: 1, status: "addressed", agent: " " } };
72
+ await handler(job as any, app as any);
73
+ assertEquals((inserts.rounds[0] as any).worker, undefined, "blank worker -> NULL column");
74
+ });
75
+
59
76
  // When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
60
77
  // desync), persist-round reconstructs the `pull_requests` row before recording the round so the
61
78
  // insert never dies with an opaque FOREIGN KEY constraint failure.
@@ -7,7 +7,7 @@
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
- import { pollIncidentsImpl, submitPr } from "./service.ts";
10
+ import { pollIncidentsImpl, repoEnvelopeVars, submitPr } from "./service.ts";
11
11
 
12
12
  function memTable(rows: any[], key: string) {
13
13
  return {
@@ -331,3 +331,19 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
331
331
  assertEquals(get(), false);
332
332
  });
333
333
  });
334
+
335
+ // The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
336
+ // emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
337
+ // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
338
+ // falls back to the legacy launch-dir behavior instead of cloning the wrong default branch).
339
+ test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch", () => {
340
+ const vars = repoEnvelopeVars("owner/repo", "feat/issue-12");
341
+ const env = (vars as any)["io.nanobpm.agentTask"];
342
+ assertEquals(env.repository.url, "https://github.com/owner/repo.git");
343
+ assertEquals(env.repository.ref, "feat/issue-12");
344
+ assertEquals(env.repository.provider, "github");
345
+ });
346
+
347
+ test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
348
+ assertEquals(Object.keys(repoEnvelopeVars("owner/repo", null)).length, 0);
349
+ });
package/app/service.ts CHANGED
@@ -12,6 +12,7 @@ import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
12
12
  import {
13
13
  classifyMergeability,
14
14
  ensureFreshHeadRun,
15
+ fetchPrHead,
15
16
  fetchPrMeta,
16
17
  fetchPrReviews,
17
18
  fetchPrState,
@@ -277,6 +278,27 @@ async function registerDependencies(data: DataLayer, prKey: string, depKeys: str
277
278
  }
278
279
  }
279
280
 
281
+ /** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
282
+ * (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
283
+ const AGENT_TASK_NS = "io.nanobpm.agentTask";
284
+
285
+ /** Build the repository slice of the agent-task envelope for a PR-based agent job (review-round,
286
+ * fix-ci, rebase). Delivered as a *process variable* under the reserved `io.nanobpm.agentTask`
287
+ * key so the harness provisions an isolated clone checked out on the PR's head branch — instead of
288
+ * the agent inheriting whatever directory the worker was launched from (which only happened to be
289
+ * a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
290
+ * is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
291
+ * launch-dir behavior rather than silently cloning the repo's default branch. The static
292
+ * `task.prompt` header on the service task deep-merges with this over the same namespace. */
293
+ export function repoEnvelopeVars(repo: string, ref: string | null): Record<string, unknown> {
294
+ if (!ref) return {};
295
+ return {
296
+ [AGENT_TASK_NS]: {
297
+ repository: { provider: "github", url: `https://github.com/${repo}.git`, ref },
298
+ },
299
+ };
300
+ }
301
+
280
302
  /** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
281
303
  * `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
282
304
  * recorded as the PR's merge-stage dependency set. */
@@ -298,16 +320,23 @@ export async function submitPr(
298
320
  // A transport failure (no gh/token) must not block submission — we just skip enrichment.
299
321
  const token = process.env.GITHUB_TOKEN ?? "";
300
322
  let title: string | null = null;
323
+ let headRef: string | null = null;
301
324
  const depKeys = new Set(dependsOn.map((d) => parsePr(d)?.prKey).filter((k): k is string => !!k));
302
325
  try {
303
326
  const meta = await fetchPrMeta(parsed.repo, parsed.number, token);
304
327
  if (meta) {
305
328
  title = meta.title;
329
+ headRef = meta.headRef;
306
330
  for (const k of parseDependsOn(meta.body)) depKeys.add(k);
307
331
  }
308
332
  } catch (err) {
309
333
  console.warn(`[submit] ${parsed.prKey} meta fetch: ${err}`);
310
334
  }
335
+ if (!headRef) {
336
+ // Without the head branch the harness can't check out the PR; the review agent then falls
337
+ // back to the worker's launch dir (the legacy behavior) and escalates if it isn't a checkout.
338
+ console.warn(`[submit] ${parsed.prKey} head branch unresolved — agent workspace won't be provisioned`);
339
+ }
311
340
  await registerDependencies(data, parsed.prKey, [...depKeys]);
312
341
 
313
342
  const ts = now();
@@ -375,6 +404,10 @@ export async function submitPr(
375
404
  // review-round agent's prompt, so it can stop before pushing if the run is cancelled.
376
405
  abandonUrl: abUrl,
377
406
  abandonBrief: renderAbandonBrief(abUrl),
407
+ // Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
408
+ // harness clones an isolated workspace checked out on the PR head branch. Spread last so an
409
+ // unresolved head (`{}`) leaves the other vars untouched.
410
+ ...repoEnvelopeVars(parsed.repo, headRef),
378
411
  },
379
412
  });
380
413
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -400,6 +433,19 @@ export async function startMerge(
400
433
  await prs(data).update(pr.prKey, { abandon_token: abandonToken, updated_at: now() });
401
434
  }
402
435
  const abUrl = abandonUrl(abandonToken);
436
+ // Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
437
+ // out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
438
+ // means the envelope is omitted and the agent falls back to the worker's launch dir.
439
+ const token = process.env.GITHUB_TOKEN ?? "";
440
+ let headRef: string | null = null;
441
+ try {
442
+ headRef = (await fetchPrHead(pr.repo, pr.number, token))?.headRef ?? null;
443
+ } catch (err) {
444
+ console.warn(`[startMerge] ${pr.prKey} head branch fetch: ${err}`);
445
+ }
446
+ if (!headRef) {
447
+ console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
448
+ }
403
449
  const { processInstanceKey } = await engine.createInstance({
404
450
  processDefinitionId: MERGE_PROCESS_ID,
405
451
  variables: {
@@ -414,6 +460,9 @@ export async function startMerge(
414
460
  rebaseMax: MAX_REBASE_ROUNDS,
415
461
  abandonUrl: abUrl,
416
462
  abandonBrief: renderAbandonBrief(abUrl),
463
+ // Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
464
+ // fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
465
+ ...repoEnvelopeVars(pr.repo, headRef),
417
466
  },
418
467
  });
419
468
  if (processInstanceKey != null) {
@@ -0,0 +1,9 @@
1
+ -- Identify the servicing worker on the durable history. The c8ctl harness completes each agent
2
+ -- job with an `agent` variable (its profile name, e.g. `senior`), which propagates to the
3
+ -- downstream `pr.persist-round` / `pr.persist-escalation` jobs. Recording it next to the round /
4
+ -- escalation the agent produced lets a human tell *which* worker did the work when reading the
5
+ -- transcript — without cross-referencing the transient `pull_requests.active_worker` lease (which
6
+ -- is cleared once the agent finishes).
7
+
8
+ ALTER TABLE rounds ADD COLUMN worker TEXT;
9
+ ALTER TABLE escalations ADD COLUMN worker TEXT;
@@ -0,0 +1,175 @@
1
+ // End-to-end pilot for @nanobpm/urban-testkit (nano-ide issue #157, slice S3).
2
+ //
3
+ // Boots this whole Urban app in-process against the WASM engine and a virtual clock via
4
+ // `bootTestApp`, then drives its real ADR-0059 OpenAPI operations by `operationId` — the same
5
+ // spec-driven `/app/api/*` surface a browser, a CI relay, or Swagger hit in production. No socket
6
+ // is opened, no wall-clock is waited on, and no GitHub network is touched.
7
+ //
8
+ // Network isolation: the app's GitHub transport (app/github.ts) is forced to `token` mode with no
9
+ // token, so every best-effort GitHub read short-circuits to `null`/idle instead of reaching out.
10
+ // That keeps the pilot hermetic and deterministic in CI.
11
+ //
12
+ // Run with `npm run e2e` (a dedicated node:test invocation, kept out of the fast unit `npm test`).
13
+
14
+ import assert from "node:assert/strict";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import { mkdtempSync, rmSync, readFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { after, before, describe, test } from "node:test";
20
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
21
+
22
+ // The app root is this repo's root (one level up from `e2e/`) — where nano.app.json + openapi.yaml
23
+ // + db/migrations + resources/processes live.
24
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
25
+
26
+ // Provision the app's SQLite in a throwaway temp dir so the pilot never touches (or leaks into) the
27
+ // repo's real ./app.db, and every run starts from a freshly-migrated, empty schema.
28
+ const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-e2e-"));
29
+
30
+ // Derive the reconciler's poll interval from the app manifest (its single source of truth) rather
31
+ // than hard-coding it, so this test tracks nano.app.json instead of duplicating the value: a change
32
+ // to `pollMs` there stays correct here. Read the `pull_requests` instanceTracking entry's pollMs.
33
+ interface InstanceTrackingEntry {
34
+ table: string;
35
+ pollMs: number;
36
+ }
37
+ interface AppManifest {
38
+ instanceTracking?: InstanceTrackingEntry[];
39
+ }
40
+ const APP_MANIFEST: AppManifest = JSON.parse(
41
+ readFileSync(join(APP_ROOT, "nano.app.json"), "utf8"),
42
+ );
43
+ const PR_TRACKING = APP_MANIFEST.instanceTracking?.find((e) => e.table === "pull_requests");
44
+ assert.ok(PR_TRACKING, "nano.app.json declares a pull_requests instanceTracking entry");
45
+ const PR_POLL_MS = PR_TRACKING.pollMs;
46
+
47
+ // Force the app fully offline. github.ts reads `process.env` directly (not the harness env overlay),
48
+ // so seal the GitHub transport on process.env: `token` mode with no GITHUB_TOKEN means every
49
+ // best-effort GitHub read in submitPr short-circuits to null instead of shelling out to `gh`/fetch.
50
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
51
+ NANO_PR_GITHUB_TRANSPORT: "token",
52
+ GITHUB_TOKEN: "",
53
+ };
54
+ const savedEnv = new Map<string, string | undefined>();
55
+
56
+ // The harness `env` overlay drives the runtime's `${NANO_APP_DB_URL}` resolution — provision the
57
+ // app's SQLite in a throwaway temp dir so the pilot never touches (or leaks into) the repo's real
58
+ // ./app.db, and every run starts from a freshly-migrated, empty schema.
59
+ const HARNESS_ENV = {
60
+ NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`,
61
+ } as const;
62
+
63
+ describe("nano-workforce e2e (urban-testkit pilot)", () => {
64
+ let app: TestApp;
65
+
66
+ before(async () => {
67
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
68
+ savedEnv.set(k, process.env[k]);
69
+ process.env[k] = v;
70
+ }
71
+ app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV });
72
+ // This app declares an `api` binding, so the spec-driven driver must be present.
73
+ assert.ok(app.api, "app.api driver should be defined (nano.app.json declares an `api` binding)");
74
+ });
75
+
76
+ after(async () => {
77
+ await app?.stop();
78
+ for (const [k, v] of savedEnv) {
79
+ if (v === undefined) delete process.env[k];
80
+ else process.env[k] = v;
81
+ }
82
+ rmSync(DB_DIR, { recursive: true, force: true });
83
+ });
84
+
85
+ test("drives the blackboard operations round-trip through the OpenAPI driver", async () => {
86
+ const api = app.api;
87
+ assert.ok(api);
88
+
89
+ // Seed a plan with a capability token — the credential the blackboard operations authorize on.
90
+ const token = "pilot-blackboard-token";
91
+ const planKey = "acme/widgets#7";
92
+ const nowIso = new Date(app.now()).toISOString();
93
+ await app.db.table("plans", "plan_key").insert({
94
+ plan_key: planKey,
95
+ repo: "acme/widgets",
96
+ issue_number: 7,
97
+ issue_url: "https://github.com/acme/widgets/issues/7",
98
+ title: "Pilot plan",
99
+ status: "planning",
100
+ task_count: 0,
101
+ blackboard_token: token,
102
+ created_at: nowIso,
103
+ updated_at: nowIso,
104
+ });
105
+
106
+ // POST an entry via the `appendBlackboard` operation (operationId → /app/api/hooks/blackboard).
107
+ const appended = await api.call<{ id: number; inserted: boolean }>("appendBlackboard", {
108
+ query: { token },
109
+ body: { author_task: "t1", kind: "note", body: "hello from the pilot" },
110
+ });
111
+ assert.equal(appended.status, 201, "append returns 201 Created");
112
+ assert.equal(appended.body.inserted, true, "entry was inserted");
113
+ assert.ok(Number.isFinite(appended.body.id), "append returns a numeric entry id");
114
+
115
+ // GET it back via `readBlackboard` — the entry the POST just wrote must be visible.
116
+ const read = await api.call<{ planKey: string; entries: Array<{ id: number; body: string }> }>(
117
+ "readBlackboard",
118
+ { query: { token } },
119
+ );
120
+ assert.equal(read.status, 200, "read returns 200 OK");
121
+ assert.equal(read.body.planKey, planKey, "read is scoped to the seeded plan");
122
+ assert.equal(read.body.entries.length, 1, "exactly the one appended entry is returned");
123
+ assert.equal(read.body.entries[0].body, "hello from the pilot", "round-tripped body matches");
124
+ assert.equal(read.body.entries[0].id, appended.body.id, "read id matches the appended id");
125
+
126
+ // An unknown token is a 404 (never leaks which plans exist).
127
+ const unknown = await api.call("readBlackboard", { query: { token: "nope" } });
128
+ assert.equal(unknown.status, 404, "an unknown token is a 404, not a leak");
129
+ });
130
+
131
+ test("starts the convergence loop and reconciles its tracking row when terminated", async () => {
132
+ const api = app.api;
133
+ assert.ok(api);
134
+
135
+ const prKey = "acme/widgets#42";
136
+ // POST the real production door for starting a review: `startConvergenceLoop`. `convergeOnly`
137
+ // keeps the run off the merge-loop; the offline env keeps `submitPr`'s best-effort GitHub
138
+ // enrichment from touching the network.
139
+ const started = await api.call<{ prKey: string }>("startConvergenceLoop", {
140
+ body: { pr: prKey, convergeOnly: true },
141
+ });
142
+ assert.equal(started.status, 202, "start returns 202 Accepted");
143
+ assert.equal(started.body.prKey, prKey, "the response echoes the parsed PR key");
144
+
145
+ // The operation registered the PR aggregate (instanceTracking table) and started a real engine
146
+ // instance — synchronously, before any worker ran (we never settled).
147
+ const prs = app.db.table<{ pr_key: string; status: string; process_key: string | null }>(
148
+ "pull_requests",
149
+ "pr_key",
150
+ );
151
+ const row = await prs.findOne({ pr_key: prKey });
152
+ assert.ok(row, "a pull_requests row was registered");
153
+ assert.equal(row?.status, "converging", "the PR is tracked as actively converging");
154
+ assert.ok(row?.process_key, "the row carries the engine process-instance key");
155
+
156
+ const processInstanceKey = row!.process_key!;
157
+ const before = await app.engine.searchProcessInstances({
158
+ processInstanceKeys: [processInstanceKey],
159
+ });
160
+ assert.equal(before.length, 1, "the engine has exactly one instance for this PR");
161
+
162
+ // Terminate the instance out-of-band (the class of event the reconciler exists to catch — a
163
+ // PR merged or cancelled independently of the loop). The row is still `converging` until a poll.
164
+ await app.engine.cancelInstance({ processInstanceKey });
165
+ const stillActive = await prs.findOne({ pr_key: prKey });
166
+ assert.equal(stillActive?.status, "converging", "row not yet reconciled before any poll fires");
167
+
168
+ // Advance past the instanceTracking pollMs (derived from nano.app.json above, plus a margin):
169
+ // the reconciler observes TERMINATED and applies the manifest `onTerminated.set` → status
170
+ // `abandoned`, escalation pointers cleared.
171
+ await app.advanceTime(PR_POLL_MS + 1000);
172
+ const reconciled = await prs.findOne({ pr_key: prKey });
173
+ assert.equal(reconciled?.status, "abandoned", "reconciler abandoned the terminated PR's row");
174
+ });
175
+ });
package/nano.app.json CHANGED
@@ -87,6 +87,10 @@
87
87
  "taskType": "pr.mark-merged",
88
88
  "handler": "workers/mark-merged/worker.ts"
89
89
  },
90
+ {
91
+ "taskType": "pr.record-dependency",
92
+ "handler": "workers/record-dependency/worker.ts"
93
+ },
90
94
  {
91
95
  "taskType": "pr.record-plan",
92
96
  "handler": "workers/record-plan/worker.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.43.0",
3
+ "version": "0.44.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",
@@ -41,14 +41,16 @@
41
41
  "layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
42
42
  "dev": "urban dev",
43
43
  "test": "node --experimental-strip-types --test",
44
- "lint": "biome check app operations workers pages components scripts main.ts",
45
- "lint:fix": "biome check --write app operations workers pages components scripts main.ts"
44
+ "e2e": "node --experimental-strip-types --test \"e2e/**/*.e2e.ts\"",
45
+ "lint": "biome check app operations workers pages components scripts e2e main.ts",
46
+ "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
46
47
  },
47
48
  "dependencies": {
48
49
  "@nanobpm/urban": "^0.42.0"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@biomejs/biome": "^2.4.11",
53
+ "@nanobpm/urban-testkit": "^0.3.0",
52
54
  "@semantic-release/changelog": "^6.0.3",
53
55
  "@semantic-release/git": "^10.0.1",
54
56
  "@semantic-release/npm": "^13.1.5",
@@ -109,6 +109,7 @@
109
109
  "columns": [
110
110
  { "field": "round_no", "header": "#" },
111
111
  { "field": "status", "header": "Result" },
112
+ { "field": "worker", "header": "Worker" },
112
113
  { "field": "summary", "header": "Summary" }
113
114
  ],
114
115
  "lazyField": { "field": "transcript", "label": "Transcript", "lazy": true }
@@ -123,6 +124,7 @@
123
124
  "columns": [
124
125
  { "field": "round_no", "header": "#" },
125
126
  { "field": "kind", "header": "Kind" },
127
+ { "field": "worker", "header": "Worker" },
126
128
  { "field": "question", "header": "Question" },
127
129
  { "field": "status", "header": "Status" },
128
130
  { "field": "answer", "header": "Answer" }
package/prompts/fix-ci.md CHANGED
@@ -16,6 +16,15 @@ protocol with a status URL is appended below: **before you push the fix, curl th
16
16
  `-fsS`) and stop immediately if the check **fails** or reports `"abandoned": true`. Re-check right
17
17
  before the push.
18
18
 
19
+ ## Workspace (host mode) — read this first
20
+
21
+ When the worker harness (e.g. `c8ctl nano work`) provisions a workspace, your **current
22
+ working directory is a fresh, isolated clone of the repo checked out on the PR's head
23
+ branch** — exposed via `AGENT_WORKSPACE`, `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF`.
24
+ When it does, **work only inside `cwd`**, do **not** re-clone, `cd` elsewhere, or add a
25
+ `git worktree`, and do not touch global/host state — other jobs get their own clones. If
26
+ `AGENT_WORKSPACE` is **unset** (no provisioning), check out the PR head branch yourself.
27
+
19
28
  ## Job input (`job.variables`)
20
29
 
21
30
  | var | meaning |
@@ -32,7 +41,8 @@ the PR's checks yourself (`gh pr checks`, `gh run view`).
32
41
 
33
42
  ## What to do
34
43
 
35
- 1. Check out the PR's head branch (it already exists on the remote).
44
+ 1. Check out the PR's head branch (already provisioned as your `cwd` in host mode; else it
45
+ exists on the remote).
36
46
  2. For each failing check, read its logs to find the **root cause** — a real
37
47
  failure (a bug, a broken test, a lint/type error, a missing file). Do **not**
38
48
  paper over it (no `--no-verify`, no disabling the check, no `it.skip`, no
@@ -56,9 +66,20 @@ the PR's checks yourself (`gh pr checks`, `gh run view`).
56
66
  Return a structured result:
57
67
 
58
68
  - `status: "fixed"` — you pushed a fix you believe makes the failing checks pass.
59
- - `status: "blocked"` — you could **not** fix it (e.g. the failure needs a human
60
- decision, a secret, or an upstream change). Set `question` to a concise,
61
- specific description of what is blocking and what a human must decide.
69
+ - `status: "waiting-on-pr"` — the PR cannot merge yet because **another PR must land
70
+ first**, and this is an ordering constraint, not a defect: e.g. the failing check
71
+ is a required linked-issue / "closes #N" gate that a sibling PR will satisfy, the
72
+ PR is stacked on a base PR that has not merged, or the PR body / an issue it
73
+ references says it depends on another PR. This is a **wait, not an escalation** — do
74
+ **not** ask a human to babysit it. Set `dependsOn` to the PR(s) that must merge
75
+ first, as `owner/repo#N` refs (or PR URLs), separated by commas or spaces. The
76
+ process records the dependency and automatically re-attempts the merge once every
77
+ named PR has landed.
78
+ - `status: "blocked"` — you could **not** fix it and it genuinely needs a human
79
+ **decision** (a secret, an upstream change, or a judgement call). Set `question` to a
80
+ concise, specific description of what is blocking and what a human must decide.
81
+ Reserve this for a real decision — if the PR is merely waiting on another PR, use
82
+ `waiting-on-pr` instead so no human is pulled in.
62
83
 
63
84
  Never report `fixed` unless you actually pushed a change. If nothing was wrong on
64
85
  the branch (the failure was transient infrastructure), say so in `summary` and
package/prompts/rebase.md CHANGED
@@ -17,6 +17,15 @@ with a status URL is appended below: **before you push the rebased branch, curl
17
17
  `-fsS`) and stop immediately if the check **fails** or reports `"abandoned": true`. Re-check right
18
18
  before the push.
19
19
 
20
+ ## Workspace (host mode) — read this first
21
+
22
+ When the worker harness (e.g. `c8ctl nano work`) provisions a workspace, your **current
23
+ working directory is a fresh, isolated clone of the repo checked out on the PR's head
24
+ branch** — exposed via `AGENT_WORKSPACE`, `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF`.
25
+ When it does, **work only inside `cwd`**, do **not** re-clone, `cd` elsewhere, or add a
26
+ `git worktree`, and do not touch global/host state — other jobs get their own clones. If
27
+ `AGENT_WORKSPACE` is **unset** (no provisioning), check out the PR head branch yourself.
28
+
20
29
  ## Job input (`job.variables`)
21
30
 
22
31
  | var | meaning |
@@ -29,7 +38,8 @@ before the push.
29
38
 
30
39
  ## What to do
31
40
 
32
- 1. Check out the PR's head branch (it already exists on the remote) and identify
41
+ 1. Check out the PR's head branch (already provisioned as your `cwd` in host mode; else it
42
+ already exists on the remote) and identify
33
43
  the base branch (`gh pr view <prNumber> --repo <repo> --json baseRefName`).
34
44
  2. Update the branch onto the current base. Prefer a **rebase**
35
45
  (`git fetch origin && git rebase origin/<base>`); if the repo's history policy
@@ -67,10 +77,20 @@ Return a structured result:
67
77
  - `status: "rebased"` — the branch tip now contains the latest base: you resolved
68
78
  any conflicts mechanically and pushed, **or** it was already up to date. The
69
79
  process will re-attempt the merge.
80
+ - `status: "waiting-on-pr"` — the PR cannot merge yet because **another PR must land
81
+ first**, and this is an ordering constraint, not a conflict you can resolve: e.g.
82
+ the branch is stacked on a base PR that has not merged, or the PR body / an issue
83
+ it references says it depends on another PR that must close a blocking issue first.
84
+ This is a **wait, not an escalation** — do **not** ask a human to babysit it. Set
85
+ `dependsOn` to the PR(s) that must merge first, as `owner/repo#N` refs (or PR URLs),
86
+ separated by commas or spaces. The process records the dependency and automatically
87
+ re-attempts the merge once every named PR has landed.
70
88
  - `status: "blocked"` — you could **not** resolve it mechanically (a genuine
71
89
  semantic conflict where two changes contradict and a human must decide which
72
90
  behaviour wins, or the branch is un-rebaseable). Set `question` to a concise,
73
91
  specific description of the conflicting intent and the decision a human must make.
92
+ Reserve this for a real decision — if the PR is merely waiting on another PR to land
93
+ first, use `waiting-on-pr` instead so no human is pulled in.
74
94
 
75
95
  Report `rebased` when the branch tip now contains the latest base — either
76
96
  because you pushed a resolved update, or because it was **already up to date**
@@ -31,7 +31,7 @@ cancel can land anytime.
31
31
  The worker harness (e.g. `c8ctl nano work`) has **already provisioned an isolated,
32
32
  per-job workspace for you**: your **current working directory is a fresh clone of
33
33
  the repo, checked out on the PR's head branch**. The harness exposes it via the
34
- `AGENT_WORKSPACE`, `REPO_URL`, `REPO_BRANCH` and `REPO_REF` environment variables,
34
+ `AGENT_WORKSPACE`, `AGENT_REPO_URL`, `AGENT_REPO_BRANCH` and `AGENT_REPO_REF` environment variables,
35
35
  and it **reaps that workspace after the job ends**.
36
36
 
37
37
  Because several agents may run on the same host at once:
@@ -76,6 +76,7 @@
76
76
  <nano:extend name="status" type="string" />
77
77
  <nano:extend name="summary" type="string" optional="true" />
78
78
  <nano:extend name="question" type="string" optional="true" />
79
+ <nano:extend name="dependsOn" type="string" optional="true" />
79
80
  </nano:shape>
80
81
  <nano:shape id="RebaseIn" name="Rebase — input">
81
82
  <nano:extend name="prKey" type="string" />
@@ -88,6 +89,11 @@
88
89
  <nano:extend name="status" type="string" />
89
90
  <nano:extend name="summary" type="string" optional="true" />
90
91
  <nano:extend name="question" type="string" optional="true" />
92
+ <nano:extend name="dependsOn" type="string" optional="true" />
93
+ </nano:shape>
94
+ <nano:shape id="RecordDepIn" name="Record discovered dependency — input">
95
+ <nano:extend name="prKey" type="string" />
96
+ <nano:extend name="dependsOn" type="string" optional="true" />
91
97
  </nano:shape>
92
98
  <nano:shape id="MergeEscalationAnswered" name="escalation-answered message payload">
93
99
  <nano:extend name="answer" type="string" />
@@ -100,6 +106,7 @@
100
106
  </bpmn:startEvent>
101
107
  <bpmn:intermediateCatchEvent id="wait-deps" name="Wait: dependencies merged">
102
108
  <bpmn:incoming>f_m_start</bpmn:incoming>
109
+ <bpmn:incoming>f_dep_rewait</bpmn:incoming>
103
110
  <bpmn:outgoing>f_m_deps</bpmn:outgoing>
104
111
  <bpmn:messageEventDefinition id="med_depsCleared" messageRef="Message_depsCleared" />
105
112
  </bpmn:intermediateCatchEvent>
@@ -237,6 +244,7 @@
237
244
  <bpmn:exclusiveGateway id="gw-ci-result" name="fixed?" default="f_ci_blocked">
238
245
  <bpmn:incoming>f_ci_done</bpmn:incoming>
239
246
  <bpmn:outgoing>f_ci_fixed</bpmn:outgoing>
247
+ <bpmn:outgoing>f_ci_wait</bpmn:outgoing>
240
248
  <bpmn:outgoing>f_ci_blocked</bpmn:outgoing>
241
249
  </bpmn:exclusiveGateway>
242
250
  <bpmn:exclusiveGateway id="gw-rebase" name="auto-rebase?" default="f_reb_giveup">
@@ -265,8 +273,20 @@
265
273
  <bpmn:exclusiveGateway id="gw-rebase-result" name="rebased?" default="f_reb_blocked">
266
274
  <bpmn:incoming>f_reb_done</bpmn:incoming>
267
275
  <bpmn:outgoing>f_reb_rebased</bpmn:outgoing>
276
+ <bpmn:outgoing>f_reb_wait</bpmn:outgoing>
268
277
  <bpmn:outgoing>f_reb_blocked</bpmn:outgoing>
269
278
  </bpmn:exclusiveGateway>
279
+ <bpmn:serviceTask id="record-merge-dep" name="Wait on another PR">
280
+ <bpmn:extensionElements>
281
+ <zeebe:taskDefinition type="pr.record-dependency" />
282
+ <zeebe:properties>
283
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordDepIn" />
284
+ </zeebe:properties>
285
+ </bpmn:extensionElements>
286
+ <bpmn:incoming>f_ci_wait</bpmn:incoming>
287
+ <bpmn:incoming>f_reb_wait</bpmn:incoming>
288
+ <bpmn:outgoing>f_dep_rewait</bpmn:outgoing>
289
+ </bpmn:serviceTask>
270
290
  <bpmn:sequenceFlow id="f_m_start" sourceRef="MergeStart" targetRef="wait-deps" />
271
291
  <bpmn:sequenceFlow id="f_m_deps" sourceRef="wait-deps" targetRef="arm-merge" />
272
292
  <bpmn:sequenceFlow id="f_m_arm" sourceRef="arm-merge" targetRef="wait-mergeable" />
@@ -290,6 +310,9 @@
290
310
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "fixed"</bpmn:conditionExpression>
291
311
  </bpmn:sequenceFlow>
292
312
  <bpmn:sequenceFlow id="f_ci_blocked" name="could not fix" sourceRef="gw-ci-result" targetRef="merge-esc-attempt" />
313
+ <bpmn:sequenceFlow id="f_ci_wait" name="waits on another PR" sourceRef="gw-ci-result" targetRef="record-merge-dep">
314
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
315
+ </bpmn:sequenceFlow>
293
316
  <bpmn:sequenceFlow id="f_reb_go" name="within budget" sourceRef="gw-rebase" targetRef="rebase">
294
317
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=rebaseRound &lt; rebaseMax</bpmn:conditionExpression>
295
318
  </bpmn:sequenceFlow>
@@ -299,6 +322,10 @@
299
322
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "rebased"</bpmn:conditionExpression>
300
323
  </bpmn:sequenceFlow>
301
324
  <bpmn:sequenceFlow id="f_reb_blocked" name="could not resolve" sourceRef="gw-rebase-result" targetRef="merge-esc-attempt" />
325
+ <bpmn:sequenceFlow id="f_reb_wait" name="waits on another PR" sourceRef="gw-rebase-result" targetRef="record-merge-dep">
326
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
327
+ </bpmn:sequenceFlow>
328
+ <bpmn:sequenceFlow id="f_dep_rewait" sourceRef="record-merge-dep" targetRef="wait-deps" />
302
329
  <bpmn:sequenceFlow id="f_m_attempt" sourceRef="attempt-merge" targetRef="gw-merge" />
303
330
  <bpmn:sequenceFlow id="f_m_gMerged" name="merged" sourceRef="gw-merge" targetRef="mark-merged">
304
331
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "merged"</bpmn:conditionExpression>
@@ -327,7 +354,7 @@
327
354
  <bpmndi:BPMNShape id="BPMNShape_wait-deps" bpmnElement="wait-deps">
328
355
  <dc:Bounds x="216" y="102" width="36" height="36" />
329
356
  <bpmndi:BPMNLabel>
330
- <dc:Bounds x="190" y="143" width="88" height="42" />
357
+ <dc:Bounds x="190" y="55" width="88" height="42" />
331
358
  </bpmndi:BPMNLabel>
332
359
  </bpmndi:BPMNShape>
333
360
  <bpmndi:BPMNShape id="BPMNShape_arm-merge" bpmnElement="arm-merge">
@@ -415,14 +442,17 @@
415
442
  </bpmndi:BPMNLabel>
416
443
  </bpmndi:BPMNShape>
417
444
  <bpmndi:BPMNShape id="BPMNShape_rebase" bpmnElement="rebase">
418
- <dc:Bounds x="1038" y="1040" width="100" height="80" />
445
+ <dc:Bounds x="1038" y="1200" width="100" height="80" />
419
446
  </bpmndi:BPMNShape>
420
447
  <bpmndi:BPMNShape id="BPMNShape_gw-rebase-result" bpmnElement="gw-rebase-result" isMarkerVisible="true">
421
- <dc:Bounds x="1238" y="1055" width="50" height="50" />
448
+ <dc:Bounds x="1238" y="1215" width="50" height="50" />
422
449
  <bpmndi:BPMNLabel>
423
- <dc:Bounds x="1233" y="1036" width="60" height="14" />
450
+ <dc:Bounds x="1173" y="1233" width="60" height="14" />
424
451
  </bpmndi:BPMNLabel>
425
452
  </bpmndi:BPMNShape>
453
+ <bpmndi:BPMNShape id="BPMNShape_record-merge-dep" bpmnElement="record-merge-dep">
454
+ <dc:Bounds x="1388" y="1040" width="100" height="80" />
455
+ </bpmndi:BPMNShape>
426
456
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_start" bpmnElement="f_m_start">
427
457
  <di:waypoint x="116" y="120" />
428
458
  <di:waypoint x="216" y="120" />
@@ -473,7 +503,7 @@
473
503
  <di:waypoint x="1438" y="920" />
474
504
  <di:waypoint x="1438" y="640" />
475
505
  <bpmndi:BPMNLabel>
476
- <dc:Bounds x="1319" y="898" width="89" height="14" />
506
+ <dc:Bounds x="1319" y="928" width="89" height="14" />
477
507
  </bpmndi:BPMNLabel>
478
508
  </bpmndi:BPMNEdge>
479
509
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_giveup" bpmnElement="f_reb_giveup">
@@ -485,11 +515,12 @@
485
515
  </bpmndi:BPMNLabel>
486
516
  </bpmndi:BPMNEdge>
487
517
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
488
- <di:waypoint x="1288" y="1080" />
489
- <di:waypoint x="1438" y="1080" />
518
+ <di:waypoint x="1263" y="1215" />
519
+ <di:waypoint x="1263" y="1020" />
520
+ <di:waypoint x="1438" y="1020" />
490
521
  <di:waypoint x="1438" y="640" />
491
522
  <bpmndi:BPMNLabel>
492
- <dc:Bounds x="1331" y="1047" width="64" height="28" />
523
+ <dc:Bounds x="1319" y="987" width="64" height="28" />
493
524
  </bpmndi:BPMNLabel>
494
525
  </bpmndi:BPMNEdge>
495
526
  <bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
@@ -552,17 +583,33 @@
552
583
  <dc:Bounds x="893" y="839" width="49" height="28" />
553
584
  </bpmndi:BPMNLabel>
554
585
  </bpmndi:BPMNEdge>
586
+ <bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
587
+ <di:waypoint x="1288" y="920" />
588
+ <di:waypoint x="1438" y="920" />
589
+ <di:waypoint x="1438" y="1040" />
590
+ <bpmndi:BPMNLabel>
591
+ <dc:Bounds x="1326" y="887" width="75" height="28" />
592
+ </bpmndi:BPMNLabel>
593
+ </bpmndi:BPMNEdge>
555
594
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_go" bpmnElement="f_reb_go">
556
595
  <di:waypoint x="913" y="280" />
557
596
  <di:waypoint x="933" y="280" />
558
597
  <di:waypoint x="933" y="305" />
559
598
  <di:waypoint x="1158" y="305" />
560
- <di:waypoint x="1158" y="1080" />
561
- <di:waypoint x="1138" y="1080" />
599
+ <di:waypoint x="1158" y="1240" />
600
+ <di:waypoint x="1138" y="1240" />
562
601
  <bpmndi:BPMNLabel>
563
602
  <dc:Bounds x="1101" y="310" width="49" height="28" />
564
603
  </bpmndi:BPMNLabel>
565
604
  </bpmndi:BPMNEdge>
605
+ <bpmndi:BPMNEdge id="BPMNEdge_f_reb_wait" bpmnElement="f_reb_wait">
606
+ <di:waypoint x="1263" y="1215" />
607
+ <di:waypoint x="1263" y="1080" />
608
+ <di:waypoint x="1388" y="1080" />
609
+ <bpmndi:BPMNLabel>
610
+ <dc:Bounds x="1288" y="1047" width="75" height="28" />
611
+ </bpmndi:BPMNLabel>
612
+ </bpmndi:BPMNEdge>
566
613
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_gQueued" bpmnElement="f_m_gQueued">
567
614
  <di:waypoint x="1088" y="145" />
568
615
  <di:waypoint x="1088" y="280" />
@@ -593,12 +640,12 @@
593
640
  <di:waypoint x="1238" y="920" />
594
641
  </bpmndi:BPMNEdge>
595
642
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_done" bpmnElement="f_reb_done">
596
- <di:waypoint x="1138" y="1100" />
597
- <di:waypoint x="1158" y="1100" />
598
- <di:waypoint x="1158" y="1125" />
599
- <di:waypoint x="1238" y="1125" />
600
- <di:waypoint x="1263" y="1125" />
601
- <di:waypoint x="1263" y="1105" />
643
+ <di:waypoint x="1138" y="1260" />
644
+ <di:waypoint x="1158" y="1260" />
645
+ <di:waypoint x="1158" y="1285" />
646
+ <di:waypoint x="1238" y="1285" />
647
+ <di:waypoint x="1263" y="1285" />
648
+ <di:waypoint x="1263" y="1265" />
602
649
  </bpmndi:BPMNEdge>
603
650
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
604
651
  <di:waypoint x="1263" y="945" />
@@ -610,14 +657,20 @@
610
657
  </bpmndi:BPMNLabel>
611
658
  </bpmndi:BPMNEdge>
612
659
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_rebased" bpmnElement="f_reb_rebased">
613
- <di:waypoint x="1263" y="1105" />
614
- <di:waypoint x="1263" y="1140" />
615
- <di:waypoint x="402" y="1140" />
660
+ <di:waypoint x="1263" y="1265" />
661
+ <di:waypoint x="1263" y="1300" />
662
+ <di:waypoint x="402" y="1300" />
616
663
  <di:waypoint x="402" y="160" />
617
664
  <bpmndi:BPMNLabel>
618
- <dc:Bounds x="806" y="1118" width="53" height="14" />
665
+ <dc:Bounds x="806" y="1278" width="53" height="14" />
619
666
  </bpmndi:BPMNLabel>
620
667
  </bpmndi:BPMNEdge>
668
+ <bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
669
+ <di:waypoint x="1438" y="1120" />
670
+ <di:waypoint x="1438" y="1320" />
671
+ <di:waypoint x="234" y="1320" />
672
+ <di:waypoint x="234" y="138" />
673
+ </bpmndi:BPMNEdge>
621
674
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
622
675
  <di:waypoint x="1438" y="458" />
623
676
  <di:waypoint x="1438" y="478" />
package/tsconfig.json CHANGED
@@ -45,7 +45,8 @@
45
45
  "src/**/*.ts",
46
46
  "scripts/**/*.ts",
47
47
  "actions/**/*.ts",
48
- "test/**/*.ts"
48
+ "test/**/*.ts",
49
+ "e2e/**/*.ts"
49
50
  ],
50
51
  "exclude": [
51
52
  "node_modules",
@@ -44,6 +44,13 @@ function transcriptOf(vars: Record<string, unknown>): string | null {
44
44
  return typeof env?.output === "string" ? env.output : null;
45
45
  }
46
46
 
47
+ // The c8ctl harness completes each agent job with an `agent` variable (its profile name), which
48
+ // propagates here. Record it on the round/escalation so a human can identify the servicing worker
49
+ // from the durable history. Reuses the `nonBlank` domain rule (blank/absent -> NULL column).
50
+ function workerOf(vars: Record<string, unknown>): string | undefined {
51
+ return nonBlank(vars.agent);
52
+ }
53
+
47
54
  // Synthesize a concrete, answerable question when the agent left one blank. A blank question is
48
55
  // almost always a *no-result* round: a prompt-less agent that never wrote its result file, so
49
56
  // `status` is empty and `gw-status` falls through its default `f_escalate` arm (the empty
@@ -72,6 +79,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
72
79
  const rawStatus = nonBlank(job.variables.status);
73
80
  const status = rawStatus ?? "needs_input";
74
81
  const transcript = transcriptOf(job.variables);
82
+ const worker = workerOf(job.variables);
75
83
  // A blank question must never open an unanswerable escalation. Every legitimate arm sets a
76
84
  // concrete question — the agent contract requires one for needs_input/blocked, and the
77
85
  // max-rounds + review-timeout arms set a literal via the model. When one is still missing
@@ -110,6 +118,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
110
118
  status,
111
119
  summary,
112
120
  transcript,
121
+ worker,
113
122
  started_at: now,
114
123
  ended_at: now,
115
124
  });
@@ -120,6 +129,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
120
129
  kind,
121
130
  question,
122
131
  transcript,
132
+ worker,
123
133
  status: "open",
124
134
  asked_at: now,
125
135
  });
@@ -34,6 +34,14 @@ function transcriptOf(vars: Record<string, unknown>): string | null {
34
34
  return typeof env?.output === "string" ? env.output : null;
35
35
  }
36
36
 
37
+ // The c8ctl harness completes each agent job with an `agent` variable (its profile name), which
38
+ // propagates here. Record it on the round so a human can identify the servicing worker from the
39
+ // durable history. Undefined (blank/absent) leaves the nullable `worker` column NULL.
40
+ function workerOf(vars: Record<string, unknown>): string | undefined {
41
+ const v = vars.agent;
42
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
43
+ }
44
+
37
45
  const handler: AppJobHandler<In> = async (job, app) => {
38
46
  // This worker is the "addressed"/"waiting" path, so `status` resolves to one of those
39
47
  // domain values. `summary` is left undefined when absent: the write boundary omits it so the
@@ -66,6 +74,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
66
74
  status,
67
75
  summary,
68
76
  transcript: transcriptOf(job.variables),
77
+ worker: workerOf(job.variables),
69
78
  started_at: now,
70
79
  ended_at: now,
71
80
  });
@@ -0,0 +1,116 @@
1
+ // pr.record-dependency: a merge-stage agent that discovers "this PR must wait for another PR to
2
+ // merge first" turns that into a durable dependency wait (parking the PR back in `waiting_deps`)
3
+ // instead of a human escalation — the wait, not the escalation, is the correct outcome.
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
6
+ import handler from "./worker.ts";
7
+
8
+ interface DepRow {
9
+ pr_key: string;
10
+ depends_on_key: string;
11
+ created_at: string;
12
+ }
13
+
14
+ function fakeApp(seedDeps: DepRow[] = []) {
15
+ const logs: { level: string; msg: string }[] = [];
16
+ const stores: Record<string, Record<string, unknown>[]> = {
17
+ pr_dependencies: seedDeps as unknown as Record<string, unknown>[],
18
+ pull_requests: [{ pr_key: "o/r#1", status: "waiting_merge" }],
19
+ };
20
+ return {
21
+ app: {
22
+ data: {
23
+ table(name: string, key: string) {
24
+ const store = (stores[name] ??= []);
25
+ return {
26
+ get: (k: unknown) => Promise.resolve(store.find((r) => r[key] === k)),
27
+ find: (q: Record<string, unknown>) =>
28
+ Promise.resolve(
29
+ store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
30
+ ),
31
+ insert: (row: Record<string, unknown>) => {
32
+ store.push(row);
33
+ return Promise.resolve(store.length);
34
+ },
35
+ update: (k: unknown, patch: Record<string, unknown>) => {
36
+ const row = store.find((r) => r[key] === k);
37
+ if (row) Object.assign(row, patch);
38
+ return Promise.resolve(row);
39
+ },
40
+ delete: (k: unknown) => {
41
+ for (let i = store.length - 1; i >= 0; i--) if (store[i][key] === k) store.splice(i, 1);
42
+ return Promise.resolve(undefined);
43
+ },
44
+ };
45
+ },
46
+ },
47
+ log: (level: string, msg: string) => logs.push({ level, msg }),
48
+ engine: {},
49
+ // biome-ignore lint/suspicious/noExplicitAny: minimal test double for AppContext
50
+ } as any,
51
+ stores,
52
+ logs,
53
+ };
54
+ }
55
+
56
+ const job = (variables: Record<string, unknown>) => ({ variables }) as never;
57
+
58
+ test("records a discovered dependency from a string ref and parks the PR in waiting_deps", async () => {
59
+ const { app, stores } = fakeApp();
60
+ await handler(job({ prKey: "o/r#1", dependsOn: "o/r#2" }), app);
61
+
62
+ assertEquals(stores.pr_dependencies.length, 1);
63
+ assertEquals(stores.pr_dependencies[0].pr_key, "o/r#1");
64
+ assertEquals(stores.pr_dependencies[0].depends_on_key, "o/r#2");
65
+ assertEquals(stores.pull_requests[0].status, "waiting_deps");
66
+ });
67
+
68
+ test("parses several refs (commas/spaces/URLs), dedupes, and never waits on itself", async () => {
69
+ const { app, stores } = fakeApp();
70
+ await handler(
71
+ job({
72
+ prKey: "o/r#1",
73
+ dependsOn: "o/r#2, o/r#2 https://github.com/o/r/pull/3 o/r#1",
74
+ }),
75
+ app,
76
+ );
77
+
78
+ const keys = stores.pr_dependencies.map((d) => d.depends_on_key).sort();
79
+ assertEquals(keys, ["o/r#2", "o/r#3"]); // #2 deduped, self #1 dropped
80
+ assertEquals(stores.pull_requests[0].status, "waiting_deps");
81
+ });
82
+
83
+ test("appends to existing edges without wiping them and skips already-recorded ones", async () => {
84
+ const { app, stores } = fakeApp([
85
+ { pr_key: "o/r#1", depends_on_key: "o/r#9", created_at: "t0" },
86
+ ]);
87
+ await handler(job({ prKey: "o/r#1", dependsOn: ["o/r#9", "o/r#2"] }), app);
88
+
89
+ const keys = stores.pr_dependencies.map((d) => d.depends_on_key).sort();
90
+ assertEquals(keys, ["o/r#2", "o/r#9"]); // pre-existing #9 preserved, only #2 added
91
+ });
92
+
93
+ test("no parseable ref still parks in waiting_deps and logs the miswiring loudly", async () => {
94
+ const { app, stores, logs } = fakeApp();
95
+ await handler(job({ prKey: "o/r#1", dependsOn: "not-a-pr" }), app);
96
+
97
+ assertEquals(stores.pr_dependencies.length, 0);
98
+ assertEquals(stores.pull_requests[0].status, "waiting_deps");
99
+ assertEquals(logs.some((l) => l.level === "error"), true);
100
+ });
101
+
102
+ test("heals a missing pull_requests parent row before parking so the merge poller can watch it", async () => {
103
+ const { app, stores } = fakeApp();
104
+ stores.pull_requests.length = 0; // engine/app.db desync: no parent row for o/r#1
105
+
106
+ await handler(job({ prKey: "o/r#1", dependsOn: "o/r#2" }), app);
107
+
108
+ // ensurePr reconstructed the row, and the subsequent update landed on it (not a silent no-op).
109
+ assertEquals(stores.pull_requests.length, 1);
110
+ const healed = stores.pull_requests[0];
111
+ assertEquals(healed.pr_key, "o/r#1");
112
+ assertEquals(healed.repo, "o/r");
113
+ assertEquals(healed.number, 1);
114
+ assertEquals(healed.status, "waiting_deps");
115
+ assertEquals(stores.pr_dependencies.length, 1);
116
+ });
@@ -0,0 +1,109 @@
1
+ // pr.record-dependency — a merge-stage agent discovered that this PR cannot land until ANOTHER
2
+ // PR merges first (e.g. a stacked base PR must land, or a sibling PR closes an issue this one
3
+ // requires). That is a WAIT, not a human escalation: record the discovered edge(s) in the
4
+ // `pr_dependencies` DAG and park the PR back in `waiting_deps` so the merge poller's dependency
5
+ // pass (`pollMerges` block 1) advances it — publishing `deps-cleared` — once every named PR has
6
+ // merged. The process re-enters its existing `wait-deps` catch, so no human has to babysit an
7
+ // ordering constraint the machinery already knows how to satisfy.
8
+ //
9
+ // `dependsOn` is whatever the agent returned (see prompts/fix-ci.md, prompts/rebase.md): a
10
+ // string of one or more `owner/repo#N` refs (or PR URLs) separated by commas/whitespace/newlines,
11
+ // or an array of such tokens. We parse each robustly (reusing `parsePr`), drop self-references and
12
+ // duplicates, and insert missing edges idempotently — a worker retry never double-inserts, and an
13
+ // already-recorded edge is a no-op.
14
+ import type { AppJobHandler } from "@nanobpm/urban";
15
+ import { ensurePr, parsePr } from "../../app/service.ts";
16
+
17
+ interface In extends Record<string, unknown> {
18
+ prKey: string;
19
+ dependsOn?: unknown;
20
+ }
21
+
22
+ interface DependencyRow {
23
+ pr_key: string;
24
+ depends_on_key: string;
25
+ created_at: string;
26
+ }
27
+
28
+ /** Normalize the agent's `dependsOn` into a de-duplicated list of `owner/repo#N` keys.
29
+ * Accepts a string (split on commas/whitespace/newlines) or an array of such tokens; unparseable
30
+ * tokens are ignored, mirroring `parseDependsOn`'s tolerance for the `Depends-on:` PR-body line. */
31
+ function parseDependsOn(raw: unknown): string[] {
32
+ const tokens: string[] = [];
33
+ if (typeof raw === "string") {
34
+ tokens.push(...raw.split(/[,\s]+/));
35
+ } else if (Array.isArray(raw)) {
36
+ for (const item of raw) {
37
+ if (typeof item === "string") tokens.push(...item.split(/[,\s]+/));
38
+ }
39
+ }
40
+ const out = new Set<string>();
41
+ for (const tok of tokens) {
42
+ const parsed = parsePr(tok);
43
+ if (parsed) out.add(parsed.prKey);
44
+ }
45
+ return [...out];
46
+ }
47
+
48
+ const handler: AppJobHandler<In> = async (job, app) => {
49
+ const prKey = job.variables.prKey;
50
+ const depKeys = parseDependsOn(job.variables.dependsOn);
51
+ const ts = new Date().toISOString();
52
+
53
+ const depTable = app.data.table<DependencyRow>("pr_dependencies", "pr_key");
54
+
55
+ // Append the discovered edges to whatever the plan DAG already declared — never wipe the set
56
+ // (a `registerDependencies`-style replace would drop still-relevant sibling ordering). Dedupe
57
+ // against existing rows so this is safe to retry.
58
+ const existing = await depTable.find({ pr_key: prKey });
59
+ const have = new Set(existing.map((d) => d.depends_on_key));
60
+ let recorded = 0;
61
+ for (const depKey of depKeys) {
62
+ if (depKey === prKey || have.has(depKey)) continue; // never wait on self; skip known edges
63
+ await depTable.insert({ pr_key: prKey, depends_on_key: depKey, created_at: ts });
64
+ have.add(depKey);
65
+ recorded += 1;
66
+ }
67
+
68
+ if (depKeys.length === 0) {
69
+ // The agent signalled "waiting-on-pr" but named no parseable PR. Whether this actually strands
70
+ // the PR depends on the existing DAG: with no other edges the poller clears it immediately (an
71
+ // empty dep set trivially "all merged"); if prior edges already exist it will keep waiting on
72
+ // those. Either way the miswiring — a "waiting-on-pr" signal with no parseable ref — is a
73
+ // defect worth logging loudly.
74
+ const clause =
75
+ have.size === 0
76
+ ? "it will clear immediately (no other dependencies recorded)"
77
+ : `it still has ${have.size} previously-recorded dependency edge(s) to wait on`;
78
+ app.log(
79
+ "error",
80
+ `record-dependency: ${prKey} reported waiting-on-pr but no parseable dependsOn ref; ` +
81
+ `${clause}. Raw: ${JSON.stringify(job.variables.dependsOn)}`,
82
+ );
83
+ }
84
+
85
+ // Heal a missing FK parent (engine/app.db desync) before updating `pull_requests`; otherwise the
86
+ // update silently no-ops and the instance re-enters `wait-deps` with no row for the merge poller
87
+ // to watch — wedging the merge loop. Mirrors persist-round's heal; repo/number are derived from
88
+ // the canonical `owner/repo#N` prKey since the RecordDepIn envelope carries only prKey/dependsOn.
89
+ const parsed = parsePr(prKey);
90
+ if (parsed) {
91
+ await ensurePr(app.data, { prKey, repo: parsed.repo, number: parsed.number, url: parsed.url });
92
+ }
93
+
94
+ // Park the PR back in the merge-stage dependency wait so `pollMerges` block 1 watches it.
95
+ await app.data.table("pull_requests", "pr_key").update(prKey, {
96
+ status: "waiting_deps",
97
+ updated_at: ts,
98
+ });
99
+
100
+ app.log("info", `record-dependency: ${prKey} waiting on ${depKeys.length} PR(s)`, {
101
+ prKey,
102
+ dependsOn: depKeys,
103
+ recorded,
104
+ });
105
+
106
+ return {};
107
+ };
108
+
109
+ export default handler;