@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +41 -0
  3. package/app/agentSla.test.ts +48 -0
  4. package/app/agentSla.ts +36 -11
  5. package/app/agentic/vocab/agent-sla-boundary.test.ts +170 -0
  6. package/app/agentic/vocab/job-types.ts +60 -0
  7. package/app/conformance.test.ts +114 -0
  8. package/app/conformance.ts +72 -0
  9. package/app/contracts.ts +2 -2
  10. package/app/deliveryRunner.ts +5 -0
  11. package/app/feature.ts +5 -0
  12. package/app/implementEscalationReason.test.ts +216 -0
  13. package/app/implementEscalationReason.ts +213 -0
  14. package/app/implementReconcile.test.ts +168 -11
  15. package/app/implementReconcile.ts +112 -18
  16. package/app/plan.ts +12 -0
  17. package/app/retro.test.ts +5 -0
  18. package/app/retro.ts +4 -0
  19. package/app/service.ts +27 -8
  20. package/e2e/agent-sla-boundary.e2e.ts +598 -0
  21. package/e2e/feature-run.e2e.ts +8 -1
  22. package/e2e/plan-fanout.e2e.ts +75 -2
  23. package/e2e/support/pin-agent-sla.ts +47 -0
  24. package/package.json +1 -1
  25. package/resources/processes/feature.bpmn +1 -0
  26. package/resources/processes/implement-cell.bpmn +122 -47
  27. package/resources/processes/merge-cell.bpmn +60 -12
  28. package/resources/processes/plan-fanout.bpmn +237 -211
  29. package/resources/processes/retro.bpmn +100 -3
  30. package/resources/prompts/feature.md +16 -1
  31. package/test/derivation-parity/derivation-parity.test.ts +35 -11
  32. package/test/derivation-parity/flows.ts +14 -1
  33. package/workers/conformance-record/worker.test.ts +87 -1
  34. package/workers/conformance-record/worker.ts +34 -7
  35. package/workers/reconcile-implement/worker.test.ts +31 -3
  36. package/workers/reconcile-implement/worker.ts +5 -0
  37. package/workers/record-feature-escalation/worker.test.ts +58 -3
  38. package/workers/record-feature-escalation/worker.ts +24 -10
