diff --git a/README.md b/README.md index f0ec381..11ab25b 100644 --- a/README.md +++ b/README.md @@ -175,16 +175,15 @@ type NodePrompt = string | { type BraidInputNode = | { type: "execute"; id: string; prompt: NodePrompt; model?: string; workspace?: "read-only" | "worktree"; notifyOnCompletion?: boolean; - requireSuccess?: boolean; pauseAfter?: boolean } + requireSuccess?: boolean; pauseAfter?: boolean; maxRuns?: number } | { type: "decision"; id: string; prompt: NodePrompt; choices: readonly string[]; model?: string; workspace?: "read-only" | "worktree"; notifyOnCompletion?: boolean; - requireSuccess?: boolean; pauseAfter?: boolean } + requireSuccess?: boolean; pauseAfter?: boolean; maxRuns?: number } | { type: "merge" | "integrate"; id: string; prompt?: NodePrompt; model?: string; notifyOnCompletion?: boolean; - requireSuccess?: boolean; pauseAfter?: boolean }; + requireSuccess?: boolean; pauseAfter?: boolean; maxRuns?: number }; -// limit: only on the edge that closes a cycle; how many times it may fire. -type Edge = { from: string; to: string; choice?: string; limit?: number }; +type Edge = { from: string; to: string; choice?: string }; type BraidInput = { goal: string; nodes: readonly BraidInputNode[]; @@ -202,12 +201,16 @@ snapshots; read-only disables write capabilities. Outside Git all filesystem access is read-only. `notifyOnCompletion` requests host reminders, `pauseAfter` holds outgoing scheduling, and `requireSuccess` makes failure abort the whole run. All three booleans default to false. `BraidRun.holdAfter` adds or removes the -same hold on an execution that is already running. +same hold on an execution that is already running. `maxRuns` caps how many +executions of a node may start, counting cycle rounds and downstream reruns +(skipped ones do not count); it is unlimited when omitted or `Infinity`, and an +owner's replacement of a started node always starts. A rerun past it waits with +`waitReason: "limit"` (see [limits](docs/execution-control.md#limits)). Unknown fields, missing references, and duplicate edges (the same `from`, `to`, and `choice`) are rejected. A cycle is a loop and must be closed by a decision's choice edge back to the node where each round starts; nested or overlapping -cycles are rejected. Disconnected components are allowed; every root runs. +cycles are rejected. Bound a cycle's rounds with `maxRuns` on a node in it. Disconnected components are allowed; every root runs. Only decision nodes may have choice edges, and choices need not all have exits. `validateGraph(input)` validates without execution. Invalid graphs raise @@ -289,7 +292,7 @@ length or token cap (see [resource limits](docs/resource-limits.md)). | `nodeTimeoutMs` | Unlimited | Separate deadline for each node, starting when it runs (not while queued or suspended) | | `graphTimeoutMs` | Unlimited | Whole execution deadline, including node queueing, paused gates, and suspensions; starts after validation | | `onNodeTimeout` | `"pause"` (`"fail"` in `braid()`) | `"pause"` suspends a timed-out execution with its work until it is resumed or cancelled (needs an interruptible runner); `"fail"` fails it with `NODE_TIMEOUT` | -| `onLimit` | `"pause"` (`"fail"` in `braid()`) | What reaching an edge's `limit`, `maxExecutions`, `graphTimeoutMs`, or `maxTokens` does: `"pause"` holds the decision that chose the edge or pauses the run until the owner raises the limit; `"fail"` fails the run with `EDGE_LIMIT`, `EXECUTION_LIMIT`, `GRAPH_TIMEOUT`, or `TOKEN_LIMIT` | +| `onLimit` | `"pause"` (`"fail"` in `braid()`) | What reaching a node's `maxRuns`, `maxExecutions`, `graphTimeoutMs`, or `maxTokens` does: `"pause"` keeps the rerun pending or pauses the run until the owner raises the limit; `"fail"` fails the run with `RUN_LIMIT`, `EXECUTION_LIMIT`, `GRAPH_TIMEOUT`, or `TOKEN_LIMIT` | | `signal` | None | Caller cancellation signal; aborts running nodes and marks queued nodes cancelled | | `onEvent` | None | Live observer for graph and execution creation, starts, handoffs, completions, skips, failures, and graph completion | diff --git a/ROADMAP.md b/ROADMAP.md index f03c96d..55271e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -190,7 +190,7 @@ graphical workflow editor ([#57](https://github.com/Epsirom/braid/issues/57)). E | Supported | Outside the current scope | | --- | --- | -| Cycles closed by a decision's choice edge, with optional edge limits; sequential and independent cycles | Other cycles, and nested/overlapping cycles | +| Cycles closed by a decision's choice edge, bounded by optional per-node `maxRuns`; sequential and independent cycles | Other cycles, and nested/overlapping cycles | | In-memory updates and pause/resume before finalization | Durable workflow recovery, restart/resume, or reopening finalized jobs | | Git checkpoints and backup refs for inspecting/recovering files | Persistence of scheduler state or host sessions | | Per-execution workspace capabilities and isolated model context | A security sandbox, arbitrary code nodes, or recursive worker delegation | diff --git a/docs/compatibility.md b/docs/compatibility.md index 71d4d7c..ec0c178 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -235,10 +235,10 @@ instances. need no round limit. Callers that relied on the old bounds, including `braid()` callers, must set them explicitly. Pi and DSH already defaulted both timeouts to unlimited; their jobs also lose the 1000-execution bound. -- **Limits pause by default.** With `startBraid`, a decision that chooses an edge - at its limit is held, `maxExecutions` stops admission, and `graphTimeoutMs` - pauses the run, instead of failing with `EDGE_LIMIT`, `EXECUTION_LIMIT`, or - `GRAPH_TIMEOUT`. Pass `onLimit: "fail"` to keep failing. +- **Limits pause by default.** With `startBraid`, a rerun past its node's + `maxRuns` waits, `maxExecutions` stops admission, and `graphTimeoutMs` pauses + the run, instead of failing with `RUN_LIMIT` (was `LOOP_LIMIT`), + `EXECUTION_LIMIT`, or `GRAPH_TIMEOUT`. Pass `onLimit: "fail"` to keep failing. Otherwise, raise the limit and continue as described in [limits](execution-control.md#limits), or cancel the run. `braid()` still fails and rejects `onLimit: "pause"`. @@ -268,17 +268,27 @@ change without effect. See [execution control](execution-control.md) for the full model. **Graphs and loops.** Remove `loops` and every edge's `feedback`. A cycle closed -by a decision's choice edge is a loop; move the loop's `maxIterations` onto that -edge as `limit`, which counts how many times the edge fires, so it is one less: +by a decision's choice edge is a loop; move the loop's `maxIterations` onto a node +in the cycle, usually its entry, as `maxRuns` with the same number. `maxRuns` +counts every execution of that node that starts, including reruns caused by +upstream changes: ```ts // 0.5 +nodes: [{ type: "execute", id: "edit", prompt }, /* ... */], loops: [{ id: "refine", entry: "edit", maxIterations: 3 }], edges: [{ from: "review", to: "edit", choice: "again", feedback: "refine" }], // 0.6 -edges: [{ from: "review", to: "edit", choice: "again", limit: 2 }], +nodes: [{ type: "execute", id: "edit", prompt, maxRuns: 3 }, /* ... */], +edges: [{ from: "review", to: "edit", choice: "again" }], ``` +At the limit, the decision is no longer held: the next round's execution of that +node is created but waits pending (`waitReason: "limit"`, listed in +`limitedExecutionIds`). Raise `maxRuns` by upserting the node to let it start, or +release it with `resume` to end the cycle, as described in +[limits](execution-control.md#limits). + The cycle rules are otherwise unchanged: the closing edge must be a decision's choice edge into the cycle's entry, the decision needs an exit choice, edges enter only at the entry and leave only from the decision, and cycles cannot nest or @@ -327,19 +337,23 @@ unless you replace it. instance, with `generation`, `cause`, and `supersedes` or `trigger`), `node_runnable`, `loop_started`, and `loop_completed`. New events: `execution_removed` and `execution_superseded`. `limit_reached.limit` is - `"edge"` instead of `"maxIterations"` and carries the `edge`. -- `ExecutionError.code` `LOOP_LIMIT` is now `EDGE_LIMIT`, and the hold source - `loop_limit` is now `edge_limit`. -- New: `Edge.limit`, `WaitReason`, `NodeResult.waitReason`, `held`, - `supersedes`, and `supersededBy`, `NodeExecution.generation`, and the - `closingEdges` helper. + `"maxRuns"` instead of `"maxIterations"`, with the waiting execution's + `executionId`. +- `ExecutionError.code` `LOOP_LIMIT` is now `RUN_LIMIT`, and the hold source + `loop_limit` is gone: a limit no longer holds the decision. +- New: `maxRuns` on every node type, `WaitReason` (including `"limit"`), + `NodeResult.waitReason`, `held`, `supersedes`, and `supersededBy`, + `NodeExecution.generation`, `BraidSnapshot.limitedExecutionIds` and + `graph_waiting.limitedExecutionIds`, and the `closingEdges` helper. + `resume` also accepts a limited execution, which ends that rerun. **Pi and DSH.** The `braid` and `braid_update` tools drop `loops`, `feedback`, and -`executionId` on edges, and accept `limit` on the closing edge; `removeEdges` -ignores `limit`. Status text lists executions oldest first with their wait -reasons, holds, and replacements, and marks the edge that closes a cycle. -Completion notices drop `iteration` and replace `loopLimit` with `edgeLimit`, -and `formatLoopLimit` is now `formatEdgeLimit`. The Pi live state replaces +`executionId` on edges, and accept `maxRuns` on nodes. Status text lists +executions oldest first with their wait reasons, holds, and replacements, and +marks the edge that closes a cycle. Completion notices drop `iteration` and +`loopLimit`, and `formatLoopLimit` is removed: a rerun at its node's `maxRuns` +appears in the waiting reminder instead, as a `WaitingItem` of kind `"limited"` +with source `"maxRuns"`. Pi's `OutstandingJob` adds `limitedExecutionIds`. The Pi live state replaces `iterations` with `generations`, and the DSH panel's `PanelJob` drops `loops`, marks the closing edge with `closesCycle`, and gives each execution a `generation`. diff --git a/docs/execution-control.md b/docs/execution-control.md index 18d8eb7..edb9372 100644 --- a/docs/execution-control.md +++ b/docs/execution-control.md @@ -45,6 +45,7 @@ and `supersedes` on its replacement. Staleness is derived, not stored. | `predecessors` | An input has not run yet | | `stale` | An input has a newer instance that has not settled, or will get one because something upstream of it is rerunning | | `hold` | An input, or something upstream of it, is held | +| `limit` | It is a rerun beyond its node's `maxRuns` (see [limits](#limits)) | | `run_paused` | The run is paused, by `pauseRun` or at a run limit | | `slot` | No concurrency slot is free, or the instance it replaces is still stopping | @@ -88,14 +89,14 @@ explicit operation: human input, a hold, a replacement, or cancellation. const graph = { goal: "Refine until the review passes", nodes: [ - { type: "execute", id: "edit", prompt: "Implement the feedback." }, + { type: "execute", id: "edit", prompt: "Implement the feedback.", maxRuns: 3 }, { type: "decision", id: "review", prompt: "Review the result.", choices: ["again", "done"], workspace: "read-only" }, { type: "integrate", id: "apply" }, ], edges: [ { from: "edit", to: "review" }, - { from: "review", to: "edit", choice: "again", limit: 2 }, + { from: "review", to: "edit", choice: "again" }, { from: "review", to: "apply", choice: "done" }, ], }; @@ -117,10 +118,10 @@ everything in the previous round that feeds the decision has settled and been released. Exits wait while the cycle is rerunning and use the final round. Inputs from outside the cycle stay available to every round. -The closing edge's optional `limit` counts how many times it may fire, so -`limit: 2` allows three rounds. Without a limit the decision alone ends the -cycle. Choosing the edge at its limit holds the decision for the graph owner, -or fails the job with `EDGE_LIMIT` under `onLimit: "fail"` (see [limits](#limits)). +Without a limit the decision alone ends the cycle. To bound the rounds, set +`maxRuns` on a node in the cycle: `maxRuns: 3` on `edit` allows three rounds. +The round that would exceed it waits for the graph owner, or fails the job with +`RUN_LIMIT` under `onLimit: "fail"` (see [limits](#limits)). An edge from a new node into a settled lineage that does not close a cycle reruns that lineage once, for example `fix → test` added after `test` finished. @@ -170,10 +171,8 @@ with the next generation. A cancelled instance of a replaced or removed node nev fails the run, even with `requireSuccess`. Edges are identified by `from`, `to`, and `choice`. `addEdges` rejects an edge that -already exists, and `removeEdges` rejects one that does not; `limit` is ignored -there. To change an edge's limit, remove it and add it again in the same update: -its firing count is kept. Removing a node, or an edge, that does not exist is an -error. `promptTemplates` replaces the template map when supplied; pending +already exists, and `removeEdges` rejects one that does not. Removing a node, or +an edge, that does not exist is an error. `promptTemplates` replaces the template map when supplied; pending instances are rendered again, started ones keep the prompt they ran with. `pauseAfter` holds an execution's outgoing scheduling and keeps the job alive, @@ -315,7 +314,7 @@ The run emits `graph_waiting` when it enters that state because the last active execution settled, was held, or suspended: nothing more runs until the graph owner resumes, updates, or cancels it. The event has `settledExecutionId`, the execution that left nothing active, `pausedExecutionIds`, `suspendedExecutionIds`, -the run's `pause` if any, and the current `revision`. It is emitted once per +`limitedExecutionIds` (reruns waiting at their node's `maxRuns`), the run's `pause` if any, and the current `revision`. It is emitted once per transition. An update or resume that starts nothing does not repeat it, and neither does cancelling an execution that was already suspended; the next one follows after some execution runs again. A run that waits before anything ran, @@ -443,7 +442,7 @@ finishes or its owner stops it. Set a limit only to bound a run: | Limit | Scope | Under `onLimit: "pause"` | Under `onLimit: "fail"` | | --- | --- | --- | --- | -| `edge.limit` | Times the edge that closes a cycle fires, so rounds after the first | Holds the decision that chose it, with hold source `"edge_limit"` | `EDGE_LIMIT` | +| `node.maxRuns` | Started executions of one node (lineage), counting cycle rounds and downstream reruns; skipped ones do not count, and an owner's replacement always starts | Keeps the rerun pending with wait reason `"limit"` | `RUN_LIMIT` | | `maxExecutions` | Started and skipped instances, across rounds and updates; pending instances do not count | Stops admission (pause source `"execution_limit"`); started executions finish | `EXECUTION_LIMIT` | | `graphTimeoutMs` | Wall time from the start of the run, including gates and pauses; never reset by an update or resume | Pauses the run and its running executions (`"graph_timeout"`) | `GRAPH_TIMEOUT` | | `maxTokens` | Reported input and output tokens of all executions, checked as each settles, so running executions can exceed it | Pauses the run and its running executions (`"token_limit"`) | `TOKEN_LIMIT` | @@ -451,23 +450,33 @@ finishes or its owner stops it. Set a limit only to bound a run: `onLimit` defaults to `"pause"` in `startBraid`. `braid()` has no handle to resume with, so it always fails at a limit and rejects `onLimit: "pause"`. -At an edge limit, the decision is held like `pauseAfter`: it appears in -`pausedExecutionIds`, and core emits `execution_hold_added`, `limit_reached` -(with `limit: "edge"`, the edge, and the decision's `nodeId`), and -`execution_paused`. To run more rounds, replace the edge with a higher limit, or -none, in an update that resumes the decision; the edge keeps the count of times -it already fired. Releasing the decision without a higher limit ends the cycle -there: the edge does not fire, and the decision's other edges stay inactive. +`maxRuns` limits automatic reruns: the rounds of a cycle and the reruns an +upstream change cascades downstream. When an edge reaches a settled head that has +already run `maxRuns` times, the clone is still created, but it stays pending with +`waitReason: "limit"` instead of starting. Once its inputs are current, core emits +`limit_reached` (with `limit: "maxRuns"`, the value, the `nodeId`, and the +waiting `executionId`) and lists it in `limitedExecutionIds` in the snapshot and in +`graph_waiting`; a run with nothing else active then waits for its owner. A rerun +that would be skipped is not held, and an owner's replacement of a started node +always starts, even past the limit. + +To let the rerun start, raise `maxRuns` with an update that upserts the node: the +pending rerun is edited in place, not replaced, and starts. To end it instead, +release it with `resume` (or `resume` in an update) without raising the limit: the +rerun is removed (`execution_removed`), the node keeps its last result, and nothing +downstream reruns for it. In a cycle this ends the cycle there; the decision chose +the closing edge, so its other edges stay inactive. ```ts const state = run.snapshot(); -const edge = { from: "review", to: "edit", choice: "again" }; +const [rerun] = state.limitedExecutionIds; +// Allow one more round: run.update({ expectedRevision: state.revision, - removeEdges: [edge], - addEdges: [{ ...edge, limit: 4 }], - resume: state.pausedExecutionIds, + upsertNodes: [{ type: "execute", id: "edit", prompt: "Implement the feedback.", maxRuns: 4 }], }); +// Or end the cycle here: +run.resume([rerun], state.revision); ``` At a run limit, core emits `limit_reached` and `graph_paused` with the limit's diff --git a/docs/resource-limits.md b/docs/resource-limits.md index 42510fc..9009926 100644 --- a/docs/resource-limits.md +++ b/docs/resource-limits.md @@ -2,8 +2,8 @@ Braid targets small reasoning graphs. Graph size, prompt/output size, event-log size, and the number of simultaneous graph submissions have no hard cap. Started and -skipped executions can be capped with `maxExecutions`, and the rounds of a cycle with -its closing edge's `limit`; both are unlimited by default. +skipped executions can be capped with `maxExecutions`, and the runs of a node, +including the rounds of a cycle, with its `maxRuns`; both are unlimited by default. The scheduler rescans the graph as work settles. Consult the [benchmark](benchmark.md) for measured local overhead rather than treating the validator's deep-graph tests as a production capacity guarantee. @@ -18,10 +18,11 @@ well as the compact definition. | --- | --- | --- | | Active runtime-managed invocations per graph | 4 | 4 | | Started and skipped executions across rounds/updates | Unlimited (`maxExecutions`) | Unlimited (`maxExecutions`) | +| Started executions of one node, including cycle rounds and reruns | Unlimited (`maxRuns`) | Unlimited (`maxRuns`) | | Node timeout (starts at admission, stops while suspended) | Unlimited | Unlimited | | On node timeout (`onNodeTimeout`) | Pause with `startBraid` and an interruptible runner; fail with `braid()` | Pause; the parent gets a reminder to resume or cancel | | Graph timeout (includes queueing, paused gates, and suspensions) | Unlimited | Unlimited | -| On reaching a limit that is set (`onLimit`) | Pause with `startBraid` (hold the decision at an edge `limit`, or pause the run); fail with `braid()` | Pause; the parent gets a reminder to raise the limit or stop | +| On reaching a limit that is set (`onLimit`) | Pause with `startBraid` (keep a rerun past `maxRuns` pending, or pause the run); fail with `braid()` | Pause; the parent gets a reminder to raise the limit or stop | | Caller cancellation | `AbortSignal`; `cancelExecutions` for single executions | `braid_cancel` / panel `c`; `braid_cancel` with `executionIds` / panel `x` | | Pause running work | `pause` / `pauseRun` | `braid_pause` / panel `p` | | Worker tool loop | Decision continuation / merge Git-tool loop, bounded by deadlines | Unlimited by default; optional `maxToolRounds` and `maxToolCalls` | diff --git a/examples/execution-control.ts b/examples/execution-control.ts index 8574f95..21f3b29 100644 --- a/examples/execution-control.ts +++ b/examples/execution-control.ts @@ -7,11 +7,12 @@ await inTemporaryDirectory(async cwd => { run = startBraid({ goal: "Refine a proposal, then add the next step using the accepted result.", nodes: [ - { type: "execute", id: "draft", prompt: "Refine the proposal." }, + // draft may run twice, so there are at most two rounds. + { type: "execute", id: "draft", prompt: "Refine the proposal.", maxRuns: 2 }, { type: "decision", id: "review", prompt: "Review it.", choices: ["again", "done"], pauseAfter: true }, ], - // The choice edge back to draft closes the cycle; it may fire once, so there are at most two rounds. - edges: [{ from: "draft", to: "review" }, { from: "review", to: "draft", choice: "again", limit: 1 }], + // The choice edge back to draft closes the cycle. + edges: [{ from: "draft", to: "review" }, { from: "review", to: "draft", choice: "again" }], }, { cwd, runner: async request => { diff --git a/integrations/dsh/README.md b/integrations/dsh/README.md index b673842..dcf4346 100644 --- a/integrations/dsh/README.md +++ b/integrations/dsh/README.md @@ -235,18 +235,20 @@ execution, keeping finished ones in history. An edge added into a finished node reruns it once its new input finishes. Include new nodes and their dependencies in the same update: a disconnected root can start immediately. Rejected revisions/patches change nothing. Supplied templates replace the whole map. -`removeEdges` identifies edges by `from`, `to`, and `choice` (`limit` is -ignored); an omitted `choice` is not a wildcard. Finalized jobs cannot be +`removeEdges` identifies edges by `from`, `to`, and `choice`; an omitted +`choice` is not a wildcard. Finalized jobs cannot be reopened. A cycle is closed by a decision's choice edge back to the node where each round -starts, and the decision needs an exit choice. The optional `limit` on that edge -counts how many times it may fire, so `limit: 2` allows three rounds; without it -the decision alone ends the cycle. A decision that chooses the edge at its limit -is held and the parent gets a pause reminder: replace the edge with a higher -limit (`removeEdges` and `addEdges`) in `braid_update` with `resume` to run more -rounds, or release it with `braid_resume` to end the cycle without taking its -exits. Cycles cannot overlap or nest. Prompt +starts, and the decision needs an exit choice. Without a limit the decision alone +ends the cycle; to bound the rounds, set `maxRuns` on a node in the cycle, so +`maxRuns: 3` allows three rounds. `maxRuns` counts every run of that node, +including reruns after an upstream change, but never blocks the parent's own +replacement of it. A rerun past the limit waits instead of starting, and once +nothing else is active the parent gets the waiting reminder: upsert the node with +a higher `maxRuns` in `braid_update` to let the waiting rerun start (it is edited +in place), or release it with `braid_resume` to end the rerun, which in a cycle +ends the cycle without taking its exits. Cycles cannot overlap or nest. Prompt templates use `{{name}}` placeholders and node prompts such as `{ "template": "review", "variables": { "target": "src/runtime.ts" } }`. Variable names must match exactly; values are inserted literally. @@ -328,7 +330,7 @@ they do not survive a DSH restart. `options` accepts `maxConcurrency` (default 4), `maxExecutions`, `maxTokens`, `maxToolRounds`, `maxToolCalls`, `nodeTimeoutMs`, `graphTimeoutMs`, `onNodeTimeout` (`pause` by default, or `fail`), and `onLimit` (`pause` by -default, or `fail`). Every limit is unlimited when omitted. Reaching +default, or `fail`); nodes accept `maxRuns`. Every limit is unlimited when omitted. Reaching `maxExecutions`, `graphTimeoutMs`, or `maxTokens` pauses the job; once nothing is active the parent gets the waiting reminder, and `braid_resume({ jobId, limits })` raises the limit and resumes the job. Limits count rejected tool calls too. Workers diff --git a/integrations/dsh/client/Panel.tsx b/integrations/dsh/client/Panel.tsx index 9d3807c..5908aef 100644 --- a/integrations/dsh/client/Panel.tsx +++ b/integrations/dsh/client/Panel.tsx @@ -78,17 +78,17 @@ export function Graph({ job, selected, select }: { job: PanelJob; selected: stri if (!a || !b) return null; const x = a.x + 77, y = a.y + 72, endX = b.x + 77, endY = b.y; const d = edge.closesCycle ? `M ${a.x} ${a.y + 36} C 6 ${a.y + 36}, 6 ${b.y + 36}, ${b.x} ${b.y + 36}` : `M ${x} ${y} C ${x} ${y + 26}, ${endX} ${endY - 26}, ${endX} ${endY}`; - return {edge.from} → {edge.to}{edge.choice ? ` (${edge.choice})` : ""}{edge.closesCycle ? " · starts another round" : ""}{edge.limit === undefined ? "" : ` · limit ${edge.limit}`} + return {edge.from} → {edge.to}{edge.choice ? ` (${edge.choice})` : ""}{edge.closesCycle ? " · starts another round" : ""} {edge.choice && !edge.closesCycle && {edge.choice.slice(0, 22)}}; })} - {job.nodes.map(node => { const p = layout.positions.get(node.id)!; return ; })}
Solid · dependencyDashed · back to the start of a cycleClick a node to inspect
- {job.edges.filter(edge => edge.closesCycle).map(edge =>

Cycle {edge.from} → {edge.to} · {edge.limit === undefined ? "no round limit" : `at most ${edge.limit} more rounds`}

)} + {job.edges.filter(edge => edge.closesCycle).map(edge =>

Cycle {edge.from} → {edge.to}

)} ; } diff --git a/integrations/dsh/display.ts b/integrations/dsh/display.ts index 738ae95..bc4fc76 100644 --- a/integrations/dsh/display.ts +++ b/integrations/dsh/display.ts @@ -7,16 +7,17 @@ export function renderPanel(job: JobSnapshot, activity: (executionId: string) => const lines = [`Braid ${job.handle} · ${job.status} · revision ${state.revision}`, job.goal, `Paused: ${state.pausedExecutionIds.join(", ") || "none"}`, ...state.suspendedExecutionIds.length ? [`Suspended: ${state.suspendedExecutionIds.join(", ")}`] : [], + ...state.limitedExecutionIds.length ? [`Waiting at maxRuns: ${state.limitedExecutionIds.join(", ")}`] : [], ...state.pause ? [`Job paused by ${state.pause.source}: no new executions start`] : [], ""]; const closing = new Set(closingEdges(state.graph)); for (const node of state.graph.nodes.slice(0, 80)) { const result = state.nodes[node.id]; const progress = result?.executionId ? job.progress[result.executionId] : undefined; - lines.push(`${result?.suspension && result.suspension.state !== "pausing" ? "‖" : result?.status === "running" ? "▶" : "·"} ${node.id} [${node.type}] ${result?.executionId ?? ""} ${result?.suspension?.state ?? result?.status ?? "pending"}${result?.waitReason ? ` (${result.waitReason.replace("_", " ")})` : ""}${result?.holdAfter && result.status === "running" ? " · hold" : ""}` + + lines.push(`${result?.suspension && result.suspension.state !== "pausing" ? "‖" : result?.status === "running" ? "▶" : "·"} ${node.id} [${node.type}${node.maxRuns === undefined ? "" : `, maxRuns ${node.maxRuns}`}] ${result?.executionId ?? ""} ${result?.suspension?.state ?? result?.status ?? "pending"}${result?.waitReason ? ` (${result.waitReason.replace("_", " ")})` : ""}${result?.holdAfter && result.status === "running" ? " · hold" : ""}` + (progress ? ` · context ${progress.contextTokens}${progress.contextWindow ? `/${progress.contextWindow}` : ""} (${progress.contextSource}) · tools ${progress.toolCalls}` : "") + (result?.startedAt ? ` · ${Math.max(0, (result.finishedAt ?? Date.now()) - result.startedAt)} ms` : "")); for (const edge of state.graph.edges.filter(edge => edge.from === node.id)) - lines.push(` └─${edge.choice ? ` ${edge.choice}` : ""}${closing.has(edge) ? ` ↻${edge.limit === undefined ? "" : ` limit ${edge.limit}`}` : ""} → ${edge.to}`); + lines.push(` └─${edge.choice ? ` ${edge.choice}` : ""}${closing.has(edge) ? " ↻" : ""} → ${edge.to}`); const live = result?.status === "running" && result.executionId ? activity(result.executionId) : undefined; if (live) lines.push(` activity: ${formatActivityStatus(live, now)} · ${live.lastActivity.slice(0, 160)}`); if (result?.error) lines.push(` ${result.error.code}: ${result.error.message}`); diff --git a/integrations/dsh/index.ts b/integrations/dsh/index.ts index 49e798f..03cd190 100644 --- a/integrations/dsh/index.ts +++ b/integrations/dsh/index.ts @@ -9,7 +9,7 @@ import type { BraidInput } from "@chrok/braid"; import { assertCoreVersion } from "./core-version.js"; import { BraidJobs, type ControlNotice, type JobOptions, type JobSnapshot } from "./jobs.js"; import { createBraidTools } from "./tools.js"; -import { formatControlNotice, formatJobList, formatEdgeLimit, formatReceipt, formatReplanRequest, formatTimeoutPause, formatWaiting } from "./status-text.js"; +import { formatControlNotice, formatJobList, formatReceipt, formatReplanRequest, formatTimeoutPause, formatWaiting } from "./status-text.js"; import { BRAID_USAGE_GUIDANCE, BRAID_WAIT_REMINDER } from "./schema.js"; import { renderPanel } from "./display.js"; import { BraidPanel } from "./panel.js"; @@ -72,13 +72,13 @@ export function apply(ctx: Context): void { if (existing) return existing; const pending = new Map(); const jobs = new BraidJobs(ctx.llm, completion => { - const { jobId, handle, status, executionId, nodeId, paused, sequence, replanRequested, edgeLimit } = completion; + const { jobId, handle, status, executionId, nodeId, paused, sequence, replanRequested } = completion; const reminderId = JSON.stringify([jobId, executionId ?? "job", sequence ?? 0]); const lookup = JSON.stringify({ jobId: handle, ...(executionId ? { executionId } : {}) }); const summary = `Braid ${handle}${nodeId ? ` / ${nodeId}` : ""}: ${paused ? "paused" : status}`.slice(0, 120); const message = createUserMessage({ source: { kind: "braid", reminderId, form: "notice", summary }, - content: [{ type: "text", text: `[system-reminder] ${summary}.${replanRequested ? " It asked you to replan; braid_status shows its request." : ""} Retrieve results with braid_status(${lookup}) and continue the original task. Failed or cancelled jobs may contain successful partial results. ${edgeLimit && executionId ? formatEdgeLimit(handle, executionId, edgeLimit) : paused ? "Outgoing scheduling was paused; if nothing else is running, nothing more runs until you act. Inspect CURRENT execution.revision and pausedExecutionIds before braid_update with resume or braid_resume. Deadlines continue; finalized jobs cannot resume." : "This reminder does not pause scheduling."} [/system-reminder]` }], + content: [{ type: "text", text: `[system-reminder] ${summary}.${replanRequested ? " It asked you to replan; braid_status shows its request." : ""} Retrieve results with braid_status(${lookup}) and continue the original task. Failed or cancelled jobs may contain successful partial results. ${paused ? "Outgoing scheduling was paused; if nothing else is running, nothing more runs until you act. Inspect CURRENT execution.revision and pausedExecutionIds before braid_update with resume or braid_resume. Deadlines continue; finalized jobs cannot resume." : "This reminder does not pause scheduling."} [/system-reminder]` }], }); pending.set(reminderId, message); send(agent, message); }, notice => { noteControl(agent, notice); }, pause => { diff --git a/integrations/dsh/jobs.ts b/integrations/dsh/jobs.ts index 1975a68..bc72218 100644 --- a/integrations/dsh/jobs.ts +++ b/integrations/dsh/jobs.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { setImmediate as nextTurn } from "node:timers/promises"; -import { ExecutionActivityTracker, formatActivityStatus, startBraid, validateGraph, type BraidInput, type BraidLimits, type ExecutionActivity, type ExecutionActivityPage, type BraidRun, type BraidResult, type BraidSnapshot, type Edge, type GraphUpdate, type ExecutionEvent, type NodeResult, type ResumeOptions } from "@chrok/braid"; +import { ExecutionActivityTracker, formatActivityStatus, startBraid, validateGraph, type BraidInput, type BraidLimits, type ExecutionActivity, type ExecutionActivityPage, type BraidRun, type BraidResult, type BraidSnapshot, type GraphUpdate, type ExecutionEvent, type NodeResult, type ResumeOptions } from "@chrok/braid"; import type { TokenUsage } from "@deepseek-ai/dsh-llm"; import { createDshRunner, sumUsage, validateBudgets, type NodeProgress, type DshLlm } from "./runner.js"; @@ -9,7 +9,7 @@ export interface JobOptions extends BraidLimits { nodeTimeoutMs?: number; /** Default "pause": a node timeout suspends the execution and wakes the parent. */ onNodeTimeout?: "pause" | "fail"; - /** Default "pause": a limit holds the loop's decision or pauses the job, and wakes the parent. */ + /** Default "pause": a rerun at its node's maxRuns waits, or the job pauses at a job limit, and the parent is woken. */ onLimit?: "pause" | "fail"; maxToolRounds?: number; maxToolCalls?: number; @@ -62,9 +62,12 @@ export interface TimeoutPause { sequence: number; } -/** Work a job with nothing active waits on, and who put it there: "agent", "user", "definition", "replan", or "node_timeout". */ +/** + * Work a job with nothing active waits on, and who put it there: "agent", "user", "definition", "replan", + * "node_timeout", or "maxRuns" for a rerun at its node's maxRuns. + */ export interface WaitingItem { - kind: "held" | "suspended" | "job"; + kind: "held" | "suspended" | "limited" | "job"; /** Omitted for the job pause. */ executionId?: string; nodeId?: string; @@ -95,6 +98,7 @@ function waitingOn(state: BraidSnapshot, event: Extract execution("held", id, state.executions[id]?.holdAfter?.source)), ...event.suspendedExecutionIds.map(id => execution("suspended", id, state.executions[id]?.suspension?.source)), + ...event.limitedExecutionIds.map(id => execution("limited", id, "maxRuns")), ...event.pause ? [{ kind: "job" as const, source: event.pause.source }] : [], ]; return items.some(item => item.source !== "user") ? items : undefined; @@ -119,8 +123,6 @@ export interface Completion { nodeId?: string; paused?: boolean; sequence?: number; - /** A decision held because the edge it chose is at its limit. */ - edgeLimit?: Edge; /** The execution asked for a replan; its result is held for the parent. */ replanRequested?: boolean; } @@ -139,12 +141,6 @@ export interface ReplanNotice { } /** One manager per live DSH agent; foreground cancellation never owns these runs. */ -/** The edge at its limit that holds a decision, for its pause reminder. */ -function edgeLimit(state: BraidSnapshot, node: BraidSnapshot["executions"][string]): Pick { - if (node.holdAfter?.source !== "edge_limit") return {}; - const edge = state.graph.edges.find(edge => edge.from === node.id && edge.choice === node.decision && edge.limit !== undefined); - return edge ? { edgeLimit: { ...edge } } : {}; -} export class BraidJobs { private jobs = new Map(); @@ -269,7 +265,7 @@ export class BraidJobs { const paused = event.type === "execution_paused"; if (!paused && (!node.node.notifyOnCompletion || (node.holdAfter && !state?.error))) return; this.remind({ jobId, handle, status: node.status, executionId: node.executionId, nodeId: node.id, paused, sequence: event.sequence, - ...(node.replan ? { replanRequested: true } : {}), ...edgeLimit(state!, node) }); + ...(node.replan ? { replanRequested: true } : {}) }); } /** A timeout pause always wakes the parent, which must resume or cancel it. */ diff --git a/integrations/dsh/panel-types.ts b/integrations/dsh/panel-types.ts index 5724f70..3171387 100644 --- a/integrations/dsh/panel-types.ts +++ b/integrations/dsh/panel-types.ts @@ -43,9 +43,9 @@ export interface PanelJob extends PanelRow { revision: number; phase: string; error?: string; /** Host clock when this frame was built, for elapsed and idle times. */ observedAt: number; - nodes: { id: string; type: string; status: string }[]; + nodes: { id: string; type: string; status: string; maxRuns?: number }[]; /** closesCycle marks the decision's choice edge back to where each round of a cycle starts. */ - edges: { from: string; to: string; choice?: string; limit?: number; closesCycle?: boolean }[]; + edges: { from: string; to: string; choice?: string; closesCycle?: boolean }[]; executions: PanelExecution[]; pausedExecutionIds: string[]; suspendedExecutionIds: string[]; diff --git a/integrations/dsh/panel.ts b/integrations/dsh/panel.ts index 1c4ef10..0f39840 100644 --- a/integrations/dsh/panel.ts +++ b/integrations/dsh/panel.ts @@ -19,7 +19,8 @@ export function panelJob(job: JobSnapshot, activity: (executionId: string) => Ex jobId: job.jobId, handle: job.handle, goal: clip(job.goal), status: job.status, createdAt: job.createdAt, revision: state.revision, phase: state.status, observedAt: Date.now(), ...(job.error || state.error ? { error: clip(job.error ?? state.error!.message) } : {}), - nodes: state.graph.nodes.map(node => ({ id: node.id, type: node.type, status: state.nodes[node.id]?.status ?? "pending" })), + nodes: state.graph.nodes.map(node => ({ id: node.id, type: node.type, status: state.nodes[node.id]?.status ?? "pending", + ...(node.maxRuns === undefined ? {} : { maxRuns: node.maxRuns }) })), edges: state.graph.edges.map(edge => ({ ...edge, ...(closing.has(edge) ? { closesCycle: true } : {}) })), executions: Object.values(state.executions).map(e => ({ id: e.id, executionId: e.executionId, status: e.status, type: e.node.type, revision: e.revision, diff --git a/integrations/dsh/schema.ts b/integrations/dsh/schema.ts index 71973da..17c363d 100644 --- a/integrations/dsh/schema.ts +++ b/integrations/dsh/schema.ts @@ -35,6 +35,7 @@ const commonNodeParameters = { notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Send an execution completion reminder; default false. Does not pause scheduling." })), pauseAfter: Type.Optional(Type.Boolean({ description: "Hold this execution's outgoing dependencies until braid_resume or an atomic braid_update with resume. Sends a pause reminder." })), requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })), + maxRuns: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "How many times this node may run, counting reruns from cycle rounds and from upstream changes; maxRuns 3 on a node in a cycle allows 3 rounds. Usually omit: by default there is no limit. A rerun beyond it waits, and once nothing else runs you get a reminder to raise maxRuns or end the rerun. Your own replacement of the node always runs." })), }; const workspace = Type.Optional(StringEnum(["read-only", "worktree"], { description: "Only for execute/decision; omit for merge/integrate. Defaults to worktree in Git. Both modes use fresh predecessor snapshots; read-only disables writes and shell tools. Outside Git, all workers are read-only.", @@ -54,18 +55,17 @@ export const nodeParameters = Type.Object({ notifyOnCompletion: commonNodeParameters.notifyOnCompletion, pauseAfter: commonNodeParameters.pauseAfter, requireSuccess: commonNodeParameters.requireSuccess, -}, { additionalProperties: false, description: "execute/decision REQUIRE prompt; decision also REQUIRES choices. merge/integrate may omit prompt but MUST omit choices and workspace. Omit unused optional fields. All types accept model, notifyOnCompletion, pauseAfter, and requireSuccess." }); + maxRuns: commonNodeParameters.maxRuns, +}, { additionalProperties: false, description: "execute/decision REQUIRE prompt; decision also REQUIRES choices. merge/integrate may omit prompt but MUST omit choices and workspace. Omit unused optional fields. All types accept model, notifyOnCompletion, pauseAfter, requireSuccess, and maxRuns." }); export const edgeParameters = Type.Object({ from: text("Source node ID."), to: text("Target node ID."), choice: Type.Optional(text("Only for a decision source: one exact declared choice. Omit for an unconditional dependency, including error recovery.")), - limit: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Only on the decision choice edge that closes a cycle: how many times it may fire, each time starting another round (limit 2 allows 3 rounds). Usually omit: by default the decision alone ends the cycle. When the decision chooses this edge at its limit, the job holds the decision and sends you a reminder." })), }, { additionalProperties: false }); export const edgeIdentityParameters = Type.Object({ from: text("Source node ID."), to: text("Target node ID."), choice: Type.Optional(text("The edge's choice, when it has one.")), - limit: Type.Optional(Type.Integer({ description: "Ignored: an edge is identified by from, to, and choice." })), }, { additionalProperties: false }); const limitCount = (description: string) => Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description })); export const limitFields = { @@ -77,14 +77,14 @@ const templates = Type.Record(Type.String(), text(), { description: templateDesc export const braidParameters = Type.Object({ goal: text("Shared goal included in every worker's context."), nodes: Type.Array(nodeParameters, { minItems: 1 }), - edges: Type.Array(edgeParameters, { description: "Dependencies between node IDs; use [] for independent roots. A cycle is a loop: the edge back to the node where each round starts must be a decision's choice edge, and the decision needs an exit choice. Edges enter the cycle only at its start node and leave only from that decision; cycles cannot overlap or nest." }), + edges: Type.Array(edgeParameters, { description: "Dependencies between node IDs; use [] for independent roots. A cycle is a loop: the edge back to the node where each round starts must be a decision's choice edge, and the decision needs an exit choice. Edges enter the cycle only at its start node and leave only from that decision; cycles cannot overlap or nest. To bound the rounds, set maxRuns on a node in the cycle (unlimited by default)." }), promptTemplates: Type.Optional(templates), options: Type.Optional(Type.Object({ maxConcurrency: Type.Optional(Type.Integer({ minimum: 1, description: "Maximum simultaneous node executions; default 4." })), ...limitFields, nodeTimeoutMs: timeout("node"), graphTimeoutMs: timeout("graph"), maxToolRounds: toolBudget("rounds"), maxToolCalls: toolBudget("calls"), onNodeTimeout: Type.Optional(Type.Union([Type.Literal("pause"), Type.Literal("fail")], { description: "What a node timeout does. Usually omit to keep the default, pause: interrupt the current step and suspend the execution with its work, and send you a reminder to resume it with a new time budget or cancel it. fail: the execution fails with NODE_TIMEOUT and cannot continue its conversation; use it only when the user asks for it." })), - onLimit: Type.Optional(Type.Union([Type.Literal("pause"), Type.Literal("fail")], { description: "What reaching an edge limit, maxExecutions, graphTimeoutMs, or maxTokens does. Usually omit to keep the default, pause: the job holds the decision or pauses, and sends you a reminder to raise the limit or stop. fail: the job fails; use it only when the user asks for it." })), + onLimit: Type.Optional(Type.Union([Type.Literal("pause"), Type.Literal("fail")], { description: "What reaching a node's maxRuns, maxExecutions, graphTimeoutMs, or maxTokens does. Usually omit to keep the default, pause: the rerun waits or the job pauses, and you get a reminder to raise the limit or stop. fail: the job fails; use it only when the user asks for it." })), }, { additionalProperties: false })), }, { additionalProperties: false }); @@ -118,9 +118,9 @@ export const BRAID_USAGE_GUIDANCE = [ "Change the graph with braid_update using expectedRevision. Upserting a node that has not started edits it in place. Upserting a node that has started, running or finished, replaces it: a new execution runs with the new definition, a running one is cancelled, and once the replacement finishes, the nodes downstream of it rerun; an unchanged definition simply reruns the node. An edge added into a finished node reruns it once its new input finishes. To review a replacement before its downstream reruns, give it pauseAfter=true. Use resume in the same update to apply changes and release held executions atomically, or braid_resume for no graph changes.", "Use braid_pause to stop running executions temporarily (each stops before its next model request and keeps its conversation and workspace), or the whole job; adjust the graph, then resume. " + "pauseAfter is captured when an execution starts; to review a running execution's result before downstream work starts, use braid_pause({jobId, executionIds, after: true}) instead, and braid_resume({jobId, executionIds, after: true}) removes that hold. If the last running execution finishes while work you paused or held remains, you get a reminder that the job waits for you: resume, update, or cancel it. The user can also pause, hold, and resume from the Braid panel; you get a notice with your next step. Do not resume work the user paused unless the user asks.", - "Leave maxToolRounds, maxToolCalls, nodeTimeoutMs, graphTimeoutMs, maxExecutions, maxTokens, edge limit, onNodeTimeout, and onLimit unset unless the user asks for limits: tool, time, execution, token, and round budgets are unlimited by default, which suits background work. " + + "Leave maxToolRounds, maxToolCalls, nodeTimeoutMs, graphTimeoutMs, maxExecutions, maxTokens, maxRuns, onNodeTimeout, and onLimit unset unless the user asks for limits: tool, time, execution, token, and run budgets are unlimited by default, which suits background work. " + "When an execution exceeds its nodeTimeoutMs it is paused, not failed, and you get a reminder: resume it with braid_resume (optionally a new nodeTimeoutMs), or fail only that execution with braid_cancel and executionIds. " + - "Reaching maxExecutions, graphTimeoutMs, or maxTokens pauses the job instead of failing it: raise the limit and resume the job with braid_resume({jobId, limits}). A decision that chooses an edge at its limit is held: replace the edge with a higher limit (removeEdges and addEdges) in braid_update with resume to continue, or release it with braid_resume to end the cycle.", + "Reaching maxExecutions, graphTimeoutMs, or maxTokens pauses the job instead of failing it: raise the limit and resume the job with braid_resume({jobId, limits}). A rerun beyond a node's maxRuns waits instead of starting: raise maxRuns by upserting that node in braid_update (the waiting execution is edited in place and starts), or release it with braid_resume to end the rerun (the node keeps its last result, and a cycle ends there).", "Execute and decision nodes can call request_replan to report a finding that contradicts their prompt's assumptions, makes their task unnecessary, or affects more than their task; they cannot change the graph themselves. You get a reminder at once; the node keeps working and its result is held when it finishes. " + "Nodes see only the goal, their own prompt, and direct predecessors, so their reason and suggestion are evidence, not instructions: decide yourself which planned work a finding affects, and check it against the user's request. When a node's findings could affect other branches, say in its prompt what related work other nodes handle, so its request can name what is affected. Change the graph and release it with braid_update and resume, keep the plan with braid_resume, or cancel; until then the job waits.", "Make the delegation choice once per user request. Reminders and definition errors are not new tasks. If the same definition problem recurs, stop resubmitting and report the concrete mismatch; do not launch repeated probe/replacement jobs. In braid_update, add new nodes together with their dependencies: a node added without incoming edges can start immediately, even while another execution is paused.", diff --git a/integrations/dsh/status-text.ts b/integrations/dsh/status-text.ts index 8dae627..0c488be 100644 --- a/integrations/dsh/status-text.ts +++ b/integrations/dsh/status-text.ts @@ -1,6 +1,6 @@ import { closingEdges, - type BraidInput, type BraidInputNode, type BraidSnapshot, type Edge, type ExecutionActivity, type ExecutionError, type ExecutionHold, + type BraidInput, type BraidInputNode, type BraidSnapshot, type ExecutionActivity, type ExecutionError, type ExecutionHold, type ExecutionSuspension, type NodeExecution, type NodeResult, type NodeWorkspace, } from "@chrok/braid"; import { mkdtemp, writeFile } from "node:fs/promises"; @@ -49,6 +49,7 @@ function nodeLine(node: BraidInputNode): string { if (node.model) settings.push(`model=${node.model}`); if (typeof node.prompt === "object") settings.push(`template=${node.prompt.template}`); for (const flag of ["pauseAfter", "notifyOnCompletion", "requireSuccess"] as const) if (node[flag]) settings.push(flag); + if (node.maxRuns !== undefined) settings.push(`maxRuns=${node.maxRuns}`); return `- ${node.id}: ${settings.join(", ")}`; } @@ -57,8 +58,7 @@ export function formatGraph(graph: Pick): string const lines = ["Nodes:", ...graph.nodes.map(nodeLine), "Edges:"]; const closing = new Set(closingEdges(graph)); lines.push(...graph.edges.length ? graph.edges.map(edge => { - const labels = [edge.choice && `choice=${edge.choice}`, closing.has(edge) && "closes a cycle", - edge.limit !== undefined && `limit=${edge.limit}`].filter(Boolean); + const labels = [edge.choice && `choice=${edge.choice}`, closing.has(edge) && "closes a cycle"].filter(Boolean); return `- ${edge.from} -> ${edge.to}${labels.length ? ` [${labels.join(", ")}]` : ""}`; }) : ["- none"]); return lines.join("\n"); @@ -76,7 +76,7 @@ function workspaceLine(workspace: NodeWorkspace): string { const waitReasons = { predecessors: "an input has not run yet", stale: "an input is being rerun", hold: "an input is held", - run_paused: "the job is paused", slot: "a free slot", + limit: "its node's maxRuns; raise it or release this rerun to end it", run_paused: "the job is paused", slot: "a free slot", } as const; function executionLine(execution: NodeResult): string { @@ -101,12 +101,13 @@ export function formatJobList(jobs: readonly Pick `- ${job.handle} · ${job.status} · started ${iso(job.createdAt)} · id ${job.jobId}\n Goal: ${job.goal}`)].join("\n"); } -type Control = Pick & Partial>; +type Control = Pick & Partial>; /** Held (pauseAfter) and suspended executions are resumed the same way; a job pause has its own resume. */ function control(execution: Control): string { const parts = [`Graph ${execution.status}`, `revision ${execution.revision}`, `paused executions: ${ids(execution.pausedExecutionIds)}`]; if (execution.suspendedExecutionIds?.length) parts.push(`suspended executions: ${ids(execution.suspendedExecutionIds)}`); + if (execution.limitedExecutionIds?.length) parts.push(`reruns at maxRuns: ${ids(execution.limitedExecutionIds)}`); if (execution.pause) parts.push(`job paused by ${execution.pause.source} at ${iso(execution.pause.pausedAt)}`); const limits = Object.entries(execution.limits ?? {}); if (limits.length) parts.push(`limits: ${limits.map(([key, value]) => `${key} ${value}`).join(", ")}`); @@ -121,8 +122,7 @@ const USER_HOLD_NOTE = "The user set this hold; do not remove it or release the function pendingHold(execution: NodeResult): boolean { return Boolean(execution.holdAfter) && execution.status === "running"; } -const holdSource = (hold: ExecutionHold) => hold.source === "definition" ? "pauseAfter" : hold.source === "replan" ? "its replan request" - : hold.source === "edge_limit" ? "its edge limit" : hold.source; +const holdSource = (hold: ExecutionHold) => hold.source === "definition" ? "pauseAfter" : hold.source === "replan" ? "its replan request" : hold.source; /** A node timeout pause waits for the parent's decision. */ const TIMEOUT_PAUSE_NOTE = "Its node timeout ran out. Resume it with braid_resume (optionally with nodeTimeoutMs; the default is the job's nodeTimeoutMs again), or fail only this execution with braid_cancel and executionIds. Its successors wait until then."; @@ -302,10 +302,10 @@ export function formatTimeoutPause(pause: TimeoutPause): string { /** The job option each limit pause raises, by pause source. */ const jobLimits: Record = { execution_limit: "maxExecutions", graph_timeout: "graphTimeoutMs", token_limit: "maxTokens" }; const waitingSource = (source: string) => - ({ definition: "pauseAfter", replan: "its replan request", node_timeout: "node timeout", agent: "you", edge_limit: "its edge limit", + ({ definition: "pauseAfter", replan: "its replan request", node_timeout: "node timeout", agent: "you", maxRuns: "its node's maxRuns", ...Object.fromEntries(Object.entries(jobLimits).map(([key, limit]) => [key, `its ${limit} limit`])) } as Record)[source] ?? source; const waitingItem = (item: WaitingItem) => item.kind === "job" ? `the job pause (by ${waitingSource(item.source)})` - : `${item.kind === "held" ? "the held result of" : "suspended"} node ${JSON.stringify(item.nodeId)} execution ${item.executionId} (${waitingSource(item.source)})`; + : `${item.kind === "held" ? "the held result of" : item.kind === "limited" ? "the rerun of" : "suspended"} node ${JSON.stringify(item.nodeId)} execution ${item.executionId} (${waitingSource(item.source)})`; /** The waking reminder for a job with nothing active that waits on paused work. */ export function formatWaiting(notice: WaitingNotice): string { @@ -318,6 +318,9 @@ export function formatWaiting(notice: WaitingNotice): string { users.length ? `The user paused ${users.map(waitingItem).join("; ")}: leave those unless the user asks.` : "", `Check it with braid_status({"jobId":"${notice.handle}"}), then release held results or resume executions with`, `braid_resume({"jobId":"${notice.handle}","expectedRevision":${notice.revision},"executionIds":[...]}) or braid_update with resume,`, + notice.waitingOn.some(item => item.kind === "limited") + ? "let a rerun at its node's maxRuns start by upserting that node with a higher maxRuns in braid_update (the rerun is edited in place), or release it to end the rerun: the node keeps its last result, and a cycle ends there," + : "", limit ? `raise its ${limit} and resume the job with braid_resume({"jobId":"${notice.handle}","limits":{"${limit}":}}) (the job does not resume while the limit is still reached),` : job ? `resume the job with braid_resume({"jobId":"${notice.handle}"}),` : "", "fail single executions with braid_cancel and executionIds, or cancel the job with braid_cancel.", @@ -325,17 +328,6 @@ export function formatWaiting(notice: WaitingNotice): string { ].filter(Boolean).join(" "); } -/** Reminder text for a decision held because the edge it chose is at its limit. */ -export function formatEdgeLimit(handle: string, executionId: string, edge: Edge): string { - const identity = JSON.stringify({ from: edge.from, to: edge.to, ...(edge.choice !== undefined ? { choice: edge.choice } : {}) }); - return [ - `It chose ${JSON.stringify(edge.choice)}, but the edge back to ${JSON.stringify(edge.to)} already fired its limit of ${edge.limit} times, so the job holds it instead of starting another round.`, - `To run more rounds, call braid_update with removeEdges [${identity}], addEdges [the same edge with a higher limit, or without limit], and resume ["${executionId}"] in the same update.`, - `To stop, release it with braid_resume({"jobId":"${handle}","expectedRevision":,"executionIds":["${executionId}"]}): the cycle ends and its other edges stay inactive.`, - "Or cancel the job with braid_cancel. Nothing in this cycle runs until you act.", - ].join(" "); -} - /** The waking reminder for a running execution that asked the parent to replan. */ export function formatReplanRequest(notice: ReplanNotice): string { const execution = notice.executionId; diff --git a/integrations/dsh/test/limits.test.ts b/integrations/dsh/test/limits.test.ts index 7ad7e48..ae72a70 100644 --- a/integrations/dsh/test/limits.test.ts +++ b/integrations/dsh/test/limits.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { BraidJobs, type Completion, type WaitingNotice } from "../jobs.js"; +import { BraidJobs, type WaitingNotice } from "../jobs.js"; import { createBraidTools, type ToolValue } from "../tools.js"; -import { formatEdgeLimit, formatWaiting } from "../status-text.js"; +import { formatWaiting } from "../status-text.js"; import { answer, calls, context, llm, payload, until } from "./helpers.js"; const node = (id: string) => ({ type: "execute" as const, id, prompt: `task:${id}` }); @@ -33,7 +33,7 @@ test("a job paused at maxExecutions wakes the parent and resumes once braid_resu await jobs.dispose(); }); -test("a loop decision held at maxIterations sends a reminder that says how to continue or stop", async () => { +test("a rerun at its node's maxRuns wakes the parent with how to continue or stop", async () => { const rounds = new Set(); const runtime = llm(options => { const data = payload(options); @@ -41,20 +41,20 @@ test("a loop decision held at maxIterations sends a reminder that says how to co rounds.add(data.execution.executionId); return calls({ name: "decide", args: { choice: "again" } }); }); - const completions: Completion[] = []; - const jobs = new BraidJobs(runtime, completion => { completions.push(completion); }); + const waiting: WaitingNotice[] = []; + const jobs = new BraidJobs(runtime, undefined, undefined, undefined, undefined, notice => { waiting.push(notice); }); const job = jobs.start({ - goal: "refine", nodes: [node("work"), { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }], - edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again", limit: 1 }], + goal: "refine", nodes: [{ ...node("work"), maxRuns: 1 }, { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }], + edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again" }], }, {}, context()); - await until(() => completions.length === 1); - const [held] = completions; - assert.equal(held!.paused, true); - assert.equal(held!.executionId, "check@2"); - assert.deepEqual(held!.edgeLimit, { from: "check", to: "work", choice: "again", limit: 1 }); - assert.match(formatEdgeLimit(job.handle, held!.executionId!, held!.edgeLimit!), /already fired its limit of 1 times.*the cycle ends/); - jobs.resume(job.handle, [held!.executionId!], 0); + await until(() => waiting.length === 1); + assert.deepEqual(waiting[0]!.waitingOn, [{ kind: "limited", executionId: "work@2", nodeId: "work", source: "maxRuns" }]); + assert.match(formatWaiting(waiting[0]!), + /It waits on the rerun of node "work" execution work@2 \(its node's maxRuns\)\..*higher maxRuns in braid_update.*release it to end the rerun/); + jobs.resume(job.handle, ["work@2"], 0); await jobs.wait(job.handle); - assert.equal(jobs.get(job.handle).status, "completed"); + const state = jobs.get(job.handle); + assert.equal(state.status, "completed"); + assert.equal(state.execution.nodes.work!.executionId, "work@1"); await jobs.dispose(); }); diff --git a/integrations/dsh/test/runner.test.ts b/integrations/dsh/test/runner.test.ts index c854478..109d56f 100644 --- a/integrations/dsh/test/runner.test.ts +++ b/integrations/dsh/test/runner.test.ts @@ -52,9 +52,9 @@ test("cycles use fresh invocations and prompt templates", async () => { const cwd = directory(); const rounds = new Map(); const graph: BraidInput = { goal: "loop", promptTemplates: { task: "Work on {{target}}" }, nodes: [ - { type: "execute", id: "work", prompt: { template: "task", variables: { target: "loop" } } }, + { type: "execute", id: "work", prompt: { template: "task", variables: { target: "loop" } }, maxRuns: 2 }, { type: "decision", id: "check", prompt: "check", choices: ["retry", "done"] }, - ], edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "retry", limit: 1 }] }; + ], edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "retry" }] }; const result = await braid(graph, { cwd, defaultModel: "fake/model", runner: createDshRunner(llm(options => { const p = payload(options); if (p.nodeId === "work") { assert.equal(p.prompt, "Work on loop"); return answer(); } diff --git a/integrations/dsh/tools.ts b/integrations/dsh/tools.ts index ba3fcd9..411fcc6 100644 --- a/integrations/dsh/tools.ts +++ b/integrations/dsh/tools.ts @@ -102,7 +102,7 @@ export function createBraidTools( const cancelled = jobs.cancel(args.jobId); return { text: cancelled ? "Cancellation requested." : "Job has already finished.", jobId: job.handle, canonicalJobId: job.jobId, cancelled }; }), - tool("braid_update", "Atomically change the graph and optionally resume paused executions at expectedRevision. upsertNodes takes complete definitions: a node that has not started is edited in place; a node that has started, running or finished, is replaced by a new execution (a running one is cancelled) and its downstream nodes rerun once the replacement finishes, so upsert only the nodes you mean to change or rerun. removeNodeIds removes nodes with their edges and cancels a running execution; finished executions stay in history. An edge added into a finished node reruns it once its new input finishes. Include new nodes and all dependencies in the SAME update: disconnected roots may start immediately. removeEdges identifies edges by from, to, and choice; to change a limit, remove the edge and add it again. Rejected patches have no effects; retry the complete corrected patch. Finalized jobs cannot be reopened.", Type.Object({ + tool("braid_update", "Atomically change the graph and optionally resume paused executions at expectedRevision. upsertNodes takes complete definitions: a node that has not started is edited in place; a node that has started, running or finished, is replaced by a new execution (a running one is cancelled) and its downstream nodes rerun once the replacement finishes, so upsert only the nodes you mean to change or rerun. removeNodeIds removes nodes with their edges and cancels a running execution; finished executions stay in history. An edge added into a finished node reruns it once its new input finishes. Include new nodes and all dependencies in the SAME update: disconnected roots may start immediately. removeEdges identifies edges by from, to, and choice. To let a rerun waiting at its node's maxRuns start, upsert that node with a higher maxRuns: the waiting execution is edited in place. Rejected patches have no effects; retry the complete corrected patch. Finalized jobs cannot be reopened.", Type.Object({ jobId: id, expectedRevision: revision, upsertNodes: Type.Optional(Type.Array(nodeParameters)), removeNodeIds: Type.Optional(Type.Array(id)), addEdges: Type.Optional(Type.Array(edgeParameters)), removeEdges: Type.Optional(Type.Array(edgeIdentityParameters)), @@ -116,7 +116,7 @@ export function createBraidTools( catch (error) { throw new Error(`Braid update rejected; no changes or resumes applied. ${error instanceof Error ? error.message : String(error)}. Retry the complete corrected patch with dependencies and resume IDs.`); } return { text: formatUpdate(job), jobId: job.handle, canonicalJobId: job.jobId, revision: job.execution.revision, pausedExecutionIds: job.execution.pausedExecutionIds }; }), - tool("braid_resume", "Release paused (pauseAfter) executions, or resume suspended ones in the same conversation, by execution ID at expectedRevision. Omit executionIds to resume a job paused with braid_pause or at a job limit; executions paused separately stay suspended. A job paused at a limit resumes only once limits raises that limit. This does not reset tool budgets or the graph deadline. An execution paused by its node timeout gets the job's nodeTimeoutMs again, or nodeTimeoutMs if given; resuming the job does not resume it. With after=true it instead removes the hold on still-running executions. Do not resume work the user paused or held unless the user asks.", Type.Object({ + tool("braid_resume", "Release paused (pauseAfter) executions, or resume suspended ones in the same conversation, by execution ID at expectedRevision. Releasing a rerun that waits at its node's maxRuns ends it: it is removed and the node keeps its last result. Omit executionIds to resume a job paused with braid_pause or at a job limit; executions paused separately stay suspended. A job paused at a limit resumes only once limits raises that limit. This does not reset tool budgets or the graph deadline. An execution paused by its node timeout gets the job's nodeTimeoutMs again, or nodeTimeoutMs if given; resuming the job does not resume it. With after=true it instead removes the hold on still-running executions. Do not resume work the user paused or held unless the user asks.", Type.Object({ jobId: id, expectedRevision: Type.Optional(revision), executionIds: Type.Optional(Type.Array(id, { minItems: 1, uniqueItems: true })), after: Type.Optional(Type.Boolean({ description: "true: remove the hold that braid_pause after=true or pauseAfter put on these still-running executions, so their results go downstream when they finish. Requires executionIds; expectedRevision is not needed. A result that is already held is released without after." })), nodeTimeoutMs: Type.Optional(nodeTimeoutMs), diff --git a/integrations/pi/README.md b/integrations/pi/README.md index 906e2bc..1db3de5 100644 --- a/integrations/pi/README.md +++ b/integrations/pi/README.md @@ -186,13 +186,15 @@ for independent roots. A retry cycle is closed by a decision's choice edge back to the node where each round starts, such as `{ "from": "check", "to": "work", "choice": "retry" }`. The -decision needs another choice to exit. The optional `limit` on that edge counts -how many times it may fire, so `limit: 2` allows three rounds; omit it unless -the user asks for a limit. When the decision chooses the edge at its limit, the -job holds that decision and sends the parent a pause reminder: replace the edge -with a higher limit (`removeEdges` and `addEdges`) in `braid_update` with -`resume` to run more rounds, or release it with `braid_resume` to end the cycle -without taking its exits. Edges enter a cycle only at its start node and leave +decision needs another choice to exit. To bound the rounds, set `maxRuns` on a +node in the cycle, so `maxRuns: 3` on `work` allows three rounds; omit it unless +the user asks for a limit. `maxRuns` counts every run of that node, including +reruns after an upstream change, but never blocks your own replacement of it. A +rerun past the limit waits instead of starting, and once nothing else is active +the parent gets the waiting reminder: upsert the node with a higher `maxRuns` in +`braid_update` to let the waiting rerun start (it is edited in place), or release +it with `braid_resume` to end the rerun, which in a cycle ends the cycle without +taking its exits. Edges enter a cycle only at its start node and leave only from its decision; cycles cannot nest or overlap. Each round runs new executions, named `@`. See the [cycle example](../../docs/execution-control.md#cycles). @@ -300,14 +302,14 @@ the event log still records where pauses occurred. `promptTemplates` replaces the entire map when supplied; omitting it preserves the current templates. `removeNodeIds` removes nodes with their edges and cancels a running execution; finished executions stay in `braid_status`. `removeEdges` -identifies edges by `from`, `to`, and `choice` (`limit` is ignored); an omitted -`choice` is not a wildcard, and removing a node or edge that does not exist is an -error. `resume` and `braid_resume.executionIds` take paused execution -IDs, not node IDs. +identifies edges by `from`, `to`, and `choice`; an omitted `choice` is not a +wildcard, and removing a node or edge that does not exist is an error. `resume` +and `braid_resume.executionIds` take paused, suspended, or limited execution IDs, +not node IDs. `requireSuccess` defaults to false. Optional failures retain artifacts and allow unconditional recovery; required failures cancel siblings and fail the job after -cleanup. Edge `limit`, total `maxExecutions`, `maxTokens`, and the +cleanup. A node's `maxRuns`, total `maxExecutions`, `maxTokens`, and the timeouts are unlimited unless set; `maxExecutions` spans updates. Deadlines keep running through pauses. The graph timeout includes time waiting for the parent to inspect and update a paused graph, and resuming does not extend it. @@ -317,7 +319,8 @@ of failing it (`options.onLimit: "fail"` fails it). `maxExecutions` only stops n executions; the other two also pause running ones. Once nothing is active, the parent gets the waiting reminder, and `braid_resume({ jobId, limits })` raises the limit and resumes the job. The job does not resume while the limit is still -reached. `/braid:review` keeps failing at its round limit. +reached. `/braid:review` bounds its rounds with `maxRuns` on its writer node and +keeps failing at that limit. See [execution control](../../docs/execution-control.md) for cycle schemas, exact update and rerun semantics, and workspace lineage. There is no @@ -741,7 +744,7 @@ pass any of these fields in the `braid` tool's `options`: | `graphTimeoutMs` | Time allowed for the entire graph, including queueing and pauses, in milliseconds | | `maxExecutions` | Total started and skipped executions; unlimited by default, positive safe integer | | `maxTokens` | Total input and output tokens reported by all executions, checked as each finishes; unlimited by default | -| `onLimit` | `pause` (default) holds a decision that chose an edge at its `limit`, or pauses the job at the other limits, and wakes the parent; `fail` fails the job | +| `onLimit` | `pause` (default) keeps a rerun past its node's `maxRuns` waiting, or pauses the job at the other limits, and wakes the parent; `fail` fails the job | For example, `options: { maxToolRounds: 20, maxToolCalls: 60, nodeTimeoutMs: 120000 }`. Omit tool, time, execution, and token fields for no limit; programmatic options also accept `Infinity`. diff --git a/integrations/pi/display.ts b/integrations/pi/display.ts index b744d88..52ce731 100644 --- a/integrations/pi/display.ts +++ b/integrations/pi/display.ts @@ -181,7 +181,6 @@ function graphParts(result: BraidResult | BraidLiveState): { from: event.from, to: event.to, ...(event.choice ? { choice: event.choice } : {}), - ...(event.limit !== undefined ? { limit: event.limit } : {}), }); } } @@ -567,9 +566,9 @@ function eventText(event: ExecutionEvent): string { case "replan_requested": return `replan requested · ${compact(event.nodeId, 40)}: ${compact(event.reason, 64)}`; case "graph_paused": return `job paused · by ${compact(event.source, 20)}`; case "graph_resumed": return "job resumed"; - case "limit_reached": return `limit reached · ${event.edge ? `edge ${compact(event.edge.from, 30)} → ${compact(event.edge.to, 30)}` : event.limit} ${event.value}${event.nodeId ? ` · holds ${compact(event.nodeId, 40)}` : ""}`; + case "limit_reached": return `limit reached · ${event.limit} ${event.value}${event.executionId ? ` · ${compact(event.executionId, 40)} waits` : ""}`; case "limits_updated": return `limits updated · ${Object.entries(event.limits).map(([key, value]) => `${key} ${value}`).join(", ") || "none"}`; - case "graph_waiting": return `nothing active · ${event.pausedExecutionIds.length} held · ${event.suspendedExecutionIds.length} suspended${event.pause ? " · job paused" : ""}`; + case "graph_waiting": return `nothing active · ${event.pausedExecutionIds.length} held · ${event.suspendedExecutionIds.length} suspended${event.limitedExecutionIds.length ? ` · ${event.limitedExecutionIds.length} at maxRuns` : ""}${event.pause ? " · job paused" : ""}`; case "graph_created": return `graph created · ${event.nodeCount} nodes · ${event.edgeCount} edges`; case "execution_created": @@ -651,6 +650,9 @@ export function createLiveState(): BraidLiveState { }; } +/** The head each pending rerun replaced, by the rerun's execution ID. */ +const clonedHeads = new WeakMap>(); + export function applyEvent(state: BraidLiveState, event: ExecutionEvent): void { // Keep per-update payloads bounded, without discarding any events from the core result. const preview = { ...event }; @@ -674,7 +676,6 @@ export function applyEvent(state: BraidLiveState, event: ExecutionEvent): void { from: event.from, to: event.to, ...(event.choice ? { choice: event.choice } : {}), - ...(event.limit !== undefined ? { limit: event.limit } : {}), }); if (state.events.length > MAX_VISIBLE_EVENTS) state.events.shift(); if (event.type === "graph_completed" || event.type === "graph_failed") { @@ -686,6 +687,11 @@ export function applyEvent(state: BraidLiveState, event: ExecutionEvent): void { return; } if (event.type === "execution_created") { + // Remember the head a rerun replaces, in case the rerun ends at its maxRuns without starting. + if (event.cause === "cloned" && event.executionId && Object.hasOwn(state.nodes, event.nodeId)) { + const heads = clonedHeads.get(state) ?? clonedHeads.set(state, new Map()).get(state)!; + heads.set(event.executionId, state.nodes[event.nodeId]!); + } // The new instance becomes the node's head. Object.defineProperty(state.nodes, event.nodeId, { value: { @@ -703,6 +709,17 @@ export function applyEvent(state: BraidLiveState, event: ExecutionEvent): void { state.generations[event.nodeId] = event.generation; return; } + if ((event.type === "node_started" || event.type === "node_skipped") && event.executionId) clonedHeads.get(state)?.delete(event.executionId); + if (event.type === "execution_removed") { + // A rerun released at its maxRuns: the head it replaced is current again. + const previous = event.executionId ? clonedHeads.get(state)?.get(event.executionId) : undefined; + if (previous && Object.hasOwn(state.nodes, event.nodeId) && state.nodes[event.nodeId]!.executionId === event.executionId) { + clonedHeads.get(state)!.delete(event.executionId!); + state.nodes[event.nodeId] = previous; + if (previous.executionId) state.generations![event.nodeId] = Number(previous.executionId.split("@").at(-1)); + } + return; + } if (event.type === "graph_updated") { state.revision = event.revision ?? state.revision ?? 0; state.edges = structuredClone([...event.graph.edges]); diff --git a/integrations/pi/drain.ts b/integrations/pi/drain.ts index 4aad50f..57a2c28 100644 --- a/integrations/pi/drain.ts +++ b/integrations/pi/drain.ts @@ -13,7 +13,7 @@ export const isSingleShot = (ctx: Pick) => ctx.mode == export const BRAID_DRAIN_GUIDANCE = "This is a single-shot run. It stays open until every Braid job finishes and its reminder is delivered, so submit, end your turn, and continue from the reminder as usual. " + - "Paused executions, including results held by a node's replan request, and paused jobs are not resumed automatically: resume or cancel them before ending your turn. If only paused work remains when you end a turn, you get one warning; ending the next turn without resuming it cancels those jobs and the run exits with a failure status."; + "Paused executions, including results held by a node's replan request, reruns waiting at a node's maxRuns, and paused jobs are not resumed automatically: resume or cancel them before ending your turn. If only paused work remains when you end a turn, you get one warning; ending the next turn without resuming it cancels those jobs and the run exits with a failure status."; export const BRAID_FINISH_GUIDANCE = "When the task is done and no Braid job is outstanding, call braid_finish once with status success or failure and a short summary of the outcome, then end your turn with your final response. " + @@ -37,13 +37,14 @@ const waitingOn = (job: OutstandingJob) => [ job.pausedExecutionIds.length ? `paused executions ${JSON.stringify(job.pausedExecutionIds)}` : "", job.suspendedExecutionIds.length ? `suspended executions ${JSON.stringify(job.suspendedExecutionIds)}` + (job.timedOutExecutionIds?.length ? ` (paused by their node timeout: ${JSON.stringify(job.timedOutExecutionIds)})` : "") : "", + job.limitedExecutionIds.length ? `reruns at their node's maxRuns ${JSON.stringify(job.limitedExecutionIds)}` : "", job.paused ? "its job pause" : "", ].filter(Boolean).join(" and ") || "paused work"; const stalledReminder = (jobs: OutstandingJob[]) => `[system-reminder] This single-shot run cannot end while Braid jobs are outstanding, and nothing can progress: ` + jobs.map(job => `${job.handle} is waiting on ${waitingOn(job)} at revision ${job.revision}`).join("; ") + - `. Resume executions with braid_resume or braid_update with resume, resume a paused job with braid_resume({jobId}), fail single executions with braid_cancel and executionIds, or cancel the job with braid_cancel, then continue the original task. ` + + `. Resume executions with braid_resume or braid_update with resume, raise a node's maxRuns with braid_update, resume a paused job with braid_resume({jobId}), fail single executions with braid_cancel and executionIds, or cancel the job with braid_cancel, then continue the original task. ` + `If you end your turn again without doing so, Braid cancels these jobs and the run exits with a failure status. [/system-reminder]`; /** diff --git a/integrations/pi/index.ts b/integrations/pi/index.ts index f41af64..7588bdb 100644 --- a/integrations/pi/index.ts +++ b/integrations/pi/index.ts @@ -16,7 +16,7 @@ import { registerBraidCommand, registerBraidWidget } from "./command.js"; import { registerReviewCommand } from "./review.js"; import { BRAID_DRAIN_GUIDANCE, BRAID_FINISH_GUIDANCE, isSingleShot, registerDrain } from "./drain.js"; import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js"; -import { formatCancelExecutions, formatHold, formatJobList, formatEdgeLimit, formatJobStatus, formatNodeStatus, formatPause, formatReceipt, formatReplanRequest, formatResume, formatTimeoutPause, formatUpdate, formatWaiting } from "./status-text.js"; +import { formatCancelExecutions, formatHold, formatJobList, formatJobStatus, formatNodeStatus, formatPause, formatReceipt, formatReplanRequest, formatResume, formatTimeoutPause, formatUpdate, formatWaiting } from "./status-text.js"; const text = (description?: string) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) }); const prompt = () => Type.Union([ @@ -63,6 +63,7 @@ const commonNodeParameters = { notifyOnCompletion: Type.Optional(Type.Boolean({ description: "Send an execution completion reminder; default false. Does not pause scheduling." })), pauseAfter: Type.Optional(Type.Boolean({ description: "Hold this execution's outgoing dependencies until braid_resume or an atomic braid_update with resume. Sends a pause reminder." })), requireSuccess: Type.Optional(Type.Boolean({ description: "If true, failure aborts the entire job and cancels running siblings. Default false: unconditional successors can recover." })), + maxRuns: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "How many times this node may run, counting reruns from cycle rounds and from upstream changes; maxRuns 3 on a node in a cycle allows 3 rounds. Usually omit: by default there is no limit. A rerun beyond it waits, and once nothing else runs you get a reminder to raise maxRuns or end the rerun. Your own replacement of the node always runs." })), }; const workspace = Type.Optional(StringEnum(["read-only", "worktree"], { description: "Only for execute/decision; omit for merge/integrate. Defaults to worktree in Git. Both modes use fresh predecessor snapshots; read-only disables writes and shell tools. Outside Git, all workers are read-only.", @@ -82,18 +83,17 @@ const nodeParameters = Type.Object({ notifyOnCompletion: commonNodeParameters.notifyOnCompletion, pauseAfter: commonNodeParameters.pauseAfter, requireSuccess: commonNodeParameters.requireSuccess, -}, { additionalProperties: false, description: "execute/decision REQUIRE prompt; decision also REQUIRES choices. merge/integrate may omit prompt but MUST omit choices and workspace. Omit unused optional fields. All types accept model, notifyOnCompletion, pauseAfter, and requireSuccess." }); + maxRuns: commonNodeParameters.maxRuns, +}, { additionalProperties: false, description: "execute/decision REQUIRE prompt; decision also REQUIRES choices. merge/integrate may omit prompt but MUST omit choices and workspace. Omit unused optional fields. All types accept model, notifyOnCompletion, pauseAfter, requireSuccess, and maxRuns." }); const edgeParameters = Type.Object({ from: text("Source node ID."), to: text("Target node ID."), choice: Type.Optional(text("Only for a decision source: one exact declared choice. Omit for an unconditional dependency, including error recovery.")), - limit: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER, description: "Only on the decision choice edge that closes a cycle: how many times it may fire, each time starting another round (limit 2 allows 3 rounds). Usually omit: by default the decision alone ends the cycle. When the decision chooses this edge at its limit, the job holds the decision and sends you a reminder." })), }, { additionalProperties: false }); const edgeIdentityParameters = Type.Object({ from: text("Source node ID."), to: text("Target node ID."), choice: Type.Optional(text("The edge's choice, when it has one.")), - limit: Type.Optional(Type.Integer({ description: "Ignored: an edge is identified by from, to, and choice." })), }, { additionalProperties: false }); const templateDescription = "Named prompt strings. Placeholders use {{name}} with names matching [A-Za-z_][A-Za-z0-9_]*. Node variables must match exactly; string values are inserted literally."; const templates = Type.Record(Type.String(), text(), { description: templateDescription }); @@ -107,7 +107,7 @@ const braidParameters = Type.Object({ ...limitFields, nodeTimeoutMs: timeout("node"), graphTimeoutMs: timeout("graph"), maxToolRounds: toolBudget("rounds"), maxToolCalls: toolBudget("calls"), onNodeTimeout: Type.Optional(StringEnum(["pause", "fail"], { description: "What a node timeout does. Usually omit to keep the default, pause: interrupt the current step and suspend the execution with its work, and send you a reminder to resume it with a new time budget or cancel it. fail: the execution fails with NODE_TIMEOUT and cannot continue its conversation; use it only when the user asks for it." })), - onLimit: Type.Optional(StringEnum(["pause", "fail"], { description: "What reaching an edge limit, maxExecutions, graphTimeoutMs, or maxTokens does. Usually omit to keep the default, pause: the job holds the decision or pauses, and sends you a reminder to raise the limit or stop. fail: the job fails; use it only when the user asks for it." })), + onLimit: Type.Optional(StringEnum(["pause", "fail"], { description: "What reaching a node's maxRuns, maxExecutions, graphTimeoutMs, or maxTokens does. Usually omit to keep the default, pause: the rerun waits or the job pauses, and you get a reminder to raise the limit or stop. fail: the job fails; use it only when the user asks for it." })), maxModelRetries: Type.Optional(Type.Integer({ minimum: 0, maximum: 10, description: "Retries per model request for transient provider errors such as rate limits, overload, and network failures. The node continues where it was; completed tool calls are not repeated. Default 3; 0 disables." })), }, { additionalProperties: false })), }, { additionalProperties: false }); @@ -131,7 +131,7 @@ export const BRAID_ASYNC_GUIDANCE = "pauseAfter is captured when an execution starts; to review a running execution's result before downstream work starts, use braid_pause({jobId, executionIds, after: true}) instead, and braid_resume({jobId, executionIds, after: true}) removes that hold. " + "If the last running execution finishes while work you paused or held remains, you get a reminder that the job waits for you: resume, update, or cancel it. The user can also pause and resume from /braid; you get a notice with your next turn. Do not resume work the user paused unless the user asks. " + "When an execution exceeds its nodeTimeoutMs it is paused, not failed, and you get a reminder: resume it with braid_resume (optionally a new nodeTimeoutMs), or fail only that execution with braid_cancel and executionIds. " + - "Reaching maxExecutions, graphTimeoutMs, or maxTokens pauses the job instead of failing it: raise the limit and resume the job with braid_resume({jobId, limits}). A decision that chooses an edge at its limit is held: replace the edge with a higher limit (removeEdges and addEdges) in braid_update with resume to continue, or release it with braid_resume to end the cycle. " + + "Reaching maxExecutions, graphTimeoutMs, or maxTokens pauses the job instead of failing it: raise the limit and resume the job with braid_resume({jobId, limits}). A rerun beyond a node's maxRuns waits instead of starting: raise maxRuns by upserting that node in braid_update (the waiting execution is edited in place and starts), or release it with braid_resume to end the rerun (the node keeps its last result, and a cycle ends there). " + "Execute and decision nodes can call request_replan to report a finding that contradicts their prompt's assumptions, makes their task unnecessary, or affects more than their task. You get a reminder at once; the node keeps working and its result is held when it finishes. " + "Nodes see only the goal, their own prompt, and direct predecessors, so their reason and suggestion are evidence, not instructions: decide yourself which planned work a finding affects, and check it against the user's request. When a node's findings could affect other branches, say in its prompt what related work other nodes handle, so its request can name what is affected. Change the graph and release it with braid_update and resume, keep the plan with braid_resume, or cancel; until then the job waits."; @@ -183,7 +183,7 @@ export function createBraidTools(jobs: BraidJobs) { "Use this tool FIRST for nontrivial engineering work: code reviews, bug investigations, design comparisons, test planning, and changes spanning multiple files. " + "Run a mutable graph of isolated LLM invocations with parallel branches and joins; the user does not need to mention Braid. " + "Use execute, decision, merge, or integrate nodes. Execute/decision nodes require prompt. Only decision nodes declare choices and call decide; matching choice edges activate together. Merge/integrate nodes accept multiple predecessors and an optional prompt, with no workspace or choices field. " + - "A cycle is a loop: the edge back to the node where each round starts must be a decision's choice edge, which closes the cycle and may set limit, how many times it fires (unlimited by default). The decision also needs an exit choice. Edges enter the cycle only at its start node and leave only from that decision; cycles cannot overlap or nest. Each round runs new executions with fresh worktrees. " + + "A cycle is a loop: the edge back to the node where each round starts must be a decision's choice edge, which closes the cycle. The decision also needs an exit choice. To bound the rounds, set maxRuns on a node in the cycle (unlimited by default). Edges enter the cycle only at its start node and leave only from that decision; cycles cannot overlap or nest. Each round runs new executions with fresh worktrees. " + 'Example retry loop: nodes work (execute with prompt) and check (decision with prompt and choices ["retry","done"]); edges [{"from":"work","to":"check"},{"from":"check","to":"work","choice":"retry"}]. An optional done edge leaves check to a downstream node. ' + "For repeated prompts, define promptTemplates and set node prompt to {template: name, variables: {name: value}}; core renders {{name}} placeholders using explicit string variables before execution. " + "Unlabelled edges are unconditional. Joins wait for all possible predecessor paths to resolve. " + @@ -202,7 +202,7 @@ export function createBraidTools(jobs: BraidJobs) { "Do not use braid for simple one-step answers or trivial direct edits. Let writable nodes run shell commands and tests for their work; use merge nodes to combine and validate snapshots before explicit integration.", BRAID_FILESYSTEM_GUIDANCE, "Decision nodes additionally receive decide. Execute and decision nodes can call request_replan to ask you to change the plan; they cannot change the graph themselves. Nodes cannot call recursive Braid.", - "Leave maxToolRounds, maxToolCalls, nodeTimeoutMs, graphTimeoutMs, maxExecutions, maxTokens, edge limit, onNodeTimeout, and onLimit unset unless the user asks for limits: tool, time, execution, token, and round budgets are unlimited by default, which suits background work. When limits are set, nodes receive system reminders of their remaining tool and time budgets before each model call, a node timeout pauses the execution, and the other limits pause the job or hold the cycle's decision; each sends you a reminder to raise the limit or stop.", + "Leave maxToolRounds, maxToolCalls, nodeTimeoutMs, graphTimeoutMs, maxExecutions, maxTokens, maxRuns, onNodeTimeout, and onLimit unset unless the user asks for limits: tool, time, execution, token, and run budgets are unlimited by default, which suits background work. When limits are set, nodes receive system reminders of their remaining tool and time budgets before each model call, a node timeout pauses the execution, maxRuns keeps a rerun waiting, and the other limits pause the job; each sends you a reminder to raise the limit or stop.", ], parameters: braidParameters, renderCall(args, theme) { @@ -387,9 +387,9 @@ export function createBraidTools(jobs: BraidJobs) { upsertNodes: Type.Optional(Type.Array(nodeParameters, { description: "Complete node definitions, not partial patches. A new ID adds a node. A node that has not started is edited in place. A node that has started, running or finished, is replaced by a new execution: a running one is cancelled, and its downstream nodes rerun after the replacement finishes. Add dependencies in this SAME update: new roots may start immediately even if another execution is paused." })), removeNodeIds: Type.Optional(Type.Array(text(), { description: "Existing node IDs to remove, together with all incident edges. A running execution of a removed node is cancelled; finished executions stay in braid_status history." })), addEdges: Type.Optional(Type.Array(edgeParameters, { description: "Edges to add. An edge into a node that already finished reruns it once its new input finishes." })), - removeEdges: Type.Optional(Type.Array(edgeIdentityParameters, { description: "Existing edges to remove, identified by from, to, and choice when present; limit is ignored. Copy them from the current graph in braid_status. To change an edge's limit, remove it and add it again in the same update." })), + removeEdges: Type.Optional(Type.Array(edgeIdentityParameters, { description: "Existing edges to remove, identified by from, to, and choice when present. Copy them from the current graph in braid_status." })), promptTemplates: Type.Optional(Type.Record(Type.String(), text(), { description: `${templateDescription} Replaces the entire template map; omit to preserve it. Include every template still referenced by nodes.` })), - resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused or suspended execution IDs from braid_status, not node IDs. Releases them atomically with the graph edit." })), + resume: Type.Optional(Type.Array(text(), { uniqueItems: true, description: "Paused, suspended, or limited execution IDs from braid_status, not node IDs. Releases them atomically with the graph edit; releasing a rerun waiting at its node's maxRuns ends it, unless this update raises maxRuns." })), resumeOptions: Type.Optional(Type.Object({ nodeTimeoutMs: resumeTimeout }, { additionalProperties: false, description: "Requires resume. Applies to the suspended executions in resume." })), }, { additionalProperties: false }); const updateTool = defineTool({ @@ -420,7 +420,7 @@ export function createBraidTools(jobs: BraidJobs) { }, { additionalProperties: false }); const resumeTool = defineTool({ name: "braid_resume", label: "Resume Braid", - description: "Release paused (pauseAfter) executions, or resume suspended ones in the same conversation, by execution ID with expectedRevision from braid_status. Omit executionIds to resume a job paused with braid_pause or at a job limit; executions you paused separately stay suspended. A job paused at a limit resumes only once limits raises that limit. Independent branches keep running during a pause; resuming does not reset the graph deadline or execution limits. An execution paused by its node timeout gets the job's nodeTimeoutMs again, or nodeTimeoutMs if given; resuming the job does not resume it. With after=true it instead removes the hold on still-running executions. Do not resume work the user paused or held unless the user asks.", + description: "Release paused (pauseAfter) executions, or resume suspended ones in the same conversation, by execution ID with expectedRevision from braid_status. Releasing a rerun that waits at its node's maxRuns ends it: it is removed and the node keeps its last result. Omit executionIds to resume a job paused with braid_pause or at a job limit; executions you paused separately stay suspended. A job paused at a limit resumes only once limits raises that limit. Independent branches keep running during a pause; resuming does not reset the graph deadline or execution limits. An execution paused by its node timeout gets the job's nodeTimeoutMs again, or nodeTimeoutMs if given; resuming the job does not resume it. With after=true it instead removes the hold on still-running executions. Do not resume work the user paused or held unless the user asks.", parameters: resumeParameters, async execute(_id, params) { if (params.limits && (params.executionIds || params.after)) throw new Error("limits applies to the whole job; omit executionIds and after"); @@ -526,13 +526,13 @@ export default function braidExtension(pi: ExtensionAPI) { pending.set(reminderId, message); remind(message); }, (completion) => { - const { jobId, handle, nodeId, executionId, status, eventSequence, errorCode, paused, replanRequested, edgeLimit } = completion; + const { jobId, handle, nodeId, executionId, status, eventSequence, errorCode, paused, replanRequested } = completion; const reminderId = JSON.stringify([jobId, "execution", executionId, eventSequence]); const lookup = JSON.stringify({ jobId: handle, executionId }); const message = { customType: "braid-node-completed", display: true, - content: `[system-reminder] Braid job ${handle} node ${JSON.stringify(nodeId)} execution ${executionId} finished with status ${status}${errorCode ? ` (${errorCode})` : ""}.${replanRequested ? " It asked you to replan; braid_status shows its request." : ""} Retrieve its output or error with braid_status(${lookup}) and continue the original task. ${edgeLimit ? formatEdgeLimit(handle, executionId, edgeLimit) : paused ? "Outgoing scheduling was paused at completion. Inspect the current status, revision, and pausedExecutionIds; if this execution is still paused, use braid_update with resume or braid_resume to continue. Independent branches may still be running; if none are, nothing more runs until you act. A finalized job cannot be resumed." : "This execution reminder does not pause downstream scheduling; the job may still be running."} [/system-reminder]`, + content: `[system-reminder] Braid job ${handle} node ${JSON.stringify(nodeId)} execution ${executionId} finished with status ${status}${errorCode ? ` (${errorCode})` : ""}.${replanRequested ? " It asked you to replan; braid_status shows its request." : ""} Retrieve its output or error with braid_status(${lookup}) and continue the original task. ${paused ? "Outgoing scheduling was paused at completion. Inspect the current status, revision, and pausedExecutionIds; if this execution is still paused, use braid_update with resume or braid_resume to continue. Independent branches may still be running; if none are, nothing more runs until you act. A finalized job cannot be resumed." : "This execution reminder does not pause downstream scheduling; the job may still be running."} [/system-reminder]`, details: { ...completion, reminderId }, }; pending.set(reminderId, message); diff --git a/integrations/pi/jobs.ts b/integrations/pi/jobs.ts index 9a7ccd4..6c28972 100644 --- a/integrations/pi/jobs.ts +++ b/integrations/pi/jobs.ts @@ -14,7 +14,6 @@ import { validateGraph, type BraidInput, type BraidResult, - type Edge, type ExecutionError, type ExecutionEvent, type NodeResult, @@ -37,7 +36,7 @@ export interface JobOptions extends BraidLimits { nodeTimeoutMs?: number; /** Default "pause": a node timeout suspends the execution and wakes the parent. */ onNodeTimeout?: "pause" | "fail"; - /** Default "pause": a limit holds the loop's decision or pauses the job, and wakes the parent. */ + /** Default "pause": a rerun at its node's maxRuns waits, or the job pauses at a job limit, and the parent is woken. */ onLimit?: "pause" | "fail"; maxToolRounds?: number; maxToolCalls?: number; @@ -88,8 +87,6 @@ export interface NodeCompletion { errorCode?: ExecutionError["code"]; /** The execution asked for a replan; its result is held for the parent. */ replanRequested?: boolean; - /** A decision held because the edge it chose is at its limit. */ - edgeLimit?: Edge; } /** A running execution asked the parent to replan; its result will be held when it finishes. */ @@ -121,9 +118,12 @@ export interface TimeoutPause { eventSequence: number; } -/** Work a job with nothing active waits on, and who put it there: "agent", "user", "definition", "replan", or "node_timeout". */ +/** + * Work a job with nothing active waits on, and who put it there: "agent", "user", "definition", "replan", + * "node_timeout", or "maxRuns" for a rerun at its node's maxRuns. + */ export interface WaitingItem { - kind: "held" | "suspended" | "job"; + kind: "held" | "suspended" | "limited" | "job"; /** Omitted for the job pause. */ executionId?: string; nodeId?: string; @@ -154,6 +154,7 @@ function waitingOn(state: BraidSnapshot, event: Extract execution("held", id, state.executions[id]?.holdAfter?.source)), ...event.suspendedExecutionIds.map(id => execution("suspended", id, state.executions[id]?.suspension?.source)), + ...event.limitedExecutionIds.map(id => execution("limited", id, "maxRuns")), ...event.pause ? [{ kind: "job" as const, source: event.pause.source }] : [], ]; return items.some(item => item.source !== "user") ? items : undefined; @@ -177,19 +178,14 @@ export interface OutstandingJob { revision?: number; pausedExecutionIds: string[]; suspendedExecutionIds: string[]; + /** Reruns waiting at their node's maxRuns. */ + limitedExecutionIds: string[]; /** Suspended executions their node timeout paused; they wait for the parent. */ timedOutExecutionIds?: string[]; /** The whole job is paused and admits no new executions. */ paused?: boolean; } -/** The edge at its limit that holds a decision, for its pause reminder. */ -function edgeLimit(state: BraidSnapshot, instance: BraidSnapshot["executions"][string]): Pick { - if (instance.holdAfter?.source !== "edge_limit") return {}; - const edge = state.graph.edges.find(edge => edge.from === instance.id && edge.choice === instance.decision && edge.limit !== undefined); - return edge ? { edgeLimit: { ...edge } } : {}; -} - function timedOut(state: BraidSnapshot | undefined): Pick { const ids = (state?.suspendedExecutionIds ?? []).filter(id => state!.executions[id]?.suspension?.source === "node_timeout"); return ids.length ? { timedOutExecutionIds: ids } : {}; @@ -431,7 +427,6 @@ export class BraidJobs { eventSequence: event.sequence, ...(instance.error ? { errorCode: instance.error.code } : {}), ...(instance.replan ? { replanRequested: true } : {}), - ...edgeLimit(state!, instance), })).catch(() => {}); } catch { /* Notification failures must not affect scheduling or results. */ @@ -648,6 +643,7 @@ export class BraidJobs { ...(state ? { revision: state.revision } : {}), pausedExecutionIds: state?.pausedExecutionIds ?? [], suspendedExecutionIds: state?.suspendedExecutionIds ?? [], + limitedExecutionIds: state?.limitedExecutionIds ?? [], ...timedOut(state), ...(state?.pause ? { paused: true } : {}), }; diff --git a/integrations/pi/review.ts b/integrations/pi/review.ts index 3f48e05..28ef76f 100644 --- a/integrations/pi/review.ts +++ b/integrations/pi/review.ts @@ -20,7 +20,8 @@ If nothing qualifies, say exactly "No issues found." End with "Merge verdict: BL }, nodes: [ { - type: "execute", id: "scope", workspace: "worktree", requireSuccess: true, + // Each retry reruns scope, so it runs at most once per round. + type: "execute", id: "scope", workspace: "worktree", requireSuccess: true, maxRuns: maxRounds, prompt: `Prepare this review round by inspecting the requested target and relevant repository instructions. You are the only fix writer. On the FIRST round only, resolve the target without editing files. For the default target, compare workspace.baseCommit (the caller's original HEAD) to workspace.snapshotCommit, including captured staged/unstaged/non-ignored new files. If identical, review the latest original commit against its parent; for an initial/unborn commit inspect all captured files. Do not mistake a clean isolated worktree for an empty target. For an explicit commit/range target, resolve HEAD against workspace.baseCommit, not the detached snapshot HEAD, and pin all endpoints as commit hashes. Pin the original baseline commit, original target endpoint commit, initial job snapshot commit (first-round workspace.snapshotCommit), and original target/file scope in the first handoff. For captured uncommitted changes the original target endpoint is the initial job snapshot; for latest-commit review it is workspace.baseCommit; for an explicit range it is the resolved upper endpoint. On later rounds retain all these exact values from the previous check handoff; never re-resolve HEAD-relative refs or switch target modes. Inspect two separate diffs: original baseline to original target endpoint, and initial job snapshot to current workspace.snapshotCommit for cumulative loop fixes. Do not review baseline-to-current-snapshot as the target: file filtering cannot exclude later commits or unrelated dirty edits in the same target file. Preserve those out-of-target edits and do not fix their defects. @@ -53,8 +54,8 @@ Inspect the final diff and run or confirm focused validation without changing co { from: `review-${slot}`, to: "check" }, ]), { from: "scope", to: "check" }, - // Closes the review cycle: each retry reruns scope, at most maxRounds - 1 times. - { from: "check", to: "scope", choice: "retry", limit: maxRounds - 1 }, + // Closes the review cycle; scope's maxRuns bounds the rounds. + { from: "check", to: "scope", choice: "retry" }, { from: "check", to: "apply", choice: "done" }, ], }; diff --git a/integrations/pi/status-text.ts b/integrations/pi/status-text.ts index 06a2cd8..b852cc7 100644 --- a/integrations/pi/status-text.ts +++ b/integrations/pi/status-text.ts @@ -1,6 +1,6 @@ import { closingEdges, - type BraidInput, type BraidInputNode, type BraidSnapshot, type Edge, type ExecutionActivity, type ExecutionError, type ExecutionHold, + type BraidInput, type BraidInputNode, type BraidSnapshot, type ExecutionActivity, type ExecutionError, type ExecutionHold, type ExecutionSuspension, type NodeExecution, type NodeResult, type NodeWorkspace, } from "@chrok/braid"; import type { Usage } from "@earendil-works/pi-ai"; @@ -32,6 +32,7 @@ function nodeLine(node: BraidInputNode): string { if (node.model) settings.push(`model=${node.model}`); if (typeof node.prompt === "object") settings.push(`template=${node.prompt.template}`); for (const flag of ["pauseAfter", "notifyOnCompletion", "requireSuccess"] as const) if (node[flag]) settings.push(flag); + if (node.maxRuns !== undefined) settings.push(`maxRuns=${node.maxRuns}`); return `- ${node.id}: ${settings.join(", ")}`; } @@ -40,8 +41,7 @@ export function formatGraph(graph: Pick): string const lines = ["Nodes:", ...graph.nodes.map(nodeLine), "Edges:"]; const closing = new Set(closingEdges(graph)); lines.push(...graph.edges.length ? graph.edges.map(edge => { - const labels = [edge.choice && `choice=${edge.choice}`, closing.has(edge) && "closes a cycle", - edge.limit !== undefined && `limit=${edge.limit}`].filter(Boolean); + const labels = [edge.choice && `choice=${edge.choice}`, closing.has(edge) && "closes a cycle"].filter(Boolean); return `- ${edge.from} -> ${edge.to}${labels.length ? ` [${labels.join(", ")}]` : ""}`; }) : ["- none"]); return lines.join("\n"); @@ -88,11 +88,11 @@ export function formatJobList(jobs: readonly Pick `- ${job.handle} · ${job.status} · started ${iso(job.createdAt)} · id ${job.jobId}\n Goal: ${job.goal}`)].join("\n"); } -type Control = Pick & Partial>; +type Control = Pick & Partial>; const waitReasons = { predecessors: "an input has not run yet", stale: "an input is being rerun", hold: "an input is held", - run_paused: "the job is paused", slot: "a free slot", + limit: "its node's maxRuns; raise it or release this rerun to end it", run_paused: "the job is paused", slot: "a free slot", } as const; /** Held (pauseAfter) and suspended executions are resumed the same way; a job pause has its own resume. */ @@ -100,6 +100,7 @@ function control(execution: Control | undefined): string { if (!execution) return "Graph not started"; const parts = [`Graph ${execution.status}`, `revision ${execution.revision}`, `paused executions: ${ids(execution.pausedExecutionIds)}`]; if (execution.suspendedExecutionIds?.length) parts.push(`suspended executions: ${ids(execution.suspendedExecutionIds)}`); + if (execution.limitedExecutionIds?.length) parts.push(`reruns at maxRuns: ${ids(execution.limitedExecutionIds)}`); if (execution.pause) parts.push(`job paused by ${execution.pause.source} at ${iso(execution.pause.pausedAt)}`); const limits = Object.entries(execution.limits ?? {}); if (limits.length) parts.push(`limits: ${limits.map(([key, value]) => `${key} ${value}`).join(", ")}`); @@ -114,8 +115,7 @@ const USER_HOLD_NOTE = "The user set this hold; do not remove it or release the function pendingHold(execution: NodeResult): boolean { return Boolean(execution.holdAfter) && execution.status === "running"; } -const holdSource = (hold: ExecutionHold) => hold.source === "definition" ? "pauseAfter" : hold.source === "replan" ? "its replan request" - : hold.source === "edge_limit" ? "its edge limit" : hold.source; +const holdSource = (hold: ExecutionHold) => hold.source === "definition" ? "pauseAfter" : hold.source === "replan" ? "its replan request" : hold.source; function userPaused(execution: Control | undefined, executions: readonly NodeResult[]): boolean { return execution?.pause?.source === "user" || executions.some(item => item.suspension?.source === "user" || item.holdAfter?.source === "user"); @@ -273,10 +273,10 @@ export function formatTimeoutPause(pause: TimeoutPause): string { /** The job option each limit pause raises, by pause source. */ const jobLimits: Record = { execution_limit: "maxExecutions", graph_timeout: "graphTimeoutMs", token_limit: "maxTokens" }; const waitingSource = (source: string) => - ({ definition: "pauseAfter", replan: "its replan request", node_timeout: "node timeout", agent: "you", edge_limit: "its edge limit", + ({ definition: "pauseAfter", replan: "its replan request", node_timeout: "node timeout", agent: "you", maxRuns: "its node's maxRuns", ...Object.fromEntries(Object.entries(jobLimits).map(([key, limit]) => [key, `its ${limit} limit`])) } as Record)[source] ?? source; const waitingItem = (item: WaitingItem) => item.kind === "job" ? `the job pause (by ${waitingSource(item.source)})` - : `${item.kind === "held" ? "the held result of" : "suspended"} node ${JSON.stringify(item.nodeId)} execution ${item.executionId} (${waitingSource(item.source)})`; + : `${item.kind === "held" ? "the held result of" : item.kind === "limited" ? "the rerun of" : "suspended"} node ${JSON.stringify(item.nodeId)} execution ${item.executionId} (${waitingSource(item.source)})`; /** The waking reminder for a job with nothing active that waits on paused work. */ export function formatWaiting(notice: WaitingNotice): string { @@ -289,6 +289,9 @@ export function formatWaiting(notice: WaitingNotice): string { users.length ? `The user paused ${users.map(waitingItem).join("; ")}: leave those unless the user asks.` : "", `Check it with braid_status({"jobId":"${notice.handle}"}), then release held results or resume executions with`, `braid_resume({"jobId":"${notice.handle}","expectedRevision":${notice.revision},"executionIds":[...]}) or braid_update with resume,`, + notice.waitingOn.some(item => item.kind === "limited") + ? "let a rerun at its node's maxRuns start by upserting that node with a higher maxRuns in braid_update (the rerun is edited in place), or release it to end the rerun: the node keeps its last result, and a cycle ends there," + : "", limit ? `raise its ${limit} and resume the job with braid_resume({"jobId":"${notice.handle}","limits":{"${limit}":}}) (the job does not resume while the limit is still reached),` : job ? `resume the job with braid_resume({"jobId":"${notice.handle}"}),` : "", "fail single executions with braid_cancel and executionIds, or cancel the job with braid_cancel.", @@ -296,17 +299,6 @@ export function formatWaiting(notice: WaitingNotice): string { ].filter(Boolean).join(" "); } -/** Reminder text for a decision held because the edge it chose is at its limit. */ -export function formatEdgeLimit(handle: string, executionId: string, edge: Edge): string { - const identity = JSON.stringify({ from: edge.from, to: edge.to, ...(edge.choice !== undefined ? { choice: edge.choice } : {}) }); - return [ - `It chose ${JSON.stringify(edge.choice)}, but the edge back to ${JSON.stringify(edge.to)} already fired its limit of ${edge.limit} times, so the job holds it instead of starting another round.`, - `To run more rounds, call braid_update with removeEdges [${identity}], addEdges [the same edge with a higher limit, or without limit], and resume ["${executionId}"] in the same update.`, - `To stop, release it with braid_resume({"jobId":"${handle}","expectedRevision":,"executionIds":["${executionId}"]}): the cycle ends and its other edges stay inactive.`, - "Or cancel the job with braid_cancel. Nothing in this cycle runs until you act.", - ].join(" "); -} - /** The waking reminder for a running execution that asked the parent to replan. */ export function formatReplanRequest(notice: ReplanNotice): string { const execution = notice.executionId; diff --git a/integrations/pi/test/execution-control.test.ts b/integrations/pi/test/execution-control.test.ts index db6745a..9796f67 100644 --- a/integrations/pi/test/execution-control.test.ts +++ b/integrations/pi/test/execution-control.test.ts @@ -84,9 +84,9 @@ test("Pi loop reminders and status use a distinct execution ID for each round", t.after(() => jobs.dispose()); const { braidTool, statusTool } = createBraidTools(jobs); const job = await braidTool.execute("loop", { goal: "repeat", nodes: [ - { type: "execute", id: "work", prompt: "work", notifyOnCompletion: true }, + { type: "execute", id: "work", prompt: "work", notifyOnCompletion: true, maxRuns: 2 }, { type: "decision", id: "review", prompt: "review", choices: ["again", "done"] }, - ], edges: [{ from: "work", to: "review" }, { from: "review", to: "work", choice: "again", limit: 1 }], + ], edges: [{ from: "work", to: "review" }, { from: "review", to: "work", choice: "again" }], }, undefined, undefined, ctx); await jobs.wait(job.details!.handle); assert.equal(jobs.get(job.details!.handle)!.status, "completed"); diff --git a/integrations/pi/test/graph-views.test.ts b/integrations/pi/test/graph-views.test.ts index 0d88f35..4d993c0 100644 --- a/integrations/pi/test/graph-views.test.ts +++ b/integrations/pi/test/graph-views.test.ts @@ -157,7 +157,7 @@ test("g switches the panel between live and timeline; selection and Enter follow { type: "execute", id: "ship", prompt: "ship" }, { type: "execute", id: "human", prompt: "ask" }, ], edges: [ - { from: "work", to: "review" }, { from: "review", to: "work", choice: "again", limit: 5 }, + { from: "work", to: "review" }, { from: "review", to: "work", choice: "again" }, { from: "review", to: "ship", choice: "done" }, { from: "review", to: "human", choice: "escalate" }, ] }, {}, ctx); await started.promise; diff --git a/integrations/pi/test/limits.test.ts b/integrations/pi/test/limits.test.ts index 28830b4..929cc8f 100644 --- a/integrations/pi/test/limits.test.ts +++ b/integrations/pi/test/limits.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { validateToolArguments, type Context, type Tool } from "@earendil-works/pi-ai"; -import { BraidJobs, type NodeCompletion, type WaitingNotice } from "../jobs.js"; +import { BraidJobs, type WaitingNotice } from "../jobs.js"; import { createBraidTools } from "../index.js"; -import { formatEdgeLimit, formatWaiting } from "../status-text.js"; +import { formatWaiting } from "../status-text.js"; import { context, deferred, response } from "./helpers.js"; const until = async (condition: () => boolean, timeoutMs = 5_000) => { @@ -17,10 +17,10 @@ function validate(tool: Tool, args: Record) { } const node = (id: string) => ({ type: "execute" as const, id, prompt: `task:${id}` }); -test("Pi schemas accept cycles without an edge limit, job limits, and limits on job resume", () => { +test("Pi schemas accept maxRuns on nodes, job limits, and limits on job resume, but no edge limit", () => { const tools = createBraidTools(new BraidJobs()); const graph = { - goal: "refine", nodes: [node("work"), { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }], + goal: "refine", nodes: [{ ...node("work"), maxRuns: 3 }, { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }], edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again" }], options: { maxExecutions: 10, maxTokens: 1_000, onLimit: "pause" }, }; @@ -30,6 +30,9 @@ test("Pi schemas accept cycles without an edge limit, job limits, and limits on for (const limits of [{}, { maxExecutions: 0 }, { maxConcurrency: 2 }]) assert.throws(() => validate(tools.resumeTool, { jobId: "job-1", limits }), /Validation failed/); assert.throws(() => validate(tools.braidTool, { ...graph, options: { onLimit: "wait" } }), /Validation failed/); + assert.throws(() => validate(tools.braidTool, { ...graph, nodes: [{ ...node("work"), maxRuns: 0 }] }), /Validation failed/); + assert.throws(() => validate(tools.braidTool, { ...graph, edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again", limit: 2 }] }), + /Validation failed/); }); test("a job paused at maxExecutions wakes the parent and resumes once braid_resume raises the limit", { timeout: 10_000 }, async t => { @@ -62,7 +65,7 @@ test("a job paused at maxExecutions wakes the parent and resumes once braid_resu assert.equal(jobs.get(handle)!.result!.nodes.c!.status, "completed"); }); -test("a decision held at an edge limit sends a pause reminder that says how to continue or stop", { timeout: 10_000 }, async t => { +test("a rerun at its node's maxRuns wakes the parent with how to continue or stop", { timeout: 10_000 }, async t => { const ctx = context(async (_signal, worker: Context) => { const payload = JSON.parse(worker.messages[0]!.content as string) as { nodeId: string }; if (payload.nodeId === "check" && worker.messages.length === 1) return { @@ -71,23 +74,28 @@ test("a decision held at an edge limit sends a pause reminder that says how to c }; return response(payload.nodeId); }); - const completions: NodeCompletion[] = []; - const jobs = new BraidJobs(undefined, value => { completions.push(value); }); + const waiting: WaitingNotice[] = []; + const jobs = new BraidJobs(undefined, undefined, undefined, undefined, undefined, notice => { waiting.push(notice); }); t.after(() => jobs.dispose()); - const { braidTool } = createBraidTools(jobs); + const { braidTool, updateTool } = createBraidTools(jobs); const handle = (await braidTool.execute("loop", { - goal: "refine", nodes: [node("work"), { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }], - edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again", limit: 1 }], + goal: "refine", nodes: [{ ...node("work"), maxRuns: 1 }, { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }], + edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again" }], }, undefined, undefined, ctx)).details!.handle; - await until(() => completions.length === 1); - const [held] = completions; - assert.equal(held!.paused, true); - assert.equal(held!.executionId, "check@2"); - assert.deepEqual(held!.edgeLimit, { from: "check", to: "work", choice: "again", limit: 1 }); - assert.match(formatEdgeLimit(handle, held!.executionId, held!.edgeLimit!), - /already fired its limit of 1 times.*removeEdges \[\{"from":"check","to":"work","choice":"again"\}\].*resume \["check@2"\].*release it with braid_resume.*the cycle ends/); + await until(() => waiting.length === 1); + assert.deepEqual(waiting[0]!.waitingOn, [{ kind: "limited", executionId: "work@2", nodeId: "work", source: "maxRuns" }]); + assert.match(formatWaiting(waiting[0]!), + /It waits on the rerun of node "work" execution work@2 \(its node's maxRuns\)\..*higher maxRuns in braid_update.*release it to end the rerun/); + assert.deepEqual(jobs.outstanding()[0]!.limitedExecutionIds, ["work@2"]); - jobs.resume(handle, [held!.executionId], 0); + // Raising maxRuns starts the waiting rerun; releasing the next one ends the cycle. + await updateTool.execute("raise", { jobId: handle, expectedRevision: 0, upsertNodes: [{ ...node("work"), maxRuns: 2 }] }, undefined, undefined, ctx); + await until(() => waiting.length === 2); + assert.deepEqual(jobs.get(handle)!.execution!.limitedExecutionIds, ["work@3"]); + jobs.resume(handle, ["work@3"], 1); await jobs.wait(handle); - assert.equal(jobs.get(handle)!.status, "completed"); + const result = jobs.get(handle)!.result!; + assert.equal(result.status, "completed"); + assert.equal(result.nodes.work!.executionId, "work@2"); + assert.equal(result.executions["work@3"], undefined); }); diff --git a/integrations/pi/test/render.test.ts b/integrations/pi/test/render.test.ts index 98aef34..f935e57 100644 --- a/integrations/pi/test/render.test.ts +++ b/integrations/pi/test/render.test.ts @@ -312,7 +312,7 @@ test("cycle charts draw forward handoffs dotted and the closing edge solid witho edges: [ { from: "start", to: "inspect" }, { from: "inspect", to: "route" }, { from: "start", to: "route" }, - { from: "route", to: "start", choice: "again", limit: 2 }, + { from: "route", to: "start", choice: "again" }, { from: "route", to: "end", choice: "done" }, ], }; @@ -356,12 +356,12 @@ test("cycle charts draw forward handoffs dotted and the closing edge solid witho const cases: Array<{ name: string; edges: Edge[] }> = [ { name: "labelled shortcut", edges: graph.edges.map(edge => edge.from === "start" && edge.to === "route" ? { ...edge, choice: "direct" } : edge) }, { name: "required forward edge", edges: graph.edges.filter(edge => edge.from !== "inspect" && edge.to !== "inspect") }, - { name: "ordinary DAG shortcut", edges: graph.edges.filter(edge => edge.limit === undefined) }, + { name: "ordinary DAG shortcut", edges: graph.edges.filter(edge => edge.choice !== "again") }, ]; for (const { name, edges } of cases) { const rendered = chart({ ...live, edges }); assert.notEqual(rendered, chart({ ...live, edges: withoutShortcut(edges) }), name); - if (edges.some(edge => edge.limit !== undefined)) assert.match(rendered, /[╎╌]/, name); + if (edges.some(edge => edge.choice === "again")) assert.match(rendered, /[╎╌]/, name); else assert.doesNotMatch(rendered, /[╎╌]/, name); if (name === "labelled shortcut") assert.match(rendered, /direct/); const fromEvents = createLiveState(); @@ -547,3 +547,18 @@ test("node details render at the observation time and show recoverable workspace assert.match(archived, /workspace: worktree · archived · refs\/braid\/checkpoint/); assert.doesNotMatch(archived, /\/tmp\/wt/); }); + +test("the live view restores a node's head when its rerun ends at maxRuns", () => { + const live = createLiveState(); + const at = (sequence: number) => ({ sequence, timestamp: 0 }); + applyEvent(live, { type: "execution_created", nodeId: "work", nodeType: "execute", generation: 1, cause: "submitted", executionId: "work@1", ...at(1) }); + applyEvent(live, { type: "node_started", nodeId: "work", executionId: "work@1", ...at(2) }); + applyEvent(live, { type: "node_completed", nodeId: "work", executionId: "work@1", output: "first", latencyMs: 1, ...at(3) }); + applyEvent(live, { type: "execution_created", nodeId: "work", nodeType: "execute", generation: 2, cause: "cloned", executionId: "work@2", ...at(4) }); + assert.equal(live.nodes.work!.status, "pending"); + applyEvent(live, { type: "execution_removed", nodeId: "work", executionId: "work@2", ...at(5) }); + assert.equal(live.nodes.work!.executionId, "work@1"); + assert.equal(live.nodes.work!.status, "completed"); + assert.equal(live.nodes.work!.output, "first"); + assert.equal(live.generations!.work, 1); +}); diff --git a/integrations/pi/test/result-text.test.ts b/integrations/pi/test/result-text.test.ts index cf4451c..06698cc 100644 --- a/integrations/pi/test/result-text.test.ts +++ b/integrations/pi/test/result-text.test.ts @@ -13,19 +13,19 @@ test("git results read like Pi's bash output and keep output byte-exact", () => test("graphs list node settings and labelled edges, including the edge that closes a cycle, without prompts", () => { assert.equal(formatGraph({ nodes: [ - { type: "execute", id: "work", prompt: { template: "task", variables: {} }, workspace: "read-only", model: "p/m", requireSuccess: true }, + { type: "execute", id: "work", prompt: { template: "task", variables: {} }, workspace: "read-only", model: "p/m", requireSuccess: true, maxRuns: 3 }, { type: "decision", id: "check", prompt: "secret prompt", choices: ["retry", "done"], pauseAfter: true }, { type: "merge", id: "join" }, ], - edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "retry", limit: 2 }, { from: "check", to: "join", choice: "done" }], + edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "retry" }, { from: "check", to: "join", choice: "done" }], }), [ "Nodes:", - "- work: execute, workspace=read-only, model=p/m, template=task, requireSuccess", + "- work: execute, workspace=read-only, model=p/m, template=task, requireSuccess, maxRuns=3", "- check: decision, choices=retry|done, pauseAfter", "- join: merge", "Edges:", "- work -> check", - "- check -> work [choice=retry, closes a cycle, limit=2]", + "- check -> work [choice=retry, closes a cycle]", "- check -> join [choice=done]", ].join("\n")); }); diff --git a/integrations/pi/test/review.test.ts b/integrations/pi/test/review.test.ts index ca374e8..76ab516 100644 --- a/integrations/pi/test/review.test.ts +++ b/integrations/pi/test/review.test.ts @@ -83,7 +83,8 @@ test("/braid:review registers a bounded background graph and trims its target", assert.ok(input); assert.match(input.goal, /HEAD~2\.\.HEAD/); assert.doesNotMatch(input.goal, / HEAD/); - assert.deepEqual(input.edges.find(edge => edge.to === "scope"), { from: "check", to: "scope", choice: "retry", limit: 2 }); + assert.deepEqual(input.edges.find(edge => edge.to === "scope"), { from: "check", to: "scope", choice: "retry" }); + assert.equal(input.nodes.find(node => node.id === "scope")!.maxRuns, 3); assert.deepEqual(input.edges.filter(edge => edge.to === "check").map(edge => edge.from).sort(), ["review-1", "review-2", "review-3", "scope"]); assert.ok(input.nodes.every(node => node.requireSuccess)); assert.deepEqual(options, { maxConcurrency: 3, maxExecutions: 16, onLimit: "fail" }); @@ -244,12 +245,13 @@ for (const outcome of ["clean", "capped", "limit", "reviewer failure"] as const) if (outcome === "capped") assert.match(result.terminalOutputs.apply!.output, /Round limit reached; unresolved P1/); } else { assert.equal(result.status, "failed"); - assert.equal(result.error!.code, outcome === "limit" ? "EDGE_LIMIT" : "REQUIRED_NODE_FAILED"); + assert.equal(result.error!.code, outcome === "limit" ? "RUN_LIMIT" : "REQUIRED_NODE_FAILED"); assert.ok(!calls.has("apply")); if (outcome === "limit") { - // 15 started executions, and apply, skipped when the run failed. - assert.equal(Object.keys(result.executions).length, 16); + // 15 started executions; apply and the rerun of scope beyond its maxRuns were skipped when the run failed. + assert.equal(Object.keys(result.executions).length, 17); assert.equal(result.executions["apply@1"]!.skipReason, "run_failed"); + assert.equal(result.executions["scope@4"]!.skipReason, "run_failed"); assert.deepEqual(Object.values(result.executions).filter(node => node.id === "check").map(node => node.generation), [1, 2, 3]); } else assert.ok(!calls.has("check")); } diff --git a/integrations/pi/test/suspension.test.ts b/integrations/pi/test/suspension.test.ts index db1266a..7fd4855 100644 --- a/integrations/pi/test/suspension.test.ts +++ b/integrations/pi/test/suspension.test.ts @@ -54,7 +54,7 @@ test("braid_pause suspends before the next model request and braid_resume contin assert.deepEqual(jobs.progress(handle)!.running, [], "a suspended execution is not running"); assert.equal(jobs.progress(handle)!.paused, 1); assert.deepEqual(jobs.outstanding(), [{ - jobId: submitted.details!.jobId, handle, stalled: true, revision: 0, pausedExecutionIds: [], suspendedExecutionIds: [executionId], + jobId: submitted.details!.jobId, handle, stalled: true, revision: 0, pausedExecutionIds: [], suspendedExecutionIds: [executionId], limitedExecutionIds: [], }]); const status = textOf(await tools.statusTool.execute("status", { jobId: handle, executionId }, undefined, undefined, ctx)); assert.match(status, /suspended executions: /); diff --git a/integrations/pi/test/tool-schema.test.ts b/integrations/pi/test/tool-schema.test.ts index ed1559a..ccd13f6 100644 --- a/integrations/pi/test/tool-schema.test.ts +++ b/integrations/pi/test/tool-schema.test.ts @@ -22,7 +22,7 @@ for (const templated of [false, true]) { const graph = { goal: "Review and integrate", nodes: [ - { type: "execute", id: "work", prompt, model: "provider/model-id", workspace: "worktree", pauseAfter: true, notifyOnCompletion: true, requireSuccess: false }, + { type: "execute", id: "work", prompt, model: "provider/model-id", workspace: "worktree", pauseAfter: true, notifyOnCompletion: true, requireSuccess: false, maxRuns: 3 }, { type: "decision", id: "check", prompt, choices: ["retry", "done"], workspace: "read-only" }, { type: "merge", id: "merge", prompt, requireSuccess: true }, { type: "integrate", id: "integrate", prompt }, @@ -31,7 +31,7 @@ for (const templated of [false, true]) { ], edges: [ { from: "work", to: "check" }, - { from: "check", to: "work", choice: "retry", limit: 2 }, + { from: "check", to: "work", choice: "retry" }, { from: "check", to: "merge", choice: "done" }, { from: "merge", to: "integrate" }, ], @@ -107,26 +107,25 @@ test("actual Kimi/Anthropic request exposes flat nodes for both submission and u const schema: NodeSchema = payload.tools.find(tool => tool.name === name)!.input_schema.properties[field!]!.items; assert.equal(schema.type, "object"); assert.equal(schema.anyOf, undefined); - for (const key of ["type", "id", "prompt", "model", "workspace", "choices", "pauseAfter", "notifyOnCompletion", "requireSuccess"]) { + for (const key of ["type", "id", "prompt", "model", "workspace", "choices", "pauseAfter", "notifyOnCompletion", "requireSuccess", "maxRuns"]) { assert.ok(Object.hasOwn(schema.properties, key), `${name}: missing ${key}`); } } }); -test("edges are identified by from, to, and choice; loops, feedback, and execution pins are gone", () => { - for (const edge of [{ from: "a", to: "b", executionId: "a@1" }, { from: "a", to: "a", feedback: "retry" }]) { +test("edges are identified by from, to, and choice; loops, feedback, limits, and execution pins are gone", () => { + for (const edge of [{ from: "a", to: "b", executionId: "a@1" }, { from: "a", to: "a", feedback: "retry" }, { from: "a", to: "a", choice: "again", limit: 2 }]) { assert.throws(() => validate(tools.braidTool, { ...input, edges: [edge] }), /Validation failed/); assert.throws(() => validate(tools.updateTool, { jobId: "job-1", expectedRevision: 0, addEdges: [edge] }), /Validation failed/); } assert.throws(() => validate(tools.braidTool, { ...input, loops: [] }), /Validation failed/); - // removeEdges ignores limit, so an edge copied from braid_status can be removed as is. - const patch = { jobId: "job-1", expectedRevision: 0, removeEdges: [{ from: "a", to: "b", choice: "x", limit: 2 }], addEdges: [{ from: "a", to: "b", choice: "x", limit: 3 }], resume: ["a@1"] }; + const patch = { jobId: "job-1", expectedRevision: 0, removeEdges: [{ from: "a", to: "b", choice: "x" }], addEdges: [{ from: "a", to: "b", choice: "y" }], resume: ["a@1"] }; assert.deepEqual(validate(tools.updateTool, patch), patch); }); -test("edge limits are positive safe integers", () => { - for (const limit of [0, 1.5, Number.MAX_SAFE_INTEGER + 1]) { - assert.throws(() => validate(tools.braidTool, { ...input, edges: [{ from: "a", to: "a", choice: "again", limit }] }), /Validation failed/); +test("maxRuns is a positive safe integer", () => { + for (const maxRuns of [0, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => validate(tools.braidTool, { ...input, nodes: [{ ...input.nodes[0], maxRuns }] }), /Validation failed/); } }); diff --git a/src/runtime.ts b/src/runtime.ts index 960ce24..4681638 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -574,12 +574,12 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { const consulted = new Map>(); // Cycle-closing firings attached to a pending instance. const attached = new Map(); - // Times each edge identity fired, for edge limits. - const firings = new Map(); // Settled instances whose cycle-closing edges have not been routed yet. const unrouted = new Set(); - // Decisions held at an edge limit; releasing one without a higher limit ends the cycle. - const limitHeld = new Set(); + // Instances cloned because an edge reached a settled head: the reruns that maxRuns limits. + const clones = new Set(); + // Pending clones at their node's maxRuns, reported by limit_reached. + const limited = new Set(); // Running instances per lineage ID: a lineage starts its next instance only after the last one stops. const runningLineages = new Map(); // Instances cancelled because an update replaced or removed them; their failure is not a run failure. @@ -637,6 +637,7 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { const waits = assess(); const state = structuredClone({ runId, status, revision, graph: definition, nodes: finalNodes ?? heads(), executions: Object.fromEntries(executions), pausedExecutionIds: [...paused], suspendedExecutionIds: [...suspended], + limitedExecutionIds: [...limited], ...(runPause ? { pause: runPause } : {}), limits: finiteLimits(), ...(controller.signal.aborted ? { error: describeError(controller.signal.reason) } : {}) }); if (!finalNodes) for (const record of [...Object.values(state.nodes), ...Object.values(state.executions)]) mark(record, waits); @@ -713,11 +714,11 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { } function resumable(id: string) { const state = executions.get(id)?.suspension?.state; - return paused.has(id) || state === "pausing" || state === "suspended"; + return paused.has(id) || state === "pausing" || state === "suspended" || (limited.has(id) && atRunLimit(executions.get(id)!)); } function validateResume(ids: readonly string[], options?: ResumeOptions) { if (!Array.isArray(ids) || new Set(ids).size !== ids.length || ids.some(id => !resumable(id))) - throw new Error("resume must contain unique held or paused execution IDs"); + throw new Error("resume must contain unique held, paused, or limited execution IDs"); if (options === undefined) return; if (!options || typeof options !== "object" || Object.keys(options).some(key => key !== "nodeTimeoutMs")) throw new TypeError("Unknown resume option"); @@ -729,7 +730,13 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { function stageResume(ids: readonly string[], options?: ResumeOptions): string[] { const continued: string[] = []; for (const id of ids) { - const record = executions.get(id)!; + const record = executions.get(id); + // A limited rerun is pending. An update in the same call may have raised its maxRuns, and then it + // simply starts, or removed its node. + if (!record || record.status === "pending") { + if (record && limited.has(id) && atRunLimit(record)) endRerun(record); + continue; + } pausedByRun.delete(id); // A timeout pause gets a full new window unless the caller chose one. const budget = options?.nodeTimeoutMs ?? (record.suspension?.source === "node_timeout" ? nodeTimeoutMs : undefined); @@ -950,6 +957,7 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { ...(links.supersedes ? { supersedes: links.supersedes } : {}), }; executions.set(record.executionId, record); + if (cause === "cloned") clones.add(record.executionId); let lineage = lineages.get(id); if (!lineage) lineages.set(id, lineage = { instances: [], queued: [] }); lineage.instances.push(record); @@ -975,9 +983,29 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { /** Drop a settled instance's hold without releasing it downstream. */ function dropHold(record: NodeExecution) { paused.delete(record.executionId); - limitHeld.delete(record.executionId); unrouted.delete(record.executionId); } + /** Forget a pending instance that never started; no history is kept. */ + function forget(record: NodeExecution) { + executions.delete(record.executionId); + attached.delete(record.executionId); + clones.delete(record.executionId); + limited.delete(record.executionId); + } + /** Release a limited rerun without raising maxRuns: remove it, and its lineage keeps its last result. */ + function endRerun(record: NodeExecution) { + const lineage = lineages.get(record.id)!; + lineage.instances.pop(); + forget(record); + // The previous head accepts its settled inputs, so the same edges do not clone it again; + // an input that is still rerunning reaches it once it settles. + const used = consulted.get(lineage.instances.at(-1)!.executionId)!; + for (const edge of graph.incoming.get(record.id)!) { + const source = head(edge.from); + if (!graph.closing.has(edge) && done(source) && !paused.has(source.executionId)) used.set(edge.from, source.executionId); + } + emit({ type: "execution_removed", nodeId: record.id, ...context(record) }); + } /** Validate a graph edit against the current graph without applying it. */ function candidateGraph(patch: GraphUpdate | ReplanSuggestion): { candidate: BraidInput; compiled: Graph } { @@ -999,7 +1027,7 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { const existing = new Set(definition.edges.map(edgeIdentity)); const removedEdges = new Set(); for (const edge of patch.removeEdges ?? []) { - if (!edge || typeof edge !== "object" || Object.keys(edge).some(key => !["from", "to", "choice", "limit"].includes(key)) || + if (!edge || typeof edge !== "object" || Object.keys(edge).some(key => !["from", "to", "choice"].includes(key)) || [edge.from, edge.to, ...(edge.choice === undefined ? [] : [edge.choice])].some(value => typeof value !== "string" || !value.trim())) throw new TypeError("removeEdges must contain edge identities: from, to, and choice when present"); const key = edgeIdentity(edge); @@ -1034,8 +1062,7 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { lineages.delete(id); if (record.status === "pending") { // Never started: no history is kept. - executions.delete(record.executionId); - attached.delete(record.executionId); + forget(record); emit({ type: "execution_removed", nodeId: id, ...context(record) }); } else { discard(record, "removed"); @@ -1107,7 +1134,7 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { if (!lineages.has(id)) continue; const upstream = upstreamDirt(id, dirt); const record = head(id); - if (record.status === "pending") waits.set(record.executionId, upstream ?? (runPause ? "run_paused" : "slot")); + if (record.status === "pending") waits.set(record.executionId, upstream ?? (overLimit(record) ? "limit" : runPause ? "run_paused" : "slot")); dirt.set(id, lineageDirt(id, upstream)); } return waits; @@ -1119,44 +1146,45 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { for (const id of [...unrouted]) { const record = executions.get(id)!; if (paused.has(id)) continue; + unrouted.delete(id); // Superseded and removed instances no longer route. - if (lineages.get(record.id)?.instances.at(-1) !== record) { unrouted.delete(id); continue; } - let held = false; + if (lineages.get(record.id)?.instances.at(-1) !== record) continue; for (const edge of graph.outgoing.get(record.id)!) { if (!graph.closing.has(edge) || edgeState(edge, record) !== "active") continue; - const key = edgeIdentity(edge); - const count = firings.get(key) ?? 0; - const limit = edge.limit ?? Infinity; - if (count >= limit) { - if (onLimit === "fail") { - controller.abort(new RunError("EDGE_LIMIT", `Edge '${edge.from}' -> '${edge.to}' reached its limit of ${limit}`)); - return true; - } - // Released without a higher limit: the cycle ends here, and the decision's other edges stay inactive. - if (limitHeld.has(id)) continue; - // Hold the decision until the owner raises the limit or releases it to end the cycle. - limitHeld.add(id); - record.holdAfter = { source: "edge_limit", requestedAt: Date.now() }; - paused.add(id); - log.emit({ type: "execution_hold_added", nodeId: record.id, source: "edge_limit", ...context(record) }); - log.emit({ type: "limit_reached", limit: "edge", value: limit, nodeId: record.id, - edge: { from: edge.from, to: edge.to, ...(edge.choice !== undefined ? { choice: edge.choice } : {}), limit }, ...context(record) }); - log.emit({ type: "execution_paused", nodeId: record.id, ...context(record) }); - held = progressed = true; - break; - } - firings.set(key, count + 1); const target = head(edge.to); const firing = { edge, source: record }; if (target.status === "pending") (attached.get(target.executionId) ?? attached.set(target.executionId, []).get(target.executionId)!).push(firing); else lineages.get(edge.to)!.queued.push(firing); progressed = true; } - if (!held) { unrouted.delete(id); limitHeld.delete(id); } } return progressed; } + /** A clone whose node has already started maxRuns executions. */ + function atRunLimit(record: NodeExecution): boolean { + if (!clones.has(record.executionId)) return false; + const runs = lineages.get(record.id)!.instances.filter(value => value.status !== "pending" && value.status !== "skipped").length; + return runs >= (record.node.maxRuns ?? Infinity); + } + /** A clone that would start, not be skipped, beyond its node's maxRuns. Valid once its inputs are current. */ + function overLimit(record: NodeExecution): boolean { + return atRunLimit(record) && !willSkip(record); + } + /** Keep a clone at its node's maxRuns pending for the owner, or fail the run; false when the run fails. */ + function reachRunLimit(record: NodeExecution): boolean { + const maxRuns = record.node.maxRuns!; + if (onLimit === "fail") { + controller.abort(new RunError("RUN_LIMIT", `Node '${record.id}' reached its maxRuns of ${maxRuns}`)); + return false; + } + if (!limited.has(record.executionId)) { + limited.add(record.executionId); + log.emit({ type: "limit_reached", limit: "maxRuns", value: maxRuns, nodeId: record.id, ...context(record) }); + } + return true; + } + /** Start or skip a pending head whose inputs are settled and current. */ function admit(record: NodeExecution): boolean { if (runLimits.execution_limit.reached()) { @@ -1254,8 +1282,11 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { } if (record.status === "pending" && !upstream && !runPause && !runningLineages.get(id)) { const skip = willSkip(record); - // Resumed executions waiting for a slot take priority over new admissions. - if (skip || (active() < maxConcurrency && !resumeQueue.length)) { + if (overLimit(record)) { + if (!reachRunLimit(record)) return true; + } else if (skip || (active() < maxConcurrency && !resumeQueue.length)) { + // Resumed executions waiting for a slot take priority over new admissions. + limited.delete(record.executionId); if (!admit(record)) return true; progressed = true; // A skipped record's event may have submitted an update. @@ -1289,12 +1320,12 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { const schedulingRevision = revision; const progressed = schedule(); if (controller.signal.aborted || progressed || schedulingRevision !== revision) continue; - if (running.size || paused.size || runPause) { + if (running.size || paused.size || runPause || limited.size) { status = active() ? "running" : "waiting"; // Updates and resumes that start nothing leave lastSettled unset, so they do not report again. if (status === "waiting" && lastSettled) { log.emit({ type: "graph_waiting", settledExecutionId: lastSettled, pausedExecutionIds: [...paused], - suspendedExecutionIds: [...suspended], ...(runPause ? { pause: { ...runPause } } : {}), revision }); + suspendedExecutionIds: [...suspended], limitedExecutionIds: [...limited], ...(runPause ? { pause: { ...runPause } } : {}), revision }); lastSettled = undefined; } await changed; @@ -1311,6 +1342,7 @@ export function startBraid(input: BraidInput, options: BraidOptions): BraidRun { status = "finalizing"; // Terminal holds are no longer resumable; the event log retains pause history. paused.clear(); + limited.clear(); runPause = undefined; try { await workspaces.archivePending("Execution checkpoint retained for inspection and future recovery"); } catch (error) { cleanupError = { code: "CLEANUP_FAILED", message: error instanceof Error ? error.message : String(error) }; } diff --git a/src/types.ts b/src/types.ts index cbd69a6..ff92b51 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,12 @@ export interface ExecuteNode { requireSuccess?: boolean; /** Hold this execution's outgoing dependencies until explicitly resumed. */ pauseAfter?: boolean; + /** + * How many executions of this node may start, counting reruns: cycle rounds and + * downstream reruns. Skipped executions do not count. An owner's replacement is + * never blocked. Unlimited when omitted or Infinity. + */ + maxRuns?: number; /** Fresh predecessor snapshot in Git; read-only disables writes. Read-only outside Git. */ workspace?: "read-only" | "worktree"; } @@ -25,6 +31,12 @@ export interface DecisionNode { requireSuccess?: boolean; /** Hold this execution's outgoing dependencies until explicitly resumed. */ pauseAfter?: boolean; + /** + * How many executions of this node may start, counting reruns: cycle rounds and + * downstream reruns. Skipped executions do not count. An owner's replacement is + * never blocked. Unlimited when omitted or Infinity. + */ + maxRuns?: number; /** Fresh predecessor snapshot in Git; read-only disables writes. Read-only outside Git. */ workspace?: "read-only" | "worktree"; } @@ -41,6 +53,12 @@ export interface MergeNode { requireSuccess?: boolean; /** Hold this execution's outgoing dependencies until explicitly resumed. */ pauseAfter?: boolean; + /** + * How many executions of this node may start, counting reruns: cycle rounds and + * downstream reruns. Skipped executions do not count. An owner's replacement is + * never blocked. Unlimited when omitted or Infinity. + */ + maxRuns?: number; } export interface IntegrateNode extends Omit { type: "integrate"; } @@ -121,11 +139,6 @@ export interface Edge { to: string; /** Omit for an unconditional edge, including from a decision node. */ choice?: string; - /** - * Only on the edge that closes a cycle: how many times it may fire, each time - * cloning its target for another round. Omit or use Infinity for no limit. - */ - limit?: number; } export interface BraidInput { @@ -146,10 +159,11 @@ export type NodeStatus = /** * Why a pending instance has not started. predecessors: an input has not run yet; * stale: an input has a newer instance, or will get one, that has not settled; - * hold: an input is held; run_paused: the run is paused; slot: no concurrency - * slot is free, or the instance it replaces is still stopping. + * hold: an input is held; limit: a rerun at its node's maxRuns; run_paused: the + * run is paused; slot: no concurrency slot is free, or the instance it replaces + * is still stopping. */ -export type WaitReason = "predecessors" | "stale" | "hold" | "run_paused" | "slot"; +export type WaitReason = "predecessors" | "stale" | "hold" | "limit" | "run_paused" | "slot"; export interface TokenUsage { inputTokens: number; @@ -287,11 +301,7 @@ export interface ExecutionSuspension { /** A hold on an execution's outgoing scheduling, applied once it settles. */ export interface ExecutionHold { - /** - * "definition" for the node's pauseAfter, "replan" for a replan request, - * "edge_limit" for a decision that chose an edge at its limit, otherwise the - * caller-supplied label. - */ + /** "definition" for the node's pauseAfter, "replan" for a replan request, otherwise the caller-supplied label. */ source: string; requestedAt: number; } @@ -350,17 +360,15 @@ export type ExecutionEvent = Readonly< | { type: "graph_resumed" } | { /** - * A limit was reached under onLimit: "pause". An edge limit holds the decision - * that chose the edge; the other limits pause the run. + * A limit was reached under onLimit: "pause". maxRuns keeps the rerun pending, + * and executionId names it; the other limits pause the run. */ type: "limit_reached"; - limit: keyof BraidLimits | "edge"; + limit: keyof BraidLimits | "maxRuns"; /** The limit's value when it was reached. */ value: number; - /** The held decision, for an edge limit. */ + /** The node at its maxRuns. */ nodeId?: string; - /** The edge at its limit. */ - edge?: Edge; } | { type: "limits_updated"; limits: Readonly } | { @@ -373,6 +381,7 @@ export type ExecutionEvent = Readonly< settledExecutionId: string; pausedExecutionIds: readonly string[]; suspendedExecutionIds: readonly string[]; + limitedExecutionIds: readonly string[]; pause?: Readonly<{ source: string; pausedAt: number }>; } | { type: "workspace_updated"; workspace: Readonly } @@ -443,11 +452,11 @@ export interface BraidOptions extends BraidLimits { */ onNodeTimeout?: "pause" | "fail"; /** - * What reaching an edge limit or a run limit does. "pause" holds the decision - * that chose the edge, or pauses the run, until the owner raises the limit or - * decides otherwise; "fail" fails the run with EDGE_LIMIT, EXECUTION_LIMIT, - * GRAPH_TIMEOUT, or TOKEN_LIMIT. Default: "pause" for startBraid, and "fail" for - * braid(), which cannot resume. + * What reaching a node's maxRuns or a run limit does. "pause" keeps the rerun + * pending, or pauses the run, until the owner raises the limit or decides + * otherwise; "fail" fails the run with RUN_LIMIT, EXECUTION_LIMIT, GRAPH_TIMEOUT, + * or TOKEN_LIMIT. Default: "pause" for startBraid, and "fail" for braid(), which + * cannot resume. */ onLimit?: "pause" | "fail"; /** Caller cancellation, independent of node and graph deadlines. */ @@ -470,7 +479,7 @@ export interface ExecutionError { | "CHECKPOINT_FAILED" | "WORKSPACE_MERGE_REQUIRED" | "EXECUTION_LIMIT" - | "EDGE_LIMIT" + | "RUN_LIMIT" | "TOKEN_LIMIT" | "REQUIRED_NODE_FAILED" | "SCHEDULING_ERROR"; @@ -565,10 +574,10 @@ export interface GraphUpdate { /** Remove lineages with their edges. A pending head is deleted, a running one cancelled; started instances stay in history. */ removeNodeIds?: readonly string[]; addEdges?: readonly Edge[]; - /** Edge identities (from, to, choice) to remove; `limit` is ignored. */ + /** Edge identities (from, to, choice) to remove. */ removeEdges?: readonly Edge[]; promptTemplates?: Readonly>; - /** Resume these held or paused executions atomically with this update. */ + /** Resume these held, paused, or limited executions atomically with this update. */ resume?: readonly string[]; /** Applies to the paused executions in `resume`. Requires `resume`. */ resumeOptions?: ResumeOptions; @@ -602,10 +611,15 @@ export interface BraidSnapshot { graph: BraidInput; nodes: Record; executions: Record; - /** Settled executions whose outgoing edges are held by pauseAfter, holdAfter, a replan request, or an edge limit. */ + /** Settled executions whose outgoing edges are held by pauseAfter, holdAfter, or a replan request. */ pausedExecutionIds: string[]; /** Running executions stopped at a checkpoint, including resumed ones waiting for a slot. */ suspendedExecutionIds: string[]; + /** + * Pending reruns at their node's maxRuns. One starts once its maxRuns is raised; + * resuming it instead ends the rerun: it is removed and its node keeps its last result. + */ + limitedExecutionIds: string[]; /** * Present while the whole run is paused: no new executions are admitted. A limit * under onLimit: "pause" uses source "execution_limit", "graph_timeout", or "token_limit". @@ -622,7 +636,10 @@ export interface BraidRun { result: Promise; snapshot(): BraidSnapshot; update(update: GraphUpdate): BraidSnapshot; - /** Release held executions, or resume paused ones. Resuming a still-pausing execution withdraws the request. */ + /** + * Release held executions, or resume paused ones. Resuming a still-pausing execution + * withdraws the request. Resuming a limited rerun ends it (see limitedExecutionIds). + */ resume(executionIds: readonly string[], expectedRevision: number, options?: ResumeOptions): BraidSnapshot; /** Suspend running executions at their runner's next checkpoint. Requires a pausable runner. */ pause(executionIds: readonly string[], options?: PauseOptions): BraidSnapshot; diff --git a/src/validate.ts b/src/validate.ts index 48a299d..40badff 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -142,10 +142,10 @@ export function compileGraph(input: BraidInput): Graph { fields( node, node.type === "decision" - ? ["type", "id", "prompt", "model", "choices", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"] + ? ["type", "id", "prompt", "model", "choices", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter", "maxRuns"] : node.type === "execute" - ? ["type", "id", "prompt", "model", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter"] - : ["type", "id", "prompt", "model", "notifyOnCompletion", "requireSuccess", "pauseAfter"], + ? ["type", "id", "prompt", "model", "workspace", "notifyOnCompletion", "requireSuccess", "pauseAfter", "maxRuns"] + : ["type", "id", "prompt", "model", "notifyOnCompletion", "requireSuccess", "pauseAfter", "maxRuns"], `Node '${node.id}' (${node.type})`, ); requireValid(!byId.has(node.id), `Duplicate node id '${node.id}'`); @@ -168,6 +168,9 @@ export function compileGraph(input: BraidInput): Graph { for (const field of ["requireSuccess", "pauseAfter"] as const) { requireValid(node[field] === undefined || typeof node[field] === "boolean", `Invalid ${field} on '${node.id}': expected a boolean`); } + requireValid(node.maxRuns === undefined || node.maxRuns === Infinity || + (typeof node.maxRuns === "number" && Number.isSafeInteger(node.maxRuns) && node.maxRuns > 0), + `Invalid maxRuns on '${node.id}': expected a positive integer, Infinity, or omitted`); const common = { id: node.id, prompt, @@ -175,6 +178,7 @@ export function compileGraph(input: BraidInput): Graph { ...(node.notifyOnCompletion !== undefined ? { notifyOnCompletion: node.notifyOnCompletion } : {}), ...(node.requireSuccess !== undefined ? { requireSuccess: node.requireSuccess as boolean } : {}), ...(node.pauseAfter !== undefined ? { pauseAfter: node.pauseAfter as boolean } : {}), + ...(node.maxRuns !== undefined ? { maxRuns: node.maxRuns } : {}), ...(workspace !== undefined ? { workspace } as const : {}), }; if (node.type === "decision") { @@ -204,7 +208,7 @@ export function compileGraph(input: BraidInput): Graph { } for (const edge of input.edges) { requireValid(isRecord(edge), "Edge must be an object"); - fields(edge, ["from", "to", "choice", "limit"], `Edge '${edge.from}' -> '${edge.to}'`); + fields(edge, ["from", "to", "choice"], `Edge '${edge.from}' -> '${edge.to}'`); requireValid(text(edge.from) && byId.has(edge.from), `Missing source node '${edge.from}'`); requireValid(text(edge.to) && byId.has(edge.to), `Missing target node '${edge.to}'`); requireValid(edge.choice === undefined || text(edge.choice), "Invalid edge choice"); @@ -213,9 +217,6 @@ export function compileGraph(input: BraidInput): Graph { requireValid(source.type === "decision", `Choice edge from non-decision '${edge.from}'`); requireValid(source.choices.includes(edge.choice), `Undeclared edge choice '${edge.choice}' on '${edge.from}'`); } - requireValid(edge.limit === undefined || edge.limit === Infinity || - (typeof edge.limit === "number" && Number.isSafeInteger(edge.limit) && edge.limit > 0), - `Edge '${edge.from}' -> '${edge.to}' limit must be a positive integer, Infinity, or omitted`); const key = edgeIdentity(edge as unknown as Edge); requireValid(!seen.has(key), `Duplicate edge '${edge.from}' -> '${edge.to}'`); seen.add(key); @@ -223,7 +224,6 @@ export function compileGraph(input: BraidInput): Graph { from: edge.from, to: edge.to, ...(edge.choice !== undefined ? { choice: edge.choice } : {}), - ...(edge.limit !== undefined ? { limit: edge.limit } : {}), }; edges.push(snapshot); incoming.get(edge.to)!.push(snapshot); @@ -253,10 +253,6 @@ export function compileGraph(input: BraidInput): Graph { closing.add(back); cycles.push({ entry: back.to, edge: back, members }); } - for (const edge of edges) { - requireValid(edge.limit === undefined || closing.has(edge), - `Edge '${edge.from}' -> '${edge.to}' sets limit, but only the edge that closes a cycle can`); - } // Kahn's algorithm is iterative, including for very deep graphs. const remaining = new Map(nodes.map(node => [node.id, incoming.get(node.id)!.filter(edge => !closing.has(edge)).length])); const topologicalOrder = nodes.filter(node => remaining.get(node.id) === 0); @@ -330,7 +326,7 @@ export function closingEdges(graph: { nodes: readonly Pick cycle.candidates[0]!); } -/** Edges are identified by source, target, and choice; `limit` is an attribute. */ +/** Edges are identified by source, target, and choice. */ export function edgeIdentity(edge: Pick): string { return JSON.stringify([edge.from, edge.to, edge.choice ?? null]); } diff --git a/test/activity.test.ts b/test/activity.test.ts index c48f76b..c6f888e 100644 --- a/test/activity.test.ts +++ b/test/activity.test.ts @@ -71,8 +71,8 @@ test("repeated loop visits keep separate execution histories", async () => { let visits = 0; const result = await braid({ goal: "loop", - nodes: [execute("work"), { type: "decision", id: "check", prompt: "check", choices: ["retry", "done"] }], - edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "retry", limit: 1 }], + nodes: [{ ...execute("work"), maxRuns: 2 }, { type: "decision", id: "check", prompt: "check", choices: ["retry", "done"] }], + edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "retry" }], }, { onEvent: event => tracker.observe(event), runner: async request => { diff --git a/test/execution-control.test.ts b/test/execution-control.test.ts index a80dfce..f65150b 100644 --- a/test/execution-control.test.ts +++ b/test/execution-control.test.ts @@ -7,8 +7,8 @@ import { braid, closingEdges, startBraid, validateGraph, type BraidInput, type B import { deferred } from "./helpers.js"; const node = (id: string) => ({ type: "execute" as const, id, prompt: id }); -const loop = (limit = 2): BraidInput => ({ goal: "refine", nodes: [node("work"), { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }, node("end")], - edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again", limit }, { from: "check", to: "end", choice: "done" }] }); +const loop = (maxRuns = 3): BraidInput => ({ goal: "refine", nodes: [{ ...node("work"), maxRuns }, { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }, node("end")], + edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again" }, { from: "check", to: "end", choice: "done" }] }); async function outsideGit(t: TestContext) { const cwd = await mkdtemp(join(tmpdir(), "braid-control-")); t.after(() => rm(cwd, { recursive: true, force: true })); @@ -38,13 +38,15 @@ test("a cycle closed by a choice edge clones one instance per round and feeds th assert.ok(!result.events.some(event => event.type === "node_skipped")); }); -test("an edge limit is fatal under onLimit fail even when the decision is optional", async t => { +test("maxRuns is fatal under onLimit fail even when the decision is optional", async t => { const result = await braid(loop(), { cwd: await outsideGit(t), runner: async request => { request.decide?.("again"); return { output: "keep going" }; } }); - assert.equal(result.error?.code, "EDGE_LIMIT"); - assert.match(result.error!.message, /'check' -> 'work' reached its limit of 2/); - assert.equal(Object.values(result.executions).filter(value => value.id === "work").length, 3); + assert.equal(result.error?.code, "RUN_LIMIT"); + assert.match(result.error!.message, /Node 'work' reached its maxRuns of 3/); + // The rerun that would exceed it never starts. + assert.deepEqual(Object.values(result.executions).filter(value => value.id === "work").map(value => value.skipReason ?? value.status), + ["completed", "completed", "completed", "run_failed"]); }); test("execution IDs name the lineage and generation, so node IDs cannot contain @", async t => { @@ -342,16 +344,8 @@ test("a cycle can be changed mid-round; later rounds follow the current edges", assert.deepEqual(seen, ["work@1", "extra@1", "check@1", "work@2", "extra@2", "check@2", "end@1"]); }); -test("cycles must be closed by a decision's choice edge, and only that edge takes a limit", () => { +test("cycles must be closed by a decision's choice edge", () => { assert.throws(() => validateGraph({ goal: "bad", nodes: [node("a")], edges: [{ from: "a", to: "a" }] }), /closed by a decision's choice edge/); - for (const limit of [0, 1.5, -1, Number.NaN]) - assert.throws(() => validateGraph(loop(limit)), /limit must be a positive integer/); - for (const limit of [undefined, Infinity]) { - const input = loop(); - validateGraph({ ...input, edges: input.edges.map(edge => edge.to === "work" ? { from: edge.from, to: edge.to, choice: edge.choice!, ...(limit === undefined ? {} : { limit }) } : edge) }); - } - assert.throws(() => validateGraph({ ...loop(), edges: [...loop().edges.slice(0, 2), { from: "check", to: "end", choice: "done", limit: 1 }] }), - /'check' -> 'end' sets limit, but only the edge that closes a cycle can/); assert.throws(() => validateGraph({ goal: "loops", nodes: [{ type: "decision", id: "check", prompt: "p", choices: ["again"] }, node("work")], edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again" }] }), /needs an exit choice/); // Two cycles sharing check overlap. @@ -360,7 +354,7 @@ test("cycles must be closed by a decision's choice edge, and only that edge take }); test("closingEdges finds the edge that closes each cycle for displays", () => { - assert.deepEqual(closingEdges(loop()), [{ from: "check", to: "work", choice: "again", limit: 2 }]); + assert.deepEqual(closingEdges(loop()), [{ from: "check", to: "work", choice: "again" }]); // Without an external input, the entry listed first wins between two choice edges. const decisions = ["a", "b"].map(id => ({ type: "decision" as const, id, prompt: id, choices: ["next", "stop"] })); assert.deepEqual(closingEdges({ nodes: decisions, edges: [{ from: "a", to: "b", choice: "next" }, { from: "b", to: "a", choice: "next" }] }), @@ -497,12 +491,12 @@ test("node IDs such as __proto__ stay ordinary snapshot properties", async t => test("decision branches are resolved anew in each round", async t => { const inputs: string[][] = []; const result = await braid({ goal: "alternate branches", nodes: [ - { type: "decision", id: "route", prompt: "pick", choices: ["left", "right"] }, node("left"), node("right"), + { type: "decision", id: "route", prompt: "pick", choices: ["left", "right"], maxRuns: 2 }, node("left"), node("right"), { type: "decision", id: "check", prompt: "continue", choices: ["again", "done"] }, ], edges: [ { from: "route", to: "left", choice: "left" }, { from: "route", to: "right", choice: "right" }, { from: "left", to: "check" }, { from: "right", to: "check" }, - { from: "check", to: "route", choice: "again", limit: 1 }, + { from: "check", to: "route", choice: "again" }, ] }, { cwd: await outsideGit(t), runner: async request => { if (request.node.id === "route") request.decide!(request.execution.generation === 1 ? "left" : "right"); if (request.node.id === "check") { @@ -521,7 +515,7 @@ test("a replaced held instance is released, and only the replacement routes", as const runner: ModelRunner = async request => { request.decide?.("again"); return { output: request.execution.executionId! }; }; const check = { type: "decision" as const, id: "check", prompt: "review", choices: ["again", "done"], pauseAfter: true }; let run!: BraidRun; - run = startBraid(loop(5), { cwd: await outsideGit(t), runner, onEvent(event) { + run = startBraid(loop(6), { cwd: await outsideGit(t), runner, onEvent(event) { if (event.type === "execution_paused" && event.executionId === "check@1") run.update({ expectedRevision: 1, upsertNodes: [{ ...check, prompt: "review again" }] }); } }); diff --git a/test/limits.test.ts b/test/limits.test.ts index 398f7d0..f56d65a 100644 --- a/test/limits.test.ts +++ b/test/limits.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { braid, startBraid, + validateGraph, type BraidInput, type BraidOptions, type BraidRun, @@ -16,10 +17,10 @@ import { import { deferred } from "./helpers.js"; const node = (id: string) => ({ type: "execute" as const, id, prompt: id }); -const loop = (limit?: number): BraidInput => ({ +const loop = (maxRuns?: number): BraidInput => ({ goal: "refine", - nodes: [node("work"), { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }, node("end")], - edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again", ...(limit === undefined ? {} : { limit }) }, { from: "check", to: "end", choice: "done" }], + nodes: [{ ...node("work"), ...(maxRuns === undefined ? {} : { maxRuns }) }, { type: "decision", id: "check", prompt: "review", choices: ["again", "done"] }, node("end")], + edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again" }, { from: "check", to: "end", choice: "done" }], }); const until = async (condition: () => boolean, timeoutMs = 5_000) => { const deadline = performance.now() + timeoutMs; @@ -54,43 +55,127 @@ test("limits default to unlimited", async t => { assert.ok(deadlines.every(value => value === undefined)); }); -test("an edge at its limit holds the decision until the owner raises the limit or ends the cycle", async t => { +test("a rerun beyond maxRuns waits pending until the owner raises maxRuns or ends the cycle", async t => { const events: ExecutionEvent[] = []; - const run = await start(t, loop(1), { runner: repeating }, events); + const run = await start(t, loop(2), { runner: repeating }, events); await until(() => run.snapshot().status === "waiting"); - const [first, second] = executions(run, "check"); - assert.equal(first!.holdAfter, undefined); - assert.deepEqual(run.snapshot().pausedExecutionIds, [second!.executionId]); - assert.equal(second!.holdAfter?.source, "edge_limit"); - assert.equal(second!.held, true); + let state = run.snapshot(); + assert.deepEqual(executions(run, "work").map(record => [record.executionId, record.status]), + [["work@1", "completed"], ["work@2", "completed"], ["work@3", "pending"]]); + assert.deepEqual(state.limitedExecutionIds, ["work@3"]); + assert.deepEqual(state.pausedExecutionIds, []); + assert.equal(state.nodes.work!.waitReason, "limit"); + assert.equal(state.nodes.end!.waitReason, "stale"); const reached = events.find(event => event.type === "limit_reached"); - assert.deepEqual(reached && { limit: reached.limit, value: reached.value, nodeId: reached.nodeId, executionId: reached.executionId, edge: reached.edge }, - { limit: "edge", value: 1, nodeId: "check", executionId: "check@2", edge: { from: "check", to: "work", choice: "again", limit: 1 } }); - assert.deepEqual(events.filter(event => event.executionId === second!.executionId && event.type.startsWith("execution_") && event.type !== "execution_created").map(event => event.type), - ["execution_hold_added", "execution_paused"]); - assert.equal(run.snapshot().nodes.end!.waitReason, "hold"); - - // Raising the limit in the same update continues with the next round; the edge keeps its count. - const edge = { from: "check", to: "work", choice: "again" }; - run.update({ expectedRevision: run.snapshot().revision, removeEdges: [edge], addEdges: [{ ...edge, limit: 2 }], resume: [second!.executionId] }); - await until(() => executions(run, "check").length === 3 && run.snapshot().status === "waiting"); - const third = executions(run, "check")[2]!; - assert.deepEqual(run.snapshot().pausedExecutionIds, [third.executionId]); - - // Releasing it without a higher limit ends the cycle; its exit is not taken. - run.resume([third.executionId], run.snapshot().revision); + assert.deepEqual(reached && { limit: reached.limit, value: reached.value, nodeId: reached.nodeId, executionId: reached.executionId }, + { limit: "maxRuns", value: 2, nodeId: "work", executionId: "work@3" }); + const waiting = events.filter(event => event.type === "graph_waiting").at(-1); + assert.deepEqual(waiting?.type === "graph_waiting" && waiting.limitedExecutionIds, ["work@3"]); + + // Raising maxRuns edits the pending rerun in place, and it starts. + run.update({ expectedRevision: state.revision, upsertNodes: [{ ...node("work"), maxRuns: 3 }] }); + await until(() => executions(run, "work").length === 4 && run.snapshot().status === "waiting"); + state = run.snapshot(); + assert.equal(state.executions["work@3"]!.status, "completed"); + assert.equal(state.executions["work@3"]!.supersedes, undefined); + assert.deepEqual(state.limitedExecutionIds, ["work@4"]); + + // Releasing it without raising maxRuns ends the cycle: the rerun is removed and its exit is not taken. + run.resume(["work@4"], state.revision); const result = await run.result; assert.equal(result.status, "completed"); assert.equal(result.nodes.end!.status, "skipped"); assert.equal(result.nodes.end!.skipReason, "inactive"); - assert.deepEqual(result.terminalExecutionIds, [third.executionId]); - assert.equal(executions(run, "work").length, 3); + assert.deepEqual(result.terminalExecutionIds, ["check@3"]); + assert.equal(result.executions["work@4"], undefined); + assert.equal(result.nodes.work!.executionId, "work@3"); + assert.ok(events.some(event => event.type === "execution_removed" && event.executionId === "work@4")); + assert.deepEqual(run.snapshot().limitedExecutionIds, []); }); -test("onLimit fail fails the run at an edge limit", async t => { +test("onLimit fail fails the run at maxRuns", async t => { const run = await start(t, loop(1), { runner: repeating, onLimit: "fail" }); const result = await run.result; - assert.equal(result.error?.code, "EDGE_LIMIT"); + assert.equal(result.error?.code, "RUN_LIMIT"); + assert.match(result.error!.message, /Node 'work' reached its maxRuns of 1/); +}); + +test("maxRuns limits downstream reruns, never the owner's replacement", async t => { + const run = await start(t, { goal: "rerun", nodes: [node("a"), { ...node("b"), maxRuns: 1 }, { ...node("gate"), pauseAfter: true }], + edges: [{ from: "a", to: "b" }] }, { runner: async request => ({ output: request.node.prompt! }) }); + await until(() => run.snapshot().status === "waiting"); + // Replacing b starts it even though it has run maxRuns times. + run.update({ expectedRevision: 0, upsertNodes: [{ ...node("b"), prompt: "b2", maxRuns: 1 }] }); + await until(() => run.snapshot().executions["b@2"]?.status === "completed" && run.snapshot().status === "waiting"); + // A rerun of a reaches b, which is past its limit: b's rerun waits. + run.update({ expectedRevision: 1, upsertNodes: [{ ...node("a"), prompt: "a2" }] }); + await until(() => run.snapshot().limitedExecutionIds.length > 0 && run.snapshot().status === "waiting"); + let state = run.snapshot(); + assert.deepEqual(state.limitedExecutionIds, ["b@3"]); + assert.equal(state.executions["b@3"]!.waitReason, "limit"); + assert.throws(() => run.resume(["b@2"], state.revision), /held, paused, or limited/); + + // Ending the rerun in an update keeps b@2, and b is not cloned again for the same input. + run.update({ expectedRevision: state.revision, resume: ["b@3", state.pausedExecutionIds[0]!] }); + const result = await run.result; + assert.equal(result.status, "completed"); + assert.deepEqual(Object.keys(result.executions).filter(id => id.startsWith("b@")), ["b@1", "b@2"]); + assert.equal(result.nodes.b!.output, "b2"); + state = run.snapshot(); + assert.equal(state.nodes.b!.executionId, "b@2"); +}); + +test("ending a limited rerun keeps inputs that are still rerunning, and an update may remove it instead", async t => { + const gate = deferred(); + const run = await start(t, { goal: "rerun", nodes: [node("a"), { ...node("b"), maxRuns: 1 }, { ...node("hold"), pauseAfter: true }], + edges: [{ from: "a", to: "b" }] }, { runner: async request => { + if (request.execution.executionId === "a@3") await gate.promise; + return { output: request.node.prompt! }; + } }); + await until(() => run.snapshot().status === "waiting"); + run.update({ expectedRevision: 0, upsertNodes: [{ ...node("a"), prompt: "a2" }] }); + await until(() => run.snapshot().limitedExecutionIds.length > 0 && run.snapshot().status === "waiting"); + // a reruns again while b@2 waits; ending b@2 now must not accept a@3 before it settles. + run.update({ expectedRevision: 1, upsertNodes: [{ ...node("a"), prompt: "a3" }] }); + await until(() => run.snapshot().executions["a@3"]?.status === "running"); + run.resume(["b@2"], 2); + assert.equal(run.snapshot().executions["b@2"], undefined); + gate.resolve(); + await until(() => run.snapshot().limitedExecutionIds.includes("b@3") && run.snapshot().status === "waiting"); + + // Removing b and releasing its rerun in one update does not fail. + const state = run.snapshot(); + run.update({ expectedRevision: state.revision, removeNodeIds: ["b"], resume: ["b@3", ...state.pausedExecutionIds] }); + const result = await run.result; + assert.equal(result.status, "completed"); + assert.equal(result.executions["b@3"], undefined); +}); + +test("skipped reruns do not count toward maxRuns", async t => { + const result = await braid({ goal: "branch", nodes: [ + { type: "decision", id: "route", prompt: "route", choices: ["left", "right"] }, { ...node("left"), maxRuns: 1 }, node("right"), + { type: "decision", id: "check", prompt: "check", choices: ["again", "done"] }, + ], edges: [{ from: "route", to: "left", choice: "left" }, { from: "route", to: "right", choice: "right" }, + { from: "left", to: "check" }, { from: "right", to: "check" }, { from: "check", to: "route", choice: "again" }] }, { + runner: async request => { + const round = request.execution.generation!; + if (request.node.id === "route") request.decide!(round === 3 ? "left" : "right"); + if (request.node.id === "check") request.decide!(round === 3 ? "done" : "again"); + return { output: request.execution.executionId! }; + }, + }); + assert.equal(result.status, "completed", JSON.stringify(result.error)); + assert.deepEqual(Object.values(result.executions).filter(record => record.id === "left").map(record => record.status), + ["skipped", "skipped", "completed"]); +}); + +test("maxRuns is validated on every node type, and edges take no limit", () => { + for (const maxRuns of [0, 1.5, -1, Number.NaN, "2"]) + assert.throws(() => validateGraph({ goal: "x", nodes: [{ ...node("a"), maxRuns } as never], edges: [] }), /Invalid maxRuns on 'a'/); + for (const type of ["execute", "decision", "merge", "integrate"] as const) + validateGraph({ goal: "x", nodes: [{ type, id: "a", prompt: "a", ...(type === "decision" ? { choices: ["x"] } : {}), maxRuns: Infinity } as never], edges: [] }); + assert.throws(() => validateGraph({ ...loop(), edges: [{ from: "work", to: "check" }, { from: "check", to: "work", choice: "again", limit: 2 } as never] }), + /unsupported field 'limit'/); }); test("maxExecutions stops admission, lets admitted work finish, and resumes after setLimits", async t => { diff --git a/test/merge-recovery.test.ts b/test/merge-recovery.test.ts index 3015685..97659d0 100644 --- a/test/merge-recovery.test.ts +++ b/test/merge-recovery.test.ts @@ -193,8 +193,8 @@ test("merge context previews only node changes, bounds large diffs, and preserve test("loop executions derive fresh worktrees from the preceding result and never mutate the caller", async t => { const cwd = await repository(t); const paths: string[] = []; - const result = await braid({ goal: "iterate", nodes: [execute("edit"), { ...decision("check", ["again", "done"]), workspace: "read-only" }], - edges: [{ from: "edit", to: "check" }, { from: "check", to: "edit", choice: "again", limit: 2 }], + const result = await braid({ goal: "iterate", nodes: [{ ...execute("edit"), maxRuns: 3 }, { ...decision("check", ["again", "done"]), workspace: "read-only" }], + edges: [{ from: "edit", to: "check" }, { from: "check", to: "edit", choice: "again" }], }, { cwd, runner: async request => { paths.push(request.workspace!.worktreeRoot!); const contents = await readFile(join(request.workspace!.workingDirectory, "file.txt"), "utf8"); diff --git a/test/waiting.test.ts b/test/waiting.test.ts index 4275d40..2cad421 100644 --- a/test/waiting.test.ts +++ b/test/waiting.test.ts @@ -67,7 +67,7 @@ for (const outcome of ["succeeds", "fails"] as const) { assert.equal(rest.length, 0); const held = executionOf(run, "held").executionId; assert.deepEqual({ ...event, sequence: 0, timestamp: 0 }, { - type: "graph_waiting", settledExecutionId: a, pausedExecutionIds: [held], suspendedExecutionIds: [], revision: 0, sequence: 0, timestamp: 0, + type: "graph_waiting", settledExecutionId: a, pausedExecutionIds: [held], suspendedExecutionIds: [], limitedExecutionIds: [], revision: 0, sequence: 0, timestamp: 0, }); const settled = events.findIndex(item => item.executionId === a && (item.type === "node_completed" || item.type === "node_failed")); assert.ok(events.indexOf(event!) > settled, "it follows the settling execution's own event");