@nanobpm/nano-workforce 0.203.0 → 0.204.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/feature.ts +9 -0
- package/app/github.ts +14 -6
- package/app/issuePrLink.test.ts +703 -0
- package/app/issuePrLink.ts +312 -0
- package/app/service.ts +3 -0
- package/db/migrations/125_feature_runs_pr_linked.sql +78 -0
- package/docs/agent-guide.md +282 -855
- package/e2e/feature-run.e2e.ts +58 -1
- package/e2e/plan-fanout.e2e.ts +24 -1
- package/e2e/support/github-admit.ts +33 -0
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +1 -0
- package/skills/nano-workforce/SKILL.md +20 -65
- package/workers/record-feature/worker.test.ts +256 -0
- package/workers/record-feature/worker.ts +27 -0
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -163,6 +163,49 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
163
163
|
|
|
164
164
|
const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({});
|
|
165
165
|
assert.equal(prs.length, 0, "raise-only did NOT enroll the PR into the convergence loop");
|
|
166
|
+
|
|
167
|
+
// Issue #907: the HOST posted exactly one idempotent "PR opened" comment on the claimed
|
|
168
|
+
// issue, and recorded the link on the run row so a repeated pass never double-posts.
|
|
169
|
+
// (Snapshot the issue's comments: the github state is shared across this file's tests.)
|
|
170
|
+
const comments = (githubState.issueComments.get(7) ?? []).filter((c) => c.body.includes("nwf:pr-linked:#101"));
|
|
171
|
+
assert.equal(comments.length, 1, "exactly one comment was posted on the claimed issue for PR #101");
|
|
172
|
+
assert.ok(comments[0].body.includes("Pull request opened: #101"), "the comment names the PR");
|
|
173
|
+
assert.ok(
|
|
174
|
+
comments[0].body.includes("<!-- nwf:pr-linked:#101 https://github.com/owner/repo/pull/101 -->"),
|
|
175
|
+
"the comment carries the URL-keyed dedupe marker",
|
|
176
|
+
);
|
|
177
|
+
assert.equal(
|
|
178
|
+
(run as unknown as { pr_linked_pr_key: string | null }).pr_linked_pr_key,
|
|
179
|
+
"owner/repo#101",
|
|
180
|
+
"the durable link record was written",
|
|
181
|
+
);
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("idempotence: re-running record-feature for the SAME delivered PR posts nothing more (issue #907)", async () => {
|
|
187
|
+
// A retried at-least-once `record-feature` job (or a second reconcile pass) must never
|
|
188
|
+
// double-post the "PR opened" comment: the durable `pr_linked_pr_key` claim short-circuits the
|
|
189
|
+
// repeat before any GitHub write.
|
|
190
|
+
await withApp(
|
|
191
|
+
{ "senior:feature": () => ({ status: "opened", pr: "owner/repo#101", summary: "built it" }) },
|
|
192
|
+
{ baseBranch: "epic/e2e" },
|
|
193
|
+
async ({ app, featureKey }) => {
|
|
194
|
+
const before = (githubState.issueComments.get(7) ?? []).length;
|
|
195
|
+
assert.equal(before >= 0, true);
|
|
196
|
+
// Drive the REAL record-feature handler a second time with the same delivered PR (the
|
|
197
|
+
// at-least-once repeat / second reconcile pass), against the SAME live app DB.
|
|
198
|
+
const { default: recordFeature } = await import("../workers/record-feature/worker.ts");
|
|
199
|
+
const log = { info: () => {}, warn: () => {}, error: () => {} };
|
|
200
|
+
await recordFeature(
|
|
201
|
+
{ jobKey: "repeat-1", variables: { featureKey, status: "opened", pr: "owner/repo#101", claimIssue: true } } as never,
|
|
202
|
+
{ data: app.db, log } as never,
|
|
203
|
+
);
|
|
204
|
+
assert.equal(
|
|
205
|
+
(githubState.issueComments.get(7) ?? []).length,
|
|
206
|
+
before,
|
|
207
|
+
"a repeated record pass posted NO second comment",
|
|
208
|
+
);
|
|
166
209
|
},
|
|
167
210
|
);
|
|
168
211
|
});
|
|
@@ -272,7 +315,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
272
315
|
calls += 1;
|
|
273
316
|
return calls === 1
|
|
274
317
|
? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
|
|
275
|
-
: { status: "opened", pr: "owner/repo#
|
|
318
|
+
: { status: "opened", pr: "owner/repo#104", summary: "resumed and opened" };
|
|
276
319
|
},
|
|
277
320
|
},
|
|
278
321
|
{ baseBranch: "epic/e2e" },
|
|
@@ -298,6 +341,13 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
298
341
|
);
|
|
299
342
|
assert.ok(!flows.includes("ic_gw_answer->ic_end"), "the abandon (default) flow was NOT taken");
|
|
300
343
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
344
|
+
|
|
345
|
+
// Issue #907: the RETRY opened the PR — the host still posted exactly one "PR opened"
|
|
346
|
+
// comment on the claimed issue (the first, escalated pass delivered no PR, so nothing was
|
|
347
|
+
// posted before the answer). Uses a PR number (#104) no earlier scenario links, so a pre-existing marker in the shared github state cannot satisfy this.
|
|
348
|
+
const comments = (githubState.issueComments.get(7) ?? []).filter((c) => c.body.includes("nwf:pr-linked:#104"));
|
|
349
|
+
assert.equal(comments.length, 1, "exactly one comment was posted across the escalate+resume");
|
|
350
|
+
assert.ok(comments[0].body.includes("Pull request opened: #104"), "the comment names the retry-opened PR");
|
|
301
351
|
},
|
|
302
352
|
);
|
|
303
353
|
});
|
|
@@ -388,6 +438,13 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
388
438
|
!tasks.some((t) => t.elementId === "escalation"),
|
|
389
439
|
"no human-escalation task was created for the adopted run",
|
|
390
440
|
);
|
|
441
|
+
|
|
442
|
+
// Issue #907: the ADOPTED PR was announced on the claimed issue exactly once — the
|
|
443
|
+
// reconcile path delivers the PR without the agent reporting it, and the host-side link
|
|
444
|
+
// covers it all the same. (Filtered to THIS PR's marker — the github state is shared.)
|
|
445
|
+
const comments = (githubState.issueComments.get(7) ?? []).filter((c) => c.body.includes("nwf:pr-linked:#801"));
|
|
446
|
+
assert.equal(comments.length, 1, "exactly one comment was posted for the adopted PR");
|
|
447
|
+
assert.ok(comments[0].body.includes("Pull request opened: #801"), "the comment names the adopted PR");
|
|
391
448
|
},
|
|
392
449
|
);
|
|
393
450
|
} finally {
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -57,6 +57,8 @@ type Stub = (job: EngineJob) => Record<string, unknown> | void;
|
|
|
57
57
|
describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → userTask + form)", () => {
|
|
58
58
|
const savedEnv = new Map<string, string | undefined>();
|
|
59
59
|
let restoreGithub: (() => void) | undefined;
|
|
60
|
+
// The shared hermetic GitHub state (issue #907 asserts NO epic-slice comment lands on the parent).
|
|
61
|
+
const githubState = admitGithubState("owner/repo", "main");
|
|
60
62
|
|
|
61
63
|
before(() => {
|
|
62
64
|
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
@@ -65,7 +67,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
65
67
|
}
|
|
66
68
|
// ADR 0003: `startPlanFanout` + the `pr.ensure-base-branch` head task now pass through base
|
|
67
69
|
// admission, which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
|
|
68
|
-
restoreGithub = installAdmitGithub(
|
|
70
|
+
restoreGithub = installAdmitGithub(githubState);
|
|
69
71
|
});
|
|
70
72
|
|
|
71
73
|
after(() => {
|
|
@@ -452,6 +454,27 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
452
454
|
);
|
|
453
455
|
});
|
|
454
456
|
|
|
457
|
+
test("an epic slice that opens a PR posts NO comment on the parent epic (issue #907)", async () => {
|
|
458
|
+
// The "PR opened" issue link is gated on `claimIssue`, which only a single-issue feature run
|
|
459
|
+
// seeds (app/feature.ts). An epic slice's `issue` is the SHARED parent epic, which must never be
|
|
460
|
+
// claimed or commented per-slice — so a slice that opens its PR leaves the epic's thread empty.
|
|
461
|
+
const before = (githubState.issueComments.get(1) ?? []).length;
|
|
462
|
+
await withApp(
|
|
463
|
+
{
|
|
464
|
+
"senior:plan": singleTaskPlan,
|
|
465
|
+
"senior:plan-review": approveReview,
|
|
466
|
+
"senior:feature": () => ({ status: "opened", pr: "owner/repo#101", summary: "opened the slice" }),
|
|
467
|
+
},
|
|
468
|
+
async () => {
|
|
469
|
+
assert.equal(
|
|
470
|
+
(githubState.issueComments.get(1) ?? []).length,
|
|
471
|
+
before,
|
|
472
|
+
"the parent epic gained NO comment from the slice's delivered PR",
|
|
473
|
+
);
|
|
474
|
+
},
|
|
475
|
+
);
|
|
476
|
+
});
|
|
477
|
+
|
|
455
478
|
// Capability edge (issue #289): a task that declares cross-repo `needs` must PARK at the
|
|
456
479
|
// `wait-caps-resolved` barrier (never take the no-needs shortcut), and publishing `caps-resolved`
|
|
457
480
|
// correlated on the per-task barrier key `<planKey>:<taskId>` must release it into `implement-task`
|
|
@@ -20,6 +20,9 @@ export interface AdmitGithubState {
|
|
|
20
20
|
* head via `listPrsForHead`. Empty by default → the pulls listing returns `[]` (no adoptable PR),
|
|
21
21
|
* so suites that don't opt in keep exactly today's escalate behaviour. */
|
|
22
22
|
openPrs: Map<string, { number: number; base?: string }>;
|
|
23
|
+
/** Issue comments keyed by issue number (issue #907): the record-feature "PR opened" link lists
|
|
24
|
+
* existing comments (marker dedupe) and POSTs new ones here. Seeded empty. */
|
|
25
|
+
issueComments: Map<number, { body: string }[]>;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
/** Build a fresh admit-github state with the default branch pre-seeded with a HEAD sha so an
|
|
@@ -35,6 +38,7 @@ export function admitGithubState(
|
|
|
35
38
|
creates: [],
|
|
36
39
|
resets: [],
|
|
37
40
|
openPrs: new Map(),
|
|
41
|
+
issueComments: new Map(),
|
|
38
42
|
};
|
|
39
43
|
}
|
|
40
44
|
|
|
@@ -95,6 +99,35 @@ function admitFetch(state: AdmitGithubState) {
|
|
|
95
99
|
]),
|
|
96
100
|
);
|
|
97
101
|
}
|
|
102
|
+
// GET /repos/{repo}/pulls/{n} → a PR's meta (fetchPrMeta — the "PR opened" link's best-effort
|
|
103
|
+
// title read, issue #907). Served from the openPrs entry when one names this number.
|
|
104
|
+
const pullMatch = path.match(/^\/repos\/([^/]+\/[^/]+)\/pulls\/(\d+)$/);
|
|
105
|
+
if (method === "GET" && pullMatch) {
|
|
106
|
+
const number = Number(pullMatch[2]);
|
|
107
|
+
for (const [branch, hit] of state.openPrs) {
|
|
108
|
+
if (hit.number === number) {
|
|
109
|
+
return Promise.resolve(
|
|
110
|
+
json({ title: `PR #${number}`, body: "", head: { ref: branch }, base: { ref: hit.base ?? state.defaultBranch } }),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return Promise.resolve(json({ title: `PR #${number}`, body: "", head: { ref: null }, base: { ref: state.defaultBranch } }));
|
|
115
|
+
}
|
|
116
|
+
// GET /repos/{repo}/issues/{n}/comments → the issue's comments (the "PR opened" marker dedupe
|
|
117
|
+
// scan, issue #907). POST appends a comment.
|
|
118
|
+
const commentsMatch = path.match(/^\/repos\/([^/]+\/[^/]+)\/issues\/(\d+)\/comments$/);
|
|
119
|
+
if (commentsMatch) {
|
|
120
|
+
const issueNumber = Number(commentsMatch[2]);
|
|
121
|
+
if (method === "GET") return Promise.resolve(json(state.issueComments.get(issueNumber) ?? []));
|
|
122
|
+
if (method === "POST") {
|
|
123
|
+
// biome-ignore lint/plugin: runtime contract boundary for parsed JSON
|
|
124
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { body?: string };
|
|
125
|
+
const list = state.issueComments.get(issueNumber) ?? [];
|
|
126
|
+
list.push({ body: String(body.body ?? "") });
|
|
127
|
+
state.issueComments.set(issueNumber, list);
|
|
128
|
+
return Promise.resolve(json({ id: list.length }, 201));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
98
131
|
// Any other endpoint is a best-effort read the sealed transport used to skip → 404 (null).
|
|
99
132
|
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
100
133
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.204.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<nano:extend name="status" type="string" optional="true" />
|
|
9
9
|
<nano:extend name="pr" type="string" optional="true" />
|
|
10
10
|
<nano:extend name="summary" type="string" optional="true" />
|
|
11
|
+
<nano:extend name="claimIssue" type="boolean" optional="true" />
|
|
11
12
|
</nano:shape>
|
|
12
13
|
<nano:shape id="RecordFeatureImplementingIn" name="Record feature implementing — input">
|
|
13
14
|
<nano:extend name="featureKey" type="string" optional="true" />
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
<zeebe:ioMapping>
|
|
30
30
|
<zeebe:input source="=" --- " + task.prompt + (if (is defined(baseBranchBrief) and baseBranchBrief != null) then baseBranchBrief else "") + (if (is defined(blackboardBrief) and blackboardBrief != null) then blackboardBrief else "") + (if (is defined(resolvedDepsBrief) and resolvedDepsBrief != null) then resolvedDepsBrief else "") + (if (is defined(resolvedArtifacts) = false or resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then "" else (" --- **Bound upstream readiness.** Build/install/clone against EXACTLY these resolved published versions (the ones first carrying the awaited capability), and bump the consumer dependency to them — never merely the newest: " + string join(resolvedArtifacts[item != null], " "))) + (if (is defined(customInstructions) = false or customInstructions = null) then "" else " --- ## Operator custom instructions The operator supplied these instructions for this run — follow them: " + customInstructions) + (if (is defined(retryNudge) and retryNudge != null) then retryNudge else "")" target="appendPrompt" />
|
|
31
31
|
<zeebe:input source="=if (is defined(transcriptUrlBase)) then transcriptUrlBase else null" target="transcriptUrlBase" />
|
|
32
|
+
<zeebe:input source="=if (is defined(claimIssue)) then claimIssue else null" target="claimIssue" />
|
|
32
33
|
<zeebe:output source="=status" target="status" />
|
|
33
34
|
<zeebe:output source="=question" target="question" />
|
|
34
35
|
<zeebe:output source="=if (is defined(summary)) then summary else null" target="summary" />
|
|
@@ -11,27 +11,15 @@ implements → converges** them across a fleet of coding agents, and runs
|
|
|
11
11
|
**agent-authored delivery graphs** (heterogeneous cross-repo, human-in-the-loop
|
|
12
12
|
DAGs — ADR 0005).
|
|
13
13
|
|
|
14
|
-
**This skill is a thin bootstrap
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
reach that live surface and follow it, never to work from a cached copy that drifts
|
|
18
|
-
across versions and instances.
|
|
19
|
-
|
|
20
|
-
There are two paths. **Prefer MCP (§A).** If your client has no MCP support, use the
|
|
21
|
-
fetch-the-live-guide fallback (§B). Both talk to the same app; MCP is a projection of
|
|
22
|
-
the same OpenAPI contract, not a different system (ADR 0067).
|
|
14
|
+
**This skill is a thin bootstrap.** Every running instance describes itself, over **MCP**
|
|
15
|
+
(§A, preferred) or through its **live operator guide** (§B). Reach that live surface and
|
|
16
|
+
follow it rather than a cached copy that drifts between versions.
|
|
23
17
|
|
|
24
18
|
## A. Preferred — drive over MCP
|
|
25
19
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(process instances, wait states, variables, incidents) and the app's projection
|
|
30
|
-
reads. The operator **guide** (the same guide as §B) is itself one of those projected
|
|
31
|
-
tools — `GET /app/api/agent` becomes the `getAgentInstructions` read tool — so the
|
|
32
|
-
workflow knowledge (orient first, preview before dispatch, escalations are for humans)
|
|
33
|
-
is discoverable over the same channel as the drive tools; the runtime additionally
|
|
34
|
-
serves its derived **system brief** as an MCP resource plus an orientation prompt.
|
|
20
|
+
Every instance serves a Streamable-HTTP MCP endpoint at **`/app/mcp`**. Its tools are the
|
|
21
|
+
app's operations (projected from its OpenAPI spec) plus framework engine-debug tools. The
|
|
22
|
+
operator guide itself is the `getAgentInstructions` tool.
|
|
35
23
|
|
|
36
24
|
**Register one MCP server entry per instance.** Naming the instance
|
|
37
25
|
(`"drive workforce-merlin"`) makes the wrong-instance mistake structurally
|
|
@@ -64,14 +52,9 @@ Then: start a session, confirm the `workforce-*` tools appear, and ask the agent
|
|
|
64
52
|
use a **named** instance (`"Using workforce-local, show what's in flight and any open
|
|
65
53
|
escalations"`). It should call the status operation tool, not curl.
|
|
66
54
|
|
|
67
|
-
**Guard posture.**
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
**is set**, that secret is required as an `x-hook-secret` header on **both reads and
|
|
71
|
-
mutations** — read endpoints like `getAgentInstructions`/`getVersion` also return `401`
|
|
72
|
-
without it — put it in the server entry's `headers`, never in chat. **Operator-only doors stay operator-only:** the delivery-graph
|
|
73
|
-
dispatch/dismiss lifecycle (the human clicking Dispatch *is* the approval, ADR 0005)
|
|
74
|
-
is `x-mcp`-excluded and is **not** a tool — dispatch stays a human action in the
|
|
55
|
+
**Guard posture.** If `NANO_PR_WEBHOOK_SECRET` is set, every read and mutation needs it as an
|
|
56
|
+
`x-hook-secret` header (otherwise `401`). Put it in the server entry's `headers`, never in
|
|
57
|
+
chat. Delivery-graph dispatch is deliberately **not** a tool: it stays a human action in the
|
|
75
58
|
cockpit.
|
|
76
59
|
|
|
77
60
|
The full server-entry recipes (multiple instances, Basic-Auth-fronted instances,
|
|
@@ -129,45 +112,17 @@ curl -sS ${NANO_PR_WEBHOOK_SECRET:+-H "x-hook-secret: $NANO_PR_WEBHOOK_SECRET"}
|
|
|
129
112
|
`GET /app/api/agent` (`getAgentInstructions`) returns
|
|
130
113
|
`{ format, appVersion, generatedAt, baseUrl, engineBase, instructions }` — the
|
|
131
114
|
authoritative, version-matched operator guide, every example keyed to this
|
|
132
|
-
instance. Read it
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
### B.3 Orient before acting
|
|
137
|
-
|
|
138
|
-
```bash
|
|
139
|
-
curl -sS "$BASE/version" | jq # app/urban version, git sha, uptime
|
|
140
|
-
curl -sS "$BASE/status" | jq # every PR/instance in flight + open escalations
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
`/status` is the primary situational-awareness endpoint — check it before you submit
|
|
144
|
-
or unstick anything.
|
|
145
|
-
|
|
146
|
-
### B.4 What you can drive (all detailed in the live guide)
|
|
147
|
-
|
|
148
|
-
- **Submit a PR** for review convergence — `POST $BASE/actions/start/convergence-loop`.
|
|
149
|
-
- **Submit an issue/epic** for plan → implement → converge — `POST $BASE/actions/start/plan-fanout`.
|
|
150
|
-
- **Submit a delivery graph** (ADR 0005) — propose → preview → approve → **dispatch**.
|
|
151
|
-
Compile/preview is a pure, side-effect-free path; **dispatch is an operator action
|
|
152
|
-
in the cockpit**, not an agent door.
|
|
153
|
-
- **Answer an escalation** — `POST $BASE/actions/complete-user-task`, or the agent
|
|
154
|
-
hook `POST $BASE/hooks/agent-complete` (`agentCompleteEscalation`).
|
|
155
|
-
- **Debug** — relate an in-flight PR to its engine process instance via `processKey`
|
|
156
|
-
from `/status`, then inspect and unstick it against the engine REST base
|
|
157
|
-
(`engineBase` from the guide).
|
|
115
|
+
instance. Read it and follow it: orientation, submitting work, answering escalations,
|
|
116
|
+
debugging. **Re-fetch it every session.** For one section, use
|
|
117
|
+
`GET $BASE/agent/guide?section=<id>` (no `section` = table of contents).
|
|
158
118
|
|
|
159
119
|
## Principles
|
|
160
120
|
|
|
161
|
-
- **
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
- **
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
- **
|
|
168
|
-
|
|
169
|
-
start. Dispatch itself is the operator's call.
|
|
170
|
-
- **Idempotency.** Submissions carry dedupe keys; re-submitting the same work must not
|
|
171
|
-
double-dispatch. Honour the keys the guide documents.
|
|
172
|
-
- **Escalations are for humans.** When the workforce parks on a human node, surface it
|
|
173
|
-
with options; don't silently auto-answer design/product decisions.
|
|
121
|
+
- **The live surface wins.** If this skill and the instance disagree, follow the instance.
|
|
122
|
+
- **Confirm the target instance** before any side-effecting call (name the MCP server entry,
|
|
123
|
+
or ask when the base URL is ambiguous).
|
|
124
|
+
- **Preview before dispatch.** Show the user the plan before any bulk or side-effecting start.
|
|
125
|
+
Dispatching a delivery graph is the operator's call.
|
|
126
|
+
- **Submissions are idempotent.** Re-submitting the same work must not double-dispatch.
|
|
127
|
+
- **Escalations are for humans.** Surface them with options; don't silently answer design or
|
|
128
|
+
product decisions.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Unit coverage for pr.record-feature's "PR opened" issue link (issue #907).
|
|
2
|
+
//
|
|
3
|
+
// record-feature is the ONE point every PR-delivering path of a single-issue feature run passes
|
|
4
|
+
// through — the agent's own `opened`, a retry's `opened`, and the reconcile step's ADOPTED `opened`
|
|
5
|
+
// all land here — so it is the canonical host-side writer of the idempotent
|
|
6
|
+
// `🤖 Pull request opened: #n` comment on the claimed issue. The decision itself lives in
|
|
7
|
+
// app/issuePrLink.ts (exhaustively covered in app/issuePrLink.test.ts); these tests pin the
|
|
8
|
+
// WIRING: the worker records the owed link + attempts it only for a claimed (single-issue) run
|
|
9
|
+
// that delivered a keyed PR, the comment targets the CLAIMED issue (the run row's repo +
|
|
10
|
+
// issue_number, not the PR's repository), and a failed comment never fails the job.
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { assert, assertEquals } from "#test-assert";
|
|
13
|
+
import { noopLog } from "../../test/log.ts";
|
|
14
|
+
import handler from "./worker.ts";
|
|
15
|
+
|
|
16
|
+
// biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors record-feature-implementing.worker.test
|
|
17
|
+
function fakeApp(rows: Record<string, unknown>[]): { app: any; linkWrites: string[] } {
|
|
18
|
+
const stores: Record<string, Record<string, unknown>[]> = { feature_runs: rows };
|
|
19
|
+
const linkWrites: string[] = [];
|
|
20
|
+
return {
|
|
21
|
+
app: {
|
|
22
|
+
stores,
|
|
23
|
+
data: {
|
|
24
|
+
table(name: string, key: string) {
|
|
25
|
+
const store = (stores[name] ??= []);
|
|
26
|
+
return {
|
|
27
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
28
|
+
get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
30
|
+
update: (k: any, patch: any) => {
|
|
31
|
+
const row = store.find((r) => r[key] === k);
|
|
32
|
+
if (row) Object.assign(row, patch);
|
|
33
|
+
return Promise.resolve(row);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
open() {
|
|
38
|
+
return {
|
|
39
|
+
exec(sql: string, params: unknown[]) {
|
|
40
|
+
linkWrites.push(sql);
|
|
41
|
+
// Mirror the issue-link state machine's SQL on the in-memory row so the link record
|
|
42
|
+
// is observable: the owed write, the lease CAS, and the confirm.
|
|
43
|
+
const row = stores.feature_runs[0];
|
|
44
|
+
if (!row) return { changed: 0 };
|
|
45
|
+
if (/SET "pr_link_want_pr_key" = \?/.test(sql)) {
|
|
46
|
+
row.pr_link_want_pr_key = String(params[0]);
|
|
47
|
+
return { changed: 1 };
|
|
48
|
+
}
|
|
49
|
+
if (/SET "pr_link_lease_at" = \?/.test(sql)) {
|
|
50
|
+
row.pr_link_lease_at = String(params[0]);
|
|
51
|
+
return { changed: 1 };
|
|
52
|
+
}
|
|
53
|
+
if (/SET "pr_linked_pr_key" = \?, "pr_link_lease_at" = NULL/.test(sql)) {
|
|
54
|
+
row.pr_linked_pr_key = String(params[0]);
|
|
55
|
+
row.pr_link_lease_at = null;
|
|
56
|
+
return { changed: 1 };
|
|
57
|
+
}
|
|
58
|
+
return { changed: 0 };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
log: noopLog(),
|
|
64
|
+
},
|
|
65
|
+
linkWrites,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Hermetic GitHub stub (token transport + fetch double) capturing posted comment bodies and the
|
|
70
|
+
* path of every call (so a test can pin WHICH repo/issue a comment or title read targeted). */
|
|
71
|
+
function stubGithubComments(existing: { body: string }[] = [], opts: { failPost?: boolean } = {}) {
|
|
72
|
+
const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
73
|
+
const prevToken = process.env.GITHUB_TOKEN;
|
|
74
|
+
const prevFetch = globalThis.fetch;
|
|
75
|
+
process.env.NANO_PR_GITHUB_TRANSPORT = "token";
|
|
76
|
+
process.env.GITHUB_TOKEN = "t";
|
|
77
|
+
const posted: string[] = [];
|
|
78
|
+
const calls: string[] = [];
|
|
79
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
80
|
+
const method = (init?.method ?? "GET").toUpperCase();
|
|
81
|
+
const u = new URL(String(url));
|
|
82
|
+
calls.push(`${method} ${u.pathname}`);
|
|
83
|
+
if (method === "GET" && /\/issues\/\d+\/comments$/.test(u.pathname)) {
|
|
84
|
+
return new Response(JSON.stringify(existing), { status: 200 });
|
|
85
|
+
}
|
|
86
|
+
if (method === "GET" && /\/issues\/\d+$/.test(u.pathname)) {
|
|
87
|
+
return new Response(JSON.stringify({ title: "the issue" }), { status: 200 });
|
|
88
|
+
}
|
|
89
|
+
if (method === "GET" && /\/pulls\/\d+$/.test(u.pathname)) {
|
|
90
|
+
return new Response(JSON.stringify({ title: "the PR" }), { status: 200 });
|
|
91
|
+
}
|
|
92
|
+
if (method === "POST" && /\/issues\/\d+\/comments$/.test(u.pathname)) {
|
|
93
|
+
if (opts.failPost) return new Response("boom", { status: 500 });
|
|
94
|
+
// biome-ignore lint/plugin: runtime contract boundary for parsed JSON in a test double
|
|
95
|
+
posted.push(String((JSON.parse(String(init?.body ?? "{}")) as { body?: string }).body ?? ""));
|
|
96
|
+
return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
97
|
+
}
|
|
98
|
+
return new Response("Not Found", { status: 404 });
|
|
99
|
+
}) as typeof fetch;
|
|
100
|
+
return {
|
|
101
|
+
posted,
|
|
102
|
+
calls,
|
|
103
|
+
restore: () => {
|
|
104
|
+
globalThis.fetch = prevFetch;
|
|
105
|
+
if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
106
|
+
else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
|
|
107
|
+
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
108
|
+
else process.env.GITHUB_TOKEN = prevToken;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ROW = { feature_key: "owner/repo#7", repo: "owner/repo", issue_number: 7, status: "running" };
|
|
114
|
+
|
|
115
|
+
test("an opened run on a claimed issue posts the PR-opened comment and records the link", async () => {
|
|
116
|
+
const gh = stubGithubComments();
|
|
117
|
+
const rows = [{ ...ROW }];
|
|
118
|
+
const { app } = fakeApp(rows);
|
|
119
|
+
try {
|
|
120
|
+
const out = await handler(
|
|
121
|
+
{ jobKey: "j1", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#101", claimIssue: true } } as never,
|
|
122
|
+
app,
|
|
123
|
+
);
|
|
124
|
+
assertEquals(out.featureStatus, "opened");
|
|
125
|
+
assertEquals(out.prKey, "owner/repo#101");
|
|
126
|
+
assertEquals(gh.posted.length, 1, "the claimed issue got the PR-opened comment");
|
|
127
|
+
assert(gh.posted[0].includes("Pull request opened: #101"), "the comment names the PR");
|
|
128
|
+
assert(
|
|
129
|
+
gh.posted[0].includes("<!-- nwf:pr-linked:#101 https://github.com/owner/repo/pull/101 -->"),
|
|
130
|
+
"the comment carries the URL-keyed dedupe marker",
|
|
131
|
+
);
|
|
132
|
+
assertEquals(rows[0].pr_link_want_pr_key, "owner/repo#101", "the owed link was recorded");
|
|
133
|
+
assertEquals(rows[0].pr_linked_pr_key, "owner/repo#101", "the durable link was confirmed");
|
|
134
|
+
} finally {
|
|
135
|
+
gh.restore();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("an ADOPTED opened (reconcile path) posts the comment too — the worker cannot tell the difference", async () => {
|
|
140
|
+
const gh = stubGithubComments();
|
|
141
|
+
const rows = [{ ...ROW }];
|
|
142
|
+
const { app } = fakeApp(rows);
|
|
143
|
+
try {
|
|
144
|
+
// The reconcile step re-emitted status=opened + pr for a PR the harness never reported; the
|
|
145
|
+
// record worker treats it exactly like an agent-reported opened.
|
|
146
|
+
await handler(
|
|
147
|
+
{ jobKey: "j2", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#801", claimIssue: true } } as never,
|
|
148
|
+
app,
|
|
149
|
+
);
|
|
150
|
+
assertEquals(gh.posted.length, 1, "the adopted PR was announced on the issue");
|
|
151
|
+
assert(gh.posted[0].includes("#801"), "the comment names the adopted PR");
|
|
152
|
+
} finally {
|
|
153
|
+
gh.restore();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a CROSS-REPO PR comments on the CLAIMED issue (the run row's repo + issue_number), not the PR's repo", async () => {
|
|
158
|
+
// Regression for the review finding: the old worker derived the comment target from the PR's
|
|
159
|
+
// repository + the issue NUMBER, so a run for owner/issues#7 delivering owner/code#101 commented
|
|
160
|
+
// on owner/code#7 (or 404'd). The target must come from the run row; the PR repo is used only
|
|
161
|
+
// for the title lookup.
|
|
162
|
+
const gh = stubGithubComments();
|
|
163
|
+
const rows = [{ feature_key: "owner/issues#7", repo: "owner/issues", issue_number: 7, status: "running" }];
|
|
164
|
+
const { app } = fakeApp(rows);
|
|
165
|
+
try {
|
|
166
|
+
const out = await handler(
|
|
167
|
+
{ jobKey: "j2x", variables: { featureKey: "owner/issues#7", status: "opened", pr: "owner/code#101", claimIssue: true } } as never,
|
|
168
|
+
app,
|
|
169
|
+
);
|
|
170
|
+
assertEquals(out.prKey, "owner/code#101");
|
|
171
|
+
assertEquals(gh.posted.length, 1, "the claimed issue got the PR-opened comment");
|
|
172
|
+
assert(
|
|
173
|
+
gh.calls.some((c) => c === "POST /repos/owner/issues/issues/7/comments"),
|
|
174
|
+
`the comment landed on the claimed issue owner/issues#7 (calls: ${gh.calls.join(", ")})`,
|
|
175
|
+
);
|
|
176
|
+
assert(
|
|
177
|
+
gh.calls.some((c) => c === "GET /repos/owner/code/pulls/101"),
|
|
178
|
+
"the PR title was read from the PR's OWN repository",
|
|
179
|
+
);
|
|
180
|
+
assert(
|
|
181
|
+
!gh.calls.some((c) => c.includes("/repos/owner/code/issues/")),
|
|
182
|
+
"the PR repository's issues were never targeted",
|
|
183
|
+
);
|
|
184
|
+
assert(gh.posted[0].includes("https://github.com/owner/code/pull/101"), "the comment links the cross-repo PR");
|
|
185
|
+
} finally {
|
|
186
|
+
gh.restore();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a repeated record pass for the SAME PR posts nothing more (idempotent)", async () => {
|
|
191
|
+
const gh = stubGithubComments();
|
|
192
|
+
const rows = [{ ...ROW, pr_link_want_pr_key: "owner/repo#101", pr_linked_pr_key: "owner/repo#101" }];
|
|
193
|
+
const { app, linkWrites } = fakeApp(rows);
|
|
194
|
+
try {
|
|
195
|
+
await handler(
|
|
196
|
+
{ jobKey: "j3", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#101", claimIssue: true } } as never,
|
|
197
|
+
app,
|
|
198
|
+
);
|
|
199
|
+
assertEquals(gh.posted.length, 0, "no duplicate comment");
|
|
200
|
+
assert(
|
|
201
|
+
!linkWrites.some((sql) => /SET "pr_link_lease_at"/.test(sql)),
|
|
202
|
+
"the confirmed link short-circuited before the lease CAS (and before any GitHub read)",
|
|
203
|
+
);
|
|
204
|
+
} finally {
|
|
205
|
+
gh.restore();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("an epic slice (claimIssue unset) posts NOTHING on its parent epic", async () => {
|
|
210
|
+
const gh = stubGithubComments();
|
|
211
|
+
const rows = [{ ...ROW }];
|
|
212
|
+
const { app, linkWrites } = fakeApp(rows);
|
|
213
|
+
try {
|
|
214
|
+
await handler(
|
|
215
|
+
{ jobKey: "j4", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#101" } } as never,
|
|
216
|
+
app,
|
|
217
|
+
);
|
|
218
|
+
assertEquals(gh.posted.length, 0, "no comment was posted");
|
|
219
|
+
assertEquals(linkWrites.length, 0, "no link state was written");
|
|
220
|
+
} finally {
|
|
221
|
+
gh.restore();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a blocked run posts nothing", async () => {
|
|
226
|
+
const gh = stubGithubComments();
|
|
227
|
+
const rows = [{ ...ROW }];
|
|
228
|
+
const { app } = fakeApp(rows);
|
|
229
|
+
try {
|
|
230
|
+
const out = await handler(
|
|
231
|
+
{ jobKey: "j5", variables: { featureKey: "owner/repo#7", status: "blocked", claimIssue: true } } as never,
|
|
232
|
+
app,
|
|
233
|
+
);
|
|
234
|
+
assertEquals(out.featureStatus, "blocked");
|
|
235
|
+
assertEquals(gh.posted.length, 0);
|
|
236
|
+
} finally {
|
|
237
|
+
gh.restore();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("a failed comment POST never fails the job — the run still records opened", async () => {
|
|
242
|
+
const gh = stubGithubComments([], { failPost: true });
|
|
243
|
+
const rows = [{ ...ROW }];
|
|
244
|
+
const { app } = fakeApp(rows);
|
|
245
|
+
try {
|
|
246
|
+
const out = await handler(
|
|
247
|
+
{ jobKey: "j6", variables: { featureKey: "owner/repo#7", status: "opened", pr: "owner/repo#101", claimIssue: true } } as never,
|
|
248
|
+
app,
|
|
249
|
+
);
|
|
250
|
+
assertEquals(out.featureStatus, "opened", "the job completed normally");
|
|
251
|
+
assertEquals(rows[0].status, "opened", "the run row was still recorded");
|
|
252
|
+
assertEquals(rows[0].pr_key, "owner/repo#101");
|
|
253
|
+
} finally {
|
|
254
|
+
gh.restore();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
@@ -11,8 +11,18 @@
|
|
|
11
11
|
//
|
|
12
12
|
// Enrollment itself lives in the separate `pr.converge-feature` worker (gated by the `converge?`
|
|
13
13
|
// branch) so the hand-off is legible in the process, not buried in a persistence step.
|
|
14
|
+
//
|
|
15
|
+
// This is ALSO the one point every PR-delivering path passes through — the agent's own `opened`,
|
|
16
|
+
// a retry's `opened`, and the implement-cell reconcile step's ADOPTED `opened` all land here — so
|
|
17
|
+
// it is the canonical host-side writer of the idempotent "PR opened" comment on the claimed issue
|
|
18
|
+
// (issue #907, app/issuePrLink.ts): a claimed single-issue run (`claimIssue`) that delivered a
|
|
19
|
+
// keyed PR gets exactly one `🤖 Pull request opened: #n` comment on its issue, however the PR was
|
|
20
|
+
// delivered. Epic slices never set `claimIssue`, so they never comment on the parent epic. The
|
|
21
|
+
// comment is best-effort — a failure never fails the run; the owed link is persisted and the
|
|
22
|
+
// poller's retry pass (app/service.ts → retryOutstandingIssuePrLinks) re-attempts it.
|
|
14
23
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
15
24
|
import { type FeatureRunStatus, featureRuns } from "../../app/feature.ts";
|
|
25
|
+
import { requestIssuePrLink } from "../../app/issuePrLink.ts";
|
|
16
26
|
import { parsePr } from "../../app/service.ts";
|
|
17
27
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
18
28
|
|
|
@@ -58,6 +68,23 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
58
68
|
});
|
|
59
69
|
app.log.info("record-feature", { featureKey, featureStatus, rowStatus, prKey });
|
|
60
70
|
|
|
71
|
+
// Issue #907: announce the delivered PR on the claimed issue — ONE idempotent comment, posted by
|
|
72
|
+
// the HOST so every delivery path (agent-opened, retry-opened, reconcile-adopted) covers it.
|
|
73
|
+
// Only a claimed single-issue run (`claimIssue`) with a keyed PR qualifies; epic slices never set
|
|
74
|
+
// `claimIssue`, so they never comment on the parent epic. The ISSUE target comes from the run row
|
|
75
|
+
// (`repo` + `issue_number`), never from the PR's repository, so a cross-repo PR still lands on the
|
|
76
|
+
// claimed issue. Best-effort: a failed attempt leaves the owed link for the poller to retry and
|
|
77
|
+
// never fails the run.
|
|
78
|
+
const link = await requestIssuePrLink(
|
|
79
|
+
{
|
|
80
|
+
featureKey,
|
|
81
|
+
prKey,
|
|
82
|
+
claimIssue: job.variables.claimIssue,
|
|
83
|
+
},
|
|
84
|
+
app.data,
|
|
85
|
+
);
|
|
86
|
+
if (link !== "skipped") app.log.info("record-feature: issue PR link", { featureKey, prKey, link });
|
|
87
|
+
|
|
61
88
|
return { featureStatus, prKey };
|
|
62
89
|
};
|
|
63
90
|
|