fix(undo): preserve partial /redo progress, bound _states, public redo_count bump, clamp /redo - #65
Conversation
…o_count bump, clamp /redo Four follow-up fixes from Greptile review of merged PR #49 (half-turn /undo+/redo). 1. hermes_undo.redo: a multi-op /redo that restored rows in an earlier op then hit the transcript-rewrite path (restore_ids->0) in a later op discarded the whole stack and returned reactivated_count:0 — so the caller printed 'nothing to redo' and SKIPPED its history reload (screen/DB desync), with redo_count never bumped despite committed work. Now stops at the rewrite, keeps + reports the earlier ops' progress, and bumps redo_count only when real work committed. 2. hermes_state.SessionDB.bump_redo_count: new public helper; hermes_undo no longer reaches into the private _execute_write. 3. hermes_undo._states: was an unbounded module-global dict keyed by session_id (memory leak in a long-running gateway). Now an LRU OrderedDict capped at _STATE_CAP=2048; eviction drops an in-memory redo branch, identical to the existing 'redo doesn't survive a restart' contract. 4. cli.py /redo handler: add the non-positive-count clamp /undo already has, so /redo 0 and /redo -N clamp to 1 instead of a misleading 'nothing to redo'. Tests: 8 new cases (partial-progress preservation, helper usage, LRU eviction, clamp parametrization) + full undo/redo suite (49) green.
🔎 Lint report:
|
| Rule | Count |
|---|---|
invalid-argument-type |
1 |
First entries
tests/cli/test_undo_redo_half_turn.py:140: [invalid-argument-type] invalid-argument-type: Argument to function `HermesCLI.process_command` is incorrect: Expected `HermesCLI`, found `SimpleNamespace`
✅ Fixed issues: none
Unchanged: 5856 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
|
CI note (not a blocker): the
All deterministic floor checks are green: |
|
| Filename | Overview |
|---|---|
| hermes_undo.py | Core redo logic correctly preserves partial-op progress on transcript-rewrite path, LRU OrderedDict cap is properly implemented, and private _execute_write access is replaced with the new public helper. |
| hermes_state.py | Adds public bump_redo_count() helper; clean delegation to _execute_write with no logic changes to the surrounding code. |
| cli.py | Adds non-positive count clamp for /redo (parity with /undo), but redo_last does not surface the new partial-success message that hermes_undo.redo() now returns. |
| tests/test_undo_redo_stack.py | New tests cover partial-progress preservation, LRU eviction with recency refresh, public-helper contract via AST/source inspection, all scenarios well-parameterised and cleanly isolated. |
| tests/cli/test_undo_redo_half_turn.py | Adds parametrised tests for /redo count clamping (0, -3) and non-numeric rejection, consistent with the /undo contract. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["/redo N called"] --> B["k = min(N, len(undo_stack))"]
B --> C{k == 0?}
C -->|Yes| D["Return: nothing to redo"]
C -->|No| E["Loop k times: pop op from undo_stack"]
E --> F["db.restore_ids(op.rewound_ids)"]
F --> G{reactivated == 0\nAND op had ids?}
G -->|Yes: transcript rewritten| H["transcript_changed = True\nbreak loop"]
G -->|No| I{reactivated != len\nop.rewound_ids?}
I -->|Yes: partial| J["raise RuntimeError\n(fail loud)"]
I -->|No: full restore| K["reactivated_total += reactivated\nops_redone += 1\nredo_stack.append(op)"]
K --> E
H --> L{reactivated_total > 0?}
L -->|No work done| M["undo_stack.clear()\nredo_stack.clear()\nReturn: reactivated=0 + message"]
L -->|Earlier ops committed| N["undo_stack.clear()\nfall through"]
N --> O["db.bump_redo_count(session_id)"]
K --> O
O --> P["Return: reactivated_total\n+ optional partial message"]
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A["/redo N called"] --> B["k = min(N, len(undo_stack))"]
B --> C{k == 0?}
C -->|Yes| D["Return: nothing to redo"]
C -->|No| E["Loop k times: pop op from undo_stack"]
E --> F["db.restore_ids(op.rewound_ids)"]
F --> G{reactivated == 0\nAND op had ids?}
G -->|Yes: transcript rewritten| H["transcript_changed = True\nbreak loop"]
G -->|No| I{reactivated != len\nop.rewound_ids?}
I -->|Yes: partial| J["raise RuntimeError\n(fail loud)"]
I -->|No: full restore| K["reactivated_total += reactivated\nops_redone += 1\nredo_stack.append(op)"]
K --> E
H --> L{reactivated_total > 0?}
L -->|No work done| M["undo_stack.clear()\nredo_stack.clear()\nReturn: reactivated=0 + message"]
L -->|Earlier ops committed| N["undo_stack.clear()\nfall through"]
N --> O["db.bump_redo_count(session_id)"]
K --> O
O --> P["Return: reactivated_total\n+ optional partial message"]
Comments Outside Diff (1)
-
cli.py, line 6209-6210 (link)Partial-redo warning message is silently dropped
hermes_undo.redo()now setsresult["message"]on a partial-success path (e.g. "redid 1 operation(s); the rest can't be redone (transcript changed since undo)"), butredo_lastonly readsresult.get('message')whenreactivated <= 0. Whenreactivated > 0the message is ignored entirely — a user who runs/redo 3and gets only 1 op restored sees(^_^)b Redid 3 undo operation(s) (X message(s) restored).with no indication the other two ops were silently dropped. Consider printingresult.get("message")after the success line when it is present.
Reviews (1): Last reviewed commit: "fix(undo): preserve partial /redo progre..." | Re-trigger Greptile
Summary
Four follow-up fixes for the real issues Greptile flagged on merged PR #49
(
feat: reversible half-turn /undo + /redo across CLI, gateway, TUI). All fourwere valid; none were addressed before that PR merged.
1.
redo()discarded earlier progress on a multi-op/redo(count/state desync)In a
/redo N(N≥2), the loop pops ops LIFO. If an earlier op restored rows(real DB work —
redo_stackgrew) and a later op hit the transcript-rewritepath (
restore_ids→ 0, e.g. after/compressor/retry), the old codecleared the whole stack and returned
reactivated_count: 0. Consequences:reactivated <= 0, prints "Nothing to redo", and skips itshistory reload → on-screen transcript desyncs from the DB, which did change;
redo_countis never bumped even though an op committed.Now: stop at the rewrite, keep and report the earlier ops' progress
(
reactivated_count > 0+ an explanatory message), drop only the dead remainder,and bump
redo_countonly when real work committed. A single-op partial restorestill fails loud (unchanged).
2. Reaching into the private
SessionDB._execute_writeredo()bumpedredo_countviadb._execute_write(...). Added a publicSessionDB.bump_redo_count(session_id)helper and call that instead.3. Unbounded
_statesmodule-global (gateway memory leak)_states: Dict[str, UndoRedoState]was keyed bysession_idand never evicted,growing without limit in a long-running gateway. Replaced with an LRU
OrderedDictcapped at_STATE_CAP = 2048. Eviction merely drops an in-memoryredo branch — identical to the existing "redo doesn't survive a restart" contract
that
redo()already handles gracefully.4.
/redohad no non-positive-count guard/undoclamps a non-positive count to 1;/redo 0//redo -1fell through to amisleading "nothing to redo". Added the same clamp for parity.
Tests
8 new cases + full undo/redo suite green:
Behavior contracts, not snapshots: partial-progress preservation,
_statesLRUeviction (recency-refresh on access), helper-not-private-access (AST/source check),
and clamp parity.