@nanobpm/nano-workforce 0.44.0 → 0.44.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +7 -0
- package/CHANGELOG.md +7 -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/service.test.ts +17 -1
- package/app/service.ts +49 -0
- package/db/migrations/018_round_worker.sql +9 -0
- package/e2e/convergence-loop.e2e.ts +175 -0
- package/package.json +5 -3
- package/pages/home.page.json +2 -0
- package/prompts/fix-ci.md +11 -1
- package/prompts/rebase.md +11 -1
- package/prompts/review-round.md +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,10 @@
|
|
|
1
|
+
## [0.44.1](https://github.com/nanobpm/nano-workforce/compare/v0.44.0...v0.44.1) (2026-08-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* provision isolated agent workspaces via the c8ctl repository envelope ([#124](https://github.com/nanobpm/nano-workforce/issues/124)) ([79c999b](https://github.com/nanobpm/nano-workforce/commit/79c999bc34dd7984ceffd7746a4538705565f2a5))
|
|
7
|
+
|
|
1
8
|
# [0.44.0](https://github.com/nanobpm/nano-workforce/compare/v0.43.0...v0.44.0) (2026-08-11)
|
|
2
9
|
|
|
3
10
|
|
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/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,19 @@ test("submitPr defaults convergeOnly to false so the global auto-merge default g
|
|
|
331
331
|
assertEquals(get(), false);
|
|
332
332
|
});
|
|
333
333
|
});
|
|
334
|
+
|
|
335
|
+
// The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
|
|
336
|
+
// emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
|
|
337
|
+
// checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness
|
|
338
|
+
// falls back to the legacy launch-dir behavior instead of cloning the wrong default branch).
|
|
339
|
+
test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch", () => {
|
|
340
|
+
const vars = repoEnvelopeVars("owner/repo", "feat/issue-12");
|
|
341
|
+
const env = (vars as any)["io.nanobpm.agentTask"];
|
|
342
|
+
assertEquals(env.repository.url, "https://github.com/owner/repo.git");
|
|
343
|
+
assertEquals(env.repository.ref, "feat/issue-12");
|
|
344
|
+
assertEquals(env.repository.provider, "github");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
|
|
348
|
+
assertEquals(Object.keys(repoEnvelopeVars("owner/repo", null)).length, 0);
|
|
349
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
|
12
12
|
import {
|
|
13
13
|
classifyMergeability,
|
|
14
14
|
ensureFreshHeadRun,
|
|
15
|
+
fetchPrHead,
|
|
15
16
|
fetchPrMeta,
|
|
16
17
|
fetchPrReviews,
|
|
17
18
|
fetchPrState,
|
|
@@ -277,6 +278,27 @@ async function registerDependencies(data: DataLayer, prKey: string, depKeys: str
|
|
|
277
278
|
}
|
|
278
279
|
}
|
|
279
280
|
|
|
281
|
+
/** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
|
|
282
|
+
* (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
|
|
283
|
+
const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
284
|
+
|
|
285
|
+
/** Build the repository slice of the agent-task envelope for a PR-based agent job (review-round,
|
|
286
|
+
* fix-ci, rebase). Delivered as a *process variable* under the reserved `io.nanobpm.agentTask`
|
|
287
|
+
* key so the harness provisions an isolated clone checked out on the PR's head branch — instead of
|
|
288
|
+
* the agent inheriting whatever directory the worker was launched from (which only happened to be
|
|
289
|
+
* a usable checkout for repos already present locally). `ref` MUST be the PR head branch; when it
|
|
290
|
+
* is unresolved we emit nothing (no `repository.url`) so the harness falls back to the legacy
|
|
291
|
+
* launch-dir behavior rather than silently cloning the repo's default branch. The static
|
|
292
|
+
* `task.prompt` header on the service task deep-merges with this over the same namespace. */
|
|
293
|
+
export function repoEnvelopeVars(repo: string, ref: string | null): Record<string, unknown> {
|
|
294
|
+
if (!ref) return {};
|
|
295
|
+
return {
|
|
296
|
+
[AGENT_TASK_NS]: {
|
|
297
|
+
repository: { provider: "github", url: `https://github.com/${repo}.git`, ref },
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
280
302
|
/** Register a PR row (if new) and start the convergence process. Idempotent on prKey. Optional
|
|
281
303
|
* `dependsOn` (explicit refs) is unioned with any `Depends-on:` line parsed from the PR body and
|
|
282
304
|
* recorded as the PR's merge-stage dependency set. */
|
|
@@ -298,16 +320,23 @@ export async function submitPr(
|
|
|
298
320
|
// A transport failure (no gh/token) must not block submission — we just skip enrichment.
|
|
299
321
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
300
322
|
let title: string | null = null;
|
|
323
|
+
let headRef: string | null = null;
|
|
301
324
|
const depKeys = new Set(dependsOn.map((d) => parsePr(d)?.prKey).filter((k): k is string => !!k));
|
|
302
325
|
try {
|
|
303
326
|
const meta = await fetchPrMeta(parsed.repo, parsed.number, token);
|
|
304
327
|
if (meta) {
|
|
305
328
|
title = meta.title;
|
|
329
|
+
headRef = meta.headRef;
|
|
306
330
|
for (const k of parseDependsOn(meta.body)) depKeys.add(k);
|
|
307
331
|
}
|
|
308
332
|
} catch (err) {
|
|
309
333
|
console.warn(`[submit] ${parsed.prKey} meta fetch: ${err}`);
|
|
310
334
|
}
|
|
335
|
+
if (!headRef) {
|
|
336
|
+
// Without the head branch the harness can't check out the PR; the review agent then falls
|
|
337
|
+
// back to the worker's launch dir (the legacy behavior) and escalates if it isn't a checkout.
|
|
338
|
+
console.warn(`[submit] ${parsed.prKey} head branch unresolved — agent workspace won't be provisioned`);
|
|
339
|
+
}
|
|
311
340
|
await registerDependencies(data, parsed.prKey, [...depKeys]);
|
|
312
341
|
|
|
313
342
|
const ts = now();
|
|
@@ -375,6 +404,10 @@ export async function submitPr(
|
|
|
375
404
|
// review-round agent's prompt, so it can stop before pushing if the run is cancelled.
|
|
376
405
|
abandonUrl: abUrl,
|
|
377
406
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
407
|
+
// Host-git provisioning (c8ctl): deliver the repository envelope so the `senior:pr-review`
|
|
408
|
+
// harness clones an isolated workspace checked out on the PR head branch. Spread last so an
|
|
409
|
+
// unresolved head (`{}`) leaves the other vars untouched.
|
|
410
|
+
...repoEnvelopeVars(parsed.repo, headRef),
|
|
378
411
|
},
|
|
379
412
|
});
|
|
380
413
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -400,6 +433,19 @@ export async function startMerge(
|
|
|
400
433
|
await prs(data).update(pr.prKey, { abandon_token: abandonToken, updated_at: now() });
|
|
401
434
|
}
|
|
402
435
|
const abUrl = abandonUrl(abandonToken);
|
|
436
|
+
// Resolve the PR head branch so the merge agents (fix-ci, rebase) get an isolated clone checked
|
|
437
|
+
// out on it (same host-git provisioning path as review-round). Best-effort: an unresolved head
|
|
438
|
+
// means the envelope is omitted and the agent falls back to the worker's launch dir.
|
|
439
|
+
const token = process.env.GITHUB_TOKEN ?? "";
|
|
440
|
+
let headRef: string | null = null;
|
|
441
|
+
try {
|
|
442
|
+
headRef = (await fetchPrHead(pr.repo, pr.number, token))?.headRef ?? null;
|
|
443
|
+
} catch (err) {
|
|
444
|
+
console.warn(`[startMerge] ${pr.prKey} head branch fetch: ${err}`);
|
|
445
|
+
}
|
|
446
|
+
if (!headRef) {
|
|
447
|
+
console.warn(`[startMerge] ${pr.prKey} head branch unresolved — merge-agent workspace won't be provisioned`);
|
|
448
|
+
}
|
|
403
449
|
const { processInstanceKey } = await engine.createInstance({
|
|
404
450
|
processDefinitionId: MERGE_PROCESS_ID,
|
|
405
451
|
variables: {
|
|
@@ -414,6 +460,9 @@ export async function startMerge(
|
|
|
414
460
|
rebaseMax: MAX_REBASE_ROUNDS,
|
|
415
461
|
abandonUrl: abUrl,
|
|
416
462
|
abandonBrief: renderAbandonBrief(abUrl),
|
|
463
|
+
// Host-git provisioning (c8ctl): same repository envelope as the convergence loop, so the
|
|
464
|
+
// fix-ci/rebase agents operate on an isolated checkout of the PR head branch.
|
|
465
|
+
...repoEnvelopeVars(pr.repo, headRef),
|
|
417
466
|
},
|
|
418
467
|
});
|
|
419
468
|
if (processInstanceKey != null) {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Identify the servicing worker on the durable history. The c8ctl harness completes each agent
|
|
2
|
+
-- job with an `agent` variable (its profile name, e.g. `senior`), which propagates to the
|
|
3
|
+
-- downstream `pr.persist-round` / `pr.persist-escalation` jobs. Recording it next to the round /
|
|
4
|
+
-- escalation the agent produced lets a human tell *which* worker did the work when reading the
|
|
5
|
+
-- transcript — without cross-referencing the transient `pull_requests.active_worker` lease (which
|
|
6
|
+
-- is cleared once the agent finishes).
|
|
7
|
+
|
|
8
|
+
ALTER TABLE rounds ADD COLUMN worker TEXT;
|
|
9
|
+
ALTER TABLE escalations ADD COLUMN worker TEXT;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// End-to-end pilot for @nanobpm/urban-testkit (nano-ide issue #157, slice S3).
|
|
2
|
+
//
|
|
3
|
+
// Boots this whole Urban app in-process against the WASM engine and a virtual clock via
|
|
4
|
+
// `bootTestApp`, then drives its real ADR-0059 OpenAPI operations by `operationId` — the same
|
|
5
|
+
// spec-driven `/app/api/*` surface a browser, a CI relay, or Swagger hit in production. No socket
|
|
6
|
+
// is opened, no wall-clock is waited on, and no GitHub network is touched.
|
|
7
|
+
//
|
|
8
|
+
// Network isolation: the app's GitHub transport (app/github.ts) is forced to `token` mode with no
|
|
9
|
+
// token, so every best-effort GitHub read short-circuits to `null`/idle instead of reaching out.
|
|
10
|
+
// That keeps the pilot hermetic and deterministic in CI.
|
|
11
|
+
//
|
|
12
|
+
// Run with `npm run e2e` (a dedicated node:test invocation, kept out of the fast unit `npm test`).
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { after, before, describe, test } from "node:test";
|
|
20
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
21
|
+
|
|
22
|
+
// The app root is this repo's root (one level up from `e2e/`) — where nano.app.json + openapi.yaml
|
|
23
|
+
// + db/migrations + resources/processes live.
|
|
24
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
|
+
|
|
26
|
+
// Provision the app's SQLite in a throwaway temp dir so the pilot never touches (or leaks into) the
|
|
27
|
+
// repo's real ./app.db, and every run starts from a freshly-migrated, empty schema.
|
|
28
|
+
const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-e2e-"));
|
|
29
|
+
|
|
30
|
+
// Derive the reconciler's poll interval from the app manifest (its single source of truth) rather
|
|
31
|
+
// than hard-coding it, so this test tracks nano.app.json instead of duplicating the value: a change
|
|
32
|
+
// to `pollMs` there stays correct here. Read the `pull_requests` instanceTracking entry's pollMs.
|
|
33
|
+
interface InstanceTrackingEntry {
|
|
34
|
+
table: string;
|
|
35
|
+
pollMs: number;
|
|
36
|
+
}
|
|
37
|
+
interface AppManifest {
|
|
38
|
+
instanceTracking?: InstanceTrackingEntry[];
|
|
39
|
+
}
|
|
40
|
+
const APP_MANIFEST: AppManifest = JSON.parse(
|
|
41
|
+
readFileSync(join(APP_ROOT, "nano.app.json"), "utf8"),
|
|
42
|
+
);
|
|
43
|
+
const PR_TRACKING = APP_MANIFEST.instanceTracking?.find((e) => e.table === "pull_requests");
|
|
44
|
+
assert.ok(PR_TRACKING, "nano.app.json declares a pull_requests instanceTracking entry");
|
|
45
|
+
const PR_POLL_MS = PR_TRACKING.pollMs;
|
|
46
|
+
|
|
47
|
+
// Force the app fully offline. github.ts reads `process.env` directly (not the harness env overlay),
|
|
48
|
+
// so seal the GitHub transport on process.env: `token` mode with no GITHUB_TOKEN means every
|
|
49
|
+
// best-effort GitHub read in submitPr short-circuits to null instead of shelling out to `gh`/fetch.
|
|
50
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
51
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
52
|
+
GITHUB_TOKEN: "",
|
|
53
|
+
};
|
|
54
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
55
|
+
|
|
56
|
+
// The harness `env` overlay drives the runtime's `${NANO_APP_DB_URL}` resolution — provision the
|
|
57
|
+
// app's SQLite in a throwaway temp dir so the pilot never touches (or leaks into) the repo's real
|
|
58
|
+
// ./app.db, and every run starts from a freshly-migrated, empty schema.
|
|
59
|
+
const HARNESS_ENV = {
|
|
60
|
+
NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
describe("nano-workforce e2e (urban-testkit pilot)", () => {
|
|
64
|
+
let app: TestApp;
|
|
65
|
+
|
|
66
|
+
before(async () => {
|
|
67
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
68
|
+
savedEnv.set(k, process.env[k]);
|
|
69
|
+
process.env[k] = v;
|
|
70
|
+
}
|
|
71
|
+
app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV });
|
|
72
|
+
// This app declares an `api` binding, so the spec-driven driver must be present.
|
|
73
|
+
assert.ok(app.api, "app.api driver should be defined (nano.app.json declares an `api` binding)");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
after(async () => {
|
|
77
|
+
await app?.stop();
|
|
78
|
+
for (const [k, v] of savedEnv) {
|
|
79
|
+
if (v === undefined) delete process.env[k];
|
|
80
|
+
else process.env[k] = v;
|
|
81
|
+
}
|
|
82
|
+
rmSync(DB_DIR, { recursive: true, force: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("drives the blackboard operations round-trip through the OpenAPI driver", async () => {
|
|
86
|
+
const api = app.api;
|
|
87
|
+
assert.ok(api);
|
|
88
|
+
|
|
89
|
+
// Seed a plan with a capability token — the credential the blackboard operations authorize on.
|
|
90
|
+
const token = "pilot-blackboard-token";
|
|
91
|
+
const planKey = "acme/widgets#7";
|
|
92
|
+
const nowIso = new Date(app.now()).toISOString();
|
|
93
|
+
await app.db.table("plans", "plan_key").insert({
|
|
94
|
+
plan_key: planKey,
|
|
95
|
+
repo: "acme/widgets",
|
|
96
|
+
issue_number: 7,
|
|
97
|
+
issue_url: "https://github.com/acme/widgets/issues/7",
|
|
98
|
+
title: "Pilot plan",
|
|
99
|
+
status: "planning",
|
|
100
|
+
task_count: 0,
|
|
101
|
+
blackboard_token: token,
|
|
102
|
+
created_at: nowIso,
|
|
103
|
+
updated_at: nowIso,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// POST an entry via the `appendBlackboard` operation (operationId → /app/api/hooks/blackboard).
|
|
107
|
+
const appended = await api.call<{ id: number; inserted: boolean }>("appendBlackboard", {
|
|
108
|
+
query: { token },
|
|
109
|
+
body: { author_task: "t1", kind: "note", body: "hello from the pilot" },
|
|
110
|
+
});
|
|
111
|
+
assert.equal(appended.status, 201, "append returns 201 Created");
|
|
112
|
+
assert.equal(appended.body.inserted, true, "entry was inserted");
|
|
113
|
+
assert.ok(Number.isFinite(appended.body.id), "append returns a numeric entry id");
|
|
114
|
+
|
|
115
|
+
// GET it back via `readBlackboard` — the entry the POST just wrote must be visible.
|
|
116
|
+
const read = await api.call<{ planKey: string; entries: Array<{ id: number; body: string }> }>(
|
|
117
|
+
"readBlackboard",
|
|
118
|
+
{ query: { token } },
|
|
119
|
+
);
|
|
120
|
+
assert.equal(read.status, 200, "read returns 200 OK");
|
|
121
|
+
assert.equal(read.body.planKey, planKey, "read is scoped to the seeded plan");
|
|
122
|
+
assert.equal(read.body.entries.length, 1, "exactly the one appended entry is returned");
|
|
123
|
+
assert.equal(read.body.entries[0].body, "hello from the pilot", "round-tripped body matches");
|
|
124
|
+
assert.equal(read.body.entries[0].id, appended.body.id, "read id matches the appended id");
|
|
125
|
+
|
|
126
|
+
// An unknown token is a 404 (never leaks which plans exist).
|
|
127
|
+
const unknown = await api.call("readBlackboard", { query: { token: "nope" } });
|
|
128
|
+
assert.equal(unknown.status, 404, "an unknown token is a 404, not a leak");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("starts the convergence loop and reconciles its tracking row when terminated", async () => {
|
|
132
|
+
const api = app.api;
|
|
133
|
+
assert.ok(api);
|
|
134
|
+
|
|
135
|
+
const prKey = "acme/widgets#42";
|
|
136
|
+
// POST the real production door for starting a review: `startConvergenceLoop`. `convergeOnly`
|
|
137
|
+
// keeps the run off the merge-loop; the offline env keeps `submitPr`'s best-effort GitHub
|
|
138
|
+
// enrichment from touching the network.
|
|
139
|
+
const started = await api.call<{ prKey: string }>("startConvergenceLoop", {
|
|
140
|
+
body: { pr: prKey, convergeOnly: true },
|
|
141
|
+
});
|
|
142
|
+
assert.equal(started.status, 202, "start returns 202 Accepted");
|
|
143
|
+
assert.equal(started.body.prKey, prKey, "the response echoes the parsed PR key");
|
|
144
|
+
|
|
145
|
+
// The operation registered the PR aggregate (instanceTracking table) and started a real engine
|
|
146
|
+
// instance — synchronously, before any worker ran (we never settled).
|
|
147
|
+
const prs = app.db.table<{ pr_key: string; status: string; process_key: string | null }>(
|
|
148
|
+
"pull_requests",
|
|
149
|
+
"pr_key",
|
|
150
|
+
);
|
|
151
|
+
const row = await prs.findOne({ pr_key: prKey });
|
|
152
|
+
assert.ok(row, "a pull_requests row was registered");
|
|
153
|
+
assert.equal(row?.status, "converging", "the PR is tracked as actively converging");
|
|
154
|
+
assert.ok(row?.process_key, "the row carries the engine process-instance key");
|
|
155
|
+
|
|
156
|
+
const processInstanceKey = row!.process_key!;
|
|
157
|
+
const before = await app.engine.searchProcessInstances({
|
|
158
|
+
processInstanceKeys: [processInstanceKey],
|
|
159
|
+
});
|
|
160
|
+
assert.equal(before.length, 1, "the engine has exactly one instance for this PR");
|
|
161
|
+
|
|
162
|
+
// Terminate the instance out-of-band (the class of event the reconciler exists to catch — a
|
|
163
|
+
// PR merged or cancelled independently of the loop). The row is still `converging` until a poll.
|
|
164
|
+
await app.engine.cancelInstance({ processInstanceKey });
|
|
165
|
+
const stillActive = await prs.findOne({ pr_key: prKey });
|
|
166
|
+
assert.equal(stillActive?.status, "converging", "row not yet reconciled before any poll fires");
|
|
167
|
+
|
|
168
|
+
// Advance past the instanceTracking pollMs (derived from nano.app.json above, plus a margin):
|
|
169
|
+
// the reconciler observes TERMINATED and applies the manifest `onTerminated.set` → status
|
|
170
|
+
// `abandoned`, escalation pointers cleared.
|
|
171
|
+
await app.advanceTime(PR_POLL_MS + 1000);
|
|
172
|
+
const reconciled = await prs.findOne({ pr_key: prKey });
|
|
173
|
+
assert.equal(reconciled?.status, "abandoned", "reconciler abandoned the terminated PR's row");
|
|
174
|
+
});
|
|
175
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -41,14 +41,16 @@
|
|
|
41
41
|
"layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
|
|
42
42
|
"dev": "urban dev",
|
|
43
43
|
"test": "node --experimental-strip-types --test",
|
|
44
|
-
"
|
|
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/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/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:
|
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
|
});
|