737 lines
33 KiB
Python
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()
|