Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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
Expand Down Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
50 changes: 32 additions & 18 deletions docs/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
55 changes: 32 additions & 23 deletions docs/execution-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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" },
],
};
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -443,31 +442,41 @@ 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` |

`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
Expand Down
7 changes: 4 additions & 3 deletions docs/resource-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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` |
Expand Down
Loading
Loading