Skip to content

feat(phase 2): sibling-branch pruning on compress + rewind with notification - #70

Merged
yogthos merged 1 commit into
mainfrom
feat/phase2-sibling-prune
May 21, 2026
Merged

feat(phase 2): sibling-branch pruning on compress + rewind with notification#70
yogthos merged 1 commit into
mainfrom
feat/phase2-sibling-prune

Conversation

@yogthos

@yogthos yogthos commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Phase 2 of the 6-phase plan. Pruning algorithm mirrors opencode's drop-with-truncation (no fancy preservation), but adds an explicit chat notification when forked branches are discarded so the loss isn't silent. New Session::compress_reporting returns count of sibling nodes pruned. Same algorithm reused in rewind_session. 3 new tests, 639 pass.

…ication

Phase 2 of the 6-phase plan. Reference pattern: opencode's
`packages/opencode/src/session/compaction.ts:386-396` — drop branches
outside the preserved tail with no special preservation. dirge adds
an explicit "discarded N forked branches" notification on top so the
user sees the loss instead of finding it later in `/tree`.

## Problem

When `Session::compress` (or `rewind_session`) drops messages from
the active path, any sibling branches whose parent nodes were among
the dropped ids end up with `tree.entries[child].parent` pointing
at a removed id. Subsequent `switch_to_leaf` / `/tree` walks
either fail silently or render dangling phantom branches.

Before Phase 2: dropped messages removed from tree + store, but
their forked descendants were left orphaned.

## Fix

New `Session::compress_reporting(summary, cut_idx, savings) -> usize`:
- Builds the to-drop set from active-path ids (`messages[..cut_idx]`).
- Builds an EXCLUSION set of currently-active ids (messages still
  in `self.messages` after drain + summary insert) — without this
  the new first-kept message gets caught because its parent is
  still pointing at a dropped id at this point in the function.
- Fixed-point walk: each pass finds tree entries whose parent is
  in `to_prune` but which aren't excluded, adds them; repeat until
  no change. O(N * K) where K = avg branch depth.
- Prunes the union from `tree.entries` + `message_store`.
- Returns the count of non-active-path nodes pruned (subtracts
  the direct dropped count) so the caller can notify.

`Session::compress` becomes a thin `#[cfg(test)]` wrapper that
calls `compress_reporting` and discards the count, kept for the
4 existing compress tests that didn't care about the count.

Mirror the same walk in `ui::mod.rs::rewind_session` — both paths
that prune active-path messages now also prune dependent siblings.

`handle_compress` (slash.rs) renders a red
`discarded N forked branch node(s) that were rooted in the
compressed region` line when the count is non-zero. Same for
rewind. opencode handles this silently; dirge surfaces it
because branched sessions are intentional and a silent loss
would surprise users.

## Tests

3 new session tests:
- `compress_prunes_sibling_branches_rooted_at_dropped_messages` —
  builds a branched fixture (u1 → a1 → u2 → a2 with a sibling
  subtree `sib1 → sib2` rooted at u1), compresses past u1, asserts
  both sibs are gone from tree + store and the count is 2.
- `compress_reports_zero_pruned_when_no_siblings` — linear
  session, count should be 0.

Plus the existing rewind tests still pass (the new prune logic
doesn't fire when there are no siblings, so linear rewind is
unaffected).

639 pass total (was 637), 0 warnings across all build profiles.

## Test plan

- [x] `cargo test --features plugin` -> 639 pass.
- [x] `cargo build --all-features` -> 0 warnings.

## Up next: Phase 3 (tool-call structured persistence)

When a session is resumed, prior tool calls + results are
currently lost (only assistant text is in `SessionMessage`). The
LLM sees text-only traces and may re-attempt the same tools.
Phase 3 will add structured `tool_calls: Vec<ToolCallEntry>` to
`SessionMessage` (opencode's `ToolPart` pattern with interrupted-
state pairing for Anthropic compatibility).
@yogthos
yogthos merged commit e8af8c2 into main May 21, 2026
1 check passed
@yogthos
yogthos deleted the feat/phase2-sibling-prune branch May 21, 2026 02:39
yogthos added a commit that referenced this pull request May 21, 2026
)

Phase 5 of the plan. No clean equivalent in opencode or pi — both
manage allowlist entries only via the interactive permission
prompt's "allow always" answer. dirge adds explicit CRUD so users
can inspect, manually add, drop one, or clear all entries without
editing the session JSON.

## Commands

