@nanobpm/nano-workforce 0.55.0 → 0.57.0

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -1
  3. package/SPEC.md +6 -2
  4. package/app/agentGuide.ts +1 -1
  5. package/app/agentic/cockpit/supply-render.test.ts +36 -0
  6. package/app/agentic/cockpit/supply-render.ts +22 -1
  7. package/app/agentic/cockpit/supply-view.test.ts +40 -0
  8. package/app/agentic/cockpit/supply-view.ts +81 -3
  9. package/app/agentic/correlation.test.ts +132 -0
  10. package/app/agentic/correlation.ts +193 -0
  11. package/app/agentic/families/correlation.family.test.ts +47 -0
  12. package/app/agentic/families/correlation.family.ts +39 -0
  13. package/app/github.test.ts +179 -1
  14. package/app/github.ts +132 -0
  15. package/app/plan.test.ts +268 -20
  16. package/app/plan.ts +147 -15
  17. package/docs/agentic-cockpit.md +135 -0
  18. package/nano.app.json +4 -0
  19. package/openapi.yaml +89 -12
  20. package/operations/getAgenticSupply.test.ts +40 -0
  21. package/operations/getAgenticSupply.ts +32 -9
  22. package/operations/startAndMessage.test.ts +62 -2
  23. package/operations/startPlanFanout.admission.integration.test.ts +263 -0
  24. package/operations/startPlanFanout.ts +70 -11
  25. package/package.json +1 -1
  26. package/pages/cockpit/cockpit.css +17 -0
  27. package/pages/cockpit/mount.js +35 -4
  28. package/pages/epic.page.json +4 -1
  29. package/resources/agent-guide.md +38 -2
  30. package/resources/processes/plan-fanout.bpmn +168 -149
  31. package/test/agentic-e2e.test.ts +258 -0
  32. package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
  33. package/workers/ensure-base-branch/worker.test.ts +104 -0
  34. package/workers/ensure-base-branch/worker.ts +31 -0