@@ -0,0 +1,598 @@
1
+ // End-to-end runtime proof for the AGENT-task liveness SLA boundary (issue #849, review round 2).
2
+ // The structural guard (`app/agentic/vocab/agent-sla-boundary.test.ts`) only proves every bounded
3
+ // external agent task CARRIES an interrupting timer boundary — it does not exercise the runtime
4
+ // behaviour. This suite boots the whole app against the WASM engine + virtual clock, parks the
5
+ // implementation agent (a hung/looping agent that never completes its job), advances the engine
6
+ // clock past the seeded `agentSlaTimeout` (PT2H), and asserts the token took the boundary's
7
+ // escalation arm — the durable in-process liveness the slice lands.
8
+ //
9
+ // Scope: the implement-cell's `implement-task`, driven through BOTH of its real parent call
10
+ // activities — a standalone `feature` run and a plan-fanout wave slice — so the parent→child
11
+ // `agentSlaTimeout` scope propagation added this round (the explicit callActivity input mapping) is
12
+ // covered, not just the boundary's existence. A too-short advance (under PT2H) leaves the agent
13
+ // parked and the escalation task absent; the full advance fires `be_implement_sla`, routes through
14
+ // `record-escalation-sla` (which synthesises the SLA question), and parks the shared
15
+ // `human-escalation` cell's `escalation` user task.
16
+ //
17
+ // The boundary path is the falsifiable core: the WASM engine folds a completed instance's variables
18
+ // away, so we assert on the cumulative taken sequence flows. A hung agent with NO SLA boundary would
19
+ // simply park forever — never taking `ic_sla` — so a green here proves the bound is real. Each
20
+ // scenario boots its own app so `takenSequenceFlows` (engine-global, cumulative) reflects exactly one
21
+ // instance's routing.
22
+ //
23
+ // Run with `npm run e2e`.
24
+
25
+ // MUST be the FIRST import: pins `NANO_PR_AGENT_SLA_TIMEOUT` before `app/agentSla.ts` freezes its
26
+ // import-time `AGENT_SLA_TIMEOUT` const, so the suite is isolated from ambient config (see module).
27
+ import { PAST_AGENT_SLA_MS, UNDER_AGENT_SLA_MS } from "./support/pin-agent-sla.ts";
28
+ import assert from "node:assert/strict";
29
+ import { mkdtempSync, rmSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { dirname, join, resolve } from "node:path";
32
+ import { after, before, describe, test } from "node:test";
33
+ import { fileURLToPath } from "node:url";
34
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
35
+ import { AGENT_SLA_TIMEOUT } from "../app/agentSla.ts";
36
+ import { DEFAULT_ESCALATION_SLA_TIMEOUT } from "../app/escalationSla.ts";
37
+ import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
38
+ import { advancePastTimer, settleFully } from "./support/time.ts";
39
+
40
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
41
+
42
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
43
+ NANO_PR_GITHUB_TRANSPORT: "token",
44
+ GITHUB_TOKEN: "",
45
+ };
46
+
47
+ // The agent SLA is pinned to a fixed duration (PT2H) for this suite via ./support/pin-agent-sla.ts,
48
+ // so `AGENT_SLA_TIMEOUT` is deterministic regardless of the ambient `NANO_PR_AGENT_SLA_TIMEOUT`.
49
+ // `PAST_AGENT_SLA_MS` (1.5×) advances just past it so the AGENT boundary fires while the downstream
50
+ // human-escalation SLA (PT24H) stays dormant — isolating the agent-SLA arm; `UNDER_AGENT_SLA_MS`
51
+ // (0.25×) proves the boundary is genuinely armed on the seeded duration, not firing spuriously.
52
+ // Both thresholds derive from the SAME pinned duration, so they cannot drift from the seeded SLA.
53
+
54
+ interface TakenFlow {
55
+ from: string;
56
+ to: string;
57
+ }
58
+
59
+ function takenFlows(app: TestApp): string[] {
60
+ const snapshot = app.snapshot();
61
+ const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
62
+ return flows
63
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
64
+ .map((f) => `${f.from}->${f.to}`);
65
+ }
66
+
67
+ /** Park the implementation agent: a mock with a single never-matching clause resolves to
68
+ * `undefined`, so the dispatch falls through and (with no real `senior:*` handler registered) the
69
+ * job stays locked — the deterministic stand-in for a hung/looping agent that never completes. */
70
+ function parkAgent(app: TestApp, jobType: string): void {
71
+ app.engine.mockWorker(jobType).when(() => false).completeWith({});
72
+ }
73
+
74
+ async function openEscalationTask(app: TestApp, processKey: string) {
75
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
76
+ return tasks.find((t) => t.elementId === "escalation");
77
+ }
78
+
79
+ /** Find an open user task by element id on a (possibly standalone-cell) instance. Used by the
80
+ * rerouted-arm scenarios below, whose human-decision tasks live directly on the cell's own
81
+ * instance (`trial-merge-decision`, `conformance-escalation`) rather than on a `human-escalation`
82
+ * grandchild. */
83
+ async function openTaskById(app: TestApp, processKey: string, elementId: string) {
84
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
85
+ return tasks.find((t) => t.elementId === elementId);
86
+ }
87
+
88
+ describe("agent-task SLA boundary — runtime escalation (#849)", () => {
89
+ const savedEnv = new Map<string, string | undefined>();
90
+ let restoreGithub: (() => void) | undefined;
91
+
92
+ before(() => {
93
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
94
+ savedEnv.set(k, process.env[k]);
95
+ process.env[k] = v;
96
+ }
97
+ restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
98
+ });
99
+
100
+ after(() => {
101
+ restoreGithub?.();
102
+ for (const [k, v] of savedEnv) {
103
+ if (v === undefined) delete process.env[k];
104
+ else process.env[k] = v;
105
+ }
106
+ });
107
+
108
+ async function withApp(
109
+ body: (ctx: { app: TestApp; processKey: string }) => Promise<void>,
110
+ start: (app: TestApp) => Promise<string>,
111
+ ): Promise<void> {
112
+ const dbDir = mkdtempSync(join(tmpdir(), "nwf-849sla-"));
113
+ const app = await bootTestApp(APP_ROOT, {
114
+ env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` },
115
+ });
116
+ try {
117
+ const processKey = await start(app);
118
+ await body({ app, processKey });
119
+ } finally {
120
+ await app.stop();
121
+ rmSync(dbDir, { recursive: true, force: true });
122
+ }
123
+ }
124
+
125
+ test("implement-cell (via a feature run): a hung agent hits its SLA boundary and escalates to a human", async () => {
126
+ await withApp(
127
+ async ({ app, processKey }) => {
128
+ // Under the SLA the agent is still parked: no boundary flow, no escalation task yet.
129
+ await advancePastTimer(app, UNDER_AGENT_SLA_MS);
130
+ let flows = takenFlows(app);
131
+ assert.ok(
132
+ !flows.includes("be_implement_sla->record-escalation-sla"),
133
+ "under the SLA the agent boundary has NOT fired",
134
+ );
135
+ assert.ok(
136
+ !(await openEscalationTask(app, processKey)),
137
+ "no human-escalation task before the SLA elapses",
138
+ );
139
+
140
+ // Past the SLA the interrupting boundary cancels the parked agent job and routes through the
141
+ // SLA recorder to the shared human-escalation cell.
142
+ await advancePastTimer(app, PAST_AGENT_SLA_MS);
143
+ flows = takenFlows(app);
144
+ assert.ok(
145
+ flows.includes("be_implement_sla->record-escalation-sla"),
146
+ `the agent SLA boundary fired into the SLA recorder (flows: ${flows.join(", ")})`,
147
+ );
148
+ assert.ok(
149
+ flows.includes("record-escalation-sla->escalate"),
150
+ "the SLA recorder handed off to the human-escalation cell",
151
+ );
152
+ const task = await openEscalationTask(app, processKey);
153
+ assert.ok(task?.userTaskKey, "the human-escalation cell parked a completable escalation task");
154
+ },
155
+ async (app) => {
156
+ parkAgent(app, "senior:feature");
157
+ const featureKey = "owner/repo#7";
158
+ const started = await app.api?.call("startFeature", { body: { issue: featureKey, baseBranch: "epic/e2e" } });
159
+ assert.equal(started?.status, 202, "startFeature accepted the issue");
160
+ await settleFully(app);
161
+ const run = await app.db
162
+ .table<{ feature_key: string; process_key: string | null }>("feature_runs", "feature_key")
163
+ .findOne({ feature_key: featureKey });
164
+ assert.ok(run?.process_key, "the feature_runs row carries the engine process-instance key");
165
+ return run!.process_key!;
166
+ },
167
+ );
168
+ });
169
+
170
+ test("implement-cell (via a plan-fanout wave): the parent-mapped agentSlaTimeout arms the child boundary", async () => {
171
+ await withApp(
172
+ async ({ app, processKey }) => {
173
+ await advancePastTimer(app, PAST_AGENT_SLA_MS);
174
+ const flows = takenFlows(app);
175
+ // The slice's implement-task is in the implement-cell child spawned by plan-fanout's MI body;
176
+ // the boundary fires only because the parent now maps `agentSlaTimeout` into the call activity.
177
+ assert.ok(
178
+ flows.includes("be_implement_sla->record-escalation-sla"),
179
+ `the wave slice's agent SLA boundary fired (flows: ${flows.join(", ")})`,
180
+ );
181
+ const task = await openEscalationTask(app, processKey);
182
+ assert.ok(task?.userTaskKey, "the slice's human-escalation cell parked a completable escalation task");
183
+ },
184
+ async (app) => {
185
+ // Plan + plan-review complete; the implement agent hangs.
186
+ await app.engine.registerWorker("senior:plan", async () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] }));
187
+ await app.engine.registerWorker("senior:plan-review", async () => ({ approved: true, findings: "" }));
188
+ parkAgent(app, "senior:feature");
189
+ const planKey = "owner/repo#1";
190
+ const started = await app.api?.call("startPlanFanout", { body: { issue: planKey, baseBranch: "epic/e2e" } });
191
+ assert.equal(started?.status, 202, "startPlanFanout accepted the issue");
192
+ await settleFully(app);
193
+ const plan = await app.db
194
+ .table<{ plan_key: string; process_key: string | null }>("plans", "plan_key")
195
+ .findOne({ plan_key: planKey });
196
+ assert.ok(plan?.process_key, "the plan row carries the engine process-instance key");
197
+ return plan!.process_key!;
198
+ },
199
+ );
200
+ });
201
+ });
202
+
203
+ // Runtime proof for the OTHER two rerouted agent-SLA arms landed this same round (#849 review r2).
204
+ // The implement-cell boundary above is driven through its real parents; merge-cell and retro are
205
+ // standalone cells (merge-cell is not yet composed by any callActivity, retro's trigger needs a
206
+ // whole epic's DB state), so we create each instance directly and seed exactly the process
207
+ // variables their real parents seed (`agentSlaTimeout`, and for merge-cell `escalationSlaTimeout`
208
+ // for the downstream human-decision boundary). Each test parks the agent at the bounded task,
209
+ // advances the engine clock past `agentSlaTimeout`, and asserts the token traversed the NEW
210
+ // recorder → escalation path — so a broken edge (wrong `targetRef`, or `agentSlaTimeout` missing
211
+ // from the recorder's scope) is caught by a red test, not at runtime. Mirrors the premise of the
212
+ // implement-cell suite above: a structural guard proves the boundary EXISTS; only this proves it
213
+ // ROUTES.
214
+ describe("agent-task SLA boundary — rerouted recorder→escalation arms (#849 review r2)", () => {
215
+ const savedEnv = new Map<string, string | undefined>();
216
+ let restoreGithub: (() => void) | undefined;
217
+
218
+ before(() => {
219
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
220
+ savedEnv.set(k, process.env[k]);
221
+ process.env[k] = v;
222
+ }
223
+ restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
224
+ });
225
+
226
+ after(() => {
227
+ restoreGithub?.();
228
+ for (const [k, v] of savedEnv) {
229
+ if (v === undefined) delete process.env[k];
230
+ else process.env[k] = v;
231
+ }
232
+ });
233
+
234
+ async function bootApp(): Promise<{ app: TestApp; dbDir: string }> {
235
+ const dbDir = mkdtempSync(join(tmpdir(), "nwf-849sla2-"));
236
+ const app = await bootTestApp(APP_ROOT, {
237
+ env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` },
238
+ });
239
+ return { app, dbDir };
240
+ }
241
+
242
+ // The retro SLA recorders persist through `pr.conformance-record` → `recordConformance`, which
243
+ // inserts into `plan_conformance` whose `plan_key` has a FOREIGN KEY to `plans` (052). Seed the
244
+ // minimal parent row so the recorder's write doesn't violate the constraint — the real retro is
245
+ // only ever started (app/retro.ts) for an already-recorded plan, so this mirrors production scope.
246
+ async function seedPlan(app: TestApp, planKey: string): Promise<void> {
247
+ const ts = new Date().toISOString();
248
+ await app.db
249
+ .table<Record<string, unknown>>("plans", "plan_key")
250
+ .insert({
251
+ plan_key: planKey,
252
+ repo: "owner/repo",
253
+ issue_number: 1,
254
+ issue_url: "https://github.com/owner/repo/issues/1",
255
+ status: "done",
256
+ created_at: ts,
257
+ updated_at: ts,
258
+ });
259
+ }
260
+
261
+ test("merge-cell: a hung trial-merge agent hits be_trial_agent_sla and routes through record-trial-merge to the human decision", async () => {
262
+ const { app, dbDir } = await bootApp();
263
+ try {
264
+ parkAgent(app, "senior:trial-merge");
265
+ const { processInstanceKey } = await app.engine.createInstance({
266
+ processDefinitionId: "merge-cell",
267
+ variables: {
268
+ planKey: "owner/repo#1",
269
+ repo: "owner/repo",
270
+ trialMergeWave: 1,
271
+ agentSlaTimeout: AGENT_SLA_TIMEOUT,
272
+ // Arms the downstream trial-merge-decision SLA boundary (`=escalationSlaTimeout`); a long
273
+ // PT24H timer that must NOT fire in our 3h jump — without it the boundary's timer-creation
274
+ // FEEL would resolve null and incident.
275
+ escalationSlaTimeout: DEFAULT_ESCALATION_SLA_TIMEOUT,
276
+ },
277
+ });
278
+ const processKey = String(processInstanceKey);
279
+
280
+ // Under the SLA the agent is still parked: boundary not fired, no decision task yet.
281
+ await advancePastTimer(app, UNDER_AGENT_SLA_MS);
282
+ let flows = takenFlows(app);
283
+ assert.ok(
284
+ !flows.includes("be_trial_agent_sla->record-trial-merge-sla"),
285
+ "under the SLA the trial-merge agent boundary has NOT fired",
286
+ );
287
+ assert.ok(
288
+ !(await openTaskById(app, processKey, "trial-merge-decision")),
289
+ "no trial-merge decision task before the SLA elapses",
290
+ );
291
+
292
+ // Past the SLA the interrupting boundary cancels the hung agent and routes through the
293
+ // dedicated SLA recorder (record-trial-merge-sla) — NOT the shared normal-path recorder.
294
+ await advancePastTimer(app, PAST_AGENT_SLA_MS);
295
+ flows = takenFlows(app);
296
+ assert.ok(
297
+ flows.includes("be_trial_agent_sla->record-trial-merge-sla"),
298
+ `the agent SLA boundary fired into the SLA recorder (flows: ${flows.join(", ")})`,
299
+ );
300
+ assert.ok(
301
+ flows.includes("record-trial-merge-sla->gw-trial"),
302
+ "the SLA recorder handed off to the trial-red gateway",
303
+ );
304
+ assert.ok(
305
+ flows.includes("gw-trial->trial-merge-decision"),
306
+ "a null/absent trial-merge result is treated as suite-failed and escalates to the human decision",
307
+ );
308
+ const task = await openTaskById(app, processKey, "trial-merge-decision");
309
+ assert.ok(task?.userTaskKey, "the merge-cell parked a completable trial-merge decision task");
310
+ } finally {
311
+ await app.stop();
312
+ rmSync(dbDir, { recursive: true, force: true });
313
+ }
314
+ });
315
+
316
+ // Regression for PR #864 review r3: on an SLA during a RERUN the recorder must not replay the
317
+ // prior COMPLETED attempt's process-scope `result`/`summary`/`conflicts`/`failing`. The
318
+ // `=null` ioMapping on `trial-merge` is task-local, so without a dedicated SLA recorder the
319
+ // interrupted second attempt would inherit attempt one's stale verdict. Drive ONE instance: a
320
+ // suite-failed first attempt escalates, the human answers "rebase" to rerun, the second agent
321
+ // parks, the SLA fires — then assert on the durable audit (`plan_trial_merges`): the SLA row must
322
+ // carry the TIMEOUT verdict (and cleared conflicts/failing), while the first attempt's row keeps
323
+ // the agent's real output (the normal path is not clobbered by the SLA literals).
324
+ test("merge-cell: an SLA on a rerun does NOT replay the prior attempt's stale result/summary", async () => {
325
+ const { app, dbDir } = await bootApp();
326
+ try {
327
+ // First dispatch completes SUITE-FAILED with a distinctive stale summary (seeding process
328
+ // scope); the rerun's dispatch parks (the `firstDone` flag flips the mock to no-match → the
329
+ // job stays locked → the SLA boundary fires).
330
+ let firstDone = false;
331
+ app.engine
332
+ .mockWorker("senior:trial-merge")
333
+ .when(() => !firstDone)
334
+ .completeWith({
335
+ result: "suite-failed",
336
+ summary: "stale prior-attempt summary",
337
+ conflicts: ["stale-conflict"],
338
+ failing: ["stale-spec"],
339
+ });
340
+
341
+ const { processInstanceKey } = await app.engine.createInstance({
342
+ processDefinitionId: "merge-cell",
343
+ variables: {
344
+ planKey: "owner/repo#1",
345
+ repo: "owner/repo",
346
+ trialMergeWave: 1,
347
+ agentSlaTimeout: AGENT_SLA_TIMEOUT,
348
+ escalationSlaTimeout: DEFAULT_ESCALATION_SLA_TIMEOUT,
349
+ },
350
+ });
351
+ const processKey = String(processInstanceKey);
352
+ await settleFully(app);
353
+
354
+ // Attempt 1 escalates to the human decision; its audit row keeps the AGENT's real output
355
+ // (proving the dedicated SLA recorder's literals do NOT clobber the normal path).
356
+ let flows = takenFlows(app);
357
+ assert.ok(
358
+ flows.includes("trial-merge->record-trial-merge") && flows.includes("gw-trial->trial-merge-decision"),
359
+ `a suite-failed first attempt records normally and escalates (flows: ${flows.join(", ")})`,
360
+ );
361
+ const auditsAfterFirst = await app.db
362
+ .table<{ id: number; plan_key: string; summary: string | null }>("plan_trial_merges", "id")
363
+ .find({ plan_key: "owner/repo#1" });
364
+ assert.equal(auditsAfterFirst.length, 1, "one audit row after the first attempt");
365
+ assert.equal(
366
+ auditsAfterFirst[0].summary,
367
+ "stale prior-attempt summary",
368
+ "the normal path records the agent's real summary (not the SLA literal)",
369
+ );
370
+ const first = await openTaskById(app, processKey, "trial-merge-decision");
371
+ assert.ok(first?.userTaskKey, "the first attempt parked a trial-merge decision task");
372
+
373
+ // The human answers "rebase" → the cell reruns trial-merge. Park the second agent.
374
+ firstDone = true;
375
+ await app.engine.completeUserTask(first!.userTaskKey, { action: "rebase" });
376
+ await settleFully(app);
377
+ flows = takenFlows(app);
378
+ assert.ok(
379
+ flows.includes("gw-trial-answer->trial-merge"),
380
+ `the rebase answer reran the trial-merge agent (flows: ${flows.join(", ")})`,
381
+ );
382
+
383
+ // The second agent hangs; the SLA fires. The DEDICATED SLA recorder must pin the TIMEOUT verdict.
384
+ await advancePastTimer(app, PAST_AGENT_SLA_MS);
385
+ flows = takenFlows(app);
386
+ assert.ok(
387
+ flows.includes("be_trial_agent_sla->record-trial-merge-sla"),
388
+ `the rerun's agent SLA boundary fired into the dedicated SLA recorder (flows: ${flows.join(", ")})`,
389
+ );
390
+ assert.ok(
391
+ flows.includes("record-trial-merge-sla->gw-trial") && flows.includes("gw-trial->trial-merge-decision"),
392
+ "the SLA timeout verdict (suite-failed) routes through the gateway to the human decision again",
393
+ );
394
+
395
+ // The decisive assertion: the SLA audit row carries the TIMEOUT summary with CLEARED
396
+ // conflicts/failing — NOT the stale first-attempt values a shared recorder would have replayed.
397
+ const audits = await app.db
398
+ .table<{ id: number; plan_key: string; result: string; summary: string | null; conflicts: string | null; failing: string | null }>(
399
+ "plan_trial_merges",
400
+ "id",
401
+ )
402
+ .find({ plan_key: "owner/repo#1" });
403
+ const slaRow = audits.sort((a, b) => b.id - a.id)[0];
404
+ assert.equal(slaRow.result, "suite-failed", "the SLA row records the timeout verdict");
405
+ assert.equal(
406
+ slaRow.summary,
407
+ `The trial-merge agent exceeded its time budget (SLA ${AGENT_SLA_TIMEOUT}) without returning a machine-readable result — it is hung or looping. Acknowledge to record the timeout and decide whether to rerun the trial merge.`,
408
+ "the SLA row pins the timeout summary, not the stale prior-attempt summary",
409
+ );
410
+ assert.equal(slaRow.conflicts, null, "the SLA row clears the stale prior-attempt conflicts");
411
+ assert.equal(slaRow.failing, null, "the SLA row clears the stale prior-attempt failing list");
412
+ } finally {
413
+ await app.stop();
414
+ rmSync(dbDir, { recursive: true, force: true });
415
+ }
416
+ });
417
+
418
+ test("retro: a hung conformance agent hits be_conformance_sla and routes through record-conformance-sla to the human review", async () => {
419
+ const { app, dbDir } = await bootApp();
420
+ try {
421
+ await seedPlan(app, "owner/repo#1");
422
+ parkAgent(app, "senior:conformance");
423
+ const { processInstanceKey } = await app.engine.createInstance({
424
+ processDefinitionId: "retro",
425
+ variables: {
426
+ planKey: "owner/repo#1",
427
+ repo: "owner/repo",
428
+ issueUrl: "https://github.com/owner/repo/issues/1",
429
+ agentSlaTimeout: AGENT_SLA_TIMEOUT,
430
+ },
431
+ });
432
+ const processKey = String(processInstanceKey);
433
+ // Let `gather` (pr.retro-gather) complete and the conformance agent park before arming the clock.
434
+ await settleFully(app);
435
+
436
+ await advancePastTimer(app, UNDER_AGENT_SLA_MS);
437
+ let flows = takenFlows(app);
438
+ assert.ok(
439
+ !flows.includes("be_conformance_sla->record-conformance-sla"),
440
+ "under the SLA the conformance agent boundary has NOT fired",
441
+ );
442
+ assert.ok(
443
+ !(await openTaskById(app, processKey, "conformance-escalation")),
444
+ "no conformance-escalation task before the SLA elapses",
445
+ );
446
+
447
+ await advancePastTimer(app, PAST_AGENT_SLA_MS);
448
+ flows = takenFlows(app);
449
+ assert.ok(
450
+ flows.includes("be_conformance_sla->record-conformance-sla"),
451
+ `the conformance agent SLA boundary fired into its recorder (flows: ${flows.join(", ")})`,
452
+ );
453
+ assert.ok(
454
+ flows.includes("record-conformance-sla->conformance-escalation"),
455
+ "the SLA recorder handed off to the human conformance review",
456
+ );
457
+ const task = await openTaskById(app, processKey, "conformance-escalation");
458
+ assert.ok(task?.userTaskKey, "the retro parked a completable conformance-escalation task");
459
+ } finally {
460
+ await app.stop();
461
+ rmSync(dbDir, { recursive: true, force: true });
462
+ }
463
+ });
464
+
465
+ test("retro: a hung synthesize agent hits be_synthesize_sla and routes through record-synthesize-sla to the human review", async () => {
466
+ const { app, dbDir } = await bootApp();
467
+ try {
468
+ // Conformance completes cleanly (no deviations) so the token reaches `synthesize`; that agent hangs.
469
+ await app.engine.registerWorker("senior:conformance", async () => ({ status: "skipped" }));
470
+ parkAgent(app, "senior:retro");
471
+ await seedPlan(app, "owner/repo#1");
472
+ const { processInstanceKey } = await app.engine.createInstance({
473
+ processDefinitionId: "retro",
474
+ variables: {
475
+ planKey: "owner/repo#1",
476
+ repo: "owner/repo",
477
+ issueUrl: "https://github.com/owner/repo/issues/1",
478
+ agentSlaTimeout: AGENT_SLA_TIMEOUT,
479
+ },
480
+ });
481
+ const processKey = String(processInstanceKey);
482
+ // Let gather + conformance + record-conformance run and the synthesize agent park before arming.
483
+ await settleFully(app);
484
+
485
+ await advancePastTimer(app, UNDER_AGENT_SLA_MS);
486
+ let flows = takenFlows(app);
487
+ assert.ok(
488
+ flows.includes("gw-deviations->synthesize"),
489
+ `a clean conformance run reaches synthesize (flows: ${flows.join(", ")})`,
490
+ );
491
+ assert.ok(
492
+ !flows.includes("be_synthesize_sla->record-synthesize-sla"),
493
+ "under the SLA the synthesize agent boundary has NOT fired",
494
+ );
495
+
496
+ await advancePastTimer(app, PAST_AGENT_SLA_MS);
497
+ flows = takenFlows(app);
498
+ assert.ok(
499
+ flows.includes("be_synthesize_sla->record-synthesize-sla"),
500
+ `the synthesize agent SLA boundary fired into its recorder (flows: ${flows.join(", ")})`,
501
+ );
502
+ assert.ok(
503
+ flows.includes("record-synthesize-sla->conformance-escalation"),
504
+ "the synthesize SLA recorder handed off to the human conformance review",
505
+ );
506
+ const task = await openTaskById(app, processKey, "conformance-escalation");
507
+ assert.ok(task?.userTaskKey, "the retro parked a completable conformance-escalation task on the synthesize-SLA arm");
508
+ } finally {
509
+ await app.stop();
510
+ rmSync(dbDir, { recursive: true, force: true });
511
+ }
512
+ });
513
+
514
+ test("retro: a synthesize SLA does NOT overwrite the conformance verdict filed earlier in the same run", async () => {
515
+ const { app, dbDir } = await bootApp();
516
+ try {
517
+ // Conformance FILES a real verdict (with a report comment + per-item counts) and NO deviations,
518
+ // so the token routes on to `synthesize`; that agent then hangs and its SLA fires. The preserve
519
+ // path must leave the filed verdict intact.
520
+ await app.engine.registerWorker("senior:conformance", async () => ({
521
+ status: "filed",
522
+ commentUrl: "https://github.com/owner/repo/issues/1#issuecomment-9",
523
+ slicesMet: 3,
524
+ slicesReduced: 0,
525
+ slicesNotVerified: 0,
526
+ deviationsRaised: 0,
527
+ deviationsUnraised: 0,
528
+ hasDeviations: false,
529
+ summary: "3 items, 3 met",
530
+ }));
531
+ parkAgent(app, "senior:retro");
532
+ await seedPlan(app, "owner/repo#1");
533
+ const { processInstanceKey } = await app.engine.createInstance({
534
+ processDefinitionId: "retro",
535
+ variables: {
536
+ planKey: "owner/repo#1",
537
+ repo: "owner/repo",
538
+ issueUrl: "https://github.com/owner/repo/issues/1",
539
+ agentSlaTimeout: AGENT_SLA_TIMEOUT,
540
+ },
541
+ });
542
+ const processKey = String(processInstanceKey);
543
+ await settleFully(app);
544
+
545
+ // The filed conformance verdict is recorded before synthesize parks.
546
+ const filed = await app.db
547
+ .table<{ status: string; summary: string | null; slices_met: number; report: string | null }>(
548
+ "plan_conformance",
549
+ "plan_key",
550
+ )
551
+ .get("owner/repo#1");
552
+ assert.equal(filed?.status, "filed", "the conformance verdict was filed before synthesize");
553
+ assert.equal(filed?.slices_met, 3);
554
+
555
+ // Fire the synthesize SLA.
556
+ await advancePastTimer(app, PAST_AGENT_SLA_MS);
557
+ const flows = takenFlows(app);
558
+ assert.ok(
559
+ flows.includes("be_synthesize_sla->record-synthesize-sla"),
560
+ `the synthesize SLA boundary fired (flows: ${flows.join(", ")})`,
561
+ );
562
+ const task = await openTaskById(app, processKey, "conformance-escalation");
563
+ assert.ok(task?.userTaskKey, "the synthesize-SLA arm parked the conformance-escalation task");
564
+
565
+ // The decisive assertion: the filed conformance verdict SURVIVES the synthesize-SLA timeout —
566
+ // status/counts/report unchanged, review_status flipped to reviewing, and the synthesis-timeout
567
+ // reason APPENDED to (not replacing) the audit summary.
568
+ const row = await app.db
569
+ .table<{
570
+ status: string;
571
+ comment_url: string | null;
572
+ slices_met: number;
573
+ slices_reduced: number;
574
+ has_deviations: number;
575
+ summary: string | null;
576
+ report: string | null;
577
+ review_status: string;
578
+ }>("plan_conformance", "plan_key")
579
+ .get("owner/repo#1");
580
+ assert.equal(row?.status, "filed", "the filed verdict status is preserved, not reset to skipped");
581
+ assert.equal(row?.comment_url, "https://github.com/owner/repo/issues/1#issuecomment-9");
582
+ assert.equal(row?.slices_met, 3, "the verdict counts are preserved");
583
+ assert.equal(row?.slices_reduced, 0);
584
+ assert.equal(row?.review_status, "reviewing", "the escalation is tracked for the ack task");
585
+ assert.ok(
586
+ (row?.summary ?? "").startsWith("3 items, 3 met"),
587
+ `the audit summary is preserved and the SLA reason appended (got: ${row?.summary})`,
588
+ );
589
+ assert.ok(
590
+ (row?.summary ?? "").includes("retrospective-synthesis agent exceeded its time budget"),
591
+ "the synthesis-timeout context is appended to the summary",
592
+ );
593
+ } finally {
594
+ await app.stop();
595
+ rmSync(dbDir, { recursive: true, force: true });
596
+ }
597
+ });
598
+ });
@@ -456,7 +456,14 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
456
456
  .table<{ user_task_key: string; element_id: string; question: string | null }>("user_tasks", "user_task_key")
457
457
  .findOne({ user_task_key: task!.userTaskKey });
458
458
  assert.ok(inboxRow, "the poller projected the escalation onto the Tasks inbox read-model");
459
- assert.equal(inboxRow!.question, "Which API should I use?", "the agent's question is surfaced from the audit log");
459
+ // The recorder enriches the agent's own question with its summary as supporting context
460
+ // (issue #865 review) — the audit log carries the leading question AND the appended summary, and
461
+ // `pollUserTasks` surfaces that enriched text unchanged.
462
+ assert.equal(
463
+ inboxRow!.question,
464
+ 'Which API should I use? The agent\'s own summary: "parked for a human".',
465
+ "the agent's question, enriched with its summary, is surfaced from the audit log",
466
+ );
460
467
 
461
468
  // Answer through the ONE canonical `complete-user-task` door (issue #332 retired the bespoke
462
469
  // `answer-escalation` operation) — the attributed human completer resumes the SAME implement task