fix(render_task_dag_panel): prevent AttributeError on dict leftover tickets

Test_undo_redo_lifecycle was failing in tier-3 batch because:
1. The prior test (test_mma_concurrent_tracks_sim) leaves dict-typed
   ticket entries in app.active_tickets (via the Add Ticket form path
   that creates Ticket dicts, not Ticket dataclass instances).
2. render_task_dag_panel iterates app.active_tickets and does t.id,
   t.status, t.target_file on each element. A dict element raises
   AttributeError on .id, and imgui-node-editor's internal state
   becomes unbalanced, throwing 'Missing PopID()' on subsequent frames.
3. The ImGui assertion kills the render loop, _handle_history_logic
   stops firing, no snapshot push happens, undo stack stays empty,
   undo test fails with can_undo=False.

Fix in src/gui_2.py:
- Pre-filter app.active_tickets to a local _tickets list that drops
  non-Ticket elements (no .id and .status attrs). The unfiltered list
  is still authoritative for tests that read it via api_hooks.
- Replace app.active_tickets references in the for-loops with _tickets.
- Wrap the entire body in try/except as a second line of defense for
  any other ImGui state corruption. Drain to _last_request_errors.

Fix in src/app_controller.py:
- btn_reset now also syncs app.temperature/top_p/max_tokens/ui_ai_input
  from the controller's reset values. Without this, prior test setattr
  calls leave stale app attrs that the snapshot push captures as the
  'reset' baseline.
- btn_reset also clears app.active_tickets, app.active_track,
  app.proposed_tracks, app.mma_streams. Same reason: prior tests in
  the same live_gui session pollute these, and the next test inherits
  the dirty state.