@@ -0,0 +1,263 @@
1
+ // Integration coverage for the base-branch ADMISSION gate (ADR 0003) driven through the operation
2
+ // EDGE — `startPlanFanout` → `admitPlan` → HTTP status. The unit tests in app/plan.test.ts already
3
+ // prove `admitPlan`'s decision matrix in isolation; this file proves the COMPOSED behaviour at the
4
+ // door: each admission rule maps to the correct HTTP status (400 / 409) and each accept path reaches
5
+ // the 202 fan-out. It runs the real delegate against an in-memory app/data/engine and a faked github
6
+ // transport (token mode + stubbed `globalThis.fetch`) — no network, deterministic on a single run.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import type { AppApi } from "@nanobpm/urban";
10
+ import { resetDefaultBranchCache } from "../app/github.ts";
11
+ import { noopLog } from "../test/log.ts";
12
+ import startPlanFanout from "./startPlanFanout.ts";
13
+
14
+ // ── in-memory github model ───────────────────────────────────────────────────
15
+ // A minimal fake of the GitHub REST surface `admitPlan` touches: the repo-meta GET (default branch),
16
+ // the ref GET (branch existence, returning a synthetic per-branch head sha) and the ref-create POST.
17
+ // `default_branch` is `main`; a missing `epic/*` base is auto-created off the default branch's head
18
+ // sha (which `admitPlan` reads via the ref GET, so here that is `main-sha`). `creates` records the
19
+ // full ref-create POST body (`ref` + `sha`) so a test can assert a branch was (or was NOT) created
20
+ // AND that the create payload points the new ref at the resolved base sha, not a stale/blank value.
21
+ interface GithubState {
22
+ repo: string;
23
+ defaultBranch: string;
24
+ branches: Set<string>;
25
+ creates: { ref: string; sha: string }[];
26
+ }
27
+
28
+ function githubFetch(state: GithubState) {
29
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
30
+ const u = new URL(String(url));
31
+ const method = (init?.method ?? "GET").toUpperCase();
32
+ const path = u.pathname;
33
+ const json = (obj: unknown, status = 200) =>
34
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
35
+ if (method === "GET" && path === `/repos/${state.repo}`) {
36
+ return Promise.resolve(json({ default_branch: state.defaultBranch }));
37
+ }
38
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
39
+ if (method === "GET" && path.startsWith(refPrefix)) {
40
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
41
+ if (!state.branches.has(branch)) return Promise.resolve(new Response("Not Found", { status: 404 }));
42
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha: `${branch}-sha` } }));
43
+ }
44
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
45
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
46
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
47
+ const ref = String(body.ref ?? "");
48
+ const sha = String(body.sha ?? "");
49
+ const branch = ref.replace(/^refs\/heads\//, "");
50
+ if (state.branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
51
+ state.creates.push({ ref, sha });
52
+ state.branches.add(branch);
53
+ return Promise.resolve(json({ ref }, 201));
54
+ }
55
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
56
+ };
57
+ }
58
+
59
+ async function withGithub<T>(state: GithubState, fn: () => Promise<T>): Promise<T> {
60
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
61
+ const prevTok = process.env["GITHUB_TOKEN"];
62
+ const prevFetch = globalThis.fetch;
63
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
64
+ process.env["GITHUB_TOKEN"] = "tok";
65
+ resetDefaultBranchCache(); // isolate: don't inherit or leak another test's default-branch entry
66
+ globalThis.fetch = githubFetch(state) as typeof fetch;
67
+ try {
68
+ return await fn();
69
+ } finally {
70
+ resetDefaultBranchCache();
71
+ globalThis.fetch = prevFetch;
72
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
73
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
74
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
75
+ else process.env["GITHUB_TOKEN"] = prevTok;
76
+ }
77
+ }
78
+
79
+ // ── in-memory app (data + engine) ────────────────────────────────────────────
80
+ // A generic table over an array, matching the DataLayer surface `startPlan`/`findActivePlansByBase`
81
+ // use (get/find/insert/update/delete). `seedPlans` pre-loads the `plans` table so the shared-base
82
+ // guard has active rows to find. `started` records each engine.createInstance call so an accept path
83
+ // can be asserted to have fanned out.
84
+ function makeApp(seedPlans: Record<string, unknown>[] = []) {
85
+ const tables = new Map<string, Record<string, unknown>[]>();
86
+ tables.set("plans", [...seedPlans]);
87
+ const started: { processDefinitionId: string; variables?: Record<string, unknown> }[] = [];
88
+ const table = (name: string, key: string) => {
89
+ const rows = tables.get(name) ?? (() => {
90
+ const fresh: Record<string, unknown>[] = [];
91
+ tables.set(name, fresh);
92
+ return fresh;
93
+ })();
94
+ return {
95
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
96
+ find: (q: Record<string, unknown>) =>
97
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
98
+ insert: (r: Record<string, unknown>) => {
99
+ rows.push(r);
100
+ return Promise.resolve(r);
101
+ },
102
+ update: (k: unknown, patch: Record<string, unknown>) => {
103
+ const row = rows.find((r) => r[key] === k);
104
+ if (row) Object.assign(row, patch);
105
+ return Promise.resolve(row);
106
+ },
107
+ delete: (k: unknown) => {
108
+ const i = rows.findIndex((r) => r[key] === k);
109
+ if (i >= 0) rows.splice(i, 1);
110
+ return Promise.resolve();
111
+ },
112
+ };
113
+ };
114
+ const app = {
115
+ data: { table },
116
+ engine: {
117
+ createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
118
+ started.push(req);
119
+ return Promise.resolve({ processInstanceKey: "PI-1" });
120
+ },
121
+ },
122
+ log: noopLog(),
123
+ } as any as AppApi;
124
+ return { app, started };
125
+ }
126
+
127
+ function input(body: unknown) {
128
+ return {
129
+ req: { method: "POST", path: "/", query: new URLSearchParams(), headers: new Headers(), text: async () => "" } as any,
130
+ params: {},
131
+ query: {},
132
+ body,
133
+ };
134
+ }
135
+
136
+ function freshGithub(repo: string, extraBranches: string[] = []): GithubState {
137
+ return { repo, defaultBranch: "main", branches: new Set(["main", ...extraBranches]), creates: [] };
138
+ }
139
+
140
+ // ── Rule 1 — required + explicit ──────────────────────────────────────────────
141
+
142
+ // Rule 1 rejects both a MISSING baseBranch field and an explicit blank/whitespace
143
+ // one — the latter is a distinct edge input that must not slip past as a "present"
144
+ // value. Table-drive both so the required-and-explicit rule is covered end to end.
145
+ for (const [label, body] of [
146
+ ["missing field", { issue: "owner/repo#1" }],
147
+ ["empty string", { issue: "owner/repo#1", baseBranch: "" }],
148
+ ["whitespace only", { issue: "owner/repo#1", baseBranch: " " }],
149
+ ] as const) {
150
+ test(`edge: ${label} baseBranch → 400`, async () => {
151
+ const gh = freshGithub("owner/repo");
152
+ await withGithub(gh, async () => {
153
+ const { app, started } = makeApp();
154
+ const res = (await startPlanFanout(input(body), app)) as any;
155
+ assertEquals(res.status, 400);
156
+ assertEquals(typeof res.body.error, "string");
157
+ assertEquals(started.length, 0); // rejected before any fan-out
158
+ assertEquals(gh.creates, []); // no ref created on a rejected input
159
+ });
160
+ });
161
+ }
162
+
163
+ // ── Rule 2 — create-if-missing (epic/* guard), synchronously at the edge ──────
164
+
165
+ test("edge: non-epic/* base that does not exist → 400 (BaseBranchMustExistError path)", async () => {
166
+ // A typo'd, non-epic/* base is NOT auto-created — admitPlan throws BaseBranchMustExistError
167
+ // synchronously, which the delegate maps to a clean 400 at the door (not a late per-task failure).
168
+ const gh = freshGithub("owner/repo"); // "release-9" absent, not epic/* → must-exist
169
+ await withGithub(gh, async () => {
170
+ const { app, started } = makeApp();
171
+ const res = (await startPlanFanout(input({ issue: "owner/repo#2", baseBranch: "release-9" }), app)) as any;
172
+ assertEquals(res.status, 400);
173
+ assertEquals(typeof res.body.error, "string");
174
+ assertEquals(started.length, 0);
175
+ assertEquals(gh.creates, []); // never created
176
+ });
177
+ });
178
+
179
+ test("edge: missing epic/* base → created off default HEAD, then 202", async () => {
180
+ const gh = freshGithub("owner/repo"); // epic/new absent → auto-created off main
181
+ await withGithub(gh, async () => {
182
+ const { app, started } = makeApp();
183
+ const res = (await startPlanFanout(input({ issue: "owner/repo#3", baseBranch: "epic/new" }), app)) as any;
184
+ assertEquals(res.status, 202);
185
+ // Created off the resolved base sha: the ref-create payload names epic/new AND points it at the
186
+ // default branch's head sha (`main-sha` here), proving the create body carries the base sha.
187
+ assertEquals(gh.creates, [{ ref: "refs/heads/epic/new", sha: "main-sha" }]);
188
+ assertEquals(started.length, 1);
189
+ });
190
+ });
191
+
192
+ // ── Rule 3 — confirm-default ──────────────────────────────────────────────────
193
+
194
+ test("edge: target == default branch WITHOUT confirmDefaultBase → 400", async () => {
195
+ const gh = freshGithub("owner/repo"); // base "main" IS the default
196
+ await withGithub(gh, async () => {
197
+ const { app, started } = makeApp();
198
+ const res = (await startPlanFanout(input({ issue: "owner/repo#4", baseBranch: "main" }), app)) as any;
199
+ assertEquals(res.status, 400);
200
+ assertEquals(typeof res.body.error, "string");
201
+ assertEquals(started.length, 0);
202
+ });
203
+ });
204
+
205
+ test("edge: target == default branch WITH confirmDefaultBase → 202", async () => {
206
+ const gh = freshGithub("owner/repo");
207
+ await withGithub(gh, async () => {
208
+ const { app, started } = makeApp();
209
+ const res = (await startPlanFanout(
210
+ input({ issue: "owner/repo#5", baseBranch: "main", confirmDefaultBase: true }),
211
+ app,
212
+ )) as any;
213
+ assertEquals(res.status, 202);
214
+ assertEquals(started.length, 1);
215
+ });
216
+ });
217
+
218
+ // ── Rule 4 — shared-base guard ────────────────────────────────────────────────
219
+
220
+ test("edge: active plan on the same CUSTOM base WITHOUT allowSharedBase → 409", async () => {
221
+ const gh = freshGithub("owner/repo", ["epic/shared"]); // base exists → ensureBaseBranch no-ops
222
+ await withGithub(gh, async () => {
223
+ // A DIFFERENT active plan already targets epic/shared on this repo.
224
+ const { app, started } = makeApp([
225
+ { plan_key: "owner/repo#98", repo: "owner/repo", base_branch: "epic/shared", status: "planning" },
226
+ ]);
227
+ const res = (await startPlanFanout(input({ issue: "owner/repo#6", baseBranch: "epic/shared" }), app)) as any;
228
+ assertEquals(res.status, 409);
229
+ assertEquals(typeof res.body.error, "string");
230
+ assertEquals(started.length, 0);
231
+ });
232
+ });
233
+
234
+ test("edge: same CUSTOM base WITH allowSharedBase → 202", async () => {
235
+ const gh = freshGithub("owner/repo", ["epic/shared"]);
236
+ await withGithub(gh, async () => {
237
+ const { app, started } = makeApp([
238
+ { plan_key: "owner/repo#98", repo: "owner/repo", base_branch: "epic/shared", status: "planning" },
239
+ ]);
240
+ const res = (await startPlanFanout(
241
+ input({ issue: "owner/repo#7", baseBranch: "epic/shared", allowSharedBase: true }),
242
+ app,
243
+ )) as any;
244
+ assertEquals(res.status, 202);
245
+ assertEquals(started.length, 1);
246
+ });
247
+ });
248
+
249
+ test("edge: two plans sharing the DEFAULT branch → 202 (default is exempt from the shared-base guard)", async () => {
250
+ const gh = freshGithub("owner/repo"); // base "main" == default → exempt
251
+ await withGithub(gh, async () => {
252
+ // An active plan already targets main; a second one is still admitted (confirmed default).
253
+ const { app, started } = makeApp([
254
+ { plan_key: "owner/repo#97", repo: "owner/repo", base_branch: "main", status: "planning" },
255
+ ]);
256
+ const res = (await startPlanFanout(
257
+ input({ issue: "owner/repo#8", baseBranch: "main", confirmDefaultBase: true }),
258
+ app,
259
+ )) as any;
260
+ assertEquals(res.status, 202);
261
+ assertEquals(started.length, 1);
262
+ });
263
+ });
@@ -10,7 +10,16 @@
10
10
  // ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at the edge; this delegate just
