@nanobpm/nano-workforce 0.189.0 → 0.189.2

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,274 @@
1
+ // Red/green regression for issue #806 — the convergence loop must not re-escalate an already-answered
2
+ // `wait-answer` question. When a durable adjudication exists for (this PR, this question fingerprint),
3
+ // the poller (`pollUserTasks`) auto-resumes the parked `wait-answer` with the recorded answer through
4
+ // the SAME `completeEscalationAsHuman` door a human uses — attributed to the prior adjudicator —
5
+ // instead of re-parking a human (PR #800 / proc 46310: the same design question escalated at round 2
6
+ // and again at round 13, both answered identically).
7
+ //
8
+ // A materially different question (no matching adjudication) still escalates normally.
9
+ import { test } from "node:test";
10
+ import { assertEquals } from "#test-assert";
11
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
+ import { questionFingerprint } from "./github.ts";
13
+ import { pollUserTasks } from "./service.ts";
14
+
15
+ // biome-ignore lint/suspicious/noExplicitAny: in-memory table double, mirrors pollUserTasks.test.ts
16
+ function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: Record<string, any[]> } {
17
+ // biome-ignore lint/suspicious/noExplicitAny: see above
18
+ const stores: Record<string, any[]> = {};
19
+ for (const [k, v] of Object.entries(seed)) stores[k] = v.map((r) => ({ ...r }));
20
+ function tbl(name: string, pk = "id") {
21
+ // biome-ignore lint/suspicious/noExplicitAny: see above
22
+ const rows = (stores[name] ??= [] as any[]);
23
+ // biome-ignore lint/suspicious/noExplicitAny: see above
24
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
25
+ return {
26
+ async all() {
27
+ return rows.slice();
28
+ },
29
+ // biome-ignore lint/suspicious/noExplicitAny: see above
30
+ async get(id: any) {
31
+ return rows.find((r) => r[pk] === id);
32
+ },
33
+ // biome-ignore lint/suspicious/noExplicitAny: see above
34
+ async find(where: any = {}) {
35
+ return rows.filter((r) => match(r, where));
36
+ },
37
+ // biome-ignore lint/suspicious/noExplicitAny: see above
38
+ async insert(r: any) {
39
+ rows.push({ ...r });
40
+ return r[pk];
41
+ },
42
+ // biome-ignore lint/suspicious/noExplicitAny: see above
43
+ async update(id: any, patch: any) {
44
+ const r = rows.find((row) => row[pk] === id);
45
+ if (r) Object.assign(r, patch);
46
+ },
47
+ // biome-ignore lint/suspicious/noExplicitAny: see above
48
+ async delete(id: any) {
49
+ const i = rows.findIndex((r) => r[pk] === id);
50
+ if (i >= 0) rows.splice(i, 1);
51
+ },
52
+ };
53
+ }
54
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as unknown as DataLayer;
55
+ return { data, stores };
56
+ }
57
+
58
+ type FakeTask = { userTaskKey: string; elementId: string; processInstanceKey: string };
59
+
60
+ /** A fake engine backing BOTH the poller's per-instance `openUserTasks({processInstanceKey})` scan AND
61
+ * the `completeEscalationAsHuman` door's unfiltered `openUserTasks()` resolve. `completeUserTask`
62
+ * removes the task (a resumed task is no longer open) and records the completion for assertions. */
63
+ function fakeEngine(tasks: FakeTask[]) {
64
+ const open = tasks.slice();
65
+ const completions: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
66
+ // Every `openUserTasks` filter seen this run, so a test can assert the auto-apply resolve scans a
67
+ // single instance (`{processInstanceKey}`) rather than an engine-wide unfiltered scan (issue #806).
68
+ const scans: (undefined | { processInstanceKey?: string; rootProcessInstanceKey?: string })[] = [];
69
+ const engine = {
70
+ openUserTasks: (filter?: { processInstanceKey?: string; rootProcessInstanceKey?: string }) => {
71
+ scans.push(filter);
72
+ return Promise.resolve(
73
+ open.filter((t) => {
74
+ if (filter?.processInstanceKey) return t.processInstanceKey === filter.processInstanceKey;
75
+ if (filter?.rootProcessInstanceKey) return t.processInstanceKey === filter.rootProcessInstanceKey;
76
+ return true;
77
+ }),
78
+ );
79
+ },
80
+ completeUserTask: (userTaskKey: string, variables: Record<string, unknown>) => {
81
+ const i = open.findIndex((t) => t.userTaskKey === userTaskKey);
82
+ if (i < 0) return Promise.reject(new Error("no such open task"));
83
+ open.splice(i, 1);
84
+ completions.push({ userTaskKey, variables });
85
+ return Promise.resolve();
86
+ },
87
+ } as unknown as EngineClient;
88
+ return { engine, completions, scans };
89
+ }
90
+
91
+ test("pollUserTasks: auto-resumes an already-answered wait-answer instead of re-parking a human (#806)", async () => {
92
+ const question = "Should the timeout be a boundary event or a poller sentinel?";
93
+ const { data, stores } = memData({
94
+ pull_requests: [{ pr_key: "o/r#800", status: "escalated", process_key: "rp-800", url: "https://github.com/o/r/pull/800", title: "Converge" }],
95
+ escalations: [{ id: 1, pr_key: "o/r#800", status: "open", question }],
96
+ pr_adjudications: [
97
+ {
98
+ id: 1,
99
+ pr_key: "o/r#800",
100
+ // Whitespace/case variant — the canonical fingerprint normalises it to the same key.
101
+ question_fingerprint: questionFingerprint(` ${question.toUpperCase()} `),
102
+ answer: "Option A: a bounded boundary event.",
103
+ adjudicated_by: "alice",
104
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
105
+ },
106
+ ],
107
+ });
108
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-800", elementId: "wait-answer", processInstanceKey: "rp-800" }]);
109
+
110
+ await pollUserTasks(data, engine);
111
+
112
+ assertEquals(completions.length, 1, "the parked wait-answer is auto-resumed");
113
+ assertEquals(completions[0].userTaskKey, "ut-800");
114
+ assertEquals(completions[0].variables.answer, "Option A: a bounded boundary event.", "resumed with the recorded answer");
115
+ assertEquals((stores.user_tasks ?? []).length, 0, "no wait-answer row is projected — the human is NOT re-parked");
116
+ const ledger = stores.task_completions ?? [];
117
+ assertEquals(ledger.length, 1, "the auto-resume is recorded in the completion ledger");
118
+ assertEquals(ledger[0].actor_id, "alice", "attributed to the prior adjudicator");
119
+ assertEquals(ledger[0].actor_kind, "human", "the prior adjudicator's kind is preserved (a human-settled decision)");
120
+ assertEquals(ledger[0].auto_applied, 1, "the replay is marked auto_applied — distinguishable from a first-hand submission");
121
+ assertEquals(ledger[0].reversible, 1, "an auto-applied replay is human-overridable");
122
+ });
123
+
124
+ test("pollUserTasks: auto-resume PRESERVES an agent adjudicator's kind and stays reversible (#806 review)", async () => {
125
+ // A prior AGENT-settled adjudication (ADR 0046) must replay as an agent completion, never laundered
126
+ // into an irreversible human authority — the whole point of recording `adjudicated_kind`.
127
+ const question = "Which retry cap should the husk loop use?";
128
+ const { data, stores } = memData({
129
+ pull_requests: [{ pr_key: "o/r#801", status: "escalated", process_key: "rp-801", url: "https://github.com/o/r/pull/801", title: "Converge" }],
130
+ escalations: [{ id: 1, pr_key: "o/r#801", status: "open", question }],
131
+ pr_adjudications: [
132
+ {
133
+ id: 1,
134
+ pr_key: "o/r#801",
135
+ question_fingerprint: questionFingerprint(question),
136
+ answer: "Cap at 3.",
137
+ adjudicated_by: "senior-agent",
138
+ adjudicated_kind: "agent",
139
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
140
+ },
141
+ ],
142
+ });
143
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-801", elementId: "wait-answer", processInstanceKey: "rp-801" }]);
144
+
145
+ await pollUserTasks(data, engine);
146
+
147
+ assertEquals(completions.length, 1, "the parked wait-answer is auto-resumed");
148
+ const ledger = stores.task_completions ?? [];
149
+ assertEquals(ledger[0].actor_kind, "agent", "the agent adjudicator's kind is preserved — not laundered into a human");
150
+ assertEquals(ledger[0].actor_id, "senior-agent");
151
+ assertEquals(ledger[0].auto_applied, 1, "still marked auto_applied");
152
+ assertEquals(ledger[0].reversible, 1, "still reversible — a human may override the replayed agent answer");
153
+ });
154
+
155
+ test("pollUserTasks: a DIFFERENT question with no adjudication still escalates to a human (#806)", async () => {
156
+ const { data, stores } = memData({
157
+ pull_requests: [{ pr_key: "o/r#800", status: "escalated", process_key: "rp-800", url: "https://github.com/o/r/pull/800", title: "Converge" }],
158
+ escalations: [{ id: 1, pr_key: "o/r#800", status: "open", question: "A brand-new question nobody has answered." }],
159
+ pr_adjudications: [
160
+ {
161
+ id: 1,
162
+ pr_key: "o/r#800",
163
+ question_fingerprint: questionFingerprint("Some other, already-settled question."),
164
+ answer: "Prior answer.",
165
+ adjudicated_by: "alice",
166
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
167
+ },
168
+ ],
169
+ });
170
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-800", elementId: "wait-answer", processInstanceKey: "rp-800" }]);
171
+
172
+ await pollUserTasks(data, engine);
173
+
174
+ assertEquals(completions.length, 0, "no auto-resume — the question is materially different");
175
+ const rows = stores.user_tasks ?? [];
176
+ assertEquals(rows.length, 1, "the new question is projected for a human to answer");
177
+ assertEquals(rows[0].user_task_key, "ut-800");
178
+ assertEquals(rows[0].question, "A brand-new question nobody has answered.");
179
+ });
180
+
181
+ test("pollUserTasks: an adjudication with UNKNOWN provenance fails open to a human, not a synthetic actor (#806 review)", async () => {
182
+ // A settled row whose `adjudicated_by` is blank (completed out of band, so `latestAdjudicator`
183
+ // returned no actor) must NOT be auto-replayed as a manufactured `human` actor — that would audit an
184
+ // unknown-provenance replay as a first-hand human decision. It fails open to a fresh human task.
185
+ const question = "Should the cache be write-through or write-back?";
186
+ const { data, stores } = memData({
187
+ pull_requests: [{ pr_key: "o/r#802", status: "escalated", process_key: "rp-802", url: "https://github.com/o/r/pull/802", title: "Converge" }],
188
+ escalations: [{ id: 1, pr_key: "o/r#802", status: "open", question }],
189
+ pr_adjudications: [
190
+ {
191
+ id: 1,
192
+ pr_key: "o/r#802",
193
+ question_fingerprint: questionFingerprint(question),
194
+ answer: "Write-through.",
195
+ adjudicated_by: null,
196
+ adjudicated_kind: null,
197
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
198
+ },
199
+ ],
200
+ });
201
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-802", elementId: "wait-answer", processInstanceKey: "rp-802" }]);
202
+
203
+ await pollUserTasks(data, engine);
204
+
205
+ assertEquals(completions.length, 0, "no auto-resume — a synthetic human actor is never manufactured");
206
+ const rows = stores.user_tasks ?? [];
207
+ assertEquals(rows.length, 1, "the question projects for a human to answer (fail-open)");
208
+ assertEquals(rows[0].user_task_key, "ut-802");
209
+ });
210
+
211
+ test("pollUserTasks: a transient adjudication-lookup error fails open and never aborts the pass (#806 review)", async () => {
212
+ // The adjudication LOOKUP is inside the fail-open try, so a transient `pr_adjudications.find` error
213
+ // must NOT reject `project`/abort `pollUserTasks` — the task still projects and reaches a human.
214
+ const question = "Should retries be capped?";
215
+ const { data, stores } = memData({
216
+ pull_requests: [{ pr_key: "o/r#803", status: "escalated", process_key: "rp-803", url: "https://github.com/o/r/pull/803", title: "Converge" }],
217
+ escalations: [{ id: 1, pr_key: "o/r#803", status: "open", question }],
218
+ });
219
+ const base = data.table.bind(data);
220
+ const failing = {
221
+ table(name: string, pk?: string) {
222
+ const t = base(name, pk);
223
+ if (name === "pr_adjudications") {
224
+ return { ...t, find: () => Promise.reject(new Error("transient db error")) };
225
+ }
226
+ return t;
227
+ },
228
+ } as unknown as DataLayer;
229
+ const { engine, completions } = fakeEngine([{ userTaskKey: "ut-803", elementId: "wait-answer", processInstanceKey: "rp-803" }]);
230
+
231
+ await pollUserTasks(failing, engine);
232
+
233
+ assertEquals(completions.length, 0, "no auto-resume on a lookup error");
234
+ const rows = stores.user_tasks ?? [];
235
+ assertEquals(rows.length, 1, "the task still projects — the poller did not abort (fail-open)");
236
+ assertEquals(rows[0].user_task_key, "ut-803");
237
+ });
238
+
239
+ test("pollUserTasks: auto-resume resolves the task per-instance, never an engine-wide scan (#806 review)", async () => {
240
+ // Copilot review of #806: `completeEscalationAutoApplied` -> `resolveEscalationTask` used an
241
+ // UNFILTERED `openUserTasks()` scan, run once per already-adjudicated PR in a single poll pass — so
242
+ // N parked-and-answered PRs cost N full engine scans (O(N²) work/REST). The poller already knows each
243
+ // task's owning instance, so the resolve must scan THAT instance (`{processInstanceKey}`) only. Seed
244
+ // several answered PRs and assert the pass issues ZERO unfiltered scans.
245
+ const q = "Boundary event or poller sentinel?";
246
+ const prKeys = ["o/r#810", "o/r#811", "o/r#812"];
247
+ const { data } = memData({
248
+ pull_requests: prKeys.map((pr_key, i) => ({
249
+ pr_key,
250
+ status: "escalated",
251
+ process_key: `rp-81${i}`,
252
+ url: `https://github.com/o/r/pull/81${i}`,
253
+ title: "Converge",
254
+ })),
255
+ escalations: prKeys.map((pr_key, i) => ({ id: i + 1, pr_key, status: "open", question: q })),
256
+ pr_adjudications: prKeys.map((pr_key, i) => ({
257
+ id: i + 1,
258
+ pr_key,
259
+ question_fingerprint: questionFingerprint(q),
260
+ answer: "Option A: a bounded boundary event.",
261
+ adjudicated_by: "alice",
262
+ adjudicated_at: "2025-01-01T00:00:00.000Z",
263
+ })),
264
+ });
265
+ const { engine, completions, scans } = fakeEngine(
266
+ prKeys.map((_, i) => ({ userTaskKey: `ut-81${i}`, elementId: "wait-answer", processInstanceKey: `rp-81${i}` })),
267
+ );
268
+
269
+ await pollUserTasks(data, engine);
270
+
271
+ assertEquals(completions.length, 3, "all three answered wait-answers auto-resume");
272
+ const unfiltered = scans.filter((f) => !f?.processInstanceKey && !f?.rootProcessInstanceKey);
273
+ assertEquals(unfiltered.length, 0, "no engine-wide (unfiltered) openUserTasks scan — the resolve is per-instance");
274
+ });
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, type Mergeability, type PrState } from "./github.ts";
6
+ import { BaseBranchMustExistError, checkConclusions, classifyMergeability, classifyPrLiveness, coalesceTitle, createPullRequest, ensureBaseBranch, ensurePromotionPr, fetchBranchHead, fetchIssueTitle, fetchPrFiles, fetchPrHead, fetchPrReviews, isNotAPullRequestError, listPrsForHead, type GhReview, type Mergeability, type PrState } from "./github.ts";
7
7
  import { DEFAULT_MERGE_PROTOCOL, type MergeProtocol, type RequiredCheck } from "./mergeProtocol.ts";