- `/allow` or `/allow list` — show numbered entries
- `/allow add <tool> <pattern>` — add a (tool, pattern) entry,
  e.g. `/allow add bash 'cargo *'`
- `/allow remove <idx>` — drop one entry by 0-based index from
  the `list` output
- `/allow clear` — drop all entries

The `add` form preserves pattern strings containing spaces by
re-deriving the args from the raw `text` slice (rather than
relying on the SmallVec `parts[2]` which would get truncated at
the first whitespace). Same approach `/cd` uses for paths.

## State sync

Both surfaces stay aligned:
- `PermissionChecker::session_allowlist` (in-memory, used by
  `check` / `check_path`) — updated via the existing
  `add_session_allowlist` and the two new
  `remove_session_allowlist_at(idx)` + `clear_session_allowlist`.
- `Session::permission_allowlist` (persisted to JSON, restored
  on resume via `load_session_allowlist`) — mirrored
  manually in the slash handler so save/load round-trips show
  the user-edited list.

Without the session-side mirror, a `/allow add foo bar` would
stay in memory for this run but vanish on `-c` resume. Dedup at
the session level too so `save()` writes a clean list.

## Tests

3 new permission tests, written failing first:

- `remove_session_allowlist_at_returns_removed_entry`:
  remove(1) on [bash:cargo*, bash:git*, read:/tmp/*] yields
  Some(("bash","git *")) and the surviving entries shift down.
- `remove_session_allowlist_at_out_of_range_returns_none`:
  remove(99) returns None without panicking; existing entries
  intact.
- `clear_session_allowlist_empties_the_list`: clear() drops
  all entries.

The slash handler itself is not unit-tested (requires the full
UI loop fixtures); manual smoke test:

```
$ dirge --restrictive
> bash 'echo hi'
> (a) allow always
> /allow list      → [0] bash echo *
> /allow add read /tmp/*
> /allow list      → [0] bash echo *  [1] read /tmp/*
> /allow remove 0
> /allow list      → [0] read /tmp/*
> /allow clear
> /allow list      → empty
```

## Docs

`/help` text gets four new lines covering each subcommand.

## Test plan

- [x] `cargo test --features plugin` -> 649 pass (was 646).
- [x] `cargo build --all-features` -> compiles.
- [x] `cargo build --no-default-features` -> compiles.

## Plan status

| Phase | PR | Done |
|---|---|---|
| 1 | #69 | first-wins block + docs |
| 2 | #70 | sibling-branch pruning + notification |
| 3 | #71 | structured tool-call persistence |
| 4 | #72 | branch summary metadata in /tree |
| 5 | this | /allow CRUD |
| 6 | — | skipped per user request (cost tracking) |

5 of 6 phases complete; Phase 6 (cost tracking) skipped.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…ication (dirge-code#70)

Phase 2 of the 6-phase plan. Reference pattern: opencode's
`packages/opencode/src/session/compaction.ts:386-396` — drop branches
outside the preserved tail with no special preservation. dirge adds
an explicit "discarded N forked branches" notification on top so the
user sees the loss instead of finding it later in `/tree`.

## Problem

When `Session::compress` (or `rewind_session`) drops messages from
the active path, any sibling branches whose parent nodes were among
the dropped ids end up with `tree.entries[child].parent` pointing
at a removed id. Subsequent `switch_to_leaf` / `/tree` walks
either fail silently or render dangling phantom branches.

Before Phase 2: dropped messages removed from tree + store, but
their forked descendants were left orphaned.

## Fix

New `Session::compress_reporting(summary, cut_idx, savings) -> usize`:
- Builds the to-drop set from active-path ids (`messages[..cut_idx]`).
- Builds an EXCLUSION set of currently-active ids (messages still
  in `self.messages` after drain + summary insert) — without this
  the new first-kept message gets caught because its parent is
  still pointing at a dropped id at this point in the function.
- Fixed-point walk: each pass finds tree entries whose parent is
  in `to_prune` but which aren't excluded, adds them; repeat until
  no change. O(N * K) where K = avg branch depth.
- Prunes the union from `tree.entries` + `message_store`.
- Returns the count of non-active-path nodes pruned (subtracts
  the direct dropped count) so the caller can notify.

`Session::compress` becomes a thin `#[cfg(test)]` wrapper that
calls `compress_reporting` and discards the count, kept for the
4 existing compress tests that didn't care about the count.

Mirror the same walk in `ui::mod.rs::rewind_session` — both paths
that prune active-path messages now also prune dependent siblings.

`handle_compress` (slash.rs) renders a red
`discarded N forked branch node(s) that were rooted in the
compressed region` line when the count is non-zero. Same for
rewind. opencode handles this silently; dirge surfaces it
because branched sessions are intentional and a silent loss
would surprise users.

## Tests

3 new session tests:
- `compress_prunes_sibling_branches_rooted_at_dropped_messages` —
  builds a branched fixture (u1 → a1 → u2 → a2 with a sibling
  subtree `sib1 → sib2` rooted at u1), compresses past u1, asserts
  both sibs are gone from tree + store and the count is 2.
- `compress_reports_zero_pruned_when_no_siblings` — linear
  session, count should be 0.

Plus the existing rewind tests still pass (the new prune logic
doesn't fire when there are no siblings, so linear rewind is
unaffected).

639 pass total (was 637), 0 warnings across all build profiles.

## Test plan

- [x] `cargo test --features plugin` -> 639 pass.
- [x] `cargo build --all-features` -> 0 warnings.

## Up next: Phase 3 (tool-call structured persistence)

When a session is resumed, prior tool calls + results are
currently lost (only assistant text is in `SessionMessage`). The
LLM sees text-only traces and may re-attempt the same tools.
Phase 3 will add structured `tool_calls: Vec<ToolCallEntry>` to
`SessionMessage` (opencode's `ToolPart` pattern with interrupted-
state pairing for Anthropic compatibility).

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…irge-code#73)

Phase 5 of the plan. No clean equivalent in opencode or pi — both
manage allowlist entries only via the interactive permission
prompt's "allow always" answer. dirge adds explicit CRUD so users
can inspect, manually add, drop one, or clear all entries without
editing the session JSON.

## Commands

- `/allow` or `/allow list` — show numbered entries
- `/allow add <tool> <pattern>` — add a (tool, pattern) entry,
  e.g. `/allow add bash 'cargo *'`
- `/allow remove <idx>` — drop one entry by 0-based index from
  the `list` output
- `/allow clear` — drop all entries

The `add` form preserves pattern strings containing spaces by
re-deriving the args from the raw `text` slice (rather than
relying on the SmallVec `parts[2]` which would get truncated at
the first whitespace). Same approach `/cd` uses for paths.

## State sync

Both surfaces stay aligned:
- `PermissionChecker::session_allowlist` (in-memory, used by
  `check` / `check_path`) — updated via the existing
  `add_session_allowlist` and the two new
  `remove_session_allowlist_at(idx)` + `clear_session_allowlist`.
- `Session::permission_allowlist` (persisted to JSON, restored
  on resume via `load_session_allowlist`) — mirrored
  manually in the slash handler so save/load round-trips show
  the user-edited list.

Without the session-side mirror, a `/allow add foo bar` would
stay in memory for this run but vanish on `-c` resume. Dedup at
the session level too so `save()` writes a clean list.

## Tests

3 new permission tests, written failing first:

- `remove_session_allowlist_at_returns_removed_entry`:
  remove(1) on [bash:cargo*, bash:git*, read:/tmp/*] yields
  Some(("bash","git *")) and the surviving entries shift down.
- `remove_session_allowlist_at_out_of_range_returns_none`:
  remove(99) returns None without panicking; existing entries
  intact.
- `clear_session_allowlist_empties_the_list`: clear() drops
  all entries.

The slash handler itself is not unit-tested (requires the full
UI loop fixtures); manual smoke test:

```
$ dirge --restrictive
> bash 'echo hi'
> (a) allow always
> /allow list      → [0] bash echo *
> /allow add read /tmp/*
> /allow list      → [0] bash echo *  [1] read /tmp/*
> /allow remove 0
> /allow list      → [0] read /tmp/*
> /allow clear
> /allow list      → empty
```

## Docs

`/help` text gets four new lines covering each subcommand.

## Test plan

- [x] `cargo test --features plugin` -> 649 pass (was 646).
- [x] `cargo build --all-features` -> compiles.
- [x] `cargo build --no-default-features` -> compiles.

## Plan status

| Phase | PR | Done |
|---|---|---|
| 1 | dirge-code#69 | first-wins block + docs |
| 2 | dirge-code#70 | sibling-branch pruning + notification |
| 3 | dirge-code#71 | structured tool-call persistence |
| 4 | dirge-code#72 | branch summary metadata in /tree |
| 5 | this | /allow CRUD |
| 6 | — | skipped per user request (cost tracking) |

5 of 6 phases complete; Phase 6 (cost tracking) skipped.

Co-authored-by: Yogthos <yogthos@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant