@nanobpm/nano-workforce 0.201.0 → 0.201.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.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +153 -0
- package/app/agentActivationLedger.test.ts +109 -0
- package/app/agentActivationLedger.ts +144 -0
- package/app/agentActivationsPoll.test.ts +180 -0
- package/app/agentCompletion.test.ts +52 -0
- package/app/agentCompletion.ts +20 -5
- package/app/agentSlaEscalation.test.ts +137 -0
- package/app/agentSlaEscalation.ts +169 -0
- package/app/agentSlaMarkerGuard.test.ts +172 -0
- package/app/contracts.ts +9 -0
- package/app/deliveryGraph.test.ts +251 -0
- package/app/deliveryGraph.ts +334 -1
- package/app/deliveryGraphCompiler.test.ts +1345 -30
- package/app/deliveryGraphCompiler.ts +882 -240
- package/app/deliveryGraphDeploy.test.ts +472 -3
- package/app/deliveryGraphRun.ts +2 -2
- package/app/deliveryHuman.test.ts +59 -5
- package/app/deliveryHuman.ts +115 -3
- package/app/deliveryRunner.test.ts +3 -3
- package/app/mergeLoopBehaviour.test.ts +46 -1
- package/app/redactText.ts +88 -0
- package/app/service.ts +110 -2
- package/db/migrations/119_agent_job_activations.sql +50 -0
- package/e2e/agent-sla-boundary.e2e.ts +51 -8
- package/e2e/delivery-graph-dispatch.e2e.ts +2 -1
- package/e2e/delivery-graph-node-scope.e2e.ts +358 -0
- package/e2e/delivery-graph.e2e.ts +11 -7
- package/package.json +1 -1
- package/resources/forms/delivery-escalation.form +41 -0
- package/resources/forms/delivery-human-generic.form +4 -3
- package/resources/processes/convergence-loop.bpmn +10 -0
- package/resources/processes/implement-cell.bpmn +4 -1
- package/resources/processes/merge-cell.bpmn +4 -1
- package/resources/processes/merge-loop.bpmn +7 -0
- package/resources/processes/plan-fanout.bpmn +5 -0
- package/resources/processes/retro.bpmn +5 -2
- package/test/derivation-parity/flows.ts +5 -3
- package/workers/conformance-record/worker.ts +15 -1
- package/workers/persist-escalation/worker.ts +22 -3
- package/workers/record-feature-escalation/worker.ts +28 -12
- package/workers/record-trial-merge/worker.ts +16 -1
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
completeEscalationAsAgent,
|
|
19
19
|
completeEscalationAsHuman,
|
|
20
20
|
completeUserTaskAttributed,
|
|
21
|
+
escalationFormId,
|
|
21
22
|
latestCompletion,
|
|
22
23
|
revertAgentCompletion,
|
|
23
24
|
type TaskCompletion,
|
|
@@ -884,6 +885,57 @@ test("validateEscalationVariables derives its contract from the canonical .form
|
|
|
884
885
|
assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
|
|
885
886
|
});
|
|
886
887
|
|
|
888
|
+
test("validateEscalationVariables enforces the `decision` select on a delivery-graph service-escalation twin (retry-node)", async () => {
|
|
889
|
+
// A delivery-graph service-node escalation twin (`…__esc`/`…__contract`) ALWAYS renders the fixed
|
|
890
|
+
// `delivery-escalation` form, whose `decision` select is {continue, retry}. The canonical completers
|
|
891
|
+
// must therefore reject a present-but-invalid `decision` (e.g. an agent/direct-API typo `retrry`),
|
|
892
|
+
// instead of silently computing `retryRequested=false` and taking the deadlock-safe Continue default
|
|
893
|
+
// — which would skip the node (PR #863 review — MEDIUM "Validate decision values" advisory).
|
|
894
|
+
const escTwin = "delivery-human-task__n1__esc";
|
|
895
|
+
const contractTwin = "delivery-human-task__n2__contract";
|
|
896
|
+
// present-but-invalid decision → rejected on BOTH twin kinds
|
|
897
|
+
assert(validateEscalationVariables(escTwin, { decision: "retrry" }) !== null, "invalid decision rejected on __esc twin");
|
|
898
|
+
assert(validateEscalationVariables(contractTwin, { decision: "nope" }) !== null, "invalid decision rejected on __contract twin");
|
|
899
|
+
// valid decisions pass
|
|
900
|
+
assertEquals(validateEscalationVariables(escTwin, { decision: "retry" }), null);
|
|
901
|
+
assertEquals(validateEscalationVariables(escTwin, { decision: "continue" }), null);
|
|
902
|
+
// decision OMITTED → still valid (defaults to Continue); the generic-form wait-escalation twins, which
|
|
903
|
+
// never submit `decision`, must not be false-rejected (the form has no required fields).
|
|
904
|
+
assertEquals(validateEscalationVariables(escTwin, { value: "something", escalationNote: "n" }), null);
|
|
905
|
+
// the bare per-node human task (NOT a twin) stays unenforced — it renders a different form per node.
|
|
906
|
+
assertEquals(validateEscalationVariables("delivery-human-task__n1", { decision: "retrry" }), null);
|
|
907
|
+
// a node literally named `esc` has base id `delivery-human-task__esc` — NOT misread as a twin.
|
|
908
|
+
assertEquals(validateEscalationVariables("delivery-human-task__esc", { decision: "retrry" }), null);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test("escalationFormId derives the COMPILED form per twin kind — wait-gate twins get the generic form, not the retry form (PR #863 review)", () => {
|
|
912
|
+
// Regression guard (Copilot thread app/agentCompletion.ts:186): the bare `__esc` suffix is shared by a
|
|
913
|
+
// service-node timeout twin (retry-capable → `delivery-escalation`) AND a `wait`-gate twin (no retry
|
|
914
|
+
// path → `delivery-human-generic`). Inferring one form from the suffix misclassified the wait gate, so
|
|
915
|
+
// the #461 Tasks-inbox `form_key` fallback rendered a bogus "Retry this step" the wait process silently
|
|
916
|
+
// ignores. The compiler now stamps a `__wait` kind marker into the wait twin's id so the form is
|
|
917
|
+
// DERIVED from the id.
|
|
918
|
+
// service-node twins → retry-capable escalation form
|
|
919
|
+
assertEquals(escalationFormId("delivery-human-task__n1__esc"), "delivery-escalation");
|
|
920
|
+
assertEquals(escalationFormId("delivery-human-task__n2__contract"), "delivery-escalation");
|
|
921
|
+
// wait-gate twin (kind marker) → select-less generic form
|
|
922
|
+
assertEquals(escalationFormId("delivery-human-task__gate__esc__wait"), "delivery-human-generic");
|
|
923
|
+
// a bare per-node human task (variable form per node) has no static contract
|
|
924
|
+
assertEquals(escalationFormId("delivery-human-task__n1"), undefined);
|
|
925
|
+
// a node literally named `esc`/`esc__wait` is NOT misread as a twin (needs a non-empty base before the suffix)
|
|
926
|
+
assertEquals(escalationFormId("delivery-human-task__esc"), undefined);
|
|
927
|
+
assertEquals(escalationFormId("delivery-human-task__esc__wait"), undefined);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test("validateEscalationVariables: a wait-gate twin is validated against the generic form (no `decision` select to enforce)", () => {
|
|
931
|
+
// The wait-gate twin renders the select-less generic form, so a `decision` value is out of contract
|
|
932
|
+
// there — but the generic form declares no `decision` allowed-set, so the completer does not enforce
|
|
933
|
+
// one (the wait process ignores `decision` entirely; there is no retry path to guard).
|
|
934
|
+
const waitTwin = "delivery-human-task__gate__esc__wait";
|
|
935
|
+
assertEquals(validateEscalationVariables(waitTwin, { value: "pkg@1.2.3" }), null, "a value-only wait-gate resume is valid");
|
|
936
|
+
assertEquals(validateEscalationVariables(waitTwin, {}), null, "the generic form has no required fields");
|
|
937
|
+
});
|
|
938
|
+
|
|
887
939
|
test("feature-escalation demands non-blank answer on the answer path, but not on the hidden abandon path", async () => {
|
|
888
940
|
// resolution=answer shows the conditional `answer` field, which is required → blank/missing rejected.
|
|
889
941
|
assert(
|
package/app/agentCompletion.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
|
|
|
24
24
|
import type { DataLayer, EngineClient, GatewayDataSource } from "@nanobpm/urban";
|
|
25
25
|
import { invalidateAdjudication, invalidateAdjudicationByCompletion } from "./adjudications.ts";
|
|
26
26
|
import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
|
|
27
|
-
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
27
|
+
import { DELIVERY_HUMAN_ELEMENT, ESCALATION_FORM, GENERIC_HUMAN_FORM, isDeliveryEscalationTwin, isDeliveryHumanElement, isDeliveryWaitEscalationTwin } from "./deliveryHuman.ts";
|
|
28
28
|
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, PR_MERGE_APPROVAL_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
|
|
29
29
|
|
|
30
30
|
const now = () => new Date().toISOString();
|
|
@@ -165,17 +165,32 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
165
165
|
// contract is enforced instead by `bindHumanEmits` against the node's declared `emits[]` (binds are
|
|
166
166
|
// validated, not stringly — Decision 3/4), so `validateEscalationVariables` leaves it unenforced
|
|
167
167
|
// (returns `null`) and the completer accepts the captured form variables for the emit binder to type.
|
|
168
|
+
// (Its SERVICE-node escalation TWINS — `…__esc`/`…__contract` — are different: they render a FIXED
|
|
169
|
+
// form, so `escalationFormId` maps them to `ESCALATION_FORM` via `isDeliveryEscalationTwin` rather
|
|
170
|
+
// than listing each per-node id here.)
|
|
168
171
|
};
|
|
169
172
|
|
|
170
173
|
/** The fixed `.form` linkage (a `zeebe:formDefinition formId`) that governs a fixed-form escalation
|
|
171
|
-
* kind's completion, or `undefined` for a kind with no static form (the delivery-graph `human`
|
|
172
|
-
* which renders DIFFERENT forms per node — its `formKey` only ever comes from the engine at
|
|
174
|
+
* kind's completion, or `undefined` for a kind with no static form (the per-node delivery-graph `human`
|
|
175
|
+
* task, which renders DIFFERENT forms per node — its `formKey` only ever comes from the engine at
|
|
176
|
+
* runtime). A delivery-graph SERVICE-node escalation TWIN (`…__esc`/`…__contract`), by contrast, ALWAYS
|
|
177
|
+
* renders the fixed {@link ESCALATION_FORM} — a strict superset of the generic form — so it DOES carry a
|
|
178
|
+
* static contract here (via {@link isDeliveryEscalationTwin}): this is what lets the canonical completers
|
|
179
|
+
* reject a present-but-invalid `decision` (continue/retry) on a retry-node escalation, while leaving the
|
|
180
|
+
* select-less generic-form twins unaffected (the form has no required fields, so they never false-reject).
|
|
173
181
|
* Exposed so the Tasks-inbox poller can denormalise a row's `form_key` from this SAME single source of
|
|
174
182
|
* truth when the raw `/v2/user-tasks/search` result omits the engine-resolved key (issue #461) — the
|
|
175
183
|
* REST gateway addresses a deployed form by value whether that value is a deploy key or an authored
|
|
176
184
|
* form id, so this id resolves the same deployed `.form` the completer validates against. */
|
|
177
185
|
export function escalationFormId(elementId: string): string | undefined {
|
|
178
|
-
|
|
186
|
+
// Derive the twin's COMPILED form from its id rather than inferring one form from the shared `__esc`
|
|
187
|
+
// suffix: a `wait`-gate twin (`…__esc__wait`, kind marker stamped by the compiler) renders the
|
|
188
|
+
// select-less GENERIC_HUMAN_FORM (no retry path); a service-node `__esc`/`__contract` twin renders the
|
|
189
|
+
// retry-capable ESCALATION_FORM. Mapping the wait twin to ESCALATION_FORM would denormalise a bogus
|
|
190
|
+
// "Retry this step" onto its Tasks row (the wait process silently ignores `decision`) whenever the
|
|
191
|
+
// engine omits `formKey` (PR #863 review, app/agentCompletion.ts:186).
|
|
192
|
+
if (isDeliveryWaitEscalationTwin(elementId)) return GENERIC_HUMAN_FORM;
|
|
193
|
+
return ESCALATION_FORM_BY_ELEMENT[elementId] ?? (isDeliveryEscalationTwin(elementId) ? ESCALATION_FORM : undefined);
|
|
179
194
|
}
|
|
180
195
|
|
|
181
196
|
/** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
|
|
@@ -261,7 +276,7 @@ export function validateEscalationVariables(
|
|
|
261
276
|
elementId: string,
|
|
262
277
|
variables: Record<string, unknown>,
|
|
263
278
|
): string | null {
|
|
264
|
-
const formId =
|
|
279
|
+
const formId = escalationFormId(elementId);
|
|
265
280
|
if (!formId) return null;
|
|
266
281
|
const { required, allowed, hideWhen } = formContract(formId);
|
|
267
282
|
for (const key of required) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Red/green for issue #879: the agent-task SLA escalation must distinguish a NEVER-ACTIVATED
|
|
2
|
+
// (queue-starved) agent job from an ACTIVATED-but-hung one, name the job type, and say how long it
|
|
3
|
+
// waited — never the old blanket "it is hung or looping" for a job no worker ever picked up.
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { assertEquals, assertStringIncludes } from "#test-assert";
|
|
6
|
+
import { recordAgentJobObservation } from "./agentActivationLedger.ts";
|
|
7
|
+
import {
|
|
8
|
+
agentSlaEscalationQuestion,
|
|
9
|
+
buildAgentSlaEscalationReason,
|
|
10
|
+
humanizeIsoDuration,
|
|
11
|
+
humanizeMs,
|
|
12
|
+
} from "./agentSlaEscalation.ts";
|
|
13
|
+
import { memDataFor } from "../test/worldDb.ts";
|
|
14
|
+
|
|
15
|
+
const RECOVERY = "Answer to resume, or abandon it.";
|
|
16
|
+
|
|
17
|
+
test("humanizeMs renders a compact span and floors sub-minute to seconds", () => {
|
|
18
|
+
assertEquals(humanizeMs(2 * 3600_000 + 5 * 60_000), "2h 5m");
|
|
19
|
+
assertEquals(humanizeMs(45 * 60_000), "45m");
|
|
20
|
+
assertEquals(humanizeMs(30_000), "30s");
|
|
21
|
+
assertEquals(humanizeMs(0), "less than a minute");
|
|
22
|
+
assertEquals(humanizeMs(-5), "less than a minute");
|
|
23
|
+
assertEquals(humanizeMs(Number.NaN), "less than a minute");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("humanizeIsoDuration humanizes the SLA budget", () => {
|
|
27
|
+
assertEquals(humanizeIsoDuration("PT2H"), "2h");
|
|
28
|
+
assertEquals(humanizeIsoDuration("PT15M"), "15m");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("never-observed-activated: queue starvation is reported hedged, NOT as hung or looping", () => {
|
|
32
|
+
const reason = buildAgentSlaEscalationReason({
|
|
33
|
+
jobType: "senior:feature",
|
|
34
|
+
sla: "PT2H",
|
|
35
|
+
// Observed queued, never leased (activated_at null) → the defect scenario.
|
|
36
|
+
activation: { firstSeenAt: "2024-01-01T00:00:00Z", activatedAt: null, worker: null },
|
|
37
|
+
now: "2024-01-01T02:00:00Z",
|
|
38
|
+
recovery: RECOVERY,
|
|
39
|
+
});
|
|
40
|
+
assertStringIncludes(reason, "senior:feature");
|
|
41
|
+
// Observational, hedged wording — never the over-confident "the agent never started".
|
|
42
|
+
assertStringIncludes(reason, "No worker was ever observed to pick up");
|
|
43
|
+
assertStringIncludes(reason, "queue starvation");
|
|
44
|
+
// Names how long it waited and the SLA budget.
|
|
45
|
+
assertStringIncludes(reason, "2h");
|
|
46
|
+
// Acknowledges the poll-window race rather than claiming certainty (issue #881 review).
|
|
47
|
+
assertStringIncludes(reason, "that would not have been recorded");
|
|
48
|
+
// Must NOT misdiagnose it as hung/looping.
|
|
49
|
+
assertEquals(reason.includes("may be hung or looping"), false);
|
|
50
|
+
assertEquals(reason.includes("agent never started"), false);
|
|
51
|
+
assertStringIncludes(reason, RECOVERY);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("started-but-exceeded: a leased job may be hung or looping", () => {
|
|
55
|
+
const reason = buildAgentSlaEscalationReason({
|
|
56
|
+
jobType: "senior:trial-merge",
|
|
57
|
+
sla: "PT2H",
|
|
58
|
+
activation: {
|
|
59
|
+
firstSeenAt: "2024-01-01T00:00:00Z",
|
|
60
|
+
activatedAt: "2024-01-01T00:10:00Z",
|
|
61
|
+
worker: "agent-7",
|
|
62
|
+
},
|
|
63
|
+
now: "2024-01-01T02:10:00Z",
|
|
64
|
+
recovery: RECOVERY,
|
|
65
|
+
});
|
|
66
|
+
assertStringIncludes(reason, "senior:trial-merge");
|
|
67
|
+
assertStringIncludes(reason, "started");
|
|
68
|
+
assertStringIncludes(reason, "hung or looping");
|
|
69
|
+
// Reports both the queue wait and the run time, plus the worker.
|
|
70
|
+
assertStringIncludes(reason, "10m");
|
|
71
|
+
assertStringIncludes(reason, "agent-7");
|
|
72
|
+
assertEquals(reason.includes("never started"), false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("activation unknown: the poller never observed the job → honest ambiguity", () => {
|
|
76
|
+
const reason = buildAgentSlaEscalationReason({
|
|
77
|
+
jobType: "senior:retro",
|
|
78
|
+
sla: "PT2H",
|
|
79
|
+
activation: null,
|
|
80
|
+
now: "2024-01-01T02:00:00Z",
|
|
81
|
+
recovery: RECOVERY,
|
|
82
|
+
});
|
|
83
|
+
assertStringIncludes(reason, "senior:retro");
|
|
84
|
+
assertStringIncludes(reason, "no worker-activation was ever recorded");
|
|
85
|
+
assertEquals(reason.includes("hung or looping"), false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("agentSlaEscalationQuestion reads the ledger and distinguishes never-started", async () => {
|
|
89
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
90
|
+
// A queued-but-never-activated observation (worker null).
|
|
91
|
+
await recordAgentJobObservation(data, {
|
|
92
|
+
processInstanceKey: "PI-1",
|
|
93
|
+
jobType: "senior:feature",
|
|
94
|
+
worker: null,
|
|
95
|
+
now: "2024-01-01T00:00:00Z",
|
|
96
|
+
});
|
|
97
|
+
const q = await agentSlaEscalationQuestion(data, {
|
|
98
|
+
processInstanceKey: "PI-1",
|
|
99
|
+
jobType: "senior:feature",
|
|
100
|
+
sla: "PT2H",
|
|
101
|
+
recovery: RECOVERY,
|
|
102
|
+
now: "2024-01-01T02:00:00Z",
|
|
103
|
+
});
|
|
104
|
+
assertStringIncludes(q, "No worker was ever observed to pick up");
|
|
105
|
+
assertEquals(q.includes("may be hung or looping"), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("agentSlaEscalationQuestion reads an activated observation as started-but-hung", async () => {
|
|
109
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
110
|
+
await recordAgentJobObservation(data, {
|
|
111
|
+
processInstanceKey: "PI-2",
|
|
112
|
+
jobType: "senior:feature",
|
|
113
|
+
worker: "agent-x",
|
|
114
|
+
now: "2024-01-01T00:05:00Z",
|
|
115
|
+
});
|
|
116
|
+
const q = await agentSlaEscalationQuestion(data, {
|
|
117
|
+
processInstanceKey: "PI-2",
|
|
118
|
+
jobType: "senior:feature",
|
|
119
|
+
sla: "PT2H",
|
|
120
|
+
recovery: RECOVERY,
|
|
121
|
+
now: "2024-01-01T02:05:00Z",
|
|
122
|
+
});
|
|
123
|
+
assertStringIncludes(q, "hung or looping");
|
|
124
|
+
assertStringIncludes(q, "agent-x");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("agentSlaEscalationQuestion with no ledger row reports activation unknown", async () => {
|
|
128
|
+
const { data } = memDataFor(["119_agent_job_activations.sql"]);
|
|
129
|
+
const q = await agentSlaEscalationQuestion(data, {
|
|
130
|
+
processInstanceKey: "PI-404",
|
|
131
|
+
jobType: "senior:feature",
|
|
132
|
+
sla: "PT2H",
|
|
133
|
+
recovery: RECOVERY,
|
|
134
|
+
now: "2024-01-01T02:00:00Z",
|
|
135
|
+
});
|
|
136
|
+
assertStringIncludes(q, "no worker-activation was ever recorded");
|
|
137
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Agent-task SLA-escalation reason builder (issue #879) — the single canonical, pure text builder for
|
|
2
|
+
// the human-facing escalation raised when an agent service task's interrupting SLA timer boundary
|
|
3
|
+
// fires. It distinguishes the two outcomes the old wording conflated:
|
|
4
|
+
//
|
|
5
|
+
// • NEVER OBSERVED ACTIVATED — no worker was ever observed to pick the job up (almost certainly
|
|
6
|
+
// queue starvation / no capacity for the job type). The job sat CREATED for ~the whole SLA budget
|
|
7
|
+
// and was cancelled by the boundary. Telling the human it is "hung or looping" and to "check its
|
|
8
|
+
// progress" is a false diagnosis — the likely fix is CAPACITY, not intervention. The wording is
|
|
9
|
+
// hedged (a worker could lease it in the final moment before the boundary fires, between activation
|
|
10
|
+
// polls, unrecorded), so it reports the likely cause without over-claiming certainty.
|
|
11
|
+
// • STARTED BUT EXCEEDED — a worker leased the job (an agent ran) but did not complete within the
|
|
12
|
+
// budget — it may genuinely be hung or looping, and intervening / checking its progress is apt.
|
|
13
|
+
//
|
|
14
|
+
// The distinction is read from the durable activation ledger (`app/agentActivationLedger.ts`), which
|
|
15
|
+
// the poller populates while the job is live. Every escalation names the JOB TYPE and HOW LONG it
|
|
16
|
+
// waited, per the issue's acceptance criteria.
|
|
17
|
+
//
|
|
18
|
+
// Derivation over duplication: this is the ONE builder every agent-SLA recorder (implement-cell,
|
|
19
|
+
// merge-cell, retro) routes through, so the never-started/hung wording can never drift between sites.
|
|
20
|
+
// The pure `buildAgentSlaEscalationReason` is exhaustively testable; `agentSlaEscalationQuestion` is
|
|
21
|
+
// the thin async wrapper that reads the ledger and calls it.
|
|
22
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
23
|
+
import { type AgentJobActivation, getAgentJobActivation } from "./agentActivationLedger.ts";
|
|
24
|
+
import { isoDurationToMs } from "./reviewWait.ts";
|
|
25
|
+
|
|
26
|
+
/** The activation facts the builder needs (a slice of the ledger row), or `null` when the poller never
|
|
27
|
+
* observed the job (activation unknown). */
|
|
28
|
+
export interface AgentSlaActivation {
|
|
29
|
+
/** ISO ts a leasing worker was first observed; `null`/absent ⇒ never activated. */
|
|
30
|
+
activatedAt?: string | null;
|
|
31
|
+
/** ISO ts the job was first observed queued. */
|
|
32
|
+
firstSeenAt?: string | null;
|
|
33
|
+
/** The leasing worker's name, when known. */
|
|
34
|
+
worker?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AgentSlaEscalationInput {
|
|
38
|
+
/** The agent job type that timed out, e.g. `senior:feature`. Named in the reason. */
|
|
39
|
+
jobType: string;
|
|
40
|
+
/** The SLA budget (ISO-8601 duration) the boundary armed, e.g. `PT2H`. */
|
|
41
|
+
sla: string;
|
|
42
|
+
/** The activation record for this job, or `null` when the poller never observed it. */
|
|
43
|
+
activation: AgentSlaActivation | null;
|
|
44
|
+
/** The escalation moment (ISO ts) — the elapsed wait is measured against it. */
|
|
45
|
+
now: string;
|
|
46
|
+
/** How the human resumes after answering — appended verbatim so each caller keeps its own
|
|
47
|
+
* routing hint (e.g. "answer to resume … or abandon it"). */
|
|
48
|
+
recovery: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Humanise a millisecond span to a compact, human-facing string (e.g. `2h 5m`, `45m`, `30s`). A
|
|
52
|
+
* non-positive/NaN span renders `less than a minute`. */
|
|
53
|
+
export function humanizeMs(ms: number): string {
|
|
54
|
+
if (!Number.isFinite(ms) || ms <= 0) return "less than a minute";
|
|
55
|
+
const totalMinutes = Math.floor(ms / 60_000);
|
|
56
|
+
if (totalMinutes < 1) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
|
57
|
+
const days = Math.floor(totalMinutes / 1440);
|
|
58
|
+
const hours = Math.floor((totalMinutes % 1440) / 60);
|
|
59
|
+
const minutes = totalMinutes % 60;
|
|
60
|
+
const parts: string[] = [];
|
|
61
|
+
if (days > 0) parts.push(`${days}d`);
|
|
62
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
63
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
64
|
+
return parts.join(" ") || "less than a minute";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Humanise an ISO-8601 duration (the SLA budget) to the same compact form, via the canonical parser. */
|
|
68
|
+
export function humanizeIsoDuration(iso: string): string {
|
|
69
|
+
return humanizeMs(isoDurationToMs(iso, iso));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const elapsedMs = (fromIso: string | null | undefined, toIso: string): number | null => {
|
|
73
|
+
if (!fromIso) return null;
|
|
74
|
+
const from = Date.parse(fromIso);
|
|
75
|
+
const to = Date.parse(toIso);
|
|
76
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) return null;
|
|
77
|
+
return to - from;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Build the accurate, self-contained agent-SLA escalation reason. Pure — distinguishes never-started
|
|
81
|
+
* (queue starvation) from started-but-hung, names the job type and how long it waited, then appends
|
|
82
|
+
* the caller's recovery hint. */
|
|
83
|
+
export function buildAgentSlaEscalationReason(input: AgentSlaEscalationInput): string {
|
|
84
|
+
const { jobType, sla, activation, now, recovery } = input;
|
|
85
|
+
const slaHuman = humanizeIsoDuration(sla);
|
|
86
|
+
const activatedAt = activation?.activatedAt ?? null;
|
|
87
|
+
|
|
88
|
+
if (activatedAt) {
|
|
89
|
+
// STARTED BUT EXCEEDED: a worker leased the job, so it may genuinely be hung or looping.
|
|
90
|
+
const queueMs = elapsedMs(activation?.firstSeenAt, activatedAt);
|
|
91
|
+
const queuedClause = queueMs !== null && queueMs > 0
|
|
92
|
+
? ` (after waiting ~${humanizeMs(queueMs)} in the queue)`
|
|
93
|
+
: "";
|
|
94
|
+
const ranMs = elapsedMs(activatedAt, now);
|
|
95
|
+
const ranClause = ranMs !== null && ranMs > 0 ? ` and then ran for ~${humanizeMs(ranMs)}` : "";
|
|
96
|
+
const workerClause = activation?.worker ? ` (worker "${activation.worker}")` : "";
|
|
97
|
+
return (
|
|
98
|
+
`The ${jobType} agent started${queuedClause}${workerClause}${ranClause} but exceeded its time budget ` +
|
|
99
|
+
`(SLA ${sla} ≈ ${slaHuman}) without completing — it may be hung or looping. ${recovery}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Measure the wait: from first-seen to now when the poller observed the queued job, else the full
|
|
104
|
+
// SLA budget (the boundary fired exactly one budget after the task was entered).
|
|
105
|
+
const waitedMs = elapsedMs(activation?.firstSeenAt, now);
|
|
106
|
+
const waited = waitedMs !== null && waitedMs > 0 ? humanizeMs(waitedMs) : slaHuman;
|
|
107
|
+
|
|
108
|
+
if (activation === null) {
|
|
109
|
+
// ACTIVATION UNKNOWN: the poller never observed the job (e.g. the activation poll was unavailable).
|
|
110
|
+
// Report the ambiguity honestly rather than guessing "hung" or "never started".
|
|
111
|
+
return (
|
|
112
|
+
`The ${jobType} agent did not complete within its time budget (SLA ${sla} ≈ ${slaHuman}), and no ` +
|
|
113
|
+
`worker-activation was ever recorded for it, so we cannot confirm whether a worker ever picked it ` +
|
|
114
|
+
`up — it may have been starved of capacity for ${jobType}, or started and hung. ${recovery}`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// NEVER OBSERVED ACTIVATED: seen queued, never seen leased. This is the best-effort observation of
|
|
119
|
+
// queue starvation — hedged, because a worker could in principle lease the job in the final moment
|
|
120
|
+
// before the boundary cancels it, between activation polls, and that would not be recorded. We report
|
|
121
|
+
// the likely diagnosis (and its capacity fix) without over-claiming certainty.
|
|
122
|
+
return (
|
|
123
|
+
`No worker was ever observed to pick up the ${jobType} agent's job for its entire SLA budget ` +
|
|
124
|
+
`(SLA ${sla} ≈ ${slaHuman}): it was seen queued for ~${waited}, then the SLA boundary cancelled it. ` +
|
|
125
|
+
`This is almost certainly queue starvation (no capacity for ${jobType}) rather than a hung or ` +
|
|
126
|
+
`looping agent, so the likely fix is to add capacity for ${jobType} (enrol a worker that serves it, ` +
|
|
127
|
+
`or reduce concurrent demand) rather than to check a non-existent agent's progress. (If a worker ` +
|
|
128
|
+
`leased it in the final moment before the boundary fired, between activation polls, that would not ` +
|
|
129
|
+
`have been recorded here.) ${recovery}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Read the activation ledger for this job and build the escalation reason — the thin async wrapper the
|
|
134
|
+
* recorder workers call. `processInstanceKey` is the instance the agent job AND its SLA recorder share,
|
|
135
|
+
* so the lookup is by (instance, jobType). An absent ledger row ⇒ activation unknown. */
|
|
136
|
+
export async function agentSlaEscalationQuestion(
|
|
137
|
+
data: DataLayer,
|
|
138
|
+
input: {
|
|
139
|
+
processInstanceKey: string;
|
|
140
|
+
jobType: string;
|
|
141
|
+
sla: string;
|
|
142
|
+
recovery: string;
|
|
143
|
+
now?: string;
|
|
144
|
+
},
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
const now = input.now ?? new Date().toISOString();
|
|
147
|
+
let activation: AgentSlaActivation | null = null;
|
|
148
|
+
if (input.processInstanceKey && input.processInstanceKey.length > 0) {
|
|
149
|
+
const row: AgentJobActivation | undefined = await getAgentJobActivation(
|
|
150
|
+
data,
|
|
151
|
+
input.processInstanceKey,
|
|
152
|
+
input.jobType,
|
|
153
|
+
);
|
|
154
|
+
if (row) {
|
|
155
|
+
activation = {
|
|
156
|
+
activatedAt: row.activated_at,
|
|
157
|
+
firstSeenAt: row.first_seen_at,
|
|
158
|
+
worker: row.worker,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return buildAgentSlaEscalationReason({
|
|
163
|
+
jobType: input.jobType,
|
|
164
|
+
sla: input.sla,
|
|
165
|
+
activation,
|
|
166
|
+
now,
|
|
167
|
+
recovery: input.recovery,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// DEFECT-CLASS GUARD (PR #881 review, thread r4214170285): `agentSlaJobType` is the marker that
|
|
2
|
+
// selects the agent-SLA escalation builder in the four recorder workers (`pr.persist-escalation`,
|
|
3
|
+
// `pr.record-feature-escalation`, `pr.record-trial-merge`, `pr.conformance-record`). It is set
|
|
4
|
+
// ONLY by the BPMN SLA arm (an interrupting timer boundary's route), never by the agent — but the
|
|
5
|
+
// c8ctl harness HOISTS the agent's result-JSON keys into the SAME process-variable namespace the
|
|
6
|
+
// recorders read it from. So a NORMAL (non-SLA) agent result that happens to carry an
|
|
7
|
+
// `agentSlaJobType` key would wrongly select the SLA builder and replace a genuine question or
|
|
8
|
+
// audit summary with a timeout diagnosis.
|
|
9
|
+
//
|
|
10
|
+
// This is the exact defect class PR #864 (review r4181010853) already closed for the boolean
|
|
11
|
+
// SLA-control flags `agentSlaElapsed` / `preserveConformance` — by pinning each to a LITERAL on
|
|
12
|
+
// every recorder task's ioMapping so hoisted agent output can never select the internal control
|
|
13
|
+
// path. `agentSlaJobType` was left unpinned. This guard closes that gap: every NORMAL-path recorder
|
|
14
|
+
// (one that does NOT set the marker to a real job-type literal) must pin `agentSlaJobType` to the
|
|
15
|
+
// `=null` literal, so a hoisted agent-supplied value is overridden to null on the normal path.
|
|
16
|
+
// SLA-arm recorders legitimately pin it to a job-type literal (e.g. `="senior:feature"`); those
|
|
17
|
+
// are the trusted source and are NOT required to pin null.
|
|
18
|
+
//
|
|
19
|
+
// The guard scans the deployed corpus and fails if any recorder task reads `agentSlaJobType`
|
|
20
|
+
// without pinning it to a literal (null on the normal path, a job-type on an SLA arm) — so a
|
|
21
|
+
// newly-added or edited recorder can never re-open the hole.
|
|
22
|
+
//
|
|
23
|
+
// EXCEPTION — merge-loop's SHARED SLA-sink recorders (PR #881 review, adversarial round 3): unlike
|
|
24
|
+
// the other processes, merge-loop has NO dedicated `*-sla` recorder task. Its fix-ci / rebase
|
|
25
|
+
// interrupting-SLA arms (`end_ci_sla` → "senior:fix-ci", `end_reb_sla` → "senior:rebase") and its
|
|
26
|
+
// NON-SLA escalation arms CONVERGE on the SAME two recorders (`merge-esc-attempt`,
|
|
27
|
+
// `merge-esc-conflict`). So those two recorders MUST READ `agentSlaJobType` from process scope
|
|
28
|
+
// (set by the trusted SLA end event) — a blanket `=null` input pin there would clobber the
|
|
29
|
+
// legitimate SLA job type and silently regress the issue-#879 never-started-vs-hung diagnosis. The
|
|
30
|
+
// hoisted-agent-output hole is closed on these shared sinks a DIFFERENT way: every path that can
|
|
31
|
+
// carry a hoisted (spoofable) value CLEARS `agentSlaJobType` to null via an OUTPUT mapping before
|
|
32
|
+
// it reaches the recorder — at the loop head (`arm-merge`, scrubbing any stale/cross-iteration
|
|
33
|
+
// value), on the two non-SLA escalate end events that bypass the loop head (`end_ci_esc`,
|
|
34
|
+
// `end_reb_blocked`), and on the CI reconcile re-check exit (`end_ci_recheck`, which re-enters the
|
|
35
|
+
// merge wait via `gw-merge-entry` without passing through `arm-merge`). Only the trusted SLA arms
|
|
36
|
+
// leave it set. This guard therefore (a) requires the shared sinks to NOT pin, and (b) requires
|
|
37
|
+
// those clears + the SLA-arm literals to exist.
|
|
38
|
+
import { readFileSync } from "node:fs";
|
|
39
|
+
import { test } from "node:test";
|
|
40
|
+
import { dirname, join } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
import { assert } from "#test-assert";
|
|
43
|
+
|
|
44
|
+
const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../resources/processes");
|
|
45
|
+
const PROCESS_FILES = [
|
|
46
|
+
"convergence-loop.bpmn",
|
|
47
|
+
"merge-loop.bpmn",
|
|
48
|
+
"implement-cell.bpmn",
|
|
49
|
+
"merge-cell.bpmn",
|
|
50
|
+
"plan-fanout.bpmn",
|
|
51
|
+
"retro.bpmn",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// The recorder job types that read `agentSlaJobType` to select the SLA builder.
|
|
55
|
+
const RECORDER_TYPES = [
|
|
56
|
+
"pr.persist-escalation",
|
|
57
|
+
"pr.record-feature-escalation",
|
|
58
|
+
"pr.record-trial-merge",
|
|
59
|
+
"pr.conformance-record",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const SERVICE_TASK = /<bpmn:serviceTask\b[\s\S]*?<\/bpmn:serviceTask>/g;
|
|
63
|
+
// A trusted pin is a LITERAL input: `=null` (normal path) or `="..."`/`="..."` (SLA arm's
|
|
64
|
+
// job-type). A process-variable REFERENCE (e.g. `=agentSlaJobType`) is NOT a pin — it would read
|
|
65
|
+
// hoisted agent output, which is exactly the hole being closed.
|
|
66
|
+
const LITERAL_PIN =
|
|
67
|
+
/<zeebe:input\s+source="=(?:null|&#[0-9]+;[^"]*&#[0-9]+;|"[^"]*")"\s+target="agentSlaJobType"\s*\/>/;
|
|
68
|
+
|
|
69
|
+
// Any INPUT mapping on `agentSlaJobType` (literal OR reference) — used to assert the shared SLA-sink
|
|
70
|
+
// recorders carry NONE (they must read the SLA arm's value from process scope, not shadow it).
|
|
71
|
+
const ANY_INPUT_PIN = /<zeebe:input\s+source="=[^"]*"\s+target="agentSlaJobType"\s*\/>/;
|
|
72
|
+
// A `=null` OUTPUT clear (writes process scope) — the fail-closed scrub on merge-loop's spoofable
|
|
73
|
+
// non-SLA paths.
|
|
74
|
+
const NULL_OUTPUT_CLEAR = /<zeebe:output\s+source="=null"\s+target="agentSlaJobType"\s*\/>/;
|
|
75
|
+
|
|
76
|
+
// merge-loop's two SHARED SLA-sink recorders: both SLA and non-SLA arms converge on them, so they
|
|
77
|
+
// MUST read `agentSlaJobType` from scope and MUST NOT pin it (see header).
|
|
78
|
+
const SHARED_SLA_SINKS = new Set(["merge-esc-attempt", "merge-esc-conflict"]);
|
|
79
|
+
|
|
80
|
+
/** Extract the XML block for a BPMN element by id (any element tag). */
|
|
81
|
+
function elementBlock(xml: string, id: string): string | undefined {
|
|
82
|
+
const re = new RegExp(`<bpmn:(\\w+)\\b[^>]*\\bid="${id}"[\\s\\S]*?</bpmn:\\1>`);
|
|
83
|
+
return xml.match(re)?.[0];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
test("DEFECT-CLASS GUARD: every SLA-builder recorder pins agentSlaJobType to a literal (agent output cannot select the SLA path)", () => {
|
|
87
|
+
let recorderCount = 0;
|
|
88
|
+
for (const file of PROCESS_FILES) {
|
|
89
|
+
const xml = readFileSync(join(PROCESSES_DIR, file), "utf8");
|
|
90
|
+
for (const [block] of xml.matchAll(SERVICE_TASK)) {
|
|
91
|
+
if (!RECORDER_TYPES.some((t) => block.includes(`type="${t}"`))) continue;
|
|
92
|
+
const id = block.match(/<bpmn:serviceTask\b[^>]*\bid="([^"]*)"/)?.[1] ?? "(unknown)";
|
|
93
|
+
// The shared SLA-sink recorders read the marker from scope — they must NOT pin it, else a
|
|
94
|
+
// `=null` pin would clobber the trusted SLA job type (issue #879 regression). They are
|
|
95
|
+
// protected by the OUTPUT clears asserted below, not by an input pin.
|
|
96
|
+
if (SHARED_SLA_SINKS.has(id)) {
|
|
97
|
+
assert(
|
|
98
|
+
!ANY_INPUT_PIN.test(block),
|
|
99
|
+
`${file}: shared SLA-sink recorder "${id}" must NOT pin agentSlaJobType — both the SLA ` +
|
|
100
|
+
`and non-SLA arms converge on it, so it must READ the SLA end event's value from ` +
|
|
101
|
+
`process scope. A =null pin here clobbers the trusted SLA job type and regresses the ` +
|
|
102
|
+
`never-started-vs-hung diagnosis (PR #881 adversarial review). Remove the pin; the ` +
|
|
103
|
+
`hole is closed by the =null OUTPUT clears on arm-merge / end_ci_esc / end_reb_blocked.`,
|
|
104
|
+
);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
recorderCount++;
|
|
108
|
+
assert(
|
|
109
|
+
LITERAL_PIN.test(block),
|
|
110
|
+
`${file}: recorder task "${id}" reads agentSlaJobType but does not pin it to a literal ` +
|
|
111
|
+
`(=null on the normal path, a job-type literal on an SLA arm). A hoisted agent result ` +
|
|
112
|
+
`carrying agentSlaJobType would wrongly select the SLA escalation builder (PR #881 review ` +
|
|
113
|
+
`r4214170285). Pin it: <zeebe:input source="=null" target="agentSlaJobType" /> on the ` +
|
|
114
|
+
`normal path.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Sanity: the guard is not vacuously green — the corpus really has these recorders (the
|
|
119
|
+
// normal-path recorders + the SLA arms across the six processes).
|
|
120
|
+
assert(recorderCount >= 11, `expected >=11 SLA-builder recorder tasks, found ${recorderCount}`);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("DEFECT-CLASS GUARD: merge-loop's shared SLA sinks are fed a trusted agentSlaJobType (SLA arms set it; spoofable non-SLA paths clear it)", () => {
|
|
124
|
+
const xml = readFileSync(join(PROCESSES_DIR, "merge-loop.bpmn"), "utf8");
|
|
125
|
+
|
|
126
|
+
// The two shared sinks really exist and read from scope (no input pin) — guards against the file
|
|
127
|
+
// being renamed/removed and silently passing.
|
|
128
|
+
for (const id of SHARED_SLA_SINKS) {
|
|
129
|
+
const block = elementBlock(xml, id);
|
|
130
|
+
assert(block, `merge-loop.bpmn: expected shared SLA-sink recorder "${id}" to exist`);
|
|
131
|
+
assert(
|
|
132
|
+
!ANY_INPUT_PIN.test(block),
|
|
133
|
+
`merge-loop.bpmn: "${id}" must read agentSlaJobType from scope, not pin it`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The trusted SLA arms SET the job-type literal (this is the value the shared sinks must receive).
|
|
138
|
+
for (const [id, literal] of [
|
|
139
|
+
["end_ci_sla", "senior:fix-ci"],
|
|
140
|
+
["end_reb_sla", "senior:rebase"],
|
|
141
|
+
] as const) {
|
|
142
|
+
const block = elementBlock(xml, id);
|
|
143
|
+
assert(block, `merge-loop.bpmn: expected SLA arm "${id}" to exist`);
|
|
144
|
+
const re = new RegExp(
|
|
145
|
+
`<zeebe:output\\s+source="="${literal.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}""\\s+target="agentSlaJobType"\\s*/>`,
|
|
146
|
+
);
|
|
147
|
+
assert(
|
|
148
|
+
re.test(block),
|
|
149
|
+
`merge-loop.bpmn: SLA arm "${id}" must set agentSlaJobType to the "${literal}" literal via ` +
|
|
150
|
+
`an output mapping (the trusted source the shared sink reads).`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Every spoofable path that can carry a hoisted agentSlaJobType into a shared sink CLEARS it to
|
|
155
|
+
// null via output BEFORE the recorder: the loop head (stale/cross-iteration), the two non-SLA
|
|
156
|
+
// escalate end events that bypass the loop head (same-iteration agent spoof), and the CI
|
|
157
|
+
// reconcile re-check exit (`end_ci_recheck`), which re-enters the merge wait via `gw-merge-entry`
|
|
158
|
+
// — bypassing `arm-merge` — so a fix-ci result with status:"blocked"/pushed:false carrying a
|
|
159
|
+
// hoisted marker would otherwise reach `merge-esc-conflict` on a later draft/budget-exhausted
|
|
160
|
+
// merge with no SLA timer having fired (PR #881 review, "Previously missed" r5451284362).
|
|
161
|
+
for (const id of ["arm-merge", "end_ci_esc", "end_reb_blocked", "end_ci_recheck"]) {
|
|
162
|
+
const block = elementBlock(xml, id);
|
|
163
|
+
assert(block, `merge-loop.bpmn: expected "${id}" to exist`);
|
|
164
|
+
assert(
|
|
165
|
+
NULL_OUTPUT_CLEAR.test(block),
|
|
166
|
+
`merge-loop.bpmn: "${id}" must clear agentSlaJobType to null via ` +
|
|
167
|
+
`<zeebe:output source="=null" target="agentSlaJobType" /> so a hoisted (spoofable) agent ` +
|
|
168
|
+
`value can never reach the shared SLA-sink recorder and fabricate a never-started/hung ` +
|
|
169
|
+
`SLA diagnosis (PR #881 adversarial review).`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
});
|
package/app/contracts.ts
CHANGED
|
@@ -559,6 +559,15 @@ export const WIRE_CONTRACTS = {
|
|
|
559
559
|
"The engine-native AgentInstance/AgentHistory READ path (issue #745/#747, umbrella #746 — the CONSUMER half of the PRODUCER `agentTask.agentDefinition` marker). The Cockpit HISTORICAL transcript + per-turn/instance metrics are sourced from the engine read model through the SINGLE engine-read seam — `@nanobpm/urban`'s `EngineClient.searchAgentInstances`/`searchAgentInstanceHistory`/`getAgentInstance` (added in urban 0.93 / nanobpm/nano-ide#563; the escalation's option (a) — NO `orchestration-cluster-api-js` fork / no second broker-REST client, so the read path is exercised by the testkit WASM double, which records read-as-absence — an empty list / null — while a live engine validates the behavioural parity). ONE narrow reader (`AgentHistoryReader`) + ONE projection (`listAgentInstances`/`readAgentHistory`) in app/agentic/agent-history.ts, served by `GET /agentic/agent-instances` + `GET /agentic/agent-instances/{agentInstanceKey}/history`. Correlation keys are the AGENT-INSTANCE / PROCESS-INSTANCE / ELEMENT-INSTANCE keys (the same #544 per-occupancy element-instance handle the relay correlation uses) — NEVER the slash-bearing `job:<jobKey>` relay stream id, so the #744 gateway-proxy bug class is moot for settled history. The token-granular relay stays the LIVE overlay only (settled history = engine, live tail = relay). Advisory read-only (ADR 0056): it observes the engine read model, never activates/completes a job or gates a sequence flow. The wire shapes (`AgentInstance`/`AgentInstanceList`/`AgentHistoryRecord`/`AgentHistory` + the metrics shapes) are declared in openapi.yaml and reuse the `@nanobpm/agentic/transcript` parity conversation grammar the transcript store already models. Consume this ONE read path — do not add a second agent-history client or a synonym endpoint.",
|
|
560
560
|
shape: "GET /agentic/agent-instances[?processInstanceKey&rootProcessInstanceKey&elementId&status] → AgentInstanceList; GET /agentic/agent-instances/{agentInstanceKey}/history[?role&loopIteration&elementInstanceKey] → AgentHistory",
|
|
561
561
|
},
|
|
562
|
+
"deliveryGraph.workerResult": {
|
|
563
|
+
category: "wire",
|
|
564
|
+
name: "deliveryGraph.workerResult",
|
|
565
|
+
owner: "app/deliveryGraph.ts",
|
|
566
|
+
semantics:
|
|
567
|
+
"The delivery-graph worker-completion RESULT envelope (issue #863) — the canonical set of variable names a fleet worker (agent/connector) or an escalation completion returns on a delivery node's job, which the deliveryGraph compiler declares node-LOCAL on the node's subProcess (and clears on retry) so a parallel node never overwrites a sibling's result and a retry never reads a stale one. Three sibling sets, all exported from app/deliveryGraph.ts (the validator's home, so the reserved-fact-name set derives from them without an import cycle): `AGENT_RESULT_LOCAL_VARS` (status/summary/question/output/error/pr/prUrl/pullRequest/branch/commits/exitCode/next_steps/issue/completed/pushed/truncated/agentCheckpoint/transcriptUrl/delta/approved/findings/commentUrl/hasDeviations/slicesMet/slicesReduced/slicesNotVerified/deviationsRaised/deviationsUnraised, plus the prompt-contract outputs dependsOn/adversarialFindings/adversarialSummary/scopeBlocked/scopeBlockReason/tasks/result/conflicts/failing/note), `CONNECTOR_RESULT_LOCAL_VARS` (connectorOutcome/connectorDedupeKey/connectorDetail), and `ESCALATION_LOCAL_VARS` (decision/value/escalationNote — the escalation-completion controls; the retry-guidance note is `escalationNote`, NOT the plain `note`, which is a plan.md WORKER result field that would otherwise be fed back as operator guidance on a note-less retry). These are job-completion outputs and escalation-form variables, NOT message payloads, so the scalar-only message-envelope rule does not apply (array-typed fields like dependsOn/tasks/conflicts/failing are legal here). A new worker/escalation result field MUST be added to the matching set here — never re-declared as a synonym list in the compiler or a worker — or it leaks to the shared root scope.",
|
|
568
|
+
shape:
|
|
569
|
+
"AGENT_RESULT_LOCAL_VARS: readonly string[] (28 base + 10 prompt-contract outputs); CONNECTOR_RESULT_LOCAL_VARS: readonly ['connectorOutcome','connectorDedupeKey','connectorDetail']; ESCALATION_LOCAL_VARS: readonly ['decision','value','escalationNote']",
|
|
570
|
+
},
|
|
562
571
|
} as const satisfies Record<string, WireContract>;
|
|
563
572
|
|
|
564
573
|
export const TYPE_CONTRACTS = {
|