"""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()