@nanobpm/nano-workforce 0.44.0 → 0.45.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.
- package/.github/workflows/ci.yml +7 -0
- package/CHANGELOG.md +14 -0
- package/SPEC.md +12 -6
- package/app/github.ts +9 -5
- package/app/persist-escalation.test.ts +12 -0
- package/app/persist-round.test.ts +17 -0
- package/app/plan.test.ts +119 -1
- package/app/plan.ts +78 -1
- package/app/service.test.ts +53 -1
- package/app/service.ts +57 -0
- package/db/migrations/018_round_worker.sql +9 -0
- package/db/migrations/019_plan_base_branch.sql +8 -0
- package/e2e/convergence-loop.e2e.ts +175 -0
- package/openapi.yaml +14 -0
- package/operations/startAndMessage.test.ts +12 -0
- package/operations/startPlanFanout.ts +22 -2
- package/package.json +5 -3
- package/pages/epic.page.json +3 -1
- package/pages/home.page.json +2 -0
- package/prompts/feature.md +14 -2
- package/prompts/fix-ci.md +11 -1
- package/prompts/rebase.md +11 -1
- package/prompts/review-round.md +14 -1
- package/resources/processes/plan-fanout.bpmn +1 -1
- package/tsconfig.json +2 -1
- package/workers/persist-escalation/worker.ts +10 -0
- package/workers/persist-round/worker.ts +9 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -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.45.0](https://github.com/nanobpm/nano-workforce/compare/v0.44.1...v0.45.0) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **plan:** pin an epic's base branch so the fleet lands on an integration branch ([#125](https://github.com/nanobpm/nano-workforce/issues/125)) ([1c3bfa1](https://github.com/nanobpm/nano-workforce/commit/1c3bfa1a1dbc212e31e510931766f46904dae1a3)), closes [#124](https://github.com/nanobpm/nano-workforce/issues/124) [nanobpm/nano-workforce#124](https://github.com/nanobpm/nano-workforce/issues/124)
|
|
7
|
+
|
|
8
|
+
## [0.44.1](https://github.com/nanobpm/nano-workforce/compare/v0.44.0...v0.44.1) (2026-08-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* 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))
|
|
14
|
+
|
|
1
15
|
# [0.44.0](https://github.com/nanobpm/nano-workforce/compare/v0.43.0...v0.44.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`/`
|
|
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).
|
|
187
|
-
|
|
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
|
|
|
@@ -510,9 +515,10 @@ the loop runs one parallel `implement` MI fan-out per wave:
|
|
|
510
515
|
|
|
511
516
|
- **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
|
|
512
517
|
integration provisions the repo and checks out the PR's head branch (it must
|
|
513
|
-
already give the worker repo access to work at all)
|
|
514
|
-
|
|
515
|
-
|
|
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.
|
|
516
522
|
- **review-ready via GitHub webhook** — same message, swappable faster trigger,
|
|
517
523
|
when the app is publicly reachable. Deferred (poller-only for v1).
|
|
518
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.
|
package/app/plan.test.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
|
|
6
6
|
// is not a positive integer, so the loop is always bounded.
|
|
7
7
|
import { test } from "node:test";
|
|
8
|
-
import { assertEquals } from "#test-assert";
|
|
8
|
+
import { assertEquals, assertThrows } from "#test-assert";
|
|
9
9
|
import { positiveIntEnv } from "./plan.ts";
|
|
10
10
|
|
|
11
11
|
const KEY = "NANO_PLAN_REVIEW_ROUNDS_TEST";
|
|
@@ -292,3 +292,121 @@ test("answerTaskEscalation is a no-op when no open escalation matches the correl
|
|
|
292
292
|
const r = await answerTaskEscalation(data, engine, "owner/repo#9:missing", "x");
|
|
293
293
|
assertEquals(r.ok, false);
|
|
294
294
|
});
|
|
295
|
+
|
|
296
|
+
// Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql).
|
|
297
|
+
//
|
|
298
|
+
// A plan may pin a base branch so the fleet branches off — and opens every PR against — a long-lived
|
|
299
|
+
// integration branch instead of the repo default, keeping an epic off the default branch (and off any
|
|
300
|
+
// merge-to-default side effect such as auto-publishing) until the integration branch is deliberately
|
|
301
|
+
// merged. `normalizeBaseBranch` decides "unset" (fall back to default), `renderBaseBranchBrief` is the
|
|
302
|
+
// authoritative prompt override, and `startPlan` must persist the branch and seed BOTH the `baseBranch`
|
|
303
|
+
// variable and the `baseBranchBrief` (which rides `appendPrompt`) — or leave them null when unpinned.
|
|
304
|
+
import { InvalidBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
|
|
305
|
+
|
|
306
|
+
test("normalizeBaseBranch: blank/whitespace/undefined → null; a real branch is trimmed", () => {
|
|
307
|
+
assertEquals(normalizeBaseBranch(undefined), null);
|
|
308
|
+
assertEquals(normalizeBaseBranch(null), null);
|
|
309
|
+
assertEquals(normalizeBaseBranch(""), null);
|
|
310
|
+
assertEquals(normalizeBaseBranch(" "), null);
|
|
311
|
+
assertEquals(normalizeBaseBranch(" epic/agent-protocol "), "epic/agent-protocol");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("normalizeBaseBranch: accepts conservative git-branch shapes", () => {
|
|
315
|
+
assertEquals(normalizeBaseBranch("main"), "main");
|
|
316
|
+
assertEquals(normalizeBaseBranch("release-1.2"), "release-1.2");
|
|
317
|
+
assertEquals(normalizeBaseBranch("feature/x_y.z"), "feature/x_y.z");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("normalizeBaseBranch: rejects injection-prone / implausible branch names", () => {
|
|
321
|
+
// `baseBranch` is interpolated into an authoritative agent prompt that carries shell
|
|
322
|
+
// commands, so anything that isn't a plausible git ref must be rejected at the edge —
|
|
323
|
+
// not silently rendered into `git`/`gh` snippets or the prompt Markdown.
|
|
324
|
+
const bad = [
|
|
325
|
+
"foo bar", // whitespace
|
|
326
|
+
"-rf", // leading dash → looks like a CLI flag
|
|
327
|
+
"foo; rm -rf /", // shell metacharacters
|
|
328
|
+
"foo`whoami`", // command substitution
|
|
329
|
+
"foo$(id)", // command substitution
|
|
330
|
+
"foo\nbar", // newline → breaks rendered instructions
|
|
331
|
+
"foo..bar", // git-illegal double dot
|
|
332
|
+
"/foo", // leading slash
|
|
333
|
+
"foo/", // trailing slash
|
|
334
|
+
"foo.", // trailing dot
|
|
335
|
+
"foo//bar", // empty path component
|
|
336
|
+
"foo.lock", // git-reserved .lock suffix
|
|
337
|
+
"épée", // outside the conservative allowlist
|
|
338
|
+
];
|
|
339
|
+
for (const value of bad) {
|
|
340
|
+
assertThrows(() => normalizeBaseBranch(value), InvalidBaseBranchError);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("renderBaseBranchBrief names the branch in every instruction (branch-off, read, PR base)", () => {
|
|
345
|
+
const brief = renderBaseBranchBrief("epic/agent-protocol");
|
|
346
|
+
// Authoritative marker so it overrides the static "default branch" wording.
|
|
347
|
+
assertEquals(brief.includes("authoritative"), true);
|
|
348
|
+
assertEquals(brief.includes("git checkout -b feat/<task.id> origin/epic/agent-protocol"), true);
|
|
349
|
+
assertEquals(brief.includes("gh pr create --base epic/agent-protocol"), true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("startPlan pins the base branch: persisted on the row + seeded as baseBranch/baseBranchBrief variables", async () => {
|
|
353
|
+
const PLAN_KEY = "owner/repo#124";
|
|
354
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
355
|
+
plans: { rows: [], key: "plan_key" },
|
|
356
|
+
plan_tasks: { rows: [], key: "id" },
|
|
357
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
358
|
+
plan_escalations: { rows: [], key: "id" },
|
|
359
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
360
|
+
};
|
|
361
|
+
const data = memData(stores);
|
|
362
|
+
let seen: any = null;
|
|
363
|
+
const engine = {
|
|
364
|
+
createInstance: (req: any) => {
|
|
365
|
+
seen = req.variables;
|
|
366
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
367
|
+
},
|
|
368
|
+
} as any;
|
|
369
|
+
|
|
370
|
+
await startPlan(
|
|
371
|
+
data,
|
|
372
|
+
engine,
|
|
373
|
+
{ repo: "owner/repo", number: 124, url: "https://github.com/owner/repo/issues/124", planKey: PLAN_KEY },
|
|
374
|
+
" epic/agent-protocol ",
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// Persisted (trimmed) on the plan row for the epic UI + resume.
|
|
378
|
+
assertEquals((stores.plans.rows[0] as any).base_branch, "epic/agent-protocol");
|
|
379
|
+
// Process variables the implement-task consumes.
|
|
380
|
+
assertEquals(seen.baseBranch, "epic/agent-protocol");
|
|
381
|
+
assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("startPlan without a base branch keeps default-branch behaviour (null row + null variables)", async () => {
|
|
385
|
+
const PLAN_KEY = "owner/repo#200";
|
|
386
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
387
|
+
plans: { rows: [], key: "plan_key" },
|
|
388
|
+
plan_tasks: { rows: [], key: "id" },
|
|
389
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
390
|
+
plan_escalations: { rows: [], key: "id" },
|
|
391
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
392
|
+
};
|
|
393
|
+
const data = memData(stores);
|
|
394
|
+
let seen: any = null;
|
|
395
|
+
const engine = {
|
|
396
|
+
createInstance: (req: any) => {
|
|
397
|
+
seen = req.variables;
|
|
398
|
+
return Promise.resolve({ processInstanceKey: "PI-2" });
|
|
399
|
+
},
|
|
400
|
+
} as any;
|
|
401
|
+
|
|
402
|
+
await startPlan(data, engine, {
|
|
403
|
+
repo: "owner/repo",
|
|
404
|
+
number: 200,
|
|
405
|
+
url: "https://github.com/owner/repo/issues/200",
|
|
406
|
+
planKey: PLAN_KEY,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
assertEquals((stores.plans.rows[0] as any).base_branch, null);
|
|
410
|
+
assertEquals(seen.baseBranch, null);
|
|
411
|
+
assertEquals(seen.baseBranchBrief, null);
|
|
412
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface Plan {
|
|
|
51
51
|
// Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
|
|
52
52
|
// plans created before the blackboard shipped.
|
|
53
53
|
blackboard_token: string | null;
|
|
54
|
+
// Optional target base branch (019_plan_base_branch.sql): when set, the fleet branches off this
|
|
55
|
+
// branch and opens every task PR against it instead of the repository's default branch, landing
|
|
56
|
+
// the whole epic on a long-lived integration branch. NULL keeps the default-branch behaviour.
|
|
57
|
+
base_branch: string | null;
|
|
54
58
|
created_at: string;
|
|
55
59
|
updated_at: string;
|
|
56
60
|
}
|
|
@@ -185,14 +189,79 @@ export function parseIssue(input: string): ParsedIssue | null {
|
|
|
185
189
|
return null;
|
|
186
190
|
}
|
|
187
191
|
|
|
192
|
+
/** Raised when a caller supplies a `baseBranch` that isn't a plausible git branch name. The
|
|
193
|
+
* value is interpolated into the authoritative implementer prompt (which carries `git`/`gh`
|
|
194
|
+
* shell snippets and inline-code Markdown), so a non-ref value could break the rendered
|
|
195
|
+
* instructions or smuggle in a command/prompt fragment — reject it at the edge instead. */
|
|
196
|
+
export class InvalidBaseBranchError extends Error {
|
|
197
|
+
readonly value: string;
|
|
198
|
+
constructor(value: string) {
|
|
199
|
+
super(`invalid base branch name: ${JSON.stringify(value)}`);
|
|
200
|
+
this.name = "InvalidBaseBranchError";
|
|
201
|
+
this.value = value;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
|
|
206
|
+
* purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
|
|
207
|
+
* no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
|
|
208
|
+
* This rejects whitespace, shell metacharacters, command substitution, and newlines outright. */
|
|
209
|
+
function isPlausibleBranchName(s: string): boolean {
|
|
210
|
+
if (s.length === 0 || s.length > 255) return false;
|
|
211
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(s)) return false;
|
|
212
|
+
if (/^[/.-]/.test(s) || /[/.]$/.test(s)) return false;
|
|
213
|
+
if (s.includes("..") || s.includes("//")) return false;
|
|
214
|
+
return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Normalise a caller-supplied base branch: trim, and treat blank as "unset" (null) so the fleet
|
|
218
|
+
* falls back to the repository's default branch — the legacy behaviour. A non-blank value that is
|
|
219
|
+
* not a plausible git branch name is rejected (`InvalidBaseBranchError`) rather than persisted or
|
|
220
|
+
* rendered into the agent prompt; the operation edge maps that to a 400. */
|
|
221
|
+
export function normalizeBaseBranch(input: string | null | undefined): string | null {
|
|
222
|
+
const s = (input ?? "").trim();
|
|
223
|
+
if (s.length === 0) return null;
|
|
224
|
+
if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
|
|
225
|
+
return s;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The per-instance brief appended to an implementer agent's prompt when the plan pins a base
|
|
229
|
+
* branch. It is authoritative over the static "branch off the default branch" wording in
|
|
230
|
+
* prompts/feature.md, so the agent branches off — and opens its PR against — the integration
|
|
231
|
+
* branch, and reads the epic's latest landed state there rather than the repo default branch. */
|
|
232
|
+
export function renderBaseBranchBrief(baseBranch: string): string {
|
|
233
|
+
return [
|
|
234
|
+
"",
|
|
235
|
+
"",
|
|
236
|
+
"---",
|
|
237
|
+
"",
|
|
238
|
+
`**Base branch (authoritative — overrides any "default branch" instruction above): \`${baseBranch}\`.**`,
|
|
239
|
+
"",
|
|
240
|
+
`This epic lands on \`${baseBranch}\`, NOT the repository default branch. Everywhere the`,
|
|
241
|
+
"instructions say \"default branch\", use this branch instead:",
|
|
242
|
+
"",
|
|
243
|
+
`- Branch off it: \`git fetch origin ${baseBranch} && git checkout -b feat/<task.id> origin/${baseBranch}\`.`,
|
|
244
|
+
`- Read the epic's latest landed state from \`${baseBranch}\` (your prerequisites merged there, not into the default branch).`,
|
|
245
|
+
`- Open your PR against it: \`gh pr create --base ${baseBranch} ...\`.`,
|
|
246
|
+
"",
|
|
247
|
+
"Do not target the repository default branch — a PR opened against it will not be merged into the epic.",
|
|
248
|
+
].join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
188
251
|
/** Register a plan row (if new) and start the plan-fanout process. Idempotent on
|
|
189
252
|
* planKey: a plan already in flight is not restarted. */
|
|
190
|
-
export async function startPlan(
|
|
253
|
+
export async function startPlan(
|
|
254
|
+
data: DataLayer,
|
|
255
|
+
engine: EngineClient,
|
|
256
|
+
parsed: ParsedIssue,
|
|
257
|
+
baseBranch: string | null = null,
|
|
258
|
+
) {
|
|
191
259
|
const table = plans(data);
|
|
192
260
|
const existing = await table.get(parsed.planKey);
|
|
193
261
|
if (existing && !PLAN_TERMINAL_STATUSES.includes(existing.status)) {
|
|
194
262
|
return { planKey: parsed.planKey, alreadyRunning: true };
|
|
195
263
|
}
|
|
264
|
+
const base = normalizeBaseBranch(baseBranch);
|
|
196
265
|
const ts = now();
|
|
197
266
|
// Mint (or reuse, on a re-plan) this plan's blackboard capability token, and render the
|
|
198
267
|
// coordination brief that carries its concrete URL. The token is the credential; agents reach
|
|
@@ -235,6 +304,7 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
|
|
|
235
304
|
open_task_corr_key: null,
|
|
236
305
|
open_task_id: null,
|
|
237
306
|
blackboard_token: token,
|
|
307
|
+
base_branch: base,
|
|
238
308
|
updated_at: ts,
|
|
239
309
|
});
|
|
240
310
|
} else {
|
|
@@ -246,6 +316,7 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
|
|
|
246
316
|
status: "planning",
|
|
247
317
|
task_count: 0,
|
|
248
318
|
blackboard_token: token,
|
|
319
|
+
base_branch: base,
|
|
249
320
|
created_at: ts,
|
|
250
321
|
updated_at: ts,
|
|
251
322
|
});
|
|
@@ -265,6 +336,12 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
|
|
|
265
336
|
// out-of-band.
|
|
266
337
|
blackboardUrl: bbUrl,
|
|
267
338
|
blackboardBrief: renderCoordinationBrief(bbUrl),
|
|
339
|
+
// Optional epic base branch (019_plan_base_branch.sql): the branch the fleet branches off and
|
|
340
|
+
// opens every PR against instead of the repo default. `baseBranchBrief` rides `appendPrompt`
|
|
341
|
+
// in the implement-task (like `blackboardBrief`); both are null when no base branch is pinned,
|
|
342
|
+
// so the agent keeps the default-branch behaviour from prompts/feature.md.
|
|
343
|
+
baseBranch: base,
|
|
344
|
+
baseBranchBrief: base == null ? null : renderBaseBranchBrief(base),
|
|
268
345
|
},
|
|
269
346
|
});
|
|
270
347
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
package/app/service.test.ts
CHANGED
|
@@ -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,55 @@ 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
|
+
});
|
|
350
|
+
|
|
351
|
+
test("repoEnvelopeVars emits nothing for a malformed repo (not owner/repo)", () => {
|
|
352
|
+
// Defence in depth: a repo that isn't exactly `owner/repo` would build a bogus clone URL, so the
|
|
353
|
+
// helper emits no envelope (harness falls back to the launch dir) rather than a malformed URL.
|
|
354
|
+
for (const bad of [
|
|
355
|
+
"",
|
|
356
|
+
"noslash",
|
|
357
|
+
"a/b/c",
|
|
358
|
+
"owner /repo",
|
|
359
|
+
"owner/re po",
|
|
360
|
+
"/repo",
|
|
361
|
+
"owner/",
|
|
362
|
+
// A trailing `.git` would build a double-suffixed clone URL (…/owner/repo.git.git).
|
|
363
|
+
"owner/repo.git",
|
|
364
|
+
"owner/repo.GIT",
|
|
365
|
+
// Query/fragment/host-injection characters must never reach the clone URL.
|
|
366
|
+
"owner/repo?x",
|
|
367
|
+
"owner/repo#frag",
|
|
368
|
+
"owner/repo:x",
|
|
369
|
+
"owner/re~po",
|
|
370
|
+
// Owner is a GitHub login: no dots or underscores allowed there.
|
|
371
|
+
"own.er/repo",
|
|
372
|
+
"own_er/repo",
|
|
373
|
+
]) {
|
|
374
|
+
assertEquals(Object.keys(repoEnvelopeVars(bad, "feat/x")).length, 0, `expected no envelope for "${bad}"`);
|
|
375
|
+
}
|
|
376
|
+
// Well-formed repos still emit (guard is not over-eager): hyphens, dots and underscores
|
|
377
|
+
// are legal in the repo-name segment, mixed case is preserved.
|
|
378
|
+
for (const good of ["owner/repo", "my-org/my.repo", "Owner123/Repo_2", "a-b/c-d"]) {
|
|
379
|
+
assertEquals(
|
|
380
|
+
((repoEnvelopeVars(good, "feat/x") as any)["io.nanobpm.agentTask"].repository.url),
|
|
381
|
+
`https://github.com/${good}.git`,
|
|
382
|
+
`expected envelope for "${good}"`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
});
|
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,35 @@ 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
|
+
// Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
|
|
296
|
+
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
297
|
+
// that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
|
|
298
|
+
// then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
|
|
299
|
+
// The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
|
|
300
|
+
// `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
|
|
301
|
+
// `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
|
|
302
|
+
if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
|
|
303
|
+
return {
|
|
304
|
+
[AGENT_TASK_NS]: {
|
|
305
|
+
repository: { provider: "github", url: `https://github.com/${repo}.git`, ref },
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
280
310
|
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
281
311
|
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
282
312
|
* recorded as the PR's merge-stage dependency set. */
|
|
@@ -298,16 +328,23 @@ export async function submitPr(
|
|
|
298
328
|
// A transport failure (no gh/token) must not block submission — we just skip enrichment.
|
|
299
329
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
300
330
|
let title: string | null = null;
|
|
331
|
+
let headRef: string | null = null;
|
|
301
332
|
const depKeys = new Set(dependsOn.map((d) => parsePr(d)?.prKey).filter((k): k is string => !!k));
|
|
302
333
|
try {
|
|
303
334
|
const meta = await fetchPrMeta(parsed.repo, parsed.number, token);
|
|
304
335
|
if (meta) {
|
|
305
336
|
title = meta.title;
|
|
337
|
+
headRef = meta.headRef;
|
|
306
338
|
for (const k of parseDependsOn(meta.body)) depKeys.add(k);
|
|
307
339
|
}
|
|
308
340
|
} catch (err) {
|
|
309
341
|
console.warn(`[submit] ${parsed.prKey} meta fetch: ${err}`);
|
|
310
342
|
}
|
|
343
|
+
if (!headRef) {
|
|
344
|
+
// Without the head branch the harness can't check out the PR; the review agent then falls
|
|
345
|
+
// back to the worker's launch dir (the legacy behavior) and escalates if it isn't a checkout.
|
|
346
|
+
console.warn(`[submit] ${parsed.prKey} head branch unresolved — agent workspace won't be provisioned`);
|
|
347
|
+
}
|
|
311
348
|
await registerDependencies(data, parsed.prKey, [...depKeys]);
|
|
312
349
|
|
|
313
350
|
const ts = now();
|
|
@@ -375,6 +412,10 @@ export async function submitPr(
|
|
|
375
412
|
// review-round agent's prompt, so it can stop before pushing if the run is cancelled.
|
|
376
413
|
abandonUrl: abUrl,
|
|
377
414
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
415
|
+
// Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
|
|
416
|
+
// harness clones an isolated workspace checked out on the PR head branch. Spread last so an
|
|
417
|
+
// unresolved head (`{}`) leaves the other vars untouched.
|
|
418
|
+
...repoEnvelopeVars(parsed.repo, headRef),
|
|
378
419
|
},
|
|
379
420
|
});
|
|
380
421
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -400,6 +441,19 @@ export async function startMerge(
|
|
|
400
441
|
await prs(data).update(pr.prKey, { abandon_token: abandonToken, updated_at: now() });
|
|
401
442
|
}
|
|
402
443
|
const abUrl = abandonUrl(abandonToken);
|
|
444
|
+
// Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
|
|
445
|
+
// out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
|
|
446
|
+
// means the envelope is omitted and the agent falls back to the worker's launch dir.
|
|
447
|
+
const token = process.env.GITHUB_TOKEN ?? "";
|
|
448
|
+
let headRef: string | null = null;
|
|
449
|
+
try {
|
|
450
|
+
headRef = (await fetchPrHead(pr.repo, pr.number, token))?.headRef ?? null;
|
|
451
|
+
} catch (err) {
|
|
452
|
+
console.warn(`[startMerge] ${pr.prKey} head branch fetch: ${err}`);
|
|
453
|
+
}
|
|
454
|
+
if (!headRef) {
|
|
455
|
+
console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
|
|
456
|
+
}
|
|
403
457
|
const { processInstanceKey } = await engine.createInstance({
|
|
404
458
|
processDefinitionId: MERGE_PROCESS_ID,
|
|
405
459
|
variables: {
|
|
@@ -414,6 +468,9 @@ export async function startMerge(
|
|
|
414
468
|
rebaseMax: MAX_REBASE_ROUNDS,
|
|
415
469
|
abandonUrl: abUrl,
|
|
416
470
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
471
|
+
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
472
|
+
// fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
|
|
473
|
+
...repoEnvelopeVars(pr.repo, headRef),
|
|
417
474
|
},
|
|
418
475
|
});
|
|
419
476
|
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,8 @@
|
|
|
1
|
+
-- Per-plan target base branch (epic base-branch control). When set, the fleet branches off this
|
|
2
|
+
-- branch and opens every task PR against it instead of the repository's default branch, so an
|
|
3
|
+
-- entire epic can land on a long-lived integration branch (e.g. `epic/agent-protocol`) and reach
|
|
4
|
+
-- the default branch — and any merge-to-default side effect such as auto-publishing a package —
|
|
5
|
+
-- only when the integration branch is deliberately merged. NULL keeps the legacy behaviour (the
|
|
6
|
+
-- repo default branch), so pre-migration plans are unaffected.
|
|
7
|
+
|
|
8
|
+
ALTER TABLE plans ADD COLUMN base_branch 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/openapi.yaml
CHANGED
|
@@ -271,6 +271,15 @@ components:
|
|
|
271
271
|
issue:
|
|
272
272
|
type: string
|
|
273
273
|
description: "Issue reference: owner/repo#123."
|
|
274
|
+
baseBranch:
|
|
275
|
+
type: string
|
|
276
|
+
description: >-
|
|
277
|
+
Optional target branch the fleet branches off and opens every PR against, instead of the
|
|
278
|
+
repository's default branch. Use this to land an entire epic on a long-lived integration
|
|
279
|
+
branch (e.g. `epic/agent-protocol`) so nothing reaches the default branch — and any
|
|
280
|
+
merge-to-default side effect, such as auto-publishing a package — until you deliberately
|
|
281
|
+
merge the integration branch. Blank/omitted keeps the current behaviour (the repo
|
|
282
|
+
default branch).
|
|
274
283
|
PlanStartByUrl:
|
|
275
284
|
type: object
|
|
276
285
|
additionalProperties: false
|
|
@@ -280,6 +289,11 @@ components:
|
|
|
280
289
|
url:
|
|
281
290
|
type: string
|
|
282
291
|
description: A bare issue URL, when no `owner/repo#123` reference is supplied.
|
|
292
|
+
baseBranch:
|
|
293
|
+
type: string
|
|
294
|
+
description: >-
|
|
295
|
+
Optional target branch the fleet branches off and opens every PR against, instead of the
|
|
296
|
+
repository's default branch. See `PlanStartByIssue.baseBranch`.
|
|
283
297
|
MessageResult:
|
|
284
298
|
type: object
|
|
285
299
|
description: The result of publishing a message / answering an escalation. Shape varies by message
|
|
@@ -121,6 +121,18 @@ test("startPlanFanout → 400 (not 500) on a missing request body", async () =>
|
|
|
121
121
|
assertEquals(typeof r.body.error, "string");
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
test("startPlanFanout → 400 on an invalid baseBranch (not persisted/rendered)", async () => {
|
|
125
|
+
// A non-blank baseBranch that isn't a plausible git branch name (shell metacharacters here)
|
|
126
|
+
// must be rejected at the edge as a 400 — never persisted or interpolated into the agent prompt.
|
|
127
|
+
const res = await startPlanFanout(
|
|
128
|
+
input({ issue: "owner/repo#123", baseBranch: "epic/agent; rm -rf /" }),
|
|
129
|
+
app,
|
|
130
|
+
);
|
|
131
|
+
const r = res as any;
|
|
132
|
+
assertEquals(r.status, 400);
|
|
133
|
+
assertEquals(typeof r.body.error, "string");
|
|
134
|
+
});
|
|
135
|
+
|
|
124
136
|
test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () => {
|
|
125
137
|
await withGithubOff(async () => {
|
|
126
138
|
const { app: capApp } = captureApp();
|
|
@@ -10,7 +10,7 @@
|
|
|
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 { parseIssue, startPlan } from "../app/plan.ts";
|
|
13
|
+
import { InvalidBaseBranchError, normalizeBaseBranch, parseIssue, startPlan } from "../app/plan.ts";
|
|
14
14
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
15
15
|
|
|
16
16
|
export default defineOperation("startPlanFanout", async ({ body }, app) => {
|
|
@@ -26,9 +26,29 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
|
|
|
26
26
|
app.log.warn("start-plan rejected: unparseable issue reference", { raw });
|
|
27
27
|
return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
|
|
28
28
|
}
|
|
29
|
-
|
|
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;
|
|
36
|
+
try {
|
|
37
|
+
normalizedBase = normalizeBaseBranch(baseBranch);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (err instanceof InvalidBaseBranchError) {
|
|
40
|
+
app.log.warn("start-plan rejected: invalid base branch", { baseBranch: err.value });
|
|
41
|
+
return {
|
|
42
|
+
status: 400,
|
|
43
|
+
body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. epic/agent-protocol)" },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
const result = await startPlan(app.data, app.engine, parsed, normalizedBase);
|
|
30
49
|
app.log.info("plan fan-out started", {
|
|
31
50
|
planKey: parsed.planKey,
|
|
51
|
+
baseBranch: normalizedBase ?? "(default branch)",
|
|
32
52
|
alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
|
|
33
53
|
});
|
|
34
54
|
return { status: 202, body: result };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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",
|
|
@@ -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
|
-
"
|
|
45
|
-
"lint
|
|
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",
|
package/pages/epic.page.json
CHANGED
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"submitLabel": "Plan & implement",
|
|
36
36
|
"action": { "path": "/app/api/actions/start/plan-fanout", "body": "{{form}}" },
|
|
37
37
|
"fields": [
|
|
38
|
-
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" }
|
|
38
|
+
{ "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
|
|
39
|
+
{ "key": "baseBranch", "label": "Base branch (blank = repo default; e.g. epic/agent-protocol to land the whole epic on an integration branch)", "type": "text" }
|
|
39
40
|
]
|
|
40
41
|
}
|
|
41
42
|
},
|
|
@@ -76,6 +77,7 @@
|
|
|
76
77
|
"fields": [
|
|
77
78
|
{ "field": "repo", "label": "Repository" },
|
|
78
79
|
{ "field": "issue_number", "label": "Issue number" },
|
|
80
|
+
{ "field": "base_branch", "label": "Base branch (blank = repo default)" },
|
|
79
81
|
{ "field": "outcome", "label": "Outcome" },
|
|
80
82
|
{ "field": "open_task_question", "label": "Open escalation question" }
|
|
81
83
|
]
|
package/pages/home.page.json
CHANGED
|
@@ -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/feature.md
CHANGED
|
@@ -26,14 +26,26 @@ process with no memory of your last run, the branch name MUST be derivable from
|
|
|
26
26
|
(`git ls-remote --heads origin feat/<task.id>` or
|
|
27
27
|
`gh pr list --head feat/<task.id> --state all`):
|
|
28
28
|
|
|
29
|
-
- **It does not exist** → this is a first run. Branch off the
|
|
29
|
+
- **It does not exist** → this is a first run. Branch off the base branch (see
|
|
30
|
+
the note below — usually the repository default branch, but an epic may pin an
|
|
31
|
+
integration branch in your appended task context).
|
|
30
32
|
- **It exists** → this is a **resume**. `git fetch` and check it out, read its diff
|
|
31
33
|
and any open (draft) PR, and **continue from there** — do not restart from
|
|
32
34
|
scratch. Fold in `variables.answer` as the guidance you were waiting on.
|
|
33
35
|
|
|
36
|
+
## Your base branch (default branch, unless the epic pins one)
|
|
37
|
+
|
|
38
|
+
Branch off — and open your PR against — the repository's **default branch**,
|
|
39
|
+
UNLESS your appended task context carries a **"Base branch (authoritative)"**
|
|
40
|
+
note pinning an epic integration branch. When it does, that branch wins
|
|
41
|
+
everywhere below: branch off `origin/<that branch>`, read the epic's latest
|
|
42
|
+
landed state there, and pass `gh pr create --base <that branch>`. A PR opened
|
|
43
|
+
against the wrong base will not be merged into the epic.
|
|
44
|
+
|
|
34
45
|
## What to do
|
|
35
46
|
|
|
36
|
-
1. Clone / check out
|
|
47
|
+
1. Clone / check out your base branch (first run — the default branch, or the
|
|
48
|
+
pinned epic branch if your context names one) or your existing
|
|
37
49
|
`feat/<task.id>` branch (resume — see above).
|
|
38
50
|
2. Implement `task.prompt`. Keep the change scoped to this slice only.
|
|
39
51
|
3. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the
|
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 (
|
|
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
|
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 (
|
|
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
|
package/prompts/review-round.md
CHANGED
|
@@ -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`, `
|
|
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:
|
|
@@ -50,11 +50,21 @@ Because several agents may run on the same host at once:
|
|
|
50
50
|
|
|
51
51
|
1. **Read the latest review.** Fetch the newest Copilot review + its inline
|
|
52
52
|
comments on the PR (`gh pr view`, `gh api .../pulls/{n}/reviews`, `.../comments`).
|
|
53
|
+
Also read Copilot's **suppressed / low-confidence** advisories — the collapsed
|
|
54
|
+
"low confidence" list Copilot folds into the **review body** (`.../reviews`
|
|
55
|
+
`body`). These are NOT in the default inline-comment API set, so a plain
|
|
56
|
+
`.../comments` read misses them; scan the review body for them explicitly.
|
|
53
57
|
If `answer` is present, treat it as the human's decision on the escalation you
|
|
54
58
|
raised last round and act on it first.
|
|
55
59
|
2. **Triage each comment** into: *fix* (correct, worth doing), *nitpick* (apply
|
|
56
60
|
silently), *needs human input* (design/product/tradeoff you can't decide), or
|
|
57
61
|
*push back* (wrong / false positive — reply with evidence, make no change).
|
|
62
|
+
Triage the suppressed / low-confidence advisories the **same** way — but do not
|
|
63
|
+
treat "suppressed" as either automatically actionable or automatically ignorable:
|
|
64
|
+
if one is a **cheap, correct** robustness/correctness win, just do it (a
|
|
65
|
+
*nitpick*); otherwise **decline it explicitly with a one-line rationale in your
|
|
66
|
+
`summary`** (e.g. "declined suppressed advisory X — input already validated
|
|
67
|
+
upstream at Y"). Never silently drop one.
|
|
58
68
|
3. **Act.** Make the code changes for all fixes + nitpicks in your workspace (`cwd`)
|
|
59
69
|
in one coherent, signed-off commit (`git commit -s`). Run the repo's
|
|
60
70
|
build/test/lint locally before pushing. Push to the PR's head branch (the branch
|
|
@@ -107,6 +117,9 @@ Consider the PR **converged** when the latest review has no actionable comment:
|
|
|
107
117
|
comments") and there are no new inline comments, **or**
|
|
108
118
|
- every new comment is a nitpick you already handled or intentionally declined,
|
|
109
119
|
**or**
|
|
120
|
+
- the only remaining items are suppressed / low-confidence advisories you have
|
|
121
|
+
triaged and either applied or declined-with-rationale (a suppressed advisory
|
|
122
|
+
you have recorded a decision on does **not** block convergence), **or**
|
|
110
123
|
- Copilot is looping — reiterating a point you already addressed or pushed back
|
|
111
124
|
on (two rounds of the same substantive point = converged).
|
|
112
125
|
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
<zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{feature}}" />
|
|
94
94
|
</zeebe:taskHeaders>
|
|
95
95
|
<zeebe:ioMapping>
|
|
96
|
-
<zeebe:input source="=" --- " + task.prompt + (if (blackboardBrief = null) then "" else blackboardBrief)" target="appendPrompt" />
|
|
96
|
+
<zeebe:input source="=" --- " + task.prompt + (if (blackboardBrief = null) then "" else blackboardBrief) + (if (baseBranchBrief = null) then "" else baseBranchBrief)" target="appendPrompt" />
|
|
97
97
|
</zeebe:ioMapping>
|
|
98
98
|
</bpmn:extensionElements>
|
|
99
99
|
<bpmn:incoming>w_toImpl</bpmn:incoming>
|
package/tsconfig.json
CHANGED
|
@@ -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
|
});
|