8
8
 
9
9
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
@@ -40,6 +40,51 @@ async function withTokenTransport<T>(pages: number[], fn: () => Promise<T>): Pro
40
40
  }
41
41
  }
42
42
 
43
+ function reviewFetch(pages: GhReview[][], requests: string[]) {
44
+ return (url: string | URL | Request): Promise<Response> => {
45
+ const u = new URL(String(url));
46
+ requests.push(u.toString());
47
+ const page = Number(u.searchParams.get("page") ?? "1");
48
+ const headers = new Headers();
49
+ if (page < pages.length) {
50
+ headers.set(
51
+ "link",
52
+ `<https://api.github.com/repos/o/r/pulls/1/reviews?per_page=100&page=${page + 1}>; rel="next", ` +
53
+ `<https://api.github.com/repos/o/r/pulls/1/reviews?per_page=100&page=${pages.length}>; rel="last"`,
54
+ );
55
+ }
56
+ return Promise.resolve(new Response(JSON.stringify(pages[page - 1] ?? []), { status: 200, headers }));
57
+ };
58
+ }
59
+
60
+ async function withTokenFetch<T>(fetchImpl: typeof fetch, fn: () => Promise<T>): Promise<T> {
61
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
62
+ const prevFetch = globalThis.fetch;
63
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
64
+ globalThis.fetch = fetchImpl;
65
+ try {
66
+ return await fn();
67
+ } finally {
68
+ globalThis.fetch = prevFetch;
69
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
70
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
71
+ }
72
+ }
73
+
74
+ test("fetchPrReviews: returns the newest review when it is beyond page one", async () => {
75
+ const requests: string[] = [];
76
+ const pages = [
77
+ Array.from({ length: 100 }, (_, i) => ({ id: i + 1, state: "COMMENTED" })),
78
+ [{ id: 101, state: "APPROVED", submitted_at: "2026-09-15T12:00:00Z" }],
79
+ ];
80
+ const reviews = await withTokenFetch(reviewFetch(pages, requests) as typeof fetch, () =>
81
+ fetchPrReviews("o/r", 1, "tok"),
82
+ );
83
+ assertEquals(reviews?.length, 101);
84
+ assertEquals(reviews?.[reviews.length - 1]?.id, 101);
85
+ assertEquals(requests.length, 2, "the final page must be fetched after page one");
86
+ });
87
+
43
88
  test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
