Skip to content

Commit fb55a82

Browse files
committed
fix(coding-agent): executor review fixes (wait gating, mid-settle resume, JSON-strict guards)
Wait states are gated at the validator (the rlm.watch.* host handlers arrive with the communication series #2351/#2356), so the executor's wait registration/timeout/settle/cancel paths and their tests are removed entirely; quiescence, re-entry, and fan-out are unchanged. Review fixes: a max_transitions pause mid-settle now records the transition index and a resume continues after it (previously the settle re-fired already-fired transitions, duplicating spawned work); eq/ne guards compare JSON-strictly (a bool never equals a number, numbers compare numerically); contains requires a non-empty list value at validation and is defensively false at runtime; the max_transitions pause fires its own max_transitions_exceeded milestone kind (no collision with the run-budget budget_exceeded, both notice-able); the control-loop stall reports the pending entry whose input source never settled, with a test; EVENT_WINDOW grows 50 -> 200 (the pr-manager happy path is ~43 events before any retry); refinement validateEdit accepts machine-form swarm edits (either dag or machine object, never both). Also adds optional inputs: an input flagged optional binds a null sentinel instead of waiting when its source state never settled, which lets loop states (review with the previous fix report) run their first round before the fixer exists; compiled dags never emit it.
1 parent 0748869 commit fb55a82

9 files changed

Lines changed: 417 additions & 301 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
- Added the swarm DAG executor: `rlm.swarm.run/status/stop/resume` validates a stored swarm, spawns ready nodes without holding the model turn open, resumes through collect polls, binds typed outputs into dependent prompts, enforces per-node and run budgets, retries failures, applies fail_fast/continue/escalate policies, cascades cancellation, and wakes the parent once per milestone.
1+
- Added the state-machine swarm executor: `rlm.swarm.run/status/stop/resume` canonicalizes the stored swarm (dag sugar compiles to machine form), enters entry states, and drives the machine to quiescence — every settle evaluates its guarded transitions (fan-out legal, max_entries blocking recorded as transition_blocked, re-entry re-binds inputs), with bounded re-entry, per-entry foreach/budgets/retries, fail_fast/continue/escalate policies, cancellation cascades, and one quiet notice per milestone (finished/failed/paused/budget_exceeded/max_transitions_exceeded). `status()` reports per-state entries_used/max_entries and a transitions_fired usage count over a 200-event window. Wait states stay specified but gated: the watch host handlers (`rlm.watch.*`) arrive with the communication series, so the validator rejects wait blocks until then.

‎packages/coding-agent/src/core/messages.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ Command: ${JSON.stringify(details.command)}`,
271271

272272
export interface SwarmProgressDetails {
273273
runId: string;
274-
kind: "finished" | "failed" | "paused" | "budget_exceeded";
274+
kind: "finished" | "failed" | "paused" | "budget_exceeded" | "max_transitions_exceeded";
275275
node?: string;
276276
detail: string;
277277
}

‎packages/coding-agent/src/core/refinement/refinement.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,10 +1067,15 @@ function validateEdit(edit: RefinementEdit, computedId?: string): string | undef
10671067
}
10681068
if (edit.action !== "delete" && edit.kind === "swarm") {
10691069
// Structural check only: the kernel validator (rlm.swarm) enforces the full
1070-
// DAG semantics at write time; do not reimplement it here.
1070+
// machine semantics at write time; do not reimplement it here.
10711071
const dag = edit.arguments?.dag;
1072-
if (typeof dag !== "object" || dag === null || Array.isArray(dag)) {
1073-
return "swarm entry requires a dag object in arguments";
1072+
const machine = edit.arguments?.machine;
1073+
if (dag !== undefined && machine !== undefined) {
1074+
return "pass either dag or machine form, not both";
1075+
}
1076+
const spec = machine ?? dag;
1077+
if (typeof spec !== "object" || spec === null || Array.isArray(spec)) {
1078+
return "swarm entry requires a dag or machine object in arguments";
10741079
}
10751080
}
10761081
return undefined;

‎packages/coding-agent/src/core/rlm-runtime.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ interface AsyncBashConsumedRequest {
126126

127127
type AsyncBashConsumedHandler = (request: AsyncBashConsumedRequest) => void | Promise<void>;
128128

129-
export type SwarmProgressKind = "finished" | "failed" | "paused" | "budget_exceeded";
129+
export type SwarmProgressKind = "finished" | "failed" | "paused" | "budget_exceeded" | "max_transitions_exceeded";
130130

131131
export interface SwarmProgressRequest {
132132
runId: string;
@@ -137,7 +137,13 @@ export interface SwarmProgressRequest {
137137

138138
export type SwarmProgressHandler = (request: SwarmProgressRequest) => void | Promise<void>;
139139

140-
const SWARM_PROGRESS_KINDS: readonly SwarmProgressKind[] = ["finished", "failed", "paused", "budget_exceeded"];
140+
const SWARM_PROGRESS_KINDS: readonly SwarmProgressKind[] = [
141+
"finished",
142+
"failed",
143+
"paused",
144+
"budget_exceeded",
145+
"max_transitions_exceeded",
146+
];
141147

142148
function isSwarmProgressKind(value: unknown): value is SwarmProgressKind {
143149
return typeof value === "string" && (SWARM_PROGRESS_KINDS as readonly string[]).includes(value);

‎packages/coding-agent/test/refinement.test.ts‎

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ const swarmDag = {
100100
],
101101
};
102102

103+
const swarmMachine = {
104+
states: [
105+
{ id: "collect", entry: true, subagent: "researcher", outputs: [{ name: "findings", type: "text" }] },
106+
{
107+
id: "review",
108+
subagent: { prompt: "Review the findings." },
109+
inputs: [{ name: "draft", type: "text", from: "collect.findings" }],
110+
},
111+
],
112+
transitions: [{ from: "collect", to: "review" }],
113+
};
114+
103115
function proposal(summary: string, edits: RefinementProposal["edits"]): RefinementProposal {
104116
return {
105117
summary,
@@ -296,12 +308,12 @@ describe("harness refinement", () => {
296308
expect(state.refinements.at(-1)?.changes).toEqual([`delete ${kind}:${id}`]);
297309
});
298310

299-
it("requires a dag object in arguments for swarm creates and updates", () => {
311+
it("requires exactly one dag or machine object in arguments for swarm creates and updates", () => {
300312
const state = loadHarnessState(makeTempDir());
301313

302-
const missingDag = applyRefinementProposal(
314+
const missingSpec = applyRefinementProposal(
303315
state,
304-
proposal("Create swarm without a dag", [
316+
proposal("Create swarm without a spec", [
305317
{
306318
action: "create",
307319
kind: "swarm",
@@ -313,9 +325,9 @@ describe("harness refinement", () => {
313325
{ id: "refine_swarm_missing_dag" },
314326
);
315327

316-
expect(missingDag.appliedEdits[0]).toMatchObject({
328+
expect(missingSpec.appliedEdits[0]).toMatchObject({
317329
applied: false,
318-
error: "swarm entry requires a dag object in arguments",
330+
error: "swarm entry requires a dag or machine object in arguments",
319331
});
320332
expect(state.entries.swarm.swarm_entry).toBeUndefined();
321333
expect(state.refinements.at(-1)?.changes).toEqual([]);
@@ -337,8 +349,29 @@ describe("harness refinement", () => {
337349

338350
expect(nonObjectDag.appliedEdits[0]).toMatchObject({
339351
applied: false,
340-
error: "swarm entry requires a dag object in arguments",
352+
error: "swarm entry requires a dag or machine object in arguments",
353+
});
354+
355+
const bothForms = applyRefinementProposal(
356+
state,
357+
proposal("Create swarm with both forms", [
358+
{
359+
action: "create",
360+
kind: "swarm",
361+
id: "swarm_entry",
362+
title: "Swarm title",
363+
content: "Swarm content",
364+
arguments: { dag: swarmDag, machine: swarmMachine },
365+
},
366+
]),
367+
{ id: "refine_swarm_both_forms" },
368+
);
369+
370+
expect(bothForms.appliedEdits[0]).toMatchObject({
371+
applied: false,
372+
error: "pass either dag or machine form, not both",
341373
});
374+
expect(state.entries.swarm.swarm_entry).toBeUndefined();
342375

343376
const created = applyRefinementProposal(
344377
state,
@@ -360,9 +393,27 @@ describe("harness refinement", () => {
360393
expect(created.appliedEdits[0].applied).toBe(true);
361394
expect(state.entries.swarm.swarm_entry.arguments).toEqual({ dag: swarmDag });
362395

363-
const updateWithoutDag = applyRefinementProposal(
396+
const machineCreated = applyRefinementProposal(
397+
state,
398+
proposal("Create swarm with a machine", [
399+
{
400+
action: "create",
401+
kind: "swarm",
402+
id: "swarm_machine_entry",
403+
title: "Swarm machine",
404+
content: "Swarm machine content",
405+
arguments: { machine: swarmMachine },
406+
},
407+
]),
408+
{ id: "refine_swarm_machine_valid" },
409+
);
410+
411+
expect(machineCreated.appliedEdits[0].applied).toBe(true);
412+
expect(state.entries.swarm.swarm_machine_entry.arguments).toEqual({ machine: swarmMachine });
413+
414+
const updateWithoutSpec = applyRefinementProposal(
364415
state,
365-
proposal("Update swarm without a dag", [
416+
proposal("Update swarm without a spec", [
366417
{
367418
action: "update",
368419
kind: "swarm",
@@ -374,9 +425,9 @@ describe("harness refinement", () => {
374425
{ id: "refine_swarm_update_missing_dag" },
375426
);
376427

377-
expect(updateWithoutDag.appliedEdits[0]).toMatchObject({
428+
expect(updateWithoutSpec.appliedEdits[0]).toMatchObject({
378429
applied: false,
379-
error: "swarm entry requires a dag object in arguments",
430+
error: "swarm entry requires a dag or machine object in arguments",
380431
});
381432
expect(state.entries.swarm.swarm_entry.title).toBe("Swarm title");
382433
});

‎packages/coding-agent/test/swarm-executor.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,17 @@ describe("swarm progress", () => {
2424
]);
2525
});
2626

27+
it("renders the max_transitions pause kind verbatim (distinct from budget_exceeded)", () => {
28+
const message = createSwarmProgressMessage({
29+
runId: "run-2",
30+
kind: "max_transitions_exceeded",
31+
detail: "max_transitions 1 exceeded; no new entries",
32+
});
33+
expect(message.content).toBe(
34+
"[swarm-progress run:run-2] max_transitions_exceeded: max_transitions 1 exceeded; no new entries",
35+
);
36+
});
37+
2738
it("renders the finished, failed, and paused milestone kinds verbatim", () => {
2839
expect(createSwarmProgressMessage({ runId: "r", kind: "finished", detail: "all 3 nodes done" }).content).toBe(
2940
"[swarm-progress run:r] finished: all 3 nodes done",

0 commit comments

Comments
 (0)