@nanobpm/nano-workforce 0.200.7 → 0.200.9
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 +41 -0
- package/app/agentSla.test.ts +48 -0
- package/app/agentSla.ts +36 -11
- package/app/agentic/vocab/agent-sla-boundary.test.ts +170 -0
- package/app/agentic/vocab/job-types.ts +60 -0
- package/app/conformance.test.ts +114 -0
- package/app/conformance.ts +72 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryRunner.ts +5 -0
- package/app/feature.ts +5 -0
- package/app/implementEscalationReason.test.ts +216 -0
- package/app/implementEscalationReason.ts +213 -0
- package/app/implementReconcile.test.ts +168 -11
- package/app/implementReconcile.ts +112 -18
- package/app/plan.ts +12 -0
- package/app/retro.test.ts +5 -0
- package/app/retro.ts +4 -0
- package/app/service.ts +27 -8
- package/e2e/agent-sla-boundary.e2e.ts +598 -0
- package/e2e/feature-run.e2e.ts +8 -1
- package/e2e/plan-fanout.e2e.ts +75 -2
- package/e2e/support/pin-agent-sla.ts +47 -0
- package/package.json +1 -1
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +122 -47
- package/resources/processes/merge-cell.bpmn +60 -12
- package/resources/processes/plan-fanout.bpmn +237 -211
- package/resources/processes/retro.bpmn +100 -3
- package/resources/prompts/feature.md +16 -1
- package/test/derivation-parity/derivation-parity.test.ts +35 -11
- package/test/derivation-parity/flows.ts +14 -1
- package/workers/conformance-record/worker.test.ts +87 -1
- package/workers/conformance-record/worker.ts +34 -7
- package/workers/reconcile-implement/worker.test.ts +31 -3
- package/workers/reconcile-implement/worker.ts +5 -0
- package/workers/record-feature-escalation/worker.test.ts +58 -3
- package/workers/record-feature-escalation/worker.ts +24 -10
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
<nano:extend name="deviationsRaised" type="integer" optional="true" />
|
|
24
24
|
<nano:extend name="deviationsUnraised" type="integer" optional="true" />
|
|
25
25
|
<nano:extend name="hasDeviations" type="boolean" optional="true" />
|
|
26
|
+
<nano:extend name="agentSlaElapsed" type="boolean" optional="true" />
|
|
27
|
+
<nano:extend name="preserveConformance" type="boolean" optional="true" />
|
|
26
28
|
<nano:extend name="summary" type="string" optional="true" />
|
|
27
29
|
</nano:shape>
|
|
28
30
|
</nano:shapes>
|
|
@@ -54,16 +56,56 @@
|
|
|
54
56
|
<bpmn:incoming>f_toConformance</bpmn:incoming>
|
|
55
57
|
<bpmn:outgoing>f_toRecordConformance</bpmn:outgoing>
|
|
56
58
|
</bpmn:serviceTask>
|
|
59
|
+
<bpmn:boundaryEvent id="be_conformance_sla" name="Agent SLA elapsed" attachedToRef="conformance">
|
|
60
|
+
<bpmn:outgoing>f_conformanceSla</bpmn:outgoing>
|
|
61
|
+
<bpmn:timerEventDefinition id="ted_conformance_sla">
|
|
62
|
+
<bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=agentSlaTimeout</bpmn:timeDuration>
|
|
63
|
+
</bpmn:timerEventDefinition>
|
|
64
|
+
</bpmn:boundaryEvent>
|
|
57
65
|
<bpmn:serviceTask id="record-conformance" name="Record conformance">
|
|
58
66
|
<bpmn:extensionElements>
|
|
59
67
|
<zeebe:taskDefinition type="pr.conformance-record" />
|
|
60
68
|
<zeebe:properties>
|
|
61
69
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="ConformanceRecordIn" />
|
|
62
70
|
</zeebe:properties>
|
|
71
|
+
<zeebe:ioMapping>
|
|
72
|
+
<zeebe:input source="=false" target="agentSlaElapsed" />
|
|
73
|
+
<zeebe:input source="=false" target="preserveConformance" />
|
|
74
|
+
</zeebe:ioMapping>
|
|
63
75
|
</bpmn:extensionElements>
|
|
64
76
|
<bpmn:incoming>f_toRecordConformance</bpmn:incoming>
|
|
65
77
|
<bpmn:outgoing>f_toDeviationsGw</bpmn:outgoing>
|
|
66
78
|
</bpmn:serviceTask>
|
|
79
|
+
<bpmn:serviceTask id="record-conformance-sla" name="Record conformance (agent SLA)">
|
|
80
|
+
<bpmn:extensionElements>
|
|
81
|
+
<zeebe:taskDefinition type="pr.conformance-record" />
|
|
82
|
+
<zeebe:properties>
|
|
83
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="ConformanceRecordIn" />
|
|
84
|
+
</zeebe:properties>
|
|
85
|
+
<zeebe:ioMapping>
|
|
86
|
+
<zeebe:input source="=true" target="agentSlaElapsed" />
|
|
87
|
+
<zeebe:input source="=false" target="preserveConformance" />
|
|
88
|
+
<zeebe:input source="="The spec-conformance agent exceeded its time budget (SLA " + agentSlaTimeout + ") without returning a verdict — it is hung or looping. Acknowledge to record the timeout and continue to the retrospective synthesis."" target="summary" />
|
|
89
|
+
</zeebe:ioMapping>
|
|
90
|
+
</bpmn:extensionElements>
|
|
91
|
+
<bpmn:incoming>f_conformanceSla</bpmn:incoming>
|
|
92
|
+
<bpmn:outgoing>f_conformanceSlaToEsc</bpmn:outgoing>
|
|
93
|
+
</bpmn:serviceTask>
|
|
94
|
+
<bpmn:serviceTask id="record-synthesize-sla" name="Record synthesize (agent SLA)">
|
|
95
|
+
<bpmn:extensionElements>
|
|
96
|
+
<zeebe:taskDefinition type="pr.conformance-record" />
|
|
97
|
+
<zeebe:properties>
|
|
98
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="ConformanceRecordIn" />
|
|
99
|
+
</zeebe:properties>
|
|
100
|
+
<zeebe:ioMapping>
|
|
101
|
+
<zeebe:input source="=true" target="agentSlaElapsed" />
|
|
102
|
+
<zeebe:input source="=true" target="preserveConformance" />
|
|
103
|
+
<zeebe:input source="="The retrospective-synthesis agent exceeded its time budget (SLA " + agentSlaTimeout + ") without completing — it is hung or looping. Acknowledge to record the timeout and continue."" target="summary" />
|
|
104
|
+
</zeebe:ioMapping>
|
|
105
|
+
</bpmn:extensionElements>
|
|
106
|
+
<bpmn:incoming>f_synthesizeSla</bpmn:incoming>
|
|
107
|
+
<bpmn:outgoing>f_synthesizeSlaToEsc</bpmn:outgoing>
|
|
108
|
+
</bpmn:serviceTask>
|
|
67
109
|
<bpmn:exclusiveGateway id="gw-deviations" name="deviations?" default="f_noDeviations">
|
|
68
110
|
<bpmn:incoming>f_toDeviationsGw</bpmn:incoming>
|
|
69
111
|
<bpmn:outgoing>f_deviations</bpmn:outgoing>
|
|
@@ -104,6 +146,12 @@
|
|
|
104
146
|
<bpmn:incoming>f_ackToSynthesize</bpmn:incoming>
|
|
105
147
|
<bpmn:outgoing>f_toRecord</bpmn:outgoing>
|
|
106
148
|
</bpmn:serviceTask>
|
|
149
|
+
<bpmn:boundaryEvent id="be_synthesize_sla" name="Agent SLA elapsed" attachedToRef="synthesize">
|
|
150
|
+
<bpmn:outgoing>f_synthesizeSla</bpmn:outgoing>
|
|
151
|
+
<bpmn:timerEventDefinition id="ted_synthesize_sla">
|
|
152
|
+
<bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=agentSlaTimeout</bpmn:timeDuration>
|
|
153
|
+
</bpmn:timerEventDefinition>
|
|
154
|
+
</bpmn:boundaryEvent>
|
|
107
155
|
<bpmn:serviceTask id="record" name="Record retro">
|
|
108
156
|
<bpmn:extensionElements>
|
|
109
157
|
<zeebe:taskDefinition type="pr.retro-record" />
|
|
@@ -119,6 +167,10 @@
|
|
|
119
167
|
</bpmn:endEvent>
|
|
120
168
|
<bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="gather" />
|
|
121
169
|
<bpmn:sequenceFlow id="f_toConformance" sourceRef="gather" targetRef="conformance" />
|
|
170
|
+
<bpmn:sequenceFlow id="f_conformanceSla" name="conformance agent SLA elapsed" sourceRef="be_conformance_sla" targetRef="record-conformance-sla" />
|
|
171
|
+
<bpmn:sequenceFlow id="f_synthesizeSla" name="synthesize agent SLA elapsed" sourceRef="be_synthesize_sla" targetRef="record-synthesize-sla" />
|
|
172
|
+
<bpmn:sequenceFlow id="f_conformanceSlaToEsc" sourceRef="record-conformance-sla" targetRef="conformance-escalation" />
|
|
173
|
+
<bpmn:sequenceFlow id="f_synthesizeSlaToEsc" sourceRef="record-synthesize-sla" targetRef="conformance-escalation" />
|
|
122
174
|
<bpmn:sequenceFlow id="f_toRecordConformance" sourceRef="conformance" targetRef="record-conformance" />
|
|
123
175
|
<bpmn:sequenceFlow id="f_toDeviationsGw" sourceRef="record-conformance" targetRef="gw-deviations" />
|
|
124
176
|
<bpmn:sequenceFlow id="f_deviations" name="deviations" sourceRef="gw-deviations" targetRef="conformance-escalation">
|
|
@@ -147,6 +199,12 @@
|
|
|
147
199
|
<bpmndi:BPMNShape id="BPMNShape_record-conformance" bpmnElement="record-conformance">
|
|
148
200
|
<dc:Bounds x="616" y="80" width="100" height="80" />
|
|
149
201
|
</bpmndi:BPMNShape>
|
|
202
|
+
<bpmndi:BPMNShape id="BPMNShape_record-conformance-sla" bpmnElement="record-conformance-sla">
|
|
203
|
+
<dc:Bounds x="616" y="400" width="100" height="80" />
|
|
204
|
+
</bpmndi:BPMNShape>
|
|
205
|
+
<bpmndi:BPMNShape id="BPMNShape_record-synthesize-sla" bpmnElement="record-synthesize-sla">
|
|
206
|
+
<dc:Bounds x="1566" y="400" width="100" height="80" />
|
|
207
|
+
</bpmndi:BPMNShape>
|
|
150
208
|
<bpmndi:BPMNShape id="BPMNShape_gw-deviations" bpmnElement="gw-deviations" isMarkerVisible="true">
|
|
151
209
|
<dc:Bounds x="816" y="95" width="50" height="50" />
|
|
152
210
|
<bpmndi:BPMNLabel>
|
|
@@ -171,6 +229,18 @@
|
|
|
171
229
|
<dc:Bounds x="1744" y="143" width="80" height="14" />
|
|
172
230
|
</bpmndi:BPMNLabel>
|
|
173
231
|
</bpmndi:BPMNShape>
|
|
232
|
+
<bpmndi:BPMNShape id="BPMNShape_be_conformance_sla" bpmnElement="be_conformance_sla">
|
|
233
|
+
<dc:Bounds x="448" y="142" width="36" height="36" />
|
|
234
|
+
<bpmndi:BPMNLabel>
|
|
235
|
+
<dc:Bounds x="370" y="183" width="72" height="28" />
|
|
236
|
+
</bpmndi:BPMNLabel>
|
|
237
|
+
</bpmndi:BPMNShape>
|
|
238
|
+
<bpmndi:BPMNShape id="BPMNShape_be_synthesize_sla" bpmnElement="be_synthesize_sla">
|
|
239
|
+
<dc:Bounds x="1398" y="142" width="36" height="36" />
|
|
240
|
+
<bpmndi:BPMNLabel>
|
|
241
|
+
<dc:Bounds x="1320" y="183" width="72" height="28" />
|
|
242
|
+
</bpmndi:BPMNLabel>
|
|
243
|
+
</bpmndi:BPMNShape>
|
|
174
244
|
<bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
|
|
175
245
|
<di:waypoint x="116" y="120" />
|
|
176
246
|
<di:waypoint x="216" y="120" />
|
|
@@ -207,14 +277,41 @@
|
|
|
207
277
|
<dc:Bounds x="846" y="206" width="74" height="14" />
|
|
208
278
|
</bpmndi:BPMNLabel>
|
|
209
279
|
</bpmndi:BPMNEdge>
|
|
280
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_conformanceSlaToEsc" bpmnElement="f_conformanceSlaToEsc">
|
|
281
|
+
<di:waypoint x="716" y="440" />
|
|
282
|
+
<di:waypoint x="1016" y="440" />
|
|
283
|
+
<di:waypoint x="1016" y="320" />
|
|
284
|
+
</bpmndi:BPMNEdge>
|
|
210
285
|
<bpmndi:BPMNEdge id="BPMNEdge_f_toConformanceAck" bpmnElement="f_toConformanceAck">
|
|
211
286
|
<di:waypoint x="1066" y="280" />
|
|
212
287
|
<di:waypoint x="1166" y="280" />
|
|
213
288
|
</bpmndi:BPMNEdge>
|
|
214
289
|
<bpmndi:BPMNEdge id="BPMNEdge_f_ackToSynthesize" bpmnElement="f_ackToSynthesize">
|
|
215
|
-
<di:waypoint x="
|
|
216
|
-
<di:waypoint x="
|
|
217
|
-
<di:waypoint x="
|
|
290
|
+
<di:waypoint x="1216" y="240" />
|
|
291
|
+
<di:waypoint x="1216" y="120" />
|
|
292
|
+
<di:waypoint x="1366" y="120" />
|
|
293
|
+
</bpmndi:BPMNEdge>
|
|
294
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_conformanceSla" bpmnElement="f_conformanceSla">
|
|
295
|
+
<di:waypoint x="466" y="178" />
|
|
296
|
+
<di:waypoint x="466" y="440" />
|
|
297
|
+
<di:waypoint x="616" y="440" />
|
|
298
|
+
<bpmndi:BPMNLabel>
|
|
299
|
+
<dc:Bounds x="471" y="288" width="84" height="42" />
|
|
300
|
+
</bpmndi:BPMNLabel>
|
|
301
|
+
</bpmndi:BPMNEdge>
|
|
302
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_synthesizeSla" bpmnElement="f_synthesizeSla">
|
|
303
|
+
<di:waypoint x="1416" y="178" />
|
|
304
|
+
<di:waypoint x="1416" y="440" />
|
|
305
|
+
<di:waypoint x="1566" y="440" />
|
|
306
|
+
<bpmndi:BPMNLabel>
|
|
307
|
+
<dc:Bounds x="1421" y="288" width="74" height="42" />
|
|
308
|
+
</bpmndi:BPMNLabel>
|
|
309
|
+
</bpmndi:BPMNEdge>
|
|
310
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_synthesizeSlaToEsc" bpmnElement="f_synthesizeSlaToEsc">
|
|
311
|
+
<di:waypoint x="1616" y="480" />
|
|
312
|
+
<di:waypoint x="1616" y="500" />
|
|
313
|
+
<di:waypoint x="1016" y="500" />
|
|
314
|
+
<di:waypoint x="1016" y="320" />
|
|
218
315
|
</bpmndi:BPMNEdge>
|
|
219
316
|
</bpmndi:BPMNPlane>
|
|
220
317
|
</bpmndi:BPMNDiagram>
|
|
@@ -18,6 +18,19 @@ The job payload (stdin JSON) carries:
|
|
|
18
18
|
|
|
19
19
|
You have `gh` / git authenticated for the target repository.
|
|
20
20
|
|
|
21
|
+
## Workspace confinement — work ONLY inside your run workspace
|
|
22
|
+
|
|
23
|
+
Your run has a private workspace directory (the git repository you start in — the
|
|
24
|
+
clone/worktree the harness checked out for you). **Do all your work there.** Never
|
|
25
|
+
`cd /tmp/<repo>`, clone into a shared path, or edit files outside your workspace:
|
|
26
|
+
that directory is torn down when your run ends, so edits made elsewhere are
|
|
27
|
+
**silently lost** (and can collide with a sibling run sharing the path). If you
|
|
28
|
+
need the repo, use the one you are already in — do not clone a second copy into
|
|
29
|
+
`/tmp`. A completion that leaves no commit on `feat/<task.id>` and no PR is treated
|
|
30
|
+
as a **failed, undelivered attempt** (see the reconcile/escalation behaviour the
|
|
31
|
+
app applies), so your work must land in a pushed commit on your branch, nowhere
|
|
32
|
+
else.
|
|
33
|
+
|
|
21
34
|
## Your branch (deterministic — the same across a resume)
|
|
22
35
|
|
|
23
36
|
Always use the branch **`feat/<task.id>`**. Because a resumed run gets a fresh
|
|
@@ -192,7 +205,9 @@ Write a JSON object of **result variables** to the file named by the
|
|
|
192
205
|
|
|
193
206
|
Rules:
|
|
194
207
|
|
|
195
|
-
- `status` — one of
|
|
208
|
+
- `status` — one of EXACTLY these four tokens (any other value — e.g.
|
|
209
|
+
`completed`, `done`, `success` — is **off-vocabulary**: the app cannot route on
|
|
210
|
+
it and will treat your slice as **not delivered**, so never invent one):
|
|
196
211
|
- `opened` — a PR was created (ready for review). Set `pr`.
|
|
197
212
|
- `escalated` — you need a human decision; set `question`, and set `pr` to the
|
|
198
213
|
**draft** PR you opened to preserve your work (if you managed to open one).
|
|
@@ -173,33 +173,57 @@ test("loop() inserts a gateway head, so back-edges cannot merge into a task", ()
|
|
|
173
173
|
// structured topology (clears class 2), but issue #745 added the engine-native
|
|
174
174
|
// AgentTask marker `<zeebe:agentDefinition agentType="external" />` to its two
|
|
175
175
|
// prompt-bearing `senior:*` tasks and the published compiler cannot emit it.
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
|
|
176
|
+
// Issue #849 then added an interrupting `agentSlaTimeout` timer boundary to each
|
|
177
|
+
// of those tasks (routing to the `conformance-escalation` user task) — the
|
|
178
|
+
// code-first builder cannot express a boundary event on a task either (its
|
|
179
|
+
// `FlowNode` union has no such node; AGENTS.md). Prove the blockers are real AND
|
|
180
|
+
// that they are the ONLY divergence, so the parked entry is a complete, verified
|
|
181
|
+
// port held ready — not an abandoned one.
|
|
182
|
+
test("retro's complete port differs from its golden by ONLY the agent-task marker + SLA boundaries", () => {
|
|
179
183
|
const golden = readFileSync(goldenPath("retro"), "utf8");
|
|
180
184
|
const derived = declarativeToBpmn(retroFlow);
|
|
181
185
|
const markers = (xml: string): number =>
|
|
182
186
|
(xml.match(/<(?:\w+:)?agentDefinition\b[^>]*\bagentType="external"/g) ?? []).length;
|
|
187
|
+
const boundaries = (xml: string): number =>
|
|
188
|
+
(xml.match(/<(?:\w+:)?boundaryEvent\b/g) ?? []).length;
|
|
183
189
|
|
|
184
190
|
// (a) the golden really carries the marker, on BOTH of its agent tasks — and it
|
|
185
191
|
// cannot simply be dropped: app/agentic/vocab/agent-marker.test.ts is a
|
|
186
192
|
// defect-class guard requiring it on every deployed prompt-bearing agent task.
|
|
187
193
|
assertEquals(markers(golden), 2, "retro's golden should mark senior:conformance and senior:retro");
|
|
194
|
+
// …and each marked task is SLA-bounded (issue #849; the defect-class guard is
|
|
195
|
+
// app/agentic/vocab/agent-sla-boundary.test.ts).
|
|
196
|
+
assertEquals(boundaries(golden), 2, "retro's golden should SLA-bound both agent tasks");
|
|
188
197
|
|
|
189
|
-
// (b) the published compiler emits none of it — the
|
|
190
|
-
// When
|
|
191
|
-
//
|
|
198
|
+
// (b) the published compiler emits none of it — the blockers are real, not a guess.
|
|
199
|
+
// When the marker assertion starts failing, upstream task() grew marker support;
|
|
200
|
+
// when the boundary assertion starts failing, the builder grew boundary events:
|
|
201
|
+
// un-park retro by threading `retroFlow` back into its PORTS entry in ./flows.ts.
|
|
192
202
|
assertEquals(markers(derived), 0, "@nanobpm/workflow can now emit <zeebe:agentDefinition/> — un-park retro");
|
|
203
|
+
assertEquals(boundaries(derived), 0, "@nanobpm/workflow can now emit boundary events — un-park retro");
|
|
193
204
|
|
|
194
|
-
// (c) strip exactly the marker lines
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
205
|
+
// (c) strip exactly the marker lines AND the SLA boundary blocks (plus their
|
|
206
|
+
// sequence flows, the two SLA recorder service tasks they feed, and the
|
|
207
|
+
// extra incomings on the escalation user task) from the golden and the port
|
|
208
|
+
// derives the WHOLE model green, through the shared harness's own
|
|
209
|
+
// normalize/equality — so nothing but the marker + SLA-escalation apparatus
|
|
210
|
+
// diverges.
|
|
211
|
+
const unmarked = golden
|
|
212
|
+
.replace(/^[ \t]*<(?:\w+:)?agentDefinition\b[^>]*\/>[ \t]*\r?\n/gm, "")
|
|
213
|
+
.replace(/^[ \t]*<(?:\w+:)?boundaryEvent\b[\s\S]*?<\/(?:\w+:)?boundaryEvent>\r?\n/gm, "")
|
|
214
|
+
// the SLA recorder service tasks (record-conformance-sla / record-synthesize-sla) and the
|
|
215
|
+
// `<!-- Agent-SLA recorders: … -->` comment block immediately preceding them
|
|
216
|
+
.replace(/^[ \t]*<!--[\s\S]*?-->[ \t]*\r?\n(?=[ \t]*<(?:\w+:)?serviceTask\b[^>]*\bid="record-(?:conformance|synthesize)-sla")/gm, "")
|
|
217
|
+
.replace(/^[ \t]*<(?:\w+:)?serviceTask\b[^>]*\bid="record-(?:conformance|synthesize)-sla"[\s\S]*?<\/(?:\w+:)?serviceTask>\r?\n/gm, "")
|
|
218
|
+
.replace(/^[ \t]*<(?:\w+:)?sequenceFlow\b[^>]*\bsourceRef="be_[^"]*"[^>]*\/>[ \t]*\r?\n/gm, "")
|
|
219
|
+
.replace(/^[ \t]*<(?:\w+:)?sequenceFlow\b[^>]*\bsourceRef="record-(?:conformance|synthesize)-sla"[^>]*\/>[ \t]*\r?\n/gm, "")
|
|
220
|
+
.replace(/^[ \t]*<(?:\w+:)?incoming>f_(?:conformanceSla|synthesizeSla)(?:ToEsc)?<\/(?:\w+:)?incoming>[ \t]*\r?\n/gm, "");
|
|
198
221
|
assertEquals(markers(unmarked), 0, "the strip must remove every marker line");
|
|
222
|
+
assertEquals(boundaries(unmarked), 0, "the strip must remove every SLA boundary");
|
|
199
223
|
const expected = normalize(unmarked);
|
|
200
224
|
const actual = normalize(derived);
|
|
201
225
|
assert(
|
|
202
226
|
modelsEqual(expected, actual),
|
|
203
|
-
`retro's port must derive its golden once the agent-task marker
|
|
227
|
+
`retro's port must derive its golden once the agent-task marker and SLA boundaries are stripped — residual drift:\n${diffModels(expected, actual)}`,
|
|
204
228
|
);
|
|
205
229
|
});
|
|
@@ -133,7 +133,20 @@ export const retroFlow: DeclarativeFlow = defineFlow(
|
|
|
133
133
|
jobType: "senior:conformance",
|
|
134
134
|
prompt: { resourceId: "prompts/conformance.md", bindingType: "latest", append: "=conformanceDigest" },
|
|
135
135
|
});
|
|
136
|
-
w.task("record-conformance", {
|
|
136
|
+
w.task("record-conformance", {
|
|
137
|
+
jobType: "pr.conformance-record",
|
|
138
|
+
// The golden pins both SLA-mode control flags to `=false` on this NORMAL-path recorder
|
|
139
|
+
// (round-7 review, PR #864): the flags share the external conformance agent's result
|
|
140
|
+
// namespace, so a hoisted agent output must never select the SLA/preserve branch. The port
|
|
141
|
+
// reproduces those pins so the parity diagnostic's only residual is the class-3 marker +
|
|
142
|
+
// SLA boundaries.
|
|
143
|
+
io: {
|
|
144
|
+
input: [
|
|
145
|
+
{ source: "=false", target: "agentSlaElapsed" },
|
|
146
|
+
{ source: "=false", target: "preserveConformance" },
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
});
|
|
137
150
|
w.branch("hasDeviations = true", {
|
|
138
151
|
then: (b) => {
|
|
139
152
|
b.human("conformance-escalation", {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
1
3
|
import { test } from "node:test";
|
|
2
|
-
import {
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { assert, assertEquals, assertRejects } from "#test-assert";
|
|
3
6
|
import { noopLog } from "../../test/log.ts";
|
|
4
7
|
import handler from "./worker.ts";
|
|
5
8
|
|
|
@@ -239,3 +242,86 @@ test("conformance-record: fails (not a silent, untrackable escalation) when ther
|
|
|
239
242
|
// Nothing was persisted: the throw precedes the write, so no untrackable row is left behind.
|
|
240
243
|
assertEquals(stores.plan_conformance.length, 0);
|
|
241
244
|
});
|
|
245
|
+
|
|
246
|
+
test("conformance-record: an agent-SLA timeout persists a reviewing row (so the ack finds it) with no deviations", async () => {
|
|
247
|
+
const { app, stores } = fakeApp();
|
|
248
|
+
// The retro SLA arm routes here with agentSlaElapsed=true + an SLA summary and NO agent verdict
|
|
249
|
+
// (the conformance/synthesize agent timed out). The shared conformance-escalation ack task always
|
|
250
|
+
// runs pr.conformance-ack → acknowledgeConformance, which THROWS when no plan_conformance row
|
|
251
|
+
// exists — so the SLA path must persist a `reviewing` row even though nothing was scored.
|
|
252
|
+
const out = await handler(
|
|
253
|
+
{
|
|
254
|
+
processInstanceKey: "retro-inst-13",
|
|
255
|
+
variables: {
|
|
256
|
+
planKey: "o/r#13",
|
|
257
|
+
agentSlaElapsed: true,
|
|
258
|
+
summary: "The spec-conformance agent exceeded its time budget (SLA PT2H) without returning a verdict.",
|
|
259
|
+
},
|
|
260
|
+
} as any,
|
|
261
|
+
app as any,
|
|
262
|
+
);
|
|
263
|
+
const row = stores.plan_conformance[0];
|
|
264
|
+
// A timeout produces no verdict: status coerces to skipped, all counts zeroed, has_deviations 0 …
|
|
265
|
+
assertEquals(row.status, "skipped");
|
|
266
|
+
assertEquals(row.has_deviations, 0);
|
|
267
|
+
// … but the row IS parked at `reviewing` (keyed off the instance) so acknowledgeConformance settles
|
|
268
|
+
// it instead of throwing, and the Tasks inbox shows the SLA-timeout summary as the reason.
|
|
269
|
+
assertEquals(row.review_status, "reviewing");
|
|
270
|
+
assertEquals(row.process_key, "retro-inst-13");
|
|
271
|
+
assertEquals(row.summary, "The spec-conformance agent exceeded its time budget (SLA PT2H) without returning a verdict.");
|
|
272
|
+
// The SLA arm routes to the escalation task directly (not via gw-deviations), so the returned flag
|
|
273
|
+
// stays the ground-truth `hasDeviations` (false) — it is not read on the SLA path.
|
|
274
|
+
assertEquals(out, { hasDeviations: false });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("conformance-record: an agent-SLA timeout with no processKey fails loudly (untrackable escalation)", async () => {
|
|
278
|
+
const { app, stores } = fakeApp();
|
|
279
|
+
await assertRejects(
|
|
280
|
+
() =>
|
|
281
|
+
handler(
|
|
282
|
+
{ variables: { planKey: "o/r#14", agentSlaElapsed: true, summary: "sla" } } as any,
|
|
283
|
+
app as any,
|
|
284
|
+
),
|
|
285
|
+
Error,
|
|
286
|
+
"no processInstanceKey",
|
|
287
|
+
);
|
|
288
|
+
assertEquals(stores.plan_conformance.length, 0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// DEFECT-CLASS GUARD (PR #864 review r4181010853): the SLA-control flags `agentSlaElapsed` and
|
|
292
|
+
// `preserveConformance` share the process-variable namespace the external conformance agent's
|
|
293
|
+
// result-JSON is hoisted into. If a `pr.conformance-record` task does NOT pin both flags to a
|
|
294
|
+
// literal via ioMapping, a completed agent that emits `agentSlaElapsed:true` / `preserveConformance:true`
|
|
295
|
+
// could make the NORMAL recorder persist `review_status='reviewing'` (or take the preserve branch)
|
|
296
|
+
// while `gw-deviations` routes away from the ack task — leaving the row permanently stuck. Every
|
|
297
|
+
// recorder task must therefore pin BOTH flags to a constant so agent output can never select this
|
|
298
|
+
// internal control path. This structural guard fails if any recorder (now or a future one) forgets.
|
|
299
|
+
const RETRO_BPMN = join(
|
|
300
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
301
|
+
"../../resources/processes/retro.bpmn",
|
|
302
|
+
);
|
|
303
|
+
const SERVICE_TASK = /<bpmn:serviceTask\b[\s\S]*?<\/bpmn:serviceTask>/g;
|
|
304
|
+
|
|
305
|
+
test("DEFECT-CLASS GUARD: every pr.conformance-record task pins agentSlaElapsed + preserveConformance to a literal (agent output cannot select SLA control path)", () => {
|
|
306
|
+
const xml = readFileSync(RETRO_BPMN, "utf8");
|
|
307
|
+
const recorders: string[] = [];
|
|
308
|
+
for (const [block] of xml.matchAll(SERVICE_TASK)) {
|
|
309
|
+
if (!/type="pr\.conformance-record"/.test(block)) continue;
|
|
310
|
+
const id = block.match(/<bpmn:serviceTask\b[^>]*\bid="([^"]*)"/)?.[1] ?? "(unknown)";
|
|
311
|
+
recorders.push(id);
|
|
312
|
+
for (const flag of ["agentSlaElapsed", "preserveConformance"]) {
|
|
313
|
+
// A literal pin is `<zeebe:input source="=true|false" target="<flag>" />` — the source must be
|
|
314
|
+
// a boolean constant, NOT a process-variable reference (which would read hoisted agent output).
|
|
315
|
+
const pin = new RegExp(
|
|
316
|
+
`<zeebe:input\\s+source="=(?:true|false)"\\s+target="${flag}"\\s*/>`,
|
|
317
|
+
);
|
|
318
|
+
assert(
|
|
319
|
+
pin.test(block),
|
|
320
|
+
`retro.bpmn: record task "${id}" does not pin "${flag}" to a literal — agent output could ` +
|
|
321
|
+
`select the internal SLA/preserve control path (PR #864 review r4181010853)`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Sanity: there really are recorder tasks (guard is not vacuously green) — normal + 2 SLA arms.
|
|
326
|
+
assert(recorders.length >= 3, `expected >=3 pr.conformance-record tasks, found ${recorders.length}`);
|
|
327
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// reads `status`/`pr`/`summary` through), and exposes the raw transcript under the
|
|
10
10
|
// `io.nanobpm.agentResult` envelope's `.output`.
|
|
11
11
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
12
|
-
import { recordConformance } from "../../app/conformance.ts";
|
|
12
|
+
import { markConformanceSynthesisSla, recordConformance } from "../../app/conformance.ts";
|
|
13
13
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
14
14
|
|
|
15
15
|
const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
|
|
@@ -81,6 +81,22 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
81
81
|
// the same `String(...)` coercion app/service.ts applies when it stamps `process_key`.
|
|
82
82
|
const processKey = job.processInstanceKey != null ? String(job.processInstanceKey) : null;
|
|
83
83
|
|
|
84
|
+
// Agent-SLA path (issue #849 review): the conformance/synthesize agent's interrupting timer
|
|
85
|
+
// boundary fired (retro.bpmn routes the SLA arm here with `agentSlaElapsed=true` + an SLA summary).
|
|
86
|
+
// The agent produced no verdict, so there is nothing to score — but the shared conformance-escalation
|
|
87
|
+
// ack task ALWAYS runs `pr.conformance-ack`, whose `acknowledgeConformance` throws when no
|
|
88
|
+
// `plan_conformance` row exists. Persist a `reviewing` row so the ack settles it and the Tasks inbox
|
|
89
|
+
// shows the SLA-timeout reason. This escalates exactly like a deviations finding.
|
|
90
|
+
const agentSlaElapsed = asBool(job.variables.agentSlaElapsed);
|
|
91
|
+
const escalate = hasDeviations || agentSlaElapsed;
|
|
92
|
+
|
|
93
|
+
// Synthesis-SLA preserve path (PR #864 review): the `record-synthesize-sla` arm runs AFTER
|
|
94
|
+
// `record-conformance` already persisted a real verdict, so it sets `preserveConformance=true` to
|
|
95
|
+
// flip the row to `reviewing` + append the synthesis-timeout context WITHOUT overwriting the filed
|
|
96
|
+
// status/counts/report/summary. Only the synthesize arm sets this; the conformance-SLA arm
|
|
97
|
+
// (`record-conformance-sla`) interrupts BEFORE any verdict exists, so it takes the upsert below.
|
|
98
|
+
const preserveConformance = asBool(job.variables.preserveConformance);
|
|
99
|
+
|
|
84
100
|
// Invariant: an escalation must be trackable. If we found deviations to escalate but have no
|
|
85
101
|
// process key to key the `reviewing` row off, the `hasDeviations` return below would still route
|
|
86
102
|
// retro to the `conformance-escalation` user task — yet `pollUserTasks` can never surface that ack
|
|
@@ -88,13 +104,23 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
88
104
|
// escalation wedges forever, invisible to any human. Rather than record that silent, unreachable
|
|
89
105
|
// state, fail loudly so the run retries/alerts. `job.processInstanceKey` is always present for an
|
|
90
106
|
// activated job, so this only fires on a genuine engine-contract violation.
|
|
91
|
-
if (
|
|
107
|
+
if (escalate && processKey == null) {
|
|
92
108
|
throw new Error(
|
|
93
|
-
`conformance-record: ${planKey} has
|
|
109
|
+
`conformance-record: ${planKey} has an escalation to record but no processInstanceKey to track ` +
|
|
94
110
|
"the escalation — refusing to route to an untrackable conformance-escalation ack task",
|
|
95
111
|
);
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
if (preserveConformance) {
|
|
115
|
+
// Preserve the existing conformance verdict; only mark it reviewing + append the SLA reason.
|
|
116
|
+
await markConformanceSynthesisSla(app.data, planKey, {
|
|
117
|
+
summary: summary ?? "The retrospective-synthesis agent exceeded its time budget without completing.",
|
|
118
|
+
processKey,
|
|
119
|
+
});
|
|
120
|
+
app.log.info(`conformance-record: ${planKey} — synthesis SLA, preserved existing verdict (reviewing)`);
|
|
121
|
+
return { hasDeviations };
|
|
122
|
+
}
|
|
123
|
+
|
|
98
124
|
await recordConformance(app.data, planKey, {
|
|
99
125
|
status,
|
|
100
126
|
commentUrl: filed ? commentUrl : null,
|
|
@@ -110,17 +136,18 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
110
136
|
// Only enter the `reviewing` inbox scan when we actually have a `processKey` to key off — a
|
|
111
137
|
// null key can never be found by `pollUserTasks` (it skips rows without `process_key`) nor
|
|
112
138
|
// cleared by the `instanceTracking` `onTerminated` binding, so a `reviewing` row with no key
|
|
113
|
-
// would wedge forever. The invariant guard above already rejected
|
|
139
|
+
// would wedge forever. The invariant guard above already rejected any escalation with a null
|
|
114
140
|
// key, so `reviewing` here always carries a non-null `processKey`.
|
|
115
|
-
reviewStatus:
|
|
141
|
+
reviewStatus: escalate ? "reviewing" : "reviewed",
|
|
116
142
|
});
|
|
117
143
|
|
|
118
144
|
app.log.info(
|
|
119
|
-
`conformance-record: ${planKey} — status=${status} deviations=${hasDeviations ? "yes" : "no"}`,
|
|
145
|
+
`conformance-record: ${planKey} — status=${status} deviations=${hasDeviations ? "yes" : "no"} agentSla=${agentSlaElapsed ? "yes" : "no"}`,
|
|
120
146
|
);
|
|
121
147
|
// Return the ground-truth `hasDeviations` as a process variable so the `gw-deviations` gateway
|
|
122
148
|
// routes to the human ack task (retro.bpmn) — overriding the agent's hoisted flag with the value
|
|
123
|
-
// reconciled against the recorded counts above.
|
|
149
|
+
// reconciled against the recorded counts above. (The SLA arm routes to the escalation task
|
|
150
|
+
// directly, not through gw-deviations, so this flag is only read on the normal path.)
|
|
124
151
|
return { hasDeviations };
|
|
125
152
|
};
|
|
126
153
|
|
|
@@ -28,7 +28,7 @@ test("adopts an open PR on the branch: emits reconciled + status=opened + pr", a
|
|
|
28
28
|
{ jobKey: "j1", variables: { subjectKey: "owner/repo#801", task: { id: "issue-801" }, status: null } } as never,
|
|
29
29
|
fakeApp,
|
|
30
30
|
);
|
|
31
|
-
assertEquals(out, { reconciled: true, status: "opened", pr: "owner/repo#801" });
|
|
31
|
+
assertEquals(out, { reconciled: true, status: "opened", pr: "owner/repo#801", retry: false, retryNudge: null, retried: false, deliveryVerified: true });
|
|
32
32
|
} finally {
|
|
33
33
|
globalThis.fetch = prevFetch;
|
|
34
34
|
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
@@ -54,7 +54,7 @@ test("wires baseBranch through: a wrong-base open PR is NOT adopted → escalate
|
|
|
54
54
|
{ jobKey: "j3", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null, baseBranch: "epic/feat-x" } } as never,
|
|
55
55
|
fakeApp,
|
|
56
56
|
);
|
|
57
|
-
assertEquals(out, { reconciled: false, status: null, pr: null });
|
|
57
|
+
assertEquals(out, { reconciled: false, status: null, pr: null, retry: false, retryNudge: null, retried: false, deliveryVerified: true });
|
|
58
58
|
} finally {
|
|
59
59
|
globalThis.fetch = prevFetch;
|
|
60
60
|
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
@@ -76,7 +76,35 @@ test("no open PR on the branch: falls through to escalate (reconciled=false)", a
|
|
|
76
76
|
{ jobKey: "j2", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null } } as never,
|
|
77
77
|
fakeApp,
|
|
78
78
|
);
|
|
79
|
-
assertEquals(out, { reconciled: false, status: null, pr: null });
|
|
79
|
+
assertEquals(out, { reconciled: false, status: null, pr: null, retry: false, retryNudge: null, retried: false, deliveryVerified: true });
|
|
80
|
+
} finally {
|
|
81
|
+
globalThis.fetch = prevFetch;
|
|
82
|
+
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
83
|
+
else process.env.GITHUB_TOKEN = prevToken;
|
|
84
|
+
if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
85
|
+
else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("claimed-completion (off-vocabulary status) + no PR → auto-retry once with a nudge (issue #865)", async () => {
|
|
90
|
+
const prevToken = process.env.GITHUB_TOKEN;
|
|
91
|
+
const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
|
|
92
|
+
const prevFetch = globalThis.fetch;
|
|
93
|
+
process.env.GITHUB_TOKEN = "t";
|
|
94
|
+
process.env.NANO_PR_GITHUB_TRANSPORT = "token";
|
|
95
|
+
globalThis.fetch = (async () => new Response(JSON.stringify([]), { status: 200 })) as typeof fetch;
|
|
96
|
+
try {
|
|
97
|
+
const out = await handler(
|
|
98
|
+
{
|
|
99
|
+
jobKey: "j4",
|
|
100
|
+
variables: { subjectKey: "owner/repo#41", task: { id: "issue-41" }, status: "completed" },
|
|
101
|
+
} as never,
|
|
102
|
+
fakeApp,
|
|
103
|
+
);
|
|
104
|
+
assertEquals(out.reconciled, false);
|
|
105
|
+
assertEquals(out.retry, true);
|
|
106
|
+
assertEquals(out.retried, true);
|
|
107
|
+
assertEquals(typeof out.retryNudge === "string" && out.retryNudge.length > 0, true);
|
|
80
108
|
} finally {
|
|
81
109
|
globalThis.fetch = prevFetch;
|
|
82
110
|
if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
|
|
@@ -28,6 +28,8 @@ interface In extends Record<string, unknown> {
|
|
|
28
28
|
status?: unknown;
|
|
29
29
|
pr?: unknown;
|
|
30
30
|
baseBranch?: unknown;
|
|
31
|
+
question?: unknown;
|
|
32
|
+
implementRetried?: unknown;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** The cell's deterministic branch is `feat/<task.id>`; `task` is the implement-cell's slice object.
|
|
@@ -45,6 +47,8 @@ const handler: AppJobHandler<In, ReconcileImplementResult> = async (job, app) =>
|
|
|
45
47
|
taskId: taskId(job.variables.task),
|
|
46
48
|
pr: job.variables.pr,
|
|
47
49
|
baseBranch: job.variables.baseBranch,
|
|
50
|
+
question: job.variables.question,
|
|
51
|
+
retried: job.variables.implementRetried,
|
|
48
52
|
},
|
|
49
53
|
listPrsForHead,
|
|
50
54
|
process.env.GITHUB_TOKEN ?? "",
|
|
@@ -52,6 +56,7 @@ const handler: AppJobHandler<In, ReconcileImplementResult> = async (job, app) =>
|
|
|
52
56
|
app.log.info("reconcile-implement", {
|
|
53
57
|
subjectKey: job.variables.subjectKey ?? null,
|
|
54
58
|
reconciled: res.reconciled,
|
|
59
|
+
retry: res.retry,
|
|
55
60
|
pr: res.pr,
|
|
56
61
|
});
|
|
57
62
|
return res;
|
|
@@ -97,14 +97,43 @@ test("no-result: a missing status/question synthesises the #360 answerable quest
|
|
|
97
97
|
assertEquals(app.stores.feature_escalations[0].question, NO_RESULT_QUESTION);
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
test("blank-question escalated:
|
|
100
|
+
test("blank-question escalated: synthesises an ACCURATE reported-status reason, not the false no-result text (#865 review)", async () => {
|
|
101
|
+
// A reported `escalated` with a blank question reaches the builder. It must name the reported status
|
|
102
|
+
// accurately — never the false "no status was reported" (no-result) diagnosis — and never dead-end.
|
|
101
103
|
const app = fakeApp([]);
|
|
102
104
|
const out = await handler(
|
|
103
105
|
{ jobKey: "job-b", variables: { subjectKey: "owner/repo#2", status: "escalated", question: " " } } as never,
|
|
104
106
|
app,
|
|
105
107
|
);
|
|
106
|
-
assertEquals(out
|
|
107
|
-
assertEquals(
|
|
108
|
+
assertEquals(out.question.includes('reported status "escalated"'), true);
|
|
109
|
+
assertEquals(out.question.includes("gave no answerable question"), true);
|
|
110
|
+
assertEquals(out.question.includes("without a machine-readable result"), false);
|
|
111
|
+
assertEquals(app.stores.feature_escalations[0].question, out.question);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("escalated with a question + summary + transcript: preserves ALL context, not just the question (#865 review)", async () => {
|
|
115
|
+
// The #865-review defect: an `escalated` result carrying a question, summary, and transcript persisted
|
|
116
|
+
// ONLY the question, leaving pollUserTasks without the supporting decision context.
|
|
117
|
+
const rows = [{ feature_key: "owner/repo#9", status: "running" }];
|
|
118
|
+
const app = fakeApp(rows);
|
|
119
|
+
const out = await handler(
|
|
120
|
+
{
|
|
121
|
+
jobKey: "job-9",
|
|
122
|
+
variables: {
|
|
123
|
+
subjectKey: "owner/repo#9",
|
|
124
|
+
status: "escalated",
|
|
125
|
+
question: "Should I target the epic base or main?",
|
|
126
|
+
summary: "drafted the migration but the base is ambiguous",
|
|
127
|
+
transcriptUrl: "http://merlin.local:3000/t?stream=job%3A777",
|
|
128
|
+
},
|
|
129
|
+
} as never,
|
|
130
|
+
app,
|
|
131
|
+
);
|
|
132
|
+
assertEquals(out.question.startsWith("Should I target the epic base or main?"), true);
|
|
133
|
+
assertEquals(out.question.includes('The agent\'s own summary: "drafted the migration but the base is ambiguous".'), true);
|
|
134
|
+
assertEquals(out.question.includes("Transcript: http://merlin.local:3000/t?stream=job%3A777"), true);
|
|
135
|
+
assertEquals(rows[0].status, "escalated");
|
|
136
|
+
assertEquals(app.stores.feature_escalations[0].question, out.question);
|
|
108
137
|
});
|
|
109
138
|
|
|
110
139
|
test("fails fast (incident) when subjectKey is absent — never appends a corrupt undefined-keyed audit row", async () => {
|
|
@@ -132,3 +161,29 @@ test("a retried job (same jobKey) reuses its audit row, never duplicating", asyn
|
|
|
132
161
|
assertEquals(app.stores.feature_escalations.length, 1, "the retry reuses the row, no duplicate append");
|
|
133
162
|
assertEquals(app.stores.feature_escalations[0].job_key, "job-retry");
|
|
134
163
|
});
|
|
164
|
+
|
|
165
|
+
test("claimed-completion (off-vocabulary status) with no question: accurate reason, not the false no-result text (issue #865)", async () => {
|
|
166
|
+
// The #865 defect reproduction: the agent reported `status: "completed"` with a summary but no PR.
|
|
167
|
+
// The synthesised reason must name the reported status + fold in the summary and transcript link —
|
|
168
|
+
// never the false "no status was reported" diagnosis.
|
|
169
|
+
const rows = [{ feature_key: "owner/repo#41", status: "running" }];
|
|
170
|
+
const app = fakeApp(rows);
|
|
171
|
+
const out = await handler(
|
|
172
|
+
{
|
|
173
|
+
jobKey: "job-41",
|
|
174
|
+
variables: {
|
|
175
|
+
subjectKey: "owner/repo#41",
|
|
176
|
+
status: "completed",
|
|
177
|
+
summary: "implemented src/pin.rs and supporting changes",
|
|
178
|
+
transcriptUrl: "http://merlin.local:3000/t?stream=job%3A215572",
|
|
179
|
+
},
|
|
180
|
+
} as never,
|
|
181
|
+
app,
|
|
182
|
+
);
|
|
183
|
+
assertEquals(out.question.includes('reported status "completed"'), true);
|
|
184
|
+
assertEquals(out.question.includes("without a machine-readable result"), false);
|
|
185
|
+
assertEquals(out.question.includes('The agent\'s own summary: "implemented src/pin.rs and supporting changes".'), true);
|
|
186
|
+
assertEquals(out.question.includes("Transcript: http://merlin.local:3000/t?stream=job%3A215572"), true);
|
|
187
|
+
assertEquals(rows[0].status, "escalated");
|
|
188
|
+
assertEquals(app.stores.feature_escalations[0].question, out.question);
|
|
189
|
+
});
|