44
89
  const files = await withTokenTransport([100, 42], () => fetchPrFiles("o/r", 1, "tok"));
45
90
  assertEquals(files?.length, 142);
package/app/github.ts CHANGED
@@ -240,6 +240,16 @@ export function advisoryStableKey(path: string, text: string): string {
240
240
  return `${path.trim()}#${fingerprint(normalizeAdvisoryText(text))}`;
241
241
  }
242
242
 
243
+ /** The line-stable fingerprint of a convergence escalation QUESTION (issue #806): the SAME canonical
244
+ * `normalizeAdvisoryText` + `fingerprint` digest advisory acks key on, applied to the escalation's
245
+ * question text. Reuses the ONE normaliser/fingerprint pair (no second implementation) so a durable
246
+ * wait-answer adjudication keyed by `(prKey, questionFingerprint)` is byte/semantic-stable the exact
247
+ * disciplined way an advisory ack is — only a semantically-identical, already-answered question is
248
+ * suppressed; a materially different question keys differently and still escalates. */
249
+ export function questionFingerprint(text: string): string {
250
+ return fingerprint(normalizeAdvisoryText(text));
251
+ }
252
+
243
253
  /** Parse Copilot's suppressed / low-confidence advisories out of a review body. Copilot renders them
244
254
  * under a `<summary>Suppressed comments (N)</summary>` block, each as a bold `**path:line**` header
245
255
  * followed by the advisory prose. Returns de-duplicated advisories (empty when there is no block). */