@nanobpm/nano-workforce 0.111.1 → 0.112.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 +7 -0
- package/app/escalationTaxonomy.test.ts +1 -1
- package/app/escalationTaxonomy.ts +4 -2
- package/app/planFanoutCleanTerminal.test.ts +40 -0
- package/app/pollUserTasks.test.ts +208 -0
- package/app/service.ts +211 -196
- package/app/userTasks.test.ts +20 -2
- package/app/userTasks.ts +11 -4
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/plan-fanout.bpmn +161 -135
- package/workers/record-wave/worker.test.ts +181 -0
- package/workers/record-wave/worker.ts +44 -11
- package/workers/record-wave-escalation/worker.test.ts +95 -0
- package/workers/record-wave-escalation/worker.ts +69 -0
|
@@ -274,3 +274,184 @@ test("record-wave runs trial merge for non-queue repos with populated heads", as
|
|
|
274
274
|
restore();
|
|
275
275
|
}
|
|
276
276
|
});
|
|
277
|
+
|
|
278
|
+
// ── Fail-closed forensics (issue #360) ─────────────────────────────────────────────────────────────
|
|
279
|
+
// A slice that returns no machine-readable result is still coerced to terminal `blocked` here (the
|
|
280
|
+
// escalation/answer decision already happened in the `implement` subprocess, before this aggregator).
|
|
281
|
+
// But the aggregator must no longer LOSE information when it fails closed: it must (2) retain any PR the
|
|
282
|
+
// agent demonstrably opened so the work is recoverable from the UI (on `draft_pr_key`, NOT `pr_key` —
|
|
283
|
+
// a non-handed-off key on `pr_key` would wedge the delivery rollup), and (3) synthesise a reason so the
|
|
284
|
+
// epic-detail Summary is never blank. Before #360 both were dropped (`pr_key = NULL`, `summary = NULL`).
|
|
285
|
+
test("record-wave retains the PR and synthesises a summary for a no-result slice (issue #360)", async () => {
|
|
286
|
+
const rows: Row[] = [{ id: 1, plan_key: "owner/repo#64", task_id: "scaffold", status: "pending", wave: 0 }];
|
|
287
|
+
const { app } = fakeApp(rows);
|
|
288
|
+
|
|
289
|
+
await handler(
|
|
290
|
+
{
|
|
291
|
+
variables: {
|
|
292
|
+
planKey: "owner/repo#64",
|
|
293
|
+
currentWave: 0,
|
|
294
|
+
waveCount: 1,
|
|
295
|
+
waveTasks: [{ id: "scaffold" }],
|
|
296
|
+
// No `status` — the agent finished without a machine-readable result — but it DID open a PR.
|
|
297
|
+
waveResults: [{ pr: "owner/repo#84" }],
|
|
298
|
+
},
|
|
299
|
+
} as any,
|
|
300
|
+
app,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const row = rows[0] as unknown as Record<string, unknown>;
|
|
304
|
+
// Still fail-closed to `blocked` (we never assume the un-reported PR is mergeable) …
|
|
305
|
+
assertEquals(row.status, "blocked");
|
|
306
|
+
// … the PR the agent opened is retained on the DRAFT ref (recoverable from the UI as "Draft PR") …
|
|
307
|
+
assertEquals(row.draft_pr_key, "owner/repo#84");
|
|
308
|
+
// … but NOT on `pr_key`: a non-handed-off key there would read as a handed-off slice PR in
|
|
309
|
+
// `pollDelivery`/promotion rollups (MISSING → in-flight), wedging the epic "converging" forever.
|
|
310
|
+
assertEquals(row.pr_key ?? null, null);
|
|
311
|
+
// … and the reason is no longer blank.
|
|
312
|
+
assertEquals(typeof row.summary, "string");
|
|
313
|
+
assertEquals((row.summary as string).length > 0, true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// A slice that DID return a machine-readable status which simply isn't a clean terminal (e.g. an
|
|
317
|
+
// `escalated` slice the operator abandoned, or an SLA auto-abandon — the `implement` subprocess ends
|
|
318
|
+
// without rewriting `status`) must not be misreported as "returned no machine-readable result": it did.
|
|
319
|
+
test("record-wave synthesises an accurate reason for an escalated-then-abandoned slice, not the no-result reason (Copilot advisory, #360)", async () => {
|
|
320
|
+
const rows: Row[] = [{ id: 1, plan_key: "owner/repo#64", task_id: "scaffold", status: "pending", wave: 0 }];
|
|
321
|
+
const { app } = fakeApp(rows);
|
|
322
|
+
|
|
323
|
+
await handler(
|
|
324
|
+
{
|
|
325
|
+
variables: {
|
|
326
|
+
planKey: "owner/repo#64",
|
|
327
|
+
currentWave: 0,
|
|
328
|
+
waveCount: 1,
|
|
329
|
+
waveTasks: [{ id: "scaffold" }],
|
|
330
|
+
// The slice escalated; the operator abandoned it, so the subprocess ended with status "escalated".
|
|
331
|
+
waveResults: [{ status: "escalated" }],
|
|
332
|
+
},
|
|
333
|
+
} as any,
|
|
334
|
+
app,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const row = rows[0] as unknown as Record<string, unknown>;
|
|
338
|
+
assertEquals(row.status, "blocked");
|
|
339
|
+
const summary = row.summary as string;
|
|
340
|
+
assertEquals(typeof summary, "string");
|
|
341
|
+
// Must NOT claim a slice that returned a machine-readable status returned none …
|
|
342
|
+
assertEquals(summary.includes("no machine-readable result"), false);
|
|
343
|
+
// … and must name the status it actually reported.
|
|
344
|
+
assertEquals(summary.includes("escalated"), true);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// The twin invariant of the retention fix: a genuinely HANDED-OFF (`opened` with a usable key) slice
|
|
348
|
+
// must still persist its PR on `pr_key` (the delivery-bearing column `pollDelivery`/promotion join on)
|
|
349
|
+
// and NOT on the draft ref — otherwise a landed slice would never count toward epic delivery.
|
|
350
|
+
test("record-wave persists a handed-off opened slice's PR on pr_key, not draft_pr_key (issue #360)", async () => {
|
|
351
|
+
const restore = installGithubStub("gh-merge");
|
|
352
|
+
try {
|
|
353
|
+
const rows: Row[] = [{ id: 1, plan_key: "owner/repo#64", task_id: "scaffold", status: "pending", wave: 0 }];
|
|
354
|
+
const { app } = fakeApp(rows);
|
|
355
|
+
|
|
356
|
+
await handler(
|
|
357
|
+
{
|
|
358
|
+
variables: {
|
|
359
|
+
planKey: "owner/repo#64",
|
|
360
|
+
currentWave: 0,
|
|
361
|
+
waveCount: 1,
|
|
362
|
+
waveTasks: [{ id: "scaffold" }],
|
|
363
|
+
waveResults: [{ status: "opened", pr: "owner/repo#84" }],
|
|
364
|
+
},
|
|
365
|
+
} as any,
|
|
366
|
+
app,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const row = rows[0] as unknown as Record<string, unknown>;
|
|
370
|
+
assertEquals(row.status, "opened");
|
|
371
|
+
assertEquals(row.pr_key, "owner/repo#84");
|
|
372
|
+
assertEquals(row.draft_pr_key ?? null, null);
|
|
373
|
+
} finally {
|
|
374
|
+
restore();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("record-wave preserves the agent's own summary rather than overwriting it (issue #360)", async () => {
|
|
379
|
+
const rows: Row[] = [{ id: 1, plan_key: "owner/repo#64", task_id: "scaffold", status: "pending", wave: 0 }];
|
|
380
|
+
const { app } = fakeApp(rows);
|
|
381
|
+
|
|
382
|
+
await handler(
|
|
383
|
+
{
|
|
384
|
+
variables: {
|
|
385
|
+
planKey: "owner/repo#64",
|
|
386
|
+
currentWave: 0,
|
|
387
|
+
waveCount: 1,
|
|
388
|
+
waveTasks: [{ id: "scaffold" }],
|
|
389
|
+
waveResults: [{ status: "blocked", summary: "upstream API not ready" }],
|
|
390
|
+
},
|
|
391
|
+
} as any,
|
|
392
|
+
app,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const row = rows[0] as unknown as Record<string, unknown>;
|
|
396
|
+
assertEquals(row.status, "blocked");
|
|
397
|
+
// A genuine, machine-readable `blocked` with its own summary is left untouched — no synthesis.
|
|
398
|
+
assertEquals(row.summary, "upstream API not ready");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// The D2 conflict-scan (issue #58) is a deliberate over-approximation of the merge-exclusion graph:
|
|
402
|
+
// a slice whose PR is retained (as a work-preserving DRAFT, NOT handed off) can still touch files a
|
|
403
|
+
// sibling's PR touches, so omitting it would silently under-approximate the exclusions. The no-result
|
|
404
|
+
// path (#360) newly retains such a draft PR (`retainedPr` → `draft_pr_key`), so it MUST be scanned too —
|
|
405
|
+
// exactly like an `escalated` draft already is. This locks the scan set to every retained draft, not
|
|
406
|
+
// only `opened`/`escalated` ones (Copilot advisory, record-wave/worker.ts:126).
|
|
407
|
+
test("record-wave includes a no-result slice's retained draft PR in the D2 conflict scan (Copilot advisory, #360)", async () => {
|
|
408
|
+
const oldToken = process.env["GITHUB_TOKEN"];
|
|
409
|
+
const oldTransport = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
410
|
+
const oldFetch = globalThis.fetch;
|
|
411
|
+
process.env["GITHUB_TOKEN"] = "test-token";
|
|
412
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
413
|
+
globalThis.fetch = ((input: string | URL | Request) => {
|
|
414
|
+
const url = String(input);
|
|
415
|
+
// Both the handed-off `opened` PR and the retained no-result draft touch the SAME file, so the
|
|
416
|
+
// scan must derive exactly one exclusion edge between them — but only if BOTH are scanned.
|
|
417
|
+
if (url.includes("/files")) {
|
|
418
|
+
return Promise.resolve(new Response(JSON.stringify([{ filename: "src/shared.ts" }])));
|
|
419
|
+
}
|
|
420
|
+
return Promise.resolve(new Response("not found", { status: 404 }));
|
|
421
|
+
}) as typeof fetch;
|
|
422
|
+
try {
|
|
423
|
+
const rows: Row[] = [
|
|
424
|
+
{ id: 1, plan_key: "owner/repo#64", task_id: "a", status: "pending", wave: 0 },
|
|
425
|
+
{ id: 2, plan_key: "owner/repo#64", task_id: "b", status: "pending", wave: 0 },
|
|
426
|
+
];
|
|
427
|
+
const { app } = fakeApp(rows);
|
|
428
|
+
const stores = (app.data as any).table("plan_merge_exclusions", "id");
|
|
429
|
+
|
|
430
|
+
await handler(
|
|
431
|
+
{
|
|
432
|
+
variables: {
|
|
433
|
+
planKey: "owner/repo#64",
|
|
434
|
+
currentWave: 0,
|
|
435
|
+
waveCount: 1,
|
|
436
|
+
waveTasks: [{ id: "a" }, { id: "b" }],
|
|
437
|
+
// `a` handed off an opened PR; `b` returned NO machine-readable status but DID open a PR
|
|
438
|
+
// (retained as a draft). Both touch src/shared.ts, so they merge-exclude each other.
|
|
439
|
+
waveResults: [
|
|
440
|
+
{ status: "opened", pr: "owner/repo#101" },
|
|
441
|
+
{ pr: "owner/repo#102" },
|
|
442
|
+
],
|
|
443
|
+
},
|
|
444
|
+
} as any,
|
|
445
|
+
app,
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const edges = await stores.find({});
|
|
449
|
+
assertEquals(edges.length, 1, "the no-result draft PR must be scanned, yielding one exclusion edge");
|
|
450
|
+
} finally {
|
|
451
|
+
if (oldToken == null) delete process.env["GITHUB_TOKEN"];
|
|
452
|
+
else process.env["GITHUB_TOKEN"] = oldToken;
|
|
453
|
+
if (oldTransport == null) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
454
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = oldTransport;
|
|
455
|
+
globalThis.fetch = oldFetch;
|
|
456
|
+
}
|
|
457
|
+
});
|
|
@@ -85,10 +85,11 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
85
85
|
|
|
86
86
|
// The tasks with a concurrently-open PR in THIS wave — the set the D2 conflict-scan runs over
|
|
87
87
|
// (cross-wave pairs are moot: the wave barrier merges earlier waves before later ones start).
|
|
88
|
-
// This includes both `opened` PRs (also handed off below) AND
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
88
|
+
// This includes both `opened` PRs (also handed off below) AND any RETAINED work-preserving DRAFT
|
|
89
|
+
// PR — an `escalated` task's draft (feature.md) and the no-result path's retained draft (#360). A
|
|
90
|
+
// draft's changed files can still overlap a sibling's, so omitting it would silently
|
|
91
|
+
// under-approximate the merge-exclusion graph (the scan is a deliberate over-approximation).
|
|
92
|
+
// Retained drafts are scanned but NEVER handed off (not ready for review).
|
|
92
93
|
const openedThisWave: { taskId: string; repo: string; number: number | string }[] = [];
|
|
93
94
|
const readyHeadsThisWave: { repo: string; number: number | string }[] = [];
|
|
94
95
|
|
|
@@ -102,21 +103,51 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
102
103
|
: "blocked";
|
|
103
104
|
const summary = str(res.summary);
|
|
104
105
|
const prRef = str(res.pr);
|
|
105
|
-
// Only trust a PR ref when the agent reports it actually opened one.
|
|
106
|
+
// Only trust a PR ref as HANDOFF-ready when the agent reports it actually opened one.
|
|
106
107
|
const parsed = status === "opened" && prRef ? parsePr(prRef) : null;
|
|
107
108
|
// A keyless "opened" is effectively blocked: downstream waves gate on `opened` meaning
|
|
108
109
|
// "this dependency has an opened PR", so an "opened" with no usable PR key must NOT satisfy
|
|
109
110
|
// a dependant (it would let dependents run with a phantom, un-mergeable dependency).
|
|
110
111
|
const effectiveStatus = status === "opened" && !parsed ? "blocked" : status;
|
|
112
|
+
// Issue #360 — a result that isn't a clean, machine-readable terminal (`opened`/`blocked`/
|
|
113
|
+
// `skipped`): a missing status, or an `escalated` that fell through the answer loop to abandon.
|
|
114
|
+
// This is the fail-closed path, and it must stop LOSING information:
|
|
115
|
+
// (2) never discard a PR the agent demonstrably opened — persist its key on the row even for a
|
|
116
|
+
// non-`opened` status so the work is recoverable from the UI, not just SQLite. It is NOT
|
|
117
|
+
// handed off (only `parsed`/`opened` is, below) — a non-`opened` PR is not review-ready, so
|
|
118
|
+
// it is retained on `draft_pr_key` (the escalation work-preserving column, surfaced as
|
|
119
|
+
// "Draft PR" on the epic-detail page) and DELIBERATELY kept OUT of `pr_key`: `pollDelivery`
|
|
120
|
+
// and the promotion rollup join every non-null `plan_tasks.pr_key` as a handed-off slice PR,
|
|
121
|
+
// so an un-enrolled key there reads as MISSING → in-flight, wedging an otherwise-done epic
|
|
122
|
+
// permanently "converging"/Active and blocking promotion (Copilot review, #360).
|
|
123
|
+
// (3) synthesise a reason so a blocked slice is never blank on the epic-detail Summary, the way
|
|
124
|
+
// record-trial-merge does for its own no-machine-readable-result case.
|
|
125
|
+
const unreadable = !rawStatus || !isWaveResultStatus(rawStatus);
|
|
126
|
+
const retainedPr = parsed ?? (prRef ? parsePr(prRef) : null);
|
|
127
|
+
// Distinguish a slice that reported NO status at all from one that reported a status which simply
|
|
128
|
+
// isn't a clean terminal (e.g. `escalated` that fell through the answer loop to operator Abandon /
|
|
129
|
+
// SLA auto-abandon — the subprocess ends without rewriting `status`). The latter DID return a
|
|
130
|
+
// machine-readable result, so the generic "no result" reason would misreport it.
|
|
131
|
+
const noResultSummary = !rawStatus
|
|
132
|
+
? "The implementation agent returned no machine-readable result"
|
|
133
|
+
: `The implementation agent did not return a clean terminal result (reported status "${rawStatus}"), so the slice was treated as blocked`;
|
|
134
|
+
const effectiveSummary = summary ?? (unreadable ? noResultSummary : undefined);
|
|
111
135
|
|
|
112
136
|
const row = byTaskId.get(taskId);
|
|
113
137
|
if (row) {
|
|
114
138
|
const patch: Partial<PlanTask> = { status: effectiveStatus, updated_at: ts };
|
|
115
|
-
if (
|
|
139
|
+
if (effectiveSummary !== undefined) patch.summary = effectiveSummary;
|
|
116
140
|
if (parsed?.prKey) {
|
|
141
|
+
// Handed-off/opened PR — the delivery-bearing key `pollDelivery`/promotion join on.
|
|
117
142
|
patch.pr_key = parsed.prKey;
|
|
118
143
|
// Keep the in-memory row current so a same-wave dependant (rare) sees the PR key.
|
|
119
144
|
row.pr_key = parsed.prKey;
|
|
145
|
+
} else if (retainedPr?.prKey) {
|
|
146
|
+
// A PR the agent demonstrably opened but that was NOT handed off (blocked / escalated /
|
|
147
|
+
// keyless-"opened"): preserve it as a draft ref so the work stays recoverable from the UI,
|
|
148
|
+
// but keep it out of `pr_key` so it never wedges the delivery rollup (see comment above).
|
|
149
|
+
patch.draft_pr_key = retainedPr.prKey;
|
|
150
|
+
row.draft_pr_key = retainedPr.prKey;
|
|
120
151
|
}
|
|
121
152
|
await taskTable.update(row.id, patch);
|
|
122
153
|
}
|
|
@@ -162,11 +193,13 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
162
193
|
}
|
|
163
194
|
|
|
164
195
|
// Include this task's PR in the D2 conflict-scan set when it is concurrently open in the wave:
|
|
165
|
-
// an `opened` PR (also handed off below), OR
|
|
166
|
-
// (feature.md — `status: "escalated"` may carry the draft `pr` it
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
196
|
+
// an `opened` PR (also handed off below), OR ANY retained work-preserving DRAFT PR — an
|
|
197
|
+
// `escalated` task's draft (feature.md — `status: "escalated"` may carry the draft `pr` it
|
|
198
|
+
// opened) AND the no-result path's retained draft (#360, `retainedPr` → `draft_pr_key`). Any
|
|
199
|
+
// such draft's changed files can overlap a sibling's, so scanning every retained PR (not just
|
|
200
|
+
// `opened`/`escalated`) keeps the merge-exclusion graph a conservative over-approximation
|
|
201
|
+
// instead of silently missing those overlaps.
|
|
202
|
+
const scanPr = retainedPr;
|
|
170
203
|
if (scanPr) {
|
|
171
204
|
openedThisWave.push({ taskId, repo: scanPr.repo, number: scanPr.number });
|
|
172
205
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Unit coverage for pr.record-wave-escalation — the plan-fanout implement-stage escalation net (issue
|
|
2
|
+
// #360). It runs on the `w_gw` "clean terminal?" gateway's `not clean` arm, BEFORE the shared
|
|
3
|
+
// `feature-escalation` user task, and must:
|
|
4
|
+
// • pass through the agent's own answerable question when it declared a genuine escalation, but
|
|
5
|
+
// • SYNTHESISE an answerable question when the agent left none (a no-machine-readable result) so the
|
|
6
|
+
// parked task is never a dead end — the fix for the silent epic-death this issue reports, and
|
|
7
|
+
// • append the resolved question to the canonical `feature_escalations` audit log keyed by `planKey`
|
|
8
|
+
// (the plan-root IS the subject of an embedded slice), the source `pollUserTasks` reads (issue #358),
|
|
9
|
+
// • re-emit the resolved `question` so the `feature-escalation` form and the answer loop see it.
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assertEquals } from "#test-assert";
|
|
12
|
+
import { noopLog } from "../../test/log.ts";
|
|
13
|
+
import handler, { NO_RESULT_QUESTION } from "./worker.ts";
|
|
14
|
+
|
|
15
|
+
// biome-ignore lint/suspicious/noExplicitAny: tiny in-memory app double, mirrors record-feature-escalation.worker.test
|
|
16
|
+
function fakeApp(): any {
|
|
17
|
+
const stores: Record<string, Record<string, unknown>[]> = {};
|
|
18
|
+
return {
|
|
19
|
+
stores,
|
|
20
|
+
data: {
|
|
21
|
+
table(name: string, key: string) {
|
|
22
|
+
const store = (stores[name] ??= []);
|
|
23
|
+
return {
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
25
|
+
get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
|
|
26
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
27
|
+
find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
28
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
29
|
+
findOne: (q: any) =>
|
|
30
|
+
Promise.resolve(store.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
32
|
+
insert: (row: any) => {
|
|
33
|
+
store.push(row);
|
|
34
|
+
return Promise.resolve(store.length);
|
|
35
|
+
},
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
37
|
+
update: (k: any, patch: any) => {
|
|
38
|
+
const row = store.find((r) => r[key] === k);
|
|
39
|
+
if (row) Object.assign(row, patch);
|
|
40
|
+
return Promise.resolve(row);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
log: noopLog(),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("record-wave-escalation: a no-machine-readable result synthesises an answerable question and audits it (issue #360)", async () => {
|
|
50
|
+
const app = fakeApp();
|
|
51
|
+
// The agent finished with no status and no question — the exact failure that used to fall straight to
|
|
52
|
+
// terminal `blocked` and silently kill the epic. It must now become an answerable escalation.
|
|
53
|
+
const out = await handler(
|
|
54
|
+
{ jobKey: "job-1", variables: { planKey: "owner/repo#64", status: undefined, question: undefined } } as never,
|
|
55
|
+
app,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
assertEquals(out, { question: NO_RESULT_QUESTION });
|
|
59
|
+
assertEquals(app.stores.feature_escalations.length, 1);
|
|
60
|
+
assertEquals(app.stores.feature_escalations[0].feature_key, "owner/repo#64");
|
|
61
|
+
assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
|
|
62
|
+
assertEquals(app.stores.feature_escalations[0].job_key, "job-1");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("record-wave-escalation: a genuine agent escalation passes its own question through unchanged (issue #360)", async () => {
|
|
66
|
+
const app = fakeApp();
|
|
67
|
+
const out = await handler(
|
|
68
|
+
{ jobKey: "job-2", variables: { planKey: "owner/repo#64", status: "escalated", question: "Which auth library should the scaffold use?" } } as never,
|
|
69
|
+
app,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
assertEquals(out, { question: "Which auth library should the scaffold use?" });
|
|
73
|
+
assertEquals(app.stores.feature_escalations[0].question, "Which auth library should the scaffold use?");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("record-wave-escalation: an escalated status with a blank question is still given an answerable one (issue #360)", async () => {
|
|
77
|
+
// `escalated` with no usable question is as much a dead end as a no-result — the taxonomy classes it a
|
|
78
|
+
// NON-escalation, so without synthesis the parked task would show nothing to decide. Synthesise instead.
|
|
79
|
+
const app = fakeApp();
|
|
80
|
+
const out = await handler(
|
|
81
|
+
{ jobKey: "job-3", variables: { planKey: "owner/repo#64", status: "escalated", question: " " } } as never,
|
|
82
|
+
app,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assertEquals(out, { question: NO_RESULT_QUESTION });
|
|
86
|
+
assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("record-wave-escalation: a retried job (same jobKey) reuses its audit row, never duplicating (issue #360)", async () => {
|
|
90
|
+
const app = fakeApp();
|
|
91
|
+
const job = { jobKey: "job-retry", variables: { planKey: "owner/repo#64", status: undefined, question: undefined } } as never;
|
|
92
|
+
await handler(job, app);
|
|
93
|
+
await handler(job, app);
|
|
94
|
+
assertEquals(app.stores.feature_escalations.length, 1, "the retry reuses the row, no duplicate append");
|
|
95
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// pr.record-wave-escalation — a plan-fanout wave slice did NOT return a clean terminal result, so its
|
|
2
|
+
// `implement` subprocess routes here (the `w_gw` "clean terminal?" gateway's `not clean` arm) BEFORE
|
|
3
|
+
// the `feature-escalation` user task is created. It is the plan-fanout analogue of feature.bpmn's
|
|
4
|
+
// `record-feature-escalation`, extended with the no-result net of issue #360.
|
|
5
|
+
//
|
|
6
|
+
// The implement stage was the ONLY agent stage with no net for "I couldn't read the agent's result":
|
|
7
|
+
// • review rounds re-enter the durable review wait (app/roundResultDefault.ts),
|
|
8
|
+
// • trial merge raises an answerable human escalation (workers/record-trial-merge/worker.ts),
|
|
9
|
+
// • implement/wave coerced a missing status straight to terminal `blocked` — silently failing the
|
|
10
|
+
// epic and orphaning any PR the agent opened (issue #360).
|
|
11
|
+
// This worker closes that gap by routing every non-clean-terminal slice onto the SAME
|
|
12
|
+
// `feature-escalation` user task a genuine `status:"escalated"` already uses, so a human can enrol the
|
|
13
|
+
// PR or abandon the slice instead of the epic dying with a blank reason.
|
|
14
|
+
//
|
|
15
|
+
// It does two things while the process variables are still in scope on the job:
|
|
16
|
+
// • synthesises an answerable `question` when the agent didn't provide one (a no-machine-readable
|
|
17
|
+
// result carries no question), mirroring record-trial-merge, and re-emits it as the `question`
|
|
18
|
+
// variable so the `feature-escalation` form and the poller both see it, and
|
|
19
|
+
// • appends that question to the append-only `feature_escalations` audit log keyed by `planKey` — the
|
|
20
|
+
// canonical, poller-readable source `pollUserTasks` reads to enrich the parked task's question on
|
|
21
|
+
// the Tasks inbox (issue #358). The plan-root embeds the slice as a multi-instance subprocess, so
|
|
22
|
+
// there is no standalone `feature_runs` row; the epic (plan) IS the subject, hence the `planKey`
|
|
23
|
+
// key. Capturing it HERE (not in the poller) is required because the WASM engine does not surface a
|
|
24
|
+
// user task's ioMapping-mapped local variables through the user-task query.
|
|
25
|
+
import type { AppJobHandler } from "@nanobpm/urban";
|
|
26
|
+
import { classifyEscalation } from "../../app/escalationTaxonomy.ts";
|
|
27
|
+
import { recordFeatureEscalation } from "../../app/feature.ts";
|
|
28
|
+
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
29
|
+
|
|
30
|
+
// Input typed off the model data envelope (`RecordWaveEscalationIn` in plan-fanout.bpmn) — ADR 0040.
|
|
31
|
+
type In = WorkerInputs["pr.record-wave-escalation"];
|
|
32
|
+
interface Out extends Record<string, unknown> {
|
|
33
|
+
question: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const str = (v: unknown): string | undefined =>
|
|
37
|
+
typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
38
|
+
|
|
39
|
+
// The answerable prompt synthesised when the agent left no usable question — the implement-stage
|
|
40
|
+
// analogue of record-trial-merge's synthesised trial-merge question. It names the recoverable work (a
|
|
41
|
+
// PR may exist on the slice's branch) and the two answers the `w_gw_answer` gateway routes on.
|
|
42
|
+
const NO_RESULT_QUESTION =
|
|
43
|
+
'The implementation agent finished without a machine-readable result (no status was reported), so we cannot tell whether the slice succeeded. It may still have opened a PR (check for a branch targeting the epic base). Choose "Answer" and give guidance to re-run the slice — or choose "Abandon" to skip it and continue the epic.';
|
|
44
|
+
|
|
45
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
46
|
+
const planKey = job.variables.planKey;
|
|
47
|
+
const rawQuestion = str(job.variables.question);
|
|
48
|
+
// The agent's own question is authoritative when it declared a real escalation with one; otherwise
|
|
49
|
+
// (a no-machine-readable result, or an escalation with a blank question) synthesise an answerable
|
|
50
|
+
// one so the parked task is never a dead end. Route through the single canonical taxonomy so this
|
|
51
|
+
// net can never drift from the tier logic every other raise site uses.
|
|
52
|
+
// A "task"-kind escalation is `decision-required` only when the agent left an answerable question, so
|
|
53
|
+
// this already covers the blank-question case; the extra `&& rawQuestion` is the type narrowing that lets
|
|
54
|
+
// us hand the string through without an assertion.
|
|
55
|
+
const agentEscalated = classifyEscalation({ kind: "task", status: job.variables.status, question: rawQuestion }) ===
|
|
56
|
+
"decision-required";
|
|
57
|
+
const question = agentEscalated && rawQuestion ? rawQuestion : NO_RESULT_QUESTION;
|
|
58
|
+
|
|
59
|
+
// Append to the canonical `feature_escalations` audit log (the surviving table `pollUserTasks` reads),
|
|
60
|
+
// keyed by `planKey` because the plan-root instance IS the subject of the embedded slice's escalation.
|
|
61
|
+
await recordFeatureEscalation(app.data, { featureKey: planKey, question, jobKey: job.jobKey });
|
|
62
|
+
app.log.info("record-wave-escalation", { planKey, synthesised: question === NO_RESULT_QUESTION });
|
|
63
|
+
|
|
64
|
+
// Re-emit the resolved question so the `feature-escalation` form (and the answer loop) see it.
|
|
65
|
+
return { question };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default handler;
|
|
69
|
+
export { NO_RESULT_QUESTION };
|