@nanobpm/nano-workforce 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/agentic/README.md +57 -0
- package/app/agentic/channel.test.ts +244 -0
- package/app/agentic/channel.ts +124 -0
- package/app/agentic/families/example.family.ts +35 -0
- package/app/agentic/loader.test.ts +79 -0
- package/app/agentic/loader.ts +84 -0
- package/app/agentic/registry.test.ts +160 -0
- package/app/agentic/registry.ts +130 -0
- package/docs/adr/0001-cross-repo-epics-and-artifact-wait-gates.md +151 -0
- package/main.ts +34 -0
- package/package.json +3 -2
- package/prompts/plan-review.md +13 -0
- package/prompts/plan.md +35 -0
- package/workers/merge/worker.test.ts +112 -0
- package/workers/merge/worker.ts +23 -18
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Unit tests for the agentic family-registration seam (ADR 0056, H0 / #143).
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals, assertRejects, assertThrows } from "#test-assert";
|
|
4
|
+
import { noopLog } from "../../test/log.ts";
|
|
5
|
+
import { type AgenticContext, AgenticFamilyRegistry, type AgenticFamily } from "./registry.ts";
|
|
6
|
+
|
|
7
|
+
// A minimal context — the seam only threads it through to `mount`, so the tests don't need a real
|
|
8
|
+
// hub. `undefined`/no-op handles are fine here; the channel test exercises the real handles.
|
|
9
|
+
function fakeCtx(): AgenticContext {
|
|
10
|
+
// biome-ignore lint/suspicious/noExplicitAny: seam only forwards ctx opaquely in these tests
|
|
11
|
+
const stub: any = {};
|
|
12
|
+
return { hub: stub, registry: stub, transport: stub, data: undefined, log: noopLog() };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A family that records the order of mount/teardown calls into a shared trace. */
|
|
16
|
+
function tracer(name: string, trace: string[]): AgenticFamily {
|
|
17
|
+
return {
|
|
18
|
+
name,
|
|
19
|
+
mount() {
|
|
20
|
+
trace.push(`mount:${name}`);
|
|
21
|
+
},
|
|
22
|
+
teardown() {
|
|
23
|
+
trace.push(`teardown:${name}`);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test("mounts families in registration order, tears them down in reverse", async () => {
|
|
29
|
+
const trace: string[] = [];
|
|
30
|
+
const reg = new AgenticFamilyRegistry();
|
|
31
|
+
reg.registerAll([tracer("a", trace), tracer("b", trace), tracer("c", trace)]);
|
|
32
|
+
assertEquals(reg.names(), ["a", "b", "c"]);
|
|
33
|
+
|
|
34
|
+
await reg.mountAll(fakeCtx());
|
|
35
|
+
assertEquals(trace, ["mount:a", "mount:b", "mount:c"]);
|
|
36
|
+
|
|
37
|
+
await reg.teardownAll(noopLog());
|
|
38
|
+
assertEquals(trace, ["mount:a", "mount:b", "mount:c", "teardown:c", "teardown:b", "teardown:a"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("mountAll is idempotent — a second call never re-mounts", async () => {
|
|
42
|
+
const trace: string[] = [];
|
|
43
|
+
const reg = new AgenticFamilyRegistry();
|
|
44
|
+
reg.register(tracer("a", trace));
|
|
45
|
+
const ctx = fakeCtx();
|
|
46
|
+
await reg.mountAll(ctx);
|
|
47
|
+
await reg.mountAll(ctx);
|
|
48
|
+
assertEquals(trace, ["mount:a"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("teardownAll only reverses families that actually mounted, and is idempotent", async () => {
|
|
52
|
+
const trace: string[] = [];
|
|
53
|
+
const reg = new AgenticFamilyRegistry();
|
|
54
|
+
reg.register(tracer("a", trace));
|
|
55
|
+
await reg.mountAll(fakeCtx());
|
|
56
|
+
await reg.teardownAll();
|
|
57
|
+
await reg.teardownAll();
|
|
58
|
+
assertEquals(trace, ["mount:a", "teardown:a"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a rejected duplicate family name protects one-family-one-slot", () => {
|
|
62
|
+
const reg = new AgenticFamilyRegistry();
|
|
63
|
+
reg.register({ name: "dup", mount() {} });
|
|
64
|
+
assertThrows(() => reg.register({ name: "dup", mount() {} }), Error, "duplicate agentic family");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("registering after mount is refused", async () => {
|
|
68
|
+
const reg = new AgenticFamilyRegistry();
|
|
69
|
+
reg.register({ name: "a", mount() {} });
|
|
70
|
+
await reg.mountAll(fakeCtx());
|
|
71
|
+
assertThrows(() => reg.register({ name: "b", mount() {} }), Error, "after mount");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a family with no teardown is skipped cleanly on shutdown", async () => {
|
|
75
|
+
const trace: string[] = [];
|
|
76
|
+
const reg = new AgenticFamilyRegistry();
|
|
77
|
+
reg.register({ name: "no-teardown", mount() {
|
|
78
|
+
trace.push("mount");
|
|
79
|
+
} });
|
|
80
|
+
await reg.mountAll(fakeCtx());
|
|
81
|
+
await reg.teardownAll();
|
|
82
|
+
assertEquals(trace, ["mount"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("one family's teardown throw is isolated and does not strand siblings", async () => {
|
|
86
|
+
const trace: string[] = [];
|
|
87
|
+
const reg = new AgenticFamilyRegistry();
|
|
88
|
+
reg.register(tracer("a", trace));
|
|
89
|
+
reg.register({
|
|
90
|
+
name: "boom",
|
|
91
|
+
mount() {
|
|
92
|
+
trace.push("mount:boom");
|
|
93
|
+
},
|
|
94
|
+
teardown() {
|
|
95
|
+
throw new Error("teardown boom");
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
await reg.mountAll(fakeCtx());
|
|
99
|
+
// Should not throw despite "boom" failing; "a" must still tear down.
|
|
100
|
+
await reg.teardownAll(noopLog());
|
|
101
|
+
assertEquals(trace, ["mount:a", "mount:boom", "teardown:a"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a mount failure only tears down what actually mounted", async () => {
|
|
105
|
+
const trace: string[] = [];
|
|
106
|
+
const reg = new AgenticFamilyRegistry();
|
|
107
|
+
reg.register(tracer("a", trace));
|
|
108
|
+
reg.register({
|
|
109
|
+
name: "fails",
|
|
110
|
+
mount() {
|
|
111
|
+
throw new Error("mount fails");
|
|
112
|
+
},
|
|
113
|
+
teardown() {
|
|
114
|
+
trace.push("teardown:fails");
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
await assertRejects(() => reg.mountAll(fakeCtx()), Error, "mount fails");
|
|
118
|
+
await reg.teardownAll();
|
|
119
|
+
// "fails" never completed mount, so its teardown must not run; "a" did mount, so it tears down.
|
|
120
|
+
assertEquals(trace, ["mount:a", "teardown:a"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("mountAll self-heals after a mid-mount failure — a retry re-mounts, never a stuck no-op", async () => {
|
|
124
|
+
const trace: string[] = [];
|
|
125
|
+
const reg = new AgenticFamilyRegistry();
|
|
126
|
+
reg.register(tracer("a", trace));
|
|
127
|
+
let shouldFail = true;
|
|
128
|
+
reg.register({
|
|
129
|
+
name: "flaky",
|
|
130
|
+
mount() {
|
|
131
|
+
if (shouldFail) {
|
|
132
|
+
shouldFail = false;
|
|
133
|
+
throw new Error("mount fails once");
|
|
134
|
+
}
|
|
135
|
+
trace.push("mount:flaky");
|
|
136
|
+
},
|
|
137
|
+
teardown() {
|
|
138
|
+
trace.push("teardown:flaky");
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// First attempt fails mid-mount; mountAll must reverse the partial mount and reset its own state
|
|
143
|
+
// (without the caller having to call teardownAll)...
|
|
144
|
+
await assertRejects(() => reg.mountAll(fakeCtx()), Error, "mount fails once");
|
|
145
|
+
assertEquals(trace, ["mount:a", "teardown:a"]);
|
|
146
|
+
|
|
147
|
+
// ...so a retry actually re-mounts instead of being a silent no-op (the wedged-state regression).
|
|
148
|
+
await reg.mountAll(fakeCtx());
|
|
149
|
+
assertEquals(trace, ["mount:a", "teardown:a", "mount:a", "mount:flaky"]);
|
|
150
|
+
|
|
151
|
+
await reg.teardownAll();
|
|
152
|
+
assertEquals(trace, [
|
|
153
|
+
"mount:a",
|
|
154
|
+
"teardown:a",
|
|
155
|
+
"mount:a",
|
|
156
|
+
"mount:flaky",
|
|
157
|
+
"teardown:flaky",
|
|
158
|
+
"teardown:a",
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// nano-workforce — the agentic-channel family-registration SEAM (ADR 0056, H0 / #143).
|
|
2
|
+
//
|
|
3
|
+
// This module is OWNED by H0 (the keystone slice). It is the single extension point every sibling
|
|
4
|
+
// slice of the agentic-visibility epic (#142) plugs into:
|
|
5
|
+
//
|
|
6
|
+
// - H1 presence (#144) → adds `app/agentic/families/presence.family.ts`
|
|
7
|
+
// - H3 relay (#146) → adds `app/agentic/families/relay.family.ts`
|
|
8
|
+
// - H4 blackboard(#147) → adds `app/agentic/families/blackboard.family.ts`
|
|
9
|
+
//
|
|
10
|
+
// A sibling adds ONE NEW FILE under `app/agentic/families/` exporting an {@link AgenticFamily} and
|
|
11
|
+
// NOTHING ELSE — it never edits `main.ts`, `drainAndExit`, or any shared boot line. The loader
|
|
12
|
+
// ({@link ./loader.ts}) discovers those files by convention (`*.family.ts`) and hands them to this
|
|
13
|
+
// registry, so there is no central registration list for siblings to collide on either — the
|
|
14
|
+
// shared-file collision the plan review flagged is designed out, not merely relocated.
|
|
15
|
+
//
|
|
16
|
+
// The registry mounts families on boot (in discovery order) and tears them down in REVERSE order on
|
|
17
|
+
// shutdown — the mirror-image lifecycle a stack of resources needs so a later family that depends on
|
|
18
|
+
// an earlier one is torn down first.
|
|
19
|
+
//
|
|
20
|
+
// RESERVED forward-only migration prefixes (H0 pre-allocates these so no two siblings independently
|
|
21
|
+
// grab "the next" number — current highest committed prefix is 022):
|
|
22
|
+
// - `db/migrations/023_agentic_presence.sql` → H1 (#144)
|
|
23
|
+
// - `db/migrations/024_agentic_transcript.sql` → H3 (#146)
|
|
24
|
+
// - `db/migrations/025_agentic_blackboard.sql` → H4 (#147), only if it needs a schema change
|
|
25
|
+
//
|
|
26
|
+
// Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
|
|
27
|
+
// is untouched — the agentic channel is the only new conversation; advisory semantics are preserved
|
|
28
|
+
// (a family NEVER hard-locks or gates a BPMN sequence flow).
|
|
29
|
+
import type { AgenticHub, ConnectionRegistry, WebSocketChannelTransport } from "@nanobpm/agentic/channel";
|
|
30
|
+
import type { DataLayer, Logger } from "@nanobpm/urban";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The reusable handle the seam threads to every family module at mount time. A sibling family uses
|
|
34
|
+
* these — and only these — so it never re-mounts the transport, re-authenticates, or reaches into
|
|
35
|
+
* the boot script.
|
|
36
|
+
*/
|
|
37
|
+
export interface AgenticContext {
|
|
38
|
+
/** The app-tier hub: attach a family message handler via `hub.registerFamilyHandler(...)`. */
|
|
39
|
+
readonly hub: AgenticHub;
|
|
40
|
+
/** The shared connection registry with liveness (presence detail is attached here by H1). */
|
|
41
|
+
readonly registry: ConnectionRegistry;
|
|
42
|
+
/** The listening WebSocket transport bound to the app's OWN port. */
|
|
43
|
+
readonly transport: WebSocketChannelTransport;
|
|
44
|
+
/** The app's SQLite data layer — the same store the advisory blackboard uses (may be absent). */
|
|
45
|
+
readonly data: DataLayer | undefined;
|
|
46
|
+
/** A structured logger for boot/shutdown lifecycle lines. */
|
|
47
|
+
readonly log: Logger;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One pluggable family module. A sibling slice implements this and exports it (default export, or a
|
|
52
|
+
* named `family` export) from a `*.family.ts` file under `app/agentic/families/`.
|
|
53
|
+
*/
|
|
54
|
+
export interface AgenticFamily {
|
|
55
|
+
/** A stable, unique name (used for ordering diagnostics, `inspect()`, and teardown logging). */
|
|
56
|
+
readonly name: string;
|
|
57
|
+
/** Attach the family's behaviour to the hub/channel. May be async. */
|
|
58
|
+
mount(ctx: AgenticContext): void | Promise<void>;
|
|
59
|
+
/** Release anything `mount` acquired. Called in REVERSE registration order on shutdown. */
|
|
60
|
+
teardown?(): void | Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The seam itself: collects registered families, mounts them all on boot (in registration order),
|
|
65
|
+
* and tears them down in reverse on shutdown. Mounting is idempotent-guarded (each family mounts at
|
|
66
|
+
* most once) so a double `mountAll` can never double-attach a handler.
|
|
67
|
+
*/
|
|
68
|
+
export class AgenticFamilyRegistry {
|
|
69
|
+
readonly #families: AgenticFamily[] = [];
|
|
70
|
+
readonly #mounted: AgenticFamily[] = [];
|
|
71
|
+
#isMounted = false;
|
|
72
|
+
|
|
73
|
+
/** Register a family. Rejects a duplicate name so two slices cannot silently claim one slot. */
|
|
74
|
+
register(family: AgenticFamily): void {
|
|
75
|
+
if (this.#isMounted) {
|
|
76
|
+
throw new Error(`cannot register agentic family "${family.name}" after mount`);
|
|
77
|
+
}
|
|
78
|
+
if (this.#families.some((f) => f.name === family.name)) {
|
|
79
|
+
throw new Error(`duplicate agentic family name "${family.name}"`);
|
|
80
|
+
}
|
|
81
|
+
this.#families.push(family);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Register several families at once (the loader hands the discovered set here). */
|
|
85
|
+
registerAll(families: Iterable<AgenticFamily>): void {
|
|
86
|
+
for (const family of families) this.register(family);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The registered family names, in registration order. Surfaced in `inspect()`/logs. */
|
|
90
|
+
names(): string[] {
|
|
91
|
+
return this.#families.map((f) => f.name);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Mount every registered family, in registration order. A no-op if already mounted. */
|
|
95
|
+
async mountAll(ctx: AgenticContext): Promise<void> {
|
|
96
|
+
if (this.#isMounted) return;
|
|
97
|
+
this.#isMounted = true;
|
|
98
|
+
try {
|
|
99
|
+
for (const family of this.#families) {
|
|
100
|
+
await family.mount(ctx);
|
|
101
|
+
// Track post-mount so a failure mid-mount only tears down what actually mounted.
|
|
102
|
+
this.#mounted.push(family);
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// A mid-mount failure must not wedge the registry at #isMounted=true (which would make every
|
|
106
|
+
// later mountAll a silent no-op). Reuse the canonical teardown to reverse the partial mount and
|
|
107
|
+
// reset the flag, leaving the registry clean and re-mountable, then rethrow to the caller.
|
|
108
|
+
await this.teardownAll(ctx.log);
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Tear every mounted family down in REVERSE mount order. Each teardown is isolated: a throw is
|
|
115
|
+
* logged (when a logger is supplied) and swallowed so one family's failure cannot strand another's
|
|
116
|
+
* cleanup. Safe to call more than once; the second call is a no-op.
|
|
117
|
+
*/
|
|
118
|
+
async teardownAll(log?: Logger): Promise<void> {
|
|
119
|
+
while (this.#mounted.length > 0) {
|
|
120
|
+
const family = this.#mounted.pop();
|
|
121
|
+
if (!family?.teardown) continue;
|
|
122
|
+
try {
|
|
123
|
+
await family.teardown();
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log?.error("agentic family teardown failed", { family: family.name, err: String(err) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
this.#isMounted = false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# ADR 0001 — Cross-repo epics, release-ordered integration, and generic artifact wait-gates
|
|
2
|
+
|
|
3
|
+
Status: **Proposed.**
|
|
4
|
+
Date: 2026-08-13.
|
|
5
|
+
|
|
6
|
+
> **Scope note.** This is a **nano-workforce-local** ADR — it governs how *this app's* agent
|
|
7
|
+
> workforce decomposes and integrates epics. The platform-wide ADRs live in
|
|
8
|
+
> `Magikcraft/nano-bpm/docs/adr` (referenced here by their number + repo, e.g. "nano-bpm ADR 0056").
|
|
9
|
+
> nano-workforce's own decisions start their own series here at 0001.
|
|
10
|
+
|
|
11
|
+
Relates to:
|
|
12
|
+
nano-bpm **ADR 0051** (nano-workforce — the crew orchestrator this app implements),
|
|
13
|
+
nano-bpm **ADR 0056** (the Nano agentic protocol — the first consumer/producer *pair* that forced this
|
|
14
|
+
question: the hub epic https://github.com/nanobpm/nano-workforce/issues/142 and the worker epic
|
|
15
|
+
https://github.com/jwulf/c8ctl-plugin-nano/issues/38 live in **different repos** yet share one
|
|
16
|
+
published contract),
|
|
17
|
+
the **review-ready poller** in `main.ts` (a bespoke "wait for an external condition, then correlate a
|
|
18
|
+
message" loop — the seed this ADR generalizes into a first-class wait-gate),
|
|
19
|
+
and nano-bpm **ADR 0059** (the app-hosted OpenAPI hook surface these gates would be signalled through).
|
|
20
|
+
|
|
21
|
+
## Context
|
|
22
|
+
|
|
23
|
+
nano-workforce executes an epic as a **single-repo** unit, and every load-bearing piece assumes it:
|
|
24
|
+
|
|
25
|
+
- **The repo is epic-level, not task-level.** `Plan.repo` carries the repository; `PlanTask`
|
|
26
|
+
(`app/plan.ts`) has **no** `repo` field. Fan-out clones *the epic's* repo for every slice.
|
|
27
|
+
- **Integration converges on one base branch in one repo.** The epic lands on a base branch; slices
|
|
28
|
+
PR into it; the merge-loop trial-merges the set and merges the epic (nano-bpm ADR 0051 machinery:
|
|
29
|
+
wave gates, base-branch guards, merge-loop reconciliation).
|
|
30
|
+
- **There is no "publish" step and no "wait for the outside world" primitive.** The only wait nwf does
|
|
31
|
+
is the hand-rolled review-ready poller in `main.ts`, which polls GitHub and correlates a
|
|
32
|
+
`review-ready` message. It is not reusable and knows only about PR reviews.
|
|
33
|
+
|
|
34
|
+
But real delivery in this ecosystem is **cross-repo by construction**. Shared libraries
|
|
35
|
+
(`@nanobpm/urban`, `@nanobpm/agentic`) are published from one repo and consumed downstream (this app,
|
|
36
|
+
c8ctl). The `urban → nano-workforce → c8ctl` chain is exactly a producer→consumer graph across repos
|
|
37
|
+
with an npm publish in the middle. Building the agentic visibility plane surfaced three concrete gaps:
|
|
38
|
+
(a) no per-task repo, (b) no release/publish step, (c) no wait-for-external-artifact primitive.
|
|
39
|
+
|
|
40
|
+
The temptation is to answer all three at once by making nwf a cross-repo, release-orchestrating
|
|
41
|
+
engine. That is a real redesign of the integration model and its highest-risk parts. Before paying for
|
|
42
|
+
it, note that **most cross-repo coupling in practice is a *versioned contract*, not a merge order** —
|
|
43
|
+
and a contract can be consumed *after* it is published, with no live cross-repo sequencing at all.
|
|
44
|
+
|
|
45
|
+
## Decision
|
|
46
|
+
|
|
47
|
+
### 1. Prefer contract-coupled per-repo epics over cross-repo epics (the default)
|
|
48
|
+
|
|
49
|
+
When two sides of a feature share a **versioned contract** — a published package plus a **conformance
|
|
50
|
+
corpus** both sides are held to — split the work into **one single-repo epic per repo**, each building
|
|
51
|
+
against the **already-published** contract. They coordinate through the contract, never through a merge
|
|
52
|
+
order. Neither epic waits on the other's code.
|
|
53
|
+
|
|
54
|
+
The agentic visibility plane is exactly this and ships this way, as **two** epics:
|
|
55
|
+
|
|
56
|
+
- **producer:** https://github.com/jwulf/c8ctl-plugin-nano/issues/38 (`nano work` → REGISTER/SERVE/relay);
|
|
57
|
+
- **hub + cockpit:** https://github.com/nanobpm/nano-workforce/issues/142.
|
|
58
|
+
|
|
59
|
+
Both consume the published `@nanobpm/agentic` and are held to `@nanobpm/agentic/protocol/conformance`.
|
|
60
|
+
This keeps nwf's proven single-repo integration model **entirely intact** and is the default posture
|
|
61
|
+
for any producer/consumer pair that can be expressed against a stable contract.
|
|
62
|
+
|
|
63
|
+
### 2. A generic artifact-readiness wait-gate (not npm-specific)
|
|
64
|
+
|
|
65
|
+
Generalize the review-ready poller into a **first-class, durable wait-gate**: a service task that
|
|
66
|
+
**polls a declared readiness probe with backoff until it is satisfied or a timeout escalates**, then
|
|
67
|
+
lets the flow proceed (or correlates a message). It is modeled on the engine (timer + receive), so a
|
|
68
|
+
worker or hub restart **resumes** the wait rather than losing it.
|
|
69
|
+
|
|
70
|
+
The probe is **declared as data, not code** — a `ReadinessProbe` descriptor with a `kind` and pluggable
|
|
71
|
+
matchers, so authors add readiness sources without editing the BPMN or the worker:
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
// ReadinessProbe — the gate is agnostic to what "ready" means.
|
|
75
|
+
{
|
|
76
|
+
"kind": "http", // http | command | npm | oci | git-ref | github-release | github-check | file
|
|
77
|
+
"target": "https://example/health", // URL | shell command | "pkg@version" | "image:tag" | "owner/repo@ref" | path
|
|
78
|
+
"match": { "status": 200 }, // per-kind predicate (status/body, exit code/stdout, version present, digest, …)
|
|
79
|
+
"poll": { "everyMs": 15000, "timeoutMs": 1800000, "backoff": "exponential" },
|
|
80
|
+
"onTimeout": "escalate" // escalate (default) | fail | continue
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Invariants:
|
|
85
|
+
|
|
86
|
+
- **Never npm-specific.** `npm` is *one* kind among many; `command` is the escape hatch that subsumes
|
|
87
|
+
almost anything (`gh`, `curl`, `docker manifest inspect`, a custom probe) for cases no built-in kind
|
|
88
|
+
covers. Adding a kind is a new matcher, not a schema change.
|
|
89
|
+
- **Bounded.** A probe that never goes green must **time out and escalate** (mirroring the per-task
|
|
90
|
+
escalation path) — a hanging probe can never wedge a plan.
|
|
91
|
+
- **Idempotent / resumable.** The gate only *reads* readiness; it holds no state a re-run could corrupt,
|
|
92
|
+
so a restarted worker simply re-probes.
|
|
93
|
+
|
|
94
|
+
This is immediately useful well beyond releases: waiting on CI, a downstream deploy, an external
|
|
95
|
+
system, a human approval, or a produced artifact.
|
|
96
|
+
|
|
97
|
+
### 3. The shared-library bump stays a manual maintainer seam — for now
|
|
98
|
+
|
|
99
|
+
A downstream version bump (e.g. `@nanobpm/urban` → this app) after an upstream release is handled by a
|
|
100
|
+
maintainer **outside** the epic, until §4 lands. It is cheap, low-risk, and rare relative to the
|
|
101
|
+
intra-repo work of an epic. This is the deliberate, documented seam that lets §1 stay simple: the
|
|
102
|
+
*only* cross-repo dependency in the agentic plane (nano-ide `UrbanApp.httpServer`,
|
|
103
|
+
https://github.com/nanobpm/nano-ide/issues/221 → an `@nanobpm/urban` release → a bump here) is a
|
|
104
|
+
one-line human step, not a reason to build a cross-repo engine.
|
|
105
|
+
|
|
106
|
+
### 4. Release-ordered cross-repo integration (the "release DAG") — deferred, sketched
|
|
107
|
+
|
|
108
|
+
Some future work genuinely cannot decouple: a consumer needs a producer's **new** release *mid-epic*.
|
|
109
|
+
For those cases only, model integration as a **DAG across independent per-repo merge trains**:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
producer PRs → merge → publish → [artifact wait-gate §2] → consumer PRs open/build → merge
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
This replaces the single-epic-branch assumption **for those cases**, and requires, in order:
|
|
116
|
+
|
|
117
|
+
1. **Per-task repo.** Add `repo` to `PlanTask`, derive it from each sub-issue (`parseIssue` already
|
|
118
|
+
yields `owner/repo`), and thread it into the `io.nanobpm.agentTask.repository` clone header. The
|
|
119
|
+
merge/review/finalize workers are *already* repo-parameterized (they take `repo` per PR and load the
|
|
120
|
+
merge protocol per repo), so this is mostly plan/task plumbing.
|
|
121
|
+
2. **A release task type.** bump version → merge → **§2 wait-gate on artifact availability** → signal
|
|
122
|
+
downstream. Publish is at-least-once; the task must tolerate a re-run (mirror the idempotent
|
|
123
|
+
`scripts/publish.mjs` "skip already-published" discipline).
|
|
124
|
+
3. **Cross-train ordering.** A meta-plan (or a first-class multi-repo epic) that sequences the per-repo
|
|
125
|
+
trains and their gates.
|
|
126
|
+
|
|
127
|
+
This is its **own follow-up epic with its own design**. Do **not** build it speculatively — §1 removes
|
|
128
|
+
the need for the foreseeable roadmap, and §2 is the reusable building block it will stand on.
|
|
129
|
+
|
|
130
|
+
## Consequences
|
|
131
|
+
|
|
132
|
+
- The agentic visibility plane ships **now** as two single-repo epics; nwf's integration model
|
|
133
|
+
(base-branch, trial-merge, merge-loop, wave gates) is untouched and unrisked.
|
|
134
|
+
- nwf gains a durable **"wait for the world"** primitive it currently fakes with the bespoke review
|
|
135
|
+
poller; the poller can later be re-expressed as one `github-check`/`http` gate.
|
|
136
|
+
- New surface to own: the `ReadinessProbe` kinds. A malformed or hanging probe is bounded by the
|
|
137
|
+
mandatory timeout+escalation, so it cannot stall a plan.
|
|
138
|
+
- Shared-library bumps stay manual until §4 — an accepted cost given their frequency.
|
|
139
|
+
- When §4 is eventually built, §1 + §2 mean it is *additive* (a new integration topology + a release
|
|
140
|
+
task) rather than a rewrite.
|
|
141
|
+
|
|
142
|
+
## Open questions
|
|
143
|
+
|
|
144
|
+
- **Probe extensibility model:** a curated registry of `kind`s vs leaning on the `command` escape hatch
|
|
145
|
+
for the long tail — and how a probe's credentials/secrets are supplied without leaking into logs.
|
|
146
|
+
- **Where cross-train ordering lives (§4):** a meta-plan across existing epics, or a genuine
|
|
147
|
+
first-class multi-repo epic with per-task repos.
|
|
148
|
+
- **Per-repo divergence when per-task repo lands:** merge protocol, required checks, Copilot-review
|
|
149
|
+
provisioning (not available on every repo), and push auth all differ per repo.
|
|
150
|
+
- **Gate signalling:** in-flow receive task vs an out-of-band message correlated by an app-side poller
|
|
151
|
+
(the review-ready shape) — likely both, chosen per use.
|
package/main.ts
CHANGED
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
// The reviewer agent (job type `senior:pr-review`) is deliberately NOT hosted here — it is an
|
|
17
17
|
// EXTERNAL worker. Point a coding-agent harness at that job type (the same one that services
|
|
18
18
|
// the code-first twin) so the automated review stays decoupled from the orchestration.
|
|
19
|
+
import { Server } from "node:http";
|
|
19
20
|
import { createNanoSdkEngineClient, runFromEnv, selectHost } from "@nanobpm/urban";
|
|
21
|
+
import { type AgenticChannelHandle, mountAgenticChannel } from "./app/agentic/channel.ts";
|
|
20
22
|
import { MAX_ROUNDS, pollOnce } from "./app/service.ts";
|
|
23
|
+
import { envVar } from "./app/version.ts";
|
|
21
24
|
|
|
22
25
|
const PORT = Number(process.env.PR_REVIEW_PORT ?? 3000);
|
|
23
26
|
const POLL_MS = Number(process.env.NANO_PR_POLL_MS ?? 60_000);
|
|
@@ -40,6 +43,30 @@ const engine = await createNanoSdkEngineClient({
|
|
|
40
43
|
// signal handler would only stop the HTTP server, leaving the poller keeping us alive).
|
|
41
44
|
const app = await runFromEnv({ engine, host, port: PORT, handleSignals: false });
|
|
42
45
|
|
|
46
|
+
// Agentic visibility channel (ADR 0056, epic #142). Ride the app's OWN HTTP server so the channel
|
|
47
|
+
// shares the app port (no sidecar). This is the ONLY main.ts wiring for the whole epic — sibling
|
|
48
|
+
// slices (H1/H3/H4) extend it by dropping a family module under `app/agentic/families/`, never here.
|
|
49
|
+
// Mount only when a shared identity secret is configured, so the app never exposes an
|
|
50
|
+
// unauthenticated upgrade; `app.httpServer` is a `node:http` Server once started (undefined on hosts
|
|
51
|
+
// that don't surface one, e.g. Deno).
|
|
52
|
+
let agentic: AgenticChannelHandle | undefined;
|
|
53
|
+
const agenticSecret = envVar("NANO_AGENTIC_SECRET") ?? envVar("NANO_PR_WEBHOOK_SECRET");
|
|
54
|
+
const httpServer = app.httpServer;
|
|
55
|
+
if (httpServer instanceof Server) {
|
|
56
|
+
if (agenticSecret) {
|
|
57
|
+
agentic = await mountAgenticChannel({
|
|
58
|
+
server: httpServer,
|
|
59
|
+
secret: agenticSecret,
|
|
60
|
+
data: app.data,
|
|
61
|
+
log: app.log,
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
app.log.warn("agentic channel not mounted: set NANO_AGENTIC_SECRET (or NANO_PR_WEBHOOK_SECRET)");
|
|
65
|
+
}
|
|
66
|
+
} else if (agenticSecret) {
|
|
67
|
+
app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
|
|
68
|
+
}
|
|
69
|
+
|
|
43
70
|
// Review-ready poller. Self-scheduling (not setInterval) so a slow GitHub call can never
|
|
44
71
|
// overlap two passes (which could double-signal `review-ready`); the next pass is scheduled
|
|
45
72
|
// only after the previous one settles.
|
|
@@ -59,6 +86,13 @@ async function drainAndExit(): Promise<void> {
|
|
|
59
86
|
if (shuttingDown) return;
|
|
60
87
|
shuttingDown = true;
|
|
61
88
|
if (pollTimer) clearTimeout(pollTimer);
|
|
89
|
+
// Tear the agentic families + hub down (releases the WS clients) before the app stops its HTTP
|
|
90
|
+
// server, which the channel shares.
|
|
91
|
+
if (agentic) {
|
|
92
|
+
try {
|
|
93
|
+
await agentic.teardown();
|
|
94
|
+
} catch { /* best-effort channel shutdown */ }
|
|
95
|
+
}
|
|
62
96
|
try {
|
|
63
97
|
await app.stop();
|
|
64
98
|
} catch { /* already stopped */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@nanobpm/
|
|
49
|
+
"@nanobpm/agentic": "^0.1.0",
|
|
50
|
+
"@nanobpm/urban": "^0.46.0"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
53
|
"@biomejs/biome": "^2.4.11",
|
package/prompts/plan-review.md
CHANGED
|
@@ -40,6 +40,19 @@ decomposition **from the issues**, then test the plan against it.
|
|
|
40
40
|
`dependsOn` it. Reject a `dependsOn` edge added purely to **serialise the landing** of otherwise
|
|
41
41
|
parallel work — that is not a fix, it just needlessly serialises implementation; name the pair,
|
|
42
42
|
the shared surface, and which of (a)/(b) the planner should apply.
|
|
43
|
+
- **Package fragmentation (Conway artifact).** The plan gives a cohesive body of work its own
|
|
44
|
+
published unit **per task** — N tasks ⇒ N npm packages / crates / services — where one library
|
|
45
|
+
with the slices as **subpath exports / subdirectories** would serve the same consumers. This is
|
|
46
|
+
the task decomposition leaking into the artifact's module boundaries: separate packages are the
|
|
47
|
+
frictionless maximum of independence, so they get chosen by default, then have to be
|
|
48
|
+
unfragmented by hand (and each extra published unit is a publish/credentials bootstrap +
|
|
49
|
+
changelog + version cadence forever). Try to disprove that each **new** published-package
|
|
50
|
+
boundary is **consumer-driven**: is there a distinct external consumer of *it* alone, an
|
|
51
|
+
intentional independent release cadence, or a different runtime tier? If not for a given
|
|
52
|
+
package, flag it and demand the remedy: **coarsen the siblings into one package** exposing
|
|
53
|
+
subpaths, landing a **wave-0 scaffold task** (manifest with the full exports map
|
|
54
|
+
pre-declared + one empty subdirectory per slice) first if the shared manifest would otherwise be
|
|
55
|
+
a merge collision. Name the packages that lack a consumer-facing justification.
|
|
43
56
|
- **Non-self-contained prompt.** A task's `prompt` can't be executed without reasoning the planner
|
|
44
57
|
kept to itself.
|
|
45
58
|
- **Sequencing intent violated.** If the issues state an ordering (e.g. "audit the foundation
|
package/prompts/plan.md
CHANGED
|
@@ -157,6 +157,41 @@ Choose (1) when the surface *is* the task; choose (2) when the surface is shared
|
|
|
157
157
|
infrastructure several distinct tasks sit on top of. Reserve plain parallel tasks
|
|
158
158
|
(no shared surface) for genuinely disjoint work.
|
|
159
159
|
|
|
160
|
+
### Packaging cohesion → one library, subpaths, not a package per task
|
|
161
|
+
|
|
162
|
+
The shared-surface rule above pushes toward independence, and independence has a
|
|
163
|
+
seductive failure mode: giving each task its **own published unit** (npm package,
|
|
164
|
+
crate, service) is the *frictionless maximum* of independence — a separate
|
|
165
|
+
manifest, separate exports, separate directory mean zero shared surface and zero
|
|
166
|
+
merge collision. So a plan that slices a single cohesive library into N tasks will,
|
|
167
|
+
left alone, tend to emit **N packages** — one per task. That is not a design; it is
|
|
168
|
+
your task decomposition leaking into the artifact's module boundaries (Conway's
|
|
169
|
+
Law). It has to be unfragmented by hand later, and each extra published unit is a
|
|
170
|
+
one-time publish/credentials bootstrap plus a changelog and version cadence forever.
|
|
171
|
+
|
|
172
|
+
So, before you slice: **a new published unit requires a consumer-facing
|
|
173
|
+
justification, not merely "this is an independent task."** A new package/crate/
|
|
174
|
+
service is warranted only when at least one is true:
|
|
175
|
+
|
|
176
|
+
- a **distinct external consumer** imports it on its own (something outside the
|
|
177
|
+
family depends on *it*, not on its siblings);
|
|
178
|
+
- it needs an **independent release cadence** (versioned and shipped separately on
|
|
179
|
+
purpose); or
|
|
180
|
+
- it is a **different runtime tier** (e.g. a browser bundle vs. a server library vs.
|
|
181
|
+
a worker client) that consumers install separately.
|
|
182
|
+
|
|
183
|
+
Absent one of those, the default is **one library, with the slices as subpath
|
|
184
|
+
exports / subdirectories inside it** (the shape of a package that exposes several
|
|
185
|
+
surfaces — e.g. `./runtime`, `./toolkit`, `./worker` — from a single manifest). The
|
|
186
|
+
slices stay independent to *write*: use the
|
|
187
|
+
**wave-0 scaffold task** (option 2 above) to land the library skeleton first — its
|
|
188
|
+
manifest with the **full exports map pre-declared** and an empty subdirectory per
|
|
189
|
+
slice — so every sibling only **adds files inside its own subdirectory** and never
|
|
190
|
+
touches the shared manifest or barrel. That buys parallel-merge independence **and**
|
|
191
|
+
a cohesive published artifact at the same time. Reserve genuinely separate packages
|
|
192
|
+
for the consumer-facing cases above, and say in the task prompt which consumer
|
|
193
|
+
justifies the split.
|
|
194
|
+
|
|
160
195
|
## Output contract
|
|
161
196
|
|
|
162
197
|
Write a JSON object of **result variables** to the file named by the
|