Skip to content

Accept unconditional, nested, and overlapping cycles, and report each new cycle - #127

Merged
Epsirom merged 1 commit into
mainfrom
chrok/peaceful-blackwell-9f3de3
Oct 25, 2026
Merged

Epsirom merged 1 commit into
mainfrom
chrok/peaceful-blackwell-9f3de3

Conversation

@Epsirom

@Epsirom Epsirom commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #114. Part of RSI Mode (#118). Builds on #128, where maxRuns replaced edge limits.

What changes

  • Validation accepts any cycle. Unconditional, nested, and overlapping cycles, cycles with several entries or exits from any node, and decisions without an exit choice are no longer rejected.
  • Closing edges only decide where each round starts. Braid picks them per strongly connected part: it keeps the other edges as ordinary dependencies, and an edge that would complete a cycle closes it instead. Preference for closing: edges into an entry (a node an outside edge enters), then a decision's choice edges, then edges back to nodes listed earlier. Graphs accepted before keep their closing edge, except a rare cycle without an entry where a decision inside it has a choice edge back to an earlier-listed node; its round start can move. That is acceptable because the cycle rules are unreleased since 0.5; docs/compatibility.md notes it. closingEdges(graph) returns them all.
  • Exits per round. Edges that leave a cycle wait while any of its nodes reruns. For a cycle closed by a decision's choice edge they still wait for the final round. For an unconditional closing edge they fire in every round: downstream nodes outside the cycle run with each finished round (inactive ones wait for a later round). Round clones are created at the end of the scheduling pass, so exits are admitted before the next round takes the slots; Limit how often a node runs with maxRuns instead of edge limits #128's maxRuns check still applies when a clone is admitted.
  • Overlapping cycles that reach a shared node together merge: the first closing edge clones it, the second attaches to the clone as another input.
  • Bounds. Cycles are bounded by maxRuns on their nodes (Limit how often a node runs with maxRuns instead of edge limits #128): total runs, never reset per round of an enclosing cycle (tested and documented).
  • Cycle notices. Each submission or update that creates cycles emits cycles_created with each new cycle (GraphCycle: nodeIds starting where its rounds start, edges ending with its closing edge, stops = the decision choice edges on it, limitedNodeIds = its nodes with a finite maxRuns; at most 20, with truncated). newCycles(graph, previous?) computes the same list. Pi and DSH braid / braid_update results show it, and flag a cycle with nothing that stops or bounds it, so an accidental test → fix next to fix → test is noticed.
  • Pi and DSH tool descriptions, guidance, and READMEs describe the new rules. Pi's display.ts gets only the event-text case the exhaustive switch requires.

Docs

docs/execution-control.md (Cycles, Cycle notices), README graph schema and out-of-scope list, ROADMAP (#114 checked, scope row), and a 0.6 migration subsection in docs/compatibility.md.

Compatibility

Not labelled breaking: existing callers need no changes. TypeScript code with an exhaustive switch over ExecutionEvent needs a case for cycles_created.

Decided: maxRuns never resets

maxRuns is the most runs a lineage can reach, including in a nested cycle across rounds of the enclosing one; it does not reset per round. The owner raises or lowers it by updating the node; since #129, an upsert that only changes maxRuns keeps a started head instead of replacing it.

Tests

New test/cycles.test.ts covers:

  • an unconditional cycle with an exit that runs each round, ended at maxRuns
  • a nested cycle, and maxRuns counting across outer rounds
  • overlapping cycles merging at a shared node
  • cycle reports for submissions and updates, and the report cap

Validation and closing-edge tests are updated, and Pi and DSH tests cover the notices. npm run verify passes on main with #128 and #129 (core 309, Pi 195, DSH 69, package smoke).

@Epsirom Epsirom added the enhancement New feature or request label Oct 25, 2026
@Epsirom
Epsirom force-pushed the chrok/peaceful-blackwell-9f3de3 branch from 956a1a9 to 4f8a70d Compare October 25, 2026 14:09
@Epsirom
Epsirom force-pushed the chrok/peaceful-blackwell-9f3de3 branch from 4f8a70d to c57f78f Compare October 25, 2026 14:31
@Epsirom
Epsirom changed the base branch from main to chrok/blissful-hopper-09c273 October 25, 2026 14:31
Base automatically changed from chrok/blissful-hopper-09c273 to main October 25, 2026 14:33
Epsirom added a commit that referenced this pull request Oct 25, 2026
## Change

Part of RSI Mode ([#118](#118)).
Replaces the per-edge `limit` added in #124 with an optional `maxRuns`
on every node type. It lands before #127 (arbitrary cycles), which no
longer needs to prefer limited edges when it picks closing edges, and
before 0.6, so edge limits were never released.

People think "review at most 3 times", not "this edge fires n-1 times",
and with arbitrary cycles it is hard to tell which edge closes a cycle
or to bound overlapping cycles at a shared node with one edge's limit. A
node limit has none of those problems.

### Behavior

- `maxRuns` counts the executions of a node's lineage that start;
skipped ones do not count. Unlimited when omitted or `Infinity`.
`maxRuns: 3` on a node in a cycle allows three rounds.
- When an edge reaching a settled head would rerun it past `maxRuns`,
the clone is still created but stays `pending` with the new wait reason
`limit`. Once its inputs are current, core emits `limit_reached`
(`limit: "maxRuns"`, the waiting `executionId`) and lists it in the new
`BraidSnapshot.limitedExecutionIds` and
`graph_waiting.limitedExecutionIds`, so a run with nothing else active
waits and Pi/DSH wake the owner with the waiting reminder.
- Raising `maxRuns` with a normal `update`/`upsertNodes` edits the
pending rerun in place and it starts.
- Releasing it with `resume` (or `resume` in an update) without raising
ends the rerun: it is removed (`execution_removed`), the node keeps its
last result, and nothing downstream reruns for it. In a cycle this
mirrors the old "release without raising ends the loop": the decision
chose the closing edge, so its exits stay inactive. If the same update
raises `maxRuns`, the rerun starts instead.
- An owner's replacement of a started node is never blocked; the limit
only caps automatic reruns (cycle rounds and downstream cascades).
- Under `onLimit: "fail"` the run fails with the new code `RUN_LIMIT`.

### Removed

`Edge.limit`, the rule that a limit must sit on a cycle-closing edge,
the `edge_limit` hold source, `limit_reached.edge`, and `EDGE_LIMIT`.
Cycle validation is otherwise unchanged. `removeEdges` now accepts only
`from`, `to`, and `choice`.

### Pi and DSH

- Node schemas accept `maxRuns`; edge schemas drop `limit`. Tool
guidance, `onLimit` text, and `braid_update`/`braid_resume` descriptions
explain raising or releasing a waiting rerun.
- The waiting reminder lists a rerun at its limit as a `WaitingItem` of
kind `limited` (source `maxRuns`) and says how to raise or release it.
`formatEdgeLimit` and `edgeLimit` on completion notices are removed.
Pi's `OutstandingJob` and the single-shot drain warning include limited
reruns.
- Status text shows `maxRuns=` on nodes, the `limit` wait reason, and
reruns at their limit. The Pi live view restores the previous head when
a rerun is released. The DSH panel drops edge limits and shows a node's
`maxRuns`.
- `/braid:review` bounds its rounds with `maxRuns: 3` on its writer node
instead of an edge limit and still fails at the cap.

### Docs

Execution control (cycles, wait reasons, `graph_waiting`, Limits),
README graph schema and options, resource limits, the 0.6 migration in
`docs/compatibility.md` (`maxIterations n` becomes `maxRuns n` on a node
in the cycle), ROADMAP scope table, and both adapter READMEs.

### Compatibility

Not breaking against released versions: edge limits were never released.
Callers of 0.5 still migrate `maxIterations` as described in the 0.6
guide, now to `maxRuns`.

## Validation

`npm run verify` passes (core 303, Pi 194, DSH 68, package smoke).
Edge-limit tests are replaced by `maxRuns` tests: raising in place and
releasing to end a cycle, `RUN_LIMIT`, downstream reruns versus the
owner's replacement, skipped runs not counting, releasing while an input
still reruns, removing a node and releasing its rerun in one update, and
validation. Pi and DSH tests cover the waiting reminder, schemas,
`/braid:review`, and the live-view restore.

- [x] Relevant tests and type checks pass.
- [x] User-facing docs are updated where needed.
- [x] The title reads as a release-note line and the category label is
correct.
- [x] Breaking changes, if any, include migration guidance in the docs.
@Epsirom
Epsirom force-pushed the chrok/peaceful-blackwell-9f3de3 branch from c57f78f to e344a28 Compare October 25, 2026 14:35
Epsirom added a commit that referenced this pull request Oct 25, 2026
Follow-up to #128. Part of RSI Mode (#118).

`maxRuns` is the most runs a node's lineage can reach, and it never
resets, including per round of an enclosing cycle (decided in #127). The
owner adjusts it by updating the node. Before this change, that worked
only while a rerun was waiting at the limit. On a running or settled
head, any upsert replaced it: raising `maxRuns` ahead of time cancelled
a running execution and reran it, and reran a settled one.

## What changes

- An upsert whose definition differs from the current one **only in
`maxRuns`** keeps a started head. A running head keeps running, and a
settled one keeps its result and any hold. Later reruns (clones) use the
new limit, so the limit can be raised or lowered at any time.
- Any other change, including an unchanged upsert, still replaces a
started head as before. A pending head (including a rerun waiting at its
limit) is still edited in place.
- The started head's own `node` keeps the definition it ran with;
`snapshot().graph` shows the new `maxRuns`.
- Docs: `docs/execution-control.md` (live updates, limits),
`GraphUpdate.upsertNodes` doc, `docs/compatibility.md`, and the Pi/DSH
READMEs and tool descriptions (`maxRuns`, `braid_update`).

## Tests

`test/limits.test.ts` covers:
- raising `maxRuns` while the head runs: no cancellation, and later
reruns stop at the new limit
- changing only `maxRuns` on a settled, held head: no replacement, and
the hold is kept
- any other change still replaces the head

`npm run verify` passes (core 305, Pi 194, DSH 68, package smoke).

## Overlap with #127

#127 edits the cycle paragraphs of the same READMEs and docs. This PR
puts its sentences elsewhere to avoid conflicts, but whichever merges
second may need a trivial rebase.
@Epsirom
Epsirom force-pushed the chrok/peaceful-blackwell-9f3de3 branch from e344a28 to 51f08a2 Compare October 25, 2026 14:54
@Epsirom
Epsirom merged commit f87bf82 into main Oct 25, 2026
18 checks passed
@Epsirom
Epsirom deleted the chrok/peaceful-blackwell-9f3de3 branch October 25, 2026 14:57
Epsirom added a commit that referenced this pull request Oct 27, 2026
…hat wait for a choice (#131)

## Change

Part of RSI Mode (#118). Follows #127. Found while testing 0.6 in Pi
with a task-queue graph: `impl -> tests/docs -> merge -> review`, with
`review -> impl [choice=again]` and `maxRuns` on `impl`. Braid closed
the cycle at `impl -> merge`, so each round started at `merge`. `impl`
ran only after review chose `again`, and the first `merge` ran without
it.

The heuristic was not changed. `impl -> merge -> review -(again)-> impl`
has the same shape as `build -> check -(again)-> fix -> build`, where
starting at `build` is right. So the graph author now says which one
they mean, and Braid points out the ambiguity:

- **`Edge.closesCycle: true`** makes an edge close the cycle it is on,
so each round starts at its target. Braid still chooses closing edges
for any other cycles. Validation rejects the flag on an edge that is not
on a cycle. The flag is not part of the edge's identity, so changing it
in an update means removing the edge and adding it back (tested). It is
accepted by core, the replan tool schema, and the Pi and DSH edge
schemas. Pi's panel keeps it on `edge_created`.
- **`GraphCycle.choiceGated`** lists the cycle's non-closing choice
edges whose target waits only on choice edges, meaning the target does
not run until the decision picks it. Pi and DSH cycle notices add a line
for each:
> impl runs only after review chooses again, not before it in the first
round; if each round should start at impl, set closesCycle: true on
review -> impl [choice=again].
- **Reminder wording.** Waiting and node-timeout-pause reminders said
"The graph timeout keeps running" even when no graph timeout was set. Pi
and DSH `WaitingNotice` and `TimeoutPause` now carry `graphTimeoutMs`
from the snapshot's limits. The sentence appears only when it is set,
and not when the job is paused by `graph_timeout` itself.

Docs: `docs/execution-control.md` (Cycles, Cycle notices), README, the
Pi and DSH READMEs, and the 0.6 section of `docs/compatibility.md`.

Compatibility: no changes needed from callers. Code that compares whole
`GraphCycle` objects needs the new `choiceGated` field.

## Validation

`npm run verify:checks` passes: core 312/312, Pi 196/196, DSH 70/70,
plus type checks and examples.

New tests:
- `test/cycles.test.ts`: `choiceGated` is reported for the task-queue
graph and disappears with `closesCycle`; with `closesCycle`, `merge@1`
waits for `impl@1`, `tests@1`, and `docs@1`, and `impl@2` follows
`review@1`; validation of the flag, and moving it with an update.
- Pi and DSH: exact cycle-notice text with and without `closesCycle`,
and the reminder with and without a graph timeout and when paused by
`graph_timeout`. Pi also checks that its schema accepts `closesCycle`.

Not re-tested in Pi yet, because the local Pi install points at the main
checkout.

- [x] Relevant tests and type checks pass.
- [x] User-facing docs are updated where needed.
- [x] The title reads as a release-note line and the category label is
correct
(see [release
notes](https://github.com/Epsirom/braid/blob/main/docs/releasing.md#release-notes)).
- [x] Breaking changes, if any, include migration guidance in the docs.
- [ ] For release PRs: `.github/upgrade-notes.md` lists what users must
do to upgrade.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Core: accept unconditional, nested, and overlapping cycles

1 participant