@nanobpm/nano-workforce 0.157.0 → 0.158.1
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/supply-boot.test.ts +29 -5
- package/app/agentic/cockpit/supply-boot.ts +17 -7
- package/app/convergeTargets.ts +19 -9
- package/app/deliveryConnector.test.ts +9 -0
- package/app/deliveryConnector.ts +6 -4
- package/app/deliveryGraph.test.ts +61 -0
- package/app/deliveryGraph.ts +51 -0
- package/app/nodePolicy.test.ts +84 -0
- package/app/nodePolicy.ts +113 -0
- package/docs/agent-guide.md +20 -8
- package/openapi.yaml +23 -0
- package/operations/getAgentInstructions.test.ts +1 -1
- package/package.json +1 -1
- package/pages/cockpit/mount.js +32 -12
- package/test/cockpit-embed-endpoints.test.ts +84 -0
- package/workers/delivery-connector/worker.test.ts +11 -0
- package/workers/delivery-connector/worker.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.158.1](https://github.com/nanobpm/nano-workforce/compare/v0.158.0...v0.158.1) (2026-08-29)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **cockpit:** derive relay URL from import.meta.url, not location.host ([#600](https://github.com/nanobpm/nano-workforce/issues/600)) ([#612](https://github.com/nanobpm/nano-workforce/issues/612)) ([69923fa](https://github.com/nanobpm/nano-workforce/commit/69923fad688aaa849302be8a1906dd7c6f938c49))
|
|
6
|
+
|
|
7
|
+
## [0.158.0](https://github.com/nanobpm/nano-workforce/compare/v0.157.0...v0.158.0) (2026-08-29)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **delivery-graph:** converge?/merge? as first-class edge-gated node policy; two-level merge (ADR 0006 S5) ([#604](https://github.com/nanobpm/nano-workforce/issues/604)) ([a880bac](https://github.com/nanobpm/nano-workforce/commit/a880baca513328af01e57cc9bcb5c1c6371a858c)), closes [#592](https://github.com/nanobpm/nano-workforce/issues/592)
|
|
12
|
+
|
|
1
13
|
## [0.157.0](https://github.com/nanobpm/nano-workforce/compare/v0.156.0...v0.157.0) (2026-08-29)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -164,23 +164,47 @@ test("relay output is written to the drilled worker's terminal", async () => {
|
|
|
164
164
|
assert.deepEqual(r.terminalWrites, ["boot\n"]);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
-
test("a live drill
|
|
167
|
+
test("a live drill reads 'connecting', promotes to 'waiting' on the subscribe ack, then clears on the first frame", async () => {
|
|
168
168
|
const r = rig();
|
|
169
169
|
const cockpit = bootSupplyCockpit(r.env);
|
|
170
170
|
await cockpit.refresh();
|
|
171
171
|
const note = () => r.host.byClass("cockpit-terminal-note")[0];
|
|
172
172
|
|
|
173
173
|
cockpit.drill("wk-a");
|
|
174
|
+
// Before the socket opens/subscribes the note honestly reads "connecting" — not a false "connected".
|
|
175
|
+
assert.equal(note()?.getAttribute("data-terminal-note"), "connecting", "note armed as connecting before the socket opens");
|
|
176
|
+
assert.match(note()?.textContent ?? "", /connecting/i);
|
|
177
|
+
|
|
174
178
|
r.sockets[0]?.fireOpen();
|
|
175
|
-
|
|
179
|
+
// The hub ACKs the subscribe → the note promotes to "waiting for live output" on a connected-but-quiet stream.
|
|
180
|
+
r.sockets[0]?.deliver({ lane: "control", family: "relay", seq: 0, payload: { op: "subscribed", stream: "wk-a", gap: false, nextOffset: 0 } });
|
|
181
|
+
assert.equal(note()?.getAttribute("data-terminal-note"), "waiting", "note promoted to waiting once the subscribe is acked");
|
|
176
182
|
assert.match(note()?.textContent ?? "", /waiting for live output/i);
|
|
177
183
|
|
|
178
|
-
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq:
|
|
184
|
+
r.sockets[0]?.deliver({ lane: "bulk", family: "relay", seq: 1, payload: { stream: "wk-a", offset: 0, chunk: "hi\n" } });
|
|
179
185
|
assert.equal(note()?.getAttribute("data-terminal-note"), "none", "note cleared the instant the first frame is written");
|
|
180
186
|
assert.equal(note()?.textContent, "");
|
|
181
187
|
});
|
|
182
188
|
|
|
183
|
-
test("
|
|
189
|
+
test("a drill whose socket never opens surfaces 'connecting', never a false 'connected'/'waiting' (#600)", async () => {
|
|
190
|
+
const r = rig();
|
|
191
|
+
const cockpit = bootSupplyCockpit(r.env);
|
|
192
|
+
await cockpit.refresh();
|
|
193
|
+
const note = () => r.host.byClass("cockpit-terminal-note")[0];
|
|
194
|
+
|
|
195
|
+
// A dead relay target (the #600 symptom: reconnect-looping, never subscribed) — the socket is created
|
|
196
|
+
// but never fires open, so the subscribe is never acked. The note must NOT claim the stream connected.
|
|
197
|
+
cockpit.drill("wk-a");
|
|
198
|
+
assert.equal(note()?.getAttribute("data-terminal-note"), "connecting", "a socket that never opens reads as connecting, not connected");
|
|
199
|
+
assert.match(note()?.textContent ?? "", /connecting/i);
|
|
200
|
+
assert.doesNotMatch(
|
|
201
|
+
note()?.textContent ?? "",
|
|
202
|
+
/connected|waiting for live output/i,
|
|
203
|
+
"a dead socket must not masquerade as a connected-but-quiet stream (#600)",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("switching to a new stream re-arms the note (the prior stream's cleared note does not persist)", async () => {
|
|
184
208
|
const r = rig();
|
|
185
209
|
const cockpit = bootSupplyCockpit(r.env);
|
|
186
210
|
await cockpit.refresh();
|
|
@@ -192,7 +216,7 @@ test("switching to a new stream re-arms the 'waiting' note (the prior stream's c
|
|
|
192
216
|
assert.equal(note()?.getAttribute("data-terminal-note"), "none");
|
|
193
217
|
|
|
194
218
|
cockpit.drill("wk-b");
|
|
195
|
-
assert.equal(note()?.getAttribute("data-terminal-note"), "
|
|
219
|
+
assert.equal(note()?.getAttribute("data-terminal-note"), "connecting", "the new drill re-arms the note (connecting), not stuck on the prior stream's cleared none");
|
|
196
220
|
});
|
|
197
221
|
|
|
198
222
|
test("the terminal survives a list refresh — it is not re-mounted and keeps streaming", async () => {
|
|
@@ -314,15 +314,15 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
314
314
|
if (mode !== "replay") this.#structuredRegion?.replaceChildren();
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
-
/** Show (or clear) the terminal status note — the "
|
|
318
|
-
#setNote(text: string | undefined): void {
|
|
317
|
+
/** Show (or clear) the terminal status note — the "connecting" / "waiting for output" affordance. */
|
|
318
|
+
#setNote(text: string | undefined, state: "connecting" | "waiting" = "waiting"): void {
|
|
319
319
|
if (text === undefined) {
|
|
320
320
|
this.#terminalNote.textContent = "";
|
|
321
321
|
this.#terminalNote.setAttribute("data-terminal-note", "none");
|
|
322
322
|
return;
|
|
323
323
|
}
|
|
324
324
|
this.#terminalNote.textContent = text;
|
|
325
|
-
this.#terminalNote.setAttribute("data-terminal-note",
|
|
325
|
+
this.#terminalNote.setAttribute("data-terminal-note", state);
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
async refresh(): Promise<void> {
|
|
@@ -528,7 +528,16 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
528
528
|
let session: TerminalSession | undefined;
|
|
529
529
|
const client = new RelayChannelClient({
|
|
530
530
|
connect: this.#env.connectRelay,
|
|
531
|
-
onRelay: (message) =>
|
|
531
|
+
onRelay: (message) => {
|
|
532
|
+
// Promote the note to "waiting for live output" only once the hub ACKs the subscribe. Until
|
|
533
|
+
// then it honestly reads "connecting", so a socket that never opens/subscribes stops
|
|
534
|
+
// masquerading as a connected-but-quiet stream (#600). Gate on `!cleared` so a reconnect's
|
|
535
|
+
// resubscribe ack does not re-arm the note after real output has already flowed.
|
|
536
|
+
if (!cleared && "op" in message && message.op === "subscribed") {
|
|
537
|
+
this.#setNote("Waiting for live output…", "waiting");
|
|
538
|
+
}
|
|
539
|
+
session?.handle(message);
|
|
540
|
+
},
|
|
532
541
|
// Re-attach on EVERY (re)connect → resume-from-offset: the terminal survives a cockpit
|
|
533
542
|
// reconnect without losing or double-writing output.
|
|
534
543
|
onOpen: () => session?.attach(),
|
|
@@ -544,9 +553,10 @@ class SupplyCockpit implements SupplyCockpitHandle {
|
|
|
544
553
|
client.open();
|
|
545
554
|
this.#drill = { stream, client };
|
|
546
555
|
this.#setMode("live", stream);
|
|
547
|
-
// Arm the
|
|
548
|
-
//
|
|
549
|
-
|
|
556
|
+
// Arm the note as "connecting" (after #setMode, which clears it) BEFORE the socket opens. It only
|
|
557
|
+
// becomes "waiting for live output" when the subscribe is ACKed (onRelay above) and clears on the
|
|
558
|
+
// first byte, so a dead socket reads as "connecting", never a falsely-"connected" quiet stream (#600).
|
|
559
|
+
this.#setNote("Connecting…", "connecting");
|
|
550
560
|
} catch (err) {
|
|
551
561
|
// Building the new terminal failed AFTER the prior drill + terminal were already torn down
|
|
552
562
|
// above. Leaving #mode/#shownStream at their prior value would keep the panel showing a stale
|
package/app/convergeTargets.ts
CHANGED
|
@@ -10,21 +10,31 @@
|
|
|
10
10
|
export const CONVERGE_TARGET = "converge";
|
|
11
11
|
|
|
12
12
|
/** The converge-AND-merge target: enrolls a PR into the shared convergence loop and drives the merge
|
|
13
|
-
* loop too (the canonical `agent → connector[converge-merge] → wait[pr, merged]` land shape).
|
|
13
|
+
* loop too (the canonical `agent → connector[converge-merge] → wait[pr, merged]` land shape). This is
|
|
14
|
+
* the UNIT-level land (ADR 0006 §3 two-level merge): a unit PR lands onto its own base branch — for a
|
|
15
|
+
* unit inside an epic that base is the epic integration branch, never `main` directly. */
|
|
14
16
|
export const CONVERGE_MERGE_TARGET = "converge-merge";
|
|
15
17
|
|
|
16
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
/** The GRAPH-level top-level merge target (ADR 0006 §3 / S5 two-level merge). Enrolls the graph/epic
|
|
19
|
+
* INTEGRATION PR into the shared convergence+merge doors like {@link CONVERGE_MERGE_TARGET}, but names
|
|
20
|
+
* the second, top-of-graph level whose base branch IS `main`. Behaviourally identical to
|
|
21
|
+
* `converge-merge` in dispatch (both `submitPr` with `convergeOnly=false`, landing to the PR's own
|
|
22
|
+
* base); the distinction is the LEVEL — kept a first-class literal so the two-level merge (unit → base;
|
|
23
|
+
* graph → `main`, ADR 0003 base-branch admission) is authored/enforced explicitly, not left emergent. */
|
|
24
|
+
export const MERGE_MAIN_TARGET = "merge-main";
|
|
25
|
+
|
|
26
|
+
/** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge` / `merge-main`)?
|
|
27
|
+
* The single predicate the connector worker branches on to route a dispatch into `submitPr`, and the
|
|
28
|
+
* validator branches on to require a bound/literal PR (issue #548). */
|
|
19
29
|
export function isConvergeTarget(target: string): boolean {
|
|
20
|
-
return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
|
|
30
|
+
return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET || target === MERGE_MAIN_TARGET;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
/** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
|
|
24
|
-
* `converged`), `converge-merge`
|
|
25
|
-
* `convergeOnly` argument. An author may still override it per-dispatch via the
|
|
26
|
-
* `convergeOnly`. Only ever consulted behind `isConvergeTarget`, so a non-converge
|
|
27
|
-
* is unreachable. */
|
|
34
|
+
* `converged`), `converge-merge` and `merge-main` drive the merge loop too (`false`). Maps directly
|
|
35
|
+
* onto `submitPr`'s `convergeOnly` argument. An author may still override it per-dispatch via the
|
|
36
|
+
* connector payload's `convergeOnly`. Only ever consulted behind `isConvergeTarget`, so a non-converge
|
|
37
|
+
* target's `false` is unreachable. */
|
|
28
38
|
export function convergeOnlyForTarget(target: string): boolean {
|
|
29
39
|
return target === CONVERGE_TARGET;
|
|
30
40
|
}
|
|
@@ -17,6 +17,7 @@ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
|
17
17
|
import {
|
|
18
18
|
CONVERGE_MERGE_TARGET,
|
|
19
19
|
CONVERGE_TARGET,
|
|
20
|
+
MERGE_MAIN_TARGET,
|
|
20
21
|
connectorDedupeKey,
|
|
21
22
|
convergeOnlyForTarget,
|
|
22
23
|
type DeliveryConnectorDispatchRow,
|
|
@@ -103,6 +104,14 @@ test("converge targets: `converge`/`converge-merge` are the enrollment targets;
|
|
|
103
104
|
assertEquals(convergeOnlyForTarget("converge-merge"), false);
|
|
104
105
|
});
|
|
105
106
|
|
|
107
|
+
test("two-level merge: `merge-main` is the graph-level enrollment target that drives the merge loop (S5)", () => {
|
|
108
|
+
assertEquals(MERGE_MAIN_TARGET, "merge-main");
|
|
109
|
+
// The graph-level top-level merge (graph → main) enrolls via `submitPr` like `converge-merge`.
|
|
110
|
+
assert(isConvergeTarget("merge-main"));
|
|
111
|
+
// It drives the merge loop (not review-only), so its `convergeOnly` default is false.
|
|
112
|
+
assertEquals(convergeOnlyForTarget("merge-main"), false);
|
|
113
|
+
});
|
|
114
|
+
|
|
106
115
|
test("first dispatch delivers exactly once; a redelivery on the same key dedupes and never re-acts", async () => {
|
|
107
116
|
await withApp(async (app) => {
|
|
108
117
|
const at = "2025-01-01T00:00:00.000Z";
|
package/app/deliveryConnector.ts
CHANGED
|
@@ -33,12 +33,13 @@ export const DELIVERY_CONNECTOR_TASK_TYPE = "pr.delivery-connector";
|
|
|
33
33
|
export const OUTCOME_CLAIMED = "claimed";
|
|
34
34
|
export const OUTCOME_DELIVERED = "delivered";
|
|
35
35
|
|
|
36
|
-
/** The
|
|
36
|
+
/** The three connector `target`s that enroll an agent-opened PR into the app's SHARED convergence /
|
|
37
37
|
* merge doors via `submitPr` (issue #500) — the delivery-graph side of the exact seam the feature
|
|
38
|
-
* cell reuses (`workers/converge-feature`), no duplicated machinery. `converge-merge`
|
|
39
|
-
*
|
|
38
|
+
* cell reuses (`workers/converge-feature`), no duplicated machinery. `converge-merge` (unit-level) and
|
|
39
|
+
* `merge-main` (graph-level, ADR 0006 §3 two-level merge) drive review convergence AND the merge loop;
|
|
40
|
+
* `converge` stops at `converged` (converge-only). This is the "real
|
|
40
41
|
* target dispatch" ADR 0005 deferred as a later slice for the connector I/O surface: a `converge`/
|
|
41
|
-
* `converge-merge` connector IS the "automated, side-effecting outbound action" a connector is
|
|
42
|
+
* `converge-merge`/`merge-main` connector IS the "automated, side-effecting outbound action" a connector is
|
|
42
43
|
* defined to be. The converge-target vocabulary lives in the dependency-free {@link ./convergeTargets.ts}
|
|
43
44
|
* so the pure validator can share it; re-exported here for the worker's existing import surface. */
|
|
44
45
|
export {
|
|
@@ -46,6 +47,7 @@ export {
|
|
|
46
47
|
CONVERGE_TARGET,
|
|
47
48
|
convergeOnlyForTarget,
|
|
48
49
|
isConvergeTarget,
|
|
50
|
+
MERGE_MAIN_TARGET,
|
|
49
51
|
} from "./convergeTargets.ts";
|
|
50
52
|
|
|
51
53
|
/** One durable dispatch-claim row — the at-most-once ledger entry a connector writes before it acts. */
|
|
@@ -311,6 +311,67 @@ test("a human node may omit its config (generic-fallback resolution lands in S3)
|
|
|
311
311
|
assertEquals(validateDeliveryGraph({ nodes: [{ id: "done", kind: "human" }] }), []);
|
|
312
312
|
});
|
|
313
313
|
|
|
314
|
+
test("raw-converge-node: a raw `senior:converge`/`senior:merge` agent job is not expressible (S5)", () => {
|
|
315
|
+
for (const jobType of ["senior:converge", "senior:merge", "converge", "merge"]) {
|
|
316
|
+
const errors = validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType } }] });
|
|
317
|
+
const err = hasCode(errors, "raw-converge-node");
|
|
318
|
+
assertEquals(err.path, "nodes[0].agent.jobType");
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("raw-converge-node: `senior:trial-merge` (the merge-cell body) is NOT swept up (exact-verb)", () => {
|
|
323
|
+
assertEquals(
|
|
324
|
+
validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:trial-merge" } }] }),
|
|
325
|
+
[],
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("a cell node may carry first-class `converge`/`merge` policy (S5)", () => {
|
|
330
|
+
assertEquals(
|
|
331
|
+
validateDeliveryGraph({
|
|
332
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", converge: true, merge: true } }],
|
|
333
|
+
}),
|
|
334
|
+
[],
|
|
335
|
+
);
|
|
336
|
+
// converge-only (stop at green) is legal on its own.
|
|
337
|
+
assertEquals(
|
|
338
|
+
validateDeliveryGraph({
|
|
339
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", converge: true } }],
|
|
340
|
+
}),
|
|
341
|
+
[],
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("converge-merge-type: `agent.converge`/`agent.merge` must be boolean when present (S5 trust boundary)", () => {
|
|
346
|
+
// `validateDeliveryGraph` is the trust boundary before `as DeliveryGraph`, so a graph that bypassed
|
|
347
|
+
// OpenAPI validation must not be able to smuggle a non-boolean `converge`/`merge` past the S5 policy
|
|
348
|
+
// checks (which compare `=== true`) — a truthy `"true"`/`1` would silently evade merge-requires-converge.
|
|
349
|
+
for (const bad of ["true", 1, 0, null] as const) {
|
|
350
|
+
const cErr = hasCode(
|
|
351
|
+
validateDeliveryGraph({
|
|
352
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", converge: bad } }],
|
|
353
|
+
}),
|
|
354
|
+
"converge-merge-type",
|
|
355
|
+
);
|
|
356
|
+
assertEquals(cErr.path, "nodes[0].agent.converge");
|
|
357
|
+
const mErr = hasCode(
|
|
358
|
+
validateDeliveryGraph({
|
|
359
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", converge: true, merge: bad } }],
|
|
360
|
+
}),
|
|
361
|
+
"converge-merge-type",
|
|
362
|
+
);
|
|
363
|
+
assertEquals(mErr.path, "nodes[0].agent.merge");
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("merge-requires-converge: `agent.merge` without `agent.converge` is rejected (S5 edge-gate)", () => {
|
|
368
|
+
const errors = validateDeliveryGraph({
|
|
369
|
+
nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", merge: true } }],
|
|
370
|
+
});
|
|
371
|
+
const err = hasCode(errors, "merge-requires-converge");
|
|
372
|
+
assertEquals(err.path, "nodes[0].agent.merge");
|
|
373
|
+
});
|
|
374
|
+
|
|
314
375
|
test("duplicate-fact: two emits sharing a name on one node is rejected", () => {
|
|
315
376
|
const errors = validateDeliveryGraph({
|
|
316
377
|
nodes: [
|
package/app/deliveryGraph.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// caller can point the author straight at the offending input.
|
|
21
21
|
|
|
22
22
|
import { isConvergeTarget } from "./convergeTargets.ts";
|
|
23
|
+
import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
|
|
23
24
|
|
|
24
25
|
/** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
|
|
25
26
|
* deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
|
|
@@ -90,6 +91,9 @@ export type DeliveryGraphErrorCode =
|
|
|
90
91
|
| "non-exhaustive-split"
|
|
91
92
|
| "exclusive-merge-parity"
|
|
92
93
|
| "unsupported-on-timeout"
|
|
94
|
+
| "raw-converge-node"
|
|
95
|
+
| "merge-requires-converge"
|
|
96
|
+
| "converge-merge-type"
|
|
93
97
|
| "unbound-pr";
|
|
94
98
|
|
|
95
99
|
/** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
|
|
@@ -377,6 +381,53 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
|
|
|
377
381
|
code: "unsupported-on-timeout",
|
|
378
382
|
});
|
|
379
383
|
}
|
|
384
|
+
// S5 (ADR 0006 §3): converge/merge are first-class, edge-gated CELL POLICY, not raw nodes.
|
|
385
|
+
// A raw converge/merge agent job (`senior:converge`, `senior:merge`, or a bare `converge`/
|
|
386
|
+
// `merge` verb) is retired as user-facing vocabulary — reject it at compile so "a raw converge
|
|
387
|
+
// node is not expressible" (issue #592). The author expresses convergence/landing via the
|
|
388
|
+
// cell's `converge?`/`merge?` policy instead. `senior:trial-merge` (the merge-cell's internal
|
|
389
|
+
// trial body) and every other verb are unaffected (exact-verb match).
|
|
390
|
+
if (kind === "agent" && typeof config.jobType === "string" && isRawConvergeMergeJobType(config.jobType)) {
|
|
391
|
+
errors.push({
|
|
392
|
+
path: `${path}.${configKey}.jobType`,
|
|
393
|
+
message:
|
|
394
|
+
`raw converge/merge agent job "${config.jobType}" is not expressible — converge and merge ` +
|
|
395
|
+
"are first-class cell policy (set `agent.converge`/`agent.merge`), not a raw agent node " +
|
|
396
|
+
"(ADR 0006 §3 / S5)",
|
|
397
|
+
code: "raw-converge-node",
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
// S5 trust boundary: `validateDeliveryGraph` is the gate before `dispatchDeliveryGraphRun`
|
|
401
|
+
// narrows `graph: unknown` with `as DeliveryGraph`, so a graph that bypassed OpenAPI validation
|
|
402
|
+
// must not smuggle a non-boolean `converge`/`merge` past the policy checks below (which compare
|
|
403
|
+
// `=== true`). A truthy `"true"`/`1` would otherwise silently evade merge-requires-converge and
|
|
404
|
+
// the S5 cell policy. Reject any present-but-non-boolean value path-qualified.
|
|
405
|
+
if (kind === "agent") {
|
|
406
|
+
for (const flag of NODE_COMPLETION_POLICIES) {
|
|
407
|
+
const value = config[flag];
|
|
408
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
409
|
+
errors.push({
|
|
410
|
+
path: `${path}.${configKey}.${flag}`,
|
|
411
|
+
message:
|
|
412
|
+
`\`agent.${flag}\`, when present, must be a boolean — got ${JSON.stringify(value)} ` +
|
|
413
|
+
"(the S5 cell policy is edge-gated on strict `true`/`false`, not a truthy value)",
|
|
414
|
+
code: "converge-merge-type",
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// S5 edge-gate: `merge` presupposes `converge`. Landing a PR you have not driven to green is
|
|
420
|
+
// incoherent (the two are separable phases, but merge REQUIRES converge). Reject `merge: true`
|
|
421
|
+
// without `converge: true` so the enforced policy can't express "land without converging".
|
|
422
|
+
if (kind === "agent" && config.merge === true && config.converge !== true) {
|
|
423
|
+
errors.push({
|
|
424
|
+
path: `${path}.${configKey}.merge`,
|
|
425
|
+
message:
|
|
426
|
+
"`agent.merge` requires `agent.converge` — a PR cannot be landed before it is driven to " +
|
|
427
|
+
"green (ADR 0006 §3 two separable-but-ordered phases / S5)",
|
|
428
|
+
code: "merge-requires-converge",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
380
431
|
// #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
|
|
381
432
|
// binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
|
|
382
433
|
if (typeof id === "string" && id.length > 0) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Unit coverage for the pure node completion-policy vocabulary `app/nodePolicy.ts` (ADR 0006 §3,
|
|
2
|
+
// slice S5). Exercises the two ideas S5 promotes to first-class, enforced policy — with no engine and
|
|
3
|
+
// no side effects:
|
|
4
|
+
// • the RAW converge/merge retirement predicate (`isRawConvergeMergeJobType`) that makes "a raw
|
|
5
|
+
// converge node not expressible" (issue #592 acceptance #1) while leaving every legitimate verb —
|
|
6
|
+
// crucially `senior:trial-merge`, the merge-cell's own internal body — untouched, and
|
|
7
|
+
// • the TWO-LEVEL merge mapping (`mergeLevelForTarget` / `mergeBranchForLevel`): a unit lands onto
|
|
8
|
+
// its base branch, the graph lands onto `main`, and a unit can NEVER silently collapse onto `main`
|
|
9
|
+
// (issue #592 acceptance #2, ADR 0003 base-branch admission).
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
12
|
+
import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
|
|
13
|
+
import {
|
|
14
|
+
GRAPH_MERGE_BRANCH,
|
|
15
|
+
isRawConvergeMergeJobType,
|
|
16
|
+
jobTypeVerb,
|
|
17
|
+
MERGE_LEVEL_TARGET,
|
|
18
|
+
mergeBranchForLevel,
|
|
19
|
+
mergeLevelForTarget,
|
|
20
|
+
NODE_COMPLETION_POLICIES,
|
|
21
|
+
RAW_CONVERGE_MERGE_VERBS,
|
|
22
|
+
} from "./nodePolicy.ts";
|
|
23
|
+
|
|
24
|
+
test("RAW_CONVERGE_MERGE_VERBS is derived from NODE_COMPLETION_POLICIES (single source of truth, no drift)", () => {
|
|
25
|
+
assertEquals([...RAW_CONVERGE_MERGE_VERBS], [...NODE_COMPLETION_POLICIES]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("jobTypeVerb extracts the task verb after the last colon, trimmed + lower-cased", () => {
|
|
29
|
+
assertEquals(jobTypeVerb("senior:feature"), "feature");
|
|
30
|
+
assertEquals(jobTypeVerb("senior:trial-merge"), "trial-merge");
|
|
31
|
+
assertEquals(jobTypeVerb("converge"), "converge");
|
|
32
|
+
assertEquals(jobTypeVerb("staff:Merge"), "merge");
|
|
33
|
+
assertEquals(jobTypeVerb("a:b:merge "), "merge");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("isRawConvergeMergeJobType retires raw converge/merge agent jobs (any rank, bare, mixed case)", () => {
|
|
37
|
+
assert(isRawConvergeMergeJobType("senior:converge"));
|
|
38
|
+
assert(isRawConvergeMergeJobType("senior:merge"));
|
|
39
|
+
assert(isRawConvergeMergeJobType("converge"));
|
|
40
|
+
assert(isRawConvergeMergeJobType("merge"));
|
|
41
|
+
assert(isRawConvergeMergeJobType("staff:Merge"));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("isRawConvergeMergeJobType leaves legitimate verbs untouched (exact-verb match, not substring)", () => {
|
|
45
|
+
// `senior:trial-merge` is the merge-cell's own internal trial body — it must NOT be swept up.
|
|
46
|
+
assert(!isRawConvergeMergeJobType("senior:trial-merge"));
|
|
47
|
+
assert(!isRawConvergeMergeJobType("senior:feature"));
|
|
48
|
+
assert(!isRawConvergeMergeJobType("senior:fix"));
|
|
49
|
+
assert(!isRawConvergeMergeJobType("j"));
|
|
50
|
+
assert(!isRawConvergeMergeJobType("merge-cell"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("MERGE_LEVEL_TARGET pairs each level with its converge-enrollment target", () => {
|
|
54
|
+
assertEquals(MERGE_LEVEL_TARGET.unit, CONVERGE_MERGE_TARGET);
|
|
55
|
+
assertEquals(MERGE_LEVEL_TARGET.graph, MERGE_MAIN_TARGET);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("mergeLevelForTarget maps the two-level merge targets, null for non-landing/other targets", () => {
|
|
59
|
+
assertEquals(mergeLevelForTarget(CONVERGE_MERGE_TARGET), "unit");
|
|
60
|
+
assertEquals(mergeLevelForTarget(MERGE_MAIN_TARGET), "graph");
|
|
61
|
+
// `converge` is review-only (non-landing) → not a merge level.
|
|
62
|
+
assertEquals(mergeLevelForTarget(CONVERGE_TARGET), null);
|
|
63
|
+
assertEquals(mergeLevelForTarget("slack:#x"), null);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("mergeBranchForLevel: two-level merge — unit lands on its base branch, graph lands on main", () => {
|
|
67
|
+
assertEquals(mergeBranchForLevel("unit", { baseBranch: "epic/some-epic" }), "epic/some-epic");
|
|
68
|
+
assertEquals(mergeBranchForLevel("graph", { baseBranch: "epic/some-epic" }), GRAPH_MERGE_BRANCH);
|
|
69
|
+
assertEquals(GRAPH_MERGE_BRANCH, "main");
|
|
70
|
+
// The graph level ignores any supplied base — it ALWAYS targets main.
|
|
71
|
+
assertEquals(mergeBranchForLevel("graph", { baseBranch: null }), "main");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("mergeBranchForLevel: a unit can NEVER silently collapse onto main — missing base throws", () => {
|
|
75
|
+
assertThrows(() => mergeBranchForLevel("unit", { baseBranch: null }));
|
|
76
|
+
assertThrows(() => mergeBranchForLevel("unit", { baseBranch: " " }));
|
|
77
|
+
assertThrows(() => mergeBranchForLevel("unit", {}));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("mergeBranchForLevel: a unit can NEVER target main directly — explicit main base throws", () => {
|
|
81
|
+
assertThrows(() => mergeBranchForLevel("unit", { baseBranch: "main" }));
|
|
82
|
+
assertThrows(() => mergeBranchForLevel("unit", { baseBranch: " main " }));
|
|
83
|
+
assertThrows(() => mergeBranchForLevel("unit", { baseBranch: GRAPH_MERGE_BRANCH }));
|
|
84
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// nano-workforce — the first-class NODE COMPLETION-POLICY vocabulary for an agent-authored delivery
|
|
2
|
+
// graph (ADR 0006 §3, slice S5). The pure, dependency-free source of truth for two ideas the ADR
|
|
3
|
+
// promotes from EMERGENT behaviour / smuggled prompt prose to explicit, EDGE-GATED, compiler-enforced
|
|
4
|
+
// policy:
|
|
5
|
+
//
|
|
6
|
+
// 1. `converge?` / `merge?` are first-class completion-policy FLAGS on a cell (`agent`) node, NOT
|
|
7
|
+
// raw nodes. "get to green, then land" used to live in a gateway inside `feature.bpmn`
|
|
8
|
+
// (`gw-converge` + the `autoMerge` boolean) and, for a delivery-graph `agent` node, in FREE TEXT
|
|
9
|
+
// in a prompt ("un-draft + merge #B"). This module gives the validator/compiler the predicate
|
|
10
|
+
// that RETIRES a raw converge/merge agent job (`senior:converge`, `senior:merge`) — converge and
|
|
11
|
+
// merge survive only as the `converge`/`merge` policy on a cell, so "a raw converge node is not
|
|
12
|
+
// expressible in the authored vocabulary" (issue #592 acceptance #1).
|
|
13
|
+
//
|
|
14
|
+
// 2. `merge` is TWO-LEVEL (ADR 0003 base-branch admission, ADR 0006 §3): a UNIT's merge lands onto
|
|
15
|
+
// its epic/graph BASE branch, never `main` directly; the GRAPH's final merge-to-`main` is a
|
|
16
|
+
// separate top-level step. This module maps each level to its converge-enrollment target
|
|
17
|
+
// (`converge-merge` = unit → base, `merge-main` = graph → main) and resolves the branch a level
|
|
18
|
+
// lands on, so the two levels are authored/enforced explicitly rather than collapsed.
|
|
19
|
+
//
|
|
20
|
+
// Kept import-light (only the dependency-free `convergeTargets.ts` literals) so the pure semantic
|
|
21
|
+
// validator (`deliveryGraph.ts`) can share these predicates WITHOUT pulling in the connector module's
|
|
22
|
+
// urban/data-layer deps.
|
|
23
|
+
|
|
24
|
+
import { CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
|
|
25
|
+
|
|
26
|
+
/** The first-class node completion-policy flags a cell (`agent`) node may carry (ADR 0006 §3). Both
|
|
27
|
+
* are separable phases: `converge` drives the PR through its review-convergence loop to green;
|
|
28
|
+
* `merge` lands it. Kept as the single source of truth so the validator, the openapi shape, and any
|
|
29
|
+
* future compiler agree on the closed policy set. */
|
|
30
|
+
export const NODE_COMPLETION_POLICIES = ["converge", "merge"] as const;
|
|
31
|
+
|
|
32
|
+
/** A node completion policy, narrowed to the closed set. */
|
|
33
|
+
export type NodeCompletionPolicy = (typeof NODE_COMPLETION_POLICIES)[number];
|
|
34
|
+
|
|
35
|
+
/** The reserved task VERBS that name a converge/merge phase. An `agent` node's `jobType` is
|
|
36
|
+
* `<rank>:<task>` (e.g. `senior:feature`), or a bare `<task>`; its task verb is the segment after the
|
|
37
|
+
* last `:`. A jobType whose verb is exactly `converge` or `merge` is a RAW converge/merge node — the
|
|
38
|
+
* exact thing S5 retires: converge/merge are cell POLICY (`converge?`/`merge?`), never a raw agent
|
|
39
|
+
* job. Matched by exact verb equality (case-insensitive) so a legitimately-different verb that merely
|
|
40
|
+
* CONTAINS the word — e.g. `senior:trial-merge` (verb `trial-merge`), the real merge-cell body — is
|
|
41
|
+
* NOT swept up. Derived from {@link NODE_COMPLETION_POLICIES} so the reserved-verb vocabulary and the
|
|
42
|
+
* cell policy set cannot drift (they are the same closed set, seen from two angles). */
|
|
43
|
+
export const RAW_CONVERGE_MERGE_VERBS: readonly string[] = NODE_COMPLETION_POLICIES;
|
|
44
|
+
|
|
45
|
+
/** Extract the task verb from an agent `jobType`: the segment after the LAST `:` (`senior:feature` →
|
|
46
|
+
* `feature`), or the whole string when unqualified. Trimmed and lower-cased for a stable compare. */
|
|
47
|
+
export function jobTypeVerb(jobType: string): string {
|
|
48
|
+
const colon = jobType.lastIndexOf(":");
|
|
49
|
+
const verb = colon >= 0 ? jobType.slice(colon + 1) : jobType;
|
|
50
|
+
return verb.trim().toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when `jobType` names a RAW converge/merge node — an agent job whose task verb is exactly
|
|
54
|
+
* `converge` or `merge`. These are retired as user-facing vocabulary (issue #592): the author must
|
|
55
|
+
* express convergence/landing via the cell's first-class `converge?` / `merge?` policy, not a raw
|
|
56
|
+
* agent job. `senior:trial-merge` (the merge-cell's internal trial body) and every non-converge verb
|
|
57
|
+
* are unaffected. */
|
|
58
|
+
export function isRawConvergeMergeJobType(jobType: string): boolean {
|
|
59
|
+
const verb = jobTypeVerb(jobType);
|
|
60
|
+
for (const raw of RAW_CONVERGE_MERGE_VERBS) if (raw === verb) return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The two levels a `merge` lands at (ADR 0006 §3 two-level merge). `unit` = a delivery UNIT (a
|
|
65
|
+
* feature/slice PR) landing onto its epic/graph base branch; `graph` = the top-of-graph integration
|
|
66
|
+
* landing onto `main`. */
|
|
67
|
+
export const MERGE_LEVELS = ["unit", "graph"] as const;
|
|
68
|
+
|
|
69
|
+
/** A merge level, narrowed to the closed set. */
|
|
70
|
+
export type MergeLevel = (typeof MERGE_LEVELS)[number];
|
|
71
|
+
|
|
72
|
+
/** The converge-enrollment connector target each merge level dispatches through: a UNIT merge lands
|
|
73
|
+
* via `converge-merge` (onto its own base branch), the GRAPH's top-level merge via `merge-main` (onto
|
|
74
|
+
* `main`). The single source of truth pairing the two-level policy with the connector vocabulary. */
|
|
75
|
+
export const MERGE_LEVEL_TARGET: Record<MergeLevel, string> = {
|
|
76
|
+
unit: CONVERGE_MERGE_TARGET,
|
|
77
|
+
graph: MERGE_MAIN_TARGET,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Reverse of {@link MERGE_LEVEL_TARGET}: the merge level a converge-enrollment `target` names, or
|
|
81
|
+
* `null` when the target is not a two-level merge target (`converge` is review-only, non-landing).
|
|
82
|
+
* Lets the validator/compiler decide which branch a merge cell lands on from the authored target. */
|
|
83
|
+
export function mergeLevelForTarget(target: string): MergeLevel | null {
|
|
84
|
+
if (target === CONVERGE_MERGE_TARGET) return "unit";
|
|
85
|
+
if (target === MERGE_MAIN_TARGET) return "graph";
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve the branch a merge LEVEL lands on (ADR 0003 base-branch admission). A `unit` merge lands on
|
|
90
|
+
* the supplied `baseBranch` (the epic/graph integration branch the unit PR targets); a `graph` merge
|
|
91
|
+
* lands on `main`. The two-level invariant in one place: a unit NEVER lands on `main` directly, and
|
|
92
|
+
* the graph's top-level merge ALWAYS targets `main`. `baseBranch` is required for a unit level (its
|
|
93
|
+
* whole point is "not main"); an absent/empty base for a unit — or an explicit `main` base — is a
|
|
94
|
+
* caller error surfaced as a throw so a unit can never silently collapse onto `main`. */
|
|
95
|
+
export const GRAPH_MERGE_BRANCH = "main";
|
|
96
|
+
|
|
97
|
+
export function mergeBranchForLevel(level: MergeLevel, opts: { baseBranch?: string | null }): string {
|
|
98
|
+
if (level === "graph") return GRAPH_MERGE_BRANCH;
|
|
99
|
+
const base = opts.baseBranch?.trim();
|
|
100
|
+
if (!base) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
"unit-level merge requires a base branch (ADR 0003 two-level merge: a unit lands onto its " +
|
|
103
|
+
"epic/graph base branch, never `main` directly)",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
if (base === GRAPH_MERGE_BRANCH) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"unit-level merge must not target `main` directly (ADR 0003 two-level merge: a unit lands onto " +
|
|
109
|
+
"its epic/graph base branch; the graph's top-level `merge-main` is the only path to `main`)",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return base;
|
|
113
|
+
}
|
package/docs/agent-guide.md
CHANGED
|
@@ -438,10 +438,10 @@ layer schedules, it does not re-implement execution):
|
|
|
438
438
|
|
|
439
439
|
| kind | config | what it does | may `emits`? |
|
|
440
440
|
|---|---|---|---|
|
|
441
|
-
| `agent` | `agent: { jobType, prompt? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** | yes |
|
|
441
|
+
| `agent` | `agent: { jobType, prompt?, converge?, merge? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** First-class **`converge?` / `merge?`** cell policy (§9.4) — a **declared, compiler-validated** completion-policy flag that *declares* review-convergence / landing intent (`merge` requires `converge`); a raw `senior:converge`/`senior:merge` job is rejected. This slice adds + validates the flags; the delivery-graph execution wiring that consumes them lands in a follow-up slice. | yes |
|
|
442
442
|
| `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`, `epic`. Read-only. | yes (binds observed facts) |
|
|
443
443
|
| `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
|
|
444
|
-
| `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe).
|
|
444
|
+
| `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Three **real targets** ship today — **`converge`**, **`converge-merge`** (unit → base branch) and **`merge-main`** (graph → `main`, the two-level top-level land) (§9.4); other targets are a forward-declared stub. | yes |
|
|
445
445
|
|
|
446
446
|
A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
|
|
447
447
|
intake uses): `{ kind, target, onTimeout?, match?, poll? }`, where `poll` is
|
|
@@ -578,7 +578,7 @@ publish and records the version → open+merge PR #303 (repo 3) consuming that v
|
|
|
578
578
|
"wait": { "kind": "pr", "target": "acme/repo-1#101", "match": { "prState": "merged" },
|
|
579
579
|
"poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } },
|
|
580
580
|
{ "id": "undraft-merge-b", "kind": "agent",
|
|
581
|
-
"agent": { "jobType": "senior:merge", "prompt": "Take draft PR acme/repo-2#202 out of draft
|
|
581
|
+
"agent": { "jobType": "senior:feature", "converge": true, "merge": true, "prompt": "Take draft PR acme/repo-2#202 out of draft; converge it to green and land it." } },
|
|
582
582
|
{ "id": "manual-publish", "kind": "human",
|
|
583
583
|
"human": { "prompt": "Run the manual OTP-authenticated `npm publish` for @acme/widget and set up OIDC trusted publishing. Record the exact published version." },
|
|
584
584
|
"emits": [ { "name": "publishedVersion", "type": "version", "description": "The version just published to npm." } ] },
|
|
@@ -621,19 +621,31 @@ the consumer a `wait` node with `kind: "capability"` (resolving *which published
|
|
|
621
621
|
`pkg@version` first carries the change*) fed by the same `manual-publish.publishedVersion`
|
|
622
622
|
fact — the fact-edge syntax is identical.
|
|
623
623
|
|
|
624
|
-
### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge`)
|
|
624
|
+
### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge` / `merge-main`)
|
|
625
625
|
|
|
626
|
-
A `connector` node with **`target: "converge-merge"`** (or **`"converge"`**
|
|
627
|
-
agent-opened PR into the app's **shared convergence loop** — the *same* enrollment §1 (a
|
|
626
|
+
A `connector` node with **`target: "converge-merge"`** (or **`"converge"`** / **`"merge-main"`**)
|
|
627
|
+
enrolls an agent-opened PR into the app's **shared convergence loop** — the *same* enrollment §1 (a
|
|
628
628
|
standalone submit) and a feature run use (`submitPr`), no duplicated machinery. This replaces
|
|
629
629
|
the old habit of bridging an `agent`-opened PR to review with a **human `land-*` gate** whose
|
|
630
630
|
only job was "go run convergence yourself".
|
|
631
631
|
|
|
632
|
-
- **`converge-merge`** — drive review convergence **and then the merge
|
|
633
|
-
|
|
632
|
+
- **`converge-merge`** — the **unit-level** land: drive review convergence **and then the merge
|
|
633
|
+
loop**, landing the PR onto **its own base branch** (for a unit inside an epic that base is the
|
|
634
|
+
epic integration branch, never `main` directly — ADR 0003 base-branch admission). Equivalent to a
|
|
635
|
+
submit with `convergeOnly: false`.
|
|
636
|
+
- **`merge-main`** — the **graph-level** top-level land: the second level of the two-level merge
|
|
637
|
+
(ADR 0006 §3), landing the graph/epic **integration** PR onto **`main`**. Dispatch-identical to
|
|
638
|
+
`converge-merge` (both enroll + merge); the distinction is the *level*, kept a first-class literal
|
|
639
|
+
so the two levels are authored explicitly rather than left emergent.
|
|
634
640
|
- **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
|
|
635
641
|
handing off to the merge loop (equivalent to `convergeOnly: true`).
|
|
636
642
|
|
|
643
|
+
> **converge/merge are cell POLICY, not raw nodes.** A raw `senior:converge` / `senior:merge`
|
|
644
|
+
> **agent** job is **not expressible** — the compiler rejects it (`raw-converge-node`). Express
|
|
645
|
+
> "get to green, then land" via a cell node's first-class **`agent.converge` / `agent.merge`**
|
|
646
|
+
> policy flags (`merge` requires `converge`), or, for enrolling an already-open PR, the
|
|
647
|
+
> `connector` targets above (ADR 0006 §3 / S5).
|
|
648
|
+
|
|
637
649
|
**Payload:** `{ pr: "owner/repo#123", convergeOnly?: boolean, dependsOn?: string[] }`. `pr` is
|
|
638
650
|
required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
|
|
639
651
|
`convergeOnly` defaults from the target and may be overridden per-node; `dependsOn` is unioned
|
package/openapi.yaml
CHANGED
|
@@ -1430,6 +1430,29 @@ components:
|
|
|
1430
1430
|
type: string
|
|
1431
1431
|
maxLength: 20000
|
|
1432
1432
|
description: OPTIONAL steering prompt appended to the node's job brief.
|
|
1433
|
+
converge:
|
|
1434
|
+
type: boolean
|
|
1435
|
+
description: >-
|
|
1436
|
+
OPTIONAL first-class CONVERGE policy (ADR 0006 §3 / S5) — a DECLARED, compiler-
|
|
1437
|
+
validated completion-policy flag on this cell node. It declares that the node's
|
|
1438
|
+
opened PR is to be driven through the review-convergence loop to green as an
|
|
1439
|
+
edge-gated completion policy; this slice adds and validates the flag, with the
|
|
1440
|
+
delivery-graph execution wiring that consumes it landing in a follow-up slice.
|
|
1441
|
+
It supersedes (in intent) the emergent `feature.bpmn` `gw-converge` gateway and
|
|
1442
|
+
the "un-draft + merge #B" prompt prose a delivery-graph `agent` node used to
|
|
1443
|
+
smuggle. Converge and merge are SEPARABLE phases; a node may converge without
|
|
1444
|
+
merging (stop at green and gate the landing behind a downstream node).
|
|
1445
|
+
merge:
|
|
1446
|
+
type: boolean
|
|
1447
|
+
description: >-
|
|
1448
|
+
OPTIONAL first-class MERGE (land) policy (ADR 0006 §3 / S5) — a DECLARED,
|
|
1449
|
+
compiler-validated flag. When set it declares that the cell lands its PR.
|
|
1450
|
+
REQUIRES `converge: true` — you cannot land a PR you have not driven to green
|
|
1451
|
+
(the validator rejects `merge` without `converge`). This slice adds and validates
|
|
1452
|
+
the flag; the execution wiring that consumes it lands in a follow-up slice.
|
|
1453
|
+
TWO-LEVEL (ADR 0003 base-branch admission): a UNIT node lands onto its epic/graph
|
|
1454
|
+
base branch, never `main` directly; the graph's final merge-to-`main` is a
|
|
1455
|
+
separate top-level step.
|
|
1433
1456
|
timeout:
|
|
1434
1457
|
type: string
|
|
1435
1458
|
pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
|
|
@@ -60,7 +60,7 @@ test("the guide documents the delivery-graph surface (ADR 0005)", async () => {
|
|
|
60
60
|
// The closed node vocabulary: assert the exact config snippet for each of the four kinds,
|
|
61
61
|
// so the test fails if §9's node-kind table is removed or reworded — not merely if the bare
|
|
62
62
|
// words "agent"/"wait"/"human"/"connector" appear anywhere else in the guide.
|
|
63
|
-
assert(md.includes("agent: { jobType, prompt? }"), "documents the agent node config");
|
|
63
|
+
assert(md.includes("agent: { jobType, prompt?, converge?, merge? }"), "documents the agent node config");
|
|
64
64
|
assert(md.includes("wait: <ReadinessProbe>"), "documents the wait node config");
|
|
65
65
|
assert(md.includes("human?: { formKey?, prompt? }"), "documents the human node config");
|
|
66
66
|
assert(md.includes("connector: { target, dedupeKey?, payload? }"), "documents the connector node config");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.158.1",
|
|
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
|
@@ -542,14 +542,14 @@ export function mountCockpit(host, opts = {}) {
|
|
|
542
542
|
setNote(undefined);
|
|
543
543
|
}
|
|
544
544
|
|
|
545
|
-
function setNote(text) {
|
|
545
|
+
function setNote(text, state = "waiting") {
|
|
546
546
|
if (text == null) {
|
|
547
547
|
terminalNote.textContent = "";
|
|
548
548
|
terminalNote.setAttribute("data-terminal-note", "none");
|
|
549
549
|
return;
|
|
550
550
|
}
|
|
551
551
|
terminalNote.textContent = text;
|
|
552
|
-
terminalNote.setAttribute("data-terminal-note",
|
|
552
|
+
terminalNote.setAttribute("data-terminal-note", state);
|
|
553
553
|
}
|
|
554
554
|
|
|
555
555
|
function teardownTerminal() {
|
|
@@ -628,7 +628,14 @@ export function mountCockpit(host, opts = {}) {
|
|
|
628
628
|
let session;
|
|
629
629
|
const client = new RelayChannelClient({
|
|
630
630
|
connect: connectRelay,
|
|
631
|
-
onRelay: (message) =>
|
|
631
|
+
onRelay: (message) => {
|
|
632
|
+
// Promote the note to "waiting for live output" only once the hub ACKs the subscribe. Until
|
|
633
|
+
// then it honestly reads "connecting", so a socket that never opens/subscribes stops
|
|
634
|
+
// masquerading as a connected-but-quiet stream (#600). Gate on `!cleared` so a reconnect's
|
|
635
|
+
// resubscribe ack does not re-arm the note after real output has already flowed.
|
|
636
|
+
if (!cleared && message?.op === "subscribed") setNote("Waiting for live output…", "waiting");
|
|
637
|
+
session?.handle(message);
|
|
638
|
+
},
|
|
632
639
|
onOpen: () => session?.attach(),
|
|
633
640
|
onError,
|
|
634
641
|
});
|
|
@@ -636,8 +643,10 @@ export function mountCockpit(host, opts = {}) {
|
|
|
636
643
|
client.open();
|
|
637
644
|
drill = { stream, client };
|
|
638
645
|
setMode("live", stream);
|
|
639
|
-
// Arm the
|
|
640
|
-
|
|
646
|
+
// Arm the note as "connecting" (after setMode, which clears it) BEFORE the socket opens. It only
|
|
647
|
+
// becomes "waiting for live output" when the subscribe is ACKed (onRelay above) and clears on the
|
|
648
|
+
// first byte, so a dead socket reads as "connecting", never a falsely-"connected" quiet stream (#600).
|
|
649
|
+
setNote("Connecting…", "connecting");
|
|
641
650
|
} catch (err) {
|
|
642
651
|
// The new terminal failed to build after the prior one was torn down: reset the region to idle
|
|
643
652
|
// (and drop any partially-built terminal) so the UI never shows a stale "live"/"replay"
|
|
@@ -811,7 +820,7 @@ export function mountCockpit(host, opts = {}) {
|
|
|
811
820
|
}
|
|
812
821
|
|
|
813
822
|
/**
|
|
814
|
-
* Derive the channel WebSocket URL
|
|
823
|
+
* Derive the channel WebSocket URL module-relatively (path `/agentic`, anchored to import.meta.url).
|
|
815
824
|
*
|
|
816
825
|
* The agentic hub authenticates upgrades with an identity token only
|
|
817
826
|
* (`sharedSecretAuthenticator({ requireCredential: false })`) — no capability credential is required.
|
|
@@ -824,10 +833,21 @@ export function mountCockpit(host, opts = {}) {
|
|
|
824
833
|
* token (or an explicit `relayUrl`) for secured deployments.
|
|
825
834
|
*/
|
|
826
835
|
function defaultRelayUrl(token, capability) {
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
836
|
+
// Anchor the relay endpoint to THIS MODULE's url (import.meta.url), NOT location.host — exactly as
|
|
837
|
+
// the HTTP endpoints (reportUrl/transcriptsUrl) are (#279/#467). mount.js is ALWAYS served at
|
|
838
|
+
// `<appMount>/cockpit/mount.js`, so `../agentic` resolves to `<appMount>/agentic` on every surface,
|
|
839
|
+
// then swap the scheme http→ws / https→wss:
|
|
840
|
+
// • standalone / local App-View: `ws://<app-origin>/agentic` — byte-identical to the old behaviour.
|
|
841
|
+
// • Studio console App-View: `ws://<console>/console/app-view/<app>/agentic` — the app-view
|
|
842
|
+
// -prefixed path the console proxies, NOT the console origin root.
|
|
843
|
+
// Deriving from `location.host` instead dialed the CONSOLE origin behind the App-View — which has no
|
|
844
|
+
// `/agentic` route (404) and whose app-view proxy refuses WS upgrades (501, ADR 0057 §3) — leaving
|
|
845
|
+
// the live terminal permanently dead behind the console (#600). This is the WS hop of the same
|
|
846
|
+
// failure class the HTTP endpoints already fixed by anchoring to import.meta.url.
|
|
847
|
+
const url = new URL("../agentic", import.meta.url);
|
|
848
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
849
|
+
if (!token) return url.href;
|
|
850
|
+
url.searchParams.set("token", token);
|
|
851
|
+
if (capability) url.searchParams.set("capability", capability);
|
|
852
|
+
return url.href;
|
|
833
853
|
}
|
|
@@ -94,3 +94,87 @@ test("#279: embed.html forwards BOTH reportUrl and transcriptsUrl from the injec
|
|
|
94
94
|
"embed.html must forward reportUrl: cfg.reportUrl",
|
|
95
95
|
);
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
// #600: the relay WebSocket default is the WS hop of the SAME #279/#467 failure class. It used to be
|
|
99
|
+
// derived from `location.host` — behind the Studio console that is the console origin (:8080), which
|
|
100
|
+
// has no `/agentic` route (404) and whose app-view proxy refuses WS upgrades (501, ADR 0057 §3), so
|
|
101
|
+
// the live terminal was permanently dead behind the console. The fix anchors it to import.meta.url
|
|
102
|
+
// exactly like the HTTP endpoints, `../agentic` off `<appMount>/cockpit/mount.js`, then swaps the
|
|
103
|
+
// scheme http→ws / https→wss. Unlike reportUrl/transcriptsUrl (inlined `.href`) it is BUILT from
|
|
104
|
+
// `new URL("<spec>", import.meta.url)` so the scheme can be swapped, so it is matched separately.
|
|
105
|
+
|
|
106
|
+
// Pull the module-relative relay spec out of `new URL("<spec>", import.meta.url);` in defaultRelayUrl()
|
|
107
|
+
// (the HTTP defaults are `new URL(..., import.meta.url).href`, so `)\s*;` matches only the relay one).
|
|
108
|
+
function relaySpec(): string {
|
|
109
|
+
const m = MOUNT_JS.match(/new URL\(\s*"([^"]*)"\s*,\s*import\.meta\.url\s*\)\s*;/);
|
|
110
|
+
assert(
|
|
111
|
+
m,
|
|
112
|
+
"mount.js defaultRelayUrl() must derive the relay default from new URL(\"<spec>\", import.meta.url), " +
|
|
113
|
+
"anchored to the module's own served location, not location.host (#600)",
|
|
114
|
+
);
|
|
115
|
+
return m![1];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Resolve the relay spec against a REAL served mount url and swap the scheme, mirroring defaultRelayUrl().
|
|
119
|
+
function resolveRelay(mount: string): string {
|
|
120
|
+
const url = new URL(relaySpec(), mount);
|
|
121
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
122
|
+
return url.href;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test("#600: defaultRelayUrl derives the relay target from import.meta.url, not location.host", () => {
|
|
126
|
+
assert(
|
|
127
|
+
!/\$\{location\.host\}/.test(MOUNT_JS),
|
|
128
|
+
"the relay default must not be interpolated from location.host: behind the console that is the " +
|
|
129
|
+
"console origin (:8080), which has no /agentic route (404) and refuses WS upgrades (501) (#600)",
|
|
130
|
+
);
|
|
131
|
+
const spec = relaySpec();
|
|
132
|
+
assert(
|
|
133
|
+
!spec.startsWith("/"),
|
|
134
|
+
`relay spec "${spec}" must not be absolute: a leading-slash path resolves against the iframe ORIGIN ` +
|
|
135
|
+
`(console :8080), not the app-view base the console proxies (#279 class)`,
|
|
136
|
+
);
|
|
137
|
+
assert(
|
|
138
|
+
spec.startsWith("../"),
|
|
139
|
+
`relay spec "${spec}" must step up out of /cockpit/ (mount.js is at <appMount>/cockpit/mount.js; the ` +
|
|
140
|
+
`hub is a sibling at <appMount>/agentic) (#467 class)`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("#600: relay default resolves to ws://<app-origin>/agentic standalone (behaviour unchanged)", () => {
|
|
145
|
+
assertEquals(
|
|
146
|
+
resolveRelay(STANDALONE_MOUNT),
|
|
147
|
+
"ws://127.0.0.1:3000/agentic",
|
|
148
|
+
"standalone the relay default must dial the app origin's /agentic exactly as it does today",
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("#600: relay default resolves onto the app-view base inside the Studio console iframe", () => {
|
|
153
|
+
assertEquals(
|
|
154
|
+
resolveRelay(STUDIO_MOUNT),
|
|
155
|
+
"ws://studio-host:8080/console/app-view/Workforce/agentic",
|
|
156
|
+
"behind the console the relay must dial the app-view-prefixed /agentic the console proxies, not the " +
|
|
157
|
+
"console origin root (#279 class) nor the /cockpit/ shell base (#467 class)",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("#600: relay default swaps https→wss on a secure surface", () => {
|
|
162
|
+
assertEquals(
|
|
163
|
+
resolveRelay("https://studio-host:8443/console/app-view/Workforce/cockpit/mount.js"),
|
|
164
|
+
"wss://studio-host:8443/console/app-view/Workforce/agentic",
|
|
165
|
+
"an https surface must yield a wss relay url",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("#600: an explicit relayUrl opt and injected __NANO_APP_VIEW__.relayUrl outrank the derived default", () => {
|
|
170
|
+
// Precedence is pinned in source: opts.relayUrl wins over the module-relative default, and embed.html
|
|
171
|
+
// forwards the console-injected cfg.relayUrl into that opt so it outranks the default behind the console.
|
|
172
|
+
assert(
|
|
173
|
+
/opts\.relayUrl\s*\?\?\s*defaultRelayUrl\(/.test(MOUNT_JS),
|
|
174
|
+
"mount.js must honour an explicit opts.relayUrl over the derived default (opts.relayUrl ?? defaultRelayUrl(...)) (#600)",
|
|
175
|
+
);
|
|
176
|
+
assert(
|
|
177
|
+
/relayUrl:\s*cfg\.relayUrl/.test(EMBED_HTML),
|
|
178
|
+
"embed.html must forward relayUrl: cfg.relayUrl so a console-injected __NANO_APP_VIEW__.relayUrl reaches the relay (#600)",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
@@ -132,6 +132,17 @@ test("readConvergeInput: parses pr; convergeOnly defaults from the target; depen
|
|
|
132
132
|
assertEquals(conv.convergeOnly, true);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test("readConvergeInput: `merge-main` (graph-level two-level merge, S5) parses pr and defaults convergeOnly false", () => {
|
|
136
|
+
// The graph-level top-level enrollment target drives the merge loop like `converge-merge`,
|
|
137
|
+
// so its worker-level `readConvergeInput` default must also be `convergeOnly=false`.
|
|
138
|
+
const mm = readConvergeInput("merge-main", { pr: "owner/repo#7" }, null);
|
|
139
|
+
assertEquals(mm.parsed.prKey, "owner/repo#7");
|
|
140
|
+
assertEquals(mm.convergeOnly, false);
|
|
141
|
+
assertEquals(mm.dependsOn, []);
|
|
142
|
+
// An explicit payload override still wins over the target default.
|
|
143
|
+
assertEquals(readConvergeInput("merge-main", { pr: "owner/repo#7", convergeOnly: true }, null).convergeOnly, true);
|
|
144
|
+
});
|
|
145
|
+
|
|
135
146
|
test("readConvergeInput: an explicit payload.convergeOnly overrides the target default; dependsOn threads through", () => {
|
|
136
147
|
const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] }, null);
|
|
137
148
|
assertEquals(r.convergeOnly, true, "the explicit boolean wins over the target default");
|
|
@@ -77,9 +77,9 @@ export function safeStringify(value: unknown): string {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** Parse + validate the converge connector's payload (`{ pr, convergeOnly?, dependsOn? }`) for a
|
|
80
|
-
* `converge` / `converge-merge` target. `pr` is REQUIRED and must parse to a canonical `owner/repo#N`
|
|
80
|
+
* `converge` / `converge-merge` / `merge-main` target. `pr` is REQUIRED and must parse to a canonical `owner/repo#N`
|
|
81
81
|
* (fail CLOSED — a converge connector with no target PR is meaningless and could never enroll).
|
|
82
|
-
* `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge` → drive
|
|
82
|
+
* `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge`/`merge-main` → drive
|
|
83
83
|
* the merge loop `false`) and may be overridden per-dispatch by an explicit boolean. `dependsOn` is an
|
|
84
84
|
* optional list of PR refs unioned into the enrolled PR's merge-stage dependency set (only non-string
|
|
85
85
|
* entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage.
|