@nanobpm/nano-workforce 0.206.3 → 0.207.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/SPEC.md +3 -1
- package/app/deliveryGraph.test.ts +1 -1
- package/app/deliveryGraph.ts +3 -1
- package/app/deliveryGraphCompiler.test.ts +49 -2
- package/app/deliveryGraphCompiler.ts +12 -1
- package/app/deliveryGraphReadModel.test.ts +4 -2
- package/app/deliveryGraphRun.ts +12 -0
- package/app/deliveryRunner.test.ts +31 -0
- package/app/deliveryRunner.ts +18 -1
- package/app/incidentStatus.test.ts +175 -0
- package/app/mcpExclusions.test.ts +4 -3
- package/app/mcpToolSurface.ts +11 -2
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.test.ts +117 -0
- package/app/service.ts +244 -93
- package/build-info.json +4 -4
- package/db/migrations/126_pull_requests_incident_type_since.sql +8 -0
- package/db/migrations/127_delivery_graph_run_incidents.sql +60 -0
- package/db/migrations/128_pull_requests_read_model_incidents.sql +58 -0
- package/db/migrations/129_delivery_graph_read_model_incidents.sql +58 -0
- package/docs/agent-guide.md +4 -1
- package/docs/mcp-runbook.md +2 -2
- package/e2e/delivery-graph.e2e.ts +60 -0
- package/openapi.yaml +238 -0
- package/operations/listIncidents.test.ts +70 -0
- package/operations/listIncidents.ts +78 -0
- package/operations/retryIncident.test.ts +280 -0
- package/operations/retryIncident.ts +201 -0
- package/package.json +1 -1
- package/pages/delivery-graphs.page.json +16 -3
- package/pages/home.page.json +19 -0
- package/pages/overview.page.json +9 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.207.0](https://github.com/nanobpm/nano-workforce/compare/v0.206.4...v0.207.0) (2026-11-09)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **status:** surface engine incidents on loop and delivery rows with a retry action ([#938](https://github.com/nanobpm/nano-workforce/issues/938)) ([e3955c8](https://github.com/nanobpm/nano-workforce/commit/e3955c8145a966a29806400872b7a9c362d1d992)), closes [#931](https://github.com/nanobpm/nano-workforce/issues/931) [#931](https://github.com/nanobpm/nano-workforce/issues/931)
|
|
6
|
+
|
|
7
|
+
## [0.206.4](https://github.com/nanobpm/nano-workforce/compare/v0.206.3...v0.206.4) (2026-11-09)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **delivery:** accept affirmative-completion aliases as producer terminal-success statuses ([#934](https://github.com/nanobpm/nano-workforce/issues/934)) ([b8c5e4e](https://github.com/nanobpm/nano-workforce/commit/b8c5e4e5cb59ce42bb2d6573e107d1865393fa27)), closes [731/#760](https://github.com/731/nano-workforce/issues/760) [nano-bpm#1337](https://github.com/nanobpm/nano-bpm/issues/1337) [#1464](https://github.com/nanobpm/nano-workforce/issues/1464) [nano-bpm#1463](https://github.com/nanobpm/nano-bpm/issues/1463) [#1476](https://github.com/nanobpm/nano-workforce/issues/1476) [#731](https://github.com/nanobpm/nano-workforce/issues/731) [#760](https://github.com/nanobpm/nano-workforce/issues/760) [#761](https://github.com/nanobpm/nano-workforce/issues/761)
|
|
12
|
+
|
|
1
13
|
## [0.206.3](https://github.com/nanobpm/nano-workforce/compare/v0.206.2...v0.206.3) (2026-11-09)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/SPEC.md
CHANGED
|
@@ -1289,7 +1289,9 @@ reserved — with **one exception**: an agent emitting `pr` (the canonical
|
|
|
1289
1289
|
result field holds, and the retry reset correctly clears both — reserving them would
|
|
1290
1290
|
forbid that flagship pattern. The exception is the agent completion-control **`status`**:
|
|
1291
1291
|
the producer status gate reads that same node-local field as the node's completion status
|
|
1292
|
-
and accepts only
|
|
1292
|
+
and accepts only the terminal-success vocabulary (`AGENT_TERMINAL_SUCCESS_STATUSES` — the
|
|
1293
|
+
canonical `done`/`opened`/`skipped` plus the issue-#932 affirmative-completion aliases
|
|
1294
|
+
`completed`/`succeeded`/`success`), so an agent emit named `status` carrying any
|
|
1293
1295
|
other routing value (`approved`, …) always escalates, and `status="done"` can never take an
|
|
1294
1296
|
`approved` branch — the emit contract and the gate conflict. `status` is therefore reserved
|
|
1295
1297
|
for an **`agent`** (a connector has no producer status gate, so a connector `status` emit
|
|
@@ -538,7 +538,7 @@ test("invalid-fact-name: an AGENT emit named `status` is reserved — the produc
|
|
|
538
538
|
// Regression guard (PR #863 Copilot Medium "Previously missed", deliveryGraph.ts:418): the result-set
|
|
539
539
|
// exemption was too broad for an agent emit named `status`. The producer status gate
|
|
540
540
|
// (`agentContractProceedCondition`) interprets that same node-local field as the completion status and
|
|
541
|
-
// accepts only `
|
|
541
|
+
// accepts only the terminal-success vocabulary (`AGENT_TERMINAL_SUCCESS_STATUSES`), while the emit contract may require a different routing
|
|
542
542
|
// value — `status="approved"` always escalates, and `status="done"` can never take an `approved`
|
|
543
543
|
// branch. Unlike an aligned result fact such as `pr` (whose emit value and result field agree),
|
|
544
544
|
// `status` is CONSTRAINED by the gate, so the two uses conflict. Reserve the agent completion-control
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -458,7 +458,9 @@ export const DELIVERY_SCAFFOLDING_VARS = ["boundFacts", "nodeInputs", "nodeInput
|
|
|
458
458
|
* correctly clears both. Reserving the whole set would forbid the flagship
|
|
459
459
|
* `agent → connector[converge] → wait[pr]` pattern. The exception is the agent completion-control
|
|
460
460
|
* `status`: the producer status gate reads that SAME node-local field as the node's completion status
|
|
461
|
-
* and accepts only
|
|
461
|
+
* and accepts only the terminal-success vocabulary (`AGENT_TERMINAL_SUCCESS_STATUSES` — the canonical
|
|
462
|
+
* `done`/`opened`/`skipped` plus the #932 affirmative-completion aliases), so an agent emit named
|
|
463
|
+
* `status` whose routing value is
|
|
462
464
|
* anything else (`approved`, …) ALWAYS escalates, and `status="done"` can never take an `approved`
|
|
463
465
|
* branch — the emit contract and the gate conflict. `status` is therefore reserved for an `agent`
|
|
464
466
|
* (a connector has no producer status gate, so its `status` emit collides with nothing and stays
|
|
@@ -15,7 +15,7 @@ import { test } from "node:test";
|
|
|
15
15
|
import { assert, assertEquals } from "#test-assert";
|
|
16
16
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
17
17
|
import { missingExternalMarkerAgentTaskTypes } from "./agentic/vocab/job-types.ts";
|
|
18
|
-
import { compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets, nodeDisplay, redactFreeText, resumeValidVar } from "./deliveryGraphCompiler.ts";
|
|
18
|
+
import { AGENT_TERMINAL_SUCCESS_STATUSES, compileDeliveryGraph, digestInvisibleRawValues, graphCarriesRedactedSecrets, nodeDisplay, redactFreeText, resumeValidVar } from "./deliveryGraphCompiler.ts";
|
|
19
19
|
import { isDeliveryEscalationTwin } from "./deliveryHuman.ts";
|
|
20
20
|
|
|
21
21
|
/** Compile and assert success, returning the narrowed ok-result. */
|
|
@@ -2131,7 +2131,11 @@ test("#731 producer status gate: an agent node inserts a post-completion contrac
|
|
|
2131
2131
|
assert(g0, "the contract-met success flow exists");
|
|
2132
2132
|
assert(g0![1].includes(`=${el}_contractMet = true`), "the success flow reads the node-unique contract decision");
|
|
2133
2133
|
const cond = contractMetExpr(r.bpmn, el);
|
|
2134
|
-
|
|
2134
|
+
// The gate's FEEL allowlist is DERIVED from the single source of truth (AGENT_TERMINAL_SUCCESS_STATUSES)
|
|
2135
|
+
// — assert against the derived rendering, not a hand-copied literal, so this test can never drift from
|
|
2136
|
+
// the vocabulary it guards (issue #932).
|
|
2137
|
+
const derivedList = `[${AGENT_TERMINAL_SUCCESS_STATUSES.map((s) => `"${s}"`).join(", ")}]`;
|
|
2138
|
+
assert(cond.includes(`list contains(${derivedList}, status)`), `the success flow gates on the derived terminal-success status allowlist, got: ${cond}`);
|
|
2135
2139
|
assert(cond.includes("not(is defined(status)) or status = null"), "an absent/null status is not itself the failure mode — it still proceeds");
|
|
2136
2140
|
// The contract escalation's read-only context names the node and its reported status (#731).
|
|
2137
2141
|
const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
@@ -2139,6 +2143,49 @@ test("#731 producer status gate: an agent node inserts a post-completion contrac
|
|
|
2139
2143
|
assert(esc.includes("Reported status="), "the context surfaces the actual reported status");
|
|
2140
2144
|
});
|
|
2141
2145
|
|
|
2146
|
+
test("#932 producer status gate: the affirmative-completion synonyms agents actually report are allowlisted terminal-success statuses, derived from one source", () => {
|
|
2147
|
+
// Issue #932: agents keep self-reporting `status: "completed"` (and other unambiguous success
|
|
2148
|
+
// synonyms) after FULLY delivering — every required emit present — yet the #731 gate accepted only
|
|
2149
|
+
// `done`/`opened`/`skipped`, so a fully-delivered node parked on a human "Continue" escalation as a
|
|
2150
|
+
// contract violation (nano-bpm#1337 → PR #1464 escalation 584376; nano-bpm#1463 → PR #1476
|
|
2151
|
+
// escalation 599960). The fix is the CLASS fix: the affirmative-completion aliases are
|
|
2152
|
+
// terminal-success statuses in the ONE canonical vocabulary (AGENT_TERMINAL_SUCCESS_STATUSES), which
|
|
2153
|
+
// the gate's FEEL AND the #760 prompt contract both derive from. Pin the vocabulary content so a
|
|
2154
|
+
// future "tidy-up" cannot silently re-narrow it (a removed synonym re-introduces the escalation).
|
|
2155
|
+
for (const synonym of ["completed", "success", "succeeded"]) {
|
|
2156
|
+
assert(
|
|
2157
|
+
AGENT_TERMINAL_SUCCESS_STATUSES.includes(synonym),
|
|
2158
|
+
`"${synonym}" — an unambiguous affirmative-completion alias agents report — must be a terminal-success status (issue #932)`,
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
// The canonical statuses stay.
|
|
2162
|
+
for (const canonical of ["done", "opened", "skipped"]) {
|
|
2163
|
+
assert(AGENT_TERMINAL_SUCCESS_STATUSES.includes(canonical), `the canonical "${canonical}" status stays allowlisted`);
|
|
2164
|
+
}
|
|
2165
|
+
// And the vocabulary remains CLOSED to ambiguous non-success reports: a failure/give-up/in-progress
|
|
2166
|
+
// self-report must NEVER be normalised to success (the gate stays fail-closed for those).
|
|
2167
|
+
for (const nonSuccess of ["in_progress", "blocked", "failed", "escalated", "needs_input"]) {
|
|
2168
|
+
assert(
|
|
2169
|
+
!AGENT_TERMINAL_SUCCESS_STATUSES.includes(nonSuccess),
|
|
2170
|
+
`"${nonSuccess}" is not a success claim — it must stay OFF the terminal-success allowlist`,
|
|
2171
|
+
);
|
|
2172
|
+
}
|
|
2173
|
+
});
|
|
2174
|
+
|
|
2175
|
+
test("#932 producer status gate: the compiled contract gate accepts a `completed` report (the observed failure) as terminal-success", async () => {
|
|
2176
|
+
// The compile-level half of the #932 regression guard: the gate's FEEL allowlist (derived from
|
|
2177
|
+
// AGENT_TERMINAL_SUCCESS_STATUSES) must literally contain `completed`, so a fully-delivered node
|
|
2178
|
+
// reporting it passes the producer gate instead of escalating (the e2e half, in
|
|
2179
|
+
// e2e/delivery-graph.e2e.ts, proves the routing end-to-end).
|
|
2180
|
+
const r = await compileOk(PRODUCER_GATE);
|
|
2181
|
+
const el = elementForNode(r.bpmn, "open");
|
|
2182
|
+
const cond = contractMetExpr(r.bpmn, el);
|
|
2183
|
+
assert(cond.includes('"completed"'), `the gate's status allowlist contains "completed", got: ${cond}`);
|
|
2184
|
+
// The escalation context line names the SAME derived vocabulary (never a stale hand-typed list).
|
|
2185
|
+
const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
|
|
2186
|
+
assert(esc.includes("completed"), "the escalation context names the widened vocabulary");
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2142
2189
|
test("#731 required-emit gate: a producer's declared emit consumed as a required data dependency adds a non-null gate clause and a resumable escalation NAMING the fact", async () => {
|
|
2143
2190
|
const r = await compileOk(PRODUCER_GATE);
|
|
2144
2191
|
const el = elementForNode(r.bpmn, "open");
|
|
@@ -254,11 +254,22 @@ export function resumeValidVar(esc: string): string {
|
|
|
254
254
|
* and escalates AT the node instead of threading an incomplete result into a downstream consumer. An
|
|
255
255
|
* ABSENT/null status passes the gate (a status-less completion — an older fleet worker or a bare test
|
|
256
256
|
* stub — is not itself the failure mode; the required-emit gate still catches a missing data fact).
|
|
257
|
+
*
|
|
258
|
+
* The list deliberately includes the unambiguous AFFIRMATIVE-COMPLETION aliases agents actually report
|
|
259
|
+
* — `completed`/`success`/`succeeded` (issue #932: agents kept reporting `status: "completed"` after
|
|
260
|
+
* FULLY delivering — every required emit present — and the narrow list parked that good work on a
|
|
261
|
+
* human "Continue" escalation as a contract violation; nano-bpm#1337 → PR #1464 escalation 584376,
|
|
262
|
+
* nano-bpm#1463 → PR #1476 escalation 599960). An affirmative alias is normalised to terminal-success
|
|
263
|
+
* AT THE GATE: it routes onward exactly like `done` — the required-emit clauses still gate it, so a
|
|
264
|
+
* `completed` WITHOUT its required emits still escalates. Only UNAMBIGUOUS success claims belong here:
|
|
265
|
+
* a failure / input-required / in-progress report (`failed`, `needs_input`, `in_progress`, …) is NOT a
|
|
266
|
+
* completion claim and must NEVER be added — the gate stays fail-closed for those.
|
|
267
|
+
*
|
|
257
268
|
* Sorted for the compiler's byte-identical-output determinism. Exported as the SINGLE SOURCE OF TRUTH:
|
|
258
269
|
* the compiler's contract gate reads it here, and the runner's `renderProducerContract` (#760) derives
|
|
259
270
|
* the agent-facing status vocabulary from the SAME list — changing it changes both the gate and the
|
|
260
271
|
* prompt at once, so the two representations of the producer contract can never drift. */
|
|
261
|
-
export const AGENT_TERMINAL_SUCCESS_STATUSES: readonly string[] = ["done", "opened", "skipped"];
|
|
272
|
+
export const AGENT_TERMINAL_SUCCESS_STATUSES: readonly string[] = ["completed", "done", "opened", "skipped", "succeeded", "success"];
|
|
262
273
|
|
|
263
274
|
/** A never-reached exhaustiveness guard: `compileNode`'s `switch` covers every allowlisted kind, so
|
|
264
275
|
* the closed union narrows to `never` here. If a future kind is added to the vocabulary without a
|
|
@@ -39,7 +39,7 @@ import { applyMigrationSet, readMigrationSetFromDisk } from "../test/migrations.
|
|
|
39
39
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
40
40
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
41
41
|
|
|
42
|
-
const READ_MODEL_MIGRATION = "
|
|
42
|
+
const READ_MODEL_MIGRATION = "129_delivery_graph_read_model_incidents.sql";
|
|
43
43
|
|
|
44
44
|
// A minimal in-memory DB carrying the base `delivery_graph_runs` / `pull_requests` shapes the VIEW
|
|
45
45
|
// reads, plus stand-ins for the managed `<table>__tracking` derived VIEWs urban provisions at mount
|
|
@@ -53,7 +53,9 @@ function viewDb(): DatabaseSync {
|
|
|
53
53
|
run_key TEXT PRIMARY KEY, process_key TEXT, process_definition_id TEXT, digest TEXT,
|
|
54
54
|
status TEXT, side_effecting INTEGER, node_count INTEGER, human_node_count INTEGER,
|
|
55
55
|
side_effect_count INTEGER, title TEXT, phase TEXT, phase_node_id TEXT, human_labels TEXT,
|
|
56
|
-
created_at TEXT, updated_at TEXT, acknowledged_at TEXT, auto_resolve_escalations INTEGER,
|
|
56
|
+
created_at TEXT, updated_at TEXT, acknowledged_at TEXT, auto_resolve_escalations INTEGER,
|
|
57
|
+
incident_key TEXT, incident_type TEXT, incident_message TEXT, incident_since TEXT,
|
|
58
|
+
derived_status_override TEXT);
|
|
57
59
|
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, root_request_key TEXT, status TEXT,
|
|
58
60
|
derived_status_override TEXT);`,
|
|
59
61
|
);
|
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -56,6 +56,14 @@ export interface DeliveryGraphRun {
|
|
|
56
56
|
/** Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this run's
|
|
57
57
|
* escalations are eligible for steward auto-resolution. Default 0. */
|
|
58
58
|
auto_resolve_escalations: number;
|
|
59
|
+
/** Technical-incident mirror (127_delivery_graph_run_incidents.sql, issue #931), written by the
|
|
60
|
+
* poller's `pollIncidents` pass — the delivery-side twin of the PR incident columns (017/126).
|
|
61
|
+
* All NULL when the run's instance has no active incident; `/status` derives the `incident`
|
|
62
|
+
* overlay from these columns (see `activeDeliveries` in app/service.ts). */
|
|
63
|
+
incident_key: string | null;
|
|
64
|
+
incident_type: string | null;
|
|
65
|
+
incident_message: string | null;
|
|
66
|
+
incident_since: string | null;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
/** The run lifecycle. `awaiting-approval` is RESERVED but no longer produced (issue #460 moved dispatch
|
|
@@ -530,5 +538,9 @@ export function buildDeliveryGraphRunRow(input: {
|
|
|
530
538
|
created_at: input.createdAt ?? at,
|
|
531
539
|
updated_at: at,
|
|
532
540
|
acknowledged_at: null,
|
|
541
|
+
incident_key: null,
|
|
542
|
+
incident_type: null,
|
|
543
|
+
incident_message: null,
|
|
544
|
+
incident_since: null,
|
|
533
545
|
};
|
|
534
546
|
}
|
|
@@ -279,6 +279,25 @@ test("producer contract semantics coverage (#761 follow-up): renderProducerContr
|
|
|
279
279
|
});
|
|
280
280
|
|
|
281
281
|
|
|
282
|
+
test("#932 producer contract prompt: the affirmative-completion synonyms agents actually report are listed AND documented, so the prompt and the gate accept the same vocabulary", () => {
|
|
283
|
+
// Issue #932: agents kept reporting `status: "completed"` (and synonyms) after fully delivering and
|
|
284
|
+
// were parked on a human escalation, because the #731 gate accepted only `done`/`opened`/`skipped`
|
|
285
|
+
// and the #760 prompt told agents the same narrow list. The class fix puts the synonyms in the ONE
|
|
286
|
+
// canonical vocabulary (AGENT_TERMINAL_SUCCESS_STATUSES); the prompt contract is DERIVED from it, so
|
|
287
|
+
// the agent is now TOLD these are valid — pin that the observed aliases surface with semantics.
|
|
288
|
+
const rendered = renderProducerContract([]);
|
|
289
|
+
for (const synonym of ["completed", "success", "succeeded"]) {
|
|
290
|
+
assert(
|
|
291
|
+
rendered.includes(`- \`${synonym}\` — `),
|
|
292
|
+
`the prompt documents the "${synonym}" alias with its semantics (issue #932), got: ${rendered}`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
// The "do not invent a status" warning must name the widened list too (derived, never hand-typed).
|
|
296
|
+
const list = AGENT_TERMINAL_SUCCESS_STATUSES.map((s) => `\`${s}\``).join(", ");
|
|
297
|
+
assert(rendered.includes(`Any \`status\` OUTSIDE ${list}`), `the fail-closed warning names the derived allowlist, got: ${rendered}`);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
|
|
282
301
|
test("agent node idempotency preflight (#551): every agent prompt leads with adopt-and-report guidance; non-agent nodes are untouched", async () => {
|
|
283
302
|
// #551: a delivery agent node dispatches a raw retry-carrying `senior:feature` job with no
|
|
284
303
|
// PR-existence guard, so a re-dispatch opened a DUPLICATE PR (instance 43077 n0 → #979/#980). The
|
|
@@ -754,3 +773,15 @@ test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATC
|
|
|
754
773
|
assert(r.ok, `expected the canonical fact-bound wait[pr] graph to launch, got ${JSON.stringify(r)}`);
|
|
755
774
|
assertEquals(r.handle.processInstanceKey, "555");
|
|
756
775
|
});
|
|
776
|
+
test("PR #934 producer contract prompt: no accepted status is ever cited as a forbidden example", () => {
|
|
777
|
+
// Class: the rejection sentence named `success` as invalid after it became an accepted alias, so every
|
|
778
|
+
// agent got contradictory instructions. Every accepted status must be absent from that sentence.
|
|
779
|
+
const rendered = renderProducerContract([]);
|
|
780
|
+
const rejectLine = rendered.split("\n").find((l) => l.includes("including a free-form"));
|
|
781
|
+
assert(rejectLine !== undefined, `the fail-closed warning carries rejection examples, got: ${rendered}`);
|
|
782
|
+
const examples = rejectLine.split("including a free-form")[1] ?? "";
|
|
783
|
+
for (const s of AGENT_TERMINAL_SUCCESS_STATUSES) {
|
|
784
|
+
assert(!examples.includes(`\`${s}\``), `accepted status "${s}" must not be cited as a rejected example: ${rejectLine}`);
|
|
785
|
+
}
|
|
786
|
+
assert(examples.includes("`in_progress`"), `a genuine out-of-vocabulary example remains: ${rejectLine}`);
|
|
787
|
+
});
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -495,6 +495,13 @@ const PRODUCER_STATUS_SEMANTICS: Readonly<Record<string, string>> = {
|
|
|
495
495
|
opened: "you opened OR adopted a PR (return it in your `pr` emit if this node declares one)",
|
|
496
496
|
done: "the work completed with no PR to open",
|
|
497
497
|
skipped: "there was genuinely nothing to do",
|
|
498
|
+
// Issue #932: the unambiguous AFFIRMATIVE-COMPLETION aliases agents actually self-report. They are
|
|
499
|
+
// full terminal-success statuses (normalised at the gate), documented here so the prompt and the
|
|
500
|
+
// gate accept the SAME vocabulary — an agent told only `done`/`opened`/`skipped` keeps inventing
|
|
501
|
+
// `completed` and parking good work on a human escalation.
|
|
502
|
+
completed: "an alias of `done`/`opened` — the work fully completed (with its required emits populated)",
|
|
503
|
+
succeeded: "an alias of `done`/`opened` — the work fully succeeded (with its required emits populated)",
|
|
504
|
+
success: "an alias of `done`/`opened` — the work fully succeeded (with its required emits populated)",
|
|
498
505
|
};
|
|
499
506
|
|
|
500
507
|
/** Render the producer-completion contract auto-injected into EVERY `agent` node's `appendPrompt`
|
|
@@ -518,6 +525,11 @@ const PRODUCER_STATUS_SEMANTICS: Readonly<Record<string, string>> = {
|
|
|
518
525
|
* contract already tells the agent to omit an undecidable routing fact. Listing every DECLARED emit
|
|
519
526
|
* here instead would contradict that guidance and push agents to guess values that should stay
|
|
520
527
|
* optional (#761). */
|
|
528
|
+
/** Illustrative out-of-vocabulary statuses named in the producer contract's rejection sentence. Always
|
|
529
|
+
* filtered against `AGENT_TERMINAL_SUCCESS_STATUSES` at render time, so an example that later becomes an
|
|
530
|
+
* accepted alias silently drops out instead of contradicting the accepted list. */
|
|
531
|
+
const PRODUCER_REJECTED_STATUS_EXAMPLES: readonly string[] = ["in_progress", "failed", "partial", "success"];
|
|
532
|
+
|
|
521
533
|
export function renderProducerContract(requiredEmits: readonly DeliveryFact[]): string {
|
|
522
534
|
const list = AGENT_TERMINAL_SUCCESS_STATUSES.map((s) => `\`${s}\``).join(", ");
|
|
523
535
|
const semantics = AGENT_TERMINAL_SUCCESS_STATUSES.map((s) => {
|
|
@@ -530,6 +542,11 @@ export function renderProducerContract(requiredEmits: readonly DeliveryFact[]):
|
|
|
530
542
|
}
|
|
531
543
|
return `- \`${s}\` — ${doc}.`;
|
|
532
544
|
});
|
|
545
|
+
// Rejection examples are FILTERED against the allowlist so the prompt can never present one status as
|
|
546
|
+
// both accepted and forbidden (PR #934 review: `success` became an alias but stayed an example).
|
|
547
|
+
const rejected = PRODUCER_REJECTED_STATUS_EXAMPLES.filter((s) => !AGENT_TERMINAL_SUCCESS_STATUSES.includes(s))
|
|
548
|
+
.map((s) => `\`${s}\``)
|
|
549
|
+
.join("/");
|
|
533
550
|
const lines = [
|
|
534
551
|
"",
|
|
535
552
|
"",
|
|
@@ -543,7 +560,7 @@ export function renderProducerContract(requiredEmits: readonly DeliveryFact[]):
|
|
|
543
560
|
"",
|
|
544
561
|
...semantics,
|
|
545
562
|
"",
|
|
546
|
-
`Any \`status\` OUTSIDE ${list} — including a free-form
|
|
563
|
+
`Any \`status\` OUTSIDE ${list} — including a free-form ${rejected} — parks the`,
|
|
547
564
|
"run on a human escalation (the gate is fail-closed), EVEN when your underlying work was correct. So",
|
|
548
565
|
"do not invent a status: report exactly one of the allowlisted values above.",
|
|
549
566
|
];
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Red/green for issue #931 — an engine incident parked on a convergence loop's job must be VISIBLE
|
|
2
|
+
// on the `/status` row, not hide behind a healthy-looking `status: "converging"`.
|
|
3
|
+
//
|
|
4
|
+
// The defect (observed on merlin, 2026-11-09): a loop whose `senior:pr-review` job exhausted its
|
|
5
|
+
// retries (`JOB_NO_RETRIES`, the agent idle-timeout) sat dead for hours while `/status` reported
|
|
6
|
+
// `status: "converging"`, `openEscalation: null`, `activeWorker: null` — indistinguishable from a
|
|
7
|
+
// healthy queued run. `pollIncidents` mirrored the incident onto `incident_key`/`incident_message`
|
|
8
|
+
// (issue #94), but the `/status` projection never READ those columns, so the only discovery path
|
|
9
|
+
// was a hand-run `POST /v2/incidents/search`.
|
|
10
|
+
//
|
|
11
|
+
// The fix surfaces the mirrored incident on every ActivePr/ActiveDelivery row:
|
|
12
|
+
// status — overlaid to "incident" while the row carries an ACTIVE incident (the base
|
|
13
|
+
// `pull_requests.status` workflow transient is untouched; the overlay is derived at
|
|
14
|
+
// read time so it can never wedge the status machine), and
|
|
15
|
+
// incident — { key, type, message, since }: the engine incidentKey, errorType, errorMessage and
|
|
16
|
+
// creationTime the poller mirrored, so an operator sees WHAT broke and WHEN without
|
|
17
|
+
// opening the engine.
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import { assertEquals } from "#test-assert";
|
|
20
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
21
|
+
import { activeDeliveries, activePrs } from "./service.ts";
|
|
22
|
+
|
|
23
|
+
function memTable(rows: any[], key: string) {
|
|
24
|
+
return {
|
|
25
|
+
get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
|
|
26
|
+
all: () => Promise.resolve([...rows]),
|
|
27
|
+
find: (q: any) =>
|
|
28
|
+
Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
|
|
29
|
+
findOne: (q: any) =>
|
|
30
|
+
Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
|
|
31
|
+
insert: (r: any) => {
|
|
32
|
+
rows.push(r);
|
|
33
|
+
return Promise.resolve(r);
|
|
34
|
+
},
|
|
35
|
+
update: (k: any, patch: any) => {
|
|
36
|
+
const r = rows.find((x) => x[key] === k);
|
|
37
|
+
if (r) Object.assign(r, patch);
|
|
38
|
+
return Promise.resolve(r);
|
|
39
|
+
},
|
|
40
|
+
delete: (k: any) => {
|
|
41
|
+
for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Stores = Record<string, { rows: any[]; key: string }>;
|
|
48
|
+
|
|
49
|
+
function memData(stores: Stores) {
|
|
50
|
+
return {
|
|
51
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
52
|
+
} as any;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A fake engine whose `searchVariables` serves the given per-instance variables (none here — the
|
|
56
|
+
* merge policy degrades to its historical default, which these tests do not exercise). */
|
|
57
|
+
function fakeEngine() {
|
|
58
|
+
return {
|
|
59
|
+
searchVariables: () => Promise.resolve([]),
|
|
60
|
+
} as any;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function prRow(over: Record<string, unknown>) {
|
|
64
|
+
return {
|
|
65
|
+
pr_key: "owner/repo#1",
|
|
66
|
+
repo: "owner/repo",
|
|
67
|
+
number: 1,
|
|
68
|
+
url: "https://github.com/owner/repo/pull/1",
|
|
69
|
+
title: "t",
|
|
70
|
+
status: "converging",
|
|
71
|
+
current_round: 1,
|
|
72
|
+
process_key: "PI-1",
|
|
73
|
+
updated_at: "2024-01-01",
|
|
74
|
+
...over,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function statusOf(rowOver: Record<string, unknown> = {}) {
|
|
79
|
+
const stores: Stores = {
|
|
80
|
+
pull_requests: { rows: [prRow(rowOver)], key: "pr_key" },
|
|
81
|
+
user_tasks: { rows: [], key: "id" },
|
|
82
|
+
};
|
|
83
|
+
const [pr] = await activePrs(memData(stores), fakeEngine());
|
|
84
|
+
return pr;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test("incident surfacing: a PR row carrying an ACTIVE incident reads status 'incident', not 'converging' (the #931 defect)", async () => {
|
|
88
|
+
const pr = await statusOf({
|
|
89
|
+
incident_key: "598235",
|
|
90
|
+
incident_type: "JOB_NO_RETRIES",
|
|
91
|
+
incident_message: 'agent "nano-coder" failed: no output and no subtree progress for >= 300000ms (idle)',
|
|
92
|
+
incident_since: "2026-11-09T01:00:00.000Z",
|
|
93
|
+
});
|
|
94
|
+
assertEquals(pr.status, "incident");
|
|
95
|
+
assertEquals(pr.incident, {
|
|
96
|
+
key: "598235",
|
|
97
|
+
type: "JOB_NO_RETRIES",
|
|
98
|
+
message: 'agent "nano-coder" failed: no output and no subtree progress for >= 300000ms (idle)',
|
|
99
|
+
since: "2026-11-09T01:00:00.000Z",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("incident surfacing: a healthy PR row keeps its workflow status and a null incident", async () => {
|
|
104
|
+
const pr = await statusOf({
|
|
105
|
+
incident_key: null,
|
|
106
|
+
incident_type: null,
|
|
107
|
+
incident_message: null,
|
|
108
|
+
incident_since: null,
|
|
109
|
+
});
|
|
110
|
+
assertEquals(pr.status, "converging");
|
|
111
|
+
assertEquals(pr.incident, null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("incident surfacing: the overlay applies to any live workflow status (e.g. waiting_merge), never just converging", async () => {
|
|
115
|
+
const pr = await statusOf({
|
|
116
|
+
status: "waiting_merge",
|
|
117
|
+
incident_key: "INC-9",
|
|
118
|
+
incident_type: "EXTRACT_VALUE_ERROR",
|
|
119
|
+
incident_message: "failed to evaluate expression",
|
|
120
|
+
incident_since: "2026-11-09T02:00:00.000Z",
|
|
121
|
+
});
|
|
122
|
+
assertEquals(pr.status, "incident");
|
|
123
|
+
assertEquals(pr.incident?.key, "INC-9");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("incident surfacing: a delivery run carrying an ACTIVE incident reads status 'incident' with the incident block", async () => {
|
|
127
|
+
const run = {
|
|
128
|
+
run_key: "dg-1",
|
|
129
|
+
title: "ship it",
|
|
130
|
+
status: "running",
|
|
131
|
+
phase: "Running",
|
|
132
|
+
process_key: "PI-DG-1",
|
|
133
|
+
updated_at: "2024-01-01",
|
|
134
|
+
auto_resolve_escalations: 0,
|
|
135
|
+
incident_key: "INC-DG-1",
|
|
136
|
+
incident_type: "JOB_NO_RETRIES",
|
|
137
|
+
incident_message: "delivery node job exhausted retries",
|
|
138
|
+
incident_since: "2026-11-09T03:00:00.000Z",
|
|
139
|
+
};
|
|
140
|
+
const stores: Stores = {
|
|
141
|
+
delivery_graph_runs: { rows: [run], key: "run_key" },
|
|
142
|
+
user_tasks: { rows: [], key: "id" },
|
|
143
|
+
};
|
|
144
|
+
const [delivery] = await activeDeliveries(memData(stores));
|
|
145
|
+
assertEquals(delivery.status, "incident");
|
|
146
|
+
assertEquals(delivery.incident, {
|
|
147
|
+
key: "INC-DG-1",
|
|
148
|
+
type: "JOB_NO_RETRIES",
|
|
149
|
+
message: "delivery node job exhausted retries",
|
|
150
|
+
since: "2026-11-09T03:00:00.000Z",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("incident surfacing: a healthy delivery run keeps its status and a null incident", async () => {
|
|
155
|
+
const run = {
|
|
156
|
+
run_key: "dg-2",
|
|
157
|
+
title: "ship it",
|
|
158
|
+
status: "running",
|
|
159
|
+
phase: "Running",
|
|
160
|
+
process_key: "PI-DG-2",
|
|
161
|
+
updated_at: "2024-01-01",
|
|
162
|
+
auto_resolve_escalations: 0,
|
|
163
|
+
incident_key: null,
|
|
164
|
+
incident_type: null,
|
|
165
|
+
incident_message: null,
|
|
166
|
+
incident_since: null,
|
|
167
|
+
};
|
|
168
|
+
const stores: Stores = {
|
|
169
|
+
delivery_graph_runs: { rows: [run], key: "run_key" },
|
|
170
|
+
user_tasks: { rows: [], key: "id" },
|
|
171
|
+
};
|
|
172
|
+
const [delivery] = await activeDeliveries(memData(stores));
|
|
173
|
+
assertEquals(delivery.status, "running");
|
|
174
|
+
assertEquals(delivery.incident, null);
|
|
175
|
+
});
|
|
@@ -31,9 +31,10 @@ function projectedOperations() {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// The operator-only cockpit doors: the staged delivery-graph lifecycle (stage -> dispatch ->
|
|
34
|
-
// dismiss) whose approval is a human click in the cockpit (ADR 0005 Decision 7)
|
|
35
|
-
//
|
|
36
|
-
|
|
34
|
+
// dismiss) whose approval is a human click in the cockpit (ADR 0005 Decision 7), plus the
|
|
35
|
+
// incident-retry remediation door (issue #931) — the human unstick action on an incident-parked
|
|
36
|
+
// run. These — and ONLY these — must be excluded from the projected MCP tool surface.
|
|
37
|
+
const EXPECTED_EXCLUDED = ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal", "retryIncident"];
|
|
37
38
|
|
|
38
39
|
// A representative set of agent-facing operations that MUST stay exposed as tools — the drive doors
|
|
39
40
|
// (submit PR / epic / delivery-graph set), the pure compile/preview doors, and the read/orientation
|
package/app/mcpToolSurface.ts
CHANGED
|
@@ -87,8 +87,12 @@ export const FRAMEWORK_TOOL_PREFIX = "urban_debug_";
|
|
|
87
87
|
* regression guard, not the reduction itself: the reduction an agent actually experiences comes from
|
|
88
88
|
* importing {@link CURATED_MCP_TOOLS} rather than `["*"]`, and the transport-level shrink of the
|
|
89
89
|
* framework tool family is tracked upstream (nano-ide#488).
|
|
90
|
+
*
|
|
91
|
+
* RAISE PROVENANCE — 60 → 61 (#931): the read-only `listIncidents` door (sibling of
|
|
92
|
+
* `listEscalations`) stays on the surface per the `x-mcp` convention that read/orient doors are
|
|
93
|
+
* exposed; its operator-only remedy `retryIncident` is excluded. Deliberate, documented growth.
|
|
90
94
|
*/
|
|
91
|
-
export const MCP_TOOL_COUNT_BUDGET =
|
|
95
|
+
export const MCP_TOOL_COUNT_BUDGET = 61;
|
|
92
96
|
|
|
93
97
|
/**
|
|
94
98
|
* Hard CEILING on the serialized byte size of the full `tools/list` payload (the schema bytes a
|
|
@@ -138,8 +142,13 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
|
|
|
138
142
|
* intended description text (a correctness fix, not new surface). Measured 90,420 bytes serialized
|
|
139
143
|
* (over the old ceiling's 920-byte headroom). Deliberate, documented growth of restored description
|
|
140
144
|
* text — not schema fat.
|
|
145
|
+
*
|
|
146
|
+
* RAISE PROVENANCE — 91_000 → 92_000 (#931): the read-only `listIncidents` tool (engine-incident
|
|
147
|
+
* liveness sibling of `listEscalations`) plus the `incident` field on the status row schemas.
|
|
148
|
+
* Measured 91,069 bytes serialized (over the old ceiling's headroom). Deliberate, documented growth
|
|
149
|
+
* of a real new read surface — not schema fat.
|
|
141
150
|
*/
|
|
142
|
-
export const MCP_SURFACE_BYTES_BUDGET =
|
|
151
|
+
export const MCP_SURFACE_BYTES_BUDGET = 92_000;
|
|
143
152
|
|
|
144
153
|
/**
|
|
145
154
|
* The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
|
|
34
34
|
const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
|
|
35
35
|
|
|
36
|
-
const READ_MODEL_MIGRATION = "
|
|
36
|
+
const READ_MODEL_MIGRATION = "128_pull_requests_read_model_incidents.sql";
|
|
37
37
|
|
|
38
38
|
// The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
|
|
39
39
|
// hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
|
package/app/service.test.ts
CHANGED
|
@@ -675,6 +675,123 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
|
|
|
675
675
|
assertEquals(row.incident_message, "the first fault");
|
|
676
676
|
});
|
|
677
677
|
|
|
678
|
+
// Issue #931: the mirror must carry the incident's `errorType` and `creationTime` (not just key +
|
|
679
|
+
// message) so `/status` can show WHAT broke and SINCE WHEN, and it must cover a delivery-graph
|
|
680
|
+
// run's instance too — a delivery parked on an engine incident was just as invisible as a PR.
|
|
681
|
+
test("pollIncidents mirrors errorType + creationTime alongside the key/message, and clears them with the incident", async () => {
|
|
682
|
+
const row = {
|
|
683
|
+
pr_key: "owner/repo#12",
|
|
684
|
+
status: "converging",
|
|
685
|
+
process_key: "PI-12",
|
|
686
|
+
incident_key: null as string | null,
|
|
687
|
+
incident_type: null as string | null,
|
|
688
|
+
incident_message: null as string | null,
|
|
689
|
+
incident_since: null as string | null,
|
|
690
|
+
updated_at: "t0",
|
|
691
|
+
};
|
|
692
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
693
|
+
pull_requests: { rows: [row], key: "pr_key" },
|
|
694
|
+
};
|
|
695
|
+
const data = {
|
|
696
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
697
|
+
open: () => memOpen(stores),
|
|
698
|
+
} as any;
|
|
699
|
+
const headers = { "content-type": "application/json" };
|
|
700
|
+
|
|
701
|
+
const prevFetch = globalThis.fetch;
|
|
702
|
+
globalThis.fetch = incidentFetch({
|
|
703
|
+
"PI-12": [{
|
|
704
|
+
incidentKey: "598235",
|
|
705
|
+
errorType: "JOB_NO_RETRIES",
|
|
706
|
+
errorMessage: 'agent "nano-coder" failed: no output and no subtree progress for >= 300000ms (idle)',
|
|
707
|
+
state: "ACTIVE",
|
|
708
|
+
creationTime: "2026-11-09T01:00:00.000Z",
|
|
709
|
+
}],
|
|
710
|
+
}) as typeof fetch;
|
|
711
|
+
try {
|
|
712
|
+
await pollIncidentsImpl(data, "http://engine/v2", headers);
|
|
713
|
+
} finally {
|
|
714
|
+
globalThis.fetch = prevFetch;
|
|
715
|
+
}
|
|
716
|
+
assertEquals(row.incident_key, "598235");
|
|
717
|
+
assertEquals(row.incident_type, "JOB_NO_RETRIES");
|
|
718
|
+
assertEquals(row.incident_message, 'agent "nano-coder" failed: no output and no subtree progress for >= 300000ms (idle)');
|
|
719
|
+
assertEquals(row.incident_since, "2026-11-09T01:00:00.000Z");
|
|
720
|
+
assertEquals(row.status, "converging"); // orthogonal: the workflow transient is never touched
|
|
721
|
+
|
|
722
|
+
// Once resolved, ALL FOUR columns clear idempotently — a stale `incident_since` must not linger.
|
|
723
|
+
globalThis.fetch = incidentFetch({ "PI-12": [] }) as typeof fetch;
|
|
724
|
+
try {
|
|
725
|
+
await pollIncidentsImpl(data, "http://engine/v2", headers);
|
|
726
|
+
} finally {
|
|
727
|
+
globalThis.fetch = prevFetch;
|
|
728
|
+
}
|
|
729
|
+
assertEquals(row.incident_key, null);
|
|
730
|
+
assertEquals(row.incident_type, null);
|
|
731
|
+
assertEquals(row.incident_message, null);
|
|
732
|
+
assertEquals(row.incident_since, null);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("pollIncidents mirrors an ACTIVE incident onto a delivery-graph run row, and skips a terminal run", async () => {
|
|
736
|
+
const running = {
|
|
737
|
+
run_key: "dg-1",
|
|
738
|
+
status: "running",
|
|
739
|
+
process_key: "PI-DG-1",
|
|
740
|
+
incident_key: null as string | null,
|
|
741
|
+
incident_type: null as string | null,
|
|
742
|
+
incident_message: null as string | null,
|
|
743
|
+
incident_since: null as string | null,
|
|
744
|
+
updated_at: "t0",
|
|
745
|
+
};
|
|
746
|
+
const done = {
|
|
747
|
+
run_key: "dg-2",
|
|
748
|
+
status: "done",
|
|
749
|
+
process_key: "PI-DG-2",
|
|
750
|
+
incident_key: "STALE-DG",
|
|
751
|
+
incident_type: "JOB_NO_RETRIES",
|
|
752
|
+
incident_message: "left over",
|
|
753
|
+
incident_since: "2026-11-08T00:00:00.000Z",
|
|
754
|
+
updated_at: "t0",
|
|
755
|
+
};
|
|
756
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
757
|
+
delivery_graph_runs: { rows: [running, done], key: "run_key" },
|
|
758
|
+
};
|
|
759
|
+
const data = {
|
|
760
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
761
|
+
open: () => memOpen(stores),
|
|
762
|
+
} as any;
|
|
763
|
+
const headers = { "content-type": "application/json" };
|
|
764
|
+
|
|
765
|
+
const prevFetch = globalThis.fetch;
|
|
766
|
+
globalThis.fetch = incidentFetch({
|
|
767
|
+
"PI-DG-1": [{
|
|
768
|
+
incidentKey: "INC-DG-1",
|
|
769
|
+
errorType: "JOB_NO_RETRIES",
|
|
770
|
+
errorMessage: "delivery node job exhausted retries",
|
|
771
|
+
state: "ACTIVE",
|
|
772
|
+
creationTime: "2026-11-09T03:00:00.000Z",
|
|
773
|
+
}],
|
|
774
|
+
// A terminal run's instance is gone — the pass must never query it (the stub would serve an
|
|
775
|
+
// incident, so a query would wrongly overwrite the clearing below).
|
|
776
|
+
"PI-DG-2": [{ incidentKey: "INC-DG-2", errorMessage: "ghost", state: "ACTIVE", creationTime: "2026-11-09T04:00:00.000Z" }],
|
|
777
|
+
}) as typeof fetch;
|
|
778
|
+
try {
|
|
779
|
+
await pollIncidentsImpl(data, "http://engine/v2", headers);
|
|
780
|
+
} finally {
|
|
781
|
+
globalThis.fetch = prevFetch;
|
|
782
|
+
}
|
|
783
|
+
assertEquals(running.incident_key, "INC-DG-1");
|
|
784
|
+
assertEquals(running.incident_type, "JOB_NO_RETRIES");
|
|
785
|
+
assertEquals(running.incident_message, "delivery node job exhausted retries");
|
|
786
|
+
assertEquals(running.incident_since, "2026-11-09T03:00:00.000Z");
|
|
787
|
+
assertEquals(running.status, "running"); // orthogonal: status untouched
|
|
788
|
+
// Terminal run: the stale incident is cleared without ever consulting the engine.
|
|
789
|
+
assertEquals(done.incident_key, null);
|
|
790
|
+
assertEquals(done.incident_type, null);
|
|
791
|
+
assertEquals(done.incident_message, null);
|
|
792
|
+
assertEquals(done.incident_since, null);
|
|
793
|
+
});
|
|
794
|
+
|
|
678
795
|
|
|
679
796
|
// Red/green regression (nano-workforce#845 Copilot review): the convergence loop runs TWO external
|
|
680
797
|
// agent task types while the PR reads `converging` — `review-round` (`senior:pr-review`) and, since
|