11from __future__ import annotations
22
3+ from dataclasses import dataclass
34from typing import Any
45
56from ...todos .contract import (
6- TODO_TASK_CLASS_ADVANCEMENT ,
7- normalize_todo_claimed_by ,
87 normalize_todo_id ,
98)
109from ...todos .deferred_resume import todo_summary_blocked_successor_items
1110from ...todos .projection import (
12- todo_item_excludes_agent ,
13- todo_item_is_actionable_open ,
14- todo_item_task_class ,
11+ agent_scoped_selectable_advancement_todo_ids ,
1512)
1613from ..goal_vision_state import goal_vision_state_is_closed
1714
4037)
4138
4239
40+ @dataclass (frozen = True )
41+ class FallbackDeclaration :
42+ """Structured declaration of a fallback direction and its associated work."""
43+
44+ declaration_id : str
45+ target_todo_id : str | None = None
46+ successor_todo_id : str | None = None
47+
48+ @property
49+ def candidate_todo_ids (self ) -> set [str ]:
50+ return {
51+ todo_id
52+ for todo_id in (
53+ self .target_todo_id ,
54+ self .successor_todo_id ,
55+ self .declaration_id ,
56+ )
57+ if todo_id
58+ }
59+
60+ @property
61+ def unresolved_todo_id (self ) -> str :
62+ return self .target_todo_id or self .declaration_id
63+
64+
4365def _compact_text (value : Any , * , limit : int ) -> str :
4466 return " " .join (str (value or "" ).strip ().split ())[:limit ]
4567
@@ -63,50 +85,133 @@ def parse_vision_todo_delta_entries(entries: Any) -> list[tuple[str, str]]:
6385 return parsed
6486
6587
66- def agent_scoped_selectable_advancement_todo_ids (
67- agent_todo_summary : dict [str , Any ] | None ,
68- * ,
69- agent_id : str | None ,
70- ) -> set [str ]:
71- """Return the ids the agent-scoped selectable advancement frontier holds.
88+ def parse_fallback_declarations (
89+ agent_vision : dict [str , Any ] | None ,
90+ agent_todo_summary : dict [str , Any ] | None = None ,
91+ ) -> list [FallbackDeclaration ]:
92+ """Extract structured fallback declarations from vision and linkage contracts."""
7293
73- Mirrors the slot order and item predicates of the authoritative
74- ``todo_advancement_frontier_counts`` counter (executable backlog first,
75- unclaimed priority plus agent-claimed advancement items as the slotless
76- fallback), including claim ownership: peer-claimed advancement work is
77- intentionally not part of this agent's selectable frontier.
78- """
94+ declarations : list [FallbackDeclaration ] = []
95+ seen : set [tuple [str , str | None , str | None ]] = set ()
7996
80- if not isinstance (agent_todo_summary , dict ):
81- return set ()
82- normalized_agent_id = normalize_todo_claimed_by (agent_id )
83- executable_items = agent_todo_summary .get ("executable_backlog_items" )
84- if isinstance (executable_items , list ):
85- slots : tuple [Any , ...] = (executable_items ,)
86- else :
87- slots = (
88- agent_todo_summary .get ("unclaimed_priority_open_items" ),
89- agent_todo_summary .get ("claimed_advancement_open_items" ),
90- )
91- selectable : set [str ] = set ()
92- for items in slots :
93- if not isinstance (items , list ):
94- continue
95- for item in items :
96- if not isinstance (item , dict ):
97- continue
98- if not todo_item_is_actionable_open (item ):
99- continue
100- if todo_item_task_class (item ) != TODO_TASK_CLASS_ADVANCEMENT :
101- continue
102- claimed_by = normalize_todo_claimed_by (item .get ("claimed_by" ))
103- if normalized_agent_id and claimed_by and claimed_by != normalized_agent_id :
97+ def add_declaration (
98+ declaration_id : str ,
99+ target_todo_id : str | None = None ,
100+ successor_todo_id : str | None = None ,
101+ ) -> None :
102+ key = (declaration_id , target_todo_id , successor_todo_id )
103+ if key not in seen :
104+ seen .add (key )
105+ declarations .append (
106+ FallbackDeclaration (
107+ declaration_id = declaration_id ,
108+ target_todo_id = target_todo_id ,
109+ successor_todo_id = successor_todo_id ,
110+ )
111+ )
112+
113+ if isinstance (agent_vision , dict ):
114+ patch = agent_vision .get ("vision_patch" )
115+ patch = patch if isinstance (patch , dict ) else {}
116+ for source in (
117+ agent_vision .get ("fallback_declarations" ),
118+ agent_vision .get ("fallback_relationships" ),
119+ agent_vision .get ("fallbacks" ),
120+ patch .get ("fallback_declarations" ),
121+ patch .get ("fallback_relationships" ),
122+ patch .get ("fallbacks" ),
123+ ):
124+ if not isinstance (source , list ):
104125 continue
105- if todo_item_excludes_agent (item , agent_id = normalized_agent_id ):
126+ for raw in source :
127+ if isinstance (raw , dict ):
128+ raw_decl_id = (
129+ raw .get ("declaration_id" )
130+ or raw .get ("fallback_id" )
131+ or raw .get ("id" )
132+ or raw .get ("name" )
133+ or raw .get ("fallback_todo_id" )
134+ or raw .get ("todo_id" )
135+ )
136+ declaration_id = _compact_text (
137+ raw_decl_id ,
138+ limit = VISION_TODO_DELTA_ID_LIMIT ,
139+ )
140+ target_todo_id = normalize_todo_id (
141+ raw .get ("target_todo_id" )
142+ or raw .get ("fallback_todo_id" )
143+ or raw .get ("todo_id" )
144+ )
145+ successor_todo_id = normalize_todo_id (
146+ raw .get ("successor_todo_id" )
147+ or raw .get ("successor_id" )
148+ )
149+ if not declaration_id and target_todo_id :
150+ declaration_id = target_todo_id
151+ if not target_todo_id and not successor_todo_id and declaration_id :
152+ target_todo_id = normalize_todo_id (declaration_id )
153+ if declaration_id :
154+ add_declaration (
155+ declaration_id = declaration_id ,
156+ target_todo_id = target_todo_id ,
157+ successor_todo_id = successor_todo_id ,
158+ )
159+ elif isinstance (raw , str ):
160+ text = _compact_text (raw , limit = VISION_TODO_DELTA_ID_LIMIT )
161+ if not text :
162+ continue
163+ if "->" in text :
164+ decl , _ , succ = text .partition ("->" )
165+ d_id = decl .strip ()
166+ s_id = normalize_todo_id (succ .strip ())
167+ add_declaration (
168+ declaration_id = d_id ,
169+ target_todo_id = normalize_todo_id (d_id ),
170+ successor_todo_id = s_id ,
171+ )
172+ elif ":" in text and not text .startswith (("todo_" , "task_" )):
173+ decl , _ , succ = text .partition (":" )
174+ d_id = decl .strip ()
175+ s_id = normalize_todo_id (succ .strip ())
176+ add_declaration (
177+ declaration_id = d_id ,
178+ target_todo_id = normalize_todo_id (d_id ),
179+ successor_todo_id = s_id ,
180+ )
181+ else :
182+ t_id = normalize_todo_id (text ) or text
183+ add_declaration (
184+ declaration_id = text ,
185+ target_todo_id = t_id ,
186+ successor_todo_id = t_id ,
187+ )
188+
189+ # Check linkage contract on blocked successor items in agent_todo_summary
190+ if isinstance (agent_todo_summary , dict ):
191+ for slot in (
192+ "deferred_items" ,
193+ "deferred_resume_candidates" ,
194+ "current_agent_blocker_items" ,
195+ ):
196+ items = agent_todo_summary .get (slot )
197+ if not isinstance (items , list ):
106198 continue
107- if todo_id := normalize_todo_id (item .get ("todo_id" )):
108- selectable .add (todo_id )
109- return selectable
199+ for item in items :
200+ if not isinstance (item , dict ):
201+ continue
202+ if fallback_todo_id := normalize_todo_id (item .get ("fallback_todo_id" )):
203+ add_declaration (
204+ declaration_id = fallback_todo_id ,
205+ target_todo_id = fallback_todo_id ,
206+ successor_todo_id = fallback_todo_id ,
207+ )
208+ if fallback_succ_id := normalize_todo_id (item .get ("fallback_successor_todo_id" )):
209+ add_declaration (
210+ declaration_id = fallback_succ_id ,
211+ successor_todo_id = fallback_succ_id ,
212+ )
213+
214+ return declarations
110215
111216
112217def _blocked_successor_todo_ids (
@@ -173,16 +278,19 @@ def declared_fallback_gap_from_agent_vision(
173278) -> dict [str , Any ] | None :
174279 """Project one advisory gap for an unresolved declared fallback.
175280
176- A fallback direction is declared structurally: the vision's todo_delta
177- links it to existing Todos via activate/resume/retain entries. Prose
178- mentions never declare a fallback, so negated or non-English vision text
179- cannot invent a gap. The declared direction is resolved when one of:
180- a linked Todo sits on the authoritative agent-scoped selectable
181- advancement frontier (peer-claimed primary-path Todos do not), the
182- todo_delta declares a bounded create/reopen successor, or the vision
183- records an explicit terminal disposition (closed-family state or
184- path_delta.outcome=stop). The primary path's own blocked successors are
185- the wait state itself, not fallback declarations, and are excluded.
281+ A fallback direction is declared structurally via the agent vision's
282+ ``fallback_declarations`` / ``fallback_relationships`` contract or the
283+ task linkage contract on blocked successor items. Prose mentions never
284+ declare a fallback, and generic ``todo_delta`` actions are not fallback
285+ declarations on their own.
286+
287+ The declared direction is resolved when one of:
288+ 1. A linked Todo sits on the authoritative agent-scoped selectable
289+ advancement frontier (peer-claimed primary-path Todos do not);
290+ 2. A bounded successor Todo is created or reopened specifically for
291+ this fallback direction; or
292+ 3. The vision records an explicit terminal disposition (closed-family state
293+ or path_delta.outcome=stop).
186294
187295 When the primary path is blocked and none of the resolutions holds, the
188296 declared fallback would otherwise disappear silently behind the
@@ -200,26 +308,53 @@ def declared_fallback_gap_from_agent_vision(
200308 agent_id = agent_id ,
201309 ):
202310 return None
203- todo_delta = parse_vision_todo_delta_entries (agent_vision .get ("todo_delta" ))
204- if {action for action , _ in todo_delta } & VISION_TODO_DELTA_SUCCESSOR_ACTIONS :
311+
312+ declarations = parse_fallback_declarations (
313+ agent_vision ,
314+ agent_todo_summary = agent_todo_summary ,
315+ )
316+ if not declarations :
205317 return None
318+
319+ selectable_ids = agent_scoped_selectable_advancement_todo_ids (
320+ agent_todo_summary ,
321+ agent_id = agent_id ,
322+ )
206323 waiting_todo_ids = _blocked_successor_todo_ids (
207324 agent_todo_summary ,
208325 agent_id = agent_id ,
209326 )
210- declared_linkage_todo_ids = {
327+ todo_delta = parse_vision_todo_delta_entries (agent_vision .get ("todo_delta" ))
328+ created_or_reopened_ids = {
211329 todo_id
212330 for action , todo_id in todo_delta
213- if action in VISION_TODO_DELTA_LINKAGE_ACTIONS
214- and todo_id not in waiting_todo_ids
331+ if action in VISION_TODO_DELTA_SUCCESSOR_ACTIONS
215332 }
216- if not declared_linkage_todo_ids :
217- return None
218- if declared_linkage_todo_ids & agent_scoped_selectable_advancement_todo_ids (
219- agent_todo_summary ,
220- agent_id = agent_id ,
221- ):
333+
334+ unresolved_ids : set [str ] = set ()
335+ for declaration in declarations :
336+ candidate_ids = declaration .candidate_todo_ids - waiting_todo_ids
337+ if not candidate_ids :
338+ continue
339+ # Disposition 1: Runnable on authoritative selectable advancement frontier
340+ if candidate_ids & selectable_ids :
341+ continue
342+ # Disposition 2: Bounded successor created/reopened specifically for this fallback
343+ if candidate_ids & created_or_reopened_ids :
344+ continue
345+ if (
346+ declaration .successor_todo_id
347+ and declaration .successor_todo_id in created_or_reopened_ids
348+ ):
349+ continue
350+
351+ unresolved_id = declaration .unresolved_todo_id
352+ if unresolved_id not in waiting_todo_ids :
353+ unresolved_ids .add (unresolved_id )
354+
355+ if not unresolved_ids :
222356 return None
357+
223358 gap : dict [str , Any ] = {
224359 "kind" : VISION_FALLBACK_GAP_TRIGGER ,
225360 "source" : "latest_agent_vision" ,
@@ -229,7 +364,7 @@ def declared_fallback_gap_from_agent_vision(
229364 "recommended_action" : VISION_FALLBACK_RECOMMENDED_ACTION ,
230365 }
231366 unresolved_todo_ids = [
232- todo_id for todo_id in sorted (declared_linkage_todo_ids ) if todo_id
367+ todo_id for todo_id in sorted (unresolved_ids ) if todo_id
233368 ][:VISION_FALLBACK_RUNNABLE_ITEM_LIMIT ]
234369 if unresolved_todo_ids :
235370 gap ["unresolved_todo_ids" ] = unresolved_todo_ids
0 commit comments