11
11
  // narrows the validated variant and keeps the issue-FORMAT parse guard (schema can't express it).
12
12
 
13
- import { InvalidBaseBranchError, normalizeBaseBranch, parseIssue, startPlan } from "../app/plan.ts";
13
+ import { BaseBranchMustExistError } from "../app/github.ts";
14
+ import {
15
+ admitPlan,
16
+ DefaultBaseNotConfirmedError,
17
+ InvalidBaseBranchError,
18
+ MissingBaseBranchError,
19
+ parseIssue,
20
+ SharedBaseError,
21
+ startPlan,
22
+ } from "../app/plan.ts";
14
23
  import { defineOperation } from "../nano-generated/operations.ts";
15
24
 
16
25
  export default defineOperation("startPlanFanout", async ({ body }, app) => {
@@ -26,16 +35,29 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
26
35
  app.log.warn("start-plan rejected: unparseable issue reference", { raw });
27
36
  return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
28
37
  }
29
- // Optional epic base branch: the branch the fleet branches off and opens every PR against instead
30
- // of the repo default. Present on both oneOf variants; blank/absent keeps the default-branch
31
- // behaviour. It is later interpolated into the authoritative implementer prompt (with `git`/`gh`
32
- // shell snippets), so validate/normalise it HERE a non-blank value that isn't a plausible git
33
- // branch name is a 400 at the edge, never persisted or rendered. `normalizeBaseBranch` blank → null.
34
- const baseBranch = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
35
- let normalizedBase: string | null;
38
+ // Epic base branch (ADR 0003): admit the launch through the fail-fast `admitPlan` gate BEFORE any
39
+ // fan-out. It composes the four ordered admission rules required+explicit, create-if-missing
40
+ // (epic/* guard, run synchronously so a typo is a clean edge 400), confirm-default, and
41
+ // shared-base and returns the normalized base. Errors map to specific HTTP statuses at the edge.
42
+ const rawBase = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
43
+ const allowSharedBase = "allowSharedBase" in body && body.allowSharedBase === true;
44
+ const confirmDefaultBase = "confirmDefaultBase" in body && body.confirmDefaultBase === true;
45
+ const token = process.env.GITHUB_TOKEN ?? "";
46
+ let normalizedBase: string;
36
47
  try {
37
- normalizedBase = normalizeBaseBranch(baseBranch);
48
+ normalizedBase = await admitPlan(app.data, parsed.repo, rawBase, token, {
49
+ allowSharedBase,
50
+ confirmDefaultBase,
51
+ selfPlanKey: parsed.planKey,
52
+ });
38
53
  } catch (err) {
54
+ if (err instanceof MissingBaseBranchError) {
55
+ app.log.warn("start-plan rejected: missing base branch");
56
+ return {
57
+ status: 400,
58
+ body: { error: "baseBranch is required (name the integration branch, e.g. epic/agent-protocol)" },
59
+ };
60
+ }
39
61
  if (err instanceof InvalidBaseBranchError) {
40
62
  app.log.warn("start-plan rejected: invalid base branch", { baseBranch: err.value });
41
63
  return {
@@ -43,13 +65,50 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
43
65
  body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. epic/agent-protocol)" },
44
66
  };
45
67
  }
68
+ if (err instanceof BaseBranchMustExistError) {
69
+ app.log.warn("start-plan rejected: base branch does not exist", { baseBranch: err.branch });
70
+ return {
71
+ status: 400,
72
+ body: {
73
+ error:
74
+ `baseBranch "${err.branch}" does not exist and is not an epic/* branch, so it is not ` +
75
+ `auto-created — create it first, or use the epic/* convention`,
76
+ },
77
+ };
78
+ }
79
+ if (err instanceof DefaultBaseNotConfirmedError) {
80
+ app.log.warn("start-plan rejected: default base not confirmed", { baseBranch: err.branch });
81
+ return {
82
+ status: 400,
83
+ body: {
84
+ error:
85
+ `baseBranch "${err.branch}" is the repository default branch — every task would land ` +
86
+ `directly on it with no integration branch. Re-submit with confirmDefaultBase: true to proceed`,
87
+ },
88
+ };
89
+ }
90
+ if (err instanceof SharedBaseError) {
91
+ app.log.warn("start-plan rejected: shared base branch", { baseBranch: err.branch });
92
+ return {
93
+ status: 409,
94
+ body: {
95
+ error:
96
+ `baseBranch "${err.branch}" is already in use by another active epic. Re-submit with ` +
97
+ `allowSharedBase: true to stack on it, or name a distinct epic/* branch`,
98
+ },
99
+ };
100
+ }
46
101
  throw err;
47
102
  }
