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:
+21
-4
@@ -3954,10 +3954,18 @@ class AppController:
|
||||
self.temperature = 0.0
|
||||
self.top_p = 1.0
|
||||
self.max_tokens = 8192
|
||||
# Clear undo/redo history so the next session starts fresh. Without
|
||||
# this, prior tests in the same live_gui session leave stale entries
|
||||
# that interfere with tests like tests/test_undo_redo_sim.py that
|
||||
# assume btn_reset provides a clean history baseline.
|
||||
# Clear undo/redo history AND sync the App's snapshot attrs so the
|
||||
# next session starts fresh. Without the App-side sync, prior tests in
|
||||
# the same live_gui session leave ai_input/temperature/etc. set on the
|
||||
# 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:
|
||||
app = self.hook_server.app
|
||||
if hasattr(app, 'history'):
|
||||
@@ -3966,6 +3974,15 @@ class AppController:
|
||||
if hasattr(app, '_last_ui_snapshot'): app._last_ui_snapshot = None
|
||||
if hasattr(app, '_pending_snapshot'): app._pending_snapshot = False
|
||||
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 worker() -> "Result[None]":
|
||||
|
||||
+164
-139
@@ -7367,147 +7367,172 @@ def render_task_dag_panel(app: App) -> None: # 4. Task DAG Visualizer
|
||||
"""
|
||||
imgui.text("Task DAG")
|
||||
if (app.active_track or app.active_tickets) and app.node_editor_ctx:
|
||||
ed.set_current_editor(app.node_editor_ctx)
|
||||
ed.begin('Visual DAG')
|
||||
# Selection detection
|
||||
selected = ed.get_selected_nodes()
|
||||
if selected:
|
||||
for node_id in selected:
|
||||
node_val = node_id.id()
|
||||
for t in app.active_tickets:
|
||||
if abs(hash(str(t.id))) == node_val:
|
||||
app.ui_selected_ticket_id = str(t.id)
|
||||
break
|
||||
break
|
||||
for t in app.active_tickets:
|
||||
tid = str(t.id) if t.id else '??'
|
||||
int_id = abs(hash(tid))
|
||||
ed.begin_node(ed.NodeId(int_id))
|
||||
if getattr(app, "ui_project_execution_mode", "native") == "beads":
|
||||
imgui.text_colored(theme.get_color("status_info"), "[B] ")
|
||||
imgui.same_line()
|
||||
imgui.text_colored(C_KEY(), f"Ticket: {tid}")
|
||||
status = t.status
|
||||
s_col = C_VAL()
|
||||
if status == 'done' or status == 'complete': s_col = C_IN()
|
||||
elif status == 'in_progress' or status == 'running': s_col = C_OUT()
|
||||
elif status == 'error': s_col = theme.get_color("status_error")
|
||||
imgui.text("Status: ")
|
||||
imgui.same_line()
|
||||
imgui.text_colored(s_col, status)
|
||||
imgui.text(f"Target: {t.target_file or ''}")
|
||||
ed.begin_pin(ed.PinId(abs(hash(tid + "_in"))), ed.PinKind.input)
|
||||
imgui.text("->")
|
||||
ed.end_pin()
|
||||
imgui.same_line()
|
||||
ed.begin_pin(ed.PinId(abs(hash(tid + "_out"))), ed.PinKind.output)
|
||||
imgui.text("->")
|
||||
ed.end_pin()
|
||||
ed.end_node()
|
||||
for t in app.active_tickets:
|
||||
tid = str(t.id) if t.id else '??'
|
||||
for dep in t.depends_on:
|
||||
ed.link(ed.LinkId(abs(hash(dep + "_" + tid))), ed.PinId(abs(hash(dep + "_out"))), ed.PinId(abs(hash(tid + "_in"))))
|
||||
|
||||
# Handle link creation
|
||||
if ed.begin_create():
|
||||
start_pin = ed.PinId()
|
||||
end_pin = ed.PinId()
|
||||
if ed.query_new_link(start_pin, end_pin):
|
||||
if ed.accept_new_item():
|
||||
s_id = start_pin.id()
|
||||
e_id = end_pin.id()
|
||||
source_tid = None
|
||||
target_tid = None
|
||||
for t in app.active_tickets:
|
||||
tid = str(t.id)
|
||||
if abs(hash(tid + "_out")) == s_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")) == e_id: target_tid = tid
|
||||
if source_tid and target_tid and source_tid != target_tid:
|
||||
for t in app.active_tickets:
|
||||
if str(t.id) == target_tid:
|
||||
if source_tid not in t.depends_on:
|
||||
t.depends_on = list(t.depends_on) + [source_tid]
|
||||
app._push_mma_state_update()
|
||||
break
|
||||
ed.end_create()
|
||||
|
||||
# Handle link deletion
|
||||
if ed.begin_delete():
|
||||
link_id = ed.LinkId()
|
||||
while ed.query_deleted_link(link_id):
|
||||
if ed.accept_deleted_item():
|
||||
lid_val = link_id.id()
|
||||
for t in app.active_tickets:
|
||||
tid = str(t.id)
|
||||
deps = t.depends_on
|
||||
if any(abs(hash(d + "_" + tid)) == lid_val for d in deps):
|
||||
t.depends_on = [dep for dep in deps if abs(hash(dep + "_" + tid)) != lid_val]
|
||||
app._push_mma_state_update()
|
||||
try:
|
||||
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')
|
||||
# Selection detection
|
||||
selected = ed.get_selected_nodes()
|
||||
if selected:
|
||||
for node_id in selected:
|
||||
node_val = node_id.id()
|
||||
for t in _tickets:
|
||||
if abs(hash(str(t.id))) == node_val:
|
||||
app.ui_selected_ticket_id = str(t.id)
|
||||
break
|
||||
ed.end_delete()
|
||||
|
||||
# Validate DAG after any changes
|
||||
cycle_result = _dag_cycle_check_result(app)
|
||||
if not cycle_result.ok:
|
||||
if not hasattr(app, '_last_request_errors'): app._last_request_errors = []
|
||||
app._last_request_errors.append(('render_task_dag_panel.cycle_check', cycle_result.errors[0]))
|
||||
elif cycle_result.data:
|
||||
imgui.open_popup("Cycle Detected!")
|
||||
|
||||
ed.end()
|
||||
# 5. Add Ticket Form
|
||||
imgui.separator()
|
||||
if imgui.button("Add Ticket"):
|
||||
app._show_add_ticket_form = not app._show_add_ticket_form
|
||||
break
|
||||
for t in _tickets:
|
||||
tid = str(t.id) if t.id else '??'
|
||||
int_id = abs(hash(tid))
|
||||
ed.begin_node(ed.NodeId(int_id))
|
||||
if getattr(app, "ui_project_execution_mode", "native") == "beads":
|
||||
imgui.text_colored(theme.get_color("status_info"), "[B] ")
|
||||
imgui.same_line()
|
||||
imgui.text_colored(C_KEY(), f"Ticket: {tid}")
|
||||
status = t.status
|
||||
s_col = C_VAL()
|
||||
if status == 'done' or status == 'complete': s_col = C_IN()
|
||||
elif status == 'in_progress' or status == 'running': s_col = C_OUT()
|
||||
elif status == 'error': s_col = theme.get_color("status_error")
|
||||
imgui.text("Status: ")
|
||||
imgui.same_line()
|
||||
imgui.text_colored(s_col, status)
|
||||
imgui.text(f"Target: {t.target_file or ''}")
|
||||
ed.begin_pin(ed.PinId(abs(hash(tid + "_in"))), ed.PinKind.input)
|
||||
imgui.text("->")
|
||||
ed.end_pin()
|
||||
imgui.same_line()
|
||||
ed.begin_pin(ed.PinId(abs(hash(tid + "_out"))), ed.PinKind.output)
|
||||
imgui.text("->")
|
||||
ed.end_pin()
|
||||
ed.end_node()
|
||||
# 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 '??'
|
||||
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"))))
|
||||
|
||||
# Handle link creation
|
||||
if ed.begin_create():
|
||||
start_pin = ed.PinId()
|
||||
end_pin = ed.PinId()
|
||||
if ed.query_new_link(start_pin, end_pin):
|
||||
if ed.accept_new_item():
|
||||
s_id = start_pin.id()
|
||||
e_id = end_pin.id()
|
||||
source_tid = None
|
||||
target_tid = None
|
||||
for t in _tickets:
|
||||
tid = str(t.id)
|
||||
if abs(hash(tid + "_out")) == s_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")) == e_id: target_tid = tid
|
||||
if source_tid and target_tid and source_tid != target_tid:
|
||||
for t in _tickets:
|
||||
if str(t.id) == target_tid:
|
||||
if source_tid not in t.depends_on:
|
||||
t.depends_on = list(t.depends_on) + [source_tid]
|
||||
app._push_mma_state_update()
|
||||
break
|
||||
ed.end_create()
|
||||
|
||||
# Handle link deletion
|
||||
if ed.begin_delete():
|
||||
link_id = ed.LinkId()
|
||||
while ed.query_deleted_link(link_id):
|
||||
if ed.accept_deleted_item():
|
||||
lid_val = link_id.id()
|
||||
for t in _tickets:
|
||||
tid = str(t.id)
|
||||
deps = t.depends_on
|
||||
if any(abs(hash(d + "_" + tid)) == lid_val for d in deps):
|
||||
t.depends_on = [dep for dep in deps if abs(hash(dep + "_" + tid)) != lid_val]
|
||||
app._push_mma_state_update()
|
||||
break
|
||||
ed.end_delete()
|
||||
|
||||
# Validate DAG after any changes
|
||||
cycle_result = _dag_cycle_check_result(app)
|
||||
if not cycle_result.ok:
|
||||
if not hasattr(app, '_last_request_errors'): app._last_request_errors = []
|
||||
app._last_request_errors.append(('render_task_dag_panel.cycle_check', cycle_result.errors[0]))
|
||||
elif cycle_result.data:
|
||||
imgui.open_popup("Cycle Detected!")
|
||||
|
||||
ed.end()
|
||||
# 5. Add Ticket Form
|
||||
imgui.separator()
|
||||
if imgui.button("Add Ticket"):
|
||||
app._show_add_ticket_form = not app._show_add_ticket_form
|
||||
if app._show_add_ticket_form:
|
||||
# Default Ticket ID
|
||||
max_id = 0
|
||||
for t in app.active_tickets:
|
||||
tid = t.id
|
||||
if tid.startswith('T-'):
|
||||
parse_result = _ticket_id_max_int_result(tid)
|
||||
if parse_result.ok:
|
||||
max_id = max(max_id, parse_result.data)
|
||||
else:
|
||||
if not hasattr(app, '_last_request_errors'): app._last_request_errors = []
|
||||
app._last_request_errors.append(('render_task_dag_panel.ticket_id_parse', parse_result.errors[0]))
|
||||
app.ui_new_ticket_id = f"T-{max_id + 1:03d}"
|
||||
app.ui_new_ticket_desc = ""
|
||||
app.ui_new_ticket_target = ""
|
||||
app.ui_new_ticket_deps = ""
|
||||
if app._show_add_ticket_form:
|
||||
# Default Ticket ID
|
||||
max_id = 0
|
||||
for t in app.active_tickets:
|
||||
tid = t.id
|
||||
if tid.startswith('T-'):
|
||||
parse_result = _ticket_id_max_int_result(tid)
|
||||
if parse_result.ok:
|
||||
max_id = max(max_id, parse_result.data)
|
||||
else:
|
||||
if not hasattr(app, '_last_request_errors'): app._last_request_errors = []
|
||||
app._last_request_errors.append(('render_task_dag_panel.ticket_id_parse', parse_result.errors[0]))
|
||||
app.ui_new_ticket_id = f"T-{max_id + 1:03d}"
|
||||
app.ui_new_ticket_desc = ""
|
||||
app.ui_new_ticket_target = ""
|
||||
app.ui_new_ticket_deps = ""
|
||||
if app._show_add_ticket_form:
|
||||
imgui.begin_child("add_ticket_form", imgui.ImVec2(-1, 220), True)
|
||||
imgui.text_colored(C_VAL(), "New Ticket Details")
|
||||
_, app.ui_new_ticket_id = imgui.input_text("ID##new_ticket", app.ui_new_ticket_id)
|
||||
_, app.ui_new_ticket_desc = imgui.input_text_multiline("Description##new_ticket", app.ui_new_ticket_desc, imgui.ImVec2(-1, 60))
|
||||
_, app.ui_new_ticket_target = imgui.input_text("Target File##new_ticket", app.ui_new_ticket_target)
|
||||
_, app.ui_new_ticket_deps = imgui.input_text("Depends On (IDs, comma-separated)##new_ticket", app.ui_new_ticket_deps)
|
||||
imgui.text("Priority:")
|
||||
imgui.same_line()
|
||||
if imgui.begin_combo("##new_prio", app.ui_new_ticket_priority):
|
||||
for p_opt in ['high', 'medium', 'low']:
|
||||
if imgui.selectable(p_opt, p_opt == app.ui_new_ticket_priority)[0]:
|
||||
app.ui_new_ticket_priority = p_opt
|
||||
imgui.end_combo()
|
||||
if imgui.button("Create"):
|
||||
new_ticket = {
|
||||
"id": app.ui_new_ticket_id,
|
||||
"description": app.ui_new_ticket_desc,
|
||||
"status": "todo",
|
||||
"priority": app.ui_new_ticket_priority,
|
||||
"assigned_to": "tier3-worker",
|
||||
"target_file": app.ui_new_ticket_target,
|
||||
"depends_on": [d.strip() for d in app.ui_new_ticket_deps.split(",") if d.strip()]
|
||||
}
|
||||
app.active_tickets.append(new_ticket)
|
||||
app._show_add_ticket_form = False
|
||||
app._push_mma_state_update()
|
||||
imgui.same_line()
|
||||
if imgui.button("Cancel"): app._show_add_ticket_form = False
|
||||
imgui.end_child()
|
||||
imgui.begin_child("add_ticket_form", imgui.ImVec2(-1, 220), True)
|
||||
imgui.text_colored(C_VAL(), "New Ticket Details")
|
||||
_, app.ui_new_ticket_id = imgui.input_text("ID##new_ticket", app.ui_new_ticket_id)
|
||||
_, app.ui_new_ticket_desc = imgui.input_text_multiline("Description##new_ticket", app.ui_new_ticket_desc, imgui.ImVec2(-1, 60))
|
||||
_, app.ui_new_ticket_target = imgui.input_text("Target File##new_ticket", app.ui_new_ticket_target)
|
||||
_, app.ui_new_ticket_deps = imgui.input_text("Depends On (IDs, comma-separated)##new_ticket", app.ui_new_ticket_deps)
|
||||
imgui.text("Priority:")
|
||||
imgui.same_line()
|
||||
if imgui.begin_combo("##new_prio", app.ui_new_ticket_priority):
|
||||
for p_opt in ['high', 'medium', 'low']:
|
||||
if imgui.selectable(p_opt, p_opt == app.ui_new_ticket_priority)[0]:
|
||||
app.ui_new_ticket_priority = p_opt
|
||||
imgui.end_combo()
|
||||
if imgui.button("Create"):
|
||||
new_ticket = {
|
||||
"id": app.ui_new_ticket_id,
|
||||
"description": app.ui_new_ticket_desc,
|
||||
"status": "todo",
|
||||
"priority": app.ui_new_ticket_priority,
|
||||
"assigned_to": "tier3-worker",
|
||||
"target_file": app.ui_new_ticket_target,
|
||||
"depends_on": [d.strip() for d in app.ui_new_ticket_deps.split(",") if d.strip()]
|
||||
}
|
||||
app.active_tickets.append(new_ticket)
|
||||
app._show_add_ticket_form = False
|
||||
app._push_mma_state_update()
|
||||
imgui.same_line()
|
||||
if imgui.button("Cancel"): app._show_add_ticket_form = False
|
||||
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:
|
||||
imgui.text_disabled("No active MMA track or tickets.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user