Private
Public Access
0
0
Files
manual_slop/docs/guide_state_lifecycle.md
T
conductor-tier2 161ebb0da6 docs(fix): correct nav link case + relative-path level
Gitea (and any case-sensitive filesystem) was rendering the [Top]
nav links in /docs as broken because of two bugs:

1. Case-sensitivity: 22 links used '../README.md' (all-uppercase)
   but the actual file is 'docs/Readme.md' (capital R, lowercase
   rest). 21 guide_*.md nav bars were affected, plus 1 internal
   cross-link in Readme.md itself. Works on Windows (case-
   insensitive) but broken on Linux/Gitea.

   Fix: 22 occurrences across 22 files changed
   '../README.md' -> '../Readme.md'

2. Wrong relative-path level: 16 links used '../../conductor/...'
   from 'docs/guide_*.md' to reach 'conductor/'. This goes up 2
   levels to 'projects/', which doesn't exist. The correct path
   from 'docs/guide_*.md' to 'conductor/' is 1 level up
   ('../conductor/...'). 12 unique patterns across 10 files
   affected.

   Fix: 16 occurrences across 10 files changed
   '../../conductor/' -> '../conductor/'

3. Bonus: 1 planned-guide link in guide_context_curation.md
   referenced a never-written 'guide_context_presets.md'. The
   ContextPreset schema is now fully covered in the new
   'guide_context_aggregation.md' (per the 2026-06-08 docs
   refresh). Fix: link target updated.

No content was changed, only link paths. 24 files, 37 link
replacements, 37 deletions.

