@nanobpm/nano-workforce 0.43.0 → 0.44.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +7 -0
- package/CHANGELOG.md +14 -0
- package/SPEC.md +26 -10
- 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/nano.app.json +4 -0
- package/package.json +5 -3
- package/pages/home.page.json +2 -0
- package/prompts/fix-ci.md +25 -4
- package/prompts/rebase.md +21 -1
- package/prompts/review-round.md +1 -1
- package/resources/processes/merge-loop.bpmn +73 -20
- package/tsconfig.json +2 -1
- package/workers/persist-escalation/worker.ts +10 -0
- package/workers/persist-round/worker.ts +9 -0
- package/workers/record-dependency/worker.test.ts +116 -0
- package/workers/record-dependency/worker.ts +109 -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.44.1](https://github.com/nanobpm/nano-workforce/compare/v0.44.0...v0.44.1) (2026-08-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* provision isolated agent workspaces via the c8ctl repository envelope ([#124](https://github.com/nanobpm/nano-workforce/issues/124)) ([79c999b](https://github.com/nanobpm/nano-workforce/commit/79c999bc34dd7984ceffd7746a4538705565f2a5))
|
|
7
|
+
|
|
8
|
+
# [0.44.0](https://github.com/nanobpm/nano-workforce/compare/v0.43.0...v0.44.0) (2026-08-11)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **merge-loop:** wait on a blocking PR instead of escalating to a human ([#122](https://github.com/nanobpm/nano-workforce/issues/122)) ([aec738f](https://github.com/nanobpm/nano-workforce/commit/aec738f165df5a216b9094b4eb9b5b6d4a7ac419))
|
|
14
|
+
|
|
1
15
|
# [0.43.0](https://github.com/nanobpm/nano-workforce/compare/v0.42.0...v0.43.0) (2026-08-11)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -173,7 +173,7 @@ triaging, editing/replying/pushing, and (when `addressed`) re-requesting review.
|
|
|
173
173
|
Workspace isolation is the **worker harness's** responsibility, not this app's and
|
|
174
174
|
not the prompt's. The `c8ctl nano work` host-git provisioning (frozen v1 envelope)
|
|
175
175
|
gives **each job its own `mkdtemp` run-dir + fresh clone**, runs the agent with
|
|
176
|
-
`cwd` set to it (`AGENT_WORKSPACE`/`
|
|
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
|
|
|
@@ -368,11 +373,21 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
|
|
|
368
373
|
exhausted, the agent reports `blocked`, or the branch is in `conflict` does it fall
|
|
369
374
|
through to the human escalation path.
|
|
370
375
|
|
|
376
|
+
- **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
|
|
377
|
+
the PR cannot land because **another PR must merge first** (a required linked-issue
|
|
378
|
+
gate a sibling PR will close, a stacked base PR, or a `Depends-on:` the agent read
|
|
379
|
+
from the PR/issue text). That is an ordering **wait, not a human decision**: the
|
|
380
|
+
agent returns `status: "waiting-on-pr"` with a `dependsOn` list of `owner/repo#N`
|
|
381
|
+
refs. `pr.record-dependency` appends those edges to `pr_dependencies` and parks the
|
|
382
|
+
PR back at *wait: deps merged*, so the same poller pass lands it automatically once
|
|
383
|
+
the named PRs merge — no escalation is opened.
|
|
384
|
+
|
|
371
385
|
- **Dependencies** — `pr_dependencies(pr_key, depends_on_key)` (migration 004).
|
|
372
|
-
Declared
|
|
373
|
-
submit)
|
|
374
|
-
|
|
375
|
-
|
|
386
|
+
Declared three ways: a `Depends-on: owner/repo#N` line in the PR body (parsed on
|
|
387
|
+
submit), a `dependsOn` array on the submit request, and/or **discovered at merge
|
|
388
|
+
time** by a `fix-ci`/`rebase` agent (`status: "waiting-on-pr"`, above). `merge-loop`
|
|
389
|
+
parks at *wait: deps merged*; the poller checks each dependency (own tracked row
|
|
390
|
+
first, else GitHub `merged` state) and publishes `deps-cleared` once all have landed.
|
|
376
391
|
- **Mergeability** — the poller classifies GitHub's `mergeStateStatus`:
|
|
377
392
|
`CLEAN`/`HAS_HOOKS`/`UNSTABLE`/`BEHIND` → `ready`; `DIRTY` → `conflict`;
|
|
378
393
|
`BLOCKED` → `blocked` if a required check is failing, else keep waiting;
|
|
@@ -500,9 +515,10 @@ the loop runs one parallel `implement` MI fan-out per wave:
|
|
|
500
515
|
|
|
501
516
|
- **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
|
|
502
517
|
integration provisions the repo and checks out the PR's head branch (it must
|
|
503
|
-
already give the worker repo access to work at all)
|
|
504
|
-
|
|
505
|
-
|
|
518
|
+
already give the worker repo access to work at all). The **app** resolves the head
|
|
519
|
+
branch and passes it in the `io.nanobpm.agentTask.repository.{url,ref}` envelope
|
|
520
|
+
(a `createInstance` process variable — see `repoEnvelopeVars`); the harness is
|
|
521
|
+
PR-agnostic and provisions from that envelope. The worker stays a pure provisioner.
|
|
506
522
|
- **review-ready via GitHub webhook** — same message, swappable faster trigger,
|
|
507
523
|
when the app is publicly reachable. Deferred (poller-only for v1).
|
|
508
524
|
- **Supervised vs external worker** — the agent runs as an external
|
package/app/github.ts
CHANGED
|
@@ -177,6 +177,10 @@ async function useGh(): Promise<boolean> {
|
|
|
177
177
|
export interface PrMeta {
|
|
178
178
|
title: string | null;
|
|
179
179
|
body: string;
|
|
180
|
+
/** The PR's head branch name (e.g. `feat/issue-12`). Drives the c8ctl harness's isolated
|
|
181
|
+
* workspace checkout (`io.nanobpm.agentTask.repository.ref`) so the review agent lands on the
|
|
182
|
+
* PR branch instead of the worker's launch directory. `null` when GitHub doesn't return it. */
|
|
183
|
+
headRef: string | null;
|
|
180
184
|
}
|
|
181
185
|
|
|
182
186
|
export async function fetchPrMeta(
|
|
@@ -185,10 +189,10 @@ export async function fetchPrMeta(
|
|
|
185
189
|
token: string,
|
|
186
190
|
): Promise<PrMeta | null> {
|
|
187
191
|
if (await useGh()) {
|
|
188
|
-
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body"]);
|
|
192
|
+
const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body,headRefName"]);
|
|
189
193
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
190
|
-
const j = JSON.parse(out) as { title?: string; body?: string };
|
|
191
|
-
return { title: j.title ?? null, body: j.body ?? "" };
|
|
194
|
+
const j = JSON.parse(out) as { title?: string; body?: string; headRefName?: string | null };
|
|
195
|
+
return { title: j.title ?? null, body: j.body ?? "", headRef: j.headRefName ?? null };
|
|
192
196
|
}
|
|
193
197
|
if (!token) return null;
|
|
194
198
|
const r = await fetch(`https://api.github.com/repos/${repo}/pulls/${number}`, {
|
|
@@ -196,8 +200,8 @@ export async function fetchPrMeta(
|
|
|
196
200
|
});
|
|
197
201
|
if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
|
|
198
202
|
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
199
|
-
const j = (await r.json()) as { title?: string; body?: string };
|
|
200
|
-
return { title: j.title ?? null, body: j.body ?? "" };
|
|
203
|
+
const j = (await r.json()) as { title?: string; body?: string; head?: { ref?: string | null } };
|
|
204
|
+
return { title: j.title ?? null, body: j.body ?? "", headRef: j.head?.ref ?? null };
|
|
201
205
|
}
|
|
202
206
|
|
|
203
207
|
/** A PR's merge state, narrowed to what the merge poller needs to classify landability.
|
|
@@ -57,6 +57,18 @@ test("escalation arm without the flag still records the round", async () => {
|
|
|
57
57
|
assertEquals((inserts.rounds[0] as any).round_no, 3);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
// The servicing worker name (harness `agent` var) is stamped on both the round it recorded and
|
|
61
|
+
// the escalation it opened, so the durable history identifies who did the work.
|
|
62
|
+
test("persist-escalation records the servicing worker on the round and escalation", async () => {
|
|
63
|
+
const { app, inserts } = fakeApp();
|
|
64
|
+
const job = {
|
|
65
|
+
variables: { prKey: "o/r#1", round: 3, status: "blocked", question: "max rounds", agent: "senior" },
|
|
66
|
+
};
|
|
67
|
+
await handler(job as any, app as any);
|
|
68
|
+
assertEquals((inserts.rounds[0] as any).worker, "senior", "the round carries the worker name");
|
|
69
|
+
assertEquals((inserts.escalations[0] as any).worker, "senior", "the escalation carries the worker name");
|
|
70
|
+
});
|
|
71
|
+
|
|
60
72
|
// When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
|
|
61
73
|
// desync), persist-escalation reconstructs the `pull_requests` row before the rounds/escalations
|
|
62
74
|
// inserts so opening an escalation never dies with an opaque FOREIGN KEY constraint failure.
|
|
@@ -56,6 +56,23 @@ for (const status of ["addressed", "waiting"]) {
|
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
// The harness completes each agent job with `agent` (its profile name); persist-round records it
|
|
60
|
+
// on the round so a human can identify the servicing worker from the durable history.
|
|
61
|
+
test("persist-round records the servicing worker name from the agent variable", async () => {
|
|
62
|
+
const { app, inserts } = fakeApp();
|
|
63
|
+
const job = { variables: { prKey: "o/r#1", round: 1, status: "addressed", agent: "senior" } };
|
|
64
|
+
await handler(job as any, app as any);
|
|
65
|
+
assertEquals((inserts.rounds[0] as any).worker, "senior", "the round carries the worker name");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// A blank/absent agent name leaves the nullable column NULL (the write boundary omits undefined).
|
|
69
|
+
test("persist-round leaves worker undefined when the agent name is blank", async () => {
|
|
70
|
+
const { app, inserts } = fakeApp();
|
|
71
|
+
const job = { variables: { prKey: "o/r#1", round: 1, status: "addressed", agent: " " } };
|
|
72
|
+
await handler(job as any, app as any);
|
|
73
|
+
assertEquals((inserts.rounds[0] as any).worker, undefined, "blank worker -> NULL column");
|
|
74
|
+
});
|
|
75
|
+
|
|
59
76
|
// When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
|
|
60
77
|
// desync), persist-round reconstructs the `pull_requests` row before recording the round so the
|
|
61
78
|
// insert never dies with an opaque FOREIGN KEY constraint failure.
|
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/nano.app.json
CHANGED
|
@@ -87,6 +87,10 @@
|
|
|
87
87
|
"taskType": "pr.mark-merged",
|
|
88
88
|
"handler": "workers/mark-merged/worker.ts"
|
|
89
89
|
},
|
|
90
|
+
{
|
|
91
|
+
"taskType": "pr.record-dependency",
|
|
92
|
+
"handler": "workers/record-dependency/worker.ts"
|
|
93
|
+
},
|
|
90
94
|
{
|
|
91
95
|
"taskType": "pr.record-plan",
|
|
92
96
|
"handler": "workers/record-plan/worker.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
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
|
|
@@ -56,9 +66,20 @@ the PR's checks yourself (`gh pr checks`, `gh run view`).
|
|
|
56
66
|
Return a structured result:
|
|
57
67
|
|
|
58
68
|
- `status: "fixed"` — you pushed a fix you believe makes the failing checks pass.
|
|
59
|
-
- `status: "
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
- `status: "waiting-on-pr"` — the PR cannot merge yet because **another PR must land
|
|
70
|
+
first**, and this is an ordering constraint, not a defect: e.g. the failing check
|
|
71
|
+
is a required linked-issue / "closes #N" gate that a sibling PR will satisfy, the
|
|
72
|
+
PR is stacked on a base PR that has not merged, or the PR body / an issue it
|
|
73
|
+
references says it depends on another PR. This is a **wait, not an escalation** — do
|
|
74
|
+
**not** ask a human to babysit it. Set `dependsOn` to the PR(s) that must merge
|
|
75
|
+
first, as `owner/repo#N` refs (or PR URLs), separated by commas or spaces. The
|
|
76
|
+
process records the dependency and automatically re-attempts the merge once every
|
|
77
|
+
named PR has landed.
|
|
78
|
+
- `status: "blocked"` — you could **not** fix it and it genuinely needs a human
|
|
79
|
+
**decision** (a secret, an upstream change, or a judgement call). Set `question` to a
|
|
80
|
+
concise, specific description of what is blocking and what a human must decide.
|
|
81
|
+
Reserve this for a real decision — if the PR is merely waiting on another PR, use
|
|
82
|
+
`waiting-on-pr` instead so no human is pulled in.
|
|
62
83
|
|
|
63
84
|
Never report `fixed` unless you actually pushed a change. If nothing was wrong on
|
|
64
85
|
the branch (the failure was transient infrastructure), say so in `summary` and
|
package/prompts/rebase.md
CHANGED
|
@@ -17,6 +17,15 @@ with a status URL is appended below: **before you push the rebased branch, curl
|
|
|
17
17
|
`-fsS`) and stop immediately if the check **fails** or reports `"abandoned": true`. Re-check right
|
|
18
18
|
before the push.
|
|
19
19
|
|
|
20
|
+
## Workspace (host mode) — read this first
|
|
21
|
+
|
|
22
|
+
When the worker harness (e.g. `c8ctl nano work`) provisions a workspace, your **current
|
|
23
|
+
working directory is a fresh, isolated clone of the repo checked out on the PR's head
|
|
24
|
+
branch** — exposed via `AGENT_WORKSPACE`, `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF`.
|
|
25
|
+
When it does, **work only inside `cwd`**, do **not** re-clone, `cd` elsewhere, or add a
|
|
26
|
+
`git worktree`, and do not touch global/host state — other jobs get their own clones. If
|
|
27
|
+
`AGENT_WORKSPACE` is **unset** (no provisioning), check out the PR head branch yourself.
|
|
28
|
+
|
|
20
29
|
## Job input (`job.variables`)
|
|
21
30
|
|
|
22
31
|
| var | meaning |
|
|
@@ -29,7 +38,8 @@ before the push.
|
|
|
29
38
|
|
|
30
39
|
## What to do
|
|
31
40
|
|
|
32
|
-
1. Check out the PR's head branch (
|
|
41
|
+
1. Check out the PR's head branch (already provisioned as your `cwd` in host mode; else it
|
|
42
|
+
already exists on the remote) and identify
|
|
33
43
|
the base branch (`gh pr view <prNumber> --repo <repo> --json baseRefName`).
|
|
34
44
|
2. Update the branch onto the current base. Prefer a **rebase**
|
|
35
45
|
(`git fetch origin && git rebase origin/<base>`); if the repo's history policy
|
|
@@ -67,10 +77,20 @@ Return a structured result:
|
|
|
67
77
|
- `status: "rebased"` — the branch tip now contains the latest base: you resolved
|
|
68
78
|
any conflicts mechanically and pushed, **or** it was already up to date. The
|
|
69
79
|
process will re-attempt the merge.
|
|
80
|
+
- `status: "waiting-on-pr"` — the PR cannot merge yet because **another PR must land
|
|
81
|
+
first**, and this is an ordering constraint, not a conflict you can resolve: e.g.
|
|
82
|
+
the branch is stacked on a base PR that has not merged, or the PR body / an issue
|
|
83
|
+
it references says it depends on another PR that must close a blocking issue first.
|
|
84
|
+
This is a **wait, not an escalation** — do **not** ask a human to babysit it. Set
|
|
85
|
+
`dependsOn` to the PR(s) that must merge first, as `owner/repo#N` refs (or PR URLs),
|
|
86
|
+
separated by commas or spaces. The process records the dependency and automatically
|
|
87
|
+
re-attempts the merge once every named PR has landed.
|
|
70
88
|
- `status: "blocked"` — you could **not** resolve it mechanically (a genuine
|
|
71
89
|
semantic conflict where two changes contradict and a human must decide which
|
|
72
90
|
behaviour wins, or the branch is un-rebaseable). Set `question` to a concise,
|
|
73
91
|
specific description of the conflicting intent and the decision a human must make.
|
|
92
|
+
Reserve this for a real decision — if the PR is merely waiting on another PR to land
|
|
93
|
+
first, use `waiting-on-pr` instead so no human is pulled in.
|
|
74
94
|
|
|
75
95
|
Report `rebased` when the branch tip now contains the latest base — either
|
|
76
96
|
because you pushed a resolved update, or because it was **already up to date**
|
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:
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
<nano:extend name="status" type="string" />
|
|
77
77
|
<nano:extend name="summary" type="string" optional="true" />
|
|
78
78
|
<nano:extend name="question" type="string" optional="true" />
|
|
79
|
+
<nano:extend name="dependsOn" type="string" optional="true" />
|
|
79
80
|
</nano:shape>
|
|
80
81
|
<nano:shape id="RebaseIn" name="Rebase — input">
|
|
81
82
|
<nano:extend name="prKey" type="string" />
|
|
@@ -88,6 +89,11 @@
|
|
|
88
89
|
<nano:extend name="status" type="string" />
|
|
89
90
|
<nano:extend name="summary" type="string" optional="true" />
|
|
90
91
|
<nano:extend name="question" type="string" optional="true" />
|
|
92
|
+
<nano:extend name="dependsOn" type="string" optional="true" />
|
|
93
|
+
</nano:shape>
|
|
94
|
+
<nano:shape id="RecordDepIn" name="Record discovered dependency — input">
|
|
95
|
+
<nano:extend name="prKey" type="string" />
|
|
96
|
+
<nano:extend name="dependsOn" type="string" optional="true" />
|
|
91
97
|
</nano:shape>
|
|
92
98
|
<nano:shape id="MergeEscalationAnswered" name="escalation-answered message payload">
|
|
93
99
|
<nano:extend name="answer" type="string" />
|
|
@@ -100,6 +106,7 @@
|
|
|
100
106
|
</bpmn:startEvent>
|
|
101
107
|
<bpmn:intermediateCatchEvent id="wait-deps" name="Wait: dependencies merged">
|
|
102
108
|
<bpmn:incoming>f_m_start</bpmn:incoming>
|
|
109
|
+
<bpmn:incoming>f_dep_rewait</bpmn:incoming>
|
|
103
110
|
<bpmn:outgoing>f_m_deps</bpmn:outgoing>
|
|
104
111
|
<bpmn:messageEventDefinition id="med_depsCleared" messageRef="Message_depsCleared" />
|
|
105
112
|
</bpmn:intermediateCatchEvent>
|
|
@@ -237,6 +244,7 @@
|
|
|
237
244
|
<bpmn:exclusiveGateway id="gw-ci-result" name="fixed?" default="f_ci_blocked">
|
|
238
245
|
<bpmn:incoming>f_ci_done</bpmn:incoming>
|
|
239
246
|
<bpmn:outgoing>f_ci_fixed</bpmn:outgoing>
|
|
247
|
+
<bpmn:outgoing>f_ci_wait</bpmn:outgoing>
|
|
240
248
|
<bpmn:outgoing>f_ci_blocked</bpmn:outgoing>
|
|
241
249
|
</bpmn:exclusiveGateway>
|
|
242
250
|
<bpmn:exclusiveGateway id="gw-rebase" name="auto-rebase?" default="f_reb_giveup">
|
|
@@ -265,8 +273,20 @@
|
|
|
265
273
|
<bpmn:exclusiveGateway id="gw-rebase-result" name="rebased?" default="f_reb_blocked">
|
|
266
274
|
<bpmn:incoming>f_reb_done</bpmn:incoming>
|
|
267
275
|
<bpmn:outgoing>f_reb_rebased</bpmn:outgoing>
|
|
276
|
+
<bpmn:outgoing>f_reb_wait</bpmn:outgoing>
|
|
268
277
|
<bpmn:outgoing>f_reb_blocked</bpmn:outgoing>
|
|
269
278
|
</bpmn:exclusiveGateway>
|
|
279
|
+
<bpmn:serviceTask id="record-merge-dep" name="Wait on another PR">
|
|
280
|
+
<bpmn:extensionElements>
|
|
281
|
+
<zeebe:taskDefinition type="pr.record-dependency" />
|
|
282
|
+
<zeebe:properties>
|
|
283
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="RecordDepIn" />
|
|
284
|
+
</zeebe:properties>
|
|
285
|
+
</bpmn:extensionElements>
|
|
286
|
+
<bpmn:incoming>f_ci_wait</bpmn:incoming>
|
|
287
|
+
<bpmn:incoming>f_reb_wait</bpmn:incoming>
|
|
288
|
+
<bpmn:outgoing>f_dep_rewait</bpmn:outgoing>
|
|
289
|
+
</bpmn:serviceTask>
|
|
270
290
|
<bpmn:sequenceFlow id="f_m_start" sourceRef="MergeStart" targetRef="wait-deps" />
|
|
271
291
|
<bpmn:sequenceFlow id="f_m_deps" sourceRef="wait-deps" targetRef="arm-merge" />
|
|
272
292
|
<bpmn:sequenceFlow id="f_m_arm" sourceRef="arm-merge" targetRef="wait-mergeable" />
|
|
@@ -290,6 +310,9 @@
|
|
|
290
310
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "fixed"</bpmn:conditionExpression>
|
|
291
311
|
</bpmn:sequenceFlow>
|
|
292
312
|
<bpmn:sequenceFlow id="f_ci_blocked" name="could not fix" sourceRef="gw-ci-result" targetRef="merge-esc-attempt" />
|
|
313
|
+
<bpmn:sequenceFlow id="f_ci_wait" name="waits on another PR" sourceRef="gw-ci-result" targetRef="record-merge-dep">
|
|
314
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
|
|
315
|
+
</bpmn:sequenceFlow>
|
|
293
316
|
<bpmn:sequenceFlow id="f_reb_go" name="within budget" sourceRef="gw-rebase" targetRef="rebase">
|
|
294
317
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=rebaseRound < rebaseMax</bpmn:conditionExpression>
|
|
295
318
|
</bpmn:sequenceFlow>
|
|
@@ -299,6 +322,10 @@
|
|
|
299
322
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "rebased"</bpmn:conditionExpression>
|
|
300
323
|
</bpmn:sequenceFlow>
|
|
301
324
|
<bpmn:sequenceFlow id="f_reb_blocked" name="could not resolve" sourceRef="gw-rebase-result" targetRef="merge-esc-attempt" />
|
|
325
|
+
<bpmn:sequenceFlow id="f_reb_wait" name="waits on another PR" sourceRef="gw-rebase-result" targetRef="record-merge-dep">
|
|
326
|
+
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "waiting-on-pr"</bpmn:conditionExpression>
|
|
327
|
+
</bpmn:sequenceFlow>
|
|
328
|
+
<bpmn:sequenceFlow id="f_dep_rewait" sourceRef="record-merge-dep" targetRef="wait-deps" />
|
|
302
329
|
<bpmn:sequenceFlow id="f_m_attempt" sourceRef="attempt-merge" targetRef="gw-merge" />
|
|
303
330
|
<bpmn:sequenceFlow id="f_m_gMerged" name="merged" sourceRef="gw-merge" targetRef="mark-merged">
|
|
304
331
|
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=mergeStatus = "merged"</bpmn:conditionExpression>
|
|
@@ -327,7 +354,7 @@
|
|
|
327
354
|
<bpmndi:BPMNShape id="BPMNShape_wait-deps" bpmnElement="wait-deps">
|
|
328
355
|
<dc:Bounds x="216" y="102" width="36" height="36" />
|
|
329
356
|
<bpmndi:BPMNLabel>
|
|
330
|
-
<dc:Bounds x="190" y="
|
|
357
|
+
<dc:Bounds x="190" y="55" width="88" height="42" />
|
|
331
358
|
</bpmndi:BPMNLabel>
|
|
332
359
|
</bpmndi:BPMNShape>
|
|
333
360
|
<bpmndi:BPMNShape id="BPMNShape_arm-merge" bpmnElement="arm-merge">
|
|
@@ -415,14 +442,17 @@
|
|
|
415
442
|
</bpmndi:BPMNLabel>
|
|
416
443
|
</bpmndi:BPMNShape>
|
|
417
444
|
<bpmndi:BPMNShape id="BPMNShape_rebase" bpmnElement="rebase">
|
|
418
|
-
<dc:Bounds x="1038" y="
|
|
445
|
+
<dc:Bounds x="1038" y="1200" width="100" height="80" />
|
|
419
446
|
</bpmndi:BPMNShape>
|
|
420
447
|
<bpmndi:BPMNShape id="BPMNShape_gw-rebase-result" bpmnElement="gw-rebase-result" isMarkerVisible="true">
|
|
421
|
-
<dc:Bounds x="1238" y="
|
|
448
|
+
<dc:Bounds x="1238" y="1215" width="50" height="50" />
|
|
422
449
|
<bpmndi:BPMNLabel>
|
|
423
|
-
<dc:Bounds x="
|
|
450
|
+
<dc:Bounds x="1173" y="1233" width="60" height="14" />
|
|
424
451
|
</bpmndi:BPMNLabel>
|
|
425
452
|
</bpmndi:BPMNShape>
|
|
453
|
+
<bpmndi:BPMNShape id="BPMNShape_record-merge-dep" bpmnElement="record-merge-dep">
|
|
454
|
+
<dc:Bounds x="1388" y="1040" width="100" height="80" />
|
|
455
|
+
</bpmndi:BPMNShape>
|
|
426
456
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_start" bpmnElement="f_m_start">
|
|
427
457
|
<di:waypoint x="116" y="120" />
|
|
428
458
|
<di:waypoint x="216" y="120" />
|
|
@@ -473,7 +503,7 @@
|
|
|
473
503
|
<di:waypoint x="1438" y="920" />
|
|
474
504
|
<di:waypoint x="1438" y="640" />
|
|
475
505
|
<bpmndi:BPMNLabel>
|
|
476
|
-
<dc:Bounds x="1319" y="
|
|
506
|
+
<dc:Bounds x="1319" y="928" width="89" height="14" />
|
|
477
507
|
</bpmndi:BPMNLabel>
|
|
478
508
|
</bpmndi:BPMNEdge>
|
|
479
509
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_giveup" bpmnElement="f_reb_giveup">
|
|
@@ -485,11 +515,12 @@
|
|
|
485
515
|
</bpmndi:BPMNLabel>
|
|
486
516
|
</bpmndi:BPMNEdge>
|
|
487
517
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
|
|
488
|
-
<di:waypoint x="
|
|
489
|
-
<di:waypoint x="
|
|
518
|
+
<di:waypoint x="1263" y="1215" />
|
|
519
|
+
<di:waypoint x="1263" y="1020" />
|
|
520
|
+
<di:waypoint x="1438" y="1020" />
|
|
490
521
|
<di:waypoint x="1438" y="640" />
|
|
491
522
|
<bpmndi:BPMNLabel>
|
|
492
|
-
<dc:Bounds x="
|
|
523
|
+
<dc:Bounds x="1319" y="987" width="64" height="28" />
|
|
493
524
|
</bpmndi:BPMNLabel>
|
|
494
525
|
</bpmndi:BPMNEdge>
|
|
495
526
|
<bpmndi:BPMNEdge id="BPMNEdge_f_eg_landed" bpmnElement="f_eg_landed">
|
|
@@ -552,17 +583,33 @@
|
|
|
552
583
|
<dc:Bounds x="893" y="839" width="49" height="28" />
|
|
553
584
|
</bpmndi:BPMNLabel>
|
|
554
585
|
</bpmndi:BPMNEdge>
|
|
586
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_wait" bpmnElement="f_ci_wait">
|
|
587
|
+
<di:waypoint x="1288" y="920" />
|
|
588
|
+
<di:waypoint x="1438" y="920" />
|
|
589
|
+
<di:waypoint x="1438" y="1040" />
|
|
590
|
+
<bpmndi:BPMNLabel>
|
|
591
|
+
<dc:Bounds x="1326" y="887" width="75" height="28" />
|
|
592
|
+
</bpmndi:BPMNLabel>
|
|
593
|
+
</bpmndi:BPMNEdge>
|
|
555
594
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_go" bpmnElement="f_reb_go">
|
|
556
595
|
<di:waypoint x="913" y="280" />
|
|
557
596
|
<di:waypoint x="933" y="280" />
|
|
558
597
|
<di:waypoint x="933" y="305" />
|
|
559
598
|
<di:waypoint x="1158" y="305" />
|
|
560
|
-
<di:waypoint x="1158" y="
|
|
561
|
-
<di:waypoint x="1138" y="
|
|
599
|
+
<di:waypoint x="1158" y="1240" />
|
|
600
|
+
<di:waypoint x="1138" y="1240" />
|
|
562
601
|
<bpmndi:BPMNLabel>
|
|
563
602
|
<dc:Bounds x="1101" y="310" width="49" height="28" />
|
|
564
603
|
</bpmndi:BPMNLabel>
|
|
565
604
|
</bpmndi:BPMNEdge>
|
|
605
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_wait" bpmnElement="f_reb_wait">
|
|
606
|
+
<di:waypoint x="1263" y="1215" />
|
|
607
|
+
<di:waypoint x="1263" y="1080" />
|
|
608
|
+
<di:waypoint x="1388" y="1080" />
|
|
609
|
+
<bpmndi:BPMNLabel>
|
|
610
|
+
<dc:Bounds x="1288" y="1047" width="75" height="28" />
|
|
611
|
+
</bpmndi:BPMNLabel>
|
|
612
|
+
</bpmndi:BPMNEdge>
|
|
566
613
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_gQueued" bpmnElement="f_m_gQueued">
|
|
567
614
|
<di:waypoint x="1088" y="145" />
|
|
568
615
|
<di:waypoint x="1088" y="280" />
|
|
@@ -593,12 +640,12 @@
|
|
|
593
640
|
<di:waypoint x="1238" y="920" />
|
|
594
641
|
</bpmndi:BPMNEdge>
|
|
595
642
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_done" bpmnElement="f_reb_done">
|
|
596
|
-
<di:waypoint x="1138" y="
|
|
597
|
-
<di:waypoint x="1158" y="
|
|
598
|
-
<di:waypoint x="1158" y="
|
|
599
|
-
<di:waypoint x="1238" y="
|
|
600
|
-
<di:waypoint x="1263" y="
|
|
601
|
-
<di:waypoint x="1263" y="
|
|
643
|
+
<di:waypoint x="1138" y="1260" />
|
|
644
|
+
<di:waypoint x="1158" y="1260" />
|
|
645
|
+
<di:waypoint x="1158" y="1285" />
|
|
646
|
+
<di:waypoint x="1238" y="1285" />
|
|
647
|
+
<di:waypoint x="1263" y="1285" />
|
|
648
|
+
<di:waypoint x="1263" y="1265" />
|
|
602
649
|
</bpmndi:BPMNEdge>
|
|
603
650
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ci_fixed" bpmnElement="f_ci_fixed">
|
|
604
651
|
<di:waypoint x="1263" y="945" />
|
|
@@ -610,14 +657,20 @@
|
|
|
610
657
|
</bpmndi:BPMNLabel>
|
|
611
658
|
</bpmndi:BPMNEdge>
|
|
612
659
|
<bpmndi:BPMNEdge id="BPMNEdge_f_reb_rebased" bpmnElement="f_reb_rebased">
|
|
613
|
-
<di:waypoint x="1263" y="
|
|
614
|
-
<di:waypoint x="1263" y="
|
|
615
|
-
<di:waypoint x="402" y="
|
|
660
|
+
<di:waypoint x="1263" y="1265" />
|
|
661
|
+
<di:waypoint x="1263" y="1300" />
|
|
662
|
+
<di:waypoint x="402" y="1300" />
|
|
616
663
|
<di:waypoint x="402" y="160" />
|
|
617
664
|
<bpmndi:BPMNLabel>
|
|
618
|
-
<dc:Bounds x="806" y="
|
|
665
|
+
<dc:Bounds x="806" y="1278" width="53" height="14" />
|
|
619
666
|
</bpmndi:BPMNLabel>
|
|
620
667
|
</bpmndi:BPMNEdge>
|
|
668
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_dep_rewait" bpmnElement="f_dep_rewait">
|
|
669
|
+
<di:waypoint x="1438" y="1120" />
|
|
670
|
+
<di:waypoint x="1438" y="1320" />
|
|
671
|
+
<di:waypoint x="234" y="1320" />
|
|
672
|
+
<di:waypoint x="234" y="138" />
|
|
673
|
+
</bpmndi:BPMNEdge>
|
|
621
674
|
<bpmndi:BPMNEdge id="BPMNEdge_f_m_evicted" bpmnElement="f_m_evicted">
|
|
622
675
|
<di:waypoint x="1438" y="458" />
|
|
623
676
|
<di:waypoint x="1438" y="478" />
|
package/tsconfig.json
CHANGED
|
@@ -44,6 +44,13 @@ function transcriptOf(vars: Record<string, unknown>): string | null {
|
|
|
44
44
|
return typeof env?.output === "string" ? env.output : null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// The c8ctl harness completes each agent job with an `agent` variable (its profile name), which
|
|
48
|
+
// propagates here. Record it on the round/escalation so a human can identify the servicing worker
|
|
49
|
+
// from the durable history. Reuses the `nonBlank` domain rule (blank/absent -> NULL column).
|
|
50
|
+
function workerOf(vars: Record<string, unknown>): string | undefined {
|
|
51
|
+
return nonBlank(vars.agent);
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
// Synthesize a concrete, answerable question when the agent left one blank. A blank question is
|
|
48
55
|
// almost always a *no-result* round: a prompt-less agent that never wrote its result file, so
|
|
49
56
|
// `status` is empty and `gw-status` falls through its default `f_escalate` arm (the empty
|
|
@@ -72,6 +79,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
72
79
|
const rawStatus = nonBlank(job.variables.status);
|
|
73
80
|
const status = rawStatus ?? "needs_input";
|
|
74
81
|
const transcript = transcriptOf(job.variables);
|
|
82
|
+
const worker = workerOf(job.variables);
|
|
75
83
|
// A blank question must never open an unanswerable escalation. Every legitimate arm sets a
|
|
76
84
|
// concrete question — the agent contract requires one for needs_input/blocked, and the
|
|
77
85
|
// max-rounds + review-timeout arms set a literal via the model. When one is still missing
|
|
@@ -110,6 +118,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
110
118
|
status,
|
|
111
119
|
summary,
|
|
112
120
|
transcript,
|
|
121
|
+
worker,
|
|
113
122
|
started_at: now,
|
|
114
123
|
ended_at: now,
|
|
115
124
|
});
|
|
@@ -120,6 +129,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
120
129
|
kind,
|
|
121
130
|
question,
|
|
122
131
|
transcript,
|
|
132
|
+
worker,
|
|
123
133
|
status: "open",
|
|
124
134
|
asked_at: now,
|
|
125
135
|
});
|
|
@@ -34,6 +34,14 @@ function transcriptOf(vars: Record<string, unknown>): string | null {
|
|
|
34
34
|
return typeof env?.output === "string" ? env.output : null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// The c8ctl harness completes each agent job with an `agent` variable (its profile name), which
|
|
38
|
+
// propagates here. Record it on the round so a human can identify the servicing worker from the
|
|
39
|
+
// durable history. Undefined (blank/absent) leaves the nullable `worker` column NULL.
|
|
40
|
+
function workerOf(vars: Record<string, unknown>): string | undefined {
|
|
41
|
+
const v = vars.agent;
|
|
42
|
+
return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
37
45
|
const handler: AppJobHandler<In> = async (job, app) => {
|
|
38
46
|
// This worker is the "addressed"/"waiting" path, so `status` resolves to one of those
|
|
39
47
|
// domain values. `summary` is left undefined when absent: the write boundary omits it so the
|
|
@@ -66,6 +74,7 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
66
74
|
status,
|
|
67
75
|
summary,
|
|
68
76
|
transcript: transcriptOf(job.variables),
|
|
77
|
+
worker: workerOf(job.variables),
|
|
69
78
|
started_at: now,
|
|
70
79
|
ended_at: now,
|
|
71
80
|
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// pr.record-dependency: a merge-stage agent that discovers "this PR must wait for another PR to
|
|
2
|
+
// merge first" turns that into a durable dependency wait (parking the PR back in `waiting_deps`)
|
|
3
|
+
// instead of a human escalation — the wait, not the escalation, is the correct outcome.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals } from "#test-assert";
|
|
6
|
+
import handler from "./worker.ts";
|
|
7
|
+
|
|
8
|
+
interface DepRow {
|
|
9
|
+
pr_key: string;
|
|
10
|
+
depends_on_key: string;
|
|
11
|
+
created_at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fakeApp(seedDeps: DepRow[] = []) {
|
|
15
|
+
const logs: { level: string; msg: string }[] = [];
|
|
16
|
+
const stores: Record<string, Record<string, unknown>[]> = {
|
|
17
|
+
pr_dependencies: seedDeps as unknown as Record<string, unknown>[],
|
|
18
|
+
pull_requests: [{ pr_key: "o/r#1", status: "waiting_merge" }],
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
app: {
|
|
22
|
+
data: {
|
|
23
|
+
table(name: string, key: string) {
|
|
24
|
+
const store = (stores[name] ??= []);
|
|
25
|
+
return {
|
|
26
|
+
get: (k: unknown) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
27
|
+
find: (q: Record<string, unknown>) =>
|
|
28
|
+
Promise.resolve(
|
|
29
|
+
store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
|
|
30
|
+
),
|
|
31
|
+
insert: (row: Record<string, unknown>) => {
|
|
32
|
+
store.push(row);
|
|
33
|
+
return Promise.resolve(store.length);
|
|
34
|
+
},
|
|
35
|
+
update: (k: unknown, patch: Record<string, unknown>) => {
|
|
36
|
+
const row = store.find((r) => r[key] === k);
|
|
37
|
+
if (row) Object.assign(row, patch);
|
|
38
|
+
return Promise.resolve(row);
|
|
39
|
+
},
|
|
40
|
+
delete: (k: unknown) => {
|
|
41
|
+
for (let i = store.length - 1; i >= 0; i--) if (store[i][key] === k) store.splice(i, 1);
|
|
42
|
+
return Promise.resolve(undefined);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
48
|
+
engine: {},
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal test double for AppContext
|
|
50
|
+
} as any,
|
|
51
|
+
stores,
|
|
52
|
+
logs,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const job = (variables: Record<string, unknown>) => ({ variables }) as never;
|
|
57
|
+
|
|
58
|
+
test("records a discovered dependency from a string ref and parks the PR in waiting_deps", async () => {
|
|
59
|
+
const { app, stores } = fakeApp();
|
|
60
|
+
await handler(job({ prKey: "o/r#1", dependsOn: "o/r#2" }), app);
|
|
61
|
+
|
|
62
|
+
assertEquals(stores.pr_dependencies.length, 1);
|
|
63
|
+
assertEquals(stores.pr_dependencies[0].pr_key, "o/r#1");
|
|
64
|
+
assertEquals(stores.pr_dependencies[0].depends_on_key, "o/r#2");
|
|
65
|
+
assertEquals(stores.pull_requests[0].status, "waiting_deps");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("parses several refs (commas/spaces/URLs), dedupes, and never waits on itself", async () => {
|
|
69
|
+
const { app, stores } = fakeApp();
|
|
70
|
+
await handler(
|
|
71
|
+
job({
|
|
72
|
+
prKey: "o/r#1",
|
|
73
|
+
dependsOn: "o/r#2, o/r#2 https://github.com/o/r/pull/3 o/r#1",
|
|
74
|
+
}),
|
|
75
|
+
app,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const keys = stores.pr_dependencies.map((d) => d.depends_on_key).sort();
|
|
79
|
+
assertEquals(keys, ["o/r#2", "o/r#3"]); // #2 deduped, self #1 dropped
|
|
80
|
+
assertEquals(stores.pull_requests[0].status, "waiting_deps");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("appends to existing edges without wiping them and skips already-recorded ones", async () => {
|
|
84
|
+
const { app, stores } = fakeApp([
|
|
85
|
+
{ pr_key: "o/r#1", depends_on_key: "o/r#9", created_at: "t0" },
|
|
86
|
+
]);
|
|
87
|
+
await handler(job({ prKey: "o/r#1", dependsOn: ["o/r#9", "o/r#2"] }), app);
|
|
88
|
+
|
|
89
|
+
const keys = stores.pr_dependencies.map((d) => d.depends_on_key).sort();
|
|
90
|
+
assertEquals(keys, ["o/r#2", "o/r#9"]); // pre-existing #9 preserved, only #2 added
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("no parseable ref still parks in waiting_deps and logs the miswiring loudly", async () => {
|
|
94
|
+
const { app, stores, logs } = fakeApp();
|
|
95
|
+
await handler(job({ prKey: "o/r#1", dependsOn: "not-a-pr" }), app);
|
|
96
|
+
|
|
97
|
+
assertEquals(stores.pr_dependencies.length, 0);
|
|
98
|
+
assertEquals(stores.pull_requests[0].status, "waiting_deps");
|
|
99
|
+
assertEquals(logs.some((l) => l.level === "error"), true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("heals a missing pull_requests parent row before parking so the merge poller can watch it", async () => {
|
|
103
|
+
const { app, stores } = fakeApp();
|
|
104
|
+
stores.pull_requests.length = 0; // engine/app.db desync: no parent row for o/r#1
|
|
105
|
+
|
|
106
|
+
await handler(job({ prKey: "o/r#1", dependsOn: "o/r#2" }), app);
|
|
107
|
+
|
|
108
|
+
// ensurePr reconstructed the row, and the subsequent update landed on it (not a silent no-op).
|
|
109
|
+
assertEquals(stores.pull_requests.length, 1);
|
|
110
|
+
const healed = stores.pull_requests[0];
|
|
111
|
+
assertEquals(healed.pr_key, "o/r#1");
|
|
112
|
+
assertEquals(healed.repo, "o/r");
|
|
113
|
+
assertEquals(healed.number, 1);
|
|
114
|
+
assertEquals(healed.status, "waiting_deps");
|
|
115
|
+
assertEquals(stores.pr_dependencies.length, 1);
|
|
116
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// pr.record-dependency — a merge-stage agent discovered that this PR cannot land until ANOTHER
|
|
2
|
+
// PR merges first (e.g. a stacked base PR must land, or a sibling PR closes an issue this one
|
|
3
|
+
// requires). That is a WAIT, not a human escalation: record the discovered edge(s) in the
|
|
4
|
+
// `pr_dependencies` DAG and park the PR back in `waiting_deps` so the merge poller's dependency
|
|
5
|
+
// pass (`pollMerges` block 1) advances it — publishing `deps-cleared` — once every named PR has
|
|
6
|
+
// merged. The process re-enters its existing `wait-deps` catch, so no human has to babysit an
|
|
7
|
+
// ordering constraint the machinery already knows how to satisfy.
|
|
8
|
+
//
|
|
9
|
+
// `dependsOn` is whatever the agent returned (see prompts/fix-ci.md, prompts/rebase.md): a
|
|
10
|
+
// string of one or more `owner/repo#N` refs (or PR URLs) separated by commas/whitespace/newlines,
|
|
11
|
+
// or an array of such tokens. We parse each robustly (reusing `parsePr`), drop self-references and
|
|
12
|
+
// duplicates, and insert missing edges idempotently — a worker retry never double-inserts, and an
|
|
13
|
+
// already-recorded edge is a no-op.
|
|
14
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
15
|
+
import { ensurePr, parsePr } from "../../app/service.ts";
|
|
16
|
+
|
|
17
|
+
interface In extends Record<string, unknown> {
|
|
18
|
+
prKey: string;
|
|
19
|
+
dependsOn?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface DependencyRow {
|
|
23
|
+
pr_key: string;
|
|
24
|
+
depends_on_key: string;
|
|
25
|
+
created_at: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Normalize the agent's `dependsOn` into a de-duplicated list of `owner/repo#N` keys.
|
|
29
|
+
* Accepts a string (split on commas/whitespace/newlines) or an array of such tokens; unparseable
|
|
30
|
+
* tokens are ignored, mirroring `parseDependsOn`'s tolerance for the `Depends-on:` PR-body line. */
|
|
31
|
+
function parseDependsOn(raw: unknown): string[] {
|
|
32
|
+
const tokens: string[] = [];
|
|
33
|
+
if (typeof raw === "string") {
|
|
34
|
+
tokens.push(...raw.split(/[,\s]+/));
|
|
35
|
+
} else if (Array.isArray(raw)) {
|
|
36
|
+
for (const item of raw) {
|
|
37
|
+
if (typeof item === "string") tokens.push(...item.split(/[,\s]+/));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const out = new Set<string>();
|
|
41
|
+
for (const tok of tokens) {
|
|
42
|
+
const parsed = parsePr(tok);
|
|
43
|
+
if (parsed) out.add(parsed.prKey);
|
|
44
|
+
}
|
|
45
|
+
return [...out];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const handler: AppJobHandler<In> = async (job, app) => {
|
|
49
|
+
const prKey = job.variables.prKey;
|
|
50
|
+
const depKeys = parseDependsOn(job.variables.dependsOn);
|
|
51
|
+
const ts = new Date().toISOString();
|
|
52
|
+
|
|
53
|
+
const depTable = app.data.table<DependencyRow>("pr_dependencies", "pr_key");
|
|
54
|
+
|
|
55
|
+
// Append the discovered edges to whatever the plan DAG already declared — never wipe the set
|
|
56
|
+
// (a `registerDependencies`-style replace would drop still-relevant sibling ordering). Dedupe
|
|
57
|
+
// against existing rows so this is safe to retry.
|
|
58
|
+
const existing = await depTable.find({ pr_key: prKey });
|
|
59
|
+
const have = new Set(existing.map((d) => d.depends_on_key));
|
|
60
|
+
let recorded = 0;
|
|
61
|
+
for (const depKey of depKeys) {
|
|
62
|
+
if (depKey === prKey || have.has(depKey)) continue; // never wait on self; skip known edges
|
|
63
|
+
await depTable.insert({ pr_key: prKey, depends_on_key: depKey, created_at: ts });
|
|
64
|
+
have.add(depKey);
|
|
65
|
+
recorded += 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (depKeys.length === 0) {
|
|
69
|
+
// The agent signalled "waiting-on-pr" but named no parseable PR. Whether this actually strands
|
|
70
|
+
// the PR depends on the existing DAG: with no other edges the poller clears it immediately (an
|
|
71
|
+
// empty dep set trivially "all merged"); if prior edges already exist it will keep waiting on
|
|
72
|
+
// those. Either way the miswiring — a "waiting-on-pr" signal with no parseable ref — is a
|
|
73
|
+
// defect worth logging loudly.
|
|
74
|
+
const clause =
|
|
75
|
+
have.size === 0
|
|
76
|
+
? "it will clear immediately (no other dependencies recorded)"
|
|
77
|
+
: `it still has ${have.size} previously-recorded dependency edge(s) to wait on`;
|
|
78
|
+
app.log(
|
|
79
|
+
"error",
|
|
80
|
+
`record-dependency: ${prKey} reported waiting-on-pr but no parseable dependsOn ref; ` +
|
|
81
|
+
`${clause}. Raw: ${JSON.stringify(job.variables.dependsOn)}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Heal a missing FK parent (engine/app.db desync) before updating `pull_requests`; otherwise the
|
|
86
|
+
// update silently no-ops and the instance re-enters `wait-deps` with no row for the merge poller
|
|
87
|
+
// to watch — wedging the merge loop. Mirrors persist-round's heal; repo/number are derived from
|
|
88
|
+
// the canonical `owner/repo#N` prKey since the RecordDepIn envelope carries only prKey/dependsOn.
|
|
89
|
+
const parsed = parsePr(prKey);
|
|
90
|
+
if (parsed) {
|
|
91
|
+
await ensurePr(app.data, { prKey, repo: parsed.repo, number: parsed.number, url: parsed.url });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Park the PR back in the merge-stage dependency wait so `pollMerges` block 1 watches it.
|
|
95
|
+
await app.data.table("pull_requests", "pr_key").update(prKey, {
|
|
96
|
+
status: "waiting_deps",
|
|
97
|
+
updated_at: ts,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
app.log("info", `record-dependency: ${prKey} waiting on ${depKeys.length} PR(s)`, {
|
|
101
|
+
prKey,
|
|
102
|
+
dependsOn: depKeys,
|
|
103
|
+
recorded,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export default handler;
|