@nanobpm/nano-workforce 0.180.0 → 0.182.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/agentGuide.test.ts +87 -0
- package/app/agentGuide.ts +61 -0
- package/app/deliveryGraph.ts +30 -1
- package/app/deliveryGraphCompiler.ts +39 -20
- package/app/deliveryGraphProposals.test.ts +60 -0
- package/app/deliveryGraphProposals.ts +61 -3
- package/app/deliveryGraphStage.ts +11 -1
- package/app/deliveryRunner.test.ts +120 -58
- package/app/deliveryRunner.ts +154 -55
- package/app/repoEnvelope.ts +73 -1
- package/app/sequenceIssues.test.ts +143 -1
- package/app/sequenceIssues.ts +246 -31
- package/e2e/addressable-guide.e2e.ts +43 -0
- package/openapi.yaml +310 -29
- package/operations/compileDeliveryGraph.test.ts +4 -0
- package/operations/dispatchDeliveryGraph.test.ts +50 -4
- package/operations/dispatchDeliveryGraph.ts +44 -27
- package/operations/getAgentGuide.test.ts +13 -0
- package/operations/getAgentGuide.ts +40 -5
- package/package.json +1 -1
|
@@ -35,7 +35,9 @@ function handAuthored(behind: string | null, issues: string[]): AnyGraph {
|
|
|
35
35
|
nodes.push({
|
|
36
36
|
id: `open-${n}`,
|
|
37
37
|
kind: "agent",
|
|
38
|
-
|
|
38
|
+
// #739: the generator stamps each agent cell with the issue's OWN repository, so a multi-repo
|
|
39
|
+
// sequence provisions each cell's isolation envelope from that issue's repo.
|
|
40
|
+
agent: { jobType: "senior:feature", prompt: `Implement ${issue} and open a PR.`, repository: issue.split("#")[0] },
|
|
39
41
|
emits: [{ name: "pr", type: "pr" }],
|
|
40
42
|
});
|
|
41
43
|
nodes.push({ id: `land-${n}`, kind: "connector", connector: { target: "converge-merge", payload: { pr: `open-${n}.pr` } } });
|
|
@@ -106,6 +108,15 @@ test("sequenceIssues: each issue emits agent(senior:feature,emits pr) → connec
|
|
|
106
108
|
assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "merged-1"));
|
|
107
109
|
});
|
|
108
110
|
|
|
111
|
+
test("sequenceIssues: each agent cell is stamped with its OWN issue's repository — a cross-repo sequence (#739)", () => {
|
|
112
|
+
// Two issues in DIFFERENT repos → each `open-*` agent node declares that issue's repository, so the
|
|
113
|
+
// delivery runner provisions each cell's isolation envelope from its own repo (no uniform run-level
|
|
114
|
+
// repository, no `repoless`). This is the generator half of #739.
|
|
115
|
+
const graph = ok({ issues: ["acme/one#1", "beta/two#2"] });
|
|
116
|
+
assertEquals(graph.nodes.find((n) => n.id === "open-1").agent.repository, "acme/one");
|
|
117
|
+
assertEquals(graph.nodes.find((n) => n.id === "open-2").agent.repository, "beta/two");
|
|
118
|
+
});
|
|
119
|
+
|
|
109
120
|
test("sequenceIssues: merge/epic gates carry a realistic poll budget (not the 30-min default trap)", () => {
|
|
110
121
|
const graph = ok({ behind: "acme/repo#9", issues: ["acme/repo#1"] });
|
|
111
122
|
const gate = graph.nodes.find((n) => n.id === "gate-epic");
|
|
@@ -184,3 +195,134 @@ test("sequenceIssues: more than the max issues → rejected", () => {
|
|
|
184
195
|
const issues = rejects({ issues: many });
|
|
185
196
|
assert(issues.some((i) => i.path === "issues"));
|
|
186
197
|
});
|
|
198
|
+
|
|
199
|
+
// ── Interleaved gates (issue #740) ──────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
test("sequenceIssues: the issue's example — an npm gate between two issues generates a wait[npm] node gating the agent on prior merge AND publish", () => {
|
|
202
|
+
const graph = ok({
|
|
203
|
+
issues: [
|
|
204
|
+
"nanobpm/nano-ide#557",
|
|
205
|
+
{ gate: { kind: "npm", target: "@nanobpm/agentic@0.13.0" }, issue: "jwulf/c8ctl-plugin-nano#186" },
|
|
206
|
+
"nanobpm/nano-workforce#738",
|
|
207
|
+
],
|
|
208
|
+
});
|
|
209
|
+
// 3 issues × 3 canonical nodes + 1 interleaved gate node = 10.
|
|
210
|
+
assertEquals(graph.nodes.length, 10);
|
|
211
|
+
const gate = graph.nodes.find((n) => n.id === "gate-2");
|
|
212
|
+
assert(gate, "the interleaved gate node must exist");
|
|
213
|
+
assertEquals(gate.kind, "wait");
|
|
214
|
+
assertEquals(gate.wait.kind, "npm");
|
|
215
|
+
assertEquals(gate.wait.target, "@nanobpm/agentic@0.13.0");
|
|
216
|
+
// The gate carries the bounded default budget (not the 30-min trap).
|
|
217
|
+
assertEquals(gate.wait.poll, { everyMs: 300_000, timeoutMs: 259_200_000 });
|
|
218
|
+
assertEquals(gate.wait.onTimeout, "escalate");
|
|
219
|
+
// Gated on the prior merge: merged-1 → gate-2. Gated on the publish → the agent waits on the gate:
|
|
220
|
+
// gate-2 → open-2. Together the agent (open-2) starts only after issue-1 merged AND the npm publish.
|
|
221
|
+
assert(graph.edges.some((e) => e.from === "merged-1" && e.to === "gate-2"), "gate waits on the prior merge");
|
|
222
|
+
assert(graph.edges.some((e) => e.from === "gate-2" && e.to === "open-2"), "the agent waits on the gate");
|
|
223
|
+
// No direct merged-1 → open-2 edge — the gate is spliced in between.
|
|
224
|
+
assert(!graph.edges.some((e) => e.from === "merged-1" && e.to === "open-2"), "the gate replaces the direct sequence edge");
|
|
225
|
+
// The third (ungated) issue still sequences directly off the second's merge.
|
|
226
|
+
assert(graph.edges.some((e) => e.from === "merged-2" && e.to === "open-3"), "an ungated issue keeps the direct sequence edge");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("sequenceIssues: a gate on the FIRST issue behind an epic gate chains gate-epic → gate-1 → open-1", () => {
|
|
230
|
+
const graph = ok({
|
|
231
|
+
behind: "acme/repo#100",
|
|
232
|
+
issues: [{ gate: { kind: "github-check", target: "acme/repo@main" }, issue: "acme/repo#1" }],
|
|
233
|
+
});
|
|
234
|
+
assert(graph.nodes.some((n) => n.id === "gate-epic"), "the leading epic gate exists");
|
|
235
|
+
assert(graph.nodes.some((n) => n.id === "gate-1"), "the interleaved first-issue gate exists");
|
|
236
|
+
assert(graph.edges.some((e) => e.from === "gate-epic" && e.to === "gate-1"), "epic gate → interleaved gate");
|
|
237
|
+
assert(graph.edges.some((e) => e.from === "gate-1" && e.to === "open-1"), "interleaved gate → agent");
|
|
238
|
+
assert(!graph.edges.some((e) => e.from === "gate-epic" && e.to === "open-1"), "the interleaved gate is spliced before the agent");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("sequenceIssues: a gated first issue with NO behind gate starts the gate immediately (no predecessor edge)", () => {
|
|
242
|
+
const graph = ok({ issues: [{ gate: { kind: "npm", target: "pkg@1.0.0" }, issue: "acme/repo#1" }] });
|
|
243
|
+
// No inbound edge to gate-1 (nothing precedes it); the agent still waits on the gate.
|
|
244
|
+
assert(!graph.edges.some((e) => e.to === "gate-1"), "an ungated-first gate has no predecessor edge");
|
|
245
|
+
assert(graph.edges.some((e) => e.from === "gate-1" && e.to === "open-1"), "the agent waits on the gate");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("sequenceIssues: a gate's `kind`/`target` are trimmed before validation AND persistence (no whitespace-poisoned probe)", () => {
|
|
249
|
+
// Surrounding whitespace must neither trip a confusing "unknown kind" rejection nor survive into
|
|
250
|
+
// the generated `wait` node, where a stray trailing space silently probes the wrong target.
|
|
251
|
+
const graph = ok({ issues: [{ gate: { kind: " npm ", target: " pkg@1.0.0 " }, issue: "acme/repo#1" }] });
|
|
252
|
+
const gate = graph.nodes.find((n) => n.id === "gate-1");
|
|
253
|
+
assert(gate, "the interleaved gate node must exist");
|
|
254
|
+
assertEquals(gate.wait.kind, "npm");
|
|
255
|
+
assertEquals(gate.wait.target, "pkg@1.0.0");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("sequenceIssues: an object entry accepts optional gate fields (match, poll, onTimeout, credentialEnv)", () => {
|
|
259
|
+
const graph = ok({
|
|
260
|
+
issues: [
|
|
261
|
+
{
|
|
262
|
+
gate: {
|
|
263
|
+
kind: "http",
|
|
264
|
+
target: "https://example.test/ready",
|
|
265
|
+
match: { status: 200 },
|
|
266
|
+
poll: { everyMs: 1000, timeoutMs: 60000 },
|
|
267
|
+
onTimeout: "continue",
|
|
268
|
+
credentialEnv: "MY_TOKEN",
|
|
269
|
+
},
|
|
270
|
+
issue: "acme/repo#1",
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
});
|
|
274
|
+
const gate = graph.nodes.find((n) => n.id === "gate-1");
|
|
275
|
+
assertEquals(gate.wait.match, { status: 200 });
|
|
276
|
+
assertEquals(gate.wait.poll, { everyMs: 1000, timeoutMs: 60000 });
|
|
277
|
+
assertEquals(gate.wait.onTimeout, "continue");
|
|
278
|
+
assertEquals(gate.wait.credentialEnv, "MY_TOKEN");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("sequenceIssues: a bare-string entry is byte-for-byte identical to the object form without a gate", () => {
|
|
282
|
+
const bare = ok({ issues: ["acme/repo#1", "acme/repo#2"] });
|
|
283
|
+
const objs = ok({ issues: [{ issue: "acme/repo#1" }, { issue: "acme/repo#2" }] });
|
|
284
|
+
assertEquals(objs.nodes, bare.nodes);
|
|
285
|
+
assertEquals(objs.edges, bare.edges);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("sequenceIssues: a graph with an interleaved gate passes validateDeliveryGraph AND compiles", async () => {
|
|
289
|
+
const graph = ok({
|
|
290
|
+
issues: [
|
|
291
|
+
"acme/repo#1",
|
|
292
|
+
{ gate: { kind: "npm", target: "@scope/pkg@2.0.0" }, issue: "acme/repo#2" },
|
|
293
|
+
],
|
|
294
|
+
});
|
|
295
|
+
assertEquals(validateDeliveryGraph(graph), []);
|
|
296
|
+
const compiled = await compileDeliveryGraph(graph);
|
|
297
|
+
assert(compiled.ok, `expected the gated graph to compile, got ${JSON.stringify(compiled)}`);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("sequenceIssues: an unknown gate kind → rejected at issues[i].gate.kind", () => {
|
|
301
|
+
const issues = rejects({ issues: [{ gate: { kind: "no-such-probe", target: "x" }, issue: "acme/repo#1" }] });
|
|
302
|
+
assert(issues.some((i) => i.path === "issues[0].gate.kind"), `expected issues[0].gate.kind, got ${JSON.stringify(issues)}`);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("sequenceIssues: a gate missing its target → rejected at issues[i].gate.target", () => {
|
|
306
|
+
const issues = rejects({ issues: [{ gate: { kind: "npm" }, issue: "acme/repo#1" }] });
|
|
307
|
+
assert(issues.some((i) => i.path === "issues[0].gate.target"), `expected issues[0].gate.target, got ${JSON.stringify(issues)}`);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("sequenceIssues: a gate with onTimeout:fail → rejected at issues[i].gate.onTimeout", () => {
|
|
311
|
+
const issues = rejects({ issues: [{ gate: { kind: "npm", target: "pkg@1", onTimeout: "fail" }, issue: "acme/repo#1" }] });
|
|
312
|
+
assert(issues.some((i) => i.path === "issues[0].gate.onTimeout"), `expected issues[0].gate.onTimeout, got ${JSON.stringify(issues)}`);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("sequenceIssues: an object entry missing `issue` → rejected at issues[i].issue", () => {
|
|
316
|
+
const issues = rejects({ issues: [{ gate: { kind: "npm", target: "pkg@1" } }] });
|
|
317
|
+
assert(issues.some((i) => i.path === "issues[0].issue"), `expected issues[0].issue, got ${JSON.stringify(issues)}`);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("sequenceIssues: a fully-gated max-length sequence behind an epic gate exceeds the node ceiling → rejected", () => {
|
|
321
|
+
const many = Array.from({ length: MAX_SEQUENCE_ISSUES }, (_, i) => ({
|
|
322
|
+
gate: { kind: "npm", target: `pkg@${i + 1}` },
|
|
323
|
+
issue: `acme/repo#${i + 1}`,
|
|
324
|
+
}));
|
|
325
|
+
// 64 × 4 nodes + 1 epic gate = 257 > 256.
|
|
326
|
+
const issues = rejects({ behind: "acme/repo#999", issues: many });
|
|
327
|
+
assert(issues.some((i) => i.path === "issues" && /too many nodes/.test(i.message)), `expected a node-ceiling rejection, got ${JSON.stringify(issues)}`);
|
|
328
|
+
});
|
package/app/sequenceIssues.ts
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
// `pr`-typed emits, threaded fact edges, a DAG. It is validated against the S3 vocabulary
|
|
20
20
|
// (`deliveryGraphVocabulary`) so an unknown connector target / probe kind is rejected at the door with
|
|
21
21
|
// `issues[{path,message}]` rather than only surfacing at compile time.
|
|
22
|
-
import type { DeliveryEdge, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
|
|
22
|
+
import type { DeliveryEdge, DeliveryGraph, DeliveryNode, ReadinessProbe } from "../nano-generated/api-io.d.ts";
|
|
23
23
|
import { CONVERGE_MERGE_TARGET } from "./convergeTargets.ts";
|
|
24
|
+
import { GRAPH_MAX_NODES } from "./deliveryGraph.ts";
|
|
24
25
|
import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
|
|
25
26
|
import { type ParsedIssue, parseIssue } from "./plan.ts";
|
|
26
27
|
|
|
@@ -31,10 +32,42 @@ export interface SequenceIssueError {
|
|
|
31
32
|
readonly message: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
+
/** An interleaved `wait` GATE that must go green before a given issue's agent starts (issue #740).
|
|
36
|
+
* Reuses the exact probe schema `wait` nodes already accept — the `npm`/`github-check`/`http`/…
|
|
37
|
+
* vocabulary — so an author can insert e.g. "wait for `@nanobpm/agentic@0.13.0` to publish" between
|
|
38
|
+
* two sequence steps without falling off the intent onto raw node/edge JSON. */
|
|
39
|
+
export interface SequenceGate {
|
|
40
|
+
/** The wait-probe kind (must be a known kind in the S3 vocabulary — `npm`, `github-check`, `http`,
|
|
41
|
+
* `command`, `capability`, `pr`, `epic`). */
|
|
42
|
+
kind: string;
|
|
43
|
+
/** The probe target — kind-specific (`pkg@version` for `npm`, `owner/repo@ref` for `github-check`,
|
|
44
|
+
* a URL for `http`, …). */
|
|
45
|
+
target: string;
|
|
46
|
+
/** Optional kind-specific readiness `match` fields (e.g. `{ version }` for `npm`). */
|
|
47
|
+
match?: Record<string, unknown>;
|
|
48
|
+
/** Optional poll budget; defaults to the bounded merge-gate budget ({@link MERGE_POLL}) so a gate
|
|
49
|
+
* never falls into the 30-minute default trap. */
|
|
50
|
+
poll?: { everyMs?: number; timeoutMs?: number };
|
|
51
|
+
/** What to do when the gate never goes green within its budget — `escalate` (default) or `continue`.
|
|
52
|
+
* `fail` is rejected (not yet supported by the compiler/engine). */
|
|
53
|
+
onTimeout?: "escalate" | "continue";
|
|
54
|
+
/** Optional env-key name supplying a credential for the probe (`http`/`capability`). */
|
|
55
|
+
credentialEnv?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One `issues[]` entry with an OPTIONAL leading {@link SequenceGate} — the object form. A bare string
|
|
59
|
+
* entry (no gate) keeps today's behaviour byte-for-byte. */
|
|
60
|
+
export interface SequenceIssueEntry {
|
|
61
|
+
gate?: SequenceGate;
|
|
62
|
+
issue: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `sequenceIssues` intent body — an optional leading `behind` gate plus the ordered `issues`,
|
|
66
|
+
* each of which may be a bare `owner/repo#N` string OR a `{ gate?, issue }` object interleaving a
|
|
67
|
+
* `wait` gate before that issue's agent (issue #740). */
|
|
35
68
|
export interface SequenceIssuesIntent {
|
|
36
69
|
behind?: string;
|
|
37
|
-
issues: string[];
|
|
70
|
+
issues: (string | SequenceIssueEntry)[];
|
|
38
71
|
}
|
|
39
72
|
|
|
40
73
|
/** The result of {@link buildSequenceGraph}: either the constructed graph, or the path-qualified
|
|
@@ -63,6 +96,30 @@ export const MAX_SEQUENCE_ISSUES = 64;
|
|
|
63
96
|
* downstream connector / `wait[pr]` node late-binds its target PR from the fact (§9.4, issue #548). */
|
|
64
97
|
const PR_EMIT = { name: "pr", type: "pr" as const };
|
|
65
98
|
|
|
99
|
+
/** A validated, ready-to-emit interleaved gate: `poll` and `onTimeout` are resolved to concrete values
|
|
100
|
+
* (defaults applied) so {@link assembleGraph} can drop it straight into a `wait` node's config. */
|
|
101
|
+
interface BuiltGate {
|
|
102
|
+
kind: ReadinessProbe["kind"];
|
|
103
|
+
target: string;
|
|
104
|
+
match?: Record<string, unknown>;
|
|
105
|
+
poll: { everyMs?: number; timeoutMs?: number };
|
|
106
|
+
onTimeout: "escalate" | "continue";
|
|
107
|
+
credentialEnv?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A parsed `issues[]` entry: the parsed issue (carrying its `owner/repo#N` target, used to stamp the
|
|
111
|
+
* generated agent node's own `repository` — issue #739) plus its optional interleaved gate (#740). */
|
|
112
|
+
interface ParsedEntry {
|
|
113
|
+
issue: ParsedIssue;
|
|
114
|
+
gate: BuiltGate | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Narrow a validated string to the closed `ReadinessProbe["kind"]` union — backed by the runtime
|
|
118
|
+
* vocabulary set so the type guard and the door validation share one source of truth. */
|
|
119
|
+
function isProbeKind(kind: string, probeKinds: Set<string>): kind is ReadinessProbe["kind"] {
|
|
120
|
+
return probeKinds.has(kind);
|
|
121
|
+
}
|
|
122
|
+
|
|
66
123
|
/** Parse a ref into an issue target ONLY if its number is a positive, safe integer. `parseIssue`'s
|
|
67
124
|
* `\d+` accepts `#0` and precision-overflowing numbers (e.g. `#99999999999999999999`, which coerces
|
|
68
125
|
* past `Number.MAX_SAFE_INTEGER`), but such a target can never resolve to a real issue/PR — staging a
|
|
@@ -74,12 +131,111 @@ function parseIssueRef(ref: unknown): ParsedIssue | null {
|
|
|
74
131
|
return Number.isSafeInteger(parsed.number) && parsed.number >= 1 ? parsed : null;
|
|
75
132
|
}
|
|
76
133
|
|
|
134
|
+
/** Validate one interleaved `gate` spec against the probe vocabulary, applying defaults. Returns the
|
|
135
|
+
* built gate or path-qualified `issues[{path,message}]` rejections (keyed under `path`, e.g.
|
|
136
|
+
* `issues[1].gate.kind`). Reuses the same probe schema `wait` nodes accept: `{ kind, target, match?,
|
|
137
|
+
* poll?, onTimeout?, credentialEnv? }`. */
|
|
138
|
+
function parseGate(raw: unknown, path: string, probeKinds: Set<string>): { gate?: BuiltGate; errors: SequenceIssueError[] } {
|
|
139
|
+
const errors: SequenceIssueError[] = [];
|
|
140
|
+
if (!isRecord(raw)) {
|
|
141
|
+
return { errors: [{ path, message: "`gate` must be an object carrying a `wait` probe (`{ kind, target, … }`)." }] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Trim before validating AND before persisting: harmless surrounding whitespace must neither
|
|
145
|
+
// trip a confusing "unknown kind" rejection nor survive into the generated `wait` node, where a
|
|
146
|
+
// stray trailing space silently probes the wrong target (`pkg@1.0.0 ` → a gate that never goes
|
|
147
|
+
// green). The persisted value is always the trimmed one.
|
|
148
|
+
let validKind: ReadinessProbe["kind"] | null = null;
|
|
149
|
+
const kind = raw.kind;
|
|
150
|
+
if (typeof kind !== "string" || kind.trim() === "") {
|
|
151
|
+
errors.push({ path: `${path}.kind`, message: "`gate.kind` is required and must be a non-empty wait-probe kind." });
|
|
152
|
+
} else {
|
|
153
|
+
const trimmedKind = kind.trim();
|
|
154
|
+
if (!isProbeKind(trimmedKind, probeKinds)) {
|
|
155
|
+
errors.push({ path: `${path}.kind`, message: `wait-probe kind \`${trimmedKind}\` is not in the delivery-graph vocabulary.` });
|
|
156
|
+
} else {
|
|
157
|
+
validKind = trimmedKind;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let validTarget: string | null = null;
|
|
162
|
+
const target = raw.target;
|
|
163
|
+
if (typeof target !== "string" || target.trim() === "") {
|
|
164
|
+
errors.push({ path: `${path}.target`, message: "`gate.target` is required and must be a non-empty string." });
|
|
165
|
+
} else {
|
|
166
|
+
validTarget = target.trim();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let match: Record<string, unknown> | undefined;
|
|
170
|
+
if (raw.match !== undefined) {
|
|
171
|
+
if (!isRecord(raw.match)) {
|
|
172
|
+
errors.push({ path: `${path}.match`, message: "`gate.match`, when present, must be an object of readiness fields." });
|
|
173
|
+
} else {
|
|
174
|
+
match = raw.match;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const poll: { everyMs?: number; timeoutMs?: number } = { ...MERGE_POLL };
|
|
179
|
+
if (raw.poll !== undefined) {
|
|
180
|
+
if (!isRecord(raw.poll)) {
|
|
181
|
+
errors.push({ path: `${path}.poll`, message: "`gate.poll`, when present, must be `{ everyMs?, timeoutMs? }`." });
|
|
182
|
+
} else {
|
|
183
|
+
for (const key of ["everyMs", "timeoutMs"] as const) {
|
|
184
|
+
const v = raw.poll[key];
|
|
185
|
+
if (v === undefined) continue;
|
|
186
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 1) {
|
|
187
|
+
errors.push({ path: `${path}.poll.${key}`, message: `\`gate.poll.${key}\`, when present, must be a positive integer (milliseconds).` });
|
|
188
|
+
} else {
|
|
189
|
+
poll[key] = v;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let onTimeout: "escalate" | "continue" = "escalate";
|
|
196
|
+
if (raw.onTimeout !== undefined) {
|
|
197
|
+
if (raw.onTimeout !== "escalate" && raw.onTimeout !== "continue") {
|
|
198
|
+
errors.push({
|
|
199
|
+
path: `${path}.onTimeout`,
|
|
200
|
+
message: "`gate.onTimeout` must be `escalate` (default) or `continue` — `fail` is not supported on a `wait` node.",
|
|
201
|
+
});
|
|
202
|
+
} else {
|
|
203
|
+
onTimeout = raw.onTimeout;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let credentialEnv: string | undefined;
|
|
208
|
+
if (raw.credentialEnv !== undefined) {
|
|
209
|
+
if (typeof raw.credentialEnv !== "string" || raw.credentialEnv.trim() === "") {
|
|
210
|
+
errors.push({ path: `${path}.credentialEnv`, message: "`gate.credentialEnv`, when present, must be a non-empty env-key name." });
|
|
211
|
+
} else {
|
|
212
|
+
credentialEnv = raw.credentialEnv;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (errors.length > 0 || validKind === null || validTarget === null) return { errors };
|
|
217
|
+
return {
|
|
218
|
+
gate: {
|
|
219
|
+
kind: validKind,
|
|
220
|
+
target: validTarget,
|
|
221
|
+
...(match ? { match } : {}),
|
|
222
|
+
poll,
|
|
223
|
+
onTimeout,
|
|
224
|
+
...(credentialEnv ? { credentialEnv } : {}),
|
|
225
|
+
},
|
|
226
|
+
errors: [],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
77
230
|
/**
|
|
78
231
|
* Build the canonical delivery graph for a `sequenceIssues` intent, or return path-qualified
|
|
79
232
|
* `issues[{path,message}]` rejections for invalid input. Validates:
|
|
80
233
|
* - `issues` is a non-empty array within {@link MAX_SEQUENCE_ISSUES};
|
|
81
|
-
* - every `issues[i]`
|
|
82
|
-
*
|
|
234
|
+
* - every `issues[i]` (a bare ref, or a `{ gate?, issue }` object) and the optional `behind` parse
|
|
235
|
+
* as an `owner/repo#N` reference;
|
|
236
|
+
* - each interleaved `gate` names a wait-probe kind known to the S3 vocabulary (issue #740);
|
|
237
|
+
* - the connector target and wait-probe kinds it emits are known to the S3 vocabulary (drift guard);
|
|
238
|
+
* - the generated graph stays within the compiler's node ceiling.
|
|
83
239
|
* Pure — no I/O. The constructed graph passes `validateDeliveryGraph` by construction.
|
|
84
240
|
*/
|
|
85
241
|
export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
@@ -89,8 +245,12 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
89
245
|
const rawIssues = body.issues;
|
|
90
246
|
const rawBehind = body.behind;
|
|
91
247
|
|
|
92
|
-
//
|
|
93
|
-
const
|
|
248
|
+
// The vocabulary is needed up front to validate interleaved gate `kind`s as each entry is parsed.
|
|
249
|
+
const vocab = deliveryGraphVocabulary();
|
|
250
|
+
const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
|
|
251
|
+
|
|
252
|
+
// ── `issues`: a non-empty, bounded array of parseable refs, each optionally carrying a gate ────
|
|
253
|
+
const parsedEntries: ParsedEntry[] = [];
|
|
94
254
|
if (!Array.isArray(rawIssues)) {
|
|
95
255
|
issues.push({ path: "issues", message: "`issues` must be a non-empty array of `owner/repo#N` issue references." });
|
|
96
256
|
} else if (rawIssues.length === 0) {
|
|
@@ -101,16 +261,26 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
101
261
|
message: `\`issues\` has too many entries (${rawIssues.length}) — the limit is ${MAX_SEQUENCE_ISSUES}.`,
|
|
102
262
|
});
|
|
103
263
|
} else {
|
|
104
|
-
rawIssues.forEach((
|
|
105
|
-
|
|
264
|
+
rawIssues.forEach((entry, i) => {
|
|
265
|
+
// A bare string entry is a gate-less issue (today's behaviour, byte-for-byte). An object entry
|
|
266
|
+
// carries a required `issue` ref plus an OPTIONAL leading `gate` (issue #740).
|
|
267
|
+
const rawRef = isRecord(entry) ? entry.issue : entry;
|
|
268
|
+
const parsed = parseIssueRef(rawRef);
|
|
106
269
|
if (!parsed) {
|
|
270
|
+
const shown = isRecord(entry) ? String(rawRef) : String(entry);
|
|
107
271
|
issues.push({
|
|
108
|
-
path: `issues[${i}]`,
|
|
109
|
-
message: `\`${
|
|
272
|
+
path: isRecord(entry) ? `issues[${i}].issue` : `issues[${i}]`,
|
|
273
|
+
message: `\`${shown}\` is not a valid \`owner/repo#N\` issue reference.`,
|
|
110
274
|
});
|
|
111
275
|
return;
|
|
112
276
|
}
|
|
113
|
-
|
|
277
|
+
let gate: BuiltGate | null = null;
|
|
278
|
+
if (isRecord(entry) && entry.gate !== undefined && entry.gate !== null) {
|
|
279
|
+
const parsedGate = parseGate(entry.gate, `issues[${i}].gate`, probeKinds);
|
|
280
|
+
issues.push(...parsedGate.errors);
|
|
281
|
+
gate = parsedGate.gate ?? null;
|
|
282
|
+
}
|
|
283
|
+
parsedEntries.push({ issue: parsed, gate });
|
|
114
284
|
});
|
|
115
285
|
}
|
|
116
286
|
|
|
@@ -135,7 +305,6 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
135
305
|
// ── Vocabulary drift guard (S3): the target/probe kinds this generator emits MUST be known to the
|
|
136
306
|
// structured vocabulary. This can only trip if the closed vocabulary changes underneath us — it is
|
|
137
307
|
// surfaced as a door `issue` (not a throw) so the failure mode is a clean rejection, not a 500. ──
|
|
138
|
-
const vocab = deliveryGraphVocabulary();
|
|
139
308
|
const realTargets = new Set(vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target));
|
|
140
309
|
if (!realTargets.has(CONVERGE_MERGE_TARGET)) {
|
|
141
310
|
issues.push({
|
|
@@ -143,28 +312,44 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
|
|
|
143
312
|
message: `connector target \`${CONVERGE_MERGE_TARGET}\` is not a real target in the delivery-graph vocabulary.`,
|
|
144
313
|
});
|
|
145
314
|
}
|
|
146
|
-
const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
|
|
147
315
|
for (const kind of behindKey ? [PR_PROBE_KIND, EPIC_PROBE_KIND] : [PR_PROBE_KIND]) {
|
|
148
316
|
if (!probeKinds.has(kind)) {
|
|
149
317
|
issues.push({ path: "issues", message: `wait-probe kind \`${kind}\` is not in the delivery-graph vocabulary.` });
|
|
150
318
|
}
|
|
151
319
|
}
|
|
152
320
|
|
|
321
|
+
// ── Node-budget guard: interleaved gates add a node per gated issue, so a fully-gated max-length
|
|
322
|
+
// sequence (plus the optional `behind` gate) can push past the compiler's node ceiling. Reject it
|
|
323
|
+
// at the door so a SUCCESSFULLY generated graph always validates `by construction`. ──
|
|
324
|
+
if (issues.length === 0) {
|
|
325
|
+
const projectedNodes = (behindKey ? 1 : 0) + parsedEntries.reduce((n, e) => n + 3 + (e.gate ? 1 : 0), 0);
|
|
326
|
+
if (projectedNodes > GRAPH_MAX_NODES) {
|
|
327
|
+
issues.push({
|
|
328
|
+
path: "issues",
|
|
329
|
+
message: `the generated graph would have too many nodes (${projectedNodes}) — the limit is ${GRAPH_MAX_NODES}; use fewer issues or interleaved gates.`,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
153
334
|
if (issues.length > 0) return { ok: false, issues };
|
|
154
335
|
|
|
155
|
-
return { ok: true, graph: assembleGraph(
|
|
336
|
+
return { ok: true, graph: assembleGraph(parsedEntries, behindKey) };
|
|
156
337
|
}
|
|
157
338
|
|
|
158
|
-
/** Assemble the canonical node/edge chain for the (already-validated)
|
|
159
|
-
|
|
339
|
+
/** Assemble the canonical node/edge chain for the (already-validated) parsed entries + optional gate.
|
|
340
|
+
* Each entry emits `agent → connector[converge-merge] → wait[pr, merged]`; an entry with an
|
|
341
|
+
* interleaved `gate` gets a leading `wait[<gate.kind>]` node spliced between the prior sequence step
|
|
342
|
+
* and its agent, so the agent starts only once the prior issue merged AND the gate went green. Each
|
|
343
|
+
* agent node is stamped with its own `repository` (issue #739) from the issue it implements. */
|
|
344
|
+
function assembleGraph(entries: ParsedEntry[], behindKey: string | null): DeliveryGraph {
|
|
160
345
|
const nodes: DeliveryNode[] = [];
|
|
161
346
|
const edges: DeliveryEdge[] = [];
|
|
162
347
|
|
|
163
348
|
// Optional leading `wait[epic]` gate — the whole sequence waits for `behind` to be fully merged.
|
|
164
|
-
const
|
|
349
|
+
const EPIC_GATE_ID = "gate-epic";
|
|
165
350
|
if (behindKey) {
|
|
166
351
|
nodes.push({
|
|
167
|
-
id:
|
|
352
|
+
id: EPIC_GATE_ID,
|
|
168
353
|
kind: "wait",
|
|
169
354
|
wait: {
|
|
170
355
|
kind: EPIC_PROBE_KIND,
|
|
@@ -177,18 +362,50 @@ function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryG
|
|
|
177
362
|
});
|
|
178
363
|
}
|
|
179
364
|
|
|
180
|
-
|
|
365
|
+
entries.forEach((entry, i) => {
|
|
366
|
+
const issue = entry.issue;
|
|
181
367
|
const n = i + 1;
|
|
182
368
|
const openId = `open-${n}`;
|
|
183
369
|
const landId = `land-${n}`;
|
|
184
370
|
const mergedId = `merged-${n}`;
|
|
185
371
|
const prRef = `${openId}.pr`;
|
|
372
|
+
const issueKey = issue.planKey;
|
|
373
|
+
|
|
374
|
+
// The sequence predecessor whose completion releases THIS issue: the prior issue's merge, else the
|
|
375
|
+
// leading epic gate for the first issue (null when the first issue is ungated by `behind`).
|
|
376
|
+
const predecessor = i === 0 ? (behindKey ? EPIC_GATE_ID : null) : `merged-${i}`;
|
|
186
377
|
|
|
187
|
-
//
|
|
378
|
+
// Interleaved gate (issue #740): a `wait[<kind>]` node the issue's agent waits on. It inherits the
|
|
379
|
+
// sequence predecessor's edge (so it only starts probing once the prior issue merged) and the
|
|
380
|
+
// agent then waits on the gate — gating the agent on the prior merge AND the gate condition.
|
|
381
|
+
let agentPredecessor = predecessor;
|
|
382
|
+
if (entry.gate) {
|
|
383
|
+
const gateId = `gate-${n}`;
|
|
384
|
+
const g = entry.gate;
|
|
385
|
+
nodes.push({
|
|
386
|
+
id: gateId,
|
|
387
|
+
kind: "wait",
|
|
388
|
+
wait: {
|
|
389
|
+
kind: g.kind,
|
|
390
|
+
target: g.target,
|
|
391
|
+
...(g.match ? { match: g.match } : {}),
|
|
392
|
+
poll: { ...g.poll },
|
|
393
|
+
onTimeout: g.onTimeout,
|
|
394
|
+
...(g.credentialEnv ? { credentialEnv: g.credentialEnv } : {}),
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
if (predecessor) edges.push({ from: predecessor, to: gateId });
|
|
398
|
+
agentPredecessor = gateId;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind. Stamp the
|
|
402
|
+
// node's OWN `repository` (#739) from the `owner/repo#N` it implements, so a cross-repo sequence
|
|
403
|
+
// provisions each cell's isolation envelope node-locally — no uniform run-level repo, no `repoless`.
|
|
404
|
+
// The base branch is left to the run-level fallback (an issue ref names no branch — issue #739 Notes).
|
|
188
405
|
nodes.push({
|
|
189
406
|
id: openId,
|
|
190
407
|
kind: "agent",
|
|
191
|
-
agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR
|
|
408
|
+
agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR.`, repository: issue.repo },
|
|
192
409
|
emits: [{ ...PR_EMIT }],
|
|
193
410
|
});
|
|
194
411
|
// connector[converge-merge] → drive the opened PR through review convergence + the merge loop.
|
|
@@ -214,18 +431,16 @@ function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryG
|
|
|
214
431
|
edges.push({ from: prRef, to: landId });
|
|
215
432
|
edges.push({ from: prRef, to: mergedId });
|
|
216
433
|
|
|
217
|
-
// Sequence: this issue's agent starts once
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
} else {
|
|
221
|
-
edges.push({ from: `merged-${i}`, to: openId });
|
|
222
|
-
}
|
|
434
|
+
// Sequence: this issue's agent starts once its predecessor (prior merge / epic gate / interleaved
|
|
435
|
+
// gate) released. A first ungated issue has no predecessor edge (it starts immediately).
|
|
436
|
+
if (agentPredecessor) edges.push({ from: agentPredecessor, to: openId });
|
|
223
437
|
});
|
|
224
438
|
|
|
439
|
+
const gateCount = entries.filter((e) => e.gate).length;
|
|
225
440
|
const name =
|
|
226
|
-
|
|
227
|
-
? `sequence ${
|
|
228
|
-
: `sequence ${
|
|
441
|
+
entries.length === 1
|
|
442
|
+
? `sequence ${entries[0].issue.planKey}`
|
|
443
|
+
: `sequence ${entries.length} issues${gateCount > 0 ? ` with ${gateCount} gate${gateCount === 1 ? "" : "s"}` : ""}${behindKey ? ` behind ${behindKey}` : ""}`;
|
|
229
444
|
|
|
230
445
|
return { name, nodes, edges };
|
|
231
446
|
}
|
|
@@ -69,6 +69,49 @@ describe("S5 — the addressable operator guide over MCP (#611)", () => {
|
|
|
69
69
|
assert(res.text.length < 30000, "one section must fit a typical tool-result budget in a single call");
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
test("section pagination: start/length pages a section and nextStart walks it to completion (#740)", async () => {
|
|
73
|
+
const whole = await h.callTool("getAgentGuide", { section: "delivery-graphs" });
|
|
74
|
+
const wholeSection = (whole.json as { section?: { instructions: string } }).section!;
|
|
75
|
+
const wholeChars = Array.from(wholeSection.instructions);
|
|
76
|
+
|
|
77
|
+
const PAGE = 4000;
|
|
78
|
+
let start = 0;
|
|
79
|
+
let assembled = "";
|
|
80
|
+
let pages = 0;
|
|
81
|
+
let total = -1;
|
|
82
|
+
for (;;) {
|
|
83
|
+
const res = await h.callTool("getAgentGuide", { section: "delivery-graphs", start, length: PAGE });
|
|
84
|
+
assert(!res.isError, `a paged fetch must not error: ${res.text}`);
|
|
85
|
+
const body = res.json as {
|
|
86
|
+
kind?: string;
|
|
87
|
+
section?: { id: string; instructions: string; start: number; length: number; totalLength: number; nextStart: number | null };
|
|
88
|
+
};
|
|
89
|
+
assert.equal(body.kind, "section");
|
|
90
|
+
const s = body.section!;
|
|
91
|
+
assert.equal(s.id, "delivery-graphs");
|
|
92
|
+
assert(Array.from(s.instructions).length <= PAGE, "a page must be bounded by `length`");
|
|
93
|
+
assert.equal(s.start, start);
|
|
94
|
+
if (total !== -1) assert.equal(s.totalLength, total, "totalLength is stable across pages");
|
|
95
|
+
total = s.totalLength;
|
|
96
|
+
assembled += s.instructions;
|
|
97
|
+
pages++;
|
|
98
|
+
if (s.nextStart === null) break;
|
|
99
|
+
start = s.nextStart;
|
|
100
|
+
assert(pages < 100, "pagination must terminate");
|
|
101
|
+
}
|
|
102
|
+
assert(pages > 1, "a large section must span multiple pages at this window");
|
|
103
|
+
assert.equal(total, wholeChars.length, "totalLength equals the full section length");
|
|
104
|
+
assert.equal(assembled, wholeSection.instructions, "the reassembled pages equal the whole section");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("section pagination: a non-integer/negative start is a uniform issues[{path,message}] 400 (#740)", async () => {
|
|
108
|
+
const res = await h.callTool("getAgentGuide", { section: "delivery-graphs", start: -5 });
|
|
109
|
+
assert(res.isError, "an invalid pagination arg must surface as a tool-level error");
|
|
110
|
+
const body = res.json as { issues?: { path: string; message: string }[] };
|
|
111
|
+
assert(Array.isArray(body.issues) && body.issues.length >= 1, "must answer with issues[]");
|
|
112
|
+
assert(body.issues!.some((i) => i.path.includes("start")), "the issue must name the `start` argument");
|
|
113
|
+
});
|
|
114
|
+
|
|
72
115
|
test("an unknown section id is rejected with issues[{path,message}]", async () => {
|
|
73
116
|
const res = await h.callTool("getAgentGuide", { section: "no-such-section" });
|
|
74
117
|
assert(res.isError, "an unknown section id must surface as a tool-level error");
|