@nanobpm/nano-workforce 0.200.7 → 0.200.8
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 +6 -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/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/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 +96 -46
- package/resources/processes/merge-cell.bpmn +60 -12
- package/resources/processes/plan-fanout.bpmn +1 -0
- package/resources/processes/retro.bpmn +100 -3
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Isolate the agent-SLA e2e suite from ambient `NANO_PR_AGENT_SLA_TIMEOUT`.
|
|
2
|
+
//
|
|
3
|
+
// `app/agentSla.ts` resolves its `AGENT_SLA_TIMEOUT` constant ONCE, at import time, from the ambient
|
|
4
|
+
// `NANO_PR_AGENT_SLA_TIMEOUT` env (through the typed env schema). That frozen constant is what every
|
|
5
|
+
// process start seeds as the `agentSlaTimeout` process variable — including the app-booted feature
|
|
6
|
+
// run, whose `app/feature.ts` imports the same const. So the SLA the boundary fires on is whatever
|
|
7
|
+
// env was present when the module graph loaded, NOT a value the test can set in a `before` hook.
|
|
8
|
+
//
|
|
9
|
+
// If a CI box or a developer shell exports e.g. `NANO_PR_AGENT_SLA_TIMEOUT=PT30M`, the suite's
|
|
10
|
+
// "under SLA" advance (shorter than the default PT2H) would suddenly EXCEED the SLA and fire the
|
|
11
|
+
// boundary; with `PT4H` the "past SLA" advance would no longer reach it. Either way the suite fails
|
|
12
|
+
// for a reason that has nothing to do with the code under test — a non-deterministic,
|
|
13
|
+
// ambient-config-driven failure (AGENTS.md: "no such thing as flaky tests").
|
|
14
|
+
//
|
|
15
|
+
// This module pins a FIXED duration BEFORE `app/agentSla.ts` can be evaluated, so the frozen const
|
|
16
|
+
// is deterministic regardless of the ambient env. `agent-sla-boundary.e2e.ts` imports it FIRST (no
|
|
17
|
+
// support module nor the engine testkit imports `app/agentSla.ts`, so this side effect lands before
|
|
18
|
+
// that module's const is computed). Both the direct-instance seeds and the advance thresholds derive
|
|
19
|
+
// from the SAME pinned duration, so they can never drift apart.
|
|
20
|
+
|
|
21
|
+
/** The fixed agent SLA this suite runs against (ISO-8601 duration), pinned into the env before any
|
|
22
|
+
* app module reads it. Kept equal to the production default `DEFAULT_AGENT_SLA_TIMEOUT` (PT2H). */
|
|
23
|
+
export const PINNED_AGENT_SLA = "PT2H";
|
|
24
|
+
|
|
25
|
+
process.env.NANO_PR_AGENT_SLA_TIMEOUT = PINNED_AGENT_SLA;
|
|
26
|
+
|
|
27
|
+
/** Parse the subset of ISO-8601 durations this suite uses (`PT#H#M#S`) to milliseconds. Throws on an
|
|
28
|
+
* unparseable value so a mistyped pin fails loudly rather than silently yielding a 0ms threshold. */
|
|
29
|
+
function isoDurationToMs(iso: string): number {
|
|
30
|
+
const match = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/.exec(iso);
|
|
31
|
+
if (!match || (!match[1] && !match[2] && !match[3])) {
|
|
32
|
+
throw new Error(`pin-agent-sla: cannot parse pinned duration "${iso}" (expected PT#H#M#S)`);
|
|
33
|
+
}
|
|
34
|
+
const [, h, m, s] = match;
|
|
35
|
+
return ((Number(h ?? 0) * 60 + Number(m ?? 0)) * 60 + Number(s ?? 0)) * 1000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The pinned SLA in ms — the single source both advance thresholds derive from. */
|
|
39
|
+
export const PINNED_AGENT_SLA_MS = isoDurationToMs(PINNED_AGENT_SLA);
|
|
40
|
+
|
|
41
|
+
/** Comfortably PAST the SLA (1.5×) so the agent boundary fires, yet well under the downstream
|
|
42
|
+
* human-escalation SLA (PT24H) so that one stays dormant — isolating the agent-SLA arm. */
|
|
43
|
+
export const PAST_AGENT_SLA_MS = Math.round(PINNED_AGENT_SLA_MS * 1.5);
|
|
44
|
+
|
|
45
|
+
/** Comfortably UNDER the SLA (0.25×) so the boundary is proven genuinely armed on the seeded
|
|
46
|
+
* duration, not firing spuriously. */
|
|
47
|
+
export const UNDER_AGENT_SLA_MS = Math.round(PINNED_AGENT_SLA_MS * 0.25);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.200.
|
|
3
|
+
"version": "0.200.8",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -182,6 +182,7 @@
|
|
|
182
182
|
<zeebe:input source="=if (is defined(transcriptUrlBase)) then transcriptUrlBase else null" target="transcriptUrlBase" />
|
|
183
183
|
<zeebe:input source="=if (is defined(escalationAssignee)) then escalationAssignee else null" target="escalationAssignee" />
|
|
184
184
|
<zeebe:input source="=escalationSlaTimeout" target="escalationSlaTimeout" />
|
|
185
|
+
<zeebe:input source="=agentSlaTimeout" target="agentSlaTimeout" />
|
|
185
186
|
<zeebe:input source="=featureKey" target="subjectKey" />
|
|
186
187
|
<zeebe:output source="=status" target="status" />
|
|
187
188
|
<zeebe:output source="=if (is defined(summary)) then summary else null" target="summary" />
|