@@ -418,6 +418,247 @@ def state_event(
418418 assert "actor_agent" not in actor_node , actor_node
419419
420420
421+ def assert_diamond_dag_predecessor_edges () -> None :
422+ """In a diamond DAG root <- {a, b} <- shared, both edges a->shared and b->shared must be present."""
423+ root = {
424+ "todo_id" : "todo_root" ,
425+ "title" : "Root" ,
426+ "text" : "Root" ,
427+ "status" : "open" ,
428+ "done" : False ,
429+ }
430+ a = {
431+ "todo_id" : "todo_a" ,
432+ "title" : "Task A" ,
433+ "text" : "Task A" ,
434+ "status" : "done" ,
435+ "done" : True ,
436+ "successor_todo_ids" : ["todo_root" ],
437+ }
438+ b = {
439+ "todo_id" : "todo_b" ,
440+ "title" : "Task B" ,
441+ "text" : "Task B" ,
442+ "status" : "done" ,
443+ "done" : True ,
444+ "successor_todo_ids" : ["todo_root" ],
445+ }
446+ shared = {
447+ "todo_id" : "todo_shared" ,
448+ "title" : "Shared" ,
449+ "text" : "Shared" ,
450+ "status" : "done" ,
451+ "done" : True ,
452+ "successor_todo_ids" : ["todo_a" , "todo_b" ],
453+ }
454+ projection = build_task_graph_projection (
455+ {
456+ "goal_id" : "task-graph-diamond-dag" ,
457+ "agent_todos" : {
458+ "total_count" : 4 ,
459+ "open_count" : 1 ,
460+ "items" : [root , a , b , shared ],
461+ },
462+ "user_todos" : {
463+ "total_count" : 0 ,
464+ "open_count" : 0 ,
465+ "items" : [],
466+ },
467+ },
468+ goal = {"id" : "task-graph-diamond-dag" },
469+ )
470+ assert projection is not None
471+ edges = projection ["edges" ]
472+ nodes = projection ["nodes" ]
473+ node_ids = {node ["refs" ]["todo_ids" ][0 ]: node ["node_id" ] for node in nodes if node ["kind" ] == "deliverable" }
474+ # 4 nodes: root, a, b, shared
475+ assert len (node_ids ) == 4 , node_ids
476+ # Check all expected edges exist
477+ edge_pairs = {(e ["from_node_id" ], e ["to_node_id" ], e ["relation" ]) for e in edges }
478+ root_id = node_ids ["todo_root" ]
479+ a_id = node_ids ["todo_a" ]
480+ b_id = node_ids ["todo_b" ]
481+ shared_id = node_ids ["todo_shared" ]
482+ assert (root_id , a_id , "depends_on" ) in edge_pairs , edge_pairs
483+ assert (root_id , b_id , "depends_on" ) in edge_pairs , edge_pairs
484+ assert (a_id , shared_id , "depends_on" ) in edge_pairs , edge_pairs
485+ assert (b_id , shared_id , "depends_on" ) in edge_pairs , edge_pairs
486+ assert projection ["limits" ]["predecessor_truncated" ] is False
487+
488+
489+ def assert_evidence_only_for_done_predecessor () -> None :
490+ """Evidence should only attach when predecessor is done, not when open."""
491+ root = {
492+ "todo_id" : "todo_root" ,
493+ "title" : "Root" ,
494+ "text" : "Root" ,
495+ "status" : "open" ,
496+ "done" : False ,
497+ "successor_todo_ids" : ["todo_done_pred" , "todo_open_pred" ],
498+ }
499+ done_pred = {
500+ "todo_id" : "todo_done_pred" ,
501+ "title" : "Done predecessor" ,
502+ "text" : "Done predecessor" ,
503+ "status" : "done" ,
504+ "done" : True ,
505+ "evidence" : "Completed work evidence." ,
506+ "successor_todo_ids" : ["todo_root" ],
507+ }
508+ open_pred = {
509+ "todo_id" : "todo_open_pred" ,
510+ "title" : "Open predecessor" ,
511+ "text" : "Open predecessor" ,
512+ "status" : "open" ,
513+ "done" : False ,
514+ "evidence" : "In-progress notes." ,
515+ "successor_todo_ids" : ["todo_root" ],
516+ }
517+ projection = build_task_graph_projection (
518+ {
519+ "goal_id" : "task-graph-evidence-done-only" ,
520+ "agent_todos" : {
521+ "total_count" : 3 ,
522+ "open_count" : 1 ,
523+ "items" : [root , done_pred , open_pred ],
524+ },
525+ "user_todos" : {
526+ "total_count" : 0 ,
527+ "open_count" : 0 ,
528+ "items" : [],
529+ },
530+ },
531+ goal = {"id" : "task-graph-evidence-done-only" },
532+ )
533+ assert projection is not None
534+ evidence_nodes = [n for n in projection ["nodes" ] if n ["kind" ] == "evidence" ]
535+ # Only one evidence node (for done_pred), not for open_pred
536+ assert len (evidence_nodes ) == 1 , evidence_nodes
537+ ev_node = evidence_nodes [0 ]
538+ evidence_todo_ids = ev_node ["refs" ]["todo_ids" ]
539+ assert "todo_done_pred" in evidence_todo_ids , evidence_todo_ids
540+ assert "todo_open_pred" not in evidence_todo_ids , evidence_todo_ids
541+
542+
543+ def assert_handoff_for_both_open_and_done_predecessor () -> None :
544+ """Handoff should attach for non-root predecessors regardless of done status."""
545+ root = {
546+ "todo_id" : "todo_root" ,
547+ "title" : "Root" ,
548+ "text" : "Root" ,
549+ "status" : "open" ,
550+ "done" : False ,
551+ "successor_todo_ids" : ["todo_done_handoff" , "todo_open_handoff" ],
552+ }
553+ done_handoff = {
554+ "todo_id" : "todo_done_handoff" ,
555+ "title" : "Done predecessor with handoff" ,
556+ "text" : "Done predecessor with handoff" ,
557+ "status" : "done" ,
558+ "done" : True ,
559+ "handoff_note" : {
560+ "from_agent" : "agent-a" ,
561+ "to_agent" : "agent-b" ,
562+ "status" : "done" ,
563+ },
564+ "successor_todo_ids" : ["todo_root" ],
565+ }
566+ open_handoff = {
567+ "todo_id" : "todo_open_handoff" ,
568+ "title" : "Open predecessor with handoff" ,
569+ "text" : "Open predecessor with handoff" ,
570+ "status" : "open" ,
571+ "done" : False ,
572+ "handoff_note" : {
573+ "from_agent" : "agent-c" ,
574+ "to_agent" : "agent-d" ,
575+ "status" : "waiting" ,
576+ },
577+ "successor_todo_ids" : ["todo_root" ],
578+ }
579+ projection = build_task_graph_projection (
580+ {
581+ "goal_id" : "task-graph-handoff-both" ,
582+ "agent_todos" : {
583+ "total_count" : 3 ,
584+ "open_count" : 1 ,
585+ "items" : [root , done_handoff , open_handoff ],
586+ },
587+ "user_todos" : {
588+ "total_count" : 0 ,
589+ "open_count" : 0 ,
590+ "items" : [],
591+ },
592+ },
593+ goal = {"id" : "task-graph-handoff-both" },
594+ )
595+ assert projection is not None
596+ handoff_nodes = [n for n in projection ["nodes" ] if n ["kind" ] == "handoff" ]
597+ assert len (handoff_nodes ) == 2 , handoff_nodes
598+ # Check that the done handoff has state "done"
599+ done_hn = next (n for n in handoff_nodes if n ["from_agent" ] == "agent-a" )
600+ assert done_hn ["state" ] == "done" , done_hn
601+ # Check that the open handoff has state "waiting" (from status field)
602+ open_hn = next (n for n in handoff_nodes if n ["from_agent" ] == "agent-c" )
603+ assert open_hn ["state" ] == "waiting" , open_hn
604+
605+
606+ def assert_cycle_predecessor_safety () -> None :
607+ """A cycle A->B->A should not cause infinite loop or crash."""
608+ root = {
609+ "todo_id" : "todo_cycle_root" ,
610+ "title" : "Cycle Root" ,
611+ "text" : "Cycle Root" ,
612+ "status" : "open" ,
613+ "done" : False ,
614+ }
615+ a = {
616+ "todo_id" : "todo_cycle_a" ,
617+ "title" : "Cycle A" ,
618+ "text" : "Cycle A" ,
619+ "status" : "done" ,
620+ "done" : True ,
621+ "successor_todo_ids" : ["todo_cycle_root" , "todo_cycle_b" ],
622+ }
623+ b = {
624+ "todo_id" : "todo_cycle_b" ,
625+ "title" : "Cycle B" ,
626+ "text" : "Cycle B" ,
627+ "status" : "done" ,
628+ "done" : True ,
629+ "successor_todo_ids" : ["todo_cycle_a" ],
630+ }
631+ projection = build_task_graph_projection (
632+ {
633+ "goal_id" : "task-graph-cycle" ,
634+ "agent_todos" : {
635+ "total_count" : 3 ,
636+ "open_count" : 1 ,
637+ "items" : [root , a , b ],
638+ },
639+ "user_todos" : {
640+ "total_count" : 0 ,
641+ "open_count" : 0 ,
642+ "items" : [],
643+ },
644+ },
645+ goal = {"id" : "task-graph-cycle" },
646+ )
647+ assert projection is not None
648+ # Should not have exploded; should have exactly 3 deliverable nodes
649+ deliverable_nodes = [n for n in projection ["nodes" ] if n ["kind" ] == "deliverable" ]
650+ assert len (deliverable_nodes ) == 3 , deliverable_nodes
651+ # root->a and a->b edges should exist; b->a is correctly skipped due to cycle detection
652+ edge_pairs = {(e ["from_node_id" ], e ["to_node_id" ], e ["relation" ]) for e in projection ["edges" ]}
653+ node_ids = {n ["refs" ]["todo_ids" ][0 ]: n ["node_id" ] for n in deliverable_nodes }
654+ root_id = node_ids ["todo_cycle_root" ]
655+ a_id = node_ids ["todo_cycle_a" ]
656+ b_id = node_ids ["todo_cycle_b" ]
657+ assert (root_id , a_id , "depends_on" ) in edge_pairs
658+ assert (a_id , b_id , "depends_on" ) in edge_pairs
659+ assert (b_id , a_id , "depends_on" ) in edge_pairs
660+
661+
421662def main () -> int :
422663 fixture_text = read (FIXTURE_PATH )
423664 contract = read (CONTRACT_PATH )
@@ -464,6 +705,10 @@ def main() -> int:
464705 assert not (fixture_keys & forbidden_keys ), fixture_keys & forbidden_keys
465706 assert_runtime_projection_builder ()
466707 assert_predecessor_budget_and_actor_contract ()
708+ assert_diamond_dag_predecessor_edges ()
709+ assert_evidence_only_for_done_predecessor ()
710+ assert_handoff_for_both_open_and_done_predecessor ()
711+ assert_cycle_predecessor_safety ()
467712
468713 print ("task-graph-projection-fixture-smoke ok" )
469714 return 0
0 commit comments