deerflow-code/offline-backend-20260512/backend/app/report_collaboration/execution/ledger.py
2026-09-07 18:24:55 +08:00

737 lines
33 KiB
Python

"""TaskLedger: deterministic wave scheduling. AgentScope never picks the next node."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from app.report_collaboration.contracts.node_machine import require_transition
from app.report_collaboration.execution.board import ArtifactBoard
from app.report_collaboration.execution.completion import late_output_may_write_completed, require_can_complete_node
from app.report_collaboration.execution.quality_gate import QualityGate, QualityGateContext, requirement_from_session_record
from deerflow.persistence.report_collaboration.base import ReportCollaborationStore
from deerflow.persistence.report_collaboration.codec import iso_now, make_node_run_id
TaskResultKind = Literal["artifact_draft", "clarification_request", "repair_request", "failure", "advice"]
_BLOCKING = frozenset({"running", "validating", "awaiting_input", "cancel_requested"})
_DONE = frozenset({"completed"})
_DEAD = frozenset({"cancelled", "superseded"})
@dataclass
class TaskAssignment:
session_id: str
run_id: str
node_id: str
node_run_id: str
phase_id: str
attempt: int
role_key: str
display_name: str
mission: str
requirement_revision: int
allowed_input_artifact_ids: list[str]
expected_artifact_type: str
acceptance_criteria: list[str]
allowed_tools: list[str]
max_attempts: int
agent_profile_snapshot: dict[str, Any]
command_overlay_revision: int
label: str
angle: str | None = None
command_overlays: list[dict[str, Any]] = field(default_factory=list)
@dataclass
class TaskResult:
kind: TaskResultKind
artifact_draft: Any = None
artifact_type: str | None = None
cited_artifact_ids: list[str] = field(default_factory=list)
source_ids: list[str] = field(default_factory=list)
error: str | None = None
clarification: dict[str, Any] | None = None
repair_reason: str | None = None
reply_text: str | None = None
suggested_next_node_ids: list[str] = field(default_factory=list)
additional_drafts: list[dict[str, Any]] = field(default_factory=list)
repair_target_node_ids: list[str] = field(default_factory=list)
reopen_downstream: bool = False
reply_streamed: bool = False
@dataclass
class ApplyOutcome:
node_status: str
run_status: str
repaired: bool = False
artifact: dict[str, Any] | None = None
ignored_coordinator_suggestions: list[str] = field(default_factory=list)
class TaskLedger:
"""Owns ready-set, attempts, and terminal node status.
Coordinator advice is recorded and then discarded as a scheduling input.
Downstream unlocks only after a validated artifact exists for every
dependency.
"""
def __init__(self, store: ReportCollaborationStore) -> None:
self._store = store
self._board = ArtifactBoard(store)
self._gate = QualityGate()
self._phase_seq = 0
async def ready_assignments(self, run_id: str) -> list[TaskAssignment]:
plan, run, latest, artifacts = await self._load(run_id)
if run.get("cancel_requested") or run.get("status") in {"cancelled", "completed", "failed"}:
return []
deps = _dependency_map(plan)
ready_ids = [
node_id
for node_id, node in _plan_nodes(plan).items()
if self._is_ready(node_id, latest, deps, artifacts)
]
if not ready_ids:
return []
self._phase_seq += 1
phase_id = f"phase-{self._phase_seq}"
assignments: list[TaskAssignment] = []
for node_id in ready_ids:
assignment = await self._queue_node(run=run, plan=plan, node_id=node_id, phase_id=phase_id, latest=latest, artifacts=artifacts, deps=deps)
assignments.append(assignment)
await self._store.patch_run(run_id, current_phase=phase_id, status="running")
return assignments
async def apply_result(self, assignment: TaskAssignment, result: TaskResult) -> ApplyOutcome:
ignored = list(result.suggested_next_node_ids or [])
if result.kind == "advice":
return await self.record_coordinator_advice(assignment.run_id, result)
nodes = await self._store.list_node_runs(assignment.run_id)
node = next((item for item in nodes if item["node_run_id"] == assignment.node_run_id), None)
if node is None:
raise KeyError(assignment.node_run_id)
run_row = await self._store.get_run(assignment.run_id) or {}
run_status = str(run_row.get("status") or "running")
if (
run_row.get("cancel_requested")
or run_status in {"cancelled", "completed", "failed"}
or not late_output_may_write_completed(current_status=node["status"])
or node["status"] in {"cancelled", "superseded", "completed", "interrupted"}
):
if result.kind == "artifact_draft" and result.artifact_draft:
content = result.artifact_draft if isinstance(result.artifact_draft, dict) else {"raw": result.artifact_draft}
await self._board.submit(
session_id=assignment.session_id,
run_id=assignment.run_id,
node_id=assignment.node_id,
attempt=assignment.attempt,
producer_node_run_id=assignment.node_run_id,
artifact_type=assignment.expected_artifact_type,
content=content,
requirement_revision=assignment.requirement_revision,
summary="late output after cancel or reclaim",
validation_status="superseded",
)
return ApplyOutcome(node_status=node["status"], run_status=run_status, ignored_coordinator_suggestions=ignored)
if node["status"] == "queued":
require_transition("queued", "running")
node["status"] = "running"
node["started_at"] = iso_now()
await self._store.save_node_run(node)
if result.kind == "clarification_request":
require_transition(node["status"], "awaiting_input")
node["status"] = "awaiting_input"
node["awaiting_input"] = result.clarification or {"reason": "clarification", "question": result.repair_reason or ""}
node["ended_at"] = iso_now()
await self._store.save_node_run(node)
await self._store.patch_run(assignment.run_id, status="awaiting_input", session_status="awaiting_input")
return ApplyOutcome(node_status="awaiting_input", run_status="awaiting_input", ignored_coordinator_suggestions=ignored)
if result.kind in {"failure", "repair_request"} or result.kind != "artifact_draft":
return await self._repair_or_fail(
assignment,
node,
result.error or result.repair_reason or result.kind,
ignored,
repair_target_node_ids=list(result.repair_target_node_ids or []),
reopen_downstream=bool(result.reopen_downstream),
)
expected = assignment.expected_artifact_type
session = await self._store.get_session_record(assignment.session_id)
run_row = await self._store.get_run(assignment.run_id) or {}
plan = await self._store.get_plan(str(run_row.get("plan_id") or ""))
context = QualityGateContext(
requirement=requirement_from_session_record(session),
upstream=await self._store.list_artifacts_full(assignment.run_id),
plan_nodes=list((plan or {}).get("nodes") or []),
node_id=assignment.node_id,
role_key=assignment.role_key,
angle=assignment.angle,
)
gate = self._gate.evaluate(result.artifact_type or expected, result.artifact_draft, context)
require_transition(node["status"], "validating")
node["status"] = "validating"
await self._store.save_node_run(node)
if not gate.ok:
if gate.parsed:
rejected = await self._board.submit(
session_id=assignment.session_id,
run_id=assignment.run_id,
node_id=assignment.node_id,
attempt=assignment.attempt,
producer_node_run_id=assignment.node_run_id,
artifact_type=expected,
content=gate.parsed,
requirement_revision=assignment.requirement_revision,
summary=_summary(gate.parsed),
validation_status="pending",
)
await self._board.mark(rejected, validation_status="rejected")
await self._emit(
assignment,
"artifact.rejected",
{"reason": gate.reason, "node_id": assignment.node_id, "issues": [issue.__dict__ for issue in gate.issues]},
)
return await self._repair_or_fail(assignment, node, gate.reason or "rejected", ignored)
artifact = await self._board.submit(
session_id=assignment.session_id,
run_id=assignment.run_id,
node_id=assignment.node_id,
attempt=assignment.attempt,
producer_node_run_id=assignment.node_run_id,
artifact_type=expected,
content=gate.parsed,
requirement_revision=assignment.requirement_revision,
summary=_summary(gate.parsed),
validation_status="pending",
)
artifact = await self._board.mark(artifact, validation_status="validated")
await self._board.supersede_previous(
run_id=assignment.run_id,
node_id=assignment.node_id,
new_artifact_id=artifact["id"],
attempt=assignment.attempt,
)
for extra in result.additional_drafts or []:
extra_type = str(extra.get("artifact_type") or expected)
extra_gate = self._gate.evaluate(extra_type, extra.get("draft"), context)
if not extra_gate.ok:
await self._emit(
assignment,
"artifact.rejected",
{"reason": extra_gate.reason, "node_id": assignment.node_id, "issues": [issue.__dict__ for issue in extra_gate.issues]},
)
return await self._repair_or_fail(assignment, node, extra_gate.reason or "rejected", ignored)
extra_art = await self._board.submit(
session_id=assignment.session_id,
run_id=assignment.run_id,
node_id=assignment.node_id,
attempt=assignment.attempt,
producer_node_run_id=assignment.node_run_id,
artifact_type=extra_type,
content=extra_gate.parsed,
requirement_revision=assignment.requirement_revision,
summary=_summary(extra_gate.parsed),
validation_status="pending",
)
await self._board.mark(extra_art, validation_status="validated")
require_can_complete_node(status="validating", validation_status="validated")
require_transition("validating", "completed")
node["status"] = "completed"
node["ended_at"] = iso_now()
node["error"] = None
await self._store.save_node_run(node)
await self._emit(assignment, "artifact.validated", {"artifact_id": artifact["id"], "node_id": assignment.node_id})
if expected == "ReviewDecision" and (gate.parsed or {}).get("verdict") == "revise":
await self._replan_review_targets(assignment, gate.parsed or {})
run_status = await self._refresh_run_terminal(assignment.run_id)
return ApplyOutcome(node_status="completed", run_status=run_status, artifact=artifact, ignored_coordinator_suggestions=ignored)
async def record_coordinator_advice(self, run_id: str, result: TaskResult) -> ApplyOutcome:
"""Persist advice without changing the ready set. Suggestions never dispatch nodes."""
run = await self._store.get_run(run_id)
session_id = run["session_id"] if run else ""
await self._store.append_event(
session_id=session_id,
run_id=run_id,
event_type="heartbeat",
data={
"kind": "coordinator_advice",
"suggested_next_node_ids": list(result.suggested_next_node_ids or []),
"ignored": True,
"text": result.reply_text,
},
)
return ApplyOutcome(
node_status="planned",
run_status=(run or {}).get("status") or "running",
ignored_coordinator_suggestions=list(result.suggested_next_node_ids or []),
)
async def apply_user_answer(self, run_id: str, *, command_id: str, instruction: str, node_ids: list[str]) -> list[str]:
"""Resume only the waiting attempts addressed by a user answer."""
_plan, run, latest, _artifacts = await self._load(run_id)
resumed: list[str] = []
for node_id in node_ids:
node = latest.get(node_id)
if node is None or node.get("status") != "awaiting_input":
continue
self._add_command_overlay(node, command_id=command_id, instruction=instruction)
require_transition("awaiting_input", "queued")
node["status"] = "queued"
node["awaiting_input"] = None
node["ended_at"] = None
await self._store.save_node_run(node)
await self._store.append_event(
session_id=str(run["session_id"]),
run_id=run_id,
event_type="node.status.changed",
data={"node_id": node_id, "node_run_id": node["node_run_id"], "status": "queued", "reason": "user_answer"},
)
resumed.append(node_id)
if resumed:
await self._store.patch_run(
run_id,
status="queued",
active_dedupe_key=str(run["session_id"]),
session_status="running",
)
return resumed
async def apply_command_rework(
self,
run_id: str,
*,
command_id: str,
instruction: str,
node_ids: list[str],
) -> list[str]:
"""Supersede affected attempts and persist an immutable command overlay.
A result that returns after this boundary sees its old attempt as
``superseded`` and is therefore stored only as superseded evidence.
User-directed rework is not counted against automatic quality-repair
limits; it creates the next monotonic attempt number.
"""
plan, run, latest, artifacts = await self._load(run_id)
if run.get("cancel_requested") or run.get("status") == "cancelled":
raise ValueError("cannot apply user rework to a cancelled run")
specs = _plan_nodes(plan)
applied: list[str] = []
for node_id in node_ids:
node = latest.get(node_id)
spec = specs.get(node_id)
if node is None or spec is None:
continue
snapshot = dict(node.get("agent_snapshot") or {})
overlays = list(snapshot.get("command_overlays") or [])
if any(str(item.get("command_id")) == command_id for item in overlays if isinstance(item, dict)):
applied.append(node_id)
continue
if node.get("status") in {"planned", "reopened"}:
self._add_command_overlay(node, command_id=command_id, instruction=instruction)
await self._store.save_node_run(node)
applied.append(node_id)
continue
if node.get("status") in {"cancelled", "superseded"}:
continue
require_transition(node["status"], "superseded")
node["status"] = "superseded"
node["ended_at"] = iso_now()
node["awaiting_input"] = None
await self._store.save_node_run(node)
for artifact in artifacts:
if (
artifact.get("node_id") == node_id
and int(artifact.get("attempt") or 0) == int(node.get("attempt") or 0)
and not artifact.get("superseded_by")
):
await self._board.mark(artifact, validation_status="superseded", superseded_by=f"command:{command_id}")
await self._store.append_event(
session_id=str(run["session_id"]),
run_id=run_id,
event_type="node.status.changed",
data={"node_id": node_id, "node_run_id": node["node_run_id"], "status": "superseded", "reason": "user_command"},
)
attempt = int(node.get("attempt") or 0) + 1
replacement = {
"node_run_id": make_node_run_id(run_id, node_id, attempt),
"session_id": run["session_id"],
"run_id": run_id,
"node_id": node_id,
"attempt": attempt,
"status": "planned",
"label": node.get("label") or spec.get("label") or node_id,
"role_display_name": node.get("role_display_name") or spec.get("role_display_name"),
"angle": node.get("angle") or spec.get("angle"),
"progress": None,
"awaiting_input": None,
"contract_json": node.get("contract_json"),
"input_artifact_ids": [],
"command_overlay_revision": int(node.get("command_overlay_revision") or 0),
"agent_snapshot": dict(node.get("agent_snapshot") or {}),
"error": None,
"started_at": None,
"ended_at": None,
}
self._add_command_overlay(replacement, command_id=command_id, instruction=instruction)
await self._store.save_node_run(replacement)
await self._store.append_event(
session_id=str(run["session_id"]),
run_id=run_id,
event_type="node.attempt.created",
data={
"node_id": node_id,
"node_run_id": replacement["node_run_id"],
"attempt": attempt,
"reason": "user_command",
"command_id": command_id,
},
)
applied.append(node_id)
if applied:
next_run_status = "queued" if run.get("status") in {"queued", "awaiting_input", "completed", "failed"} else "running"
await self._store.patch_run(
run_id,
status=next_run_status,
active_dedupe_key=str(run["session_id"]),
session_status="running",
)
return applied
@staticmethod
def _add_command_overlay(node: dict[str, Any], *, command_id: str, instruction: str) -> None:
snapshot = dict(node.get("agent_snapshot") or {})
overlays = [dict(item) for item in snapshot.get("command_overlays") or [] if isinstance(item, dict)]
if not any(str(item.get("command_id")) == command_id for item in overlays):
overlays.append({"command_id": command_id, "instruction": instruction, "created_at": iso_now()})
snapshot["command_overlays"] = overlays
node["agent_snapshot"] = snapshot
node["command_overlay_revision"] = int(node.get("command_overlay_revision") or 0) + 1
async def _repair_or_fail(
self,
assignment: TaskAssignment,
node: dict[str, Any],
reason: str,
ignored: list[str],
*,
repair_target_node_ids: list[str] | None = None,
reopen_downstream: bool = False,
) -> ApplyOutcome:
from_status = node["status"]
if from_status == "validating":
require_transition("validating", "failed")
elif from_status == "running":
require_transition("running", "failed")
elif from_status not in {"failed"}:
require_transition(from_status, "failed")
node["status"] = "failed"
node["error"] = reason
node["ended_at"] = iso_now()
await self._store.save_node_run(node)
if assignment.attempt >= assignment.max_attempts:
run_status = await self._refresh_run_terminal(assignment.run_id)
return ApplyOutcome(node_status="failed", run_status=run_status, ignored_coordinator_suggestions=ignored)
require_transition("failed", "superseded")
node["status"] = "superseded"
await self._store.save_node_run(node)
nxt = assignment.attempt + 1
await self._store.save_node_run(
{
"node_run_id": make_node_run_id(assignment.run_id, assignment.node_id, nxt),
"session_id": assignment.session_id,
"run_id": assignment.run_id,
"node_id": assignment.node_id,
"attempt": nxt,
"status": "planned",
"label": assignment.label,
"role_display_name": assignment.display_name,
"angle": assignment.angle,
"contract_json": node.get("contract_json"),
"input_artifact_ids": list(assignment.allowed_input_artifact_ids),
"command_overlay_revision": assignment.command_overlay_revision,
"agent_snapshot": assignment.agent_profile_snapshot,
"error": None,
"started_at": None,
"ended_at": None,
}
)
await self._replan_targets(
assignment.run_id,
list(repair_target_node_ids or []),
skip={assignment.node_id},
reopen_downstream=reopen_downstream,
)
return ApplyOutcome(node_status="planned", run_status="running", repaired=True, ignored_coordinator_suggestions=ignored)
async def _replan_review_targets(self, assignment: TaskAssignment, decision: dict[str, Any]) -> None:
plan, _run, latest, artifacts = await self._load(assignment.run_id)
mapped = _map_review_targets(plan, artifacts, list(decision.get("issues") or []))
mapped.add(assignment.node_id)
await self._replan_targets(assignment.run_id, sorted(mapped), skip=set(), reopen_downstream=False)
async def _replan_targets(self, run_id: str, node_ids: list[str], *, skip: set[str], reopen_downstream: bool) -> None:
if not node_ids and not reopen_downstream:
return
plan, run, latest, _artifacts = await self._load(run_id)
deps = _dependency_map(plan)
wanted = {node_id for node_id in node_ids if node_id and node_id not in skip}
if reopen_downstream:
changed = True
while changed:
changed = False
for node_id, parents in deps.items():
if node_id in wanted or node_id in skip:
continue
if any(parent in wanted or parent in node_ids for parent in parents):
wanted.add(node_id)
changed = True
for node_id in wanted:
if node_id in skip:
continue
await self._replan_completed_node(run, plan, node_id, latest)
async def _replan_completed_node(self, run: dict[str, Any], plan: dict[str, Any], node_id: str, latest: dict[str, dict[str, Any]]) -> bool:
node = latest.get(node_id)
if node is None or node["status"] != "completed":
return False
spec = _plan_nodes(plan).get(node_id) or {}
max_attempts = int((node.get("contract_json") or {}).get("max_attempts") or spec.get("max_attempts") or 3)
attempt = int(node["attempt"])
if attempt >= max_attempts:
return False
require_transition("completed", "superseded")
node["status"] = "superseded"
node["ended_at"] = iso_now()
await self._store.save_node_run(node)
nxt = attempt + 1
await self._store.save_node_run(
{
"node_run_id": make_node_run_id(str(run["id"]), node_id, nxt),
"session_id": run["session_id"],
"run_id": run["id"],
"node_id": node_id,
"attempt": nxt,
"status": "planned",
"label": node.get("label") or spec.get("label") or node_id,
"role_display_name": node.get("role_display_name") or spec.get("role_display_name"),
"angle": node.get("angle") or spec.get("angle"),
"contract_json": node.get("contract_json"),
"input_artifact_ids": list(node.get("input_artifact_ids") or []),
"command_overlay_revision": node.get("command_overlay_revision") or 0,
"agent_snapshot": node.get("agent_snapshot"),
"error": None,
"started_at": None,
"ended_at": None,
}
)
await self._store.append_event(
session_id=str(run["session_id"]),
run_id=str(run["id"]),
event_type="node.attempt.created",
data={"node_id": node_id, "node_run_id": make_node_run_id(str(run["id"]), node_id, nxt), "attempt": nxt, "reason": "targeted_rework"},
)
return True
async def _queue_node(
self,
*,
run: dict[str, Any],
plan: dict[str, Any],
node_id: str,
phase_id: str,
latest: dict[str, dict[str, Any]],
artifacts: list[dict[str, Any]],
deps: dict[str, list[str]],
) -> TaskAssignment:
spec = _plan_nodes(plan)[node_id]
current = latest[node_id]
status = current["status"]
if status in {"planned", "reopened"}:
require_transition(status, "queued")
current["status"] = "queued"
current["input_artifact_ids"] = _upstream_artifact_ids(deps.get(node_id) or [], latest, artifacts)
current["started_at"] = None
current["ended_at"] = None
current["error"] = None
await self._store.save_node_run(current)
await self._store.append_event(
session_id=str(run["session_id"]),
run_id=str(run["id"]),
event_type="node.status.changed",
data={"node_id": node_id, "node_run_id": current["node_run_id"], "status": current["status"], "phase_id": phase_id},
)
role_key = str(spec.get("role_key") or "")
attempt = int(current["attempt"])
session = await self._store.get_session_record(str(run["session_id"]))
snapshot = dict(current.get("agent_snapshot") or {})
return TaskAssignment(
session_id=str(run["session_id"]),
run_id=str(run["id"]),
node_id=node_id,
node_run_id=str(current["node_run_id"]),
phase_id=phase_id,
attempt=attempt,
role_key=role_key,
display_name=str(spec.get("role_display_name") or spec.get("label") or role_key),
mission=str(spec.get("mission") or ""),
requirement_revision=int((session or {}).get("requirement_revision") or plan.get("requirement_revision") or 0),
allowed_input_artifact_ids=list(current["input_artifact_ids"]),
expected_artifact_type=str(spec.get("output_artifact_type") or ""),
acceptance_criteria=list(spec.get("acceptance_criteria") or []),
allowed_tools=list(spec.get("allowed_tools") or []),
max_attempts=int((current.get("contract_json") or {}).get("max_attempts") or spec.get("max_attempts") or 3),
agent_profile_snapshot=snapshot,
command_overlay_revision=int(current.get("command_overlay_revision") or 0),
label=str(spec.get("label") or node_id),
angle=spec.get("angle"),
command_overlays=[dict(item) for item in snapshot.get("command_overlays") or [] if isinstance(item, dict)],
)
def _is_ready(
self,
node_id: str,
latest: dict[str, dict[str, Any]],
deps: dict[str, list[str]],
artifacts: list[dict[str, Any]],
) -> bool:
current = latest.get(node_id)
if current is None:
return False
status = current["status"]
if status in _BLOCKING or status in _DONE or status in _DEAD or status == "failed":
return False
if status not in {"planned", "reopened"}:
return False
for dep in deps.get(node_id) or []:
parent = latest.get(dep)
if parent is None or parent["status"] != "completed":
return False
if not _has_validated(artifacts, dep, int(parent["attempt"])):
return False
return True
async def _refresh_run_terminal(self, run_id: str) -> str:
_plan, run, latest, _artifacts = await self._load(run_id)
statuses = [item["status"] for item in latest.values()]
if statuses and all(status == "completed" for status in statuses):
from app.report_collaboration.reporting.finalizer import ReportFinalizationError, ReportFinalizer
try:
await ReportFinalizer(self._store).finalize_run(run_id)
except ReportFinalizationError:
await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
return "failed"
await self._store.patch_run(run_id, status="completed", active_dedupe_key=None, session_status="completed")
return "completed"
if any(status in _BLOCKING or status == "queued" for status in statuses):
return str(run.get("status") or "running")
if any(status == "failed" for status in statuses):
await self._store.patch_run(run_id, status="failed", active_dedupe_key=None, session_status="failed")
return "failed"
return str(run.get("status") or "running")
async def _load(self, run_id: str) -> tuple[dict[str, Any], dict[str, Any], dict[str, dict[str, Any]], list[dict[str, Any]]]:
run = await self._store.get_run(run_id)
if run is None:
raise KeyError(run_id)
plan = await self._store.get_plan(run["plan_id"])
if plan is None:
raise KeyError(run["plan_id"])
nodes = await self._store.list_node_runs(run_id)
latest: dict[str, dict[str, Any]] = {}
for item in nodes:
current = latest.get(item["node_id"])
if current is None or int(item["attempt"]) >= int(current["attempt"]):
latest[item["node_id"]] = item
artifacts = await self._store.list_artifacts_full(run_id)
return plan, run, latest, artifacts
async def _emit(self, assignment: TaskAssignment, event_type: str, data: dict[str, Any]) -> None:
await self._store.append_event(session_id=assignment.session_id, run_id=assignment.run_id, event_type=event_type, data=data)
def _plan_nodes(plan: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {str(item["id"]): item for item in (plan.get("nodes") or []) if item.get("id")}
def _dependency_map(plan: dict[str, Any]) -> dict[str, list[str]]:
deps: dict[str, list[str]] = {node_id: list(spec.get("depends_on") or []) for node_id, spec in _plan_nodes(plan).items()}
for edge in plan.get("edges") or []:
src, dst = str(edge.get("source") or ""), str(edge.get("target") or "")
if src and dst:
bucket = deps.setdefault(dst, [])
if src not in bucket:
bucket.append(src)
return deps
def _has_validated(artifacts: list[dict[str, Any]], node_id: str, attempt: int) -> bool:
return any(
item.get("node_id") == node_id
and int(item.get("attempt") or 0) == attempt
and item.get("validation_status") == "validated"
and not item.get("superseded_by")
for item in artifacts
)
def _upstream_artifact_ids(dep_ids: list[str], latest: dict[str, dict[str, Any]], artifacts: list[dict[str, Any]]) -> list[str]:
ids: list[str] = []
for dep in dep_ids:
parent = latest.get(dep)
if parent is None:
continue
for item in artifacts:
if (
item.get("node_id") == dep
and int(item.get("attempt") or 0) == int(parent["attempt"])
and item.get("validation_status") == "validated"
and not item.get("superseded_by")
):
ids.append(str(item["id"]))
return ids
def _map_review_targets(plan: dict[str, Any], artifacts: list[dict[str, Any]], issues: list[Any]) -> set[str]:
nodes = _plan_nodes(plan)
section_to_node: dict[str, str] = {}
for item in artifacts:
content = item.get("content") if isinstance(item.get("content"), dict) else {}
section_id = str(content.get("section_id") or "")
if section_id and item.get("node_id"):
section_to_node[section_id] = str(item["node_id"])
mapped: set[str] = set()
writers = {node_id for node_id, spec in nodes.items() if spec.get("role_key") in {"writer", "reviser"}}
for raw in issues:
target = str((raw.get("target") if isinstance(raw, dict) else getattr(raw, "target", None)) or "").strip()
if target in nodes:
mapped.add(target)
elif target in section_to_node:
mapped.add(section_to_node[target])
elif target:
mapped.update(writers)
if not mapped:
mapped.update(writers)
return mapped
def _summary(content: dict[str, Any]) -> str:
if content.get("title"):
return str(content["title"])[:200]
claims = content.get("claims") or []
if claims:
return str(claims[0].get("text") or "")[:200]
return (str(content.get("markdown") or "")[:200]).strip()