Verification:
- All .md links in docs/ now resolve to existing files
  (validated by path-resolution check from each file's directory)
- The 3 new guides from the previous docs refresh commit
  (guide_discussions.md, guide_state_lifecycle.md,
  guide_context_aggregation.md) had the case bug inherited from
  guide_architecture.md's existing nav pattern; their top-of-file
  nav bars are now correct
- The 21 pre-existing guide nav bars that had the same bug
  (all 21 of them, except the 3 that used the correct case:
  guide_mma.md, guide_simulations.md, guide_tools.md) are now
  also fixed
- Inter-guide links (e.g. [Discussions](guide_discussions.md))
  were not affected; they were always correct because both the
  link text and the actual filename are lowercase

This is a docs-only fix. No code modified.
2026-06-08 19:51:55 -04:00

24 KiB
Raw Blame History

State Lifecycle: Undo/Redo, Reset, and State Delegation

Top | App Controller | Discussions | GUI Main


Overview

Manual Slop's state lifecycle has three load-bearing concerns:

  1. Undo/Redo via HistoryManager + UISnapshot — the non-provider history system
  2. Reset via _handle_reset_session — the "throw away everything" flow
  3. State delegation via App.__getattr__/__setattr__ — the App is a thin proxy over the Controller

This guide covers the implementation of all three. The design philosophy is documented in conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1 (state visibility) and §9 (edit-the-input, not the output).

Domain classification. All three concerns are Application-domain. The Hook API (which exposes state) is also Application, but crosses into Meta-Tooling via the bridge scripts. See guide_meta_boundary.md.


1. Undo/Redo: HistoryManager + UISnapshot

Why a Non-Provider History?

Manual Slop's history is not the same as the provider-side conversation history. The provider (ai_client._anthropic_history, _deepseek_history, etc.) tracks the exact bytes sent to and received from the LLM. The Manual Slop history (app.history + UISnapshot) tracks the user's UI state — text inputs, sliders, file lists, discussions.

This separation is intentional. It means:

  • The user can Ctrl+Z to undo a typo in the AI input box, even if the previous LLM call's history is preserved on the provider side.
  • The provider's history is the authoritative transcript for the LLM; the Manual Slop history is the user's working state.
  • A reset of the Manual Slop state does not clear the provider's history (and vice versa) — see §"Reset" below.

This is exactly the kind of "edit the input, not the output" pattern nagent uses; see nagent takeaways §9.

UISnapshot — The Serializable State

src/history.py:8 UISnapshot is a frozen-shaped dataclass capturing the 13 user-mutable fields:

Field Type Source
ai_input str self.ui_ai_input
project_system_prompt str self.ui_project_system_prompt
global_system_prompt str self.ui_global_system_prompt
base_system_prompt str self.ui_base_system_prompt
use_default_base_prompt bool self.ui_use_default_base_prompt
temperature float self.temperature
top_p float self.top_p
max_tokens int self.max_tokens
auto_add_history bool self.ui_auto_add_history
disc_entries list[dict] copy.deepcopy(self.disc_entries)
files list[dict] [f.to_dict() if hasattr(f, 'to_dict') else f for f in self.files]
context_files list[dict] [f.to_dict() if hasattr(f, 'to_dict') else f for f in self.context_files]
screenshots list[str] list(self.screenshots)

to_dict() / from_dict() are explicit serializers, used by the Hook API for the /api/session endpoint. The dataclass is not auto-serialized; explicit to_dict is required so the schema is documented.

HistoryManager — The Undo/Redo Stack

src/history.py:71 HistoryManager is a 100-snapshot capacity stack:

class HistoryManager:
    def __init__(self, max_capacity: int = 100): ...
    def push(self, state: typing.Any, description: str) -> None: ...
    def undo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ...
    def redo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ...
    def jump_to_undo(self, index: int, current_state: typing.Any, current_description: str = "Before Jump") -> typing.Optional[HistoryEntry]: ...
    @property
    def can_undo(self) -> bool: ...
    @property
    def can_redo(self) -> bool: ...
    def get_history(self) -> typing.List[typing.Dict[str, typing.Any]]: ...
  • push appends a new entry; clears the redo stack; pops the oldest if capacity exceeded.
  • undo moves the current state to the redo stack and returns the top of the undo stack.
  • redo is the inverse.
  • jump_to_undo allows time-traveling to any past snapshot, moving subsequent states to the redo stack.
  • get_history returns [{description, timestamp}, ...] for the History List view in the GUI.

The max_capacity=100 is the default and is sufficient for a 5-second window of rapid typing or a longer session of infrequent edits.

The Push Trigger — gui_2.py:1140-1170

The undo stack is not pushed on every keystroke. It's pushed via debounced change-detection at the start of every render frame:

current = self._take_snapshot()
if self._last_ui_snapshot is None:
    self._last_ui_snapshot = current
    return

changed = (
    current.ai_input != self._last_ui_snapshot.ai_input or
    current.project_system_prompt != self._last_ui_snapshot.project_system_prompt or
    # ... 10 more field comparisons ...
    len(current.disc_entries) != len(self._last_ui_snapshot.disc_entries) or
    len(current.files) != len(self._last_ui_snapshot.files) or
    len(current.context_files) != len(self._last_ui_snapshot.context_files) or
    len(current.screenshots) != len(self._last_ui_snapshot.screenshots)
)

if not changed and len(current.disc_entries) > 0:
    if current.disc_entries[-1].get('content') != self._last_ui_snapshot.disc_entries[-1].get('content'):
        changed = True

if changed:
    self.history.push(current, description="<auto>")
    self._last_ui_snapshot = current

The change detector compares:

  • 7 scalar fields directly (!=)
  • 2 float fields with epsilon (abs(...) > 1e-5)
  • 4 list fields by length
  • The disc_entries[-1]["content"] separately (because streaming AI responses can change the last entry's content without changing the length)

Performance: The check is at the start of every render frame. copy.deepcopy(self.disc_entries) (line 748) is the most expensive part — O(N) where N is the entry count. For a 100-entry discussion, this is microseconds. The full snapshot push only happens when a change is detected.

The Apply Trigger — gui_2.py:819 _apply_undo / _apply_redo / _apply_jump

Three wrapper methods invoke _apply_snapshot(entry.state) with the right description:

  • _apply_undo(...) — pops from undo, pushes current to redo, applies popped
  • _apply_redo(...) — pops from redo, pushes current to undo, applies popped
  • _apply_jump(index, ...) — invokes jump_to_undo(index, current_state), applies the result

_apply_snapshot is the single restore function (gui_2.py:754-789):

def _apply_snapshot(self, snapshot: history.UISnapshot) -> None:
    self._is_applying_snapshot = True
    try:
        self.ui_ai_input                = snapshot.ai_input
        self.ui_project_system_prompt   = snapshot.project_system_prompt
        # ... 10 more assignments ...
        self.disc_entries               = snapshot.disc_entries
        # Restore files as FileItem objects
        from src import models
        self.files = []
        for f in snapshot.files:
            if isinstance(f, dict):
                self.files.append(models.FileItem.from_dict(f))
            else:
                self.files.append(models.FileItem(path=str(f)))
        # ... similar for context_files, screenshots ...
    finally:
        self._is_applying_snapshot = False

The _is_applying_snapshot flag is set during the restore to prevent re-pushing a new snapshot from the changes made by the restore itself. (Without this, pressing Ctrl+Z would push a new snapshot identical to the restored one, which would clear the redo stack — making Ctrl+Y a no-op.)


2. State Delegation: App.__getattr__ / __setattr__

The Pattern

App (src/gui_2.py:264+) is a thin wrapper around AppController (src/app_controller.py:772+). The wrapper exists because:

  • The Controller is the headless orchestrator (no ImGui dependencies).
  • The App is the render side (ImGui calls).
  • Render functions take app: App and read state via app.<field>.

The simplest design would be: copy every Controller field to the App. But that's brittle (every new Controller field requires an App copy). The actual pattern uses Python's __getattr__/__setattr__ for transparent delegation.

App.__getattr__ — Read Fallthrough

gui_2.py:666-669:

def __getattr__(self, name: str) -> Any:
    if name == 'controller':
        raise AttributeError(name)
    return getattr(self.controller, name)

When app.foo is accessed and foo is not an instance attribute of App, Python falls through to __getattr__, which reads from self.controller. This means app.disc_entries, app.history, app.temperature all read from the Controller transparently.

The 'controller' exception prevents infinite recursion if the Controller is not yet set (during early __init__).

App.__setattr__ — Write Fallthrough

gui_2.py:671-675:

def __setattr__(self, name: str, value: Any) -> None:
    if name != 'controller' and hasattr(self, 'controller') and hasattr(self.controller, name):
        setattr(self.controller, name, value)
    else:
        object.__setattr__(self, name, value)

When app.foo = bar is assigned and the Controller has a foo attribute, the write goes through to the Controller. Otherwise it stores on the App instance.

Why This Matters

  • No data duplication. There is one source of truth (the Controller). The App never has its own copy of disc_entries, temperature, etc.
  • No boilerplate. New Controller fields are automatically available via the App without code changes.
  • Backward-compatible. Existing App fields (e.g. app._is_applying_snapshot) work because object.__setattr__ is the fallback when the Controller doesn't have the field.

The 4 Edge Cases (per the guide_gui_2.md Known Issues)

  1. app.controller is itself an App attribute — it must not be delegated, hence the explicit if name == 'controller' guard.
  2. App instance attributes shadow Controller attributes — the object.__setattr__ fallback stores on the App; the next __getattr__ call returns the App's value (because Python's normal attribute lookup finds the instance attribute first). This is sometimes intentional (e.g. app._is_applying_snapshot is App-local).
  3. Fields in app._app (not on the Controller) are stored on the App. The _app field is the Controller's back-reference to the App (set at gui_2.py:264).
  4. Underscore-prefixed App-specific fields like app._mma_approval_open, app._pending_ask_dialog are stored on the App because the Controller doesn't have them. They appear in the Controller's __getattr__ mirror via hasattr check.

Known Fragility

guide_gui_2.md notes: ui_separate_context_preview, ui_separate_message_panel, ui_separate_response_panel, ui_separate_tool_calls_panel, ui_separate_external_tools, ui_discussion_split_h are NOT in the Controller's _settable_fields, so __setattr__ falls through to object.__setattr__ and stores them on the App. This is intentional for window-separator flags (they're render-only and shouldn't pollute the Controller), but it does mean they don't survive a hot-reload of App.


3. Reset: _handle_reset_session

src/app_controller.py:3286-3356 _handle_reset_session is the nuclear reset, called from the "Reset Session" button in the message panel.

What It Clears

Group What Why
AI client ai_client.reset_session() + clear_comms_log() Clears provider-side history.
Tool stats _tool_log.clear(), _tool_stats.clear(), _comms_log.clear() Clears the in-memory activity logs.
Discussion self.disc_entries.clear(); for each discussion in project: discussions[d_name]["history"] = [] Empties the current take and all takes across all discussions.
Files self.files.clear(), self.context_files.clear() Drops the FileItem lists.
Tracks self.tracks.clear() Drops the loaded tracks.
Project dict (full replacement) self.project = project_manager.default_project(...) The project is replaced with a fresh default, not mutated.
Project paths self.project_paths = [] Clears the recent-projects list.
Project switch state _project_switch_in_progress = False etc. Resets the in-flight switch state machine.
AI status ai_status = "session reset", ai_response = "" Status bar update.
UI inputs ui_ai_input = "", ui_manual_approve = False, ui_auto_add_history = False Empties the message box.
MMA active_track = None, active_tier = None, mma_status = "idle", proposed_tracks = [], active_tickets = [], engines.clear(), mma_streams.clear(), _worker_status.clear() Drops all MMA state.
Provider/model _current_provider = "gemini", _current_model = "gemini-2.5-flash-lite", ai_client.set_provider(...) Resets to defaults.
Locks + queues _pending_history_adds.clear(), _api_event_queue.clear(), _pending_gui_tasks.clear() Drains all queues under their locks.
Prompts ui_use_default_base_prompt = True, all 3 system prompts = '' Resets to default base prompt.
Persona/tool settings ui_active_persona = '', ui_active_tool_preset = None, ui_active_bias_profile = None Drops active persona.
Generation params temperature = 0.0, top_p = 1.0, max_tokens = 8192 Defaults.

What It Does NOT Touch

Field Why preserved
self.active_project_path _do_project_switch writes to this path; clearing it would cause OSError on next switch and an infinite re-switch loop. The 2026-06-08 regression test test_context_sim_live documents this.
self.history (the HistoryManager) The undo stack survives a reset. Ctrl+Z after a reset can restore the pre-reset state. This may be a bug or a feature.
The on-disk manual_slop.toml The saved project TOML is not deleted or rewritten. Switching projects after reset reloads from disk.
self.discussion_sent_markdown / discussion_sent_system_prompt These are cleared (set to ""). They're not in the preserve list.

The _is_applying_snapshot Guard During Reset

_handle_reset_session does not set self._is_applying_snapshot. This means the change-detection logic will push a new snapshot to the undo stack after the reset. The snapshot will contain the post-reset state. To get the pre-reset state, the user must Ctrl+Z twice (once to push the post-reset snapshot, once to restore the pre-reset snapshot).

This is a known papercut. The fix is to set self._is_applying_snapshot = True during the reset and clear it at the end. See tests/test_session_logger_reset.py:test_reset_session for the current behavior.

The 2026-06-08 Regression

app_controller.py:3307-3312 documents a regression that was caught by test_context_sim_live:

The test's client.click("btn_reset") resets the AI session but does not reset the project (see _handle_reset_session at line 3244 — it clears files, context_files, disc_entries, etc. but not self.project or self.active_project_path).

The fix was to not clear self.active_project_path. The project dict is replaced (self.project = project_manager.default_project(...)), but the path is preserved. This is the right behavior: the user is resetting the session, not abandoning the project.


4. State Synchronization Across Threads

The state lifecycle has 4 distinct threads of access:

  1. Render thread (60 FPS) — reads app.<field> to render ImGui widgets
  2. AI response callback thread (background) — appends to app.disc_entries under _disc_entries_lock
  3. Hook API thread (HTTP server on 127.0.0.1:8999) — reads via _gettable_fields and writes via _predefined_callbacks
  4. MMA worker thread(s) — write to _api_event_queue, _pending_gui_tasks, _pending_history_adds

The synchronization primitives:

  • _disc_entries_lock: threading.Lock — protects disc_entries (read by render, written by AI callback and Truncate)
  • _pending_history_adds_lock — protects the queue of pending discussion entries
  • _api_event_queue_lock — protects the Hook API event stream
  • _pending_gui_tasks_lock — protects the queue of GUI tasks scheduled from background threads
  • _project_switch_lock — protects the project-switch state machine
  • ai_client._send_lock: threading.Lock — serializes all ai_client.send() calls (the global lock per guide_ai_client.md)
  • ai_client._<provider>_history_lock — per-provider history lock (one per provider)

Invariant: the render thread never blocks. It reads lock-free. All locks are acquired by the writer (AI callback, Truncate button, Hook API), held for the minimum critical section, and released before the writer returns.

See docs/reports/MUTATION_MATRIX_PHASE5.md for the full matrix of state mutations × lock × thread.


5. State Persistence

Three layers of state persistence:

  1. In-memory only (lost on crash): ai_client._<provider>_history, app.disc_entries, app.history (undo stack)
  2. Project TOML (persisted on Save / switch discussion): project.discussion.discussions[*].history, project.discussion.discussions[*].context_snapshot, project.discussion.discussions[*].sent_markdown, project.discussion.discussions[*].sent_system_prompt
  3. Config TOML (persisted on save_config()): config.disc_roles, config.use_default_base_system_prompt, config.ui_global_system_prompt, etc.

The auto-save flow is:

  • _flush_to_project() → writes project TOML
  • _flush_to_config() → writes in-memory config to AppController.config
  • save_config() → calls models.save_config(config) to write to disk

These three calls are manual (not automatic). The user must click Save, or trigger a state change that flushes (switch discussion, branch, etc.). The reset path explicitly does not save (per the "What reset does NOT touch" section above).

The comms log is its own persistence layer. ai_client._comms_log and app._comms_log are in-memory, but every entry is also written to logs/sessions/<session_id>/comms.log (JSON-L) via the _on_comms_entry callback. The reset clears the in-memory log; the on-disk log survives. This is intentional — the comms log is an audit trail, not a working state.


6. Hot Reload Integration

src/gui_2.py is hot-reloadable. The HotReloader (covered in guide_hot_reload.md) swaps module references at runtime. The state lifecycle interacts with hot-reload in 3 ways:

  1. HistoryManager survives hot-reload because it lives on the Controller, not the App. The gui_2.py module's functions can be reloaded without losing the undo stack.
  2. UISnapshot schema is the contract — if a hot-reload changes the fields captured by UISnapshot, the old snapshots in the undo stack may have different shapes. The from_dict method handles missing fields via .get(..., default), so old snapshots degrade gracefully.
  3. _app back-reference is set in App.__init__ (line 264). After a hot-reload of gui_2.py, the Controller's self._app still points to the (now reloaded) App instance. Render functions that captured app in closures may still hold the old App — but the App is a thin wrapper, so the new App is functionally equivalent.

See guide_hot_reload.md §"What can/cannot be safely reloaded" for the full list.


7. Hook API Surface

The state lifecycle is exposed to the Hook API via two registries on the Controller (src/app_controller.py:296-326 in App.__init__):

  • _predefined_callbacks: dict[str, Callable] — name → function. The Hook API exposes each as a custom_callback action. Includes:
    • save_context_preset, load_context_preset, delete_context_preset
    • set_ui_file_paths, set_ui_screenshot_paths
    • set_context_files_for_test, set_screenshots_for_test
    • _toggle_command_palette
    • get_app_debug_info, save_context_preset_force
    • set_ui_attr(k, v), set_context_files
    • simulate_save_preset
  • _gettable_fields: dict[str, str] — public name → internal field name. The Hook API exposes each as a readable state field. Includes show_command_palette, app_debug_info, and 50+ other UI/state fields.

The Hook API does not directly expose disc_entries mutation; it goes through /api/session POST which replaces the full list.

/api/session is the most state-relevant endpoint:

  • GET /api/session{"session": {"entries": [...]}}
  • POST /api/session with {"session": {"entries": [...]}}{"status": "updated"}

See guide_tools.md §"Hook API" and guide_api_hooks.md for the full surface.


8. Tests

  • tests/test_history.pytest_undo_redo, test_jump_to_undo, test_max_capacity, test_redo_cleared_on_push, test_push_state, test_initial_state
  • tests/test_history_manager.pyTestHistoryManager class with: test_snapshot_roundtrip, test_push_and_undo, test_push_clears_redo_stack, test_undo_and_redo, test_undo_no_history_returns_none, test_redo_no_history_returns_none, test_get_history_returns_descriptions, test_jump_to_undo
  • tests/test_session_logger_reset.pytest_reset_session
  • tests/test_state_inventory.py — validates the state inventory is up-to-date
  • tests/test_state_delegation.py — validates App.__getattr__/__setattr__ behavior
  • tests/test_live_gui_state_sync.py — validates that Hook API state reads are consistent with the live GUI state
  • tests/test_gui_fast_render.pytest_render_discussion_panel_fast (the change-detection path)
  • tests/conftest.py:app_instance, tests/conftest.py:mock_app — the App fixtures used by these tests

9. Known Limitations

  1. Per-edit save is not debounced to disk. See guide_discussions.md §"Known Limitations" for the related issue. The fix is to hook the change-detection in gui_2.py:1140-1170 to also call _flush_disc_entries_to_project after a debounce.
  2. _handle_reset_session pushes a new snapshot to the undo stack. The pre-reset state is two Ctrl+Z presses away, not one. Fix: set self._is_applying_snapshot = True during the reset.
  3. Provider-side history and Manual Slop state can diverge. When the user edits an entry's content via the discussion UI, the provider's ai_client._<provider>_history still has the original. This is Pitfall #4 in conductor/tracks/nagent_review_20260608/report.md and Decision candidate #3.
  4. Undo stack capacity is 100. For long sessions with infrequent edits, this is plenty. For a 5-second window of rapid typing, you can fill it. Capacity is set in app.history = HistoryManager(max_capacity=100) in App.__init__ and is not configurable.
  5. Hook API state writes are not undoable. A POST /api/session that replaces the discussion list is a single change; if the change-detection logic pushes a snapshot, it's pushed as one entry. The user can Ctrl+Z to revert it, but the API caller has no way to know the change is undoable.

Cross-References

  • Undo/redo core: src/history.py:8 UISnapshot, src/history.py:71 HistoryManager
  • App-side wiring: src/gui_2.py:735 _take_snapshot, src/gui_2.py:754 _apply_snapshot, src/gui_2.py:819 _apply_undo, src/gui_2.py:825 _apply_redo, src/gui_2.py:832 _apply_jump, src/gui_2.py:1140-1170 change_detection, src/gui_2.py:666 __getattr__, src/gui_2.py:671 __setattr__
  • Controller-side reset: src/app_controller.py:3286 _handle_reset_session, src/app_controller.py:3357 _handle_compress_discussion
  • Hook API registries: src/gui_2.py:296-326 (the _predefined_callbacks / _gettable_fields assignments in App.__init__)
  • State inventory: docs/reports/STATE_INVENTORY_PHASE5.md, docs/reports/MUTATION_MATRIX_PHASE5.md
  • Discussion integration: guide_discussions.md
  • Actionable patterns: conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1 (state visibility), §9 (edit-the-input)
  • Future-track candidate for stateless LLMClient: conductor/tracks/nagent_review_20260608/decisions.md candidate #3