@nanobpm/nano-workforce 0.203.1 β†’ 0.204.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.
@@ -0,0 +1,312 @@
1
+ // The canonical "PR opened" issue-link decision (issue #907).
2
+ //
3
+ // When a single-issue feature run delivers its PR, the claimed issue's comment thread must link to
4
+ // it: the agent posts `πŸ€– Starting work…` (resources/prompts/feature.md) but nothing ever said
5
+ // "PR opened: #N", so a reader saw the claim and then silence β€” and concluded the agent stalled
6
+ // (camunda/camunda#19090: the PR existed, opened by a retry, but the thread never showed it).
7
+ //
8
+ // This module is the ONE host-side writer (derivation over duplication β€” NOT a second instruction
9
+ // in each prompt). `pr.record-feature` calls it on the run's delivery point, which EVERY
10
+ // PR-delivering path passes through: the agent's own `opened`, a retry's `opened`, and the
11
+ // implement-cell reconcile step's ADOPTED `opened` (app/implementReconcile.ts) all converge there.
12
+ //
13
+ // Gating mirrors the claim rule: only when the run seeded `claimIssue: true` (a single-issue
14
+ // feature run β€” app/feature.ts). An epic slice never sets it, so a wave slice NEVER comments on
15
+ // the shared parent epic.
16
+ //
17
+ // Durable state machine (migration 125) β€” three columns on `feature_runs`:
18
+ // β€’ `pr_link_want_pr_key` β€” the PR the issue is OWED a link for. Written by `requestIssuePrLink`
19
+ // (pr.record-feature) ONLY for a claimed single-issue run with a keyed PR.
20
+ // β€’ `pr_linked_pr_key` β€” CONFIRMED: written only after a successful POST or a marker match. A
21
+ // pending/in-flight claim is NEVER mistaken for a confirmed link.
22
+ // β€’ `pr_link_lease_at` β€” an EXPIRING in-flight lease, CAS-claimed before any GitHub I/O so two
23
+ // racing passes never both post. On ANY failure (unreadable scan, rejected/thrown POST, crash)
24
+ // the lease is simply left to expire β€” that is the retry backoff β€” and the poller's
25
+ // `pollIssuePrLinks` pass (app/service.ts β†’ `retryOutstandingIssuePrLinks`) re-attempts every
26
+ // outstanding link through this same `attemptIssuePrLink`, so a transient failure never leaves
27
+ // the delivered PR unannounced and never fails or restarts the run.
28
+ // Each comment also carries a hidden `<!-- nwf:pr-linked:#n <canonical-pr-url> -->` marker; the
29
+ // issue's comments are scanned (ALL pages) before posting, which heals a crash between a successful
30
+ // POST and the confirm. The marker keys on the canonical PR URL, not just the number, because PR
31
+ // numbers are repository-local β€” a same-number PR in another repo must NOT satisfy the scan.
32
+ //
33
+ // Stale-lease safety: the lease is re-validated with a guarded CAS (re-claiming a FRESH timestamp)
34
+ // IMMEDIATELY before the POST. A worker that stalls on the marker scan / title lookup past the lease
35
+ // expiry can have its lease re-claimed by the poller (which then posts); when the worker resumes it
36
+ // must NOT post a duplicate. The pre-post CAS rejects that stale holder (its claimed ts no longer
37
+ // matches) BEFORE the write β€” the after-the-fact confirm guard would be too late.
38
+ //
39
+ // Targeting: the comment goes to the CLAIMED ISSUE (the run row's `repo` + `issue_number`), never
40
+ // to the PR's repository β€” a cross-repo PR is looked up (title) in its own repo only.
41
+ //
42
+ // Best-effort throughout: every path resolves to a status string and NEVER throws.
43
+ import type { DataLayer } from "@nanobpm/urban";
44
+ import { featureRuns } from "./feature.ts";
45
+ import { fetchPrMeta, runGh, useGh } from "./github.ts";
46
+ import { parsePr } from "./prParse.ts";
47
+
48
+ /** The hidden per-PR dedupe marker prefix carried by every PR-opened comment. The full marker is
49
+ * `<!-- nwf:pr-linked:#<number> <canonical-pr-url> -->`; scanning for it makes a repeated pass
50
+ * idempotent even when the durable claim was lost (crash between the POST and the record write).
51
+ * The canonical PR URL is part of the marker because PR numbers are repository-local: a number-only
52
+ * marker would let a same-number PR in a DIFFERENT repo satisfy the scan and confirm a link that
53
+ * was never announced (issue #907 review). */
54
+ export const ISSUE_PR_LINK_MARKER_PREFIX = "<!-- nwf:pr-linked:#";
55
+
56
+ /** Build the full dedupe marker for one PR: `<!-- nwf:pr-linked:#<number> <canonical-pr-url> -->`.
57
+ * The URL disambiguates a same-number PR in another repository. */
58
+ export function issuePrLinkMarker(pr: { number: number; url: string }): string {
59
+ return `${ISSUE_PR_LINK_MARKER_PREFIX}${pr.number} ${pr.url} -->`;
60
+ }
61
+
62
+ /** The discriminated outcome of a link attempt β€” logged by the caller so a skipped/failed link is
63
+ * observable without ever failing the run. */
64
+ export type IssuePrLinkOutcome =
65
+ | "posted" // the comment was posted and the link confirmed
66
+ | "already-recorded" // the confirmed record already names the owed PR β€” no GitHub I/O at all
67
+ | "already-marked" // the marker scan found the comment β€” link confirmed without re-posting
68
+ | "claim-lost" // another pass holds a live lease (or the CAS lost) β€” that pass owns the attempt
69
+ | "post-failed" // GitHub rejected/threw on the POST β€” lease left to expire; the poller retries
70
+ | "list-failed" // the marker scan was unreadable β€” nothing posted blind; the poller retries
71
+ | "skipped"; // not a claimed single-issue run, no PR, no row, or a guard failure
72
+
73
+ /** The comment body: one line naming the PR (number, optional title, URL) plus the hidden marker. */
74
+ export function prOpenedCommentBody(pr: { number: number; title?: string | null; url: string }): string {
75
+ const title = typeof pr.title === "string" && pr.title.trim() !== "" ? ` β€” ${pr.title.trim()}` : "";
76
+ return `πŸ€– Pull request opened: #${pr.number}${title} (${pr.url})\n\n${issuePrLinkMarker(pr)}`;
77
+ }
78
+
79
+ /** How long an in-flight lease blocks other passes. A crash or failure leaves it to expire, after
80
+ * which the poller retries β€” so this is also the retry backoff. */
81
+ export const ISSUE_PR_LINK_LEASE_MS = 5 * 60 * 1000;
82
+
83
+ /** Hard deadline on EVERY GitHub read/write this module makes (both transports). Kept well below the
84
+ * lease so a pass that re-validated its lease immediately before the POST still owns it for the
85
+ * whole pending write: a POST can never outlive the lease and race a poller re-claim into a
86
+ * duplicate. A timed-out POST is `post-failed` (never confirmed); the lease then expires and the
87
+ * retry's marker scan heals the case where GitHub committed it anyway. */
88
+ export const ISSUE_PR_LINK_IO_TIMEOUT_MS = 60 * 1000;
89
+
90
+ const PAGE_SIZE = 100;
91
+ /** Hard bound on pages scanned (10k comments) so a pathological issue can't loop forever. Hitting it
92
+ * is treated as UNREADABLE (fail closed: never post blind). */
93
+ const MAX_PAGES = 100;
94
+
95
+ /** List the bodies of EVERY comment on an issue, across all pages. `null` when no transport is
96
+ * usable; THROWS on a transport failure (or an over-long thread) so the caller can distinguish
97
+ * "unreadable" (post nothing) from "empty". */
98
+ async function listIssueCommentBodies(
99
+ repo: string,
100
+ issueNumber: number,
101
+ token: string,
102
+ timeoutMs: number,
103
+ ): Promise<string[] | null> {
104
+ const ghMode = await useGh();
105
+ if (!ghMode && !token) return null;
106
+ const bodies: string[] = [];
107
+ for (let page = 1; page <= MAX_PAGES; page++) {
108
+ const path = `repos/${repo}/issues/${issueNumber}/comments?per_page=${PAGE_SIZE}&page=${page}`;
109
+ let arr: unknown;
110
+ if (ghMode) {
111
+ arr = JSON.parse(await runGh(["api", path], { timeoutMs }));
112
+ } else {
113
+ const r = await fetch(`https://api.github.com/${path}`, {
114
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
115
+ signal: AbortSignal.timeout(timeoutMs),
116
+ });
117
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
118
+ arr = await r.json();
119
+ }
120
+ if (!Array.isArray(arr)) throw new Error("github: unexpected comments payload");
121
+ for (const c of arr) {
122
+ const body = c !== null && typeof c === "object" && "body" in c ? c.body : "";
123
+ bodies.push(typeof body === "string" ? body : "");
124
+ }
125
+ if (arr.length < PAGE_SIZE) return bodies;
126
+ }
127
+ throw new Error(`github: issue ${repo}#${issueNumber} has more than ${MAX_PAGES * PAGE_SIZE} comments`);
128
+ }
129
+
130
+ /** Post one comment on an issue. Returns whether GitHub accepted it; `false` when no transport is
131
+ * usable or EITHER transport rejected or threw (a connection failure is normalised, never thrown). */
132
+ async function postIssueComment(
133
+ repo: string,
134
+ issueNumber: number,
135
+ body: string,
136
+ token: string,
137
+ timeoutMs: number,
138
+ ): Promise<boolean> {
139
+ try {
140
+ if (await useGh()) {
141
+ await runGh(["issue", "comment", String(issueNumber), "--repo", repo, "--body", body], { timeoutMs });
142
+ return true;
143
+ }
144
+ if (!token) return false;
145
+ const r = await fetch(`https://api.github.com/repos/${repo}/issues/${issueNumber}/comments`, {
146
+ method: "POST",
147
+ headers: {
148
+ authorization: `Bearer ${token}`,
149
+ accept: "application/vnd.github+json",
150
+ "content-type": "application/json",
151
+ },
152
+ body: JSON.stringify({ body }),
153
+ signal: AbortSignal.timeout(timeoutMs),
154
+ });
155
+ return r.ok;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ export interface RequestIssuePrLinkInput {
162
+ /** The run's `feature_key` (`owner/repo#N`) β€” the durable record key. */
163
+ featureKey: string;
164
+ /** The delivered PR key (null/undefined β‡’ nothing to announce). */
165
+ prKey: string | null | undefined;
166
+ /** The run's `claimIssue` seed β€” true ONLY for single-issue feature runs (app/feature.ts). */
167
+ claimIssue: unknown;
168
+ }
169
+
170
+ /** Record that the claimed issue is OWED a "PR opened" link for `prKey`, then attempt it once.
171
+ * Called by pr.record-feature; a failed attempt is retried by the poller (`retryOutstandingIssuePrLinks`).
172
+ * Resolves to the outcome (never throws). */
173
+ export async function requestIssuePrLink(
174
+ input: RequestIssuePrLinkInput,
175
+ data: DataLayer,
176
+ token: string = process.env.GITHUB_TOKEN ?? "",
177
+ ): Promise<IssuePrLinkOutcome> {
178
+ try {
179
+ // Gate: claimed single-issue runs only, with a keyed PR. An epic slice (`claimIssue` unset) and a
180
+ // blocked/skipped run (no PR) are a pure no-op β€” no state, no GitHub I/O.
181
+ if (input.claimIssue !== true) return "skipped";
182
+ if (!input.prKey || !parsePr(input.prKey)) return "skipped";
183
+ const r = await data
184
+ .open()
185
+ .exec(`UPDATE "feature_runs" SET "pr_link_want_pr_key" = ? WHERE "feature_key" = ?`, [
186
+ input.prKey,
187
+ input.featureKey,
188
+ ]);
189
+ if (r.changed === 0) return "skipped";
190
+ return await attemptIssuePrLink(input.featureKey, data, token);
191
+ } catch {
192
+ return "skipped";
193
+ }
194
+ }
195
+
196
+ /** Attempt the OWED link for one run (the single canonical attempt, shared by record-feature and the
197
+ * poller retry). Never throws. */
198
+ export async function attemptIssuePrLink(
199
+ featureKey: string,
200
+ data: DataLayer,
201
+ token: string = process.env.GITHUB_TOKEN ?? "",
202
+ now: () => Date = () => new Date(),
203
+ ioTimeoutMs: number = ISSUE_PR_LINK_IO_TIMEOUT_MS,
204
+ ): Promise<IssuePrLinkOutcome> {
205
+ try {
206
+ const row = await featureRuns(data).get(featureKey);
207
+ if (!row) return "skipped";
208
+ const want = row.pr_link_want_pr_key ?? null;
209
+ const pr = want ? parsePr(want) : null;
210
+ if (!want || !pr) return "skipped";
211
+ // The confirmed record already names the owed PR β€” no GitHub I/O at all.
212
+ if (row.pr_linked_pr_key === want) return "already-recorded";
213
+ const issueRepo = typeof row.repo === "string" ? row.repo : "";
214
+ const issueNumber = Number(row.issue_number);
215
+ if (!issueRepo || !Number.isInteger(issueNumber) || issueNumber <= 0) return "skipped";
216
+ // Claim an EXPIRING lease before any GitHub I/O (guarded CAS): only a free or expired lease, for
217
+ // a still-owed and still-unconfirmed PR, flips β€” so two racing passes never both post.
218
+ const t = now();
219
+ const ts = t.toISOString();
220
+ const expired = new Date(t.getTime() - ISSUE_PR_LINK_LEASE_MS).toISOString();
221
+ const claim = await data
222
+ .open()
223
+ .exec(
224
+ `UPDATE "feature_runs" SET "pr_link_lease_at" = ? WHERE "feature_key" = ? AND "pr_link_want_pr_key" = ? AND ("pr_linked_pr_key" IS NULL OR "pr_linked_pr_key" != ?) AND ("pr_link_lease_at" IS NULL OR "pr_link_lease_at" < ?)`,
225
+ [ts, featureKey, want, want, expired],
226
+ );
227
+ if (claim.changed === 0) return "claim-lost";
228
+ // Confirm the link, releasing the lease. Guards on the CURRENT lease ts (the one this pass last
229
+ // (re-)claimed), so only the live holder confirms.
230
+ const confirm = async (leaseTs: string) => {
231
+ await data
232
+ .open()
233
+ .exec(
234
+ `UPDATE "feature_runs" SET "pr_linked_pr_key" = ?, "pr_link_lease_at" = NULL WHERE "feature_key" = ? AND "pr_link_lease_at" = ?`,
235
+ [want, featureKey, leaseTs],
236
+ );
237
+ };
238
+ // The marker scan (every page) heals a crash between a successful POST and the confirm.
239
+ let bodies: string[] | null;
240
+ try {
241
+ bodies = await listIssueCommentBodies(issueRepo, issueNumber, token, ioTimeoutMs);
242
+ } catch {
243
+ // UNREADABLE β€” posting blind could double-post. Leave the lease to expire; the poller retries.
244
+ return "list-failed";
245
+ }
246
+ const marker = issuePrLinkMarker(pr);
247
+ if (bodies?.some((b) => b.includes(marker))) {
248
+ await confirm(ts);
249
+ return "already-marked";
250
+ }
251
+ // The title is best-effort, read from the PR's OWN repository.
252
+ let title: string | null = null;
253
+ try {
254
+ title = (await fetchPrMeta(pr.repo, pr.number, token, ioTimeoutMs))?.title ?? null;
255
+ } catch {
256
+ title = null;
257
+ }
258
+ // Re-validate lease ownership IMMEDIATELY before the write. The scan + title lookup above are
259
+ // each bounded by ioTimeoutMs (so a hung read can never wedge pr.record-feature or the poller
260
+ // pass), but together they can still approach the lease: if this pass stalled past the lease expiry, the poller may have re-claimed the
261
+ // lease and posted β€” and the after-the-fact confirm guard runs too late to stop a duplicate.
262
+ // Re-claim a FRESH timestamp guarded on the one this pass holds: only the CURRENT holder flips
263
+ // (changed=1), extending its lease through the write; a stale holder whose ts was overwritten
264
+ // gets changed=0 and backs off WITHOUT posting.
265
+ const ts2 = now().toISOString();
266
+ const stillHolder = await data
267
+ .open()
268
+ .exec(`UPDATE "feature_runs" SET "pr_link_lease_at" = ? WHERE "feature_key" = ? AND "pr_link_lease_at" = ?`, [
269
+ ts2,
270
+ featureKey,
271
+ ts,
272
+ ]);
273
+ if (stillHolder.changed === 0) return "claim-lost";
274
+ const posted = await postIssueComment(
275
+ issueRepo,
276
+ issueNumber,
277
+ prOpenedCommentBody({ number: pr.number, title, url: pr.url }),
278
+ token,
279
+ ioTimeoutMs,
280
+ );
281
+ // A failure leaves the lease to expire (never confirmed) β€” the poller's retry pass re-attempts.
282
+ if (!posted) return "post-failed";
283
+ await confirm(ts2);
284
+ return "posted";
285
+ } catch {
286
+ return "skipped";
287
+ }
288
+ }
289
+
290
+ /** The poller retry pass: re-attempt every OUTSTANDING owed link (owed, unconfirmed, lease free or
291
+ * expired). Only rows pr.record-feature marked as owed are scanned β€” never pre-#907 rows or epic
292
+ * slices. Never throws. */
293
+ export async function retryOutstandingIssuePrLinks(
294
+ data: DataLayer,
295
+ token: string = process.env.GITHUB_TOKEN ?? "",
296
+ now: () => Date = () => new Date(),
297
+ ): Promise<Record<string, IssuePrLinkOutcome>> {
298
+ const out: Record<string, IssuePrLinkOutcome> = {};
299
+ try {
300
+ const expired = new Date(now().getTime() - ISSUE_PR_LINK_LEASE_MS).toISOString();
301
+ const rows = await data
302
+ .open()
303
+ .query<{ feature_key: string }>(
304
+ `SELECT "feature_key" FROM "feature_runs" WHERE "pr_link_want_pr_key" IS NOT NULL AND ("pr_linked_pr_key" IS NULL OR "pr_linked_pr_key" != "pr_link_want_pr_key") AND ("pr_link_lease_at" IS NULL OR "pr_link_lease_at" < ?)`,
305
+ [expired],
306
+ );
307
+ for (const r of rows) out[r.feature_key] = await attemptIssuePrLink(r.feature_key, data, token, now);
308
+ } catch {
309
+ // Best-effort: a link retry must never break the poll loop.
310
+ }
311
+ return out;
312
+ }
package/app/service.ts CHANGED
@@ -59,6 +59,7 @@ import {
59
59
  requestCopilotReview,
60
60
  } from "./github.ts";
61
61
  import { activeStatusesFor, derivedTrackingTable } from "./instanceTracking.ts";
62
+ import { retryOutstandingIssuePrLinks } from "./issuePrLink.ts";
62
63
  import { pollLineage } from "./lineage.ts";
63
64
  import { mergeableRepollInterval, mergeableWaitTimeout } from "./mergeableWait.ts";
64
65
  import { mergeLanes, readExclusions } from "./mergeExclusion.ts";
@@ -3922,6 +3923,8 @@ export async function pollOnce(
3922
3923
  await pollWaitGate(data);
3923
3924
  await pollPromotion(data, engine, token);
3924
3925
  await pollFeatureDelivery(data, engine);
3926
+ // Issue #907: retry every owed-but-unconfirmed "PR opened" issue link (best-effort, never throws).
3927
+ await retryOutstandingIssuePrLinks(data, token);
3925
3928
  await pollLineage(data);
3926
3929
  await pollUserTasks(data, engine, engineRest);
3927
3930
  // The escalation steward is an OPTIONAL, best-effort pass: a steward-specific failure (a search or
@@ -0,0 +1,78 @@
1
+ -- Issue #907: the host posts one idempotent "PR opened" comment on the claimed issue when a
2
+ -- single-issue feature run delivers its PR (app/issuePrLink.ts β€” attempted inline by
3
+ -- pr.record-feature and RETRIED by the poller's `pollIssuePrLinks` pass until it lands).
4
+ --
5
+ -- Three columns carry the durable link state machine:
6
+ -- β€’ `pr_link_want_pr_key` β€” the `owner/repo#N` PR the claimed issue is OWED a link for. Written by
7
+ -- pr.record-feature ONLY for a claimed single-issue run that delivered a keyed PR, so the
8
+ -- poller's retry pass never touches pre-#907 rows or epic slices.
9
+ -- β€’ `pr_linked_pr_key` β€” CONFIRMED: set only after a successful POST or a marker match. A link is
10
+ -- outstanding while it differs from `pr_link_want_pr_key`.
11
+ -- β€’ `pr_link_lease_at` β€” a short-lived in-flight lease (ISO time), claimed with a guarded CAS
12
+ -- before any GitHub write so two racing passes never both post. A crash or failure leaves the
13
+ -- lease to EXPIRE (it is never mistaken for a confirmed link), which doubles as retry backoff.
14
+ -- The hidden `<!-- nwf:pr-linked:#n -->` marker scanned on the issue's comments heals a crash
15
+ -- between a successful POST and the confirm write.
16
+ --
17
+ -- Additive (expand): a nullable column with no default write β€” existing rows read NULL (never
18
+ -- linked), exactly the pre-#907 state (no comment was ever posted for them).
19
+ --
20
+ -- The column is mirrored through the `delivery_units` aggregate so the `feature_runs__units` compat
21
+ -- VIEW stays row-for-row equal to `feature_runs` (ADR 0006, parity-tested in
22
+ -- app/deliveryUnit.test.ts): the Feature sync triggers (089) and VIEW (091) are re-created verbatim
23
+ -- plus `pr_linked_pr_key`, exactly as migration 116 did for `human_approval` and 120 for
24
+ -- `auto_resolve_escalations`.
25
+
26
+ ALTER TABLE feature_runs ADD COLUMN pr_linked_pr_key TEXT;
27
+ ALTER TABLE feature_runs ADD COLUMN pr_link_want_pr_key TEXT;
28
+ ALTER TABLE feature_runs ADD COLUMN pr_link_lease_at TEXT;
29
+
30
+ ALTER TABLE delivery_units ADD COLUMN pr_linked_pr_key TEXT;
31
+ ALTER TABLE delivery_units ADD COLUMN pr_link_want_pr_key TEXT;
32
+ ALTER TABLE delivery_units ADD COLUMN pr_link_lease_at TEXT;
33
+
34
+ DROP TRIGGER IF EXISTS feature_runs__du_ai;
35
+ CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
36
+ BEGIN
37
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, outcome, delivery_label, pr_linked_pr_key, pr_link_want_pr_key, pr_link_lease_at, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
38
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.outcome, NEW.delivery_label, NEW.pr_linked_pr_key, NEW.pr_link_want_pr_key, NEW.pr_link_lease_at, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
39
+ END;
40
+
41
+ DROP TRIGGER IF EXISTS feature_runs__du_au;
42
+ CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
43
+ BEGIN
44
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, outcome, delivery_label, pr_linked_pr_key, pr_link_want_pr_key, pr_link_lease_at, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
45
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.outcome, NEW.delivery_label, NEW.pr_linked_pr_key, NEW.pr_link_want_pr_key, NEW.pr_link_lease_at, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
46
+ END;
47
+
48
+ DROP VIEW IF EXISTS feature_runs__units;
49
+ CREATE VIEW feature_runs__units AS
50
+ SELECT
51
+ du.legacy_key AS feature_key,
52
+ du.repo AS repo,
53
+ du.issue_number AS issue_number,
54
+ du.issue_url AS issue_url,
55
+ du.base_branch AS base_branch,
56
+ du.status AS status,
57
+ du.process_key AS process_key,
58
+ du.pr_key AS pr_key,
59
+ du.converge AS converge,
60
+ du.auto_merge AS auto_merge,
61
+ du.human_approval AS human_approval,
62
+ du.auto_resolve_escalations AS auto_resolve_escalations,
63
+ du.outcome AS outcome,
64
+ du.created_at AS created_at,
65
+ du.updated_at AS updated_at,
66
+ du.delivery_label AS delivery_label,
67
+ du.pr_linked_pr_key AS pr_linked_pr_key,
68
+ du.pr_link_want_pr_key AS pr_link_want_pr_key,
69
+ du.pr_link_lease_at AS pr_link_lease_at,
70
+ du.title AS title,
71
+ du.acknowledged_at AS acknowledged_at,
72
+ du.stage AS stage,
73
+ du.stage_state AS stage_state,
74
+ du.stage_skipped AS stage_skipped,
75
+ du.attention AS attention,
76
+ du.list_bucket AS list_bucket
77
+ FROM delivery_units du
78
+ WHERE du.kind = 'feature';
@@ -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#103", summary: "resumed and opened" };
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 {
@@ -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(admitGithubState("owner/repo", "main"));
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.203.1",
3
+ "version": "0.204.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",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "dependencies": {
68
68
  "@nanobpm/agentic": "^0.15.0",
69
- "@nanobpm/urban": "^0.96.0",
69
+ "@nanobpm/urban": "^0.98.0",
70
70
  "bpmn-auto-layout": "^2.0.0-alpha.2"
71
71
  },
72
72
  "devDependencies": {
@@ -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="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (is defined(baseBranchBrief) and baseBranchBrief != null) then baseBranchBrief else &#34;&#34;) + (if (is defined(blackboardBrief) and blackboardBrief != null) then blackboardBrief else &#34;&#34;) + (if (is defined(resolvedDepsBrief) and resolvedDepsBrief != null) then resolvedDepsBrief else &#34;&#34;) + (if (is defined(resolvedArtifacts) = false or resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then &#34;&#34; else (&#34;&#10;&#10;---&#10;&#10;**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:&#10;&#10;&#34; + string join(resolvedArtifacts[item != null], &#34;&#10;&#34;))) + (if (is defined(customInstructions) = false or customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run β€” follow them:&#10;&#10;&#34; + customInstructions) + (if (is defined(retryNudge) and retryNudge != null) then retryNudge else &#34;&#34;)" 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" />