48
103
  const result = await startPlan(app.data, app.engine, parsed, normalizedBase);
104
+ const alreadyRunning = "alreadyRunning" in result && result.alreadyRunning === true;
49
105
  app.log.info("plan fan-out started", {
50
106
  planKey: parsed.planKey,
51
- baseBranch: normalizedBase ?? "(default branch)",
52
- alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
107
+ // The base the caller requested. When `alreadyRunning`, `startPlan` short-circuits before this
108
+ // base takes effect (it may not match the in-flight plan's persisted base), so name it as the
109
+ // request — not the effective base — to keep the log honest.
110
+ requestedBaseBranch: normalizedBase,
111
+ alreadyRunning,
53
112
  });
54
113
  return { status: 202, body: result };
55
114
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
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",
@@ -131,6 +131,23 @@
131
131
 
132
132
  .cockpit-worker:hover { color: #58a6ff; }
133
133
 
134
+ .cockpit-supply-process { color: var(--cockpit-muted); }
135
+
136
+ .cockpit-correlation {
137
+ background: none;
138
+ border: none;
139
+ color: var(--cockpit-text);
140
+ cursor: pointer;
141
+ display: block;
142
+ font: inherit;
143
+ padding: 0;
144
+ text-align: left;
145
+ text-decoration: underline;
146
+ text-underline-offset: 2px;
147
+ }
148
+
149
+ .cockpit-correlation:hover { color: #58a6ff; }
150
+
134
151
  .cockpit-supply-liveness { color: var(--cockpit-muted); }
135
152
 
136
153
  .cockpit-supply-empty {
@@ -33,8 +33,21 @@ function liveness(worker, staleAfterMs) {
33
33
  return worker.staleMs >= staleAfterMs ? "stale" : "live";
34
34
  }
35
35
 
36
- function workerView(worker, staleAfterMs) {
36
+ function correlationLabel(c) {
37
+ const parts = [];
38
+ if (c.bpmnProcessId != null) parts.push(c.bpmnProcessId);
39
+ if (c.elementId != null) parts.push(c.elementId);
40
+ if (c.processInstanceKey != null) parts.push(`inst ${c.processInstanceKey}`);
41
+ if (c.planKey != null) parts.push(c.planKey);
42
+ return parts.length > 0 ? parts.join(" \u00b7 ") : `job ${c.jobKey}`;
43
+ }
44
+
45
+ function workerView(worker, staleAfterMs, byJobKey) {
37
46
  const jobKeys = [...(worker.jobKeys ?? [])].sort((a, b) => a.localeCompare(b));
47
+ const correlations = jobKeys
48
+ .map((jobKey) => byJobKey.get(jobKey))
49
+ .filter((c) => c != null)
50
+ .map((c) => ({ jobKey: c.jobKey, stream: c.stream, label: correlationLabel(c) }));
38
51
  return {
39
52
  instance: worker.instance,
40
53
  identity: worker.identity,
@@ -43,6 +56,7 @@ function workerView(worker, staleAfterMs) {
43
56
  host: worker.host ?? "\u2014",
44
57
  jobKeys,
45
58
  jobs: jobKeys.length,
59
+ correlations,
46
60
  liveness: liveness(worker, staleAfterMs),
47
61
  staleMs: worker.staleMs,
48
62
  };
@@ -50,9 +64,11 @@ function workerView(worker, staleAfterMs) {
50
64
 
51
65
  function supplyView(report, staleAfterMs) {
52
66
  const byInstance = (a, b) => a.instance.localeCompare(b.instance);
67
+ const byJobKey = new Map();
68
+ for (const c of report.correlations ?? []) byJobKey.set(c.jobKey, c);
53
69
  const leaves = (report.leaves ?? [])
54
70
  .map((leaf) => {
55
- const workers = leaf.workers.map((w) => workerView(w, staleAfterMs)).sort(byInstance);
71
+ const workers = leaf.workers.map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
56
72
  return {
57
73
  token: leaf.token,
58
74
  workers,
@@ -61,7 +77,7 @@ function supplyView(report, staleAfterMs) {
61
77
  };
62
78
  })
63
79
  .sort((a, b) => a.token.localeCompare(b.token));
64
- const workers = (report.workers ?? []).map((w) => workerView(w, staleAfterMs)).sort(byInstance);
80
+ const workers = (report.workers ?? []).map((w) => workerView(w, staleAfterMs, byJobKey)).sort(byInstance);
65
81
  return { leaves, workers, count: workers.length, live: workers.filter((w) => w.liveness === "live").length };
66
82
  }
67
83
 
@@ -100,6 +116,21 @@ function workerRow(doc, worker, onDrill) {
100
116
  const jobsCell = el(doc, "td", "cockpit-td cockpit-supply-jobs", worker.jobs === 0 ? "\u2014" : worker.jobKeys.join(", "));
101
117
  jobsCell.setAttribute("data-jobs", String(worker.jobs));
102
118
  row.appendChild(jobsCell);
119
+ const processCell = el(doc, "td", "cockpit-td cockpit-supply-process");
120
+ processCell.setAttribute("data-correlations", String(worker.correlations.length));
121
+ if (worker.correlations.length === 0) {
122
+ processCell.textContent = "\u2014";
123
+ } else {
124
+ for (const correlation of worker.correlations) {
125
+ const link = el(doc, "button", "cockpit-correlation", correlation.label);
126
+ link.setAttribute("type", "button");
127
+ link.setAttribute("data-job-key", correlation.jobKey);
128
+ link.setAttribute("data-stream", correlation.stream);
129
+ if (onDrill) link.addEventListener("click", () => onDrill(correlation.stream));
130
+ processCell.appendChild(link);
131
+ }
132
+ }
133
+ row.appendChild(processCell);
103
134
  const livenessCell = el(doc, "td", "cockpit-td cockpit-supply-liveness", worker.liveness);
104
135
  livenessCell.setAttribute("data-liveness", worker.liveness);
105
136
  row.appendChild(livenessCell);
@@ -116,7 +147,7 @@ function leafSection(doc, leaf, onDrill) {
116
147
  const table = el(doc, "table", "cockpit-supply-table");
117
148
  const thead = el(doc, "thead", "cockpit-supply-thead");
118
149
  const head = el(doc, "tr", "cockpit-supply-head");
119
- for (const label of ["worker", "family", "host", "jobs", "liveness"]) head.appendChild(el(doc, "th", "cockpit-th", label));
150
+ for (const label of ["worker", "family", "host", "jobs", "process / plan", "liveness"]) head.appendChild(el(doc, "th", "cockpit-th", label));
120
151
  thead.appendChild(head);
121
152
  table.appendChild(thead);
122
153
  const tbody = el(doc, "tbody", "cockpit-supply-tbody");
@@ -38,7 +38,9 @@
38
38
  "action": { "path": "/app/api/actions/start/plan-fanout", "body": "{{form}}" },
39
39
  "fields": [
40
40
  { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
41
- { "key": "baseBranch", "label": "Base branch (blank = repo default; e.g. epic/agent-protocol to land the whole epic on an integration branch)", "type": "text" }
41
+ { "key": "baseBranch", "label": "Base branch (REQUIRED; e.g. epic/agent-protocol to land the whole epic on an integration branch). A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text" },
42
+ { "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default (every task lands directly on it, with any merge-to-default side effect firing per task)", "type": "checkbox" },
43
+ { "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic \u2014 required only when another in-flight epic already targets this same custom base", "type": "checkbox" }
42
44
  ]
43
45
  }
44
46
  },
@@ -70,6 +72,7 @@
70
72
  "columns": [
71
73
  { "field": "plan_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
72
74
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
75
+ { "field": "base_branch", "header": "Base branch" },
73
76
  { "field": "wave_label", "header": "Wave" },
74
77
  { "field": "task_count", "header": "Tasks" },
75
78
  { "field": "open_plan_findings", "header": "Attention", "badge": { "tone": "danger", "label": "!" } },
@@ -97,13 +97,49 @@ PR is then enrolled into its own convergence loop (§1).
97
97
  ```bash
98
98
  curl -sS -X POST __BASE__/actions/start/plan-fanout \
99
99
  -H 'content-type: application/json' \
100
- -d '{ "issue": "owner/repo#123" }'
100
+ -d '{ "issue": "owner/repo#123", "baseBranch": "epic/agent-protocol" }'
101
101
  ```
102
102
 
103
- The body is flat: `issue` (or `url`) — `owner/repo#123` or an issue URL. Starting a
103
+ The body is flat: `issue` (or `url`) — `owner/repo#123` or an issue URL plus a REQUIRED
104
+ `baseBranch` (ADR 0003), the branch the fleet branches off and opens every PR against; a
105
+ blank/absent base is rejected with a 400. Starting a
104
106
  plan is idempotent on the plan key; an already-running plan short-circuits. The
105
107
  response (202) echoes the `planKey` and engine `processKey`.
106
108
 
109
+ ### Base-branch admission (ADR 0003)
110
+
111
+ `startPlanFanout` admits the base through one fail-fast gate before any task fans out.
112
+ Four ordered rules govern which base is accepted:
113
+
114
+ 1. **Required + explicit.** `baseBranch` is mandatory — a blank/absent value is a `400`
115
+ (`MissingBaseBranchError`), and an implausible name is a `400` (`InvalidBaseBranchError`).
116
+ There is no silent "land on the default branch" fallback.
117
+ 2. **Create-if-missing, `epic/*` only.** A missing `epic/*` base is **auto-created** off the
118
+ repository default branch's HEAD (idempotently — an existing branch is never reset). A
119
+ missing base that is **not** `epic/*` is a clean `400` (`BaseBranchMustExistError`): a typo
120
+ can't silently spawn a wrong-rooted branch, so any non-`epic/*` base must already exist.
121
+ 3. **Confirm-default.** Naming the repository **default branch** as the base requires
122
+ `confirmDefaultBase: true`, else `400` (`DefaultBaseNotConfirmedError`). This is a
123
+ deliberate acknowledgement that every task lands directly on the default branch with no
124
+ integration buffer, and any merge-to-default side effect fires per task.
125
+ 4. **Shared-base guard.** If another **active** epic (status not `done`/`failed`/`abandoned`)
126
+ already targets the **same repo + same custom base**, admission is a `409` (`SharedBaseError`)
127
+ unless you pass `allowSharedBase: true`. The default branch is exempt — many epics target it
128
+ concurrently without colliding.
129
+
130
+ So the body may also carry two optional booleans — `confirmDefaultBase` and `allowSharedBase` —
131
+ each a "warn you can't skip" for its rule:
132
+
133
+ ```bash
134
+ curl -sS -X POST __BASE__/actions/start/plan-fanout \
135
+ -H 'content-type: application/json' \
136
+ -d '{ "issue": "owner/repo#123", "baseBranch": "main", "confirmDefaultBase": true }'
137
+ ```
138
+
139
+ Grandfathered: in-flight plans launched before this admission gate (with a `null` base branch)
140
+ keep running unchanged — the requirement is enforced at admission of **new** launches, not by a
141
+ database constraint.
142
+
107
143
  Track a plan the same way you track PRs — its `process_key` is an engine instance you
108
144
  can inspect in §5, and the PRs it opens show up in `/status` as ordinary convergence
109
145
  loops.