@nanobpm/nano-workforce 0.188.0 → 0.189.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 +12 -0
- package/app/agentic/cockpit/mount.test.ts +50 -0
- package/app/agentic/cockpit/supply-render.test.ts +20 -0
- package/app/agentic/cockpit/supply-render.ts +15 -0
- package/app/agentic/cockpit/supply-view.ts +19 -2
- package/app/agentic/vocab/demand-report.test.ts +66 -1
- package/app/agentic/vocab/demand-report.ts +54 -6
- package/app/contracts.ts +24 -0
- package/app/fineGrainedCells.test.ts +23 -0
- package/app/harnessProtocol.test.ts +170 -0
- package/app/harnessProtocol.ts +312 -0
- package/app/implementReconcile.test.ts +166 -0
- package/app/implementReconcile.ts +112 -0
- package/app/mcpToolSurface.ts +7 -1
- package/db/migrations/107_worker_harness_protocol.sql +30 -0
- package/e2e/feature-run.e2e.ts +45 -1
- package/e2e/support/github-admit.ts +23 -0
- package/nano.app.json +4 -0
- package/openapi.yaml +71 -1
- package/operations/enrolAgenticWorker.test.ts +84 -0
- package/operations/enrolAgenticWorker.ts +67 -7
- package/operations/getAgenticRegistry.ts +1 -1
- package/operations/getAgenticSupply.test.ts +80 -0
- package/operations/getAgenticSupply.ts +15 -3
- package/package.json +1 -1
- package/pages/cockpit/mount.js +18 -0
- package/resources/processes/feature.bpmn +1 -0
- package/resources/processes/implement-cell.bpmn +77 -25
- package/resources/processes/plan-fanout.bpmn +1 -0
- package/test/worldDb.ts +6 -0
- package/workers/reconcile-implement/worker.test.ts +87 -0
- package/workers/reconcile-implement/worker.ts +60 -0
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -67,6 +67,7 @@ interface PrRow {
|
|
|
67
67
|
describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
68
68
|
const savedEnv = new Map<string, string | undefined>();
|
|
69
69
|
let restoreGithub: (() => void) | undefined;
|
|
70
|
+
const githubState = admitGithubState("owner/repo", "main");
|
|
70
71
|
|
|
71
72
|
before(() => {
|
|
72
73
|
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
@@ -75,7 +76,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
75
76
|
}
|
|
76
77
|
// ADR 0003: `startFeature` + the `pr.ensure-base-branch` head task pass through base admission,
|
|
77
78
|
// which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
|
|
78
|
-
restoreGithub = installAdmitGithub(
|
|
79
|
+
restoreGithub = installAdmitGithub(githubState);
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
after(() => {
|
|
@@ -331,6 +332,49 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
331
332
|
}
|
|
332
333
|
});
|
|
333
334
|
|
|
335
|
+
test("reconcile before escalate: a no-status result with an open PR on the branch adopts & converges (issue #801)", async () => {
|
|
336
|
+
// The #796/#801 defect: a harness returns NO machine-readable status but has pushed the branch and
|
|
337
|
+
// opened a green PR. Instead of dead-ending at a human, the cell's reconcile step observes the open
|
|
338
|
+
// PR on `feat/<task.id>` (= `feat/issue-7`), adopts it (status=opened, prKey), and converges.
|
|
339
|
+
githubState.openPrs.set("feat/issue-7", { number: 801, base: "epic/e2e" });
|
|
340
|
+
try {
|
|
341
|
+
await withApp(
|
|
342
|
+
{ "senior:feature": () => ({ summary: "opened a PR but reported no status" }) },
|
|
343
|
+
{ baseBranch: "epic/e2e", converge: true },
|
|
344
|
+
async ({ app, featureKey }) => {
|
|
345
|
+
const flows = takenFlows(app);
|
|
346
|
+
assert.ok(
|
|
347
|
+
flows.includes("ic_reconcile_gw->ic_end"),
|
|
348
|
+
`the adopted PR routed straight to the cell's done end (flows: ${flows.join(", ")})`,
|
|
349
|
+
);
|
|
350
|
+
assert.ok(
|
|
351
|
+
!flows.includes("ic_reconcile_gw->record-escalation"),
|
|
352
|
+
"the run did NOT escalate to a human",
|
|
353
|
+
);
|
|
354
|
+
assert.ok(
|
|
355
|
+
flows.includes("gw-converge->converge"),
|
|
356
|
+
`the adopted PR was handed to the convergence loop (flows: ${flows.join(", ")})`,
|
|
357
|
+
);
|
|
358
|
+
const run = await featureRow(app, featureKey);
|
|
359
|
+
assert.equal(run.status, "converging", "the reconciled run settled at converging");
|
|
360
|
+
assert.equal(run.pr_key, "owner/repo#801", "the adopted PR key is recorded on the run");
|
|
361
|
+
const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({ pr_key: "owner/repo#801" });
|
|
362
|
+
assert.equal(prs.length, 1, "the adopted PR was enrolled into the convergence loop (submitPr)");
|
|
363
|
+
|
|
364
|
+
// A native user-task escalation was never parked — the machine-recoverable outcome was
|
|
365
|
+
// reconciled without pulling in a person.
|
|
366
|
+
const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: run.process_key! });
|
|
367
|
+
assert.ok(
|
|
368
|
+
!tasks.some((t) => t.elementId === "escalation"),
|
|
369
|
+
"no human-escalation task was created for the adopted run",
|
|
370
|
+
);
|
|
371
|
+
},
|
|
372
|
+
);
|
|
373
|
+
} finally {
|
|
374
|
+
githubState.openPrs.delete("feat/issue-7");
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
334
378
|
test("escalate + abandon: abandoning routes to record-feature (default flow)", async () => {
|
|
335
379
|
await withApp(
|
|
336
380
|
{
|
|
@@ -16,6 +16,10 @@ export interface AdmitGithubState {
|
|
|
16
16
|
branches: Map<string, string>; // branch → head sha
|
|
17
17
|
creates: { ref: string; sha: string }[];
|
|
18
18
|
resets: string[]; // any PATCH/force-update on an existing ref (must stay empty)
|
|
19
|
+
/** Open PRs keyed by head branch (issue #801): the implement-cell reconcile step lists PRs for a
|
|
20
|
+
* head via `listPrsForHead`. Empty by default → the pulls listing returns `[]` (no adoptable PR),
|
|
21
|
+
* so suites that don't opt in keep exactly today's escalate behaviour. */
|
|
22
|
+
openPrs: Map<string, { number: number; base?: string }>;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/** Build a fresh admit-github state with the default branch pre-seeded with a HEAD sha so an
|
|
@@ -30,6 +34,7 @@ export function admitGithubState(
|
|
|
30
34
|
branches: new Map([[defaultBranch, "0".repeat(40)]]),
|
|
31
35
|
creates: [],
|
|
32
36
|
resets: [],
|
|
37
|
+
openPrs: new Map(),
|
|
33
38
|
};
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -72,6 +77,24 @@ function admitFetch(state: AdmitGithubState) {
|
|
|
72
77
|
state.resets.push(decodeURIComponent(path.split("/git/refs/heads/")[1] ?? ""));
|
|
73
78
|
return Promise.resolve(json({ ok: true }));
|
|
74
79
|
}
|
|
80
|
+
// GET /repos/{repo}/pulls?state=…&head=owner:branch → the open PRs for a head branch, as read by
|
|
81
|
+
// `listPrsForHead` (the implement-cell reconcile step, issue #801). Default empty state → `[]`.
|
|
82
|
+
if (method === "GET" && path === `/repos/${state.repo}/pulls`) {
|
|
83
|
+
const head = u.searchParams.get("head") ?? "";
|
|
84
|
+
const branch = head.includes(":") ? head.slice(head.indexOf(":") + 1) : head;
|
|
85
|
+
const hit = state.openPrs.get(branch);
|
|
86
|
+
if (!hit) return Promise.resolve(json([]));
|
|
87
|
+
return Promise.resolve(
|
|
88
|
+
json([
|
|
89
|
+
{
|
|
90
|
+
number: hit.number,
|
|
91
|
+
html_url: `https://github.com/${state.repo}/pull/${hit.number}`,
|
|
92
|
+
state: "open",
|
|
93
|
+
base: { ref: hit.base ?? state.defaultBranch },
|
|
94
|
+
},
|
|
95
|
+
]),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
75
98
|
// Any other endpoint is a best-effort read the sealed transport used to skip → 404 (null).
|
|
76
99
|
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
|
77
100
|
};
|
package/nano.app.json
CHANGED
|
@@ -239,6 +239,10 @@
|
|
|
239
239
|
{
|
|
240
240
|
"taskType": "pr.record-feature-implementing",
|
|
241
241
|
"handler": "workers/record-feature-implementing/worker.ts"
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
"taskType": "pr.reconcile-implement",
|
|
245
|
+
"handler": "workers/reconcile-implement/worker.ts"
|
|
242
246
|
}
|
|
243
247
|
],
|
|
244
248
|
"externalTaskTypes": [
|
package/openapi.yaml
CHANGED
|
@@ -429,6 +429,7 @@ components:
|
|
|
429
429
|
- jobKeys
|
|
430
430
|
- live
|
|
431
431
|
- staleMs
|
|
432
|
+
- harnessStale
|
|
432
433
|
properties:
|
|
433
434
|
instance:
|
|
434
435
|
type: string
|
|
@@ -456,6 +457,17 @@ components:
|
|
|
456
457
|
staleMs:
|
|
457
458
|
type: integer
|
|
458
459
|
description: Milliseconds since the last liveness refresh (0 when fresh).
|
|
460
|
+
harnessProtocol:
|
|
461
|
+
type: integer
|
|
462
|
+
minimum: 0
|
|
463
|
+
description: The worker-harness protocol version this worker advertised at enrolment (issue #802), when a numeric one is known.
|
|
464
|
+
harnessStale:
|
|
465
|
+
type: boolean
|
|
466
|
+
description: >-
|
|
467
|
+
Whether this worker's harness is STALE (issue #802) — below the configured minimum protocol
|
|
468
|
+
or advertising no version at all — so it may silently swallow AgentInstance / transcript /
|
|
469
|
+
result-envelope artifacts. Surfaced so the operator can drain it. Distinct from the
|
|
470
|
+
liveness `staleMs` heartbeat grade.
|
|
459
471
|
AgenticSupplyLeaf:
|
|
460
472
|
type: object
|
|
461
473
|
description: The supply for one leaf token — the workers registered under it.
|
|
@@ -622,6 +634,16 @@ components:
|
|
|
622
634
|
sets false) redrives a re-leased round from scratch. Recorded only when `instance` is
|
|
623
635
|
a non-blank string — a missing, empty, or whitespace-only `instance` is echoed back for
|
|
624
636
|
provenance but the flag is not persisted.
|
|
637
|
+
harnessProtocol:
|
|
638
|
+
type: integer
|
|
639
|
+
minimum: 0
|
|
640
|
+
description: >-
|
|
641
|
+
The worker-harness protocol version (issue #802) — a non-negative integer declaring which
|
|
642
|
+
machine-readable artifacts the harness emits (AgentInstance, transcript flush, result
|
|
643
|
+
envelope). An ENROLMENT attribute, never a routing token. Recorded per instance so the app
|
|
644
|
+
can flag a stale harness in getAgenticSupply / the registry and — under
|
|
645
|
+
NANO_AGENTIC_STALE_HARNESS_POLICY=refuse — refuse it agent-job routing. A missing version is
|
|
646
|
+
treated as stale.
|
|
625
647
|
EnrolledRole:
|
|
626
648
|
type: object
|
|
627
649
|
description: One matched role in an enrolment resolution — provenance for the resolved SERVE set.
|
|
@@ -643,6 +665,7 @@ components:
|
|
|
643
665
|
- roles
|
|
644
666
|
- demandVersion
|
|
645
667
|
- leaseTtl
|
|
668
|
+
- harnessStale
|
|
646
669
|
properties:
|
|
647
670
|
instance:
|
|
648
671
|
type: string
|
|
@@ -655,6 +678,20 @@ components:
|
|
|
655
678
|
guarantee of durable persistence — recording into the durable-resume registry is
|
|
656
679
|
best-effort (skipped when `instance` is absent/blank, and a registry write hiccup is
|
|
657
680
|
logged without failing enrolment).
|
|
681
|
+
harnessProtocol:
|
|
682
|
+
type: integer
|
|
683
|
+
minimum: 0
|
|
684
|
+
description: >-
|
|
685
|
+
Echo of the request's advertised harness protocol version (issue #802). Present only when
|
|
686
|
+
the request supplied it.
|
|
687
|
+
harnessStale:
|
|
688
|
+
type: boolean
|
|
689
|
+
description: >-
|
|
690
|
+
Whether this worker's harness is STALE (issue #802) — below the configured minimum protocol
|
|
691
|
+
(NANO_AGENTIC_MIN_HARNESS_PROTOCOL) or advertising no version at all. Always present. Under
|
|
692
|
+
NANO_AGENTIC_STALE_HARNESS_POLICY=refuse a stale harness is handed an EMPTY `serve` set so
|
|
693
|
+
it wins no job leases; under the default `flag` policy `serve` is unchanged and the worker
|
|
694
|
+
is only flagged for observability/drain.
|
|
658
695
|
serve:
|
|
659
696
|
type: array
|
|
660
697
|
description: The SERVE token set — sorted, de-duplicated leaf tokens the worker may serve.
|
|
@@ -812,7 +849,36 @@ components:
|
|
|
812
849
|
status:
|
|
813
850
|
type: string
|
|
814
851
|
enum: [green, amber, red]
|
|
815
|
-
description:
|
|
852
|
+
description: >-
|
|
853
|
+
The overall SLO — worst of the missing-agent signal and the diversity SLO, and folded to
|
|
854
|
+
`red` when any enrolled harness is stale (`staleWorkers` non-empty, issue #802), since the
|
|
855
|
+
board renders only this pill as its overall signal.
|
|
856
|
+
staleWorkers:
|
|
857
|
+
type: array
|
|
858
|
+
description: >-
|
|
859
|
+
The enrolled workers whose harness is STALE (issue #802) — below the configured minimum
|
|
860
|
+
protocol or advertising no version at all — so they may silently swallow AgentInstance /
|
|
861
|
+
transcript / result-envelope artifacts and should be drained. Present (possibly empty) when
|
|
862
|
+
the app's harness-protocol registry is available.
|
|
863
|
+
items:
|
|
864
|
+
$ref: "#/components/schemas/StaleWorker"
|
|
865
|
+
StaleWorker:
|
|
866
|
+
type: object
|
|
867
|
+
description: One enrolled worker flagged as running a stale harness (issue #802).
|
|
868
|
+
required:
|
|
869
|
+
- instance
|
|
870
|
+
- stale
|
|
871
|
+
properties:
|
|
872
|
+
instance:
|
|
873
|
+
type: string
|
|
874
|
+
description: The worker instance id.
|
|
875
|
+
harnessProtocol:
|
|
876
|
+
type: integer
|
|
877
|
+
minimum: 0
|
|
878
|
+
description: The advertised harness protocol version, when a numeric one is known (omitted when none was advertised).
|
|
879
|
+
stale:
|
|
880
|
+
type: boolean
|
|
881
|
+
description: Whether the worker's harness is stale (always true for entries in this list).
|
|
816
882
|
AgenticTranscript:
|
|
817
883
|
type: object
|
|
818
884
|
description: One captured agent session's transcript metadata (H3/#146 transcript store). A durable
|
|
@@ -3993,6 +4059,10 @@ paths:
|
|
|
3993
4059
|
durableResume:
|
|
3994
4060
|
type: boolean
|
|
3995
4061
|
description: "Whether this worker's harness advertises durable-resume (issue #325, ADR 0062 Slice 5/5) — an ENROLMENT attribute, never a routing token. Recorded per instance so the app emits the world-restore marker only to a fleet with a participant; a harness that omits it (or sets false) redrives a re-leased round from scratch. Recorded only when `instance` is a non-blank string — a missing, empty, or whitespace-only `instance` is echoed back for provenance but the flag is not persisted."
|
|
4062
|
+
harnessProtocol:
|
|
4063
|
+
type: integer
|
|
4064
|
+
minimum: 0
|
|
4065
|
+
description: 'The worker-harness protocol version (issue #802) — a non-negative integer declaring which machine-readable artifacts the harness emits (AgentInstance, transcript flush, result envelope). An ENROLMENT attribute, never a routing token. Recorded per instance so the app can flag a stale harness in getAgenticSupply / the registry and — under NANO_AGENTIC_STALE_HARNESS_POLICY=refuse — refuse it agent-job routing. A missing version is treated as stale.'
|
|
3996
4066
|
# END generated:mcp-body
|
|
3997
4067
|
responses:
|
|
3998
4068
|
"200":
|
|
@@ -4,6 +4,7 @@ import { assert, assertEquals } from "#test-assert";
|
|
|
4
4
|
import type { AppApi } from "@nanobpm/urban";
|
|
5
5
|
import { memDataFor } from "../test/worldDb.ts";
|
|
6
6
|
import { DurableResumeRegistry } from "../app/durableResume.ts";
|
|
7
|
+
import { HarnessProtocolRegistry } from "../app/harnessProtocol.ts";
|
|
7
8
|
import { noopLog } from "../test/log.ts";
|
|
8
9
|
import handler from "./enrolAgenticWorker.ts";
|
|
9
10
|
|
|
@@ -151,3 +152,86 @@ test("enforces the shared secret when NANO_PR_WEBHOOK_SECRET is set", async () =
|
|
|
151
152
|
else process.env["NANO_PR_WEBHOOK_SECRET"] = prev;
|
|
152
153
|
}
|
|
153
154
|
});
|
|
155
|
+
|
|
156
|
+
// Harness-protocol enrolment gate (issue #802).
|
|
157
|
+
const HARNESS_MIGRATIONS = ["052_worker_durable_resume.sql", "107_worker_harness_protocol.sql"];
|
|
158
|
+
|
|
159
|
+
test("echoes harnessProtocol and reports harnessStale=false for a healthy protocol (>= minimum)", async () => {
|
|
160
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", harnessProtocol: 2 }), app)) as any;
|
|
161
|
+
assertEquals(res.status, 200);
|
|
162
|
+
assertEquals(res.body.harnessProtocol, 2);
|
|
163
|
+
assertEquals(res.body.harnessStale, false);
|
|
164
|
+
// No routing regression under the default `flag` policy: SERVE is unchanged.
|
|
165
|
+
assert(res.body.serve.includes("decide"));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("flags harnessStale=true when the harness advertises no version at all (absent = stale)", async () => {
|
|
169
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), app)) as any;
|
|
170
|
+
assertEquals(res.status, 200);
|
|
171
|
+
assertEquals("harnessProtocol" in res.body, false, "no protocol echoed when none advertised");
|
|
172
|
+
assertEquals(res.body.harnessStale, true);
|
|
173
|
+
// Default `flag` policy: a stale harness is still routed (only flagged), so no fleet regression.
|
|
174
|
+
assert(res.body.serve.includes("decide"), "flag policy leaves SERVE intact");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("flags harnessStale=true for a below-minimum protocol", async () => {
|
|
178
|
+
const prev = process.env["NANO_AGENTIC_MIN_HARNESS_PROTOCOL"];
|
|
179
|
+
process.env["NANO_AGENTIC_MIN_HARNESS_PROTOCOL"] = "3";
|
|
180
|
+
try {
|
|
181
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", harnessProtocol: 1 }), app)) as any;
|
|
182
|
+
assertEquals(res.status, 200);
|
|
183
|
+
assertEquals(res.body.harnessStale, true);
|
|
184
|
+
} finally {
|
|
185
|
+
if (prev === undefined) delete process.env["NANO_AGENTIC_MIN_HARNESS_PROTOCOL"];
|
|
186
|
+
else process.env["NANO_AGENTIC_MIN_HARNESS_PROTOCOL"] = prev;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("rejects a non-integer/negative harnessProtocol as 400", async () => {
|
|
191
|
+
const nonInt = (await handler(input({ capability: { cognition: "decide" }, harnessProtocol: 1.5 }), app)) as any;
|
|
192
|
+
assertEquals(nonInt.status, 400);
|
|
193
|
+
const negative = (await handler(input({ capability: { cognition: "decide" }, harnessProtocol: -1 }), app)) as any;
|
|
194
|
+
assertEquals(negative.status, 400);
|
|
195
|
+
const str = (await handler(input({ capability: { cognition: "decide" }, harnessProtocol: "2" }), app)) as any;
|
|
196
|
+
assertEquals(str.status, 400);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("records the advertised harness protocol in the registry when a data layer + instance are present", async () => {
|
|
200
|
+
const { data } = memDataFor(HARNESS_MIGRATIONS);
|
|
201
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
202
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1", harnessProtocol: 2 }), withData)) as any;
|
|
203
|
+
assertEquals(res.status, 200);
|
|
204
|
+
assertEquals(await new HarnessProtocolRegistry(data).protocolFor("w1"), 2);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("a re-enrol WITHOUT a protocol clears a stale-healthy recorded value (degrade to stale)", async () => {
|
|
208
|
+
const { data } = memDataFor(HARNESS_MIGRATIONS);
|
|
209
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
210
|
+
await handler(input({ capability: { cognition: "decide" }, instance: "w1", harnessProtocol: 3 }), withData);
|
|
211
|
+
assertEquals(await new HarnessProtocolRegistry(data).protocolFor("w1"), 3);
|
|
212
|
+
const res = (await handler(input({ capability: { cognition: "decide" }, instance: "w1" }), withData)) as any;
|
|
213
|
+
assertEquals(res.status, 200);
|
|
214
|
+
assertEquals(await new HarnessProtocolRegistry(data).protocolFor("w1"), undefined, "stale-healthy value cleared");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("under the `refuse` policy a stale harness is handed an EMPTY SERVE set (no job leases)", async () => {
|
|
218
|
+
const prev = process.env["NANO_AGENTIC_STALE_HARNESS_POLICY"];
|
|
219
|
+
process.env["NANO_AGENTIC_STALE_HARNESS_POLICY"] = "refuse";
|
|
220
|
+
try {
|
|
221
|
+
const mod = await import(`./enrolAgenticWorker.ts?refuse=${Date.now()}`);
|
|
222
|
+
const guarded = mod.default as typeof handler;
|
|
223
|
+
// A stale (version-less) worker: SERVE withheld.
|
|
224
|
+
const stale = (await guarded(input({ capability: { cognition: "decide" }, instance: "w1" }), app)) as any;
|
|
225
|
+
assertEquals(stale.status, 200);
|
|
226
|
+
assertEquals(stale.body.harnessStale, true);
|
|
227
|
+
assertEquals(stale.body.serve, [], "refuse policy withholds SERVE for a stale harness");
|
|
228
|
+
assertEquals(stale.body.roles, []);
|
|
229
|
+
// A healthy worker is routed exactly as today.
|
|
230
|
+
const healthy = (await guarded(input({ capability: { cognition: "decide" }, instance: "w2", harnessProtocol: 5 }), app)) as any;
|
|
231
|
+
assertEquals(healthy.body.harnessStale, false);
|
|
232
|
+
assert(healthy.body.serve.includes("decide"), "healthy harness routed under refuse policy");
|
|
233
|
+
} finally {
|
|
234
|
+
if (prev === undefined) delete process.env["NANO_AGENTIC_STALE_HARNESS_POLICY"];
|
|
235
|
+
else process.env["NANO_AGENTIC_STALE_HARNESS_POLICY"] = prev;
|
|
236
|
+
}
|
|
237
|
+
});
|
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
import type { Capability } from "@nanobpm/agentic/protocol";
|
|
14
14
|
import { resolveEnrolment } from "../app/agentic/vocab/enrol.ts";
|
|
15
15
|
import { DurableResumeRegistry } from "../app/durableResume.ts";
|
|
16
|
+
import {
|
|
17
|
+
HarnessProtocolRegistry,
|
|
18
|
+
isStaleProtocol,
|
|
19
|
+
minHarnessProtocol,
|
|
20
|
+
staleHarnessPolicy,
|
|
21
|
+
} from "../app/harnessProtocol.ts";
|
|
16
22
|
import { envVar } from "../app/version.ts";
|
|
17
23
|
import type { EnrolResult } from "../nano-generated/api-io.d.ts";
|
|
18
24
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -73,6 +79,22 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
|
|
|
73
79
|
app.log.warn("enrolAgenticWorker rejected: non-boolean durableResume");
|
|
74
80
|
return { status: 400, body: { error: "`durableResume` must be a boolean when provided" } };
|
|
75
81
|
}
|
|
82
|
+
// The harness-protocol enrolment attribute (issue #802) — a non-negative integer the harness
|
|
83
|
+
// advertises declaring which machine-readable artifacts it emits (AgentInstance, transcript flush,
|
|
84
|
+
// result envelope). A directly-invoked delegate bypasses the OpenAPI runtime validation, so guard the
|
|
85
|
+
// type here: a non-integer / negative value would corrupt the persisted staleness gate.
|
|
86
|
+
if (
|
|
87
|
+
body.harnessProtocol !== undefined &&
|
|
88
|
+
(typeof body.harnessProtocol !== "number" ||
|
|
89
|
+
!Number.isInteger(body.harnessProtocol) ||
|
|
90
|
+
body.harnessProtocol < 0)
|
|
91
|
+
) {
|
|
92
|
+
app.log.warn("enrolAgenticWorker rejected: non-integer harnessProtocol");
|
|
93
|
+
return {
|
|
94
|
+
status: 400,
|
|
95
|
+
body: { error: "`harnessProtocol` must be a non-negative integer when provided" },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
76
98
|
|
|
77
99
|
// Fold a top-level `host` into the capability when the capability didn't carry its own — a worker
|
|
78
100
|
// may declare its host either on the capability or beside it (ADR 0059 `{ capability, host }`).
|
|
@@ -102,19 +124,57 @@ export default defineOperation("enrolAgenticWorker", async ({ req, body }, app)
|
|
|
102
124
|
}
|
|
103
125
|
}
|
|
104
126
|
|
|
127
|
+
// Harness-protocol enrolment gate (issue #802): record the advertised protocol per instance so the
|
|
128
|
+
// app can expose it in getAgenticSupply / the registry and gate on it. Recorded even on a downgrade
|
|
129
|
+
// (a re-enrol WITHOUT a version clears a stale-healthy value to absent → stale). Like durable-resume
|
|
130
|
+
// it needs a non-blank `instance` and is best-effort — a registry write hiccup must not fail enrol.
|
|
131
|
+
if (app.data && instanceKey) {
|
|
132
|
+
try {
|
|
133
|
+
await new HarnessProtocolRegistry(app.data).recordEnrolment(instanceKey, body.harnessProtocol);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
app.log.warn("enrolAgenticWorker: harness-protocol record failed", { instance: instanceKey, err: String(err) });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Derive staleness from the just-advertised protocol against the configured minimum (absent version =
|
|
140
|
+
// stale, the #802 signature). Under the `refuse` policy a stale harness is handed an EMPTY SERVE set
|
|
141
|
+
// so it wins no job leases — a REGISTER→SERVE gate, never an engine/job-protocol change. Under the
|
|
142
|
+
// default `flag` policy the SERVE set is unchanged (no routing regression); the worker is only
|
|
143
|
+
// flagged so the cockpit can surface it for drain.
|
|
144
|
+
const harnessStale = isStaleProtocol(body.harnessProtocol, minHarnessProtocol());
|
|
145
|
+
const refuseRouting = harnessStale && staleHarnessPolicy() === "refuse";
|
|
146
|
+
if (refuseRouting) {
|
|
147
|
+
app.log.warn("enrolAgenticWorker: refusing routing for a stale harness", {
|
|
148
|
+
instance: body.instance,
|
|
149
|
+
harnessProtocol: body.harnessProtocol,
|
|
150
|
+
minHarnessProtocol: minHarnessProtocol(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
105
154
|
const result: EnrolResult = {
|
|
106
|
-
serve: [...resolved.serve],
|
|
107
|
-
roles:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
155
|
+
serve: refuseRouting ? [] : [...resolved.serve],
|
|
156
|
+
roles: refuseRouting
|
|
157
|
+
? []
|
|
158
|
+
: resolved.roles.map((role) => {
|
|
159
|
+
const out: EnrolResult["roles"][number] = { token: role.token, seatsDistinctFamily: role.seatsDistinctFamily };
|
|
160
|
+
if (role.weight !== undefined) out.weight = role.weight;
|
|
161
|
+
return out;
|
|
162
|
+
}),
|
|
112
163
|
demandVersion: resolved.demandVersion,
|
|
113
164
|
leaseTtl: resolved.leaseTtl,
|
|
165
|
+
// Always surface the staleness verdict so the caller (and the cockpit) can see a stale harness even
|
|
166
|
+
// when it advertised no version at all.
|
|
167
|
+
harnessStale,
|
|
114
168
|
};
|
|
115
169
|
if (body.instance !== undefined) result.instance = body.instance;
|
|
116
170
|
if (body.durableResume !== undefined) result.durableResume = body.durableResume;
|
|
171
|
+
if (body.harnessProtocol !== undefined) result.harnessProtocol = body.harnessProtocol;
|
|
117
172
|
|
|
118
|
-
app.log.info("agentic enrol resolved", {
|
|
173
|
+
app.log.info("agentic enrol resolved", {
|
|
174
|
+
instance: body.instance,
|
|
175
|
+
serve: result.serve,
|
|
176
|
+
family: capability.family,
|
|
177
|
+
harnessStale,
|
|
178
|
+
});
|
|
119
179
|
return { status: 200, body: result };
|
|
120
180
|
});
|
|
@@ -20,6 +20,6 @@ export default defineOperation("getAgenticRegistry", async ({ req }, app) => {
|
|
|
20
20
|
app.log.warn("getAgenticRegistry rejected: missing/invalid shared secret");
|
|
21
21
|
return { status: 401, body: { error: "unauthorized" } };
|
|
22
22
|
}
|
|
23
|
-
const report = await computeRegistryReport(app.log);
|
|
23
|
+
const report = await computeRegistryReport(app.log, app.data);
|
|
24
24
|
return { status: 200, body: toWireReport(report) };
|
|
25
25
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// by instance, family/host/jobKeys/liveness) — driven through a REAL AgenticHub + in-memory transport
|
|
6
6
|
// exactly as the presence family is exercised, so the singleton the operation reads is the live one.
|
|
7
7
|
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
8
9
|
import { test } from "node:test";
|
|
9
10
|
import { AgenticHub } from "@nanobpm/agentic/channel";
|
|
10
11
|
import type { Authenticator, ChannelConnection, ChannelTransport } from "@nanobpm/agentic/channel";
|
|
@@ -39,6 +40,19 @@ function memData(db: SqliteDb): DataLayer {
|
|
|
39
40
|
return { source: () => ({ db }) } as unknown as DataLayer;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Wrap an existing DatabaseSync as a SqliteDb (so the presence store and a raw insert share one db). */
|
|
44
|
+
function memSqliteOver(db: DatabaseSync): SqliteDb {
|
|
45
|
+
return {
|
|
46
|
+
exec: (sql) => db.exec(sql),
|
|
47
|
+
run: (sql, params = []) => {
|
|
48
|
+
const r = db.prepare(sql).run(...(params as never[]));
|
|
49
|
+
return { changes: Number(r.changes), lastInsertRowid: Number(r.lastInsertRowid) };
|
|
50
|
+
},
|
|
51
|
+
all: <T = Record<string, unknown>>(sql: string, params: unknown[] = []) =>
|
|
52
|
+
db.prepare(sql).all(...(params as never[])) as T[],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
function memTransport(): { transport: ChannelTransport; connect(conn: ChannelConnection): void } {
|
|
43
57
|
let onConnection: ((conn: ChannelConnection) => void) | undefined;
|
|
44
58
|
const transport: ChannelTransport = {
|
|
@@ -259,3 +273,69 @@ test("#738 drift: the supply advertises the producer's instance-scoped stream id
|
|
|
259
273
|
await hub.close();
|
|
260
274
|
}
|
|
261
275
|
});
|
|
276
|
+
|
|
277
|
+
// Harness staleness (issue #802): the supply report flags a worker whose harness protocol is below the
|
|
278
|
+
// minimum / absent, joined by instance from the app's harness-protocol registry.
|
|
279
|
+
test("#802: flags harnessStale per worker, joining the harness-protocol registry by instance", async () => {
|
|
280
|
+
const raw = new DatabaseSync(":memory:");
|
|
281
|
+
raw.exec("PRAGMA foreign_keys = ON;");
|
|
282
|
+
raw.exec(readFileSync(new URL("../db/migrations/107_worker_harness_protocol.sql", import.meta.url), "utf8"));
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
// wk-a advertised a healthy protocol (>= min 1); wk-a's presence row is minted below.
|
|
285
|
+
raw.prepare("INSERT INTO worker_harness_protocol (instance, harness_protocol, updated_at) VALUES (?, ?, ?)").run("wk-a", 2, now);
|
|
286
|
+
|
|
287
|
+
// A data layer providing BOTH the presence `source().db` handle and the RAD `table()` surface over
|
|
288
|
+
// the SAME db, so the presence family and the harness registry read one store.
|
|
289
|
+
const sqlite = memSqliteOver(raw);
|
|
290
|
+
const quote = (id: string) => `"${id.replace(/"/g, '""')}"`;
|
|
291
|
+
const table = (name: string, pk = "id") => ({
|
|
292
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
|
|
293
|
+
async find(where: any = {}): Promise<any[]> {
|
|
294
|
+
const keys = Object.keys(where);
|
|
295
|
+
const clause = keys.length ? `WHERE ${keys.map((k) => `${quote(k)} = ?`).join(" AND ")}` : "";
|
|
296
|
+
return raw.prepare(`SELECT * FROM ${quote(name)} ${clause}`).all(...keys.map((k) => where[k])) as any[];
|
|
297
|
+
},
|
|
298
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
|
|
299
|
+
async findOne(where: any = {}): Promise<any> {
|
|
300
|
+
return (await this.find(where))[0];
|
|
301
|
+
},
|
|
302
|
+
_pk: pk,
|
|
303
|
+
});
|
|
304
|
+
const data = {
|
|
305
|
+
source: () => ({ db: sqlite }),
|
|
306
|
+
table,
|
|
307
|
+
// The raw-SQL surface the harness registry's bounded `WHERE instance IN (…)` read binds to, over
|
|
308
|
+
// the SAME db as `table`/presence.
|
|
309
|
+
// biome-ignore lint/suspicious/noExplicitAny: test-only gateway.
|
|
310
|
+
open: () => ({ query: async (sql: string, params: any[] = []) => raw.prepare(sql).all(...params) as any[] }),
|
|
311
|
+
} as unknown as DataLayer;
|
|
312
|
+
|
|
313
|
+
const transport = memTransport();
|
|
314
|
+
const hub = new AgenticHub({ transport: transport.transport, authenticator, sweepIntervalMs: 0 });
|
|
315
|
+
await family.mount({ hub, registry: hub.registry, transport: transport.transport as never, data, log: noopLog() });
|
|
316
|
+
const healthyConn = fakeConn("c1", "leafA");
|
|
317
|
+
transport.connect(healthyConn.conn);
|
|
318
|
+
await flush();
|
|
319
|
+
healthyConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-a", capability: {} } });
|
|
320
|
+
const staleConn = fakeConn("c2", "leafA");
|
|
321
|
+
transport.connect(staleConn.conn);
|
|
322
|
+
await flush();
|
|
323
|
+
// wk-b registers but has NO harness-protocol row → absent = stale.
|
|
324
|
+
staleConn.feed({ lane: "control", family: "register", seq: 1, payload: { instance: "wk-b", capability: {} } });
|
|
325
|
+
await flush();
|
|
326
|
+
|
|
327
|
+
const withData = { log: noopLog(), data } as unknown as AppApi;
|
|
328
|
+
try {
|
|
329
|
+
const res = (await handler(input(), withData)) as { status: number; body: { workers: Array<Record<string, unknown>> } };
|
|
330
|
+
assertEquals(res.status, 200);
|
|
331
|
+
const byInstance = new Map(res.body.workers.map((w) => [w.instance, w]));
|
|
332
|
+
assertEquals(byInstance.get("wk-a")?.harnessStale, false, "healthy protocol is not stale");
|
|
333
|
+
assertEquals(byInstance.get("wk-a")?.harnessProtocol, 2);
|
|
334
|
+
assertEquals(byInstance.get("wk-b")?.harnessStale, true, "no recorded protocol = stale");
|
|
335
|
+
assertEquals("harnessProtocol" in (byInstance.get("wk-b") ?? {}), false, "no protocol echoed for a stale worker");
|
|
336
|
+
} finally {
|
|
337
|
+
family.teardown?.();
|
|
338
|
+
await hub.close();
|
|
339
|
+
raw.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { type ClaimRegistry, currentClaimRegistry } from "../app/agentic/claim-registry.ts";
|
|
24
24
|
import { currentCorrelation, type JobCorrelation } from "../app/agentic/correlation.ts";
|
|
25
25
|
import { currentPresenceRegistry, type SupplyWorker } from "../app/agentic/families/presence.family.ts";
|
|
26
|
+
import { assessWorkers, type HarnessAssessment } from "../app/harnessProtocol.ts";
|
|
26
27
|
import { envVar } from "../app/version.ts";
|
|
27
28
|
import type { AgenticJobCorrelation, AgenticSupplyReport, AgenticSupplyWorker } from "../nano-generated/api-io.d.ts";
|
|
28
29
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
@@ -36,7 +37,7 @@ const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
|
36
37
|
// (`composeStreamId(instance, jobKey)`, issue #738) when the claim registry knows a current claim for
|
|
37
38
|
// it (#713) — keyed by the CLAIM, not by the connection — so drilling in opens the LIVE job's terminal
|
|
38
39
|
// (the exact stream the producer writes) even before any transcript lands.
|
|
39
|
-
function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSupplyWorker {
|
|
40
|
+
function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined, harness: HarnessAssessment | undefined): AgenticSupplyWorker {
|
|
40
41
|
const out: AgenticSupplyWorker = {
|
|
41
42
|
instance: w.instance,
|
|
42
43
|
identity: w.identity,
|
|
@@ -44,9 +45,13 @@ function toWorker(w: SupplyWorker, claims: ClaimRegistry | undefined): AgenticSu
|
|
|
44
45
|
jobKeys: [...w.jobKeys],
|
|
45
46
|
live: w.live,
|
|
46
47
|
staleMs: w.staleMs,
|
|
48
|
+
// Harness staleness (issue #802) — an absent registry entry / unmounted data layer reads as stale
|
|
49
|
+
// (fail loud). Distinct from the liveness `staleMs` heartbeat grade above.
|
|
50
|
+
harnessStale: harness?.stale ?? true,
|
|
47
51
|
};
|
|
48
52
|
if (w.family !== undefined) out.family = w.family;
|
|
49
53
|
if (w.host !== undefined) out.host = w.host;
|
|
54
|
+
if (harness?.harnessProtocol !== undefined) out.harnessProtocol = harness.harnessProtocol;
|
|
50
55
|
return out;
|
|
51
56
|
}
|
|
52
57
|
|
|
@@ -82,11 +87,18 @@ export default defineOperation("getAgenticSupply", async ({ req }, app) => {
|
|
|
82
87
|
// process-instance / plan context surfaced in `correlations`, but no longer feeds visibility.
|
|
83
88
|
const correlation = currentCorrelation();
|
|
84
89
|
const snapshot = registry.snapshot(claims ? { jobKeysFor: (instance) => claims.jobKeysFor(instance) } : {});
|
|
90
|
+
// Harness-staleness (issue #802): assess every visible worker's advertised protocol against the
|
|
91
|
+
// configured minimum — the ONE canonical staleness derivation (no second heuristic). Best-effort:
|
|
92
|
+
// an unmounted data layer / read failure reads as stale (fail loud).
|
|
93
|
+
const harness = await assessWorkers(app.data, snapshot.workers.map((w) => w.instance));
|
|
85
94
|
const report: AgenticSupplyReport = {
|
|
86
95
|
count: snapshot.count,
|
|
87
96
|
generatedAt: new Date().toISOString(),
|
|
88
|
-
workers: snapshot.workers.map((w) => toWorker(w, claims)),
|
|
89
|
-
leaves: snapshot.leaves.map((leaf) => ({
|
|
97
|
+
workers: snapshot.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
|
|
98
|
+
leaves: snapshot.leaves.map((leaf) => ({
|
|
99
|
+
token: leaf.token,
|
|
100
|
+
workers: leaf.workers.map((w) => toWorker(w, claims, harness.get(w.instance))),
|
|
101
|
+
})),
|
|
90
102
|
correlations: correlation ? correlation.snapshot().correlations.map(toCorrelation) : [],
|
|
91
103
|
};
|
|
92
104
|
return { status: 200, body: report };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.189.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",
|
package/pages/cockpit/mount.js
CHANGED
|
@@ -84,6 +84,10 @@ function workerView(worker, staleAfterMs, byJobKey) {
|
|
|
84
84
|
correlations,
|
|
85
85
|
liveness: liveness(worker, staleAfterMs),
|
|
86
86
|
staleMs: worker.staleMs,
|
|
87
|
+
// Fail loud: default a missing harness verdict to STALE (mirrors the typed cockpit view and the
|
|
88
|
+
// server's fail-loud assessment) so an older/cached response can't hide a stale/unknown worker.
|
|
89
|
+
harnessStale: worker.harnessStale ?? true,
|
|
90
|
+
...(worker.harnessProtocol !== undefined ? { harnessProtocol: worker.harnessProtocol } : {}),
|
|
87
91
|
};
|
|
88
92
|
}
|
|
89
93
|
|
|
@@ -141,6 +145,7 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
|
|
|
141
145
|
row.setAttribute("data-worker", worker.instance);
|
|
142
146
|
row.setAttribute("data-liveness", worker.liveness);
|
|
143
147
|
row.setAttribute("data-stream", worker.stream);
|
|
148
|
+
row.setAttribute("data-harness-stale", String(worker.harnessStale));
|
|
144
149
|
|
|
145
150
|
const nameCell = el(doc, "td", "cockpit-td cockpit-supply-name");
|
|
146
151
|
nameCell.appendChild(dot(doc, worker.liveness));
|
|
@@ -162,6 +167,19 @@ function workerRow(doc, worker, onDrill, onOpenWorker) {
|
|
|
162
167
|
if (onDrill) drill.addEventListener("click", () => onDrill(worker.stream));
|
|
163
168
|
nameCell.appendChild(drill);
|
|
164
169
|
}
|
|
170
|
+
// A stale harness silently swallows machine-readable artifacts (issue #802) — surface it as a
|
|
171
|
+
// distinct badge (mirrors app/agentic/cockpit/supply-render.ts).
|
|
172
|
+
if (worker.harnessStale) {
|
|
173
|
+
const badge = el(
|
|
174
|
+
doc,
|
|
175
|
+
"span",
|
|
176
|
+
"cockpit-supply-harness-stale",
|
|
177
|
+
worker.harnessProtocol === undefined ? "stale harness" : `stale harness (v${worker.harnessProtocol})`,
|
|
178
|
+
);
|
|
179
|
+
badge.setAttribute("data-harness-stale", "true");
|
|
180
|
+
badge.setAttribute("title", "Harness protocol below the configured minimum (or none advertised); jobs may dead-end.");
|
|
181
|
+
nameCell.appendChild(badge);
|
|
182
|
+
}
|
|
165
183
|
row.appendChild(nameCell);
|
|
166
184
|
|
|
167
185
|
row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
|
|
@@ -174,6 +174,7 @@
|
|
|
174
174
|
<zeebe:calledElement processId="implement-cell" />
|
|
175
175
|
<zeebe:ioMapping>
|
|
176
176
|
<zeebe:input source="=task" target="task" />
|
|
177
|
+
<zeebe:input source="=baseBranch" target="baseBranch" />
|
|
177
178
|
<zeebe:input source="=if (is defined(baseBranchBrief)) then baseBranchBrief else null" target="baseBranchBrief" />
|
|
178
179
|
<zeebe:input source="=if (is defined(resolvedArtifacts)) then resolvedArtifacts else null" target="resolvedArtifacts" />
|
|
179
180
|
<zeebe:input source="=if (is defined(customInstructions)) then customInstructions else null" target="customInstructions" />
|