Skip to content

Commit ca01a6a

Browse files
cocolordTRAE CLI
andcommitted
fix(task-graph): separate visited from edge projection and fix evidence/handoff semantics
Two P1 fixes: 1. Diamond DAG: In _task_graph_build_predecessor_chain, separate "node already created/expanded" from "relationship already projected". Previously visited prevented both cycle detection and edge projection, so in a diamond DAG root <- {a, b} <- shared, the edge b->shared was skipped. Now each queue item projects the edge from successor to current before checking visited for expansion. Removed the enqueue-time visited guard so that the already_visited path at dequeue can handle edge projection. 2. Evidence/handoff: Attach completion evidence only when predecessor is done, not unconditionally. For handoff, derive state from typed handoff status (done/waiting/unknown) rather than hardcoding done. Handoff now attaches for non-root predecessors regardless of done status. Added regression tests: diamond DAG edges, evidence-only-for-done, handoff for both open and done predecessors, cycle safety. Co-authored-by: TRAE CLI <noreply@bytedance.com>
1 parent 443eae5 commit ca01a6a

2 files changed

Lines changed: 275 additions & 25 deletions

File tree

examples/control_plane/task-graph-projection-fixture-smoke.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
421662
def 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

loopx/control_plane/work_items/task_graph.py

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,8 @@ def _task_graph_attach_handoff(
462462
handoff_to = public_safe_compact_text(handoff_note.get("to_agent"), limit=80)
463463
if not handoff_from or not handoff_to or handoff_from == handoff_to:
464464
return
465+
handoff_status = public_safe_compact_text(handoff_note.get("status"), limit=80)
466+
handoff_state = handoff_status if handoff_status in ("done", "waiting", "unknown") else "done"
465467
handoff_id = f"handoff:{current_tid}:{handoff_from}:{handoff_to}"
466468
handoff_node_id = builder.add_node(
467469
{
@@ -472,7 +474,7 @@ def _task_graph_attach_handoff(
472474
),
473475
"kind": "handoff",
474476
"title": f"Handoff from {handoff_from} to {handoff_to}",
475-
"state": "done",
477+
"state": handoff_state,
476478
"refs": _task_graph_refs(
477479
"todo_ids",
478480
current_tid,
@@ -594,9 +596,7 @@ def _task_graph_build_predecessor_chain(
594596

595597
while queue:
596598
current_tid, successor_nid, edge_rel_hint = queue.pop(0)
597-
if current_tid in visited:
598-
continue
599-
visited.add(current_tid)
599+
already_visited = current_tid in visited
600600
current_todo = all_todos_by_id.get(current_tid)
601601
if not isinstance(current_todo, dict):
602602
continue
@@ -605,7 +605,7 @@ def _task_graph_build_predecessor_chain(
605605
str(normalize_todo_status(current_todo.get("status")) or todo_status_open)
606606
)
607607

608-
if not is_root and emitted_count >= max_predecessor_nodes:
608+
if not is_root and not already_visited and emitted_count >= max_predecessor_nodes:
609609
truncated = True
610610
break
611611

@@ -650,27 +650,34 @@ def _task_graph_build_predecessor_chain(
650650
if not current_nid:
651651
continue
652652

653+
if already_visited:
654+
continue
655+
656+
visited.add(current_tid)
657+
653658
if not is_root:
654659
emitted_count += 1
655660

656-
_task_graph_attach_evidence(
657-
current_todo=current_todo,
658-
current_tid=current_tid,
659-
current_nid=current_nid,
660-
builder=builder,
661-
public_safe_compact_text=public_safe_compact_text,
662-
)
661+
if is_done:
662+
_task_graph_attach_evidence(
663+
current_todo=current_todo,
664+
current_tid=current_tid,
665+
current_nid=current_nid,
666+
builder=builder,
667+
public_safe_compact_text=public_safe_compact_text,
668+
)
669+
670+
if not is_root:
671+
_task_graph_attach_handoff(
672+
current_todo=current_todo,
673+
current_tid=current_tid,
674+
current_nid=current_nid,
675+
successor_nid=successor_nid,
676+
builder=builder,
677+
public_safe_compact_text=public_safe_compact_text,
678+
)
663679

664680
if not is_done:
665-
if not is_root:
666-
_task_graph_attach_handoff(
667-
current_todo=current_todo,
668-
current_tid=current_tid,
669-
current_nid=current_nid,
670-
successor_nid=successor_nid,
671-
builder=builder,
672-
public_safe_compact_text=public_safe_compact_text,
673-
)
674681
if not is_root:
675682
continue
676683
pred_list = _task_graph_resolve_direct_predecessors(
@@ -681,8 +688,7 @@ def _task_graph_build_predecessor_chain(
681688
public_safe_compact_text=public_safe_compact_text,
682689
)
683690
for pred_id, rel_hint in pred_list:
684-
if pred_id not in visited:
685-
queue.append((pred_id, current_nid, rel_hint))
691+
queue.append((pred_id, current_nid, rel_hint))
686692
continue
687693

688694
pred_list = _task_graph_resolve_direct_predecessors(
@@ -693,8 +699,7 @@ def _task_graph_build_predecessor_chain(
693699
public_safe_compact_text=public_safe_compact_text,
694700
)
695701
for pred_id, rel_hint in pred_list:
696-
if pred_id not in visited:
697-
queue.append((pred_id, current_nid, rel_hint))
702+
queue.append((pred_id, current_nid, rel_hint))
698703

699704
return {
700705
"emitted_predecessor_count": emitted_count,

0 commit comments

Comments
 (0)