@nanobpm/nano-workforce 0.201.4 → 0.202.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/SPEC.md +104 -0
- package/app/agentOutputEnvelope.test.ts +464 -0
- package/app/agentic/README.md +48 -0
- package/app/agentic/claim-registry.test.ts +61 -0
- package/app/agentic/claim-registry.ts +35 -1
- package/app/agentic/families/claim.family.test.ts +216 -0
- package/app/agentic/families/claim.family.ts +100 -4
- package/app/contracts.ts +8 -0
- package/app/mergeLoopBehaviour.test.ts +208 -1
- package/docs/agentic-cockpit.md +34 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +108 -1
- package/resources/processes/merge-loop.bpmn +80 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.202.0](https://github.com/nanobpm/nano-workforce/compare/v0.201.5...v0.202.0) (2026-10-12)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **agentic:** authoritative claim resync on supervisor reconnect (Closes [#893](https://github.com/nanobpm/nano-workforce/issues/893)) ([#894](https://github.com/nanobpm/nano-workforce/issues/894)) ([2f39fcb](https://github.com/nanobpm/nano-workforce/commit/2f39fcb680ccafa22f633b409e2f66674ae85a7c))
|
|
6
|
+
|
|
7
|
+
## [0.201.5](https://github.com/nanobpm/nano-workforce/compare/v0.201.4...v0.201.5) (2026-10-11)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* protect engine-owned process vars from agent-completion clobber (Closes [#887](https://github.com/nanobpm/nano-workforce/issues/887)) ([#888](https://github.com/nanobpm/nano-workforce/issues/888)) ([4245287](https://github.com/nanobpm/nano-workforce/commit/4245287e3d7553881f29bdda50cbd6d2b60b6268))
|
|
12
|
+
|
|
1
13
|
## [0.201.4](https://github.com/nanobpm/nano-workforce/compare/v0.201.3...v0.201.4) (2026-10-11)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/SPEC.md
CHANGED
|
@@ -428,6 +428,79 @@ Notes:
|
|
|
428
428
|
`answer`/`scopePending` (inputs the agent consumes) stay cleared on the
|
|
429
429
|
review-round output.
|
|
430
430
|
|
|
431
|
+
- **Engine-owned process variables are restored around every external-agent task
|
|
432
|
+
(issue #887).** The engine merges **every** variable an external agent returns
|
|
433
|
+
into process scope **unfiltered** — the `io.nanobpm.dataEnvelope.out` shape is
|
|
434
|
+
codegen/typing-only and applies **no** runtime filtering or coercion. An agent
|
|
435
|
+
that echoes an engine-owned variable with a colliding name or drifted type (a
|
|
436
|
+
`senior:pr-review` job that returned `round: "4"` — a string) therefore
|
|
437
|
+
overwrites the real process value, and the next FEEL guard that reads it
|
|
438
|
+
(`round >= maxRounds` at `gw-guard`) raises `comparison not defined for string
|
|
439
|
+
and number` and wedges the loop on an unrecoverable incident. The categorical
|
|
440
|
+
guard is a **snapshot/restore** on each external-agent task (`review-round`,
|
|
441
|
+
`adversarial-review`, `classify-scope`): before the agent runs, each
|
|
442
|
+
engine-owned variable is copied into a task-**local** `guard<Name>` input
|
|
443
|
+
(`<zeebe:input source="=round" target="guardRound"/>` …), and the task's output
|
|
444
|
+
mapping **restores** the process variable from that snapshot
|
|
445
|
+
(`<zeebe:output source="=guardRound" target="round"/>`). Because the snapshot
|
|
446
|
+
holds the pre-merge value, the restore overwrites whatever the completion
|
|
447
|
+
merged in, so the agent's real outputs (`status`/`summary`/`question`,
|
|
448
|
+
`adversarialFindings`, the scope verdict) flow through but it can never alter
|
|
449
|
+
the protected set. The protected set is **every engine-owned variable the loop
|
|
450
|
+
relies on that the agent does not own as an output**: the control counters and
|
|
451
|
+
budgets (`round`, `maxRounds`, `advPass`, `advMax`, `ackRetryRound`,
|
|
452
|
+
`ackRetryMax`, `huskRetries`, `reviewWaitTimeout`), the `prKey` correlation
|
|
453
|
+
key, **and** the PR identity inputs (`repo`, `prNumber`, `prUrl`) — the last
|
|
454
|
+
three because the unfiltered merge lets an input-echo replace the identity that
|
|
455
|
+
`progress-check`, `converge-gate`, and `finalize` go on to read (a wrong
|
|
456
|
+
`prNumber` would make `finalize` start the merge loop for the wrong PR) — **and**
|
|
457
|
+
the gate controls `humanApproval` and `convergeOnly`. These last two are
|
|
458
|
+
decision flags the loop acts on after the agent returns: `humanApproval = true`
|
|
459
|
+
routes a converged PR through the `gw-approval` human gate instead of
|
|
460
|
+
auto-merging, and `convergeOnly = true` ends a review-only run without ever
|
|
461
|
+
handing the PR to the merge loop. Left unguarded, a converged completion that
|
|
462
|
+
echoed `humanApproval: false` would **bypass** the approval gate, and one that
|
|
463
|
+
echoed `convergeOnly: false` would hand a review-only PR to the merge loop.
|
|
464
|
+
`convergeOnly` is a plain seeded constant, so the usual `=guardConvergeOnly`
|
|
465
|
+
restore applies. `humanApproval` is the one engine-owned flag that is **also
|
|
466
|
+
legitimately mutated mid-flight** — a gated caller that adopts a live loop
|
|
467
|
+
raises it `false → true` via `engine.setVariables` (`submitPr` in
|
|
468
|
+
`app/service.ts`); it is **monotonic** (only ever rises). A plain
|
|
469
|
+
`=guardHumanApproval` restore would clobber that concurrent host-set true, so
|
|
470
|
+
the restore is the monotonic join
|
|
471
|
+
`=(guardHumanApproval = true) or (humanApproval = true)`: it recovers the
|
|
472
|
+
snapshot's `true`, honours a concurrent adopter's `true`, and ignores an agent
|
|
473
|
+
echo of `false`. The direction is **fail-safe** — the only state it can force
|
|
474
|
+
is *more* human review, never a merge the loop would otherwise have gated. The
|
|
475
|
+
protected set also covers the two **progress bookkeeping** values the loop
|
|
476
|
+
reads back after the agent returns: `reviewStale` (the converge-gate's
|
|
477
|
+
stale-body signal — `f_guardMax` escalates only when `round >= maxRounds and
|
|
478
|
+
reviewStale != true`, so a forged `reviewStale: true` would suppress the
|
|
479
|
+
max-round escalation) and `roundEntryHead` (the within-round baseline
|
|
480
|
+
`pr.capture-head` captures and `pr.progress-check` consumes — a forged head
|
|
481
|
+
would falsify the no-progress decision). Both are plain process-owned reads, so
|
|
482
|
+
the usual `=guardReviewStale` / `=guardRoundEntryHead` restore pins them. The
|
|
483
|
+
protected set further covers the two **seeded cooperative-abandon** reads:
|
|
484
|
+
`abandonUrl` (the capability `pr.persist-round`/`pr.persist-escalation`/
|
|
485
|
+
`pr.finalize` record for a human — a forged echo would redirect the abandon
|
|
486
|
+
token) and `abandonBrief` (which feeds the NEXT agent task's `appendPrompt`
|
|
487
|
+
input `=(if abandonBrief = null then "" else abandonBrief)` — a non-string echo
|
|
488
|
+
would carry a wrong/non-string brief into that later prompt). Both are plain
|
|
489
|
+
process-owned reads, pinned by the usual `=guardAbandonUrl` / `=guardAbandonBrief`
|
|
490
|
+
restore. The reserved **`io.nanobpm.agentTask`** repository envelope is
|
|
491
|
+
*deliberately not* in this list: it is a flat dotted framework key with no
|
|
492
|
+
FEEL-path access, so snapshotting it would need backtick-escaped FEEL
|
|
493
|
+
(`` =`io.nanobpm.agentTask` ``) that has no precedent on this engine and would,
|
|
494
|
+
if evaluated as the default dotted path, resolve to `null` and **null the
|
|
495
|
+
fleet-critical envelope on restore** — a strictly worse failure than the
|
|
496
|
+
hypothetical clobber (an agent would have to emit a result-JSON key literally
|
|
497
|
+
named `io.nanobpm.agentTask`). A counter the task itself advances is incremented
|
|
498
|
+
**from the snapshot** (`adversarial-review` computes `advPass` as
|
|
499
|
+
`(if guardAdvPass = null then 0
|
|
500
|
+
else guardAdvPass) + 1`), never from the mergeable process variable. Any new
|
|
501
|
+
external-agent task added to this loop must apply the same guard to every
|
|
502
|
+
engine-owned variable in scope.
|
|
503
|
+
|
|
431
504
|
|
|
432
505
|
## 5. Agent job contract (`senior:pr-review`)
|
|
433
506
|
|
|
@@ -817,6 +890,37 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
|
|
|
817
890
|
`pr.persist-escalation` worker as the review stage (status `escalated`), answered
|
|
818
891
|
via the native `wait-merge-answer` `userTask` (the same `completeUserTask` door as
|
|
819
892
|
the review escalation, #256); answering re-arms and retries.
|
|
893
|
+
- **Engine-owned variable guard (issue #887)** — the merge-loop applies the same
|
|
894
|
+
snapshot/restore invariant as the convergence loop (§4) around its external-agent
|
|
895
|
+
tasks (`fix-ci`, `rebase`). Each guards the loop counters and budgets
|
|
896
|
+
(`ciFixRound`, `ciFixMax`, `rebaseRound`, `rebaseMax`, `mergeRetryRound`,
|
|
897
|
+
`mergeRetryMax`, `mergeStallRounds`, `mergeStallMax`), the `prKey` correlation
|
|
898
|
+
key, and the PR identity inputs (`repo`, `prNumber`, `prUrl`) — the last three
|
|
899
|
+
because `pr.merge` reads them directly for GitHub operations after the agent
|
|
900
|
+
re-arms the poller, so an input-echo that replaced them would merge the wrong
|
|
901
|
+
PR. It further guards the **lifecycle marker and timeout config** the loop
|
|
902
|
+
re-arms on every pass: `ciBlockedReconciled` (arm-merge seeds it `null`;
|
|
903
|
+
`ci-reconcile` sets it `true`; `gw-ci-blocked` runs the one-shot ground-truth
|
|
904
|
+
reconcile exactly once per episode while it is `!= true`) and the seeded timer
|
|
905
|
+
durations (`agentSlaTimeout`, `landedWaitTimeout`, `mergeableWaitTimeout`,
|
|
906
|
+
`mergeableRepollInterval`). Left unguarded, a `{status:"blocked", pushed:false,
|
|
907
|
+
ciBlockedReconciled:true}` completion would **skip** the mandatory first
|
|
908
|
+
reconcile and page a human immediately, and a clobbered timeout would silently
|
|
909
|
+
reshape a wait a later loop pass re-arms. The guard also pins the two values
|
|
910
|
+
`pr.merge` (attempt-merge) consumes directly after a successful fix/rebase
|
|
911
|
+
re-arms the poller: `round` (the converged round `ensurePr` records on the
|
|
912
|
+
merge audit row) and `abandonUrl` (the cooperative-abandon capability URL whose
|
|
913
|
+
token the merge worker extracts). A completion echoing either would corrupt the
|
|
914
|
+
recorded round or replace the abandon token, so `=guardRound` /
|
|
915
|
+
`=guardAbandonUrl` restores pin both. It likewise guards `abandonBrief`, which
|
|
916
|
+
feeds the fix-ci/rebase `appendPrompt` input (`=(if abandonBrief = null then ""
|
|
917
|
+
else abandonBrief)`) on the next re-arm — a non-string echo would carry a
|
|
918
|
+
non-string brief into that later prompt. (As in the convergence loop (§4), the
|
|
919
|
+
reserved dotted `io.nanobpm.agentTask` repository envelope is *not* guarded: it
|
|
920
|
+
has no FEEL-path access and a backtick-escaped restore, untested on this engine,
|
|
921
|
+
would risk nulling the envelope used for later agent provisioning.) The counter
|
|
922
|
+
the task advances is incremented from the snapshot (`ciFixRound`/`rebaseRound`
|
|
923
|
+
become `guard<Name> + 1`).
|
|
820
924
|
- **Terminal** — `merged` (with `merged_at`), or `converged` when
|
|
821
925
|
`NANO_PR_AUTO_MERGE=0` (review-only), or `abandoned` on cancel.
|
|
822
926
|
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// Regression guard for issue #887 — an external-agent job's completion variables must not clobber
|
|
2
|
+
// engine-owned process state.
|
|
3
|
+
//
|
|
4
|
+
// The defect (merlin PI 351101, incident 363350 at `gw-guard`): a `senior:pr-review` job returned
|
|
5
|
+
// `round: "4"` (a STRING) alongside its result. The engine merges EVERY variable an external agent
|
|
6
|
+
// returns into process scope UNFILTERED (the `io.nanobpm.dataEnvelope.out` shape is typing-only — no
|
|
7
|
+
// runtime filtering/coercion), so the agent's string overwrote the engine-owned integer counter
|
|
8
|
+
// `round`. The next `gw-guard` evaluation (`round >= maxRounds`) then raised
|
|
9
|
+
// "comparison not defined for string and number"
|
|
10
|
+
// and wedged the loop on an unrecoverable incident.
|
|
11
|
+
//
|
|
12
|
+
// Failure CLASS: any external-agent task can corrupt engine-owned state (round, maxRounds, the
|
|
13
|
+
// adversarial/ack/husk counters, prKey…) by name collision or type drift. The categorical fix snapshots
|
|
14
|
+
// each engine-owned control variable into a task-LOCAL input before the agent runs and RESTORES it from
|
|
15
|
+
// that snapshot on the task's output mapping, so an agent completion can never alter it (the snapshot is
|
|
16
|
+
// the pre-merge value; an output `source="=round"` would read the already-clobbered merge — proven by the
|
|
17
|
+
// `app/agentCompletion*`/testkit notes). The agent's real outputs (status/summary/question for a review
|
|
18
|
+
// round, findings for the adversarial pass, the scope verdict) still flow through untouched.
|
|
19
|
+
//
|
|
20
|
+
// These deploy the committed convergence-loop into the real WASM engine and drive a token to `gw-guard`
|
|
21
|
+
// with an agent that echoes `round: "4"`; the fix means NO incident is raised and `round` stays the
|
|
22
|
+
// engine-managed integer.
|
|
23
|
+
import { after, test } from "node:test";
|
|
24
|
+
import { assert, assertEquals } from "#test-assert";
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { createWasmEngineClient, type WasmEngineClient } from "@nanobpm/urban-testkit";
|
|
27
|
+
|
|
28
|
+
const MODEL = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
29
|
+
|
|
30
|
+
type Output = Record<string, unknown>;
|
|
31
|
+
type Responder = Output | ((job: { variables: Record<string, unknown> }) => Output);
|
|
32
|
+
|
|
33
|
+
const ALL_JOB_TYPES = [
|
|
34
|
+
"senior:pr-review",
|
|
35
|
+
"senior:adversarial-review",
|
|
36
|
+
"pr.capture-head",
|
|
37
|
+
"pr.persist-round",
|
|
38
|
+
"pr.progress-check",
|
|
39
|
+
"pr.converge-gate",
|
|
40
|
+
"senior:scope-classify",
|
|
41
|
+
"pr.finalize",
|
|
42
|
+
"pr.persist-escalation",
|
|
43
|
+
"pr.answer-escalation",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
const DEFAULT_RESPONSES: Record<string, Responder> = {
|
|
47
|
+
"senior:pr-review": { status: "addressed", summary: "fixed" },
|
|
48
|
+
"senior:adversarial-review": { adversarialFindings: "", adversarialSummary: "clean" },
|
|
49
|
+
"pr.capture-head": { roundEntryHead: "sha-entry" },
|
|
50
|
+
"pr.persist-round": {},
|
|
51
|
+
"pr.progress-check": { progressed: true },
|
|
52
|
+
"pr.converge-gate": { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false },
|
|
53
|
+
"senior:scope-classify": { scopeBlocked: false, scopeBlockReason: "" },
|
|
54
|
+
"pr.finalize": {},
|
|
55
|
+
"pr.persist-escalation": { escalated: true },
|
|
56
|
+
"pr.answer-escalation": {},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const DEFAULT_VARS: Record<string, unknown> = {
|
|
60
|
+
prKey: "o/r#1",
|
|
61
|
+
repo: "o/r",
|
|
62
|
+
prNumber: 1,
|
|
63
|
+
prUrl: "https://example.test/pr/1",
|
|
64
|
+
round: 1,
|
|
65
|
+
maxRounds: 20,
|
|
66
|
+
reviewWaitTimeout: "PT30M",
|
|
67
|
+
ackRetryRound: 0,
|
|
68
|
+
ackRetryMax: 2,
|
|
69
|
+
abandonUrl: "https://hooks.test/abandon?token=seed",
|
|
70
|
+
abandonBrief: "\n\n---\nAbort if cancelled: curl …",
|
|
71
|
+
status: null,
|
|
72
|
+
question: null,
|
|
73
|
+
summary: null,
|
|
74
|
+
answer: null,
|
|
75
|
+
scopePending: false,
|
|
76
|
+
scopeAnswer: null,
|
|
77
|
+
convergeBlocked: null,
|
|
78
|
+
convergeBlockReason: null,
|
|
79
|
+
convergeAckOnly: null,
|
|
80
|
+
reviewStale: null,
|
|
81
|
+
roundEntryHead: null,
|
|
82
|
+
huskRetries: 0,
|
|
83
|
+
progressed: true,
|
|
84
|
+
escalated: null,
|
|
85
|
+
humanApproval: false,
|
|
86
|
+
convergeOnly: false,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const engines: WasmEngineClient[] = [];
|
|
90
|
+
after(async () => {
|
|
91
|
+
await Promise.all(engines.map((e) => e.close()));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async function boot(opts: { responses?: Record<string, Responder>; vars?: Record<string, unknown> } = {}) {
|
|
95
|
+
const engine = await createWasmEngineClient();
|
|
96
|
+
engines.push(engine);
|
|
97
|
+
await engine.deployResources([{ name: "convergence-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
|
|
98
|
+
const responses = { ...DEFAULT_RESPONSES, ...(opts.responses ?? {}) };
|
|
99
|
+
for (const jobType of ALL_JOB_TYPES) {
|
|
100
|
+
const responder = responses[jobType];
|
|
101
|
+
await engine.registerWorker(jobType, (job) =>
|
|
102
|
+
typeof responder === "function" ? responder(job as { variables: Record<string, unknown> }) : (responder ?? {}),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const created = await engine.createInstance({
|
|
106
|
+
processDefinitionId: "convergence-loop",
|
|
107
|
+
awaitCompletion: false,
|
|
108
|
+
variables: { ...DEFAULT_VARS, ...(opts.vars ?? {}) },
|
|
109
|
+
});
|
|
110
|
+
return { engine, processInstanceKey: created.processInstanceKey };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function instanceVars(engine: WasmEngineClient): Record<string, unknown> {
|
|
114
|
+
const snap = engine.snapshot() as { instances?: { variables?: Record<string, unknown> }[] };
|
|
115
|
+
return snap.instances?.[0]?.variables ?? {};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read one variable's deserialized value via the engine read channel — works on a COMPLETED instance. */
|
|
119
|
+
async function variableValue(engine: WasmEngineClient, processInstanceKey: string, name: string): Promise<unknown> {
|
|
120
|
+
const rows = await engine.searchVariables({ processInstanceKey, name });
|
|
121
|
+
const row = rows.find((v) => v.name === name);
|
|
122
|
+
assert(row, `expected variable ${name} to be present`);
|
|
123
|
+
return JSON.parse(row.value);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test("a review-round agent echoing round:\"4\" does not clobber the engine-owned counter or wedge gw-guard", async () => {
|
|
127
|
+
const { engine } = await boot({
|
|
128
|
+
responses: { "senior:pr-review": { status: "addressed", summary: "fixed", round: "4" } },
|
|
129
|
+
});
|
|
130
|
+
// gw-guard evaluates `round >= maxRounds`. If the agent's string "4" clobbered `round`, the engine
|
|
131
|
+
// raises "comparison not defined for string and number" and parks the token on an open incident.
|
|
132
|
+
const incidents = await engine.searchIncidents();
|
|
133
|
+
assertEquals(incidents.length, 0, `no incident must be raised; got: ${JSON.stringify(incidents)}`);
|
|
134
|
+
// The engine-owned counter must remain the integer it was — untouched by the agent's echo.
|
|
135
|
+
assertEquals(instanceVars(engine).round, 1, "engine-owned `round` must stay the integer 1, unclobbered");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("a review-round agent echoing numeric engine counters cannot alter them", async () => {
|
|
139
|
+
// A well-formed numeric echo must still be ignored — the agent owns NONE of these control variables.
|
|
140
|
+
const { engine } = await boot({
|
|
141
|
+
responses: {
|
|
142
|
+
"senior:pr-review": { status: "addressed", summary: "fixed", round: 99, maxRounds: 1, prKey: "evil/repo#999" },
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
const incidents = await engine.searchIncidents();
|
|
146
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
147
|
+
const vars = instanceVars(engine);
|
|
148
|
+
assertEquals(vars.round, 1, "round must be unchanged");
|
|
149
|
+
assertEquals(vars.maxRounds, 20, "maxRounds must be unchanged");
|
|
150
|
+
assertEquals(vars.prKey, "o/r#1", "prKey correlation key must be unchanged");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a review-round agent echoing PR identity inputs cannot alter them", async () => {
|
|
154
|
+
// `repo`/`prNumber`/`prUrl` are the agent's declared *inputs* (PrReviewRoundIn), not its outputs —
|
|
155
|
+
// but the engine merges a completion UNFILTERED, so an input-echo would replace the valid process
|
|
156
|
+
// identity that `progress-check`, `converge-gate`, and `finalize` go on to read. The snapshot/restore
|
|
157
|
+
// guard must pin them.
|
|
158
|
+
const { engine } = await boot({
|
|
159
|
+
responses: {
|
|
160
|
+
"senior:pr-review": {
|
|
161
|
+
status: "addressed",
|
|
162
|
+
summary: "fixed",
|
|
163
|
+
repo: "evil/repo",
|
|
164
|
+
prNumber: 999,
|
|
165
|
+
prUrl: "https://evil.test/pr/999",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const incidents = await engine.searchIncidents();
|
|
170
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
171
|
+
const vars = instanceVars(engine);
|
|
172
|
+
assertEquals(vars.repo, "o/r", "repo must be unchanged");
|
|
173
|
+
assertEquals(vars.prNumber, 1, "prNumber must be unchanged");
|
|
174
|
+
assertEquals(vars.prUrl, "https://example.test/pr/1", "prUrl must be unchanged");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The adversarial reviewer (`senior:adversarial-review`) and scope classifier
|
|
178
|
+
// (`senior:scope-classify`) are hardened by the SAME snapshot/restore mappings as `review-round`;
|
|
179
|
+
// these drive each of those tasks with an engine-owned-field echo so removing either task's guard
|
|
180
|
+
// turns this suite red (the `review-round` regressions above would stay green).
|
|
181
|
+
|
|
182
|
+
test("an adversarial-review agent echoing engine counters cannot alter them", async () => {
|
|
183
|
+
// advMax: 1 makes the adversarial pass due on round 1. The agent echoes `advPass`/`advMax` (and a
|
|
184
|
+
// counter) as the WRONG type; the guard must keep the engine's own increment (0 → 1) and budget.
|
|
185
|
+
const { engine } = await boot({
|
|
186
|
+
vars: { advMax: 1 },
|
|
187
|
+
responses: {
|
|
188
|
+
"senior:adversarial-review": { adversarialFindings: "", adversarialSummary: "clean", advPass: "7", advMax: "0", round: "9" },
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const incidents = await engine.searchIncidents();
|
|
192
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
193
|
+
const vars = instanceVars(engine);
|
|
194
|
+
assertEquals(vars.advPass, 1, "advPass must be the engine increment (0 → 1), not the agent's \"7\"");
|
|
195
|
+
assertEquals(vars.advMax, 1, "advMax budget ceiling must be unchanged");
|
|
196
|
+
assertEquals(vars.round, 1, "round must be unchanged");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("an adversarial-review agent echoing PR identity inputs cannot alter them", async () => {
|
|
200
|
+
const { engine } = await boot({
|
|
201
|
+
vars: { advMax: 1 },
|
|
202
|
+
responses: {
|
|
203
|
+
"senior:adversarial-review": {
|
|
204
|
+
adversarialFindings: "",
|
|
205
|
+
adversarialSummary: "clean",
|
|
206
|
+
repo: "evil/repo",
|
|
207
|
+
prNumber: 999,
|
|
208
|
+
prUrl: "https://evil.test/pr/999",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const incidents = await engine.searchIncidents();
|
|
213
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
214
|
+
const vars = instanceVars(engine);
|
|
215
|
+
assertEquals(vars.repo, "o/r", "repo must be unchanged");
|
|
216
|
+
assertEquals(vars.prNumber, 1, "prNumber must be unchanged");
|
|
217
|
+
assertEquals(vars.prUrl, "https://example.test/pr/1", "prUrl must be unchanged");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a scope-classify agent echoing engine-owned fields cannot alter them", async () => {
|
|
221
|
+
// Drive to the scope classifier via a `converged` review. The classifier echoes prKey + the PR
|
|
222
|
+
// identity inputs; the guard must keep them. The instance runs to completion, so read the
|
|
223
|
+
// variables back through the read channel (a completed instance's `snapshot().variables` is folded).
|
|
224
|
+
const { engine, processInstanceKey } = await boot({
|
|
225
|
+
responses: {
|
|
226
|
+
"senior:pr-review": { status: "converged", summary: "done" },
|
|
227
|
+
"senior:scope-classify": {
|
|
228
|
+
scopeBlocked: false,
|
|
229
|
+
scopeBlockReason: "",
|
|
230
|
+
prKey: "evil/repo#999",
|
|
231
|
+
repo: "evil/repo",
|
|
232
|
+
prNumber: 999,
|
|
233
|
+
prUrl: "https://evil.test/pr/999",
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
const incidents = await engine.searchIncidents();
|
|
238
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
239
|
+
assertEquals(await variableValue(engine, processInstanceKey, "prKey"), "o/r#1", "prKey correlation key must be unchanged");
|
|
240
|
+
assertEquals(await variableValue(engine, processInstanceKey, "repo"), "o/r", "repo must be unchanged");
|
|
241
|
+
assertEquals(await variableValue(engine, processInstanceKey, "prNumber"), 1, "prNumber must be unchanged");
|
|
242
|
+
assertEquals(await variableValue(engine, processInstanceKey, "prUrl"), "https://example.test/pr/1", "prUrl must be unchanged");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Issue #887 (round 3): the seeded merge-control gates `humanApproval` and
|
|
247
|
+
// `convergeOnly` are ALSO engine-owned and must survive an agent completion.
|
|
248
|
+
// A converged review that echoes `humanApproval:false` would bypass `gw-approval`
|
|
249
|
+
// (skip the required human merge approval), and `convergeOnly:false` would hand a
|
|
250
|
+
// review-only PR to the merge loop. The snapshot/restore guard pins both.
|
|
251
|
+
//
|
|
252
|
+
// `humanApproval` is MONOTONIC (false→true only — the host flips it true via
|
|
253
|
+
// `engine.setVariables` when a gated caller adopts a live loop, app/service.ts).
|
|
254
|
+
// So its restore is `(guardHumanApproval = true) or (humanApproval = true)`: it
|
|
255
|
+
// recovers a snapshot-true against an agent's false AND preserves a concurrent
|
|
256
|
+
// host-set true, but never lets an agent force it back to false.
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
test("a review-round agent echoing humanApproval:false cannot bypass the approval gate", async () => {
|
|
260
|
+
// Seeded gated (humanApproval:true). The agent echoes false; the monotonic restore must keep it true.
|
|
261
|
+
const { engine } = await boot({
|
|
262
|
+
vars: { humanApproval: true },
|
|
263
|
+
responses: { "senior:pr-review": { status: "addressed", summary: "fixed", humanApproval: false } },
|
|
264
|
+
});
|
|
265
|
+
const incidents = await engine.searchIncidents();
|
|
266
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
267
|
+
assertEquals(instanceVars(engine).humanApproval, true, "seeded humanApproval:true must survive the agent's false echo");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("a review-round agent echoing convergeOnly:false cannot re-target a review-only run at the merge loop", async () => {
|
|
271
|
+
// Seeded review-only (convergeOnly:true). The agent echoes false; the restore must keep it true.
|
|
272
|
+
const { engine } = await boot({
|
|
273
|
+
vars: { convergeOnly: true },
|
|
274
|
+
responses: { "senior:pr-review": { status: "addressed", summary: "fixed", convergeOnly: false } },
|
|
275
|
+
});
|
|
276
|
+
const incidents = await engine.searchIncidents();
|
|
277
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
278
|
+
assertEquals(instanceVars(engine).convergeOnly, true, "seeded convergeOnly:true must survive the agent's false echo");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("an adversarial-review agent echoing humanApproval/convergeOnly cannot alter the seeded gates", async () => {
|
|
282
|
+
const { engine } = await boot({
|
|
283
|
+
vars: { advMax: 1, humanApproval: true, convergeOnly: true },
|
|
284
|
+
responses: {
|
|
285
|
+
"senior:adversarial-review": {
|
|
286
|
+
adversarialFindings: "",
|
|
287
|
+
adversarialSummary: "clean",
|
|
288
|
+
humanApproval: false,
|
|
289
|
+
convergeOnly: false,
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
const incidents = await engine.searchIncidents();
|
|
294
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
295
|
+
const vars = instanceVars(engine);
|
|
296
|
+
assertEquals(vars.humanApproval, true, "humanApproval gate must be unchanged");
|
|
297
|
+
assertEquals(vars.convergeOnly, true, "convergeOnly flag must be unchanged");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("a scope-classify agent echoing humanApproval/convergeOnly cannot alter the seeded gates", async () => {
|
|
301
|
+
// Drive to the scope classifier via a `converged` review with the gates seeded true; the classifier
|
|
302
|
+
// echoes them false. With humanApproval:true the instance then parks at the merge-approval user task
|
|
303
|
+
// (still ACTIVE), so read the gates back through the variables channel after classify-scope ran.
|
|
304
|
+
const { engine, processInstanceKey } = await boot({
|
|
305
|
+
vars: { humanApproval: true, convergeOnly: true },
|
|
306
|
+
responses: {
|
|
307
|
+
"senior:pr-review": { status: "converged", summary: "done" },
|
|
308
|
+
"senior:scope-classify": {
|
|
309
|
+
scopeBlocked: false,
|
|
310
|
+
scopeBlockReason: "",
|
|
311
|
+
humanApproval: false,
|
|
312
|
+
convergeOnly: false,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
const incidents = await engine.searchIncidents();
|
|
317
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
318
|
+
assertEquals(await variableValue(engine, processInstanceKey, "humanApproval"), true, "humanApproval gate must be unchanged");
|
|
319
|
+
assertEquals(await variableValue(engine, processInstanceKey, "convergeOnly"), true, "convergeOnly flag must be unchanged");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Issue #887 (round 4 — Copilot "previously missed"): `reviewStale` and
|
|
324
|
+
// `roundEntryHead` are ALSO process-owned and readable by downstream decisions,
|
|
325
|
+
// so an agent completion must not overwrite them:
|
|
326
|
+
// * `f_guardMax` escalates only when `round >= maxRounds and reviewStale != true`
|
|
327
|
+
// — a forged `reviewStale:true` suppresses the max-round escalation.
|
|
328
|
+
// * `check-progress` (pr.progress-check) consumes `roundEntryHead` as the
|
|
329
|
+
// within-round baseline — a forged head falsifies the progress decision.
|
|
330
|
+
// The snapshot/restore guard pins both on all three agent tasks.
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
test("a review-round agent echoing reviewStale:true cannot suppress the max-round escalation", async () => {
|
|
334
|
+
// Seeded at the max-round boundary with reviewStale false. The agent echoes reviewStale:true;
|
|
335
|
+
// without the guard, gw-guard's `reviewStale != true` arm would go false and suppress escalation.
|
|
336
|
+
const { engine } = await boot({
|
|
337
|
+
vars: { round: 20, maxRounds: 20, reviewStale: false },
|
|
338
|
+
responses: { "senior:pr-review": { status: "addressed", summary: "fixed", reviewStale: true } },
|
|
339
|
+
});
|
|
340
|
+
const incidents = await engine.searchIncidents();
|
|
341
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
342
|
+
assertEquals(instanceVars(engine).reviewStale, false, "seeded reviewStale:false must survive the agent's true echo");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("a review-round agent echoing a forged roundEntryHead cannot falsify the progress baseline", async () => {
|
|
346
|
+
// capture-head seeds roundEntryHead="sha-entry"; the agent echoes a forged head. The restore must keep it.
|
|
347
|
+
const { engine } = await boot({
|
|
348
|
+
responses: { "senior:pr-review": { status: "addressed", summary: "fixed", roundEntryHead: "sha-forged" } },
|
|
349
|
+
});
|
|
350
|
+
const incidents = await engine.searchIncidents();
|
|
351
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
352
|
+
assertEquals(instanceVars(engine).roundEntryHead, "sha-entry", "captured roundEntryHead must survive the agent's forged echo");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("an adversarial-review agent echoing reviewStale/roundEntryHead cannot alter them", async () => {
|
|
356
|
+
const { engine } = await boot({
|
|
357
|
+
vars: { advMax: 1, reviewStale: false },
|
|
358
|
+
responses: {
|
|
359
|
+
"senior:adversarial-review": {
|
|
360
|
+
adversarialFindings: "",
|
|
361
|
+
adversarialSummary: "clean",
|
|
362
|
+
reviewStale: true,
|
|
363
|
+
roundEntryHead: "sha-forged",
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
const incidents = await engine.searchIncidents();
|
|
368
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
369
|
+
const vars = instanceVars(engine);
|
|
370
|
+
assertEquals(vars.reviewStale, false, "reviewStale must be unchanged");
|
|
371
|
+
assertEquals(vars.roundEntryHead, "sha-entry", "roundEntryHead must be unchanged");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("a scope-classify agent echoing reviewStale/roundEntryHead cannot alter them", async () => {
|
|
375
|
+
// Drive to the scope classifier via a `converged` review; the classifier echoes both. With
|
|
376
|
+
// humanApproval:false the instance runs to completion, so read the vars back via the channel.
|
|
377
|
+
const { engine, processInstanceKey } = await boot({
|
|
378
|
+
vars: { reviewStale: false },
|
|
379
|
+
responses: {
|
|
380
|
+
"senior:pr-review": { status: "converged", summary: "done" },
|
|
381
|
+
"senior:scope-classify": {
|
|
382
|
+
scopeBlocked: false,
|
|
383
|
+
scopeBlockReason: "",
|
|
384
|
+
reviewStale: true,
|
|
385
|
+
roundEntryHead: "sha-forged",
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
const incidents = await engine.searchIncidents();
|
|
390
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
391
|
+
assertEquals(await variableValue(engine, processInstanceKey, "reviewStale"), false, "reviewStale must be unchanged");
|
|
392
|
+
assertEquals(await variableValue(engine, processInstanceKey, "roundEntryHead"), "sha-entry", "roundEntryHead must be unchanged");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// Issue #887 (round 5 — Copilot "previously missed"): `abandonUrl` and
|
|
397
|
+
// `abandonBrief` are ALSO seeded engine-owned state readable by downstream
|
|
398
|
+
// persistence and prompt mappings:
|
|
399
|
+
// * `abandonUrl` is the cooperative-abandon capability recorded by
|
|
400
|
+
// `pr.persist-round`/`pr.persist-escalation`/`pr.finalize` (EscalationIn.abandonUrl,
|
|
401
|
+
// PrPersistRoundIn.abandonUrl, PrFinalizeIn.abandonUrl) — a forged echo
|
|
402
|
+
// redirects the abandon token persisted for a human.
|
|
403
|
+
// * `abandonBrief` feeds the `appendPrompt` input of the NEXT agent task
|
|
404
|
+
// (`=(if (abandonBrief = null) then "" else abandonBrief)`) — a non-string
|
|
405
|
+
// echo makes that later prompt mapping carry a non-string / wrong brief.
|
|
406
|
+
// The snapshot/restore guard pins both on all three agent tasks. (The reserved
|
|
407
|
+
// dotted `io.nanobpm.agentTask` envelope was DECLINED — it has no FEEL-path
|
|
408
|
+
// access and a backtick-escaped restore, untested on this engine, would null the
|
|
409
|
+
// fleet-critical repository envelope; see the PR discussion.)
|
|
410
|
+
|
|
411
|
+
test("a review-round agent echoing abandonUrl/abandonBrief cannot clobber the seeded capability or brief", async () => {
|
|
412
|
+
const { engine } = await boot({
|
|
413
|
+
responses: {
|
|
414
|
+
"senior:pr-review": {
|
|
415
|
+
status: "addressed",
|
|
416
|
+
summary: "fixed",
|
|
417
|
+
abandonUrl: "https://evil.test/abandon?token=forged",
|
|
418
|
+
abandonBrief: 42,
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
const incidents = await engine.searchIncidents();
|
|
423
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
424
|
+
const vars = instanceVars(engine);
|
|
425
|
+
assertEquals(vars.abandonUrl, "https://hooks.test/abandon?token=seed", "seeded abandonUrl must survive the agent's forged echo");
|
|
426
|
+
assertEquals(vars.abandonBrief, "\n\n---\nAbort if cancelled: curl …", "seeded abandonBrief must survive the agent's non-string echo");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("an adversarial-review agent echoing abandonUrl/abandonBrief cannot alter them", async () => {
|
|
430
|
+
const { engine } = await boot({
|
|
431
|
+
vars: { advMax: 1 },
|
|
432
|
+
responses: {
|
|
433
|
+
"senior:adversarial-review": {
|
|
434
|
+
adversarialFindings: "",
|
|
435
|
+
adversarialSummary: "clean",
|
|
436
|
+
abandonUrl: "https://evil.test/abandon?token=forged",
|
|
437
|
+
abandonBrief: 42,
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
const incidents = await engine.searchIncidents();
|
|
442
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
443
|
+
const vars = instanceVars(engine);
|
|
444
|
+
assertEquals(vars.abandonUrl, "https://hooks.test/abandon?token=seed", "abandonUrl must be unchanged");
|
|
445
|
+
assertEquals(vars.abandonBrief, "\n\n---\nAbort if cancelled: curl …", "abandonBrief must be unchanged");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("a scope-classify agent echoing abandonUrl/abandonBrief cannot alter them", async () => {
|
|
449
|
+
const { engine, processInstanceKey } = await boot({
|
|
450
|
+
responses: {
|
|
451
|
+
"senior:pr-review": { status: "converged", summary: "done" },
|
|
452
|
+
"senior:scope-classify": {
|
|
453
|
+
scopeBlocked: false,
|
|
454
|
+
scopeBlockReason: "",
|
|
455
|
+
abandonUrl: "https://evil.test/abandon?token=forged",
|
|
456
|
+
abandonBrief: 42,
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
const incidents = await engine.searchIncidents();
|
|
461
|
+
assertEquals(incidents.length, 0, `no incident; got: ${JSON.stringify(incidents)}`);
|
|
462
|
+
assertEquals(await variableValue(engine, processInstanceKey, "abandonUrl"), "https://hooks.test/abandon?token=seed", "abandonUrl must be unchanged");
|
|
463
|
+
assertEquals(await variableValue(engine, processInstanceKey, "abandonBrief"), "\n\n---\nAbort if cancelled: curl …", "abandonBrief must be unchanged");
|
|
464
|
+
});
|
package/app/agentic/README.md
CHANGED
|
@@ -79,6 +79,54 @@ derivation half:
|
|
|
79
79
|
a chosen offset, so an exited session can be branched and replayed independently, offset-parity
|
|
80
80
|
preserved. Byte-replay and resume-from-offset (`transcript-read.ts`) are untouched.
|
|
81
81
|
|
|
82
|
+
## Job-ownership claims & authoritative reconnect resync (#713, #893)
|
|
83
|
+
|
|
84
|
+
The `claim` / `release` family (`families/claim.family.ts`) backs the {@link ClaimRegistry}
|
|
85
|
+
(`claim-registry.ts`) — the authoritative "which jobs a worker instance is running" visibility source
|
|
86
|
+
the supply snapshot reads. Each `claim` (wire code 8) / `release` (wire code 9) frame names its OWNING
|
|
87
|
+
`instance` explicitly, so one per-host supervisor connection can carry many workers' ownership frames.
|
|
88
|
+
|
|
89
|
+
**Authoritative resync (the supervisor's view wins).** On a WS reconnect a supervisor re-`register`s
|
|
90
|
+
every worker and re-`claim`s every active jobKey. Re-claims alone are *additive* — a claim that is not
|
|
91
|
+
re-asserted is never dropped, so a `release` lost during a hub outage would leave the job shown as
|
|
92
|
+
"working" until the worker exits entirely. To close that gap a supervisor may end its resync with an
|
|
93
|
+
**authoritative snapshot**: a `claim` frame enriched with the full `jobKeys` set it currently owns
|
|
94
|
+
(`{ instance, jobKeys: [...] }` — possibly empty, the meaningful "owns nothing now" resync). The hub
|
|
95
|
+
then calls `ClaimRegistry.replaceInstance(instance, jobKeys)`, which **drops every claim that instance
|
|
96
|
+
held but did not re-assert** and (re-)claims the snapshot's set. The snapshot rides the existing
|
|
97
|
+
`claim` wire family as a forward-compatible enrichment (the protocol's structural validators tolerate
|
|
98
|
+
the extra field), so **no new wire code** is needed.
|
|
99
|
+
|
|
100
|
+
The `claim` family thus carries **two payload variants** — the classic per-job claim
|
|
101
|
+
`{ instance, jobKey }` and the resync snapshot `{ instance, jobKeys }` (the `claim.snapshot` wire
|
|
102
|
+
contract in `app/contracts.ts`). The pinned `@nanobpm/agentic` validator predates the snapshot variant
|
|
103
|
+
and requires a non-empty `jobKey`, so the hub validates the **union** itself: a frame is well-formed
|
|
104
|
+
iff it is a valid snapshot (non-empty `instance` + a `jobKeys` array of **non-empty** strings) or a
|
|
105
|
+
valid per-job claim. This is a **union with a validated overlap**, not an exclusive-or: a frame
|
|
106
|
+
carrying *both* fields is accepted, but only when *both* halves validate, so the snapshot branch never
|
|
107
|
+
bypasses validation. The **presence** of `jobKeys` (not its validity) is the snapshot discriminant, so
|
|
108
|
+
a present-but-malformed `jobKeys` — a non-array, a non-string element, **or an empty-string element**
|
|
109
|
+
(held to the same non-empty bar as the per-job `jobKey`) — is a broken snapshot and is **rejected**,
|
|
110
|
+
never treated as absent and laundered through the per-job validator (which would accept it on any
|
|
111
|
+
frame that also carries a valid `jobKey`). The empty *array* remains the meaningful "owns nothing now"
|
|
112
|
+
resync — only empty *elements* are rejected. Versioning the snapshot into the canonical `@nanobpm/agentic` protocol payloads is the
|
|
113
|
+
nano-ide follow-up issue #893 scopes ("coordinate with the `@nanobpm/agentic` package owners"); the
|
|
114
|
+
producer half (one snapshot per instance, including empty sets) is nano-supervisor#63.
|
|
115
|
+
|
|
116
|
+
Guarantees `replaceInstance` keeps:
|
|
117
|
+
|
|
118
|
+
- A claim re-asserted in the snapshot is unchanged.
|
|
119
|
+
- A jobKey that has **moved to another instance** is never blanked by the old instance's resync (the
|
|
120
|
+
old instance no longer holds it, so the drop pass cannot touch it).
|
|
121
|
+
- Repeated resyncs are safe (idempotent).
|
|
122
|
+
- **Compatibility:** a supervisor that never sends a snapshot keeps the pre-#893 additive behaviour —
|
|
123
|
+
claims are then cleared only by an explicit `release` or the presence-driven `reconcile`.
|
|
124
|
+
|
|
125
|
+
Authoritative replacement is safe because ownership is advisory, short-lived visibility the supervisor
|
|
126
|
+
can always restate; the registry never gates a BPMN flow (ADR 0056). This is what lets a downstream hub
|
|
127
|
+
client (nano-supervisor#63) **bound** its RELEASE buffer during an outage instead of buffering every
|
|
128
|
+
RELEASE without limit.
|
|
129
|
+
|
|
82
130
|
## Invariants (ADR 0056)
|
|
83
131
|
|
|
84
132
|
- **App-tier only** — never the engine. The Camunda-8 job protocol (worker⇄engine) is untouched;
|