@nanobpm/nano-workforce 0.103.0 → 0.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +1 -0
- package/app/abandon.ts +12 -3
- package/app/conformance.test.ts +220 -0
- package/app/conformance.ts +240 -0
- package/app/contracts.ts +8 -0
- package/app/dbFence.ts +18 -0
- package/app/durableResume.test.ts +89 -0
- package/app/durableResume.ts +141 -0
- package/app/migration052.test.ts +66 -0
- package/app/migration053.test.ts +84 -0
- package/app/plan.ts +6 -0
- package/app/retro.test.ts +32 -2
- package/app/retro.ts +26 -10
- package/app/service.test.ts +223 -3
- package/app/service.ts +146 -7
- package/app/waves.test.ts +12 -0
- package/app/world/store.ts +6 -6
- package/db/migrations/004_planning.sql +1 -1
- package/db/migrations/052_plan_conformance.sql +28 -0
- package/db/migrations/052_worker_durable_resume.sql +35 -0
- package/db/migrations/053_merges_abandon_dedupe.sql +29 -0
- package/nano.app.json +6 -1
- package/openapi.yaml +17 -0
- package/operations/enrolAgenticWorker.test.ts +64 -0
- package/operations/enrolAgenticWorker.ts +30 -1
- package/package.json +1 -1
- package/resources/processes/retro.bpmn +59 -8
- package/resources/prompts/conformance.md +105 -0
- package/resources/prompts/retro.md +5 -0
- package/test/worldDb.ts +16 -4
- package/workers/conformance-record/worker.test.ts +199 -0
- package/workers/conformance-record/worker.ts +95 -0
- package/workers/merge/worker.test.ts +4 -0
- package/workers/merge/worker.ts +13 -18
- package/workers/retro-gather/worker.test.ts +6 -0
- package/workers/retro-gather/worker.ts +17 -5
package/openapi.yaml
CHANGED
|
@@ -386,6 +386,15 @@ components:
|
|
|
386
386
|
instance:
|
|
387
387
|
type: string
|
|
388
388
|
description: The worker instance id, echoed back for provenance (optional).
|
|
389
|
+
durableResume:
|
|
390
|
+
type: boolean
|
|
391
|
+
description: >-
|
|
392
|
+
Whether this worker's harness advertises durable-resume (issue #325, ADR 0062 Slice 5/5)
|
|
393
|
+
— an ENROLMENT attribute, never a routing token. Recorded per instance so the app emits
|
|
394
|
+
the world-restore marker only to a fleet with a participant; a harness that omits it (or
|
|
395
|
+
sets false) redrives a re-leased round from scratch. Recorded only when `instance` is
|
|
396
|
+
a non-blank string — a missing, empty, or whitespace-only `instance` is echoed back for
|
|
397
|
+
provenance but the flag is not persisted.
|
|
389
398
|
EnrolledRole:
|
|
390
399
|
type: object
|
|
391
400
|
description: One matched role in an enrolment resolution — provenance for the resolved SERVE set.
|
|
@@ -411,6 +420,14 @@ components:
|
|
|
411
420
|
instance:
|
|
412
421
|
type: string
|
|
413
422
|
description: The worker instance id, echoed from the request when supplied.
|
|
423
|
+
durableResume:
|
|
424
|
+
type: boolean
|
|
425
|
+
description: >-
|
|
426
|
+
Echo of the request's durable-resume declaration (issue #325, ADR 0062 Slice 5/5).
|
|
427
|
+
Present only when the request supplied it. This reflects the value the worker sent, not a
|
|
428
|
+
guarantee of durable persistence — recording into the durable-resume registry is
|
|
429
|
+
best-effort (skipped when `instance` is absent/blank, and a registry write hiccup is
|
|
430
|
+
logged without failing enrolment).
|
|
414
431
|
serve:
|
|
415
432
|
type: array
|
|
416
433
|
description: The SERVE token set — sorted, de-duplicated leaf tokens the worker may serve.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import { assert, assertEquals } from "#test-assert";
|
|
4
4
|
import type { AppApi } from "@nanobpm/urban";
|
|
5
|
+
import { memDataFor } from "../test/worldDb.ts";
|
|
6
|
+
import { DurableResumeRegistry } from "../app/durableResume.ts";
|
|
5
7
|
import { noopLog } from "../test/log.ts";
|
|
6
8
|
import handler from "./enrolAgenticWorker.ts";
|
|
7
9
|
|
|
@@ -69,6 +71,68 @@ test("rejects non-finite capability.weight (NaN/Infinity) as 400", async () => {
|
|
|
69
71
|
assertEquals(infWeight.status, 400);
|
|
70
72
|
});
|
|
71
73
|
|
|
74
|
+
// Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5).
|
|
75
|
+
test("echoes durableResume back in the result when the worker declares it", async () => {
|
|
76
|
+
const on = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: true }), app)) as any;
|
|
77
|
+
assertEquals(on.status, 200);
|
|
78
|
+
assertEquals(on.body.durableResume, true);
|
|
79
|
+
const off = (await handler(input({ capability: { cognition: "decide" }, instance: "w2", durableResume: false }), app)) as any;
|
|
80
|
+
assertEquals(off.body.durableResume, false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("omits durableResume from the result when the worker does not declare it", async () => {
|
|
84
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), app)) as any;
|
|
85
|
+
assertEquals(res.status, 200);
|
|
86
|
+
assertEquals("durableResume" in res.body, false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("rejects a non-boolean durableResume as 400", async () => {
|
|
90
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: "yes" }), app)) as any;
|
|
91
|
+
assertEquals(res.status, 400);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("records durable-resume participation in the registry when a data layer + instance are present", async () => {
|
|
95
|
+
const { data } = memDataFor(["052_worker_durable_resume.sql"]);
|
|
96
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
97
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: true }), withData)) as any;
|
|
98
|
+
assertEquals(res.status, 200);
|
|
99
|
+
assertEquals(await new DurableResumeRegistry(data).isParticipant("w1"), true);
|
|
100
|
+
assertEquals(await new DurableResumeRegistry(data).anyParticipant(), true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a re-enrol omitting durableResume persists an explicit false, clearing a stale true (degrade to scratch)", async () => {
|
|
104
|
+
const { data } = memDataFor(["052_worker_durable_resume.sql"]);
|
|
105
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
106
|
+
// First enrol advertises durable-resume.
|
|
107
|
+
await handler(input({ capability: { cognition: "decide" }, instance: "w1", durableResume: true }), withData);
|
|
108
|
+
assertEquals(await new DurableResumeRegistry(data).isParticipant("w1"), true);
|
|
109
|
+
// Re-enrol WITHOUT the field (downgrade/rollback/client bug) must clear the stale flag.
|
|
110
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), withData)) as any;
|
|
111
|
+
assertEquals(res.status, 200);
|
|
112
|
+
assertEquals("durableResume" in res.body, false, "still omitted from the echo");
|
|
113
|
+
assertEquals(await new DurableResumeRegistry(data).isParticipant("w1"), false, "stale true cleared");
|
|
114
|
+
assertEquals(await new DurableResumeRegistry(data).anyParticipant(), false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a declaration without an instance is echoed but not persisted (enrolment is per-instance)", async () => {
|
|
118
|
+
const { data } = memDataFor(["052_worker_durable_resume.sql"]);
|
|
119
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
120
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, durableResume: true }), withData)) as any;
|
|
121
|
+
assertEquals(res.status, 200);
|
|
122
|
+
assertEquals(res.body.durableResume, true, "still echoed");
|
|
123
|
+
assertEquals(await new DurableResumeRegistry(data).anyParticipant(), false, "nothing recorded without an instance key");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a blank/whitespace instance is echoed but not persisted (avoids a shared registry-row collision)", async () => {
|
|
127
|
+
const { data } = memDataFor(["052_worker_durable_resume.sql"]);
|
|
128
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
129
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: " ", durableResume: true }), withData)) as any;
|
|
130
|
+
assertEquals(res.status, 200);
|
|
131
|
+
assertEquals(res.body.instance, " ", "still echoed verbatim");
|
|
132
|
+
assertEquals(res.body.durableResume, true, "still echoed");
|
|
133
|
+
assertEquals(await new DurableResumeRegistry(data).anyParticipant(), false, "nothing recorded for a blank instance key");
|
|
134
|
+
});
|
|
135
|
+
|
|
72
136
|
test("enforces the shared secret when NANO_PR_WEBHOOK_SECRET is set", async () => {
|
|
73
137
|
// The module captures the secret at load, so re-import a cache-busted copy with the env var set to
|
|
74
138
|
// exercise the guarded 401 path and the authorized 200 path.
|
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
// NANO_PR_WEBHOOK_SECRET is set, callers must present it via the x-hook-secret header. Unset → open.
|
|
13
13
|
import type { Capability } from "@nanobpm/agentic/protocol";
|
|
14
14
|
import { resolveEnrolment } from "../app/agentic/vocab/enrol.ts";
|
|
15
|
+
import { DurableResumeRegistry } from "../app/durableResume.ts";
|
|
15
16
|
import { envVar } from "../app/version.ts";
|
|
16
17
|
import type { EnrolResult } from "../nano-generated/api-io.d.ts";
|
|
17
18
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
18
19
|
|
|
19
20
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
20
21
|
|
|
21
|
-
export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
|
|
22
|
+
export default defineOperation("enrolAgenticWorker", async ({ req, body }, app) => {
|
|
22
23
|
if (SECRET && req.headers.get("x-hook-secret") !== SECRET) {
|
|
23
24
|
app.log.warn("enrolAgenticWorker rejected: missing/invalid shared secret");
|
|
24
25
|
return { status: 401, body: { error: "unauthorized" } };
|
|
@@ -65,6 +66,13 @@ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
|
|
|
65
66
|
body: { error: "`capability.weight` must be a finite number when provided" },
|
|
66
67
|
};
|
|
67
68
|
}
|
|
69
|
+
// The durable-resume enrolment attribute (issue #325, ADR 0062 Slice 5/5) — a boolean the harness
|
|
70
|
+
// advertises. A directly-invoked delegate bypasses the OpenAPI runtime validation, so guard the type
|
|
71
|
+
// here (a non-boolean would corrupt the {0,1} enrolment flag the world-restore gate reads).
|
|
72
|
+
if (body.durableResume !== undefined && typeof body.durableResume !== "boolean") {
|
|
73
|
+
app.log.warn("enrolAgenticWorker rejected: non-boolean durableResume");
|
|
74
|
+
return { status: 400, body: { error: "`durableResume` must be a boolean when provided" } };
|
|
75
|
+
}
|
|
68
76
|
|
|
69
77
|
// Fold a top-level `host` into the capability when the capability didn't carry its own — a worker
|
|
70
78
|
// may declare its host either on the capability or beside it (ADR 0059 `{ capability, host }`).
|
|
@@ -74,6 +82,26 @@ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
|
|
|
74
82
|
: body.capability;
|
|
75
83
|
|
|
76
84
|
const resolved = resolveEnrolment(capability);
|
|
85
|
+
|
|
86
|
+
// Durable-resume enrolment gate (issue #325, ADR 0062 Slice 5/5): record whether this worker's
|
|
87
|
+
// harness advertises durable-resume so the world-restore marker is emitted only to a fleet with a
|
|
88
|
+
// participant. Recorded per instance (ADR 0056 §7 — an enrolment attribute, never a routing token),
|
|
89
|
+
// so it needs a non-blank `instance`; a declaration without one — or with a blank/whitespace
|
|
90
|
+
// string — is echoed but not persisted (a blank key would let unrelated workers collide on the
|
|
91
|
+
// same registry row and wrongly open/close the fleet-wide durable-resume gate). Omission of the
|
|
92
|
+
// field on a re-enrol persists an explicit `false` (degrade to scratch), so a harness that previously
|
|
93
|
+
// advertised durable-resume and later re-enrols without the field clears its stale `true` rather than
|
|
94
|
+
// leaving `fleetSupportsDurableResume()` true indefinitely. Best-effort — the enrolment resolution
|
|
95
|
+
// must not fail on a registry write hiccup.
|
|
96
|
+
const instanceKey = body.instance?.trim();
|
|
97
|
+
if (app.data && instanceKey) {
|
|
98
|
+
try {
|
|
99
|
+
await new DurableResumeRegistry(app.data).recordEnrolment(instanceKey, body.durableResume ?? false);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
app.log.warn("enrolAgenticWorker: durable-resume record failed", { instance: instanceKey, err: String(err) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
const result: EnrolResult = {
|
|
78
106
|
serve: [...resolved.serve],
|
|
79
107
|
roles: resolved.roles.map((role) => {
|
|
@@ -85,6 +113,7 @@ export default defineOperation("enrolAgenticWorker", ({ req, body }, app) => {
|
|
|
85
113
|
leaseTtl: resolved.leaseTtl,
|
|
86
114
|
};
|
|
87
115
|
if (body.instance !== undefined) result.instance = body.instance;
|
|
116
|
+
if (body.durableResume !== undefined) result.durableResume = body.durableResume;
|
|
88
117
|
|
|
89
118
|
app.log.info("agentic enrol resolved", { instance: body.instance, serve: result.serve, family: capability.family });
|
|
90
119
|
return { status: 200, body: result };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.105.0",
|
|
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",
|
|
@@ -13,6 +13,18 @@
|
|
|
13
13
|
<nano:extend name="pr" type="string" optional="true" />
|
|
14
14
|
<nano:extend name="summary" type="string" optional="true" />
|
|
15
15
|
</nano:shape>
|
|
16
|
+
<nano:shape id="ConformanceRecordIn" name="Record conformance — input">
|
|
17
|
+
<nano:extend name="planKey" type="string" />
|
|
18
|
+
<nano:extend name="status" type="string" optional="true" />
|
|
19
|
+
<nano:extend name="commentUrl" type="string" optional="true" />
|
|
20
|
+
<nano:extend name="slicesMet" type="integer" optional="true" />
|
|
21
|
+
<nano:extend name="slicesReduced" type="integer" optional="true" />
|
|
22
|
+
<nano:extend name="slicesNotVerified" type="integer" optional="true" />
|
|
23
|
+
<nano:extend name="deviationsRaised" type="integer" optional="true" />
|
|
24
|
+
<nano:extend name="deviationsUnraised" type="integer" optional="true" />
|
|
25
|
+
<nano:extend name="hasDeviations" type="boolean" optional="true" />
|
|
26
|
+
<nano:extend name="summary" type="string" optional="true" />
|
|
27
|
+
</nano:shape>
|
|
16
28
|
</nano:shapes>
|
|
17
29
|
</bpmn:extensionElements>
|
|
18
30
|
<bpmn:startEvent id="Start" name="Epic complete">
|
|
@@ -26,6 +38,29 @@
|
|
|
26
38
|
</zeebe:properties>
|
|
27
39
|
</bpmn:extensionElements>
|
|
28
40
|
<bpmn:incoming>f_start</bpmn:incoming>
|
|
41
|
+
<bpmn:outgoing>f_toConformance</bpmn:outgoing>
|
|
42
|
+
</bpmn:serviceTask>
|
|
43
|
+
<bpmn:serviceTask id="conformance" name="Verify implementation vs spec (agent)">
|
|
44
|
+
<bpmn:extensionElements>
|
|
45
|
+
<zeebe:taskDefinition type="senior:conformance" />
|
|
46
|
+
<zeebe:linkedResources>
|
|
47
|
+
<zeebe:linkedResource resourceId="conformance.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
|
|
48
|
+
</zeebe:linkedResources>
|
|
49
|
+
<zeebe:ioMapping>
|
|
50
|
+
<zeebe:input source="=conformanceDigest" target="appendPrompt" />
|
|
51
|
+
</zeebe:ioMapping>
|
|
52
|
+
</bpmn:extensionElements>
|
|
53
|
+
<bpmn:incoming>f_toConformance</bpmn:incoming>
|
|
54
|
+
<bpmn:outgoing>f_toRecordConformance</bpmn:outgoing>
|
|
55
|
+
</bpmn:serviceTask>
|
|
56
|
+
<bpmn:serviceTask id="record-conformance" name="Record conformance">
|
|
57
|
+
<bpmn:extensionElements>
|
|
58
|
+
<zeebe:taskDefinition type="pr.conformance-record" />
|
|
59
|
+
<zeebe:properties>
|
|
60
|
+
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="ConformanceRecordIn" />
|
|
61
|
+
</zeebe:properties>
|
|
62
|
+
</bpmn:extensionElements>
|
|
63
|
+
<bpmn:incoming>f_toRecordConformance</bpmn:incoming>
|
|
29
64
|
<bpmn:outgoing>f_toSynthesize</bpmn:outgoing>
|
|
30
65
|
</bpmn:serviceTask>
|
|
31
66
|
<bpmn:serviceTask id="synthesize" name="Synthesize & promote (agent)">
|
|
@@ -55,7 +90,9 @@
|
|
|
55
90
|
<bpmn:incoming>f_toEnd</bpmn:incoming>
|
|
56
91
|
</bpmn:endEvent>
|
|
57
92
|
<bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="gather" />
|
|
58
|
-
<bpmn:sequenceFlow id="
|
|
93
|
+
<bpmn:sequenceFlow id="f_toConformance" sourceRef="gather" targetRef="conformance" />
|
|
94
|
+
<bpmn:sequenceFlow id="f_toRecordConformance" sourceRef="conformance" targetRef="record-conformance" />
|
|
95
|
+
<bpmn:sequenceFlow id="f_toSynthesize" sourceRef="record-conformance" targetRef="synthesize" />
|
|
59
96
|
<bpmn:sequenceFlow id="f_toRecord" sourceRef="synthesize" targetRef="record" />
|
|
60
97
|
<bpmn:sequenceFlow id="f_toEnd" sourceRef="record" targetRef="End" />
|
|
61
98
|
</bpmn:process>
|
|
@@ -70,34 +107,48 @@
|
|
|
70
107
|
<bpmndi:BPMNShape id="BPMNShape_gather" bpmnElement="gather">
|
|
71
108
|
<dc:Bounds x="216" y="80" width="100" height="80" />
|
|
72
109
|
</bpmndi:BPMNShape>
|
|
73
|
-
<bpmndi:BPMNShape id="
|
|
110
|
+
<bpmndi:BPMNShape id="BPMNShape_conformance" bpmnElement="conformance">
|
|
74
111
|
<dc:Bounds x="416" y="80" width="100" height="80" />
|
|
75
112
|
</bpmndi:BPMNShape>
|
|
76
|
-
<bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
|
|
113
|
+
<bpmndi:BPMNShape id="BPMNShape_record-conformance" bpmnElement="record-conformance">
|
|
77
114
|
<dc:Bounds x="616" y="80" width="100" height="80" />
|
|
78
115
|
</bpmndi:BPMNShape>
|
|
116
|
+
<bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
|
|
117
|
+
<dc:Bounds x="816" y="80" width="100" height="80" />
|
|
118
|
+
</bpmndi:BPMNShape>
|
|
119
|
+
<bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
|
|
120
|
+
<dc:Bounds x="1016" y="80" width="100" height="80" />
|
|
121
|
+
</bpmndi:BPMNShape>
|
|
79
122
|
<bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
|
|
80
|
-
<dc:Bounds x="
|
|
123
|
+
<dc:Bounds x="1216" y="102" width="36" height="36" />
|
|
81
124
|
<bpmndi:BPMNLabel>
|
|
82
|
-
<dc:Bounds x="
|
|
125
|
+
<dc:Bounds x="1194" y="143" width="80" height="14" />
|
|
83
126
|
</bpmndi:BPMNLabel>
|
|
84
127
|
</bpmndi:BPMNShape>
|
|
85
128
|
<bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
|
|
86
129
|
<di:waypoint x="116" y="120" />
|
|
87
130
|
<di:waypoint x="216" y="120" />
|
|
88
131
|
</bpmndi:BPMNEdge>
|
|
89
|
-
<bpmndi:BPMNEdge id="
|
|
132
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toConformance" bpmnElement="f_toConformance">
|
|
90
133
|
<di:waypoint x="316" y="120" />
|
|
91
134
|
<di:waypoint x="416" y="120" />
|
|
92
135
|
</bpmndi:BPMNEdge>
|
|
93
|
-
<bpmndi:BPMNEdge id="
|
|
136
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toRecordConformance" bpmnElement="f_toRecordConformance">
|
|
94
137
|
<di:waypoint x="516" y="120" />
|
|
95
138
|
<di:waypoint x="616" y="120" />
|
|
96
139
|
</bpmndi:BPMNEdge>
|
|
97
|
-
<bpmndi:BPMNEdge id="
|
|
140
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toSynthesize" bpmnElement="f_toSynthesize">
|
|
98
141
|
<di:waypoint x="716" y="120" />
|
|
99
142
|
<di:waypoint x="816" y="120" />
|
|
100
143
|
</bpmndi:BPMNEdge>
|
|
144
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
|
|
145
|
+
<di:waypoint x="916" y="120" />
|
|
146
|
+
<di:waypoint x="1016" y="120" />
|
|
147
|
+
</bpmndi:BPMNEdge>
|
|
148
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
|
|
149
|
+
<di:waypoint x="1116" y="120" />
|
|
150
|
+
<di:waypoint x="1216" y="120" />
|
|
151
|
+
</bpmndi:BPMNEdge>
|
|
101
152
|
</bpmndi:BPMNPlane>
|
|
102
153
|
</bpmndi:BPMNDiagram>
|
|
103
154
|
</bpmn:definitions>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Conformance agent — verify the implementation against the spec, by reading the code
|
|
2
|
+
|
|
3
|
+
You are the **spec-conformance agent**. An epic (a fan-out `plan`) has finished — every one of its
|
|
4
|
+
slices has reached a terminal state and the landed PRs are now in the tree. Your job is to answer a
|
|
5
|
+
single, evidence-based question for a human:
|
|
6
|
+
|
|
7
|
+
> **Did we actually build what the spec asked for — no more, no less?**
|
|
8
|
+
|
|
9
|
+
You are NOT the retro agent (that one distils lessons). You are the auditor. Crucially, **you verify
|
|
10
|
+
against the real implementation, not against what the agents claimed.** Transcripts, `learning`
|
|
11
|
+
notes, and even a slice's own status can be optimistic or wrong. The diff cannot. Read the code.
|
|
12
|
+
|
|
13
|
+
## Input
|
|
14
|
+
|
|
15
|
+
The job payload (stdin JSON) carries:
|
|
16
|
+
|
|
17
|
+
- `variables.planKey` — the epic's key, e.g. `owner/repo#123`.
|
|
18
|
+
- `variables.repo` — the **target repo** `owner/repo` the work landed in.
|
|
19
|
+
- `variables.issueUrl` — the epic's source issue. **This issue body is the SPEC.**
|
|
20
|
+
- **`variables.conformanceDigest`** — appended below the `---` separator: the spec broken into
|
|
21
|
+
slices (each slice's planner-supplied `prompt` is its acceptance brief), the list of **delivered
|
|
22
|
+
PRs** you must examine, and the deviations that were **raised** during implementation
|
|
23
|
+
(`scope-change` entries). This tells you *what to check* and *where to look* — it does NOT tell
|
|
24
|
+
you the answer.
|
|
25
|
+
|
|
26
|
+
You have `gh` / git authenticated for the target repository.
|
|
27
|
+
|
|
28
|
+
## What to do
|
|
29
|
+
|
|
30
|
+
1. **Read the spec.** `gh issue view <n> --repo <repo>` for the epic issue body, plus every slice's
|
|
31
|
+
`prompt` in the digest. Together these are the acceptance criteria you are auditing against.
|
|
32
|
+
2. **Examine the ACTUAL implementation.** For every delivered PR in the digest:
|
|
33
|
+
`gh pr diff <n> --repo <repo>`, and read the touched source and tests (clone/checkout if you
|
|
34
|
+
need to navigate). Do not trust the PR description — read what the code does.
|
|
35
|
+
3. **Distinguish real delivery from the appearance of it.** This is the whole point of examining
|
|
36
|
+
code. For each spec item, decide whether it is *load-bearing* in the shipped system:
|
|
37
|
+
- Is the new code actually **wired in / reachable**, or is it a dead entrypoint behind a flag,
|
|
38
|
+
a stub, or a synthetic path nothing calls?
|
|
39
|
+
- Do the **tests exercise the real behaviour**, or are they asserting on mocks/gated paths so
|
|
40
|
+
they pass without proving the feature works?
|
|
41
|
+
- Was the item delivered **in full**, or narrowed to a subset while looking complete?
|
|
42
|
+
4. **Assign each spec item an acceptance verdict**, with a one-line evidence pointer (file / PR /
|
|
43
|
+
test) for each:
|
|
44
|
+
- **met** — delivered as specified, wired in, and genuinely tested.
|
|
45
|
+
- **met-in-unit-only** — implemented and unit-tested, but not proven wired into the live system.
|
|
46
|
+
- **reduced** — delivered in a narrower form than the spec asked for.
|
|
47
|
+
- **not-verified** — you could not confirm it from the implementation (missing, stubbed, dead
|
|
48
|
+
code, or tests that don't actually exercise it).
|
|
49
|
+
5. **Reconcile the raised deviations.** For each `scope-change` entry in the digest, confirm the
|
|
50
|
+
code matches what was said, and note it as a **raised** deviation.
|
|
51
|
+
6. **Hunt for UNRAISED deviations** — things the code does that the spec did *not* ask for, or spec
|
|
52
|
+
items silently dropped, that were **never** flagged on the blackboard. These are the most
|
|
53
|
+
valuable finding: scope drift nobody surfaced.
|
|
54
|
+
7. **Post the conformance report as a comment on the epic issue**
|
|
55
|
+
(`gh issue comment <n> --repo <repo> --body-file <file>`). Structure it: a one-line verdict, a
|
|
56
|
+
per-item table (item → verdict → evidence), the raised deviations, and the unraised deviations.
|
|
57
|
+
Be specific and cite files/PRs — this comment is the deliverable a human reads.
|
|
58
|
+
|
|
59
|
+
## Do not
|
|
60
|
+
|
|
61
|
+
- Do **not** open, modify, or merge any PR. You audit; you do not remediate. (A later stage decides
|
|
62
|
+
whether a finding warrants a follow-up task.)
|
|
63
|
+
- Do **not** soften a `not-verified` into a `met` because the transcript sounded confident. Absence
|
|
64
|
+
of evidence in the code is `not-verified`.
|
|
65
|
+
- Do **not** manufacture deviations to look thorough. A clean epic that fully met its spec is a
|
|
66
|
+
perfectly good — and common — result.
|
|
67
|
+
|
|
68
|
+
## Output contract
|
|
69
|
+
|
|
70
|
+
Write a JSON object of **result variables** to the file named by the `AGENT_RESULT_FILE`
|
|
71
|
+
environment variable:
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"status": "filed",
|
|
76
|
+
"commentUrl": "https://github.com/owner/repo/issues/123#issuecomment-456",
|
|
77
|
+
"slicesMet": 4,
|
|
78
|
+
"slicesReduced": 1,
|
|
79
|
+
"slicesNotVerified": 1,
|
|
80
|
+
"deviationsRaised": 2,
|
|
81
|
+
"deviationsUnraised": 1,
|
|
82
|
+
"hasDeviations": true,
|
|
83
|
+
"summary": "6 items: 4 met, 1 reduced (auth rate-limit narrowed to per-IP), 1 not-verified (webhook retry path is dead code). 2 raised + 1 unraised deviation (added a /debug route not in spec)."
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Rules:
|
|
88
|
+
|
|
89
|
+
- `status` — one of:
|
|
90
|
+
- `filed` — you examined the implementation and posted the report comment. Set `commentUrl`.
|
|
91
|
+
- `skipped` — there was no landed implementation to examine (nothing shipped). Explain in
|
|
92
|
+
`summary`; omit the counts.
|
|
93
|
+
- `blocked` — you could not proceed (e.g. no read access, could not fetch a PR). Explain in
|
|
94
|
+
`summary`.
|
|
95
|
+
- `commentUrl` — the URL of the report comment you posted on the epic issue, for `filed`.
|
|
96
|
+
- `slicesMet` / `slicesReduced` / `slicesNotVerified` — integer counts of your per-item verdicts
|
|
97
|
+
(count `met-in-unit-only` under `slicesReduced`, since it is not full live delivery).
|
|
98
|
+
- `deviationsRaised` — count of `scope-change` deviations you reconciled.
|
|
99
|
+
- `deviationsUnraised` — count of deviations you found that were never flagged.
|
|
100
|
+
- `hasDeviations` — `true` when anything is reduced / not-verified / an unraised deviation exists;
|
|
101
|
+
i.e. the epic did not cleanly meet its spec. A later stage uses this to decide escalation.
|
|
102
|
+
- `summary` — a short human-readable verdict.
|
|
103
|
+
|
|
104
|
+
You are advisory and post-merge: you gate no delivery. Your value is an honest, code-grounded
|
|
105
|
+
account of what shipped versus what was asked — surface it plainly, and let the humans act on it.
|
|
@@ -10,6 +10,11 @@ so the *next* fleet — and human contributors — never re-learn it the hard wa
|
|
|
10
10
|
You are the mechanism that lifts a lesson from "a thing one agent happened to hit" to "a thing the
|
|
11
11
|
repo now tells everyone up front."
|
|
12
12
|
|
|
13
|
+
> **Scope:** a separate **conformance** agent runs before you and audits whether the epic met its
|
|
14
|
+
> spec (posting its own report on the epic issue). That is *not* your job — do **not** re-verify
|
|
15
|
+
> acceptance or re-litigate scope here. Focus purely on distilling reusable **lessons** and
|
|
16
|
+
> promoting them into the repo.
|
|
17
|
+
|
|
13
18
|
## Input
|
|
14
19
|
|
|
15
20
|
The job payload (stdin JSON) carries:
|
package/test/worldDb.ts
CHANGED
|
@@ -89,15 +89,27 @@ function openDataSource(db: DatabaseSync): MemDataSource {
|
|
|
89
89
|
return ds;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
/** A `DataLayer` stub over a fresh in-memory db with the
|
|
93
|
-
|
|
92
|
+
/** A `DataLayer` stub over a fresh in-memory db with the given migration files applied (in order).
|
|
93
|
+
* The generic factory behind {@link memWorldData} — reused by tests that need a different durable
|
|
94
|
+
* table (e.g. `worker_durable_resume`, migration 052) without re-authoring the gateway/tx shim. */
|
|
95
|
+
export function memDataFor(migrationFiles: readonly string[]): { data: DataLayer; db: DatabaseSync } {
|
|
94
96
|
const db = new DatabaseSync(":memory:");
|
|
95
97
|
openDbs.add(db);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
// SQLite disables FK enforcement by default; enable it so migrations with foreign keys are
|
|
99
|
+
// exercised (and FK violations surface) exactly as the migration-specific tests do.
|
|
100
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
101
|
+
for (const file of migrationFiles) {
|
|
102
|
+
const sql = readFileSync(fileURLToPath(new URL(`../db/migrations/${file}`, import.meta.url)), "utf8");
|
|
103
|
+
db.exec(sql);
|
|
104
|
+
}
|
|
98
105
|
const data = {
|
|
99
106
|
table: (name: string, pk = "id") => gateway(db, name, pk),
|
|
100
107
|
open: () => openDataSource(db),
|
|
101
108
|
} as unknown as DataLayer;
|
|
102
109
|
return { data, db };
|
|
103
110
|
}
|
|
111
|
+
|
|
112
|
+
/** A `DataLayer` stub over a fresh in-memory db with the world schema applied. */
|
|
113
|
+
export function memWorldData(): { data: DataLayer; db: DatabaseSync } {
|
|
114
|
+
return memDataFor(["049_world_checkpoint.sql"]);
|
|
115
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import { assertEquals } from "#test-assert";
|
|
3
|
+
import { noopLog } from "../../test/log.ts";
|
|
4
|
+
import handler from "./worker.ts";
|
|
5
|
+
|
|
6
|
+
function fakeApp() {
|
|
7
|
+
const stores: Record<string, any[]> = { plan_conformance: [] };
|
|
8
|
+
const seq: Record<string, number> = {};
|
|
9
|
+
function tbl(name: string, pk = "id") {
|
|
10
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
11
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
12
|
+
return {
|
|
13
|
+
async insert(row: any) {
|
|
14
|
+
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
15
|
+
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
16
|
+
return pk === "id" ? id : row[pk];
|
|
17
|
+
},
|
|
18
|
+
async find(where: any = {}) {
|
|
19
|
+
return rows.filter((r) => match(r, where));
|
|
20
|
+
},
|
|
21
|
+
async findOne(where: any = {}) {
|
|
22
|
+
return rows.find((r) => match(r, where));
|
|
23
|
+
},
|
|
24
|
+
async get(id: any) {
|
|
25
|
+
return rows.find((row) => row[pk] === id);
|
|
26
|
+
},
|
|
27
|
+
async update(id: any, patch: any) {
|
|
28
|
+
const r = rows.find((row) => row[pk] === id);
|
|
29
|
+
if (r) Object.assign(r, patch);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: noopLog() };
|
|
34
|
+
return { app, stores };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("conformance-record: persists a filed conformance from hoisted result vars", async () => {
|
|
38
|
+
const { app, stores } = fakeApp();
|
|
39
|
+
await handler(
|
|
40
|
+
{
|
|
41
|
+
variables: {
|
|
42
|
+
planKey: "o/r#5",
|
|
43
|
+
status: "filed",
|
|
44
|
+
commentUrl: "https://github.com/o/r/issues/5#issuecomment-1",
|
|
45
|
+
slicesMet: 4,
|
|
46
|
+
slicesReduced: 1,
|
|
47
|
+
slicesNotVerified: 1,
|
|
48
|
+
deviationsRaised: 2,
|
|
49
|
+
deviationsUnraised: 1,
|
|
50
|
+
hasDeviations: true,
|
|
51
|
+
summary: "6 items, 4 met",
|
|
52
|
+
"io.nanobpm.agentResult": { output: "the full conformance report" },
|
|
53
|
+
},
|
|
54
|
+
} as any,
|
|
55
|
+
app as any,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
assertEquals(stores.plan_conformance.length, 1);
|
|
59
|
+
const row = stores.plan_conformance[0];
|
|
60
|
+
assertEquals(row.status, "filed");
|
|
61
|
+
assertEquals(row.comment_url, "https://github.com/o/r/issues/5#issuecomment-1");
|
|
62
|
+
assertEquals(row.slices_met, 4);
|
|
63
|
+
assertEquals(row.slices_reduced, 1);
|
|
64
|
+
assertEquals(row.slices_not_verified, 1);
|
|
65
|
+
assertEquals(row.deviations_raised, 2);
|
|
66
|
+
assertEquals(row.deviations_unraised, 1);
|
|
67
|
+
assertEquals(row.has_deviations, 1);
|
|
68
|
+
assertEquals(row.report, "the full conformance report");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("conformance-record: derives has_deviations from ground truth even when the agent flag is absent", async () => {
|
|
72
|
+
const { app, stores } = fakeApp();
|
|
73
|
+
await handler(
|
|
74
|
+
{ variables: { planKey: "o/r#6", status: "filed", commentUrl: "https://x/6#c", slicesNotVerified: 1 } } as any,
|
|
75
|
+
app as any,
|
|
76
|
+
);
|
|
77
|
+
// The agent didn't set hasDeviations, but a not-verified item means the epic didn't cleanly meet spec.
|
|
78
|
+
assertEquals(stores.plan_conformance[0].has_deviations, 1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("conformance-record: a clean epic records has_deviations = 0", async () => {
|
|
82
|
+
const { app, stores } = fakeApp();
|
|
83
|
+
await handler(
|
|
84
|
+
{ variables: { planKey: "o/r#7", status: "filed", commentUrl: "https://x/7#c", slicesMet: 3, hasDeviations: false } } as any,
|
|
85
|
+
app as any,
|
|
86
|
+
);
|
|
87
|
+
assertEquals(stores.plan_conformance[0].has_deviations, 0);
|
|
88
|
+
assertEquals(stores.plan_conformance[0].slices_met, 3);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("conformance-record: coerces filed without a comment URL to skipped", async () => {
|
|
92
|
+
const { app, stores } = fakeApp();
|
|
93
|
+
await handler(
|
|
94
|
+
{ variables: { planKey: "o/r#8", status: "filed", summary: "forgot to post" } } as any,
|
|
95
|
+
app as any,
|
|
96
|
+
);
|
|
97
|
+
assertEquals(stores.plan_conformance[0].status, "skipped");
|
|
98
|
+
assertEquals(stores.plan_conformance[0].comment_url, null);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("conformance-record: a non-filed status carries no verdict counts or deviations", async () => {
|
|
102
|
+
const { app, stores } = fakeApp();
|
|
103
|
+
// A "filed" that downgrades to skipped (no comment) must not persist the agent's counts /
|
|
104
|
+
// has_deviations — a skipped/blocked audit produced no verified verdict, so the row would be
|
|
105
|
+
// internally inconsistent (status=skipped yet has_deviations=1 with non-zero counts).
|
|
106
|
+
await handler(
|
|
107
|
+
{
|
|
108
|
+
variables: {
|
|
109
|
+
planKey: "o/r#11",
|
|
110
|
+
status: "filed",
|
|
111
|
+
slicesMet: 4,
|
|
112
|
+
slicesReduced: 1,
|
|
113
|
+
slicesNotVerified: 1,
|
|
114
|
+
deviationsRaised: 2,
|
|
115
|
+
deviationsUnraised: 1,
|
|
116
|
+
hasDeviations: true,
|
|
117
|
+
summary: "audit ran but never posted",
|
|
118
|
+
"io.nanobpm.agentResult": { output: "transcript explaining why" },
|
|
119
|
+
},
|
|
120
|
+
} as any,
|
|
121
|
+
app as any,
|
|
122
|
+
);
|
|
123
|
+
const row = stores.plan_conformance[0];
|
|
124
|
+
assertEquals(row.status, "skipped");
|
|
125
|
+
assertEquals(row.slices_met, 0);
|
|
126
|
+
assertEquals(row.slices_reduced, 0);
|
|
127
|
+
assertEquals(row.slices_not_verified, 0);
|
|
128
|
+
assertEquals(row.deviations_raised, 0);
|
|
129
|
+
assertEquals(row.deviations_unraised, 0);
|
|
130
|
+
assertEquals(row.has_deviations, 0);
|
|
131
|
+
// summary + report are human-readable context — retained so a skipped/blocked row still explains itself.
|
|
132
|
+
assertEquals(row.summary, "audit ran but never posted");
|
|
133
|
+
assertEquals(row.report, "transcript explaining why");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("conformance-record: coerces string-encoded numeric counts hoisted by the agent", async () => {
|
|
137
|
+
const { app, stores } = fakeApp();
|
|
138
|
+
// The agentTask runner hoists result-JSON keys as-is; an agent may emit counts as strings ("1").
|
|
139
|
+
// These must be parsed, not silently coerced to 0 (which would wrongly clear the verdict).
|
|
140
|
+
await handler(
|
|
141
|
+
{
|
|
142
|
+
variables: {
|
|
143
|
+
planKey: "o/r#12",
|
|
144
|
+
status: "filed",
|
|
145
|
+
commentUrl: "https://x/12#c",
|
|
146
|
+
slicesMet: "4",
|
|
147
|
+
slicesReduced: "1",
|
|
148
|
+
slicesNotVerified: "0",
|
|
149
|
+
deviationsRaised: "2",
|
|
150
|
+
deviationsUnraised: "0",
|
|
151
|
+
hasDeviations: false,
|
|
152
|
+
},
|
|
153
|
+
} as any,
|
|
154
|
+
app as any,
|
|
155
|
+
);
|
|
156
|
+
const row = stores.plan_conformance[0];
|
|
157
|
+
assertEquals(row.slices_met, 4);
|
|
158
|
+
assertEquals(row.slices_reduced, 1);
|
|
159
|
+
assertEquals(row.deviations_raised, 2);
|
|
160
|
+
// A reduced item is ground truth for a deviation even though the agent's flag was false.
|
|
161
|
+
assertEquals(row.has_deviations, 1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("conformance-record: honours a string-encoded hasDeviations flag", async () => {
|
|
165
|
+
const { app, stores } = fakeApp();
|
|
166
|
+
// A clean epic (no reduced / not-verified / unraised) where the agent emits hasDeviations as the
|
|
167
|
+
// string "true" must still record a deviation — a stringified boolean can't silently be dropped.
|
|
168
|
+
await handler(
|
|
169
|
+
{
|
|
170
|
+
variables: {
|
|
171
|
+
planKey: "o/r#13",
|
|
172
|
+
status: "filed",
|
|
173
|
+
commentUrl: "https://x/13#c",
|
|
174
|
+
slicesMet: 3,
|
|
175
|
+
hasDeviations: "true",
|
|
176
|
+
},
|
|
177
|
+
} as any,
|
|
178
|
+
app as any,
|
|
179
|
+
);
|
|
180
|
+
assertEquals(stores.plan_conformance[0].has_deviations, 1);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("conformance-record: honours an explicit blocked status", async () => {
|
|
184
|
+
const { app, stores } = fakeApp();
|
|
185
|
+
await handler(
|
|
186
|
+
{ variables: { planKey: "o/r#9", status: "blocked", summary: "no read access" } } as any,
|
|
187
|
+
app as any,
|
|
188
|
+
);
|
|
189
|
+
assertEquals(stores.plan_conformance[0].status, "blocked");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("conformance-record: defaults to skipped when the agent reported nothing", async () => {
|
|
193
|
+
const { app, stores } = fakeApp();
|
|
194
|
+
await handler(
|
|
195
|
+
{ variables: { planKey: "o/r#10", summary: "nothing shipped" } } as any,
|
|
196
|
+
app as any,
|
|
197
|
+
);
|
|
198
|
+
assertEquals(stores.plan_conformance[0].status, "skipped");
|
|
199
|
+
});
|