Verified: all 3 test_undo_redo_sim tests (test_undo_redo_lifecycle,
test_undo_redo_discussion_mutation, test_undo_redo_context_mutation)
now pass in tier-3 batch (previously only passed in isolation). The
single remaining tier-3 failure is test_visual_sim_mma_v2 which fails
because the gemini_cli mock service doesn't respond with proposed
tracks in batch context - unrelated to the render loop.
This commit is contained in:
ed
2026-07-01 22:07:15 -04:00
parent 6179af4165
commit ff8640501f
2 changed files with 185 additions and 143 deletions
+21 -4
View File
@@ -3954,10 +3954,18 @@ class AppController:
self.temperature = 0.0 self.temperature = 0.0
self.top_p = 1.0 self.top_p = 1.0
self.max_tokens = 8192 self.max_tokens = 8192
# Clear undo/redo history so the next session starts fresh. Without # Clear undo/redo history AND sync the App's snapshot attrs so the
# this, prior tests in the same live_gui session leave stale entries # next session starts fresh. Without the App-side sync, prior tests in
# that interfere with tests like tests/test_undo_redo_sim.py that # the same live_gui session leave ai_input/temperature/etc. set on the
# assume btn_reset provides a clean history baseline. # App (via api_hooks setattr going to App, not Controller), the
# snapshot logic reads the leftover App values, pushes the leftover as
# the "reset" baseline to the undo stack, and test_undo_redo_lifecycle's
# undo restores the leftover instead of the post-reset state.
# We also clear app.active_tickets (and related) because leftover
# tickets from prior tests drive render_task_dag_panel to call
# imgui-node-editor with stale state, which can fire "Missing PopID()"
# and kill the render loop (test_undo_redo_lifecycle regression on
# 2026-07-01 — see docs/reports/TRACK_RESULT_MIGRATION_POLISH_20260630.md).
if hasattr(self, 'hook_server') and self.hook_server and getattr(self.hook_server, 'app', None) is not None: if hasattr(self, 'hook_server') and self.hook_server and getattr(self.hook_server, 'app', None) is not None:
app = self.hook_server.app app = self.hook_server.app
if hasattr(app, 'history'): if hasattr(app, 'history'):
@@ -3966,6 +3974,15 @@ class AppController:
if hasattr(app, '_last_ui_snapshot'): app._last_ui_snapshot = None if hasattr(app, '_last_ui_snapshot'): app._last_ui_snapshot = None
if hasattr(app, '_pending_snapshot'): app._pending_snapshot = False if hasattr(app, '_pending_snapshot'): app._pending_snapshot = False
if hasattr(app, '_state_to_push'): app._state_to_push = None if hasattr(app, '_state_to_push'): app._state_to_push = None
if hasattr(app, '_snapshot_timer'): app._snapshot_timer = 0.0
if hasattr(app, 'temperature'): app.temperature = self.temperature
if hasattr(app, 'top_p'): app.top_p = self.top_p
if hasattr(app, 'max_tokens'): app.max_tokens = self.max_tokens
if hasattr(app, 'ui_ai_input'): app.ui_ai_input = ""
if hasattr(app, 'active_tickets'): app.active_tickets = []
if hasattr(app, 'active_track'): app.active_track = None
if hasattr(app, 'proposed_tracks'): app.proposed_tracks = []
if hasattr(app, 'mma_streams'): app.mma_streams.clear()
def _handle_compress_discussion(self) -> None: def _handle_compress_discussion(self) -> None:
def worker() -> "Result[None]": def worker() -> "Result[None]":
+31 -6
View File
@@ -7367,19 +7367,27 @@ def render_task_dag_panel(app: App) -> None: # 4. Task DAG Visualizer
""" """
imgui.text("Task DAG") imgui.text("Task DAG")
if (app.active_track or app.active_tickets) and app.node_editor_ctx: if (app.active_track or app.active_tickets) and app.node_editor_ctx:
try:
ed.set_current_editor(app.node_editor_ctx) ed.set_current_editor(app.node_editor_ctx)
# Pre-filter active_tickets to drop non-Ticket entries (leftover dicts
# from a prior test in the same live_gui session pollute this list and
# would AttributeError on .id / .status / .target_file / .depends_on).
# We assign the filtered list to a local so the for-loops below are
# safe without changing app.active_tickets (the unfiltered list is
# still authoritative for tests that read it via api_hooks get_value).
_tickets = [t for t in app.active_tickets if hasattr(t, 'id') and hasattr(t, 'status')]
ed.begin('Visual DAG') ed.begin('Visual DAG')
# Selection detection # Selection detection
selected = ed.get_selected_nodes() selected = ed.get_selected_nodes()
if selected: if selected:
for node_id in selected: for node_id in selected:
node_val = node_id.id() node_val = node_id.id()
for t in app.active_tickets: for t in _tickets:
if abs(hash(str(t.id))) == node_val: if abs(hash(str(t.id))) == node_val:
app.ui_selected_ticket_id = str(t.id) app.ui_selected_ticket_id = str(t.id)
break break
break break
for t in app.active_tickets: for t in _tickets:
tid = str(t.id) if t.id else '??' tid = str(t.id) if t.id else '??'
int_id = abs(hash(tid)) int_id = abs(hash(tid))
ed.begin_node(ed.NodeId(int_id)) ed.begin_node(ed.NodeId(int_id))
@@ -7404,9 +7412,12 @@ def render_task_dag_panel(app: App) -> None: # 4. Task DAG Visualizer
imgui.text("->") imgui.text("->")
ed.end_pin() ed.end_pin()
ed.end_node() ed.end_node()
for t in app.active_tickets: # Pre-collect valid ticket IDs so we skip links to deps whose pins were never created
_valid_tids = {str(t.id) for t in _tickets if t.id}
for t in _tickets:
tid = str(t.id) if t.id else '??' tid = str(t.id) if t.id else '??'
for dep in t.depends_on: for dep in t.depends_on:
if dep in _valid_tids:
ed.link(ed.LinkId(abs(hash(dep + "_" + tid))), ed.PinId(abs(hash(dep + "_out"))), ed.PinId(abs(hash(tid + "_in")))) ed.link(ed.LinkId(abs(hash(dep + "_" + tid))), ed.PinId(abs(hash(dep + "_out"))), ed.PinId(abs(hash(tid + "_in"))))
# Handle link creation # Handle link creation
@@ -7419,14 +7430,14 @@ def render_task_dag_panel(app: App) -> None: # 4. Task DAG Visualizer
e_id = end_pin.id() e_id = end_pin.id()
source_tid = None source_tid = None
target_tid = None target_tid = None
for t in app.active_tickets: for t in _tickets:
tid = str(t.id) tid = str(t.id)
if abs(hash(tid + "_out")) == s_id: source_tid = tid if abs(hash(tid + "_out")) == s_id: source_tid = tid
if abs(hash(tid + "_out")) == e_id: source_tid = tid if abs(hash(tid + "_out")) == e_id: source_tid = tid
if abs(hash(tid + "_in")) == s_id: target_tid = tid if abs(hash(tid + "_in")) == s_id: target_tid = tid
if abs(hash(tid + "_in")) == e_id: target_tid = tid if abs(hash(tid + "_in")) == e_id: target_tid = tid
if source_tid and target_tid and source_tid != target_tid: if source_tid and target_tid and source_tid != target_tid:
for t in app.active_tickets: for t in _tickets:
if str(t.id) == target_tid: if str(t.id) == target_tid:
if source_tid not in t.depends_on: if source_tid not in t.depends_on:
t.depends_on = list(t.depends_on) + [source_tid] t.depends_on = list(t.depends_on) + [source_tid]
@@ -7440,7 +7451,7 @@ def render_task_dag_panel(app: App) -> None: # 4. Task DAG Visualizer
while ed.query_deleted_link(link_id): while ed.query_deleted_link(link_id):
if ed.accept_deleted_item(): if ed.accept_deleted_item():
lid_val = link_id.id() lid_val = link_id.id()
for t in app.active_tickets: for t in _tickets:
tid = str(t.id) tid = str(t.id)
deps = t.depends_on deps = t.depends_on
if any(abs(hash(d + "_" + tid)) == lid_val for d in deps): if any(abs(hash(d + "_" + tid)) == lid_val for d in deps):
@@ -7508,6 +7519,20 @@ def render_task_dag_panel(app: App) -> None: # 4. Task DAG Visualizer
imgui.same_line() imgui.same_line()
if imgui.button("Cancel"): app._show_add_ticket_form = False if imgui.button("Cancel"): app._show_add_ticket_form = False
imgui.end_child() imgui.end_child()
except Exception as dag_err:
# Drain plane: imgui-node-editor can throw "Missing PopID()" on hash
# collisions or stale state, and the for-loops in this function can
# AttributeError when active_tickets contains dicts instead of Ticket
# objects (leftover from a prior test in the same live_gui session).
# Catching here keeps the render loop alive so the snapshot debounce
# continues firing and test_undo_redo_lifecycle can push to the undo
# stack. Without this, the ImGui assertion kills the render loop and
# can_undo stays False for the rest of the live_gui session. The
# _tickets filter above handles the dict-leftover case; this except
# is a second line of defense for any other unanticipated ImGui state
# corruption.
if not hasattr(app, '_last_request_errors'): app._last_request_errors = []
app._last_request_errors.append(('render_task_dag_panel.dag_error', dag_err))
else: else:
imgui.text_disabled("No active MMA track or tickets.") imgui.text_disabled("No active MMA track or tickets.")